@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 +26 -0
- package/lib/api.js +88 -1
- package/lib/assets.js +164 -1
- package/lib/commands.js +354 -3
- package/lib/constants.js +69 -0
- package/lib/thumbnail.js +330 -0
- package/lib/util.js +7 -0
- package/lib/workspace.js +56 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +19 -2
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
|
-
|
|
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) {
|