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
@@ -8,6 +8,7 @@
8
8
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
11
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
11
12
  import { LOCK_PATH, readLock, serializeLock, sha256 } from "./lock.js";
12
13
  function locate(text, region) {
13
14
  const start = text.indexOf(region.begin);
@@ -70,13 +71,156 @@ export function pickPatch(document, patch, owned = [], prefix = "") {
70
71
  }
71
72
  return out;
72
73
  }
74
+ function valueAt(document, path) {
75
+ return path
76
+ .split(".")
77
+ .reduce((value, part) => (isRecord(value) ? value[part] : undefined), document);
78
+ }
79
+ function setAt(document, path, value) {
80
+ const parts = path.split(".");
81
+ let target = document;
82
+ for (const part of parts.slice(0, -1)) {
83
+ const next = target[part];
84
+ if (isRecord(next))
85
+ target = next;
86
+ else {
87
+ const created = {};
88
+ target[part] = created;
89
+ target = created;
90
+ }
91
+ }
92
+ target[parts.at(-1)] = value;
93
+ }
94
+ function omitKeyedArrays(patch, keyedArrays, prefix = "") {
95
+ const out = {};
96
+ for (const [key, value] of Object.entries(patch)) {
97
+ const path = prefix ? `${prefix}.${key}` : key;
98
+ if (path in keyedArrays)
99
+ continue;
100
+ out[key] = isRecord(value) ? omitKeyedArrays(value, keyedArrays, path) : value;
101
+ }
102
+ return out;
103
+ }
104
+ function keyedArrayEntries(value, id, path) {
105
+ if (!Array.isArray(value) ||
106
+ value.some((entry) => !isRecord(entry) || typeof entry[id] !== "string")) {
107
+ throw new Error(`${path} must be an array of objects with a string ${id} field.`);
108
+ }
109
+ return value;
110
+ }
111
+ function keyedArrayState(file) {
112
+ if (!file.keyedArrays || Object.keys(file.keyedArrays).length === 0)
113
+ return undefined;
114
+ return Object.fromEntries(Object.entries(file.keyedArrays).map(([path, id]) => [
115
+ path,
116
+ {
117
+ id,
118
+ values: keyedArrayEntries(valueAt(file.patch, path), id, path).map((entry) => entry[id]),
119
+ entries: Object.fromEntries(keyedArrayEntries(valueAt(file.patch, path), id, path).map((entry) => [
120
+ entry[id],
121
+ entry,
122
+ ])),
123
+ },
124
+ ]));
125
+ }
126
+ function removeProjectedLeaves(entry, previous, next) {
127
+ const out = { ...entry };
128
+ for (const [key, oldValue] of Object.entries(previous)) {
129
+ if (!(key in next))
130
+ delete out[key];
131
+ else if (isRecord(oldValue) && isRecord(next[key]) && isRecord(out[key]))
132
+ out[key] = removeProjectedLeaves(out[key], oldValue, next[key]);
133
+ }
134
+ return out;
135
+ }
136
+ function mergeKeyedArrays(document, file, previous) {
137
+ let changed = false;
138
+ for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
139
+ const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
140
+ const previousIds = new Set(previous.files[file.path]?.keyedArrays?.[path]?.values ?? []);
141
+ const current = valueAt(document, path);
142
+ const entries = current === undefined ? [] : keyedArrayEntries(current, id, path);
143
+ const desiredById = new Map(desired.map((entry) => [entry[id], entry]));
144
+ const retained = [];
145
+ const generated = [];
146
+ for (const entry of entries) {
147
+ const key = entry[id];
148
+ const replacement = desiredById.get(key);
149
+ if (previousIds.has(key)) {
150
+ if (replacement) {
151
+ const previousEntry = previous.files[file.path]?.keyedArrays?.[path]?.entries?.[key];
152
+ generated.push(deepMerge(previousEntry ? removeProjectedLeaves(entry, previousEntry, replacement) : entry, replacement));
153
+ }
154
+ continue;
155
+ }
156
+ // Migration from a formerly whole generated file: an identical entry is ours already.
157
+ if (replacement && JSON.stringify(entry) === JSON.stringify(replacement)) {
158
+ generated.push(entry);
159
+ continue;
160
+ }
161
+ if (replacement)
162
+ throw new Error(`${file.path} ${path} already has author entry ${id}=${key}.`);
163
+ retained.push(entry);
164
+ }
165
+ const next = [
166
+ ...retained,
167
+ ...generated,
168
+ // A generated row may have been deleted by hand. It remains owned, so put its desired
169
+ // replacement back even when the previous lock knows its id already.
170
+ ...desired.filter((entry) => !generated.some((current) => current[id] === entry[id])),
171
+ ];
172
+ if (JSON.stringify(entries) !== JSON.stringify(next))
173
+ changed = true;
174
+ setAt(document, path, next);
175
+ }
176
+ return changed;
177
+ }
178
+ /** Select only generated leaves from an entry, leaving authored extension fields out of drift. */
179
+ function pickProjectedEntry(current, projected) {
180
+ if (!current)
181
+ return undefined;
182
+ const picked = {};
183
+ for (const [key, value] of Object.entries(projected)) {
184
+ const actual = current[key];
185
+ picked[key] = isRecord(value) && isRecord(actual) ? pickProjectedEntry(actual, value) : actual;
186
+ }
187
+ return picked;
188
+ }
189
+ function removeKeyedArrayEntries(document, keyedArrays) {
190
+ let changed = false;
191
+ for (const [path, state] of Object.entries(keyedArrays ?? {})) {
192
+ const current = valueAt(document, path);
193
+ if (!Array.isArray(current))
194
+ continue;
195
+ const next = current.filter((entry) => !isRecord(entry) || !state.values.includes(entry[state.id]));
196
+ if (next.length !== current.length) {
197
+ setAt(document, path, next);
198
+ changed = true;
199
+ }
200
+ }
201
+ return changed;
202
+ }
203
+ function pickMerge(document, file) {
204
+ const picked = pickPatch(document, file.patch, file.owned);
205
+ for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
206
+ const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
207
+ const current = Array.isArray(valueAt(document, path))
208
+ ? valueAt(document, path)
209
+ : [];
210
+ const byId = new Map(current.filter(isRecord).map((entry) => [entry[id], entry]));
211
+ setAt(picked, path, desired.map((entry) => pickProjectedEntry(byId.get(entry[id]), entry)));
212
+ }
213
+ return picked;
214
+ }
73
215
  /** The dotted paths a patch writes: one per leaf value, and one per object it owns whole. */
74
- export function patchKeys(patch, owned = [], prefix = "") {
216
+ export function patchKeys(patch, owned = [], keyedArrays = {}, prefix = "") {
75
217
  const keys = [];
76
218
  for (const [key, value] of Object.entries(patch)) {
77
219
  const path = prefix ? `${prefix}.${key}` : key;
220
+ if (path in keyedArrays)
221
+ continue;
78
222
  if (isRecord(value) && !owned.includes(path) && Object.keys(value).length > 0) {
79
- keys.push(...patchKeys(value, owned, path));
223
+ keys.push(...patchKeys(value, owned, keyedArrays, path));
80
224
  }
81
225
  else {
82
226
  keys.push(path);
@@ -119,12 +263,14 @@ export function removeKeys(document, keys) {
119
263
  return removed;
120
264
  }
121
265
  function parseDocument(text, format) {
122
- return (format === "toml" ? parseToml(text) : JSON.parse(text));
266
+ return (format === "toml" ? parseToml(text) : format === "yaml" ? parseYaml(text) : JSON.parse(text));
123
267
  }
124
268
  function serializeDocument(document, format) {
125
- return format === "toml"
126
- ? `${stringifyToml(document)}\n`
127
- : `${JSON.stringify(document, null, 2)}\n`;
269
+ if (format === "toml")
270
+ return `${stringifyToml(document)}\n`;
271
+ if (format === "yaml")
272
+ return stringifyYaml(document);
273
+ return `${JSON.stringify(document, null, 2)}\n`;
128
274
  }
129
275
  /** The content toolfactory manages, as one string for hashing and comparison. */
130
276
  export function managedContent(file) {
@@ -152,11 +298,15 @@ function currentManagedContent(root, file) {
152
298
  return text;
153
299
  if (file.kind === "region")
154
300
  return extractRegions(text, file)?.join(" ");
155
- return JSON.stringify(pickPatch(parseDocument(text, file.format), file.patch, file.owned));
301
+ return JSON.stringify(pickMerge(parseDocument(text, file.format), file));
156
302
  }
157
303
  /** The two structured formats merge files use; the extension is the only discriminator needed. */
158
304
  function documentFormat(path) {
159
- return path.endsWith(".toml") ? "toml" : "json";
305
+ return path.endsWith(".toml")
306
+ ? "toml"
307
+ : path.endsWith(".yaml") || path.endsWith(".yml")
308
+ ? "yaml"
309
+ : "json";
160
310
  }
161
311
  /** The keys the lock recorded for a path that the current patch no longer writes. */
162
312
  function staleKeys(lock, path, current = []) {
@@ -169,6 +319,16 @@ function stranded(root, path, keys) {
169
319
  const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
170
320
  return keys.some((key) => hasKey(document, key));
171
321
  }
322
+ function keyedEntriesRemain(root, path, keyedArrays) {
323
+ if (!keyedArrays || !existsSync(join(root, path)))
324
+ return false;
325
+ const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
326
+ return Object.entries(keyedArrays).some(([arrayPath, state]) => {
327
+ const current = valueAt(document, arrayPath);
328
+ return (Array.isArray(current) &&
329
+ current.some((entry) => isRecord(entry) && state.values.includes(entry[state.id])));
330
+ });
331
+ }
172
332
  /** The marker pairs the lock recorded for a path that the current plan no longer writes. */
173
333
  function staleRegions(lock, path, current = []) {
174
334
  return (lock.files[path]?.regions ?? []).filter((region) => !current.some((planned) => planned.begin === region.begin));
@@ -219,7 +379,9 @@ export function check(root, plan, toolfactoryVersion) {
219
379
  if (planned.has(path) || entry?.state !== "generated" || !present(root, path))
220
380
  continue;
221
381
  // A merge file toolfactory stops writing loses its keys, not its existence.
222
- if (entry.keys && !stranded(root, path, entry.keys))
382
+ if ((entry.keys || entry.keyedArrays) &&
383
+ !stranded(root, path, entry.keys ?? []) &&
384
+ !keyedEntriesRemain(root, path, entry.keyedArrays))
223
385
  continue;
224
386
  // A region file likewise loses its regions: once they are empty, nothing of ours is stranded.
225
387
  if (entry.regions && !filled(root, path, entry.regions))
@@ -240,25 +402,36 @@ function render(root, file, previous) {
240
402
  const stale = staleRegions(previous, file.path, file.regions);
241
403
  const replaced = replaceRegions(emptyRegions(text, stale) ?? text, file);
242
404
  if (replaced === undefined) {
405
+ const prior = previous.files[file.path];
406
+ // A generated full file can become a region file without forcing an author to recreate
407
+ // markers. Its lock hash proves every byte is still the old generator's, so replacing it
408
+ // cannot discard authored work. Manual or changed files still require `adopt`.
409
+ if (prior?.state === "generated" && sha256(text) === prior.sha256)
410
+ return replaceRegions(file.template, file) ?? file.template;
243
411
  throw new Error(`${file.path} exists but is missing a toolfactory region marker; restore the markers or run \`toolfactory adopt ${file.path}\`.`);
244
412
  }
245
413
  return replaced;
246
414
  }
247
415
  return replaceRegions(file.template, file) ?? file.template;
248
416
  }
249
- if (!existsSync(path))
250
- return serializeDocument(deepMerge({}, file.patch), file.format);
417
+ if (!existsSync(path)) {
418
+ const document = deepMerge({}, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned);
419
+ mergeKeyedArrays(document, file, previous);
420
+ return serializeDocument(document, file.format);
421
+ }
251
422
  const text = readFileSync(path, "utf8");
252
423
  const document = parseDocument(text, file.format);
253
424
  // The patch's inverse first: keys a previous patch wrote and this one dropped are uninstalled.
254
- const uninstalled = removeKeys(document, staleKeys(previous, file.path, patchKeys(file.patch, file.owned)));
425
+ const uninstalled = removeKeys(document, staleKeys(previous, file.path, patchKeys(file.patch, file.owned, file.keyedArrays)));
426
+ const arraysChanged = mergeKeyedArrays(document, file, previous);
255
427
  // A document that already carries the patch is left byte-for-byte alone, so a rebuild never
256
428
  // reserializes the author's file (and, for TOML, never drops their comments).
257
429
  if (!uninstalled &&
258
- JSON.stringify(pickPatch(document, file.patch, file.owned)) === JSON.stringify(file.patch)) {
430
+ !arraysChanged &&
431
+ JSON.stringify(pickMerge(document, file)) === JSON.stringify(file.patch)) {
259
432
  return text;
260
433
  }
261
- return serializeDocument(deepMerge(document, file.patch, file.owned), file.format);
434
+ return serializeDocument(deepMerge(document, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned), file.format);
262
435
  }
263
436
  /** Write a plan to the tree and refresh the lock. */
264
437
  export function apply(root, plan, toolfactoryVersion) {
@@ -279,7 +452,8 @@ export function apply(root, plan, toolfactoryVersion) {
279
452
  result.manual.push(file.path);
280
453
  continue;
281
454
  }
282
- const keys = file.kind === "merge" ? patchKeys(file.patch, file.owned) : undefined;
455
+ const keys = file.kind === "merge" ? patchKeys(file.patch, file.owned, file.keyedArrays) : undefined;
456
+ const keyedArrays = file.kind === "merge" ? keyedArrayState(file) : undefined;
283
457
  const regions = file.kind === "region" ? file.regions.map(({ begin, end }) => ({ begin, end })) : undefined;
284
458
  if (isLink(file)) {
285
459
  if (currentManagedContent(root, file) === managedContent(file)) {
@@ -308,6 +482,7 @@ export function apply(root, plan, toolfactoryVersion) {
308
482
  sha256: sha256(managedContent(file)),
309
483
  state: "generated",
310
484
  keys,
485
+ keyedArrays,
311
486
  regions,
312
487
  };
313
488
  }
@@ -320,11 +495,13 @@ export function apply(root, plan, toolfactoryVersion) {
320
495
  }
321
496
  if (!present(root, path))
322
497
  continue;
323
- if (entry.keys) {
498
+ if (entry.keys || entry.keyedArrays) {
324
499
  // The inverse of a merge file is its keys: the author keeps the file and everything else in it.
325
500
  const format = documentFormat(path);
326
501
  const document = parseDocument(readFileSync(join(root, path), "utf8"), format);
327
- if (removeKeys(document, entry.keys)) {
502
+ const removedKeys = removeKeys(document, entry.keys ?? []);
503
+ const removedEntries = removeKeyedArrayEntries(document, entry.keyedArrays);
504
+ if (removedKeys || removedEntries) {
328
505
  writeFileSync(join(root, path), serializeDocument(document, format));
329
506
  result.written.push(path);
330
507
  }
@@ -226,8 +226,111 @@ describe("apply / check — merge files", () => {
226
226
  expect(text).toContain('dependencies = [ "zod" ]');
227
227
  expect(check(root, plan, "0.1.0")).toEqual([]);
228
228
  });
229
+ it("merges YAML and keeps authored keyed-array entries while removing stale generated ones", () => {
230
+ const root = tmp();
231
+ const plan = (commands) => [
232
+ {
233
+ kind: "merge",
234
+ path: "extension.yaml",
235
+ format: "yaml",
236
+ patch: {
237
+ name: "hello",
238
+ commands: commands.map((command) => ({ command, title: command })),
239
+ },
240
+ keyedArrays: { commands: "command" },
241
+ },
242
+ ];
243
+ apply(root, plan(["hello.run", "hello.old"]), "0.1.0");
244
+ writeFileSync(join(root, "extension.yaml"), `${readFileSync(join(root, "extension.yaml"), "utf8")}commands:\n - command: author.run\n title: Author\n - command: hello.run\n title: hello.run\n - command: hello.old\n title: hello.old\n`);
245
+ // Recreate the document with a custom entry and the generated entries, as a hand-built host
246
+ // manifest would; the generated ids remain Toolfactory-owned from the lock.
247
+ writeFileSync(join(root, "extension.yaml"), "name: hello\ncommands:\n - command: author.run\n title: Author\n - command: hello.run\n title: hello.run\n - command: hello.old\n title: hello.old\n");
248
+ apply(root, plan(["hello.run", "hello.new"]), "0.1.0");
249
+ const text = readFileSync(join(root, "extension.yaml"), "utf8");
250
+ expect(text).toContain("author.run");
251
+ expect(text).toContain("hello.new");
252
+ expect(text).not.toContain("hello.old");
253
+ expect(check(root, plan(["hello.run", "hello.new"]), "0.1.0")).toEqual([]);
254
+ expect(() => apply(root, plan(["author.run"]), "0.1.0")).toThrow(/already has author entry/);
255
+ });
256
+ it("preserves unknown keyed-entry fields while removing stale projected leaves", () => {
257
+ const root = tmp();
258
+ const plan = (title, category) => [
259
+ {
260
+ kind: "merge",
261
+ path: "package.json",
262
+ format: "json",
263
+ patch: {
264
+ contributes: {
265
+ commands: [{ command: "hello.run", title, ...(category ? { category } : {}) }],
266
+ },
267
+ },
268
+ keyedArrays: { "contributes.commands": "command" },
269
+ },
270
+ ];
271
+ apply(root, plan("Run", "Toolfactory"), "0.1.0");
272
+ const path = join(root, "package.json");
273
+ const document = JSON.parse(readFileSync(path, "utf8"));
274
+ document.contributes.commands[0].icon = "$(rocket)";
275
+ writeFileSync(path, JSON.stringify(document));
276
+ // Native command details remain authored: they are not drift merely because Toolfactory
277
+ // owns the command's id and projected leaves.
278
+ expect(check(root, plan("Run", "Toolfactory"), "0.1.0")).toEqual([]);
279
+ apply(root, plan("Run tool"), "0.1.0");
280
+ expect(JSON.parse(readFileSync(path, "utf8")).contributes.commands).toEqual([
281
+ { command: "hello.run", title: "Run tool", icon: "$(rocket)" },
282
+ ]);
283
+ expect(check(root, plan("Run tool"), "0.1.0")).toEqual([]);
284
+ });
285
+ it("upgrades an untouched generated full file to a region but protects changed files", () => {
286
+ const root = tmp();
287
+ const old = [{ kind: "file", path: "entry.ts", content: "old generated\n" }];
288
+ const region = {
289
+ kind: "region",
290
+ path: "entry.ts",
291
+ regions: [{ begin: "// tf:entry", end: "// /tf:entry", content: "new generated\n" }],
292
+ template: "// tf:entry\n// /tf:entry\n\n// author tail\n",
293
+ };
294
+ apply(root, old, "0.1.0");
295
+ apply(root, [region], "0.2.0");
296
+ expect(readFileSync(join(root, "entry.ts"), "utf8")).toContain("new generated");
297
+ const changed = tmp();
298
+ apply(changed, old, "0.1.0");
299
+ writeFileSync(join(changed, "entry.ts"), "author changed\n");
300
+ expect(() => apply(changed, [region], "0.2.0")).toThrow(/missing a toolfactory region marker/);
301
+ });
229
302
  });
230
303
  describe("apply / check — inverses and outputs", () => {
304
+ it("restores deleted keyed rows, leaves steady YAML bytes alone, and uninstalls keys and rows", () => {
305
+ const root = tmp();
306
+ const plan = [
307
+ {
308
+ kind: "merge",
309
+ path: "extension.yaml",
310
+ format: "yaml",
311
+ patch: {
312
+ generated: true,
313
+ commands: [{ command: "hello.run", title: "Run" }],
314
+ },
315
+ keyedArrays: { commands: "command" },
316
+ },
317
+ ];
318
+ apply(root, plan, "0.1.0");
319
+ writeFileSync(join(root, "extension.yaml"), "# author comment\nauthor: true\ngenerated: true\ncommands:\n - command: author.run\n title: Author\n");
320
+ // The prior lock owns hello.run, so removing it by hand does not turn it into an omission.
321
+ apply(root, plan, "0.1.0");
322
+ expect(readFileSync(join(root, "extension.yaml"), "utf8")).toContain("hello.run");
323
+ // Once current, no rewrite means YAML comments and formatting survive a rebuild.
324
+ const path = join(root, "extension.yaml");
325
+ writeFileSync(path, `# preserved comment\n${readFileSync(path, "utf8")}`);
326
+ const before = readFileSync(path, "utf8");
327
+ expect(apply(root, plan, "0.1.0").unchanged).toContain("extension.yaml");
328
+ expect(readFileSync(path, "utf8")).toBe(before);
329
+ apply(root, [], "0.1.0");
330
+ expect(readFileSync(path, "utf8")).toContain("author.run");
331
+ expect(readFileSync(path, "utf8")).not.toContain("hello.run");
332
+ expect(readFileSync(path, "utf8")).not.toContain("generated:");
333
+ });
231
334
  it("uninstalls the keys a patch stops writing, keeping every other key", () => {
232
335
  const root = tmp();
233
336
  mkdirSync(root, { recursive: true });
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The gate and the release assets, as data.
3
+ *
4
+ * The gate is a command; CI is its projection. One list of steps is rendered three ways — the
5
+ * `test` job of `ci.yml`, the `gate`/`package` jobs of `release.yml`, and a shell script a local
6
+ * `toolfactory gate` / `toolfactory package` spawns — so "what green means" has exactly one
7
+ * definition whether or not the project ever sees GitHub.
8
+ *
9
+ * A step here is a command and nothing else. GitHub Actions `uses:` steps are runner provisioning
10
+ * (checkout, toolchains, caches) and stay in `src/surfaces/workflows.ts`, which wraps this list.
11
+ */
12
+ import { type NativePackageId } from "../distribution/native.js";
13
+ import { type SystemPackageId } from "../distribution/system.js";
1
14
  import { type PackageManager, type Project, type SurfaceId } from "../model.js";
2
15
  export interface GateStep {
3
16
  /** Step label: the CI step name, and the banner the shell script echoes. */
@@ -53,7 +66,9 @@ export declare function tagVersionAssert(project: Project): GateStep | undefined
53
66
  * the release workflow's `package` job, which uploads the directory as one artifact. Publishing
54
67
  * to a registry stays a CI concern; producing the artifacts never is.
55
68
  */
56
- export declare function packageSteps(project: Project): GateStep[];
69
+ export declare function packageSteps(project: Project, options?: {
70
+ system?: boolean;
71
+ }): GateStep[];
57
72
  /** npm's filename for the root package, including its flattened scope. */
58
73
  export declare function npmTarball(project: Project): string;
59
74
  /** The ClawHub tarball `npm pack ./hosts/openclaw` writes, by name, inside the release artifact. */
@@ -65,7 +80,7 @@ export declare function openclawTarball(project: Project): string;
65
80
  */
66
81
  export interface Registry {
67
82
  /** Stable id: the release job suffix, the `secrets status` row, the unpublish step name. */
68
- id: "npm" | "pypi" | "mcp-registry" | "oci" | "clawhub-package" | "clawhub-skill" | "pages" | "chrome" | "firefox" | "edge" | "safari";
83
+ id: NativePackageId | SystemPackageId | "npm" | "pypi" | "mcp-registry" | "oci" | "clawhub-package" | "clawhub-skill" | "pages" | "chrome" | "firefox" | "edge" | "safari" | "vscode-marketplace" | "open-vsx";
69
84
  /** The surfaces whose selection publishes here; all must be selected. */
70
85
  surfaces: SurfaceId[];
71
86
  /** Environment names the publish leg consumes (repository scope); [] for OIDC/GITHUB_TOKEN legs. */
@@ -9,6 +9,8 @@
9
9
  * A step here is a command and nothing else. GitHub Actions `uses:` steps are runner provisioning
10
10
  * (checkout, toolchains, caches) and stay in `src/surfaces/workflows.ts`, which wraps this list.
11
11
  */
12
+ import { nativePackageSteps, nativeRegistryRows, nativeToolchainSteps, } from "../distribution/native.js";
13
+ import { systemPackageSteps, systemRegistryRows, } from "../distribution/system.js";
12
14
  import { githubSlug } from "../hosts/github.js";
13
15
  import { githubOwner, projectName } from "../identity/name.js";
14
16
  import { isInstructionOnly } from "../model.js";
@@ -17,6 +19,7 @@ import { HOST_DIR as DSH_HOST_DIR } from "../surfaces/dsh.js";
17
19
  import { MANIFEST_PATH as MCPB_MANIFEST_PATH, MCPB_PIN } from "../surfaces/mcpb.js";
18
20
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "../surfaces/openclaw-native.js";
19
21
  import { configProperties, envName, has, isSensitive, npmName, pypiName, } from "../surfaces/shared.js";
22
+ import { extensionName, OVSX_VERSION, publisher, VSCE_VERSION, HOST_DIR as VSCODE_HOST_DIR, vsixName, } from "../surfaces/vscode-extension.js";
20
23
  /** Where `toolfactory package` and the release `package` job leave every asset. */
21
24
  export const RELEASE_DIR = "dist/release";
22
25
  /** The web app's build, shared by the release tarball and the Pages job (which sets `PAGES_BASE`). */
@@ -107,7 +110,9 @@ export function gateSteps(project) {
107
110
  when: "ci",
108
111
  });
109
112
  }
110
- if (has(project, "web") || has(project, "browser-extension")) {
113
+ if (has(project, "web") ||
114
+ has(project, "browser-extension") ||
115
+ has(project, "vscode-extension")) {
111
116
  // The web smoke and the browser extension's own Playwright smoke both drive Chromium;
112
117
  // their own npm installs fetch the browser, the runner needs its system libraries.
113
118
  steps.push({
@@ -116,6 +121,7 @@ export function gateSteps(project) {
116
121
  when: "ci",
117
122
  });
118
123
  }
124
+ steps.push(...nativeToolchainSteps(project));
119
125
  steps.push({
120
126
  name: "toolfactory validate",
121
127
  run: `${cli} validate`,
@@ -128,6 +134,8 @@ export function gateSteps(project) {
128
134
  name: "author tests",
129
135
  run: typescript ? pm.test : "uv run --with pytest pytest -q",
130
136
  });
137
+ if (typescript)
138
+ steps.push({ name: "author integration tests", run: pm.run("test:integration") });
131
139
  if (openclaw) {
132
140
  // T3, credential-free: one real OpenClaw agent turn against a scripted model. The suite only
133
141
  // exists when `tool.json` `tests.examples` names an operation the plugin carries, which is
@@ -190,13 +198,16 @@ const LEGAL_BUNDLE_PATHS = [
190
198
  * the release workflow's `package` job, which uploads the directory as one artifact. Publishing
191
199
  * to a registry stays a CI concern; producing the artifacts never is.
192
200
  */
193
- export function packageSteps(project) {
201
+ export function packageSteps(project, options = {}) {
194
202
  const cli = toolfactoryCli(project);
195
203
  const name = project.identity.name;
196
204
  const steps = [
197
205
  { name: "release directory", run: `rm -rf ${RELEASE_DIR} && mkdir -p ${RELEASE_DIR}` },
198
206
  ...bootstrapSteps(project),
199
207
  outputsStep(project),
208
+ ...nativeToolchainSteps(project),
209
+ ...nativePackageSteps(project),
210
+ ...systemPackageSteps(project, { include: options.system !== false }),
200
211
  ];
201
212
  if (has(project, "web")) {
202
213
  steps.push({
@@ -295,6 +306,12 @@ export function packageSteps(project) {
295
306
  });
296
307
  }
297
308
  // COVERAGE.md is tracked; coverage.json is a build output that need not be, so recompute it.
309
+ if (has(project, "vscode-extension")) {
310
+ steps.push({
311
+ name: "VS Code extension VSIX",
312
+ run: `npm --prefix ${VSCODE_HOST_DIR} install && npm --prefix ${VSCODE_HOST_DIR} run vsix && cp ${VSCODE_HOST_DIR}/extension.vsix ${RELEASE_DIR}/${vsixName(project)}`,
313
+ });
314
+ }
298
315
  steps.push({
299
316
  name: "coverage report",
300
317
  run: `cp COVERAGE.md ${RELEASE_DIR}/ && ${cli} coverage > ${RELEASE_DIR}/coverage.json`,
@@ -355,6 +372,22 @@ const ASC_SECRETS = ["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_PRIVATE_KEY"];
355
372
  export const RELEASE_SECRET_NAMES = [
356
373
  "NPM_TOKEN",
357
374
  "CLAWHUB_TOKEN",
375
+ "VSCE_PAT",
376
+ "OVSX_PAT",
377
+ "CARGO_REGISTRY_TOKEN",
378
+ "NUGET_API_KEY",
379
+ "MAVEN_CENTRAL_USERNAME",
380
+ "MAVEN_CENTRAL_PASSWORD",
381
+ "MAVEN_GPG_PRIVATE_KEY",
382
+ "MAVEN_GPG_PASSPHRASE",
383
+ "RUBYGEMS_API_KEY",
384
+ "HOMEBREW_TAP_TOKEN",
385
+ "SCOOP_BUCKET_TOKEN",
386
+ "CHOCOLATEY_API_KEY",
387
+ "APT_GPG_PRIVATE_KEY",
388
+ "APT_GPG_KEY_ID",
389
+ "COPR_LOGIN",
390
+ "COPR_TOKEN",
358
391
  ...CHROME_SECRETS,
359
392
  ...FIREFOX_SECRETS,
360
393
  ...EDGE_SECRETS,
@@ -379,6 +412,29 @@ export function registries(project) {
379
412
  const browserZip = (browser) => `${BROWSER_HOST_DIR}/.output/${zipName(project, browser)}`;
380
413
  const wxtSubmit = `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt submit --dry-run`;
381
414
  const rows = [
415
+ ...nativeRegistryRows(project),
416
+ ...systemRegistryRows(project),
417
+ {
418
+ id: "vscode-marketplace",
419
+ surfaces: ["vscode-extension"],
420
+ secrets: ["VSCE_PAT"],
421
+ url: "https://marketplace.visualstudio.com/manage",
422
+ probe: `npx --yes @vscode/vsce@${VSCE_VERSION} verify-pat ${publisher(project)}`,
423
+ exists: `npx --yes @vscode/vsce@${VSCE_VERSION} show ${publisher(project)}.${extensionName(project)} --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.exit(JSON.parse(s).versions.some(v=>v.version==="${version}")?0:1))'`,
424
+ gate: allSet(["VSCE_PAT"]),
425
+ retractUrl: "https://marketplace.visualstudio.com/manage",
426
+ retractHard: `npx --yes @vscode/vsce@${VSCE_VERSION} unpublish ${publisher(project)}.${extensionName(project)} --force`,
427
+ },
428
+ {
429
+ id: "open-vsx",
430
+ surfaces: ["vscode-extension"],
431
+ secrets: ["OVSX_PAT"],
432
+ url: "https://open-vsx.org/user-settings/tokens",
433
+ probe: `npx --yes ovsx@${OVSX_VERSION} verify-pat ${publisher(project)}`,
434
+ exists: `curl -fsS -o /dev/null https://open-vsx.org/api/${publisher(project)}/${extensionName(project)}/${version}`,
435
+ gate: allSet(["OVSX_PAT"]),
436
+ retractUrl: "https://open-vsx.org/user-settings/extensions",
437
+ },
382
438
  {
383
439
  id: "npm",
384
440
  surfaces: ["npm"],
@@ -527,7 +583,9 @@ export function registries(project) {
527
583
  },
528
584
  ];
529
585
  const selected = rows.filter((row) => row.surfaces.every((surface) => has(project, surface)) &&
530
- (row.id !== "safari" || project.tool.browserExtension?.safari === true));
586
+ (row.id !== "safari" || project.tool.browserExtension?.safari === true) &&
587
+ (row.id !== "vscode-marketplace" || project.tool.vscode?.marketplace !== false) &&
588
+ (row.id !== "open-vsx" || project.tool.vscode?.openvsx !== false));
531
589
  const mcpRegistry = selected.find((row) => row.id === "mcp-registry");
532
590
  if (mcpRegistry) {
533
591
  const packages = selected.filter((row) => row.id === "npm" || row.id === "pypi" || row.id === "oci");
@@ -535,6 +593,11 @@ export function registries(project) {
535
593
  ? packages.map((row) => `[ "$${gateVariable(row)}" = true ]`).join(" && ")
536
594
  : "true";
537
595
  }
596
+ const runtime = selected.find((row) => row.id === (project.tool.binding === "python" ? "pypi" : "npm"));
597
+ for (const row of selected.filter((row) => row.id === "vscode-marketplace" || row.id === "open-vsx")) {
598
+ if (runtime)
599
+ row.gate = `(${row.gate}) && { ${runtime.exists} || { ${runtime.gate}; }; }`;
600
+ }
538
601
  return selected;
539
602
  }
540
603
  /** The shell variable the release gate holds a row's presence in; the job output takes the same name. */
@@ -558,6 +621,8 @@ export function manualSteps(project) {
558
621
  const has_ = (id) => rows.some((row) => row.id === id);
559
622
  const slug = githubSlug(project.identity.repository);
560
623
  const steps = [];
624
+ if (has_("vscode-marketplace") || has_("open-vsx"))
625
+ steps.push("VS Code: register the publisher in Visual Studio Marketplace; create the Open VSX namespace and accept its publisher agreement before setting VSCE_PAT / OVSX_PAT. tool.vscode.publisher selects that identity.");
561
626
  if (has_("oci")) {
562
627
  steps.push(`ghcr.io: the first push publishes a private image whatever the repository's visibility, and GitHub has no visibility API — flip it once at ${rows.find((row) => row.id === "oci")?.url}.`);
563
628
  }
@@ -10,7 +10,7 @@ export declare function toolAtRef(root: string, ref: string): {
10
10
  schemaVersion: 1;
11
11
  identity: string;
12
12
  binding: "typescript" | "python";
13
- surfaces: ("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")[];
13
+ surfaces: ("homebrew" | "winget" | "scoop" | "chocolatey" | "apt" | "rpm" | "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")[];
14
14
  bundle: {
15
15
  runtime: "package" | "bundled";
16
16
  };
@@ -46,6 +46,34 @@ export declare function toolAtRef(root: string, ref: string): {
46
46
  hermes?: {
47
47
  toolset?: string | undefined;
48
48
  } | undefined;
49
+ vscode?: {
50
+ marketplace: boolean;
51
+ openvsx: boolean;
52
+ publisher?: string | undefined;
53
+ } | undefined;
54
+ nativePackages?: {
55
+ id: "cargo" | "nuget" | "maven-central" | "rubygems" | "packagist" | "go-module";
56
+ path: string;
57
+ identity: string;
58
+ name: string;
59
+ versionCommand?: string | undefined;
60
+ }[] | undefined;
61
+ systemPackages?: {
62
+ id: "homebrew" | "winget" | "scoop" | "chocolatey" | "apt" | "rpm";
63
+ path: string;
64
+ identity: string;
65
+ name: string;
66
+ versionCommand: string;
67
+ buildDependencies: string[];
68
+ buildCommand?: string | undefined;
69
+ asset?: string | undefined;
70
+ tap?: string | undefined;
71
+ bucket?: string | undefined;
72
+ bucketName?: string | undefined;
73
+ catalogPath?: string | undefined;
74
+ ppa?: string | undefined;
75
+ coprProject?: string | undefined;
76
+ }[] | undefined;
49
77
  openclaw?: {
50
78
  registers?: {
51
79
  api: string;