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.
Files changed (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Real end-to-end: init a fresh tool, introspect the real kernel over stdio, build,
3
+ * check for drift, and run the generated CLI as a user would. Skips when this repo's
4
+ * own node_modules isn't around to symlink in (no dependencies to run against).
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import {
8
+ existsSync,
9
+ lstatSync,
10
+ mkdtempSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ readlinkSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join, relative } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { describe, expect, it } from "vitest";
21
+ import * as commands from "./commands.js";
22
+ import type { SurfaceId } from "./model.js";
23
+
24
+ const repoRoot = join(fileURLToPath(new URL(".", import.meta.url)), "..");
25
+ const repoNodeModules = join(repoRoot, "node_modules");
26
+
27
+ describe.skipIf(!existsSync(repoNodeModules))(
28
+ "end-to-end: init -> introspect -> build -> check -> run",
29
+ () => {
30
+ it("scaffolds a real tool, snapshots its kernel, builds clean, and answers a real CLI call", {
31
+ timeout: 120_000,
32
+ }, async () => {
33
+ const dir = mkdtempSync(join(tmpdir(), "toolfactory-e2e-"));
34
+ symlinkSync(repoNodeModules, join(dir, "node_modules"), "dir");
35
+
36
+ const surfaces: SurfaceId[] = ["skill", "agent-plugins", "claude", "mcp", "cli", "npm"];
37
+ const init = commands.init({
38
+ root: dir,
39
+ name: "hello",
40
+ binding: "typescript",
41
+ surfaces,
42
+ setup: false,
43
+ });
44
+ expect(init.written).toContain("dev.toolfactory/tool.json");
45
+ expect(init.written).toContain("src/ops.ts");
46
+
47
+ const snapshot = await commands.introspect(dir);
48
+ expect(snapshot.ops.tools.map((t) => t.name)).toEqual(["echo"]);
49
+ const echo = snapshot.ops.tools[0] as {
50
+ inputSchema: { $schema?: string };
51
+ outputSchema?: { $schema?: string };
52
+ };
53
+ expect(echo.inputSchema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
54
+ expect(echo.outputSchema?.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
55
+
56
+ commands.build(dir);
57
+ await expect(commands.check(dir)).resolves.toBeDefined();
58
+
59
+ const opsOnDisk = JSON.parse(readFileSync(join(dir, "dev.toolfactory/ops.json"), "utf8"));
60
+ expect(opsOnDisk.tools.map((t: { name: string }) => t.name)).toEqual(["echo"]);
61
+
62
+ const output = execFileSync(
63
+ process.execPath,
64
+ ["--import", "tsx", "src/toolfactory/cli.ts", "echo", "--text", "hi"],
65
+ { cwd: dir, encoding: "utf8" },
66
+ );
67
+ expect(JSON.parse(output)).toEqual({ text: "hi" });
68
+ });
69
+ },
70
+ );
71
+
72
+ /** Every file in the tree except the test's own scaffolding, as path → content (a link's target). */
73
+ function tree(root: string, dir = root): Record<string, string> {
74
+ const files: Record<string, string> = {};
75
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
76
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
77
+ const path = join(dir, entry.name);
78
+ if (entry.isDirectory()) Object.assign(files, tree(root, path));
79
+ else if (entry.isSymbolicLink()) files[relative(root, path)] = readlinkSync(path);
80
+ else files[relative(root, path)] = readFileSync(path, "utf8");
81
+ }
82
+ return files;
83
+ }
84
+
85
+ function scratch(): string {
86
+ const dir = mkdtempSync(join(tmpdir(), "toolfactory-confluence-"));
87
+ symlinkSync(repoNodeModules, join(dir, "node_modules"), "dir");
88
+ return dir;
89
+ }
90
+
91
+ /** The one thing a projector may never write: a paragraph, in the body a region file leaves alone. */
92
+ const PROSE_ANCHOR = "Explain here when an agent should reach for this tool";
93
+ const PROSE = "Reach for it when a demo needs a real tool, not a mock.";
94
+
95
+ function authorProse(root: string): void {
96
+ const path = join(root, "skills/hello/SKILL.md");
97
+ const text = readFileSync(path, "utf8");
98
+ writeFileSync(path, text.replace(PROSE_ANCHOR, `${PROSE}\n\n${PROSE_ANCHOR}`));
99
+ }
100
+
101
+ function setSurfaces(root: string, surfaces: SurfaceId[]): void {
102
+ const path = join(root, "dev.toolfactory/tool.json");
103
+ writeFileSync(
104
+ path,
105
+ `${JSON.stringify({ ...JSON.parse(readFileSync(path, "utf8")), surfaces }, null, 2)}\n`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Confluence: a tool that got where it is by editing, toggling and adopting is byte-identical to
111
+ * one generated from its `tool.json` in a single step. Every generated file therefore carries its
112
+ * own inverse — an orphan its deletion, a merge file the keys the lock records — and the drift
113
+ * gate is complete: a clean `check` means the tree is exactly what a fresh build would write.
114
+ */
115
+ describe.skipIf(!existsSync(repoNodeModules))(
116
+ "confluence: a walked tree equals a fresh one",
117
+ () => {
118
+ it("converges on the same tree as init + build of the final tool.json", {
119
+ timeout: 180_000,
120
+ }, async () => {
121
+ const identity = {
122
+ name: "hello",
123
+ binding: "typescript",
124
+ repository: "https://github.com/toolfactory/hello",
125
+ } as const;
126
+ const start: SurfaceId[] = ["skill", "cli", "npm", "mcp-registry"];
127
+ const end: SurfaceId[] = ["skill", "cli", "npm"];
128
+
129
+ const walked = scratch();
130
+ commands.init({ ...identity, root: walked, surfaces: start, setup: false });
131
+ await commands.introspect(walked);
132
+ setSurfaces(walked, end);
133
+ commands.build(walked);
134
+
135
+ // The region inverse: deselecting `skill` empties its regions instead of deleting the file
136
+ // the author writes prose in, and re-selecting refills the markers the strip preserved.
137
+ authorProse(walked);
138
+ setSurfaces(
139
+ walked,
140
+ end.filter((id) => id !== "skill"),
141
+ );
142
+ expect(commands.build(walked).result.stripped).toEqual(["skills/hello/SKILL.md"]);
143
+ const husk = readFileSync(join(walked, "skills/hello/SKILL.md"), "utf8");
144
+ expect(husk).toContain(PROSE);
145
+ expect(husk).toContain("<!-- tf:operations --><!-- /tf:operations -->");
146
+ expect(husk).not.toContain("## Operations");
147
+ expect(
148
+ lstatSync(join(walked, ".agents/skills/hello"), { throwIfNoEntry: false }),
149
+ ).toBeUndefined();
150
+ await expect(commands.check(walked)).resolves.toBeDefined();
151
+ setSurfaces(walked, end);
152
+ commands.build(walked);
153
+
154
+ commands.adopt(walked, "COVERAGE.md");
155
+ writeFileSync(join(walked, "COVERAGE.md"), "hand-written while adopted\n");
156
+ commands.build(walked);
157
+ commands.unadopt(walked, "COVERAGE.md");
158
+ commands.build(walked);
159
+
160
+ const fresh = scratch();
161
+ commands.init({ ...identity, root: fresh, surfaces: end, setup: false });
162
+ await commands.introspect(fresh);
163
+ authorProse(fresh);
164
+ commands.build(fresh);
165
+
166
+ expect(tree(walked)).toEqual(tree(fresh));
167
+ });
168
+ },
169
+ );
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The I/O half of the browser-extension surface: §8 C2, "mirror scaffolds by execution, not
3
+ * transcription". Runs the real `wxt init` into a temp directory and checks that everything it
4
+ * writes still agrees with BROWSER_SCAFFOLD, so an upstream template change is inherited on the
5
+ * next toolfactory release instead of silently rotting in a constant.
6
+ *
7
+ * The same run also seeds the project's placeholder icon set. Those five PNGs are WXT's binary
8
+ * files, not toolfactory's, and a pure `plan()` can only emit text — so they are copied out of
9
+ * the fresh scaffold when the project is missing them, exactly as the shadcn CLI (not the web
10
+ * surface) writes the components under `web/src/components/ui/`.
11
+ *
12
+ * Runnable as a script (`node dist/hosts/browser.js <root>`) because that is how the surface's
13
+ * `validate()` step reaches it: one Command like every other validator.
14
+ */
15
+ import { execFileSync } from "node:child_process";
16
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { argv, exit, stderr, stdout } from "node:process";
20
+ import { fileURLToPath } from "node:url";
21
+ import type { Project } from "../model.js";
22
+ import { loadProject } from "../project/load.js";
23
+ import { BROWSER_SCAFFOLD, HOST_DIR, WXT_PIN } from "../surfaces/browser-extension.js";
24
+
25
+ export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
26
+
27
+ /** Where WXT's auto-detection looks for the icon set, and therefore where the scaffold writes it. */
28
+ const ICON_DIR = "public/icon";
29
+
30
+ function differs(label: string, upstream: unknown, pinned: unknown): string[] {
31
+ return JSON.stringify(upstream) === JSON.stringify(pinned)
32
+ ? []
33
+ : [`${label}: \`wxt init\` and BROWSER_SCAFFOLD no longer agree`];
34
+ }
35
+
36
+ /**
37
+ * Drift lines (empty means the generator still writes what BROWSER_SCAFFOLD pins) and the icons
38
+ * copied into the project because it had none.
39
+ */
40
+ export function mirrorScaffold(project: Project): { drift: string[]; seeded: string[] } {
41
+ const directory = mkdtempSync(join(tmpdir(), "toolfactory-browser-"));
42
+ const probe = join(directory, "probe");
43
+ // Prefer the wxt the extension pins as a devDependency over a fresh download.
44
+ const pinned = join(project.root, HOST_DIR, "node_modules/.bin/wxt");
45
+ try {
46
+ execFileSync(
47
+ existsSync(pinned) ? pinned : "npx",
48
+ [
49
+ ...(existsSync(pinned) ? [] : ["--yes", `wxt@${WXT_PIN}`]),
50
+ "init",
51
+ probe,
52
+ "--template",
53
+ BROWSER_SCAFFOLD.template,
54
+ "--pm",
55
+ BROWSER_SCAFFOLD.packageManager,
56
+ ],
57
+ { stdio: "pipe" },
58
+ );
59
+ const scaffold = (path: string) => readFileSync(join(probe, path), "utf8");
60
+ const packageJson = JSON.parse(scaffold("package.json")) as Record<string, unknown>;
61
+ const icons = join(project.root, HOST_DIR, ICON_DIR);
62
+ const seeded = BROWSER_SCAFFOLD.icons.flatMap((size) => {
63
+ const name = `${size}.png`;
64
+ if (!existsSync(join(probe, ICON_DIR, name))) return [];
65
+ if (existsSync(join(icons, name))) return [];
66
+ mkdirSync(icons, { recursive: true });
67
+ copyFileSync(join(probe, ICON_DIR, name), join(icons, name));
68
+ return [`${ICON_DIR}/${name}`];
69
+ });
70
+ return {
71
+ drift: [
72
+ ...(["scripts", "dependencies", "devDependencies"] as const).flatMap((key) =>
73
+ differs(`package.json ${key}`, packageJson[key], BROWSER_SCAFFOLD.packageJson[key]),
74
+ ),
75
+ ...(scaffold("tsconfig.json") === BROWSER_SCAFFOLD.tsconfig
76
+ ? []
77
+ : ["tsconfig.json: the template no longer writes what BROWSER_SCAFFOLD.tsconfig pins"]),
78
+ ...(scaffold(".gitignore") === BROWSER_SCAFFOLD.gitignore
79
+ ? []
80
+ : [".gitignore: the template no longer writes what BROWSER_SCAFFOLD.gitignore pins"]),
81
+ ...BROWSER_SCAFFOLD.icons.flatMap((size) =>
82
+ existsSync(join(probe, ICON_DIR, `${size}.png`))
83
+ ? []
84
+ : [`${ICON_DIR}/${size}.png: the template no longer ships this icon`],
85
+ ),
86
+ ],
87
+ seeded,
88
+ };
89
+ } finally {
90
+ rmSync(directory, { recursive: true, force: true });
91
+ }
92
+ }
93
+
94
+ if (argv[1] === DRIFT_ENTRY) {
95
+ const { drift, seeded } = mirrorScaffold(loadProject(argv[2] ?? "."));
96
+ for (const path of seeded) stdout.write(`copied WXT's ${path} into ${HOST_DIR}\n`);
97
+ if (drift.length) {
98
+ stderr.write(
99
+ `the wxt scaffold drifted upstream; update BROWSER_SCAFFOLD in src/surfaces/browser-extension.ts:\n${drift
100
+ .map((line) => ` ${line}`)
101
+ .join("\n")}\n`,
102
+ );
103
+ exit(1);
104
+ }
105
+ stdout.write(`wxt init still matches src/surfaces/browser-extension.ts\n`);
106
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Everything toolfactory does to a GitHub repository: creating and pushing it (`init --repo`),
3
+ * and preparing the T4 live tier — an environment with required reviewers and the secrets inside
4
+ * it, the two things GitHub only exposes over its API. All of it through the official `gh` CLI
5
+ * (§8 C1: own no client), never a hand-rolled HTTP call, and all of it optional: a project with
6
+ * no GitHub at all never reaches this file.
7
+ *
8
+ * Secret values are read from the local `.env`, piped to `gh` on stdin, and never written to a
9
+ * command line, a log line, or the returned result.
10
+ */
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { parseEnv } from "node:util";
15
+ import type { Project, SurfaceId } from "../model.js";
16
+ import { envName, has, liveCredentials, npmName } from "../surfaces/shared.js";
17
+
18
+ /** The GitHub environment the generated `live` job deploys to. */
19
+ export const LIVE_ENVIRONMENT = "live-tests";
20
+
21
+ export interface BootstrapOptions {
22
+ /** `owner/repo` to prepare; the default is the identity file's repository URL. */
23
+ repository?: string;
24
+ /** GitHub logins that must approve a live run. Empty leaves the environment ungated. */
25
+ reviewers?: string[];
26
+ /** Where the secret values come from; defaults to `<root>/.env`. */
27
+ envFile?: string;
28
+ /**
29
+ * Names the release registries consume, at repository scope — `registries(project)`'s union,
30
+ * passed in so this file stays a `gh` driver and the table stays in `project/gate.ts`.
31
+ */
32
+ releaseSecrets?: string[];
33
+ /** The one-time human steps to print at the end; `manualSteps(project)`'s list. */
34
+ manual?: string[];
35
+ /** Print the `gh` invocations instead of running them. */
36
+ dryRun?: boolean;
37
+ }
38
+
39
+ export interface BootstrapResult {
40
+ /** `owner/repo`, from the identity file's repository URL. */
41
+ repository: string;
42
+ environment: string;
43
+ reviewers: string[];
44
+ /** Names of the secrets written, in `gh` order. Never their values. */
45
+ secrets: string[];
46
+ /** Declared names with no value in `.env`: reported, never fatal — the rest of the run still lands. */
47
+ missing: string[];
48
+ /** Every `gh` invocation, in order, as it is (or would be) run. */
49
+ commands: string[];
50
+ /** The one-time human steps no API can do, each with its URL. */
51
+ manual: string[];
52
+ dryRun: boolean;
53
+ }
54
+
55
+ /** `owner/repo` from a GitHub URL or `git@` remote. */
56
+ export function githubSlug(repository: string | undefined): string | undefined {
57
+ const match = repository?.match(/github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/#?]|$)/);
58
+ return match ? `${match[1]}/${match[2]}` : undefined;
59
+ }
60
+
61
+ function gh(args: string[], stdin?: string, cwd?: string): string {
62
+ return execFileSync("gh", args, {
63
+ cwd,
64
+ encoding: "utf8",
65
+ input: stdin,
66
+ stdio: ["pipe", "pipe", "inherit"],
67
+ }).trim();
68
+ }
69
+
70
+ /** npm trust's direct-publish permission flags landed in npm 11.15.0. */
71
+ function supportsNpmTrust(): { ok: boolean; version: string } {
72
+ const result = spawnSync("npm", ["--version"], { encoding: "utf8", timeout: 10_000 });
73
+ const version = result.status === 0 ? result.stdout.trim() : "unavailable";
74
+ const [major = 0, minor = 0] = version.split(".").map(Number);
75
+ return { ok: major > 11 || (major === 11 && minor >= 15), version };
76
+ }
77
+
78
+ export function bootstrapRepo(project: Project, options: BootstrapOptions = {}): BootstrapResult {
79
+ const repository = options.repository ?? githubSlug(project.identity.repository);
80
+ if (!repository) {
81
+ throw new Error(
82
+ "bootstrap-repo needs a GitHub repository URL in the identity file to know which repo to prepare.",
83
+ );
84
+ }
85
+ const { reviewers = [], dryRun = false, releaseSecrets = [] } = options;
86
+ // npm setup below reports its actual result, replacing the generic status instructions.
87
+ const manual = (options.manual ?? []).filter((step) => !step.startsWith("npm:"));
88
+ const envFile = options.envFile ?? join(project.root, ".env");
89
+ const values = existsSync(envFile) ? parseEnv(readFileSync(envFile, "utf8")) : {};
90
+ const keys = liveCredentials(project);
91
+ const commands: string[] = [];
92
+ const written: string[] = [];
93
+ const missing: string[] = [];
94
+
95
+ // The live tier's own environment, and only when the project has a live tier: a `tool.json`
96
+ // config key that is both required and sensitive is the whole trigger (§6 T4).
97
+ if (keys.length) {
98
+ const ids = reviewers.map((login) => {
99
+ const args = ["api", `users/${login}`, "--jq", ".id"];
100
+ commands.push(`gh ${args.join(" ")}`);
101
+ return dryRun ? `<id of ${login}>` : gh(args);
102
+ });
103
+ const body = JSON.stringify(
104
+ reviewers.length
105
+ ? { reviewers: ids.map((id) => ({ type: "User", id: Number(id) || id })) }
106
+ : {},
107
+ );
108
+ const environmentArgs = [
109
+ "api",
110
+ "--method",
111
+ "PUT",
112
+ `repos/${repository}/environments/${LIVE_ENVIRONMENT}`,
113
+ "--input",
114
+ "-",
115
+ ];
116
+ commands.push(`gh ${environmentArgs.join(" ")} <<< '${body}'`);
117
+ if (!dryRun) gh(environmentArgs, body);
118
+ }
119
+
120
+ // Two scopes, one loop: a config key is the live job's, a registry token is the release's, and
121
+ // one `gh secret set -f .env` could not tell them apart. The value goes in on stdin either way,
122
+ // so it never reaches argv (`ps`), a log line, or this result.
123
+ const scoped: [string, string[]][] = [
124
+ ...keys.map((key): [string, string[]] => [envName(key), ["--env", LIVE_ENVIRONMENT]]),
125
+ ...[...new Set(releaseSecrets)].map((name): [string, string[]] => [name, []]),
126
+ ];
127
+ for (const [name, scope] of scoped) {
128
+ if (!dryRun && !values[name]) {
129
+ missing.push(name);
130
+ continue;
131
+ }
132
+ const args = ["secret", "set", name, ...scope, "--repo", repository];
133
+ commands.push(`gh ${args.join(" ")} # value from ${envFile}, on stdin`);
134
+ if (!dryRun) gh(args, values[name]);
135
+ written.push(name);
136
+ }
137
+
138
+ // Pages: a workflow's own token cannot enable it (`GITHUB_TOKEN` has no `administration` key at
139
+ // all), but a local `gh` session is a repository admin, which is exactly the bar. Idempotent:
140
+ // 404 from the read means "not enabled yet", anything else means it already is.
141
+ if (has(project, "web")) {
142
+ const read = ["api", `repos/${repository}/pages`];
143
+ const create = [
144
+ "api",
145
+ "--method",
146
+ "POST",
147
+ `repos/${repository}/pages`,
148
+ "-f",
149
+ "build_type=workflow",
150
+ ];
151
+ commands.push(`gh ${read.join(" ")} || gh ${create.join(" ")}`);
152
+ if (!dryRun && !try_(() => gh(read))) try_(() => gh(create));
153
+ }
154
+
155
+ // npm trusted publishing, so the release can use OIDC — but only once the package exists.
156
+ // npm trust requires an interactive, logged-in 2FA session; granular bypass-2FA tokens are
157
+ // explicitly unsupported, so never smuggle NPM_TOKEN into this command.
158
+ if (releaseSecrets.includes("NPM_TOKEN")) {
159
+ const pkg = npmName(project);
160
+ const trustArgs = [
161
+ "trust",
162
+ "github",
163
+ pkg,
164
+ "--file",
165
+ "release.yml",
166
+ "--repo",
167
+ repository,
168
+ "--allow-publish",
169
+ "-y",
170
+ ];
171
+ const trust = `npm ${trustArgs.join(" ")}`;
172
+ const confirmArgs = [
173
+ "variable",
174
+ "set",
175
+ "NPM_TRUSTED_PUBLISHER",
176
+ "-b",
177
+ "true",
178
+ "--repo",
179
+ repository,
180
+ ];
181
+ const confirm = `gh ${confirmArgs.join(" ")}`;
182
+ const manualTrust = `npm: \`${trust}\` requires npm@11.15+, a package that already exists, and a logged-in 2FA session; granular access tokens with bypass 2FA are unsupported. Run it from that session, then \`${confirm}\`. Until it succeeds, NPM_TOKEN is the release fallback.`;
183
+ const published =
184
+ !dryRun &&
185
+ spawnSync("npm", ["view", pkg, "version"], { stdio: "ignore", timeout: 60_000 }).status === 0;
186
+ if (!published) {
187
+ manual.push(
188
+ `npm: the first publish uses NPM_TOKEN because \`npm trust\` requires the package to exist. Afterwards ${manualTrust.slice("npm: ".length)}`,
189
+ );
190
+ } else {
191
+ const npm = supportsNpmTrust();
192
+ if (!npm.ok) {
193
+ manual.push(
194
+ `npm: did not run \`${trust}\` because npm ${npm.version} lacks npm@11.15+'s --allow-publish permission. Install npm@^11.15 in the logged-in 2FA session, then ${manualTrust.slice("npm: ".length)}`,
195
+ );
196
+ } else if (!process.stdin.isTTY || !process.stdout.isTTY) {
197
+ manual.push(
198
+ `npm: run bootstrap-repo from an interactive terminal for npm's 2FA challenge. ${manualTrust.slice("npm: ".length)}`,
199
+ );
200
+ } else {
201
+ commands.push(trust);
202
+ const result = spawnSync("npm", trustArgs, { stdio: "inherit", timeout: 120_000 });
203
+ if (result.status !== 0) {
204
+ manual.push(
205
+ `npm: \`${trust}\` exited ${result.status}. ${manualTrust.slice("npm: ".length)}`,
206
+ );
207
+ } else {
208
+ commands.push(confirm);
209
+ try {
210
+ gh(confirmArgs);
211
+ } catch {
212
+ manual.push(
213
+ `npm: trusted publishing was configured, but \`${confirm}\` did not succeed. Run it before relying on OIDC; NPM_TOKEN remains the release fallback until then.`,
214
+ );
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ return {
222
+ repository,
223
+ environment: LIVE_ENVIRONMENT,
224
+ reviewers,
225
+ secrets: written,
226
+ missing,
227
+ commands,
228
+ manual,
229
+ dryRun,
230
+ };
231
+ }
232
+
233
+ /** `gh` exits non-zero for "not found" as readily as for a real failure; both mean "keep going". */
234
+ function try_(call: () => string): boolean {
235
+ try {
236
+ call();
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * One GitHub topic per selected surface, so a repository `init` creates is discoverable as what
245
+ * it actually ships. Surfaces that only package the same tool (`cli`, `npm`, `pypi`, `web`,
246
+ * `clawhub`, `workflows`) name no ecosystem and get none.
247
+ */
248
+ export const SURFACE_TOPICS: Partial<Record<SurfaceId, string>> = {
249
+ skill: "agent-skill",
250
+ "agent-plugins": "agent-plugins",
251
+ claude: "claude-plugin",
252
+ codex: "codex-plugin",
253
+ cursor: "cursor-plugin",
254
+ mcp: "mcp-server",
255
+ "mcp-registry": "mcp-registry",
256
+ "openclaw-native": "openclaw-plugin",
257
+ "hermes-native": "hermes-plugin",
258
+ dsh: "dsh-plugin",
259
+ };
260
+
261
+ export interface CreateRepoOptions {
262
+ /** `owner/name` of the repository to create. */
263
+ slug: string;
264
+ /** Public instead of the private default. */
265
+ public?: boolean;
266
+ /** Print the `gh` invocations instead of running them. */
267
+ dryRun?: boolean;
268
+ }
269
+
270
+ export interface CreateRepoResult {
271
+ repository: string;
272
+ visibility: "private" | "public";
273
+ topics: string[];
274
+ /** Every `gh` invocation, in order, as it is (or would be) run. */
275
+ commands: string[];
276
+ dryRun: boolean;
277
+ }
278
+
279
+ /** `gh <args>`, copy-pasteable: only arguments that need quoting get it. */
280
+ function render(args: string[]): string {
281
+ return `gh ${args.map((arg) => (/[\s"'$]/.test(arg) ? JSON.stringify(arg) : arg)).join(" ")}`;
282
+ }
283
+
284
+ /**
285
+ * Create the repository from the checkout and push it: `gh repo create --source --remote --push`,
286
+ * private unless asked otherwise, then one `gh repo edit --add-topic` for the selected surfaces.
287
+ * `gh` is the only GitHub client (§8 C1); nothing here talks to the API directly.
288
+ */
289
+ export function createRepo(project: Project, options: CreateRepoOptions): CreateRepoResult {
290
+ const { slug, dryRun = false } = options;
291
+ if (!/^[^/\s]+\/[^/\s]+$/.test(slug)) {
292
+ throw new Error(`repo takes owner/name, not ${JSON.stringify(slug)}.`);
293
+ }
294
+ const visibility = options.public ? "public" : "private";
295
+ const topics = [
296
+ ...new Set(
297
+ project.tool.surfaces
298
+ .flatMap((surface) => SURFACE_TOPICS[surface] ?? [])
299
+ .concat("toolfactory"),
300
+ ),
301
+ ];
302
+ const commands: string[] = [];
303
+ const create = [
304
+ "repo",
305
+ "create",
306
+ slug,
307
+ `--${visibility}`,
308
+ ...(project.identity.description ? ["--description", project.identity.description] : []),
309
+ "--source",
310
+ ".",
311
+ "--remote",
312
+ "origin",
313
+ "--push",
314
+ ];
315
+ const edit = ["repo", "edit", slug, "--add-topic", topics.join(",")];
316
+ for (const args of [create, edit]) {
317
+ commands.push(render(args));
318
+ if (!dryRun) gh(args, undefined, project.root);
319
+ }
320
+ return { repository: slug, visibility, topics, commands, dryRun };
321
+ }