@akira-tl/forgerelay 1.2.4 → 1.3.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 (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -11
  3. package/dist/cli/config/domains/context-cli.js +86 -0
  4. package/dist/cli/config/domains/domain-cli.js +468 -0
  5. package/dist/cli/config/general.js +174 -0
  6. package/dist/cli/config/inspect.js +29 -26
  7. package/dist/cli/config/migrate.js +11 -24
  8. package/dist/cli/config/scope.js +35 -0
  9. package/dist/cli/connect/relay.js +284 -0
  10. package/dist/cli/core/command-tree.js +68 -0
  11. package/dist/cli/core/serve-options.js +71 -0
  12. package/dist/cli/init/setup-config.js +26 -2
  13. package/dist/cli/init.js +37 -8
  14. package/dist/cli/maintenance-prune.js +1 -1
  15. package/dist/cli/maintenance.js +6 -6
  16. package/dist/cli/mcp/external-mcp.js +29 -17
  17. package/dist/cli/mcp/status.js +2 -2
  18. package/dist/cli/setup-support.js +3 -2
  19. package/dist/cli/system/status.js +35 -0
  20. package/dist/cli.js +132 -272
  21. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
  22. package/dist/mcp/server/core/schemas.js +2 -10
  23. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
  24. package/dist/runtime/config/config.js +30 -29
  25. package/dist/runtime/config/definition/general-config.js +22 -6
  26. package/dist/runtime/config/external-mcp-config.js +4 -3
  27. package/dist/runtime/config/resolution/resolver.js +7 -4
  28. package/dist/runtime/config/user-config.js +9 -6
  29. package/dist/runtime/config/validation/paths.js +12 -0
  30. package/dist/runtime/config/validation/ports.js +9 -0
  31. package/dist/subagents/profiles.js +37 -0
  32. package/dist/workspaces/bootstrap.js +31 -14
  33. package/dist/workspaces/context.js +159 -7
  34. package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
  35. package/dist/workspaces/resources/context-sources.js +29 -0
  36. package/dist/workspaces/resources/resource-monitor.js +29 -6
  37. package/dist/workspaces/resources/skills.js +15 -10
  38. package/dist/workspaces/sessions.js +4 -2
  39. package/dist/workspaces/state/project-context.js +34 -8
  40. package/dist/workspaces.js +5 -2
  41. package/docs/chatgpt-coding-workflow.md +23 -20
  42. package/docs/configuration.md +40 -27
  43. package/docs/gotchas.md +8 -6
  44. package/docs/roadmap.md +1 -1
  45. package/package.json +2 -2
  46. package/schemas/v1/config.project-local.schema.json +74 -0
  47. package/schemas/v1/config.project.schema.json +74 -0
  48. package/schemas/v1/config.user.schema.json +59 -4
  49. package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
  50. package/scripts/debug/runtime.mjs +19 -3
  51. package/scripts/debug/runtime.test.mjs +3 -0
  52. package/scripts/debug/serve.mjs +2 -2
@@ -1,4 +1,4 @@
1
- import { join, resolve } from "node:path";
1
+ import { join } from "node:path";
2
2
  import { forgerelayConfigDir } from "../../runtime/config/user-config.js";
3
3
  import { ExternalMcpConfigRegistry } from "../../runtime/config/external-mcp-registry.js";
4
4
  import { resolveGeneralConfig } from "../../runtime/config/resolution/general.js";
@@ -8,12 +8,13 @@ import { readJsonConfigSource } from "../../runtime/config/resolution/project-so
8
8
  import { ConfigSourceRuntime } from "../../runtime/config/runtime/source-refresh.js";
9
9
  import { resolveSubagentProfilesConfigSources } from "../../subagents/profiles.js";
10
10
  import { ProjectContextResolver } from "../../workspaces/state/project-context.js";
11
+ import { parseConfigScopeArgs } from "./scope.js";
11
12
  const OFFLINE_LIVE_STATE = {
12
13
  mode: "offline",
13
14
  lastKnownGood: "unknown",
14
15
  appliedValues: "unknown",
15
16
  };
16
- export async function runConfigInspection(args) {
17
+ export async function runConfigInspection(args, domainFilter, logicalPathFilter) {
17
18
  let options;
18
19
  try {
19
20
  options = parseInspectionArgs(args);
@@ -25,6 +26,10 @@ export async function runConfigInspection(args) {
25
26
  let domains;
26
27
  try {
27
28
  domains = await resolveInspectionDomains(options);
29
+ if (domainFilter)
30
+ domains = domains.filter((domain) => domain.domain === domainFilter);
31
+ if (logicalPathFilter)
32
+ domains = domains.map((domain) => filterResolvedDomain(domain, logicalPathFilter));
28
33
  }
29
34
  catch (error) {
30
35
  console.error(error instanceof Error ? error.message : String(error));
@@ -95,38 +100,20 @@ export async function runConfigInspection(args) {
95
100
  return summary.errors > 0 ? 1 : 0;
96
101
  }
97
102
  function parseInspectionArgs(args) {
98
- const [command, ...rest] = args;
103
+ const scoped = parseConfigScopeArgs(args);
104
+ const [command, ...rest] = scoped.rest;
99
105
  if (command !== "check" && command !== "sources" && command !== "explain") {
100
106
  throw new Error("Expected config check, config sources, or config explain <logical-path>.");
101
107
  }
102
108
  let json = false;
103
- let global = false;
104
- let projectRoot;
105
109
  let logicalPath;
106
- for (let index = 0; index < rest.length; index += 1) {
107
- const arg = rest[index];
110
+ for (const arg of rest) {
108
111
  if (arg === "--json") {
109
112
  if (json)
110
113
  throw new Error("--json may only be supplied once.");
111
114
  json = true;
112
115
  continue;
113
116
  }
114
- if (arg === "--global") {
115
- if (global || projectRoot)
116
- throw new Error("--global and --project cannot be used together or repeated.");
117
- global = true;
118
- continue;
119
- }
120
- if (arg === "--project") {
121
- if (global || projectRoot)
122
- throw new Error("--global and --project cannot be used together or repeated.");
123
- const value = rest[index + 1];
124
- if (!value || value.startsWith("--"))
125
- throw new Error("--project requires a project path.");
126
- projectRoot = resolve(value);
127
- index += 1;
128
- continue;
129
- }
130
117
  if (command === "explain" && logicalPath === undefined && !arg.startsWith("--")) {
131
118
  logicalPath = arg;
132
119
  continue;
@@ -138,9 +125,7 @@ function parseInspectionArgs(args) {
138
125
  return {
139
126
  command,
140
127
  json,
141
- scope: global
142
- ? { mode: "global" }
143
- : { mode: "project", projectRoot: projectRoot ?? resolve(process.env.FORGERELAY_WORKSPACE_ROOT ?? process.cwd()) },
128
+ scope: scoped.scope,
144
129
  ...(logicalPath ? { logicalPath } : {}),
145
130
  };
146
131
  }
@@ -210,6 +195,24 @@ async function resolveInspectionDomains(options) {
210
195
  });
211
196
  return [general, mcp, languageServers, hooks, subagents];
212
197
  }
198
+ function filterResolvedDomain(domain, logicalPaths) {
199
+ const entries = Object.fromEntries(Object.entries(domain.entries).filter(([, entry]) => logicalPaths.has(entry.logicalPath)));
200
+ const diagnostics = domain.diagnostics.filter((diagnostic) => diagnostic.logicalPath === undefined || logicalPaths.has(diagnostic.logicalPath));
201
+ const sourceIds = new Set();
202
+ for (const entry of Object.values(entries)) {
203
+ sourceIds.add(entry.effective.source.id);
204
+ for (const shadowed of entry.shadowed)
205
+ sourceIds.add(shadowed.source.id);
206
+ }
207
+ for (const diagnostic of diagnostics)
208
+ sourceIds.add(diagnostic.source.id);
209
+ return {
210
+ ...domain,
211
+ entries,
212
+ diagnostics,
213
+ sources: domain.sources.filter((source) => sourceIds.has(source.id)),
214
+ };
215
+ }
213
216
  function shadowDiagnostics(domain) {
214
217
  return Object.values(domain.entries).flatMap((entry) => entry.shadowed.map((shadowed) => ({
215
218
  severity: shadowed.reason === "source-shadowed" ? "warning" : "info",
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
+ import { parseConfigSource } from "../../runtime/config/definition/definition.js";
5
6
  import { configSchemaId } from "../../runtime/config/definition/schema.js";
6
7
  import { generalConfigDefinition } from "../../runtime/config/definition/general-config.js";
7
8
  import { externalMcpConfigDefinition } from "../../runtime/config/definition/external-mcp.js";
@@ -13,6 +14,7 @@ import { hooksConfigDefinition, normalizeLegacyHookEntries, } from "../../mcp/ho
13
14
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
14
15
  import { canonicalSubagentProfileDocumentFromLegacy } from "../../subagents/profiles.js";
15
16
  import { resolveProjectContext } from "../../workspaces/state/project-context.js";
17
+ import { parseConfigScopeArgs } from "./scope.js";
16
18
  export async function runConfigMigration(args) {
17
19
  const options = parseMigrationArgs(args);
18
20
  const files = loadForgeRelayFiles(process.env, { readLegacyHooks: true });
@@ -52,37 +54,20 @@ export async function runConfigMigration(args) {
52
54
  printPlan(plan);
53
55
  }
54
56
  function parseMigrationArgs(args) {
57
+ const scoped = parseConfigScopeArgs(args);
55
58
  let dryRun = false;
56
- let scope;
57
- let projectPath;
58
- for (let index = 0; index < args.length; index += 1) {
59
- const arg = args[index];
59
+ for (const arg of scoped.rest) {
60
60
  if (arg === "--dry-run") {
61
61
  if (dryRun)
62
62
  throw new Error("--dry-run may only be supplied once.");
63
63
  dryRun = true;
64
64
  continue;
65
65
  }
66
- if (arg === "--global") {
67
- if (scope)
68
- throw new Error("Choose exactly one migration scope: --global or --project <path>.");
69
- scope = "global";
70
- continue;
71
- }
72
- if (arg === "--project") {
73
- if (scope)
74
- throw new Error("Choose exactly one migration scope: --global or --project <path>.");
75
- const value = args[index + 1];
76
- if (!value || value.startsWith("--"))
77
- throw new Error("--project requires a project path.");
78
- scope = "project";
79
- projectPath = resolve(value);
80
- index += 1;
81
- continue;
82
- }
83
66
  throw new Error(`Unknown config migrate option: ${arg}`);
84
67
  }
85
- return { dryRun, scope: scope ?? "global", ...(projectPath ? { projectPath } : {}) };
68
+ return scoped.scope.mode === "global"
69
+ ? { dryRun, scope: "global" }
70
+ : { dryRun, scope: "project", projectPath: scoped.scope.projectRoot };
86
71
  }
87
72
  function buildGlobalMigrationPlan(configDir) {
88
73
  const files = loadForgeRelayFiles({ ...process.env, FORGERELAY_CONFIG_DIR: configDir }, { readLegacyHooks: true });
@@ -137,8 +122,10 @@ function buildGlobalMigrationPlan(configDir) {
137
122
  nextConfig.$schema = generalSchema;
138
123
  configChanged = true;
139
124
  }
140
- if (configChanged)
141
- writes.push(jsonWrite(files.configPath, nextConfig));
125
+ if (configChanged) {
126
+ const validatedConfig = parseConfigSource(generalConfigDefinition, "user", nextConfig);
127
+ writes.push(jsonWrite(files.configPath, validatedConfig));
128
+ }
142
129
  if (configChanged && files.configExists)
143
130
  backupSources.push({ path: files.configPath, relativePath: "config.json" });
144
131
  if (files.hooksExists)
@@ -0,0 +1,35 @@
1
+ import { resolve } from "node:path";
2
+ export function parseConfigScopeArgs(args, env = process.env) {
3
+ let global = false;
4
+ let projectRoot;
5
+ const rest = [];
6
+ for (let index = 0; index < args.length; index += 1) {
7
+ const arg = args[index];
8
+ if (arg === "--global") {
9
+ if (global || projectRoot)
10
+ throw new Error("--global and --project cannot be used together or repeated.");
11
+ global = true;
12
+ continue;
13
+ }
14
+ if (arg === "--project") {
15
+ if (global || projectRoot)
16
+ throw new Error("--global and --project cannot be used together or repeated.");
17
+ const value = args[index + 1];
18
+ if (!value || value.startsWith("--"))
19
+ throw new Error("--project requires a project path.");
20
+ projectRoot = resolve(value);
21
+ index += 1;
22
+ continue;
23
+ }
24
+ if (arg === "--project-local") {
25
+ throw new Error("--project-local is not a public configuration scope.");
26
+ }
27
+ rest.push(arg);
28
+ }
29
+ return {
30
+ scope: global
31
+ ? { mode: "global" }
32
+ : { mode: "project", projectRoot: projectRoot ?? resolve(env.FORGERELAY_WORKSPACE_ROOT ?? process.cwd()) },
33
+ rest,
34
+ };
35
+ }
@@ -0,0 +1,284 @@
1
+ import { stdin as input, stdout as output } from "node:process";
2
+ import * as prompts from "@clack/prompts";
3
+ import { ensureForgeRelayInstanceId, loadForgeRelayFiles, removeForgeRelayRemote, renameForgeRelayRemote, writeForgeRelayRemote, } from "../../runtime/config/user-config.js";
4
+ import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "../../workspaces/relay/auth/remote-auth.js";
5
+ import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "../../workspaces/relay/transport/remote-transport.js";
6
+ export async function runRelayCommand(args) {
7
+ const [subcommand, ...rest] = args;
8
+ if (subcommand === "__owner-token") {
9
+ if (rest.length > 0)
10
+ throw new Error("Internal owner-token command does not accept arguments.");
11
+ process.stdout.write(`${localOwnerToken()}\n`);
12
+ return;
13
+ }
14
+ if (subcommand === "list") {
15
+ if (rest.length > 0)
16
+ throw new Error("forgerelay connect relay list does not accept additional arguments.");
17
+ printRemoteList();
18
+ return;
19
+ }
20
+ if (subcommand === "status") {
21
+ if (rest.length > 1)
22
+ throw new Error("Usage: forgerelay connect relay status [alias]");
23
+ printRemoteStatus(rest[0]);
24
+ return;
25
+ }
26
+ if (subcommand === "rename") {
27
+ const [fromAlias, toAlias, ...extra] = rest;
28
+ if (!fromAlias || !toAlias || extra.length > 0) {
29
+ throw new Error("Usage: forgerelay connect relay rename <old-alias> <new-alias>");
30
+ }
31
+ await renameForgeRelayRemote(fromAlias, toAlias);
32
+ console.log(`Renamed remote ${fromAlias} to ${toAlias}.`);
33
+ return;
34
+ }
35
+ if (subcommand === "remove") {
36
+ const [alias, ...extra] = rest;
37
+ if (!alias || extra.length > 0) {
38
+ throw new Error("Usage: forgerelay connect relay remove <alias>");
39
+ }
40
+ await removeForgeRelayRemote(alias);
41
+ console.log(`Removed remote ${alias}.`);
42
+ return;
43
+ }
44
+ if (subcommand === "test") {
45
+ const [alias, ...extra] = rest;
46
+ if (!alias || extra.length > 0)
47
+ throw new Error("Usage: forgerelay connect relay test <alias>");
48
+ await testRemote(alias);
49
+ return;
50
+ }
51
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
52
+ printRelayHelp();
53
+ return;
54
+ }
55
+ let parsed = parseRelayCommandArgs(args);
56
+ if (!parsed.target) {
57
+ if (!input.isTTY || !output.isTTY) {
58
+ throw new Error("Missing remote service target in non-interactive mode. Pass a target or run `forgerelay connect relay` in an interactive terminal.");
59
+ }
60
+ parsed = await completeInteractiveRelaySetup(parsed);
61
+ }
62
+ await authenticateAndPersist(parsed);
63
+ }
64
+ function parseRelayCommandArgs(args) {
65
+ let target;
66
+ let alias;
67
+ let ownerToken;
68
+ let sshRoute;
69
+ let sshAuth = false;
70
+ for (let index = 0; index < args.length; index += 1) {
71
+ const arg = args[index];
72
+ if (arg === "--alias") {
73
+ alias = args[++index];
74
+ if (!alias)
75
+ throw new Error("Missing value for --alias.");
76
+ continue;
77
+ }
78
+ if (arg === "--token") {
79
+ ownerToken = args[++index];
80
+ if (!ownerToken)
81
+ throw new Error("Missing value for --token.");
82
+ continue;
83
+ }
84
+ if (arg === "-J") {
85
+ const route = args[++index];
86
+ if (!route)
87
+ throw new Error("Missing value for -J.");
88
+ sshRoute = parseSshRoute(route);
89
+ continue;
90
+ }
91
+ if (arg === "--ssh-auth") {
92
+ sshAuth = true;
93
+ continue;
94
+ }
95
+ if (arg.startsWith("-"))
96
+ throw new Error(`Unknown relay option: ${arg}`);
97
+ if (target)
98
+ throw new Error(`Unexpected relay argument: ${arg}`);
99
+ target = arg;
100
+ }
101
+ if (sshAuth && !sshRoute)
102
+ throw new Error("--ssh-auth requires -J <ssh-route>.");
103
+ if (sshAuth && ownerToken)
104
+ throw new Error("--ssh-auth and --token cannot be used together.");
105
+ return { target, alias, ownerToken, sshRoute, sshAuth };
106
+ }
107
+ async function completeInteractiveRelaySetup(parsed) {
108
+ const target = parsed.target ?? await promptText("Remote service target", "", (value) => value?.trim() ? undefined : "Enter the remote service target.");
109
+ let sshRoute = parsed.sshRoute;
110
+ if (!parsed.sshRoute) {
111
+ const selectedRoute = await prompts.select({
112
+ message: "Connection route",
113
+ initialValue: "direct",
114
+ options: [
115
+ { value: "direct", label: "Direct" },
116
+ { value: "ssh", label: "SSH" },
117
+ ],
118
+ });
119
+ if (prompts.isCancel(selectedRoute))
120
+ throw new Error("Remote connection setup cancelled.");
121
+ if (selectedRoute === "ssh") {
122
+ const route = await promptText("SSH route (-J)", "", (value) => value?.trim() ? undefined : "Enter the SSH route.");
123
+ sshRoute = parseSshRoute(route);
124
+ }
125
+ }
126
+ const normalizedTarget = normalizeRemoteServiceTarget(target);
127
+ const defaultAlias = sshRoute
128
+ ? defaultSshRouteAlias(sshRoute)
129
+ : defaultRemoteAlias(normalizedTarget);
130
+ const alias = parsed.alias ?? await promptText("Forge alias", defaultAlias);
131
+ let ownerToken = parsed.ownerToken;
132
+ let sshAuth = parsed.sshAuth;
133
+ if (!ownerToken && !sshAuth) {
134
+ if (sshRoute) {
135
+ const selectedAuthentication = await prompts.select({
136
+ message: "Authentication",
137
+ initialValue: "ssh",
138
+ options: [
139
+ { value: "ssh", label: "Read owner token over SSH" },
140
+ { value: "token", label: "Enter owner token locally" },
141
+ ],
142
+ });
143
+ if (prompts.isCancel(selectedAuthentication))
144
+ throw new Error("Remote connection setup cancelled.");
145
+ if (selectedAuthentication === "ssh")
146
+ sshAuth = true;
147
+ else
148
+ ownerToken = await promptOwnerToken();
149
+ }
150
+ else {
151
+ ownerToken = await promptOwnerToken();
152
+ }
153
+ }
154
+ return { target, alias, ownerToken, sshRoute, sshAuth };
155
+ }
156
+ async function authenticateAndPersist(parsed) {
157
+ if (!parsed.target)
158
+ throw new Error("Missing remote service target.");
159
+ const target = normalizeRemoteServiceTarget(parsed.target);
160
+ const authenticated = await withRemoteServiceEndpoint(target, parsed.sshRoute, async (endpoint) => {
161
+ const ownerToken = parsed.sshAuth
162
+ ? await readRemoteOwnerToken(parsed.sshRoute ?? [])
163
+ : await resolveOwnerToken(parsed.ownerToken);
164
+ return authenticateRemote(endpoint, ownerToken);
165
+ });
166
+ const remote = {
167
+ ...authenticated,
168
+ target,
169
+ ...(parsed.sshRoute ? { sshRoute: parsed.sshRoute } : {}),
170
+ };
171
+ const files = loadForgeRelayFiles();
172
+ const existingAlias = Object.entries(files.auth.remotes ?? {}).find(([, record]) => record.instanceId === remote.instanceId)?.[0];
173
+ const defaultAlias = parsed.sshRoute
174
+ ? defaultSshRouteAlias(parsed.sshRoute)
175
+ : defaultRemoteAlias(remote.target);
176
+ const alias = parsed.alias?.trim() || existingAlias || defaultAlias;
177
+ if (!files.auth.instanceId)
178
+ await ensureForgeRelayInstanceId();
179
+ await writeForgeRelayRemote(alias, remote);
180
+ console.log(`Authenticated remote ${alias} (${remote.instanceId}).`);
181
+ }
182
+ async function resolveOwnerToken(ownerToken) {
183
+ if (ownerToken)
184
+ return ownerToken;
185
+ if (!input.isTTY || !output.isTTY) {
186
+ throw new Error("Missing owner token. Pass --token, use --ssh-auth with -J, or run in an interactive terminal.");
187
+ }
188
+ return promptOwnerToken();
189
+ }
190
+ async function promptOwnerToken() {
191
+ const result = await prompts.password({
192
+ message: "Remote ForgeRelay owner token",
193
+ validate: (value) => value?.trim() ? undefined : "Enter the remote owner token.",
194
+ });
195
+ if (prompts.isCancel(result))
196
+ throw new Error("Remote connection setup cancelled.");
197
+ return String(result);
198
+ }
199
+ async function promptText(message, defaultValue, validate) {
200
+ const result = await prompts.text({
201
+ message,
202
+ ...(defaultValue ? { placeholder: defaultValue } : {}),
203
+ validate: (value) => validate?.(value?.trim() ? value : defaultValue),
204
+ });
205
+ if (prompts.isCancel(result))
206
+ throw new Error("Remote connection setup cancelled.");
207
+ const value = String(result).trim();
208
+ return value || defaultValue;
209
+ }
210
+ function localOwnerToken() {
211
+ const token = process.env.FORGERELAY_OAUTH_OWNER_TOKEN
212
+ ?? loadForgeRelayFiles().auth.ownerToken;
213
+ if (!token)
214
+ throw new Error("ForgeRelay owner token is not configured on this machine.");
215
+ return token;
216
+ }
217
+ function printRemoteList() {
218
+ const remotes = loadForgeRelayFiles().auth.remotes ?? {};
219
+ if (Object.keys(remotes).length === 0) {
220
+ console.log("No remote ForgeRelay instances registered.");
221
+ return;
222
+ }
223
+ for (const [alias, remote] of Object.entries(remotes).sort(([left], [right]) => left.localeCompare(right))) {
224
+ console.log(`${alias}\t${remote.target}\t${remote.instanceId}`);
225
+ }
226
+ }
227
+ function printRemoteStatus(alias) {
228
+ const remotes = loadForgeRelayFiles().auth.remotes ?? {};
229
+ if (alias) {
230
+ const remote = remotes[alias];
231
+ if (!remote)
232
+ throw new Error(`Unknown remote alias: ${alias}`);
233
+ console.log(`${alias}\tregistered\t${remote.target}\t${remote.instanceId}`);
234
+ return;
235
+ }
236
+ if (Object.keys(remotes).length === 0) {
237
+ console.log("No remote ForgeRelay instances registered.");
238
+ return;
239
+ }
240
+ for (const [name, remote] of Object.entries(remotes).sort(([left], [right]) => left.localeCompare(right))) {
241
+ console.log(`${name}\tregistered\t${remote.target}\t${remote.instanceId}`);
242
+ }
243
+ }
244
+ async function testRemote(alias) {
245
+ const files = loadForgeRelayFiles();
246
+ const storedRemote = files.auth.remotes?.[alias];
247
+ if (!storedRemote)
248
+ throw new Error(`Unknown remote alias: ${alias}`);
249
+ let remote = storedRemote;
250
+ await withRemoteServiceEndpoint(remote.target, remote.sshRoute, async (endpoint) => {
251
+ let refreshed = false;
252
+ if (remote.accessTokenExpiresAt <= Math.floor(Date.now() / 1000)) {
253
+ remote = await refreshRemoteAuthentication(remote, endpoint);
254
+ await writeForgeRelayRemote(alias, remote);
255
+ refreshed = true;
256
+ }
257
+ try {
258
+ await verifyRemoteMcp(remote, endpoint);
259
+ }
260
+ catch (error) {
261
+ if (refreshed || !isRemoteMcpUnauthorized(error))
262
+ throw error;
263
+ remote = await refreshRemoteAuthentication(remote, endpoint);
264
+ await writeForgeRelayRemote(alias, remote);
265
+ await verifyRemoteMcp(remote, endpoint);
266
+ }
267
+ });
268
+ console.log(`${alias}\tok\t${remote.instanceId}`);
269
+ }
270
+ function printRelayHelp() {
271
+ console.log([
272
+ "ForgeRelay connect relay",
273
+ "",
274
+ "Usage:",
275
+ " forgerelay connect relay",
276
+ " forgerelay connect relay <target> [--alias <name>] [--token <owner-token>]",
277
+ " forgerelay connect relay -J <ssh-route> <target> [--ssh-auth|--token <owner-token>] [--alias <name>]",
278
+ " forgerelay connect relay list",
279
+ " forgerelay connect relay status [alias]",
280
+ " forgerelay connect relay test <alias>",
281
+ " forgerelay connect relay rename <old-alias> <new-alias>",
282
+ " forgerelay connect relay remove <alias>",
283
+ ].join("\n"));
284
+ }
@@ -0,0 +1,68 @@
1
+ export const CLI_ROOT_ROUTES = [
2
+ { command: "serve", handler: "serve", publicSummary: "Start the ForgeRelay runtime" },
3
+ { command: "init", handler: "init", publicSummary: "Run first-time or setup-owned configuration" },
4
+ { command: "config", handler: "config", publicSummary: "Inspect and mutate declarative configuration" },
5
+ { command: "connect", handler: "connect", publicSummary: "Manage remote ForgeRelay and External MCP relationships" },
6
+ { command: "system", handler: "system", publicSummary: "Diagnose and maintain ForgeRelay" },
7
+ { command: "help", handler: "help", publicSummary: "Show this help" },
8
+ { command: "version", handler: "version", publicSummary: "Print the installed version" },
9
+ // Compatibility-only routes. These remain dispatchable during the supported
10
+ // compatibility window but never appear in generated public root help.
11
+ { command: "start", handler: "serve" },
12
+ { command: "doctor", handler: "system", argsPrefix: ["doctor"] },
13
+ { command: "hooks", handler: "config", argsPrefix: ["hooks", "--compat"] },
14
+ { command: "auth", handler: "connect", argsPrefix: ["relay"] },
15
+ { command: "mcp", handler: "connect", argsPrefix: ["mcp"] },
16
+ { command: "maintenance", handler: "system" },
17
+ { command: "agents", compatibilityHandler: "agents" },
18
+ { command: "--help", handler: "help" },
19
+ { command: "-h", handler: "help" },
20
+ { command: "--version", handler: "version" },
21
+ { command: "-v", handler: "version" },
22
+ ];
23
+ export function resolveCliRootRoute(command) {
24
+ if (command === undefined)
25
+ return { command: "help", handler: "help" };
26
+ const route = CLI_ROOT_ROUTES.find((candidate) => candidate.command === command);
27
+ if (!route)
28
+ throw new Error(`Unknown command: ${command}`);
29
+ return route;
30
+ }
31
+ export function routeArguments(route, args) {
32
+ return [...(route.argsPrefix ?? []), ...args];
33
+ }
34
+ const SERVE_OPTION_HELP_LINES = [
35
+ "--host <host> Override the bind host for this invocation",
36
+ "--port <port> Override the listen port for this invocation",
37
+ "--root <path> Override allowed roots; repeat for multiple roots",
38
+ "--public-url <url> Override client-facing base URLs; repeat for multiple URLs",
39
+ "--allow-elevated Explicitly allow this invocation to run with elevated/unknown OS privilege",
40
+ ];
41
+ export function renderCliRootHelp() {
42
+ const publicRoutes = CLI_ROOT_ROUTES.filter((route) => route.publicSummary !== undefined);
43
+ const commandWidth = Math.max(...publicRoutes.map((route) => route.command.length));
44
+ return [
45
+ "ForgeRelay",
46
+ "",
47
+ "Usage:",
48
+ " forgerelay Show help",
49
+ " forgerelay <command> [options] Run a command",
50
+ "",
51
+ "Commands:",
52
+ ...publicRoutes.map((route) => ` forgerelay ${route.command.padEnd(commandWidth)} ${route.publicSummary}`),
53
+ "",
54
+ "Serve options:",
55
+ ...SERVE_OPTION_HELP_LINES.map((line) => ` forgerelay serve ${line}`),
56
+ ].join("\n");
57
+ }
58
+ export function renderServeHelp() {
59
+ return [
60
+ "ForgeRelay serve",
61
+ "",
62
+ "Usage:",
63
+ " forgerelay serve [options]",
64
+ "",
65
+ "Options:",
66
+ ...SERVE_OPTION_HELP_LINES.map((line) => ` ${line}`),
67
+ ].join("\n");
68
+ }
@@ -0,0 +1,71 @@
1
+ import { normalizeAllowedRootPath } from "../../runtime/config/validation/paths.js";
2
+ import { normalizePublicBaseUrlsInput, validateBindAddress, validateClientFacingBaseUrls, validatePort, } from "../setup-support.js";
3
+ export function parseServeCommandArgs(args) {
4
+ let allowElevated = false;
5
+ let host;
6
+ let port;
7
+ const roots = [];
8
+ const publicBaseUrls = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index];
11
+ if (arg === "--allow-elevated") {
12
+ if (allowElevated)
13
+ throw new Error("--allow-elevated may only be supplied once.");
14
+ allowElevated = true;
15
+ continue;
16
+ }
17
+ if (arg === "--host") {
18
+ if (host !== undefined)
19
+ throw new Error("--host may only be supplied once.");
20
+ const value = args[++index];
21
+ if (value === undefined)
22
+ throw new Error("Missing value for --host.");
23
+ const validation = validateBindAddress(value);
24
+ if (validation)
25
+ throw new Error(`Invalid --host: ${validation}`);
26
+ host = value.trim();
27
+ continue;
28
+ }
29
+ if (arg === "--port") {
30
+ if (port !== undefined)
31
+ throw new Error("--port may only be supplied once.");
32
+ const value = args[++index];
33
+ if (value === undefined)
34
+ throw new Error("Missing value for --port.");
35
+ const validation = validatePort(value);
36
+ if (validation)
37
+ throw new Error(`Invalid --port: ${validation}`);
38
+ port = Number(value);
39
+ continue;
40
+ }
41
+ if (arg === "--root") {
42
+ const value = args[++index];
43
+ if (value === undefined)
44
+ throw new Error("Missing value for --root.");
45
+ roots.push(normalizeAllowedRootPath(value));
46
+ continue;
47
+ }
48
+ if (arg === "--public-url") {
49
+ const value = args[++index];
50
+ if (value === undefined)
51
+ throw new Error("Missing value for --public-url.");
52
+ const validation = validateClientFacingBaseUrls(value);
53
+ if (validation)
54
+ throw new Error(`Invalid --public-url: ${validation}`);
55
+ publicBaseUrls.push(...normalizePublicBaseUrlsInput(value));
56
+ continue;
57
+ }
58
+ throw new Error(`Unknown serve option: ${arg}`);
59
+ }
60
+ return {
61
+ allowElevated,
62
+ runtimeOverrides: {
63
+ ...(host === undefined ? {} : { host }),
64
+ ...(port === undefined ? {} : { port }),
65
+ ...(roots.length === 0 ? {} : { allowedRoots: roots }),
66
+ ...(publicBaseUrls.length === 0
67
+ ? {}
68
+ : { publicBaseUrl: Array.from(new Set(publicBaseUrls)) }),
69
+ },
70
+ };
71
+ }