toolfactory 0.1.2 → 0.2.1
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 +27 -7
- package/dist/surfaces/dsh.test.js +37 -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/web.d.ts +1 -1
- package/dist/surfaces/web.js +1 -1
- 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 +38 -2
- package/src/surfaces/dsh.ts +28 -7
- 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/web.ts +1 -1
- 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-BHR6gCfm.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/e2e.test.ts
CHANGED
|
@@ -163,7 +163,13 @@ describe.skipIf(!existsSync(repoNodeModules))(
|
|
|
163
163
|
authorProse(fresh);
|
|
164
164
|
commands.build(fresh);
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
const walkedTree = tree(walked);
|
|
167
|
+
// Deselecting a region surface preserves its author-owned scaffold, including an empty
|
|
168
|
+
// Docker ignore file. All managed bytes still converge with the freshly generated tree.
|
|
169
|
+
expect(walkedTree[".dockerignore"]).toContain("# tf:dockerignore# /tf:dockerignore");
|
|
170
|
+
expect(walkedTree[".dockerignore"]).not.toContain("node_modules/");
|
|
171
|
+
delete walkedTree[".dockerignore"];
|
|
172
|
+
expect(walkedTree).toEqual(tree(fresh));
|
|
167
173
|
});
|
|
168
174
|
},
|
|
169
175
|
);
|
package/src/hosts/openclaw.ts
CHANGED
|
@@ -62,8 +62,10 @@ function missing(scaffold: unknown, ours: unknown, path: string, skip: Set<strin
|
|
|
62
62
|
|
|
63
63
|
function planned(files: PlannedFile[], path: string): string {
|
|
64
64
|
const file = files.find((candidate) => candidate.path === `${HOST_DIR}/${path}`);
|
|
65
|
-
if (file
|
|
66
|
-
return file.content;
|
|
65
|
+
if (!file) throw new Error(`the openclaw surface no longer plans ${path}`);
|
|
66
|
+
if (file.kind === "file") return file.content;
|
|
67
|
+
if (file.kind === "merge" && file.format === "json") return JSON.stringify(file.patch, null, 2);
|
|
68
|
+
throw new Error(`the openclaw surface no longer plans ${path} as JSON`);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** Human-readable drift lines; empty means the generator still mirrors upstream. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Verify the extension scaffold against Microsoft's generator, without touching the checkout. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { argv } from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import {
|
|
9
|
+
ESBUILD,
|
|
10
|
+
ESLINT,
|
|
11
|
+
GITIGNORE,
|
|
12
|
+
IGNORE,
|
|
13
|
+
LAUNCH,
|
|
14
|
+
TASKS,
|
|
15
|
+
VSCODE_GENERATOR,
|
|
16
|
+
VSCODE_SCAFFOLD,
|
|
17
|
+
} from "../surfaces/vscode-scaffold.js";
|
|
18
|
+
|
|
19
|
+
export function scaffoldDrift(): string[] {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-vscode-scaffold-"));
|
|
21
|
+
const target = join(root, "probe");
|
|
22
|
+
try {
|
|
23
|
+
execFileSync(
|
|
24
|
+
"npx",
|
|
25
|
+
[
|
|
26
|
+
"--yes",
|
|
27
|
+
"--package",
|
|
28
|
+
"yo",
|
|
29
|
+
"--package",
|
|
30
|
+
`generator-code@${VSCODE_GENERATOR}`,
|
|
31
|
+
"--",
|
|
32
|
+
"yo",
|
|
33
|
+
"code",
|
|
34
|
+
target,
|
|
35
|
+
"--extensionType",
|
|
36
|
+
"ts",
|
|
37
|
+
"--extensionDisplayName",
|
|
38
|
+
"ToolFactory Probe",
|
|
39
|
+
"--extensionId",
|
|
40
|
+
"tf-probe",
|
|
41
|
+
"--extensionDescription",
|
|
42
|
+
"ToolFactory scaffold probe",
|
|
43
|
+
"--pkgManager",
|
|
44
|
+
"npm",
|
|
45
|
+
"--bundler",
|
|
46
|
+
"esbuild",
|
|
47
|
+
"--gitInit",
|
|
48
|
+
"false",
|
|
49
|
+
"--skip-install",
|
|
50
|
+
"--skipOpen",
|
|
51
|
+
"--quick",
|
|
52
|
+
],
|
|
53
|
+
{ stdio: "pipe", cwd: root },
|
|
54
|
+
);
|
|
55
|
+
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf8"));
|
|
56
|
+
const drift = Object.entries(VSCODE_SCAFFOLD)
|
|
57
|
+
.filter(([key, expected]) => JSON.stringify(pkg[key]) !== JSON.stringify(expected))
|
|
58
|
+
.map(([key]) => `package.json ${key}`);
|
|
59
|
+
for (const [path, expected] of [
|
|
60
|
+
["esbuild.js", ESBUILD],
|
|
61
|
+
["eslint.config.mjs", ESLINT],
|
|
62
|
+
[".vscodeignore", IGNORE],
|
|
63
|
+
[".gitignore", GITIGNORE],
|
|
64
|
+
[".vscode/launch.json", LAUNCH],
|
|
65
|
+
[".vscode/tasks.json", TASKS],
|
|
66
|
+
]) {
|
|
67
|
+
if (readFileSync(join(target, path), "utf8") !== expected) drift.push(path);
|
|
68
|
+
}
|
|
69
|
+
return drift;
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (argv[1] === fileURLToPath(import.meta.url)) {
|
|
76
|
+
const drift = scaffoldDrift();
|
|
77
|
+
if (drift.length) throw new Error(`VS Code scaffold drifted: ${drift.join(", ")}`);
|
|
78
|
+
process.stdout.write("VS Code scaffold matches generator-code.\n");
|
|
79
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* rest is derived and written in-tree, tracked by a lock file.
|
|
8
8
|
*/
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { NATIVE_PACKAGE_IDS, nativePackageConfigSchema } from "./distribution/native.js";
|
|
11
|
+
import { SYSTEM_PACKAGE_IDS, systemPackageConfigSchema } from "./distribution/system.js";
|
|
12
|
+
import { vscodeConfigSchema } from "./surfaces/vscode-extension.js";
|
|
10
13
|
|
|
11
14
|
export const CAPABILITIES = [
|
|
12
15
|
"net",
|
|
@@ -48,6 +51,9 @@ export const SURFACE_IDS = [
|
|
|
48
51
|
"gemini",
|
|
49
52
|
"mcpb",
|
|
50
53
|
"browser-extension",
|
|
54
|
+
"vscode-extension",
|
|
55
|
+
...NATIVE_PACKAGE_IDS,
|
|
56
|
+
...SYSTEM_PACKAGE_IDS,
|
|
51
57
|
] as const;
|
|
52
58
|
export type SurfaceId = (typeof SURFACE_IDS)[number];
|
|
53
59
|
|
|
@@ -187,6 +193,9 @@ export const ToolConfigSchema = z
|
|
|
187
193
|
"Privacy-policy URLs of the services this tool talks to; Anthropic's Connectors Directory submission requires them in the MCPB manifest.",
|
|
188
194
|
),
|
|
189
195
|
hermes: z.object({ toolset: z.string().optional() }).optional(),
|
|
196
|
+
vscode: vscodeConfigSchema.optional(),
|
|
197
|
+
nativePackages: z.array(nativePackageConfigSchema).optional(),
|
|
198
|
+
systemPackages: z.array(systemPackageConfigSchema).optional(),
|
|
190
199
|
openclaw: z
|
|
191
200
|
.object({
|
|
192
201
|
registers: z
|
|
@@ -314,10 +323,15 @@ export interface RegionFile {
|
|
|
314
323
|
export interface MergeFile {
|
|
315
324
|
kind: "merge";
|
|
316
325
|
path: string;
|
|
317
|
-
format: "json" | "toml";
|
|
326
|
+
format: "json" | "toml" | "yaml";
|
|
318
327
|
patch: Record<string, unknown>;
|
|
319
328
|
/** Dotted paths inside `patch` whose object toolfactory owns whole: replaced, never merged. */
|
|
320
329
|
owned?: string[];
|
|
330
|
+
/**
|
|
331
|
+
* Arrays whose entries are shared by a stable identifier. Toolfactory replaces only entries it
|
|
332
|
+
* previously wrote, preserves the author's other entries, and rejects an id collision.
|
|
333
|
+
*/
|
|
334
|
+
keyedArrays?: Record<string, string>;
|
|
321
335
|
}
|
|
322
336
|
|
|
323
337
|
export type PlannedFile = FullFile | RegionFile | MergeFile;
|
|
@@ -279,9 +279,129 @@ describe("apply / check — merge files", () => {
|
|
|
279
279
|
expect(text).toContain('dependencies = [ "zod" ]');
|
|
280
280
|
expect(check(root, plan, "0.1.0")).toEqual([]);
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
it("merges YAML and keeps authored keyed-array entries while removing stale generated ones", () => {
|
|
284
|
+
const root = tmp();
|
|
285
|
+
const plan = (commands: string[]): MergeFile[] => [
|
|
286
|
+
{
|
|
287
|
+
kind: "merge",
|
|
288
|
+
path: "extension.yaml",
|
|
289
|
+
format: "yaml",
|
|
290
|
+
patch: {
|
|
291
|
+
name: "hello",
|
|
292
|
+
commands: commands.map((command) => ({ command, title: command })),
|
|
293
|
+
},
|
|
294
|
+
keyedArrays: { commands: "command" },
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
apply(root, plan(["hello.run", "hello.old"]), "0.1.0");
|
|
298
|
+
writeFileSync(
|
|
299
|
+
join(root, "extension.yaml"),
|
|
300
|
+
`${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`,
|
|
301
|
+
);
|
|
302
|
+
// Recreate the document with a custom entry and the generated entries, as a hand-built host
|
|
303
|
+
// manifest would; the generated ids remain Toolfactory-owned from the lock.
|
|
304
|
+
writeFileSync(
|
|
305
|
+
join(root, "extension.yaml"),
|
|
306
|
+
"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",
|
|
307
|
+
);
|
|
308
|
+
apply(root, plan(["hello.run", "hello.new"]), "0.1.0");
|
|
309
|
+
const text = readFileSync(join(root, "extension.yaml"), "utf8");
|
|
310
|
+
expect(text).toContain("author.run");
|
|
311
|
+
expect(text).toContain("hello.new");
|
|
312
|
+
expect(text).not.toContain("hello.old");
|
|
313
|
+
expect(check(root, plan(["hello.run", "hello.new"]), "0.1.0")).toEqual([]);
|
|
314
|
+
expect(() => apply(root, plan(["author.run"]), "0.1.0")).toThrow(/already has author entry/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("preserves unknown keyed-entry fields while removing stale projected leaves", () => {
|
|
318
|
+
const root = tmp();
|
|
319
|
+
const plan = (title: string, category?: string): MergeFile[] => [
|
|
320
|
+
{
|
|
321
|
+
kind: "merge",
|
|
322
|
+
path: "package.json",
|
|
323
|
+
format: "json",
|
|
324
|
+
patch: {
|
|
325
|
+
contributes: {
|
|
326
|
+
commands: [{ command: "hello.run", title, ...(category ? { category } : {}) }],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
keyedArrays: { "contributes.commands": "command" },
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
apply(root, plan("Run", "Toolfactory"), "0.1.0");
|
|
333
|
+
const path = join(root, "package.json");
|
|
334
|
+
const document = JSON.parse(readFileSync(path, "utf8"));
|
|
335
|
+
document.contributes.commands[0].icon = "$(rocket)";
|
|
336
|
+
writeFileSync(path, JSON.stringify(document));
|
|
337
|
+
// Native command details remain authored: they are not drift merely because Toolfactory
|
|
338
|
+
// owns the command's id and projected leaves.
|
|
339
|
+
expect(check(root, plan("Run", "Toolfactory"), "0.1.0")).toEqual([]);
|
|
340
|
+
apply(root, plan("Run tool"), "0.1.0");
|
|
341
|
+
expect(JSON.parse(readFileSync(path, "utf8")).contributes.commands).toEqual([
|
|
342
|
+
{ command: "hello.run", title: "Run tool", icon: "$(rocket)" },
|
|
343
|
+
]);
|
|
344
|
+
expect(check(root, plan("Run tool"), "0.1.0")).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("upgrades an untouched generated full file to a region but protects changed files", () => {
|
|
348
|
+
const root = tmp();
|
|
349
|
+
const old: PlannedFile[] = [{ kind: "file", path: "entry.ts", content: "old generated\n" }];
|
|
350
|
+
const region: RegionFile = {
|
|
351
|
+
kind: "region",
|
|
352
|
+
path: "entry.ts",
|
|
353
|
+
regions: [{ begin: "// tf:entry", end: "// /tf:entry", content: "new generated\n" }],
|
|
354
|
+
template: "// tf:entry\n// /tf:entry\n\n// author tail\n",
|
|
355
|
+
};
|
|
356
|
+
apply(root, old, "0.1.0");
|
|
357
|
+
apply(root, [region], "0.2.0");
|
|
358
|
+
expect(readFileSync(join(root, "entry.ts"), "utf8")).toContain("new generated");
|
|
359
|
+
|
|
360
|
+
const changed = tmp();
|
|
361
|
+
apply(changed, old, "0.1.0");
|
|
362
|
+
writeFileSync(join(changed, "entry.ts"), "author changed\n");
|
|
363
|
+
expect(() => apply(changed, [region], "0.2.0")).toThrow(/missing a toolfactory region marker/);
|
|
364
|
+
});
|
|
282
365
|
});
|
|
283
366
|
|
|
284
367
|
describe("apply / check — inverses and outputs", () => {
|
|
368
|
+
it("restores deleted keyed rows, leaves steady YAML bytes alone, and uninstalls keys and rows", () => {
|
|
369
|
+
const root = tmp();
|
|
370
|
+
const plan: MergeFile[] = [
|
|
371
|
+
{
|
|
372
|
+
kind: "merge",
|
|
373
|
+
path: "extension.yaml",
|
|
374
|
+
format: "yaml",
|
|
375
|
+
patch: {
|
|
376
|
+
generated: true,
|
|
377
|
+
commands: [{ command: "hello.run", title: "Run" }],
|
|
378
|
+
},
|
|
379
|
+
keyedArrays: { commands: "command" },
|
|
380
|
+
},
|
|
381
|
+
];
|
|
382
|
+
apply(root, plan, "0.1.0");
|
|
383
|
+
writeFileSync(
|
|
384
|
+
join(root, "extension.yaml"),
|
|
385
|
+
"# author comment\nauthor: true\ngenerated: true\ncommands:\n - command: author.run\n title: Author\n",
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// The prior lock owns hello.run, so removing it by hand does not turn it into an omission.
|
|
389
|
+
apply(root, plan, "0.1.0");
|
|
390
|
+
expect(readFileSync(join(root, "extension.yaml"), "utf8")).toContain("hello.run");
|
|
391
|
+
|
|
392
|
+
// Once current, no rewrite means YAML comments and formatting survive a rebuild.
|
|
393
|
+
const path = join(root, "extension.yaml");
|
|
394
|
+
writeFileSync(path, `# preserved comment\n${readFileSync(path, "utf8")}`);
|
|
395
|
+
const before = readFileSync(path, "utf8");
|
|
396
|
+
expect(apply(root, plan, "0.1.0").unchanged).toContain("extension.yaml");
|
|
397
|
+
expect(readFileSync(path, "utf8")).toBe(before);
|
|
398
|
+
|
|
399
|
+
apply(root, [], "0.1.0");
|
|
400
|
+
expect(readFileSync(path, "utf8")).toContain("author.run");
|
|
401
|
+
expect(readFileSync(path, "utf8")).not.toContain("hello.run");
|
|
402
|
+
expect(readFileSync(path, "utf8")).not.toContain("generated:");
|
|
403
|
+
});
|
|
404
|
+
|
|
285
405
|
it("uninstalls the keys a patch stops writing, keeping every other key", () => {
|
|
286
406
|
const root = tmp();
|
|
287
407
|
mkdirSync(root, { recursive: true });
|
package/src/project/apply.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "node:fs";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
20
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
20
21
|
import type { MergeFile, PlannedFile, Region } from "../model.js";
|
|
21
22
|
import { LOCK_PATH, type Lock, type Markers, readLock, serializeLock, sha256 } from "./lock.js";
|
|
22
23
|
|
|
@@ -114,17 +115,211 @@ export function pickPatch(
|
|
|
114
115
|
return out;
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
function valueAt(document: Record<string, unknown>, path: string): unknown {
|
|
119
|
+
return path
|
|
120
|
+
.split(".")
|
|
121
|
+
.reduce<unknown>((value, part) => (isRecord(value) ? value[part] : undefined), document);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setAt(document: Record<string, unknown>, path: string, value: unknown): void {
|
|
125
|
+
const parts = path.split(".");
|
|
126
|
+
let target = document;
|
|
127
|
+
for (const part of parts.slice(0, -1)) {
|
|
128
|
+
const next = target[part];
|
|
129
|
+
if (isRecord(next)) target = next;
|
|
130
|
+
else {
|
|
131
|
+
const created: Record<string, unknown> = {};
|
|
132
|
+
target[part] = created;
|
|
133
|
+
target = created;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
target[parts.at(-1) as string] = value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function omitKeyedArrays(
|
|
140
|
+
patch: Record<string, unknown>,
|
|
141
|
+
keyedArrays: Record<string, string>,
|
|
142
|
+
prefix = "",
|
|
143
|
+
): Record<string, unknown> {
|
|
144
|
+
const out: Record<string, unknown> = {};
|
|
145
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
146
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
147
|
+
if (path in keyedArrays) continue;
|
|
148
|
+
out[key] = isRecord(value) ? omitKeyedArrays(value, keyedArrays, path) : value;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function keyedArrayEntries(value: unknown, id: string, path: string): Record<string, unknown>[] {
|
|
154
|
+
if (
|
|
155
|
+
!Array.isArray(value) ||
|
|
156
|
+
value.some((entry) => !isRecord(entry) || typeof entry[id] !== "string")
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(`${path} must be an array of objects with a string ${id} field.`);
|
|
159
|
+
}
|
|
160
|
+
return value as Record<string, unknown>[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function keyedArrayState(
|
|
164
|
+
file: MergeFile,
|
|
165
|
+
):
|
|
166
|
+
| Record<
|
|
167
|
+
string,
|
|
168
|
+
{ id: string; values: string[]; entries: Record<string, Record<string, unknown>> }
|
|
169
|
+
>
|
|
170
|
+
| undefined {
|
|
171
|
+
if (!file.keyedArrays || Object.keys(file.keyedArrays).length === 0) return undefined;
|
|
172
|
+
return Object.fromEntries(
|
|
173
|
+
Object.entries(file.keyedArrays).map(([path, id]) => [
|
|
174
|
+
path,
|
|
175
|
+
{
|
|
176
|
+
id,
|
|
177
|
+
values: keyedArrayEntries(valueAt(file.patch, path), id, path).map(
|
|
178
|
+
(entry) => entry[id] as string,
|
|
179
|
+
),
|
|
180
|
+
entries: Object.fromEntries(
|
|
181
|
+
keyedArrayEntries(valueAt(file.patch, path), id, path).map((entry) => [
|
|
182
|
+
entry[id] as string,
|
|
183
|
+
entry,
|
|
184
|
+
]),
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
]),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeProjectedLeaves(
|
|
192
|
+
entry: Record<string, unknown>,
|
|
193
|
+
previous: Record<string, unknown>,
|
|
194
|
+
next: Record<string, unknown>,
|
|
195
|
+
): Record<string, unknown> {
|
|
196
|
+
const out = { ...entry };
|
|
197
|
+
for (const [key, oldValue] of Object.entries(previous)) {
|
|
198
|
+
if (!(key in next)) delete out[key];
|
|
199
|
+
else if (isRecord(oldValue) && isRecord(next[key]) && isRecord(out[key]))
|
|
200
|
+
out[key] = removeProjectedLeaves(
|
|
201
|
+
out[key] as Record<string, unknown>,
|
|
202
|
+
oldValue,
|
|
203
|
+
next[key] as Record<string, unknown>,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mergeKeyedArrays(
|
|
210
|
+
document: Record<string, unknown>,
|
|
211
|
+
file: MergeFile,
|
|
212
|
+
previous: Lock,
|
|
213
|
+
): boolean {
|
|
214
|
+
let changed = false;
|
|
215
|
+
for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
|
|
216
|
+
const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
|
|
217
|
+
const previousIds = new Set(previous.files[file.path]?.keyedArrays?.[path]?.values ?? []);
|
|
218
|
+
const current = valueAt(document, path);
|
|
219
|
+
const entries = current === undefined ? [] : keyedArrayEntries(current, id, path);
|
|
220
|
+
const desiredById = new Map(desired.map((entry) => [entry[id] as string, entry]));
|
|
221
|
+
const retained: Record<string, unknown>[] = [];
|
|
222
|
+
const generated: Record<string, unknown>[] = [];
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const key = entry[id] as string;
|
|
225
|
+
const replacement = desiredById.get(key);
|
|
226
|
+
if (previousIds.has(key)) {
|
|
227
|
+
if (replacement) {
|
|
228
|
+
const previousEntry = previous.files[file.path]?.keyedArrays?.[path]?.entries?.[key];
|
|
229
|
+
generated.push(
|
|
230
|
+
deepMerge(
|
|
231
|
+
previousEntry ? removeProjectedLeaves(entry, previousEntry, replacement) : entry,
|
|
232
|
+
replacement,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Migration from a formerly whole generated file: an identical entry is ours already.
|
|
239
|
+
if (replacement && JSON.stringify(entry) === JSON.stringify(replacement)) {
|
|
240
|
+
generated.push(entry);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (replacement)
|
|
244
|
+
throw new Error(`${file.path} ${path} already has author entry ${id}=${key}.`);
|
|
245
|
+
retained.push(entry);
|
|
246
|
+
}
|
|
247
|
+
const next = [
|
|
248
|
+
...retained,
|
|
249
|
+
...generated,
|
|
250
|
+
// A generated row may have been deleted by hand. It remains owned, so put its desired
|
|
251
|
+
// replacement back even when the previous lock knows its id already.
|
|
252
|
+
...desired.filter((entry) => !generated.some((current) => current[id] === entry[id])),
|
|
253
|
+
];
|
|
254
|
+
if (JSON.stringify(entries) !== JSON.stringify(next)) changed = true;
|
|
255
|
+
setAt(document, path, next);
|
|
256
|
+
}
|
|
257
|
+
return changed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Select only generated leaves from an entry, leaving authored extension fields out of drift. */
|
|
261
|
+
function pickProjectedEntry(
|
|
262
|
+
current: Record<string, unknown> | undefined,
|
|
263
|
+
projected: Record<string, unknown>,
|
|
264
|
+
): Record<string, unknown> | undefined {
|
|
265
|
+
if (!current) return undefined;
|
|
266
|
+
const picked: Record<string, unknown> = {};
|
|
267
|
+
for (const [key, value] of Object.entries(projected)) {
|
|
268
|
+
const actual = current[key];
|
|
269
|
+
picked[key] = isRecord(value) && isRecord(actual) ? pickProjectedEntry(actual, value) : actual;
|
|
270
|
+
}
|
|
271
|
+
return picked;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function removeKeyedArrayEntries(
|
|
275
|
+
document: Record<string, unknown>,
|
|
276
|
+
keyedArrays: Record<string, { id: string; values: string[] }> | undefined,
|
|
277
|
+
): boolean {
|
|
278
|
+
let changed = false;
|
|
279
|
+
for (const [path, state] of Object.entries(keyedArrays ?? {})) {
|
|
280
|
+
const current = valueAt(document, path);
|
|
281
|
+
if (!Array.isArray(current)) continue;
|
|
282
|
+
const next = current.filter(
|
|
283
|
+
(entry) => !isRecord(entry) || !state.values.includes(entry[state.id] as string),
|
|
284
|
+
);
|
|
285
|
+
if (next.length !== current.length) {
|
|
286
|
+
setAt(document, path, next);
|
|
287
|
+
changed = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return changed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function pickMerge(document: Record<string, unknown>, file: MergeFile): Record<string, unknown> {
|
|
294
|
+
const picked = pickPatch(document, file.patch, file.owned);
|
|
295
|
+
for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
|
|
296
|
+
const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
|
|
297
|
+
const current = Array.isArray(valueAt(document, path))
|
|
298
|
+
? (valueAt(document, path) as unknown[])
|
|
299
|
+
: [];
|
|
300
|
+
const byId = new Map(current.filter(isRecord).map((entry) => [entry[id] as string, entry]));
|
|
301
|
+
setAt(
|
|
302
|
+
picked,
|
|
303
|
+
path,
|
|
304
|
+
desired.map((entry) => pickProjectedEntry(byId.get(entry[id] as string), entry)),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return picked;
|
|
308
|
+
}
|
|
309
|
+
|
|
117
310
|
/** The dotted paths a patch writes: one per leaf value, and one per object it owns whole. */
|
|
118
311
|
export function patchKeys(
|
|
119
312
|
patch: Record<string, unknown>,
|
|
120
313
|
owned: string[] = [],
|
|
314
|
+
keyedArrays: Record<string, string> = {},
|
|
121
315
|
prefix = "",
|
|
122
316
|
): string[] {
|
|
123
317
|
const keys: string[] = [];
|
|
124
318
|
for (const [key, value] of Object.entries(patch)) {
|
|
125
319
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
320
|
+
if (path in keyedArrays) continue;
|
|
126
321
|
if (isRecord(value) && !owned.includes(path) && Object.keys(value).length > 0) {
|
|
127
|
-
keys.push(...patchKeys(value, owned, path));
|
|
322
|
+
keys.push(...patchKeys(value, owned, keyedArrays, path));
|
|
128
323
|
} else {
|
|
129
324
|
keys.push(path);
|
|
130
325
|
}
|
|
@@ -167,13 +362,15 @@ export function removeKeys(document: Record<string, unknown>, keys: readonly str
|
|
|
167
362
|
}
|
|
168
363
|
|
|
169
364
|
function parseDocument(text: string, format: MergeFile["format"]): Record<string, unknown> {
|
|
170
|
-
return (
|
|
365
|
+
return (
|
|
366
|
+
format === "toml" ? parseToml(text) : format === "yaml" ? parseYaml(text) : JSON.parse(text)
|
|
367
|
+
) as Record<string, unknown>;
|
|
171
368
|
}
|
|
172
369
|
|
|
173
370
|
function serializeDocument(document: Record<string, unknown>, format: MergeFile["format"]): string {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
371
|
+
if (format === "toml") return `${stringifyToml(document)}\n`;
|
|
372
|
+
if (format === "yaml") return stringifyYaml(document);
|
|
373
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
177
374
|
}
|
|
178
375
|
|
|
179
376
|
/** The content toolfactory manages, as one string for hashing and comparison. */
|
|
@@ -199,12 +396,16 @@ function currentManagedContent(root: string, file: PlannedFile): string | undefi
|
|
|
199
396
|
const text = readFileSync(path, "utf8");
|
|
200
397
|
if (file.kind === "file") return text;
|
|
201
398
|
if (file.kind === "region") return extractRegions(text, file)?.join(" ");
|
|
202
|
-
return JSON.stringify(
|
|
399
|
+
return JSON.stringify(pickMerge(parseDocument(text, file.format), file));
|
|
203
400
|
}
|
|
204
401
|
|
|
205
402
|
/** The two structured formats merge files use; the extension is the only discriminator needed. */
|
|
206
403
|
function documentFormat(path: string): MergeFile["format"] {
|
|
207
|
-
return path.endsWith(".toml")
|
|
404
|
+
return path.endsWith(".toml")
|
|
405
|
+
? "toml"
|
|
406
|
+
: path.endsWith(".yaml") || path.endsWith(".yml")
|
|
407
|
+
? "yaml"
|
|
408
|
+
: "json";
|
|
208
409
|
}
|
|
209
410
|
|
|
210
411
|
/** The keys the lock recorded for a path that the current patch no longer writes. */
|
|
@@ -219,6 +420,22 @@ function stranded(root: string, path: string, keys: string[]): boolean {
|
|
|
219
420
|
return keys.some((key) => hasKey(document, key));
|
|
220
421
|
}
|
|
221
422
|
|
|
423
|
+
function keyedEntriesRemain(
|
|
424
|
+
root: string,
|
|
425
|
+
path: string,
|
|
426
|
+
keyedArrays: Record<string, { id: string; values: string[] }> | undefined,
|
|
427
|
+
): boolean {
|
|
428
|
+
if (!keyedArrays || !existsSync(join(root, path))) return false;
|
|
429
|
+
const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
|
|
430
|
+
return Object.entries(keyedArrays).some(([arrayPath, state]) => {
|
|
431
|
+
const current = valueAt(document, arrayPath);
|
|
432
|
+
return (
|
|
433
|
+
Array.isArray(current) &&
|
|
434
|
+
current.some((entry) => isRecord(entry) && state.values.includes(entry[state.id] as string))
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
222
439
|
/** The marker pairs the lock recorded for a path that the current plan no longer writes. */
|
|
223
440
|
function staleRegions(lock: Lock, path: string, current: readonly Markers[] = []): Markers[] {
|
|
224
441
|
return (lock.files[path]?.regions ?? []).filter(
|
|
@@ -271,7 +488,12 @@ export function check(root: string, plan: PlannedFile[], toolfactoryVersion: str
|
|
|
271
488
|
const entry = lock.files[path];
|
|
272
489
|
if (planned.has(path) || entry?.state !== "generated" || !present(root, path)) continue;
|
|
273
490
|
// A merge file toolfactory stops writing loses its keys, not its existence.
|
|
274
|
-
if (
|
|
491
|
+
if (
|
|
492
|
+
(entry.keys || entry.keyedArrays) &&
|
|
493
|
+
!stranded(root, path, entry.keys ?? []) &&
|
|
494
|
+
!keyedEntriesRemain(root, path, entry.keyedArrays)
|
|
495
|
+
)
|
|
496
|
+
continue;
|
|
275
497
|
// A region file likewise loses its regions: once they are empty, nothing of ours is stranded.
|
|
276
498
|
if (entry.regions && !filled(root, path, entry.regions)) continue;
|
|
277
499
|
drift.push({ path, kind: "orphan" });
|
|
@@ -290,6 +512,12 @@ function render(root: string, file: PlannedFile, previous: Lock): string {
|
|
|
290
512
|
const stale = staleRegions(previous, file.path, file.regions);
|
|
291
513
|
const replaced = replaceRegions(emptyRegions(text, stale) ?? text, file);
|
|
292
514
|
if (replaced === undefined) {
|
|
515
|
+
const prior = previous.files[file.path];
|
|
516
|
+
// A generated full file can become a region file without forcing an author to recreate
|
|
517
|
+
// markers. Its lock hash proves every byte is still the old generator's, so replacing it
|
|
518
|
+
// cannot discard authored work. Manual or changed files still require `adopt`.
|
|
519
|
+
if (prior?.state === "generated" && sha256(text) === prior.sha256)
|
|
520
|
+
return replaceRegions(file.template, file) ?? file.template;
|
|
293
521
|
throw new Error(
|
|
294
522
|
`${file.path} exists but is missing a toolfactory region marker; restore the markers or run \`toolfactory adopt ${file.path}\`.`,
|
|
295
523
|
);
|
|
@@ -298,23 +526,32 @@ function render(root: string, file: PlannedFile, previous: Lock): string {
|
|
|
298
526
|
}
|
|
299
527
|
return replaceRegions(file.template, file) ?? file.template;
|
|
300
528
|
}
|
|
301
|
-
if (!existsSync(path))
|
|
529
|
+
if (!existsSync(path)) {
|
|
530
|
+
const document = deepMerge({}, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned);
|
|
531
|
+
mergeKeyedArrays(document, file, previous);
|
|
532
|
+
return serializeDocument(document, file.format);
|
|
533
|
+
}
|
|
302
534
|
const text = readFileSync(path, "utf8");
|
|
303
535
|
const document = parseDocument(text, file.format);
|
|
304
536
|
// The patch's inverse first: keys a previous patch wrote and this one dropped are uninstalled.
|
|
305
537
|
const uninstalled = removeKeys(
|
|
306
538
|
document,
|
|
307
|
-
staleKeys(previous, file.path, patchKeys(file.patch, file.owned)),
|
|
539
|
+
staleKeys(previous, file.path, patchKeys(file.patch, file.owned, file.keyedArrays)),
|
|
308
540
|
);
|
|
541
|
+
const arraysChanged = mergeKeyedArrays(document, file, previous);
|
|
309
542
|
// A document that already carries the patch is left byte-for-byte alone, so a rebuild never
|
|
310
543
|
// reserializes the author's file (and, for TOML, never drops their comments).
|
|
311
544
|
if (
|
|
312
545
|
!uninstalled &&
|
|
313
|
-
|
|
546
|
+
!arraysChanged &&
|
|
547
|
+
JSON.stringify(pickMerge(document, file)) === JSON.stringify(file.patch)
|
|
314
548
|
) {
|
|
315
549
|
return text;
|
|
316
550
|
}
|
|
317
|
-
return serializeDocument(
|
|
551
|
+
return serializeDocument(
|
|
552
|
+
deepMerge(document, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned),
|
|
553
|
+
file.format,
|
|
554
|
+
);
|
|
318
555
|
}
|
|
319
556
|
|
|
320
557
|
/** Write a plan to the tree and refresh the lock. */
|
|
@@ -336,7 +573,9 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
|
|
|
336
573
|
result.manual.push(file.path);
|
|
337
574
|
continue;
|
|
338
575
|
}
|
|
339
|
-
const keys =
|
|
576
|
+
const keys =
|
|
577
|
+
file.kind === "merge" ? patchKeys(file.patch, file.owned, file.keyedArrays) : undefined;
|
|
578
|
+
const keyedArrays = file.kind === "merge" ? keyedArrayState(file) : undefined;
|
|
340
579
|
const regions =
|
|
341
580
|
file.kind === "region" ? file.regions.map(({ begin, end }) => ({ begin, end })) : undefined;
|
|
342
581
|
if (isLink(file)) {
|
|
@@ -363,6 +602,7 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
|
|
|
363
602
|
sha256: sha256(managedContent(file)),
|
|
364
603
|
state: "generated",
|
|
365
604
|
keys,
|
|
605
|
+
keyedArrays,
|
|
366
606
|
regions,
|
|
367
607
|
};
|
|
368
608
|
}
|
|
@@ -373,11 +613,13 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
|
|
|
373
613
|
continue;
|
|
374
614
|
}
|
|
375
615
|
if (!present(root, path)) continue;
|
|
376
|
-
if (entry.keys) {
|
|
616
|
+
if (entry.keys || entry.keyedArrays) {
|
|
377
617
|
// The inverse of a merge file is its keys: the author keeps the file and everything else in it.
|
|
378
618
|
const format = documentFormat(path);
|
|
379
619
|
const document = parseDocument(readFileSync(join(root, path), "utf8"), format);
|
|
380
|
-
|
|
620
|
+
const removedKeys = removeKeys(document, entry.keys ?? []);
|
|
621
|
+
const removedEntries = removeKeyedArrayEntries(document, entry.keyedArrays);
|
|
622
|
+
if (removedKeys || removedEntries) {
|
|
381
623
|
writeFileSync(join(root, path), serializeDocument(document, format));
|
|
382
624
|
result.written.push(path);
|
|
383
625
|
}
|