From 88dc7ca351535593e50d7a9a1bcf5bbf0688d758 Mon Sep 17 00:00:00 2001 From: nicosampler Date: Mon, 3 Aug 2026 15:40:01 -0300 Subject: [PATCH 1/7] feat: compile the CLI to a standalone binary with embedded templates bun build --compile (npm run build:binary) produces a self-contained binary for the dpm component distribution channel. The binary entry embeds the bundled templates at compile time and paths.js materializes them into the project's .generated/ on demand, since init and docker compose need real files. The npm/Node channel is unchanged. --- .gitignore | 3 +++ package.json | 1 + scripts/binary-entry.js | 22 ++++++++++++++++++++++ src/paths.js | 27 +++++++++++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 scripts/binary-entry.js diff --git a/.gitignore b/.gitignore index cbff5d4..d4d90b9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ /canton-barebones.config.json /splice-localnet-overrides.yaml + +# Compiled standalone binaries (npm run build:binary) +dist/ diff --git a/package.json b/package.json index fcbd274..fed089a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "validate": "node ./bin/canton-barebones.js validate", "compose": "node ./bin/canton-barebones.js compose", "compose:config": "node ./bin/canton-barebones.js compose config", + "build:binary": "bun build --compile scripts/binary-entry.js --outfile dist/canton-barebones", "test": "node --test \"scripts/**/*.test.js\"", "test:e2e": "node ./bin/canton-barebones.js init && node ./scripts/smoke.js", "changeset": "changeset", diff --git a/scripts/binary-entry.js b/scripts/binary-entry.js new file mode 100644 index 0000000..0cef08b --- /dev/null +++ b/scripts/binary-entry.js @@ -0,0 +1,22 @@ +// Entry point for the standalone-binary build (`npm run build:binary`), the +// distribution channel used by dpm components. It is only ever compiled by +// `bun build --compile` — never executed by Node — which is why it can use +// Bun's `with { type: 'text' }` imports: they inline each template's content +// into the binary at compile time, keeping templates/ as the single source of +// truth. The registered contents are materialized back to disk on demand by +// src/paths.js, since `init` and docker compose need real files. +import configTemplate from '../templates/canton-barebones.config.json' with { type: 'text' }; +import runtimeOverridesTemplate from '../templates/runtime-overrides.yaml' with { type: 'text' }; +import localnetOverridesTemplate from '../templates/splice-localnet-overrides.yaml' with { type: 'text' }; + +import { registerEmbeddedPackageFiles } from '../src/paths.js'; + +registerEmbeddedPackageFiles({ + 'templates/canton-barebones.config.json': configTemplate, + 'templates/runtime-overrides.yaml': runtimeOverridesTemplate, + 'templates/splice-localnet-overrides.yaml': localnetOverridesTemplate, +}); + +// Imported dynamically so registration above runs first; a static import would +// be hoisted and dispatch the command before the templates are registered. +await import('../bin/canton-barebones.js'); diff --git a/src/paths.js b/src/paths.js index e3e0eb4..4addd63 100644 --- a/src/paths.js +++ b/src/paths.js @@ -5,6 +5,7 @@ // config, their overrides, and the generated files). // Keeping the two apart is what lets the same installed CLI scaffold files into, // and generate files for, any project it is run in. +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,8 +20,34 @@ export const packageRoot = path.resolve(path.dirname(thisFile), '..'); // their config file, their overrides, and everything written under .generated/. export const projectRoot = process.cwd(); +// When the CLI ships as a single compiled binary (the dpm distribution channel) +// there is no installed package directory on disk, so the bundled templates +// cannot be read from packageRoot. The binary's entry point embeds their +// contents at compile time and registers them here, keyed by their +// package-relative path (e.g. "templates/runtime-overrides.yaml"). Under Node +// (the npm channel) nothing registers and the files are read from disk as +// before. +let embeddedPackageFiles = null; + +// Called once by the compiled binary's entry point, before any command runs. +export function registerEmbeddedPackageFiles(files) { + embeddedPackageFiles = files; +} + // Builds an absolute path inside the installed package (e.g. a bundled template). +// Callers need a real file on disk — `init` copies it and docker compose reads +// it as an override (-f) — so in compiled-binary mode the embedded content is +// materialized under the project's .generated/ dir and that path is returned. export function resolveFromPackage(...segments) { + const relativePath = segments.join('/'); + if (embeddedPackageFiles && relativePath in embeddedPackageFiles) { + // Rewritten on every call: the content must always match the running binary's + // version, and a stale copy from an older binary would be silently wrong. + const materializedPath = path.resolve(projectRoot, '.generated', relativePath); + fs.mkdirSync(path.dirname(materializedPath), { recursive: true }); + fs.writeFileSync(materializedPath, embeddedPackageFiles[relativePath]); + return materializedPath; + } return path.resolve(packageRoot, ...segments); } From d02a3a934ce7a2095cdcfc1fe30a11ae35bc5e4f Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 14:47:02 -0300 Subject: [PATCH 2/7] test: cover embedded-template materialization and fail loudly on non-embedded files --- scripts/embedded-templates.test.js | 105 +++++++++++++++++++++++++++++ src/paths.js | 16 ++++- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 scripts/embedded-templates.test.js diff --git a/scripts/embedded-templates.test.js b/scripts/embedded-templates.test.js new file mode 100644 index 0000000..ede15a5 --- /dev/null +++ b/scripts/embedded-templates.test.js @@ -0,0 +1,105 @@ +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Covers the one behavior that differs between the two distribution channels: +// how resolveFromPackage locates the bundled templates. +// - npm channel: nothing is registered, so it resolves to a real file inside +// the installed package directory (next to src/). +// - compiled-binary channel (dpm): scripts/binary-entry.js registers the +// template contents that were embedded at compile time, and the function +// must write them to a real file under the project's .generated/ — both +// `init` (which copies the file) and docker compose (which reads it via -f +// from another process) need an actual path on disk, not an in-memory string. +// +// paths.js captures process.cwd() as the project root at import time, so the +// suite chdirs into a throwaway directory BEFORE importing it. That keeps every +// materialized file inside the temp dir instead of polluting the repository +// (which acts as the project when tests run from the repo root). +// realpathSync canonicalizes the freshly created dir (on macOS os.tmpdir() goes +// through the /var → /private/var symlink) so it compares equal to what paths.js +// derives from process.cwd(), which the OS reports symlink-resolved. +const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'canton-barebones-paths-'))); +process.chdir(projectDir); + +const { packageRoot, registerEmbeddedPackageFiles, resolveFromPackage } = await import( + '../src/paths.js' +); + +after(() => { + // Leave the temp dir before deleting it: removing the process's cwd fails on + // some platforms. + process.chdir(os.tmpdir()); + fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +// The npm channel: no embedded files were registered, so the function must +// behave as a pure path builder pointing inside the installed package, and it +// must not touch the filesystem (materializing is a binary-only behavior). +describe('resolveFromPackage without embedded files (npm channel)', () => { + it('resolves inside the package directory and writes nothing', () => { + // Explicitly reset: describe blocks in this file share module state, and + // this suite documents the "nothing registered" default. + registerEmbeddedPackageFiles(null); + + const resolved = resolveFromPackage('templates/runtime-overrides.yaml'); + + assert.equal(resolved, path.resolve(packageRoot, 'templates/runtime-overrides.yaml')); + // No .generated/ side effect: materialization must not happen on this channel. + assert.equal(fs.existsSync(path.resolve(projectDir, '.generated')), false); + }); +}); + +// The compiled-binary channel: the entry point registered the embedded contents, +// so asking for a package file must produce a real file under the project's +// .generated/ that other processes (docker compose) can read. +describe('resolveFromPackage with embedded files (compiled binary)', () => { + // Stands in for a template embedded at compile time. The key mirrors the real + // package-relative paths used by init.js/compose.js ("templates/"); the + // content just needs to be recognizable so the assertions can compare bytes. + const embedded = { 'templates/fake-override.yaml': 'services: {}\n# embedded marker\n' }; + + it('materializes the content under .generated/ and returns that path', () => { + registerEmbeddedPackageFiles(embedded); + + const resolved = resolveFromPackage('templates/fake-override.yaml'); + + // The returned path must live in the project (cwd), not in the package, and + // the file must exist with exactly the embedded content. + assert.equal(resolved, path.resolve(projectDir, '.generated', 'templates/fake-override.yaml')); + assert.equal(fs.readFileSync(resolved, 'utf8'), embedded['templates/fake-override.yaml']); + }); + + it('rewrites the file on every call so a stale copy cannot survive', () => { + registerEmbeddedPackageFiles(embedded); + const resolved = resolveFromPackage('templates/fake-override.yaml'); + + // Simulate a leftover from an older binary version: the template exists on + // disk but its content no longer matches the running binary. + fs.writeFileSync(resolved, 'stale content from a previous version\n'); + + // Resolving again must restore the embedded content, not trust the disk. + resolveFromPackage('templates/fake-override.yaml'); + assert.equal(fs.readFileSync(resolved, 'utf8'), embedded['templates/fake-override.yaml']); + }); + + it('fails loudly for a package file that was never embedded', () => { + registerEmbeddedPackageFiles(embedded); + + // The guarded mistake: a new template gets added to the package but not to + // scripts/binary-entry.js. Falling through to the package-directory path + // would ENOENT later (that directory does not exist inside a binary), so + // the error must name the missing file and the fix instead. + assert.throws( + () => resolveFromPackage('templates/forgotten.yaml'), + error => { + assert.match(error.message, /templates\/forgotten\.yaml/); + assert.match(error.message, /binary-entry\.js/); + return true; + }, + 'expected resolveFromPackage to throw for a non-embedded file' + ); + }); +}); diff --git a/src/paths.js b/src/paths.js index 4addd63..348e55a 100644 --- a/src/paths.js +++ b/src/paths.js @@ -40,12 +40,24 @@ export function registerEmbeddedPackageFiles(files) { // materialized under the project's .generated/ dir and that path is returned. export function resolveFromPackage(...segments) { const relativePath = segments.join('/'); - if (embeddedPackageFiles && relativePath in embeddedPackageFiles) { + if (embeddedPackageFiles) { + const content = embeddedPackageFiles[relativePath]; + // Asking for a file that was never embedded means the binary entry point + // is out of sync with the code (e.g. a template was added to the package + // but not to scripts/binary-entry.js). Fail here, naming the cause: the + // disk fallback below does not exist inside a compiled binary, so falling + // through would surface as a confusing ENOENT far from the real mistake. + if (content === undefined) { + throw new Error( + `Package file "${relativePath}" is not embedded in this binary. ` + + 'Add it to the imports in scripts/binary-entry.js and rebuild.' + ); + } // Rewritten on every call: the content must always match the running binary's // version, and a stale copy from an older binary would be silently wrong. const materializedPath = path.resolve(projectRoot, '.generated', relativePath); fs.mkdirSync(path.dirname(materializedPath), { recursive: true }); - fs.writeFileSync(materializedPath, embeddedPackageFiles[relativePath]); + fs.writeFileSync(materializedPath, content); return materializedPath; } return path.resolve(packageRoot, ...segments); From 7ef344297f3d936a792e209ccc1abfc2b2c29455 Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 14:47:41 -0300 Subject: [PATCH 3/7] chore: add Apache-2.0 license --- LICENSE | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 203 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/package.json b/package.json index fed089a..cac8092 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.2.2", "type": "module", "description": "Barebones one-click Canton developer stack.", + "license": "Apache-2.0", "repository": { "type": "git", "url": "git+https://github.com/BootNodeDev/canton-barebones.git" From 5d2331e9289a1954a21488ea2a2935e3dde3997f Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 15:06:49 -0300 Subject: [PATCH 4/7] feat: multi-platform dpm component build --- dpm-component/component.yaml | 20 ++++++++ package.json | 1 + scripts/build-component.js | 96 ++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 dpm-component/component.yaml create mode 100644 scripts/build-component.js diff --git a/dpm-component/component.yaml b/dpm-component/component.yaml new file mode 100644 index 0000000..b3b4093 --- /dev/null +++ b/dpm-component/component.yaml @@ -0,0 +1,20 @@ +# dpm component manifest (https://github.com/digital-asset/dpm). +# +# This file travels inside the OCI artifact next to the compiled binary and +# tells dpm which subcommands the component contributes: dpm surfaces `name` +# (and `aliases`) as `dpm canton-barebones ...` / `dpm cbn ...` and execs +# `path` with the user's remaining args, so init/start/stop/... keep being +# resolved by our own CLI dispatcher. +# +# This is the single source manifest: scripts/build-component.js copies it into +# every per-platform directory, rewriting `path` to the .exe name for the +# windows variant (dpm has no per-platform fields in the schema — each platform +# ships its own copy of the manifest). +apiVersion: digitalasset.com/v1 +kind: Component +spec: + commands: + - path: ./canton-barebones + name: canton-barebones + desc: Barebones one-click Canton localnet developer stack + aliases: ["cbn"] diff --git a/package.json b/package.json index cac8092..1434633 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "compose": "node ./bin/canton-barebones.js compose", "compose:config": "node ./bin/canton-barebones.js compose config", "build:binary": "bun build --compile scripts/binary-entry.js --outfile dist/canton-barebones", + "build:component": "node scripts/build-component.js", "test": "node --test \"scripts/**/*.test.js\"", "test:e2e": "node ./bin/canton-barebones.js init && node ./scripts/smoke.js", "changeset": "changeset", diff --git a/scripts/build-component.js b/scripts/build-component.js new file mode 100644 index 0000000..fa6c679 --- /dev/null +++ b/scripts/build-component.js @@ -0,0 +1,96 @@ +// Builds the dpm component for every supported platform: compiles the CLI to a +// standalone binary per OS/arch (via Bun, which cross-compiles all targets from +// any host) and assembles the directory layout `dpm publish component` expects — +// one directory per platform, each holding the binary, the component manifest +// and the LICENSE (dpm refuses to publish a component without one at its root). +// +// dist/dpm-component/-/ +// ├── canton-barebones[.exe] +// ├── component.yaml +// └── LICENSE +// +// Run with `npm run build:component`. For quick host-only iteration during +// development, `npm run build:binary` compiles just the current platform. +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Every platform the component is published for. `os`/`arch` follow dpm's +// naming (used for the directory names and the `-p /=` publish +// flags); `bunTarget` is Bun's name for the same platform. +const TARGETS = [ + { os: 'linux', arch: 'amd64', bunTarget: 'bun-linux-x64' }, + { os: 'linux', arch: 'arm64', bunTarget: 'bun-linux-arm64' }, + { os: 'darwin', arch: 'amd64', bunTarget: 'bun-darwin-x64' }, + { os: 'darwin', arch: 'arm64', bunTarget: 'bun-darwin-arm64' }, + { os: 'windows', arch: 'amd64', bunTarget: 'bun-windows-x64' }, +]; + +// The manifest is maintained once (dpm-component/component.yaml) and copied into +// every platform directory. dpm's schema has no per-platform fields, so the +// windows copy must itself point at the .exe binary name. +function platformManifest(sourceManifest, os) { + if (os !== 'windows') { + return sourceManifest; + } + const rewritten = sourceManifest.replace('path: ./canton-barebones', 'path: ./canton-barebones.exe'); + if (rewritten === sourceManifest) { + throw new Error('component.yaml: expected a "path: ./canton-barebones" command to rewrite for windows'); + } + return rewritten; +} + +// Compiles the CLI for one target. The entry point is the binary-specific one +// (scripts/binary-entry.js), which embeds the templates/ contents at compile +// time — see that file and src/paths.js for how the two channels differ. +function compileBinary(target, outDir) { + // Bun appends .exe for windows targets on its own; naming it explicitly keeps + // the manifest, this script and the output aligned without relying on that. + const binaryName = target.os === 'windows' ? 'canton-barebones.exe' : 'canton-barebones'; + const outFile = path.join(outDir, binaryName); + + const result = spawnSync( + 'bun', + ['build', '--compile', `--target=${target.bunTarget}`, 'scripts/binary-entry.js', '--outfile', outFile], + { cwd: repoRoot, stdio: 'inherit' } + ); + + if (result.error) { + if (result.error.code === 'ENOENT') { + throw new Error(`bun is required to build the component binaries: ${result.error.message}`); + } + throw result.error; + } + if (result.status !== 0) { + throw new Error(`bun build for ${target.bunTarget} exited with status ${result.status}`); + } +} + +const sourceManifest = fs.readFileSync(path.join(repoRoot, 'dpm-component', 'component.yaml'), 'utf8'); +const license = path.join(repoRoot, 'LICENSE'); + +// Start from a clean slate so removed platforms or renamed files never linger +// in the published artifact. +const componentRoot = path.join(repoRoot, 'dist', 'dpm-component'); +fs.rmSync(componentRoot, { recursive: true, force: true }); + +for (const target of TARGETS) { + const outDir = path.join(componentRoot, `${target.os}-${target.arch}`); + fs.mkdirSync(outDir, { recursive: true }); + + compileBinary(target, outDir); + fs.writeFileSync(path.join(outDir, 'component.yaml'), platformManifest(sourceManifest, target.os)); + fs.copyFileSync(license, path.join(outDir, 'LICENSE')); + + console.log(`built ${path.relative(repoRoot, outDir)}`); +} + +// The publish command needs one -p flag per platform; printing it here keeps +// the CI step and manual publishes copy-pasteable and in sync with TARGETS. +const platformFlags = TARGETS.map( + t => `-p ${t.os}/${t.arch}=dist/dpm-component/${t.os}-${t.arch}` +).join(' '); +console.log(`\npublish with:\n dpm publish component oci:///canton-barebones: ${platformFlags}`); From e7c163c90226ed752edd06510f28fe082fd099b3 Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 15:16:38 -0300 Subject: [PATCH 5/7] ci: build and validate the dpm component on pull requests --- .github/actions/setup-dpm/action.yml | 26 ++++++++++ .github/workflows/ci.yml | 73 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .github/actions/setup-dpm/action.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/actions/setup-dpm/action.yml b/.github/actions/setup-dpm/action.yml new file mode 100644 index 0000000..44eafd2 --- /dev/null +++ b/.github/actions/setup-dpm/action.yml @@ -0,0 +1,26 @@ +# Installs the dpm CLI, pinned by version and checksum. +# +# Single source of truth for which dpm both workflows run: ci.yml (validates +# the component with a publish --dry-run) and release.yml (publishes it to +# ghcr). Bumping dpm means editing the two values below in a PR — where the CI +# dry-run exercises the new version before it can ever touch a release. +# The pin matters because dpm assembles and pushes artifacts in our name: a new +# dpm release must never silently change what the pipelines execute. +name: Setup dpm +description: Install the dpm CLI, pinned by version and sha256 checksum + +runs: + using: composite + steps: + - name: Install dpm + shell: bash + env: + DPM_VERSION: 1.0.21 + DPM_SHA256: 71061e3ecee029bc88ccfc1b51367e3257162f3bd91c7edcc688af503fef5e59 + run: | + curl -fsSL -o dpm.tar.gz "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" + echo "${DPM_SHA256} dpm.tar.gz" | sha256sum -c - + tar -xzf dpm.tar.gz dpm + rm dpm.tar.gz + sudo mv dpm /usr/local/bin/dpm + dpm --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88904bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +# PR validation: unit tests plus a full dpm-component build check. +# +# The component job cross-compiles the CLI for every supported platform and +# runs `dpm publish component --dry-run`, which is dpm's own validation of the +# manifest and layout (including the LICENSE-at-component-root requirement). +# Nothing is published here — the real publish happens in release.yml when a +# Version Packages PR lands. +name: CI + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm ci + + - run: npm test + + component: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Bun is the cross-compiler for the standalone binaries (see + # scripts/build-component.js). Pinned for reproducible binaries. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.1 + + # The compiled binary bundles the runtime dependencies (zod), so they + # must be installed before Bun can resolve them. + - run: npm ci + + - run: npm run build:component + + # Pinned dpm shared with release.yml — see the action for the pin rationale. + - uses: ./.github/actions/setup-dpm + + # The version tag is a placeholder: --dry-run never contacts a registry, + # but dpm still requires a syntactically valid oci:// URI with strict + # semver. The -p flags must cover exactly the platforms the build emits + # (scripts/build-component.js prints this same command on every run). + - name: Validate component (dpm publish --dry-run) + run: | + dpm publish component 'oci://ghcr.io/bootnodedev/canton-barebones:0.0.0' \ + -p linux/amd64=dist/dpm-component/linux-amd64 \ + -p linux/arm64=dist/dpm-component/linux-arm64 \ + -p darwin/amd64=dist/dpm-component/darwin-amd64 \ + -p darwin/arm64=dist/dpm-component/darwin-arm64 \ + -p windows/amd64=dist/dpm-component/windows-amd64 \ + --dry-run + + # The linux-amd64 binary can run right here: make sure a compiled binary + # actually executes and scaffolds from its embedded templates. + - name: Smoke-test the linux binary + run: | + mkdir smoke && cd smoke + ../dist/dpm-component/linux-amd64/canton-barebones init + test -f canton-barebones.config.json + test -f splice-localnet-overrides.yaml From 1ee784b154c5456344ef6101e71a604916a7fea7 Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 15:31:44 -0300 Subject: [PATCH 6/7] ci: publish the dpm component to ghcr.io on release --- .github/workflows/release.yml | 42 ++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72a92ee..48f47d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,12 @@ -# Publishes @bootnodedev/canton-barebones to npm via Changesets. +# Publishes @bootnodedev/canton-barebones to npm via Changesets, and the dpm +# component (standalone binaries) to ghcr.io. # # Flow: every push to main runs this. The changesets action either # (a) opens/updates a "Version Packages" PR when unreleased changesets exist, or # (b) publishes to npm when that PR was merged (i.e. package.json version bumped). # Nothing is published on a normal merge — only when the Version Packages PR lands. +# The dpm component is published right after a successful npm publish, with the +# same version, so both channels always ship the same source. name: Release on: @@ -20,6 +23,7 @@ jobs: contents: write # create tags / GitHub releases and push the Version Packages PR pull-requests: write # open and update the Version Packages PR id-token: write # npm trusted publishing (OIDC) and provenance + packages: write # push the dpm component to ghcr.io steps: - uses: actions/checkout@v4 with: @@ -41,8 +45,44 @@ jobs: - run: npm test - name: Create Version PR or publish + id: changesets uses: changesets/action@v1 with: publish: npm run release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Everything below runs only when the step above actually published to npm + # (the Version Packages PR was merged); ordinary merges skip it entirely. + + - uses: oven-sh/setup-bun@v2 + if: steps.changesets.outputs.published == 'true' + with: + bun-version: 1.3.1 # keep in sync with ci.yml + + # Pinned dpm shared with ci.yml — see the action for the pin rationale. + - uses: ./.github/actions/setup-dpm + if: steps.changesets.outputs.published == 'true' + + - name: Publish dpm component to ghcr.io + if: steps.changesets.outputs.published == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run build:component + + # dpm reads registry credentials from docker's config.json, so a + # regular docker login is all the auth it needs. + echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + + # The npm publish already bumped package.json, so its version is the + # single source of truth for both channels. dpm requires strict semver + # tags; --extra-tags latest lets users pin `:latest` in daml.yaml. + VERSION=$(node -p "require('./package.json').version") + dpm publish component "oci://ghcr.io/bootnodedev/canton-barebones:${VERSION}" \ + -p linux/amd64=dist/dpm-component/linux-amd64 \ + -p linux/arm64=dist/dpm-component/linux-arm64 \ + -p darwin/amd64=dist/dpm-component/darwin-amd64 \ + -p darwin/arm64=dist/dpm-component/darwin-arm64 \ + -p windows/amd64=dist/dpm-component/windows-amd64 \ + --extra-tags latest From 41ebec59f3faf233c86eb41e774764693961d4ff Mon Sep 17 00:00:00 2001 From: nicosampler Date: Thu, 6 Aug 2026 15:40:53 -0300 Subject: [PATCH 7/7] docs: document the dpm installation channel --- .changeset/dpm-component-channel.md | 9 ++++ README.md | 41 +++++++++++++-- docs/dpm-local-testing.md | 80 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changeset/dpm-component-channel.md create mode 100644 docs/dpm-local-testing.md diff --git a/.changeset/dpm-component-channel.md b/.changeset/dpm-component-channel.md new file mode 100644 index 0000000..fc69b57 --- /dev/null +++ b/.changeset/dpm-component-channel.md @@ -0,0 +1,9 @@ +--- +"@bootnodedev/canton-barebones": minor +--- + +Add a dpm distribution channel: the CLI is now also published as a dpm component +(`oci://ghcr.io/bootnodedev/canton-barebones`) with self-contained binaries for +linux/darwin/windows — no Node required. Install it by declaring the component in +`daml.yaml` and running `dpm install package`, then use `dpm canton-barebones ` +(alias `dpm cbn`). The npm channel is unchanged. diff --git a/README.md b/README.md index db04147..fe68ff5 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,48 @@ Splice LocalNet is powerful but has many moving parts and knobs. This tool is a ### From npm -> The npm package name is **TBD** (not published yet) — replace `` below once it is published. - ```bash -npx init # scaffold the config file into the current folder (run once) -npx start # download Splice if needed, then start the stack +npx @bootnodedev/canton-barebones init # scaffold the config file into the current folder (run once) +npx @bootnodedev/canton-barebones start # download Splice if needed, then start the stack ``` `npx` pulls the runtime dependencies automatically, so there is no separate install step. +### From dpm + +The tool is also published as a [dpm](https://github.com/digital-asset/dpm) component +(a self-contained binary — no Node required). Both flows below need a project with a +`daml.yaml` and end in the same state: the component listed in `daml.yaml` and installed. + +Add it with one command: + +```bash +dpm add component oci://ghcr.io/bootnodedev/canton-barebones:latest # or pin a version +``` + +Or declare it under `components` in `daml.yaml` and install (e.g. when the entry is +already committed to the project): + +```yaml +# daml.yaml +components: + - oci://ghcr.io/bootnodedev/canton-barebones:latest +``` + +```bash +dpm install package +``` + +Once installed, the CLI is available as a dpm subcommand: + +```bash +dpm canton-barebones init # `dpm cbn ` works too +dpm canton-barebones start +``` + +Every command in the [CLI reference](#cli-reference) works the same way, prefixed with +`dpm canton-barebones` (or the `dpm cbn` alias). + ### From a cloned repo ```bash diff --git a/docs/dpm-local-testing.md b/docs/dpm-local-testing.md new file mode 100644 index 0000000..8630556 --- /dev/null +++ b/docs/dpm-local-testing.md @@ -0,0 +1,80 @@ +# Testing the dpm component locally + +End-to-end test of the dpm distribution channel without publishing anything to a real +registry: build the component, publish it to a throwaway local OCI registry, and consume +it exactly like a user would. The whole flow runs on one machine and was how the channel +was originally validated. + +Requirements: Docker, [Bun](https://bun.sh) (compiles the binaries), and +[dpm](https://github.com/digital-asset/dpm) >= 1.0.21 (`add component` does not exist in +older versions). + +## 1. Build and publish to a local registry + +From the repo root: + +```bash +npm run build:component + +# Port 5001 because macOS often occupies 5000 (AirPlay). +docker run -d --name cb-registry -p 5001:5000 registry:2 + +dpm publish component 'oci://localhost:5001/bootnodedev/canton-barebones:0.0.1' \ + -p linux/amd64=dist/dpm-component/linux-amd64 \ + -p linux/arm64=dist/dpm-component/linux-arm64 \ + -p darwin/amd64=dist/dpm-component/darwin-amd64 \ + -p darwin/arm64=dist/dpm-component/darwin-arm64 \ + -p windows/amd64=dist/dpm-component/windows-amd64 \ + --insecure +``` + +The version tag must be strict semver (dpm rejects anything else). Republishing the same +tag overwrites it, which is fine for local iteration. + +## 2. Consume it like a user + +```bash +export DPM_INSECURE_REGISTRY=true # see quirks below + +mkdir /tmp/cb-demo && cd /tmp/cb-demo +printf 'name: demo\nversion: 0.1.0\n' > daml.yaml # dpm add requires a project manifest + +dpm add component oci://localhost:5001/bootnodedev/canton-barebones:0.0.1 --insecure + +dpm canton-barebones init +dpm canton-barebones validate +dpm canton-barebones start +dpm cbn status # the alias works too +dpm canton-barebones reset +``` + +`dpm add component` pins the component by sha256 in `daml.yaml`; from then on dpm runs +the cached binary directly (inheriting your cwd, so config and `.generated/` land in the +project, same as the npm channel). + +## 3. Clean up + +```bash +dpm canton-barebones reset # tears down the stack and removes volumes +docker rm -f cb-registry +rm -rf /tmp/cb-demo +unset DPM_INSECURE_REGISTRY # it overrides your real dpm registry config +``` + +## Quirks worth knowing (they cost us time) + +- **`DPM_INSECURE_REGISTRY=true` is required for http registries.** The `--insecure` + flag covers the resolve step but not the pull, which still insists on https + (dpm 1.0.21). Both are only needed against `localhost` — the real ghcr channel uses + https and needs neither. +- **`dpm add component` requires a `daml.yaml`** (or `multi-package.yaml`) — it records + the component there. A two-line stub is enough outside a real Daml project. +- **`dpm component run` needs `--` before passthrough flags.** The ad-hoc runner + (`dpm component run canton-barebones 0.0.1 canton-barebones validate`) parses flags + like `--json` itself unless you separate them: + `dpm component run canton-barebones 0.0.1 -- canton-barebones status --json`. + Project-installed commands (`dpm canton-barebones status --json`) pass flags through + without the separator. +- **Short names only resolve against dpm's configured registry** (Digital Asset's, under + its `components/` path convention). Third-party components like this one are always + referenced by full `oci://` URI.