@akira-tl/forgerelay 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -12
- package/dist/activity/mcp-query-tools.js +47 -2
- package/dist/cli.js +181 -4
- package/dist/config.js +4 -1
- package/dist/oauth/router.js +21 -1
- package/dist/oauth-provider.js +32 -4
- package/dist/oauth-store.js +9 -0
- package/dist/remote-auth.js +110 -0
- package/dist/remote-transport.js +196 -0
- package/dist/remote-workspace-relay.js +457 -0
- package/dist/server.js +189 -15
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +3 -3
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/release/pack.mjs +36 -0
- package/scripts/release/publish.mjs +157 -0
- package/scripts/release/release-gate.test.mjs +73 -16
- package/scripts/release-parity.mjs +5 -13
- package/scripts/release-proof.mjs +28 -19
- package/scripts/release-proof.test.mjs +32 -11
- package/scripts/release-version.mjs +1 -1
package/dist/server.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createCoreOperationExecutor, } from "./operations/core-operation-execut
|
|
|
45
45
|
import { McpTransportRegistry, } from "./mcp-sessions.js";
|
|
46
46
|
import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
|
|
47
47
|
import { createReviewCheckpointManager } from "./review-checkpoints.js";
|
|
48
|
+
import { RemoteWorkspaceRelay } from "./remote-workspace-relay.js";
|
|
48
49
|
import { hostConversationScopeId, openAiConversationScopeId } from "./request-meta.js";
|
|
49
50
|
import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP_RESOURCE_TEMPLATE_REVISION, readActivityPanelAppManifestEntry, readWorkspaceAppManifestEntry, readWorkspaceLifecycleAppManifestEntry, resolveActivityPanelAppIdentity, resolveWorkspaceAppIdentity, resolveWorkspaceLifecycleAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, WORKSPACE_LIFECYCLE_APP_LEGACY_URI, WORKSPACE_LIFECYCLE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
|
|
50
51
|
import { shutdownHttpServer } from "./server-shutdown.js";
|
|
@@ -1124,6 +1125,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
1124
1125
|
}
|
|
1125
1126
|
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore, activityQueries) {
|
|
1126
1127
|
const connectionScopeId = `mcp-connection:${randomUUID()}`;
|
|
1128
|
+
const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
|
|
1127
1129
|
const hostScopeIdFor = (requestMeta, transportSessionId) => hostConversationScopeId(requestMeta, transportSessionId, connectionScopeId);
|
|
1128
1130
|
const toolDescriptions = buildToolDescriptions(config);
|
|
1129
1131
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
|
|
@@ -1735,6 +1737,26 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1735
1737
|
instructions: buildServerInstructions(config),
|
|
1736
1738
|
});
|
|
1737
1739
|
const workspacePanelStates = new Map();
|
|
1740
|
+
const workspacePanelState = (workspaceId) => {
|
|
1741
|
+
const remembered = workspacePanelStates.get(workspaceId);
|
|
1742
|
+
if (remembered)
|
|
1743
|
+
return remembered;
|
|
1744
|
+
try {
|
|
1745
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1746
|
+
return {
|
|
1747
|
+
workspaceId: workspace.id,
|
|
1748
|
+
root: workspace.root,
|
|
1749
|
+
path: workspace.root,
|
|
1750
|
+
mode: workspace.mode,
|
|
1751
|
+
sourceRoot: workspace.sourceRoot,
|
|
1752
|
+
instruction: `Use workspaceId ${workspace.id} for subsequent calls.`,
|
|
1753
|
+
summary: { mode: workspace.mode },
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
catch {
|
|
1757
|
+
return undefined;
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1738
1760
|
const rememberWorkspacePanelState = (workspaceId, response) => {
|
|
1739
1761
|
if (typeof response._meta !== "object" || response._meta === null)
|
|
1740
1762
|
return;
|
|
@@ -1772,7 +1794,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1772
1794
|
server.registerResource("ForgeRelay Activity Panel compatibility", new ResourceTemplate(ACTIVITY_PANEL_APP_URI_TEMPLATE, { list: undefined }), { ...activityPanelResourceMetadata, mimeType: RESOURCE_MIME_TYPE }, async (uri, _variables, extra) => readActivityPanelAppResource(config, uri.toString(), extra.sessionId));
|
|
1773
1795
|
registerAppTool(server, "open_workspace", {
|
|
1774
1796
|
title: "Open workspace",
|
|
1775
|
-
description: "Open or resume a local
|
|
1797
|
+
description: "Open or resume a coding workspace. Defaults to local execution; for a new workspace, relay may name a registered direct remote ForgeRelay. Reuse the returned Gateway workspaceId for later calls. Default to checkout; use mode=\"worktree\" only for explicitly isolated or parallel Git work. Bootstrap context is delivered automatically only when needed and can be suppressed or refreshed.",
|
|
1776
1798
|
inputSchema: {
|
|
1777
1799
|
action: z
|
|
1778
1800
|
.enum(["open", "list"])
|
|
@@ -1782,6 +1804,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1782
1804
|
.string()
|
|
1783
1805
|
.optional()
|
|
1784
1806
|
.describe("Project path to open. Required for action=open unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
|
|
1807
|
+
relay: z
|
|
1808
|
+
.string()
|
|
1809
|
+
.optional()
|
|
1810
|
+
.describe("Optional registered remote ForgeRelay alias. When supplied for action=open, the workspace is opened and executed on that remote instance while this Gateway returns its own workspaceId."),
|
|
1785
1811
|
workspaceId: z
|
|
1786
1812
|
.string()
|
|
1787
1813
|
.optional()
|
|
@@ -1896,14 +1922,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1896
1922
|
idempotentHint: false,
|
|
1897
1923
|
openWorldHint: false,
|
|
1898
1924
|
},
|
|
1899
|
-
}, async ({ action = "open", path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
|
|
1925
|
+
}, async ({ action = "open", path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
|
|
1900
1926
|
const startedAt = performance.now();
|
|
1901
1927
|
const conversationScopeId = openAiConversationScopeId(_meta);
|
|
1902
1928
|
const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
|
|
1903
1929
|
if (action === "list") {
|
|
1904
|
-
if (path !== undefined || baseRef !== undefined || newWorktree !== undefined ||
|
|
1930
|
+
if (path !== undefined || relay !== undefined || baseRef !== undefined || newWorktree !== undefined ||
|
|
1905
1931
|
newWorkspace !== undefined || context !== undefined) {
|
|
1906
|
-
throw new Error("open_workspace action=list does not accept path, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
|
|
1932
|
+
throw new Error("open_workspace action=list does not accept path, relay, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
|
|
1907
1933
|
}
|
|
1908
1934
|
const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
|
|
1909
1935
|
const nextOffset = inventory.page.offset + inventory.page.limit;
|
|
@@ -1949,6 +1975,96 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1949
1975
|
staleOnly !== undefined || offset !== undefined || limit !== undefined) {
|
|
1950
1976
|
throw new Error("open_workspace inventory filters root, status, state, staleOnly, offset, and limit are only valid with action=list.");
|
|
1951
1977
|
}
|
|
1978
|
+
if (relay !== undefined) {
|
|
1979
|
+
if (workspaceId !== undefined) {
|
|
1980
|
+
throw new Error("Relayed open_workspace requires a path; resuming a relayed workspace is not available in this tracer bullet.");
|
|
1981
|
+
}
|
|
1982
|
+
if (!path)
|
|
1983
|
+
throw new Error("Relayed open_workspace requires path.");
|
|
1984
|
+
const opened = await remoteWorkspaces.openWorkspace(relay, {
|
|
1985
|
+
path,
|
|
1986
|
+
mode,
|
|
1987
|
+
baseRef,
|
|
1988
|
+
newWorktree,
|
|
1989
|
+
newWorkspace,
|
|
1990
|
+
context,
|
|
1991
|
+
}, hostScopeIdFor(_meta, sessionId));
|
|
1992
|
+
const relayedSkills = Array.isArray(opened.skills)
|
|
1993
|
+
? opened.skills
|
|
1994
|
+
: [];
|
|
1995
|
+
const relayedCapabilities = Array.isArray(opened.capabilityCatalog)
|
|
1996
|
+
? opened.capabilityCatalog
|
|
1997
|
+
: [];
|
|
1998
|
+
const result = [
|
|
1999
|
+
`Opened relayed workspace ${opened.workspaceId}.`,
|
|
2000
|
+
`Execution remote: ${relay}`,
|
|
2001
|
+
`Root: ${opened.root}`,
|
|
2002
|
+
`Mode: ${opened.mode}`,
|
|
2003
|
+
relayedSkills.length > 0
|
|
2004
|
+
? `Available skills: ${relayedSkills.map((skill) => String(skill.name ?? "")).filter(Boolean).join(", ")}`
|
|
2005
|
+
: undefined,
|
|
2006
|
+
relayedCapabilities.length > 0
|
|
2007
|
+
? `Optional capabilities: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
|
|
2008
|
+
: undefined,
|
|
2009
|
+
opened.instruction,
|
|
2010
|
+
].filter(Boolean).join("\n");
|
|
2011
|
+
const response = {
|
|
2012
|
+
content: [textBlock(result)],
|
|
2013
|
+
_meta: {
|
|
2014
|
+
tool: "open_workspace",
|
|
2015
|
+
card: {
|
|
2016
|
+
workspaceId: opened.workspaceId,
|
|
2017
|
+
root: opened.root,
|
|
2018
|
+
path: opened.root,
|
|
2019
|
+
mode: opened.mode,
|
|
2020
|
+
relay,
|
|
2021
|
+
instruction: opened.instruction,
|
|
2022
|
+
summary: { mode: opened.mode, relay },
|
|
2023
|
+
},
|
|
2024
|
+
},
|
|
2025
|
+
structuredContent: {
|
|
2026
|
+
action: "open",
|
|
2027
|
+
workspaceId: opened.workspaceId,
|
|
2028
|
+
root: opened.root,
|
|
2029
|
+
mode: opened.mode,
|
|
2030
|
+
...(opened.sourceRoot ? { sourceRoot: opened.sourceRoot } : {}),
|
|
2031
|
+
...(opened.contextFingerprint !== undefined
|
|
2032
|
+
? { contextFingerprint: opened.contextFingerprint }
|
|
2033
|
+
: {}),
|
|
2034
|
+
...(opened.capabilityFingerprint !== undefined
|
|
2035
|
+
? { capabilityFingerprint: opened.capabilityFingerprint }
|
|
2036
|
+
: {}),
|
|
2037
|
+
...(opened.capabilityCatalog !== undefined
|
|
2038
|
+
? { capabilityCatalog: opened.capabilityCatalog }
|
|
2039
|
+
: {}),
|
|
2040
|
+
...(opened.capabilityGuides !== undefined
|
|
2041
|
+
? { capabilityGuides: opened.capabilityGuides }
|
|
2042
|
+
: {}),
|
|
2043
|
+
...(opened.agentsFiles !== undefined ? { agentsFiles: opened.agentsFiles } : {}),
|
|
2044
|
+
...(opened.availableAgentsFiles !== undefined
|
|
2045
|
+
? { availableAgentsFiles: opened.availableAgentsFiles }
|
|
2046
|
+
: {}),
|
|
2047
|
+
...(opened.skills !== undefined ? { skills: opened.skills } : {}),
|
|
2048
|
+
...(opened.agentProviders !== undefined
|
|
2049
|
+
? { agentProviders: opened.agentProviders }
|
|
2050
|
+
: {}),
|
|
2051
|
+
...(opened.agents !== undefined ? { agents: opened.agents } : {}),
|
|
2052
|
+
...(opened.skillDiagnostics !== undefined
|
|
2053
|
+
? { skillDiagnostics: opened.skillDiagnostics }
|
|
2054
|
+
: {}),
|
|
2055
|
+
instruction: opened.instruction,
|
|
2056
|
+
},
|
|
2057
|
+
};
|
|
2058
|
+
logToolCall(config, {
|
|
2059
|
+
tool: "open_workspace",
|
|
2060
|
+
action: "relay",
|
|
2061
|
+
path: opened.root,
|
|
2062
|
+
success: true,
|
|
2063
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2064
|
+
});
|
|
2065
|
+
rememberWorkspacePanelState(opened.workspaceId, response);
|
|
2066
|
+
return response;
|
|
2067
|
+
}
|
|
1952
2068
|
const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
|
|
1953
2069
|
conversationScopeId,
|
|
1954
2070
|
protectedWorkspaceIds,
|
|
@@ -2144,7 +2260,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2144
2260
|
rememberWorkspacePanelState(workspace.id, response);
|
|
2145
2261
|
return response;
|
|
2146
2262
|
});
|
|
2147
|
-
registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging,
|
|
2263
|
+
registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging, workspacePanelState, {
|
|
2264
|
+
panel: async (workspaceId, conversationScopeId) => remoteWorkspaces.has(workspaceId)
|
|
2265
|
+
? remoteWorkspaces.activityPanel(workspaceId, conversationScopeId)
|
|
2266
|
+
: undefined,
|
|
2267
|
+
snapshot: (input, conversationScopeId) => remoteWorkspaces.activitySnapshot(input, conversationScopeId),
|
|
2268
|
+
detail: (turnId, activityId, conversationScopeId) => remoteWorkspaces.activityDetail(turnId, activityId, conversationScopeId),
|
|
2269
|
+
output: (turnId, outputId, conversationScopeId) => remoteWorkspaces.activityOutput(turnId, outputId, conversationScopeId),
|
|
2270
|
+
});
|
|
2148
2271
|
registerAppTool(server, toolNames.capability, {
|
|
2149
2272
|
title: "Use optional capability",
|
|
2150
2273
|
description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
|
|
@@ -2182,6 +2305,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2182
2305
|
openWorldHint: true,
|
|
2183
2306
|
},
|
|
2184
2307
|
}, async ({ workspaceId, name, action, arguments: capabilityArguments, file }, extra) => {
|
|
2308
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2309
|
+
return remoteWorkspaces.capability(workspaceId, {
|
|
2310
|
+
name,
|
|
2311
|
+
action,
|
|
2312
|
+
...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
|
|
2313
|
+
...(file !== undefined ? { file } : {}),
|
|
2314
|
+
}, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2315
|
+
}
|
|
2185
2316
|
if (action === "run" && name === "batch.execute") {
|
|
2186
2317
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2187
2318
|
const startedAt = performance.now();
|
|
@@ -2318,6 +2449,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2318
2449
|
_meta: {},
|
|
2319
2450
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
2320
2451
|
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
2452
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2453
|
+
const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2454
|
+
workspacePanelStates.delete(workspaceId);
|
|
2455
|
+
return response;
|
|
2456
|
+
}
|
|
2321
2457
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2322
2458
|
const response = await runToolWithHooks(hooks, {
|
|
2323
2459
|
signal: extra.signal,
|
|
@@ -2470,6 +2606,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2470
2606
|
if ((path === undefined) === (paths === undefined)) {
|
|
2471
2607
|
throw new Error("read requires exactly one of path or paths.");
|
|
2472
2608
|
}
|
|
2609
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2610
|
+
return remoteWorkspaces.read(workspaceId, { path, paths, offset, limit }, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2611
|
+
}
|
|
2473
2612
|
if (path !== undefined) {
|
|
2474
2613
|
return coreOperations.read({ workspaceId, path, offset, limit }, {
|
|
2475
2614
|
requestMeta: extra._meta,
|
|
@@ -2548,11 +2687,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2548
2687
|
outputSchema: resultOutputSchema(),
|
|
2549
2688
|
...toolWidgetDescriptorMeta(config, "write"),
|
|
2550
2689
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
2551
|
-
}, async ({ workspaceId, ...input }, extra) =>
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2690
|
+
}, async ({ workspaceId, ...input }, extra) => {
|
|
2691
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2692
|
+
return remoteWorkspaces.write(workspaceId, input, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2693
|
+
}
|
|
2694
|
+
return coreOperations.write({ workspaceId, ...input }, {
|
|
2695
|
+
requestMeta: extra._meta,
|
|
2696
|
+
signal: extra.signal,
|
|
2697
|
+
sessionId: extra.sessionId,
|
|
2698
|
+
});
|
|
2699
|
+
});
|
|
2556
2700
|
registerAppTool(server, toolNames.edit, {
|
|
2557
2701
|
title: "Edit file",
|
|
2558
2702
|
description: toolDescriptions.edit,
|
|
@@ -2597,6 +2741,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2597
2741
|
if ((path === undefined) === (paths === undefined)) {
|
|
2598
2742
|
throw new Error("edit requires exactly one of path or paths.");
|
|
2599
2743
|
}
|
|
2744
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2745
|
+
return remoteWorkspaces.edit(workspaceId, { path, paths, edits }, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2746
|
+
}
|
|
2600
2747
|
if (path !== undefined) {
|
|
2601
2748
|
return coreOperations.edit({ workspaceId, path, edits }, {
|
|
2602
2749
|
requestMeta: extra._meta,
|
|
@@ -2626,11 +2773,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2626
2773
|
}),
|
|
2627
2774
|
...toolWidgetDescriptorMeta(config, "edit"),
|
|
2628
2775
|
annotations: EDIT_TOOL_ANNOTATIONS,
|
|
2629
|
-
}, async ({ workspaceId, path, newPath }, extra) =>
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2776
|
+
}, async ({ workspaceId, path, newPath }, extra) => {
|
|
2777
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2778
|
+
return remoteWorkspaces.rename(workspaceId, { path, newPath }, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2779
|
+
}
|
|
2780
|
+
return coreOperations.rename({ workspaceId, path, newPath }, {
|
|
2781
|
+
requestMeta: extra._meta,
|
|
2782
|
+
signal: extra.signal,
|
|
2783
|
+
sessionId: extra.sessionId,
|
|
2784
|
+
});
|
|
2785
|
+
});
|
|
2634
2786
|
registerAppTool(server, toolNames.delete, {
|
|
2635
2787
|
title: "Delete path",
|
|
2636
2788
|
description: toolDescriptions.delete,
|
|
@@ -2665,6 +2817,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2665
2817
|
if ((path === undefined) === (paths === undefined)) {
|
|
2666
2818
|
throw new Error("delete requires exactly one of path or paths.");
|
|
2667
2819
|
}
|
|
2820
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2821
|
+
return remoteWorkspaces.delete(workspaceId, { path, paths, recursive }, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
2822
|
+
}
|
|
2668
2823
|
if (path !== undefined) {
|
|
2669
2824
|
return coreOperations.delete({ workspaceId, path, recursive }, {
|
|
2670
2825
|
requestMeta: extra._meta,
|
|
@@ -2835,6 +2990,23 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
2835
2990
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
2836
2991
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
2837
2992
|
}, async ({ workspaceId, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
|
|
2993
|
+
if (remoteWorkspaces.has(workspaceId)) {
|
|
2994
|
+
return remoteWorkspaces.bash(workspaceId, {
|
|
2995
|
+
action,
|
|
2996
|
+
...(command !== undefined ? { command } : {}),
|
|
2997
|
+
...(processId !== undefined ? { processId } : {}),
|
|
2998
|
+
...(outputId !== undefined ? { outputId } : {}),
|
|
2999
|
+
...(input !== undefined ? { input } : {}),
|
|
3000
|
+
...(interrupt !== undefined ? { interrupt } : {}),
|
|
3001
|
+
...(tty !== undefined ? { tty } : {}),
|
|
3002
|
+
...(columns !== undefined ? { columns } : {}),
|
|
3003
|
+
...(rows !== undefined ? { rows } : {}),
|
|
3004
|
+
...(workingDirectory !== undefined ? { workingDirectory } : {}),
|
|
3005
|
+
...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
|
|
3006
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
3007
|
+
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
|
3008
|
+
}, hostScopeIdFor(extra._meta, extra.sessionId));
|
|
3009
|
+
}
|
|
2838
3010
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2839
3011
|
if (action === "run") {
|
|
2840
3012
|
if (!command)
|
|
@@ -3069,6 +3241,8 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
3069
3241
|
});
|
|
3070
3242
|
app.use(createForgeRelayAuthRouter({
|
|
3071
3243
|
provider: oauthProvider,
|
|
3244
|
+
cliAuthenticationProvider: oauthProvider,
|
|
3245
|
+
instanceId: config.instanceId,
|
|
3072
3246
|
issuerUrl: new URL(config.publicBaseUrl),
|
|
3073
3247
|
resourceServerUrl,
|
|
3074
3248
|
scopesSupported: config.oauth.scopes,
|
package/dist/user-config.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { expandHomePath } from "./roots.js";
|
|
6
6
|
import { mergeHookConfigs, parseHookFile, } from "./hooks.js";
|
|
7
|
+
const AUTH_LOCK_RETRY_MS = 10;
|
|
8
|
+
const AUTH_LOCK_TIMEOUT_MS = 5_000;
|
|
9
|
+
const AUTH_LOCK_STALE_MS = 30_000;
|
|
10
|
+
const AUTH_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
7
11
|
export function forgerelayConfigDir(env = process.env) {
|
|
8
12
|
const explicit = env.FORGERELAY_CONFIG_DIR ?? env.DEVSPACE_CONFIG_DIR;
|
|
9
13
|
if (explicit)
|
|
@@ -64,12 +68,81 @@ export function writeForgeRelayConfig(config, env = process.env) {
|
|
|
64
68
|
export function writeForgeRelayAuth(auth, env = process.env) {
|
|
65
69
|
const filePath = forgerelayAuthPath(env);
|
|
66
70
|
mkdirSync(forgerelayConfigDir(env), { recursive: true });
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
return withAuthFileLock(filePath, () => {
|
|
72
|
+
writeJsonFile(filePath, auth, 0o600);
|
|
73
|
+
return filePath;
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
export function generateOwnerToken() {
|
|
71
77
|
return randomBytes(32).toString("base64url");
|
|
72
78
|
}
|
|
79
|
+
export function generateInstanceId() {
|
|
80
|
+
return `forge-${randomUUID()}`;
|
|
81
|
+
}
|
|
82
|
+
export function ensureForgeRelayInstanceId(env = process.env) {
|
|
83
|
+
const existing = loadForgeRelayFiles(env).auth.instanceId?.trim();
|
|
84
|
+
if (existing)
|
|
85
|
+
return existing;
|
|
86
|
+
let resolved = "";
|
|
87
|
+
updateForgeRelayAuth((auth) => {
|
|
88
|
+
const current = auth.instanceId?.trim();
|
|
89
|
+
if (current) {
|
|
90
|
+
resolved = current;
|
|
91
|
+
return auth;
|
|
92
|
+
}
|
|
93
|
+
resolved = generateInstanceId();
|
|
94
|
+
return { ...auth, instanceId: resolved };
|
|
95
|
+
}, env);
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
function normalizeRemoteAlias(alias) {
|
|
99
|
+
const normalized = alias.trim();
|
|
100
|
+
if (!normalized || /\s/.test(normalized)) {
|
|
101
|
+
throw new Error("Remote alias must be a non-empty name without whitespace.");
|
|
102
|
+
}
|
|
103
|
+
return normalized;
|
|
104
|
+
}
|
|
105
|
+
export function writeForgeRelayRemote(alias, remote, env = process.env) {
|
|
106
|
+
const normalizedAlias = normalizeRemoteAlias(alias);
|
|
107
|
+
return updateForgeRelayAuth((auth) => {
|
|
108
|
+
const remotes = { ...(auth.remotes ?? {}) };
|
|
109
|
+
const existing = remotes[normalizedAlias];
|
|
110
|
+
if (existing && existing.instanceId !== remote.instanceId) {
|
|
111
|
+
throw new Error(`Remote alias ${normalizedAlias} already belongs to another ForgeRelay instance.`);
|
|
112
|
+
}
|
|
113
|
+
const duplicate = Object.entries(remotes).find(([name, record]) => name !== normalizedAlias && record.instanceId === remote.instanceId);
|
|
114
|
+
if (duplicate) {
|
|
115
|
+
throw new Error(`ForgeRelay instance is already registered as ${duplicate[0]}; rename that remote instead.`);
|
|
116
|
+
}
|
|
117
|
+
remotes[normalizedAlias] = remote;
|
|
118
|
+
return { ...auth, remotes };
|
|
119
|
+
}, env);
|
|
120
|
+
}
|
|
121
|
+
export function renameForgeRelayRemote(fromAlias, toAlias, env = process.env) {
|
|
122
|
+
const from = normalizeRemoteAlias(fromAlias);
|
|
123
|
+
const to = normalizeRemoteAlias(toAlias);
|
|
124
|
+
return updateForgeRelayAuth((auth) => {
|
|
125
|
+
const remotes = { ...(auth.remotes ?? {}) };
|
|
126
|
+
const remote = remotes[from];
|
|
127
|
+
if (!remote)
|
|
128
|
+
throw new Error(`Unknown remote alias: ${from}`);
|
|
129
|
+
if (from !== to && remotes[to])
|
|
130
|
+
throw new Error(`Remote alias already exists: ${to}`);
|
|
131
|
+
delete remotes[from];
|
|
132
|
+
remotes[to] = remote;
|
|
133
|
+
return { ...auth, remotes };
|
|
134
|
+
}, env);
|
|
135
|
+
}
|
|
136
|
+
export function removeForgeRelayRemote(alias, env = process.env) {
|
|
137
|
+
const normalizedAlias = normalizeRemoteAlias(alias);
|
|
138
|
+
return updateForgeRelayAuth((auth) => {
|
|
139
|
+
const remotes = { ...(auth.remotes ?? {}) };
|
|
140
|
+
if (!remotes[normalizedAlias])
|
|
141
|
+
throw new Error(`Unknown remote alias: ${normalizedAlias}`);
|
|
142
|
+
delete remotes[normalizedAlias];
|
|
143
|
+
return { ...auth, remotes };
|
|
144
|
+
}, env);
|
|
145
|
+
}
|
|
73
146
|
export function ensureForgeRelayDefaultSkills(env = process.env) {
|
|
74
147
|
const targetPath = join(forgerelaySkillsDir(env), "subagent-delegation", "SKILL.md");
|
|
75
148
|
if (existsSync(targetPath))
|
|
@@ -124,6 +197,59 @@ function readJsonFile(filePath) {
|
|
|
124
197
|
throw new Error(`Unable to read ${filePath}: ${reason}`);
|
|
125
198
|
}
|
|
126
199
|
}
|
|
200
|
+
function updateForgeRelayAuth(update, env) {
|
|
201
|
+
const filePath = forgerelayAuthPath(env);
|
|
202
|
+
mkdirSync(forgerelayConfigDir(env), { recursive: true });
|
|
203
|
+
return withAuthFileLock(filePath, () => {
|
|
204
|
+
const auth = existsSync(filePath) ? readJsonFile(filePath) : {};
|
|
205
|
+
writeJsonFile(filePath, update(auth), 0o600);
|
|
206
|
+
return filePath;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function withAuthFileLock(filePath, operation) {
|
|
210
|
+
const lockPath = `${filePath}.lock`;
|
|
211
|
+
const deadline = Date.now() + AUTH_LOCK_TIMEOUT_MS;
|
|
212
|
+
for (;;) {
|
|
213
|
+
try {
|
|
214
|
+
const fd = openSync(lockPath, "wx", 0o600);
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const code = error.code;
|
|
220
|
+
if (code !== "EEXIST")
|
|
221
|
+
throw error;
|
|
222
|
+
try {
|
|
223
|
+
if (Date.now() - statSync(lockPath).mtimeMs > AUTH_LOCK_STALE_MS) {
|
|
224
|
+
rmSync(lockPath, { force: true });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (statError) {
|
|
229
|
+
if (statError.code === "ENOENT")
|
|
230
|
+
continue;
|
|
231
|
+
throw statError;
|
|
232
|
+
}
|
|
233
|
+
if (Date.now() >= deadline) {
|
|
234
|
+
throw new Error(`Timed out waiting for ForgeRelay auth lock: ${lockPath}`);
|
|
235
|
+
}
|
|
236
|
+
Atomics.wait(AUTH_LOCK_SLEEP, 0, 0, AUTH_LOCK_RETRY_MS);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return operation();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
rmSync(lockPath, { force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
127
246
|
function writeJsonFile(filePath, value, mode) {
|
|
128
|
-
|
|
247
|
+
const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
248
|
+
try {
|
|
249
|
+
writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { mode });
|
|
250
|
+
renameSync(tempPath, filePath);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
rmSync(tempPath, { force: true });
|
|
254
|
+
}
|
|
129
255
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -386,7 +386,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
|
|
|
386
386
|
<workspace>/.forgerelay/hooks/<hook-name>.json
|
|
387
387
|
```
|
|
388
388
|
|
|
389
|
-
文件名去掉 `.json` 后就是 Hook 名,也是日志和 Agent-visible report 中显示的名称。例如 `release-tag-
|
|
389
|
+
文件名去掉 `.json` 后就是 Hook 名,也是日志和 Agent-visible report 中显示的名称。例如 `release-tag-gate.json` 会显示为 `release-tag-gate`。目录内按文件名字典序执行;需要显式排序时可以使用 `10-release-verify.json`、`20-package-inspection.json` 这样的前缀。ForgeRelay 只读取普通 `*.json` 文件,所以临时停用某条 Hook 时可以把扩展名改掉。
|
|
390
390
|
|
|
391
391
|
全局 Hook 在 server 启动时读取,修改后需要重启 ForgeRelay;项目目录在每次事件时重新读取,所以 Agent 修改项目 Hook 后不需要重启。全局规则先执行,项目规则随后执行,两边都只做追加,不互相覆盖。
|
|
392
392
|
|
|
@@ -405,7 +405,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
|
|
|
405
405
|
}
|
|
406
406
|
```
|
|
407
407
|
|
|
408
|
-
这个例子可以保存为 `.forgerelay/hooks/release-tag-
|
|
408
|
+
这个例子可以保存为 `.forgerelay/hooks/release-tag-gate.json`。Agent 通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 只执行轻量仓库状态门禁:拒绝 force/delete 形式,校验 clean working tree(含 untracked)、tag 与 package version 一致,并要求本地 tag 指向当前 HEAD。Hook 不运行、也不要求本地 CI;tag 推送后由 GitHub Actions 的 Linux/macOS/Windows 矩阵执行权威验证,全部通过后发布 job 才会继续。`npm run release:verify` 仅用于需要时本地复现云端环境。
|
|
409
409
|
|
|
410
410
|
独立 Hook 文件支持这些顶层字段:
|
|
411
411
|
|
|
@@ -472,7 +472,7 @@ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令
|
|
|
472
472
|
|
|
473
473
|
```text
|
|
474
474
|
Hook results:
|
|
475
|
-
✓ release-tag-
|
|
475
|
+
✓ release-tag-gate (BeforeTool, project) passed in 42ms
|
|
476
476
|
```
|
|
477
477
|
|
|
478
478
|
阻断失败会明确显示 `failed`。ForgeRelay 的 server instructions 要求 Agent 在出现 Hook results 时,向用户说明有意义的 Hook 是否通过或阻断了操作。异步 subagent 的 `SubagentStart` / `SubagentStop` 报告会随 session 持久化,并由 `forgerelay agents show` 展示。
|
package/docs/versioning.md
CHANGED
|
@@ -79,25 +79,20 @@ The bump commands update:
|
|
|
79
79
|
|
|
80
80
|
They do not commit, tag, push, or publish.
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
For local diagnosis or cloud-parity reproduction, you can optionally run:
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
85
|
npm run release:verify
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
`release:verify`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
performs its own `npm ci` so native addons use the same Node ABI as cloud CI,
|
|
93
|
-
then reruns the LSP/release tests most sensitive to event-loop timing, process
|
|
94
|
-
lifecycle, path canonicalization, executable discovery, and cleanup behavior.
|
|
95
|
-
It also tests that a command which exists on `PATH` but fails its `--version`
|
|
96
|
-
preflight is treated as unavailable rather than as an installed Language server.
|
|
88
|
+
`release:verify` runs the same focused parity checks in an isolated Node 22.19.0 sandbox
|
|
89
|
+
and records a local proof under `.git/forgerelay/` for debugging/audit purposes. It is
|
|
90
|
+
not a prerequisite for pushing a release tag. The authoritative release verification
|
|
91
|
+
is the tag-triggered GitHub Actions matrix on Linux, macOS, and Windows.
|
|
97
92
|
|
|
98
|
-
Cloud verification and the publication job are
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
Cloud verification and the publication job are pinned to Node 22.19.0, the minimum
|
|
94
|
+
supported Node release. The optional local parity tool uses the same runtime so a cloud
|
|
95
|
+
failure can be reproduced locally without making local execution part of the release gate.
|
|
101
96
|
|
|
102
97
|
Validate a specific tag with:
|
|
103
98
|
|
|
@@ -172,11 +167,8 @@ npm publishing token.
|
|
|
172
167
|
3. Run the appropriate `release:patch`, `release:minor`, or `release:major`
|
|
173
168
|
command.
|
|
174
169
|
4. Review the generated version and changelog diff, then commit the release-ready code and metadata.
|
|
175
|
-
5.
|
|
176
|
-
|
|
177
|
-
pushes do not need to run the full release gate.
|
|
178
|
-
6. Push the verified `main` commit without changing it afterward.
|
|
179
|
-
7. Create the exact version tag, for example:
|
|
170
|
+
5. Push the release-ready `main` commit without changing it afterward.
|
|
171
|
+
6. Create the exact version tag, for example:
|
|
180
172
|
|
|
181
173
|
```bash
|
|
182
174
|
git tag v0.2.0
|
|
@@ -185,7 +177,7 @@ npm publishing token.
|
|
|
185
177
|
|
|
186
178
|
The tag push is the publication action. The release workflow publishes npm only after cloud CI passes, then extracts the matching `CHANGELOG.md` release section as the GitHub Release body. Keep `Unreleased` user-facing and structured (`Added`, `Changed`, `Fixed`, `Security`) because those notes are what users see on the Release page.
|
|
187
179
|
|
|
188
|
-
Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ. The release Hook
|
|
180
|
+
Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ. The release Hook is intentionally lightweight: it rejects force/deletion forms, requires a clean working tree including untracked files, requires the tag to match the package version, and requires the local tag to resolve to current HEAD. It does not run or require local CI. The pushed tag then starts the authoritative Linux/macOS/Windows cloud verification; publication cannot run unless that matrix succeeds.
|
|
189
181
|
|
|
190
182
|
## Attribution guardrails
|
|
191
183
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -41,10 +41,13 @@
|
|
|
41
41
|
"debug:serve": "node scripts/debug/serve.mjs",
|
|
42
42
|
"debug:accept": "node scripts/debug/accept.mjs",
|
|
43
43
|
"lsp:interop": "node scripts/lsp-interop.mjs",
|
|
44
|
+
"ci:verify": "node scripts/ci/verify.mjs",
|
|
44
45
|
"release:parity": "node scripts/release-parity.mjs",
|
|
46
|
+
"release:pack": "node scripts/release/pack.mjs",
|
|
47
|
+
"release:publish": "node scripts/release/publish.mjs",
|
|
45
48
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
46
49
|
"start": "node dist/cli.js serve",
|
|
47
|
-
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
50
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
48
51
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
52
|
"release:check": "node scripts/release-version.mjs check",
|
|
50
53
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const npmCli = process.env.npm_execpath;
|
|
6
|
+
if (!npmCli) {
|
|
7
|
+
throw new Error("ci:verify must be launched through npm so npm_execpath is available");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
console.log(`CI environment: ${process.platform}/${process.arch} ${process.version}`);
|
|
11
|
+
runNpm(["--version"], "npm version");
|
|
12
|
+
runNpm(["run", "release:check"], "Release metadata");
|
|
13
|
+
runNpm(["run", "typecheck"], "Typecheck");
|
|
14
|
+
runNpm(["test"], "Full test suite");
|
|
15
|
+
runNpm(["run", "build"], "Build");
|
|
16
|
+
runNpm(["run", "lsp:interop"], "Optional LSP interoperability");
|
|
17
|
+
run(process.execPath, ["dist/cli.js", "doctor"], "Doctor");
|
|
18
|
+
|
|
19
|
+
console.log("CI verification passed.");
|
|
20
|
+
|
|
21
|
+
function runNpm(args, label) {
|
|
22
|
+
run(process.execPath, [npmCli, ...args], label);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(command, args, label) {
|
|
26
|
+
console.log(`\n== ${label} ==`);
|
|
27
|
+
const result = spawnSync(command, args, {
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
env: process.env,
|
|
30
|
+
stdio: "inherit",
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
shell: false,
|
|
33
|
+
});
|
|
34
|
+
if (result.error) throw result.error;
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
throw new Error(`${label} failed with exit ${result.status ?? "unknown"}`);
|
|
37
|
+
}
|
|
38
|
+
}
|