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.
- package/README.md +24 -1
- package/dist/commands.d.ts +1 -1
- package/dist/distribution/native.d.ts +50 -0
- package/dist/distribution/native.js +414 -0
- package/dist/distribution/native.test.d.ts +1 -0
- package/dist/distribution/native.test.js +127 -0
- package/dist/distribution/system.d.ts +57 -0
- package/dist/distribution/system.js +637 -0
- package/dist/distribution/system.test.d.ts +1 -0
- package/dist/distribution/system.test.js +116 -0
- package/dist/e2e.test.js +7 -1
- package/dist/hosts/openclaw.js +6 -2
- package/dist/hosts/vscode.d.ts +1 -0
- package/dist/hosts/vscode.js +67 -0
- package/dist/model.d.ts +62 -2
- package/dist/model.js +9 -0
- package/dist/ops.d.ts +52 -0
- package/dist/project/apply.d.ts +1 -1
- package/dist/project/apply.js +194 -17
- package/dist/project/apply.test.js +103 -0
- package/dist/project/gate.d.ts +17 -2
- package/dist/project/gate.js +68 -3
- package/dist/project/history.d.ts +29 -1
- package/dist/project/lock.d.ts +5 -0
- package/dist/project/lock.js +9 -0
- package/dist/surfaces/agent-plugins.js +5 -4
- package/dist/surfaces/agents.js +21 -3
- package/dist/surfaces/agents.test.js +12 -0
- package/dist/surfaces/claude.js +8 -3
- package/dist/surfaces/codex.js +8 -3
- package/dist/surfaces/codex.test.js +8 -8
- package/dist/surfaces/cursor.js +2 -2
- package/dist/surfaces/distribution.test.js +3 -3
- package/dist/surfaces/dsh.js +25 -6
- package/dist/surfaces/dsh.test.js +8 -3
- package/dist/surfaces/gemini.js +2 -2
- package/dist/surfaces/gemini.test.js +4 -4
- package/dist/surfaces/hermes-native.js +38 -9
- package/dist/surfaces/hermes-native.test.js +32 -11
- package/dist/surfaces/mcp-registry.js +43 -5
- package/dist/surfaces/mcp-registry.test.js +18 -0
- package/dist/surfaces/openclaw-native.js +10 -8
- package/dist/surfaces/openclaw-native.test.js +30 -0
- package/dist/surfaces/readme.d.ts +16 -0
- package/dist/surfaces/readme.js +7 -0
- package/dist/surfaces/registry.js +6 -0
- package/dist/surfaces/vscode-extension.d.ts +14 -0
- package/dist/surfaces/vscode-extension.js +312 -0
- package/dist/surfaces/vscode-extension.test.d.ts +1 -0
- package/dist/surfaces/vscode-extension.test.js +81 -0
- package/dist/surfaces/vscode-scaffold.d.ts +43 -0
- package/dist/surfaces/vscode-scaffold.js +43 -0
- package/dist/surfaces/workflows.js +49 -10
- package/dist/surfaces/workflows.test.js +3 -2
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +2 -1
- package/src/distribution/native.test.ts +161 -0
- package/src/distribution/native.ts +460 -0
- package/src/distribution/system.test.ts +136 -0
- package/src/distribution/system.ts +688 -0
- package/src/e2e.test.ts +7 -1
- package/src/hosts/openclaw.ts +4 -2
- package/src/hosts/vscode.ts +79 -0
- package/src/model.ts +15 -1
- package/src/project/apply.test.ts +120 -0
- package/src/project/apply.ts +257 -15
- package/src/project/gate.ts +96 -4
- package/src/project/lock.ts +12 -0
- package/src/surfaces/agent-plugins.ts +5 -4
- package/src/surfaces/agents.test.ts +16 -0
- package/src/surfaces/agents.ts +31 -3
- package/src/surfaces/claude.ts +7 -3
- package/src/surfaces/codex.test.ts +7 -7
- package/src/surfaces/codex.ts +8 -3
- package/src/surfaces/cursor.ts +2 -10
- package/src/surfaces/distribution.test.ts +2 -2
- package/src/surfaces/dsh.test.ts +8 -2
- package/src/surfaces/dsh.ts +26 -6
- package/src/surfaces/gemini.test.ts +3 -3
- package/src/surfaces/gemini.ts +1 -2
- package/src/surfaces/hermes-native.test.ts +39 -10
- package/src/surfaces/hermes-native.ts +41 -11
- package/src/surfaces/mcp-registry.test.ts +20 -0
- package/src/surfaces/mcp-registry.ts +46 -6
- package/src/surfaces/openclaw-native.test.ts +37 -0
- package/src/surfaces/openclaw-native.ts +49 -47
- package/src/surfaces/readme.ts +10 -0
- package/src/surfaces/registry.ts +6 -0
- package/src/surfaces/vscode-extension.test.ts +89 -0
- package/src/surfaces/vscode-extension.ts +345 -0
- package/src/surfaces/vscode-scaffold.ts +55 -0
- package/src/surfaces/workflows.test.ts +3 -4
- package/src/surfaces/workflows.ts +49 -10
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,460 @@
|
|
|
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 { Command, Project, Surface } from "../model.js";
|
|
9
|
+
import type { GateStep, Registry } from "../project/gate.js";
|
|
10
|
+
|
|
11
|
+
export const NATIVE_PACKAGE_IDS = [
|
|
12
|
+
"cargo",
|
|
13
|
+
"nuget",
|
|
14
|
+
"maven-central",
|
|
15
|
+
"rubygems",
|
|
16
|
+
"packagist",
|
|
17
|
+
"go-module",
|
|
18
|
+
] as const;
|
|
19
|
+
export type NativePackageId = (typeof NATIVE_PACKAGE_IDS)[number];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `name` is intentionally not derived from the Toolfactory identity: package namespaces have
|
|
23
|
+
* their own ownership rules. A standard per-ecosystem version read is used unless the author
|
|
24
|
+
* needs a `versionCommand` escape hatch.
|
|
25
|
+
*/
|
|
26
|
+
export const nativePackageConfigSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
id: z.enum(NATIVE_PACKAGE_IDS),
|
|
29
|
+
path: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.refine(
|
|
33
|
+
(path) => !/^(?:[A-Za-z]:|[\\/])/.test(path) && !path.split(/[\\/]/).includes(".."),
|
|
34
|
+
"path must stay inside the repository.",
|
|
35
|
+
),
|
|
36
|
+
identity: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.refine(
|
|
40
|
+
(path) => !/^(?:[A-Za-z]:|[\\/])/.test(path) && !path.split(/[\\/]/).includes(".."),
|
|
41
|
+
"identity must be relative to path.",
|
|
42
|
+
),
|
|
43
|
+
name: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1)
|
|
46
|
+
.describe("Registry package identity, including its required namespace."),
|
|
47
|
+
versionCommand: z
|
|
48
|
+
.string()
|
|
49
|
+
.min(1)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Command run in path that prints exactly the authored native package version."),
|
|
52
|
+
})
|
|
53
|
+
.superRefine((entry, context) => {
|
|
54
|
+
if (entry.id === "maven-central" && entry.name.split(":").length !== 2)
|
|
55
|
+
context.addIssue({
|
|
56
|
+
code: "custom",
|
|
57
|
+
path: ["name"],
|
|
58
|
+
message: "Maven Central name must be groupId:artifactId.",
|
|
59
|
+
});
|
|
60
|
+
if ((entry.id === "packagist" || entry.id === "go-module") && entry.path !== ".")
|
|
61
|
+
context.addIssue({
|
|
62
|
+
code: "custom",
|
|
63
|
+
path: ["path"],
|
|
64
|
+
message: `${entry.id} is VCS/tag-discovered and currently requires package path ".".`,
|
|
65
|
+
});
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
68
|
+
export type NativePackageConfig = z.infer<typeof nativePackageConfigSchema>;
|
|
69
|
+
|
|
70
|
+
function configs(project: Project): NativePackageConfig[] {
|
|
71
|
+
const raw = project.tool.nativePackages ?? [];
|
|
72
|
+
const parsed = raw.map((entry) => nativePackageConfigSchema.parse(entry));
|
|
73
|
+
const duplicates = parsed.filter(
|
|
74
|
+
(entry, index) => parsed.findIndex((other) => other.id === entry.id) !== index,
|
|
75
|
+
);
|
|
76
|
+
if (duplicates.length)
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Native package configuration repeats: ${[...new Set(duplicates.map((entry) => entry.id))].join(", ")}.`,
|
|
79
|
+
);
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
function selected(project: Project, id: NativePackageId): boolean {
|
|
83
|
+
return project.tool.surfaces.includes(id);
|
|
84
|
+
}
|
|
85
|
+
function shellQuote(value: string): string {
|
|
86
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
87
|
+
}
|
|
88
|
+
function defaultVersionCommand(entry: NativePackageConfig): string {
|
|
89
|
+
const identity = shellQuote(entry.identity);
|
|
90
|
+
switch (entry.id) {
|
|
91
|
+
case "cargo":
|
|
92
|
+
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))`)}`;
|
|
93
|
+
case "nuget":
|
|
94
|
+
return `dotnet msbuild ${identity} -nologo -getProperty:Version`;
|
|
95
|
+
case "maven-central":
|
|
96
|
+
return `mvn --quiet --file ${identity} -DforceStdout help:evaluate -Dexpression=project.version`;
|
|
97
|
+
case "rubygems":
|
|
98
|
+
return `ruby -e 'puts Gem::Specification.load(ARGV[0]).version' ${identity}`;
|
|
99
|
+
case "packagist":
|
|
100
|
+
case "go-module":
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${entry.id} is discovered from the release tag and has no pre-release version command.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function versionAssert(entry: NativePackageConfig, version: string | undefined): string {
|
|
107
|
+
if (!version)
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Native package "${entry.name}" requires the shared identity to declare version.`,
|
|
110
|
+
);
|
|
111
|
+
return `test "$(${entry.versionCommand ?? defaultVersionCommand(entry)})" = ${shellQuote(version)}`;
|
|
112
|
+
}
|
|
113
|
+
function configFor(project: Project, id: NativePackageId): NativePackageConfig {
|
|
114
|
+
const matches = configs(project).filter((entry) => entry.id === id);
|
|
115
|
+
if (matches.length !== 1)
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Surface "${id}" requires exactly one tool.nativePackages entry with id "${id}".`,
|
|
118
|
+
);
|
|
119
|
+
const entry = matches[0] as NativePackageConfig;
|
|
120
|
+
if ((id === "packagist" || id === "go-module") && !project.identity.repository)
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Surface "${id}" requires identity.repository because it is discovered from the final Git tag.`,
|
|
123
|
+
);
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
function activeConfigs(project: Project): NativePackageConfig[] {
|
|
127
|
+
return NATIVE_PACKAGE_IDS.filter((id) => selected(project, id)).map((id) =>
|
|
128
|
+
configFor(project, id),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
function entryVersion(project: Project, entry: NativePackageConfig): string {
|
|
132
|
+
if (!project.identity.version)
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Native package "${entry.name}" requires the shared identity to declare version.`,
|
|
135
|
+
);
|
|
136
|
+
return project.identity.version;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const commands: Record<NativePackageId, (entry: NativePackageConfig) => Command[]> = {
|
|
140
|
+
cargo: (entry) => [
|
|
141
|
+
{
|
|
142
|
+
label: "Cargo package",
|
|
143
|
+
command: "cargo",
|
|
144
|
+
args: ["package", "--manifest-path", entry.identity],
|
|
145
|
+
cwd: entry.path,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
nuget: (entry) => [
|
|
149
|
+
{
|
|
150
|
+
label: "NuGet pack",
|
|
151
|
+
command: "dotnet",
|
|
152
|
+
args: ["pack", entry.identity, "--configuration", "Release"],
|
|
153
|
+
cwd: entry.path,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
"maven-central": (entry) => [
|
|
157
|
+
{
|
|
158
|
+
label: "Maven verify",
|
|
159
|
+
command: "mvn",
|
|
160
|
+
args: ["--batch-mode", "--file", entry.identity, "verify"],
|
|
161
|
+
cwd: entry.path,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
rubygems: (entry) => [
|
|
165
|
+
{
|
|
166
|
+
label: "RubyGems build",
|
|
167
|
+
command: "gem",
|
|
168
|
+
args: ["build", "--strict", entry.identity],
|
|
169
|
+
cwd: entry.path,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
packagist: (entry) => [
|
|
173
|
+
{
|
|
174
|
+
label: "Composer validate",
|
|
175
|
+
command: "composer",
|
|
176
|
+
args: ["validate", "--strict", entry.identity],
|
|
177
|
+
cwd: entry.path,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
"go-module": (entry) => [
|
|
181
|
+
{ label: "Go module tidy", command: "go", args: ["mod", "tidy", "-diff"], cwd: entry.path },
|
|
182
|
+
{ label: "Go module tests", command: "go", args: ["test", "./..."], cwd: entry.path },
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** External registries do not generate files: their package metadata remains author-owned. */
|
|
187
|
+
export const nativePackageSurfaces: Surface[] = NATIVE_PACKAGE_IDS.map((id) => ({
|
|
188
|
+
id,
|
|
189
|
+
plan(project) {
|
|
190
|
+
configFor(project, id);
|
|
191
|
+
return [];
|
|
192
|
+
},
|
|
193
|
+
validate(project) {
|
|
194
|
+
const entry = configFor(project, id);
|
|
195
|
+
return commands[id](entry).map((command) => ({ ...command, cwd: entry.path }));
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Release packaging happens after the main package build. VCS registries deliberately have no
|
|
201
|
+
* artifact: their published object is the signed Git tag and repository contents.
|
|
202
|
+
*/
|
|
203
|
+
export function nativePackageSteps(project: Project): GateStep[] {
|
|
204
|
+
return activeConfigs(project).flatMap((entry) => {
|
|
205
|
+
// Packagist and Go proxy discovery happens from the tag created by the final release job.
|
|
206
|
+
// Before then, `git describe` can only see the prior version, so asserting it here is wrong.
|
|
207
|
+
if (entry.id === "packagist" || entry.id === "go-module") return [];
|
|
208
|
+
const directory = shellQuote(entry.path);
|
|
209
|
+
const out = `dist/release/native/${entry.id}`;
|
|
210
|
+
const check = versionAssert(entry, project.identity.version);
|
|
211
|
+
const steps: GateStep[] = [
|
|
212
|
+
{ name: `${entry.id} version matches release`, run: `cd ${directory} && ${check}` },
|
|
213
|
+
];
|
|
214
|
+
switch (entry.id) {
|
|
215
|
+
case "cargo":
|
|
216
|
+
steps.push({
|
|
217
|
+
name: "Cargo crate",
|
|
218
|
+
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)}/`,
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
case "nuget":
|
|
222
|
+
steps.push({
|
|
223
|
+
name: "NuGet package",
|
|
224
|
+
run: `mkdir -p ${shellQuote(out)} && dotnet pack ${shellQuote(`${entry.path}/${entry.identity}`)} --configuration Release --output ${shellQuote(out)}`,
|
|
225
|
+
});
|
|
226
|
+
break;
|
|
227
|
+
case "maven-central":
|
|
228
|
+
steps.push({
|
|
229
|
+
name: "Maven package",
|
|
230
|
+
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)} \\;`,
|
|
231
|
+
});
|
|
232
|
+
break;
|
|
233
|
+
case "rubygems":
|
|
234
|
+
steps.push({
|
|
235
|
+
name: "Ruby gem",
|
|
236
|
+
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)}/`,
|
|
237
|
+
});
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
return steps;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Kept with the release contract so local `gate` and the package job fail before a package
|
|
246
|
+
* command with a clear missing-tool message. GitHub's hosted Linux image supplies these upstream
|
|
247
|
+
* clients; callers that use another runner can install its normal distro packages first.
|
|
248
|
+
*/
|
|
249
|
+
export function nativeToolchainSteps(project: Project): GateStep[] {
|
|
250
|
+
const tools: Record<NativePackageId, string> = {
|
|
251
|
+
cargo: "cargo",
|
|
252
|
+
nuget: "dotnet",
|
|
253
|
+
"maven-central": "mvn",
|
|
254
|
+
rubygems: "gem",
|
|
255
|
+
packagist: "composer",
|
|
256
|
+
"go-module": "go",
|
|
257
|
+
};
|
|
258
|
+
return activeConfigs(project).map((entry) => ({
|
|
259
|
+
name: `${entry.id} upstream toolchain`,
|
|
260
|
+
run: `command -v ${tools[entry.id]} >/dev/null || { echo "${entry.id}: install its upstream ${tools[entry.id]} CLI before packaging" >&2; exit 1; }`,
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function row(project: Project, entry: NativePackageConfig): Registry {
|
|
265
|
+
const version = entryVersion(project, entry);
|
|
266
|
+
const [groupId = "", artifactId = ""] = entry.name.split(":");
|
|
267
|
+
const mavenSearch = `https://search.maven.org/solrsearch/select?q=${encodeURIComponent(`g:"${groupId}" AND a:"${artifactId}" AND v:"${version}"`)}&rows=1&wt=json`;
|
|
268
|
+
const shared: Pick<Registry, "id" | "surfaces" | "url"> = {
|
|
269
|
+
id: entry.id,
|
|
270
|
+
surfaces: [entry.id],
|
|
271
|
+
url: {
|
|
272
|
+
cargo: "https://crates.io/settings/tokens",
|
|
273
|
+
nuget: "https://www.nuget.org/account/apikeys",
|
|
274
|
+
"maven-central": "https://central.sonatype.com/account",
|
|
275
|
+
rubygems: "https://rubygems.org/profile/edit",
|
|
276
|
+
packagist: "https://packagist.org/about",
|
|
277
|
+
"go-module": "https://go.dev/doc/modules/publishing",
|
|
278
|
+
}[entry.id],
|
|
279
|
+
};
|
|
280
|
+
switch (entry.id) {
|
|
281
|
+
case "cargo":
|
|
282
|
+
return {
|
|
283
|
+
...shared,
|
|
284
|
+
secrets: ["CARGO_REGISTRY_TOKEN"],
|
|
285
|
+
exists: `curl -fsS -o /dev/null ${shellQuote(`https://crates.io/api/v1/crates/${encodeURIComponent(entry.name)}/${encodeURIComponent(version)}`)}`,
|
|
286
|
+
gate: '[ -n "$CARGO_REGISTRY_TOKEN" ]',
|
|
287
|
+
retract: `cargo yank ${shellQuote(entry.name)} --vers ${shellQuote(version)} --token "$CARGO_REGISTRY_TOKEN"`,
|
|
288
|
+
};
|
|
289
|
+
case "nuget":
|
|
290
|
+
return {
|
|
291
|
+
...shared,
|
|
292
|
+
secrets: ["NUGET_API_KEY"],
|
|
293
|
+
exists: `curl -fsS -o /dev/null ${shellQuote(`https://api.nuget.org/v3-flatcontainer/${entry.name.toLowerCase()}/${version.toLowerCase()}/${entry.name.toLowerCase()}.${version.toLowerCase()}.nupkg`)}`,
|
|
294
|
+
gate: '[ -n "$NUGET_API_KEY" ]',
|
|
295
|
+
retract: `dotnet nuget delete ${shellQuote(entry.name)} ${shellQuote(version)} --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --non-interactive`,
|
|
296
|
+
};
|
|
297
|
+
case "maven-central":
|
|
298
|
+
return {
|
|
299
|
+
...shared,
|
|
300
|
+
secrets: [
|
|
301
|
+
"MAVEN_CENTRAL_USERNAME",
|
|
302
|
+
"MAVEN_CENTRAL_PASSWORD",
|
|
303
|
+
"MAVEN_GPG_PRIVATE_KEY",
|
|
304
|
+
"MAVEN_GPG_PASSPHRASE",
|
|
305
|
+
],
|
|
306
|
+
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))")}`,
|
|
307
|
+
gate: '[ -n "$MAVEN_CENTRAL_USERNAME" ] && [ -n "$MAVEN_CENTRAL_PASSWORD" ] && [ -n "$MAVEN_GPG_PRIVATE_KEY" ] && [ -n "$MAVEN_GPG_PASSPHRASE" ]',
|
|
308
|
+
retractUrl: "https://central.sonatype.com/publishing/deployments",
|
|
309
|
+
};
|
|
310
|
+
case "rubygems":
|
|
311
|
+
return {
|
|
312
|
+
...shared,
|
|
313
|
+
secrets: ["RUBYGEMS_API_KEY"],
|
|
314
|
+
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))`)}`,
|
|
315
|
+
gate: '[ -n "$RUBYGEMS_API_KEY" ]',
|
|
316
|
+
retract: `GEM_HOST_API_KEY="$RUBYGEMS_API_KEY" gem yank ${shellQuote(entry.name)} -v ${shellQuote(version)}`,
|
|
317
|
+
};
|
|
318
|
+
case "packagist":
|
|
319
|
+
return {
|
|
320
|
+
...shared,
|
|
321
|
+
secrets: [],
|
|
322
|
+
gate: "true",
|
|
323
|
+
retractUrl: "https://packagist.org/packages",
|
|
324
|
+
};
|
|
325
|
+
case "go-module":
|
|
326
|
+
return {
|
|
327
|
+
...shared,
|
|
328
|
+
secrets: [],
|
|
329
|
+
gate: "true",
|
|
330
|
+
retractUrl: "https://go.dev/ref/mod#go-mod-file-retract-directive",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function nativeRegistryRows(project: Project): Registry[] {
|
|
336
|
+
return activeConfigs(project).map((entry) => row(project, entry));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
type Job = Record<string, unknown>;
|
|
340
|
+
const checkout = (releaseSha: string) => ({
|
|
341
|
+
uses: "actions/checkout@v7",
|
|
342
|
+
with: { ref: releaseSha },
|
|
343
|
+
});
|
|
344
|
+
const download = (artifactDir: string) => ({
|
|
345
|
+
uses: "actions/download-artifact@v8",
|
|
346
|
+
with: { name: "release-assets", path: artifactDir },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/** GitHub Actions jobs use the upstream language tool and package client directly. */
|
|
350
|
+
export function nativePublishJobs(
|
|
351
|
+
project: Project,
|
|
352
|
+
artifactDir: string,
|
|
353
|
+
releaseSha: string,
|
|
354
|
+
): Record<string, Job> {
|
|
355
|
+
return Object.fromEntries(
|
|
356
|
+
activeConfigs(project).flatMap((entry) => {
|
|
357
|
+
if (entry.id === "packagist" || entry.id === "go-module") return [];
|
|
358
|
+
const path = entry.path;
|
|
359
|
+
const base = {
|
|
360
|
+
needs: ["gate", "package"],
|
|
361
|
+
if: `needs.gate.outputs.${entry.id.replace(/-/g, "_")} == 'true'`,
|
|
362
|
+
"runs-on": "ubuntu-latest",
|
|
363
|
+
permissions: { contents: "read", "id-token": "write" },
|
|
364
|
+
};
|
|
365
|
+
let job: Job;
|
|
366
|
+
switch (entry.id) {
|
|
367
|
+
case "cargo":
|
|
368
|
+
job = {
|
|
369
|
+
...base,
|
|
370
|
+
env: { CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_REGISTRY_TOKEN }}" },
|
|
371
|
+
steps: [
|
|
372
|
+
checkout(releaseSha),
|
|
373
|
+
{ run: "cargo --version" },
|
|
374
|
+
{
|
|
375
|
+
run: `cd ${shellQuote(path)} && ${versionAssert(entry, project.identity.version)} && cargo publish --manifest-path ${shellQuote(entry.identity)} --token "$CARGO_REGISTRY_TOKEN"`,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
};
|
|
379
|
+
break;
|
|
380
|
+
case "nuget":
|
|
381
|
+
job = {
|
|
382
|
+
...base,
|
|
383
|
+
env: { NUGET_API_KEY: "${{ secrets.NUGET_API_KEY }}" },
|
|
384
|
+
steps: [
|
|
385
|
+
{ uses: "actions/setup-dotnet@v5", with: { "dotnet-version": "8.0.x" } },
|
|
386
|
+
{ run: "dotnet --info" },
|
|
387
|
+
download(artifactDir),
|
|
388
|
+
{
|
|
389
|
+
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'`,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
break;
|
|
394
|
+
case "maven-central":
|
|
395
|
+
job = {
|
|
396
|
+
...base,
|
|
397
|
+
env: {
|
|
398
|
+
MAVEN_CENTRAL_USERNAME: "${{ secrets.MAVEN_CENTRAL_USERNAME }}",
|
|
399
|
+
MAVEN_CENTRAL_PASSWORD: "${{ secrets.MAVEN_CENTRAL_PASSWORD }}",
|
|
400
|
+
MAVEN_GPG_PRIVATE_KEY: "${{ secrets.MAVEN_GPG_PRIVATE_KEY }}",
|
|
401
|
+
MAVEN_GPG_PASSPHRASE: "${{ secrets.MAVEN_GPG_PASSPHRASE }}",
|
|
402
|
+
},
|
|
403
|
+
steps: [
|
|
404
|
+
checkout(releaseSha),
|
|
405
|
+
{
|
|
406
|
+
uses: "actions/setup-java@v5",
|
|
407
|
+
with: {
|
|
408
|
+
distribution: "temurin",
|
|
409
|
+
"java-version": "21",
|
|
410
|
+
cache: "maven",
|
|
411
|
+
"server-id": "central",
|
|
412
|
+
"server-username": "MAVEN_CENTRAL_USERNAME",
|
|
413
|
+
"server-password": "MAVEN_CENTRAL_PASSWORD",
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{ run: 'echo "$MAVEN_GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import' },
|
|
417
|
+
{
|
|
418
|
+
run: `cd ${shellQuote(path)} && ${versionAssert(entry, project.identity.version)} && mvn --batch-mode --file ${shellQuote(entry.identity)} -DskipTests -Dgpg.passphrase="$MAVEN_GPG_PASSPHRASE" deploy`,
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
};
|
|
422
|
+
break;
|
|
423
|
+
case "rubygems":
|
|
424
|
+
job = {
|
|
425
|
+
...base,
|
|
426
|
+
env: { GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}" },
|
|
427
|
+
steps: [
|
|
428
|
+
{ run: "gem --version" },
|
|
429
|
+
download(artifactDir),
|
|
430
|
+
{
|
|
431
|
+
run: `gem push ${shellQuote(`${artifactDir}/native/rubygems/${entry.name}-${project.identity.version}.gem`)}`,
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
const steps = job.steps as Array<Record<string, unknown>>;
|
|
438
|
+
steps.unshift({ uses: "actions/setup-node@v7", with: { "node-version": "24" } });
|
|
439
|
+
const publish = steps.at(-1);
|
|
440
|
+
const exists = row(project, entry).exists;
|
|
441
|
+
if (publish && exists)
|
|
442
|
+
publish.run = `if ${exists}; then echo "${entry.id}: version already published"; else ${publish.run}; fi`;
|
|
443
|
+
return [[`publish-${entry.id}`, job]];
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function nativeInstallLines(project: Project): string[] {
|
|
449
|
+
return activeConfigs(project).map(
|
|
450
|
+
(entry) =>
|
|
451
|
+
({
|
|
452
|
+
cargo: `\`cargo install ${entry.name}\``,
|
|
453
|
+
nuget: `\`dotnet add package ${entry.name}\``,
|
|
454
|
+
"maven-central": `add \`${entry.name}\` as a Maven dependency`,
|
|
455
|
+
rubygems: `\`gem install ${entry.name}\``,
|
|
456
|
+
packagist: `\`composer require ${entry.name}\``,
|
|
457
|
+
"go-module": `\`go get ${entry.name}@${project.identity.version ? `v${project.identity.version}` : "latest"}\``,
|
|
458
|
+
})[entry.id],
|
|
459
|
+
);
|
|
460
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Project } from "../model.js";
|
|
3
|
+
import {
|
|
4
|
+
systemPackageConfigSchema,
|
|
5
|
+
systemPackageJobs,
|
|
6
|
+
systemPackageSteps,
|
|
7
|
+
systemPublishJobs,
|
|
8
|
+
systemRegistryRows,
|
|
9
|
+
} from "./system.js";
|
|
10
|
+
|
|
11
|
+
function project(
|
|
12
|
+
entries: unknown[],
|
|
13
|
+
surfaces = entries.map((entry) => (entry as { id: string }).id),
|
|
14
|
+
): Project {
|
|
15
|
+
return {
|
|
16
|
+
root: "/local/never-in-output",
|
|
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
|
+
systemPackages: entries,
|
|
25
|
+
} as unknown as Project["tool"],
|
|
26
|
+
identity: { name: "tool", version: "1.2.3" },
|
|
27
|
+
identityExtra: {},
|
|
28
|
+
operations: [],
|
|
29
|
+
toolfactoryVersion: "0.1.0",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const choco = {
|
|
33
|
+
id: "chocolatey",
|
|
34
|
+
path: "packaging/choco",
|
|
35
|
+
identity: "tool.nuspec",
|
|
36
|
+
name: "tool",
|
|
37
|
+
versionCommand: "node -p \"require('./tool.nuspec.version.json').version\"",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("system packages", () => {
|
|
41
|
+
it("requires real catalog/repository configuration", () => {
|
|
42
|
+
expect(() => systemPackageConfigSchema.parse({ ...choco, id: "apt" })).toThrow(/PPA/);
|
|
43
|
+
expect(() =>
|
|
44
|
+
systemPackageConfigSchema.parse({
|
|
45
|
+
id: "scoop",
|
|
46
|
+
path: "packaging/scoop",
|
|
47
|
+
identity: "tool.json",
|
|
48
|
+
name: "tool",
|
|
49
|
+
versionCommand: "Get-Content VERSION",
|
|
50
|
+
buildCommand: "./build.ps1",
|
|
51
|
+
asset: "../tool.zip",
|
|
52
|
+
bucket: "acme/scoop-bucket",
|
|
53
|
+
bucketName: "acme",
|
|
54
|
+
catalogPath: "bucket/tool.json",
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/path must stay/);
|
|
57
|
+
expect(() => systemPackageSteps(project([], ["chocolatey"]))).toThrow(/exactly one/);
|
|
58
|
+
});
|
|
59
|
+
it("packages with checkout-relative paths", () => {
|
|
60
|
+
const steps = systemPackageSteps(project([choco]), { local: false });
|
|
61
|
+
expect(steps.at(-1)?.run).toContain("choco pack 'packaging/choco/tool.nuspec'");
|
|
62
|
+
expect(steps.at(-1)?.run).not.toContain("/local/never-in-output");
|
|
63
|
+
});
|
|
64
|
+
it("stages a non-root WinGet manifest directory and Debian source changes portably", () => {
|
|
65
|
+
const winget = {
|
|
66
|
+
id: "winget",
|
|
67
|
+
path: "packaging/winget",
|
|
68
|
+
identity: "manifests/a/acme/tool/1.2.3",
|
|
69
|
+
name: "Acme.Tool",
|
|
70
|
+
versionCommand: "cat VERSION",
|
|
71
|
+
buildCommand: "./build-installer.ps1",
|
|
72
|
+
asset: "dist/tool.msi",
|
|
73
|
+
};
|
|
74
|
+
const apt = {
|
|
75
|
+
id: "apt",
|
|
76
|
+
path: "packaging/debian/source",
|
|
77
|
+
identity: "debian/control",
|
|
78
|
+
name: "tool",
|
|
79
|
+
versionCommand: "dpkg-parsechangelog -S Version | cut -d- -f1",
|
|
80
|
+
ppa: "acme/tool",
|
|
81
|
+
buildDependencies: ["debhelper"],
|
|
82
|
+
};
|
|
83
|
+
const steps = systemPackageSteps(project([winget, apt]), { local: false });
|
|
84
|
+
expect(steps.find((step) => step.name === "WinGet catalog submission")?.run).toContain(
|
|
85
|
+
"test -d 'packaging/winget/manifests/a/acme/tool/1.2.3'",
|
|
86
|
+
);
|
|
87
|
+
const debian = steps.find((step) => step.name === "Debian source package")?.run;
|
|
88
|
+
expect(debian).toContain("find 'packaging/debian/source/..'");
|
|
89
|
+
expect(debian).toContain("'dist/release/system/apt'");
|
|
90
|
+
expect(debian).not.toContain("../dist/release");
|
|
91
|
+
});
|
|
92
|
+
it("gates direct jobs and commits author-controlled Homebrew metadata after release", () => {
|
|
93
|
+
const brew = {
|
|
94
|
+
id: "homebrew",
|
|
95
|
+
path: "packaging/brew",
|
|
96
|
+
identity: "tool.rb",
|
|
97
|
+
name: "tool",
|
|
98
|
+
versionCommand: "grep VERSION tool.rb",
|
|
99
|
+
buildCommand: "make release-archive",
|
|
100
|
+
asset: "dist/tool.tar.gz",
|
|
101
|
+
tap: "acme/homebrew-tap",
|
|
102
|
+
catalogPath: "Formula/tool.rb",
|
|
103
|
+
};
|
|
104
|
+
const jobs = systemPublishJobs(
|
|
105
|
+
project([choco, brew]),
|
|
106
|
+
"release-assets",
|
|
107
|
+
"${{ needs.gate.outputs.sha }}",
|
|
108
|
+
);
|
|
109
|
+
expect(jobs["publish-chocolatey"]?.if).toBe("needs.gate.outputs.chocolatey == 'true'");
|
|
110
|
+
expect(jobs["publish-homebrew"]?.needs).toEqual(["gate", "release"]);
|
|
111
|
+
expect(JSON.stringify(jobs["publish-homebrew"])).toContain("git -C .system-catalog push");
|
|
112
|
+
expect(JSON.stringify(jobs["publish-homebrew"])).toContain("system-homebrew");
|
|
113
|
+
expect(JSON.stringify(jobs["publish-homebrew"])).toContain("toolfactory-postrelease/tap");
|
|
114
|
+
expect(JSON.stringify(jobs["publish-chocolatey"])).toContain("system-chocolatey");
|
|
115
|
+
expect(JSON.stringify(jobs)).not.toContain("/local/never-in-output");
|
|
116
|
+
expect(systemRegistryRows(project([choco])).at(0)?.secrets).toEqual(["CHOCOLATEY_API_KEY"]);
|
|
117
|
+
});
|
|
118
|
+
it("uses dedicated native runners and upload names instead of the Ubuntu package job", () => {
|
|
119
|
+
const brew = {
|
|
120
|
+
id: "homebrew",
|
|
121
|
+
path: "packaging/brew",
|
|
122
|
+
identity: "tool.rb",
|
|
123
|
+
name: "tool",
|
|
124
|
+
versionCommand: "grep VERSION tool.rb",
|
|
125
|
+
buildCommand: "make release-archive",
|
|
126
|
+
asset: "dist/tool.tar.gz",
|
|
127
|
+
tap: "acme/homebrew-tap",
|
|
128
|
+
catalogPath: "Formula/tool.rb",
|
|
129
|
+
};
|
|
130
|
+
const jobs = systemPackageJobs(project([choco, brew]), "${{ github.sha }}");
|
|
131
|
+
expect(jobs["system-homebrew"]?.["runs-on"]).toBe("macos-latest");
|
|
132
|
+
expect(jobs["system-chocolatey"]?.["runs-on"]).toBe("windows-latest");
|
|
133
|
+
expect(JSON.stringify(jobs["system-chocolatey"])).toContain("system-chocolatey");
|
|
134
|
+
expect(JSON.stringify(jobs)).not.toContain("/local/never-in-output");
|
|
135
|
+
});
|
|
136
|
+
});
|