@stage5/lumine 0.2.5 → 0.2.7

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
@@ -77,6 +77,32 @@ which can differ from a method's `Twinkle.*` SDK return shape — check
77
77
  `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
78
78
  `--allow-write`.
79
79
 
80
+ ## Assets and AI image generation
81
+
82
+ Binary media never lives in the workspace — assets are uploaded to Twinkle and
83
+ referenced from code by URL. `lumine assets upload <file...>` uploads images or
84
+ audio; `lumine assets list` prints your uploads and refreshes
85
+ `.twinkle/assets.json`.
86
+
87
+ `lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>` creates
88
+ an AI-generated image asset instead of uploading one. `--model` is required
89
+ (gpt-image-2 = best quality, slower, pricier; nano-banana = Gemini, faster,
90
+ cheaper). Generation spends your Twinkle AI Battery, so the CLI shows the
91
+ estimated cost and asks for confirmation first — non-interactive runs must pass
92
+ `--yes` to consent. `--quality low|medium|high` applies to gpt-image-2 only.
93
+
94
+ ## Thumbnails
95
+
96
+ `lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
97
+ thumbnail. `lumine thumbnail capture` screenshots the running app server-side
98
+ and sets the result (add `--out <file>` to keep a local copy).
99
+ `lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>`
100
+ generates an AI image and sets it as the thumbnail (the image is also kept as a
101
+ normal reusable asset); without a prompt the server composes one from the build
102
+ title and description. Replacing an existing thumbnail asks for confirmation;
103
+ pass `--yes` for non-interactive runs. Thumbnail commands are blocked in
104
+ read-only `pull --main` checkouts.
105
+
80
106
  The CLI checks npm for the latest `@stage5/lumine` version on normal commands.
81
107
  If the installed copy is outdated, it prints an update warning and records the
82
108
  version state in `.twinkle/lumine-project.json` so local agents can tell when
package/lib/api.js CHANGED
@@ -68,6 +68,29 @@ export async function loadBuildFiles({ options, auth, buildId, includeContent })
68
68
  });
69
69
  }
70
70
 
71
+ export async function loadBuildVersions({ options, auth, buildId, limit }) {
72
+ const url = new URL(`${options.apiUrl}/cli/build/${buildId}/versions`);
73
+ if (limit) url.searchParams.set("limit", String(limit));
74
+ return await requestJson({
75
+ url: url.toString(),
76
+ authToken: auth.token,
77
+ timeoutMs: options.timeoutMs,
78
+ });
79
+ }
80
+
81
+ export async function loadBuildVersionFiles({
82
+ options,
83
+ auth,
84
+ buildId,
85
+ versionNumber,
86
+ }) {
87
+ return await requestJson({
88
+ url: `${options.apiUrl}/cli/build/${buildId}/versions/${versionNumber}/files`,
89
+ authToken: auth.token,
90
+ timeoutMs: options.timeoutMs,
91
+ });
92
+ }
93
+
71
94
  export async function resolveBranchBuild({
72
95
  options,
73
96
  auth,
@@ -219,17 +242,81 @@ export async function buildApiJson({
219
242
  buildApiToken,
220
243
  endpointPath,
221
244
  body,
245
+ timeoutMs,
222
246
  }) {
223
247
  return requestJson({
224
248
  method: "POST",
225
249
  url: `${options.apiUrl}/build/${buildId}/${endpointPath}`,
226
250
  authToken: auth.token,
227
251
  body: body || {},
228
- timeoutMs: options.timeoutMs,
252
+ timeoutMs: timeoutMs || options.timeoutMs,
229
253
  headers: { "x-build-api-token": buildApiToken },
230
254
  });
231
255
  }
232
256
 
257
+ export async function requestThumbnailSignedUpload({
258
+ options,
259
+ auth,
260
+ buildId,
261
+ fileSize,
262
+ contentType,
263
+ }) {
264
+ return requestJson({
265
+ method: "POST",
266
+ url: `${options.apiUrl}/build/${buildId}/thumbnail`,
267
+ authToken: auth.token,
268
+ body: { fileSize, contentType },
269
+ timeoutMs: options.timeoutMs,
270
+ });
271
+ }
272
+
273
+ export async function updateBuildThumbnailUrl({
274
+ options,
275
+ auth,
276
+ buildId,
277
+ thumbnailUrl,
278
+ }) {
279
+ return requestJson({
280
+ method: "PUT",
281
+ url: `${options.apiUrl}/build/${buildId}`,
282
+ authToken: auth.token,
283
+ body: { thumbnailUrl },
284
+ timeoutMs: options.timeoutMs,
285
+ });
286
+ }
287
+
288
+ export async function captureBuildThumbnailPreview({
289
+ options,
290
+ auth,
291
+ buildId,
292
+ previewPath,
293
+ timeoutMs,
294
+ }) {
295
+ return requestJson({
296
+ method: "POST",
297
+ url: `${options.apiUrl}/build/${buildId}/thumbnail/capture-preview`,
298
+ authToken: auth.token,
299
+ body: { previewPath },
300
+ timeoutMs: timeoutMs || options.timeoutMs,
301
+ });
302
+ }
303
+
304
+ export async function generateBuildThumbnailRequest({
305
+ options,
306
+ auth,
307
+ buildId,
308
+ body,
309
+ timeoutMs,
310
+ }) {
311
+ return requestJson({
312
+ method: "POST",
313
+ url: `${options.apiUrl}/build/${buildId}/thumbnail/generate`,
314
+ authToken: auth.token,
315
+ body: body || {},
316
+ timeoutMs: timeoutMs || options.timeoutMs,
317
+ });
318
+ }
319
+
233
320
  export async function fetchAllRuntimeAssets({
234
321
  options,
235
322
  auth,
package/lib/assets.js CHANGED
@@ -4,9 +4,12 @@ import readline from "readline/promises";
4
4
  import { stdin as input, stdout as output } from "process";
5
5
 
6
6
  import {
7
+ ASSET_GENERATE_TIMEOUT_MS,
7
8
  ASSET_MIME_BY_EXTENSION,
8
9
  ASSET_UPLOAD_CHUNK_BYTES,
9
10
  ASSETS_METADATA_FILE,
11
+ GENERATE_MODEL_ALIASES,
12
+ GENERATE_QUALITIES,
10
13
  PROJECT_METADATA_DIR,
11
14
  RUNTIME_ASSET_REFERENCE_PATTERN,
12
15
  } from "./constants.js";
@@ -48,9 +51,169 @@ export async function assetsCommand(options) {
48
51
  await assetsPrune(options);
49
52
  return;
50
53
  }
54
+ if (subcommand === "generate") {
55
+ await assetsGenerate(options);
56
+ return;
57
+ }
51
58
  throw new Error(
52
- "Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets delete <assetId> | lumine assets prune [--yes]",
59
+ 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes]',
60
+ );
61
+ }
62
+
63
+ export function resolveGenerateModel(options) {
64
+ const rawModel = String(options.model || "").trim();
65
+ const model = GENERATE_MODEL_ALIASES[rawModel];
66
+ if (!model) {
67
+ throw new Error(
68
+ "--model is required: gpt-image-2 (best quality, slower, pricier) or nano-banana (Gemini — faster, cheaper). No default model is applied.",
69
+ );
70
+ }
71
+ const rawQuality = String(options.quality || "").trim();
72
+ if (rawQuality && !GENERATE_QUALITIES.has(rawQuality)) {
73
+ throw new Error("--quality must be low, medium, or high.");
74
+ }
75
+ if (rawQuality && model !== "gpt-image-2") {
76
+ throw new Error(
77
+ "--quality only applies to gpt-image-2; nano-banana has a single quality tier.",
78
+ );
79
+ }
80
+ return { model, quality: rawQuality || null };
81
+ }
82
+
83
+ export function formatBatteryPercent(energyUnits, fullBatteryUnits) {
84
+ const total = Number(fullBatteryUnits) || 1_000_000;
85
+ const percent = ((Number(energyUnits) || 0) / total) * 100;
86
+ if (percent <= 0) return "~0%";
87
+ if (percent < 1) return "<1%";
88
+ return `~${Math.round(percent)}%`;
89
+ }
90
+
91
+ export function describeGenerateHttpError(error) {
92
+ const code = String(error?.data?.code || "").trim();
93
+ if (error?.status === 429 && code === "ai_energy_depleted") {
94
+ return "Your AI battery is empty. Recharge on Twinkle or try again tomorrow.";
95
+ }
96
+ if (error?.status === 409) {
97
+ return "Another image generation is already running for your account — wait for it to finish, then retry.";
98
+ }
99
+ if (code === "build_runtime_file_storage_limit_exceeded") {
100
+ return `${error?.data?.error || "Storage quota exceeded."} Free space with \`lumine assets prune\`.`;
101
+ }
102
+ if (error?.status === 404) {
103
+ return "Twinkle API does not support asset generation yet (deploy the latest API first).";
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export async function assetsGenerate(options) {
109
+ const prompt = String(options.positional[1] || "").trim();
110
+ if (!prompt) {
111
+ throw new Error(
112
+ 'Usage: lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana> [--quality low|medium|high] [--name <fileName>] [--yes]',
113
+ );
114
+ }
115
+ const { model, quality } = resolveGenerateModel(options);
116
+ const fileName = String(options.assetName || "").trim();
117
+ const buildId = await resolveSdkBuildId(options);
118
+ const auth = await ensureAuth(options);
119
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
120
+ const { token } = await mintBuildApiToken({
121
+ options,
122
+ auth,
123
+ buildId,
124
+ scopes: ["files:read", "files:write"],
125
+ });
126
+
127
+ const requestBody = {
128
+ prompt,
129
+ model,
130
+ ...(quality ? { quality } : {}),
131
+ ...(fileName ? { fileName } : {}),
132
+ };
133
+
134
+ if (!options.assumeYes) {
135
+ let estimate = null;
136
+ try {
137
+ const estimateResult = await buildApiJson({
138
+ options,
139
+ auth,
140
+ buildId,
141
+ buildApiToken: token,
142
+ endpointPath: "api/assets/generate",
143
+ body: { ...requestBody, estimateOnly: true },
144
+ });
145
+ estimate = estimateResult?.estimate || null;
146
+ } catch (error) {
147
+ const friendly = describeGenerateHttpError(error);
148
+ if (friendly) throw new Error(friendly);
149
+ throw error;
150
+ }
151
+ const selectedOption = (estimate?.options || []).find(
152
+ (option) => option.model === model,
153
+ );
154
+ const costLine = selectedOption
155
+ ? ` Estimated battery cost: ${formatBatteryPercent(selectedOption.energyUnits, estimate?.fullBatteryUnits)} of a full AI battery (~$${Number(selectedOption.estimatedUsd || 0).toFixed(2)})`
156
+ : " Estimated battery cost: unavailable";
157
+ const remainingLine = estimate
158
+ ? ` Battery remaining now: ${formatBatteryPercent(estimate.energyRemaining, estimate.fullBatteryUnits)}`
159
+ : "";
160
+ console.log(
161
+ `Generate 1 image with ${model}${quality ? ` (quality ${quality})` : ""}?`,
162
+ );
163
+ console.log(costLine);
164
+ if (remainingLine) console.log(remainingLine);
165
+ const confirmed = await confirmPrompt("Proceed? [y/N] ");
166
+ if (confirmed === null) {
167
+ console.log("Not a TTY — re-run with --yes to generate.");
168
+ return;
169
+ }
170
+ if (!confirmed) {
171
+ console.log("Aborted. Nothing generated.");
172
+ return;
173
+ }
174
+ }
175
+
176
+ console.log("Generating image... this can take a minute.");
177
+ let result;
178
+ try {
179
+ result = await buildApiJson({
180
+ options,
181
+ auth,
182
+ buildId,
183
+ buildApiToken: token,
184
+ endpointPath: "api/assets/generate",
185
+ body: requestBody,
186
+ timeoutMs: ASSET_GENERATE_TIMEOUT_MS,
187
+ });
188
+ } catch (error) {
189
+ const friendly = describeGenerateHttpError(error);
190
+ if (friendly) throw new Error(friendly);
191
+ throw error;
192
+ }
193
+ const asset = result?.asset;
194
+ if (!asset?.url) {
195
+ throw new Error("Generation finished but no asset was returned.");
196
+ }
197
+ console.log(
198
+ `Generated ${asset.originalFileName || asset.fileName} (${formatBytes(asset.sizeBytes)}) as asset #${asset.id}`,
53
199
  );
200
+ console.log(` ${asset.url}`);
201
+ const remaining = result?.aiUsagePolicy?.energyRemaining;
202
+ if (Number.isFinite(Number(remaining))) {
203
+ console.log(
204
+ `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
205
+ );
206
+ }
207
+ console.log(
208
+ "Reference this URL from your project files (e.g. a src/assets.js module).",
209
+ );
210
+ await refreshWorkspaceAssetsManifest({
211
+ options,
212
+ auth,
213
+ buildId,
214
+ buildApiToken: token,
215
+ });
216
+ return asset;
54
217
  }
55
218
 
56
219
  export async function assetsList(options) {