@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/README.md +26 -0
- package/bin/lumine.js +1 -3158
- package/lib/api.js +445 -0
- package/lib/assets.js +833 -0
- package/lib/auth.js +217 -0
- package/lib/commands.js +1512 -0
- package/lib/constants.js +296 -0
- package/lib/http.js +72 -0
- package/lib/sdk.js +489 -0
- package/lib/thumbnail.js +330 -0
- package/lib/util.js +154 -0
- package/lib/workspace.js +577 -0
- package/package.json +5 -1
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
|
+
}
|