@datumos/datum-cli 0.0.0 → 0.588.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/LICENSE +201 -0
- package/NOTICE +8 -0
- package/README.md +37 -0
- package/cli/a2aCommand.js +225 -0
- package/cli/artifactsPublish.js +356 -0
- package/cli/authCommands.js +168 -0
- package/cli/bundleSignature.js +111 -0
- package/cli/commandTree.js +243 -0
- package/cli/credentialProcess.js +54 -0
- package/cli/credentials.js +93 -0
- package/cli/datum.js +814 -0
- package/cli/entraLogin.js +254 -0
- package/cli/help.js +509 -0
- package/cli/invoke.js +427 -0
- package/cli/mcpServe.js +361 -0
- package/cli/outputsDownload.js +126 -0
- package/cli/previewCommand.js +333 -0
- package/cli/satellitePairToken.js +48 -0
- package/cli/secureOrigin.js +37 -0
- package/cli/selfUpdate.js +779 -0
- package/cli/spaceManifestCommand.js +495 -0
- package/cli/spaceManifestDir.js +481 -0
- package/cli/spaceRepoTemplate.js +463 -0
- package/cli/updateCommand.js +184 -0
- package/npm-shrinkwrap.json +1231 -0
- package/openapi/spec.js +51199 -0
- package/package.json +25 -3
- package/spaces/reservedHostLabels.js +66 -0
package/cli/mcpServe.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import fsp from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { runArtifactsPublish } from "./artifactsPublish.js";
|
|
9
|
+
import { LoginFlowError, NotLoggedInError, deviceLogin, ensureAccessToken, spaceBaseUrl, } from "./entraLogin.js";
|
|
10
|
+
/**
|
|
11
|
+
* `datum mcp serve` — the stdio shim between a developer's Claude Code and
|
|
12
|
+
* Datum's person MCP endpoint (docs/developer-access.md, #327). Claude Code
|
|
13
|
+
* starts this process (the plugin's `.mcp.json` declares it), speaks MCP
|
|
14
|
+
* over stdio, and every `tools/list` / `tools/call` is forwarded 1:1 to
|
|
15
|
+
* `POST <space host>/api/mcp` with the person's Entra access token — the
|
|
16
|
+
* token the CLI holds, refreshed here, never seen by the model.
|
|
17
|
+
*
|
|
18
|
+
* Why a shim and not a remote HTTP server in `.mcp.json`: the MCP client's
|
|
19
|
+
* own OAuth expects dynamic client registration, which Entra has none of;
|
|
20
|
+
* the CLI already owns the device-code sign-in, so it owns the credential
|
|
21
|
+
* on this side too (issue #327 mini-FAQ).
|
|
22
|
+
*
|
|
23
|
+
* Not signed in yet? The shim exposes ONE tool, `datum_login`, whose result
|
|
24
|
+
* carries the verification URL and user code — the model relays them, the
|
|
25
|
+
* person signs in in a browser, the shim polls, stores the tokens and sends
|
|
26
|
+
* `tools/list_changed` so Claude Code re-lists and finds the real tools.
|
|
27
|
+
* Nothing about this flow lets the model choose a destination: the IdP
|
|
28
|
+
* endpoints come from the server, the space from the process arguments.
|
|
29
|
+
*/
|
|
30
|
+
export const SHIM_NAME = "datum-mcp-shim";
|
|
31
|
+
export const LOGIN_TOOL = "datum_login";
|
|
32
|
+
export const STATUS_TOOL = "datum_status";
|
|
33
|
+
/**
|
|
34
|
+
* `datum_publish_artifact` (#645) — the shim's own publish tool, and the
|
|
35
|
+
* only one whose bytes never enter the conversation.
|
|
36
|
+
*
|
|
37
|
+
* The server's `artifact_publish` takes the page as tool ARGUMENTS, so its
|
|
38
|
+
* content is charged as input tokens on every turn that follows; on
|
|
39
|
+
* 2026-09-08 one large page cost a session its whole context window. This
|
|
40
|
+
* tool takes a DIRECTORY PATH instead: the shim runs on the person's
|
|
41
|
+
* machine, so it reads the files itself, hashes them and hands them to the
|
|
42
|
+
* same transport `datum artifacts publish` uses — presigned PUTs straight
|
|
43
|
+
* to storage for anything over the inline body cap. The model sees a path
|
|
44
|
+
* and a result, never the bytes.
|
|
45
|
+
*
|
|
46
|
+
* The path is not a free hand into the filesystem: it is resolved against
|
|
47
|
+
* ONE root — the directory the shim was started in (Claude Code starts it
|
|
48
|
+
* in the project), or `DATUM_PUBLISH_ROOT` when the person sets one — and a
|
|
49
|
+
* directory outside it is refused. The model may name a path; it may not
|
|
50
|
+
* choose the tree (security-invariants.md I1, the same reason a transport
|
|
51
|
+
* pins a reply's destination).
|
|
52
|
+
*/
|
|
53
|
+
export const PUBLISH_ARTIFACT_TOOL = "datum_publish_artifact";
|
|
54
|
+
/** Env var by which a person widens the publishable root beyond the cwd. */
|
|
55
|
+
export const PUBLISH_ROOT_ENV = "DATUM_PUBLISH_ROOT";
|
|
56
|
+
const text = (t, isError = false) => ({
|
|
57
|
+
content: [{ type: "text", text: t }],
|
|
58
|
+
...(isError ? { isError: true } : {}),
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Is `candidate` the root itself or something under it? Both paths must be
|
|
62
|
+
* already resolved (symlinks included) — a link is how a directory outside
|
|
63
|
+
* the root would otherwise present itself as inside (#645).
|
|
64
|
+
*/
|
|
65
|
+
export function isInsideRoot(candidate, root) {
|
|
66
|
+
const rel = path.relative(root, candidate);
|
|
67
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The publishable directory, or the reason it is refused (#645). Resolves
|
|
71
|
+
* symlinks on both sides, so neither a link inside the root nor a `..`
|
|
72
|
+
* escapes it, and reports what a person can do about it.
|
|
73
|
+
*/
|
|
74
|
+
export async function resolvePublishDir(dir, root) {
|
|
75
|
+
let realRoot;
|
|
76
|
+
try {
|
|
77
|
+
realRoot = await fsp.realpath(root);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, reason: `the publish root ${root} does not exist — set ${PUBLISH_ROOT_ENV} to a directory that does` };
|
|
81
|
+
}
|
|
82
|
+
const target = path.resolve(realRoot, dir);
|
|
83
|
+
let realTarget;
|
|
84
|
+
try {
|
|
85
|
+
realTarget = await fsp.realpath(target);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, reason: `${target} does not exist — write the page and its assets to a directory first, then publish that directory` };
|
|
89
|
+
}
|
|
90
|
+
if (!isInsideRoot(realTarget, realRoot)) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: `${realTarget} is outside the publishable tree (${realRoot}) — this tool publishes only from there, so a page cannot ` +
|
|
94
|
+
`carry files the person did not put in the project. Copy what should be published into a directory under ${realRoot}, ` +
|
|
95
|
+
`or ask the person to restart the connection with ${PUBLISH_ROOT_ENV} pointing where they want it.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const stat = await fsp.stat(realTarget).catch(() => null);
|
|
99
|
+
if (!stat?.isDirectory())
|
|
100
|
+
return { ok: false, reason: `${realTarget} is not a directory — publish the DIRECTORY holding the page and its assets` };
|
|
101
|
+
return { ok: true, dir: realTarget };
|
|
102
|
+
}
|
|
103
|
+
/** A sink for the publish command's two streams (it writes lines, nothing else). */
|
|
104
|
+
function collector() {
|
|
105
|
+
const chunks = [];
|
|
106
|
+
return {
|
|
107
|
+
stream: { write: (chunk) => (chunks.push(String(chunk)), true) },
|
|
108
|
+
text: () => chunks.join("").trim(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The shim's server half — exported so tests drive it over an in-memory
|
|
112
|
+
* transport pair instead of stdio. */
|
|
113
|
+
export function createShimServer(opts) {
|
|
114
|
+
const io = opts.io ?? {};
|
|
115
|
+
const log = opts.log ?? ((line) => console.error(`[datum mcp] ${line}`));
|
|
116
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
117
|
+
const apiBase = opts.space ? spaceBaseUrl(opts.baseUrl, opts.space) : new URL(opts.baseUrl).origin;
|
|
118
|
+
const endpoint = new URL(`${apiBase}/api/mcp`);
|
|
119
|
+
// The one tree `datum_publish_artifact` may read (#645) — the person's
|
|
120
|
+
// choice, made when they start the connection, never the model's.
|
|
121
|
+
const publishRoot = opts.publishRoot ?? process.env[PUBLISH_ROOT_ENV] ?? process.cwd();
|
|
122
|
+
const server = new Server({ name: SHIM_NAME, version: opts.version }, { capabilities: { tools: { listChanged: true } } });
|
|
123
|
+
let upstream = null;
|
|
124
|
+
let pendingLogin = null;
|
|
125
|
+
let lastUpstreamError = "";
|
|
126
|
+
/** A connected upstream client — lazily, and only with a token in hand. */
|
|
127
|
+
const connectUpstream = async () => {
|
|
128
|
+
if (upstream)
|
|
129
|
+
return upstream;
|
|
130
|
+
// Token freshness is per request: the transport's fetch re-reads it.
|
|
131
|
+
await ensureAccessToken(opts.baseUrl, opts.store, io);
|
|
132
|
+
const client = new Client({ name: SHIM_NAME, version: opts.version });
|
|
133
|
+
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
134
|
+
fetch: async (url, init) => {
|
|
135
|
+
const token = await ensureAccessToken(opts.baseUrl, opts.store, io);
|
|
136
|
+
const headers = new Headers(init?.headers);
|
|
137
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
138
|
+
return doFetch(url, { ...init, headers });
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
await client.connect(transport);
|
|
142
|
+
upstream = client;
|
|
143
|
+
log(`connected to ${endpoint}`);
|
|
144
|
+
return client;
|
|
145
|
+
};
|
|
146
|
+
const dropUpstream = async () => {
|
|
147
|
+
const c = upstream;
|
|
148
|
+
upstream = null;
|
|
149
|
+
await c?.close().catch(() => undefined);
|
|
150
|
+
};
|
|
151
|
+
/** Turns an upstream failure into what the developer should do. */
|
|
152
|
+
const explain = (err) => {
|
|
153
|
+
if (err instanceof NotLoggedInError || err instanceof LoginFlowError)
|
|
154
|
+
return err.message;
|
|
155
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
156
|
+
if (/401/.test(message))
|
|
157
|
+
return `Datum rejected the sign-in (${message}) — run \`datum login\` again`;
|
|
158
|
+
if (/403/.test(message)) {
|
|
159
|
+
return `Datum answered 403 at ${endpoint} — developer access is off for this space (a space admin enables it in Settings → Features), or you have no access to the space`;
|
|
160
|
+
}
|
|
161
|
+
if (/404/.test(message))
|
|
162
|
+
return `no MCP endpoint at ${endpoint} — is the base URL right and is this Datum new enough for developer access?`;
|
|
163
|
+
return `Datum is unreachable at ${endpoint}: ${message}`;
|
|
164
|
+
};
|
|
165
|
+
const loginTool = (why) => ({
|
|
166
|
+
name: LOGIN_TOOL,
|
|
167
|
+
description: `Sign in to Datum (${new URL(opts.baseUrl).origin}) with your Microsoft account so Datum's tools appear here. ` +
|
|
168
|
+
`${why} ` +
|
|
169
|
+
"The result carries a URL and a code: show BOTH to the user verbatim and ask them to complete the sign-in in a browser; " +
|
|
170
|
+
"the tools list refreshes by itself once they do.",
|
|
171
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
172
|
+
});
|
|
173
|
+
const statusTool = () => ({
|
|
174
|
+
name: STATUS_TOOL,
|
|
175
|
+
description: "Sign-in status of this Datum connection (who, where, whether a sign-in is pending).",
|
|
176
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
177
|
+
});
|
|
178
|
+
const publishArtifactTool = () => ({
|
|
179
|
+
name: PUBLISH_ARTIFACT_TOOL,
|
|
180
|
+
description: "Publish a page as an ARTIFACT of this Datum space FROM DISK: you give the path of a directory, this tool reads the " +
|
|
181
|
+
"files itself and uploads them straight to storage. USE THIS, NOT `artifact_publish`, for anything but a tiny text page — " +
|
|
182
|
+
"and always for images, PDFs or any binary file. Content passed as tool arguments is charged as input tokens on every " +
|
|
183
|
+
"later turn, so publishing a big page that way costs the session its context window; this tool costs a path. " +
|
|
184
|
+
"Write the page (index.html or a single .html/.md) plus the assets it references into a directory under the project, " +
|
|
185
|
+
"then publish that directory. Dotfiles, `node_modules`, symlinks and empty files are never published; at most 200 files. " +
|
|
186
|
+
"Reuse the same `key` to append a version to an existing artifact. Who may open the page is decided by people in the " +
|
|
187
|
+
"panel, never here. Never put secrets into a page.",
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
dir: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description: "Directory holding the page and its assets — relative to the project the connection was started in, or an " +
|
|
194
|
+
"absolute path inside it. A directory outside that tree is refused.",
|
|
195
|
+
},
|
|
196
|
+
key: {
|
|
197
|
+
type: "string",
|
|
198
|
+
description: "Stable artifact identity within the space (letters, digits, `. _ : / -`). Same key = new version of the same " +
|
|
199
|
+
"artifact. Required unless the directory holds a manifest.json that carries one.",
|
|
200
|
+
},
|
|
201
|
+
title: { type: "string", description: "Title shown in the panel (default: the page's <title> / first heading / file name)." },
|
|
202
|
+
description: { type: "string", description: "One-paragraph description of the artifact." },
|
|
203
|
+
entry: { type: "string", description: "Which file is THE page when several are pages (default: index.html or the only page)." },
|
|
204
|
+
},
|
|
205
|
+
required: ["dir"],
|
|
206
|
+
additionalProperties: false,
|
|
207
|
+
},
|
|
208
|
+
annotations: { title: "Publish an artifact from disk", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
209
|
+
});
|
|
210
|
+
const publishArtifact = async (args) => {
|
|
211
|
+
const str = (value) => (typeof value === "string" && value.length > 0 ? value : undefined);
|
|
212
|
+
const dir = str(args.dir);
|
|
213
|
+
if (!dir) {
|
|
214
|
+
return text(`${PUBLISH_ARTIFACT_TOOL} needs \`dir\` — the directory holding the page and the assets it references.`, true);
|
|
215
|
+
}
|
|
216
|
+
const resolved = await resolvePublishDir(dir, publishRoot);
|
|
217
|
+
if (!resolved.ok)
|
|
218
|
+
return text(`${PUBLISH_ARTIFACT_TOOL} refused — ${resolved.reason}`, true);
|
|
219
|
+
let token;
|
|
220
|
+
try {
|
|
221
|
+
token = await ensureAccessToken(opts.baseUrl, opts.store, io);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
lastUpstreamError = explain(err);
|
|
225
|
+
return text(lastUpstreamError, true);
|
|
226
|
+
}
|
|
227
|
+
const out = collector();
|
|
228
|
+
const errOut = collector();
|
|
229
|
+
try {
|
|
230
|
+
const code = await runArtifactsPublish({
|
|
231
|
+
dir: resolved.dir,
|
|
232
|
+
...(str(args.key) !== undefined ? { key: str(args.key) } : {}),
|
|
233
|
+
...(str(args.title) !== undefined ? { title: str(args.title) } : {}),
|
|
234
|
+
...(str(args.description) !== undefined ? { description: str(args.description) } : {}),
|
|
235
|
+
...(str(args.entry) !== undefined ? { entry: str(args.entry) } : {}),
|
|
236
|
+
}, { baseUrl: apiBase, bearerToken: token }, { out: out.stream, err: errOut.stream, ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
|
|
237
|
+
const report = [out.text(), errOut.text()].filter((part) => part.length > 0).join("\n");
|
|
238
|
+
if (code === 0) {
|
|
239
|
+
log(`published ${resolved.dir}`);
|
|
240
|
+
return text(report || "published");
|
|
241
|
+
}
|
|
242
|
+
return text(report || `${PUBLISH_ARTIFACT_TOOL} failed (exit ${code})`, true);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
246
|
+
log(`publish failed: ${message}`);
|
|
247
|
+
return text(`${PUBLISH_ARTIFACT_TOOL} failed — ${message}`, true);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const startLogin = async () => {
|
|
251
|
+
if (pendingLogin && Date.now() < pendingLogin.device.expiresAt) {
|
|
252
|
+
const d = pendingLogin.device;
|
|
253
|
+
return text(`A sign-in is already pending. Open ${d.verificationUriComplete ?? d.verificationUri} and enter the code ${d.userCode} (valid until ${new Date(d.expiresAt).toLocaleTimeString()}).`);
|
|
254
|
+
}
|
|
255
|
+
let shown = null;
|
|
256
|
+
const codeShown = new Promise((resolve) => {
|
|
257
|
+
// Runs the moment the IdP hands out the code; the poll continues in
|
|
258
|
+
// the background and the tool call returns right away.
|
|
259
|
+
void deviceLogin(opts.baseUrl, opts.store, (device) => {
|
|
260
|
+
shown = device;
|
|
261
|
+
pendingLogin = { device, startedAt: Date.now() };
|
|
262
|
+
resolve(device);
|
|
263
|
+
}, io)
|
|
264
|
+
.then(async (login) => {
|
|
265
|
+
pendingLogin = null;
|
|
266
|
+
log(`signed in as ${login.account}`);
|
|
267
|
+
await dropUpstream();
|
|
268
|
+
await server.sendToolListChanged().catch(() => undefined);
|
|
269
|
+
})
|
|
270
|
+
.catch((err) => {
|
|
271
|
+
pendingLogin = null;
|
|
272
|
+
lastUpstreamError = explain(err);
|
|
273
|
+
log(`sign-in failed: ${lastUpstreamError}`);
|
|
274
|
+
if (!shown)
|
|
275
|
+
resolve({ deviceCode: "", userCode: "", verificationUri: "", expiresAt: 0, intervalMs: 0 });
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
const device = await codeShown;
|
|
279
|
+
if (!device.userCode)
|
|
280
|
+
return text(`Sign-in could not start: ${lastUpstreamError}`, true);
|
|
281
|
+
return text(`To sign in to Datum, open ${device.verificationUriComplete ?? device.verificationUri} and enter the code ${device.userCode} ` +
|
|
282
|
+
`(valid for about ${Math.max(1, Math.round((device.expiresAt - Date.now()) / 60000))} minutes). ` +
|
|
283
|
+
"Tell the user the URL and the code exactly as written. Once they finish, Datum's tools appear in this server — re-list the tools before using them.");
|
|
284
|
+
};
|
|
285
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
286
|
+
const login = opts.store.get(opts.baseUrl);
|
|
287
|
+
if (!login) {
|
|
288
|
+
return { tools: [loginTool("You are not signed in yet."), statusTool()] };
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const client = await connectUpstream();
|
|
292
|
+
const listed = await client.listTools();
|
|
293
|
+
lastUpstreamError = "";
|
|
294
|
+
// The disk-side publish tool rides next to the server's tools (#645):
|
|
295
|
+
// it is the shim's own, because only this side can read the files.
|
|
296
|
+
return { tools: [...listed.tools, statusTool(), publishArtifactTool()] };
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
lastUpstreamError = explain(err);
|
|
300
|
+
log(`tools/list failed: ${lastUpstreamError}`);
|
|
301
|
+
await dropUpstream();
|
|
302
|
+
const needsLogin = err instanceof NotLoggedInError || err instanceof LoginFlowError || /401/.test(String(err));
|
|
303
|
+
return {
|
|
304
|
+
tools: needsLogin
|
|
305
|
+
? [loginTool(`The previous sign-in no longer works: ${lastUpstreamError}`), statusTool()]
|
|
306
|
+
: [statusTool()],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
311
|
+
const { name, arguments: args } = request.params;
|
|
312
|
+
if (name === LOGIN_TOOL)
|
|
313
|
+
return startLogin();
|
|
314
|
+
if (name === PUBLISH_ARTIFACT_TOOL)
|
|
315
|
+
return publishArtifact(args ?? {});
|
|
316
|
+
if (name === STATUS_TOOL) {
|
|
317
|
+
const login = opts.store.get(opts.baseUrl);
|
|
318
|
+
return text(JSON.stringify({
|
|
319
|
+
endpoint: endpoint.toString(),
|
|
320
|
+
signedIn: Boolean(login),
|
|
321
|
+
account: login?.account ?? null,
|
|
322
|
+
tokenExpiresAt: login ? new Date(login.expiresAt).toISOString() : null,
|
|
323
|
+
pendingSignIn: pendingLogin ? { code: pendingLogin.device.userCode, url: pendingLogin.device.verificationUri } : null,
|
|
324
|
+
lastError: lastUpstreamError || null,
|
|
325
|
+
credentials: opts.store.path,
|
|
326
|
+
}, null, 2));
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const client = await connectUpstream();
|
|
330
|
+
const result = await client.callTool({ name, arguments: args ?? {} });
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
lastUpstreamError = explain(err);
|
|
335
|
+
log(`tools/call ${name} failed: ${lastUpstreamError}`);
|
|
336
|
+
await dropUpstream();
|
|
337
|
+
return text(lastUpstreamError, true);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
server,
|
|
342
|
+
close: async () => {
|
|
343
|
+
await dropUpstream();
|
|
344
|
+
await server.close().catch(() => undefined);
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** The command: serve over stdio until Claude Code closes the pipe. */
|
|
349
|
+
export async function runMcpServe(opts) {
|
|
350
|
+
const shim = createShimServer(opts);
|
|
351
|
+
const transport = new StdioServerTransport();
|
|
352
|
+
await shim.server.connect(transport);
|
|
353
|
+
(opts.log ?? ((line) => console.error(`[datum mcp] ${line}`)))(`serving Datum tools of ${opts.space ? `space ${opts.space}` : "the org host"} at ${new URL(opts.baseUrl).origin} over stdio`);
|
|
354
|
+
await new Promise((resolve) => {
|
|
355
|
+
transport.onclose = () => resolve();
|
|
356
|
+
process.stdin.once("end", () => resolve());
|
|
357
|
+
});
|
|
358
|
+
await shim.close();
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=mcpServe.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `datum outputs download <id> [--out <file>]` — an output's BYTES (#2680).
|
|
3
|
+
*
|
|
4
|
+
* Hand-written because the bytes do not live under /api: the metadata is
|
|
5
|
+
* `GET /api/outputs/{id}` (derived as `datum outputs get`), the file is
|
|
6
|
+
* `GET /outputs/{id}/{name}` on the origin — the gated byte surface that
|
|
7
|
+
* authenticates the same credential as /api (#570), with no `/public`
|
|
8
|
+
* prefix for a key. It is how a run fetches a file an earlier run made
|
|
9
|
+
* without building a raw request (skill `platform-api`): the CLI talks to
|
|
10
|
+
* the platform's own origin only, and the credential never leaves it.
|
|
11
|
+
*
|
|
12
|
+
* The bytes are streamed into a temporary file while they are counted and
|
|
13
|
+
* hashed — never buffered (an output may be hundreds of MiB) — and the
|
|
14
|
+
* transfer stops as soon as it runs past the recorded size. Only a file
|
|
15
|
+
* whose size and sha256 match the metadata is renamed to `--out` or
|
|
16
|
+
* copied to stdout; a half-written or wrong file never lands anywhere.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
20
|
+
import fsp from "node:fs/promises";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { Readable, Transform } from "node:stream";
|
|
24
|
+
import { pipeline } from "node:stream/promises";
|
|
25
|
+
import { apiSurface, UsageError, X_OA_VARIANT_HEADER } from "./invoke.js";
|
|
26
|
+
/** The byte route outside /api (outputs.interfaces.ts OUTPUTS_DOWNLOAD_PREFIX). */
|
|
27
|
+
export const OUTPUTS_BYTES_PREFIX = "/outputs";
|
|
28
|
+
export async function runOutputsDownload(input, config, io) {
|
|
29
|
+
const fetchImpl = io.fetchImpl ?? fetch;
|
|
30
|
+
let surface;
|
|
31
|
+
try {
|
|
32
|
+
surface = apiSurface(config);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err instanceof UsageError) {
|
|
36
|
+
io.err.write(`datum: ${err.message}\n`);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const id = input.id.trim();
|
|
42
|
+
const metaRes = await fetchImpl(surface.url(`/api/outputs/${encodeURIComponent(id)}`), { headers: surface.headers });
|
|
43
|
+
if (!metaRes.ok) {
|
|
44
|
+
io.err.write(`datum: output ${id}: ${metaRes.status} ${await failureText(metaRes)}\n`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const meta = (await metaRes.json());
|
|
48
|
+
// The bytes: the origin's own route, never /public (the key authenticates
|
|
49
|
+
// there exactly as on /api).
|
|
50
|
+
const origin = config.baseUrl.replace(/\/+$/, "");
|
|
51
|
+
const bytesRes = await fetchImpl(`${origin}${OUTPUTS_BYTES_PREFIX}/${encodeURIComponent(meta.id)}/${encodeURIComponent(meta.name)}`, {
|
|
52
|
+
headers: {
|
|
53
|
+
authorization: surface.headers.authorization,
|
|
54
|
+
...(surface.headers[X_OA_VARIANT_HEADER] ? { [X_OA_VARIANT_HEADER]: surface.headers[X_OA_VARIANT_HEADER] } : {}),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (!bytesRes.ok) {
|
|
58
|
+
io.err.write(`datum: download ${meta.name}: ${bytesRes.status} ${await failureText(bytesRes)}\n`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
// A fresh private directory beside the target (a rename never crosses
|
|
62
|
+
// filesystems), or under the temp directory when the bytes go to stdout:
|
|
63
|
+
// an unpredictable path, owner-only, the file created exclusively — no
|
|
64
|
+
// symlink can be followed and nobody else reads the bytes meanwhile.
|
|
65
|
+
const target = input.out ? path.resolve(input.out) : null;
|
|
66
|
+
const staging = await fsp.mkdtemp(path.join(target ? path.dirname(target) : os.tmpdir(), ".datum-download-"));
|
|
67
|
+
const partial = path.join(staging, "bytes");
|
|
68
|
+
try {
|
|
69
|
+
const hash = createHash("sha256");
|
|
70
|
+
let received = 0;
|
|
71
|
+
const meter = new Transform({
|
|
72
|
+
transform(chunk, _enc, done) {
|
|
73
|
+
received += chunk.length;
|
|
74
|
+
// Past the recorded size it is not this output: stop at once.
|
|
75
|
+
if (received > meta.size) {
|
|
76
|
+
done(new DamagedError(received));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
hash.update(chunk);
|
|
80
|
+
done(null, chunk);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (!bytesRes.body)
|
|
84
|
+
throw new DamagedError(0);
|
|
85
|
+
await pipeline(Readable.fromWeb(bytesRes.body), meter, createWriteStream(partial, { flags: "wx", mode: 0o600 }));
|
|
86
|
+
const sha256 = hash.digest("hex");
|
|
87
|
+
if (received !== meta.size || sha256 !== meta.sha256)
|
|
88
|
+
throw new DamagedError(received, sha256);
|
|
89
|
+
if (!target) {
|
|
90
|
+
await pipeline(createReadStream(partial), io.out, { end: false });
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
await fsp.rename(partial, target);
|
|
94
|
+
io.err.write(`datum: ${meta.name} → ${input.out} (${received} bytes)\n`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (!(err instanceof DamagedError))
|
|
99
|
+
throw err;
|
|
100
|
+
io.err.write(`datum: ${meta.name} arrived damaged (${err.received} bytes${err.sha256 ? `, sha256 ${err.sha256.slice(0, 12)}…` : " or more"}; expected ${meta.size} bytes, ${meta.sha256.slice(0, 12)}…) — nothing written\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await fsp.rm(staging, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The bytes do not match the output's metadata. */
|
|
108
|
+
class DamagedError extends Error {
|
|
109
|
+
received;
|
|
110
|
+
sha256;
|
|
111
|
+
constructor(received, sha256 = "") {
|
|
112
|
+
super("damaged download");
|
|
113
|
+
this.received = received;
|
|
114
|
+
this.sha256 = sha256;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function failureText(res) {
|
|
118
|
+
try {
|
|
119
|
+
const body = (await res.json());
|
|
120
|
+
return typeof body.error === "string" ? body.error : res.statusText;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return res.statusText;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=outputsDownload.js.map
|