toolfactory 0.1.2 → 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 (98) hide show
  1. package/README.md +24 -1
  2. package/dist/commands.d.ts +1 -1
  3. package/dist/distribution/native.d.ts +50 -0
  4. package/dist/distribution/native.js +414 -0
  5. package/dist/distribution/native.test.d.ts +1 -0
  6. package/dist/distribution/native.test.js +127 -0
  7. package/dist/distribution/system.d.ts +57 -0
  8. package/dist/distribution/system.js +637 -0
  9. package/dist/distribution/system.test.d.ts +1 -0
  10. package/dist/distribution/system.test.js +116 -0
  11. package/dist/e2e.test.js +7 -1
  12. package/dist/hosts/openclaw.js +6 -2
  13. package/dist/hosts/vscode.d.ts +1 -0
  14. package/dist/hosts/vscode.js +67 -0
  15. package/dist/model.d.ts +62 -2
  16. package/dist/model.js +9 -0
  17. package/dist/ops.d.ts +52 -0
  18. package/dist/project/apply.d.ts +1 -1
  19. package/dist/project/apply.js +194 -17
  20. package/dist/project/apply.test.js +103 -0
  21. package/dist/project/gate.d.ts +17 -2
  22. package/dist/project/gate.js +68 -3
  23. package/dist/project/history.d.ts +29 -1
  24. package/dist/project/lock.d.ts +5 -0
  25. package/dist/project/lock.js +9 -0
  26. package/dist/surfaces/agent-plugins.js +5 -4
  27. package/dist/surfaces/agents.js +21 -3
  28. package/dist/surfaces/agents.test.js +12 -0
  29. package/dist/surfaces/claude.js +8 -3
  30. package/dist/surfaces/codex.js +8 -3
  31. package/dist/surfaces/codex.test.js +8 -8
  32. package/dist/surfaces/cursor.js +2 -2
  33. package/dist/surfaces/distribution.test.js +3 -3
  34. package/dist/surfaces/dsh.js +25 -6
  35. package/dist/surfaces/dsh.test.js +8 -3
  36. package/dist/surfaces/gemini.js +2 -2
  37. package/dist/surfaces/gemini.test.js +4 -4
  38. package/dist/surfaces/hermes-native.js +38 -9
  39. package/dist/surfaces/hermes-native.test.js +32 -11
  40. package/dist/surfaces/mcp-registry.js +43 -5
  41. package/dist/surfaces/mcp-registry.test.js +18 -0
  42. package/dist/surfaces/openclaw-native.js +10 -8
  43. package/dist/surfaces/openclaw-native.test.js +30 -0
  44. package/dist/surfaces/readme.d.ts +16 -0
  45. package/dist/surfaces/readme.js +7 -0
  46. package/dist/surfaces/registry.js +6 -0
  47. package/dist/surfaces/vscode-extension.d.ts +14 -0
  48. package/dist/surfaces/vscode-extension.js +312 -0
  49. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  50. package/dist/surfaces/vscode-extension.test.js +81 -0
  51. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  52. package/dist/surfaces/vscode-scaffold.js +43 -0
  53. package/dist/surfaces/workflows.js +49 -10
  54. package/dist/surfaces/workflows.test.js +3 -2
  55. package/dist/toolfactory/cli.js +1 -1
  56. package/dist/toolfactory/mcp.js +1 -1
  57. package/package.json +2 -1
  58. package/src/distribution/native.test.ts +161 -0
  59. package/src/distribution/native.ts +460 -0
  60. package/src/distribution/system.test.ts +136 -0
  61. package/src/distribution/system.ts +688 -0
  62. package/src/e2e.test.ts +7 -1
  63. package/src/hosts/openclaw.ts +4 -2
  64. package/src/hosts/vscode.ts +79 -0
  65. package/src/model.ts +15 -1
  66. package/src/project/apply.test.ts +120 -0
  67. package/src/project/apply.ts +257 -15
  68. package/src/project/gate.ts +96 -4
  69. package/src/project/lock.ts +12 -0
  70. package/src/surfaces/agent-plugins.ts +5 -4
  71. package/src/surfaces/agents.test.ts +16 -0
  72. package/src/surfaces/agents.ts +31 -3
  73. package/src/surfaces/claude.ts +7 -3
  74. package/src/surfaces/codex.test.ts +7 -7
  75. package/src/surfaces/codex.ts +8 -3
  76. package/src/surfaces/cursor.ts +2 -10
  77. package/src/surfaces/distribution.test.ts +2 -2
  78. package/src/surfaces/dsh.test.ts +8 -2
  79. package/src/surfaces/dsh.ts +26 -6
  80. package/src/surfaces/gemini.test.ts +3 -3
  81. package/src/surfaces/gemini.ts +1 -2
  82. package/src/surfaces/hermes-native.test.ts +39 -10
  83. package/src/surfaces/hermes-native.ts +41 -11
  84. package/src/surfaces/mcp-registry.test.ts +20 -0
  85. package/src/surfaces/mcp-registry.ts +46 -6
  86. package/src/surfaces/openclaw-native.test.ts +37 -0
  87. package/src/surfaces/openclaw-native.ts +49 -47
  88. package/src/surfaces/readme.ts +10 -0
  89. package/src/surfaces/registry.ts +6 -0
  90. package/src/surfaces/vscode-extension.test.ts +89 -0
  91. package/src/surfaces/vscode-extension.ts +345 -0
  92. package/src/surfaces/vscode-scaffold.ts +55 -0
  93. package/src/surfaces/workflows.test.ts +3 -4
  94. package/src/surfaces/workflows.ts +49 -10
  95. package/src/toolfactory/cli.ts +1 -1
  96. package/src/toolfactory/mcp.ts +1 -1
  97. package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
  98. package/web/dist/index.html +1 -1
package/README.md CHANGED
@@ -22,7 +22,7 @@ generated and every surface still installs, builds and publishes.
22
22
  - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/toolfactory`
23
23
  - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout
24
24
  - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/toolfactory#hosts/hermes/toolfactory_hermes`
25
- - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.1.2.tgz`
25
+ - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.2.0.tgz`
26
26
  - **Web app** — `npx -y toolfactory mcp --http --open` serves the operations page beside the
27
27
  MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and
28
28
  returns the URL.
@@ -120,16 +120,39 @@ and an MCP server cannot give it (a gateway tab, browser capability, a store lis
120
120
  | `gemini` | root `gemini-extension.json` — a Gemini CLI extension installable straight from the repo, reading your `AGENTS.md` and `skills/` | `gemini extensions validate` |
121
121
  | `mcp`, `cli` | the kernel MCP server (stdio, or `--http`) and a CLI over your operations; each lists only the operations it can run | MCP Inspector, `--help` |
122
122
  | `npm`, `pypi` | package metadata merged into `package.json` / `pyproject.toml` | `npm pack`, `uv build` |
123
+ | `cargo`, `nuget`, `maven-central`, `rubygems`, `packagist`, `go-module` | releases for author-owned native packages alongside the tool; metadata stays in its native format | Cargo, .NET, Maven, RubyGems, Composer, Go |
124
+ | `homebrew`, `winget`, `scoop`, `chocolatey`, `apt`, `rpm` | native packaging and catalog/repository distribution on the appropriate OS | the manager's own tooling |
123
125
  | `mcp-registry` | `server.json` | `mcp-publisher` |
124
126
  | `mcpb` | `hosts/mcpb/manifest.json` and a `.mcpb` release asset packed from the npm tarball — the one-click install Claude Desktop takes | `mcpb validate` |
125
127
  | `openclaw-native` | `hosts/openclaw/`, mirroring `openclaw plugins init --type tool` | `openclaw plugins build --check`, `validate`, plugin-inspector |
126
128
  | `hermes-native` | `hosts/hermes/`, a manifest v2 plugin | `hermes plugins doctor --ci` |
127
129
  | `web` | `web/`, a shadcn/ui (Vite, React, Tailwind) app with a form per operation; your own pages sit beside it in `App.tsx` | `vite build`, Playwright |
128
130
  | `browser-extension` | `hosts/browser/`, one WXT extension built for Chromium, Firefox and Safari: the worker calls your kernel over loopback MCP, the popup is the `web` app, and the operations that need a page are yours to write in `entrypoints/` | `wxt build`, `web-ext lint`, Playwright against a real Chromium |
131
+ | `vscode-extension` | `hosts/vscode/`, a native TypeScript extension with MCP discovery, command palette operations, settings and SecretStorage; activation and native contributions are yours | Microsoft `generator-code` scaffold comparison, `vsce package`, real Extension Development Host |
129
132
  | `dsh` (experimental) | `hosts/dsh/`, a zero-code DSH (DeepSeek Harness) bundle: one Cordis patch row attaching your MCP server through `@deepseek-ai/dsh-mcp-client` | a keyless `dsh --profile headless` boot |
130
133
  | workflows (always) | `ci.yml`, `release.yml` (gate → package → publish legs → GitHub Release, plus Pages), `compose.toolfactory.yaml`, `.env.example`, `renovate.json`; every step is one `toolfactory gate` / `toolfactory package` runs without GitHub | the workflow itself |
131
134
  | readme (always) | the Install section of `README.md` (a marked region): one install line per selected surface, plus a static Agent Skill badge | — |
132
135
 
136
+ Select `vscode-extension` together with `mcp`, `cli`, and the runtime registry (`npm` for
137
+ TypeScript, `pypi` for Python). Set `vscode.publisher` in `dev.toolfactory/tool.json`, then build.
138
+ Open `hosts/vscode` and press F5 to use the checkout's runtime, or run `npm run vsix` in that
139
+ directory to package it. Native commands, views, webviews, language services, chat participants,
140
+ and language-model tools use the full VS Code API in your own `src/extension.ts`. Add contribution
141
+ points directly to its `package.json`; rebuilds preserve author entries and additional fields
142
+ on generated entries. Marketplace and Open VSX release jobs use `VSCE_PAT` and `OVSX_PAT` after
143
+ you register the publisher. Both can be disabled individually in `tool.json`.
144
+
145
+ The same native customization rule applies to the other surfaces: JSON, TOML and YAML manifests
146
+ merge only projected fields, and host entry points retain your code outside the marked regions.
147
+ OpenClaw, Hermes, DSH and browser shims can use their host's complete native API. `adopt` and
148
+ `eject` remain available when replacing a generated integration entirely.
149
+
150
+ Package distribution is opt-in. PyPI supports both `pip install` and `uv add`; pip uses the same
151
+ published wheel/sdist. Other ecosystems consume real native packages and metadata supplied by the
152
+ author. See [native package releases](docs/native-package-releases.md) and
153
+ [system package releases](docs/system-package-releases.md) for configuration and registry setup.
154
+ Toolfactory itself continues to use npm as its package registry.
155
+
133
156
  ## Driving toolfactory from an agent
134
157
 
135
158
  Every command is an MCP tool, so a host can drive the whole loop without a shell — and nothing is
@@ -185,4 +185,4 @@ export declare function bootstrapRepo(args: {
185
185
  dryRun?: boolean;
186
186
  }): BootstrapResult;
187
187
  export declare function doctor(): DoctorReport;
188
- export declare const SURFACES: readonly ["skill", "agent-plugins", "claude", "codex", "cursor", "mcp", "mcp-registry", "cli", "npm", "pypi", "openclaw-native", "hermes-native", "clawhub", "web", "dsh", "workflows", "agents", "readme", "gemini", "mcpb", "browser-extension"];
188
+ export declare const SURFACES: readonly ["skill", "agent-plugins", "claude", "codex", "cursor", "mcp", "mcp-registry", "cli", "npm", "pypi", "openclaw-native", "hermes-native", "clawhub", "web", "dsh", "workflows", "agents", "readme", "gemini", "mcpb", "browser-extension", "vscode-extension", "cargo", "nuget", "maven-central", "rubygems", "packagist", "go-module", "homebrew", "winget", "scoop", "chocolatey", "apt", "rpm"];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Native package registries are deliberately an escape hatch, not bindings. A project may
3
+ * carry a real Cargo/.NET/JVM/Ruby/PHP/Go package beside a Toolfactory TypeScript or Python
4
+ * kernel, but Toolfactory never translates that kernel into another language or invents a
5
+ * launcher to enter a registry.
6
+ */
7
+ import { z } from "zod";
8
+ import type { Project, Surface } from "../model.js";
9
+ import type { GateStep, Registry } from "../project/gate.js";
10
+ export declare const NATIVE_PACKAGE_IDS: readonly ["cargo", "nuget", "maven-central", "rubygems", "packagist", "go-module"];
11
+ export type NativePackageId = (typeof NATIVE_PACKAGE_IDS)[number];
12
+ /**
13
+ * `name` is intentionally not derived from the Toolfactory identity: package namespaces have
14
+ * their own ownership rules. A standard per-ecosystem version read is used unless the author
15
+ * needs a `versionCommand` escape hatch.
16
+ */
17
+ export declare const nativePackageConfigSchema: z.ZodObject<{
18
+ id: z.ZodEnum<{
19
+ cargo: "cargo";
20
+ nuget: "nuget";
21
+ "maven-central": "maven-central";
22
+ rubygems: "rubygems";
23
+ packagist: "packagist";
24
+ "go-module": "go-module";
25
+ }>;
26
+ path: z.ZodString;
27
+ identity: z.ZodString;
28
+ name: z.ZodString;
29
+ versionCommand: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strict>;
31
+ export type NativePackageConfig = z.infer<typeof nativePackageConfigSchema>;
32
+ /** External registries do not generate files: their package metadata remains author-owned. */
33
+ export declare const nativePackageSurfaces: Surface[];
34
+ /**
35
+ * Release packaging happens after the main package build. VCS registries deliberately have no
36
+ * artifact: their published object is the signed Git tag and repository contents.
37
+ */
38
+ export declare function nativePackageSteps(project: Project): GateStep[];
39
+ /**
40
+ * Kept with the release contract so local `gate` and the package job fail before a package
41
+ * command with a clear missing-tool message. GitHub's hosted Linux image supplies these upstream
42
+ * clients; callers that use another runner can install its normal distro packages first.
43
+ */
44
+ export declare function nativeToolchainSteps(project: Project): GateStep[];
45
+ export declare function nativeRegistryRows(project: Project): Registry[];
46
+ type Job = Record<string, unknown>;
47
+ /** GitHub Actions jobs use the upstream language tool and package client directly. */
48
+ export declare function nativePublishJobs(project: Project, artifactDir: string, releaseSha: string): Record<string, Job>;
49
+ export declare function nativeInstallLines(project: Project): string[];
50
+ export {};
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Native package registries are deliberately an escape hatch, not bindings. A project may
3
+ * carry a real Cargo/.NET/JVM/Ruby/PHP/Go package beside a Toolfactory TypeScript or Python
4
+ * kernel, but Toolfactory never translates that kernel into another language or invents a
5
+ * launcher to enter a registry.
6
+ */
7
+ import { z } from "zod";
8
+ export const NATIVE_PACKAGE_IDS = [
9
+ "cargo",
10
+ "nuget",
11
+ "maven-central",
12
+ "rubygems",
13
+ "packagist",
14
+ "go-module",
15
+ ];
16
+ /**
17
+ * `name` is intentionally not derived from the Toolfactory identity: package namespaces have
18
+ * their own ownership rules. A standard per-ecosystem version read is used unless the author
19
+ * needs a `versionCommand` escape hatch.
20
+ */
21
+ export const nativePackageConfigSchema = z
22
+ .object({
23
+ id: z.enum(NATIVE_PACKAGE_IDS),
24
+ path: z
25
+ .string()
26
+ .min(1)
27
+ .refine((path) => !/^(?:[A-Za-z]:|[\\/])/.test(path) && !path.split(/[\\/]/).includes(".."), "path must stay inside the repository."),
28
+ identity: z
29
+ .string()
30
+ .min(1)
31
+ .refine((path) => !/^(?:[A-Za-z]:|[\\/])/.test(path) && !path.split(/[\\/]/).includes(".."), "identity must be relative to path."),
32
+ name: z
33
+ .string()
34
+ .min(1)
35
+ .describe("Registry package identity, including its required namespace."),
36
+ versionCommand: z
37
+ .string()
38
+ .min(1)
39
+ .optional()
40
+ .describe("Command run in path that prints exactly the authored native package version."),
41
+ })
42
+ .superRefine((entry, context) => {
43
+ if (entry.id === "maven-central" && entry.name.split(":").length !== 2)
44
+ context.addIssue({
45
+ code: "custom",
46
+ path: ["name"],
47
+ message: "Maven Central name must be groupId:artifactId.",
48
+ });
49
+ if ((entry.id === "packagist" || entry.id === "go-module") && entry.path !== ".")
50
+ context.addIssue({
51
+ code: "custom",
52
+ path: ["path"],
53
+ message: `${entry.id} is VCS/tag-discovered and currently requires package path ".".`,
54
+ });
55
+ })
56
+ .strict();
57
+ function configs(project) {
58
+ const raw = project.tool.nativePackages ?? [];
59
+ const parsed = raw.map((entry) => nativePackageConfigSchema.parse(entry));
60
+ const duplicates = parsed.filter((entry, index) => parsed.findIndex((other) => other.id === entry.id) !== index);
61
+ if (duplicates.length)
62
+ throw new Error(`Native package configuration repeats: ${[...new Set(duplicates.map((entry) => entry.id))].join(", ")}.`);
63
+ return parsed;
64
+ }
65
+ function selected(project, id) {
66
+ return project.tool.surfaces.includes(id);
67
+ }
68
+ function shellQuote(value) {
69
+ return `'${value.replace(/'/g, "'\\''")}'`;
70
+ }
71
+ function defaultVersionCommand(entry) {
72
+ const identity = shellQuote(entry.identity);
73
+ switch (entry.id) {
74
+ case "cargo":
75
+ return `cargo metadata --no-deps --format-version 1 --manifest-path ${identity} | node -e ${shellQuote(`let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).packages.find(p=>p.name===${JSON.stringify(entry.name)}).version))`)}`;
76
+ case "nuget":
77
+ return `dotnet msbuild ${identity} -nologo -getProperty:Version`;
78
+ case "maven-central":
79
+ return `mvn --quiet --file ${identity} -DforceStdout help:evaluate -Dexpression=project.version`;
80
+ case "rubygems":
81
+ return `ruby -e 'puts Gem::Specification.load(ARGV[0]).version' ${identity}`;
82
+ case "packagist":
83
+ case "go-module":
84
+ throw new Error(`${entry.id} is discovered from the release tag and has no pre-release version command.`);
85
+ }
86
+ }
87
+ function versionAssert(entry, version) {
88
+ if (!version)
89
+ throw new Error(`Native package "${entry.name}" requires the shared identity to declare version.`);
90
+ return `test "$(${entry.versionCommand ?? defaultVersionCommand(entry)})" = ${shellQuote(version)}`;
91
+ }
92
+ function configFor(project, id) {
93
+ const matches = configs(project).filter((entry) => entry.id === id);
94
+ if (matches.length !== 1)
95
+ throw new Error(`Surface "${id}" requires exactly one tool.nativePackages entry with id "${id}".`);
96
+ const entry = matches[0];
97
+ if ((id === "packagist" || id === "go-module") && !project.identity.repository)
98
+ throw new Error(`Surface "${id}" requires identity.repository because it is discovered from the final Git tag.`);
99
+ return entry;
100
+ }
101
+ function activeConfigs(project) {
102
+ return NATIVE_PACKAGE_IDS.filter((id) => selected(project, id)).map((id) => configFor(project, id));
103
+ }
104
+ function entryVersion(project, entry) {
105
+ if (!project.identity.version)
106
+ throw new Error(`Native package "${entry.name}" requires the shared identity to declare version.`);
107
+ return project.identity.version;
108
+ }
109
+ const commands = {
110
+ cargo: (entry) => [
111
+ {
112
+ label: "Cargo package",
113
+ command: "cargo",
114
+ args: ["package", "--manifest-path", entry.identity],
115
+ cwd: entry.path,
116
+ },
117
+ ],
118
+ nuget: (entry) => [
119
+ {
120
+ label: "NuGet pack",
121
+ command: "dotnet",
122
+ args: ["pack", entry.identity, "--configuration", "Release"],
123
+ cwd: entry.path,
124
+ },
125
+ ],
126
+ "maven-central": (entry) => [
127
+ {
128
+ label: "Maven verify",
129
+ command: "mvn",
130
+ args: ["--batch-mode", "--file", entry.identity, "verify"],
131
+ cwd: entry.path,
132
+ },
133
+ ],
134
+ rubygems: (entry) => [
135
+ {
136
+ label: "RubyGems build",
137
+ command: "gem",
138
+ args: ["build", "--strict", entry.identity],
139
+ cwd: entry.path,
140
+ },
141
+ ],
142
+ packagist: (entry) => [
143
+ {
144
+ label: "Composer validate",
145
+ command: "composer",
146
+ args: ["validate", "--strict", entry.identity],
147
+ cwd: entry.path,
148
+ },
149
+ ],
150
+ "go-module": (entry) => [
151
+ { label: "Go module tidy", command: "go", args: ["mod", "tidy", "-diff"], cwd: entry.path },
152
+ { label: "Go module tests", command: "go", args: ["test", "./..."], cwd: entry.path },
153
+ ],
154
+ };
155
+ /** External registries do not generate files: their package metadata remains author-owned. */
156
+ export const nativePackageSurfaces = NATIVE_PACKAGE_IDS.map((id) => ({
157
+ id,
158
+ plan(project) {
159
+ configFor(project, id);
160
+ return [];
161
+ },
162
+ validate(project) {
163
+ const entry = configFor(project, id);
164
+ return commands[id](entry).map((command) => ({ ...command, cwd: entry.path }));
165
+ },
166
+ }));
167
+ /**
168
+ * Release packaging happens after the main package build. VCS registries deliberately have no
169
+ * artifact: their published object is the signed Git tag and repository contents.
170
+ */
171
+ export function nativePackageSteps(project) {
172
+ return activeConfigs(project).flatMap((entry) => {
173
+ // Packagist and Go proxy discovery happens from the tag created by the final release job.
174
+ // Before then, `git describe` can only see the prior version, so asserting it here is wrong.
175
+ if (entry.id === "packagist" || entry.id === "go-module")
176
+ return [];
177
+ const directory = shellQuote(entry.path);
178
+ const out = `dist/release/native/${entry.id}`;
179
+ const check = versionAssert(entry, project.identity.version);
180
+ const steps = [
181
+ { name: `${entry.id} version matches release`, run: `cd ${directory} && ${check}` },
182
+ ];
183
+ switch (entry.id) {
184
+ case "cargo":
185
+ steps.push({
186
+ name: "Cargo crate",
187
+ run: `mkdir -p ${shellQuote(out)} && cargo package --manifest-path ${shellQuote(`${entry.path}/${entry.identity}`)} --target-dir ${shellQuote(`dist/native-build/${entry.id}`)} && cp ${shellQuote(`dist/native-build/${entry.id}/package`)}/*.crate ${shellQuote(out)}/`,
188
+ });
189
+ break;
190
+ case "nuget":
191
+ steps.push({
192
+ name: "NuGet package",
193
+ run: `mkdir -p ${shellQuote(out)} && dotnet pack ${shellQuote(`${entry.path}/${entry.identity}`)} --configuration Release --output ${shellQuote(out)}`,
194
+ });
195
+ break;
196
+ case "maven-central":
197
+ steps.push({
198
+ name: "Maven package",
199
+ run: `mkdir -p ${shellQuote(out)} && mvn --batch-mode --file ${shellQuote(`${entry.path}/${entry.identity}`)} package && cp ${shellQuote(`${entry.path}/${entry.identity}`)} ${shellQuote(`${out}/${entry.name.split(":")[1]}-${project.identity.version}.pom`)} && find ${shellQuote(`${entry.path}/target`)} -maxdepth 1 -type f \\( -name '*.jar' -o -name '*.war' -o -name '*.pom' \\) -exec cp {} ${shellQuote(out)} \\;`,
200
+ });
201
+ break;
202
+ case "rubygems":
203
+ steps.push({
204
+ name: "Ruby gem",
205
+ run: `mkdir -p ${shellQuote(out)} && (cd ${directory} && gem build --strict ${shellQuote(entry.identity)}) && mv ${shellQuote(`${entry.path}/${entry.name}-${project.identity.version}.gem`)} ${shellQuote(out)}/`,
206
+ });
207
+ break;
208
+ }
209
+ return steps;
210
+ });
211
+ }
212
+ /**
213
+ * Kept with the release contract so local `gate` and the package job fail before a package
214
+ * command with a clear missing-tool message. GitHub's hosted Linux image supplies these upstream
215
+ * clients; callers that use another runner can install its normal distro packages first.
216
+ */
217
+ export function nativeToolchainSteps(project) {
218
+ const tools = {
219
+ cargo: "cargo",
220
+ nuget: "dotnet",
221
+ "maven-central": "mvn",
222
+ rubygems: "gem",
223
+ packagist: "composer",
224
+ "go-module": "go",
225
+ };
226
+ return activeConfigs(project).map((entry) => ({
227
+ name: `${entry.id} upstream toolchain`,
228
+ run: `command -v ${tools[entry.id]} >/dev/null || { echo "${entry.id}: install its upstream ${tools[entry.id]} CLI before packaging" >&2; exit 1; }`,
229
+ }));
230
+ }
231
+ function row(project, entry) {
232
+ const version = entryVersion(project, entry);
233
+ const [groupId = "", artifactId = ""] = entry.name.split(":");
234
+ const mavenSearch = `https://search.maven.org/solrsearch/select?q=${encodeURIComponent(`g:"${groupId}" AND a:"${artifactId}" AND v:"${version}"`)}&rows=1&wt=json`;
235
+ const shared = {
236
+ id: entry.id,
237
+ surfaces: [entry.id],
238
+ url: {
239
+ cargo: "https://crates.io/settings/tokens",
240
+ nuget: "https://www.nuget.org/account/apikeys",
241
+ "maven-central": "https://central.sonatype.com/account",
242
+ rubygems: "https://rubygems.org/profile/edit",
243
+ packagist: "https://packagist.org/about",
244
+ "go-module": "https://go.dev/doc/modules/publishing",
245
+ }[entry.id],
246
+ };
247
+ switch (entry.id) {
248
+ case "cargo":
249
+ return {
250
+ ...shared,
251
+ secrets: ["CARGO_REGISTRY_TOKEN"],
252
+ exists: `curl -fsS -o /dev/null ${shellQuote(`https://crates.io/api/v1/crates/${encodeURIComponent(entry.name)}/${encodeURIComponent(version)}`)}`,
253
+ gate: '[ -n "$CARGO_REGISTRY_TOKEN" ]',
254
+ retract: `cargo yank ${shellQuote(entry.name)} --vers ${shellQuote(version)} --token "$CARGO_REGISTRY_TOKEN"`,
255
+ };
256
+ case "nuget":
257
+ return {
258
+ ...shared,
259
+ secrets: ["NUGET_API_KEY"],
260
+ exists: `curl -fsS -o /dev/null ${shellQuote(`https://api.nuget.org/v3-flatcontainer/${entry.name.toLowerCase()}/${version.toLowerCase()}/${entry.name.toLowerCase()}.${version.toLowerCase()}.nupkg`)}`,
261
+ gate: '[ -n "$NUGET_API_KEY" ]',
262
+ retract: `dotnet nuget delete ${shellQuote(entry.name)} ${shellQuote(version)} --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --non-interactive`,
263
+ };
264
+ case "maven-central":
265
+ return {
266
+ ...shared,
267
+ secrets: [
268
+ "MAVEN_CENTRAL_USERNAME",
269
+ "MAVEN_CENTRAL_PASSWORD",
270
+ "MAVEN_GPG_PRIVATE_KEY",
271
+ "MAVEN_GPG_PASSPHRASE",
272
+ ],
273
+ exists: `curl -fsS ${shellQuote(mavenSearch)} | node -e ${shellQuote("let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.exit(JSON.parse(s).response.numFound>0?0:1))")}`,
274
+ gate: '[ -n "$MAVEN_CENTRAL_USERNAME" ] && [ -n "$MAVEN_CENTRAL_PASSWORD" ] && [ -n "$MAVEN_GPG_PRIVATE_KEY" ] && [ -n "$MAVEN_GPG_PASSPHRASE" ]',
275
+ retractUrl: "https://central.sonatype.com/publishing/deployments",
276
+ };
277
+ case "rubygems":
278
+ return {
279
+ ...shared,
280
+ secrets: ["RUBYGEMS_API_KEY"],
281
+ exists: `curl -fsS ${shellQuote(`https://rubygems.org/api/v1/versions/${encodeURIComponent(entry.name)}.json`)} | node -e ${shellQuote(`let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.exit(JSON.parse(s).some(v=>v.number===${JSON.stringify(version)})?0:1))`)}`,
282
+ gate: '[ -n "$RUBYGEMS_API_KEY" ]',
283
+ retract: `GEM_HOST_API_KEY="$RUBYGEMS_API_KEY" gem yank ${shellQuote(entry.name)} -v ${shellQuote(version)}`,
284
+ };
285
+ case "packagist":
286
+ return {
287
+ ...shared,
288
+ secrets: [],
289
+ gate: "true",
290
+ retractUrl: "https://packagist.org/packages",
291
+ };
292
+ case "go-module":
293
+ return {
294
+ ...shared,
295
+ secrets: [],
296
+ gate: "true",
297
+ retractUrl: "https://go.dev/ref/mod#go-mod-file-retract-directive",
298
+ };
299
+ }
300
+ }
301
+ export function nativeRegistryRows(project) {
302
+ return activeConfigs(project).map((entry) => row(project, entry));
303
+ }
304
+ const checkout = (releaseSha) => ({
305
+ uses: "actions/checkout@v7",
306
+ with: { ref: releaseSha },
307
+ });
308
+ const download = (artifactDir) => ({
309
+ uses: "actions/download-artifact@v8",
310
+ with: { name: "release-assets", path: artifactDir },
311
+ });
312
+ /** GitHub Actions jobs use the upstream language tool and package client directly. */
313
+ export function nativePublishJobs(project, artifactDir, releaseSha) {
314
+ return Object.fromEntries(activeConfigs(project).flatMap((entry) => {
315
+ if (entry.id === "packagist" || entry.id === "go-module")
316
+ return [];
317
+ const path = entry.path;
318
+ const base = {
319
+ needs: ["gate", "package"],
320
+ if: `needs.gate.outputs.${entry.id.replace(/-/g, "_")} == 'true'`,
321
+ "runs-on": "ubuntu-latest",
322
+ permissions: { contents: "read", "id-token": "write" },
323
+ };
324
+ let job;
325
+ switch (entry.id) {
326
+ case "cargo":
327
+ job = {
328
+ ...base,
329
+ env: { CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_REGISTRY_TOKEN }}" },
330
+ steps: [
331
+ checkout(releaseSha),
332
+ { run: "cargo --version" },
333
+ {
334
+ run: `cd ${shellQuote(path)} && ${versionAssert(entry, project.identity.version)} && cargo publish --manifest-path ${shellQuote(entry.identity)} --token "$CARGO_REGISTRY_TOKEN"`,
335
+ },
336
+ ],
337
+ };
338
+ break;
339
+ case "nuget":
340
+ job = {
341
+ ...base,
342
+ env: { NUGET_API_KEY: "${{ secrets.NUGET_API_KEY }}" },
343
+ steps: [
344
+ { uses: "actions/setup-dotnet@v5", with: { "dotnet-version": "8.0.x" } },
345
+ { run: "dotnet --info" },
346
+ download(artifactDir),
347
+ {
348
+ run: `find ${shellQuote(`${artifactDir}/native/nuget`)} -maxdepth 1 -name '*.nupkg' -print0 | xargs -0 -r -n1 sh -c 'dotnet nuget push "$0" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate'`,
349
+ },
350
+ ],
351
+ };
352
+ break;
353
+ case "maven-central":
354
+ job = {
355
+ ...base,
356
+ env: {
357
+ MAVEN_CENTRAL_USERNAME: "${{ secrets.MAVEN_CENTRAL_USERNAME }}",
358
+ MAVEN_CENTRAL_PASSWORD: "${{ secrets.MAVEN_CENTRAL_PASSWORD }}",
359
+ MAVEN_GPG_PRIVATE_KEY: "${{ secrets.MAVEN_GPG_PRIVATE_KEY }}",
360
+ MAVEN_GPG_PASSPHRASE: "${{ secrets.MAVEN_GPG_PASSPHRASE }}",
361
+ },
362
+ steps: [
363
+ checkout(releaseSha),
364
+ {
365
+ uses: "actions/setup-java@v5",
366
+ with: {
367
+ distribution: "temurin",
368
+ "java-version": "21",
369
+ cache: "maven",
370
+ "server-id": "central",
371
+ "server-username": "MAVEN_CENTRAL_USERNAME",
372
+ "server-password": "MAVEN_CENTRAL_PASSWORD",
373
+ },
374
+ },
375
+ { run: 'echo "$MAVEN_GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import' },
376
+ {
377
+ run: `cd ${shellQuote(path)} && ${versionAssert(entry, project.identity.version)} && mvn --batch-mode --file ${shellQuote(entry.identity)} -DskipTests -Dgpg.passphrase="$MAVEN_GPG_PASSPHRASE" deploy`,
378
+ },
379
+ ],
380
+ };
381
+ break;
382
+ case "rubygems":
383
+ job = {
384
+ ...base,
385
+ env: { GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}" },
386
+ steps: [
387
+ { run: "gem --version" },
388
+ download(artifactDir),
389
+ {
390
+ run: `gem push ${shellQuote(`${artifactDir}/native/rubygems/${entry.name}-${project.identity.version}.gem`)}`,
391
+ },
392
+ ],
393
+ };
394
+ break;
395
+ }
396
+ const steps = job.steps;
397
+ steps.unshift({ uses: "actions/setup-node@v7", with: { "node-version": "24" } });
398
+ const publish = steps.at(-1);
399
+ const exists = row(project, entry).exists;
400
+ if (publish && exists)
401
+ publish.run = `if ${exists}; then echo "${entry.id}: version already published"; else ${publish.run}; fi`;
402
+ return [[`publish-${entry.id}`, job]];
403
+ }));
404
+ }
405
+ export function nativeInstallLines(project) {
406
+ return activeConfigs(project).map((entry) => ({
407
+ cargo: `\`cargo install ${entry.name}\``,
408
+ nuget: `\`dotnet add package ${entry.name}\``,
409
+ "maven-central": `add \`${entry.name}\` as a Maven dependency`,
410
+ rubygems: `\`gem install ${entry.name}\``,
411
+ packagist: `\`composer require ${entry.name}\``,
412
+ "go-module": `\`go get ${entry.name}@${project.identity.version ? `v${project.identity.version}` : "latest"}\``,
413
+ })[entry.id]);
414
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { nativeInstallLines, nativePackageConfigSchema, nativePackageSteps, nativePublishJobs, nativeRegistryRows, } from "./native.js";
3
+ function project(entries, surfaces = entries.map((entry) => entry.id)) {
4
+ return {
5
+ root: "/repo",
6
+ tool: {
7
+ schemaVersion: 1,
8
+ identity: "package.json",
9
+ binding: "typescript",
10
+ surfaces: surfaces,
11
+ bundle: { runtime: "package" },
12
+ tests: { examples: {} },
13
+ nativePackages: entries,
14
+ },
15
+ identity: { name: "tool", version: "1.2.3", repository: "https://github.com/acme/tool" },
16
+ identityExtra: {},
17
+ operations: [],
18
+ toolfactoryVersion: "0.1.0",
19
+ };
20
+ }
21
+ const cargo = {
22
+ id: "cargo",
23
+ path: "native/cli",
24
+ identity: "Cargo.toml",
25
+ name: "acme-cli",
26
+ versionCommand: "cargo pkgid | sed 's/.*@//'",
27
+ };
28
+ describe("native package configuration", () => {
29
+ it("requires the package path, authored identity, and registry identity", () => {
30
+ expect(() => nativePackageConfigSchema.parse({ id: "cargo", path: "native" })).toThrow();
31
+ expect(nativePackageConfigSchema.parse(cargo)).toEqual(cargo);
32
+ });
33
+ it("rejects a selected registry with no config or duplicate configuration", () => {
34
+ expect(() => nativePackageSteps(project([], ["cargo"]))).toThrow(/requires exactly one/);
35
+ expect(() => nativePackageSteps(project([cargo, cargo]))).toThrow(/repeats/);
36
+ });
37
+ it("packages authored crates and checks their version against the release", () => {
38
+ const steps = nativePackageSteps(project([cargo]));
39
+ expect(steps.map((step) => step.name)).toEqual([
40
+ "cargo version matches release",
41
+ "Cargo crate",
42
+ ]);
43
+ expect(steps[0]?.run).toContain("= '1.2.3'");
44
+ expect(steps[1]?.run).toContain("cargo package --manifest-path 'native/cli/Cargo.toml'");
45
+ expect(steps[1]?.run).toContain("--target-dir 'dist/native-build/cargo'");
46
+ expect(steps[1]?.run).not.toContain("/repo");
47
+ });
48
+ it("reads structured Cargo metadata and the raw MSBuild version", () => {
49
+ const cargoDefault = {
50
+ id: "cargo",
51
+ path: "native/cli",
52
+ identity: "Cargo.toml",
53
+ name: "acme-cli",
54
+ };
55
+ const nuget = {
56
+ id: "nuget",
57
+ path: "dotnet",
58
+ identity: "Tool.csproj",
59
+ name: "Acme.Tool",
60
+ };
61
+ const steps = nativePackageSteps(project([cargoDefault, nuget]));
62
+ expect(steps[0]?.run).toContain("cargo metadata --no-deps --format-version 1 --manifest-path 'Cargo.toml'");
63
+ expect(steps[0]?.run).toContain("JSON.parse(s).packages.find");
64
+ expect(steps[2]?.run).toContain("dotnet msbuild 'Tool.csproj' -nologo -getProperty:Version");
65
+ expect(steps[2]?.run).not.toContain("sed -n");
66
+ });
67
+ it("leaves VCS-discovered Packagist and Go modules for the final release tag", () => {
68
+ const entries = [
69
+ {
70
+ id: "packagist",
71
+ path: ".",
72
+ identity: "composer.json",
73
+ name: "acme/tool",
74
+ },
75
+ {
76
+ id: "go-module",
77
+ path: ".",
78
+ identity: "go.mod",
79
+ name: "github.com/acme/tool",
80
+ },
81
+ ];
82
+ expect(nativePackageSteps(project(entries))).toEqual([]);
83
+ expect(nativeInstallLines(project(entries))).toEqual([
84
+ "`composer require acme/tool`",
85
+ "`go get github.com/acme/tool@v1.2.3`",
86
+ ]);
87
+ });
88
+ it("uses direct upstream publishing commands and exposes only their actual credentials", () => {
89
+ const entries = [
90
+ cargo,
91
+ {
92
+ id: "nuget",
93
+ path: "dotnet",
94
+ identity: "Tool.csproj",
95
+ name: "Acme.Tool",
96
+ versionCommand: "dotnet msbuild -getProperty:Version Tool.csproj",
97
+ },
98
+ ];
99
+ const jobs = nativePublishJobs(project(entries), "release-assets", "${{ needs.gate.outputs.sha }}");
100
+ expect(jobs["publish-cargo"]?.steps).toContainEqual(expect.objectContaining({ run: expect.stringContaining("cargo publish") }));
101
+ expect(jobs["publish-nuget"]?.steps).toContainEqual(expect.objectContaining({ run: expect.stringContaining("dotnet nuget push") }));
102
+ expect(jobs["publish-nuget"]?.steps).toContainEqual(expect.objectContaining({
103
+ uses: "actions/setup-dotnet@v5",
104
+ with: { "dotnet-version": "8.0.x" },
105
+ }));
106
+ expect(nativeRegistryRows(project(entries)).map((row) => row.secrets)).toEqual([
107
+ ["CARGO_REGISTRY_TOKEN"],
108
+ ["NUGET_API_KEY"],
109
+ ]);
110
+ expect(jobs["publish-cargo"]?.needs).toEqual(["gate", "package"]);
111
+ expect(jobs["publish-cargo"]?.if).toBe("needs.gate.outputs.cargo == 'true'");
112
+ expect(jobs["publish-cargo"]?.steps).not.toContainEqual(expect.objectContaining({ uses: "dtolnay/rust-toolchain@stable" }));
113
+ });
114
+ it("builds a gem from its package directory before moving the resulting asset", () => {
115
+ const steps = nativePackageSteps(project([
116
+ {
117
+ id: "rubygems",
118
+ path: "native/ruby",
119
+ identity: "acme.gemspec",
120
+ name: "acme",
121
+ versionCommand: "ruby -e 'puts 1.2.3'",
122
+ },
123
+ ]));
124
+ expect(steps[1]?.run).toContain("(cd 'native/ruby' && gem build --strict 'acme.gemspec')");
125
+ expect(steps[1]?.run).toContain("mv 'native/ruby/acme-1.2.3.gem'");
126
+ });
127
+ });