toolfactory 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +31 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/commands.d.ts +1 -1
  4. package/dist/distribution/native.d.ts +50 -0
  5. package/dist/distribution/native.js +414 -0
  6. package/dist/distribution/native.test.d.ts +1 -0
  7. package/dist/distribution/native.test.js +127 -0
  8. package/dist/distribution/system.d.ts +57 -0
  9. package/dist/distribution/system.js +637 -0
  10. package/dist/distribution/system.test.d.ts +1 -0
  11. package/dist/distribution/system.test.js +116 -0
  12. package/dist/e2e.test.js +7 -1
  13. package/dist/hosts/openclaw.js +6 -2
  14. package/dist/hosts/vscode.d.ts +1 -0
  15. package/dist/hosts/vscode.js +67 -0
  16. package/dist/model.d.ts +63 -2
  17. package/dist/model.js +18 -1
  18. package/dist/ops.d.ts +52 -0
  19. package/dist/project/apply.d.ts +1 -1
  20. package/dist/project/apply.js +194 -17
  21. package/dist/project/apply.test.js +103 -0
  22. package/dist/project/gate.d.ts +17 -2
  23. package/dist/project/gate.js +102 -23
  24. package/dist/project/history.d.ts +30 -1
  25. package/dist/project/lock.d.ts +5 -0
  26. package/dist/project/lock.js +9 -0
  27. package/dist/surfaces/agent-plugins.js +5 -4
  28. package/dist/surfaces/agents.js +27 -3
  29. package/dist/surfaces/agents.test.js +12 -0
  30. package/dist/surfaces/claude.js +8 -3
  31. package/dist/surfaces/codex.js +9 -4
  32. package/dist/surfaces/codex.test.js +16 -6
  33. package/dist/surfaces/cursor.js +2 -2
  34. package/dist/surfaces/distribution.test.js +121 -7
  35. package/dist/surfaces/dsh.js +29 -7
  36. package/dist/surfaces/dsh.test.js +11 -6
  37. package/dist/surfaces/gemini.js +2 -2
  38. package/dist/surfaces/gemini.test.js +4 -4
  39. package/dist/surfaces/hermes-native.js +43 -12
  40. package/dist/surfaces/hermes-native.test.js +53 -11
  41. package/dist/surfaces/mcp-registry.js +59 -7
  42. package/dist/surfaces/mcp-registry.test.js +23 -0
  43. package/dist/surfaces/mcpb.d.ts +1 -0
  44. package/dist/surfaces/mcpb.js +36 -23
  45. package/dist/surfaces/mcpb.test.js +31 -3
  46. package/dist/surfaces/npm.js +45 -14
  47. package/dist/surfaces/openclaw-native.js +10 -8
  48. package/dist/surfaces/openclaw-native.test.js +30 -0
  49. package/dist/surfaces/pypi.js +27 -3
  50. package/dist/surfaces/readme.d.ts +16 -0
  51. package/dist/surfaces/readme.js +17 -7
  52. package/dist/surfaces/registry.js +9 -3
  53. package/dist/surfaces/vscode-extension.d.ts +14 -0
  54. package/dist/surfaces/vscode-extension.js +312 -0
  55. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  56. package/dist/surfaces/vscode-extension.test.js +81 -0
  57. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  58. package/dist/surfaces/vscode-scaffold.js +43 -0
  59. package/dist/surfaces/workflows.js +57 -11
  60. package/dist/surfaces/workflows.test.js +71 -3
  61. package/dist/toolfactory/cli.js +1 -1
  62. package/dist/toolfactory/mcp.js +1 -1
  63. package/package.json +2 -1
  64. package/src/bindings/python.ts +1 -1
  65. package/src/distribution/native.test.ts +161 -0
  66. package/src/distribution/native.ts +460 -0
  67. package/src/distribution/system.test.ts +136 -0
  68. package/src/distribution/system.ts +688 -0
  69. package/src/e2e.test.ts +7 -1
  70. package/src/hosts/openclaw.ts +4 -2
  71. package/src/hosts/vscode.ts +79 -0
  72. package/src/model.ts +26 -2
  73. package/src/project/apply.test.ts +120 -0
  74. package/src/project/apply.ts +257 -15
  75. package/src/project/gate.ts +131 -24
  76. package/src/project/lock.ts +12 -0
  77. package/src/surfaces/agent-plugins.ts +5 -4
  78. package/src/surfaces/agents.test.ts +16 -0
  79. package/src/surfaces/agents.ts +38 -3
  80. package/src/surfaces/claude.ts +7 -3
  81. package/src/surfaces/codex.test.ts +15 -5
  82. package/src/surfaces/codex.ts +12 -6
  83. package/src/surfaces/cursor.ts +2 -10
  84. package/src/surfaces/distribution.test.ts +141 -6
  85. package/src/surfaces/dsh.test.ts +11 -5
  86. package/src/surfaces/dsh.ts +30 -7
  87. package/src/surfaces/gemini.test.ts +3 -3
  88. package/src/surfaces/gemini.ts +1 -2
  89. package/src/surfaces/hermes-native.test.ts +61 -10
  90. package/src/surfaces/hermes-native.ts +46 -13
  91. package/src/surfaces/mcp-registry.test.ts +28 -0
  92. package/src/surfaces/mcp-registry.ts +66 -8
  93. package/src/surfaces/mcpb.test.ts +32 -5
  94. package/src/surfaces/mcpb.ts +44 -23
  95. package/src/surfaces/npm.ts +47 -15
  96. package/src/surfaces/openclaw-native.test.ts +37 -0
  97. package/src/surfaces/openclaw-native.ts +49 -47
  98. package/src/surfaces/pypi.ts +27 -3
  99. package/src/surfaces/readme.ts +21 -6
  100. package/src/surfaces/registry.ts +9 -3
  101. package/src/surfaces/vscode-extension.test.ts +89 -0
  102. package/src/surfaces/vscode-extension.ts +345 -0
  103. package/src/surfaces/vscode-scaffold.ts +55 -0
  104. package/src/surfaces/workflows.test.ts +86 -3
  105. package/src/surfaces/workflows.ts +57 -11
  106. package/src/toolfactory/cli.ts +1 -1
  107. package/src/toolfactory/mcp.ts +1 -1
  108. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  109. package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
  110. package/web/dist/index.html +2 -2
@@ -19,6 +19,8 @@
19
19
  */
20
20
  import { stringify as yamlStringify } from "yaml";
21
21
  import { LIVE_TEST_COMMAND } from "../bindings/python.js";
22
+ import { nativePublishJobs } from "../distribution/native.js";
23
+ import { systemPackageJobs, systemPublishJobs } from "../distribution/system.js";
22
24
  import { githubOwner } from "../identity/name.js";
23
25
  import { isInstructionOnly, } from "../model.js";
24
26
  import { bootstrapSteps, gateSteps, gateVariable, MCP_PUBLISHER_FETCH, npmTarball, openclawTarball, outputsStep, PACKAGE_MANAGER_COMMANDS, packageSteps, RELEASE_DIR, RELEASE_SECRET_NAMES, registries, tagVersionAssert, toolfactoryCli, unpublishStep, WEB_BUILD, } from "../project/gate.js";
@@ -28,6 +30,7 @@ import { ociImage, registryName } from "./mcp-registry.js";
28
30
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "./openclaw-native.js";
29
31
  import { compact, configProperties, envName, has, isSensitive, liveCredentials, npmName, requiredConfig, } from "./shared.js";
30
32
  import { skillPath } from "./skill.js";
33
+ import { OVSX_VERSION, VSCE_VERSION, vsixName } from "./vscode-extension.js";
31
34
  const YAML_HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n\n";
32
35
  /** The one artifact the `package` job uploads and every later job reads. */
33
36
  const RELEASE_ARTIFACT = "release-assets";
@@ -182,6 +185,7 @@ function ciDocument(project) {
182
185
  const node = typescript ? "${{ matrix.node-version }}" : "24";
183
186
  const job = compact({
184
187
  "runs-on": "ubuntu-latest",
188
+ env: { TOOLFACTORY_SYSTEM_NATIVE_JOBS: "true" },
185
189
  strategy: typescript ? { matrix: { "node-version": ["22", "24"] } } : undefined,
186
190
  steps: [
187
191
  ...toolchainSteps(project, node),
@@ -199,7 +203,7 @@ function ciDocument(project) {
199
203
  workflow_dispatch: live ? {} : undefined,
200
204
  }),
201
205
  permissions: { contents: "read" },
202
- jobs: compact({ test: job, live }),
206
+ jobs: compact({ test: job, live, ...systemPackageJobs(project, "${{ github.sha }}") }),
203
207
  };
204
208
  }
205
209
  // ---------------------------------------------------------------------------------------------
@@ -362,11 +366,15 @@ function releaseDocument(project) {
362
366
  };
363
367
  const cli = toolfactoryCli(project);
364
368
  const assert = tagVersionAssert(project);
369
+ const systemJobs = systemPackageJobs(project, RELEASE_SHA);
370
+ for (const job of Object.values(systemJobs))
371
+ job.needs = "gate";
365
372
  const jobs = {
373
+ ...systemJobs,
366
374
  gate: compact({
367
375
  "runs-on": "ubuntu-latest",
368
376
  permissions: compact({ contents: "read", pages: has(project, "web") ? "read" : undefined }),
369
- env: { RELEASE_TAG: TAG_NAME },
377
+ env: { RELEASE_TAG: TAG_NAME, TOOLFACTORY_SYSTEM_NATIVE_JOBS: "true" },
370
378
  outputs: { sha: "${{ steps.tag.outputs.sha }}", ...presenceOutputs(rows) },
371
379
  steps: [
372
380
  // The whole history: the tag may exist (a re-run) or not yet (a dispatch cuts it), and
@@ -385,7 +393,7 @@ function releaseDocument(project) {
385
393
  ],
386
394
  }),
387
395
  package: compact({
388
- needs: "gate",
396
+ needs: needsOf(["gate", ...Object.keys(systemJobs)]),
389
397
  "runs-on": "ubuntu-latest",
390
398
  permissions: { contents: "read" },
391
399
  // The Firefox self-hosted xpi packageSteps emits (gate.ts) is opt-in on this pair: present,
@@ -398,7 +406,11 @@ function releaseDocument(project) {
398
406
  : undefined,
399
407
  steps: [
400
408
  ...toolchainSteps(project, "24", RELEASE_SHA),
401
- ...packageSteps(project).map((step) => actionStep(step, false)),
409
+ ...packageSteps(project, { system: false }).map((step) => actionStep(step, false)),
410
+ ...Object.keys(systemJobs).map((name) => ({
411
+ uses: "actions/download-artifact@v8",
412
+ with: { name, path: `${RELEASE_DIR}/system/${name.replace(/^system-/, "")}` },
413
+ })),
402
414
  {
403
415
  uses: "actions/upload-artifact@v7",
404
416
  with: { name: RELEASE_ARTIFACT, path: `${RELEASE_DIR}/` },
@@ -406,9 +418,14 @@ function releaseDocument(project) {
406
418
  ],
407
419
  }),
408
420
  };
409
- const priorLegs = [];
421
+ Object.assign(jobs, systemPublishJobs(project, RELEASE_ARTIFACT, RELEASE_SHA));
422
+ const nativeJobs = nativePublishJobs(project, RELEASE_ARTIFACT, RELEASE_SHA);
423
+ Object.assign(jobs, nativeJobs);
424
+ const priorLegs = Object.keys(nativeJobs);
410
425
  if (npmSelected) {
411
- const tarball = `${RELEASE_ARTIFACT}/${npmTarball(project)}`;
426
+ // npm treats a slash-containing bare operand as a Git spec; make the downloaded archive a
427
+ // filesystem path explicitly.
428
+ const tarball = `./${RELEASE_ARTIFACT}/${npmTarball(project)}`;
412
429
  jobs["publish-npm"] = compact({
413
430
  needs: ["gate", "package"],
414
431
  if: gated("npm"),
@@ -442,18 +459,20 @@ function releaseDocument(project) {
442
459
  }
443
460
  if (pypiSelected) {
444
461
  jobs["publish-pypi"] = compact({
445
- needs: "gate",
462
+ needs: ["gate", "package"],
446
463
  if: gated("pypi"),
447
464
  "runs-on": "ubuntu-latest",
448
465
  environment: "pypi",
449
466
  permissions: { "id-token": "write" },
450
467
  steps: [
451
- checkoutStep(RELEASE_SHA),
452
- { uses: "astral-sh/setup-uv@v6" },
453
- { run: "uv build" },
468
+ {
469
+ uses: "actions/download-artifact@v8",
470
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
471
+ },
454
472
  {
455
473
  name: "Publish package distributions to PyPI",
456
474
  uses: "pypa/gh-action-pypi-publish@release/v1",
475
+ with: { "packages-dir": `${RELEASE_ARTIFACT}/pypi/`, "skip-existing": true },
457
476
  },
458
477
  ],
459
478
  });
@@ -581,6 +600,33 @@ function releaseDocument(project) {
581
600
  comments.push("", "publish-browser-ext-safari (opt-in, tool.json browserExtension.safari) is the macOS", "host-native leg: xcrun safari-web-extension-converter, then an Xcode archive/export", "signed with an App Store Connect API key. Runs on macos-latest, needs only `gate`, and", "does not gate the release — it submits to App Store Connect, not dist/release/.");
582
601
  }
583
602
  }
603
+ for (const id of ["vscode-marketplace", "open-vsx"]) {
604
+ const row = rows.find((entry) => entry.id === id);
605
+ if (!row)
606
+ continue;
607
+ const file = `./${RELEASE_ARTIFACT}/${vsixName(project)}`;
608
+ const publish = id === "vscode-marketplace"
609
+ ? `npx --yes @vscode/vsce@${VSCE_VERSION} publish --packagePath ${file} --skip-duplicate`
610
+ : `npx --yes ovsx@${OVSX_VERSION} publish ${file} --skip-duplicate`;
611
+ const job = `publish-${id}`;
612
+ const runtimeJob = project.tool.binding === "python" ? "publish-pypi" : "publish-npm";
613
+ jobs[job] = {
614
+ needs: ["gate", "package", runtimeJob],
615
+ if: `!cancelled() && !contains(needs.*.result, 'failure') && ${gated(id)}`,
616
+ "runs-on": "ubuntu-latest",
617
+ permissions: { contents: "read" },
618
+ env: Object.fromEntries(row.secrets.map((key) => [key, `\${{ secrets.${key} }}`])),
619
+ steps: [
620
+ { uses: "actions/setup-node@v7", with: { "node-version": "24" } },
621
+ {
622
+ uses: "actions/download-artifact@v8",
623
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
624
+ },
625
+ { name: `Publish ${id}`, run: publish },
626
+ ],
627
+ };
628
+ priorLegs.push(job);
629
+ }
584
630
  jobs.release = {
585
631
  // The tag's own release, after every leg: the assets are the same ones the registries got.
586
632
  // Skipped legs must not skip it, only failed ones; hence the explicit condition.
@@ -616,7 +662,7 @@ function releaseDocument(project) {
616
662
  // A tag the dispatch is cutting does not exist yet: the Release creates it here.
617
663
  target_commitish: RELEASE_SHA,
618
664
  generate_release_notes: true,
619
- files: `${RELEASE_ARTIFACT}/*`,
665
+ files: `${RELEASE_ARTIFACT}/**/*`,
620
666
  fail_on_unmatched_files: true,
621
667
  },
622
668
  },
@@ -120,6 +120,73 @@ describe("workflows", () => {
120
120
  });
121
121
  expect(steps.some((s) => s.run === "uv run --with pytest pytest -q")).toBe(true);
122
122
  });
123
+ it("installs a native Python core into Hermes only in validation jobs", () => {
124
+ const python = project(["cli", "mcp", "hermes-native"], {
125
+ tool: {
126
+ ...project(["cli"]).tool,
127
+ binding: "python",
128
+ surfaces: ["cli", "mcp", "hermes-native"],
129
+ },
130
+ });
131
+ const files = emitted(python);
132
+ const ci = yamlParse(files[".github/workflows/ci.yml"]);
133
+ const release = yamlParse(files[".github/workflows/release.yml"]);
134
+ const command = 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .';
135
+ const ciRuns = ci.jobs.test.steps.map((step) => step.run);
136
+ const gateRuns = release.jobs.gate.steps.map((step) => step.run);
137
+ const packageRuns = release.jobs.package.steps.map((step) => step.run);
138
+ const ciValidate = ciRuns.findIndex((run) => run?.endsWith(" validate"));
139
+ const gateValidate = gateRuns.findIndex((run) => run?.endsWith(" validate"));
140
+ expect(ciRuns.indexOf(command)).toBeGreaterThan(ciRuns.indexOf("uv sync"));
141
+ expect(ciRuns.indexOf(command)).toBeLessThan(ciValidate);
142
+ expect(gateRuns.indexOf(command)).toBeGreaterThan(gateRuns.indexOf("uv sync"));
143
+ expect(gateRuns.indexOf(command)).toBeLessThan(gateValidate);
144
+ expect(packageRuns).not.toContain(command);
145
+ const withoutHermes = project(["cli", "mcp"], {
146
+ tool: { ...project(["cli"]).tool, binding: "python", surfaces: ["cli", "mcp"] },
147
+ });
148
+ const typescriptHermes = project(["cli", "mcp", "hermes-native"]);
149
+ expect(emitted(withoutHermes)[".github/workflows/ci.yml"]).not.toContain(command);
150
+ expect(emitted(typescriptHermes)[".github/workflows/ci.yml"]).not.toContain(command);
151
+ });
152
+ it("publishes the package job's prebuilt Python distributions", () => {
153
+ const python = project(["pypi", "web"], {
154
+ tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
155
+ });
156
+ const release = yamlParse(emitted(python)[".github/workflows/release.yml"] ?? "");
157
+ const publish = release.jobs["publish-pypi"];
158
+ expect(publish.needs).toEqual(["gate", "package"]);
159
+ expect(publish.steps).toEqual([
160
+ {
161
+ uses: "actions/download-artifact@v8",
162
+ with: { name: "release-assets", path: "release-assets" },
163
+ },
164
+ {
165
+ name: "Publish package distributions to PyPI",
166
+ uses: "pypa/gh-action-pypi-publish@release/v1",
167
+ with: { "packages-dir": "release-assets/pypi/", "skip-existing": true },
168
+ },
169
+ ]);
170
+ expect(release.jobs.release.steps.at(-1)?.with.files).toBe("release-assets/**/*");
171
+ expect(packageSteps(python).find((step) => step.name === "web build")).toBeDefined();
172
+ expect(packageSteps(python).find((step) => step.name === "python distributions")?.run).toBe("uv build --out-dir dist/release/pypi");
173
+ const mcpbPython = project(["pypi", "mcp", "mcpb", "web"], {
174
+ tool: {
175
+ ...project([]).tool,
176
+ binding: "python",
177
+ surfaces: ["pypi", "mcp", "mcpb", "web"],
178
+ },
179
+ });
180
+ const mcpb = packageSteps(mcpbPython).find((step) => step.name === "MCPB bundle")?.run;
181
+ expect(packageSteps(mcpbPython).map((step) => step.name)).toEqual(expect.arrayContaining(["python distributions", "MCPB bundle"]));
182
+ expect(packageSteps(mcpbPython)
183
+ .map((step) => step.name)
184
+ .indexOf("python distributions")).toBeLessThan(packageSteps(mcpbPython)
185
+ .map((step) => step.name)
186
+ .indexOf("MCPB bundle"));
187
+ expect(mcpb).toContain("tar -xzf dist/release/pypi/*.tar.gz -C dist/mcpb --strip-components=1");
188
+ expect(mcpb).not.toContain("cp -R src dist/mcpb/src");
189
+ });
123
190
  it("release.yml runs the legs in the forced order npm -> oci -> mcp-registry -> clawhub, each gated on what the gate decided", () => {
124
191
  const files = emitted(project(["npm", "mcp-registry", "clawhub", "openclaw-native", "skill", "claude", "web", "mcp"], {
125
192
  identity: {
@@ -265,8 +332,8 @@ describe("workflows", () => {
265
332
  // configured publisher selects OIDC; otherwise the stored token is a fallback. A re-dispatch
266
333
  // after adding either credential must not attempt to republish an immutable version.
267
334
  expect(release.jobs["publish-npm"].steps.some((s) => s.run?.includes("npm view hello@0.1.0 version") &&
268
- s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish release-assets/hello-0.1.0.tgz --access public') &&
269
- s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
335
+ s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ./release-assets/hello-0.1.0.tgz --access public') &&
336
+ s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ./release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
270
337
  // The oci leg pushes the very image server.json's oci entry names.
271
338
  const meta = release.jobs["publish-oci"].steps.find((s) => s.id === "meta");
272
339
  expect(meta?.with?.images).toBe("ghcr.io/acme/hello");
@@ -465,6 +532,7 @@ describe("gate", () => {
465
532
  "toolfactory validate",
466
533
  "author checks",
467
534
  "author tests",
535
+ "author integration tests",
468
536
  "openclaw end-to-end (scripted model, no LLM key)",
469
537
  ]);
470
538
  // `npm ci` and a global CLI install are the runner's job, not a checkout's: `toolfactory gate`
@@ -491,7 +559,7 @@ describe("gate", () => {
491
559
  ]);
492
560
  const runs = packageSteps(target).map((step) => step.run);
493
561
  expect(runs).toContain("npm pack --pack-destination dist/release");
494
- expect(runs).toContain("uv build --out-dir dist/release");
562
+ expect(runs).toContain("uv build --out-dir dist/release/pypi");
495
563
  expect(runs.some((run) => run.includes("npm pack ./hosts/openclaw"))).toBe(true);
496
564
  expect(runs).toContain('set -- skills .claude-plugin; for path in LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then set -- "$@" "$path"; fi; done; zip -qr dist/release/hello-plugin.zip "$@"');
497
565
  const webBuild = runs.findIndex((run) => run.includes("npm -C web run build"));
@@ -11,7 +11,7 @@ const operations = [...authored, web];
11
11
  const program = new Command()
12
12
  .name("toolfactory")
13
13
  .description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
14
- .version("0.1.1");
14
+ .version("0.2.0");
15
15
  for (const op of operations) {
16
16
  if (!serves(op, "cli"))
17
17
  continue;
@@ -30,7 +30,7 @@ const operations = [...authored, web];
30
30
  const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
31
31
  /** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
32
32
  function createServer() {
33
- const server = new McpServer({ name: "toolfactory", version: "0.1.1" });
33
+ const server = new McpServer({ name: "toolfactory", version: "0.2.0" });
34
34
  for (const op of operations) {
35
35
  if (!introspecting && !serves(op, "mcp"))
36
36
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfactory",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "check": "biome check . && tsc -p tsconfig.json --noEmit",
18
18
  "format": "biome format --write .",
19
19
  "test": "vitest run --passWithNoTests",
20
+ "test:integration": "node --import tsx scripts/check-vscode.mjs",
20
21
  "toolfactory": "node --import tsx src/toolfactory/cli.ts",
21
22
  "vendor:template": "node --import tsx src/hosts/template.ts --write"
22
23
  },
@@ -236,7 +236,7 @@ def env_route(token: "str | None") -> Route:
236
236
  return `
237
237
 
238
238
  def web_dist() -> "Path | None":
239
- """The built page: the wheel force-includes \`${WEB_DIR}/dist\` here, a checkout has it in place."""
239
+ """The wheel maps \`${WEB_DIR}/dist\` here at package time; a checkout has it in place."""
240
240
  package = Path(__file__).resolve().parent.parent
241
241
  for candidate in (package / "${WEB_DIR}", package.parent.parent / "${WEB_DIR}" / "dist"):
242
242
  if candidate.is_dir():
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Project } from "../model.js";
3
+ import {
4
+ nativeInstallLines,
5
+ nativePackageConfigSchema,
6
+ nativePackageSteps,
7
+ nativePublishJobs,
8
+ nativeRegistryRows,
9
+ } from "./native.js";
10
+
11
+ function project(
12
+ entries: unknown[],
13
+ surfaces = entries.map((entry) => (entry as { id: string }).id),
14
+ ): Project {
15
+ return {
16
+ root: "/repo",
17
+ tool: {
18
+ schemaVersion: 1,
19
+ identity: "package.json",
20
+ binding: "typescript",
21
+ surfaces: surfaces as unknown as Project["tool"]["surfaces"],
22
+ bundle: { runtime: "package" },
23
+ tests: { examples: {} },
24
+ nativePackages: entries,
25
+ } as unknown as Project["tool"],
26
+ identity: { name: "tool", version: "1.2.3", repository: "https://github.com/acme/tool" },
27
+ identityExtra: {},
28
+ operations: [],
29
+ toolfactoryVersion: "0.1.0",
30
+ };
31
+ }
32
+ const cargo = {
33
+ id: "cargo",
34
+ path: "native/cli",
35
+ identity: "Cargo.toml",
36
+ name: "acme-cli",
37
+ versionCommand: "cargo pkgid | sed 's/.*@//'",
38
+ };
39
+
40
+ describe("native package configuration", () => {
41
+ it("requires the package path, authored identity, and registry identity", () => {
42
+ expect(() => nativePackageConfigSchema.parse({ id: "cargo", path: "native" })).toThrow();
43
+ expect(nativePackageConfigSchema.parse(cargo)).toEqual(cargo);
44
+ });
45
+
46
+ it("rejects a selected registry with no config or duplicate configuration", () => {
47
+ expect(() => nativePackageSteps(project([], ["cargo"]))).toThrow(/requires exactly one/);
48
+ expect(() => nativePackageSteps(project([cargo, cargo]))).toThrow(/repeats/);
49
+ });
50
+
51
+ it("packages authored crates and checks their version against the release", () => {
52
+ const steps = nativePackageSteps(project([cargo]));
53
+ expect(steps.map((step) => step.name)).toEqual([
54
+ "cargo version matches release",
55
+ "Cargo crate",
56
+ ]);
57
+ expect(steps[0]?.run).toContain("= '1.2.3'");
58
+ expect(steps[1]?.run).toContain("cargo package --manifest-path 'native/cli/Cargo.toml'");
59
+ expect(steps[1]?.run).toContain("--target-dir 'dist/native-build/cargo'");
60
+ expect(steps[1]?.run).not.toContain("/repo");
61
+ });
62
+
63
+ it("reads structured Cargo metadata and the raw MSBuild version", () => {
64
+ const cargoDefault = {
65
+ id: "cargo",
66
+ path: "native/cli",
67
+ identity: "Cargo.toml",
68
+ name: "acme-cli",
69
+ };
70
+ const nuget = {
71
+ id: "nuget",
72
+ path: "dotnet",
73
+ identity: "Tool.csproj",
74
+ name: "Acme.Tool",
75
+ };
76
+ const steps = nativePackageSteps(project([cargoDefault, nuget]));
77
+ expect(steps[0]?.run).toContain(
78
+ "cargo metadata --no-deps --format-version 1 --manifest-path 'Cargo.toml'",
79
+ );
80
+ expect(steps[0]?.run).toContain("JSON.parse(s).packages.find");
81
+ expect(steps[2]?.run).toContain("dotnet msbuild 'Tool.csproj' -nologo -getProperty:Version");
82
+ expect(steps[2]?.run).not.toContain("sed -n");
83
+ });
84
+
85
+ it("leaves VCS-discovered Packagist and Go modules for the final release tag", () => {
86
+ const entries = [
87
+ {
88
+ id: "packagist",
89
+ path: ".",
90
+ identity: "composer.json",
91
+ name: "acme/tool",
92
+ },
93
+ {
94
+ id: "go-module",
95
+ path: ".",
96
+ identity: "go.mod",
97
+ name: "github.com/acme/tool",
98
+ },
99
+ ];
100
+ expect(nativePackageSteps(project(entries))).toEqual([]);
101
+ expect(nativeInstallLines(project(entries))).toEqual([
102
+ "`composer require acme/tool`",
103
+ "`go get github.com/acme/tool@v1.2.3`",
104
+ ]);
105
+ });
106
+
107
+ it("uses direct upstream publishing commands and exposes only their actual credentials", () => {
108
+ const entries = [
109
+ cargo,
110
+ {
111
+ id: "nuget",
112
+ path: "dotnet",
113
+ identity: "Tool.csproj",
114
+ name: "Acme.Tool",
115
+ versionCommand: "dotnet msbuild -getProperty:Version Tool.csproj",
116
+ },
117
+ ];
118
+ const jobs = nativePublishJobs(
119
+ project(entries),
120
+ "release-assets",
121
+ "${{ needs.gate.outputs.sha }}",
122
+ );
123
+ expect(jobs["publish-cargo"]?.steps).toContainEqual(
124
+ expect.objectContaining({ run: expect.stringContaining("cargo publish") }),
125
+ );
126
+ expect(jobs["publish-nuget"]?.steps).toContainEqual(
127
+ expect.objectContaining({ run: expect.stringContaining("dotnet nuget push") }),
128
+ );
129
+ expect(jobs["publish-nuget"]?.steps).toContainEqual(
130
+ expect.objectContaining({
131
+ uses: "actions/setup-dotnet@v5",
132
+ with: { "dotnet-version": "8.0.x" },
133
+ }),
134
+ );
135
+ expect(nativeRegistryRows(project(entries)).map((row) => row.secrets)).toEqual([
136
+ ["CARGO_REGISTRY_TOKEN"],
137
+ ["NUGET_API_KEY"],
138
+ ]);
139
+ expect(jobs["publish-cargo"]?.needs).toEqual(["gate", "package"]);
140
+ expect(jobs["publish-cargo"]?.if).toBe("needs.gate.outputs.cargo == 'true'");
141
+ expect(jobs["publish-cargo"]?.steps).not.toContainEqual(
142
+ expect.objectContaining({ uses: "dtolnay/rust-toolchain@stable" }),
143
+ );
144
+ });
145
+
146
+ it("builds a gem from its package directory before moving the resulting asset", () => {
147
+ const steps = nativePackageSteps(
148
+ project([
149
+ {
150
+ id: "rubygems",
151
+ path: "native/ruby",
152
+ identity: "acme.gemspec",
153
+ name: "acme",
154
+ versionCommand: "ruby -e 'puts 1.2.3'",
155
+ },
156
+ ]),
157
+ );
158
+ expect(steps[1]?.run).toContain("(cd 'native/ruby' && gem build --strict 'acme.gemspec')");
159
+ expect(steps[1]?.run).toContain("mv 'native/ruby/acme-1.2.3.gem'");
160
+ });
161
+ });