toolfactory 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/bindings/python.js +1 -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 +63 -2
- package/dist/model.js +18 -1
- 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 +102 -23
- package/dist/project/history.d.ts +30 -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 +27 -3
- package/dist/surfaces/agents.test.js +12 -0
- package/dist/surfaces/claude.js +8 -3
- package/dist/surfaces/codex.js +9 -4
- package/dist/surfaces/codex.test.js +16 -6
- package/dist/surfaces/cursor.js +2 -2
- package/dist/surfaces/distribution.test.js +121 -7
- package/dist/surfaces/dsh.js +29 -7
- package/dist/surfaces/dsh.test.js +11 -6
- package/dist/surfaces/gemini.js +2 -2
- package/dist/surfaces/gemini.test.js +4 -4
- package/dist/surfaces/hermes-native.js +43 -12
- package/dist/surfaces/hermes-native.test.js +53 -11
- package/dist/surfaces/mcp-registry.js +59 -7
- package/dist/surfaces/mcp-registry.test.js +23 -0
- package/dist/surfaces/mcpb.d.ts +1 -0
- package/dist/surfaces/mcpb.js +36 -23
- package/dist/surfaces/mcpb.test.js +31 -3
- package/dist/surfaces/npm.js +45 -14
- package/dist/surfaces/openclaw-native.js +10 -8
- package/dist/surfaces/openclaw-native.test.js +30 -0
- package/dist/surfaces/pypi.js +27 -3
- package/dist/surfaces/readme.d.ts +16 -0
- package/dist/surfaces/readme.js +17 -7
- package/dist/surfaces/registry.js +9 -3
- 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 +57 -11
- package/dist/surfaces/workflows.test.js +71 -3
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +2 -1
- package/src/bindings/python.ts +1 -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 +26 -2
- package/src/project/apply.test.ts +120 -0
- package/src/project/apply.ts +257 -15
- package/src/project/gate.ts +131 -24
- 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 +38 -3
- package/src/surfaces/claude.ts +7 -3
- package/src/surfaces/codex.test.ts +15 -5
- package/src/surfaces/codex.ts +12 -6
- package/src/surfaces/cursor.ts +2 -10
- package/src/surfaces/distribution.test.ts +141 -6
- package/src/surfaces/dsh.test.ts +11 -5
- package/src/surfaces/dsh.ts +30 -7
- package/src/surfaces/gemini.test.ts +3 -3
- package/src/surfaces/gemini.ts +1 -2
- package/src/surfaces/hermes-native.test.ts +61 -10
- package/src/surfaces/hermes-native.ts +46 -13
- package/src/surfaces/mcp-registry.test.ts +28 -0
- package/src/surfaces/mcp-registry.ts +66 -8
- package/src/surfaces/mcpb.test.ts +32 -5
- package/src/surfaces/mcpb.ts +44 -23
- package/src/surfaces/npm.ts +47 -15
- package/src/surfaces/openclaw-native.test.ts +37 -0
- package/src/surfaces/openclaw-native.ts +49 -47
- package/src/surfaces/pypi.ts +27 -3
- package/src/surfaces/readme.ts +21 -6
- package/src/surfaces/registry.ts +9 -3
- 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 +86 -3
- package/src/surfaces/workflows.ts +57 -11
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
- package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
- package/web/dist/index.html +2 -2
package/dist/project/apply.js
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/project/gate.d.ts
CHANGED
|
@@ -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
|
|
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. */
|