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