[canton-dev-tools] Extract shared DAML JS bindings tooling - #6
Conversation
Move shared codegen/publish helpers into @fairmint/canton-dev-tools so consumer repos (canton-assets) can stay thin. Adds codegen-js and prepare-release CLI commands; leaves bundle-dependencies and create-root-index in consumers for Phase 2. Co-authored-by: HardlyDifficult <hardlydiff@gmail.com>
Co-authored-by: HardlyDifficult <hardlydiff@gmail.com>
Git branch consumers (npm install github:…#branch) need a prepare lifecycle so dist/cli.js exists; prepack alone only runs for npm pack/publish. Co-authored-by: hardlydiff <hardlydiff@gmail.com>
There was a problem hiding this comment.
Pull request overview
Extracts reusable DAML-to-npm tooling for consumer repositories.
Changes:
- Adds codegen discovery, rewriting, verification, and package-generation helpers.
- Adds
codegen-js,prepare-release, andcollapse-manifestcommands. - Centralizes release preparation and documents consumer integration.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
test/unit/scripts/canton-dev-tools.test.ts |
Tests CLI help and dispatch. |
test/unit/daml/codegen.test.ts |
Tests new codegen and release helpers. |
src/prepare-release.ts |
Implements shared release preparation. |
src/daml/types.ts |
Adds package metadata types. |
src/daml/index.ts |
Exports codegen APIs. |
src/daml/codegen/verify-package-imports.ts |
Detects unresolved imports. |
src/daml/codegen/update-generated-package.ts |
Updates generated package metadata. |
src/daml/codegen/install-generated-deps.ts |
Installs generated dependencies. |
src/daml/codegen/index.ts |
Exports codegen helpers. |
src/daml/codegen/generated-package-index.ts |
Generates package entry points. |
src/daml/codegen/generated-output-helpers.ts |
Provides generated-file rewriting utilities. |
src/daml/codegen/fix-splice-refs.ts |
Rewrites Splice references and imports. |
src/daml/codegen/discover-codegen-packages.ts |
Discovers codegen-enabled packages. |
src/daml/codegen/create-package-index.ts |
Creates package indexes. |
src/daml/codegen/collapse-manifest.ts |
Collapses package manifest entries. |
src/daml/codegen/codegen-js.ts |
Orchestrates DAML JS generation. |
src/cli.ts |
Registers new commands. |
scripts/prepare-release.ts |
Delegates to shared release logic. |
README.md |
Documents the new tooling. |
package.json |
Builds branch installs automatically. |
bin/canton-dev-tools |
Dispatches new CLI commands. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const escapedImportPath = escapeRegExp(importPath); | ||
| if (isDts) { | ||
| return source.replace(new RegExp(`from '${escapedImportPath}';`, 'g'), `from '${relativePath}';`); | ||
| } | ||
|
|
||
| return source.replace( | ||
| new RegExp(`require\\('${escapedImportPath}'\\)`, 'g'), | ||
| `require('${relativePath}')` | ||
| ); |
There was a problem hiding this comment.
Fixed in 175c600 — import rewrite regex now handles both single- and double-quoted require/from paths.
| for (const file of filesToKeep) { | ||
| if (file.endsWith('.d.ts') || file.endsWith('.js')) { | ||
| collapsedFiles.add(file.replace(/\.(d\.ts|js)$/, '')); | ||
| } else { | ||
| collapsedFiles.add(file); | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in 175c600 — manifest collapse only merges entries when both .js and .d.ts counterparts exist.
| const updateTargets = packages | ||
| .filter((pkg) => fs.existsSync(path.join(pkg.absoluteGeneratedJsDir, 'package.json'))) | ||
| .map((pkg) => ({ | ||
| dir: pkg.absoluteGeneratedJsDir, | ||
| publishedPackageName: resolvePublishedPackageName({ | ||
| rootPackageName: rootPackage.name!, | ||
| pkg, | ||
| suffixes, | ||
| codegenPackageCount: packages.length, | ||
| }), | ||
| })); |
There was a problem hiding this comment.
Fixed in 175c600 — codegen-js throws when a discovered package lacks generated package.json instead of silently skipping.
| if (!latestNpmVersion && npmVersions.size === 0) { | ||
| const registryMetadata = getNpmMetadataFromRegistry(packageName); |
There was a problem hiding this comment.
Fixed in 175c600 — npm/registry lookup fails closed on transient errors; 404 alone means unpublished.
| encoding: 'utf8', | ||
| }).trim(); | ||
| console.log(`Last tag: ${lastTag}`); | ||
| commits = execSync(`git log --oneline --format="%s" ${lastTag}..HEAD`, { |
There was a problem hiding this comment.
Fixed in 175c600 — git log range uses execFileSync argv instead of shell-interpolated lastTag.
| let commits: string; | ||
| let lastTag: string | null = null; | ||
| try { | ||
| lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { |
There was a problem hiding this comment.
Fixed in 175c600 — git describe --match v[0-9].[0-9].[0-9]* so only release semver tags are used.
| const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?/i); | ||
| return match?.[1]; |
There was a problem hiding this comment.
Fixed in 175c600 — anchored changelog-repo regex allows dots in repository names.
| function parseVersion(version: string): ParsedVersion | null { | ||
| const parts = version.split('.').map(Number); | ||
| if (parts.length !== 3 || parts.some(isNaN)) { | ||
| return null; | ||
| } | ||
| if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { | ||
| return null; | ||
| } | ||
| return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! }; | ||
| } |
There was a problem hiding this comment.
Fixed in 175c600 — strict x.y.z semver validation rejects invalid segments before numeric compare.
…Phase 2) Add general-purpose DAML→JS bundling engines driven by daml-js-bundle.json with stdlib/Splice presets only (no product package names). Expose CLI commands and library exports; cover with unit fixtures. Co-authored-by: hardlydiff <hardlydiff@gmail.com>
|
@coderabbitai review Parent consumer PR: Fairmint/canton-assets#6 (branch cursor/wrapped-assets-daml-js-0d51) depends on this branch via git dep. canton-assets CI fails at npm install with |
- Rewrite both single- and double-quoted generated imports - Collapse manifest entries only when .js/.d.ts pairs both exist - Fail codegen-js when a discovered package lacks generated output - Strict x.y.z validation, dotted repo URL parsing, fail-closed npm lookup, v-semver git describe, and execFileSync for git log ranges - Validate rootIndex outputDir/copy with assertSafeRelativePath and require basename lib for bundle rewrite layout Co-authored-by: hardlydiff <hardlydiff@gmail.com>
Co-authored-by: hardlydiff <hardlydiff@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.
Suppressed comments (5)
src/daml/codegen/generated-output-helpers.ts:65
- Import discovery and verification accept both quote styles, but this rewrite only matches single-quoted specifiers. A generated file using
require("...")orfrom "..."is detected for bundling, then left unresolved while its dependency is removed. Match and back-reference either quote style.
// Preserve whichever quote style the generated import used.
if (isDts) {
return source.replace(
new RegExp(`from (['"])${escapedImportPath}\\1;`, 'g'),
`from $1${relativePath}$1;`
);
}
src/prepare-release.ts:115
- Converting components with
Numberaccepts non-semver forms such as1e2.0.0,1..0, and unsafe integers, despite the function promisingx.y.z. These values can then be selected and written back as release versions. Parse the canonical numeric form explicitly (consistent with the repository's existing strict semver parser).
} catch (error) {
return { kind: 'error', message: getErrorMessage(error) };
}
}
function isNpmNotFoundMessage(message: string): boolean {
return /\b404\b|E404|Not Found|not found/i.test(message);
}
src/prepare-release.ts:252
lastTagcomes from repository ref data and is interpolated into a shell command. Git ref names can contain shell metacharacters, so a crafted fetched tag can execute commands when release preparation runs. Invokegitwith an argument array (for example,execFileSync('git', ['log', '--oneline', '--format=%s',${lastTag}..HEAD], ...)) instead of constructing a shell string.
const baseline =
npmParsed && compareVersions(npmParsed, manifestParsed) > 0 ? npmParsed : manifestParsed;
return findNextAvailableVersion(isVersionTaken, baseline.major, baseline.minor, baseline.patch);
}
src/daml/codegen/create-root-index.ts:235
- A safe
outputDirthat does not end inlibis still broken. For example, withoutputDir: "dist", this choosesdistas the package root, so post-bundle presets and namespace indexes are written underdist/lib, while the root index and copied trees remain directly underdist; import patching also resolves wrappers under the repository'slib. Either restrictoutputDirto paths ending inlibor make these helpers operate on the configured library directory directly.
if (path.basename(outputRel) !== 'lib') {
throw new Error(
`rootIndex.outputDir must resolve to a directory named "lib" (got ${JSON.stringify(outputRel)}). ` +
src/daml/codegen/create-root-index.ts:227
copyDirectorysilently returns when a configured source entry is missing, so a typo inrootIndex.copystill reports a successful combined library and leaves generated index imports pointing at absent namespaces. Fail before copying unless each configured source exists and is a directory.
if (!fs.existsSync(pkgLib)) {
throw new Error(
| const outputDir = raw['outputDir']; | ||
| if (outputDir !== undefined && (typeof outputDir !== 'string' || outputDir.length === 0)) { | ||
| throw new Error(`Invalid ${label}.outputDir`); | ||
| } |
There was a problem hiding this comment.
Fixed in 175c600 — rootIndex.outputDir validated with assertSafeRelativePath and resolveContainedPath.
| const copyRaw = raw['copy']; | ||
| if (!Array.isArray(copyRaw) || copyRaw.length === 0 || !copyRaw.every((v) => typeof v === 'string')) { | ||
| throw new Error(`Invalid ${label}.copy (expected non-empty string[])`); | ||
| } |
There was a problem hiding this comment.
Fixed in 175c600 — rootIndex.copy entries validated with assertSafeRelativePath and resolveContainedPath.
| preset.apply({ | ||
| targetDir, | ||
| generatedJsDir: options.generatedJsDir, | ||
| pins: options.pins, | ||
| willBundleAmulet, | ||
| }); | ||
| applied.push(preset.id); | ||
| rewriteRules.push(...preset.rewriteRules(targetDir, options.pins)); | ||
| packageJsonDeps.push(...preset.importSpecs(options.pins)); |
There was a problem hiding this comment.
Fixed in 175c600 — preset apply records rewriteRules/deps only after successful apply (missing sources skip rewrite).
|
@coderabbitai review Addressed Copilot + Bugbot findings in 175c600. Please re-review. |
Align with canton-assets / canton-privy-sdk: do not run prepare-release on autofix or [skip publish] commits (or github-actions[bot] pushes). Manual workflow_dispatch still publishes. Co-authored-by: hardlydiff <hardlydiff@gmail.com>
Bugbot: prepare now invokes tsc directly (no nested npm run build), and failed/missing preset materialization skips import rewrite and package.json dep removal so packages are not left pointing at missing __bundled__ paths. Co-authored-by: hardlydiff <hardlydiff@gmail.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 920d4a3. Configure here.
- createRootIndex only rewrites presets that materialized (apply success + detection targets present), matching bundleDependenciesForTarget - splice-token-v1 wrappers/rewrites/dep cleanup only for packages that actually copied; zero copies still returns false - npm-view latest fallback uses semver compare instead of string sort Co-authored-by: hardlydiff <hardlydiff@gmail.com>
Co-authored-by: hardlydiff <hardlydiff@gmail.com>
Remove publish.yml autofix/[skip publish] guards. Run npm run fix in CI and fail when the working tree is dirty so contributors commit fixes locally. Co-authored-by: hardlydiff <hardlydiff@gmail.com>
Run npm run fix locally so CI no longer fails on uncommitted formatting after the fail-on-dirty-tree workflow change. Co-authored-by: hardlydiff <hardlydiff@gmail.com>

Summary
Extract generic DAML→npm JS bindings tooling into
@fairmint/canton-dev-toolsso public consumer repos (esp.canton-assets) stay thin.Companion: canton-assets#6.
Standard
@fairmint/canton-dev-toolscodegen-js,prepare-release,collapse-manifestdaml-js-bundle.jsonconfig (pins, namespaces, template constants)bundle-dependencies,create-root-index,fix-splice-refsNew CLI (Phase 1 + 2)
Config:
daml-js-bundle.json(presets + pins + rootIndex). Zero WrappedAssets/OCP/NFT names in CDT code.Out of scope (still consumer / later)
Fairmint/daml+open-captable-protocol-damloff local forks (same config pattern)Merge order
Verify
npm test(98) ·build·pack:check·lint