@stage5/lumine 0.2.3 → 0.2.5
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/bin/lumine.js +1 -2841
- package/lib/api.js +381 -0
- package/lib/assets.js +670 -0
- package/lib/auth.js +217 -0
- package/lib/commands.js +1491 -0
- package/lib/constants.js +256 -0
- package/lib/http.js +72 -0
- package/lib/sdk.js +489 -0
- package/lib/util.js +154 -0
- package/lib/workspace.js +577 -0
- package/package.json +5 -1
package/bin/lumine.js
CHANGED
|
@@ -1,2847 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import os from "os";
|
|
4
|
-
import path from "path";
|
|
5
|
-
import readline from "readline/promises";
|
|
6
|
-
import { spawn } from "child_process";
|
|
7
|
-
import { stdin as input, stdout as output } from "process";
|
|
8
|
-
|
|
9
|
-
const DEFAULT_API_URL = "https://api.twinkle.network";
|
|
10
|
-
const DEFAULT_SITE_URL = "https://www.twin-kle.com";
|
|
11
|
-
const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
12
|
-
const DEFAULT_AUTH_FILE = path.join(
|
|
13
|
-
os.homedir(),
|
|
14
|
-
".twinkle",
|
|
15
|
-
"lumine-cli-auth.json",
|
|
16
|
-
);
|
|
17
|
-
const DEFAULT_TIMEOUT_MS = 20000;
|
|
18
|
-
const UPDATE_CHECK_TIMEOUT_MS = 1500;
|
|
19
|
-
const DEFAULT_PROJECT_LIMIT = 50;
|
|
20
|
-
const PROJECT_METADATA_DIR = ".twinkle";
|
|
21
|
-
const PROJECT_METADATA_FILE = "lumine-project.json";
|
|
22
|
-
const DEFAULT_SAVE_SUMMARY = "Saved from Lumine CLI.";
|
|
23
|
-
const EXCLUDED_UPLOAD_DIRS = new Set([".git", ".twinkle", "node_modules"]);
|
|
24
|
-
const SDK_REFERENCE_FILE = "TWINKLE_BUILD_SDK.md";
|
|
25
|
-
const EXCLUDED_UPLOAD_FILES = new Set([
|
|
26
|
-
".DS_Store",
|
|
27
|
-
"AGENTS.md",
|
|
28
|
-
"CLAUDE.md",
|
|
29
|
-
SDK_REFERENCE_FILE,
|
|
30
|
-
]);
|
|
31
|
-
const LUMINE_AGENT_INSTRUCTIONS_MARKER =
|
|
32
|
-
"<!-- Lumine CLI Agent Instructions -->";
|
|
33
|
-
const LUMINE_SDK_REFERENCE_MARKER = "<!-- Lumine CLI SDK Reference -->";
|
|
34
|
-
const LUMINE_REFERENCE_INSTRUCTIONS_MARKER =
|
|
35
|
-
"<!-- Lumine CLI Reference Instructions -->";
|
|
36
|
-
const BUNDLED_SDK_REFERENCE_URL = new URL(
|
|
37
|
-
"../sdk/BUILD_SDK_INDEX.md",
|
|
38
|
-
import.meta.url,
|
|
39
|
-
);
|
|
40
|
-
const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
|
|
41
|
-
const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
|
|
42
|
-
# Twinkle Build SDK Reference
|
|
43
|
-
|
|
44
|
-
The bundled SDK reference could not be loaded from this Lumine CLI package.
|
|
45
|
-
|
|
46
|
-
Use these current source-of-truth rules:
|
|
47
|
-
|
|
48
|
-
- Read .twinkle/lumine-project.json before editing.
|
|
49
|
-
- Use Twinkle.capabilities.get() or Twinkle.capabilities.can(actionName) before relying on gated SDK calls.
|
|
50
|
-
- Use Twinkle.privateDb for simple private per-user preferences, drafts, settings, and small JSON state.
|
|
51
|
-
- Use Twinkle.userDb only for advanced private SQLite tables, indexes, many rows, filtered queries, or aggregates.
|
|
52
|
-
- Use Twinkle.sharedDb for shared multi-user JSON data.
|
|
53
|
-
- Use Twinkle.aiCards.list/search/get for existing public AI Card words, exampleText sentence material, and word levels.
|
|
54
|
-
- Use Twinkle.aiStories.list/search/get for existing AI Story passage text, story media, and questions.
|
|
55
|
-
- Use Twinkle.ai.chat with history entries shaped as { role, content }, not { text }.
|
|
56
|
-
- Use Twinkle.preview for canvas, WebGL, Three.js, fullscreen, and game layout.
|
|
57
|
-
- Prefer existing documented Twinkle.* methods over guessing names from old code.
|
|
58
|
-
`;
|
|
59
|
-
const LUMINE_AGENT_INSTRUCTIONS = `${LUMINE_AGENT_INSTRUCTIONS_MARKER}
|
|
60
|
-
# Lumine Project Agent Guide
|
|
61
|
-
|
|
62
|
-
This directory contains Twinkle Build project files pulled by Lumine CLI. Use
|
|
63
|
-
Lumine CLI as the source of truth for saving this workspace back to Twinkle.
|
|
64
|
-
|
|
65
|
-
## Source Of Truth
|
|
66
|
-
|
|
67
|
-
- Read .twinkle/lumine-project.json before changing files.
|
|
68
|
-
- Treat build.canWrite, build.canPublish, and build.contributionRootBuildId as authoritative.
|
|
69
|
-
- If lumineCli.updateAvailable is true, ask the user to rerun with npx @stage5/lumine@latest before saving.
|
|
70
|
-
- Read ${SDK_REFERENCE_FILE} before adding, removing, or changing any Twinkle.* SDK calls.
|
|
71
|
-
- If build.canWrite is false, do not save changes.
|
|
72
|
-
- If build.canPublish is false or contributionRootBuildId is set, this checkout is a contribution branch. Save only to this branch and do not run lumine launch or lumine save --publish.
|
|
73
|
-
- Do not edit another local checkout to bypass branch rules.
|
|
74
|
-
|
|
75
|
-
## Workflow
|
|
76
|
-
|
|
77
|
-
- Edit only project files in this workspace.
|
|
78
|
-
- Keep /index.html or /index.htm as the entry file.
|
|
79
|
-
- Run lumine save from this folder after edits, with a short summary:
|
|
80
|
-
|
|
81
|
-
\`\`\`bash
|
|
82
|
-
lumine save --summary "Describe the change"
|
|
83
|
-
\`\`\`
|
|
84
|
-
|
|
85
|
-
- Run lumine check before launch when possible.
|
|
86
|
-
- Use \`lumine sdk call <namespace.method> '{...}'\` to inspect real endpoint
|
|
87
|
-
data and measure latency (add --repeat <n>); \`lumine sdk list\` shows
|
|
88
|
-
callable methods. It prints the raw HTTP endpoint response, which can differ
|
|
89
|
-
from a method's Twinkle.* SDK return shape (some wrappers unwrap or rename
|
|
90
|
-
fields) — use ${SDK_REFERENCE_FILE} for SDK return shapes. Write methods
|
|
91
|
-
need --allow-write and mutate real app data.
|
|
92
|
-
- Owned canonical builds may be published only when the user explicitly asks.
|
|
93
|
-
|
|
94
|
-
## App Constraints
|
|
95
|
-
|
|
96
|
-
- Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
|
|
97
|
-
- Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
|
|
98
|
-
- For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
|
|
99
|
-
- For Three.js, use import * as THREE from '/build/vendor/three/0.184.0/three.module.min.js';. Addons (OrbitControls, GLTFLoader, ...) live under /build/vendor/three/0.184.0/addons/, e.g. import { OrbitControls } from '/build/vendor/three/0.184.0/addons/controls/OrbitControls.js';. Builds saved with the older /build/vendor/three/0.160.0/ path keep working.
|
|
100
|
-
- Do not invent or guess Twinkle.* SDK method names. Use ${SDK_REFERENCE_FILE} as the local SDK reference and prefer Twinkle.capabilities checks for gated features.
|
|
101
|
-
- Match storage to update frequency. Twinkle.privateDb and Twinkle.sharedDb are for LOW-frequency durable state only — things that change on a user action (settings, inventory checkpoints, completed quests, saved progress; comments, votes, room settings, submitted records). NEVER write high-frequency or per-frame/per-tick state to them (camera or cursor position, animation state, live movement, presence, autosave every frame/tick). Keep live state in client memory, broadcast realtime/presence via Twinkle.world, and for durable per-user state flush an occasional snapshot on an interval or on exit (never per frame) — e.g. the viewer/user DB or a single latest-snapshot key. The server rate-limits these writes per key and returns 429 on excess; never retry-loop a 429.
|
|
102
|
-
|
|
103
|
-
## Completion Report
|
|
104
|
-
|
|
105
|
-
Report the changed files, any SDK methods used, the lumine save result, the
|
|
106
|
-
build or branch id, and whether the result is published or unpublished changes.
|
|
107
|
-
`;
|
|
108
|
-
const LUMINE_REFERENCE_INSTRUCTIONS = `${LUMINE_REFERENCE_INSTRUCTIONS_MARKER}
|
|
109
|
-
# Lumine Reference Guide
|
|
110
|
-
|
|
111
|
-
This directory contains read-only reference files pulled from a public
|
|
112
|
-
open-source Twinkle Build. Use it for inspection and borrowing patterns, not as
|
|
113
|
-
the workspace to save.
|
|
114
|
-
|
|
115
|
-
## Source Of Truth
|
|
116
|
-
|
|
117
|
-
- Read .twinkle/lumine-project.json before using these files.
|
|
118
|
-
- If lumineCli.updateAvailable is true, ask the user to rerun with npx @stage5/lumine@latest before borrowing patterns.
|
|
119
|
-
- If metadata.readOnly is true or build.role is "reference", do not run lumine save from this directory.
|
|
120
|
-
- To start from this Build, run lumine fork with the source build id and edit the forked workspace.
|
|
121
|
-
- Do not edit another local checkout to bypass reference read-only semantics.
|
|
122
|
-
`;
|
|
123
|
-
const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
124
|
-
const COMMANDS = new Set([
|
|
125
|
-
"workspace",
|
|
126
|
-
"login",
|
|
127
|
-
"logout",
|
|
128
|
-
"whoami",
|
|
129
|
-
"new",
|
|
130
|
-
"projects",
|
|
131
|
-
"explore",
|
|
132
|
-
"select",
|
|
133
|
-
"pull",
|
|
134
|
-
"reference",
|
|
135
|
-
"fork",
|
|
136
|
-
"diff",
|
|
137
|
-
"merge",
|
|
138
|
-
"replace-main",
|
|
139
|
-
"save",
|
|
140
|
-
"push",
|
|
141
|
-
"check",
|
|
142
|
-
"launch",
|
|
143
|
-
"sdk",
|
|
144
|
-
"help",
|
|
145
|
-
]);
|
|
2
|
+
import { main } from "../lib/commands.js";
|
|
146
3
|
|
|
147
4
|
main().catch((error) => {
|
|
148
5
|
console.error(`lumine: ${error?.message || error}`);
|
|
149
6
|
process.exitCode = 1;
|
|
150
7
|
});
|
|
151
|
-
|
|
152
|
-
async function main() {
|
|
153
|
-
const options = parseArgs(process.argv.slice(2));
|
|
154
|
-
options.lumineCli = await loadLumineCliVersionInfo({ options });
|
|
155
|
-
if (options.help) {
|
|
156
|
-
printHelp();
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (options.updateCheck) {
|
|
160
|
-
await maybeCheckForLumineCliUpdate({ options });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (options.command === "workspace") {
|
|
164
|
-
await workspace(options);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (options.command === "login") {
|
|
168
|
-
await login(options);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (options.command === "logout") {
|
|
172
|
-
await logout(options);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
if (options.command === "whoami") {
|
|
176
|
-
await whoami(options);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
if (options.command === "new") {
|
|
180
|
-
await newBuild(options);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (options.command === "projects") {
|
|
184
|
-
await projects(options);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
if (options.command === "explore") {
|
|
188
|
-
await explore(options);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
if (options.command === "select") {
|
|
192
|
-
await selectProject(options);
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
if (options.command === "pull") {
|
|
196
|
-
await pull(options);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (options.command === "reference") {
|
|
200
|
-
await reference(options);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (options.command === "fork") {
|
|
204
|
-
await fork(options);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (options.command === "diff") {
|
|
208
|
-
await diff(options);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (options.command === "merge") {
|
|
212
|
-
await mergeBranch(options);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
if (options.command === "replace-main") {
|
|
216
|
-
await replaceMainWithBranch(options);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (options.command === "save" || options.command === "push") {
|
|
220
|
-
await save(options);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
if (options.command === "check") {
|
|
224
|
-
await check(options);
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
if (options.command === "launch") {
|
|
228
|
-
await launch(options);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (options.command === "sdk") {
|
|
232
|
-
await sdkCommand(options);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
printHelp();
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function login(options) {
|
|
240
|
-
const start = await requestJson({
|
|
241
|
-
method: "POST",
|
|
242
|
-
url: `${options.apiUrl}/cli/device/start`,
|
|
243
|
-
body: {
|
|
244
|
-
clientName: options.clientName,
|
|
245
|
-
scopes: [
|
|
246
|
-
"build:read",
|
|
247
|
-
"build:write",
|
|
248
|
-
"build:check",
|
|
249
|
-
"build:publish",
|
|
250
|
-
"build:sdk",
|
|
251
|
-
],
|
|
252
|
-
},
|
|
253
|
-
timeoutMs: options.timeoutMs,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const approvalUrl = start.verificationUriComplete || start.verificationUri;
|
|
257
|
-
console.log("Connect Lumine CLI to Twinkle.");
|
|
258
|
-
if (options.openBrowser && approvalUrl) {
|
|
259
|
-
console.log("Opening Twinkle in your browser...");
|
|
260
|
-
const opened = await openBrowser(approvalUrl);
|
|
261
|
-
if (!opened) console.log("Could not open the browser automatically.");
|
|
262
|
-
}
|
|
263
|
-
console.log(`Approval link: ${approvalUrl}`);
|
|
264
|
-
console.log(`Code: ${start.userCode}`);
|
|
265
|
-
console.log("Leave this terminal open. Waiting for approval...");
|
|
266
|
-
|
|
267
|
-
const intervalMs = Math.max(Number(start.interval || 3), 1) * 1000;
|
|
268
|
-
const startedAt = Date.now();
|
|
269
|
-
const expiresInMs = Math.max(Number(start.expiresIn || 600), 1) * 1000;
|
|
270
|
-
|
|
271
|
-
while (Date.now() - startedAt < expiresInMs) {
|
|
272
|
-
await sleep(intervalMs);
|
|
273
|
-
const tokenResponse = await pollToken({
|
|
274
|
-
options,
|
|
275
|
-
deviceCode: start.deviceCode,
|
|
276
|
-
});
|
|
277
|
-
if (!tokenResponse) continue;
|
|
278
|
-
|
|
279
|
-
await writeAuth({
|
|
280
|
-
options,
|
|
281
|
-
token: tokenResponse.accessToken,
|
|
282
|
-
username: tokenResponse.user?.username || "",
|
|
283
|
-
userId: tokenResponse.user?.id || null,
|
|
284
|
-
expiresAt: Date.now() + Number(tokenResponse.expiresIn || 0) * 1000,
|
|
285
|
-
});
|
|
286
|
-
console.log(
|
|
287
|
-
`Logged in${tokenResponse.user?.username ? ` as ${tokenResponse.user.username}` : ""}.`,
|
|
288
|
-
);
|
|
289
|
-
console.log("You can now run `lumine` to choose a project.");
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
throw new Error("Login code expired. Run `lumine login` again.");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function pollToken({ options, deviceCode }) {
|
|
297
|
-
try {
|
|
298
|
-
return await requestJson({
|
|
299
|
-
method: "POST",
|
|
300
|
-
url: `${options.apiUrl}/cli/device/token`,
|
|
301
|
-
body: { deviceCode },
|
|
302
|
-
timeoutMs: options.timeoutMs,
|
|
303
|
-
});
|
|
304
|
-
} catch (error) {
|
|
305
|
-
if (error.status === 428 || error.data?.error === "authorization_pending") {
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
throw error;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async function logout(options) {
|
|
313
|
-
let removed = false;
|
|
314
|
-
try {
|
|
315
|
-
await fs.unlink(options.authFile);
|
|
316
|
-
removed = true;
|
|
317
|
-
} catch (error) {
|
|
318
|
-
if (error.code !== "ENOENT") throw error;
|
|
319
|
-
}
|
|
320
|
-
console.log(
|
|
321
|
-
removed
|
|
322
|
-
? `Removed Lumine CLI login at ${options.authFile}`
|
|
323
|
-
: `No Lumine CLI login found at ${options.authFile}`,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function whoami(options) {
|
|
328
|
-
const auth = await resolveAuth(options);
|
|
329
|
-
const session = await requestJson({
|
|
330
|
-
url: `${options.apiUrl}/cli/session`,
|
|
331
|
-
authToken: auth.token,
|
|
332
|
-
timeoutMs: options.timeoutMs,
|
|
333
|
-
});
|
|
334
|
-
console.log(
|
|
335
|
-
`Logged in as ${session.username || auth.username || "unknown"} ` +
|
|
336
|
-
`(userId=${session.userId || auth.userId || "unknown"})`,
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function newBuild(options) {
|
|
341
|
-
const title = await resolveNewBuildTitle(options);
|
|
342
|
-
const description = await resolveNewBuildDescription(options);
|
|
343
|
-
const auth = await ensureAuth(options);
|
|
344
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
345
|
-
const createResult = await createBuild({
|
|
346
|
-
options,
|
|
347
|
-
auth,
|
|
348
|
-
title,
|
|
349
|
-
description,
|
|
350
|
-
});
|
|
351
|
-
const buildId = Number(createResult.build?.id || 0);
|
|
352
|
-
if (!buildId) {
|
|
353
|
-
throw new Error("Twinkle did not return a created Build.");
|
|
354
|
-
}
|
|
355
|
-
const result = await pullBuildFiles({ options, auth, buildId });
|
|
356
|
-
await saveSelectedBuild({ options, auth, build: result.build });
|
|
357
|
-
printNewBuildResult({ createResult, pullResult: result });
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function workspace(options) {
|
|
361
|
-
const auth = await ensureAuth(options);
|
|
362
|
-
const selectedBuild = options.target
|
|
363
|
-
? await loadTargetBuildMetadata({ options, auth })
|
|
364
|
-
: await chooseProject({
|
|
365
|
-
builds: await listBuilds({ options, auth }),
|
|
366
|
-
});
|
|
367
|
-
const build = await resolveEditableWorkspaceBuild({
|
|
368
|
-
options,
|
|
369
|
-
auth,
|
|
370
|
-
build: selectedBuild,
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
await saveSelectedBuild({ options, auth, build });
|
|
374
|
-
const result = await pullBuildFiles({ options, auth, buildId: build.id });
|
|
375
|
-
await saveSelectedBuild({ options, auth, build: result.build || build });
|
|
376
|
-
printPullResult(result);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async function projects(options) {
|
|
380
|
-
const auth = await resolveAuth(options);
|
|
381
|
-
const builds = await listBuilds({ options, auth });
|
|
382
|
-
printBuildList(builds);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async function explore(options) {
|
|
386
|
-
const auth = await resolveAuth(options);
|
|
387
|
-
const builds = await listOpenSourceBuilds({ options, auth });
|
|
388
|
-
printOpenSourceBuildList(builds, options);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
async function selectProject(options) {
|
|
392
|
-
const auth = await resolveAuth(options);
|
|
393
|
-
const selectedBuild = options.target
|
|
394
|
-
? await loadTargetBuildMetadata({ options, auth })
|
|
395
|
-
: await chooseProject({
|
|
396
|
-
builds: await listBuilds({ options, auth }),
|
|
397
|
-
});
|
|
398
|
-
const build = await resolveEditableWorkspaceBuild({
|
|
399
|
-
options,
|
|
400
|
-
auth,
|
|
401
|
-
build: selectedBuild,
|
|
402
|
-
});
|
|
403
|
-
await saveSelectedBuild({ options, auth, build });
|
|
404
|
-
console.log(
|
|
405
|
-
`Selected ${formatBuildTitle(build)}. Run \`lumine pull\` to get the files.`,
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function pull(options) {
|
|
410
|
-
const auth = await resolveAuth(options);
|
|
411
|
-
const requestedBuildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
412
|
-
const selectedBuild = await loadBuildMetadata({
|
|
413
|
-
options,
|
|
414
|
-
auth,
|
|
415
|
-
buildId: requestedBuildId,
|
|
416
|
-
});
|
|
417
|
-
const build = await resolveEditableWorkspaceBuild({
|
|
418
|
-
options,
|
|
419
|
-
auth,
|
|
420
|
-
build: selectedBuild,
|
|
421
|
-
});
|
|
422
|
-
const result = await pullBuildFiles({ options, auth, buildId: build.id });
|
|
423
|
-
await saveSelectedBuild({ options, auth, build: result.build });
|
|
424
|
-
printPullResult(result);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function reference(options) {
|
|
428
|
-
const auth = await resolveAuth(options);
|
|
429
|
-
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
430
|
-
const result = await pullReferenceFiles({ options, auth, buildId });
|
|
431
|
-
printReferenceResult(result);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
async function fork(options) {
|
|
435
|
-
const auth = await resolveAuth(options);
|
|
436
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
437
|
-
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
438
|
-
const forkResult = await forkBuild({ options, auth, buildId });
|
|
439
|
-
const forkedBuildId = Number(forkResult.build?.id || 0);
|
|
440
|
-
if (!forkedBuildId) {
|
|
441
|
-
throw new Error("Twinkle did not return a forked Build.");
|
|
442
|
-
}
|
|
443
|
-
const result = await pullBuildFiles({
|
|
444
|
-
options,
|
|
445
|
-
auth,
|
|
446
|
-
buildId: forkedBuildId,
|
|
447
|
-
});
|
|
448
|
-
await saveSelectedBuild({ options, auth, build: result.build });
|
|
449
|
-
printForkResult({ forkResult, pullResult: result });
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function diff(options) {
|
|
453
|
-
const auth = await resolveAuth(options);
|
|
454
|
-
const build = await loadTargetBuildMetadata({ options, auth });
|
|
455
|
-
const { rootBuildId, contributionBuildId } =
|
|
456
|
-
resolveContributionActionBuildIds(build);
|
|
457
|
-
const result = await loadContributionDiff({
|
|
458
|
-
options,
|
|
459
|
-
auth,
|
|
460
|
-
rootBuildId,
|
|
461
|
-
contributionBuildId,
|
|
462
|
-
});
|
|
463
|
-
printContributionDiff({ result, build });
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
async function mergeBranch(options) {
|
|
467
|
-
const auth = await resolveAuth(options);
|
|
468
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
469
|
-
const build = await loadTargetBuildMetadata({ options, auth });
|
|
470
|
-
const { rootBuildId, contributionBuildId } =
|
|
471
|
-
resolveContributionActionBuildIds(build);
|
|
472
|
-
const result = await mergeContributionIntoMain({
|
|
473
|
-
options,
|
|
474
|
-
auth,
|
|
475
|
-
rootBuildId,
|
|
476
|
-
contributionBuildId,
|
|
477
|
-
});
|
|
478
|
-
printContributionActionResult({
|
|
479
|
-
action: "Merged",
|
|
480
|
-
result,
|
|
481
|
-
rootBuildId,
|
|
482
|
-
contributionBuildId,
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async function replaceMainWithBranch(options) {
|
|
487
|
-
const auth = await resolveAuth(options);
|
|
488
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
489
|
-
const build = await loadTargetBuildMetadata({ options, auth });
|
|
490
|
-
const { rootBuildId, contributionBuildId } =
|
|
491
|
-
resolveContributionActionBuildIds(build);
|
|
492
|
-
const result = await replaceMainWithContribution({
|
|
493
|
-
options,
|
|
494
|
-
auth,
|
|
495
|
-
rootBuildId,
|
|
496
|
-
contributionBuildId,
|
|
497
|
-
});
|
|
498
|
-
printContributionActionResult({
|
|
499
|
-
action: "Replaced main with",
|
|
500
|
-
result,
|
|
501
|
-
rootBuildId,
|
|
502
|
-
contributionBuildId,
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
async function save(options) {
|
|
507
|
-
const auth = await resolveAuth(options);
|
|
508
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
509
|
-
const localProject = await findLocalProjectMetadata(
|
|
510
|
-
path.resolve(options.dir || process.cwd()),
|
|
511
|
-
);
|
|
512
|
-
assertLocalProjectCanBeSaved(localProject);
|
|
513
|
-
let buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
|
|
514
|
-
localProject,
|
|
515
|
-
});
|
|
516
|
-
let build = await resolveBuildForSave({
|
|
517
|
-
options,
|
|
518
|
-
auth,
|
|
519
|
-
buildId,
|
|
520
|
-
localProject,
|
|
521
|
-
});
|
|
522
|
-
if (build?.canWrite === false) {
|
|
523
|
-
throw new Error(
|
|
524
|
-
"This checkout is read-only for the current CLI login. Pull or diff it for review, then merge or replace main from the project owner workflow.",
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
buildId = Number(build?.id || buildId);
|
|
528
|
-
const dir = resolveProjectDirForSave({ options, localProject });
|
|
529
|
-
const files = await collectProjectFiles(dir);
|
|
530
|
-
const result = await saveProjectFiles({
|
|
531
|
-
options,
|
|
532
|
-
auth,
|
|
533
|
-
buildId,
|
|
534
|
-
files,
|
|
535
|
-
summary: options.summary || DEFAULT_SAVE_SUMMARY,
|
|
536
|
-
});
|
|
537
|
-
build = result.build ||
|
|
538
|
-
build ||
|
|
539
|
-
(await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
|
|
540
|
-
id: buildId,
|
|
541
|
-
title: `Build ${buildId}`,
|
|
542
|
-
};
|
|
543
|
-
await saveSelectedBuild({ options, auth, build });
|
|
544
|
-
await writeProjectMetadata({
|
|
545
|
-
dir,
|
|
546
|
-
options,
|
|
547
|
-
build,
|
|
548
|
-
manifest: result.projectManifest || null,
|
|
549
|
-
lastSavedAt: new Date().toISOString(),
|
|
550
|
-
});
|
|
551
|
-
printSaveResult({ result, build, dir, files });
|
|
552
|
-
|
|
553
|
-
if (options.publish) {
|
|
554
|
-
if (build?.canPublish === false) {
|
|
555
|
-
console.log(
|
|
556
|
-
"Saved to your branch. The project owner can merge or replace main from Twinkle.",
|
|
557
|
-
);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
const publish = await publishBuild({ options, buildId, auth });
|
|
561
|
-
if (publish.skipped) {
|
|
562
|
-
console.log("Publish skipped: already up to date.");
|
|
563
|
-
} else {
|
|
564
|
-
console.log("Publish complete.");
|
|
565
|
-
}
|
|
566
|
-
console.log(`App: ${options.siteUrl}/app/${buildId}`);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
async function check(options) {
|
|
571
|
-
const auth = await resolveAuth(options);
|
|
572
|
-
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
573
|
-
const result = await requestJson({
|
|
574
|
-
url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
|
|
575
|
-
authToken: auth.token,
|
|
576
|
-
timeoutMs: options.timeoutMs,
|
|
577
|
-
});
|
|
578
|
-
printCheck(result);
|
|
579
|
-
if (!result.ok) process.exitCode = 1;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function launch(options) {
|
|
583
|
-
const auth = await resolveAuth(options);
|
|
584
|
-
if (options.saveFirst) {
|
|
585
|
-
await save({
|
|
586
|
-
...options,
|
|
587
|
-
publish: false,
|
|
588
|
-
saveFirst: false,
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
592
|
-
const checkResult = await requestJson({
|
|
593
|
-
url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
|
|
594
|
-
authToken: auth.token,
|
|
595
|
-
timeoutMs: options.timeoutMs,
|
|
596
|
-
});
|
|
597
|
-
printCheck(checkResult);
|
|
598
|
-
const launchOk =
|
|
599
|
-
typeof checkResult.launchOk === "boolean"
|
|
600
|
-
? checkResult.launchOk
|
|
601
|
-
: checkResult.ok;
|
|
602
|
-
if (!checkResult.ok || !launchOk) {
|
|
603
|
-
process.exitCode = 1;
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const publish = await publishBuild({ options, buildId, auth });
|
|
608
|
-
const build = publish.build || checkResult.build || {};
|
|
609
|
-
const appUrl = `${options.siteUrl}/app/${buildId}`;
|
|
610
|
-
const versionId =
|
|
611
|
-
Number(build.publishedArtifactVersionId || 0) ||
|
|
612
|
-
Number(build.releaseStatus?.publishedArtifactVersionId || 0) ||
|
|
613
|
-
0;
|
|
614
|
-
|
|
615
|
-
const appProbe = await probeUrl({
|
|
616
|
-
url: appUrl,
|
|
617
|
-
timeoutMs: options.timeoutMs,
|
|
618
|
-
});
|
|
619
|
-
const previewProbe = versionId
|
|
620
|
-
? await probeUrl({
|
|
621
|
-
url: `${options.apiUrl}/build/preview/build/${buildId}/version/${versionId}`,
|
|
622
|
-
authToken: auth.token,
|
|
623
|
-
timeoutMs: options.timeoutMs,
|
|
624
|
-
})
|
|
625
|
-
: null;
|
|
626
|
-
|
|
627
|
-
if (publish.skipped) {
|
|
628
|
-
console.log("Publish skipped: already up to date.");
|
|
629
|
-
} else {
|
|
630
|
-
console.log("Publish complete.");
|
|
631
|
-
}
|
|
632
|
-
console.log(`App: ${appUrl}`);
|
|
633
|
-
console.log(
|
|
634
|
-
`Prod shell: ${appProbe.ok ? "ok" : "fail"} ${appProbe.status} bytes=${appProbe.bytes}`,
|
|
635
|
-
);
|
|
636
|
-
if (previewProbe) {
|
|
637
|
-
console.log(
|
|
638
|
-
`Published preview: ${previewProbe.ok ? "ok" : "fail"} ` +
|
|
639
|
-
`${previewProbe.status} bytes=${previewProbe.bytes}`,
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (!appProbe.ok || (previewProbe && !previewProbe.ok) || !versionId) {
|
|
644
|
-
process.exitCode = 1;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
async function publishBuild({ options, buildId, auth }) {
|
|
649
|
-
if (auth.releaseStatus?.state === "up_to_date") {
|
|
650
|
-
return { skipped: true };
|
|
651
|
-
}
|
|
652
|
-
try {
|
|
653
|
-
const result = await requestJson({
|
|
654
|
-
method: "POST",
|
|
655
|
-
url: `${options.apiUrl}/build/${buildId}/publish`,
|
|
656
|
-
authToken: auth.token,
|
|
657
|
-
body: {},
|
|
658
|
-
timeoutMs: options.timeoutMs,
|
|
659
|
-
});
|
|
660
|
-
return { skipped: false, build: result.build || null };
|
|
661
|
-
} catch (error) {
|
|
662
|
-
if (
|
|
663
|
-
error.status === 409 &&
|
|
664
|
-
error.data?.code === "build_release_up_to_date"
|
|
665
|
-
) {
|
|
666
|
-
return {
|
|
667
|
-
skipped: true,
|
|
668
|
-
build: { releaseStatus: error.data.releaseStatus || null },
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
throw error;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function printCheck(result) {
|
|
676
|
-
const checks = result.checks || {};
|
|
677
|
-
console.log(`Launch check: ${result.ok ? "ok" : "fail"}`);
|
|
678
|
-
if (checks.canonicalBuild) {
|
|
679
|
-
console.log(
|
|
680
|
-
`- canonical build: ${checks.canonicalBuild.ok ? "ok" : "fail"}`,
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
console.log(
|
|
684
|
-
`- project files: ${checks.projectFiles?.ok ? "ok" : "fail"} ` +
|
|
685
|
-
`files=${checks.projectFiles?.fileCount ?? 0}`,
|
|
686
|
-
);
|
|
687
|
-
console.log(`- toolchain: ${checks.toolchain?.ok ? "ok" : "fail"}`);
|
|
688
|
-
if (checks.publishPermission) {
|
|
689
|
-
console.log(
|
|
690
|
-
`- publish permission: ${checks.publishPermission.ok ? "ok" : "fail"}`,
|
|
691
|
-
);
|
|
692
|
-
if (checks.publishPermission.reason) {
|
|
693
|
-
console.log(` ${checks.publishPermission.reason}`);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
if (typeof result.launchOk === "boolean") {
|
|
697
|
-
console.log(`- launch gate: ${result.launchOk ? "ok" : "fail"}`);
|
|
698
|
-
}
|
|
699
|
-
console.log(
|
|
700
|
-
`- conflict markers: ${checks.conflictMarkers?.ok ? "ok" : "fail"}`,
|
|
701
|
-
);
|
|
702
|
-
for (const diagnostic of checks.toolchain?.diagnostics || []) {
|
|
703
|
-
console.log(
|
|
704
|
-
` ${diagnostic.kind} ${diagnostic.filePath}` +
|
|
705
|
-
`${diagnostic.line ? `:${diagnostic.line}` : ""} ${diagnostic.message}`,
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
for (const conflictPath of checks.conflictMarkers?.paths || []) {
|
|
709
|
-
console.log(` conflict marker: ${conflictPath}`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async function ensureAuth(options) {
|
|
714
|
-
try {
|
|
715
|
-
return await resolveAuth(options);
|
|
716
|
-
} catch (error) {
|
|
717
|
-
if (options.authToken || !isMissingLoginError(error)) throw error;
|
|
718
|
-
}
|
|
719
|
-
await login(options);
|
|
720
|
-
return await resolveAuth(options);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Build SDK data-API methods callable from the CLI. Method names match the
|
|
724
|
-
// SDK manifest (constants/buildSdkIndex.json in twinkle-api); paths are the
|
|
725
|
-
// HTTP endpoints the Build runtime parent proxies those methods to. The args
|
|
726
|
-
// JSON object is sent verbatim as the POST body, so arg shapes follow
|
|
727
|
-
// TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
|
|
728
|
-
// Anything not listed can be called with --path api/<endpoint>.
|
|
729
|
-
const SDK_CLI_METHODS = {
|
|
730
|
-
"aiStories.list": { path: "api/content/ai-stories/list", scopes: ["content:read"] },
|
|
731
|
-
"aiStories.chapters": { path: "api/content/ai-stories/chapters", scopes: ["content:read"] },
|
|
732
|
-
"aiStories.search": { path: "api/content/ai-stories/search", scopes: ["content:read"] },
|
|
733
|
-
"aiStories.get": { path: "api/content/ai-story", scopes: ["content:read"] },
|
|
734
|
-
"aiCards.list": { path: "api/content/ai-cards/list", scopes: ["content:read"] },
|
|
735
|
-
"aiCards.search": { path: "api/content/ai-cards/search", scopes: ["content:read"] },
|
|
736
|
-
"aiCards.get": { path: "api/content/ai-card", scopes: ["content:read"] },
|
|
737
|
-
"grammarbles.listQuestions": { path: "api/content/grammarbles/questions", scopes: ["content:read"] },
|
|
738
|
-
"grammarbles.getMyQuestionHistory": { path: "api/content/grammarbles/history", scopes: ["content:read"] },
|
|
739
|
-
"subjects.getMySubjects": { path: "api/content/my-subjects", scopes: ["content:read"] },
|
|
740
|
-
"subjects.search": { path: "api/content/subjects/search", scopes: ["content:read"] },
|
|
741
|
-
"subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
|
|
742
|
-
"subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
|
|
743
|
-
"subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
|
|
744
|
-
"profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
|
|
745
|
-
"profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
|
|
746
|
-
"profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
|
|
747
|
-
"profileComments.getProfileCommentCounts": { path: "api/content/profile-comment-counts", scopes: ["content:read"] },
|
|
748
|
-
"privateDb.get": { path: "api/private-db/get", scopes: ["privateDb:read"] },
|
|
749
|
-
"privateDb.list": { path: "api/private-db/list", scopes: ["privateDb:read"] },
|
|
750
|
-
"privateDb.set": { path: "api/private-db/set", scopes: ["privateDb:write"], write: true },
|
|
751
|
-
"privateDb.remove": { path: "api/private-db/delete", scopes: ["privateDb:write"], write: true },
|
|
752
|
-
"sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
|
|
753
|
-
"sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
|
|
754
|
-
"sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
|
|
755
|
-
"sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
|
|
756
|
-
"sharedDb.updateEntry": { path: "api/shared-db/entry/update", scopes: ["sharedDb:write"], write: true },
|
|
757
|
-
"sharedDb.deleteEntry": { path: "api/shared-db/entry/delete", scopes: ["sharedDb:write"], write: true },
|
|
758
|
-
"sharedDb.kv.get": { path: "api/shared-db/kv/get", scopes: ["sharedDb:read"] },
|
|
759
|
-
"sharedDb.kv.list": { path: "api/shared-db/kv/list", scopes: ["sharedDb:read"] },
|
|
760
|
-
// The kv/set endpoint is batch-shaped ({ namespace, items }); kv.set keeps
|
|
761
|
-
// the SDK's single-key signature and maps to one item.
|
|
762
|
-
"sharedDb.kv.set": {
|
|
763
|
-
path: "api/shared-db/kv/set",
|
|
764
|
-
scopes: ["sharedDb:write"],
|
|
765
|
-
write: true,
|
|
766
|
-
mapArgs: (args) => ({
|
|
767
|
-
namespace: args.namespace,
|
|
768
|
-
items: [{ key: args.key, value: args.value }],
|
|
769
|
-
}),
|
|
770
|
-
sdkReshape: "the SDK takes (namespace, key, value) and returns { item }",
|
|
771
|
-
},
|
|
772
|
-
"sharedDb.kv.setMany": { path: "api/shared-db/kv/set", scopes: ["sharedDb:write"], write: true },
|
|
773
|
-
"sharedDb.kv.remove": { path: "api/shared-db/kv/delete", scopes: ["sharedDb:write"], write: true },
|
|
774
|
-
"chat.listRooms": { path: "api/chat/rooms/list", scopes: ["chat:read"] },
|
|
775
|
-
"chat.createRoom": { path: "api/chat/rooms/create", scopes: ["chat:write"], write: true },
|
|
776
|
-
"chat.listMessages": { path: "api/chat/messages/list", scopes: ["chat:read"] },
|
|
777
|
-
"chat.sendMessage": { path: "api/chat/messages/send", scopes: ["chat:write"], write: true },
|
|
778
|
-
"chat.deleteMessage": { path: "api/chat/messages/delete", scopes: ["chat:write"], write: true },
|
|
779
|
-
"users.getUser": {
|
|
780
|
-
path: "api/user",
|
|
781
|
-
scopes: ["user:read"],
|
|
782
|
-
sdkReshape: "the SDK returns the user object directly (unwraps { user })",
|
|
783
|
-
},
|
|
784
|
-
"users.getUsers": { path: "api/users", scopes: ["users:read"] },
|
|
785
|
-
"reflections.getDailyReflections": { path: "api/daily-reflections", scopes: ["dailyReflections:read"] },
|
|
786
|
-
"reminders.list": { path: "api/reminders/list", scopes: ["reminders:read"] },
|
|
787
|
-
// getDue's backend defaults autoAcknowledge to true, which persists writes
|
|
788
|
-
// (lastTriggeredAt, and disabling one-shot reminders). Force read-only
|
|
789
|
-
// probes to autoAcknowledge:false; acknowledging is a write that needs
|
|
790
|
-
// --allow-write.
|
|
791
|
-
"reminders.getDue": {
|
|
792
|
-
path: "api/reminders/due",
|
|
793
|
-
scopes: ["reminders:read"],
|
|
794
|
-
writeWhen: (args) => args.autoAcknowledge === true,
|
|
795
|
-
mapArgs: (args) => ({
|
|
796
|
-
...args,
|
|
797
|
-
autoAcknowledge: args.autoAcknowledge === true,
|
|
798
|
-
}),
|
|
799
|
-
},
|
|
800
|
-
"reminders.create": { path: "api/reminders/create", scopes: ["reminders:write"], write: true },
|
|
801
|
-
"reminders.update": { path: "api/reminders/update", scopes: ["reminders:write"], write: true },
|
|
802
|
-
"reminders.remove": { path: "api/reminders/delete", scopes: ["reminders:write"], write: true },
|
|
803
|
-
"files.list": {
|
|
804
|
-
path: "api/files/list",
|
|
805
|
-
scopes: ["files:read"],
|
|
806
|
-
sdkReshape: "the SDK returns { assets, usage }",
|
|
807
|
-
},
|
|
808
|
-
"files.delete": {
|
|
809
|
-
path: "api/files/delete",
|
|
810
|
-
scopes: ["files:write"],
|
|
811
|
-
write: true,
|
|
812
|
-
sdkReshape: "the SDK returns { success, usage }",
|
|
813
|
-
},
|
|
814
|
-
"notifications.getSubscription": { path: "api/notifications/subscription", scopes: ["notifications:read"] },
|
|
815
|
-
"notifications.subscribe": { path: "api/notifications/subscription/subscribe", scopes: ["notifications:write"], write: true },
|
|
816
|
-
"notifications.unsubscribe": { path: "api/notifications/subscription/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
817
|
-
"notifications.subscribeMany": { path: "api/notifications/subscriptions/subscribe", scopes: ["notifications:write"], write: true },
|
|
818
|
-
"notifications.unsubscribeMany": { path: "api/notifications/subscriptions/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
819
|
-
"notifications.notifySubscribers": { path: "api/notifications/notify-subscribers", scopes: ["notifications:emit"], write: true },
|
|
820
|
-
"notifications.getSubjectUpdateSubscription": { path: "api/notifications/subject-update-subscription", scopes: ["notifications:read"] },
|
|
821
|
-
"notifications.subscribeToSubjectUpdates": { path: "api/notifications/subject-update-subscription/subscribe", scopes: ["notifications:write"], write: true },
|
|
822
|
-
"notifications.unsubscribeFromSubjectUpdates": { path: "api/notifications/subject-update-subscription/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
823
|
-
// Leaderboards use the public leaderboard routes (regular login auth, no
|
|
824
|
-
// build API token). args.boardKey selects the board; remaining args are
|
|
825
|
-
// query params (get) or the POST body (submit).
|
|
826
|
-
"leaderboards.get": { special: "leaderboardGet" },
|
|
827
|
-
"leaderboards.submit": { special: "leaderboardSubmit", write: true },
|
|
828
|
-
// userDb (the viewer's private SQLite) lives at /viewer-db/*, which uses
|
|
829
|
-
// session auth directly — no build API token. query reads, exec mutates.
|
|
830
|
-
// Body is { sql, params }. The backend's query route accepts any
|
|
831
|
-
// row-returning statement, including INSERT/UPDATE/DELETE ... RETURNING,
|
|
832
|
-
// so SQL that mutates is gated behind --allow-write here too.
|
|
833
|
-
"userDb.query": { special: "userDbQuery", writeWhen: (args) => sqlMutates(args.sql) },
|
|
834
|
-
"userDb.exec": { special: "userDbExec", write: true },
|
|
835
|
-
};
|
|
836
|
-
|
|
837
|
-
// Heuristic write-detection for raw SQL. SELECT/EXPLAIN/VALUES cannot mutate;
|
|
838
|
-
// a WITH (CTE) can wrap DML; everything else is treated as write-capable.
|
|
839
|
-
// Errs toward over-gating (safe) rather than under-gating.
|
|
840
|
-
function sqlMutates(sql) {
|
|
841
|
-
const normalized = String(sql || "")
|
|
842
|
-
.trim()
|
|
843
|
-
.replace(/^\(+/, "");
|
|
844
|
-
const leadingKeyword = (normalized.match(/^\s*([a-z]+)/i)?.[1] || "").toUpperCase();
|
|
845
|
-
if (
|
|
846
|
-
leadingKeyword === "SELECT" ||
|
|
847
|
-
leadingKeyword === "EXPLAIN" ||
|
|
848
|
-
leadingKeyword === "VALUES"
|
|
849
|
-
) {
|
|
850
|
-
return false;
|
|
851
|
-
}
|
|
852
|
-
if (leadingKeyword === "WITH") {
|
|
853
|
-
return /\b(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(normalized);
|
|
854
|
-
}
|
|
855
|
-
return true;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Reverse index of curated endpoint paths -> method name(s). Used to reject
|
|
859
|
-
// raw --path calls to curated endpoints: --path is for endpoints not in the
|
|
860
|
-
// list, and routing a curated endpoint through --path would strip its
|
|
861
|
-
// write/writeWhen/mapArgs safety handling (e.g. api/reminders/due defaults
|
|
862
|
-
// autoAcknowledge to true and mutates under a read scope).
|
|
863
|
-
const SDK_CLI_METHOD_NAMES_BY_PATH = (() => {
|
|
864
|
-
const byPath = new Map();
|
|
865
|
-
for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
|
|
866
|
-
if (!entry.path) continue;
|
|
867
|
-
if (!byPath.has(entry.path)) byPath.set(entry.path, []);
|
|
868
|
-
byPath.get(entry.path).push(name);
|
|
869
|
-
}
|
|
870
|
-
return byPath;
|
|
871
|
-
})();
|
|
872
|
-
|
|
873
|
-
// Default token scopes for --path calls. Never fall through to the server's
|
|
874
|
-
// default scope set: it includes write/emit scopes, which must not be minted
|
|
875
|
-
// without --allow-write.
|
|
876
|
-
const SDK_CLI_READ_SCOPES = [
|
|
877
|
-
"files:read",
|
|
878
|
-
"user:read",
|
|
879
|
-
"users:read",
|
|
880
|
-
"dailyReflections:read",
|
|
881
|
-
"content:read",
|
|
882
|
-
"sharedDb:read",
|
|
883
|
-
"privateDb:read",
|
|
884
|
-
"chat:read",
|
|
885
|
-
"notifications:read",
|
|
886
|
-
"reminders:read",
|
|
887
|
-
];
|
|
888
|
-
|
|
889
|
-
function isWriteCapableScope(scope) {
|
|
890
|
-
return /:(write|emit)$/.test(String(scope));
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
async function sdkCommand(options) {
|
|
894
|
-
const subcommand = String(options.positional[0] || "list");
|
|
895
|
-
if (subcommand === "list") {
|
|
896
|
-
printSdkMethodList();
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
if (subcommand !== "call") {
|
|
900
|
-
throw new Error(
|
|
901
|
-
'Usage: lumine sdk list | lumine sdk call <namespace.method> [jsonArgs]',
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
await sdkCall(options);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
function printSdkMethodList() {
|
|
908
|
-
console.log(
|
|
909
|
-
"Callable Build endpoints (args JSON = request body). Prints the raw\n" +
|
|
910
|
-
"endpoint response, which can differ from the SDK return shape (*).\n",
|
|
911
|
-
);
|
|
912
|
-
const byNamespace = new Map();
|
|
913
|
-
for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
|
|
914
|
-
const namespace = name.split(".")[0];
|
|
915
|
-
if (!byNamespace.has(namespace)) byNamespace.set(namespace, []);
|
|
916
|
-
byNamespace.get(namespace).push({ name, entry });
|
|
917
|
-
}
|
|
918
|
-
for (const [namespace, methods] of byNamespace) {
|
|
919
|
-
console.log(` ${namespace}`);
|
|
920
|
-
for (const { name, entry } of methods) {
|
|
921
|
-
const writeTag = entry.write ? " [write: needs --allow-write]" : "";
|
|
922
|
-
const reshapeTag = entry.sdkReshape ? " *" : "";
|
|
923
|
-
const pathText = entry.path || `(${entry.special})`;
|
|
924
|
-
console.log(
|
|
925
|
-
` ${name.padEnd(44)} ${pathText}${writeTag}${reshapeTag}`,
|
|
926
|
-
);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
console.log(`
|
|
930
|
-
Usage:
|
|
931
|
-
lumine sdk call aiStories.chapters '{"limit": 5}'
|
|
932
|
-
lumine sdk call privateDb.set '{"key": "k", "value": {"a": 1}}' --allow-write
|
|
933
|
-
lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
|
|
934
|
-
lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
|
|
935
|
-
lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
|
|
936
|
-
|
|
937
|
-
Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
|
|
938
|
-
endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
|
|
939
|
-
check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
|
|
940
|
-
latency stats, --build <id> to target a build outside the current workspace.
|
|
941
|
-
Use --path api/<endpoint> only for endpoints not listed above.`);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
async function sdkCall(options) {
|
|
945
|
-
const usingRawPath = Boolean(options.sdkPath);
|
|
946
|
-
const methodName = usingRawPath ? "" : String(options.positional[1] || "");
|
|
947
|
-
const argsText = String(
|
|
948
|
-
(usingRawPath ? options.positional[1] : options.positional[2]) || "",
|
|
949
|
-
).trim();
|
|
950
|
-
|
|
951
|
-
let args = {};
|
|
952
|
-
if (argsText) {
|
|
953
|
-
try {
|
|
954
|
-
args = JSON.parse(argsText);
|
|
955
|
-
} catch (error) {
|
|
956
|
-
throw new Error(`Args must be valid JSON: ${error.message}`);
|
|
957
|
-
}
|
|
958
|
-
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
959
|
-
throw new Error("Args must be a JSON object, e.g. '{\"limit\": 5}'.");
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
let endpoint;
|
|
964
|
-
if (usingRawPath) {
|
|
965
|
-
let normalizedPath = String(options.sdkPath).replace(/^\/+/, "");
|
|
966
|
-
if (!/^api\/[a-zA-Z0-9/_-]+$/.test(normalizedPath)) {
|
|
967
|
-
throw new Error("--path must look like api/<endpoint> (no query string).");
|
|
968
|
-
}
|
|
969
|
-
// Normalize the way Express routes (case-insensitive, non-strict, slashes
|
|
970
|
-
// collapsed) so route-equivalent variants like api/Token or
|
|
971
|
-
// api/reminders/due/ cannot dodge the guards below. Guard and call the
|
|
972
|
-
// same normalized path so the checked string is the executed string.
|
|
973
|
-
normalizedPath = normalizedPath
|
|
974
|
-
.toLowerCase()
|
|
975
|
-
.replace(/\/{2,}/g, "/")
|
|
976
|
-
.replace(/\/+$/, "");
|
|
977
|
-
// api/token is control-plane: the CLI mints the build API token itself,
|
|
978
|
-
// and that endpoint's response is a token whose scopes come from the
|
|
979
|
-
// request body, not from the CLI mint — so the write gate cannot see
|
|
980
|
-
// them. Calling it as a data endpoint would hand back a write-capable
|
|
981
|
-
// token without --allow-write. It is not a data method; reject it.
|
|
982
|
-
if (/^api\/token(\/|$)/.test(normalizedPath)) {
|
|
983
|
-
throw new Error(
|
|
984
|
-
"api/token is not a callable SDK endpoint. The CLI mints build API " +
|
|
985
|
-
"tokens internally for sdk calls.",
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
// --path is for endpoints not in the curated list. Reject curated paths
|
|
989
|
-
// so their read/write safety handling cannot be bypassed via --path.
|
|
990
|
-
const curatedMethodNames = SDK_CLI_METHOD_NAMES_BY_PATH.get(normalizedPath);
|
|
991
|
-
if (curatedMethodNames) {
|
|
992
|
-
throw new Error(
|
|
993
|
-
`${normalizedPath} is a curated SDK endpoint. Call it as ` +
|
|
994
|
-
`${curatedMethodNames.join(" or ")} (applies the correct read/` +
|
|
995
|
-
"write handling). --path is only for endpoints not in `lumine sdk list`.",
|
|
996
|
-
);
|
|
997
|
-
}
|
|
998
|
-
endpoint = { path: normalizedPath, scopes: SDK_CLI_READ_SCOPES };
|
|
999
|
-
} else {
|
|
1000
|
-
if (!methodName) {
|
|
1001
|
-
throw new Error(
|
|
1002
|
-
'Usage: lumine sdk call <namespace.method> [jsonArgs]. Run `lumine sdk list`.',
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
endpoint = SDK_CLI_METHODS[methodName];
|
|
1006
|
-
if (!endpoint) {
|
|
1007
|
-
throw new Error(
|
|
1008
|
-
`Unknown SDK method "${methodName}". Run \`lumine sdk list\`, or call ` +
|
|
1009
|
-
"the endpoint directly with --path api/<endpoint>.",
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// Token-capability gate: a write-capable build API token must never be
|
|
1015
|
-
// minted (and write-tagged methods never called) without --allow-write,
|
|
1016
|
-
// regardless of whether the call came from the curated table, --path, or a
|
|
1017
|
-
// --scopes override.
|
|
1018
|
-
const requestedScopes = options.sdkScopes.length
|
|
1019
|
-
? options.sdkScopes
|
|
1020
|
-
: endpoint.scopes || [];
|
|
1021
|
-
const writeScopes = requestedScopes.filter(isWriteCapableScope);
|
|
1022
|
-
// writeWhen covers endpoints that mutate under a read scope depending on
|
|
1023
|
-
// their args (e.g. reminders.getDue acknowledging due reminders).
|
|
1024
|
-
const writeByArgs =
|
|
1025
|
-
typeof endpoint.writeWhen === "function" && endpoint.writeWhen(args);
|
|
1026
|
-
const writeCapable =
|
|
1027
|
-
Boolean(endpoint.write) || writeScopes.length > 0 || writeByArgs;
|
|
1028
|
-
if (writeCapable && !options.allowWrite) {
|
|
1029
|
-
const reason = writeScopes.length
|
|
1030
|
-
? `write scopes: ${writeScopes.join(", ")}`
|
|
1031
|
-
: "this call writes app data";
|
|
1032
|
-
throw new Error(
|
|
1033
|
-
`${methodName || endpoint.path} can mutate real app data ` +
|
|
1034
|
-
`(${reason}). Re-run with --allow-write.`,
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// Resolve the build target (local-only: --build or workspace metadata)
|
|
1039
|
-
// before any auth side effects, so invalid local input fails immediately
|
|
1040
|
-
// instead of after starting the device-login flow.
|
|
1041
|
-
const buildId = await resolveSdkBuildId(options);
|
|
1042
|
-
const auth = await ensureAuth(options);
|
|
1043
|
-
await assertAuthScope({ options, auth, scope: "build:sdk" });
|
|
1044
|
-
const attempts = [];
|
|
1045
|
-
let lastResult = null;
|
|
1046
|
-
|
|
1047
|
-
if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
|
|
1048
|
-
const boardKey = String(args.boardKey || "default");
|
|
1049
|
-
const rest = { ...args };
|
|
1050
|
-
delete rest.boardKey;
|
|
1051
|
-
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1052
|
-
if (endpoint.special === "leaderboardGet") {
|
|
1053
|
-
const url = new URL(
|
|
1054
|
-
`${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}`,
|
|
1055
|
-
);
|
|
1056
|
-
for (const [key, value] of Object.entries(rest)) {
|
|
1057
|
-
if (value !== null && value !== undefined) {
|
|
1058
|
-
url.searchParams.set(key, String(value));
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
lastResult = await executeSdkHttpCall({
|
|
1062
|
-
options,
|
|
1063
|
-
method: "GET",
|
|
1064
|
-
url: url.toString(),
|
|
1065
|
-
authToken: auth.token,
|
|
1066
|
-
});
|
|
1067
|
-
} else {
|
|
1068
|
-
lastResult = await executeSdkHttpCall({
|
|
1069
|
-
options,
|
|
1070
|
-
method: "POST",
|
|
1071
|
-
url: `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}/submit`,
|
|
1072
|
-
authToken: auth.token,
|
|
1073
|
-
body: rest,
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
attempts.push(lastResult);
|
|
1077
|
-
printSdkAttemptLine(attempts.length, lastResult);
|
|
1078
|
-
}
|
|
1079
|
-
} else if (
|
|
1080
|
-
endpoint.special === "userDbQuery" ||
|
|
1081
|
-
endpoint.special === "userDbExec"
|
|
1082
|
-
) {
|
|
1083
|
-
// viewer-db uses session auth (no build API token). Body is { sql, params }.
|
|
1084
|
-
const route = endpoint.special === "userDbExec" ? "exec" : "query";
|
|
1085
|
-
const body = { sql: args.sql, params: args.params };
|
|
1086
|
-
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1087
|
-
lastResult = await executeSdkHttpCall({
|
|
1088
|
-
options,
|
|
1089
|
-
method: "POST",
|
|
1090
|
-
url: `${options.apiUrl}/build/${buildId}/viewer-db/${route}`,
|
|
1091
|
-
authToken: auth.token,
|
|
1092
|
-
body,
|
|
1093
|
-
});
|
|
1094
|
-
attempts.push(lastResult);
|
|
1095
|
-
printSdkAttemptLine(attempts.length, lastResult);
|
|
1096
|
-
}
|
|
1097
|
-
} else {
|
|
1098
|
-
// Always request explicit scopes: an empty scopes body makes the server
|
|
1099
|
-
// fall back to its default scope set, which is write-capable.
|
|
1100
|
-
const scopes = requestedScopes.length
|
|
1101
|
-
? requestedScopes
|
|
1102
|
-
: SDK_CLI_READ_SCOPES;
|
|
1103
|
-
const tokenStartedAt = Date.now();
|
|
1104
|
-
const tokenResult = await requestJson({
|
|
1105
|
-
method: "POST",
|
|
1106
|
-
url: `${options.apiUrl}/build/${buildId}/api/token`,
|
|
1107
|
-
authToken: auth.token,
|
|
1108
|
-
body: { scopes },
|
|
1109
|
-
timeoutMs: options.timeoutMs,
|
|
1110
|
-
});
|
|
1111
|
-
console.error(
|
|
1112
|
-
`token minted in ${Date.now() - tokenStartedAt}ms (scopes: ${
|
|
1113
|
-
(tokenResult.scopes || []).join(", ") || "default"
|
|
1114
|
-
})`,
|
|
1115
|
-
);
|
|
1116
|
-
const body = endpoint.mapArgs ? endpoint.mapArgs(args) : args;
|
|
1117
|
-
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1118
|
-
lastResult = await executeSdkHttpCall({
|
|
1119
|
-
options,
|
|
1120
|
-
method: "POST",
|
|
1121
|
-
url: `${options.apiUrl}/build/${buildId}/${endpoint.path}`,
|
|
1122
|
-
authToken: auth.token,
|
|
1123
|
-
body,
|
|
1124
|
-
buildApiToken: tokenResult.token,
|
|
1125
|
-
});
|
|
1126
|
-
attempts.push(lastResult);
|
|
1127
|
-
printSdkAttemptLine(attempts.length, lastResult);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
if (options.repeat > 1) {
|
|
1132
|
-
const timings = attempts.map((entry) => entry.ms);
|
|
1133
|
-
const total = timings.reduce((sum, ms) => sum + ms, 0);
|
|
1134
|
-
console.error(
|
|
1135
|
-
`latency over ${timings.length} calls: min ${Math.min(...timings)}ms · ` +
|
|
1136
|
-
`avg ${Math.round(total / timings.length)}ms · max ${Math.max(...timings)}ms`,
|
|
1137
|
-
);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// The printed JSON is the raw endpoint response. Some SDK wrappers reshape
|
|
1141
|
-
// it, so warn precisely where the SDK return shape differs.
|
|
1142
|
-
if (endpoint && endpoint.sdkReshape) {
|
|
1143
|
-
console.error(
|
|
1144
|
-
`note: raw endpoint response below — ${methodName}'s SDK wrapper ` +
|
|
1145
|
-
`reshapes it (${endpoint.sdkReshape}). See TWINKLE_BUILD_SDK.md.`,
|
|
1146
|
-
);
|
|
1147
|
-
}
|
|
1148
|
-
if (lastResult) {
|
|
1149
|
-
console.log(JSON.stringify(lastResult.data ?? lastResult.text, null, 2));
|
|
1150
|
-
}
|
|
1151
|
-
if (lastResult && !lastResult.ok) {
|
|
1152
|
-
process.exitCode = 1;
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
function printSdkAttemptLine(attemptNumber, result) {
|
|
1157
|
-
console.error(
|
|
1158
|
-
`#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
|
|
1159
|
-
`${result.bytes.toLocaleString()} bytes · ${result.ms}ms`,
|
|
1160
|
-
);
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async function executeSdkHttpCall({
|
|
1164
|
-
options,
|
|
1165
|
-
method,
|
|
1166
|
-
url,
|
|
1167
|
-
authToken,
|
|
1168
|
-
body,
|
|
1169
|
-
buildApiToken,
|
|
1170
|
-
}) {
|
|
1171
|
-
const startedAt = Date.now();
|
|
1172
|
-
const response = await request({
|
|
1173
|
-
method,
|
|
1174
|
-
url,
|
|
1175
|
-
authToken,
|
|
1176
|
-
body,
|
|
1177
|
-
timeoutMs: options.timeoutMs,
|
|
1178
|
-
headers: buildApiToken ? { "x-build-api-token": buildApiToken } : {},
|
|
1179
|
-
});
|
|
1180
|
-
const text = await response.text();
|
|
1181
|
-
return {
|
|
1182
|
-
ok: response.ok,
|
|
1183
|
-
status: response.status,
|
|
1184
|
-
ms: Date.now() - startedAt,
|
|
1185
|
-
bytes: Buffer.byteLength(text),
|
|
1186
|
-
data: parseJson(text),
|
|
1187
|
-
text,
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
async function resolveSdkBuildId(options) {
|
|
1192
|
-
if (options.buildIdFlag) {
|
|
1193
|
-
const buildId = resolveBuildId(options.buildIdFlag);
|
|
1194
|
-
if (buildId > 0) return buildId;
|
|
1195
|
-
throw new Error(`Invalid --build value: ${options.buildIdFlag}`);
|
|
1196
|
-
}
|
|
1197
|
-
const localProject = await findLocalProjectMetadata(
|
|
1198
|
-
options.dir ? path.resolve(options.dir) : process.cwd(),
|
|
1199
|
-
);
|
|
1200
|
-
const buildId = Number(localProject?.metadata?.buildId || 0);
|
|
1201
|
-
if (buildId > 0) return buildId;
|
|
1202
|
-
throw new Error(
|
|
1203
|
-
"No build workspace found. Run from a pulled workspace, pass --dir, or pass --build <id>.",
|
|
1204
|
-
);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
async function listBuilds({ options, auth }) {
|
|
1208
|
-
const url = new URL(`${options.apiUrl}/cli/builds`);
|
|
1209
|
-
url.searchParams.set("limit", String(options.limit));
|
|
1210
|
-
const result = await requestJson({
|
|
1211
|
-
url: url.toString(),
|
|
1212
|
-
authToken: auth.token,
|
|
1213
|
-
timeoutMs: options.timeoutMs,
|
|
1214
|
-
});
|
|
1215
|
-
return Array.isArray(result.builds) ? result.builds : [];
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
async function listOpenSourceBuilds({ options, auth }) {
|
|
1219
|
-
const url = new URL(`${options.apiUrl}/cli/open-source-builds`);
|
|
1220
|
-
url.searchParams.set("limit", String(options.limit));
|
|
1221
|
-
url.searchParams.set("sort", options.sort);
|
|
1222
|
-
if (options.searchQuery) {
|
|
1223
|
-
url.searchParams.set("search", options.searchQuery);
|
|
1224
|
-
}
|
|
1225
|
-
const result = await requestJson({
|
|
1226
|
-
url: url.toString(),
|
|
1227
|
-
authToken: auth.token,
|
|
1228
|
-
timeoutMs: options.timeoutMs,
|
|
1229
|
-
});
|
|
1230
|
-
return Array.isArray(result.builds) ? result.builds : [];
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
async function loadBuildMetadata({ options, auth, buildId }) {
|
|
1234
|
-
const result = await loadBuildFiles({
|
|
1235
|
-
options,
|
|
1236
|
-
auth,
|
|
1237
|
-
buildId,
|
|
1238
|
-
includeContent: false,
|
|
1239
|
-
});
|
|
1240
|
-
if (!result.build) {
|
|
1241
|
-
throw new Error(`Build ${buildId} could not be loaded.`);
|
|
1242
|
-
}
|
|
1243
|
-
return result.build;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
async function loadTargetBuildMetadata({ options, auth }) {
|
|
1247
|
-
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
1248
|
-
return await loadBuildMetadata({ options, auth, buildId });
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
async function loadOpenSourceBuildFiles({
|
|
1252
|
-
options,
|
|
1253
|
-
auth,
|
|
1254
|
-
buildId,
|
|
1255
|
-
includeContent,
|
|
1256
|
-
}) {
|
|
1257
|
-
const url = new URL(`${options.apiUrl}/cli/build/${buildId}/open-source-files`);
|
|
1258
|
-
if (!includeContent) url.searchParams.set("includeContent", "0");
|
|
1259
|
-
return await requestJson({
|
|
1260
|
-
url: url.toString(),
|
|
1261
|
-
authToken: auth.token,
|
|
1262
|
-
timeoutMs: options.timeoutMs,
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
async function loadBuildFiles({ options, auth, buildId, includeContent }) {
|
|
1267
|
-
const url = new URL(`${options.apiUrl}/cli/build/${buildId}/files`);
|
|
1268
|
-
if (!includeContent) url.searchParams.set("includeContent", "0");
|
|
1269
|
-
return await requestJson({
|
|
1270
|
-
url: url.toString(),
|
|
1271
|
-
authToken: auth.token,
|
|
1272
|
-
timeoutMs: options.timeoutMs,
|
|
1273
|
-
});
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
async function resolveBranchBuild({
|
|
1277
|
-
options,
|
|
1278
|
-
auth,
|
|
1279
|
-
rootBuildId,
|
|
1280
|
-
branchNumber,
|
|
1281
|
-
}) {
|
|
1282
|
-
const result = await requestJson({
|
|
1283
|
-
url: `${options.apiUrl}/cli/build/${rootBuildId}/branches/${branchNumber}`,
|
|
1284
|
-
authToken: auth.token,
|
|
1285
|
-
timeoutMs: options.timeoutMs,
|
|
1286
|
-
});
|
|
1287
|
-
const branchBuildId = Number(result.build?.id || 0);
|
|
1288
|
-
if (!branchBuildId) {
|
|
1289
|
-
throw new Error(`Branch ${branchNumber} for Build ${rootBuildId} could not be loaded.`);
|
|
1290
|
-
}
|
|
1291
|
-
return result.build;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
async function forkBuild({ options, auth, buildId }) {
|
|
1295
|
-
return await requestJson({
|
|
1296
|
-
method: "POST",
|
|
1297
|
-
url: `${options.apiUrl}/build/${buildId}/fork`,
|
|
1298
|
-
authToken: auth.token,
|
|
1299
|
-
body: {},
|
|
1300
|
-
timeoutMs: options.timeoutMs,
|
|
1301
|
-
});
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
async function createBuild({ options, auth, title, description }) {
|
|
1305
|
-
return await requestJson({
|
|
1306
|
-
method: "POST",
|
|
1307
|
-
url: `${options.apiUrl}/build/create`,
|
|
1308
|
-
authToken: auth.token,
|
|
1309
|
-
body: {
|
|
1310
|
-
title,
|
|
1311
|
-
...(description ? { description } : {}),
|
|
1312
|
-
},
|
|
1313
|
-
timeoutMs: options.timeoutMs,
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
async function saveProjectFiles({ options, auth, buildId, files, summary }) {
|
|
1318
|
-
return await requestJson({
|
|
1319
|
-
method: "PUT",
|
|
1320
|
-
url: `${options.apiUrl}/build/${buildId}/project-files`,
|
|
1321
|
-
authToken: auth.token,
|
|
1322
|
-
body: {
|
|
1323
|
-
files,
|
|
1324
|
-
createVersion: true,
|
|
1325
|
-
summary,
|
|
1326
|
-
},
|
|
1327
|
-
timeoutMs: options.timeoutMs,
|
|
1328
|
-
});
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
async function loadContributionDiff({
|
|
1332
|
-
options,
|
|
1333
|
-
auth,
|
|
1334
|
-
rootBuildId,
|
|
1335
|
-
contributionBuildId,
|
|
1336
|
-
}) {
|
|
1337
|
-
return await requestJson({
|
|
1338
|
-
url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}`,
|
|
1339
|
-
authToken: auth.token,
|
|
1340
|
-
timeoutMs: options.timeoutMs,
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
async function mergeContributionIntoMain({
|
|
1345
|
-
options,
|
|
1346
|
-
auth,
|
|
1347
|
-
rootBuildId,
|
|
1348
|
-
contributionBuildId,
|
|
1349
|
-
}) {
|
|
1350
|
-
return await requestJson({
|
|
1351
|
-
method: "POST",
|
|
1352
|
-
url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/merge`,
|
|
1353
|
-
authToken: auth.token,
|
|
1354
|
-
body: {},
|
|
1355
|
-
timeoutMs: options.timeoutMs,
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
async function replaceMainWithContribution({
|
|
1360
|
-
options,
|
|
1361
|
-
auth,
|
|
1362
|
-
rootBuildId,
|
|
1363
|
-
contributionBuildId,
|
|
1364
|
-
}) {
|
|
1365
|
-
return await requestJson({
|
|
1366
|
-
method: "POST",
|
|
1367
|
-
url: `${options.apiUrl}/build/${rootBuildId}/contributions/${contributionBuildId}/replace-main`,
|
|
1368
|
-
authToken: auth.token,
|
|
1369
|
-
body: {},
|
|
1370
|
-
timeoutMs: options.timeoutMs,
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
async function resolveBuildForSave({ options, auth, buildId, localProject }) {
|
|
1375
|
-
const localBuild = localProject?.metadata?.build;
|
|
1376
|
-
const localBuildId =
|
|
1377
|
-
Number(localBuild?.id || 0) ||
|
|
1378
|
-
Number(localProject?.metadata?.buildId || 0);
|
|
1379
|
-
const build =
|
|
1380
|
-
localBuild && localBuildId === Number(buildId)
|
|
1381
|
-
? { ...localBuild, id: buildId }
|
|
1382
|
-
: await loadBuildMetadata({ options, auth, buildId });
|
|
1383
|
-
return await resolveEditableWorkspaceBuild({
|
|
1384
|
-
options,
|
|
1385
|
-
auth,
|
|
1386
|
-
build,
|
|
1387
|
-
skipWriteScopeCheck: true,
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
async function resolveEditableWorkspaceBuild({
|
|
1392
|
-
options,
|
|
1393
|
-
auth,
|
|
1394
|
-
build,
|
|
1395
|
-
skipWriteScopeCheck = false,
|
|
1396
|
-
}) {
|
|
1397
|
-
if (!shouldUseContributionBranch(build)) return build;
|
|
1398
|
-
|
|
1399
|
-
if (!skipWriteScopeCheck) {
|
|
1400
|
-
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
1401
|
-
}
|
|
1402
|
-
const branch = await ensureDefaultContributionBranch({
|
|
1403
|
-
options,
|
|
1404
|
-
auth,
|
|
1405
|
-
build,
|
|
1406
|
-
});
|
|
1407
|
-
console.log(
|
|
1408
|
-
`Using your branch ${formatBuildTitle(branch)} for team project ${formatBuildTitle(build)}.`,
|
|
1409
|
-
);
|
|
1410
|
-
return branch;
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
function shouldUseContributionBranch(build) {
|
|
1414
|
-
return (
|
|
1415
|
-
build?.role === "collaborator" &&
|
|
1416
|
-
build.canWrite !== true &&
|
|
1417
|
-
!isContributionBranch(build)
|
|
1418
|
-
);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
async function ensureDefaultContributionBranch({ options, auth, build }) {
|
|
1422
|
-
const rootBuildId =
|
|
1423
|
-
Number(build?.contributionRootBuildId || 0) || Number(build?.id || 0);
|
|
1424
|
-
if (!rootBuildId) {
|
|
1425
|
-
throw new Error("Could not resolve the team project for this branch.");
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const result = await requestJson({
|
|
1429
|
-
method: "POST",
|
|
1430
|
-
url: `${options.apiUrl}/build/${rootBuildId}/contributions/default-branch`,
|
|
1431
|
-
authToken: auth.token,
|
|
1432
|
-
body: {},
|
|
1433
|
-
timeoutMs: options.timeoutMs,
|
|
1434
|
-
});
|
|
1435
|
-
const branchId = Number(result.build?.id || 0);
|
|
1436
|
-
if (!branchId) {
|
|
1437
|
-
throw new Error("Twinkle did not return a contribution branch.");
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
return await loadBuildMetadata({
|
|
1441
|
-
options,
|
|
1442
|
-
auth,
|
|
1443
|
-
buildId: branchId,
|
|
1444
|
-
}).catch(() =>
|
|
1445
|
-
normalizeContributionBranchBuild({
|
|
1446
|
-
branch: result.build,
|
|
1447
|
-
sourceBuild: build,
|
|
1448
|
-
}),
|
|
1449
|
-
);
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
function normalizeContributionBranchBuild({ branch, sourceBuild }) {
|
|
1453
|
-
const branchId = Number(branch?.id || 0);
|
|
1454
|
-
return {
|
|
1455
|
-
...branch,
|
|
1456
|
-
id: branchId,
|
|
1457
|
-
title: branch?.title || `Branch ${branchId}`,
|
|
1458
|
-
role: "owner",
|
|
1459
|
-
ownerUsername:
|
|
1460
|
-
branch?.ownerUsername ||
|
|
1461
|
-
branch?.username ||
|
|
1462
|
-
sourceBuild?.ownerUsername ||
|
|
1463
|
-
null,
|
|
1464
|
-
canWrite: true,
|
|
1465
|
-
canPublish: false,
|
|
1466
|
-
contributionStatus: branch?.contributionStatus || "draft",
|
|
1467
|
-
contributionRootBuildId:
|
|
1468
|
-
Number(branch?.contributionRootBuildId || sourceBuild?.id || 0) || null,
|
|
1469
|
-
contributionContributorId:
|
|
1470
|
-
Number(branch?.contributionContributorId || branch?.userId || 0) || null,
|
|
1471
|
-
contributionBranchNumber:
|
|
1472
|
-
Number(branch?.contributionBranchNumber || 0) || null,
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
async function assertAuthScope({ options, auth, scope }) {
|
|
1477
|
-
const session = await requestJson({
|
|
1478
|
-
url: `${options.apiUrl}/cli/session`,
|
|
1479
|
-
authToken: auth.token,
|
|
1480
|
-
timeoutMs: options.timeoutMs,
|
|
1481
|
-
});
|
|
1482
|
-
const scopes = Array.isArray(session.scopes) ? session.scopes : [];
|
|
1483
|
-
if (!scopes.includes(scope)) {
|
|
1484
|
-
throw new Error(
|
|
1485
|
-
`Saved login is missing ${scope}. Run \`lumine login\` again to grant it.`,
|
|
1486
|
-
);
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
async function chooseProject({ builds }) {
|
|
1491
|
-
if (!builds.length) {
|
|
1492
|
-
throw new Error("No owned or team Twinkle builds were found.");
|
|
1493
|
-
}
|
|
1494
|
-
if (builds.length === 1) {
|
|
1495
|
-
console.log(`Selected ${formatBuildTitle(builds[0])}.`);
|
|
1496
|
-
return builds[0];
|
|
1497
|
-
}
|
|
1498
|
-
if (!input.isTTY || !output.isTTY) {
|
|
1499
|
-
throw new Error(
|
|
1500
|
-
"Choose a project by running `lumine select <twinkle-build-url>`.",
|
|
1501
|
-
);
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
printBuildList(builds);
|
|
1505
|
-
const rl = readline.createInterface({ input, output });
|
|
1506
|
-
try {
|
|
1507
|
-
while (true) {
|
|
1508
|
-
const answer = await rl.question("Choose a project number: ");
|
|
1509
|
-
const index = Number(answer.trim());
|
|
1510
|
-
if (Number.isInteger(index) && index >= 1 && index <= builds.length) {
|
|
1511
|
-
return builds[index - 1];
|
|
1512
|
-
}
|
|
1513
|
-
console.log(`Enter a number from 1 to ${builds.length}.`);
|
|
1514
|
-
}
|
|
1515
|
-
} finally {
|
|
1516
|
-
rl.close();
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
async function pullBuildFiles({ options, auth, buildId }) {
|
|
1521
|
-
const result = await loadBuildFiles({
|
|
1522
|
-
options,
|
|
1523
|
-
auth,
|
|
1524
|
-
buildId,
|
|
1525
|
-
includeContent: true,
|
|
1526
|
-
});
|
|
1527
|
-
const build = result.build || { id: buildId, title: `Build ${buildId}` };
|
|
1528
|
-
const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
|
|
1529
|
-
const dir = path.resolve(options.dir || defaultWorkspaceDir(build));
|
|
1530
|
-
await writeProjectFiles({ dir, files });
|
|
1531
|
-
await writeAgentInstructions({ dir });
|
|
1532
|
-
await writeSdkReference({ dir });
|
|
1533
|
-
await writeProjectMetadata({
|
|
1534
|
-
dir,
|
|
1535
|
-
options,
|
|
1536
|
-
build,
|
|
1537
|
-
manifest: result.projectManifest || null,
|
|
1538
|
-
pulledAt: new Date().toISOString(),
|
|
1539
|
-
});
|
|
1540
|
-
return {
|
|
1541
|
-
build,
|
|
1542
|
-
dir,
|
|
1543
|
-
fileCount: files.length,
|
|
1544
|
-
manifest: result.projectManifest || null,
|
|
1545
|
-
};
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
async function pullReferenceFiles({ options, auth, buildId }) {
|
|
1549
|
-
const result = await loadOpenSourceBuildFiles({
|
|
1550
|
-
options,
|
|
1551
|
-
auth,
|
|
1552
|
-
buildId,
|
|
1553
|
-
includeContent: true,
|
|
1554
|
-
});
|
|
1555
|
-
const build = result.build || { id: buildId, title: `Build ${buildId}` };
|
|
1556
|
-
const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
|
|
1557
|
-
const dir = path.resolve(options.dir || defaultReferenceDir(build));
|
|
1558
|
-
await writeProjectFiles({ dir, files });
|
|
1559
|
-
await writeReferenceInstructions({ dir });
|
|
1560
|
-
await writeSdkReference({ dir });
|
|
1561
|
-
await writeReferenceMetadata({
|
|
1562
|
-
dir,
|
|
1563
|
-
options,
|
|
1564
|
-
build,
|
|
1565
|
-
manifest: result.projectManifest || null,
|
|
1566
|
-
reference: result.reference || {
|
|
1567
|
-
readOnly: true,
|
|
1568
|
-
forkable: true,
|
|
1569
|
-
sourceBuildId: Number(build.id || buildId),
|
|
1570
|
-
},
|
|
1571
|
-
pulledAt: new Date().toISOString(),
|
|
1572
|
-
});
|
|
1573
|
-
return {
|
|
1574
|
-
build,
|
|
1575
|
-
dir,
|
|
1576
|
-
fileCount: files.length,
|
|
1577
|
-
manifest: result.projectManifest || null,
|
|
1578
|
-
reference: result.reference || null,
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
async function writeAgentInstructions({ dir }) {
|
|
1583
|
-
for (const fileName of AGENT_INSTRUCTION_FILES) {
|
|
1584
|
-
const filePath = path.join(dir, fileName);
|
|
1585
|
-
try {
|
|
1586
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
1587
|
-
if (!existing.includes(LUMINE_AGENT_INSTRUCTIONS_MARKER)) {
|
|
1588
|
-
continue;
|
|
1589
|
-
}
|
|
1590
|
-
} catch (error) {
|
|
1591
|
-
if (error.code !== "ENOENT") throw error;
|
|
1592
|
-
}
|
|
1593
|
-
await fs.writeFile(filePath, LUMINE_AGENT_INSTRUCTIONS, "utf8");
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
async function writeReferenceInstructions({ dir }) {
|
|
1598
|
-
for (const fileName of AGENT_INSTRUCTION_FILES) {
|
|
1599
|
-
const filePath = path.join(dir, fileName);
|
|
1600
|
-
try {
|
|
1601
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
1602
|
-
if (!existing.includes(LUMINE_REFERENCE_INSTRUCTIONS_MARKER)) {
|
|
1603
|
-
continue;
|
|
1604
|
-
}
|
|
1605
|
-
} catch (error) {
|
|
1606
|
-
if (error.code !== "ENOENT") throw error;
|
|
1607
|
-
}
|
|
1608
|
-
await fs.writeFile(filePath, LUMINE_REFERENCE_INSTRUCTIONS, "utf8");
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
async function writeSdkReference({ dir }) {
|
|
1613
|
-
const filePath = path.join(dir, SDK_REFERENCE_FILE);
|
|
1614
|
-
try {
|
|
1615
|
-
const existing = await fs.readFile(filePath, "utf8");
|
|
1616
|
-
if (!existing.includes(LUMINE_SDK_REFERENCE_MARKER)) {
|
|
1617
|
-
return;
|
|
1618
|
-
}
|
|
1619
|
-
} catch (error) {
|
|
1620
|
-
if (error.code !== "ENOENT") throw error;
|
|
1621
|
-
}
|
|
1622
|
-
await fs.writeFile(filePath, await loadSdkReference(), "utf8");
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
async function loadSdkReference() {
|
|
1626
|
-
try {
|
|
1627
|
-
const rawReference = await fs.readFile(BUNDLED_SDK_REFERENCE_URL, "utf8");
|
|
1628
|
-
const reference = rawReference.trim();
|
|
1629
|
-
if (reference) return `${LUMINE_SDK_REFERENCE_MARKER}\n${reference}\n`;
|
|
1630
|
-
} catch {
|
|
1631
|
-
// Fall through to the compact reference so pulled workspaces still guide agents.
|
|
1632
|
-
}
|
|
1633
|
-
return SDK_REFERENCE_FALLBACK;
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
async function collectProjectFiles(dir) {
|
|
1637
|
-
const root = path.resolve(dir);
|
|
1638
|
-
const files = [];
|
|
1639
|
-
await collectProjectFilesFromDir({ root, dir: root, files });
|
|
1640
|
-
if (!files.some((file) => isIndexHtmlPath(file.path))) {
|
|
1641
|
-
throw new Error("Project files must include /index.html or /index.htm.");
|
|
1642
|
-
}
|
|
1643
|
-
assertProjectFilesAvoidNativeFormSubmission(files);
|
|
1644
|
-
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
async function collectProjectFilesFromDir({ root, dir, files }) {
|
|
1648
|
-
let entries = [];
|
|
1649
|
-
try {
|
|
1650
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1651
|
-
} catch (error) {
|
|
1652
|
-
if (error.code === "ENOENT") {
|
|
1653
|
-
throw new Error(`Project directory does not exist: ${dir}`);
|
|
1654
|
-
}
|
|
1655
|
-
throw error;
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
for (const entry of entries) {
|
|
1659
|
-
if (entry.isDirectory() && EXCLUDED_UPLOAD_DIRS.has(entry.name)) continue;
|
|
1660
|
-
if (entry.isFile() && EXCLUDED_UPLOAD_FILES.has(entry.name)) continue;
|
|
1661
|
-
const fullPath = path.join(dir, entry.name);
|
|
1662
|
-
if (entry.isDirectory()) {
|
|
1663
|
-
await collectProjectFilesFromDir({ root, dir: fullPath, files });
|
|
1664
|
-
continue;
|
|
1665
|
-
}
|
|
1666
|
-
if (!entry.isFile()) continue;
|
|
1667
|
-
const buffer = await fs.readFile(fullPath);
|
|
1668
|
-
if (buffer.includes(0)) {
|
|
1669
|
-
const relativePath = localFilePathToProjectPath({
|
|
1670
|
-
root,
|
|
1671
|
-
filePath: fullPath,
|
|
1672
|
-
});
|
|
1673
|
-
throw new Error(
|
|
1674
|
-
`Cannot save binary file ${relativePath}. Twinkle project files must be text files.`,
|
|
1675
|
-
);
|
|
1676
|
-
}
|
|
1677
|
-
files.push({
|
|
1678
|
-
path: localFilePathToProjectPath({ root, filePath: fullPath }),
|
|
1679
|
-
content: buffer.toString("utf8"),
|
|
1680
|
-
});
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
function localFilePathToProjectPath({ root, filePath }) {
|
|
1685
|
-
const relative = path.relative(root, filePath).replace(/\\/g, "/");
|
|
1686
|
-
if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
1687
|
-
throw new Error(`Unsafe local project file path: ${filePath}`);
|
|
1688
|
-
}
|
|
1689
|
-
return `/${relative}`;
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
function isIndexHtmlPath(projectPath) {
|
|
1693
|
-
const normalized = String(projectPath || "")
|
|
1694
|
-
.trim()
|
|
1695
|
-
.toLowerCase();
|
|
1696
|
-
return normalized === "/index.html" || normalized === "/index.htm";
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
function isNativeFormMarkupProjectPath(projectPath) {
|
|
1700
|
-
return /\.(?:html?|jsx?|tsx?|mjs|cjs)$/i.test(
|
|
1701
|
-
String(projectPath || "").trim(),
|
|
1702
|
-
);
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
function getLineColumnForSourceIndex(content, sourceIndex) {
|
|
1706
|
-
const before = String(content || "").slice(0, Math.max(0, sourceIndex));
|
|
1707
|
-
const lines = before.split("\n");
|
|
1708
|
-
return {
|
|
1709
|
-
line: lines.length,
|
|
1710
|
-
column: lines[lines.length - 1].length + 1,
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
function formatProjectFileLocation({ filePath, line, column }) {
|
|
1715
|
-
return `${filePath}:${line}:${column}`;
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
function assertProjectFilesAvoidNativeFormSubmission(files) {
|
|
1719
|
-
for (const file of files) {
|
|
1720
|
-
const content = String(file.content || "");
|
|
1721
|
-
if (isNativeFormMarkupProjectPath(file.path)) {
|
|
1722
|
-
const formMatch = content.match(/<form\b[^>]*>/i);
|
|
1723
|
-
if (formMatch) {
|
|
1724
|
-
const { line, column } = getLineColumnForSourceIndex(
|
|
1725
|
-
content,
|
|
1726
|
-
formMatch.index || 0,
|
|
1727
|
-
);
|
|
1728
|
-
throw new Error(
|
|
1729
|
-
`Cannot save native form markup at ${formatProjectFileLocation({
|
|
1730
|
-
filePath: file.path,
|
|
1731
|
-
line,
|
|
1732
|
-
column,
|
|
1733
|
-
})}. Twinkle Build apps run in sandboxed iframes without form-submit permission. Replace <form> with JavaScript-handled inputs and buttons.`,
|
|
1734
|
-
);
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
const requestSubmitMatch = content.match(/\brequestSubmit\s*\(/);
|
|
1739
|
-
if (requestSubmitMatch) {
|
|
1740
|
-
const { line, column } = getLineColumnForSourceIndex(
|
|
1741
|
-
content,
|
|
1742
|
-
requestSubmitMatch.index || 0,
|
|
1743
|
-
);
|
|
1744
|
-
throw new Error(
|
|
1745
|
-
`Cannot save native form submission at ${formatProjectFileLocation({
|
|
1746
|
-
filePath: file.path,
|
|
1747
|
-
line,
|
|
1748
|
-
column,
|
|
1749
|
-
})}. requestSubmit() is blocked by the Build iframe sandbox; use a JavaScript click handler instead.`,
|
|
1750
|
-
);
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
async function writeProjectFiles({ dir, files }) {
|
|
1756
|
-
await fs.mkdir(dir, { recursive: true });
|
|
1757
|
-
for (const file of files) {
|
|
1758
|
-
const filePath = resolveLocalProjectFilePath({
|
|
1759
|
-
rootDir: dir,
|
|
1760
|
-
projectPath: file.path,
|
|
1761
|
-
});
|
|
1762
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1763
|
-
await fs.writeFile(filePath, String(file.content || ""), "utf8");
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
async function writeProjectMetadata({
|
|
1768
|
-
dir,
|
|
1769
|
-
options,
|
|
1770
|
-
build,
|
|
1771
|
-
manifest,
|
|
1772
|
-
pulledAt,
|
|
1773
|
-
lastSavedAt,
|
|
1774
|
-
}) {
|
|
1775
|
-
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
1776
|
-
await fs.mkdir(metadataDir, { recursive: true });
|
|
1777
|
-
await fs.writeFile(
|
|
1778
|
-
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
1779
|
-
JSON.stringify(
|
|
1780
|
-
{
|
|
1781
|
-
schemaVersion: 1,
|
|
1782
|
-
buildId: Number(build?.id || 0) || null,
|
|
1783
|
-
build: build
|
|
1784
|
-
? {
|
|
1785
|
-
id: Number(build.id || 0) || null,
|
|
1786
|
-
title: build.title || "",
|
|
1787
|
-
role: build.role || "",
|
|
1788
|
-
ownerUsername: build.ownerUsername || null,
|
|
1789
|
-
contributionStatus: build.contributionStatus || "none",
|
|
1790
|
-
contributionRootBuildId:
|
|
1791
|
-
Number(build.contributionRootBuildId || 0) || null,
|
|
1792
|
-
contributionContributorId:
|
|
1793
|
-
Number(build.contributionContributorId || 0) || null,
|
|
1794
|
-
contributionBranchNumber:
|
|
1795
|
-
Number(build.contributionBranchNumber || 0) || null,
|
|
1796
|
-
canWrite:
|
|
1797
|
-
build.canWrite !== undefined
|
|
1798
|
-
? Boolean(build.canWrite)
|
|
1799
|
-
: build.role === "owner",
|
|
1800
|
-
canPublish:
|
|
1801
|
-
build.canPublish !== undefined
|
|
1802
|
-
? Boolean(build.canPublish)
|
|
1803
|
-
: build.role === "owner",
|
|
1804
|
-
}
|
|
1805
|
-
: null,
|
|
1806
|
-
apiUrl: options.apiUrl,
|
|
1807
|
-
siteUrl: options.siteUrl,
|
|
1808
|
-
lumineCli: serializeLumineCliMetadata(options),
|
|
1809
|
-
manifest,
|
|
1810
|
-
pulledAt,
|
|
1811
|
-
lastSavedAt,
|
|
1812
|
-
},
|
|
1813
|
-
null,
|
|
1814
|
-
2,
|
|
1815
|
-
),
|
|
1816
|
-
"utf8",
|
|
1817
|
-
);
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
async function writeReferenceMetadata({
|
|
1821
|
-
dir,
|
|
1822
|
-
options,
|
|
1823
|
-
build,
|
|
1824
|
-
manifest,
|
|
1825
|
-
reference,
|
|
1826
|
-
pulledAt,
|
|
1827
|
-
}) {
|
|
1828
|
-
const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
|
|
1829
|
-
await fs.mkdir(metadataDir, { recursive: true });
|
|
1830
|
-
const sourceBuildId =
|
|
1831
|
-
Number(reference?.sourceBuildId || 0) || Number(build?.id || 0) || null;
|
|
1832
|
-
await fs.writeFile(
|
|
1833
|
-
path.join(metadataDir, PROJECT_METADATA_FILE),
|
|
1834
|
-
JSON.stringify(
|
|
1835
|
-
{
|
|
1836
|
-
schemaVersion: 1,
|
|
1837
|
-
buildId: sourceBuildId,
|
|
1838
|
-
readOnly: true,
|
|
1839
|
-
reference: {
|
|
1840
|
-
readOnly: true,
|
|
1841
|
-
forkable: true,
|
|
1842
|
-
sourceBuildId,
|
|
1843
|
-
sourceAppUrl: sourceBuildId ? `${options.siteUrl}/app/${sourceBuildId}` : null,
|
|
1844
|
-
},
|
|
1845
|
-
build: {
|
|
1846
|
-
id: sourceBuildId,
|
|
1847
|
-
title: build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
|
|
1848
|
-
role: "reference",
|
|
1849
|
-
ownerUsername: build?.ownerUsername || null,
|
|
1850
|
-
collaborationMode: build?.collaborationMode || "open_source",
|
|
1851
|
-
canWrite: false,
|
|
1852
|
-
canPublish: false,
|
|
1853
|
-
},
|
|
1854
|
-
apiUrl: options.apiUrl,
|
|
1855
|
-
siteUrl: options.siteUrl,
|
|
1856
|
-
lumineCli: serializeLumineCliMetadata(options),
|
|
1857
|
-
manifest,
|
|
1858
|
-
pulledAt,
|
|
1859
|
-
},
|
|
1860
|
-
null,
|
|
1861
|
-
2,
|
|
1862
|
-
),
|
|
1863
|
-
"utf8",
|
|
1864
|
-
);
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
async function findLocalProjectMetadata(startDir) {
|
|
1868
|
-
let current = path.resolve(startDir || process.cwd());
|
|
1869
|
-
while (true) {
|
|
1870
|
-
const metadataPath = path.join(
|
|
1871
|
-
current,
|
|
1872
|
-
PROJECT_METADATA_DIR,
|
|
1873
|
-
PROJECT_METADATA_FILE,
|
|
1874
|
-
);
|
|
1875
|
-
try {
|
|
1876
|
-
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
1877
|
-
return { rootDir: current, metadata, metadataPath };
|
|
1878
|
-
} catch (error) {
|
|
1879
|
-
if (error.code !== "ENOENT") throw error;
|
|
1880
|
-
}
|
|
1881
|
-
const parent = path.dirname(current);
|
|
1882
|
-
if (parent === current) return null;
|
|
1883
|
-
current = parent;
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
function resolveProjectDirForSave({ options, localProject }) {
|
|
1888
|
-
if (options.dir) return path.resolve(options.dir);
|
|
1889
|
-
if (localProject?.rootDir) return localProject.rootDir;
|
|
1890
|
-
return process.cwd();
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
function assertLocalProjectCanBeSaved(localProject) {
|
|
1894
|
-
const metadata = localProject?.metadata;
|
|
1895
|
-
if (!metadata) return;
|
|
1896
|
-
if (isReadOnlyReferenceMetadata(metadata)) {
|
|
1897
|
-
const sourceBuildId =
|
|
1898
|
-
Number(metadata.reference?.sourceBuildId || 0) ||
|
|
1899
|
-
Number(metadata.buildId || 0) ||
|
|
1900
|
-
Number(metadata.build?.id || 0) ||
|
|
1901
|
-
0;
|
|
1902
|
-
throw new Error(
|
|
1903
|
-
`This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace.`,
|
|
1904
|
-
);
|
|
1905
|
-
}
|
|
1906
|
-
if (metadata.build?.canWrite === false) {
|
|
1907
|
-
throw new Error(
|
|
1908
|
-
"This Lumine checkout is read-only for the current CLI login. Pull or diff it for review; project-owner branch edits must go through merge or replace-main.",
|
|
1909
|
-
);
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
function isReadOnlyReferenceMetadata(metadata) {
|
|
1914
|
-
return (
|
|
1915
|
-
metadata?.readOnly === true ||
|
|
1916
|
-
metadata?.reference?.readOnly === true ||
|
|
1917
|
-
metadata?.build?.role === "reference"
|
|
1918
|
-
);
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
function resolveLocalProjectFilePath({ rootDir, projectPath }) {
|
|
1922
|
-
const relativePath = projectPathToRelativePath(projectPath);
|
|
1923
|
-
const root = path.resolve(rootDir);
|
|
1924
|
-
const filePath = path.resolve(root, relativePath);
|
|
1925
|
-
if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)) {
|
|
1926
|
-
return filePath;
|
|
1927
|
-
}
|
|
1928
|
-
throw new Error(`Unsafe project file path: ${projectPath}`);
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
function projectPathToRelativePath(projectPath) {
|
|
1932
|
-
const segments = String(projectPath || "")
|
|
1933
|
-
.replace(/\\/g, "/")
|
|
1934
|
-
.split("/")
|
|
1935
|
-
.filter(Boolean);
|
|
1936
|
-
if (
|
|
1937
|
-
segments.length === 0 ||
|
|
1938
|
-
segments.some((segment) => segment === "." || segment === "..")
|
|
1939
|
-
) {
|
|
1940
|
-
throw new Error(`Unsafe project file path: ${projectPath}`);
|
|
1941
|
-
}
|
|
1942
|
-
return path.join(...segments);
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
async function saveSelectedBuild({ options, auth, build }) {
|
|
1946
|
-
if (options.authToken || !build?.id) return;
|
|
1947
|
-
await writeAuthFile(options, {
|
|
1948
|
-
...auth,
|
|
1949
|
-
selectedBuildId: Number(build.id),
|
|
1950
|
-
selectedBuildTitle: build.title || `Build ${build.id}`,
|
|
1951
|
-
selectedBuildRole: build.role || "",
|
|
1952
|
-
selectedAt: new Date().toISOString(),
|
|
1953
|
-
});
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
function serializeLumineCliMetadata(options) {
|
|
1957
|
-
const info = options.lumineCli || {};
|
|
1958
|
-
return {
|
|
1959
|
-
packageName: info.packageName || "@stage5/lumine",
|
|
1960
|
-
version: info.version || null,
|
|
1961
|
-
latestVersion: info.latestVersion || null,
|
|
1962
|
-
updateAvailable: Boolean(info.updateAvailable),
|
|
1963
|
-
updateCommand: info.updateCommand || "npx @stage5/lumine@latest",
|
|
1964
|
-
checkedAt: info.checkedAt || null,
|
|
1965
|
-
checkFailed: Boolean(info.checkFailed),
|
|
1966
|
-
checkSkipped: Boolean(info.checkSkipped),
|
|
1967
|
-
};
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
function printBuildList(builds) {
|
|
1971
|
-
if (!builds.length) {
|
|
1972
|
-
console.log("No owned or team Twinkle builds found.");
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
console.log("Twinkle builds:");
|
|
1976
|
-
builds.forEach((build, index) => {
|
|
1977
|
-
console.log(`${index + 1}. ${formatBuildListItem(build)}`);
|
|
1978
|
-
});
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
function printOpenSourceBuildList(builds, options) {
|
|
1982
|
-
if (!builds.length) {
|
|
1983
|
-
const searchText = options.searchQuery
|
|
1984
|
-
? ` matching "${options.searchQuery}"`
|
|
1985
|
-
: "";
|
|
1986
|
-
console.log(`No public open-source Twinkle builds found${searchText}.`);
|
|
1987
|
-
return;
|
|
1988
|
-
}
|
|
1989
|
-
const searchText = options.searchQuery ? ` for "${options.searchQuery}"` : "";
|
|
1990
|
-
console.log(`Public open-source Twinkle builds${searchText}:`);
|
|
1991
|
-
builds.forEach((build, index) => {
|
|
1992
|
-
console.log(`${index + 1}. ${formatOpenSourceBuildListItem(build)}`);
|
|
1993
|
-
});
|
|
1994
|
-
console.log("Reference: lumine reference <build-id>");
|
|
1995
|
-
console.log("Fork: lumine fork <build-id>");
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
function formatBuildListItem(build) {
|
|
1999
|
-
const role =
|
|
2000
|
-
build.role === "owner"
|
|
2001
|
-
? "owned by you"
|
|
2002
|
-
: `team project${build.ownerUsername ? ` with ${build.ownerUsername}` : ""}`;
|
|
2003
|
-
const published = build.isPublic ? "public" : "private";
|
|
2004
|
-
return `${formatBuildTitle(build)} - ${role}, ${published}`;
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
function formatOpenSourceBuildListItem(build) {
|
|
2008
|
-
const owner = build.ownerUsername ? ` by ${build.ownerUsername}` : "";
|
|
2009
|
-
const stats = [
|
|
2010
|
-
`${Math.max(0, Number(build.forkCount || 0))} forks`,
|
|
2011
|
-
`${Math.max(0, Number(build.viewCount || 0))} views`,
|
|
2012
|
-
].join(", ");
|
|
2013
|
-
const appUrl = build.appUrl ? ` - ${build.appUrl}` : "";
|
|
2014
|
-
return `${formatBuildTitle(build)}${owner} - ${stats}${appUrl}`;
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
function formatBuildTitle(build) {
|
|
2018
|
-
return `${build.title || `Build ${build.id}`} (#${build.id})`;
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
function isContributionBranch(build) {
|
|
2022
|
-
return (
|
|
2023
|
-
String(build?.contributionStatus || "none") !== "none" ||
|
|
2024
|
-
Number(build?.contributionRootBuildId || 0) > 0 ||
|
|
2025
|
-
Number(build?.contributionBranchNumber || 0) > 0
|
|
2026
|
-
);
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
function resolveContributionActionBuildIds(build) {
|
|
2030
|
-
const contributionBuildId = Number(build?.id || 0);
|
|
2031
|
-
const rootBuildId = Number(build?.contributionRootBuildId || 0);
|
|
2032
|
-
if (!contributionBuildId || !rootBuildId || !isContributionBranch(build)) {
|
|
2033
|
-
throw new Error(
|
|
2034
|
-
"Pass a branch URL such as https://www.twin-kle.com/build/884/4, or run this from a pulled branch workspace.",
|
|
2035
|
-
);
|
|
2036
|
-
}
|
|
2037
|
-
return { rootBuildId, contributionBuildId };
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
function printPullResult(result) {
|
|
2041
|
-
const build = result.build || {};
|
|
2042
|
-
const entryPath = result.manifest?.entryPath || "unknown";
|
|
2043
|
-
console.log(`Selected ${formatBuildTitle(build)}.`);
|
|
2044
|
-
console.log(
|
|
2045
|
-
`Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
|
|
2046
|
-
);
|
|
2047
|
-
console.log(`Entry: ${entryPath}`);
|
|
2048
|
-
console.log(`SDK reference: ${SDK_REFERENCE_FILE}`);
|
|
2049
|
-
console.log(`Next: cd ${shellQuote(result.dir)}`);
|
|
2050
|
-
if (build.canWrite === false) {
|
|
2051
|
-
console.log("This checkout is read-only for the current CLI login.");
|
|
2052
|
-
if (isContributionBranch(build)) {
|
|
2053
|
-
console.log("Review changes: lumine diff");
|
|
2054
|
-
if (build.role === "project_owner") {
|
|
2055
|
-
console.log("Owner actions: lumine merge, or lumine replace-main");
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
return;
|
|
2059
|
-
}
|
|
2060
|
-
if (result.fileCount === 0) {
|
|
2061
|
-
console.log("No project files yet. Create /index.html before your first save.");
|
|
2062
|
-
}
|
|
2063
|
-
console.log('Codex: codex "Read AGENTS.md, then make the requested change."');
|
|
2064
|
-
console.log(
|
|
2065
|
-
'Claude Code: claude "Read CLAUDE.md, then make the requested change."',
|
|
2066
|
-
);
|
|
2067
|
-
console.log('Save after edits: lumine save --summary "Describe the change"');
|
|
2068
|
-
if (isContributionBranch(build) && build.canPublish === false) {
|
|
2069
|
-
console.log("The project owner can merge or replace main from Twinkle.");
|
|
2070
|
-
} else {
|
|
2071
|
-
console.log("Run `lumine check` or `lumine launch --save` when ready.");
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
function printNewBuildResult({ createResult, pullResult }) {
|
|
2076
|
-
const build = pullResult.build || createResult.build || {};
|
|
2077
|
-
console.log(`Created ${formatBuildTitle(build)}.`);
|
|
2078
|
-
printPullResult(pullResult);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
function printReferenceResult(result) {
|
|
2082
|
-
const build = result.build || {};
|
|
2083
|
-
const sourceBuildId =
|
|
2084
|
-
Number(result.reference?.sourceBuildId || 0) || Number(build.id || 0);
|
|
2085
|
-
const entryPath = result.manifest?.entryPath || "unknown";
|
|
2086
|
-
console.log(`Referenced ${formatBuildTitle(build)}.`);
|
|
2087
|
-
console.log(
|
|
2088
|
-
`Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
|
|
2089
|
-
);
|
|
2090
|
-
console.log(`Entry: ${entryPath}`);
|
|
2091
|
-
console.log("Mode: read-only reference");
|
|
2092
|
-
console.log(`Next: cd ${shellQuote(result.dir)}`);
|
|
2093
|
-
if (sourceBuildId) {
|
|
2094
|
-
console.log(`Start from this app: lumine fork ${sourceBuildId}`);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
function printForkResult({ forkResult, pullResult }) {
|
|
2099
|
-
const build = pullResult.build || forkResult.build || {};
|
|
2100
|
-
const sourceBuildId =
|
|
2101
|
-
Number(forkResult.sourceBuild?.id || 0) ||
|
|
2102
|
-
Number(forkResult.sourceBuild?.contentId || 0) ||
|
|
2103
|
-
0;
|
|
2104
|
-
console.log(
|
|
2105
|
-
forkResult.alreadyExists
|
|
2106
|
-
? `Using your existing fork ${formatBuildTitle(build)}.`
|
|
2107
|
-
: `Forked Build ${sourceBuildId || "source"} into ${formatBuildTitle(
|
|
2108
|
-
build,
|
|
2109
|
-
)}.`,
|
|
2110
|
-
);
|
|
2111
|
-
printPullResult(pullResult);
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
function printSaveResult({ result, build, dir, files }) {
|
|
2115
|
-
const entryPath = result.projectManifest?.entryPath || "unknown";
|
|
2116
|
-
const version = result.artifactVersion?.versionNumber
|
|
2117
|
-
? ` v${result.artifactVersion.versionNumber}`
|
|
2118
|
-
: "";
|
|
2119
|
-
const releaseState = result.releaseStatus?.state || "unknown";
|
|
2120
|
-
console.log(`Saved ${formatBuildTitle(build)}${version}.`);
|
|
2121
|
-
console.log(
|
|
2122
|
-
`Uploaded ${files.length} file${files.length === 1 ? "" : "s"} from ${dir}`,
|
|
2123
|
-
);
|
|
2124
|
-
console.log(`Entry: ${entryPath}`);
|
|
2125
|
-
console.log(`Release status: ${releaseState}`);
|
|
2126
|
-
if (isContributionBranch(build) && build.canPublish === false) {
|
|
2127
|
-
console.log(
|
|
2128
|
-
"Next: the project owner can merge or replace main from Twinkle.",
|
|
2129
|
-
);
|
|
2130
|
-
} else {
|
|
2131
|
-
console.log(
|
|
2132
|
-
"Next: run `lumine launch` to publish, or `lumine save --publish` next time.",
|
|
2133
|
-
);
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
function printContributionDiff({ result, build }) {
|
|
2138
|
-
const summary = result.diff?.summary || {};
|
|
2139
|
-
const files = Array.isArray(result.diff?.changedFiles)
|
|
2140
|
-
? result.diff.changedFiles
|
|
2141
|
-
: [];
|
|
2142
|
-
const branchNumber = Number(build.contributionBranchNumber || 0) || 0;
|
|
2143
|
-
const branchLabel = branchNumber ? `branch ${branchNumber}` : `branch #${build.id}`;
|
|
2144
|
-
console.log(`Diff for ${branchLabel}:`);
|
|
2145
|
-
console.log(
|
|
2146
|
-
`- total=${summary.total ?? files.length} added=${summary.added ?? 0} ` +
|
|
2147
|
-
`updated=${summary.updated ?? 0} deleted=${summary.deleted ?? 0}`,
|
|
2148
|
-
);
|
|
2149
|
-
if (result.rootDrifted) {
|
|
2150
|
-
console.log("- main changed after this branch was created");
|
|
2151
|
-
}
|
|
2152
|
-
if (!files.length) {
|
|
2153
|
-
console.log("No file changes.");
|
|
2154
|
-
return;
|
|
2155
|
-
}
|
|
2156
|
-
for (const file of files) {
|
|
2157
|
-
const mergeStatus = file.mergeStatus ? ` (${file.mergeStatus})` : "";
|
|
2158
|
-
console.log(`- ${file.status || "changed"} ${file.path}${mergeStatus}`);
|
|
2159
|
-
}
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
function printContributionActionResult({
|
|
2163
|
-
action,
|
|
2164
|
-
result,
|
|
2165
|
-
rootBuildId,
|
|
2166
|
-
contributionBuildId,
|
|
2167
|
-
}) {
|
|
2168
|
-
if (!result?.success) {
|
|
2169
|
-
console.log(result?.error || "Branch action did not complete.");
|
|
2170
|
-
process.exitCode = 1;
|
|
2171
|
-
return;
|
|
2172
|
-
}
|
|
2173
|
-
const projectFiles = Array.isArray(result.projectFiles)
|
|
2174
|
-
? result.projectFiles
|
|
2175
|
-
: [];
|
|
2176
|
-
console.log(`${action} branch #${contributionBuildId} for Build #${rootBuildId}.`);
|
|
2177
|
-
if (projectFiles.length > 0) {
|
|
2178
|
-
console.log(
|
|
2179
|
-
`Main now has ${projectFiles.length} project file${projectFiles.length === 1 ? "" : "s"}.`,
|
|
2180
|
-
);
|
|
2181
|
-
}
|
|
2182
|
-
if (result.mergeConflictsWritten || result.conflicts?.length > 0) {
|
|
2183
|
-
console.log("Merge wrote conflict markers. Resolve them in the Build workspace.");
|
|
2184
|
-
}
|
|
2185
|
-
console.log(`Main workspace: ${rootBuildId}`);
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
async function resolveAuth(options) {
|
|
2189
|
-
if (options.authToken) {
|
|
2190
|
-
return { token: options.authToken };
|
|
2191
|
-
}
|
|
2192
|
-
try {
|
|
2193
|
-
const text = await fs.readFile(options.authFile, "utf8");
|
|
2194
|
-
const auth = JSON.parse(text);
|
|
2195
|
-
if (auth.apiUrl && trimTrailingSlash(auth.apiUrl) !== options.apiUrl) {
|
|
2196
|
-
throw new Error(
|
|
2197
|
-
`Saved login is for ${auth.apiUrl}. Run ` +
|
|
2198
|
-
`lumine login --api-url ${options.apiUrl}.`,
|
|
2199
|
-
);
|
|
2200
|
-
}
|
|
2201
|
-
if (auth.token) return auth;
|
|
2202
|
-
} catch (error) {
|
|
2203
|
-
if (error.code !== "ENOENT") throw error;
|
|
2204
|
-
}
|
|
2205
|
-
throw new Error("Run `lumine login` before launching a Twinkle build.");
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
async function writeAuth({ options, token, username, userId, expiresAt }) {
|
|
2209
|
-
let existingAuth = {};
|
|
2210
|
-
try {
|
|
2211
|
-
existingAuth = JSON.parse(await fs.readFile(options.authFile, "utf8"));
|
|
2212
|
-
} catch (error) {
|
|
2213
|
-
if (error.code !== "ENOENT") throw error;
|
|
2214
|
-
}
|
|
2215
|
-
await writeAuthFile(options, {
|
|
2216
|
-
...existingAuth,
|
|
2217
|
-
token,
|
|
2218
|
-
username,
|
|
2219
|
-
userId,
|
|
2220
|
-
expiresAt,
|
|
2221
|
-
apiUrl: options.apiUrl,
|
|
2222
|
-
createdAt: new Date().toISOString(),
|
|
2223
|
-
});
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
async function writeAuthFile(options, auth) {
|
|
2227
|
-
await fs.mkdir(path.dirname(options.authFile), {
|
|
2228
|
-
recursive: true,
|
|
2229
|
-
mode: 0o700,
|
|
2230
|
-
});
|
|
2231
|
-
await fs.writeFile(options.authFile, JSON.stringify(auth, null, 2), {
|
|
2232
|
-
mode: 0o600,
|
|
2233
|
-
});
|
|
2234
|
-
await fs.chmod(options.authFile, 0o600);
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
async function requestJson({
|
|
2238
|
-
method = "GET",
|
|
2239
|
-
url,
|
|
2240
|
-
authToken,
|
|
2241
|
-
body,
|
|
2242
|
-
timeoutMs,
|
|
2243
|
-
}) {
|
|
2244
|
-
const response = await request({
|
|
2245
|
-
method,
|
|
2246
|
-
url,
|
|
2247
|
-
authToken,
|
|
2248
|
-
body,
|
|
2249
|
-
timeoutMs,
|
|
2250
|
-
});
|
|
2251
|
-
const text = await response.text();
|
|
2252
|
-
const data = parseJson(text);
|
|
2253
|
-
if (!response.ok) {
|
|
2254
|
-
const error = new Error(
|
|
2255
|
-
data?.error || data?.message || `${method} ${url} failed`,
|
|
2256
|
-
);
|
|
2257
|
-
error.status = response.status;
|
|
2258
|
-
error.data = data;
|
|
2259
|
-
throw error;
|
|
2260
|
-
}
|
|
2261
|
-
return data || {};
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
async function probeUrl({ url, authToken, timeoutMs }) {
|
|
2265
|
-
const response = await request({ url, authToken, timeoutMs });
|
|
2266
|
-
const text = await response.text();
|
|
2267
|
-
return {
|
|
2268
|
-
ok: response.ok && text.trim().length > 0,
|
|
2269
|
-
status: response.status,
|
|
2270
|
-
bytes: Buffer.byteLength(text),
|
|
2271
|
-
};
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
async function request({
|
|
2275
|
-
method = "GET",
|
|
2276
|
-
url,
|
|
2277
|
-
authToken,
|
|
2278
|
-
body,
|
|
2279
|
-
timeoutMs,
|
|
2280
|
-
headers = {},
|
|
2281
|
-
}) {
|
|
2282
|
-
const controller = new AbortController();
|
|
2283
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
2284
|
-
try {
|
|
2285
|
-
return await fetch(url, {
|
|
2286
|
-
method,
|
|
2287
|
-
headers: {
|
|
2288
|
-
...(authToken ? { authorization: authorizationHeader(authToken) } : {}),
|
|
2289
|
-
...(body ? { "content-type": "application/json" } : {}),
|
|
2290
|
-
...headers,
|
|
2291
|
-
},
|
|
2292
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
2293
|
-
signal: controller.signal,
|
|
2294
|
-
});
|
|
2295
|
-
} finally {
|
|
2296
|
-
clearTimeout(timeout);
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
async function loadLumineCliVersionInfo({ options }) {
|
|
2301
|
-
const packageMetadata = await loadLocalPackageMetadata();
|
|
2302
|
-
const packageName = packageMetadata.name || "@stage5/lumine";
|
|
2303
|
-
const version = packageMetadata.version || null;
|
|
2304
|
-
return {
|
|
2305
|
-
packageName,
|
|
2306
|
-
version,
|
|
2307
|
-
latestVersion: null,
|
|
2308
|
-
updateAvailable: false,
|
|
2309
|
-
updateCommand: `npx ${packageName}@latest`,
|
|
2310
|
-
checkedAt: null,
|
|
2311
|
-
checkFailed: false,
|
|
2312
|
-
checkSkipped: !options.updateCheck,
|
|
2313
|
-
};
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
|
-
async function loadLocalPackageMetadata() {
|
|
2317
|
-
try {
|
|
2318
|
-
const rawPackage = await fs.readFile(PACKAGE_METADATA_URL, "utf8");
|
|
2319
|
-
const parsedPackage = JSON.parse(rawPackage);
|
|
2320
|
-
return {
|
|
2321
|
-
name: String(parsedPackage?.name || "").trim(),
|
|
2322
|
-
version: String(parsedPackage?.version || "").trim(),
|
|
2323
|
-
};
|
|
2324
|
-
} catch {
|
|
2325
|
-
return {
|
|
2326
|
-
name: "@stage5/lumine",
|
|
2327
|
-
version: null,
|
|
2328
|
-
};
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
async function maybeCheckForLumineCliUpdate({ options }) {
|
|
2333
|
-
const info = options.lumineCli || (await loadLumineCliVersionInfo({ options }));
|
|
2334
|
-
const checkedAt = new Date().toISOString();
|
|
2335
|
-
if (!info.packageName || !info.version) {
|
|
2336
|
-
options.lumineCli = {
|
|
2337
|
-
...info,
|
|
2338
|
-
checkedAt,
|
|
2339
|
-
checkFailed: true,
|
|
2340
|
-
checkSkipped: false,
|
|
2341
|
-
};
|
|
2342
|
-
return;
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
try {
|
|
2346
|
-
const latestVersion = await loadLatestPackageVersion({
|
|
2347
|
-
packageName: info.packageName,
|
|
2348
|
-
registryUrl: options.npmRegistryUrl,
|
|
2349
|
-
});
|
|
2350
|
-
const updateAvailable = isNewerVersion(latestVersion, info.version);
|
|
2351
|
-
options.lumineCli = {
|
|
2352
|
-
...info,
|
|
2353
|
-
latestVersion,
|
|
2354
|
-
updateAvailable,
|
|
2355
|
-
checkedAt,
|
|
2356
|
-
checkFailed: false,
|
|
2357
|
-
checkSkipped: false,
|
|
2358
|
-
};
|
|
2359
|
-
if (updateAvailable) {
|
|
2360
|
-
printLumineCliUpdateWarning(options.lumineCli);
|
|
2361
|
-
}
|
|
2362
|
-
} catch {
|
|
2363
|
-
options.lumineCli = {
|
|
2364
|
-
...info,
|
|
2365
|
-
checkedAt,
|
|
2366
|
-
checkFailed: true,
|
|
2367
|
-
checkSkipped: false,
|
|
2368
|
-
};
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
async function loadLatestPackageVersion({ packageName, registryUrl }) {
|
|
2373
|
-
const encodedPackageName = encodeURIComponent(packageName);
|
|
2374
|
-
const result = await requestJson({
|
|
2375
|
-
url: `${registryUrl}/${encodedPackageName}/latest`,
|
|
2376
|
-
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
|
|
2377
|
-
});
|
|
2378
|
-
const latestVersion = String(result?.version || "").trim();
|
|
2379
|
-
if (!latestVersion) {
|
|
2380
|
-
throw new Error("No latest package version returned");
|
|
2381
|
-
}
|
|
2382
|
-
return latestVersion;
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
function isNewerVersion(latestVersion, currentVersion) {
|
|
2386
|
-
const latestParts = parseSemverParts(latestVersion);
|
|
2387
|
-
const currentParts = parseSemverParts(currentVersion);
|
|
2388
|
-
if (!latestParts || !currentParts) return false;
|
|
2389
|
-
for (let index = 0; index < 3; index += 1) {
|
|
2390
|
-
if (latestParts[index] > currentParts[index]) return true;
|
|
2391
|
-
if (latestParts[index] < currentParts[index]) return false;
|
|
2392
|
-
}
|
|
2393
|
-
return false;
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
function parseSemverParts(value) {
|
|
2397
|
-
const match = String(value || "")
|
|
2398
|
-
.trim()
|
|
2399
|
-
.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
2400
|
-
if (!match) return null;
|
|
2401
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
function printLumineCliUpdateWarning(info) {
|
|
2405
|
-
console.error(
|
|
2406
|
-
`lumine: update available for ${info.packageName}: ${info.version} -> ${info.latestVersion}.`,
|
|
2407
|
-
);
|
|
2408
|
-
console.error(`lumine: run \`${info.updateCommand}\` to use the latest CLI.`);
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
function parseArgs(args) {
|
|
2412
|
-
const firstArg = args[0] || "";
|
|
2413
|
-
const firstArgIsCommand =
|
|
2414
|
-
firstArg && !firstArg.startsWith("--") && COMMANDS.has(firstArg);
|
|
2415
|
-
const firstArgLooksLikeTarget =
|
|
2416
|
-
firstArg && !firstArg.startsWith("--") && resolveBuildId(firstArg) > 0;
|
|
2417
|
-
const command = firstArgIsCommand
|
|
2418
|
-
? firstArg
|
|
2419
|
-
: !firstArg || firstArg.startsWith("--") || firstArgLooksLikeTarget
|
|
2420
|
-
? "workspace"
|
|
2421
|
-
: "help";
|
|
2422
|
-
const rest = command === "workspace" ? args : args.slice(1);
|
|
2423
|
-
const raw = {};
|
|
2424
|
-
const positional = [];
|
|
2425
|
-
const booleanFlags = new Set([
|
|
2426
|
-
"noOpen",
|
|
2427
|
-
"open",
|
|
2428
|
-
"noDescription",
|
|
2429
|
-
"publish",
|
|
2430
|
-
"save",
|
|
2431
|
-
"noUpdateCheck",
|
|
2432
|
-
"allowWrite",
|
|
2433
|
-
]);
|
|
2434
|
-
|
|
2435
|
-
for (let i = 0; i < rest.length; i += 1) {
|
|
2436
|
-
const arg = rest[i];
|
|
2437
|
-
if (arg === "--help" || arg === "-h") {
|
|
2438
|
-
raw.help = true;
|
|
2439
|
-
continue;
|
|
2440
|
-
}
|
|
2441
|
-
if (!arg.startsWith("--")) {
|
|
2442
|
-
positional.push(arg);
|
|
2443
|
-
continue;
|
|
2444
|
-
}
|
|
2445
|
-
const [key, inlineValue] = arg.slice(2).split("=", 2);
|
|
2446
|
-
const camelKey = toCamelCase(key);
|
|
2447
|
-
if (inlineValue !== undefined) {
|
|
2448
|
-
raw[camelKey] = inlineValue;
|
|
2449
|
-
} else if (booleanFlags.has(camelKey)) {
|
|
2450
|
-
raw[camelKey] = true;
|
|
2451
|
-
} else {
|
|
2452
|
-
raw[camelKey] = rest[i + 1] ?? "";
|
|
2453
|
-
i += 1;
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
return {
|
|
2458
|
-
command,
|
|
2459
|
-
positional,
|
|
2460
|
-
repeat: Math.min(Math.max(Math.floor(Number(raw.repeat) || 1), 1), 20),
|
|
2461
|
-
allowWrite: parseBoolean(raw.allowWrite, false),
|
|
2462
|
-
sdkPath: raw.path ? String(raw.path) : "",
|
|
2463
|
-
sdkScopes: String(raw.scopes || "")
|
|
2464
|
-
.split(",")
|
|
2465
|
-
.map((scope) => scope.trim())
|
|
2466
|
-
.filter(Boolean),
|
|
2467
|
-
buildIdFlag: raw.build ? String(raw.build) : "",
|
|
2468
|
-
target: raw.url || raw.target || positional[0] || "",
|
|
2469
|
-
title:
|
|
2470
|
-
String(
|
|
2471
|
-
raw.title ||
|
|
2472
|
-
(command === "new" ? positional.join(" ") : ""),
|
|
2473
|
-
).trim() || "",
|
|
2474
|
-
description:
|
|
2475
|
-
Object.prototype.hasOwnProperty.call(raw, "description")
|
|
2476
|
-
? String(raw.description || "").trim()
|
|
2477
|
-
: null,
|
|
2478
|
-
descriptionProvided: Object.prototype.hasOwnProperty.call(
|
|
2479
|
-
raw,
|
|
2480
|
-
"description",
|
|
2481
|
-
),
|
|
2482
|
-
noDescription: parseBoolean(raw.noDescription, false),
|
|
2483
|
-
searchQuery:
|
|
2484
|
-
String(
|
|
2485
|
-
raw.search ||
|
|
2486
|
-
raw.query ||
|
|
2487
|
-
(command === "explore" ? positional.join(" ") : ""),
|
|
2488
|
-
).trim() || "",
|
|
2489
|
-
sort: normalizeOpenSourceSort(raw.sort),
|
|
2490
|
-
apiUrl: trimTrailingSlash(
|
|
2491
|
-
String(raw.apiUrl || process.env.TWINKLE_API_URL || DEFAULT_API_URL),
|
|
2492
|
-
),
|
|
2493
|
-
siteUrl: trimTrailingSlash(
|
|
2494
|
-
String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
|
|
2495
|
-
),
|
|
2496
|
-
npmRegistryUrl: trimTrailingSlash(
|
|
2497
|
-
String(
|
|
2498
|
-
raw.npmRegistryUrl ||
|
|
2499
|
-
process.env.LUMINE_NPM_REGISTRY_URL ||
|
|
2500
|
-
DEFAULT_NPM_REGISTRY_URL,
|
|
2501
|
-
),
|
|
2502
|
-
),
|
|
2503
|
-
authFile: String(
|
|
2504
|
-
raw.authFile || process.env.TWINKLE_CLI_AUTH_FILE || DEFAULT_AUTH_FILE,
|
|
2505
|
-
),
|
|
2506
|
-
authToken:
|
|
2507
|
-
String(raw.authToken || process.env.TWINKLE_AUTH_TOKEN || "").trim() ||
|
|
2508
|
-
null,
|
|
2509
|
-
clientName: String(raw.clientName || "Lumine CLI").slice(0, 120),
|
|
2510
|
-
dir: raw.dir ? String(raw.dir) : "",
|
|
2511
|
-
summary: raw.summary ? String(raw.summary) : "",
|
|
2512
|
-
publish: parseBoolean(raw.publish, false),
|
|
2513
|
-
saveFirst: parseBoolean(raw.save, false),
|
|
2514
|
-
limit: Math.min(
|
|
2515
|
-
Math.max(
|
|
2516
|
-
Number(raw.limit || process.env.TWINKLE_PROJECT_LIMIT) ||
|
|
2517
|
-
DEFAULT_PROJECT_LIMIT,
|
|
2518
|
-
1,
|
|
2519
|
-
),
|
|
2520
|
-
100,
|
|
2521
|
-
),
|
|
2522
|
-
openBrowser: parseBoolean(raw.noOpen, false)
|
|
2523
|
-
? false
|
|
2524
|
-
: parseBoolean(raw.open, true),
|
|
2525
|
-
updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
|
|
2526
|
-
timeoutMs: Math.max(
|
|
2527
|
-
Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
|
|
2528
|
-
DEFAULT_TIMEOUT_MS,
|
|
2529
|
-
1000,
|
|
2530
|
-
),
|
|
2531
|
-
help: !!raw.help || command === "help",
|
|
2532
|
-
};
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
async function resolveRequiredBuildIdOrSelected(
|
|
2536
|
-
options,
|
|
2537
|
-
auth,
|
|
2538
|
-
{ localProject = null } = {},
|
|
2539
|
-
) {
|
|
2540
|
-
const targetReference = resolveBuildReference(options.target);
|
|
2541
|
-
if (targetReference.buildId > 0) {
|
|
2542
|
-
return await resolveBuildReferenceBuildId({
|
|
2543
|
-
options,
|
|
2544
|
-
auth,
|
|
2545
|
-
reference: targetReference,
|
|
2546
|
-
});
|
|
2547
|
-
}
|
|
2548
|
-
const resolvedLocalProject =
|
|
2549
|
-
localProject ||
|
|
2550
|
-
(await findLocalProjectMetadata(
|
|
2551
|
-
path.resolve(options.dir || process.cwd()),
|
|
2552
|
-
));
|
|
2553
|
-
if (
|
|
2554
|
-
resolvedLocalProject?.metadata &&
|
|
2555
|
-
isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
|
|
2556
|
-
) {
|
|
2557
|
-
const sourceBuildId =
|
|
2558
|
-
Number(resolvedLocalProject.metadata.reference?.sourceBuildId || 0) ||
|
|
2559
|
-
Number(resolvedLocalProject.metadata.buildId || 0) ||
|
|
2560
|
-
Number(resolvedLocalProject.metadata.build?.id || 0) ||
|
|
2561
|
-
0;
|
|
2562
|
-
throw new Error(
|
|
2563
|
-
`This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace, or pass an explicit Build URL.`,
|
|
2564
|
-
);
|
|
2565
|
-
}
|
|
2566
|
-
const localBuildId = Number(resolvedLocalProject?.metadata?.buildId || 0);
|
|
2567
|
-
if (localBuildId > 0) return localBuildId;
|
|
2568
|
-
const selectedBuildId = Number(auth?.selectedBuildId || 0);
|
|
2569
|
-
if (selectedBuildId > 0) return selectedBuildId;
|
|
2570
|
-
throw new Error(
|
|
2571
|
-
"Choose a project with `lumine select`, run `lumine`, or pass a Twinkle build URL.",
|
|
2572
|
-
);
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
async function resolveNewBuildTitle(options) {
|
|
2576
|
-
const title = String(options.title || "").trim();
|
|
2577
|
-
if (title) return title;
|
|
2578
|
-
if (!input.isTTY || !output.isTTY) {
|
|
2579
|
-
throw new Error('Pass a title: `lumine new "My Build"` or `lumine new --title "My Build"`.');
|
|
2580
|
-
}
|
|
2581
|
-
const rl = readline.createInterface({ input, output });
|
|
2582
|
-
try {
|
|
2583
|
-
const answer = await rl.question("Build title: ");
|
|
2584
|
-
const normalized = String(answer || "").trim();
|
|
2585
|
-
if (!normalized) {
|
|
2586
|
-
throw new Error("Title is required.");
|
|
2587
|
-
}
|
|
2588
|
-
return normalized;
|
|
2589
|
-
} finally {
|
|
2590
|
-
rl.close();
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
async function resolveNewBuildDescription(options) {
|
|
2595
|
-
if (options.noDescription) return null;
|
|
2596
|
-
if (options.descriptionProvided) {
|
|
2597
|
-
return String(options.description || "").trim() || null;
|
|
2598
|
-
}
|
|
2599
|
-
if (!input.isTTY || !output.isTTY) return null;
|
|
2600
|
-
const rl = readline.createInterface({ input, output });
|
|
2601
|
-
try {
|
|
2602
|
-
const answer = await rl.question("Description (optional): ");
|
|
2603
|
-
return String(answer || "").trim() || null;
|
|
2604
|
-
} finally {
|
|
2605
|
-
rl.close();
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
function resolveRequiredBuildId(value) {
|
|
2610
|
-
const buildId = resolveBuildReference(value).buildId;
|
|
2611
|
-
if (buildId > 0) return buildId;
|
|
2612
|
-
throw new Error(
|
|
2613
|
-
"Pass a Twinkle build URL, app URL, preview URL, or build id.",
|
|
2614
|
-
);
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
function resolveBuildId(value) {
|
|
2618
|
-
return resolveBuildReference(value).buildId;
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
async function resolveBuildReferenceBuildId({ options, auth, reference }) {
|
|
2622
|
-
if (reference.branchNumber > 0) {
|
|
2623
|
-
const build = await resolveBranchBuild({
|
|
2624
|
-
options,
|
|
2625
|
-
auth,
|
|
2626
|
-
rootBuildId: reference.buildId,
|
|
2627
|
-
branchNumber: reference.branchNumber,
|
|
2628
|
-
});
|
|
2629
|
-
return Number(build.id || 0);
|
|
2630
|
-
}
|
|
2631
|
-
return Number(reference.buildId || 0);
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
function resolveBuildReference(value) {
|
|
2635
|
-
const rawValue = String(value || "").trim();
|
|
2636
|
-
const directId = Number(rawValue);
|
|
2637
|
-
if (Number.isFinite(directId) && directId > 0) {
|
|
2638
|
-
return { buildId: directId, branchNumber: 0 };
|
|
2639
|
-
}
|
|
2640
|
-
if (!rawValue) return { buildId: 0, branchNumber: 0 };
|
|
2641
|
-
|
|
2642
|
-
try {
|
|
2643
|
-
const parsedUrl = new URL(rawValue);
|
|
2644
|
-
const host = parsedUrl.hostname.toLowerCase();
|
|
2645
|
-
const previewHost = host.match(/^b-(\d+)\.preview\.lumine\.app$/);
|
|
2646
|
-
if (previewHost) {
|
|
2647
|
-
return { buildId: Number(previewHost[1]) || 0, branchNumber: 0 };
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
const parts = parsedUrl.pathname.split("/").filter(Boolean);
|
|
2651
|
-
const appIndex = parts.indexOf("app");
|
|
2652
|
-
if (appIndex >= 0) {
|
|
2653
|
-
return { buildId: Number(parts[appIndex + 1]) || 0, branchNumber: 0 };
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
const buildIndex = parts.indexOf("build");
|
|
2657
|
-
if (buildIndex >= 0) {
|
|
2658
|
-
if (parts[buildIndex + 1] === "preview") {
|
|
2659
|
-
const nestedBuildIndex = parts.indexOf("build", buildIndex + 2);
|
|
2660
|
-
return {
|
|
2661
|
-
buildId: Number(parts[nestedBuildIndex + 1]) || 0,
|
|
2662
|
-
branchNumber: 0,
|
|
2663
|
-
};
|
|
2664
|
-
}
|
|
2665
|
-
return {
|
|
2666
|
-
buildId: Number(parts[buildIndex + 1]) || 0,
|
|
2667
|
-
branchNumber: Number(parts[buildIndex + 2]) || 0,
|
|
2668
|
-
};
|
|
2669
|
-
}
|
|
2670
|
-
|
|
2671
|
-
return {
|
|
2672
|
-
buildId:
|
|
2673
|
-
Number(parsedUrl.searchParams.get("buildId")) ||
|
|
2674
|
-
Number(parsedUrl.searchParams.get("build")) ||
|
|
2675
|
-
0,
|
|
2676
|
-
branchNumber: 0,
|
|
2677
|
-
};
|
|
2678
|
-
} catch {
|
|
2679
|
-
const match = rawValue.match(
|
|
2680
|
-
/(?:^|\/)(?:app|build)\/(?:preview\/build\/)?(\d+)(?:\/(\d+))?(?:\/|$)/,
|
|
2681
|
-
);
|
|
2682
|
-
return {
|
|
2683
|
-
buildId: Number(match?.[1] || 0) || 0,
|
|
2684
|
-
branchNumber: Number(match?.[2] || 0) || 0,
|
|
2685
|
-
};
|
|
2686
|
-
}
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
function parseJson(text) {
|
|
2690
|
-
if (!text.trim()) return null;
|
|
2691
|
-
try {
|
|
2692
|
-
return JSON.parse(text);
|
|
2693
|
-
} catch {
|
|
2694
|
-
return { message: text.slice(0, 500) };
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
|
|
2698
|
-
function toCamelCase(value) {
|
|
2699
|
-
return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
function trimTrailingSlash(value) {
|
|
2703
|
-
return String(value || "").replace(/\/+$/, "");
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
function authorizationHeader(authToken) {
|
|
2707
|
-
const token = String(authToken || "").trim();
|
|
2708
|
-
if (!token) return "";
|
|
2709
|
-
return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
function isMissingLoginError(error) {
|
|
2713
|
-
return String(error?.message || "").includes("Run `lumine login`");
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
function parseBoolean(value, fallback) {
|
|
2717
|
-
if (value === undefined || value === null || value === "") return fallback;
|
|
2718
|
-
if (value === true || value === false) return value;
|
|
2719
|
-
const normalized = String(value).trim().toLowerCase();
|
|
2720
|
-
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
2721
|
-
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
2722
|
-
return fallback;
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
function normalizeOpenSourceSort(value) {
|
|
2726
|
-
const normalized = String(value || "")
|
|
2727
|
-
.trim()
|
|
2728
|
-
.toLowerCase();
|
|
2729
|
-
if (["recent", "popular", "forks"].includes(normalized)) return normalized;
|
|
2730
|
-
return "forks";
|
|
2731
|
-
}
|
|
2732
|
-
|
|
2733
|
-
async function openBrowser(url) {
|
|
2734
|
-
const command =
|
|
2735
|
-
process.platform === "darwin"
|
|
2736
|
-
? "open"
|
|
2737
|
-
: process.platform === "win32"
|
|
2738
|
-
? "cmd"
|
|
2739
|
-
: "xdg-open";
|
|
2740
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2741
|
-
|
|
2742
|
-
return await new Promise((resolve) => {
|
|
2743
|
-
const child = spawn(command, args, {
|
|
2744
|
-
detached: true,
|
|
2745
|
-
stdio: "ignore",
|
|
2746
|
-
});
|
|
2747
|
-
child.once("error", () => resolve(false));
|
|
2748
|
-
child.once("spawn", () => {
|
|
2749
|
-
child.unref();
|
|
2750
|
-
resolve(true);
|
|
2751
|
-
});
|
|
2752
|
-
});
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
function sleep(ms) {
|
|
2756
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2757
|
-
}
|
|
2758
|
-
|
|
2759
|
-
function defaultWorkspaceDir(build) {
|
|
2760
|
-
const titleSlug = slugify(build?.title || "");
|
|
2761
|
-
const buildId = Number(build?.id || 0) || "build";
|
|
2762
|
-
return `twinkle-${titleSlug || "build"}-${buildId}`;
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
function defaultReferenceDir(build) {
|
|
2766
|
-
const titleSlug = slugify(build?.title || "");
|
|
2767
|
-
const buildId = Number(build?.id || 0) || "build";
|
|
2768
|
-
return `twinkle-reference-${titleSlug || "build"}-${buildId}`;
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
function slugify(value) {
|
|
2772
|
-
return String(value || "")
|
|
2773
|
-
.toLowerCase()
|
|
2774
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
2775
|
-
.replace(/^-+|-+$/g, "")
|
|
2776
|
-
.slice(0, 48);
|
|
2777
|
-
}
|
|
2778
|
-
|
|
2779
|
-
function shellQuote(value) {
|
|
2780
|
-
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
function printHelp() {
|
|
2784
|
-
console.log(`Usage:
|
|
2785
|
-
lumine
|
|
2786
|
-
lumine login
|
|
2787
|
-
lumine whoami
|
|
2788
|
-
lumine logout
|
|
2789
|
-
lumine new [title]
|
|
2790
|
-
lumine projects
|
|
2791
|
-
lumine explore [search terms]
|
|
2792
|
-
lumine select [twinkle-build-url]
|
|
2793
|
-
lumine pull [twinkle-build-url]
|
|
2794
|
-
lumine reference <twinkle-build-url>
|
|
2795
|
-
lumine fork <twinkle-build-url>
|
|
2796
|
-
lumine diff <twinkle-branch-url>
|
|
2797
|
-
lumine merge <twinkle-branch-url>
|
|
2798
|
-
lumine replace-main <twinkle-branch-url>
|
|
2799
|
-
lumine save
|
|
2800
|
-
lumine check [twinkle-build-url]
|
|
2801
|
-
lumine launch [twinkle-build-url]
|
|
2802
|
-
lumine sdk list
|
|
2803
|
-
lumine sdk call <namespace.method> [jsonArgs]
|
|
2804
|
-
|
|
2805
|
-
Examples:
|
|
2806
|
-
npx @stage5/lumine@latest
|
|
2807
|
-
npx @stage5/lumine@latest login
|
|
2808
|
-
npx @stage5/lumine@latest new "Daily Reflection App"
|
|
2809
|
-
npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
|
|
2810
|
-
npx @stage5/lumine@latest explore --sort forks
|
|
2811
|
-
npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
|
|
2812
|
-
npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
|
|
2813
|
-
npx @stage5/lumine@latest diff https://www.twin-kle.com/build/884/4
|
|
2814
|
-
npx @stage5/lumine@latest merge https://www.twin-kle.com/build/884/4
|
|
2815
|
-
npx @stage5/lumine@latest pull
|
|
2816
|
-
npx @stage5/lumine@latest save
|
|
2817
|
-
npx @stage5/lumine@latest save --publish
|
|
2818
|
-
npx @stage5/lumine@latest launch --save
|
|
2819
|
-
npx @stage5/lumine@latest launch https://www.twin-kle.com/app/123
|
|
2820
|
-
npx @stage5/lumine@latest sdk call aiStories.chapters '{"limit": 5}'
|
|
2821
|
-
npx @stage5/lumine@latest sdk call privateDb.get '{"key": "prefs"}' --build 1374
|
|
2822
|
-
npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
|
|
2823
|
-
|
|
2824
|
-
Options:
|
|
2825
|
-
--api-url <url> Twinkle API origin
|
|
2826
|
-
--site-url <url> Twinkle website origin
|
|
2827
|
-
--auth-file <path> Saved login path
|
|
2828
|
-
--auth-token <token> Override saved login
|
|
2829
|
-
--dir <path> Directory for pulled project files
|
|
2830
|
-
--title <text> New Build title
|
|
2831
|
-
--description <text> Optional New Build description
|
|
2832
|
-
--no-description Skip the New Build description prompt
|
|
2833
|
-
--summary <text> Save summary
|
|
2834
|
-
--search <text> Search public open-source Builds
|
|
2835
|
-
--sort <sort> Sort open-source Builds: forks, popular, recent
|
|
2836
|
-
--publish Publish after saving
|
|
2837
|
-
--save Save local files before launch
|
|
2838
|
-
--limit <number> Number of projects to show
|
|
2839
|
-
--no-update-check Skip the npm latest-version check
|
|
2840
|
-
--no-open Print the approval URL without opening a browser
|
|
2841
|
-
--build <id> Build id for sdk calls outside a workspace
|
|
2842
|
-
--repeat <n> Repeat an sdk call (1-20) and print latency stats
|
|
2843
|
-
--allow-write Permit sdk methods that mutate app data
|
|
2844
|
-
--path <api/...> Call an sdk endpoint not in the curated list
|
|
2845
|
-
--scopes <a,b> Override requested build API token scopes
|
|
2846
|
-
`);
|
|
2847
|
-
}
|