@stage5/lumine 0.2.4 → 0.2.6

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/lib/sdk.js ADDED
@@ -0,0 +1,489 @@
1
+ import path from "path";
2
+
3
+ import { ensureAuth, assertAuthScope } from "./auth.js";
4
+ import { mintBuildApiToken } from "./api.js";
5
+ import { request } from "./http.js";
6
+ import { parseJson, resolveBuildId } from "./util.js";
7
+ import { findLocalProjectMetadata } from "./workspace.js";
8
+
9
+ // Build SDK data-API methods callable from the CLI. Method names match the
10
+ // SDK manifest (constants/buildSdkIndex.json in twinkle-api); paths are the
11
+ // HTTP endpoints the Build runtime parent proxies those methods to. The args
12
+ // JSON object is sent verbatim as the POST body, so arg shapes follow
13
+ // TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
14
+ // Anything not listed can be called with --path api/<endpoint>.
15
+ export const SDK_CLI_METHODS = {
16
+ "aiStories.list": { path: "api/content/ai-stories/list", scopes: ["content:read"] },
17
+ "aiStories.chapters": { path: "api/content/ai-stories/chapters", scopes: ["content:read"] },
18
+ "aiStories.search": { path: "api/content/ai-stories/search", scopes: ["content:read"] },
19
+ "aiStories.get": { path: "api/content/ai-story", scopes: ["content:read"] },
20
+ "aiCards.list": { path: "api/content/ai-cards/list", scopes: ["content:read"] },
21
+ "aiCards.search": { path: "api/content/ai-cards/search", scopes: ["content:read"] },
22
+ "aiCards.get": { path: "api/content/ai-card", scopes: ["content:read"] },
23
+ "grammarbles.listQuestions": { path: "api/content/grammarbles/questions", scopes: ["content:read"] },
24
+ "grammarbles.getMyQuestionHistory": { path: "api/content/grammarbles/history", scopes: ["content:read"] },
25
+ "subjects.getMySubjects": { path: "api/content/my-subjects", scopes: ["content:read"] },
26
+ "subjects.search": { path: "api/content/subjects/search", scopes: ["content:read"] },
27
+ "subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
28
+ "subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
29
+ "subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
30
+ "profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
31
+ "profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
32
+ "profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
33
+ "profileComments.getProfileCommentCounts": { path: "api/content/profile-comment-counts", scopes: ["content:read"] },
34
+ "privateDb.get": { path: "api/private-db/get", scopes: ["privateDb:read"] },
35
+ "privateDb.list": { path: "api/private-db/list", scopes: ["privateDb:read"] },
36
+ "privateDb.set": { path: "api/private-db/set", scopes: ["privateDb:write"], write: true },
37
+ "privateDb.remove": { path: "api/private-db/delete", scopes: ["privateDb:write"], write: true },
38
+ "sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
39
+ "sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
40
+ "sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
41
+ "sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
42
+ "sharedDb.updateEntry": { path: "api/shared-db/entry/update", scopes: ["sharedDb:write"], write: true },
43
+ "sharedDb.deleteEntry": { path: "api/shared-db/entry/delete", scopes: ["sharedDb:write"], write: true },
44
+ "sharedDb.kv.get": { path: "api/shared-db/kv/get", scopes: ["sharedDb:read"] },
45
+ "sharedDb.kv.list": { path: "api/shared-db/kv/list", scopes: ["sharedDb:read"] },
46
+ // The kv/set endpoint is batch-shaped ({ namespace, items }); kv.set keeps
47
+ // the SDK's single-key signature and maps to one item.
48
+ "sharedDb.kv.set": {
49
+ path: "api/shared-db/kv/set",
50
+ scopes: ["sharedDb:write"],
51
+ write: true,
52
+ mapArgs: (args) => ({
53
+ namespace: args.namespace,
54
+ items: [{ key: args.key, value: args.value }],
55
+ }),
56
+ sdkReshape: "the SDK takes (namespace, key, value) and returns { item }",
57
+ },
58
+ "sharedDb.kv.setMany": { path: "api/shared-db/kv/set", scopes: ["sharedDb:write"], write: true },
59
+ "sharedDb.kv.remove": { path: "api/shared-db/kv/delete", scopes: ["sharedDb:write"], write: true },
60
+ "chat.listRooms": { path: "api/chat/rooms/list", scopes: ["chat:read"] },
61
+ "chat.createRoom": { path: "api/chat/rooms/create", scopes: ["chat:write"], write: true },
62
+ "chat.listMessages": { path: "api/chat/messages/list", scopes: ["chat:read"] },
63
+ "chat.sendMessage": { path: "api/chat/messages/send", scopes: ["chat:write"], write: true },
64
+ "chat.deleteMessage": { path: "api/chat/messages/delete", scopes: ["chat:write"], write: true },
65
+ "users.getUser": {
66
+ path: "api/user",
67
+ scopes: ["user:read"],
68
+ sdkReshape: "the SDK returns the user object directly (unwraps { user })",
69
+ },
70
+ "users.getUsers": { path: "api/users", scopes: ["users:read"] },
71
+ "reflections.getDailyReflections": { path: "api/daily-reflections", scopes: ["dailyReflections:read"] },
72
+ "reminders.list": { path: "api/reminders/list", scopes: ["reminders:read"] },
73
+ // getDue's backend defaults autoAcknowledge to true, which persists writes
74
+ // (lastTriggeredAt, and disabling one-shot reminders). Force read-only
75
+ // probes to autoAcknowledge:false; acknowledging is a write that needs
76
+ // --allow-write.
77
+ "reminders.getDue": {
78
+ path: "api/reminders/due",
79
+ scopes: ["reminders:read"],
80
+ writeWhen: (args) => args.autoAcknowledge === true,
81
+ mapArgs: (args) => ({
82
+ ...args,
83
+ autoAcknowledge: args.autoAcknowledge === true,
84
+ }),
85
+ },
86
+ "reminders.create": { path: "api/reminders/create", scopes: ["reminders:write"], write: true },
87
+ "reminders.update": { path: "api/reminders/update", scopes: ["reminders:write"], write: true },
88
+ "reminders.remove": { path: "api/reminders/delete", scopes: ["reminders:write"], write: true },
89
+ "files.list": {
90
+ path: "api/files/list",
91
+ scopes: ["files:read"],
92
+ sdkReshape: "the SDK returns { assets, usage }",
93
+ },
94
+ "files.delete": {
95
+ path: "api/files/delete",
96
+ scopes: ["files:write"],
97
+ write: true,
98
+ sdkReshape: "the SDK returns { success, usage }",
99
+ },
100
+ "notifications.getSubscription": { path: "api/notifications/subscription", scopes: ["notifications:read"] },
101
+ "notifications.subscribe": { path: "api/notifications/subscription/subscribe", scopes: ["notifications:write"], write: true },
102
+ "notifications.unsubscribe": { path: "api/notifications/subscription/unsubscribe", scopes: ["notifications:write"], write: true },
103
+ "notifications.subscribeMany": { path: "api/notifications/subscriptions/subscribe", scopes: ["notifications:write"], write: true },
104
+ "notifications.unsubscribeMany": { path: "api/notifications/subscriptions/unsubscribe", scopes: ["notifications:write"], write: true },
105
+ "notifications.notifySubscribers": { path: "api/notifications/notify-subscribers", scopes: ["notifications:emit"], write: true },
106
+ "notifications.getSubjectUpdateSubscription": { path: "api/notifications/subject-update-subscription", scopes: ["notifications:read"] },
107
+ "notifications.subscribeToSubjectUpdates": { path: "api/notifications/subject-update-subscription/subscribe", scopes: ["notifications:write"], write: true },
108
+ "notifications.unsubscribeFromSubjectUpdates": { path: "api/notifications/subject-update-subscription/unsubscribe", scopes: ["notifications:write"], write: true },
109
+ // Leaderboards use the public leaderboard routes (regular login auth, no
110
+ // build API token). args.boardKey selects the board; remaining args are
111
+ // query params (get) or the POST body (submit).
112
+ "leaderboards.get": { special: "leaderboardGet" },
113
+ "leaderboards.submit": { special: "leaderboardSubmit", write: true },
114
+ // userDb (the viewer's private SQLite) lives at /viewer-db/*, which uses
115
+ // session auth directly — no build API token. query reads, exec mutates.
116
+ // Body is { sql, params }. The backend's query route accepts any
117
+ // row-returning statement, including INSERT/UPDATE/DELETE ... RETURNING,
118
+ // so SQL that mutates is gated behind --allow-write here too.
119
+ "userDb.query": { special: "userDbQuery", writeWhen: (args) => sqlMutates(args.sql) },
120
+ "userDb.exec": { special: "userDbExec", write: true },
121
+ };
122
+
123
+ // Heuristic write-detection for raw SQL. SELECT/EXPLAIN/VALUES cannot mutate;
124
+ // a WITH (CTE) can wrap DML; everything else is treated as write-capable.
125
+ // Errs toward over-gating (safe) rather than under-gating.
126
+ export function sqlMutates(sql) {
127
+ const normalized = String(sql || "")
128
+ .trim()
129
+ .replace(/^\(+/, "");
130
+ const leadingKeyword = (normalized.match(/^\s*([a-z]+)/i)?.[1] || "").toUpperCase();
131
+ if (
132
+ leadingKeyword === "SELECT" ||
133
+ leadingKeyword === "EXPLAIN" ||
134
+ leadingKeyword === "VALUES"
135
+ ) {
136
+ return false;
137
+ }
138
+ if (leadingKeyword === "WITH") {
139
+ return /\b(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(normalized);
140
+ }
141
+ return true;
142
+ }
143
+
144
+ // Reverse index of curated endpoint paths -> method name(s). Used to reject
145
+ // raw --path calls to curated endpoints: --path is for endpoints not in the
146
+ // list, and routing a curated endpoint through --path would strip its
147
+ // write/writeWhen/mapArgs safety handling (e.g. api/reminders/due defaults
148
+ // autoAcknowledge to true and mutates under a read scope).
149
+ export const SDK_CLI_METHOD_NAMES_BY_PATH = (() => {
150
+ const byPath = new Map();
151
+ for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
152
+ if (!entry.path) continue;
153
+ if (!byPath.has(entry.path)) byPath.set(entry.path, []);
154
+ byPath.get(entry.path).push(name);
155
+ }
156
+ return byPath;
157
+ })();
158
+
159
+ // Default token scopes for --path calls. Never fall through to the server's
160
+ // default scope set: it includes write/emit scopes, which must not be minted
161
+ // without --allow-write.
162
+ export const SDK_CLI_READ_SCOPES = [
163
+ "files:read",
164
+ "user:read",
165
+ "users:read",
166
+ "dailyReflections:read",
167
+ "content:read",
168
+ "sharedDb:read",
169
+ "privateDb:read",
170
+ "chat:read",
171
+ "notifications:read",
172
+ "reminders:read",
173
+ ];
174
+
175
+ export function isWriteCapableScope(scope) {
176
+ return /:(write|emit)$/.test(String(scope));
177
+ }
178
+
179
+ export async function sdkCommand(options) {
180
+ const subcommand = String(options.positional[0] || "list");
181
+ if (subcommand === "list") {
182
+ printSdkMethodList();
183
+ return;
184
+ }
185
+ if (subcommand !== "call") {
186
+ throw new Error(
187
+ 'Usage: lumine sdk list | lumine sdk call <namespace.method> [jsonArgs]',
188
+ );
189
+ }
190
+ await sdkCall(options);
191
+ }
192
+
193
+ export function printSdkMethodList() {
194
+ console.log(
195
+ "Callable Build endpoints (args JSON = request body). Prints the raw\n" +
196
+ "endpoint response, which can differ from the SDK return shape (*).\n",
197
+ );
198
+ const byNamespace = new Map();
199
+ for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
200
+ const namespace = name.split(".")[0];
201
+ if (!byNamespace.has(namespace)) byNamespace.set(namespace, []);
202
+ byNamespace.get(namespace).push({ name, entry });
203
+ }
204
+ for (const [namespace, methods] of byNamespace) {
205
+ console.log(` ${namespace}`);
206
+ for (const { name, entry } of methods) {
207
+ const writeTag = entry.write ? " [write: needs --allow-write]" : "";
208
+ const reshapeTag = entry.sdkReshape ? " *" : "";
209
+ const pathText = entry.path || `(${entry.special})`;
210
+ console.log(
211
+ ` ${name.padEnd(44)} ${pathText}${writeTag}${reshapeTag}`,
212
+ );
213
+ }
214
+ }
215
+ console.log(`
216
+ Usage:
217
+ lumine sdk call aiStories.chapters '{"limit": 5}'
218
+ lumine sdk call privateDb.set '{"key": "k", "value": {"a": 1}}' --allow-write
219
+ lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
220
+ lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
221
+ lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
222
+
223
+ Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
224
+ endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
225
+ check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
226
+ latency stats, --build <id> to target a build outside the current workspace.
227
+ Use --path api/<endpoint> only for endpoints not listed above.`);
228
+ }
229
+
230
+ export async function sdkCall(options) {
231
+ const usingRawPath = Boolean(options.sdkPath);
232
+ const methodName = usingRawPath ? "" : String(options.positional[1] || "");
233
+ const argsText = String(
234
+ (usingRawPath ? options.positional[1] : options.positional[2]) || "",
235
+ ).trim();
236
+
237
+ let args = {};
238
+ if (argsText) {
239
+ try {
240
+ args = JSON.parse(argsText);
241
+ } catch (error) {
242
+ throw new Error(`Args must be valid JSON: ${error.message}`);
243
+ }
244
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
245
+ throw new Error("Args must be a JSON object, e.g. '{\"limit\": 5}'.");
246
+ }
247
+ }
248
+
249
+ let endpoint;
250
+ if (usingRawPath) {
251
+ let normalizedPath = String(options.sdkPath).replace(/^\/+/, "");
252
+ if (!/^api\/[a-zA-Z0-9/_-]+$/.test(normalizedPath)) {
253
+ throw new Error("--path must look like api/<endpoint> (no query string).");
254
+ }
255
+ // Normalize the way Express routes (case-insensitive, non-strict, slashes
256
+ // collapsed) so route-equivalent variants like api/Token or
257
+ // api/reminders/due/ cannot dodge the guards below. Guard and call the
258
+ // same normalized path so the checked string is the executed string.
259
+ normalizedPath = normalizedPath
260
+ .toLowerCase()
261
+ .replace(/\/{2,}/g, "/")
262
+ .replace(/\/+$/, "");
263
+ // api/token is control-plane: the CLI mints the build API token itself,
264
+ // and that endpoint's response is a token whose scopes come from the
265
+ // request body, not from the CLI mint — so the write gate cannot see
266
+ // them. Calling it as a data endpoint would hand back a write-capable
267
+ // token without --allow-write. It is not a data method; reject it.
268
+ if (/^api\/token(\/|$)/.test(normalizedPath)) {
269
+ throw new Error(
270
+ "api/token is not a callable SDK endpoint. The CLI mints build API " +
271
+ "tokens internally for sdk calls.",
272
+ );
273
+ }
274
+ // --path is for endpoints not in the curated list. Reject curated paths
275
+ // so their read/write safety handling cannot be bypassed via --path.
276
+ const curatedMethodNames = SDK_CLI_METHOD_NAMES_BY_PATH.get(normalizedPath);
277
+ if (curatedMethodNames) {
278
+ throw new Error(
279
+ `${normalizedPath} is a curated SDK endpoint. Call it as ` +
280
+ `${curatedMethodNames.join(" or ")} (applies the correct read/` +
281
+ "write handling). --path is only for endpoints not in `lumine sdk list`.",
282
+ );
283
+ }
284
+ endpoint = { path: normalizedPath, scopes: SDK_CLI_READ_SCOPES };
285
+ } else {
286
+ if (!methodName) {
287
+ throw new Error(
288
+ 'Usage: lumine sdk call <namespace.method> [jsonArgs]. Run `lumine sdk list`.',
289
+ );
290
+ }
291
+ endpoint = SDK_CLI_METHODS[methodName];
292
+ if (!endpoint) {
293
+ throw new Error(
294
+ `Unknown SDK method "${methodName}". Run \`lumine sdk list\`, or call ` +
295
+ "the endpoint directly with --path api/<endpoint>.",
296
+ );
297
+ }
298
+ }
299
+
300
+ // Token-capability gate: a write-capable build API token must never be
301
+ // minted (and write-tagged methods never called) without --allow-write,
302
+ // regardless of whether the call came from the curated table, --path, or a
303
+ // --scopes override.
304
+ const requestedScopes = options.sdkScopes.length
305
+ ? options.sdkScopes
306
+ : endpoint.scopes || [];
307
+ const writeScopes = requestedScopes.filter(isWriteCapableScope);
308
+ // writeWhen covers endpoints that mutate under a read scope depending on
309
+ // their args (e.g. reminders.getDue acknowledging due reminders).
310
+ const writeByArgs =
311
+ typeof endpoint.writeWhen === "function" && endpoint.writeWhen(args);
312
+ const writeCapable =
313
+ Boolean(endpoint.write) || writeScopes.length > 0 || writeByArgs;
314
+ if (writeCapable && !options.allowWrite) {
315
+ const reason = writeScopes.length
316
+ ? `write scopes: ${writeScopes.join(", ")}`
317
+ : "this call writes app data";
318
+ throw new Error(
319
+ `${methodName || endpoint.path} can mutate real app data ` +
320
+ `(${reason}). Re-run with --allow-write.`,
321
+ );
322
+ }
323
+
324
+ // Resolve the build target (local-only: --build or workspace metadata)
325
+ // before any auth side effects, so invalid local input fails immediately
326
+ // instead of after starting the device-login flow.
327
+ const buildId = await resolveSdkBuildId(options);
328
+ const auth = await ensureAuth(options);
329
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
330
+ const attempts = [];
331
+ let lastResult = null;
332
+
333
+ if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
334
+ const boardKey = String(args.boardKey || "default");
335
+ const rest = { ...args };
336
+ delete rest.boardKey;
337
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
338
+ if (endpoint.special === "leaderboardGet") {
339
+ const url = new URL(
340
+ `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}`,
341
+ );
342
+ for (const [key, value] of Object.entries(rest)) {
343
+ if (value !== null && value !== undefined) {
344
+ url.searchParams.set(key, String(value));
345
+ }
346
+ }
347
+ lastResult = await executeSdkHttpCall({
348
+ options,
349
+ method: "GET",
350
+ url: url.toString(),
351
+ authToken: auth.token,
352
+ });
353
+ } else {
354
+ lastResult = await executeSdkHttpCall({
355
+ options,
356
+ method: "POST",
357
+ url: `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}/submit`,
358
+ authToken: auth.token,
359
+ body: rest,
360
+ });
361
+ }
362
+ attempts.push(lastResult);
363
+ printSdkAttemptLine(attempts.length, lastResult);
364
+ }
365
+ } else if (
366
+ endpoint.special === "userDbQuery" ||
367
+ endpoint.special === "userDbExec"
368
+ ) {
369
+ // viewer-db uses session auth (no build API token). Body is { sql, params }.
370
+ const route = endpoint.special === "userDbExec" ? "exec" : "query";
371
+ const body = { sql: args.sql, params: args.params };
372
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
373
+ lastResult = await executeSdkHttpCall({
374
+ options,
375
+ method: "POST",
376
+ url: `${options.apiUrl}/build/${buildId}/viewer-db/${route}`,
377
+ authToken: auth.token,
378
+ body,
379
+ });
380
+ attempts.push(lastResult);
381
+ printSdkAttemptLine(attempts.length, lastResult);
382
+ }
383
+ } else {
384
+ // Always request explicit scopes: an empty scopes body makes the server
385
+ // fall back to its default scope set, which is write-capable.
386
+ const scopes = requestedScopes.length
387
+ ? requestedScopes
388
+ : SDK_CLI_READ_SCOPES;
389
+ const tokenResult = await mintBuildApiToken({
390
+ options,
391
+ auth,
392
+ buildId,
393
+ scopes,
394
+ });
395
+ console.error(
396
+ `token minted in ${tokenResult.ms}ms (scopes: ${
397
+ tokenResult.scopes.join(", ") || "default"
398
+ })`,
399
+ );
400
+ const body = endpoint.mapArgs ? endpoint.mapArgs(args) : args;
401
+ for (let attempt = 0; attempt < options.repeat; attempt += 1) {
402
+ lastResult = await executeSdkHttpCall({
403
+ options,
404
+ method: "POST",
405
+ url: `${options.apiUrl}/build/${buildId}/${endpoint.path}`,
406
+ authToken: auth.token,
407
+ body,
408
+ buildApiToken: tokenResult.token,
409
+ });
410
+ attempts.push(lastResult);
411
+ printSdkAttemptLine(attempts.length, lastResult);
412
+ }
413
+ }
414
+
415
+ if (options.repeat > 1) {
416
+ const timings = attempts.map((entry) => entry.ms);
417
+ const total = timings.reduce((sum, ms) => sum + ms, 0);
418
+ console.error(
419
+ `latency over ${timings.length} calls: min ${Math.min(...timings)}ms · ` +
420
+ `avg ${Math.round(total / timings.length)}ms · max ${Math.max(...timings)}ms`,
421
+ );
422
+ }
423
+
424
+ // The printed JSON is the raw endpoint response. Some SDK wrappers reshape
425
+ // it, so warn precisely where the SDK return shape differs.
426
+ if (endpoint && endpoint.sdkReshape) {
427
+ console.error(
428
+ `note: raw endpoint response below — ${methodName}'s SDK wrapper ` +
429
+ `reshapes it (${endpoint.sdkReshape}). See TWINKLE_BUILD_SDK.md.`,
430
+ );
431
+ }
432
+ if (lastResult) {
433
+ console.log(JSON.stringify(lastResult.data ?? lastResult.text, null, 2));
434
+ }
435
+ if (lastResult && !lastResult.ok) {
436
+ process.exitCode = 1;
437
+ }
438
+ }
439
+
440
+ export function printSdkAttemptLine(attemptNumber, result) {
441
+ console.error(
442
+ `#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
443
+ `${result.bytes.toLocaleString()} bytes · ${result.ms}ms`,
444
+ );
445
+ }
446
+
447
+ export async function executeSdkHttpCall({
448
+ options,
449
+ method,
450
+ url,
451
+ authToken,
452
+ body,
453
+ buildApiToken,
454
+ }) {
455
+ const startedAt = Date.now();
456
+ const response = await request({
457
+ method,
458
+ url,
459
+ authToken,
460
+ body,
461
+ timeoutMs: options.timeoutMs,
462
+ headers: buildApiToken ? { "x-build-api-token": buildApiToken } : {},
463
+ });
464
+ const text = await response.text();
465
+ return {
466
+ ok: response.ok,
467
+ status: response.status,
468
+ ms: Date.now() - startedAt,
469
+ bytes: Buffer.byteLength(text),
470
+ data: parseJson(text),
471
+ text,
472
+ };
473
+ }
474
+
475
+ export async function resolveSdkBuildId(options) {
476
+ if (options.buildIdFlag) {
477
+ const buildId = resolveBuildId(options.buildIdFlag);
478
+ if (buildId > 0) return buildId;
479
+ throw new Error(`Invalid --build value: ${options.buildIdFlag}`);
480
+ }
481
+ const localProject = await findLocalProjectMetadata(
482
+ options.dir ? path.resolve(options.dir) : process.cwd(),
483
+ );
484
+ const buildId = Number(localProject?.metadata?.buildId || 0);
485
+ if (buildId > 0) return buildId;
486
+ throw new Error(
487
+ "No build workspace found. Run from a pulled workspace, pass --dir, or pass --build <id>.",
488
+ );
489
+ }