@stage5/lumine 0.2.37 → 0.2.39

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
@@ -11,6 +11,7 @@ npx @stage5/lumine@latest rename "My New Build Title"
11
11
  npx @stage5/lumine@latest rename "New Title" --target 123
12
12
  npx @stage5/lumine@latest describe "A welcoming place to build together"
13
13
  npx @stage5/lumine@latest describe --no-description --target 123
14
+ npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
14
15
  npx @stage5/lumine@latest projects
15
16
  npx @stage5/lumine@latest branches 884
16
17
  npx @stage5/lumine@latest suggestions 884
@@ -40,6 +41,14 @@ website. Both commands target the current workspace or selected Build; pass
40
41
  `--target <build-url-or-id>` to update another Build you own. Run
41
42
  `lumine describe --no-description` to clear a description explicitly.
42
43
 
44
+ The configured project-limit reviewer can run
45
+ `lumine upgrade <build-url-or-id>` to grant Main—and therefore every branch—the
46
+ full approved project room of 500 files and 5 MB. The server verifies reviewer
47
+ authority, resolves branch URLs back to their canonical Main project, and
48
+ returns the confirmed canonical limits after commit. Running the command again
49
+ is a safe no-op that reports the already-active canonical limits. Omit the
50
+ target to use the current workspace or selected project.
51
+
43
52
  For team projects, Lumine mirrors the website workspace flow: choosing or
44
53
  pulling the owner's main project creates or reuses your contribution branch and
45
54
  checks out that branch locally. Saves go to your branch, so the project owner
package/lib/api.js CHANGED
@@ -147,6 +147,16 @@ export async function createBuild({ options, auth, title, description }) {
147
147
  });
148
148
  }
149
149
 
150
+ export async function upgradeBuildProjectLimits({ options, auth, buildId }) {
151
+ return await requestJson({
152
+ method: "PUT",
153
+ url: `${options.apiUrl}/build/${buildId}/project-limit-upgrade`,
154
+ authToken: auth.token,
155
+ body: {},
156
+ timeoutMs: options.timeoutMs,
157
+ });
158
+ }
159
+
150
160
  export async function saveProjectFiles({
151
161
  options,
152
162
  auth,
package/lib/commands.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  saveProjectFiles,
47
47
  suggestBuildThumbnailToOwner,
48
48
  updateBuildMetadata,
49
+ upgradeBuildProjectLimits,
49
50
  } from "./api.js";
50
51
  import { assetsCommand, confirmPrompt, writeAssetsManifest } from "./assets.js";
51
52
  import { thumbnailCommand } from "./thumbnail.js";
@@ -67,6 +68,7 @@ import {
67
68
  defaultReferenceDir,
68
69
  defaultVersionCheckoutDir,
69
70
  defaultWorkspaceDir,
71
+ formatBytes,
70
72
  parseBoolean,
71
73
  resolveBuildId,
72
74
  resolveBuildReference,
@@ -142,6 +144,10 @@ export async function main() {
142
144
  await describeBuild(options);
143
145
  return;
144
146
  }
147
+ if (options.command === "upgrade") {
148
+ await upgradeProject(options);
149
+ return;
150
+ }
145
151
  if (options.command === "projects") {
146
152
  await projects(options);
147
153
  return;
@@ -285,6 +291,61 @@ export async function describeBuild(options) {
285
291
  );
286
292
  }
287
293
 
294
+ export async function upgradeProject(options) {
295
+ if (options.positional?.length > 1) {
296
+ throw new Error("Usage: lumine upgrade [twinkle-build-url-or-id]");
297
+ }
298
+ const explicitTarget = String(options.target || "").trim();
299
+ const explicitBuildId = explicitTarget
300
+ ? resolveRequiredBuildId(explicitTarget)
301
+ : 0;
302
+ if (
303
+ explicitTarget &&
304
+ (!Number.isSafeInteger(explicitBuildId) || explicitBuildId <= 0)
305
+ ) {
306
+ throw new Error("Pass a Twinkle build URL or positive integer build id.");
307
+ }
308
+ const auth = await ensureAuth(options);
309
+ await assertAuthScope({ options, auth, scope: "build:write" });
310
+ // A branch URL already contains canonical Main's id. Let the server resolve
311
+ // numeric branch build ids, but do not require private-project read access
312
+ // merely to translate a reviewer-provided branch URL.
313
+ const buildId = explicitTarget
314
+ ? explicitBuildId
315
+ : await resolveRequiredBuildIdOrSelected(options, auth);
316
+ if (!Number.isSafeInteger(buildId) || buildId <= 0) {
317
+ throw new Error("Pass a Twinkle build URL or positive integer build id.");
318
+ }
319
+ const result = await upgradeBuildProjectLimits({
320
+ options,
321
+ auth,
322
+ buildId,
323
+ });
324
+ const upgradedBuild = result?.build;
325
+ const canonicalBuildId = Number(upgradedBuild?.id || 0);
326
+ const maxFilesPerProject = Number(
327
+ upgradedBuild?.projectLimits?.maxFilesPerProject || 0,
328
+ );
329
+ const maxProjectBytes = Number(
330
+ upgradedBuild?.projectLimits?.maxProjectBytes || 0,
331
+ );
332
+ if (
333
+ result?.success !== true ||
334
+ canonicalBuildId <= 0 ||
335
+ maxFilesPerProject <= 0 ||
336
+ maxProjectBytes <= 0
337
+ ) {
338
+ throw new Error("Twinkle did not return the upgraded project limits.");
339
+ }
340
+ const buildLabel = formatBuildTitle(upgradedBuild);
341
+ const limitsLabel = `${maxFilesPerProject} project files and ${formatBytes(maxProjectBytes)}`;
342
+ console.log(
343
+ result.changed
344
+ ? `Upgraded ${buildLabel} to ${limitsLabel}.`
345
+ : `${buildLabel} already has ${limitsLabel}.`,
346
+ );
347
+ }
348
+
288
349
  async function updateOwnedBuildDetails({
289
350
  options,
290
351
  patch,
@@ -908,7 +969,7 @@ export async function save(options) {
908
969
  buildId = Number(build?.id || buildId);
909
970
  const dir = resolveProjectDirForSave({ options, localProject });
910
971
  const files = await collectProjectFiles(dir);
911
- assertProjectFilesWithinLimits(files);
972
+ assertProjectFilesWithinLimits(files, build?.projectLimits);
912
973
  // Only claim a base snapshot when this workspace was pulled from the same
913
974
  // build we are saving to. Missing filesHash used to mean "unguarded save",
914
975
  // which let stale checkouts silently rewind newer server versions — refuse
@@ -975,12 +1036,25 @@ export async function save(options) {
975
1036
  }
976
1037
  throw error;
977
1038
  }
978
- build = result.build ||
979
- build ||
980
- (await loadBuildMetadata({ options, auth, buildId }).catch(() => null)) || {
1039
+ build = result.build ? { ...build, ...result.build } : build;
1040
+ if (result.copilotPolicy?.limits) {
1041
+ build = {
1042
+ ...build,
1043
+ projectLimits: {
1044
+ maxProjectBytes: result.copilotPolicy.limits.maxProjectBytes,
1045
+ maxFilesPerProject: result.copilotPolicy.limits.maxFilesPerProject,
1046
+ maxFileLines: result.copilotPolicy.limits.maxFileLines,
1047
+ },
1048
+ };
1049
+ }
1050
+ if (!build) {
1051
+ build = (await loadBuildMetadata({ options, auth, buildId }).catch(
1052
+ () => null,
1053
+ )) || {
981
1054
  id: buildId,
982
1055
  title: `Build ${buildId}`,
983
1056
  };
1057
+ }
984
1058
  await saveSelectedBuild({ options, auth, build });
985
1059
  await writeProjectMetadata({
986
1060
  dir,
@@ -1010,9 +1084,10 @@ export async function save(options) {
1010
1084
  }
1011
1085
 
1012
1086
  export async function check(options) {
1013
- await reportLocalProjectFindings(options);
1014
1087
  const auth = await resolveAuth(options);
1015
1088
  const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
1089
+ const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
1090
+ await reportLocalProjectFindings(options, canonicalBuild?.projectLimits);
1016
1091
  const result = await requestJson({
1017
1092
  url: `${options.apiUrl}/cli/build/${buildId}/launch-check`,
1018
1093
  authToken: auth.token,
@@ -1022,9 +1097,13 @@ export async function check(options) {
1022
1097
  if (!result.ok) process.exitCode = 1;
1023
1098
  }
1024
1099
 
1025
- // Local, network-free half of `lumine check`: validate the workspace against
1026
- // the platform's project-file rules so agents see violations before saving.
1027
- export async function reportLocalProjectFindings(options) {
1100
+ // Local half of `lumine check`: validate the workspace against the canonical
1101
+ // project limits fetched by check(), so a newly approved allowance works
1102
+ // immediately without requiring another pull just to refresh local metadata.
1103
+ export async function reportLocalProjectFindings(
1104
+ options,
1105
+ canonicalProjectLimits,
1106
+ ) {
1028
1107
  const localProject = await findLocalProjectMetadata(
1029
1108
  path.resolve(options.dir || process.cwd()),
1030
1109
  );
@@ -1037,7 +1116,10 @@ export async function reportLocalProjectFindings(options) {
1037
1116
  process.exitCode = 1;
1038
1117
  return;
1039
1118
  }
1040
- const { errors, warnings } = collectProjectLimitFindings(files);
1119
+ const { errors, warnings } = collectProjectLimitFindings(
1120
+ files,
1121
+ canonicalProjectLimits || localProject?.metadata?.build?.projectLimits,
1122
+ );
1041
1123
  for (const warning of warnings) {
1042
1124
  console.error(`Local check warning: ${warning}`);
1043
1125
  }
@@ -1171,10 +1253,14 @@ export async function resolveBuildForSave({
1171
1253
  const localBuild = localProject?.metadata?.build;
1172
1254
  const localBuildId =
1173
1255
  Number(localBuild?.id || 0) || Number(localProject?.metadata?.buildId || 0);
1256
+ // Save is already an online operation. Refresh the server-owned Build
1257
+ // metadata before enforcing permissions and project limits so a newly
1258
+ // approved Main allowance (inherited by branches) works without re-pulling.
1259
+ const canonicalBuild = await loadBuildMetadata({ options, auth, buildId });
1174
1260
  const build =
1175
1261
  localBuild && localBuildId === Number(buildId)
1176
- ? { ...localBuild, id: buildId }
1177
- : await loadBuildMetadata({ options, auth, buildId });
1262
+ ? { ...localBuild, ...canonicalBuild, id: buildId }
1263
+ : canonicalBuild;
1178
1264
  return await resolveEditableWorkspaceBuild({
1179
1265
  options,
1180
1266
  auth,
@@ -1281,6 +1367,7 @@ export function normalizeContributionBranchBuild({ branch, sourceBuild }) {
1281
1367
  Number(branch?.contributionContributorId || branch?.userId || 0) || null,
1282
1368
  contributionBranchNumber:
1283
1369
  Number(branch?.contributionBranchNumber || 0) || null,
1370
+ projectLimits: branch?.projectLimits || sourceBuild?.projectLimits || null,
1284
1371
  };
1285
1372
  }
1286
1373
 
@@ -2478,6 +2565,7 @@ export function printHelp() {
2478
2565
  lumine new [title]
2479
2566
  lumine rename [title] [--target <twinkle-build-url-or-id>]
2480
2567
  lumine describe [description] [--target <twinkle-build-url-or-id>]
2568
+ lumine upgrade [twinkle-build-url-or-id]
2481
2569
  lumine projects
2482
2570
  lumine branches [twinkle-build-url-or-id] [--limit <n>]
2483
2571
  lumine suggest branch [message] [--target <twinkle-branch-url>]
@@ -2554,6 +2642,7 @@ Examples:
2554
2642
  npx @stage5/lumine@latest rename "New Title" --target 123
2555
2643
  npx @stage5/lumine@latest describe "A welcoming place to build together"
2556
2644
  npx @stage5/lumine@latest describe --no-description --target 123
2645
+ npx @stage5/lumine@latest upgrade https://www.twin-kle.com/app/123
2557
2646
  npx @stage5/lumine@latest new --title "Daily Reflection App" --description "Private journal with streaks"
2558
2647
  npx @stage5/lumine@latest branches 884
2559
2648
  npx @stage5/lumine@latest suggest branch "Ready for review"
@@ -2599,7 +2688,7 @@ Options:
2599
2688
  --auth-file <path> Saved login path
2600
2689
  --auth-token <token> Override saved login
2601
2690
  --dir <path> Directory for pulled project files
2602
- --target <build> Explicit Build URL or ID for rename/describe
2691
+ --target <build> Explicit Build URL or ID for rename/describe/upgrade
2603
2692
  --main With pull/versions/restore: target the team project's main
2604
2693
  --version <n> With pull: read-only checkout of previous save v<n>
2605
2694
  --title <text> Build title for new/rename
package/lib/constants.js CHANGED
@@ -69,14 +69,12 @@ export const ASSET_MIME_BY_EXTENSION = {
69
69
  ".bin": "application/octet-stream",
70
70
  ".drc": "application/octet-stream",
71
71
  };
72
- // Mirrors the platform's project-file limits. File count and effective-line
73
- // limits are hardcoded server constants; the total-byte limit is
74
- // env-overridable server-side, so the CLI only warns on it.
72
+ // Default platform project-file limits. A pulled workspace can carry larger
73
+ // canonical per-project limits approved on Main in lumine-project.json.
75
74
  export const PROJECT_MAX_FILES = 100;
76
75
  export const PROJECT_MAX_EFFECTIVE_FILE_LINES = 500;
77
76
  export const PROJECT_EFFECTIVE_LINE_MAX_COLUMNS = 160;
78
77
  export const PROJECT_MAX_TOTAL_BYTES_DEFAULT = 1024 * 1024;
79
- export const PROJECT_WARN_TOTAL_BYTES = 500 * 1024;
80
78
  // Matches asset references in project files: absolute CloudFront URLs and
81
79
  // relative /attachments/build-runtime|optimized/... paths.
82
80
  export const RUNTIME_ASSET_REFERENCE_PATTERN =
@@ -233,10 +231,12 @@ lumine save --summary "Describe the change"
233
231
 
234
232
  ## Project File Limits
235
233
 
236
- - Max ${PROJECT_MAX_FILES} files per project; each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
234
+ - Default max ${PROJECT_MAX_FILES} files and ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB per project. Read
235
+ \`.twinkle/lumine-project.json\` for this project's canonical \`projectLimits\`;
236
+ an approved Main project can allow up to 500 files and 5 MB, inherited by branches.
237
+ - Each file max ${PROJECT_MAX_EFFECTIVE_FILE_LINES} effective lines
237
238
  (a physical line counts once per ${PROJECT_EFFECTIVE_LINE_MAX_COLUMNS} characters, so minified one-liners
238
- blow the limit). Default total project size cap ${Math.floor(PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024)} KB,
239
- with a soft warning from ${Math.floor(PROJECT_WARN_TOTAL_BYTES / 1024)} KB.
239
+ blow the limit).
240
240
  - Plan file splits BEFORE writing code; \`lumine check\` validates the local
241
241
  workspace against these limits without saving, and \`lumine save\` fails fast
242
242
  locally listing every violation.
@@ -338,6 +338,7 @@ export const COMMANDS = new Set([
338
338
  "new",
339
339
  "rename",
340
340
  "describe",
341
+ "upgrade",
341
342
  "projects",
342
343
  "branches",
343
344
  "suggest",
package/lib/workspace.js CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  PROJECT_MAX_EFFECTIVE_FILE_LINES,
16
16
  PROJECT_MAX_FILES,
17
17
  PROJECT_MAX_TOTAL_BYTES_DEFAULT,
18
- PROJECT_WARN_TOTAL_BYTES,
19
18
  PROJECT_METADATA_DIR,
20
19
  PROJECT_METADATA_FILE,
21
20
  SDK_REFERENCE_FALLBACK,
@@ -342,16 +341,30 @@ export function countEffectiveLines(value) {
342
341
  );
343
342
  }
344
343
 
345
- // Local mirror of the server's project limits so violations fail fast with
346
- // the full list (the server stops at the first). File count and per-file
347
- // effective lines are hard server constants -> errors; the total-byte limit
348
- // is env-overridable server-side -> warning only, the server stays the gate.
349
- export function collectProjectLimitFindings(files) {
344
+ // Local mirror of the canonical per-project limits returned by the server so
345
+ // violations fail fast with the full list (the server stops at the first).
346
+ export function collectProjectLimitFindings(files, projectLimits = {}) {
350
347
  const errors = [];
351
348
  const warnings = [];
352
- if (files.length > PROJECT_MAX_FILES) {
349
+ const canonicalMaxFiles = Math.floor(
350
+ Number(projectLimits?.maxFilesPerProject || 0),
351
+ );
352
+ const canonicalMaxProjectBytes = Math.floor(
353
+ Number(projectLimits?.maxProjectBytes || 0),
354
+ );
355
+ const maxFilesPerProject =
356
+ canonicalMaxFiles > 0 ? canonicalMaxFiles : PROJECT_MAX_FILES;
357
+ const maxProjectBytes =
358
+ canonicalMaxProjectBytes > 0
359
+ ? canonicalMaxProjectBytes
360
+ : PROJECT_MAX_TOTAL_BYTES_DEFAULT;
361
+ const warnProjectBytes = Math.min(
362
+ Math.floor(maxProjectBytes * 0.8),
363
+ maxProjectBytes - 1,
364
+ );
365
+ if (files.length > maxFilesPerProject) {
353
366
  errors.push(
354
- `Project exceeds the ${PROJECT_MAX_FILES}-file limit (${files.length} files). Split or consolidate files before saving.`,
367
+ `Project exceeds the ${maxFilesPerProject}-file limit (${files.length} files). Split or consolidate files before saving.`,
355
368
  );
356
369
  }
357
370
  for (const file of files) {
@@ -369,26 +382,23 @@ export function collectProjectLimitFindings(files) {
369
382
  total + Buffer.byteLength(String(file.content || ""), "utf8"),
370
383
  0,
371
384
  );
372
- if (totalBytes > PROJECT_MAX_TOTAL_BYTES_DEFAULT) {
373
- warnings.push(
374
- `Project files total ${formatBytes(totalBytes)}, above the default ${Math.floor(
375
- PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
376
- )} KB project limit — the server may reject this save.`,
385
+ if (totalBytes > maxProjectBytes) {
386
+ errors.push(
387
+ `Project files total ${formatBytes(totalBytes)}, above this project's ${formatBytes(maxProjectBytes)} limit.`,
377
388
  );
378
- } else if (totalBytes > PROJECT_WARN_TOTAL_BYTES) {
389
+ } else if (totalBytes > warnProjectBytes) {
379
390
  warnings.push(
380
- `Project files total ${formatBytes(totalBytes)} — approaching the ${Math.floor(
381
- PROJECT_MAX_TOTAL_BYTES_DEFAULT / 1024,
382
- )} KB project cap (soft warning from ${Math.floor(
383
- PROJECT_WARN_TOTAL_BYTES / 1024,
384
- )} KB). Keep files lean where structure allows.`,
391
+ `Project files total ${formatBytes(totalBytes)} — approaching this project's ${formatBytes(maxProjectBytes)} cap. Keep files lean where structure allows.`,
385
392
  );
386
393
  }
387
394
  return { errors, warnings };
388
395
  }
389
396
 
390
- export function assertProjectFilesWithinLimits(files) {
391
- const { errors, warnings } = collectProjectLimitFindings(files);
397
+ export function assertProjectFilesWithinLimits(files, projectLimits = {}) {
398
+ const { errors, warnings } = collectProjectLimitFindings(
399
+ files,
400
+ projectLimits,
401
+ );
392
402
  for (const warning of warnings) {
393
403
  console.error(`lumine: warning — ${warning}`);
394
404
  }
@@ -587,6 +597,10 @@ export async function writeProjectMetadata({
587
597
  build.canPublish !== undefined
588
598
  ? Boolean(build.canPublish)
589
599
  : build.role === "owner",
600
+ projectLimits:
601
+ build.projectLimits && typeof build.projectLimits === "object"
602
+ ? build.projectLimits
603
+ : null,
590
604
  }
591
605
  : null,
592
606
  apiUrl: options.apiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {