@stage5/lumine 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@ npx @stage5/lumine@latest
7
7
  npx @stage5/lumine@latest login
8
8
  npx @stage5/lumine@latest new "Daily Reflection App"
9
9
  npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
10
+ npx @stage5/lumine@latest rename "My New Build Title"
11
+ npx @stage5/lumine@latest rename "New Title" --target 123
10
12
  npx @stage5/lumine@latest projects
11
13
  npx @stage5/lumine@latest explore --sort forks
12
14
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
@@ -40,7 +42,14 @@ to pull source files into a read-only reference folder, or
40
42
  `lumine fork <build-url-or-id>` to create your own editable fork and pull it
41
43
  locally.
42
44
 
43
- After editing pulled files, run `lumine save` from that folder. The CLI saves
45
+ After editing pulled files, run `lumine save` from that folder. Workspaces record
46
+ a `filesHash` in `.twinkle/lumine-project.json` when you pull or save; the next
47
+ save sends that hash so the server can reject a stale checkout instead of
48
+ silently overwriting newer files. Saves from a folder with no `filesHash` are
49
+ refused unless you pass `--force` (intentional overwrite). Prefer
50
+ `lumine pull` to resync before saving if the folder might be out of date.
51
+
52
+ The CLI saves
44
53
  through Twinkle's normal workspace project-file route, creates a project artifact
45
54
  version, records the same save metadata, and marks public builds as having
46
55
  unpublished changes. For projects you own, run `lumine launch` to publish the
package/lib/api.js CHANGED
@@ -139,6 +139,7 @@ export async function saveProjectFiles({
139
139
  files,
140
140
  summary,
141
141
  baseFilesHash,
142
+ force = false,
142
143
  }) {
143
144
  return await requestJson({
144
145
  method: "PUT",
@@ -152,6 +153,9 @@ export async function saveProjectFiles({
152
153
  // reject it instead of silently rewinding newer state (e.g. a branch
153
154
  // merged into main after our pull).
154
155
  ...(baseFilesHash ? { baseFilesHash } : {}),
156
+ // Intentional overwrite of a non-empty project without a base claim.
157
+ // Required when the workspace has no filesHash (old/stale checkout).
158
+ ...(force ? { force: true } : {}),
155
159
  },
156
160
  timeoutMs: options.timeoutMs,
157
161
  });
@@ -286,12 +290,26 @@ export async function updateBuildThumbnailUrl({
286
290
  auth,
287
291
  buildId,
288
292
  thumbnailUrl,
293
+ }) {
294
+ return updateBuildMetadata({
295
+ options,
296
+ auth,
297
+ buildId,
298
+ patch: { thumbnailUrl },
299
+ });
300
+ }
301
+
302
+ export async function updateBuildMetadata({
303
+ options,
304
+ auth,
305
+ buildId,
306
+ patch,
289
307
  }) {
290
308
  return requestJson({
291
309
  method: "PUT",
292
310
  url: `${options.apiUrl}/build/${buildId}`,
293
311
  authToken: auth.token,
294
- body: { thumbnailUrl },
312
+ body: patch,
295
313
  timeoutMs: options.timeoutMs,
296
314
  });
297
315
  }
package/lib/commands.js CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  replaceMainWithContribution,
41
41
  resolveBranchBuild,
42
42
  saveProjectFiles,
43
+ updateBuildMetadata,
43
44
  } from "./api.js";
44
45
  import { assetsCommand, writeAssetsManifest } from "./assets.js";
45
46
  import { thumbnailCommand } from "./thumbnail.js";
@@ -121,6 +122,10 @@ export async function main() {
121
122
  await newBuild(options);
122
123
  return;
123
124
  }
125
+ if (options.command === "rename") {
126
+ await renameBuild(options);
127
+ return;
128
+ }
124
129
  if (options.command === "projects") {
125
130
  await projects(options);
126
131
  return;
@@ -221,6 +226,58 @@ export async function newBuild(options) {
221
226
  printNewBuildResult({ createResult, pullResult: result });
222
227
  }
223
228
 
229
+ export async function renameBuild(options) {
230
+ const title = String(options.title || "").trim();
231
+ if (!title) {
232
+ throw new Error(
233
+ 'Pass a title: `lumine rename "My New Build Title"` or `lumine rename --title "My New Build Title"`.',
234
+ );
235
+ }
236
+ const auth = await ensureAuth(options);
237
+ await assertAuthScope({ options, auth, scope: "build:write" });
238
+ const localProject = await findLocalProjectMetadata(
239
+ path.resolve(options.dir || process.cwd()),
240
+ );
241
+ const buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
242
+ localProject,
243
+ });
244
+ const currentBuild = await loadBuildMetadata({ options, auth, buildId });
245
+ if (currentBuild.canWrite === false) {
246
+ throw new Error(`You cannot rename Build #${buildId}.`);
247
+ }
248
+ if (Number(currentBuild.contributionRootBuildId || 0) > 0) {
249
+ throw new Error(
250
+ "Contribution branches use the original Build title and cannot be renamed.",
251
+ );
252
+ }
253
+ const result = await updateBuildMetadata({
254
+ options,
255
+ auth,
256
+ buildId,
257
+ patch: { title },
258
+ });
259
+ if (result?.success !== true || !result?.build) {
260
+ throw new Error(result?.error || "Failed to rename the Build.");
261
+ }
262
+ const updatedBuild = { ...currentBuild, ...result.build };
263
+ await saveSelectedBuild({ options, auth, build: updatedBuild });
264
+ if (
265
+ localProject?.rootDir &&
266
+ Number(localProject.metadata?.buildId || 0) === buildId
267
+ ) {
268
+ await writeProjectMetadata({
269
+ dir: localProject.rootDir,
270
+ options,
271
+ build: updatedBuild,
272
+ manifest: localProject.metadata.manifest || null,
273
+ pulledAt: localProject.metadata.pulledAt || null,
274
+ lastSavedAt: localProject.metadata.lastSavedAt || null,
275
+ filesHash: localProject.metadata.filesHash || null,
276
+ });
277
+ }
278
+ console.log(`Renamed Build #${buildId} to "${updatedBuild.title}".`);
279
+ }
280
+
224
281
  export async function workspace(options) {
225
282
  const auth = await ensureAuth(options);
226
283
  const selectedBuild = options.target
@@ -509,13 +566,36 @@ export async function save(options) {
509
566
  const files = await collectProjectFiles(dir);
510
567
  assertProjectFilesWithinLimits(files);
511
568
  // Only claim a base snapshot when this workspace was pulled from the same
512
- // build we are saving to; otherwise the save stays unguarded (old behavior).
569
+ // build we are saving to. Missing filesHash used to mean "unguarded save",
570
+ // which let stale checkouts silently rewind newer server versions — refuse
571
+ // that path unless --force is explicit.
572
+ const metadataBuildId = Number(localProject?.metadata?.buildId || 0);
573
+ const metadataFilesHash =
574
+ typeof localProject?.metadata?.filesHash === "string" &&
575
+ localProject.metadata.filesHash.trim()
576
+ ? localProject.metadata.filesHash.trim()
577
+ : null;
513
578
  const baseFilesHash =
514
- !options.force &&
515
- Number(localProject?.metadata?.buildId || 0) === Number(buildId) &&
516
- typeof localProject?.metadata?.filesHash === "string"
517
- ? localProject.metadata.filesHash
579
+ !options.force && metadataBuildId === Number(buildId) && metadataFilesHash
580
+ ? metadataFilesHash
518
581
  : null;
582
+ if (!options.force && !baseFilesHash) {
583
+ throw new Error(
584
+ [
585
+ "Save refused: this workspace has no filesHash (server snapshot token).",
586
+ "That usually means an old or stale checkout that never recorded what",
587
+ "it was based on — saving it would overwrite the server without a guard.",
588
+ "Run `lumine pull` to sync with the server (local edits are preserved),",
589
+ "then save again. To intentionally overwrite the server with these",
590
+ "local files, re-run with --force.",
591
+ ].join("\n"),
592
+ );
593
+ }
594
+ if (options.force && !baseFilesHash) {
595
+ console.error(
596
+ "lumine: warning — saving with --force and no filesHash; this overwrites the server project with local files and skips the stale-workspace guard.",
597
+ );
598
+ }
519
599
  let result;
520
600
  try {
521
601
  result = await saveProjectFiles({
@@ -525,6 +605,7 @@ export async function save(options) {
525
605
  files,
526
606
  summary: options.summary || DEFAULT_SAVE_SUMMARY,
527
607
  baseFilesHash,
608
+ force: Boolean(options.force),
528
609
  });
529
610
  } catch (error) {
530
611
  if (error?.data?.code === "build_project_files_stale") {
@@ -537,6 +618,17 @@ export async function save(options) {
537
618
  ].join("\n"),
538
619
  );
539
620
  }
621
+ if (error?.data?.code === "build_project_files_base_required") {
622
+ throw new Error(
623
+ [
624
+ "Save rejected: the server requires a filesHash base for this project",
625
+ "(non-empty projects cannot be saved from a workspace that never",
626
+ "recorded its server snapshot).",
627
+ "Run `lumine pull` to establish a base, then save again,",
628
+ "or re-run with --force to overwrite deliberately.",
629
+ ].join("\n"),
630
+ );
631
+ }
540
632
  throw error;
541
633
  }
542
634
  build = result.build ||
@@ -1658,10 +1750,16 @@ export function parseArgs(args) {
1658
1750
  quality: raw.quality ? String(raw.quality) : "",
1659
1751
  assetName: raw.name ? String(raw.name) : "",
1660
1752
  out: raw.out ? String(raw.out) : "",
1661
- target: raw.url || raw.target || positional[0] || "",
1753
+ target:
1754
+ raw.url ||
1755
+ raw.target ||
1756
+ (command === "rename" ? "" : positional[0] || ""),
1662
1757
  title:
1663
1758
  String(
1664
- raw.title || (command === "new" ? positional.join(" ") : ""),
1759
+ raw.title ||
1760
+ (command === "new" || command === "rename"
1761
+ ? positional.join(" ")
1762
+ : ""),
1665
1763
  ).trim() || "",
1666
1764
  description: Object.prototype.hasOwnProperty.call(raw, "description")
1667
1765
  ? String(raw.description || "").trim()
@@ -1881,6 +1979,7 @@ export function printHelp() {
1881
1979
  lumine whoami
1882
1980
  lumine logout
1883
1981
  lumine new [title]
1982
+ lumine rename [title] [--target <twinkle-build-url-or-id>]
1884
1983
  lumine projects
1885
1984
  lumine explore [search terms]
1886
1985
  lumine select [twinkle-build-url]
@@ -1914,6 +2013,8 @@ Examples:
1914
2013
  npx @stage5/lumine@latest
1915
2014
  npx @stage5/lumine@latest login
1916
2015
  npx @stage5/lumine@latest new "Daily Reflection App"
2016
+ npx @stage5/lumine@latest rename "My New Build Title"
2017
+ npx @stage5/lumine@latest rename "New Title" --target 123
1917
2018
  npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
1918
2019
  npx @stage5/lumine@latest explore --sort forks
1919
2020
  npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
@@ -1948,13 +2049,14 @@ Options:
1948
2049
  --auth-file <path> Saved login path
1949
2050
  --auth-token <token> Override saved login
1950
2051
  --dir <path> Directory for pulled project files
2052
+ --target <build> Explicit Build URL or ID for rename (positionals are title-only)
1951
2053
  --main With pull/versions/restore: target the team project's main
1952
2054
  --version <n> With pull: read-only checkout of previous save v<n>
1953
- --title <text> New Build title
2055
+ --title <text> Build title for new/rename
1954
2056
  --description <text> Optional New Build description
1955
2057
  --no-description Skip the New Build description prompt
1956
2058
  --summary <text> Save summary
1957
- --force Save even if the server files changed since this workspace was pulled
2059
+ --force Overwrite server files even if this workspace is stale or missing filesHash
1958
2060
  --search <text> Search public open-source Builds
1959
2061
  --sort <sort> Sort open-source Builds: forks, popular, recent
1960
2062
  --publish Publish after saving
package/lib/constants.js CHANGED
@@ -143,6 +143,12 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
143
143
 
144
144
  - Edit only project files in this workspace.
145
145
  - Keep /index.html or /index.htm as the entry file.
146
+ - Before editing an existing project, confirm \`.twinkle/lumine-project.json\`
147
+ has a \`filesHash\` and matches the build you intend. If the folder may be
148
+ stale (old pull, missing files, fewer files than last known save), run
149
+ \`lumine pull\` first — never \`lumine save\` from a guessed/old checkout.
150
+ Saves without \`filesHash\` are refused (use \`--force\` only for deliberate
151
+ overwrites). A stale save can delete newer server files.
146
152
  - Run lumine save from this folder after edits, with a short summary:
147
153
 
148
154
  \`\`\`bash
@@ -318,6 +324,7 @@ export const COMMANDS = new Set([
318
324
  "logout",
319
325
  "whoami",
320
326
  "new",
327
+ "rename",
321
328
  "projects",
322
329
  "explore",
323
330
  "select",
package/lib/workspace.js CHANGED
@@ -597,7 +597,8 @@ export async function writeProjectMetadata({
597
597
  lastSavedAt,
598
598
  // Server-issued hash of the project files this workspace is based on.
599
599
  // `lumine save` sends it back so a stale workspace cannot silently
600
- // rewind newer server state; null means "unknown" (unguarded save).
600
+ // rewind newer server state. Missing/null refuses save unless --force
601
+ // (server also rejects unguarded saves onto non-empty projects).
601
602
  filesHash: typeof filesHash === "string" ? filesHash : null,
602
603
  },
603
604
  null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.26.3
4
- Updated: 2026-07-10
5
- Generated: 2026-07-10T05:52:15.007Z
3
+ Version: 1.27.0
4
+ Updated: 2026-07-21
5
+ Generated: 2026-07-23T05:57:27.486Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -25,7 +25,7 @@ Generated: 2026-07-10T05:52:15.007Z
25
25
  - Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
26
26
 
27
27
  ## Token Scopes
28
- files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
28
+ files:read, user:read, users:read, dailyReflections:read, content:read, content:write, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
29
29
 
30
30
  ## Namespaces
31
31
 
@@ -80,6 +80,14 @@ files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb
80
80
  - The parent validates that the target is still a Build preview URL before navigating.
81
81
  - External URLs are rejected and do not receive the Build bridge nonce.
82
82
  - Example: await Twinkle.app.navigate('./arena.html');
83
+ - async openContent(target) | scopes: none
84
+ - Returns: { success, url }
85
+ - Open a recognized Twinkle content page in the parent app from a viewer click or tap.
86
+ - Call this directly from a user click or tap handler; calls without an active user action are rejected.
87
+ - The trusted parent displays the canonical destination and requires viewer confirmation before navigating.
88
+ - Only recognized Twinkle content URLs are accepted. The parent preserves its current signed-in origin when opening the content.
89
+ - Use navigate() for routes inside the current Build and openContent() for Twinkle subjects, comments, apps, profiles, and other content pages.
90
+ - Example: await Twinkle.app.openContent('https://www.twin-kle.com/subjects/432');
83
91
 
84
92
  ### Twinkle.preview
85
93
  - getLayout() | scopes: none
@@ -310,6 +318,9 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
310
318
  - Generate or edit an image from a prompt and optional base64/data-URL reference image.
311
319
  - Signed-in viewers only.
312
320
  - Each successful image generation consumes AI Energy from the signed-in viewer.
321
+ - Call generateImage directly from an explicit viewer action such as a button click. Calls from page load, timers, background work, or programmatic retries are rejected.
322
+ - Twinkle shows a host-owned confirmation for every generation. One approval authorizes exactly one request.
323
+ - Only one image generation may be active at a time. Do not queue or automatically retry cancellation, ai_image_generation_in_progress, USER_ACTIVATION_REQUIRED, or 429 errors.
313
324
  - Default engine is openai and default quality is high.
314
325
  - The SDK timeout defaults to 390000ms for image generation because high-quality image runs can exceed normal request timing.
315
326
  - Pass onStatus to receive real-time stages from the backend: prompt_ready, in_progress, generating, partial_image, completed, and error.
@@ -382,6 +393,19 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
382
393
  - Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
383
394
  - Returns only the current viewer's own comments on the given subject.
384
395
  - Supports cursor-based pagination. Pass cursor from previous response to load more.
396
+ - async getWriteStatus({ subjectId, commentId } = {}) | scopes: content:read
397
+ - Returns: { writeStatus: { serverNow, subjectCreate, commentCreate, subjectEdit, commentEdit } }
398
+ - Each operation slice is { cooldownSeconds, availableAt, retryAfterSeconds }.
399
+ - serverNow is unix seconds so progress bars ignore client clock skew.
400
+ - Pass subjectId/commentId to include per-target edit cooldowns.
401
+ - async create({ title, description }) | scopes: content:write
402
+ - Returns: { subject, writeStatus }
403
+ - Creates a normal site subject (title + description only in v1).
404
+ - Site-wide durable cooldown: 600s between creates. 429 includes writeStatus.
405
+ - async edit({ subjectId, title, description }) | scopes: content:write
406
+ - Returns: { subject, writeStatus }
407
+ - Own subjects only (userId === uploader). Never uses moderator edit rights.
408
+ - Per-subject edit cooldown: 10s.
385
409
 
386
410
  ### Twinkle.aiCards
387
411
  - async list({ limit, cursor, level, minLevel, maxLevel, quality, userId, hasImage, hasExample } = {}) | scopes: content:read
@@ -468,6 +492,13 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
468
492
  - For subject-poster books that include poster replies, use author: subjectPoster, includeReplies: true, and replyScope: ownThread so the poster's replies to other people do not become pages.
469
493
  - Supports cursor-based pagination. Pass cursor from the previous response to load more.
470
494
  - Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 }); const subjectId = pickedSubject.id; const page = await Twinkle.subjectComments.list(subjectId, { sortBy: 'oldest', author: 'subjectPoster', includeReplies: true, replyScope: 'ownThread', limit: 50 });
495
+ - async create({ subjectId, content }) | scopes: content:write
496
+ - Returns: { comment, writeStatus }
497
+ - Adds a top-level subject comment (book page). Own subject only.
498
+ - Site-wide durable cooldown: 20s between comment creates. 429 includes writeStatus.
499
+ - async edit({ commentId, content }) | scopes: content:write
500
+ - Returns: { comment, writeStatus }
501
+ - Own comments only. Per-comment edit cooldown: 10s.
471
502
 
472
503
  ### Twinkle.profileComments
473
504
  - async getProfileComments({ profileUserId, limit, offset, sortBy, includeReplies, range, since, until } = {}) | scopes: content:read
@@ -540,7 +571,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
540
571
  - Update a viewer-owned shared row, optionally notifying safe recipients from the canonical write.
541
572
  - Updates an entry. Only the entry creator or the build owner can update.
542
573
  - data must be a JSON object, max 10 KB.
543
- - notify may include eventKey, label, summary, recipients, and target. Supported recipients include { kind: 'sharedDbEntryAuthor', entryId }.
574
+ - notify may include eventKey, label, summary, recipients, and target. Supported recipients include { kind: 'sharedDbEntryAuthor', entryId } and { kind: 'subjectAuthor', subjectId } (subject must be referenced by this build via a subject-linked sharedDb entry).
544
575
  - async deleteEntry(entryId) | scopes: sharedDb:write
545
576
  - Returns: { success: true }
546
577
  - Deletes an entry. Only the entry creator or the build owner can delete.