@topline-build/harness-mcp 0.2.0 → 0.5.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.
- package/CHANGELOG.md +17 -0
- package/README.md +81 -54
- package/dist/index.d.ts +57 -4
- package/dist/index.js +705 -155
- package/docs/generated/build-mcp-manifest.json +2120 -0
- package/docs/generated/build-mcp-reference.md +2126 -0
- package/package.json +12 -5
- package/scripts/certify-live.mjs +551 -0
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, readdir, readFile, lstat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readdir, readFile, realpath, lstat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -10,14 +10,29 @@ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
11
|
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
12
12
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
-
const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
13
|
+
export const DEFAULT_MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
14
14
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
-
const
|
|
16
|
-
export const HARNESS_MCP_VERSION = "0.
|
|
17
|
-
const DEFAULT_OAUTH_SCOPES = ["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy"];
|
|
15
|
+
export const BUILD_MCP_SERVER_NAME = "topline-build-stdio";
|
|
16
|
+
export const HARNESS_MCP_VERSION = "0.5.0";
|
|
17
|
+
export const DEFAULT_OAUTH_SCOPES = Object.freeze(["harness:artifact:read", "harness:artifact:write", "harness:artifact:deploy", "harness:artifact:delete"]);
|
|
18
|
+
export const BUILD_MCP_SETUP_OPTIONS = Object.freeze([
|
|
19
|
+
{ flag: "--url", env: "TOPLINE_API_URL", aliases: ["PLATFORM_API_URL", "OVERSITE_API_URL"], required: true, description: "Topline tenant base URL. HTTPS is required except for loopback development." },
|
|
20
|
+
{ flag: "--workspace-root", env: "TOPLINE_WORKSPACE_ROOT", aliases: ["PLATFORM_WORKSPACE_ROOT"], default: "current working directory", description: "Local root that contains artifact app directories." },
|
|
21
|
+
{ flag: "--surface", env: "TOPLINE_MCP_SURFACE", default: "build", allowed: ["build", "harness"], description: "Build is the current surface; harness preserves legacy OAuth compatibility." },
|
|
22
|
+
{ flag: "--protocol-version", env: "TOPLINE_MCP_PROTOCOL_VERSION", aliases: ["PLATFORM_MCP_PROTOCOL_VERSION"], default: DEFAULT_MCP_PROTOCOL_VERSION, description: "MCP protocol version sent to Topline HTTP endpoints." },
|
|
23
|
+
{ flag: "--auth-file", env: "TOPLINE_MCP_AUTH_FILE", aliases: ["PLATFORM_MCP_AUTH_FILE"], default: "per-tenant file in the local Topline config directory", description: "OAuth credential cache. Keep it outside artifact app directories." },
|
|
24
|
+
{ flag: "--scopes", env: "TOPLINE_MCP_SCOPES", aliases: ["PLATFORM_MCP_SCOPES"], default: DEFAULT_OAUTH_SCOPES.join(" "), description: "Space- or comma-separated OAuth scopes." },
|
|
25
|
+
{ flag: "--timeout-ms", env: "TOPLINE_MCP_TIMEOUT_MS", aliases: ["PLATFORM_MCP_TIMEOUT_MS", "OVERSITE_MCP_TIMEOUT_MS"], default: DEFAULT_TIMEOUT_MS, description: "Positive request timeout in milliseconds." },
|
|
26
|
+
{ flag: "--token", env: "TOPLINE_MCP_TOKEN", aliases: ["PLATFORM_MCP_TOKEN", "OVERSITE_MCP_TOKEN"], description: "Advanced manual bearer-token fallback. Prefer browser-approved OAuth." }
|
|
27
|
+
].map((option) => Object.freeze({
|
|
28
|
+
...option,
|
|
29
|
+
...(option.aliases ? { aliases: Object.freeze(option.aliases) } : {}),
|
|
30
|
+
...(option.allowed ? { allowed: Object.freeze(option.allowed) } : {})
|
|
31
|
+
})));
|
|
18
32
|
const BLOCK_SIZE = 512;
|
|
19
33
|
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
20
34
|
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
|
35
|
+
const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
21
36
|
const MAX_FILES = 25_000;
|
|
22
37
|
const MAX_DEPTH = 64;
|
|
23
38
|
const FORBIDDEN_DIRS = new Set([".git", "node_modules", ".next", ".vite", ".turbo", ".cache", "dist", "build", "coverage"]);
|
|
@@ -55,7 +70,7 @@ export function parseHarnessOptions(argv, env = process.env) {
|
|
|
55
70
|
surface: surfaceRaw,
|
|
56
71
|
token,
|
|
57
72
|
authFile,
|
|
58
|
-
oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : DEFAULT_OAUTH_SCOPES,
|
|
73
|
+
oauthScopes: scopes.length > 0 ? [...new Set(scopes)] : [...DEFAULT_OAUTH_SCOPES],
|
|
59
74
|
timeoutMs: Math.floor(timeoutMs),
|
|
60
75
|
protocolVersion,
|
|
61
76
|
workspaceRoot
|
|
@@ -80,30 +95,34 @@ export function cliInfo(argv) {
|
|
|
80
95
|
return [
|
|
81
96
|
"Usage: topline-mcp --url https://your-topline-host [--workspace-root <path>]",
|
|
82
97
|
"",
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
" TOPLINE_MCP_AUTH_FILE Optional OAuth credential cache path",
|
|
89
|
-
" TOPLINE_MCP_TIMEOUT_MS Defaults to 120000",
|
|
98
|
+
"Options and environment:",
|
|
99
|
+
...BUILD_MCP_SETUP_OPTIONS.map((option) => {
|
|
100
|
+
const requirement = option.required ? "required" : `default: ${option.default ?? "optional"}`;
|
|
101
|
+
return ` ${option.flag.padEnd(20)} ${option.env} (${requirement})`;
|
|
102
|
+
}),
|
|
90
103
|
"",
|
|
91
104
|
"Examples:",
|
|
92
105
|
" npx -y @topline-build/harness-mcp --url https://hawx.topline.build",
|
|
93
106
|
" TOPLINE_API_URL=https://hawx.topline.build npx -y @topline-build/harness-mcp"
|
|
94
107
|
].join("\n");
|
|
95
108
|
}
|
|
96
|
-
export function createHarnessMcpStdioServer(opts) {
|
|
97
|
-
const
|
|
98
|
-
|
|
109
|
+
export function createHarnessMcpStdioServer(opts, toolAllowlist) {
|
|
110
|
+
const allowedTools = toolAllowlist ? new Set(toolAllowlist) : null;
|
|
111
|
+
const listedTools = () => {
|
|
112
|
+
const listed = listTools();
|
|
113
|
+
return allowedTools ? { ...listed, tools: listed.tools.filter((tool) => allowedTools.has(tool.name)) } : listed;
|
|
114
|
+
};
|
|
115
|
+
const mcp = new McpServer({ name: BUILD_MCP_SERVER_NAME, version: HARNESS_MCP_VERSION }, { capabilities: { tools: {} } });
|
|
116
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => listedTools());
|
|
99
117
|
mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
if (allowedTools && !allowedTools.has(req.params.name)) {
|
|
119
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`);
|
|
120
|
+
}
|
|
121
|
+
const reportsProgress = req.params.name === "platform_deploy_artifact_app" || req.params.name === "platform_wait_artifact_deploy";
|
|
103
122
|
try {
|
|
104
|
-
const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
const result = await callHarnessTool(opts, req.params.name, req.params.arguments ?? {}, reportsProgress
|
|
124
|
+
? { onProgress: (progress, message) => sendProgress(extra, progress, 1, message) }
|
|
125
|
+
: undefined);
|
|
107
126
|
return result;
|
|
108
127
|
}
|
|
109
128
|
catch (err) {
|
|
@@ -111,15 +130,12 @@ export function createHarnessMcpStdioServer(opts) {
|
|
|
111
130
|
throw err;
|
|
112
131
|
if (reportsProgress)
|
|
113
132
|
await sendProgress(extra, 1, 1, "Failed artifact deployment");
|
|
114
|
-
return
|
|
115
|
-
content: [{ type: "text", text: redactSecrets(err instanceof Error ? err.message : String(err), opts) }],
|
|
116
|
-
isError: true
|
|
117
|
-
};
|
|
133
|
+
return structuredToolError(redactSecrets(err instanceof Error ? err.message : String(err), opts));
|
|
118
134
|
}
|
|
119
135
|
});
|
|
120
136
|
return mcp;
|
|
121
137
|
}
|
|
122
|
-
export async function callHarnessTool(opts, name, args) {
|
|
138
|
+
export async function callHarnessTool(opts, name, args, hooks) {
|
|
123
139
|
const input = isRecord(args) ? args : {};
|
|
124
140
|
const tool = listTools().tools.find((candidate) => candidate.name === name);
|
|
125
141
|
if (!tool)
|
|
@@ -129,8 +145,21 @@ export async function callHarnessTool(opts, name, args) {
|
|
|
129
145
|
return toolError(`Invalid arguments for ${name}: ${inputError}`);
|
|
130
146
|
let result;
|
|
131
147
|
switch (name) {
|
|
148
|
+
case "platform_connection_check":
|
|
149
|
+
case "platform_list_build_workspaces":
|
|
150
|
+
case "platform_list_artifact_drafts":
|
|
151
|
+
case "platform_deploy_operation":
|
|
152
|
+
case "platform_validate_artifact":
|
|
153
|
+
case "platform_run_artifact_smoke":
|
|
154
|
+
case "platform_plan_artifact_draft_delete":
|
|
155
|
+
case "platform_delete_artifact_draft":
|
|
156
|
+
result = toolResult(await callHostedBuildTool(opts, name, input));
|
|
157
|
+
break;
|
|
132
158
|
case "platform_deploy_artifact_app":
|
|
133
|
-
result = toolResult(await deployArtifactApp(opts, input));
|
|
159
|
+
result = toolResult(await deployArtifactApp(opts, input, hooks?.onProgress));
|
|
160
|
+
break;
|
|
161
|
+
case "platform_inspect_artifact_app":
|
|
162
|
+
result = toolResult(await inspectArtifactApp(opts, input));
|
|
134
163
|
break;
|
|
135
164
|
case "platform_artifact_status":
|
|
136
165
|
result = toolResult(await getArtifactStatus(opts, stringArg(input, "artifactId"), numberArg(input, "limit")));
|
|
@@ -144,6 +173,12 @@ export async function callHarnessTool(opts, name, args) {
|
|
|
144
173
|
case "platform_storybook_access":
|
|
145
174
|
result = toolResult(storybookAccess(opts));
|
|
146
175
|
break;
|
|
176
|
+
case "platform_wait_artifact_deploy":
|
|
177
|
+
result = toolResult(await waitForArtifactDeploy(opts, input, hooks?.onProgress));
|
|
178
|
+
break;
|
|
179
|
+
case "platform_init_artifact_app":
|
|
180
|
+
result = toolResult(await initArtifactApp(opts, input));
|
|
181
|
+
break;
|
|
147
182
|
default:
|
|
148
183
|
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${name}`);
|
|
149
184
|
}
|
|
@@ -152,12 +187,24 @@ export async function callHarnessTool(opts, name, args) {
|
|
|
152
187
|
: null;
|
|
153
188
|
return outputError ? toolError(`Invalid output for ${name}: ${outputError}`) : result;
|
|
154
189
|
}
|
|
155
|
-
export async function deployArtifactApp(opts, input) {
|
|
190
|
+
export async function deployArtifactApp(opts, input, onProgress) {
|
|
191
|
+
await onProgress?.(0, "Reading artifact manifest");
|
|
156
192
|
const appDir = stringArg(input, "appDir");
|
|
157
|
-
const absoluteAppDir =
|
|
158
|
-
|
|
193
|
+
const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
|
|
194
|
+
await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
|
|
195
|
+
const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
159
196
|
const manifest = await readManifest(absoluteAppDir);
|
|
197
|
+
const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
|
|
198
|
+
const clientRequestId = optionalString(input, "requestId") ?? createHash("sha256")
|
|
199
|
+
.update(JSON.stringify({ apiUrl: platformBaseUrl(opts), appDir: platformAppDir, manifestId: manifest.id ?? null }))
|
|
200
|
+
.digest("hex");
|
|
201
|
+
await onProgress?.(0.2, "Packaging local artifact app");
|
|
160
202
|
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
203
|
+
const expectedBundleSha256 = optionalString(input, "expectedBundleSha256")?.toLowerCase();
|
|
204
|
+
if (expectedBundleSha256 && bundle.sha256 !== expectedBundleSha256) {
|
|
205
|
+
throw new Error("artifact_app_bundle_changed_after_inspection");
|
|
206
|
+
}
|
|
207
|
+
await onProgress?.(0.4, "Creating private artifact draft");
|
|
161
208
|
const created = await fetchJson(opts, artifactAppsPath(opts), {
|
|
162
209
|
method: "POST",
|
|
163
210
|
headers: await jsonHeaders(opts),
|
|
@@ -166,6 +213,8 @@ export async function deployArtifactApp(opts, input) {
|
|
|
166
213
|
title: optionalString(input, "title"),
|
|
167
214
|
description: optionalString(input, "description"),
|
|
168
215
|
projectId: optionalString(input, "projectId"),
|
|
216
|
+
clientRequestId,
|
|
217
|
+
bundleSha256: bundle.sha256,
|
|
169
218
|
manifest
|
|
170
219
|
})
|
|
171
220
|
});
|
|
@@ -175,6 +224,7 @@ export async function deployArtifactApp(opts, input) {
|
|
|
175
224
|
const artifactId = stringValue(createdRecord.artifactId, "artifactId");
|
|
176
225
|
const artifactVersionId = stringValue(createdRecord.artifactVersionId, "artifactVersionId");
|
|
177
226
|
const bearerToken = await getBearerToken(opts);
|
|
227
|
+
await onProgress?.(0.6, "Uploading bundle and waiting for trusted draft deployment");
|
|
178
228
|
const deployed = await fetchJson(opts, `${artifactAppsPath(opts)}/${encodeURIComponent(artifactId)}/deploy-bundle`, {
|
|
179
229
|
method: "POST",
|
|
180
230
|
headers: {
|
|
@@ -186,11 +236,16 @@ export async function deployArtifactApp(opts, input) {
|
|
|
186
236
|
},
|
|
187
237
|
body: bundle.body
|
|
188
238
|
});
|
|
239
|
+
await onProgress?.(1, "Completed trusted draft deployment");
|
|
240
|
+
const deployedRecord = asRecord(deployed);
|
|
241
|
+
const operationId = typeof deployedRecord.taskId === "string" ? deployedRecord.taskId : null;
|
|
189
242
|
return {
|
|
190
243
|
ok: true,
|
|
191
244
|
artifactId,
|
|
192
245
|
artifactVersionId,
|
|
193
246
|
manifestId: typeof manifest.id === "string" ? manifest.id : null,
|
|
247
|
+
clientRequestId,
|
|
248
|
+
operationId,
|
|
194
249
|
bundle: {
|
|
195
250
|
fileCount: bundle.fileCount,
|
|
196
251
|
byteCount: bundle.byteCount,
|
|
@@ -201,6 +256,132 @@ export async function deployArtifactApp(opts, input) {
|
|
|
201
256
|
deploy: deployed
|
|
202
257
|
};
|
|
203
258
|
}
|
|
259
|
+
export async function inspectArtifactApp(opts, input) {
|
|
260
|
+
const appDir = stringArg(input, "appDir");
|
|
261
|
+
const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
|
|
262
|
+
await assertAuthFileOutsideAppDir(opts.authFile, absoluteAppDir);
|
|
263
|
+
const workspaceAppDir = workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir);
|
|
264
|
+
const manifest = await readManifest(absoluteAppDir);
|
|
265
|
+
const platformAppDir = logicalArtifactAppDir(workspaceAppDir, manifest);
|
|
266
|
+
const bundle = await packArtifactAppDirectory(absoluteAppDir, platformAppDir === "." ? "" : platformAppDir);
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
packageReady: true,
|
|
270
|
+
serverManifestValidationRequired: true,
|
|
271
|
+
appDir: workspaceAppDir,
|
|
272
|
+
platformAppDir,
|
|
273
|
+
manifest: {
|
|
274
|
+
id: typeof manifest.id === "string" ? manifest.id : null,
|
|
275
|
+
manifestVersion: typeof manifest.manifestVersion === "number" ? manifest.manifestVersion : null,
|
|
276
|
+
title: typeof manifest.title === "string" ? manifest.title : null,
|
|
277
|
+
kind: typeof manifest.kind === "string" ? manifest.kind : null
|
|
278
|
+
},
|
|
279
|
+
bundle: {
|
|
280
|
+
fileCount: bundle.fileCount,
|
|
281
|
+
byteCount: bundle.byteCount,
|
|
282
|
+
sha256: bundle.sha256,
|
|
283
|
+
excluded: bundle.excluded
|
|
284
|
+
},
|
|
285
|
+
nextAction: "Call platform_deploy_artifact_app with the same appDir and expectedBundleSha256 set to bundle.sha256 when this local package is ready to deploy."
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function callHostedBuildTool(opts, name, input) {
|
|
289
|
+
if (mcpSurface(opts) !== "build")
|
|
290
|
+
throw new Error(`${name}_requires_build_surface`);
|
|
291
|
+
const headers = await jsonHeaders(opts);
|
|
292
|
+
const payload = asRecord(await fetchJson(opts, `${platformBaseUrl(opts)}/mcp/build`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { ...headers, accept: "application/json, text/event-stream" },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
jsonrpc: "2.0",
|
|
297
|
+
id: `stdio-${Date.now()}`,
|
|
298
|
+
method: "tools/call",
|
|
299
|
+
params: { name, arguments: input }
|
|
300
|
+
})
|
|
301
|
+
}));
|
|
302
|
+
if (payload.error) {
|
|
303
|
+
const error = asRecord(payload.error);
|
|
304
|
+
throw new Error(`platform_mcp_tool_error:${String(error.message ?? "unknown_error")}`);
|
|
305
|
+
}
|
|
306
|
+
const result = asRecord(payload.result);
|
|
307
|
+
if (result.isError === true) {
|
|
308
|
+
const content = Array.isArray(result.content) ? result.content.filter(isRecord) : [];
|
|
309
|
+
const message = content.find((item) => item.type === "text" && typeof item.text === "string")?.text;
|
|
310
|
+
throw new Error(`platform_mcp_tool_error:${String(message ?? "tool_failed")}`);
|
|
311
|
+
}
|
|
312
|
+
if (isRecord(result.structuredContent))
|
|
313
|
+
return result.structuredContent;
|
|
314
|
+
throw new Error(`platform_mcp_tool_output_missing:${name}`);
|
|
315
|
+
}
|
|
316
|
+
export async function waitForArtifactDeploy(opts, input, onProgress) {
|
|
317
|
+
const artifactId = stringArg(input, "artifactId");
|
|
318
|
+
const operationId = stringArg(input, "operationId");
|
|
319
|
+
const timeoutSeconds = Math.min(Math.max(numberArg(input, "timeoutSeconds") ?? 300, 1), 900);
|
|
320
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
321
|
+
let afterSeq = 0;
|
|
322
|
+
let latest = {};
|
|
323
|
+
while (Date.now() < deadline) {
|
|
324
|
+
latest = asRecord(await callHostedBuildTool(opts, "platform_deploy_operation", { artifactId, operationId, afterSeq }));
|
|
325
|
+
const progress = typeof latest.progress === "number" ? latest.progress : 0.7;
|
|
326
|
+
const events = Array.isArray(latest.events) ? latest.events.filter(isRecord) : [];
|
|
327
|
+
const eventMessage = [...events].reverse().find((event) => typeof event.message === "string")?.message;
|
|
328
|
+
await onProgress?.(progress, typeof eventMessage === "string" ? eventMessage : `Artifact deployment: ${String(latest.phase ?? latest.taskStatus ?? "running")}`);
|
|
329
|
+
if (typeof latest.nextSeq === "number")
|
|
330
|
+
afterSeq = latest.nextSeq;
|
|
331
|
+
if (latest.terminal === true) {
|
|
332
|
+
if (latest.successful !== true)
|
|
333
|
+
throw new Error(`artifact_deploy_failed:${String(latest.taskStatus ?? "failed")}`);
|
|
334
|
+
return latest;
|
|
335
|
+
}
|
|
336
|
+
const waitMs = typeof latest.nextPollAfterMs === "number" ? Math.min(Math.max(latest.nextPollAfterMs, 250), 10_000) : 2_000;
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`artifact_deploy_wait_timeout:${operationId}`);
|
|
340
|
+
}
|
|
341
|
+
export async function initArtifactApp(opts, input) {
|
|
342
|
+
const appDir = stringArg(input, "appDir");
|
|
343
|
+
const title = optionalString(input, "title") ?? "Topline artifact";
|
|
344
|
+
const manifestId = (optionalString(input, "manifestId") ?? path.basename(appDir))
|
|
345
|
+
.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
346
|
+
if (!manifestId || manifestId.length > 80)
|
|
347
|
+
throw new Error("artifact_manifest_id_invalid");
|
|
348
|
+
const absoluteAppDir = await resolveWorkspacePathSafely(opts.workspaceRoot, appDir);
|
|
349
|
+
if (existsSync(absoluteAppDir) && (await readdir(absoluteAppDir)).length > 0)
|
|
350
|
+
throw new Error("artifact_app_scaffold_target_not_empty");
|
|
351
|
+
await mkdir(path.join(absoluteAppDir, "src"), { recursive: true });
|
|
352
|
+
const manifest = {
|
|
353
|
+
manifestVersion: 2,
|
|
354
|
+
id: manifestId,
|
|
355
|
+
title,
|
|
356
|
+
kind: "tool",
|
|
357
|
+
runtime: { target: "aws_lambda_function_url", region: "us-west-2", entryPoint: "artifactApp" },
|
|
358
|
+
build: { command: "node src/index.js --build", outputDir: "dist" },
|
|
359
|
+
validation: { localCommand: "node src/index.js --test", testCommand: "node src/index.js --test --artifact-tests-json" },
|
|
360
|
+
routes: [
|
|
361
|
+
{ path: "/", kind: "page", handler: "artifactApp" },
|
|
362
|
+
{ path: "/_artifact/health", kind: "api", handler: "artifactApp" }
|
|
363
|
+
],
|
|
364
|
+
sourceBindings: [],
|
|
365
|
+
env: ["ARTIFACT_ID", "ARTIFACT_VERSION_ID", "ARTIFACT_MANIFEST_URI", "PLATFORM_ARTIFACT_ID", "PLATFORM_ARTIFACT_VERSION_ID", "PLATFORM_ARTIFACT_MANIFEST_URI"],
|
|
366
|
+
viewerPolicy: "private"
|
|
367
|
+
};
|
|
368
|
+
const packageJson = { name: manifestId, private: true, type: "module", main: "src/index.js", scripts: { build: "node src/index.js --build", test: "node src/index.js --test" } };
|
|
369
|
+
const safeTitle = title.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
370
|
+
const source = `import { mkdirSync, writeFileSync } from "node:fs";\nimport path from "node:path";\n\nconst page = ${JSON.stringify(`<!doctype html><html><body><main data-platform-artifact-root><h1>${safeTitle}</h1><p>Ready.</p></main></body></html>`)};\n\nexport function artifactApp(_req, res) {\n res.setHeader("content-type", "text/html; charset=utf-8");\n res.end(page);\n}\n\nif (process.argv.includes("--build")) {\n mkdirSync("dist", { recursive: true });\n writeFileSync(path.join("dist", "index.html"), page, "utf8");\n}\n\nif (process.argv.includes("--test") && process.argv.includes("--artifact-tests-json")) {\n console.log(JSON.stringify({ tests: [{ description: "The artifact renders a visible app root.", required: true, status: "passed", summary: "The page includes data-platform-artifact-root.", evidence: { selector: "data-platform-artifact-root" } }] }));\n} else if (process.argv.includes("--build") || process.argv.includes("--test")) {\n console.log("artifact app template ok");\n}\n`;
|
|
371
|
+
await Promise.all([
|
|
372
|
+
writeFile(path.join(absoluteAppDir, "artifact-app.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }),
|
|
373
|
+
writeFile(path.join(absoluteAppDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, { flag: "wx" }),
|
|
374
|
+
writeFile(path.join(absoluteAppDir, "src", "index.js"), source, { flag: "wx" })
|
|
375
|
+
]);
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
appDir: workspaceRelativeAppDir(opts.workspaceRoot, absoluteAppDir),
|
|
379
|
+
manifestId,
|
|
380
|
+
title,
|
|
381
|
+
files: ["artifact-app.json", "package.json", "src/index.js"],
|
|
382
|
+
nextAction: `Run npm test in ${appDir}, then call platform_inspect_artifact_app with the same appDir before deployment.`
|
|
383
|
+
};
|
|
384
|
+
}
|
|
204
385
|
export async function getArtifactStatus(opts, artifactId, limit) {
|
|
205
386
|
const query = new URLSearchParams();
|
|
206
387
|
if (limit)
|
|
@@ -289,6 +470,8 @@ export function storybookAccess(opts) {
|
|
|
289
470
|
}
|
|
290
471
|
export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
|
|
291
472
|
const root = path.resolve(appDir);
|
|
473
|
+
if ((await lstat(root)).isSymbolicLink())
|
|
474
|
+
throw new Error("artifact_app_bundle_symlink_forbidden:.");
|
|
292
475
|
const prefix = archivePrefix ? validateArchivePrefix(archivePrefix) : "";
|
|
293
476
|
const files = [];
|
|
294
477
|
const excluded = [];
|
|
@@ -297,11 +480,13 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
|
|
|
297
480
|
throw new Error("artifact_app_bundle_empty");
|
|
298
481
|
let byteCount = 0;
|
|
299
482
|
const chunks = [];
|
|
300
|
-
for (const file of files.sort((a, b) => a.relative
|
|
301
|
-
byteCount
|
|
302
|
-
if (byteCount > MAX_UNPACKED_BYTES)
|
|
483
|
+
for (const file of files.sort((a, b) => compareUtf8Paths(a.relative, b.relative))) {
|
|
484
|
+
if (byteCount + file.size > MAX_UNPACKED_BYTES)
|
|
303
485
|
throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
|
|
304
486
|
const content = await readFile(file.absolute);
|
|
487
|
+
byteCount += content.length;
|
|
488
|
+
if (byteCount > MAX_UNPACKED_BYTES)
|
|
489
|
+
throw new Error(`artifact_app_bundle_unpacked_too_large:${MAX_UNPACKED_BYTES}`);
|
|
305
490
|
chunks.push(tarHeader(prefix ? `${prefix}/${file.relative}` : file.relative, content.length));
|
|
306
491
|
chunks.push(content);
|
|
307
492
|
chunks.push(Buffer.alloc(padSize(content.length)));
|
|
@@ -315,9 +500,12 @@ export async function packArtifactAppDirectory(appDir, archivePrefix = "") {
|
|
|
315
500
|
fileCount: files.length,
|
|
316
501
|
byteCount,
|
|
317
502
|
sha256: createHash("sha256").update(body).digest("hex"),
|
|
318
|
-
excluded
|
|
503
|
+
excluded: excluded.sort(compareUtf8Paths)
|
|
319
504
|
};
|
|
320
505
|
}
|
|
506
|
+
function compareUtf8Paths(left, right) {
|
|
507
|
+
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
|
508
|
+
}
|
|
321
509
|
async function walk(root, current, files, excluded) {
|
|
322
510
|
const entries = await readdir(current, { withFileTypes: true });
|
|
323
511
|
for (const entry of entries) {
|
|
@@ -335,6 +523,8 @@ async function walk(root, current, files, excluded) {
|
|
|
335
523
|
await walk(root, absolute, files, excluded);
|
|
336
524
|
}
|
|
337
525
|
else if (entry.isFile()) {
|
|
526
|
+
if (stat.nlink > 1)
|
|
527
|
+
throw new Error(`artifact_app_bundle_hardlink_forbidden:${relative}`);
|
|
338
528
|
files.push({ absolute, relative, size: stat.size });
|
|
339
529
|
if (files.length > MAX_FILES)
|
|
340
530
|
throw new Error(`artifact_app_bundle_file_limit:${MAX_FILES}`);
|
|
@@ -343,7 +533,32 @@ async function walk(root, current, files, excluded) {
|
|
|
343
533
|
}
|
|
344
534
|
async function readManifest(appDir) {
|
|
345
535
|
const manifestPath = path.join(appDir, "artifact-app.json");
|
|
346
|
-
|
|
536
|
+
let stat;
|
|
537
|
+
try {
|
|
538
|
+
stat = await lstat(manifestPath);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (error.code === "ENOENT")
|
|
542
|
+
throw new Error("artifact_app_manifest_not_found");
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
if (stat.isSymbolicLink()) {
|
|
546
|
+
throw new Error("artifact_app_bundle_symlink_forbidden:artifact-app.json");
|
|
547
|
+
}
|
|
548
|
+
if (!stat.isFile())
|
|
549
|
+
throw new Error("artifact_app_manifest_must_be_file");
|
|
550
|
+
if (stat.size > MAX_MANIFEST_BYTES)
|
|
551
|
+
throw new Error(`artifact_app_manifest_too_large:${MAX_MANIFEST_BYTES}`);
|
|
552
|
+
const source = await readFile(manifestPath, "utf8");
|
|
553
|
+
if (Buffer.byteLength(source) > MAX_MANIFEST_BYTES)
|
|
554
|
+
throw new Error(`artifact_app_manifest_too_large:${MAX_MANIFEST_BYTES}`);
|
|
555
|
+
let parsed;
|
|
556
|
+
try {
|
|
557
|
+
parsed = JSON.parse(source);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
throw new Error("artifact_app_manifest_invalid_json");
|
|
561
|
+
}
|
|
347
562
|
if (!isRecord(parsed))
|
|
348
563
|
throw new Error("artifact_app_manifest_must_be_object");
|
|
349
564
|
return parsed;
|
|
@@ -351,10 +566,61 @@ async function readManifest(appDir) {
|
|
|
351
566
|
function resolveWorkspacePath(workspaceRoot, inputPath) {
|
|
352
567
|
const resolved = path.resolve(workspaceRoot, inputPath);
|
|
353
568
|
const relative = path.relative(workspaceRoot, resolved);
|
|
354
|
-
if (
|
|
569
|
+
if (pathEscapesRoot(relative))
|
|
355
570
|
throw new Error("appDir_must_be_inside_workspace_root");
|
|
356
571
|
return resolved;
|
|
357
572
|
}
|
|
573
|
+
async function resolveWorkspacePathSafely(workspaceRoot, inputPath) {
|
|
574
|
+
const root = path.resolve(workspaceRoot);
|
|
575
|
+
const resolved = resolveWorkspacePath(root, inputPath);
|
|
576
|
+
const rootRealPath = await realpath(root);
|
|
577
|
+
let existingPath = root;
|
|
578
|
+
let candidatePath = root;
|
|
579
|
+
const relative = path.relative(root, resolved);
|
|
580
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
581
|
+
candidatePath = path.join(candidatePath, segment);
|
|
582
|
+
try {
|
|
583
|
+
const stat = await lstat(candidatePath);
|
|
584
|
+
if (stat.isSymbolicLink()) {
|
|
585
|
+
throw new Error(`appDir_symlink_forbidden:${toPosix(path.relative(root, candidatePath))}`);
|
|
586
|
+
}
|
|
587
|
+
existingPath = candidatePath;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (error.code === "ENOENT")
|
|
591
|
+
break;
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const existingRealPath = await realpath(existingPath);
|
|
596
|
+
const realRelative = path.relative(rootRealPath, existingRealPath);
|
|
597
|
+
if (pathEscapesRoot(realRelative)) {
|
|
598
|
+
throw new Error("appDir_must_resolve_inside_workspace_root");
|
|
599
|
+
}
|
|
600
|
+
return resolved;
|
|
601
|
+
}
|
|
602
|
+
function pathEscapesRoot(relativePath) {
|
|
603
|
+
return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
604
|
+
}
|
|
605
|
+
async function assertAuthFileOutsideAppDir(authFile, appDir) {
|
|
606
|
+
const relative = path.relative(path.resolve(appDir), path.resolve(authFile));
|
|
607
|
+
if (!pathEscapesRoot(relative))
|
|
608
|
+
throw new Error("artifact_app_auth_file_inside_app_dir");
|
|
609
|
+
let realAuthFile;
|
|
610
|
+
let realAppDir;
|
|
611
|
+
try {
|
|
612
|
+
realAuthFile = await realpath(authFile);
|
|
613
|
+
realAppDir = await realpath(appDir);
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error.code === "ENOENT")
|
|
617
|
+
return;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
const realRelative = path.relative(realAppDir, realAuthFile);
|
|
621
|
+
if (!pathEscapesRoot(realRelative))
|
|
622
|
+
throw new Error("artifact_app_auth_file_inside_app_dir");
|
|
623
|
+
}
|
|
358
624
|
function workspaceRelativeAppDir(workspaceRoot, absoluteAppDir) {
|
|
359
625
|
const relative = toPosix(path.relative(path.resolve(workspaceRoot), path.resolve(absoluteAppDir)));
|
|
360
626
|
return relative ? validateArchivePrefix(relative) : ".";
|
|
@@ -392,7 +658,9 @@ function tarHeader(name, size) {
|
|
|
392
658
|
writeOctal(header, 108, 8, 0);
|
|
393
659
|
writeOctal(header, 116, 8, 0);
|
|
394
660
|
writeOctal(header, 124, 12, size);
|
|
395
|
-
|
|
661
|
+
// Content-identical retries must produce the same bundle hash. A wall-clock
|
|
662
|
+
// mtime would make every archive look new even when no source file changed.
|
|
663
|
+
writeOctal(header, 136, 12, 0);
|
|
396
664
|
header.fill(0x20, 148, 156);
|
|
397
665
|
header.write("0", 156, 1, "utf8");
|
|
398
666
|
header.write("ustar", 257, 5, "utf8");
|
|
@@ -444,6 +712,10 @@ async function fetchJson(opts, url, init) {
|
|
|
444
712
|
payload = { detail: text.slice(0, 500) };
|
|
445
713
|
}
|
|
446
714
|
if (!response.ok) {
|
|
715
|
+
const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
|
|
716
|
+
if (code === "artifact_source_git_app_dir_mismatch") {
|
|
717
|
+
throw new Error("artifact_source_git_app_dir_mismatch: Use the same local app/source root as the existing artifact, or deploy without artifactId to create a new draft.");
|
|
718
|
+
}
|
|
447
719
|
throw new Error(`platform_harness_http_error:${response.status}:${redactSecrets(JSON.stringify(payload), opts)}`);
|
|
448
720
|
}
|
|
449
721
|
return payload;
|
|
@@ -457,127 +729,312 @@ async function fetchJson(opts, url, init) {
|
|
|
457
729
|
clearTimeout(timeout);
|
|
458
730
|
}
|
|
459
731
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
properties: {
|
|
470
|
-
appDir: { type: "string", minLength: 1 },
|
|
471
|
-
artifactId: { type: "string" },
|
|
472
|
-
title: { type: "string" },
|
|
473
|
-
description: { type: "string" },
|
|
474
|
-
projectId: { type: "string" }
|
|
475
|
-
},
|
|
476
|
-
required: ["appDir"],
|
|
477
|
-
additionalProperties: false
|
|
478
|
-
},
|
|
479
|
-
outputSchema: {
|
|
480
|
-
type: "object",
|
|
481
|
-
properties: {
|
|
482
|
-
ok: { type: "boolean" },
|
|
483
|
-
artifactId: { type: "string" },
|
|
484
|
-
artifactVersionId: { type: "string" },
|
|
485
|
-
bundle: { type: "object" },
|
|
486
|
-
create: { type: "object" },
|
|
487
|
-
deploy: { type: "object" }
|
|
488
|
-
},
|
|
489
|
-
required: ["ok", "artifactId", "artifactVersionId", "bundle", "create", "deploy"]
|
|
490
|
-
}
|
|
732
|
+
const BUILD_MCP_TOOLS = [
|
|
733
|
+
{
|
|
734
|
+
name: "platform_connection_check",
|
|
735
|
+
description: "Complete the one-time Developer Access verification challenge for this Build MCP client.",
|
|
736
|
+
annotations: mcpToolAnnotations("platform_connection_check", { readOnly: false, openWorld: true }),
|
|
737
|
+
inputSchema: {
|
|
738
|
+
type: "object",
|
|
739
|
+
properties: {
|
|
740
|
+
verificationCode: { type: "string", minLength: 16, maxLength: 80 }
|
|
491
741
|
},
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
required: ["artifactId"],
|
|
500
|
-
additionalProperties: false
|
|
501
|
-
},
|
|
502
|
-
outputSchema: {
|
|
503
|
-
type: "object",
|
|
504
|
-
properties: {
|
|
505
|
-
ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
|
|
506
|
-
deployments: { type: "array", items: { type: "object" } }
|
|
507
|
-
},
|
|
508
|
-
required: ["ok", "phase", "artifactId", "deployments"]
|
|
509
|
-
}
|
|
742
|
+
required: ["verificationCode"],
|
|
743
|
+
additionalProperties: false
|
|
744
|
+
},
|
|
745
|
+
outputSchema: {
|
|
746
|
+
type: "object",
|
|
747
|
+
properties: {
|
|
748
|
+
ok: { type: "boolean" }, verified: { type: "boolean" }, product: { type: "string", enum: ["build"] }, verifiedAt: { type: "string" }
|
|
510
749
|
},
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
750
|
+
required: ["ok", "verified", "product", "verifiedAt"]
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "platform_init_artifact_app",
|
|
755
|
+
description: "Create a minimal, validated artifact app starter inside the configured local workspace.",
|
|
756
|
+
annotations: mcpToolAnnotations("platform_init_artifact_app", { readOnly: false }),
|
|
757
|
+
inputSchema: {
|
|
758
|
+
type: "object",
|
|
759
|
+
properties: { appDir: { type: "string", minLength: 1 }, manifestId: { type: "string" }, title: { type: "string" } },
|
|
760
|
+
required: ["appDir"],
|
|
761
|
+
additionalProperties: false
|
|
762
|
+
},
|
|
763
|
+
outputSchema: {
|
|
764
|
+
type: "object",
|
|
765
|
+
properties: { ok: { type: "boolean" }, appDir: { type: "string" }, manifestId: { type: "string" }, title: { type: "string" }, files: { type: "array", items: { type: "string" } }, nextAction: { type: "string" } },
|
|
766
|
+
required: ["ok", "appDir", "manifestId", "title", "files", "nextAction"]
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
name: "platform_deploy_artifact_app",
|
|
771
|
+
description: "Package a local artifact app directory, upload it, deploy a private draft, and return status.",
|
|
772
|
+
annotations: mcpToolAnnotations("platform_deploy_artifact_app", { readOnly: false, openWorld: true }),
|
|
773
|
+
inputSchema: {
|
|
774
|
+
type: "object",
|
|
775
|
+
properties: {
|
|
776
|
+
appDir: { type: "string", minLength: 1 },
|
|
777
|
+
artifactId: { type: "string", format: "uuid" },
|
|
778
|
+
title: { type: "string" },
|
|
779
|
+
description: { type: "string" },
|
|
780
|
+
projectId: { type: "string", format: "uuid" },
|
|
781
|
+
expectedBundleSha256: { type: "string", pattern: "^[0-9a-fA-F]{64}$", description: "Optional SHA-256 from platform_inspect_artifact_app; deployment fails locally if source changed." },
|
|
782
|
+
requestId: { type: "string", minLength: 1, maxLength: 128, description: "Optional stable retry key; generated deterministically when omitted." }
|
|
543
783
|
},
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
},
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
|
|
559
|
-
},
|
|
560
|
-
required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
|
|
561
|
-
}
|
|
784
|
+
required: ["appDir"],
|
|
785
|
+
additionalProperties: false
|
|
786
|
+
},
|
|
787
|
+
outputSchema: {
|
|
788
|
+
type: "object",
|
|
789
|
+
properties: {
|
|
790
|
+
ok: { type: "boolean" },
|
|
791
|
+
artifactId: { type: "string" },
|
|
792
|
+
artifactVersionId: { type: "string" },
|
|
793
|
+
clientRequestId: { type: "string" },
|
|
794
|
+
operationId: { type: ["string", "null"] },
|
|
795
|
+
bundle: { type: "object" },
|
|
796
|
+
create: { type: "object" },
|
|
797
|
+
deploy: { type: "object" }
|
|
562
798
|
},
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
799
|
+
required: ["ok", "artifactId", "artifactVersionId", "clientRequestId", "operationId", "bundle", "create", "deploy"]
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: "platform_inspect_artifact_app",
|
|
804
|
+
description: "Inspect local artifact paths and package boundaries without authenticating, uploading, or creating a remote draft.",
|
|
805
|
+
annotations: mcpToolAnnotations("platform_inspect_artifact_app", { readOnly: true }),
|
|
806
|
+
inputSchema: {
|
|
807
|
+
type: "object",
|
|
808
|
+
properties: { appDir: { type: "string", minLength: 1 } },
|
|
809
|
+
required: ["appDir"],
|
|
810
|
+
additionalProperties: false
|
|
811
|
+
},
|
|
812
|
+
outputSchema: {
|
|
813
|
+
type: "object",
|
|
814
|
+
properties: {
|
|
815
|
+
ok: { type: "boolean" },
|
|
816
|
+
packageReady: { type: "boolean" },
|
|
817
|
+
serverManifestValidationRequired: { type: "boolean" },
|
|
818
|
+
appDir: { type: "string" },
|
|
819
|
+
platformAppDir: { type: "string" },
|
|
820
|
+
manifest: { type: "object" },
|
|
821
|
+
bundle: { type: "object" },
|
|
822
|
+
nextAction: { type: "string" }
|
|
823
|
+
},
|
|
824
|
+
required: ["ok", "packageReady", "serverManifestValidationRequired", "appDir", "platformAppDir", "manifest", "bundle", "nextAction"]
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
name: "platform_list_build_workspaces",
|
|
829
|
+
description: "List Topline workspaces available for artifact placement.",
|
|
830
|
+
annotations: mcpToolAnnotations("platform_list_build_workspaces", { readOnly: true, openWorld: true }),
|
|
831
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
832
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, workspaces: { type: "array", items: { type: "object" } } }, required: ["ok", "workspaces"] }
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
name: "platform_list_artifact_drafts",
|
|
836
|
+
description: "Discover private artifact drafts created through this Build MCP connection.",
|
|
837
|
+
annotations: mcpToolAnnotations("platform_list_artifact_drafts", { readOnly: true, openWorld: true }),
|
|
838
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", format: "uuid" }, limit: { type: "integer", minimum: 1, maximum: 100 } }, additionalProperties: false },
|
|
839
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, drafts: { type: "array", items: { type: "object" } } }, required: ["ok", "drafts"] }
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: "platform_deploy_operation",
|
|
843
|
+
description: "Read durable server-side progress for one artifact deployment operation.",
|
|
844
|
+
annotations: mcpToolAnnotations("platform_deploy_operation", { readOnly: true, openWorld: true }),
|
|
845
|
+
inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, operationId: { type: "string", format: "uuid" }, afterSeq: { type: "integer", minimum: 0 } }, required: ["artifactId", "operationId"], additionalProperties: false },
|
|
846
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, operationId: { type: "string" }, taskStatus: { type: "string" }, progress: { type: "number" }, terminal: { type: "boolean" }, successful: { type: "boolean" }, events: { type: "array", items: { type: "object" } } }, required: ["ok", "phase", "artifactId", "operationId", "taskStatus", "progress", "terminal", "successful", "events"] }
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
name: "platform_wait_artifact_deploy",
|
|
850
|
+
description: "Wait for a durable deployment operation and stream server-derived progress.",
|
|
851
|
+
annotations: mcpToolAnnotations("platform_wait_artifact_deploy", { readOnly: true, openWorld: true }),
|
|
852
|
+
inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, operationId: { type: "string", format: "uuid" }, timeoutSeconds: { type: "integer", minimum: 1, maximum: 900 } }, required: ["artifactId", "operationId"], additionalProperties: false },
|
|
853
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, operationId: { type: "string" }, taskStatus: { type: "string" }, progress: { type: "number" }, terminal: { type: "boolean" }, successful: { type: "boolean" }, events: { type: "array", items: { type: "object" } } }, required: ["ok", "phase", "artifactId", "operationId", "taskStatus", "progress", "terminal", "successful", "events"] }
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
name: "platform_artifact_status",
|
|
857
|
+
description: "Read private draft deployment status for a hosted artifact app.",
|
|
858
|
+
annotations: mcpToolAnnotations("platform_artifact_status", { readOnly: true, openWorld: true }),
|
|
859
|
+
inputSchema: {
|
|
860
|
+
type: "object",
|
|
861
|
+
properties: { artifactId: { type: "string", format: "uuid" }, limit: { type: "integer", minimum: 1, maximum: 50 } },
|
|
862
|
+
required: ["artifactId"],
|
|
863
|
+
additionalProperties: false
|
|
864
|
+
},
|
|
865
|
+
outputSchema: {
|
|
866
|
+
type: "object",
|
|
867
|
+
properties: {
|
|
868
|
+
ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
|
|
869
|
+
deployments: { type: "array", items: { type: "object" } }
|
|
870
|
+
},
|
|
871
|
+
required: ["ok", "phase", "artifactId", "deployments"]
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
name: "platform_read_artifact_logs",
|
|
876
|
+
description: "Read bounded, redacted deployment logs for a hosted artifact app.",
|
|
877
|
+
annotations: mcpToolAnnotations("platform_read_artifact_logs", { readOnly: true, openWorld: true }),
|
|
878
|
+
inputSchema: {
|
|
879
|
+
type: "object",
|
|
880
|
+
properties: {
|
|
881
|
+
artifactId: { type: "string", format: "uuid" },
|
|
882
|
+
deploymentRecordId: { type: "string", format: "uuid" },
|
|
883
|
+
environment: { type: "string", enum: ["draft", "live"] },
|
|
884
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
885
|
+
severity: { type: "string" },
|
|
886
|
+
contains: { type: "string" },
|
|
887
|
+
since: { type: "string", format: "date-time" },
|
|
888
|
+
windowMinutes: { type: "integer", minimum: 1, maximum: 1440 },
|
|
889
|
+
trace: { type: "string" },
|
|
890
|
+
requestId: { type: "string" },
|
|
891
|
+
route: { type: "string" }
|
|
892
|
+
},
|
|
893
|
+
required: ["artifactId"],
|
|
894
|
+
additionalProperties: false
|
|
895
|
+
},
|
|
896
|
+
outputSchema: {
|
|
897
|
+
type: "object",
|
|
898
|
+
properties: {
|
|
899
|
+
ok: { type: "boolean" },
|
|
900
|
+
phase: { type: "string" },
|
|
901
|
+
artifactId: { type: "string" },
|
|
902
|
+
entries: { type: "array", items: { type: "object" } }
|
|
903
|
+
},
|
|
904
|
+
required: ["ok", "phase", "artifactId"]
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
name: "platform_open_artifact",
|
|
909
|
+
description: "Return the operator URL and latest draft/live URLs for a hosted artifact app.",
|
|
910
|
+
annotations: mcpToolAnnotations("platform_open_artifact", { readOnly: true, openWorld: true }),
|
|
911
|
+
inputSchema: {
|
|
912
|
+
type: "object",
|
|
913
|
+
properties: { artifactId: { type: "string", format: "uuid" } },
|
|
914
|
+
required: ["artifactId"],
|
|
915
|
+
additionalProperties: false
|
|
916
|
+
},
|
|
917
|
+
outputSchema: {
|
|
918
|
+
type: "object",
|
|
919
|
+
properties: {
|
|
920
|
+
ok: { type: "boolean" }, artifactId: { type: "string" }, openUrl: { type: "string" },
|
|
921
|
+
draftUrl: { type: ["string", "null"] }, liveUrl: { type: ["string", "null"] }, versionUrl: { type: ["string", "null"] }
|
|
922
|
+
},
|
|
923
|
+
required: ["ok", "artifactId", "openUrl", "draftUrl", "liveUrl", "versionUrl"]
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
name: "platform_storybook_access",
|
|
928
|
+
description: "Return the tenant Storybook preview URL and HTTP MCP server config.",
|
|
929
|
+
annotations: mcpToolAnnotations("platform_storybook_access", { readOnly: true }),
|
|
930
|
+
inputSchema: {
|
|
931
|
+
type: "object",
|
|
932
|
+
properties: {},
|
|
933
|
+
additionalProperties: false
|
|
934
|
+
},
|
|
935
|
+
outputSchema: {
|
|
936
|
+
type: "object",
|
|
937
|
+
properties: {
|
|
938
|
+
ok: { type: "boolean" }, storybookUrl: { type: "string" }, mcpUrl: { type: "string" }, mcpServers: { type: "object" }
|
|
939
|
+
},
|
|
940
|
+
required: ["ok", "storybookUrl", "mcpUrl", "mcpServers"]
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
name: "platform_validate_artifact",
|
|
945
|
+
description: "Read trusted smoke and health evidence for a private artifact draft without requiring a browser session.",
|
|
946
|
+
annotations: mcpToolAnnotations("platform_validate_artifact", { readOnly: true }),
|
|
947
|
+
inputSchema: {
|
|
948
|
+
type: "object",
|
|
949
|
+
properties: {
|
|
950
|
+
artifactId: { type: "string", format: "uuid" },
|
|
951
|
+
deploymentRecordId: { type: "string", format: "uuid" }
|
|
952
|
+
},
|
|
953
|
+
required: ["artifactId"],
|
|
954
|
+
additionalProperties: false
|
|
955
|
+
},
|
|
956
|
+
outputSchema: {
|
|
957
|
+
type: "object",
|
|
958
|
+
properties: {
|
|
959
|
+
ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" },
|
|
960
|
+
deploymentRecordId: { type: "string" }, deploymentState: { type: "string" },
|
|
961
|
+
healthy: { type: "boolean" }, checkedAt: { type: ["string", "null"] },
|
|
962
|
+
draftUrl: { type: ["string", "null"] }, smoke: { type: ["object", "null"] }
|
|
963
|
+
},
|
|
964
|
+
required: ["ok", "phase", "artifactId", "deploymentRecordId", "deploymentState", "healthy", "checkedAt", "draftUrl", "smoke"]
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
name: "platform_run_artifact_smoke",
|
|
969
|
+
description: "Rerun the trusted draft smoke check and return fresh rendered-page evidence.",
|
|
970
|
+
annotations: mcpToolAnnotations("platform_run_artifact_smoke", { readOnly: false, openWorld: true }),
|
|
971
|
+
inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" }, deploymentRecordId: { type: "string", format: "uuid" } }, required: ["artifactId"], additionalProperties: false },
|
|
972
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, deploymentRecordId: { type: "string" }, smoke: { type: "object" }, validation: { type: "object" } }, required: ["ok", "phase", "artifactId", "deploymentRecordId", "smoke", "validation"] }
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
name: "platform_plan_artifact_draft_delete",
|
|
976
|
+
description: "Preview cleanup effects and receive the state-bound plan token required for deletion.",
|
|
977
|
+
annotations: mcpToolAnnotations("platform_plan_artifact_draft_delete", { readOnly: true, openWorld: true }),
|
|
978
|
+
inputSchema: { type: "object", properties: { artifactId: { type: "string", format: "uuid" } }, required: ["artifactId"], additionalProperties: false },
|
|
979
|
+
outputSchema: { type: "object", properties: { ok: { type: "boolean" }, phase: { type: "string" }, artifactId: { type: "string" }, eligible: { type: "boolean" }, effects: { type: "object" }, planToken: { type: "string" } }, required: ["ok", "phase", "artifactId", "eligible", "effects", "planToken"] }
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "platform_delete_artifact_draft",
|
|
983
|
+
description: "Permanently remove an unpromoted private draft created by this MCP connection after exact-id confirmation.",
|
|
984
|
+
annotations: mcpToolAnnotations("platform_delete_artifact_draft", { readOnly: false, destructive: true, openWorld: true }),
|
|
985
|
+
inputSchema: {
|
|
986
|
+
type: "object",
|
|
987
|
+
properties: {
|
|
988
|
+
artifactId: { type: "string", format: "uuid" },
|
|
989
|
+
confirmArtifactId: { type: "string", format: "uuid" },
|
|
990
|
+
planToken: { type: "string", minLength: 1 }
|
|
991
|
+
},
|
|
992
|
+
required: ["artifactId", "confirmArtifactId", "planToken"],
|
|
993
|
+
additionalProperties: false
|
|
994
|
+
},
|
|
995
|
+
outputSchema: {
|
|
996
|
+
type: "object",
|
|
997
|
+
properties: { ok: { type: "boolean" }, deleted: { type: "boolean" }, artifactId: { type: "string" } },
|
|
998
|
+
required: ["ok", "deleted", "artifactId"]
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
].map(withRecoveryOutputSchema);
|
|
1002
|
+
export function listTools() {
|
|
1003
|
+
// Preserve the original tools/list isolation boundary: callers must not be
|
|
1004
|
+
// able to mutate the registry used by later requests or input validation.
|
|
1005
|
+
return { tools: structuredClone(BUILD_MCP_TOOLS) };
|
|
1006
|
+
}
|
|
1007
|
+
function withRecoveryOutputSchema(value) {
|
|
1008
|
+
const tool = value;
|
|
1009
|
+
if (!tool.outputSchema)
|
|
1010
|
+
return tool;
|
|
1011
|
+
return {
|
|
1012
|
+
...tool,
|
|
1013
|
+
outputSchema: {
|
|
1014
|
+
type: "object",
|
|
1015
|
+
oneOf: [
|
|
1016
|
+
tool.outputSchema,
|
|
1017
|
+
{
|
|
573
1018
|
type: "object",
|
|
574
1019
|
properties: {
|
|
575
|
-
ok: {
|
|
1020
|
+
ok: { const: false },
|
|
1021
|
+
error: {
|
|
1022
|
+
type: "object",
|
|
1023
|
+
properties: {
|
|
1024
|
+
code: { type: "string" },
|
|
1025
|
+
message: { type: "string" },
|
|
1026
|
+
retryable: { type: "boolean" },
|
|
1027
|
+
recoveryAction: { type: "string" }
|
|
1028
|
+
},
|
|
1029
|
+
required: ["code", "message", "retryable", "recoveryAction"],
|
|
1030
|
+
additionalProperties: false
|
|
1031
|
+
}
|
|
576
1032
|
},
|
|
577
|
-
required: ["ok", "
|
|
1033
|
+
required: ["ok", "error"],
|
|
1034
|
+
additionalProperties: false
|
|
578
1035
|
}
|
|
579
|
-
|
|
580
|
-
|
|
1036
|
+
]
|
|
1037
|
+
}
|
|
581
1038
|
};
|
|
582
1039
|
}
|
|
583
1040
|
function mcpToolAnnotations(name, policy) {
|
|
@@ -603,7 +1060,45 @@ function toolResult(value) {
|
|
|
603
1060
|
return { content, structuredContent };
|
|
604
1061
|
}
|
|
605
1062
|
function toolError(message) {
|
|
606
|
-
return
|
|
1063
|
+
return structuredToolError(message);
|
|
1064
|
+
}
|
|
1065
|
+
const BUILD_MCP_RECOVERY_ERROR_VALUES = {
|
|
1066
|
+
platform_harness_timeout: { retryable: true, recoveryAction: "Retry the call. For a deployment, use platform_list_artifact_drafts to recover the artifact and platform_wait_artifact_deploy to resume progress." },
|
|
1067
|
+
platform_mcp_tool_error: { retryable: false, recoveryAction: "Read the returned error code, correct the request or OAuth scopes, then retry." },
|
|
1068
|
+
artifact_deploy_wait_timeout: { retryable: true, recoveryAction: "Call platform_wait_artifact_deploy again with the same artifactId and operationId." },
|
|
1069
|
+
artifact_delete_plan_stale: { retryable: true, recoveryAction: "Call platform_plan_artifact_draft_delete again, review the new effects, and use its planToken." },
|
|
1070
|
+
artifact_app_bundle_changed_after_inspection: { retryable: true, recoveryAction: "Call platform_inspect_artifact_app again, review the new bundle hash, then retry deployment with that hash." },
|
|
1071
|
+
artifact_app_bundle_hardlink_forbidden: { retryable: false, recoveryAction: "Replace the hard link with a regular file copied into appDir, then inspect again." },
|
|
1072
|
+
artifact_app_auth_file_inside_app_dir: { retryable: false, recoveryAction: "Move TOPLINE_MCP_AUTH_FILE outside appDir, reconnect the Build MCP, then inspect again." },
|
|
1073
|
+
artifact_app_manifest_not_found: { retryable: false, recoveryAction: "Create artifact-app.json inside appDir, or correct appDir, then inspect again." },
|
|
1074
|
+
artifact_app_manifest_too_large: { retryable: false, recoveryAction: "Keep artifact-app.json below 512 KiB. Move application data and source into separate files, then inspect again." },
|
|
1075
|
+
scope_required: { retryable: false, recoveryAction: "Reconnect the Build MCP and approve the requested scope." },
|
|
1076
|
+
artifact_app_scaffold_target_not_empty: { retryable: false, recoveryAction: "Choose an empty appDir or deploy the existing app without scaffolding." }
|
|
1077
|
+
};
|
|
1078
|
+
export const BUILD_MCP_RECOVERY_ERRORS = Object.freeze(Object.fromEntries(Object.entries(BUILD_MCP_RECOVERY_ERROR_VALUES).map(([code, guidance]) => [code, Object.freeze(guidance)])));
|
|
1079
|
+
function structuredToolError(message) {
|
|
1080
|
+
const code = recoveryErrorCode(message);
|
|
1081
|
+
const selected = BUILD_MCP_RECOVERY_ERRORS[code] ?? { retryable: /^platform_harness_http_error:5|timeout|rate_limited/.test(message), recoveryAction: "Inspect the code and message, correct the input if needed, then retry the same tool." };
|
|
1082
|
+
const structuredContent = { ok: false, error: { code, message, ...selected } };
|
|
1083
|
+
return {
|
|
1084
|
+
content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
|
|
1085
|
+
structuredContent,
|
|
1086
|
+
isError: true
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function recoveryErrorCode(message) {
|
|
1090
|
+
const known = [
|
|
1091
|
+
"artifact_delete_plan_stale",
|
|
1092
|
+
"artifact_deploy_wait_timeout",
|
|
1093
|
+
"artifact_app_scaffold_target_not_empty",
|
|
1094
|
+
"platform_harness_timeout",
|
|
1095
|
+
"platform_mcp_tool_error",
|
|
1096
|
+
"scope_required"
|
|
1097
|
+
].find((candidate) => message.includes(candidate));
|
|
1098
|
+
if (known)
|
|
1099
|
+
return known;
|
|
1100
|
+
const match = message.match(/(?:^|:)([a-z][a-z0-9_]{2,})(?::|$)/);
|
|
1101
|
+
return match?.[1] ?? "topline_mcp_tool_failed";
|
|
607
1102
|
}
|
|
608
1103
|
const jsonSchemaValidator = new AjvJsonSchemaValidator();
|
|
609
1104
|
function validateJsonSchema(schema, value) {
|
|
@@ -630,9 +1125,30 @@ async function jsonHeaders(opts) {
|
|
|
630
1125
|
"MCP-Protocol-Version": opts.protocolVersion
|
|
631
1126
|
};
|
|
632
1127
|
}
|
|
1128
|
+
const inFlightBearerTokenRequests = new Map();
|
|
633
1129
|
export async function getBearerToken(opts) {
|
|
634
1130
|
if (opts.token?.trim())
|
|
635
1131
|
return opts.token.trim();
|
|
1132
|
+
const key = [
|
|
1133
|
+
path.resolve(opts.authFile),
|
|
1134
|
+
platformBaseUrl(opts),
|
|
1135
|
+
mcpSurface(opts),
|
|
1136
|
+
[...opts.oauthScopes].sort().join(" ")
|
|
1137
|
+
].join("\0");
|
|
1138
|
+
const existing = inFlightBearerTokenRequests.get(key);
|
|
1139
|
+
if (existing)
|
|
1140
|
+
return existing;
|
|
1141
|
+
const pending = getOAuthBearerToken(opts);
|
|
1142
|
+
inFlightBearerTokenRequests.set(key, pending);
|
|
1143
|
+
try {
|
|
1144
|
+
return await pending;
|
|
1145
|
+
}
|
|
1146
|
+
finally {
|
|
1147
|
+
if (inFlightBearerTokenRequests.get(key) === pending)
|
|
1148
|
+
inFlightBearerTokenRequests.delete(key);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
async function getOAuthBearerToken(opts) {
|
|
636
1152
|
const stored = await readStoredOAuthCredentials(opts);
|
|
637
1153
|
if (stored?.accessToken && stored.accessTokenExpiresAt && new Date(stored.accessTokenExpiresAt).getTime() - Date.now() > 60_000) {
|
|
638
1154
|
return stored.accessToken;
|
|
@@ -776,7 +1292,7 @@ async function readStoredOAuthCredentials(opts) {
|
|
|
776
1292
|
const issuer = typeof parsed.issuer === "string" ? parsed.issuer : "";
|
|
777
1293
|
const resource = typeof parsed.resource === "string" ? parsed.resource : "";
|
|
778
1294
|
const clientId = typeof parsed.clientId === "string" ? parsed.clientId : "";
|
|
779
|
-
const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : DEFAULT_OAUTH_SCOPES;
|
|
1295
|
+
const scopes = Array.isArray(parsed.scopes) ? parsed.scopes.filter((scope) => typeof scope === "string") : [...DEFAULT_OAUTH_SCOPES];
|
|
780
1296
|
if (!issuer || !resource || !clientId)
|
|
781
1297
|
return null;
|
|
782
1298
|
const expectedIssuer = platformBaseUrl(opts);
|
|
@@ -890,6 +1406,15 @@ function platformBaseUrl(opts) {
|
|
|
890
1406
|
function mcpSurface(opts) {
|
|
891
1407
|
return opts.surface ?? "build";
|
|
892
1408
|
}
|
|
1409
|
+
function logicalArtifactAppDir(workspaceAppDir, manifest) {
|
|
1410
|
+
if (workspaceAppDir === "." || workspaceAppDir.startsWith("artifact-apps/"))
|
|
1411
|
+
return workspaceAppDir;
|
|
1412
|
+
const manifestId = typeof manifest.id === "string" ? manifest.id.trim() : "";
|
|
1413
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/.test(manifestId)) {
|
|
1414
|
+
throw new Error("artifact_app_manifest_id_not_safe_for_source_root");
|
|
1415
|
+
}
|
|
1416
|
+
return `artifact-apps/${manifestId}`;
|
|
1417
|
+
}
|
|
893
1418
|
function defaultAuthFile(apiUrl, surface) {
|
|
894
1419
|
const key = createHash("sha256").update(`${platformBaseUrl({ apiUrl })}:${surface}`).digest("hex").slice(0, 16);
|
|
895
1420
|
return path.join(os.homedir(), ".topline", "build-mcp", `${key}.json`);
|
|
@@ -904,8 +1429,19 @@ function normalizeApiUrl(raw) {
|
|
|
904
1429
|
}
|
|
905
1430
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
906
1431
|
throw new Error("TOPLINE_API_URL must be an http(s) URL");
|
|
1432
|
+
if (url.username || url.password)
|
|
1433
|
+
throw new Error("TOPLINE_API_URL must not include credentials");
|
|
1434
|
+
if (url.search || url.hash)
|
|
1435
|
+
throw new Error("TOPLINE_API_URL must not include a query or fragment");
|
|
1436
|
+
if (url.protocol !== "https:" && !isLoopbackHostname(url.hostname)) {
|
|
1437
|
+
throw new Error("TOPLINE_API_URL must use https except for loopback development");
|
|
1438
|
+
}
|
|
907
1439
|
return url.toString().replace(/\/$/g, "");
|
|
908
1440
|
}
|
|
1441
|
+
function isLoopbackHostname(hostname) {
|
|
1442
|
+
const normalized = hostname.toLowerCase();
|
|
1443
|
+
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "[::1]" || normalized === "::1";
|
|
1444
|
+
}
|
|
909
1445
|
function firstEnv(env, ...keys) {
|
|
910
1446
|
for (const key of keys) {
|
|
911
1447
|
const value = env[key]?.trim();
|
|
@@ -948,8 +1484,22 @@ function toPosix(value) {
|
|
|
948
1484
|
}
|
|
949
1485
|
function redactSecrets(value, opts) {
|
|
950
1486
|
const token = opts.token?.trim();
|
|
951
|
-
|
|
952
|
-
|
|
1487
|
+
let redacted = token ? value.split(token).join("[redacted]") : value;
|
|
1488
|
+
const localPaths = [opts.authFile, opts.workspaceRoot, os.homedir()]
|
|
1489
|
+
.map((candidate) => path.resolve(candidate))
|
|
1490
|
+
.filter((candidate, index, all) => candidate.length > path.parse(candidate).root.length && all.indexOf(candidate) === index)
|
|
1491
|
+
.sort((left, right) => right.length - left.length);
|
|
1492
|
+
for (const localPath of localPaths) {
|
|
1493
|
+
const variants = new Set([
|
|
1494
|
+
localPath,
|
|
1495
|
+
toPosix(localPath),
|
|
1496
|
+
JSON.stringify(localPath).slice(1, -1),
|
|
1497
|
+
JSON.stringify(toPosix(localPath)).slice(1, -1)
|
|
1498
|
+
]);
|
|
1499
|
+
for (const variant of variants)
|
|
1500
|
+
redacted = redacted.split(variant).join("[local-path]");
|
|
1501
|
+
}
|
|
1502
|
+
return redacted
|
|
953
1503
|
.replace(/ovmcp_[A-Za-z0-9_-]+/g, "[redacted]")
|
|
954
1504
|
.replace(/tlmcp_(?:at|rt|code)_[A-Za-z0-9_-]+/g, "[redacted]");
|
|
955
1505
|
}
|