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
package/src/ops.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toolfactory's own operations. toolfactory is a tool built with toolfactory: this file is
|
|
3
|
+
* the author-owned operation module, and `src/toolfactory/{cli,mcp}.ts` are generated from
|
|
4
|
+
* it by `toolfactory build`, exactly as for any other tool.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import * as commands from "./commands.js";
|
|
8
|
+
import { BINDINGS, SURFACE_IDS } from "./model.js";
|
|
9
|
+
import { operation } from "./toolfactory/types.js";
|
|
10
|
+
|
|
11
|
+
const root = z
|
|
12
|
+
.string()
|
|
13
|
+
.default(".")
|
|
14
|
+
.describe("Project root (directory containing dev.toolfactory/)");
|
|
15
|
+
const surfaceId = z.enum(SURFACE_IDS);
|
|
16
|
+
|
|
17
|
+
export const operations = [
|
|
18
|
+
operation({
|
|
19
|
+
name: "init",
|
|
20
|
+
description:
|
|
21
|
+
"Create a new tool: dev.toolfactory/tool.json, the authored identity file, the kernel scaffold for the chosen language, and the first build of every selected surface.",
|
|
22
|
+
input: z.object({
|
|
23
|
+
root,
|
|
24
|
+
name: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("Tool name: lowercase letters, digits, hyphens, dots (Agent Plugins rule)"),
|
|
27
|
+
binding: z.enum(BINDINGS).describe("Language of the core logic: typescript or python"),
|
|
28
|
+
runtime: z
|
|
29
|
+
.enum(["kernel", "none"])
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"`none` creates an instruction-only skill/Codex plugin with no executable payload",
|
|
33
|
+
),
|
|
34
|
+
surfaces: z
|
|
35
|
+
.array(surfaceId)
|
|
36
|
+
.min(1)
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(
|
|
39
|
+
"Surfaces to generate. Default: the skills-first minimum — skill, agent-plugins, mcp, cli and the binding's package registry; every host-specific plugin is opt-in.",
|
|
40
|
+
),
|
|
41
|
+
description: z.string().optional().describe("One-line description"),
|
|
42
|
+
license: z.string().optional().describe("SPDX license identifier"),
|
|
43
|
+
repository: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Source repository URL (GitHub URL enables the MCP Registry name)"),
|
|
47
|
+
author: z.string().optional().describe("Author name"),
|
|
48
|
+
keywords: z
|
|
49
|
+
.array(z.string())
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(
|
|
52
|
+
"Activation triggers for hosts that key off them (Kiro Powers, Agent Plugins); defaults to [name]",
|
|
53
|
+
),
|
|
54
|
+
git: z
|
|
55
|
+
.boolean()
|
|
56
|
+
.default(true)
|
|
57
|
+
.describe("git init when the directory is not a repository yet, and make the first commit"),
|
|
58
|
+
setup: z
|
|
59
|
+
.boolean()
|
|
60
|
+
.default(true)
|
|
61
|
+
.describe(
|
|
62
|
+
"Run .agents/setup: render the harness adapters, install the git hooks, install dependencies",
|
|
63
|
+
),
|
|
64
|
+
repo: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("owner/name of a GitHub repository to create with gh and push to"),
|
|
68
|
+
public: z.boolean().default(false).describe("Create that repository public, not private"),
|
|
69
|
+
dryRun: z
|
|
70
|
+
.boolean()
|
|
71
|
+
.default(false)
|
|
72
|
+
.describe("Print the gh invocations instead of running them"),
|
|
73
|
+
reviewers: z
|
|
74
|
+
.array(z.string())
|
|
75
|
+
.default([])
|
|
76
|
+
.describe(
|
|
77
|
+
"GitHub logins that must approve a live run, when the repository gets a live tier",
|
|
78
|
+
),
|
|
79
|
+
}),
|
|
80
|
+
output: z.object({
|
|
81
|
+
written: z.array(z.string()),
|
|
82
|
+
agentConfig: z.object({ setup: z.boolean(), harnesses: z.array(z.string()) }),
|
|
83
|
+
repository: z
|
|
84
|
+
.object({
|
|
85
|
+
repository: z.string(),
|
|
86
|
+
visibility: z.string(),
|
|
87
|
+
topics: z.array(z.string()),
|
|
88
|
+
secrets: z.array(z.string()),
|
|
89
|
+
commands: z.array(z.string()),
|
|
90
|
+
dryRun: z.boolean(),
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
nextSteps: z.array(z.string()),
|
|
94
|
+
}),
|
|
95
|
+
annotations: { idempotentHint: true },
|
|
96
|
+
requires: ["shell", "net", "fs"],
|
|
97
|
+
handler: async (args) => commands.init(args),
|
|
98
|
+
}),
|
|
99
|
+
operation({
|
|
100
|
+
name: "introspect",
|
|
101
|
+
description:
|
|
102
|
+
"Spawn the kernel MCP server, list its tools, and snapshot them to dev.toolfactory/ops.json.",
|
|
103
|
+
input: z.object({ root }),
|
|
104
|
+
output: z.object({ path: z.string(), changed: z.boolean(), operations: z.number() }),
|
|
105
|
+
annotations: { idempotentHint: true },
|
|
106
|
+
handler: async ({ root }) => {
|
|
107
|
+
const { path, changed, ops } = await commands.introspect(root);
|
|
108
|
+
return { path, changed, operations: ops.tools.length };
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
operation({
|
|
112
|
+
name: "build",
|
|
113
|
+
description:
|
|
114
|
+
"Generate every selected surface in-tree from the identity file and the operation snapshot, and refresh the lock.",
|
|
115
|
+
input: z.object({ root }),
|
|
116
|
+
output: z.object({
|
|
117
|
+
written: z.array(z.string()),
|
|
118
|
+
deleted: z.array(z.string()),
|
|
119
|
+
unchanged: z.array(z.string()),
|
|
120
|
+
manual: z.array(z.string()),
|
|
121
|
+
stripped: z
|
|
122
|
+
.array(z.string())
|
|
123
|
+
.describe(
|
|
124
|
+
"Region files whose generated regions were emptied because their surface is no longer selected; the authored remainder stays.",
|
|
125
|
+
),
|
|
126
|
+
nudge: z
|
|
127
|
+
.string()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
"One line when a surface that published somewhere was deselected since the last tag, pointing at `unpublish`.",
|
|
131
|
+
),
|
|
132
|
+
}),
|
|
133
|
+
annotations: { idempotentHint: true },
|
|
134
|
+
handler: async ({ root }) => commands.build(root).result,
|
|
135
|
+
}),
|
|
136
|
+
operation({
|
|
137
|
+
name: "check",
|
|
138
|
+
description:
|
|
139
|
+
"Fail if the operation snapshot or any generated file drifted from the code (the CI drift gate).",
|
|
140
|
+
input: z.object({ root }),
|
|
141
|
+
output: z.object({ ok: z.literal(true) }),
|
|
142
|
+
annotations: { readOnlyHint: true },
|
|
143
|
+
handler: async ({ root }) => {
|
|
144
|
+
await commands.check(root);
|
|
145
|
+
return { ok: true as const };
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
operation({
|
|
149
|
+
name: "validate",
|
|
150
|
+
description:
|
|
151
|
+
"Run each selected surface's own upstream validator (agentskills, claude plugin validate, MCP Inspector, openclaw, hermes, npm pack, uv build).",
|
|
152
|
+
input: z.object({ root, surface: surfaceId.optional().describe("Only this surface") }),
|
|
153
|
+
output: z.object({
|
|
154
|
+
outcomes: z.array(z.object({ label: z.string(), command: z.string(), ok: z.boolean() })),
|
|
155
|
+
}),
|
|
156
|
+
annotations: { readOnlyHint: true },
|
|
157
|
+
handler: async ({ root, surface }) => ({
|
|
158
|
+
outcomes: commands
|
|
159
|
+
.validate(root, surface)
|
|
160
|
+
.map(({ label, command, ok }) => ({ label, command, ok })),
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
operation({
|
|
164
|
+
name: "coverage",
|
|
165
|
+
description:
|
|
166
|
+
"The operation × surface verdict matrix: native, bridged, degraded, or excluded, with reasons.",
|
|
167
|
+
input: z.object({ root }),
|
|
168
|
+
output: z.object({
|
|
169
|
+
surfaces: z.array(z.string()),
|
|
170
|
+
rows: z.array(
|
|
171
|
+
z.object({
|
|
172
|
+
operation: z.string(),
|
|
173
|
+
verdicts: z.record(
|
|
174
|
+
z.string(),
|
|
175
|
+
z.object({ kind: z.string(), reason: z.string().optional() }),
|
|
176
|
+
),
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
}),
|
|
180
|
+
annotations: { readOnlyHint: true },
|
|
181
|
+
handler: async ({ root }) => commands.coverage(root),
|
|
182
|
+
}),
|
|
183
|
+
operation({
|
|
184
|
+
name: "adopt",
|
|
185
|
+
description:
|
|
186
|
+
"Stop regenerating one file; it becomes the author's (recorded as manual in the lock).",
|
|
187
|
+
input: z.object({ root, path: z.string().describe("Repo-relative path of a generated file") }),
|
|
188
|
+
output: z.object({ adopted: z.string() }),
|
|
189
|
+
handler: async ({ root, path }) => {
|
|
190
|
+
commands.adopt(root, path);
|
|
191
|
+
return { adopted: path };
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
operation({
|
|
195
|
+
name: "unadopt",
|
|
196
|
+
description: "Return an adopted file to toolfactory and regenerate it.",
|
|
197
|
+
input: z.object({ root, path: z.string().describe("Repo-relative path of an adopted file") }),
|
|
198
|
+
output: z.object({ regenerated: z.string() }),
|
|
199
|
+
handler: async ({ root, path }) => {
|
|
200
|
+
commands.unadopt(root, path);
|
|
201
|
+
return { regenerated: path };
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
operation({
|
|
205
|
+
name: "eject",
|
|
206
|
+
description: "Adopt every file a surface owns, so the author takes it over entirely.",
|
|
207
|
+
input: z.object({ root, surface: surfaceId }),
|
|
208
|
+
output: z.object({ adopted: z.array(z.string()) }),
|
|
209
|
+
handler: async ({ root, surface }) => ({ adopted: commands.eject(root, surface) }),
|
|
210
|
+
}),
|
|
211
|
+
operation({
|
|
212
|
+
name: "bootstrap-repo",
|
|
213
|
+
description:
|
|
214
|
+
"Prepare the GitHub repository from the local .env: the `live-tests` environment with its required reviewers and the sensitive config keys inside it, the release registries' tokens at repository scope, GitHub Pages with source = Actions, and npm's trusted publisher. Values go to `gh` on stdin and are never returned.",
|
|
215
|
+
input: z.object({
|
|
216
|
+
root,
|
|
217
|
+
reviewers: z
|
|
218
|
+
.array(z.string())
|
|
219
|
+
.default([])
|
|
220
|
+
.describe("GitHub logins that must approve a live run"),
|
|
221
|
+
dryRun: z
|
|
222
|
+
.boolean()
|
|
223
|
+
.default(false)
|
|
224
|
+
.describe("Print the gh invocations instead of running them"),
|
|
225
|
+
}),
|
|
226
|
+
output: z.object({
|
|
227
|
+
repository: z.string(),
|
|
228
|
+
environment: z.string(),
|
|
229
|
+
reviewers: z.array(z.string()),
|
|
230
|
+
secrets: z.array(z.string()).describe("Names written, never values"),
|
|
231
|
+
missing: z.array(z.string()).describe("Declared names the local .env has no value for"),
|
|
232
|
+
commands: z.array(z.string()),
|
|
233
|
+
manual: z.array(z.string()).describe("The one-time human steps no API can do"),
|
|
234
|
+
dryRun: z.boolean(),
|
|
235
|
+
}),
|
|
236
|
+
requires: ["shell", "net", "secret"],
|
|
237
|
+
handler: async (args) => commands.bootstrapRepo(args),
|
|
238
|
+
}),
|
|
239
|
+
operation({
|
|
240
|
+
name: "secrets",
|
|
241
|
+
description:
|
|
242
|
+
"Every credential this project's surfaces need — the tool's own sensitive config keys and the release registries' tokens — with where each one is set, whether it is present locally and on GitHub, and (check) whether the registry accepts it. Never a value.",
|
|
243
|
+
input: z.object({
|
|
244
|
+
root,
|
|
245
|
+
action: z
|
|
246
|
+
.enum(["status", "check"])
|
|
247
|
+
.default("status")
|
|
248
|
+
.describe("status: the inventory. check: run each registry's own credential probe."),
|
|
249
|
+
key: z.string().optional().describe("Only this secret"),
|
|
250
|
+
}),
|
|
251
|
+
output: z.object({
|
|
252
|
+
secrets: z.array(
|
|
253
|
+
z.object({
|
|
254
|
+
name: z.string(),
|
|
255
|
+
kind: z.enum(["config", "release"]),
|
|
256
|
+
required: z.boolean(),
|
|
257
|
+
needs: z.array(z.string()),
|
|
258
|
+
local: z.boolean(),
|
|
259
|
+
github: z.boolean().optional(),
|
|
260
|
+
url: z.string().optional(),
|
|
261
|
+
howTo: z.array(z.string()),
|
|
262
|
+
check: z.object({ ok: z.boolean(), command: z.string() }).optional(),
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
manual: z.array(z.string()),
|
|
266
|
+
}),
|
|
267
|
+
annotations: { readOnlyHint: true },
|
|
268
|
+
requires: ["shell", "net", "secret"],
|
|
269
|
+
handler: async (args) => commands.secrets(args),
|
|
270
|
+
}),
|
|
271
|
+
operation({
|
|
272
|
+
name: "unpublish",
|
|
273
|
+
description:
|
|
274
|
+
"Retract what a deselected surface used to publish. Git is the ledger: the previous tag's dev.toolfactory/tool.json says what was selected then, and every registry row that lost its surface is checked for the version that tag published and then retracted with the registry's own CLI — or reported with the exact page, where there is no API.",
|
|
275
|
+
input: z.object({
|
|
276
|
+
root,
|
|
277
|
+
ref: z
|
|
278
|
+
.string()
|
|
279
|
+
.default("HEAD")
|
|
280
|
+
.describe("Diff against the tag before this ref (the release passes the tag being cut)"),
|
|
281
|
+
dryRun: z.boolean().default(false).describe("List the retractions instead of running them"),
|
|
282
|
+
hard: z
|
|
283
|
+
.boolean()
|
|
284
|
+
.default(false)
|
|
285
|
+
.describe("Use the destructive retraction where one exists (npm unpublish --force)"),
|
|
286
|
+
}),
|
|
287
|
+
output: z.object({
|
|
288
|
+
from: z.string().optional(),
|
|
289
|
+
dropped: z.array(surfaceId),
|
|
290
|
+
steps: z.array(z.object({ name: z.string(), run: z.string() })),
|
|
291
|
+
ran: z
|
|
292
|
+
.array(z.object({ name: z.string(), ok: z.boolean(), durationMs: z.number() }))
|
|
293
|
+
.optional(),
|
|
294
|
+
}),
|
|
295
|
+
requires: ["shell", "net", "secret"],
|
|
296
|
+
handler: async ({ root, ref, dryRun, hard }) => commands.unpublish(root, { ref, dryRun, hard }),
|
|
297
|
+
}),
|
|
298
|
+
operation({
|
|
299
|
+
name: "gate",
|
|
300
|
+
description:
|
|
301
|
+
"Run the gate here, in order: build, the drift check, every selected surface's upstream validator, the author's checks and tests, and the credential-free host end-to-end. The same step list the generated ci.yml renders, so a project with no CI has the identical gate.",
|
|
302
|
+
input: z.object({ root }),
|
|
303
|
+
output: z.object({
|
|
304
|
+
steps: z.array(z.object({ name: z.string(), ok: z.boolean(), durationMs: z.number() })),
|
|
305
|
+
}),
|
|
306
|
+
requires: ["shell", "net", "fs"],
|
|
307
|
+
handler: async ({ root }) => commands.gate(root),
|
|
308
|
+
}),
|
|
309
|
+
operation({
|
|
310
|
+
name: "package",
|
|
311
|
+
description:
|
|
312
|
+
"Build every release asset into dist/release/ — npm tarball, Python distributions, OpenClaw plugin tarball, plugin bundle zip, web build, coverage — by the same steps the release workflow's package job runs. Publishing stays a CI concern.",
|
|
313
|
+
input: z.object({ root }),
|
|
314
|
+
output: z.object({
|
|
315
|
+
steps: z.array(z.object({ name: z.string(), ok: z.boolean(), durationMs: z.number() })),
|
|
316
|
+
}),
|
|
317
|
+
requires: ["shell", "net", "fs"],
|
|
318
|
+
handler: async ({ root }) => commands.packageRelease(root),
|
|
319
|
+
}),
|
|
320
|
+
operation({
|
|
321
|
+
name: "doctor",
|
|
322
|
+
description:
|
|
323
|
+
"Report which upstream CLIs this machine can delegate to (git, gh, npm, uv, claude, openclaw, clawhub, hermes, uvx, agentskills, MCP Inspector, docker).",
|
|
324
|
+
input: z.object({}),
|
|
325
|
+
output: z.object({
|
|
326
|
+
toolfactory: z.string(),
|
|
327
|
+
node: z.string(),
|
|
328
|
+
tools: z.record(z.string(), z.string()),
|
|
329
|
+
}),
|
|
330
|
+
annotations: { readOnlyHint: true },
|
|
331
|
+
handler: async () => commands.doctor(),
|
|
332
|
+
}),
|
|
333
|
+
];
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readlinkSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import type { MergeFile, PlannedFile, Project, RegionFile } from "../model.js";
|
|
15
|
+
import { apply, check, setState } from "./apply.js";
|
|
16
|
+
import { buildPlan } from "./plan.js";
|
|
17
|
+
|
|
18
|
+
function tmp(): string {
|
|
19
|
+
return mkdtempSync(join(tmpdir(), "tf-apply-"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function regionPlan(content: string): RegionFile {
|
|
23
|
+
return {
|
|
24
|
+
kind: "region",
|
|
25
|
+
path: "SKILL.md",
|
|
26
|
+
regions: [{ begin: "<!-- tf:ops -->", end: "<!-- /tf:ops -->", content }],
|
|
27
|
+
template: ["# Title", "", "Author prose.", "", "<!-- tf:ops -->", "<!-- /tf:ops -->", ""].join(
|
|
28
|
+
"\n",
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("apply / check — full files", () => {
|
|
34
|
+
it("applies a plan and check then reports it clean", () => {
|
|
35
|
+
const root = tmp();
|
|
36
|
+
const plan: PlannedFile[] = [{ kind: "file", path: "README.md", content: "hello\n" }];
|
|
37
|
+
const result = apply(root, plan, "0.1.0");
|
|
38
|
+
expect(result.written).toEqual(["README.md"]);
|
|
39
|
+
expect(readFileSync(join(root, "README.md"), "utf8")).toBe("hello\n");
|
|
40
|
+
expect(check(root, plan, "0.1.0")).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports a hand-edited generated file as changed", () => {
|
|
44
|
+
const root = tmp();
|
|
45
|
+
const plan: PlannedFile[] = [{ kind: "file", path: "README.md", content: "hello\n" }];
|
|
46
|
+
apply(root, plan, "0.1.0");
|
|
47
|
+
writeFileSync(join(root, "README.md"), "tampered\n");
|
|
48
|
+
expect(check(root, plan, "0.1.0")).toEqual([{ path: "README.md", kind: "changed" }]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a manual file is skipped by both drift and rewriting", () => {
|
|
52
|
+
const root = tmp();
|
|
53
|
+
const planA: PlannedFile[] = [{ kind: "file", path: "README.md", content: "hello\n" }];
|
|
54
|
+
apply(root, planA, "0.1.0");
|
|
55
|
+
setState(root, "README.md", "manual", "0.1.0");
|
|
56
|
+
writeFileSync(join(root, "README.md"), "author-owned\n");
|
|
57
|
+
|
|
58
|
+
const planB: PlannedFile[] = [{ kind: "file", path: "README.md", content: "regenerated\n" }];
|
|
59
|
+
expect(check(root, planB, "0.1.0")).toEqual([]);
|
|
60
|
+
const result = apply(root, planB, "0.1.0");
|
|
61
|
+
expect(result.manual).toEqual(["README.md"]);
|
|
62
|
+
expect(readFileSync(join(root, "README.md"), "utf8")).toBe("author-owned\n");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports an orphaned generated file via check and removes it on apply", () => {
|
|
66
|
+
const root = tmp();
|
|
67
|
+
const planA: PlannedFile[] = [
|
|
68
|
+
{ kind: "file", path: "a.txt", content: "a\n" },
|
|
69
|
+
{ kind: "file", path: "b.txt", content: "b\n" },
|
|
70
|
+
];
|
|
71
|
+
apply(root, planA, "0.1.0");
|
|
72
|
+
const planB: PlannedFile[] = [{ kind: "file", path: "b.txt", content: "b\n" }];
|
|
73
|
+
expect(check(root, planB, "0.1.0")).toEqual([{ path: "a.txt", kind: "orphan" }]);
|
|
74
|
+
const result = apply(root, planB, "0.1.0");
|
|
75
|
+
expect(result.deleted).toEqual(["a.txt"]);
|
|
76
|
+
expect(existsSync(join(root, "a.txt"))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("writes a symbolic link, restores one a real file replaced, and unlinks it as an orphan", () => {
|
|
80
|
+
const root = tmp();
|
|
81
|
+
mkdirSync(join(root, "skills/hello"), { recursive: true });
|
|
82
|
+
writeFileSync(join(root, "skills/hello/SKILL.md"), "---\n---\n");
|
|
83
|
+
const link: PlannedFile = {
|
|
84
|
+
kind: "file",
|
|
85
|
+
path: ".agents/skills/hello",
|
|
86
|
+
content: "../../skills/hello",
|
|
87
|
+
symlink: true,
|
|
88
|
+
};
|
|
89
|
+
apply(root, [link], "0.1.0");
|
|
90
|
+
expect(readlinkSync(join(root, ".agents/skills/hello"))).toBe("../../skills/hello");
|
|
91
|
+
expect(check(root, [link], "0.1.0")).toEqual([]);
|
|
92
|
+
|
|
93
|
+
rmSync(join(root, ".agents/skills/hello"));
|
|
94
|
+
writeFileSync(join(root, ".agents/skills/hello"), "not a link\n");
|
|
95
|
+
expect(check(root, [link], "0.1.0")).toEqual([
|
|
96
|
+
{ path: ".agents/skills/hello", kind: "changed" },
|
|
97
|
+
]);
|
|
98
|
+
apply(root, [link], "0.1.0");
|
|
99
|
+
expect(lstatSync(join(root, ".agents/skills/hello")).isSymbolicLink()).toBe(true);
|
|
100
|
+
|
|
101
|
+
expect(apply(root, [], "0.1.0").deleted).toEqual([".agents/skills/hello"]);
|
|
102
|
+
expect(existsSync(join(root, ".agents/skills/hello"))).toBe(false);
|
|
103
|
+
expect(existsSync(join(root, "skills/hello/SKILL.md"))).toBe(true);
|
|
104
|
+
|
|
105
|
+
// lstat, not stat: with its target gone the link is dangling — still present, still ours.
|
|
106
|
+
apply(root, [link], "0.1.0");
|
|
107
|
+
rmSync(join(root, "skills"), { recursive: true });
|
|
108
|
+
expect(check(root, [], "0.1.0")).toEqual([{ path: ".agents/skills/hello", kind: "orphan" }]);
|
|
109
|
+
expect(apply(root, [], "0.1.0").deleted).toEqual([".agents/skills/hello"]);
|
|
110
|
+
expect(
|
|
111
|
+
lstatSync(join(root, ".agents/skills/hello"), { throwIfNoEntry: false }),
|
|
112
|
+
).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("apply / check — region files", () => {
|
|
117
|
+
it("writes the template with the region filled when the file is absent", () => {
|
|
118
|
+
const root = tmp();
|
|
119
|
+
apply(root, [regionPlan("- op-a\n")], "0.1.0");
|
|
120
|
+
const text = readFileSync(join(root, "SKILL.md"), "utf8");
|
|
121
|
+
expect(text).toContain("Author prose.");
|
|
122
|
+
expect(text).toContain("- op-a\n");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("preserves the author's body outside the markers when the region is rebuilt", () => {
|
|
126
|
+
const root = tmp();
|
|
127
|
+
apply(root, [regionPlan("- op-a\n")], "0.1.0");
|
|
128
|
+
const path = join(root, "SKILL.md");
|
|
129
|
+
writeFileSync(
|
|
130
|
+
path,
|
|
131
|
+
readFileSync(path, "utf8").replace(
|
|
132
|
+
"Author prose.",
|
|
133
|
+
"Author prose.\n\nMore notes the author wrote by hand.",
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
apply(root, [regionPlan("- op-a\n- op-b\n")], "0.1.0");
|
|
137
|
+
const text = readFileSync(path, "utf8");
|
|
138
|
+
expect(text).toContain("More notes the author wrote by hand.");
|
|
139
|
+
expect(text).toContain("- op-b\n");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("a region file toolfactory stops writing loses its regions, not its existence", () => {
|
|
143
|
+
const root = tmp();
|
|
144
|
+
apply(root, [regionPlan("- op-a\n")], "0.1.0");
|
|
145
|
+
const path = join(root, "SKILL.md");
|
|
146
|
+
const authored = readFileSync(path, "utf8").replace(
|
|
147
|
+
"Author prose.",
|
|
148
|
+
"Author prose.\n\nA paragraph only the author can write.",
|
|
149
|
+
);
|
|
150
|
+
writeFileSync(path, authored);
|
|
151
|
+
expect(check(root, [], "0.1.0")).toEqual([{ path: "SKILL.md", kind: "orphan" }]);
|
|
152
|
+
|
|
153
|
+
const result = apply(root, [], "0.1.0");
|
|
154
|
+
expect(result).toMatchObject({ stripped: ["SKILL.md"], deleted: [] });
|
|
155
|
+
const emptied = readFileSync(path, "utf8");
|
|
156
|
+
expect(emptied).toContain("A paragraph only the author can write.");
|
|
157
|
+
expect(emptied).toContain("<!-- tf:ops --><!-- /tf:ops -->");
|
|
158
|
+
expect(emptied).not.toContain("- op-a");
|
|
159
|
+
// Silent once emptied: nothing of toolfactory's is stranded in the file any more.
|
|
160
|
+
expect(check(root, [], "0.1.0")).toEqual([]);
|
|
161
|
+
|
|
162
|
+
// Re-selection needs no code of its own: the preserved markers are refilled in place.
|
|
163
|
+
apply(root, [regionPlan("- op-a\n")], "0.1.0");
|
|
164
|
+
expect(readFileSync(path, "utf8")).toBe(authored);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("empties the region a surface stopped owning while another surface keeps the file", () => {
|
|
168
|
+
const root = tmp();
|
|
169
|
+
const install = { begin: "<!-- tf:install -->", end: "<!-- /tf:install -->" };
|
|
170
|
+
const mcpName = { begin: "<!-- tf:mcp-name -->", end: "<!-- /tf:mcp-name -->" };
|
|
171
|
+
const plan = (registry: boolean): RegionFile[] => [
|
|
172
|
+
{
|
|
173
|
+
kind: "region",
|
|
174
|
+
path: "README.md",
|
|
175
|
+
regions: [
|
|
176
|
+
{ ...install, content: "\n`npx -y hello mcp`\n" },
|
|
177
|
+
...(registry
|
|
178
|
+
? [{ ...mcpName, content: "\n<!-- mcp-name: io.github.o/hello -->\n" }]
|
|
179
|
+
: []),
|
|
180
|
+
],
|
|
181
|
+
template: [
|
|
182
|
+
"# hello",
|
|
183
|
+
"",
|
|
184
|
+
install.begin,
|
|
185
|
+
install.end,
|
|
186
|
+
"",
|
|
187
|
+
mcpName.begin,
|
|
188
|
+
mcpName.end,
|
|
189
|
+
"",
|
|
190
|
+
].join("\n"),
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
apply(root, plan(true), "0.1.0");
|
|
194
|
+
expect(check(root, plan(false), "0.1.0")).toEqual([{ path: "README.md", kind: "changed" }]);
|
|
195
|
+
apply(root, plan(false), "0.1.0");
|
|
196
|
+
const text = readFileSync(join(root, "README.md"), "utf8");
|
|
197
|
+
expect(text).not.toContain("mcp-name: io.github.o/hello");
|
|
198
|
+
expect(text).toContain("<!-- tf:mcp-name --><!-- /tf:mcp-name -->");
|
|
199
|
+
expect(text).toContain("`npx -y hello mcp`");
|
|
200
|
+
expect(check(root, plan(false), "0.1.0")).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("check reports a missing region marker as unmarked", () => {
|
|
204
|
+
const root = tmp();
|
|
205
|
+
mkdirSync(root, { recursive: true });
|
|
206
|
+
writeFileSync(join(root, "SKILL.md"), "# Title\n\nNo markers in this file at all.\n");
|
|
207
|
+
expect(check(root, [regionPlan("- op-a\n")], "0.1.0")).toEqual([
|
|
208
|
+
{ path: "SKILL.md", kind: "unmarked" },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("apply / check — merge files", () => {
|
|
214
|
+
it("merges a JSON patch, keeping keys it does not own", () => {
|
|
215
|
+
const root = tmp();
|
|
216
|
+
mkdirSync(root, { recursive: true });
|
|
217
|
+
writeFileSync(
|
|
218
|
+
join(root, "package.json"),
|
|
219
|
+
JSON.stringify({ name: "old", scripts: { test: "vitest" } }, null, 2),
|
|
220
|
+
);
|
|
221
|
+
const plan: MergeFile[] = [
|
|
222
|
+
{
|
|
223
|
+
kind: "merge",
|
|
224
|
+
path: "package.json",
|
|
225
|
+
format: "json",
|
|
226
|
+
patch: { name: "hello", version: "0.1.0" },
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
apply(root, plan, "0.1.0");
|
|
230
|
+
const document = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
231
|
+
expect(document).toEqual({ name: "hello", scripts: { test: "vitest" }, version: "0.1.0" });
|
|
232
|
+
expect(check(root, plan, "0.1.0")).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("replaces an owned object whole, so a renamed key does not leave its old name behind", () => {
|
|
236
|
+
const root = tmp();
|
|
237
|
+
mkdirSync(root, { recursive: true });
|
|
238
|
+
const bin = (name: string): MergeFile[] => [
|
|
239
|
+
{
|
|
240
|
+
kind: "merge",
|
|
241
|
+
path: "package.json",
|
|
242
|
+
format: "json",
|
|
243
|
+
patch: { name, bin: { [name]: "./dist/cli.js" } },
|
|
244
|
+
owned: ["bin"],
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
apply(root, bin("hello"), "0.1.0");
|
|
248
|
+
apply(root, bin("renamed"), "0.1.0");
|
|
249
|
+
const document = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
250
|
+
expect(document.bin).toEqual({ renamed: "./dist/cli.js" });
|
|
251
|
+
writeFileSync(
|
|
252
|
+
join(root, "package.json"),
|
|
253
|
+
JSON.stringify({ ...document, bin: { ...document.bin, stale: "./x" } }),
|
|
254
|
+
);
|
|
255
|
+
expect(check(root, bin("renamed"), "0.1.0")).toEqual([
|
|
256
|
+
{ path: "package.json", kind: "changed" },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("round-trips a TOML patch, merging into and preserving existing content", () => {
|
|
261
|
+
const root = tmp();
|
|
262
|
+
mkdirSync(root, { recursive: true });
|
|
263
|
+
writeFileSync(
|
|
264
|
+
join(root, "pyproject.toml"),
|
|
265
|
+
'[project]\nname = "old"\ndependencies = ["zod"]\n',
|
|
266
|
+
);
|
|
267
|
+
const plan: MergeFile[] = [
|
|
268
|
+
{
|
|
269
|
+
kind: "merge",
|
|
270
|
+
path: "pyproject.toml",
|
|
271
|
+
format: "toml",
|
|
272
|
+
patch: { project: { name: "hello", version: "0.1.0" } },
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
apply(root, plan, "0.1.0");
|
|
276
|
+
const text = readFileSync(join(root, "pyproject.toml"), "utf8");
|
|
277
|
+
expect(text).toContain('name = "hello"');
|
|
278
|
+
expect(text).toContain('version = "0.1.0"');
|
|
279
|
+
expect(text).toContain('dependencies = [ "zod" ]');
|
|
280
|
+
expect(check(root, plan, "0.1.0")).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("apply / check — inverses and outputs", () => {
|
|
285
|
+
it("uninstalls the keys a patch stops writing, keeping every other key", () => {
|
|
286
|
+
const root = tmp();
|
|
287
|
+
mkdirSync(root, { recursive: true });
|
|
288
|
+
writeFileSync(
|
|
289
|
+
join(root, "package.json"),
|
|
290
|
+
JSON.stringify({ scripts: { test: "vitest" } }, null, 2),
|
|
291
|
+
);
|
|
292
|
+
const plan = (registry: boolean): MergeFile[] => [
|
|
293
|
+
{
|
|
294
|
+
kind: "merge",
|
|
295
|
+
path: "package.json",
|
|
296
|
+
format: "json",
|
|
297
|
+
patch: {
|
|
298
|
+
name: "hello",
|
|
299
|
+
...(registry
|
|
300
|
+
? { scripts: { smoke: "node smoke.js" }, mcpName: "io.github.o/hello", tf: { mark: 1 } }
|
|
301
|
+
: {}),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
apply(root, plan(true), "0.1.0");
|
|
306
|
+
expect(check(root, plan(false), "0.1.0")).toEqual([{ path: "package.json", kind: "changed" }]);
|
|
307
|
+
apply(root, plan(false), "0.1.0");
|
|
308
|
+
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toEqual({
|
|
309
|
+
name: "hello",
|
|
310
|
+
scripts: { test: "vitest" },
|
|
311
|
+
});
|
|
312
|
+
expect(check(root, plan(false), "0.1.0")).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("a merge file toolfactory stops writing loses its keys, not its existence", () => {
|
|
316
|
+
const root = tmp();
|
|
317
|
+
mkdirSync(root, { recursive: true });
|
|
318
|
+
writeFileSync(
|
|
319
|
+
join(root, "package.json"),
|
|
320
|
+
JSON.stringify({ scripts: { test: "vitest" } }, null, 2),
|
|
321
|
+
);
|
|
322
|
+
const plan: MergeFile[] = [
|
|
323
|
+
{ kind: "merge", path: "package.json", format: "json", patch: { name: "hello" } },
|
|
324
|
+
];
|
|
325
|
+
apply(root, plan, "0.1.0");
|
|
326
|
+
expect(check(root, [], "0.1.0")).toEqual([{ path: "package.json", kind: "orphan" }]);
|
|
327
|
+
expect(apply(root, [], "0.1.0").deleted).toEqual([]);
|
|
328
|
+
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toEqual({
|
|
329
|
+
scripts: { test: "vitest" },
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("an output file is drift only when it is present and stale", () => {
|
|
334
|
+
const root = tmp();
|
|
335
|
+
const path = "dev.toolfactory/coverage.json";
|
|
336
|
+
const plan: PlannedFile[] = [{ kind: "file", path, content: "{}\n", output: true }];
|
|
337
|
+
apply(root, plan, "0.1.0");
|
|
338
|
+
rmSync(join(root, path));
|
|
339
|
+
expect(check(root, plan, "0.1.0")).toEqual([]);
|
|
340
|
+
writeFileSync(join(root, path), "stale\n");
|
|
341
|
+
expect(check(root, plan, "0.1.0")).toEqual([{ path, kind: "changed" }]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("refuses a selection that omits a surface another one declares it requires", () => {
|
|
345
|
+
const project = { root: tmp(), tool: { surfaces: ["cli", "web"] } } as unknown as Project;
|
|
346
|
+
expect(() => buildPlan(project)).toThrow(/Surface "web" requires surface "mcp"/);
|
|
347
|
+
});
|
|
348
|
+
});
|