@stage5/lumine 0.2.91 → 0.2.94
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 +31 -0
- package/lib/admin.js +599 -83
- package/lib/api.js +58 -0
- package/lib/app-mcp/server.js +135 -23
- package/lib/assets.js +93 -2
- package/lib/cardcraft.js +141 -0
- package/lib/commands.js +17 -0
- package/lib/constants.js +6 -0
- package/lib/rewards.js +3 -1
- package/lib/util.js +28 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +35 -6
- package/sdk/LUMINE_ADMIN.md +28 -0
package/lib/api.js
CHANGED
|
@@ -94,6 +94,38 @@ export async function createAppMcpSession({ options, auth, buildId }) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
export async function claimAppMcpPairing({
|
|
98
|
+
options,
|
|
99
|
+
auth,
|
|
100
|
+
buildId,
|
|
101
|
+
code,
|
|
102
|
+
helperName,
|
|
103
|
+
}) {
|
|
104
|
+
return await requestJson({
|
|
105
|
+
method: "POST",
|
|
106
|
+
url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/pairings/claim`,
|
|
107
|
+
authToken: auth.token,
|
|
108
|
+
body: { code, helperName: helperName || null },
|
|
109
|
+
timeoutMs: options.timeoutMs,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function setAppMcpHelperName({
|
|
114
|
+
options,
|
|
115
|
+
auth,
|
|
116
|
+
buildId,
|
|
117
|
+
sessionId,
|
|
118
|
+
helperName,
|
|
119
|
+
}) {
|
|
120
|
+
return await requestJson({
|
|
121
|
+
method: "POST",
|
|
122
|
+
url: `${options.apiUrl}/cli/build/${buildId}/app-mcp/sessions/${sessionId}/helper`,
|
|
123
|
+
authToken: auth.token,
|
|
124
|
+
body: { helperName },
|
|
125
|
+
timeoutMs: options.timeoutMs,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
97
129
|
export async function createAppMcpCall({
|
|
98
130
|
options,
|
|
99
131
|
auth,
|
|
@@ -288,6 +320,32 @@ export async function upgradeBuildProjectLimits({ options, auth, buildId }) {
|
|
|
288
320
|
});
|
|
289
321
|
}
|
|
290
322
|
|
|
323
|
+
export async function getRuntimeStorageLimit({ options, auth }) {
|
|
324
|
+
return await requestJson({
|
|
325
|
+
url: `${options.apiUrl}/build/runtime-storage-limit`,
|
|
326
|
+
authToken: auth.token,
|
|
327
|
+
timeoutMs: options.timeoutMs,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function requestRuntimeStorageLimit({
|
|
332
|
+
options,
|
|
333
|
+
auth,
|
|
334
|
+
requestedBytes,
|
|
335
|
+
reason,
|
|
336
|
+
}) {
|
|
337
|
+
return await requestJson({
|
|
338
|
+
method: "POST",
|
|
339
|
+
url: `${options.apiUrl}/build/runtime-storage-limit-request`,
|
|
340
|
+
authToken: auth.token,
|
|
341
|
+
body: {
|
|
342
|
+
...(requestedBytes ? { requestedBytes } : {}),
|
|
343
|
+
...(reason ? { reason } : {}),
|
|
344
|
+
},
|
|
345
|
+
timeoutMs: options.timeoutMs,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
export async function saveProjectFiles({
|
|
292
350
|
options,
|
|
293
351
|
auth,
|
package/lib/app-mcp/server.js
CHANGED
|
@@ -2,10 +2,12 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import readline from "node:readline";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
claimAppMcpPairing,
|
|
5
6
|
closeAppMcpSession,
|
|
6
7
|
createAppMcpCall,
|
|
7
8
|
createAppMcpSession,
|
|
8
9
|
loadAppMcpCall,
|
|
10
|
+
setAppMcpHelperName,
|
|
9
11
|
} from "../api.js";
|
|
10
12
|
import { assertAuthScope, resolveAuth } from "../auth.js";
|
|
11
13
|
import { resolveRequiredBuildId } from "../util.js";
|
|
@@ -18,6 +20,77 @@ function delay(ms) {
|
|
|
18
20
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
const PAIRED_SESSION_ENDED_MESSAGE =
|
|
24
|
+
'The AI helper connection ended (the user clicked Disconnect or left the app). Ask them to click "Connect AI helper" in the app for a new code, then restart with lumine app-mcp <buildId> --code <code>.';
|
|
25
|
+
|
|
26
|
+
function rethrowPairedSessionError(error) {
|
|
27
|
+
if (
|
|
28
|
+
Number(error?.status || 0) === 404 &&
|
|
29
|
+
/MCP session not found/i.test(String(error?.message || ""))
|
|
30
|
+
) {
|
|
31
|
+
const ended = new Error(PAIRED_SESSION_ENDED_MESSAGE);
|
|
32
|
+
ended.status = 404;
|
|
33
|
+
throw ended;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MCP clients name themselves in initialize (clientInfo.name). This is only a
|
|
39
|
+
// label for the viewer's "AI helper connected" badge.
|
|
40
|
+
export function helperDisplayName(clientInfo) {
|
|
41
|
+
const raw = String(clientInfo?.name || "").trim();
|
|
42
|
+
if (!raw) return "";
|
|
43
|
+
const known = [
|
|
44
|
+
[/^claude[-_ ]?code/i, "Claude Code"],
|
|
45
|
+
[/^codex/i, "Codex"],
|
|
46
|
+
[/^claude/i, "Claude"],
|
|
47
|
+
[/^cursor/i, "Cursor"],
|
|
48
|
+
];
|
|
49
|
+
for (const [pattern, label] of known) {
|
|
50
|
+
if (pattern.test(raw)) return label;
|
|
51
|
+
}
|
|
52
|
+
return raw.slice(0, 80);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read-only declarations travel to the MCP client as the standard
|
|
56
|
+
// readOnlyHint, plus a plain note for tools whose action decides.
|
|
57
|
+
export function toMcpTool(tool) {
|
|
58
|
+
const readOnlyWhen =
|
|
59
|
+
tool.readOnlyWhen && typeof tool.readOnlyWhen === "object"
|
|
60
|
+
? Object.entries(tool.readOnlyWhen)[0]
|
|
61
|
+
: null;
|
|
62
|
+
const note =
|
|
63
|
+
readOnlyWhen && Array.isArray(readOnlyWhen[1])
|
|
64
|
+
? ` (Works without "Allow edits" when ${readOnlyWhen[0]} is ${readOnlyWhen[1].join(", ")}.)`
|
|
65
|
+
: "";
|
|
66
|
+
return {
|
|
67
|
+
name: tool.name,
|
|
68
|
+
description: `${tool.description || ""}${note}`.trim(),
|
|
69
|
+
inputSchema: tool.inputSchema || {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
...(tool.readOnly === true ? { annotations: { readOnlyHint: true } } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pairedInstructions(session) {
|
|
78
|
+
const readTools = (session.manifest?.tools || [])
|
|
79
|
+
.filter((tool) => tool.readOnly === true)
|
|
80
|
+
.map((tool) => tool.name);
|
|
81
|
+
return [
|
|
82
|
+
session.manifest?.description ||
|
|
83
|
+
`Use the semantic tools exposed by ${session.buildTitle}.`,
|
|
84
|
+
`You are attached to the user's own open ${session.buildTitle} tab: they see every change you make, live.`,
|
|
85
|
+
"Start with a read-only state tool; what the user has selected or is looking at is usually in it.",
|
|
86
|
+
session.allowEdits
|
|
87
|
+
? "Edits are allowed right now; the user can switch them off at any time."
|
|
88
|
+
: `Edits are OFF until the user switches on "Allow edits" in the app. Until then only read-only calls work${
|
|
89
|
+
readTools.length ? ` (${readTools.join(", ")})` : ""
|
|
90
|
+
}; a write returns an "edits not allowed" error, so ask the user to switch it on.`,
|
|
91
|
+
].join(" ");
|
|
92
|
+
}
|
|
93
|
+
|
|
21
94
|
function rethrowAppMcpSessionError(error) {
|
|
22
95
|
const message = String(error?.message || error);
|
|
23
96
|
if (
|
|
@@ -56,12 +129,23 @@ function openApp(url) {
|
|
|
56
129
|
export async function appMcpCommand(options) {
|
|
57
130
|
const buildId = resolveRequiredBuildId(options.target || options.buildIdFlag);
|
|
58
131
|
if (!buildId) {
|
|
59
|
-
throw new Error(
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Usage: lumine app-mcp <published-app-url-or-id> [--code <code>]",
|
|
134
|
+
);
|
|
60
135
|
}
|
|
61
136
|
const auth = await resolveAuth(options);
|
|
62
137
|
await assertAuthScope({ options, auth, scope: "build:read" });
|
|
63
138
|
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
64
|
-
const
|
|
139
|
+
const paired = Boolean(options.pairingCode);
|
|
140
|
+
const created = paired
|
|
141
|
+
? await claimAppMcpPairing({
|
|
142
|
+
options,
|
|
143
|
+
auth,
|
|
144
|
+
buildId,
|
|
145
|
+
code: options.pairingCode,
|
|
146
|
+
helperName: options.helperName || null,
|
|
147
|
+
})
|
|
148
|
+
: await createAppMcpSession({ options, auth, buildId });
|
|
65
149
|
const session = created?.session;
|
|
66
150
|
if (!session?.id || !session?.manifest?.tools?.length) {
|
|
67
151
|
if (session?.id) {
|
|
@@ -75,26 +159,28 @@ export async function appMcpCommand(options) {
|
|
|
75
159
|
throw new Error("Twinkle did not return an app MCP session.");
|
|
76
160
|
}
|
|
77
161
|
try {
|
|
78
|
-
if (
|
|
162
|
+
if (paired) {
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`lumine app-mcp: attached to your open ${session.buildTitle} tab. ` +
|
|
165
|
+
(session.allowEdits
|
|
166
|
+
? "Edits are allowed.\n"
|
|
167
|
+
: 'Edits stay off until you switch on "Allow edits" in the app.\n'),
|
|
168
|
+
);
|
|
169
|
+
} else if (options.openBrowser !== false) {
|
|
79
170
|
openApp(session.appUrl);
|
|
80
171
|
} else {
|
|
81
172
|
process.stderr.write(
|
|
82
173
|
`lumine app-mcp: open this signed-in app tab: ${session.appUrl}\n`,
|
|
83
174
|
);
|
|
84
175
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`
|
|
88
|
-
|
|
176
|
+
if (!paired) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`lumine app-mcp: ${session.buildTitle} is pinned to artifact ${session.artifactVersionId}. ` +
|
|
179
|
+
`Keep the opened Twinkle tab running.\n`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
89
182
|
|
|
90
|
-
const tools = session.manifest.tools.map(
|
|
91
|
-
name: tool.name,
|
|
92
|
-
description: tool.description || "",
|
|
93
|
-
inputSchema: tool.inputSchema || {
|
|
94
|
-
type: "object",
|
|
95
|
-
additionalProperties: false,
|
|
96
|
-
},
|
|
97
|
-
}));
|
|
183
|
+
const tools = session.manifest.tools.map(toMcpTool);
|
|
98
184
|
const input = readline.createInterface({
|
|
99
185
|
input: process.stdin,
|
|
100
186
|
crlfDelay: Infinity,
|
|
@@ -112,6 +198,7 @@ export async function appMcpCommand(options) {
|
|
|
112
198
|
buildId,
|
|
113
199
|
session,
|
|
114
200
|
tools,
|
|
201
|
+
paired,
|
|
115
202
|
});
|
|
116
203
|
let isToolCall = false;
|
|
117
204
|
try {
|
|
@@ -152,7 +239,9 @@ async function callAppTool({
|
|
|
152
239
|
sessionId,
|
|
153
240
|
name,
|
|
154
241
|
arguments: toolArguments,
|
|
242
|
+
paired = false,
|
|
155
243
|
}) {
|
|
244
|
+
const rethrow = paired ? rethrowPairedSessionError : rethrowAppMcpSessionError;
|
|
156
245
|
const created = await createAppMcpCall({
|
|
157
246
|
options,
|
|
158
247
|
auth,
|
|
@@ -160,7 +249,7 @@ async function callAppTool({
|
|
|
160
249
|
sessionId,
|
|
161
250
|
name,
|
|
162
251
|
arguments: toolArguments,
|
|
163
|
-
}).catch(
|
|
252
|
+
}).catch(rethrow);
|
|
164
253
|
const callId = created?.call?.id;
|
|
165
254
|
if (!callId) throw new Error("Twinkle did not create the app tool call.");
|
|
166
255
|
const deadline = Date.now() + CALL_TIMEOUT_MS;
|
|
@@ -171,7 +260,7 @@ async function callAppTool({
|
|
|
171
260
|
buildId,
|
|
172
261
|
sessionId,
|
|
173
262
|
callId,
|
|
174
|
-
}).catch(
|
|
263
|
+
}).catch(rethrow);
|
|
175
264
|
const call = payload?.call;
|
|
176
265
|
if (call?.status === "completed") return call.result;
|
|
177
266
|
if (call?.status === "failed") {
|
|
@@ -185,7 +274,7 @@ async function callAppTool({
|
|
|
185
274
|
buildId,
|
|
186
275
|
sessionId,
|
|
187
276
|
callId,
|
|
188
|
-
}).catch(
|
|
277
|
+
}).catch(rethrow);
|
|
189
278
|
if (finalPayload?.call?.status === "completed") {
|
|
190
279
|
return finalPayload.call.result;
|
|
191
280
|
}
|
|
@@ -193,11 +282,21 @@ async function callAppTool({
|
|
|
193
282
|
throw new Error(finalPayload.call.errorMessage || "App tool failed.");
|
|
194
283
|
}
|
|
195
284
|
throw new Error(
|
|
196
|
-
|
|
285
|
+
paired
|
|
286
|
+
? "App tool call timed out. Ask the user to keep the app tab open in front (background tabs can pause), then retry."
|
|
287
|
+
: "App tool call timed out. Reload the appUrl tab, or restart the Lumine app-mcp driver and reopen its new appUrl.",
|
|
197
288
|
);
|
|
198
289
|
}
|
|
199
290
|
|
|
200
|
-
async function handleMcpMessage({
|
|
291
|
+
async function handleMcpMessage({
|
|
292
|
+
line,
|
|
293
|
+
options,
|
|
294
|
+
auth,
|
|
295
|
+
buildId,
|
|
296
|
+
session,
|
|
297
|
+
tools,
|
|
298
|
+
paired = false,
|
|
299
|
+
}) {
|
|
201
300
|
let message;
|
|
202
301
|
try {
|
|
203
302
|
message = JSON.parse(line);
|
|
@@ -208,6 +307,17 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
|
|
|
208
307
|
const method = String(message?.method || "");
|
|
209
308
|
if (id === undefined || id === null) return;
|
|
210
309
|
if (method === "initialize") {
|
|
310
|
+
const helperName = helperDisplayName(message?.params?.clientInfo);
|
|
311
|
+
if (paired && helperName && !options.helperName) {
|
|
312
|
+
// Label only; a failure here must never break the MCP handshake.
|
|
313
|
+
await setAppMcpHelperName({
|
|
314
|
+
options,
|
|
315
|
+
auth,
|
|
316
|
+
buildId,
|
|
317
|
+
sessionId: session.id,
|
|
318
|
+
helperName,
|
|
319
|
+
}).catch(() => {});
|
|
320
|
+
}
|
|
211
321
|
return writeMcpResult(id, {
|
|
212
322
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
213
323
|
capabilities: { tools: { listChanged: false } },
|
|
@@ -215,9 +325,10 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
|
|
|
215
325
|
name: `lumine-app-${buildId}`,
|
|
216
326
|
version: "1.0.0",
|
|
217
327
|
},
|
|
218
|
-
instructions:
|
|
219
|
-
session
|
|
220
|
-
|
|
328
|
+
instructions: paired
|
|
329
|
+
? pairedInstructions(session)
|
|
330
|
+
: session.manifest.description ||
|
|
331
|
+
`Use the semantic tools exposed by ${session.buildTitle}.`,
|
|
221
332
|
});
|
|
222
333
|
}
|
|
223
334
|
if (method === "ping") return writeMcpResult(id, {});
|
|
@@ -235,6 +346,7 @@ async function handleMcpMessage({ line, options, auth, buildId, session, tools }
|
|
|
235
346
|
sessionId: session.id,
|
|
236
347
|
name,
|
|
237
348
|
arguments: message?.params?.arguments || {},
|
|
349
|
+
paired,
|
|
238
350
|
});
|
|
239
351
|
return writeMcpResult(id, {
|
|
240
352
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
package/lib/assets.js
CHANGED
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
buildApiJson,
|
|
18
18
|
fetchAllRuntimeAssets,
|
|
19
|
+
getRuntimeStorageLimit,
|
|
19
20
|
loadBuildFiles,
|
|
20
21
|
mintBuildApiToken,
|
|
22
|
+
requestRuntimeStorageLimit,
|
|
21
23
|
} from "./api.js";
|
|
22
24
|
import { assertAuthScope, ensureAuth } from "./auth.js";
|
|
23
25
|
import { resolveSdkBuildId } from "./sdk.js";
|
|
24
|
-
import { formatBytes, sleep } from "./util.js";
|
|
26
|
+
import { formatBytes, parseStorageSizeBytes, sleep } from "./util.js";
|
|
25
27
|
import {
|
|
26
28
|
collectWorkspaceTextFilesLenient,
|
|
27
29
|
findLocalProjectMetadata,
|
|
@@ -55,8 +57,97 @@ export async function assetsCommand(options) {
|
|
|
55
57
|
await assetsGenerate(options);
|
|
56
58
|
return;
|
|
57
59
|
}
|
|
60
|
+
if (subcommand === "storage") {
|
|
61
|
+
await assetsStorage(options);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (subcommand === "request-storage") {
|
|
65
|
+
await assetsRequestStorage(options);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
58
68
|
throw new Error(
|
|
59
|
-
'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes]',
|
|
69
|
+
'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes] | lumine assets storage | lumine assets request-storage [--size 500MB|1GB|2GB] [--reason "<what it is for>"]',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Lumine file storage is counted per user across all their Builds. Past the
|
|
74
|
+
// default quota, Mikey approves more (like the per-project file-limit unlock).
|
|
75
|
+
export function formatStorageApproval(approval) {
|
|
76
|
+
if (!approval) return [];
|
|
77
|
+
const lines = [
|
|
78
|
+
`Storage limit: ${formatBytes(approval.maxRuntimeFileStorageBytes)}${approval.hasOverride ? " (approved by Mikey)" : ""} · ${formatBytes(approval.runtimeFileStorageBytes || 0)} used across ${Number(approval.runtimeFileCount || 0)} file(s) in all your builds`,
|
|
79
|
+
];
|
|
80
|
+
const latest = approval.latestRequest;
|
|
81
|
+
if (latest?.status === "pending") {
|
|
82
|
+
lines.push(
|
|
83
|
+
`Request #${latest.requestId} for ${formatBytes(latest.requestedMaxRuntimeFileStorageBytes)} is waiting for Mikey's review.`,
|
|
84
|
+
);
|
|
85
|
+
} else if (latest?.status === "rejected") {
|
|
86
|
+
lines.push(
|
|
87
|
+
`Request #${latest.requestId} was declined${latest.reviewReason ? `: ${latest.reviewReason}` : "."}`,
|
|
88
|
+
);
|
|
89
|
+
} else if (latest?.status === "approved") {
|
|
90
|
+
lines.push(
|
|
91
|
+
`Request #${latest.requestId} was approved (${formatBytes(latest.approvedMaxRuntimeFileStorageBytes || latest.requestedMaxRuntimeFileStorageBytes)}).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (approval.canRequest && latest?.status !== "pending") {
|
|
95
|
+
lines.push(
|
|
96
|
+
`Need more? \`lumine assets request-storage --size <${(approval.requestTiers || []).map((tier) => formatBytes(tier).replace(".0 ", "")).join("|")}> --reason "<what it is for>"\` asks Mikey.`,
|
|
97
|
+
);
|
|
98
|
+
} else if (!approval.canRequest) {
|
|
99
|
+
lines.push("This is the most Lumine file storage that can be approved.");
|
|
100
|
+
}
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function assetsStorage(options) {
|
|
105
|
+
const auth = await ensureAuth(options);
|
|
106
|
+
await assertAuthScope({ options, auth, scope: "build:read" });
|
|
107
|
+
const result = await getRuntimeStorageLimit({ options, auth });
|
|
108
|
+
const approval = result?.storageLimitApproval;
|
|
109
|
+
if (!approval) {
|
|
110
|
+
throw new Error("Twinkle did not return your storage limit.");
|
|
111
|
+
}
|
|
112
|
+
if (options.json) {
|
|
113
|
+
console.log(JSON.stringify(approval, null, 2));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const line of formatStorageApproval(approval)) console.log(line);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function assetsRequestStorage(options) {
|
|
120
|
+
const requestedBytes = options.storageSize
|
|
121
|
+
? parseStorageSizeBytes(options.storageSize)
|
|
122
|
+
: null;
|
|
123
|
+
const reason = String(options.adminReason || "").trim();
|
|
124
|
+
const auth = await ensureAuth(options);
|
|
125
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
126
|
+
const result = await requestRuntimeStorageLimit({
|
|
127
|
+
options,
|
|
128
|
+
auth,
|
|
129
|
+
requestedBytes,
|
|
130
|
+
reason,
|
|
131
|
+
});
|
|
132
|
+
const request = result?.request;
|
|
133
|
+
const approval = result?.storageLimitApproval;
|
|
134
|
+
if (result?.success !== true || !request) {
|
|
135
|
+
throw new Error("Twinkle did not confirm the storage request.");
|
|
136
|
+
}
|
|
137
|
+
if (options.json) {
|
|
138
|
+
console.log(JSON.stringify({ request, storageLimitApproval: approval }, null, 2));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (request.status === "approved") {
|
|
142
|
+
console.log(
|
|
143
|
+
`Approved: your Lumine file storage is now ${formatBytes(approval?.maxRuntimeFileStorageBytes || request.approvedMaxRuntimeFileStorageBytes)}.`,
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
console.log(
|
|
148
|
+
result.changed
|
|
149
|
+
? `Sent storage request #${request.requestId} for ${formatBytes(request.requestedMaxRuntimeFileStorageBytes)} to Mikey. Nothing changes until he approves it.`
|
|
150
|
+
: `Storage request #${request.requestId} for ${formatBytes(request.requestedMaxRuntimeFileStorageBytes)} is already waiting for Mikey.`,
|
|
60
151
|
);
|
|
61
152
|
}
|
|
62
153
|
|
package/lib/cardcraft.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
import { requestJson } from "./http.js";
|
|
5
|
+
import { findLocalProjectMetadata } from "./workspace.js";
|
|
6
|
+
import { ensureAuth, assertAuthScope } from "./auth.js";
|
|
7
|
+
import { resolveRequiredBuildIdOrSelected } from "./commands.js";
|
|
8
|
+
|
|
9
|
+
// Creator-side AI Card crafting tooling (Twinkle.cardCraft).
|
|
10
|
+
//
|
|
11
|
+
// An app declares what AI Cards can become in it in `cardcraft.json` at the
|
|
12
|
+
// project root: kinds (pet, snack, furniture …) with their parameters, and
|
|
13
|
+
// which kinds each card colour (1 blue … 6 black) can become. A reviewer
|
|
14
|
+
// approves the recipe; the approved copy is what crafting uses, so editing
|
|
15
|
+
// the file changes nothing live until the next approval.
|
|
16
|
+
|
|
17
|
+
const CARDCRAFT_FILE = "cardcraft.json";
|
|
18
|
+
|
|
19
|
+
async function readWorkspaceCardCraftJson(options) {
|
|
20
|
+
const localProject = await findLocalProjectMetadata(
|
|
21
|
+
path.resolve(options.dir || process.cwd()),
|
|
22
|
+
);
|
|
23
|
+
if (!localProject?.rootDir) return { present: false, value: undefined };
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = await fs.readFile(path.join(localProject.rootDir, CARDCRAFT_FILE), "utf8");
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === "ENOENT") return { present: false, value: undefined };
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return { present: true, value: JSON.parse(raw) };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`${CARDCRAFT_FILE} is not valid JSON: ${error?.message || error}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printCheck(result) {
|
|
39
|
+
if (!result?.ok) {
|
|
40
|
+
console.log("Card crafting recipe: NOT ready.");
|
|
41
|
+
for (const error of result?.errors || []) console.log(` - ${error}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const summary = result.summary || {};
|
|
45
|
+
console.log(
|
|
46
|
+
`Card crafting recipe: ok (${(summary.kinds || []).length} kind(s); card colours ${(summary.acceptedLevels || []).join(", ") || "none"}).`,
|
|
47
|
+
);
|
|
48
|
+
for (const kind of summary.kinds || [])
|
|
49
|
+
console.log(` ${kind.id}: ${kind.label} · params ${kind.params.join(", ") || "none"}`);
|
|
50
|
+
if (result.nextStep) console.log(result.nextStep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function cardcraftCommand(options) {
|
|
54
|
+
const action = String(options.positional?.[0] || "check");
|
|
55
|
+
if (options.help) {
|
|
56
|
+
printCardCraftHelp();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const auth = await ensureAuth(options);
|
|
60
|
+
const buildId = await resolveRequiredBuildIdOrSelected(options, auth);
|
|
61
|
+
if (action === "check") {
|
|
62
|
+
const local = await readWorkspaceCardCraftJson(options);
|
|
63
|
+
const result = await requestJson({
|
|
64
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft/check`,
|
|
65
|
+
method: "POST",
|
|
66
|
+
authToken: auth.token,
|
|
67
|
+
timeoutMs: options.timeoutMs,
|
|
68
|
+
body: local.present ? { cardcraftJson: local.value } : {},
|
|
69
|
+
});
|
|
70
|
+
if (options.json) {
|
|
71
|
+
console.log(JSON.stringify(result, null, 2));
|
|
72
|
+
} else {
|
|
73
|
+
console.log(
|
|
74
|
+
local.present
|
|
75
|
+
? `Checked ${CARDCRAFT_FILE} from this workspace.`
|
|
76
|
+
: `No ${CARDCRAFT_FILE} in this workspace; checked the saved version instead.`,
|
|
77
|
+
);
|
|
78
|
+
printCheck(result);
|
|
79
|
+
}
|
|
80
|
+
if (!result.ok) process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (action === "status") {
|
|
84
|
+
const result = await requestJson({
|
|
85
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft`,
|
|
86
|
+
authToken: auth.token,
|
|
87
|
+
timeoutMs: options.timeoutMs,
|
|
88
|
+
});
|
|
89
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
90
|
+
else {
|
|
91
|
+
console.log(`Card crafting: ${result.state}. ${result.message || ""}`.trim());
|
|
92
|
+
if (result.latestReview)
|
|
93
|
+
console.log(
|
|
94
|
+
`Latest review #${result.latestReview.id}: ${result.latestReview.status}${result.latestReview.reason ? ` · ${result.latestReview.reason}` : ""}`,
|
|
95
|
+
);
|
|
96
|
+
for (const problem of result.problems || []) console.log(` - ${problem}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (action === "review") {
|
|
101
|
+
await assertAuthScope({ options, auth, scope: "build:write" });
|
|
102
|
+
const result = await requestJson({
|
|
103
|
+
url: `${options.apiUrl}/build/${buildId}/cardcraft/reviews`,
|
|
104
|
+
method: "POST",
|
|
105
|
+
authToken: auth.token,
|
|
106
|
+
timeoutMs: options.timeoutMs,
|
|
107
|
+
body: {},
|
|
108
|
+
});
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify(result, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (result.state === "approved")
|
|
114
|
+
console.log(`The saved ${CARDCRAFT_FILE} is already the approved recipe (#${result.reviewId}).`);
|
|
115
|
+
else
|
|
116
|
+
console.log(
|
|
117
|
+
`Sent the saved ${CARDCRAFT_FILE} of Build ${buildId} for review (request #${result.reviewId}${result.duplicate ? ", already waiting" : ""}). Any approved recipe keeps working until this one is approved.`,
|
|
118
|
+
);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
printCardCraftHelp();
|
|
122
|
+
throw new Error(`Unknown cardcraft action: ${action}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printCardCraftHelp() {
|
|
126
|
+
console.log(`Usage:
|
|
127
|
+
lumine cardcraft check Validate cardcraft.json (workspace, else the saved version)
|
|
128
|
+
lumine cardcraft status Approved / waiting for review / needs review
|
|
129
|
+
lumine cardcraft review Send the saved cardcraft.json for review
|
|
130
|
+
|
|
131
|
+
cardcraft.json (project root) says what AI Cards can become in this app:
|
|
132
|
+
{ "guidance"?: "short creative brief",
|
|
133
|
+
"kinds": [{ "id", "label", "description"?,
|
|
134
|
+
"params": { "<name>": { "type": "enum", "values": [...] }
|
|
135
|
+
| { "type": "integer" | "number", "min", "max", "scaleWithTier"? }
|
|
136
|
+
| { "type": "boolean" } | { "type": "string", "maxLength" } } }],
|
|
137
|
+
"tiers": { "1": ["<kind>"], … "6": ["<kind>"] } }
|
|
138
|
+
Card colours: 1 blue, 2 pink, 3 orange, 4 magenta, 5 gold, 6 black (grander as they go up);
|
|
139
|
+
quality (common … legendary) sets the finish effects. A card can be crafted once, ever;
|
|
140
|
+
the asset stays with the card when it is sold. Use Twinkle.cardCraft in the app.`);
|
|
141
|
+
}
|
package/lib/commands.js
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./assets.js";
|
|
59
59
|
import { thumbnailCommand } from "./thumbnail.js";
|
|
60
60
|
import { rewardsCommand, reportRewardDeclaration } from "./rewards.js";
|
|
61
|
+
import { cardcraftCommand } from "./cardcraft.js";
|
|
61
62
|
import { chatlogCommand } from "./chatlog.js";
|
|
62
63
|
import {
|
|
63
64
|
assertAuthScope,
|
|
@@ -284,6 +285,10 @@ export async function main() {
|
|
|
284
285
|
await rewardsCommand(options);
|
|
285
286
|
return;
|
|
286
287
|
}
|
|
288
|
+
if (options.command === "cardcraft") {
|
|
289
|
+
await cardcraftCommand(options);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
287
292
|
if (options.command === "thumbnail") {
|
|
288
293
|
await thumbnailCommand(options);
|
|
289
294
|
return;
|
|
@@ -2596,6 +2601,7 @@ export function parseArgs(args) {
|
|
|
2596
2601
|
draftId: raw.draftId ? String(raw.draftId) : "",
|
|
2597
2602
|
twinkles: raw.twinkles ? String(raw.twinkles) : "",
|
|
2598
2603
|
adminReason: raw.reason ? String(raw.reason) : "",
|
|
2604
|
+
storageSize: raw.size ? String(raw.size) : "",
|
|
2599
2605
|
adminContextLimit: raw.limit === undefined ? 20 : Number(raw.limit),
|
|
2600
2606
|
adminRun: raw.run ? String(raw.run) : "",
|
|
2601
2607
|
adminTarget: raw.target ? String(raw.target) : "",
|
|
@@ -2624,6 +2630,10 @@ export function parseArgs(args) {
|
|
|
2624
2630
|
.map((scope) => scope.trim())
|
|
2625
2631
|
.filter(Boolean),
|
|
2626
2632
|
buildIdFlag: raw.build ? String(raw.build) : "",
|
|
2633
|
+
// `lumine app-mcp <id> --code <code>` attaches to the viewer's own open
|
|
2634
|
+
// app tab ("Connect AI helper") instead of opening a new tab.
|
|
2635
|
+
pairingCode: raw.code ? String(raw.code).trim() : "",
|
|
2636
|
+
helperName: raw.helperName ? String(raw.helperName).trim() : "",
|
|
2627
2637
|
model: raw.model ? String(raw.model) : "",
|
|
2628
2638
|
quality: raw.quality ? String(raw.quality) : "",
|
|
2629
2639
|
assetName: raw.name ? String(raw.name) : "",
|
|
@@ -2911,6 +2921,7 @@ export function printHelp() {
|
|
|
2911
2921
|
lumine sponsor job fail <job-id> --reason <text>
|
|
2912
2922
|
lumine sponsor jobs
|
|
2913
2923
|
lumine app-mcp <published-app-url-or-id> [--no-open]
|
|
2924
|
+
lumine app-mcp <app-url-or-id> --code <code> [--helper-name <name>]
|
|
2914
2925
|
lumine new [title]
|
|
2915
2926
|
lumine rename [title] [--target <twinkle-build-url-or-id>]
|
|
2916
2927
|
lumine describe [description] [--target <twinkle-build-url-or-id>]
|
|
@@ -2950,12 +2961,15 @@ export function printHelp() {
|
|
|
2950
2961
|
lumine assets generate "<prompt>" --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
|
|
2951
2962
|
lumine assets delete <assetId>
|
|
2952
2963
|
lumine assets prune [--yes]
|
|
2964
|
+
lumine assets storage [--json]
|
|
2965
|
+
lumine assets request-storage [--size 500MB|1GB|2GB] [--reason <what it is for>]
|
|
2953
2966
|
lumine chatlog [build] [--since 2h] [--limit 100] [--instance <id>] [--json]
|
|
2954
2967
|
lumine chatlog enable|disable [build]
|
|
2955
2968
|
lumine rewards check
|
|
2956
2969
|
lumine rewards sheet <file.json>
|
|
2957
2970
|
lumine rewards sheet --show
|
|
2958
2971
|
lumine rewards review
|
|
2972
|
+
lumine cardcraft check|status|review
|
|
2959
2973
|
lumine thumbnail set <file>
|
|
2960
2974
|
lumine thumbnail capture [--out <file>]
|
|
2961
2975
|
lumine thumbnail generate ["<prompt>"] --model <gpt-image-2.5-flare|gpt-image-2.5-sunburst|gpt-image-2|nano-banana>
|
|
@@ -2985,7 +2999,10 @@ export function printHelp() {
|
|
|
2985
2999
|
lumine admin sponsor integrity cases [--status open|pending|held|flagged|cleared|disqualified] [--json]
|
|
2986
3000
|
lumine admin sponsor integrity get <case-id> [--json]
|
|
2987
3001
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
3002
|
+
lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject|revoke <type:id> [--reason <text>] | propose rewards:<id> --dir <edited-snapshot> --config <rules.json>
|
|
2988
3003
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
3004
|
+
lumine admin cardcraft-review list [--status pending|approved|all] [--cursor <id>] | show <id> | approve|reject|revoke <id> [--reason <text>]
|
|
3005
|
+
lumine admin storage list [--status pending|approved|rejected|all] [--cursor <id>] | show <user-id-or-username> | approve <request-id> [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <request-id> [--reason <text>] | grant <user-id-or-username> --size <500MB|1GB|2GB|n MB> [--reason <text>]
|
|
2989
3006
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
2990
3007
|
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
2991
3008
|
lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
|
package/lib/constants.js
CHANGED
|
@@ -253,6 +253,11 @@ lumine save --summary "Describe the change"
|
|
|
253
253
|
\`lumine assets prune\` deletes your uploads that nothing references (server
|
|
254
254
|
checks draft files, published/version snapshots, and derived builds; local
|
|
255
255
|
unsaved edits are scanned too). The server enforces storage quotas.
|
|
256
|
+
- Storage is per user across all their builds (\`lumine assets storage\`
|
|
257
|
+
shows the limit and usage). If the quota blocks an upload and pruning is not
|
|
258
|
+
enough, tell the user and, with their OK, run
|
|
259
|
+
\`lumine assets request-storage --reason "<what the space is for>"\` to ask
|
|
260
|
+
Mikey for more (500MB/1GB/2GB tiers; nothing changes until he approves).
|
|
256
261
|
- CAUTION: asset URLs an app stores in privateDb/sharedDb/user DBs at runtime
|
|
257
262
|
(Twinkle.files uploads), or URLs referenced only inside uploaded files such as
|
|
258
263
|
.gltf manifests, are invisible to prune. Keep those asset URLs in project
|
|
@@ -447,6 +452,7 @@ export const COMMANDS = new Set([
|
|
|
447
452
|
"sdk",
|
|
448
453
|
"assets",
|
|
449
454
|
"rewards",
|
|
455
|
+
"cardcraft",
|
|
450
456
|
"chatlog",
|
|
451
457
|
"thumbnail",
|
|
452
458
|
"doctor",
|