toolfactory 0.1.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 +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one writer and the one differ. A plan is a list of files; applying it writes
|
|
3
|
+
* generated files, keeps `manual` (adopted) files untouched, refreshes managed regions
|
|
4
|
+
* inside author-owned files, merges owned keys into shared structured files, deletes
|
|
5
|
+
* orphans, and records SHAs in the lock file. Every kind carries its own inverse: a region file
|
|
6
|
+
* its markers, a full file its path, a merge file the key paths recorded in the lock.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readlinkSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
20
|
+
import type { MergeFile, PlannedFile, Region } from "../model.js";
|
|
21
|
+
import { LOCK_PATH, type Lock, type Markers, readLock, serializeLock, sha256 } from "./lock.js";
|
|
22
|
+
|
|
23
|
+
export interface Drift {
|
|
24
|
+
path: string;
|
|
25
|
+
kind: "missing" | "changed" | "orphan" | "unmarked";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ApplyResult {
|
|
29
|
+
written: string[];
|
|
30
|
+
deleted: string[];
|
|
31
|
+
unchanged: string[];
|
|
32
|
+
manual: string[];
|
|
33
|
+
/** Region files a deselected surface stopped writing: emptied of their regions and kept, because everything outside the markers is the author's. */
|
|
34
|
+
stripped: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function locate(text: string, region: Markers): { start: number; end: number } | undefined {
|
|
38
|
+
const start = text.indexOf(region.begin);
|
|
39
|
+
if (start < 0) return undefined;
|
|
40
|
+
const end = text.indexOf(region.end, start + region.begin.length);
|
|
41
|
+
if (end < 0) return undefined;
|
|
42
|
+
return { start: start + region.begin.length, end };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractRegions(
|
|
46
|
+
text: string,
|
|
47
|
+
file: { regions: readonly Markers[] },
|
|
48
|
+
): string[] | undefined {
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
for (const region of file.regions) {
|
|
51
|
+
const at = locate(text, region);
|
|
52
|
+
if (!at) return undefined;
|
|
53
|
+
parts.push(text.slice(at.start, at.end));
|
|
54
|
+
}
|
|
55
|
+
return parts;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function replaceRegions(
|
|
59
|
+
text: string,
|
|
60
|
+
file: { regions: readonly Region[] },
|
|
61
|
+
): string | undefined {
|
|
62
|
+
let next = text;
|
|
63
|
+
for (const region of file.regions) {
|
|
64
|
+
const at = locate(next, region);
|
|
65
|
+
if (!at) return undefined;
|
|
66
|
+
next = next.slice(0, at.start) + region.content + next.slice(at.end);
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A region file's inverse: the named regions blanked, their markers and the author's bytes kept. */
|
|
72
|
+
function emptyRegions(text: string, markers: readonly Markers[]): string | undefined {
|
|
73
|
+
return replaceRegions(text, { regions: markers.map((region) => ({ ...region, content: "" })) });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Objects merge key by key, except the paths a merge file owns whole, which are replaced. */
|
|
81
|
+
export function deepMerge(
|
|
82
|
+
base: Record<string, unknown>,
|
|
83
|
+
patch: Record<string, unknown>,
|
|
84
|
+
owned: string[] = [],
|
|
85
|
+
prefix = "",
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const out: Record<string, unknown> = { ...base };
|
|
88
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
89
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
90
|
+
out[key] =
|
|
91
|
+
isRecord(value) && isRecord(out[key]) && !owned.includes(path)
|
|
92
|
+
? deepMerge(out[key] as Record<string, unknown>, value, owned, path)
|
|
93
|
+
: value;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The values a merge file owns, read back out of a document; an owned object comes back whole. */
|
|
99
|
+
export function pickPatch(
|
|
100
|
+
document: Record<string, unknown>,
|
|
101
|
+
patch: Record<string, unknown>,
|
|
102
|
+
owned: string[] = [],
|
|
103
|
+
prefix = "",
|
|
104
|
+
): Record<string, unknown> {
|
|
105
|
+
const out: Record<string, unknown> = {};
|
|
106
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
107
|
+
const current = document[key];
|
|
108
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
109
|
+
out[key] =
|
|
110
|
+
isRecord(value) && isRecord(current) && !owned.includes(path)
|
|
111
|
+
? pickPatch(current, value, owned, path)
|
|
112
|
+
: current;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The dotted paths a patch writes: one per leaf value, and one per object it owns whole. */
|
|
118
|
+
export function patchKeys(
|
|
119
|
+
patch: Record<string, unknown>,
|
|
120
|
+
owned: string[] = [],
|
|
121
|
+
prefix = "",
|
|
122
|
+
): string[] {
|
|
123
|
+
const keys: string[] = [];
|
|
124
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
125
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
126
|
+
if (isRecord(value) && !owned.includes(path) && Object.keys(value).length > 0) {
|
|
127
|
+
keys.push(...patchKeys(value, owned, path));
|
|
128
|
+
} else {
|
|
129
|
+
keys.push(path);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return keys;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function walk(document: Record<string, unknown>, key: string): Record<string, unknown>[] {
|
|
136
|
+
const chain: Record<string, unknown>[] = [document];
|
|
137
|
+
let node: unknown = document;
|
|
138
|
+
for (const part of key.split(".").slice(0, -1)) {
|
|
139
|
+
node = (node as Record<string, unknown>)[part];
|
|
140
|
+
if (!isRecord(node)) return chain;
|
|
141
|
+
chain.push(node);
|
|
142
|
+
}
|
|
143
|
+
return chain;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasKey(document: Record<string, unknown>, key: string): boolean {
|
|
147
|
+
const parts = key.split(".");
|
|
148
|
+
const chain = walk(document, key);
|
|
149
|
+
return chain.length === parts.length && (parts.at(-1) as string) in (chain.at(-1) as object);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Uninstall dotted paths from a document, pruning the objects the removal empties. */
|
|
153
|
+
export function removeKeys(document: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
154
|
+
let removed = false;
|
|
155
|
+
for (const key of keys) {
|
|
156
|
+
if (!hasKey(document, key)) continue;
|
|
157
|
+
const parts = key.split(".");
|
|
158
|
+
const chain = walk(document, key);
|
|
159
|
+
delete (chain.at(-1) as Record<string, unknown>)[parts.at(-1) as string];
|
|
160
|
+
removed = true;
|
|
161
|
+
for (let depth = chain.length - 1; depth > 0; depth--) {
|
|
162
|
+
if (Object.keys(chain[depth] as object).length > 0) break;
|
|
163
|
+
delete (chain[depth - 1] as Record<string, unknown>)[parts[depth - 1] as string];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return removed;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseDocument(text: string, format: MergeFile["format"]): Record<string, unknown> {
|
|
170
|
+
return (format === "toml" ? parseToml(text) : JSON.parse(text)) as Record<string, unknown>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function serializeDocument(document: Record<string, unknown>, format: MergeFile["format"]): string {
|
|
174
|
+
return format === "toml"
|
|
175
|
+
? `${stringifyToml(document)}\n`
|
|
176
|
+
: `${JSON.stringify(document, null, 2)}\n`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The content toolfactory manages, as one string for hashing and comparison. */
|
|
180
|
+
export function managedContent(file: PlannedFile): string {
|
|
181
|
+
if (file.kind === "file") return file.content;
|
|
182
|
+
if (file.kind === "region") return file.regions.map((r) => r.content).join(" ");
|
|
183
|
+
return JSON.stringify(file.patch);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A symbolic link's managed content is its target; every other kind's is what it holds. */
|
|
187
|
+
function isLink(file: PlannedFile): boolean {
|
|
188
|
+
return file.kind === "file" && file.symlink === true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function currentManagedContent(root: string, file: PlannedFile): string | undefined {
|
|
192
|
+
const path = join(root, file.path);
|
|
193
|
+
if (isLink(file)) {
|
|
194
|
+
const entry = lstatSync(path, { throwIfNoEntry: false });
|
|
195
|
+
// A regular file or directory standing where the link belongs matches no target: it is drift.
|
|
196
|
+
return entry && (entry.isSymbolicLink() ? readlinkSync(path) : "");
|
|
197
|
+
}
|
|
198
|
+
if (!existsSync(path)) return undefined;
|
|
199
|
+
const text = readFileSync(path, "utf8");
|
|
200
|
+
if (file.kind === "file") return text;
|
|
201
|
+
if (file.kind === "region") return extractRegions(text, file)?.join(" ");
|
|
202
|
+
return JSON.stringify(pickPatch(parseDocument(text, file.format), file.patch, file.owned));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The two structured formats merge files use; the extension is the only discriminator needed. */
|
|
206
|
+
function documentFormat(path: string): MergeFile["format"] {
|
|
207
|
+
return path.endsWith(".toml") ? "toml" : "json";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The keys the lock recorded for a path that the current patch no longer writes. */
|
|
211
|
+
function staleKeys(lock: Lock, path: string, current: string[] = []): string[] {
|
|
212
|
+
return (lock.files[path]?.keys ?? []).filter((key) => !current.includes(key));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether any uninstalled key is still in the document — the only reason to touch a merge file. */
|
|
216
|
+
function stranded(root: string, path: string, keys: string[]): boolean {
|
|
217
|
+
if (keys.length === 0 || !existsSync(join(root, path))) return false;
|
|
218
|
+
const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
|
|
219
|
+
return keys.some((key) => hasKey(document, key));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The marker pairs the lock recorded for a path that the current plan no longer writes. */
|
|
223
|
+
function staleRegions(lock: Lock, path: string, current: readonly Markers[] = []): Markers[] {
|
|
224
|
+
return (lock.files[path]?.regions ?? []).filter(
|
|
225
|
+
(region) => !current.some((planned) => planned.begin === region.begin),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Whether any uninstalled region still carries content — the only reason to touch a region file. */
|
|
230
|
+
function filled(root: string, path: string, markers: readonly Markers[]): boolean {
|
|
231
|
+
if (markers.length === 0 || !existsSync(join(root, path))) return false;
|
|
232
|
+
const parts = extractRegions(readFileSync(join(root, path), "utf8"), { regions: markers });
|
|
233
|
+
return parts?.some((part) => part.trim() !== "") === true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** lstat, not stat: a dangling symlink (its target just deleted) is still present and still ours. */
|
|
237
|
+
function present(root: string, path: string): boolean {
|
|
238
|
+
return lstatSync(join(root, path), { throwIfNoEntry: false }) !== undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Compare a plan to the tree without writing. */
|
|
242
|
+
export function check(root: string, plan: PlannedFile[], toolfactoryVersion: string): Drift[] {
|
|
243
|
+
const lock = readLock(root) ?? { toolfactoryVersion, files: {} };
|
|
244
|
+
const drift: Drift[] = [];
|
|
245
|
+
const planned = new Set(plan.map((file) => file.path));
|
|
246
|
+
for (const file of plan) {
|
|
247
|
+
if (lock.files[file.path]?.state === "manual") continue;
|
|
248
|
+
const current = currentManagedContent(root, file);
|
|
249
|
+
if (current === undefined) {
|
|
250
|
+
// An output file is rebuilt, not tracked: absent is not drift, only stale is.
|
|
251
|
+
if (file.kind === "file" && file.output) continue;
|
|
252
|
+
drift.push({
|
|
253
|
+
path: file.path,
|
|
254
|
+
kind: existsSync(join(root, file.path)) ? "unmarked" : "missing",
|
|
255
|
+
});
|
|
256
|
+
} else if (current !== managedContent(file)) {
|
|
257
|
+
drift.push({ path: file.path, kind: "changed" });
|
|
258
|
+
} else if (
|
|
259
|
+
file.kind === "merge" &&
|
|
260
|
+
stranded(root, file.path, staleKeys(lock, file.path, patchKeys(file.patch, file.owned)))
|
|
261
|
+
) {
|
|
262
|
+
drift.push({ path: file.path, kind: "changed" });
|
|
263
|
+
} else if (
|
|
264
|
+
file.kind === "region" &&
|
|
265
|
+
filled(root, file.path, staleRegions(lock, file.path, file.regions))
|
|
266
|
+
) {
|
|
267
|
+
drift.push({ path: file.path, kind: "changed" });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const path of Object.keys(lock.files)) {
|
|
271
|
+
const entry = lock.files[path];
|
|
272
|
+
if (planned.has(path) || entry?.state !== "generated" || !present(root, path)) continue;
|
|
273
|
+
// A merge file toolfactory stops writing loses its keys, not its existence.
|
|
274
|
+
if (entry.keys && !stranded(root, path, entry.keys)) continue;
|
|
275
|
+
// A region file likewise loses its regions: once they are empty, nothing of ours is stranded.
|
|
276
|
+
if (entry.regions && !filled(root, path, entry.regions)) continue;
|
|
277
|
+
drift.push({ path, kind: "orphan" });
|
|
278
|
+
}
|
|
279
|
+
return drift;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function render(root: string, file: PlannedFile, previous: Lock): string {
|
|
283
|
+
const path = join(root, file.path);
|
|
284
|
+
if (file.kind === "file") return file.content;
|
|
285
|
+
if (file.kind === "region") {
|
|
286
|
+
if (existsSync(path)) {
|
|
287
|
+
const text = readFileSync(path, "utf8");
|
|
288
|
+
// The region inverse first: a marker pair a previous plan wrote and this one dropped — a
|
|
289
|
+
// shared file that lost one of its owners — is emptied before the current regions are filled.
|
|
290
|
+
const stale = staleRegions(previous, file.path, file.regions);
|
|
291
|
+
const replaced = replaceRegions(emptyRegions(text, stale) ?? text, file);
|
|
292
|
+
if (replaced === undefined) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`${file.path} exists but is missing a toolfactory region marker; restore the markers or run \`toolfactory adopt ${file.path}\`.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return replaced;
|
|
298
|
+
}
|
|
299
|
+
return replaceRegions(file.template, file) ?? file.template;
|
|
300
|
+
}
|
|
301
|
+
if (!existsSync(path)) return serializeDocument(deepMerge({}, file.patch), file.format);
|
|
302
|
+
const text = readFileSync(path, "utf8");
|
|
303
|
+
const document = parseDocument(text, file.format);
|
|
304
|
+
// The patch's inverse first: keys a previous patch wrote and this one dropped are uninstalled.
|
|
305
|
+
const uninstalled = removeKeys(
|
|
306
|
+
document,
|
|
307
|
+
staleKeys(previous, file.path, patchKeys(file.patch, file.owned)),
|
|
308
|
+
);
|
|
309
|
+
// A document that already carries the patch is left byte-for-byte alone, so a rebuild never
|
|
310
|
+
// reserializes the author's file (and, for TOML, never drops their comments).
|
|
311
|
+
if (
|
|
312
|
+
!uninstalled &&
|
|
313
|
+
JSON.stringify(pickPatch(document, file.patch, file.owned)) === JSON.stringify(file.patch)
|
|
314
|
+
) {
|
|
315
|
+
return text;
|
|
316
|
+
}
|
|
317
|
+
return serializeDocument(deepMerge(document, file.patch, file.owned), file.format);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Write a plan to the tree and refresh the lock. */
|
|
321
|
+
export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: string): ApplyResult {
|
|
322
|
+
const previous = readLock(root) ?? { toolfactoryVersion, files: {} };
|
|
323
|
+
const lock: Lock = { toolfactoryVersion, files: {} };
|
|
324
|
+
const result: ApplyResult = {
|
|
325
|
+
written: [],
|
|
326
|
+
deleted: [],
|
|
327
|
+
unchanged: [],
|
|
328
|
+
manual: [],
|
|
329
|
+
stripped: [],
|
|
330
|
+
};
|
|
331
|
+
for (const file of plan) {
|
|
332
|
+
const state = previous.files[file.path]?.state ?? "generated";
|
|
333
|
+
const path = join(root, file.path);
|
|
334
|
+
if (state === "manual") {
|
|
335
|
+
lock.files[file.path] = { sha256: previous.files[file.path]?.sha256 ?? "", state };
|
|
336
|
+
result.manual.push(file.path);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const keys = file.kind === "merge" ? patchKeys(file.patch, file.owned) : undefined;
|
|
340
|
+
const regions =
|
|
341
|
+
file.kind === "region" ? file.regions.map(({ begin, end }) => ({ begin, end })) : undefined;
|
|
342
|
+
if (isLink(file)) {
|
|
343
|
+
if (currentManagedContent(root, file) === managedContent(file)) {
|
|
344
|
+
result.unchanged.push(file.path);
|
|
345
|
+
} else {
|
|
346
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
347
|
+
// Whatever stands there — a stale link, or a real file or directory — makes way for the link.
|
|
348
|
+
rmSync(path, { recursive: true, force: true });
|
|
349
|
+
symlinkSync(managedContent(file), path);
|
|
350
|
+
result.written.push(file.path);
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const next = render(root, file, previous);
|
|
354
|
+
if (existsSync(path) && readFileSync(path, "utf8") === next) {
|
|
355
|
+
result.unchanged.push(file.path);
|
|
356
|
+
} else {
|
|
357
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
358
|
+
writeFileSync(path, next, { mode: file.kind === "file" ? file.mode : undefined });
|
|
359
|
+
result.written.push(file.path);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
lock.files[file.path] = {
|
|
363
|
+
sha256: sha256(managedContent(file)),
|
|
364
|
+
state: "generated",
|
|
365
|
+
keys,
|
|
366
|
+
regions,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
for (const [path, entry] of Object.entries(previous.files)) {
|
|
370
|
+
if (lock.files[path]) continue;
|
|
371
|
+
if (entry.state === "manual") {
|
|
372
|
+
lock.files[path] = entry;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (!present(root, path)) continue;
|
|
376
|
+
if (entry.keys) {
|
|
377
|
+
// The inverse of a merge file is its keys: the author keeps the file and everything else in it.
|
|
378
|
+
const format = documentFormat(path);
|
|
379
|
+
const document = parseDocument(readFileSync(join(root, path), "utf8"), format);
|
|
380
|
+
if (removeKeys(document, entry.keys)) {
|
|
381
|
+
writeFileSync(join(root, path), serializeDocument(document, format));
|
|
382
|
+
result.written.push(path);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (entry.regions) {
|
|
387
|
+
// The inverse of a region file is its markers: the author keeps the file, the markers, and
|
|
388
|
+
// every byte outside them, so re-selecting the surface refills them with no further code.
|
|
389
|
+
const text = readFileSync(join(root, path), "utf8");
|
|
390
|
+
const emptied = emptyRegions(text, entry.regions);
|
|
391
|
+
// Markers already gone: nothing of ours is left to uninstall.
|
|
392
|
+
if (emptied === undefined || emptied === text) continue;
|
|
393
|
+
writeFileSync(join(root, path), emptied);
|
|
394
|
+
result.stripped.push(path);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
rmSync(join(root, path));
|
|
398
|
+
result.deleted.push(path);
|
|
399
|
+
}
|
|
400
|
+
const lockPath = join(root, LOCK_PATH);
|
|
401
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
402
|
+
writeFileSync(lockPath, serializeLock(lock));
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function setState(
|
|
407
|
+
root: string,
|
|
408
|
+
path: string,
|
|
409
|
+
state: "generated" | "manual",
|
|
410
|
+
toolfactoryVersion: string,
|
|
411
|
+
): void {
|
|
412
|
+
const lock = readLock(root) ?? { toolfactoryVersion, files: {} };
|
|
413
|
+
const entry = lock.files[path];
|
|
414
|
+
if (!entry) throw new Error(`${path} is not a toolfactory-managed file.`);
|
|
415
|
+
lock.files[path] = { ...entry, state };
|
|
416
|
+
writeFileSync(join(root, LOCK_PATH), serializeLock(lock));
|
|
417
|
+
}
|