@stage5/lumine 0.1.3 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/bin/lumine.js +475 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,9 @@ Launch Twinkle Lumine builds from any terminal.
6
6
  npx @stage5/lumine@latest
7
7
  npx @stage5/lumine@latest login
8
8
  npx @stage5/lumine@latest projects
9
+ npx @stage5/lumine@latest explore --sort forks
10
+ npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
11
+ npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
9
12
  npx @stage5/lumine@latest pull
10
13
  npx @stage5/lumine@latest save
11
14
  npx @stage5/lumine@latest save --publish
@@ -21,6 +24,13 @@ pulling the owner's main project creates or reuses your contribution branch and
21
24
  checks out that branch locally. Saves go to your branch, so the project owner
22
25
  can merge or replace main from Twinkle.
23
26
 
27
+ Use `lumine explore` to list public open-source Build apps that can be used as
28
+ examples or starting points. It supports `--search` and `--sort forks`,
29
+ `--sort popular`, or `--sort recent`. Use `lumine reference <build-url-or-id>`
30
+ to pull source files into a read-only reference folder, or
31
+ `lumine fork <build-url-or-id>` to create your own editable fork and pull it
32
+ locally.
33
+
24
34
  After editing pulled files, run `lumine save` from that folder. The CLI saves
25
35
  through Twinkle's normal workspace project-file route, creates a project artifact
26
36
  version, records the same save metadata, and marks public builds as having
@@ -35,6 +45,16 @@ uploaded by `lumine save`. Build apps run in sandboxed iframes without native
35
45
  form submission, so use JavaScript-handled inputs and buttons instead of
36
46
  `<form>` elements.
37
47
 
48
+ Reference folders are marked `readOnly` in `.twinkle/lumine-project.json`.
49
+ Running `lumine save` from a reference folder is blocked; fork the source Build
50
+ first if you want an editable workspace.
51
+
52
+ The CLI checks npm for the latest `@stage5/lumine` version on normal commands.
53
+ If the installed copy is outdated, it prints an update warning and records the
54
+ version state in `.twinkle/lumine-project.json` so local agents can tell when
55
+ they should rerun with `npx @stage5/lumine@latest`. Use `--no-update-check` to
56
+ skip that advisory network check.
57
+
38
58
  After pulling a project, run an agent from the pulled folder:
39
59
 
40
60
  ```bash
package/bin/lumine.js CHANGED
@@ -8,12 +8,14 @@ import { stdin as input, stdout as output } from "process";
8
8
 
9
9
  const DEFAULT_API_URL = "https://api.twinkle.network";
10
10
  const DEFAULT_SITE_URL = "https://www.twin-kle.com";
11
+ const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
11
12
  const DEFAULT_AUTH_FILE = path.join(
12
13
  os.homedir(),
13
14
  ".twinkle",
14
15
  "lumine-cli-auth.json",
15
16
  );
16
17
  const DEFAULT_TIMEOUT_MS = 20000;
18
+ const UPDATE_CHECK_TIMEOUT_MS = 1500;
17
19
  const DEFAULT_PROJECT_LIMIT = 50;
18
20
  const PROJECT_METADATA_DIR = ".twinkle";
19
21
  const PROJECT_METADATA_FILE = "lumine-project.json";
@@ -29,10 +31,13 @@ const EXCLUDED_UPLOAD_FILES = new Set([
29
31
  const LUMINE_AGENT_INSTRUCTIONS_MARKER =
30
32
  "<!-- Lumine CLI Agent Instructions -->";
31
33
  const LUMINE_SDK_REFERENCE_MARKER = "<!-- Lumine CLI SDK Reference -->";
34
+ const LUMINE_REFERENCE_INSTRUCTIONS_MARKER =
35
+ "<!-- Lumine CLI Reference Instructions -->";
32
36
  const BUNDLED_SDK_REFERENCE_URL = new URL(
33
37
  "../sdk/BUILD_SDK_INDEX.md",
34
38
  import.meta.url,
35
39
  );
40
+ const PACKAGE_METADATA_URL = new URL("../package.json", import.meta.url);
36
41
  const SDK_REFERENCE_FALLBACK = `${LUMINE_SDK_REFERENCE_MARKER}
37
42
  # Twinkle Build SDK Reference
38
43
 
@@ -59,6 +64,7 @@ Lumine CLI as the source of truth for saving this workspace back to Twinkle.
59
64
 
60
65
  - Read .twinkle/lumine-project.json before changing files.
61
66
  - Treat build.canWrite, build.canPublish, and build.contributionRootBuildId as authoritative.
67
+ - If lumineCli.updateAvailable is true, ask the user to rerun with npx @stage5/lumine@latest before saving.
62
68
  - Read ${SDK_REFERENCE_FILE} before adding, removing, or changing any Twinkle.* SDK calls.
63
69
  - If build.canWrite is false, do not save changes.
64
70
  - 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.
@@ -90,6 +96,21 @@ lumine save --summary "Describe the change"
90
96
  Report the changed files, any SDK methods used, the lumine save result, the
91
97
  build or branch id, and whether the result is published or unpublished changes.
92
98
  `;
99
+ const LUMINE_REFERENCE_INSTRUCTIONS = `${LUMINE_REFERENCE_INSTRUCTIONS_MARKER}
100
+ # Lumine Reference Guide
101
+
102
+ This directory contains read-only reference files pulled from a public
103
+ open-source Twinkle Build. Use it for inspection and borrowing patterns, not as
104
+ the workspace to save.
105
+
106
+ ## Source Of Truth
107
+
108
+ - Read .twinkle/lumine-project.json before using these files.
109
+ - If lumineCli.updateAvailable is true, ask the user to rerun with npx @stage5/lumine@latest before borrowing patterns.
110
+ - If metadata.readOnly is true or build.role is "reference", do not run lumine save from this directory.
111
+ - To start from this Build, run lumine fork with the source build id and edit the forked workspace.
112
+ - Do not edit another local checkout to bypass reference read-only semantics.
113
+ `;
93
114
  const AGENT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
94
115
  const COMMANDS = new Set([
95
116
  "workspace",
@@ -97,8 +118,11 @@ const COMMANDS = new Set([
97
118
  "logout",
98
119
  "whoami",
99
120
  "projects",
121
+ "explore",
100
122
  "select",
101
123
  "pull",
124
+ "reference",
125
+ "fork",
102
126
  "save",
103
127
  "push",
104
128
  "check",
@@ -113,10 +137,14 @@ main().catch((error) => {
113
137
 
114
138
  async function main() {
115
139
  const options = parseArgs(process.argv.slice(2));
140
+ options.lumineCli = await loadLumineCliVersionInfo({ options });
116
141
  if (options.help) {
117
142
  printHelp();
118
143
  return;
119
144
  }
145
+ if (options.updateCheck) {
146
+ await maybeCheckForLumineCliUpdate({ options });
147
+ }
120
148
 
121
149
  if (options.command === "workspace") {
122
150
  await workspace(options);
@@ -138,6 +166,10 @@ async function main() {
138
166
  await projects(options);
139
167
  return;
140
168
  }
169
+ if (options.command === "explore") {
170
+ await explore(options);
171
+ return;
172
+ }
141
173
  if (options.command === "select") {
142
174
  await selectProject(options);
143
175
  return;
@@ -146,6 +178,14 @@ async function main() {
146
178
  await pull(options);
147
179
  return;
148
180
  }
181
+ if (options.command === "reference") {
182
+ await reference(options);
183
+ return;
184
+ }
185
+ if (options.command === "fork") {
186
+ await fork(options);
187
+ return;
188
+ }
149
189
  if (options.command === "save" || options.command === "push") {
150
190
  await save(options);
151
191
  return;
@@ -286,6 +326,12 @@ async function projects(options) {
286
326
  printBuildList(builds);
287
327
  }
288
328
 
329
+ async function explore(options) {
330
+ const auth = await resolveAuth(options);
331
+ const builds = await listOpenSourceBuilds({ options, auth });
332
+ printOpenSourceBuildList(builds, options);
333
+ }
334
+
289
335
  async function selectProject(options) {
290
336
  const auth = await resolveAuth(options);
291
337
  const selectedBuild = options.target
@@ -326,12 +372,38 @@ async function pull(options) {
326
372
  printPullResult(result);
327
373
  }
328
374
 
375
+ async function reference(options) {
376
+ const auth = await resolveAuth(options);
377
+ const buildId = resolveRequiredBuildId(options.target);
378
+ const result = await pullReferenceFiles({ options, auth, buildId });
379
+ printReferenceResult(result);
380
+ }
381
+
382
+ async function fork(options) {
383
+ const auth = await resolveAuth(options);
384
+ await assertAuthScope({ options, auth, scope: "build:write" });
385
+ const buildId = resolveRequiredBuildId(options.target);
386
+ const forkResult = await forkBuild({ options, auth, buildId });
387
+ const forkedBuildId = Number(forkResult.build?.id || 0);
388
+ if (!forkedBuildId) {
389
+ throw new Error("Twinkle did not return a forked Build.");
390
+ }
391
+ const result = await pullBuildFiles({
392
+ options,
393
+ auth,
394
+ buildId: forkedBuildId,
395
+ });
396
+ await saveSelectedBuild({ options, auth, build: result.build });
397
+ printForkResult({ forkResult, pullResult: result });
398
+ }
399
+
329
400
  async function save(options) {
330
401
  const auth = await resolveAuth(options);
331
402
  await assertAuthScope({ options, auth, scope: "build:write" });
332
403
  const localProject = await findLocalProjectMetadata(
333
404
  path.resolve(options.dir || process.cwd()),
334
405
  );
406
+ assertLocalProjectCanBeSaved(localProject);
335
407
  let buildId = await resolveRequiredBuildIdOrSelected(options, auth, {
336
408
  localProject,
337
409
  });
@@ -543,6 +615,21 @@ async function listBuilds({ options, auth }) {
543
615
  return Array.isArray(result.builds) ? result.builds : [];
544
616
  }
545
617
 
618
+ async function listOpenSourceBuilds({ options, auth }) {
619
+ const url = new URL(`${options.apiUrl}/cli/open-source-builds`);
620
+ url.searchParams.set("limit", String(options.limit));
621
+ url.searchParams.set("sort", options.sort);
622
+ if (options.searchQuery) {
623
+ url.searchParams.set("search", options.searchQuery);
624
+ }
625
+ const result = await requestJson({
626
+ url: url.toString(),
627
+ authToken: auth.token,
628
+ timeoutMs: options.timeoutMs,
629
+ });
630
+ return Array.isArray(result.builds) ? result.builds : [];
631
+ }
632
+
546
633
  async function loadBuildMetadata({ options, auth, buildId }) {
547
634
  const result = await loadBuildFiles({
548
635
  options,
@@ -556,6 +643,21 @@ async function loadBuildMetadata({ options, auth, buildId }) {
556
643
  return result.build;
557
644
  }
558
645
 
646
+ async function loadOpenSourceBuildFiles({
647
+ options,
648
+ auth,
649
+ buildId,
650
+ includeContent,
651
+ }) {
652
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/open-source-files`);
653
+ if (!includeContent) url.searchParams.set("includeContent", "0");
654
+ return await requestJson({
655
+ url: url.toString(),
656
+ authToken: auth.token,
657
+ timeoutMs: options.timeoutMs,
658
+ });
659
+ }
660
+
559
661
  async function loadBuildFiles({ options, auth, buildId, includeContent }) {
560
662
  const url = new URL(`${options.apiUrl}/cli/build/${buildId}/files`);
561
663
  if (!includeContent) url.searchParams.set("includeContent", "0");
@@ -566,6 +668,16 @@ async function loadBuildFiles({ options, auth, buildId, includeContent }) {
566
668
  });
567
669
  }
568
670
 
671
+ async function forkBuild({ options, auth, buildId }) {
672
+ return await requestJson({
673
+ method: "POST",
674
+ url: `${options.apiUrl}/build/${buildId}/fork`,
675
+ authToken: auth.token,
676
+ body: {},
677
+ timeoutMs: options.timeoutMs,
678
+ });
679
+ }
680
+
569
681
  async function saveProjectFiles({ options, auth, buildId, files, summary }) {
570
682
  return await requestJson({
571
683
  method: "PUT",
@@ -754,6 +866,40 @@ async function pullBuildFiles({ options, auth, buildId }) {
754
866
  };
755
867
  }
756
868
 
869
+ async function pullReferenceFiles({ options, auth, buildId }) {
870
+ const result = await loadOpenSourceBuildFiles({
871
+ options,
872
+ auth,
873
+ buildId,
874
+ includeContent: true,
875
+ });
876
+ const build = result.build || { id: buildId, title: `Build ${buildId}` };
877
+ const files = Array.isArray(result.projectFiles) ? result.projectFiles : [];
878
+ const dir = path.resolve(options.dir || defaultReferenceDir(build));
879
+ await writeProjectFiles({ dir, files });
880
+ await writeReferenceInstructions({ dir });
881
+ await writeSdkReference({ dir });
882
+ await writeReferenceMetadata({
883
+ dir,
884
+ options,
885
+ build,
886
+ manifest: result.projectManifest || null,
887
+ reference: result.reference || {
888
+ readOnly: true,
889
+ forkable: true,
890
+ sourceBuildId: Number(build.id || buildId),
891
+ },
892
+ pulledAt: new Date().toISOString(),
893
+ });
894
+ return {
895
+ build,
896
+ dir,
897
+ fileCount: files.length,
898
+ manifest: result.projectManifest || null,
899
+ reference: result.reference || null,
900
+ };
901
+ }
902
+
757
903
  async function writeAgentInstructions({ dir }) {
758
904
  for (const fileName of AGENT_INSTRUCTION_FILES) {
759
905
  const filePath = path.join(dir, fileName);
@@ -769,6 +915,21 @@ async function writeAgentInstructions({ dir }) {
769
915
  }
770
916
  }
771
917
 
918
+ async function writeReferenceInstructions({ dir }) {
919
+ for (const fileName of AGENT_INSTRUCTION_FILES) {
920
+ const filePath = path.join(dir, fileName);
921
+ try {
922
+ const existing = await fs.readFile(filePath, "utf8");
923
+ if (!existing.includes(LUMINE_REFERENCE_INSTRUCTIONS_MARKER)) {
924
+ continue;
925
+ }
926
+ } catch (error) {
927
+ if (error.code !== "ENOENT") throw error;
928
+ }
929
+ await fs.writeFile(filePath, LUMINE_REFERENCE_INSTRUCTIONS, "utf8");
930
+ }
931
+ }
932
+
772
933
  async function writeSdkReference({ dir }) {
773
934
  const filePath = path.join(dir, SDK_REFERENCE_FILE);
774
935
  try {
@@ -965,6 +1126,7 @@ async function writeProjectMetadata({
965
1126
  : null,
966
1127
  apiUrl: options.apiUrl,
967
1128
  siteUrl: options.siteUrl,
1129
+ lumineCli: serializeLumineCliMetadata(options),
968
1130
  manifest,
969
1131
  pulledAt,
970
1132
  lastSavedAt,
@@ -976,6 +1138,53 @@ async function writeProjectMetadata({
976
1138
  );
977
1139
  }
978
1140
 
1141
+ async function writeReferenceMetadata({
1142
+ dir,
1143
+ options,
1144
+ build,
1145
+ manifest,
1146
+ reference,
1147
+ pulledAt,
1148
+ }) {
1149
+ const metadataDir = path.join(dir, PROJECT_METADATA_DIR);
1150
+ await fs.mkdir(metadataDir, { recursive: true });
1151
+ const sourceBuildId =
1152
+ Number(reference?.sourceBuildId || 0) || Number(build?.id || 0) || null;
1153
+ await fs.writeFile(
1154
+ path.join(metadataDir, PROJECT_METADATA_FILE),
1155
+ JSON.stringify(
1156
+ {
1157
+ schemaVersion: 1,
1158
+ buildId: sourceBuildId,
1159
+ readOnly: true,
1160
+ reference: {
1161
+ readOnly: true,
1162
+ forkable: true,
1163
+ sourceBuildId,
1164
+ sourceAppUrl: sourceBuildId ? `${options.siteUrl}/app/${sourceBuildId}` : null,
1165
+ },
1166
+ build: {
1167
+ id: sourceBuildId,
1168
+ title: build?.title || (sourceBuildId ? `Build ${sourceBuildId}` : ""),
1169
+ role: "reference",
1170
+ ownerUsername: build?.ownerUsername || null,
1171
+ collaborationMode: build?.collaborationMode || "open_source",
1172
+ canWrite: false,
1173
+ canPublish: false,
1174
+ },
1175
+ apiUrl: options.apiUrl,
1176
+ siteUrl: options.siteUrl,
1177
+ lumineCli: serializeLumineCliMetadata(options),
1178
+ manifest,
1179
+ pulledAt,
1180
+ },
1181
+ null,
1182
+ 2,
1183
+ ),
1184
+ "utf8",
1185
+ );
1186
+ }
1187
+
979
1188
  async function findLocalProjectMetadata(startDir) {
980
1189
  let current = path.resolve(startDir || process.cwd());
981
1190
  while (true) {
@@ -1002,6 +1211,29 @@ function resolveProjectDirForSave({ options, localProject }) {
1002
1211
  return process.cwd();
1003
1212
  }
1004
1213
 
1214
+ function assertLocalProjectCanBeSaved(localProject) {
1215
+ const metadata = localProject?.metadata;
1216
+ if (!metadata) return;
1217
+ if (isReadOnlyReferenceMetadata(metadata)) {
1218
+ const sourceBuildId =
1219
+ Number(metadata.reference?.sourceBuildId || 0) ||
1220
+ Number(metadata.buildId || 0) ||
1221
+ Number(metadata.build?.id || 0) ||
1222
+ 0;
1223
+ throw new Error(
1224
+ `This is a read-only Lumine reference${sourceBuildId ? ` for Build ${sourceBuildId}` : ""}. Run \`lumine fork${sourceBuildId ? ` ${sourceBuildId}` : ""}\` to create an editable workspace.`,
1225
+ );
1226
+ }
1227
+ }
1228
+
1229
+ function isReadOnlyReferenceMetadata(metadata) {
1230
+ return (
1231
+ metadata?.readOnly === true ||
1232
+ metadata?.reference?.readOnly === true ||
1233
+ metadata?.build?.role === "reference"
1234
+ );
1235
+ }
1236
+
1005
1237
  function resolveLocalProjectFilePath({ rootDir, projectPath }) {
1006
1238
  const relativePath = projectPathToRelativePath(projectPath);
1007
1239
  const root = path.resolve(rootDir);
@@ -1037,6 +1269,20 @@ async function saveSelectedBuild({ options, auth, build }) {
1037
1269
  });
1038
1270
  }
1039
1271
 
1272
+ function serializeLumineCliMetadata(options) {
1273
+ const info = options.lumineCli || {};
1274
+ return {
1275
+ packageName: info.packageName || "@stage5/lumine",
1276
+ version: info.version || null,
1277
+ latestVersion: info.latestVersion || null,
1278
+ updateAvailable: Boolean(info.updateAvailable),
1279
+ updateCommand: info.updateCommand || "npx @stage5/lumine@latest",
1280
+ checkedAt: info.checkedAt || null,
1281
+ checkFailed: Boolean(info.checkFailed),
1282
+ checkSkipped: Boolean(info.checkSkipped),
1283
+ };
1284
+ }
1285
+
1040
1286
  function printBuildList(builds) {
1041
1287
  if (!builds.length) {
1042
1288
  console.log("No owned or team Twinkle builds found.");
@@ -1048,6 +1294,23 @@ function printBuildList(builds) {
1048
1294
  });
1049
1295
  }
1050
1296
 
1297
+ function printOpenSourceBuildList(builds, options) {
1298
+ if (!builds.length) {
1299
+ const searchText = options.searchQuery
1300
+ ? ` matching "${options.searchQuery}"`
1301
+ : "";
1302
+ console.log(`No public open-source Twinkle builds found${searchText}.`);
1303
+ return;
1304
+ }
1305
+ const searchText = options.searchQuery ? ` for "${options.searchQuery}"` : "";
1306
+ console.log(`Public open-source Twinkle builds${searchText}:`);
1307
+ builds.forEach((build, index) => {
1308
+ console.log(`${index + 1}. ${formatOpenSourceBuildListItem(build)}`);
1309
+ });
1310
+ console.log("Reference: lumine reference <build-id>");
1311
+ console.log("Fork: lumine fork <build-id>");
1312
+ }
1313
+
1051
1314
  function formatBuildListItem(build) {
1052
1315
  const role =
1053
1316
  build.role === "owner"
@@ -1057,6 +1320,16 @@ function formatBuildListItem(build) {
1057
1320
  return `${formatBuildTitle(build)} - ${role}, ${published}`;
1058
1321
  }
1059
1322
 
1323
+ function formatOpenSourceBuildListItem(build) {
1324
+ const owner = build.ownerUsername ? ` by ${build.ownerUsername}` : "";
1325
+ const stats = [
1326
+ `${Math.max(0, Number(build.forkCount || 0))} forks`,
1327
+ `${Math.max(0, Number(build.viewCount || 0))} views`,
1328
+ ].join(", ");
1329
+ const appUrl = build.appUrl ? ` - ${build.appUrl}` : "";
1330
+ return `${formatBuildTitle(build)}${owner} - ${stats}${appUrl}`;
1331
+ }
1332
+
1060
1333
  function formatBuildTitle(build) {
1061
1334
  return `${build.title || `Build ${build.id}`} (#${build.id})`;
1062
1335
  }
@@ -1095,6 +1368,39 @@ function printPullResult(result) {
1095
1368
  }
1096
1369
  }
1097
1370
 
1371
+ function printReferenceResult(result) {
1372
+ const build = result.build || {};
1373
+ const sourceBuildId =
1374
+ Number(result.reference?.sourceBuildId || 0) || Number(build.id || 0);
1375
+ const entryPath = result.manifest?.entryPath || "unknown";
1376
+ console.log(`Referenced ${formatBuildTitle(build)}.`);
1377
+ console.log(
1378
+ `Pulled ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} to ${result.dir}`,
1379
+ );
1380
+ console.log(`Entry: ${entryPath}`);
1381
+ console.log("Mode: read-only reference");
1382
+ console.log(`Next: cd ${shellQuote(result.dir)}`);
1383
+ if (sourceBuildId) {
1384
+ console.log(`Start from this app: lumine fork ${sourceBuildId}`);
1385
+ }
1386
+ }
1387
+
1388
+ function printForkResult({ forkResult, pullResult }) {
1389
+ const build = pullResult.build || forkResult.build || {};
1390
+ const sourceBuildId =
1391
+ Number(forkResult.sourceBuild?.id || 0) ||
1392
+ Number(forkResult.sourceBuild?.contentId || 0) ||
1393
+ 0;
1394
+ console.log(
1395
+ forkResult.alreadyExists
1396
+ ? `Using your existing fork ${formatBuildTitle(build)}.`
1397
+ : `Forked Build ${sourceBuildId || "source"} into ${formatBuildTitle(
1398
+ build,
1399
+ )}.`,
1400
+ );
1401
+ printPullResult(pullResult);
1402
+ }
1403
+
1098
1404
  function printSaveResult({ result, build, dir, files }) {
1099
1405
  const entryPath = result.projectManifest?.entryPath || "unknown";
1100
1406
  const version = result.artifactVersion?.versionNumber
@@ -1222,6 +1528,117 @@ async function request({ method = "GET", url, authToken, body, timeoutMs }) {
1222
1528
  }
1223
1529
  }
1224
1530
 
1531
+ async function loadLumineCliVersionInfo({ options }) {
1532
+ const packageMetadata = await loadLocalPackageMetadata();
1533
+ const packageName = packageMetadata.name || "@stage5/lumine";
1534
+ const version = packageMetadata.version || null;
1535
+ return {
1536
+ packageName,
1537
+ version,
1538
+ latestVersion: null,
1539
+ updateAvailable: false,
1540
+ updateCommand: `npx ${packageName}@latest`,
1541
+ checkedAt: null,
1542
+ checkFailed: false,
1543
+ checkSkipped: !options.updateCheck,
1544
+ };
1545
+ }
1546
+
1547
+ async function loadLocalPackageMetadata() {
1548
+ try {
1549
+ const rawPackage = await fs.readFile(PACKAGE_METADATA_URL, "utf8");
1550
+ const parsedPackage = JSON.parse(rawPackage);
1551
+ return {
1552
+ name: String(parsedPackage?.name || "").trim(),
1553
+ version: String(parsedPackage?.version || "").trim(),
1554
+ };
1555
+ } catch {
1556
+ return {
1557
+ name: "@stage5/lumine",
1558
+ version: null,
1559
+ };
1560
+ }
1561
+ }
1562
+
1563
+ async function maybeCheckForLumineCliUpdate({ options }) {
1564
+ const info = options.lumineCli || (await loadLumineCliVersionInfo({ options }));
1565
+ const checkedAt = new Date().toISOString();
1566
+ if (!info.packageName || !info.version) {
1567
+ options.lumineCli = {
1568
+ ...info,
1569
+ checkedAt,
1570
+ checkFailed: true,
1571
+ checkSkipped: false,
1572
+ };
1573
+ return;
1574
+ }
1575
+
1576
+ try {
1577
+ const latestVersion = await loadLatestPackageVersion({
1578
+ packageName: info.packageName,
1579
+ registryUrl: options.npmRegistryUrl,
1580
+ });
1581
+ const updateAvailable = isNewerVersion(latestVersion, info.version);
1582
+ options.lumineCli = {
1583
+ ...info,
1584
+ latestVersion,
1585
+ updateAvailable,
1586
+ checkedAt,
1587
+ checkFailed: false,
1588
+ checkSkipped: false,
1589
+ };
1590
+ if (updateAvailable) {
1591
+ printLumineCliUpdateWarning(options.lumineCli);
1592
+ }
1593
+ } catch {
1594
+ options.lumineCli = {
1595
+ ...info,
1596
+ checkedAt,
1597
+ checkFailed: true,
1598
+ checkSkipped: false,
1599
+ };
1600
+ }
1601
+ }
1602
+
1603
+ async function loadLatestPackageVersion({ packageName, registryUrl }) {
1604
+ const encodedPackageName = encodeURIComponent(packageName);
1605
+ const result = await requestJson({
1606
+ url: `${registryUrl}/${encodedPackageName}/latest`,
1607
+ timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
1608
+ });
1609
+ const latestVersion = String(result?.version || "").trim();
1610
+ if (!latestVersion) {
1611
+ throw new Error("No latest package version returned");
1612
+ }
1613
+ return latestVersion;
1614
+ }
1615
+
1616
+ function isNewerVersion(latestVersion, currentVersion) {
1617
+ const latestParts = parseSemverParts(latestVersion);
1618
+ const currentParts = parseSemverParts(currentVersion);
1619
+ if (!latestParts || !currentParts) return false;
1620
+ for (let index = 0; index < 3; index += 1) {
1621
+ if (latestParts[index] > currentParts[index]) return true;
1622
+ if (latestParts[index] < currentParts[index]) return false;
1623
+ }
1624
+ return false;
1625
+ }
1626
+
1627
+ function parseSemverParts(value) {
1628
+ const match = String(value || "")
1629
+ .trim()
1630
+ .match(/^v?(\d+)\.(\d+)\.(\d+)/);
1631
+ if (!match) return null;
1632
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
1633
+ }
1634
+
1635
+ function printLumineCliUpdateWarning(info) {
1636
+ console.error(
1637
+ `lumine: update available for ${info.packageName}: ${info.version} -> ${info.latestVersion}.`,
1638
+ );
1639
+ console.error(`lumine: run \`${info.updateCommand}\` to use the latest CLI.`);
1640
+ }
1641
+
1225
1642
  function parseArgs(args) {
1226
1643
  const firstArg = args[0] || "";
1227
1644
  const firstArgIsCommand =
@@ -1236,7 +1653,13 @@ function parseArgs(args) {
1236
1653
  const rest = command === "workspace" ? args : args.slice(1);
1237
1654
  const raw = {};
1238
1655
  const positional = [];
1239
- const booleanFlags = new Set(["noOpen", "open", "publish", "save"]);
1656
+ const booleanFlags = new Set([
1657
+ "noOpen",
1658
+ "open",
1659
+ "publish",
1660
+ "save",
1661
+ "noUpdateCheck",
1662
+ ]);
1240
1663
 
1241
1664
  for (let i = 0; i < rest.length; i += 1) {
1242
1665
  const arg = rest[i];
@@ -1263,12 +1686,26 @@ function parseArgs(args) {
1263
1686
  return {
1264
1687
  command,
1265
1688
  target: raw.url || raw.target || positional[0] || "",
1689
+ searchQuery:
1690
+ String(
1691
+ raw.search ||
1692
+ raw.query ||
1693
+ (command === "explore" ? positional.join(" ") : ""),
1694
+ ).trim() || "",
1695
+ sort: normalizeOpenSourceSort(raw.sort),
1266
1696
  apiUrl: trimTrailingSlash(
1267
1697
  String(raw.apiUrl || process.env.TWINKLE_API_URL || DEFAULT_API_URL),
1268
1698
  ),
1269
1699
  siteUrl: trimTrailingSlash(
1270
1700
  String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
1271
1701
  ),
1702
+ npmRegistryUrl: trimTrailingSlash(
1703
+ String(
1704
+ raw.npmRegistryUrl ||
1705
+ process.env.LUMINE_NPM_REGISTRY_URL ||
1706
+ DEFAULT_NPM_REGISTRY_URL,
1707
+ ),
1708
+ ),
1272
1709
  authFile: String(
1273
1710
  raw.authFile || process.env.TWINKLE_CLI_AUTH_FILE || DEFAULT_AUTH_FILE,
1274
1711
  ),
@@ -1291,6 +1728,7 @@ function parseArgs(args) {
1291
1728
  openBrowser: parseBoolean(raw.noOpen, false)
1292
1729
  ? false
1293
1730
  : parseBoolean(raw.open, true),
1731
+ updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
1294
1732
  timeoutMs: Math.max(
1295
1733
  Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
1296
1734
  DEFAULT_TIMEOUT_MS,
@@ -1312,6 +1750,19 @@ async function resolveRequiredBuildIdOrSelected(
1312
1750
  (await findLocalProjectMetadata(
1313
1751
  path.resolve(options.dir || process.cwd()),
1314
1752
  ));
1753
+ if (
1754
+ resolvedLocalProject?.metadata &&
1755
+ isReadOnlyReferenceMetadata(resolvedLocalProject.metadata)
1756
+ ) {
1757
+ const sourceBuildId =
1758
+ Number(resolvedLocalProject.metadata.reference?.sourceBuildId || 0) ||
1759
+ Number(resolvedLocalProject.metadata.buildId || 0) ||
1760
+ Number(resolvedLocalProject.metadata.build?.id || 0) ||
1761
+ 0;
1762
+ throw new Error(
1763
+ `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.`,
1764
+ );
1765
+ }
1315
1766
  const localBuildId = Number(resolvedLocalProject?.metadata?.buildId || 0);
1316
1767
  if (localBuildId > 0) return localBuildId;
1317
1768
  const selectedBuildId = Number(auth?.selectedBuildId || 0);
@@ -1403,6 +1854,14 @@ function parseBoolean(value, fallback) {
1403
1854
  return fallback;
1404
1855
  }
1405
1856
 
1857
+ function normalizeOpenSourceSort(value) {
1858
+ const normalized = String(value || "")
1859
+ .trim()
1860
+ .toLowerCase();
1861
+ if (["recent", "popular", "forks"].includes(normalized)) return normalized;
1862
+ return "forks";
1863
+ }
1864
+
1406
1865
  async function openBrowser(url) {
1407
1866
  const command =
1408
1867
  process.platform === "darwin"
@@ -1435,6 +1894,12 @@ function defaultWorkspaceDir(build) {
1435
1894
  return `twinkle-${titleSlug || "build"}-${buildId}`;
1436
1895
  }
1437
1896
 
1897
+ function defaultReferenceDir(build) {
1898
+ const titleSlug = slugify(build?.title || "");
1899
+ const buildId = Number(build?.id || 0) || "build";
1900
+ return `twinkle-reference-${titleSlug || "build"}-${buildId}`;
1901
+ }
1902
+
1438
1903
  function slugify(value) {
1439
1904
  return String(value || "")
1440
1905
  .toLowerCase()
@@ -1454,8 +1919,11 @@ function printHelp() {
1454
1919
  lumine whoami
1455
1920
  lumine logout
1456
1921
  lumine projects
1922
+ lumine explore [search terms]
1457
1923
  lumine select [twinkle-build-url]
1458
1924
  lumine pull [twinkle-build-url]
1925
+ lumine reference <twinkle-build-url>
1926
+ lumine fork <twinkle-build-url>
1459
1927
  lumine save
1460
1928
  lumine check [twinkle-build-url]
1461
1929
  lumine launch [twinkle-build-url]
@@ -1463,6 +1931,9 @@ function printHelp() {
1463
1931
  Examples:
1464
1932
  npx @stage5/lumine@latest
1465
1933
  npx @stage5/lumine@latest login
1934
+ npx @stage5/lumine@latest explore --sort forks
1935
+ npx @stage5/lumine@latest reference https://www.twin-kle.com/app/123
1936
+ npx @stage5/lumine@latest fork https://www.twin-kle.com/app/123
1466
1937
  npx @stage5/lumine@latest pull
1467
1938
  npx @stage5/lumine@latest save
1468
1939
  npx @stage5/lumine@latest save --publish
@@ -1476,9 +1947,12 @@ Options:
1476
1947
  --auth-token <token> Override saved login
1477
1948
  --dir <path> Directory for pulled project files
1478
1949
  --summary <text> Save summary
1950
+ --search <text> Search public open-source Builds
1951
+ --sort <sort> Sort open-source Builds: forks, popular, recent
1479
1952
  --publish Publish after saving
1480
1953
  --save Save local files before launch
1481
1954
  --limit <number> Number of projects to show
1955
+ --no-update-check Skip the npm latest-version check
1482
1956
  --no-open Print the approval URL without opening a browser
1483
1957
  `);
1484
1958
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {