@akira-tl/forgerelay 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +14 -0
  3. package/dist/activity/runtime/mcp-query-tools.js +15 -5
  4. package/dist/cli/config/inspect.js +331 -0
  5. package/dist/cli/config/migrate.js +351 -0
  6. package/dist/cli/init/setup-config.js +41 -0
  7. package/dist/cli/init.js +128 -130
  8. package/dist/cli/maintenance.js +15 -5
  9. package/dist/cli/mcp/external-mcp.js +14 -10
  10. package/dist/cli/mcp/status.js +11 -6
  11. package/dist/cli.js +40 -7
  12. package/dist/lsp/language-server-config.js +66 -144
  13. package/dist/lsp/runtime/manager.js +35 -4
  14. package/dist/mcp/hooks/config.js +141 -0
  15. package/dist/mcp/hooks/external-mcp-transform.js +22 -13
  16. package/dist/mcp/hooks/hook-cli.js +62 -52
  17. package/dist/mcp/hooks/hooks.js +111 -85
  18. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +8 -5
  19. package/dist/mcp/operations/external-mcp/external-mcp.js +9 -2
  20. package/dist/mcp/server/core/capability-support.js +7 -0
  21. package/dist/runtime/config/config.js +142 -64
  22. package/dist/runtime/config/definition/catalog.js +12 -0
  23. package/dist/runtime/config/definition/definition.js +115 -0
  24. package/dist/runtime/config/definition/external-mcp.js +58 -0
  25. package/dist/runtime/config/definition/general-config.js +258 -0
  26. package/dist/runtime/config/definition/language-servers.js +116 -0
  27. package/dist/runtime/config/definition/schema.js +113 -0
  28. package/dist/runtime/config/definition/types.js +2 -0
  29. package/dist/runtime/config/external-mcp-auth-store.js +105 -29
  30. package/dist/runtime/config/external-mcp-config.js +39 -0
  31. package/dist/runtime/config/external-mcp-registry.js +284 -106
  32. package/dist/runtime/config/inspection/live.js +77 -0
  33. package/dist/runtime/config/resolution/general.js +93 -0
  34. package/dist/runtime/config/resolution/hooks.js +315 -0
  35. package/dist/runtime/config/resolution/language-servers.js +237 -0
  36. package/dist/runtime/config/resolution/project-sources.js +159 -0
  37. package/dist/runtime/config/resolution/resolver.js +485 -0
  38. package/dist/runtime/config/resolution/types.js +1 -0
  39. package/dist/runtime/config/runtime/config-runtime.js +74 -0
  40. package/dist/runtime/config/runtime/source-refresh.js +263 -0
  41. package/dist/runtime/config/user-config.js +38 -31
  42. package/dist/runtime/security/project-execution-trust.js +34 -0
  43. package/dist/runtime/testing/server-fixture.js +9 -8
  44. package/dist/server.js +10 -6
  45. package/dist/subagents/profiles.js +336 -53
  46. package/dist/subagents/sessions/execution.js +6 -35
  47. package/dist/subagents/sessions/manager.js +14 -2
  48. package/dist/ui/.vite/manifest.json +275 -275
  49. package/dist/ui/activity-panel-app.html +1 -1
  50. package/dist/ui/assets/{activity-panel-app-Bw7ZFX2g.js → activity-panel-app-BeDiwd7D.js} +3 -3
  51. package/dist/ui/assets/{angular-html-DeUNP12X.js → angular-html-DuoCE7nn.js} +1 -1
  52. package/dist/ui/assets/{angular-ts-B3v8FKi_.js → angular-ts-D5tGk0hL.js} +1 -1
  53. package/dist/ui/assets/{apl-Bs3iQm4P.js → apl-lciOTPbw.js} +1 -1
  54. package/dist/ui/assets/{astro-D6GHecBY.js → astro-vJbqfNnJ.js} +1 -1
  55. package/dist/ui/assets/{blade-BMNbVOHv.js → blade-C7655Q4K.js} +1 -1
  56. package/dist/ui/assets/{c-BquuA6bv.js → c-DdHp1Jrw.js} +1 -1
  57. package/dist/ui/assets/{cobol-BuZ6kfps.js → cobol-DgvzQxZP.js} +1 -1
  58. package/dist/ui/assets/{coffee-DgC5uBzL.js → coffee-BIjVUOUd.js} +1 -1
  59. package/dist/ui/assets/{cpp-BdvWqCaB.js → cpp-CU8tGzvI.js} +1 -1
  60. package/dist/ui/assets/{crystal-0Q5qBvNI.js → crystal-CUQAwsg9.js} +1 -1
  61. package/dist/ui/assets/{css-Be_T8idh.js → css-DtMTqD3F.js} +1 -1
  62. package/dist/ui/assets/{edge-DaGLdgh_.js → edge-DoCX9RnL.js} +1 -1
  63. package/dist/ui/assets/{elixir-CMgiFIzB.js → elixir-DJxV9_U6.js} +1 -1
  64. package/dist/ui/assets/{elm-B-ufMlMC.js → elm-DIgWqK4r.js} +1 -1
  65. package/dist/ui/assets/{erb-CaGiOZM-.js → erb-BHxP0c_e.js} +1 -1
  66. package/dist/ui/assets/{git-rebase-CpwUWCzK.js → git-rebase-HbI_Vo8d.js} +1 -1
  67. package/dist/ui/assets/{glimmer-js-BRxccvnE.js → glimmer-js-CthY4Ty_.js} +1 -1
  68. package/dist/ui/assets/{glimmer-ts-CgQbPzlT.js → glimmer-ts-B2-_A1Xz.js} +1 -1
  69. package/dist/ui/assets/{glsl-D7JmHFr0.js → glsl-D9V9gq98.js} +1 -1
  70. package/dist/ui/assets/{graphql-C8n0eUBK.js → graphql-CddeAaS3.js} +1 -1
  71. package/dist/ui/assets/{hack-lVN7KVQv.js → hack-D_loGkOT.js} +1 -1
  72. package/dist/ui/assets/{haml-h18lG0pS.js → haml-BoWDczd0.js} +1 -1
  73. package/dist/ui/assets/{handlebars-lRFjRPfj.js → handlebars-ZoSOvw9y.js} +1 -1
  74. package/dist/ui/assets/{heavy-payload-Cqx9MOkH.js → heavy-payload-DO34LwoZ.js} +3 -3
  75. package/dist/ui/assets/{html-DtILqTE1.js → html-CE-obXVo.js} +1 -1
  76. package/dist/ui/assets/{html-derivative-CSTSmPup.js → html-derivative-Cd-smAbW.js} +1 -1
  77. package/dist/ui/assets/{http-DNI8fsbY.js → http-CZrkbrSl.js} +1 -1
  78. package/dist/ui/assets/{hurl-DgRi60JG.js → hurl-CxTuxBt1.js} +1 -1
  79. package/dist/ui/assets/{java-CW9pZ5bh.js → java-BshLjCat.js} +1 -1
  80. package/dist/ui/assets/{javascript-Cvg2UrWL.js → javascript-D2pxQTiX.js} +1 -1
  81. package/dist/ui/assets/{jinja-CZw_S1FB.js → jinja-kbfPP7-F.js} +1 -1
  82. package/dist/ui/assets/{jison-C56u_FZv.js → jison-C2mt9l1M.js} +1 -1
  83. package/dist/ui/assets/{json-DW2UTv1q.js → json-DU_rg_AU.js} +1 -1
  84. package/dist/ui/assets/{jsx-Zj5gobdD.js → jsx-D_8aQMlx.js} +1 -1
  85. package/dist/ui/assets/{julia-Cvcrre-L.js → julia-BmefTaVV.js} +1 -1
  86. package/dist/ui/assets/{just-8CZ2u6Tj.js → just-DXV8vk1S.js} +1 -1
  87. package/dist/ui/assets/{latex-D289AXWG.js → latex-Cjm2MvGW.js} +1 -1
  88. package/dist/ui/assets/{liquid-CFEX3SkZ.js → liquid-DyQDlo0u.js} +1 -1
  89. package/dist/ui/assets/{lua-BKpJ0ybO.js → lua-B2A1vzzy.js} +1 -1
  90. package/dist/ui/assets/{marko-D9maZ0a6.js → marko-B9vOkXOU.js} +1 -1
  91. package/dist/ui/assets/{mdc-rM9l0qWR.js → mdc-D42m6spU.js} +1 -1
  92. package/dist/ui/assets/{nginx-C9w6p2C9.js → nginx-TpfVxSUY.js} +1 -1
  93. package/dist/ui/assets/{nim-BRePHk0J.js → nim-CJr5T1FK.js} +1 -1
  94. package/dist/ui/assets/{perl-CskTmiPQ.js → perl-cJZcsdm9.js} +1 -1
  95. package/dist/ui/assets/{php-DCU_FvNI.js → php-CSZL5KRT.js} +1 -1
  96. package/dist/ui/assets/{pug-XLaVxTvv.js → pug-B1eMKzuA.js} +1 -1
  97. package/dist/ui/assets/{qml-CaONKSl-.js → qml-Dl2_7zc9.js} +1 -1
  98. package/dist/ui/assets/{r-BCVR0ZE3.js → r-F0aaO9gG.js} +1 -1
  99. package/dist/ui/assets/{razor-DDnGXxnU.js → razor-BPnkSMCw.js} +1 -1
  100. package/dist/ui/assets/{regexp-DWGTYYVx.js → regexp-BnGHMFbv.js} +1 -1
  101. package/dist/ui/assets/{rst-Dz3GWvQm.js → rst-B3CO7WYO.js} +1 -1
  102. package/dist/ui/assets/{ruby-DC3jGtMY.js → ruby-B6VMzT1T.js} +1 -1
  103. package/dist/ui/assets/{sas-7mNpvsjZ.js → sas-Df2lHVuU.js} +1 -1
  104. package/dist/ui/assets/{scss-uBsoCJVy.js → scss-4XxI6sgu.js} +1 -1
  105. package/dist/ui/assets/{shellscript-BCN9N_Jf.js → shellscript-DYASCN4r.js} +1 -1
  106. package/dist/ui/assets/{shellsession-Hsw46VXV.js → shellsession-Br6AGJO_.js} +1 -1
  107. package/dist/ui/assets/{soy-D6KV69HU.js → soy-p3ZNq1We.js} +1 -1
  108. package/dist/ui/assets/{sql-CVzVQkJh.js → sql-CAdv2OXL.js} +1 -1
  109. package/dist/ui/assets/{stata-BwUjXNhI.js → stata-Dx7yUbG_.js} +1 -1
  110. package/dist/ui/assets/{surrealql-CgloWqRa.js → surrealql-6YsE8HQa.js} +1 -1
  111. package/dist/ui/assets/{svelte-DcC3n6IC.js → svelte-YgzedwpD.js} +1 -1
  112. package/dist/ui/assets/{templ-DOFfnWTJ.js → templ-Bahqx3uD.js} +1 -1
  113. package/dist/ui/assets/{tex-ln89VPaX.js → tex-o6fTQjkH.js} +1 -1
  114. package/dist/ui/assets/{ts-tags-CWMzCstH.js → ts-tags-BO-Co0Ju.js} +1 -1
  115. package/dist/ui/assets/{tsx-DgzfiGeK.js → tsx-DtQe5AoH.js} +1 -1
  116. package/dist/ui/assets/{twig-Bi2itZL9.js → twig-BcUs6Kqc.js} +1 -1
  117. package/dist/ui/assets/{typescript-CQ24f4zW.js → typescript-BPtM8h4u.js} +1 -1
  118. package/dist/ui/assets/{vue-BS8jE5u6.js → vue-5KkGUzJJ.js} +1 -1
  119. package/dist/ui/assets/{vue-html-o38xqhyB.js → vue-html-Df_Xfsz9.js} +1 -1
  120. package/dist/ui/assets/{vue-vine-D2NvKv50.js → vue-vine-hUIe_u_Y.js} +1 -1
  121. package/dist/ui/assets/{xml-_44rx9_2.js → xml-C3Jy7as5.js} +1 -1
  122. package/dist/ui/assets/{xsl-B5QWQPoi.js → xsl-BnF9OuFo.js} +1 -1
  123. package/dist/ui/assets/{yaml-CKL2pEWe.js → yaml-DyrFgCou.js} +1 -1
  124. package/dist/workspaces/conversation-test-support.js +3 -11
  125. package/dist/workspaces/sessions.js +2 -0
  126. package/dist/workspaces/state/project-context.js +221 -0
  127. package/dist/workspaces.js +3 -1
  128. package/docs/chatgpt-coding-workflow.md +8 -3
  129. package/docs/configuration.md +99 -41
  130. package/docs/gotchas.md +24 -5
  131. package/docs/security.md +14 -3
  132. package/docs/setup.md +12 -7
  133. package/package.json +12 -3
  134. package/schemas/v1/config.project-local.schema.json +14 -0
  135. package/schemas/v1/config.project.schema.json +14 -0
  136. package/schemas/v1/config.user.schema.json +538 -0
  137. package/schemas/v1/hooks.project-local.schema.json +118 -0
  138. package/schemas/v1/hooks.project.schema.json +118 -0
  139. package/schemas/v1/hooks.user.schema.json +118 -0
  140. package/schemas/v1/language-servers.project-local.schema.json +230 -0
  141. package/schemas/v1/language-servers.project.schema.json +230 -0
  142. package/schemas/v1/language-servers.user.schema.json +230 -0
  143. package/schemas/v1/mcp.project-local.schema.json +143 -0
  144. package/schemas/v1/mcp.project.schema.json +143 -0
  145. package/schemas/v1/mcp.user.schema.json +143 -0
  146. package/scripts/ci/architecture.mjs +54 -4
  147. package/scripts/ci/cmd-acceptance.mjs +9 -9
  148. package/scripts/ci/config-v2-product-acceptance.mjs +639 -0
  149. package/scripts/ci/gates/acceptance-artifact.mjs +66 -0
  150. package/scripts/ci/gates/core-contract.mjs +27 -0
  151. package/scripts/ci/gates/platform-tests.mjs +38 -0
  152. package/scripts/ci/gates/prepare-windows-product.mjs +57 -0
  153. package/scripts/ci/gates/test-shard.mjs +71 -0
  154. package/scripts/ci/powershell51-acceptance.mjs +9 -9
  155. package/scripts/ci/pwsh-acceptance.mjs +9 -9
  156. package/scripts/ci/verify.mjs +2 -0
  157. package/scripts/ci/windows-product-acceptance.mjs +9 -10
  158. package/scripts/config/schema-text.mjs +4 -0
  159. package/scripts/config/schema-text.test.mjs +18 -0
  160. package/scripts/config/schema.mjs +74 -0
  161. package/scripts/lsp/interop.mjs +4 -1
  162. package/scripts/release/pack.mjs +64 -4
  163. package/scripts/release/release-gate.test.mjs +93 -27
  164. package/scripts/release-parity.mjs +19 -2
  165. package/templates/instructions/cmd.md +12 -0
  166. package/templates/instructions/fish.md +11 -0
  167. package/templates/instructions/powershell.md +14 -0
  168. package/templates/instructions/pwsh.md +14 -0
  169. package/templates/instructions/zsh.md +11 -0
  170. package/dist/runtime/instructions/powershell-skill.js +0 -65
@@ -0,0 +1,639 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { createServer as createNetServer } from "node:net";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import {
8
+ mkdir,
9
+ mkdtemp,
10
+ readFile,
11
+ readdir,
12
+ realpath,
13
+ rm,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import pty from "node-pty";
19
+ import { resolveAcceptanceTarball } from "./gates/acceptance-artifact.mjs";
20
+
21
+ const npmCli = process.env.npm_execpath;
22
+ if (!npmCli) {
23
+ throw new Error("Config v2 packaged acceptance must run through npm so npm_execpath is available");
24
+ }
25
+
26
+ const repoRoot = process.cwd();
27
+ const root = await realpath(await mkdtemp(join(tmpdir(), "forgerelay-config-v2-product-")));
28
+
29
+ try {
30
+ const artifactDir = join(root, "artifact");
31
+ const prefix = join(root, "prefix");
32
+ const home = join(root, "home");
33
+ await Promise.all([
34
+ mkdir(artifactDir, { recursive: true }),
35
+ mkdir(prefix, { recursive: true }),
36
+ mkdir(home, { recursive: true }),
37
+ ]);
38
+
39
+ const tarball = resolveAcceptanceTarball({ repoRoot, artifactDir, npmCli });
40
+ installProduct(prefix, tarball);
41
+ const installedRoot = join(prefix, "node_modules", "@akira-tl", "forgerelay");
42
+ const installedCli = join(installedRoot, "dist", "cli.js");
43
+ assert.ok(existsSync(installedCli), `installed package is missing dist/cli.js: ${installedRoot}`);
44
+ assert.ok(existsSync(join(installedRoot, "schemas", "v1", "config.user.schema.json")));
45
+ assert.ok(existsSync(join(installedRoot, "templates", "instructions", "cmd.md")));
46
+ assert.ok(existsSync(join(installedRoot, "capabilities", "lifecycle-hooks", "GUIDE.md")));
47
+
48
+ const probePath = join(root, "config-v2-installed-probe.mjs");
49
+ await writeFile(probePath, installedProbeSource(), "utf8");
50
+
51
+ await acceptFreshInit({ installedCli, root, home });
52
+ await acceptLegacyUpgradeAndMigration({ installedRoot, installedCli, probePath, root, home });
53
+ await acceptRuntimeSemantics({ installedRoot, probePath, root, home });
54
+ await acceptPackagedServe({ installedCli, root, home });
55
+
56
+ console.log(
57
+ `Config v2 packaged product acceptance passed on ${process.platform}/${process.arch}: fresh init, direct upgrade, diagnostics, migration equivalence, reload/LKG, Project Local, trust seam, and serve:7678.`,
58
+ );
59
+ } finally {
60
+ await rm(root, { recursive: true, force: true });
61
+ }
62
+
63
+ function installProduct(prefix, tarball) {
64
+ runNpm(
65
+ ["install", "--prefix", prefix, "--no-save", "--no-audit", "--no-fund", tarball],
66
+ repoRoot,
67
+ process.env,
68
+ 180_000,
69
+ );
70
+ }
71
+
72
+ async function acceptFreshInit({ installedCli, root, home }) {
73
+ const projectRoot = join(root, "fresh-project");
74
+ const configDir = join(root, "fresh-config");
75
+ const stateDir = join(root, "fresh-state");
76
+ await mkdir(projectRoot, { recursive: true });
77
+ const canonicalProjectRoot = await realpath(projectRoot);
78
+ await writeFile(join(canonicalProjectRoot, "README.md"), "fresh packaged config acceptance\n", "utf8");
79
+
80
+ const env = acceptanceEnv({ configDir, home, stateDir });
81
+ const child = pty.spawn(process.execPath, [installedCli, "init"], {
82
+ name: "xterm-256color",
83
+ cols: 110,
84
+ rows: 40,
85
+ cwd: projectRoot,
86
+ env: stringEnvironment(env),
87
+ });
88
+
89
+ let output = "";
90
+ let rootsAnswered = false;
91
+ let modeAnswered = false;
92
+ child.onData((data) => {
93
+ output += data;
94
+ if (!rootsAnswered && output.includes("Where are your projects located?")) {
95
+ rootsAnswered = true;
96
+ child.write("\r");
97
+ return;
98
+ }
99
+ if (rootsAnswered && !modeAnswered && output.includes("How should clients reach this ForgeRelay instance?")) {
100
+ modeAnswered = true;
101
+ child.write("\r");
102
+ }
103
+ });
104
+
105
+ const exitCode = await waitForPtyExit(child, 20_000);
106
+ assert.equal(exitCode, 0, scrubTerminal(output));
107
+ assert.equal(rootsAnswered, true, "packaged init did not ask for project roots");
108
+ assert.equal(modeAnswered, true, "packaged init did not ask for connection mode");
109
+ for (const unexpected of [
110
+ "Which local port should ForgeRelay use?",
111
+ "Which command shell should Agent commands and Hooks use?",
112
+ "Runtime Shell Instructions for this command shell?",
113
+ "Which Language Servers should ForgeRelay manage with npm?",
114
+ ]) {
115
+ assert.doesNotMatch(output, new RegExp(escapeRegExp(unexpected)));
116
+ }
117
+
118
+ const config = await readJson(join(configDir, "config.json"));
119
+ assert.deepEqual(Object.keys(config).sort(), ["$schema", "allowedRoots"]);
120
+ assert.deepEqual(config.allowedRoots, [canonicalProjectRoot]);
121
+ assert.match(String(config.$schema), /schemas\/v1\/config\.user\.schema\.json$/);
122
+ const auth = await readJson(join(configDir, "auth.json"));
123
+ assert.equal(typeof auth.ownerToken, "string");
124
+ assert.ok(auth.ownerToken.length >= 20);
125
+ assert.match(output, /OAuth mode: Owner-password approval/);
126
+ assert.match(output, /Client-facing MCP URL/);
127
+ assert.match(output, /Run `forgerelay serve` to start the MCP server\./);
128
+
129
+ const check = runInstalledCli(installedCli, ["config", "check", "--global", "--json"], env);
130
+ assert.equal(check.status, 0, check.stderr || check.stdout);
131
+ assert.equal(JSON.parse(check.stdout).summary.errors, 0);
132
+
133
+ const doctor = runInstalledCli(installedCli, ["doctor"], env);
134
+ assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
135
+ }
136
+
137
+ async function acceptLegacyUpgradeAndMigration({ installedRoot, installedCli, probePath, root, home }) {
138
+ const configDir = join(root, "legacy-config");
139
+ const projectRoot = join(root, "legacy-project");
140
+ const stateDir = join(root, "legacy-state");
141
+ await Promise.all([
142
+ mkdir(join(configDir, "agents"), { recursive: true }),
143
+ mkdir(projectRoot, { recursive: true }),
144
+ mkdir(stateDir, { recursive: true }),
145
+ ]);
146
+ await writeFile(join(projectRoot, "README.md"), "legacy direct-upgrade acceptance\n", "utf8");
147
+ await writeFile(
148
+ join(configDir, "config.json"),
149
+ JSON.stringify({
150
+ allowedRoots: [projectRoot],
151
+ mcpServers: {
152
+ legacy: {
153
+ transport: "stdio",
154
+ command: process.execPath,
155
+ args: ["-e", "process.exit(0)"],
156
+ },
157
+ },
158
+ languageServers: {
159
+ custom: {
160
+ command: process.execPath,
161
+ args: ["-e", "process.exit(0)"],
162
+ extensions: [".legacy"],
163
+ },
164
+ },
165
+ hooks: {
166
+ BeforeTool: [{
167
+ name: "guard",
168
+ matcher: { tool: "read" },
169
+ command: "echo packaged-legacy-hook",
170
+ report: true,
171
+ }],
172
+ },
173
+ }, null, 2),
174
+ "utf8",
175
+ );
176
+ await writeFile(
177
+ join(configDir, "agents", "reviewer.md"),
178
+ [
179
+ "---",
180
+ "description: Packaged legacy reviewer",
181
+ "provider: codex",
182
+ "---",
183
+ "Review packaged migration behavior.",
184
+ "",
185
+ ].join("\n"),
186
+ "utf8",
187
+ );
188
+
189
+ const env = acceptanceEnv({ configDir, home, stateDir });
190
+ const check = runInstalledCli(installedCli, ["config", "check", "--global", "--json"], env);
191
+ assert.equal(check.status, 0, check.stderr || check.stdout);
192
+ const checkJson = JSON.parse(check.stdout);
193
+ assert.equal(checkJson.summary.errors, 0);
194
+ assert.ok(checkJson.diagnostics.some((entry) => entry.code === "deprecated_source"));
195
+ const untouched = await readJson(join(configDir, "config.json"));
196
+ assert.ok(untouched.mcpServers, "direct upgrade check must not mutate legacy config");
197
+ assert.ok(untouched.languageServers, "direct upgrade check must not mutate legacy config");
198
+ assert.ok(untouched.hooks, "direct upgrade check must not mutate legacy config");
199
+
200
+ const sources = runInstalledCli(installedCli, ["config", "sources", "--global", "--json"], env);
201
+ assert.equal(sources.status, 0, sources.stderr || sources.stdout);
202
+ const sourceJson = JSON.parse(sources.stdout);
203
+ assert.ok(sourceJson.sources.some((entry) => entry.id === "legacy:user:mcpServers"));
204
+ assert.ok(sourceJson.sources.some((entry) => entry.id === "legacy:user:languageServers"));
205
+ assert.ok(sourceJson.sources.some((entry) => entry.id === "legacy:user:config-hooks"));
206
+
207
+ const explain = runInstalledCli(
208
+ installedCli,
209
+ ["config", "explain", "mcp.servers.legacy", "--global", "--json"],
210
+ env,
211
+ );
212
+ assert.equal(explain.status, 0, explain.stderr || explain.stdout);
213
+ const explainJson = JSON.parse(explain.stdout);
214
+ assert.equal(explainJson.effective.source.id, "legacy:user:mcpServers");
215
+ assert.equal(explainJson.effective.executionEffect, "process");
216
+
217
+ const dryRun = runInstalledCli(installedCli, ["config", "migrate", "--dry-run", "--global"], env);
218
+ assert.equal(dryRun.status, 0, dryRun.stderr || dryRun.stdout);
219
+ assert.match(dryRun.stdout, /DRY RUN/i);
220
+ assert.equal(existsSync(join(configDir, "mcp.json")), false);
221
+
222
+ const before = runInstalledProbe({
223
+ installedRoot,
224
+ probePath,
225
+ mode: "snapshot",
226
+ configDir,
227
+ projectRoot,
228
+ outputPath: join(root, "legacy-before.json"),
229
+ env,
230
+ });
231
+
232
+ const migration = runInstalledCli(installedCli, ["config", "migrate", "--global"], env);
233
+ assert.equal(migration.status, 0, migration.stderr || migration.stdout);
234
+ assert.match(migration.stdout, /Migration complete/i);
235
+ assert.ok(existsSync(join(configDir, "mcp.json")));
236
+ assert.ok(existsSync(join(configDir, "language-servers.json")));
237
+ assert.ok(existsSync(join(configDir, "hooks", "guard.json")));
238
+ assert.ok(existsSync(join(configDir, "subagents", "reviewer.md")));
239
+ const migrated = await readJson(join(configDir, "config.json"));
240
+ assert.equal("mcpServers" in migrated, false);
241
+ assert.equal("languageServers" in migrated, false);
242
+ assert.equal("hooks" in migrated, false);
243
+ const backupEntries = await readdir(join(configDir, "migration-backups"));
244
+ assert.equal(backupEntries.length, 1);
245
+
246
+ const after = runInstalledProbe({
247
+ installedRoot,
248
+ probePath,
249
+ mode: "snapshot",
250
+ configDir,
251
+ projectRoot,
252
+ outputPath: join(root, "legacy-after.json"),
253
+ env,
254
+ });
255
+ assert.deepEqual(after, before, "explicit migration changed effective Config v2 behavior");
256
+ }
257
+
258
+ async function acceptRuntimeSemantics({ installedRoot, probePath, root, home }) {
259
+ const configDir = join(root, "runtime-config");
260
+ const projectRoot = join(root, "runtime-project");
261
+ const stateDir = join(root, "runtime-state");
262
+ await Promise.all([
263
+ mkdir(configDir, { recursive: true }),
264
+ mkdir(projectRoot, { recursive: true }),
265
+ mkdir(stateDir, { recursive: true }),
266
+ ]);
267
+ const env = acceptanceEnv({ configDir, home, stateDir });
268
+ const result = runInstalledProbe({
269
+ installedRoot,
270
+ probePath,
271
+ mode: "runtime",
272
+ configDir,
273
+ projectRoot,
274
+ outputPath: join(root, "runtime-result.json"),
275
+ env,
276
+ });
277
+ assert.match(result.projectId, /^proj_[a-f0-9]{20}$/);
278
+ assert.equal(result.projectIdentityStable, true);
279
+ assert.equal(result.projectLocalWinner, "local");
280
+ assert.equal(result.lastKnownGoodRetained, true);
281
+ assert.equal(result.deletionCleared, true);
282
+ assert.equal(result.trustCalls, 1);
283
+ assert.equal(result.trustProjectId, result.projectId);
284
+ assert.equal(result.trustDisplay, "trust");
285
+ assert.equal(result.trustDeniedBeforeSpawn, true);
286
+ }
287
+
288
+ async function acceptPackagedServe({ installedCli, root, home }) {
289
+ const configDir = join(root, "serve-config");
290
+ const projectRoot = join(root, "serve-project");
291
+ const stateDir = join(root, "serve-state");
292
+ await Promise.all([
293
+ mkdir(configDir, { recursive: true }),
294
+ mkdir(projectRoot, { recursive: true }),
295
+ mkdir(stateDir, { recursive: true }),
296
+ ]);
297
+ await writeFile(
298
+ join(configDir, "config.json"),
299
+ JSON.stringify({
300
+ host: "127.0.0.1",
301
+ port: 7678,
302
+ publicBaseUrl: "http://127.0.0.1:7678",
303
+ allowedRoots: [projectRoot],
304
+ stateDir,
305
+ shellInstructions: false,
306
+ subagents: false,
307
+ }, null, 2),
308
+ "utf8",
309
+ );
310
+ await writeFile(
311
+ join(configDir, "auth.json"),
312
+ JSON.stringify({
313
+ ownerToken: "config-v2-packaged-owner-token-long-enough",
314
+ instanceId: "forge-config-v2-packaged",
315
+ }, null, 2),
316
+ { encoding: "utf8", mode: 0o600 },
317
+ );
318
+
319
+ await assertPortAvailable(7678);
320
+ const env = acceptanceEnv({ configDir, home, stateDir });
321
+ const child = spawn(process.execPath, [installedCli, "serve", "--allow-elevated"], {
322
+ cwd: projectRoot,
323
+ env,
324
+ stdio: ["ignore", "pipe", "pipe"],
325
+ windowsHide: true,
326
+ });
327
+ let output = "";
328
+ child.stdout?.on("data", (chunk) => { output += chunk.toString(); });
329
+ child.stderr?.on("data", (chunk) => { output += chunk.toString(); });
330
+
331
+ try {
332
+ await waitForCondition(
333
+ () => output.includes("forgerelay listening on http://127.0.0.1:7678/mcp"),
334
+ 20_000,
335
+ () => `packaged serve did not start on isolated port 7678:\n${output}`,
336
+ );
337
+ const response = await fetch("http://127.0.0.1:7678/.well-known/oauth-protected-resource/mcp");
338
+ assert.equal(response.status, 200);
339
+ const metadata = await response.json();
340
+ assert.match(String(metadata.resource), /^http:\/\/127\.0\.0\.1:7678\/mcp$/);
341
+ } finally {
342
+ await terminateChild(child);
343
+ }
344
+ }
345
+
346
+ function runInstalledCli(installedCli, args, env) {
347
+ return spawnSync(process.execPath, [installedCli, ...args], {
348
+ cwd: repoRoot,
349
+ env,
350
+ encoding: "utf8",
351
+ windowsHide: true,
352
+ shell: false,
353
+ timeout: 30_000,
354
+ });
355
+ }
356
+
357
+ function runInstalledProbe({ installedRoot, probePath, mode, configDir, projectRoot, outputPath, env }) {
358
+ const result = spawnSync(
359
+ process.execPath,
360
+ [probePath, installedRoot, mode, configDir, projectRoot, outputPath],
361
+ {
362
+ cwd: projectRoot,
363
+ env,
364
+ encoding: "utf8",
365
+ windowsHide: true,
366
+ shell: false,
367
+ timeout: 30_000,
368
+ },
369
+ );
370
+ if (result.error || result.status !== 0) {
371
+ throw new Error(
372
+ `Installed Config v2 probe failed (${mode}): ${result.error?.message ?? result.stderr ?? result.stdout ?? result.status}`,
373
+ );
374
+ }
375
+ return JSON.parse(readFileSync(outputPath, "utf8"));
376
+ }
377
+
378
+ function runNpm(args, cwd, env, timeout) {
379
+ const result = spawnSync(process.execPath, [npmCli, ...args], {
380
+ cwd,
381
+ env,
382
+ encoding: "utf8",
383
+ windowsHide: true,
384
+ shell: false,
385
+ timeout,
386
+ });
387
+ if (result.error || result.status !== 0) {
388
+ throw new Error(`npm ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.stdout ?? result.status}`);
389
+ }
390
+ return result;
391
+ }
392
+
393
+ function acceptanceEnv({ configDir, home, stateDir }) {
394
+ const env = Object.fromEntries(
395
+ Object.entries(process.env).filter(([name, value]) =>
396
+ value !== undefined
397
+ && !name.startsWith("FORGERELAY_")
398
+ && name !== "PORT"
399
+ && name !== "HOST"
400
+ ),
401
+ );
402
+ delete env.npm_lifecycle_event;
403
+ return {
404
+ ...env,
405
+ HOME: home,
406
+ USERPROFILE: home,
407
+ FORGERELAY_CONFIG_DIR: configDir,
408
+ FORGERELAY_STATE_DIR: stateDir,
409
+ FORGERELAY_WIDGETS: "off",
410
+ FORGERELAY_SKILLS: "0",
411
+ };
412
+ }
413
+
414
+ function stringEnvironment(env) {
415
+ return Object.fromEntries(
416
+ Object.entries(env).filter((entry) => typeof entry[1] === "string"),
417
+ );
418
+ }
419
+
420
+ async function readJson(path) {
421
+ return JSON.parse(await readFile(path, "utf8"));
422
+ }
423
+
424
+ function waitForPtyExit(child, timeoutMs) {
425
+ return new Promise((resolve, reject) => {
426
+ const timeout = setTimeout(() => {
427
+ child.kill();
428
+ reject(new Error("Timed out waiting for packaged interactive init to exit."));
429
+ }, timeoutMs);
430
+ child.onExit(({ exitCode }) => {
431
+ clearTimeout(timeout);
432
+ resolve(exitCode);
433
+ });
434
+ });
435
+ }
436
+
437
+ async function assertPortAvailable(port) {
438
+ await new Promise((resolve, reject) => {
439
+ const server = createNetServer();
440
+ server.once("error", reject);
441
+ server.listen(port, "127.0.0.1", () => {
442
+ server.close((error) => error ? reject(error) : resolve());
443
+ });
444
+ });
445
+ }
446
+
447
+ async function waitForCondition(condition, timeoutMs, message) {
448
+ const deadline = Date.now() + timeoutMs;
449
+ while (Date.now() < deadline) {
450
+ if (condition()) return;
451
+ await new Promise((resolve) => setTimeout(resolve, 50));
452
+ }
453
+ throw new Error(message());
454
+ }
455
+
456
+ async function terminateChild(child) {
457
+ if (child.exitCode !== null || child.signalCode !== null) return;
458
+ child.kill("SIGTERM");
459
+ const exited = await Promise.race([
460
+ new Promise((resolve) => child.once("exit", () => resolve(true))),
461
+ new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
462
+ ]);
463
+ if (exited) return;
464
+ child.kill("SIGKILL");
465
+ await Promise.race([
466
+ new Promise((resolve) => child.once("exit", resolve)),
467
+ new Promise((resolve) => setTimeout(resolve, 2_000)),
468
+ ]);
469
+ }
470
+
471
+ function scrubTerminal(value) {
472
+ return value
473
+ .replace(/\x1B\[[0-?]*[ -\/]*[@-~]/g, "")
474
+ .replace(/Owner password:\s*[^\r\n]+/g, "Owner password: <redacted>");
475
+ }
476
+
477
+ function escapeRegExp(value) {
478
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
+ }
480
+
481
+ function installedProbeSource() {
482
+ return String.raw`
483
+ import assert from "node:assert/strict";
484
+ import { mkdir, rm, writeFile } from "node:fs/promises";
485
+ import { join } from "node:path";
486
+ import { pathToFileURL } from "node:url";
487
+
488
+ const [installedRoot, mode, configDir, projectRoot, outputPath] = process.argv.slice(2);
489
+ const importInstalled = (relativePath) => import(pathToFileURL(join(installedRoot, relativePath)).href);
490
+
491
+ if (mode === "snapshot") {
492
+ const [
493
+ { ExternalMcpConfigRegistry },
494
+ { resolveLanguageServersConfig, effectiveLanguageServerEntries },
495
+ { resolveHooksConfig, effectiveHookConfigEntries },
496
+ { ConfigSourceRuntime },
497
+ { resolveSubagentProfilesConfigSources },
498
+ ] = await Promise.all([
499
+ importInstalled("dist/runtime/config/external-mcp-registry.js"),
500
+ importInstalled("dist/runtime/config/resolution/language-servers.js"),
501
+ importInstalled("dist/runtime/config/resolution/hooks.js"),
502
+ importInstalled("dist/runtime/config/runtime/source-refresh.js"),
503
+ importInstalled("dist/subagents/profiles.js"),
504
+ ]);
505
+ const sourceRuntime = new ConfigSourceRuntime();
506
+ const mcp = new ExternalMcpConfigRegistry({ configDir, sourceRuntime }).resolveGlobal();
507
+ const lsp = await resolveLanguageServersConfig({ configDir, sourceRuntime });
508
+ const hooks = await resolveHooksConfig({ configDir, sourceRuntime });
509
+ const profiles = resolveSubagentProfilesConfigSources({ configDir, sourceRuntime });
510
+ const snapshot = normalize({
511
+ mcp: Object.fromEntries(Object.entries(mcp.servers).map(([name, server]) => [name, {
512
+ transport: server.transport,
513
+ ...(server.command ? { command: server.command } : {}),
514
+ ...(server.args ? { args: server.args } : {}),
515
+ ...(server.url ? { url: server.url } : {}),
516
+ }])),
517
+ languageServers: Object.fromEntries(effectiveLanguageServerEntries(lsp).map((entry) => [entry.id, {
518
+ command: entry.value.command,
519
+ ...(entry.value.args ? { args: entry.value.args } : {}),
520
+ ...(entry.value.extensions ? { extensions: entry.value.extensions } : {}),
521
+ }])),
522
+ hooks: effectiveHookConfigEntries(hooks).flatMap((entry) =>
523
+ entry.entries.map((hook) => ({ name: entry.name, event: hook.event, command: hook.command, matcher: hook.matcher ?? null }))
524
+ ).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
525
+ profiles: profiles.values.profiles ?? {},
526
+ });
527
+ await writeFile(outputPath, JSON.stringify(snapshot), "utf8");
528
+ } else if (mode === "runtime") {
529
+ const [
530
+ { ProjectContextResolver },
531
+ { ConfigSourceRuntime },
532
+ { resolveHooksConfig, effectiveHookConfigEntries },
533
+ { HookRunner },
534
+ ] = await Promise.all([
535
+ importInstalled("dist/workspaces/state/project-context.js"),
536
+ importInstalled("dist/runtime/config/runtime/source-refresh.js"),
537
+ importInstalled("dist/runtime/config/resolution/hooks.js"),
538
+ importInstalled("dist/mcp/hooks/hooks.js"),
539
+ ]);
540
+
541
+ const sourceRuntime = new ConfigSourceRuntime();
542
+ await mkdir(join(configDir, "hooks"), { recursive: true });
543
+ await mkdir(join(projectRoot, ".forgerelay", "hooks"), { recursive: true });
544
+ const identity = new ProjectContextResolver(configDir);
545
+ const project = await identity.resolve(projectRoot);
546
+ const again = await new ProjectContextResolver(configDir).resolve(projectRoot);
547
+ await mkdir(join(project.localConfigDir, "hooks"), { recursive: true });
548
+
549
+ await writeFile(join(configDir, "hooks", "shared.json"), JSON.stringify({ event: "AfterTool", command: "echo user" }), "utf8");
550
+ await writeFile(join(project.sharedConfigDir, "hooks", "shared.json"), JSON.stringify({ event: "AfterTool", command: "echo project" }), "utf8");
551
+ await writeFile(join(project.localConfigDir, "hooks", "shared.json"), JSON.stringify({ event: "AfterTool", command: "echo local" }), "utf8");
552
+ await writeFile(join(project.sharedConfigDir, "hooks", "trust.json"), JSON.stringify({ event: "BeforeTool", command: "forgerelay-packaged-trust-must-not-spawn" }), "utf8");
553
+
554
+ const projectResolution = await resolveHooksConfig({ configDir, project, sourceRuntime });
555
+ const shared = effectiveHookConfigEntries(projectResolution).find((entry) => entry.name === "shared");
556
+ assert.equal(shared?.scope, "project-local");
557
+ assert.equal(shared?.entries[0]?.command, "echo local");
558
+
559
+ const lkgPath = join(configDir, "hooks", "lkg.json");
560
+ await writeFile(lkgPath, JSON.stringify({ event: "AfterTool", command: "echo first" }), "utf8");
561
+ const first = await resolveHooksConfig({ configDir, project, sourceRuntime });
562
+ assert.equal(effectiveHookConfigEntries(first).find((entry) => entry.name === "lkg")?.entries[0]?.command, "echo first");
563
+ await writeFile(lkgPath, "{ invalid json\n", "utf8");
564
+ const invalid = await resolveHooksConfig({ configDir, project, sourceRuntime });
565
+ const retained = effectiveHookConfigEntries(invalid).find((entry) => entry.name === "lkg")?.entries[0]?.command === "echo first";
566
+ assert.equal(invalid.diagnostics.some((entry) => entry.usingLastKnownGood === true), true);
567
+ await rm(lkgPath, { force: true });
568
+ const deleted = await resolveHooksConfig({ configDir, project, sourceRuntime });
569
+ const deletionCleared = effectiveHookConfigEntries(deleted).every((entry) => entry.name !== "lkg");
570
+
571
+ const trustRequirements = [];
572
+ const denyPolicy = {
573
+ async authorize(requirement) {
574
+ trustRequirements.push(requirement);
575
+ throw new Error("PACKAGED_TRUST_DENIED");
576
+ },
577
+ };
578
+ const logging = {
579
+ level: "silent",
580
+ format: "json",
581
+ requests: false,
582
+ assets: false,
583
+ toolCalls: false,
584
+ shellCommands: false,
585
+ trustProxy: false,
586
+ };
587
+ const commandShellRuntime = process.platform === "win32"
588
+ ? { family: "cmd", executable: process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe", source: "compatibility-default", capabilities: [] }
589
+ : { family: "bash", executable: "/bin/bash", source: "compatibility-default", capabilities: [] };
590
+ const runner = new HookRunner(
591
+ {},
592
+ logging,
593
+ process.env,
594
+ undefined,
595
+ commandShellRuntime,
596
+ configDir,
597
+ sourceRuntime,
598
+ denyPolicy,
599
+ );
600
+ let trustDeniedBeforeSpawn = false;
601
+ try {
602
+ await runner.run("BeforeTool", {
603
+ workspaceRoot: projectRoot,
604
+ cwd: projectRoot,
605
+ payload: { tool: "read" },
606
+ });
607
+ } catch (error) {
608
+ trustDeniedBeforeSpawn = String(error?.message ?? error).includes("PACKAGED_TRUST_DENIED");
609
+ }
610
+ assert.equal(trustRequirements.length, 1);
611
+ assert.equal(trustDeniedBeforeSpawn, true);
612
+
613
+ const requirement = trustRequirements[0];
614
+ await writeFile(outputPath, JSON.stringify({
615
+ projectId: project.id,
616
+ projectIdentityStable: project.id === again.id,
617
+ projectLocalWinner: shared?.entries[0]?.command.replace("echo ", ""),
618
+ lastKnownGoodRetained: retained,
619
+ deletionCleared,
620
+ trustCalls: trustRequirements.length,
621
+ trustProjectId: requirement.projectId,
622
+ trustDisplay: requirement.display.name,
623
+ trustDeniedBeforeSpawn,
624
+ }), "utf8");
625
+ } else {
626
+ throw new Error("Unknown installed Config v2 probe mode: " + mode);
627
+ }
628
+
629
+ function normalize(value) {
630
+ if (Array.isArray(value)) return value.map(normalize);
631
+ if (!value || typeof value !== "object") return value;
632
+ return Object.fromEntries(
633
+ Object.entries(value)
634
+ .sort(([left], [right]) => left.localeCompare(right))
635
+ .map(([key, entry]) => [key, normalize(entry)]),
636
+ );
637
+ }
638
+ `;
639
+ }