@akira-tl/forgerelay 1.2.5 → 1.3.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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -2
  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 +6 -22
  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 -0
  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/system/status.js +35 -0
  19. package/dist/cli.js +132 -272
  20. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +2 -2
  21. package/dist/mcp/server/core/schemas.js +2 -10
  22. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -7
  23. package/dist/runtime/config/config.js +30 -29
  24. package/dist/runtime/config/definition/general-config.js +18 -3
  25. package/dist/runtime/config/resolution/resolver.js +7 -4
  26. package/dist/runtime/config/user-config.js +6 -6
  27. package/dist/runtime/config/validation/paths.js +12 -0
  28. package/dist/subagents/profiles.js +37 -0
  29. package/dist/workspaces/bootstrap.js +31 -14
  30. package/dist/workspaces/context.js +159 -7
  31. package/dist/workspaces/relay/auth/cli-test-support.js +22 -0
  32. package/dist/workspaces/resources/context-sources.js +29 -0
  33. package/dist/workspaces/resources/resource-monitor.js +29 -6
  34. package/dist/workspaces/resources/skills.js +15 -10
  35. package/dist/workspaces/sessions.js +20 -2
  36. package/dist/workspaces/state/project-context.js +34 -8
  37. package/dist/workspaces.js +5 -2
  38. package/docs/chatgpt-coding-workflow.md +23 -20
  39. package/docs/configuration.md +40 -27
  40. package/docs/gotchas.md +8 -6
  41. package/docs/roadmap.md +1 -1
  42. package/package.json +2 -2
  43. package/schemas/v1/config.project-local.schema.json +74 -0
  44. package/schemas/v1/config.project.schema.json +74 -0
  45. package/schemas/v1/config.user.schema.json +57 -2
  46. package/scripts/ci/config-v2-product-acceptance.mjs +17 -12
  47. package/scripts/debug/runtime.mjs +19 -3
  48. package/scripts/debug/runtime.test.mjs +3 -0
  49. package/scripts/debug/serve.mjs +2 -2
@@ -0,0 +1,174 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { forgerelayConfigDir, loadForgeRelayFiles, writeConfigJsonFile, writeForgeRelayConfig, } from "../../runtime/config/user-config.js";
4
+ import { parseConfigSource } from "../../runtime/config/definition/definition.js";
5
+ import { generalConfigDefinition } from "../../runtime/config/definition/general-config.js";
6
+ import { resolveGeneralConfig } from "../../runtime/config/resolution/general.js";
7
+ import { readJsonConfigSource } from "../../runtime/config/resolution/project-sources.js";
8
+ import { assertConfigResolutionValid } from "../../runtime/config/resolution/resolver.js";
9
+ import { ProjectContextResolver } from "../../workspaces/state/project-context.js";
10
+ import { normalizeOptionalPublicBaseUrl } from "../setup-support.js";
11
+ import { parseConfigScopeArgs } from "./scope.js";
12
+ export function renderGeneralConfigHelp() {
13
+ return [
14
+ "ForgeRelay config",
15
+ "",
16
+ "Usage:",
17
+ " forgerelay config get [--project <path>|--global]",
18
+ " forgerelay config set <logical-path> <value> [--project <path>|--global]",
19
+ " forgerelay config unset <logical-path> [--project <path>|--global]",
20
+ " forgerelay config check [--project <path>|--global] [--json]",
21
+ " forgerelay config sources [--project <path>|--global] [--json]",
22
+ " forgerelay config explain <logical-path> [--project <path>|--global] [--json]",
23
+ " forgerelay config migrate [--dry-run] [--project <path>|--global]",
24
+ " forgerelay config context <get|set|unset|check|sources|explain> ...",
25
+ " forgerelay config <mcp|hooks|lsp|subagents> <get|set|unset|remove|check|sources|explain> ...",
26
+ ].join("\n");
27
+ }
28
+ export async function runGeneralConfigGet(args) {
29
+ const parsed = parseConfigScopeArgs(args);
30
+ if (parsed.rest.length > 0)
31
+ throw new Error(`Unknown config get option: ${parsed.rest[0]}`);
32
+ const resolution = await resolveGeneralConfigForScope(parsed.scope);
33
+ assertConfigResolutionValid(resolution);
34
+ console.log(JSON.stringify(resolution.values, null, 2));
35
+ }
36
+ export async function resolveGeneralConfigForScope(scope) {
37
+ const configDir = forgerelayConfigDir();
38
+ const userSource = await readJsonConfigSource({
39
+ id: "user:config",
40
+ scope: "user",
41
+ location: join(configDir, "config.json"),
42
+ });
43
+ const project = scope.mode === "project"
44
+ ? await new ProjectContextResolver(configDir).inspect(scope.projectRoot)
45
+ : undefined;
46
+ const projectSource = project
47
+ ? await readJsonConfigSource({
48
+ id: "project:config",
49
+ scope: "project",
50
+ location: join(project.sharedConfigDir, "config.json"),
51
+ })
52
+ : undefined;
53
+ const projectLocalSource = project?.localConfigDir
54
+ ? await readJsonConfigSource({
55
+ id: "project-local:config",
56
+ scope: "project-local",
57
+ location: join(project.localConfigDir, "config.json"),
58
+ })
59
+ : undefined;
60
+ return resolveGeneralConfig({
61
+ env: process.env,
62
+ ...(userSource ? { userSource } : {}),
63
+ ...(projectSource ? { projectSource } : {}),
64
+ ...(projectLocalSource ? { projectLocalSource } : {}),
65
+ });
66
+ }
67
+ export async function runGeneralConfigSet(args) {
68
+ const parsed = parseConfigScopeArgs(args);
69
+ if (parsed.rest.length < 2) {
70
+ throw new Error("Usage: forgerelay config set <logical-path> <value> [--project <path>|--global]");
71
+ }
72
+ const [logicalPath, ...valueParts] = parsed.rest;
73
+ const path = generalPathSegments(logicalPath);
74
+ const rawValue = valueParts.join(" ").trim();
75
+ if (!rawValue)
76
+ throw new Error(`Missing value for ${logicalPath}.`);
77
+ const target = await generalConfigWriteTarget(parsed.scope);
78
+ const next = readGeneralConfigTarget(target);
79
+ setNestedValue(next, path, coerceConfigValue(path, rawValue));
80
+ console.log(`Updated ${writeGeneralConfigTarget(target, next)}`);
81
+ }
82
+ export async function runGeneralConfigUnset(args) {
83
+ const parsed = parseConfigScopeArgs(args);
84
+ if (parsed.rest.length !== 1) {
85
+ throw new Error("Usage: forgerelay config unset <logical-path> [--project <path>|--global]");
86
+ }
87
+ const path = generalPathSegments(parsed.rest[0]);
88
+ const target = await generalConfigWriteTarget(parsed.scope);
89
+ const next = readGeneralConfigTarget(target);
90
+ deleteNestedValue(next, path);
91
+ console.log(`Updated ${writeGeneralConfigTarget(target, next)}`);
92
+ }
93
+ async function generalConfigWriteTarget(scope) {
94
+ const configDir = forgerelayConfigDir();
95
+ if (scope.mode === "global") {
96
+ return { scope: "user", path: join(configDir, "config.json") };
97
+ }
98
+ const project = await new ProjectContextResolver(configDir).inspect(scope.projectRoot);
99
+ return { scope: "project", path: join(project.sharedConfigDir, "config.json") };
100
+ }
101
+ function readGeneralConfigTarget(target) {
102
+ if (target.scope === "user") {
103
+ return structuredClone(loadForgeRelayFiles().config);
104
+ }
105
+ if (!existsSync(target.path))
106
+ return {};
107
+ const parsed = JSON.parse(readFileSync(target.path, "utf8"));
108
+ if (!isRecord(parsed))
109
+ throw new Error(`General configuration must be a JSON object: ${target.path}`);
110
+ return structuredClone(parsed);
111
+ }
112
+ function writeGeneralConfigTarget(target, value) {
113
+ if (target.scope === "user") {
114
+ return writeForgeRelayConfig(value);
115
+ }
116
+ const validated = parseConfigSource(generalConfigDefinition, "project", value);
117
+ mkdirSync(dirname(target.path), { recursive: true });
118
+ writeConfigJsonFile(target.path, validated, 0o600);
119
+ return target.path;
120
+ }
121
+ function generalPathSegments(logicalPath) {
122
+ const normalized = logicalPath.trim();
123
+ const path = normalized.startsWith("config.") ? normalized.slice("config.".length) : normalized;
124
+ const segments = path.split(".").filter(Boolean);
125
+ if (segments.length === 0)
126
+ throw new Error(`Invalid General Config logical path: ${logicalPath}.`);
127
+ return segments;
128
+ }
129
+ function coerceConfigValue(path, rawValue) {
130
+ let value;
131
+ try {
132
+ value = JSON.parse(rawValue);
133
+ }
134
+ catch {
135
+ value = rawValue;
136
+ }
137
+ if (path.length === 1 && path[0] === "publicBaseUrl" && typeof value === "string") {
138
+ return normalizeOptionalPublicBaseUrl(value);
139
+ }
140
+ return value;
141
+ }
142
+ function setNestedValue(target, path, value) {
143
+ let current = target;
144
+ for (const segment of path.slice(0, -1)) {
145
+ const existing = current[segment];
146
+ if (existing !== undefined && !isRecord(existing)) {
147
+ throw new Error(`Cannot set config.${path.join(".")}: config.${segment} is not an object.`);
148
+ }
149
+ const next = existing ?? {};
150
+ current[segment] = next;
151
+ current = next;
152
+ }
153
+ current[path[path.length - 1]] = value;
154
+ }
155
+ function deleteNestedValue(target, path) {
156
+ const parents = [];
157
+ let current = target;
158
+ for (const segment of path.slice(0, -1)) {
159
+ const existing = current[segment];
160
+ if (!isRecord(existing))
161
+ return;
162
+ parents.push({ object: current, key: segment });
163
+ current = existing;
164
+ }
165
+ delete current[path[path.length - 1]];
166
+ for (const parent of parents.reverse()) {
167
+ const value = parent.object[parent.key];
168
+ if (isRecord(value) && Object.keys(value).length === 0)
169
+ delete parent.object[parent.key];
170
+ }
171
+ }
172
+ function isRecord(value) {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
@@ -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",
@@ -14,6 +14,7 @@ import { hooksConfigDefinition, normalizeLegacyHookEntries, } from "../../mcp/ho
14
14
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
15
15
  import { canonicalSubagentProfileDocumentFromLegacy } from "../../subagents/profiles.js";
16
16
  import { resolveProjectContext } from "../../workspaces/state/project-context.js";
17
+ import { parseConfigScopeArgs } from "./scope.js";
17
18
  export async function runConfigMigration(args) {
18
19
  const options = parseMigrationArgs(args);
19
20
  const files = loadForgeRelayFiles(process.env, { readLegacyHooks: true });
@@ -53,37 +54,20 @@ export async function runConfigMigration(args) {
53
54
  printPlan(plan);
54
55
  }
55
56
  function parseMigrationArgs(args) {
57
+ const scoped = parseConfigScopeArgs(args);
56
58
  let dryRun = false;
57
- let scope;
58
- let projectPath;
59
- for (let index = 0; index < args.length; index += 1) {
60
- const arg = args[index];
59
+ for (const arg of scoped.rest) {
61
60
  if (arg === "--dry-run") {
62
61
  if (dryRun)
63
62
  throw new Error("--dry-run may only be supplied once.");
64
63
  dryRun = true;
65
64
  continue;
66
65
  }
67
- if (arg === "--global") {
68
- if (scope)
69
- throw new Error("Choose exactly one migration scope: --global or --project <path>.");
70
- scope = "global";
71
- continue;
72
- }
73
- if (arg === "--project") {
74
- if (scope)
75
- throw new Error("Choose exactly one migration scope: --global or --project <path>.");
76
- const value = args[index + 1];
77
- if (!value || value.startsWith("--"))
78
- throw new Error("--project requires a project path.");
79
- scope = "project";
80
- projectPath = resolve(value);
81
- index += 1;
82
- continue;
83
- }
84
66
  throw new Error(`Unknown config migrate option: ${arg}`);
85
67
  }
86
- 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 };
87
71
  }
88
72
  function buildGlobalMigrationPlan(configDir) {
89
73
  const files = loadForgeRelayFiles({ ...process.env, FORGERELAY_CONFIG_DIR: configDir }, { readLegacyHooks: true });
@@ -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
+ }