@zaflun/lumio-cli 0.5.2 → 0.10.0
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 +3 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.js +3267 -396
- package/dist/sandbox/index.html +14 -0
- package/dist/sandbox/sandbox.css +2 -0
- package/dist/sandbox/sandbox.js +11 -0
- package/package.json +31 -17
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/program.ts
|
|
2
|
-
import { Command } from "commander";
|
|
2
|
+
import { Command as Command2 } from "commander";
|
|
3
|
+
import { createRequire as createRequire3 } from "module";
|
|
3
4
|
|
|
4
5
|
// src/commands/login.ts
|
|
5
6
|
import * as http from "http";
|
|
@@ -28,7 +29,7 @@ var CredentialStore = class {
|
|
|
28
29
|
}
|
|
29
30
|
saveToken(token, apiUrl) {
|
|
30
31
|
const dir = path.dirname(this.credentialsPath);
|
|
31
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
32
33
|
const credentials = {
|
|
33
34
|
token,
|
|
34
35
|
api_url: apiUrl ?? DEFAULT_API_URL
|
|
@@ -39,6 +40,26 @@ var CredentialStore = class {
|
|
|
39
40
|
{ mode: 384 }
|
|
40
41
|
);
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Persist a full session from the browser PKCE flow — the short-lived JWT plus
|
|
45
|
+
* the long-lived refresh token and the JWT's expiry — so a later refresh can
|
|
46
|
+
* mint a fresh JWT without re-opening the browser.
|
|
47
|
+
*/
|
|
48
|
+
saveSession(session) {
|
|
49
|
+
const dir = path.dirname(this.credentialsPath);
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
51
|
+
const credentials = {
|
|
52
|
+
token: session.token,
|
|
53
|
+
refresh_token: session.refreshToken,
|
|
54
|
+
expires_at: session.expiresAt,
|
|
55
|
+
api_url: session.apiUrl ?? DEFAULT_API_URL
|
|
56
|
+
};
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
this.credentialsPath,
|
|
59
|
+
JSON.stringify(credentials, null, 2),
|
|
60
|
+
{ mode: 384 }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
42
63
|
clear() {
|
|
43
64
|
if (fs.existsSync(this.credentialsPath)) {
|
|
44
65
|
fs.unlinkSync(this.credentialsPath);
|
|
@@ -58,48 +79,65 @@ var CredentialStore = class {
|
|
|
58
79
|
};
|
|
59
80
|
|
|
60
81
|
// src/commands/login.ts
|
|
82
|
+
var DEFAULT_API_URL2 = "https://api.lumio.vision";
|
|
83
|
+
var DEFAULT_ID_URL = "https://id.lumio.vision";
|
|
84
|
+
var DEFAULT_CALLBACK_PORT = 9876;
|
|
61
85
|
function generatePkceChallenge() {
|
|
62
86
|
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
63
87
|
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
64
88
|
return { verifier, challenge };
|
|
65
89
|
}
|
|
66
90
|
function registerLogin(program2) {
|
|
67
|
-
program2.command("login").description("Authenticate with Lumio via browser (PKCE) or token").option("--token <token>", "Authenticate with
|
|
91
|
+
program2.command("login").description("Authenticate with Lumio via browser (PKCE) or an API token").option("--token <token>", "Authenticate with a Lumio API key (for CI/CD). For extension publishing, mint a developer key (lm_dev_*) under Account \u2192 Developer. Prefer the LUMIO_TOKEN env var to avoid leaking the token into shell history / the process list.").option("--api-url <url>", "Custom API URL", DEFAULT_API_URL2).option("--id-url <url>", "Custom ID (login) app URL", DEFAULT_ID_URL).option("--port <port>", "Local callback port for the browser PKCE flow", String(DEFAULT_CALLBACK_PORT)).action(async (options) => {
|
|
68
92
|
const store = new CredentialStore();
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
const token = options.token ?? process.env.LUMIO_TOKEN;
|
|
94
|
+
if (token) {
|
|
95
|
+
store.saveToken(token, options.apiUrl);
|
|
71
96
|
console.log("Authenticated with token.");
|
|
72
97
|
return;
|
|
73
98
|
}
|
|
74
99
|
const { verifier, challenge } = generatePkceChallenge();
|
|
75
100
|
const state = crypto.randomBytes(16).toString("hex");
|
|
76
|
-
const port =
|
|
101
|
+
const port = Number.parseInt(options.port, 10);
|
|
102
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
103
|
+
console.error(`Invalid --port: ${options.port}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
77
107
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
78
|
-
await new Promise((
|
|
108
|
+
await new Promise((resolve13, reject) => {
|
|
109
|
+
let timeout;
|
|
79
110
|
const server = http.createServer(async (req, res) => {
|
|
111
|
+
const finish = () => {
|
|
112
|
+
clearTimeout(timeout);
|
|
113
|
+
server.close();
|
|
114
|
+
resolve13();
|
|
115
|
+
};
|
|
116
|
+
const fail = (err) => {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
server.close();
|
|
119
|
+
reject(err);
|
|
120
|
+
};
|
|
80
121
|
if (!req.url?.startsWith("/callback")) {
|
|
81
122
|
res.writeHead(404);
|
|
82
123
|
res.end();
|
|
83
124
|
return;
|
|
84
125
|
}
|
|
85
|
-
const url = new URL(req.url, `http://
|
|
126
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
86
127
|
const code = url.searchParams.get("code");
|
|
87
128
|
const returnedState = url.searchParams.get("state");
|
|
88
129
|
if (returnedState !== state || !code) {
|
|
89
130
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
90
131
|
res.end("<html><body><h2>Authentication failed.</h2><p>Invalid state or missing code. You can close this window.</p></body></html>");
|
|
91
|
-
|
|
92
|
-
reject(new Error("Authentication failed: invalid state or missing code"));
|
|
132
|
+
fail(new Error("Authentication failed: invalid state or missing code"));
|
|
93
133
|
return;
|
|
94
134
|
}
|
|
95
135
|
try {
|
|
96
|
-
const tokenRes = await fetch(`${options.apiUrl}/v1/auth/
|
|
136
|
+
const tokenRes = await fetch(`${options.apiUrl}/v1/auth/token/exchange`, {
|
|
97
137
|
method: "POST",
|
|
98
138
|
headers: { "Content-Type": "application/json" },
|
|
99
139
|
body: JSON.stringify({
|
|
100
|
-
grant_type: "authorization_code",
|
|
101
140
|
code,
|
|
102
|
-
redirect_uri: redirectUri,
|
|
103
141
|
code_verifier: verifier
|
|
104
142
|
})
|
|
105
143
|
});
|
|
@@ -107,28 +145,31 @@ function registerLogin(program2) {
|
|
|
107
145
|
throw new Error(`Token exchange failed: ${tokenRes.status}`);
|
|
108
146
|
}
|
|
109
147
|
const tokenData = await tokenRes.json();
|
|
110
|
-
store.
|
|
148
|
+
store.saveSession({
|
|
149
|
+
token: tokenData.data.token,
|
|
150
|
+
refreshToken: tokenData.data.refresh_token,
|
|
151
|
+
expiresAt: tokenData.data.expires_at,
|
|
152
|
+
apiUrl: options.apiUrl
|
|
153
|
+
});
|
|
111
154
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
112
155
|
res.end("<html><body><h2>Authenticated!</h2><p>You can close this window and return to the CLI.</p></body></html>");
|
|
113
156
|
} catch (e) {
|
|
114
157
|
res.writeHead(500, { "Content-Type": "text/html" });
|
|
115
158
|
res.end("<html><body><h2>Authentication failed.</h2><p>Token exchange error. You can close this window.</p></body></html>");
|
|
116
|
-
|
|
117
|
-
reject(e);
|
|
159
|
+
fail(e instanceof Error ? e : new Error(String(e)));
|
|
118
160
|
return;
|
|
119
161
|
}
|
|
120
|
-
|
|
121
|
-
resolve10();
|
|
162
|
+
finish();
|
|
122
163
|
});
|
|
123
|
-
server.listen(port, async () => {
|
|
124
|
-
const
|
|
164
|
+
server.listen(port, "127.0.0.1", async () => {
|
|
165
|
+
const loginUrl = `${options.idUrl}/login?client_type=cli&code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`;
|
|
125
166
|
console.log("Opening browser for authentication...");
|
|
126
167
|
const { default: open } = await import("open");
|
|
127
|
-
await open(
|
|
168
|
+
await open(loginUrl);
|
|
128
169
|
console.log("Waiting for authentication callback...");
|
|
129
|
-
console.log("If the browser did not open, visit:",
|
|
170
|
+
console.log("If the browser did not open, visit:", loginUrl);
|
|
130
171
|
});
|
|
131
|
-
setTimeout(() => {
|
|
172
|
+
timeout = setTimeout(() => {
|
|
132
173
|
server.close();
|
|
133
174
|
reject(new Error("Authentication timed out after 120 seconds"));
|
|
134
175
|
}, 12e4);
|
|
@@ -151,6 +192,16 @@ function registerLogout(program2) {
|
|
|
151
192
|
}
|
|
152
193
|
|
|
153
194
|
// src/api/client.ts
|
|
195
|
+
var ApiError = class extends Error {
|
|
196
|
+
status;
|
|
197
|
+
body;
|
|
198
|
+
constructor(status, body, label = "API error") {
|
|
199
|
+
super(`${label} ${status}: ${body}`);
|
|
200
|
+
this.name = "ApiError";
|
|
201
|
+
this.status = status;
|
|
202
|
+
this.body = body;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
154
205
|
var LumioApiClient = class {
|
|
155
206
|
baseUrl;
|
|
156
207
|
token;
|
|
@@ -172,14 +223,18 @@ var LumioApiClient = class {
|
|
|
172
223
|
"Authorization": `Bearer ${this.token}`
|
|
173
224
|
};
|
|
174
225
|
}
|
|
226
|
+
/** Throw an {@link ApiError} (with status) when the response is not ok. */
|
|
227
|
+
async ensureOk(response, label = "API error") {
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
throw new ApiError(response.status, await response.text(), label);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
175
232
|
async get(endpoint) {
|
|
176
233
|
const response = await fetch(this.url(endpoint), {
|
|
177
234
|
method: "GET",
|
|
178
235
|
headers: this.authHeaders()
|
|
179
236
|
});
|
|
180
|
-
|
|
181
|
-
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
|
182
|
-
}
|
|
237
|
+
await this.ensureOk(response);
|
|
183
238
|
return response.json();
|
|
184
239
|
}
|
|
185
240
|
async post(endpoint, body) {
|
|
@@ -188,9 +243,7 @@ var LumioApiClient = class {
|
|
|
188
243
|
headers: this.authHeaders(),
|
|
189
244
|
body: body ? JSON.stringify(body) : void 0
|
|
190
245
|
});
|
|
191
|
-
|
|
192
|
-
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
|
193
|
-
}
|
|
246
|
+
await this.ensureOk(response);
|
|
194
247
|
return response.json();
|
|
195
248
|
}
|
|
196
249
|
async put(endpoint, body) {
|
|
@@ -199,9 +252,7 @@ var LumioApiClient = class {
|
|
|
199
252
|
headers: this.authHeaders(),
|
|
200
253
|
body: body ? JSON.stringify(body) : void 0
|
|
201
254
|
});
|
|
202
|
-
|
|
203
|
-
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
|
204
|
-
}
|
|
255
|
+
await this.ensureOk(response);
|
|
205
256
|
return response.json();
|
|
206
257
|
}
|
|
207
258
|
async delete(endpoint) {
|
|
@@ -209,9 +260,7 @@ var LumioApiClient = class {
|
|
|
209
260
|
method: "DELETE",
|
|
210
261
|
headers: this.authHeaders()
|
|
211
262
|
});
|
|
212
|
-
|
|
213
|
-
throw new Error(`API error ${response.status}: ${await response.text()}`);
|
|
214
|
-
}
|
|
263
|
+
await this.ensureOk(response);
|
|
215
264
|
return response.json();
|
|
216
265
|
}
|
|
217
266
|
async uploadFile(endpoint, formData) {
|
|
@@ -220,9 +269,7 @@ var LumioApiClient = class {
|
|
|
220
269
|
headers: this.uploadHeaders(),
|
|
221
270
|
body: formData
|
|
222
271
|
});
|
|
223
|
-
|
|
224
|
-
throw new Error(`Upload error ${response.status}: ${await response.text()}`);
|
|
225
|
-
}
|
|
272
|
+
await this.ensureOk(response, "Upload error");
|
|
226
273
|
return response.json();
|
|
227
274
|
}
|
|
228
275
|
static isCI() {
|
|
@@ -255,6 +302,22 @@ function registerWhoami(program2) {
|
|
|
255
302
|
// src/commands/init.ts
|
|
256
303
|
import * as fs2 from "fs";
|
|
257
304
|
import * as path2 from "path";
|
|
305
|
+
var SERVER_SCHEMA_SCAFFOLD = `import { defineSchema, defineTable, v } from "@zaflun/lumio-sdk/server";
|
|
306
|
+
|
|
307
|
+
export default defineSchema({
|
|
308
|
+
items: defineTable({
|
|
309
|
+
text: v.string(),
|
|
310
|
+
completed: v.boolean(),
|
|
311
|
+
}).storage("instance"),
|
|
312
|
+
});
|
|
313
|
+
`;
|
|
314
|
+
var SERVER_FUNCTIONS_SCAFFOLD = `import { queryRows, insertRow, patchRow, deleteRow, arg } from "@zaflun/lumio-sdk/server";
|
|
315
|
+
|
|
316
|
+
export const getItems = queryRows("items", { orderBy: "created_at", orderDir: "asc" });
|
|
317
|
+
export const addItem = insertRow("items", { text: arg("text"), completed: false }, { editorOnly: true });
|
|
318
|
+
export const toggleItem = patchRow("items", arg("id"), { completed: arg("completed") }, { editorOnly: true });
|
|
319
|
+
export const removeItem = deleteRow("items", arg("id"), { editorOnly: true });
|
|
320
|
+
`;
|
|
258
321
|
function registerInit(program2) {
|
|
259
322
|
program2.command("init").argument("[name]", "Extension project name").description("Create a new extension project with interactive wizard").action(async (name) => {
|
|
260
323
|
const store = new CredentialStore();
|
|
@@ -298,13 +361,19 @@ function registerInit(program2) {
|
|
|
298
361
|
choices: ["widget", "extension", "overlay", "bot_module", "integration", "theme", "automation_node"]
|
|
299
362
|
}
|
|
300
363
|
]);
|
|
301
|
-
const answers = {
|
|
364
|
+
const answers = {
|
|
365
|
+
name: baseAnswers.name,
|
|
366
|
+
description: baseAnswers.description,
|
|
367
|
+
category: baseAnswers.category,
|
|
368
|
+
targets: [],
|
|
369
|
+
server: false
|
|
370
|
+
};
|
|
302
371
|
if (answers.category === "bot_module") {
|
|
303
372
|
const { platforms } = await inquirer.prompt([{
|
|
304
373
|
type: "checkbox",
|
|
305
374
|
name: "platforms",
|
|
306
375
|
message: "Which platforms should this bot module support?",
|
|
307
|
-
choices: ["twitch", "youtube", "kick", "
|
|
376
|
+
choices: ["twitch", "youtube", "kick", "discord"],
|
|
308
377
|
default: ["twitch"],
|
|
309
378
|
validate: (input) => input.length > 0 || "Select at least one platform"
|
|
310
379
|
}]);
|
|
@@ -350,7 +419,7 @@ function registerInit(program2) {
|
|
|
350
419
|
type: "checkbox",
|
|
351
420
|
name: "targets",
|
|
352
421
|
message: "Targets:",
|
|
353
|
-
choices: ["editor", "layer"
|
|
422
|
+
choices: ["editor", "layer"],
|
|
354
423
|
default: ["editor", "layer"]
|
|
355
424
|
},
|
|
356
425
|
{ type: "confirm", name: "server", message: "Server functions?", default: false }
|
|
@@ -412,7 +481,7 @@ function registerInit(program2) {
|
|
|
412
481
|
} catch (err) {
|
|
413
482
|
if (err.status === 404) {
|
|
414
483
|
console.error(
|
|
415
|
-
"\n You need to apply as a developer first.\n Visit https://lumio.vision/
|
|
484
|
+
"\n You need to apply as a developer first.\n Visit https://lumio.vision/dashboard/settings/developer to submit your application.\n"
|
|
416
485
|
);
|
|
417
486
|
process.exit(1);
|
|
418
487
|
}
|
|
@@ -526,6 +595,8 @@ function registerInit(program2) {
|
|
|
526
595
|
},
|
|
527
596
|
devDependencies: {
|
|
528
597
|
"typescript": "^6.0.3",
|
|
598
|
+
// `lumio dev` runs the extension on the devkit's mock host.
|
|
599
|
+
...!isAutomationNode ? { "@zaflun/lumio-devkit": "latest" } : {},
|
|
529
600
|
...needsReact ? { "@types/react": "^19.1.0" } : {},
|
|
530
601
|
...useTailwind ? { "tailwindcss": "^4.3.0" } : {}
|
|
531
602
|
}
|
|
@@ -654,10 +725,8 @@ Lumio.render(<App />, { target: "${target}" });
|
|
|
654
725
|
}
|
|
655
726
|
if (answers.server) {
|
|
656
727
|
fs2.mkdirSync(path2.join(projectDir, "server"), { recursive: true });
|
|
657
|
-
|
|
658
|
-
fs2.writeFileSync(path2.join(projectDir, "server", "
|
|
659
|
-
const functions = 'import { queryRows, insertRow, patchRow, deleteRow, arg } from "@zaflun/lumio-sdk/server";\n\nexport const getItems = queryRows("items");\nexport const addItem = insertRow("items", { text: arg("text"), completed: false }, { editorOnly: true });\nexport const toggleItem = patchRow("items", arg("id"), { completed: arg("completed") }, { editorOnly: true });\nexport const removeItem = deleteRow("items", arg("id"), { editorOnly: true });\n';
|
|
660
|
-
fs2.writeFileSync(path2.join(projectDir, "server", "functions.ts"), functions);
|
|
728
|
+
fs2.writeFileSync(path2.join(projectDir, "server", "schema.ts"), SERVER_SCHEMA_SCAFFOLD);
|
|
729
|
+
fs2.writeFileSync(path2.join(projectDir, "server", "functions.ts"), SERVER_FUNCTIONS_SCAFFOLD);
|
|
661
730
|
}
|
|
662
731
|
}
|
|
663
732
|
console.log(`
|
|
@@ -667,16 +736,20 @@ Done! Created ${projectDir}/`);
|
|
|
667
736
|
}
|
|
668
737
|
|
|
669
738
|
// src/commands/dev.ts
|
|
670
|
-
import * as
|
|
671
|
-
import * as
|
|
739
|
+
import * as fs9 from "fs";
|
|
740
|
+
import * as path10 from "path";
|
|
741
|
+
import { InvalidArgumentError } from "commander";
|
|
742
|
+
import chalk3 from "chalk";
|
|
672
743
|
|
|
673
744
|
// src/config/parser.ts
|
|
745
|
+
import { NODE_TYPES, TRIGGER_MODES, VALID_PLATFORMS } from "@zaflun/lumio-extension-types";
|
|
674
746
|
var ConfigError = class extends Error {
|
|
675
|
-
constructor(
|
|
676
|
-
super(
|
|
747
|
+
constructor(message2) {
|
|
748
|
+
super(message2);
|
|
677
749
|
this.name = "ConfigError";
|
|
678
750
|
}
|
|
679
751
|
};
|
|
752
|
+
var VALID_TARGETS = ["editor", "layer", "designer"];
|
|
680
753
|
function parseConfigFile(jsonString) {
|
|
681
754
|
const raw = JSON.parse(jsonString);
|
|
682
755
|
return {
|
|
@@ -713,25 +786,35 @@ function validateConfig(config) {
|
|
|
713
786
|
if (!config.extensionId) {
|
|
714
787
|
throw new ConfigError("extensionId is required. Run `lumio init` or create an extension at the dashboard.");
|
|
715
788
|
}
|
|
789
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(config.extensionId)) {
|
|
790
|
+
throw new ConfigError("extensionId is invalid: only letters, digits, '-' and '_' (max 64 chars) are allowed.");
|
|
791
|
+
}
|
|
716
792
|
if (!config.name) {
|
|
717
793
|
throw new ConfigError("name is required in lumio.config.json");
|
|
718
794
|
}
|
|
719
795
|
if (config.category === "bot_module") {
|
|
720
796
|
if (!config.platforms || config.platforms.length === 0) {
|
|
721
|
-
throw new ConfigError(
|
|
797
|
+
throw new ConfigError(`At least one platform is required for bot_module (${VALID_PLATFORMS.join(", ")})`);
|
|
798
|
+
}
|
|
799
|
+
const invalid = config.platforms.filter((p) => !VALID_PLATFORMS.includes(p));
|
|
800
|
+
if (invalid.length > 0) {
|
|
801
|
+
throw new ConfigError(`Invalid platform(s): ${invalid.join(", ")}. Must be one of: ${VALID_PLATFORMS.join(", ")}`);
|
|
722
802
|
}
|
|
723
803
|
} else if (config.category === "automation_node") {
|
|
724
804
|
if (!config.nodeType) {
|
|
725
|
-
throw new ConfigError(
|
|
805
|
+
throw new ConfigError(`nodeType is required for automation_node extensions (${NODE_TYPES.join(", ")})`);
|
|
726
806
|
}
|
|
727
|
-
if (!
|
|
728
|
-
throw new ConfigError(
|
|
807
|
+
if (!NODE_TYPES.includes(config.nodeType)) {
|
|
808
|
+
throw new ConfigError(`nodeType must be one of: ${NODE_TYPES.join(", ")}`);
|
|
729
809
|
}
|
|
730
810
|
if (!config.node) {
|
|
731
811
|
throw new ConfigError("node configuration is required for automation_node extensions");
|
|
732
812
|
}
|
|
733
813
|
if (config.nodeType === "trigger" && !config.node.triggerMode) {
|
|
734
|
-
throw new ConfigError(
|
|
814
|
+
throw new ConfigError(`node.triggerMode is required for trigger nodes (${TRIGGER_MODES.join(", ")})`);
|
|
815
|
+
}
|
|
816
|
+
if (config.node.triggerMode && !TRIGGER_MODES.includes(config.node.triggerMode)) {
|
|
817
|
+
throw new ConfigError(`node.triggerMode must be one of: ${TRIGGER_MODES.join(", ")}`);
|
|
735
818
|
}
|
|
736
819
|
if (config.nodeType !== "trigger" && config.node.triggerMode) {
|
|
737
820
|
throw new ConfigError("node.triggerMode is only valid for trigger nodes");
|
|
@@ -742,111 +825,45 @@ function validateConfig(config) {
|
|
|
742
825
|
}
|
|
743
826
|
}
|
|
744
827
|
} else if (!config.targets || config.targets.length === 0) {
|
|
745
|
-
throw new ConfigError("At least one target is required (editor
|
|
828
|
+
throw new ConfigError("At least one target is required (editor or layer)");
|
|
829
|
+
}
|
|
830
|
+
const unknownTargets = (config.targets ?? []).filter((t) => !VALID_TARGETS.includes(t));
|
|
831
|
+
if (unknownTargets.length > 0) {
|
|
832
|
+
throw new ConfigError(
|
|
833
|
+
`Invalid target(s): ${unknownTargets.join(", ")}. Must be one of: ${VALID_TARGETS.join(", ")}. Viewer input comes through chat commands and channel point rewards handled by a bot module.`
|
|
834
|
+
);
|
|
746
835
|
}
|
|
747
836
|
if (!config.version) {
|
|
748
837
|
throw new ConfigError("version is required in lumio.config.json");
|
|
749
838
|
}
|
|
750
839
|
}
|
|
751
840
|
|
|
752
|
-
// src/commands/dev-
|
|
841
|
+
// src/commands/dev-automation-node.ts
|
|
753
842
|
import * as readline from "readline";
|
|
754
843
|
import chalk from "chalk";
|
|
755
|
-
function parseUserPrefix(input) {
|
|
756
|
-
const defaultUser = {
|
|
757
|
-
id: "user-001",
|
|
758
|
-
name: "dev_user",
|
|
759
|
-
displayName: "Dev User",
|
|
760
|
-
platform: "twitch",
|
|
761
|
-
role: "everyone",
|
|
762
|
-
isMod: false,
|
|
763
|
-
isVip: false,
|
|
764
|
-
isSub: false
|
|
765
|
-
};
|
|
766
|
-
if (!input.startsWith("@")) {
|
|
767
|
-
return { user: defaultUser, message: input };
|
|
768
|
-
}
|
|
769
|
-
const spaceIdx = input.indexOf(" ");
|
|
770
|
-
if (spaceIdx === -1) return { user: defaultUser, message: input };
|
|
771
|
-
const prefix = input.substring(1, spaceIdx);
|
|
772
|
-
const message = input.substring(spaceIdx + 1);
|
|
773
|
-
const parts = prefix.split(":");
|
|
774
|
-
const name = parts[parts.length - 1];
|
|
775
|
-
const rolePrefix = parts.length > 1 ? parts[0] : "everyone";
|
|
776
|
-
return {
|
|
777
|
-
user: {
|
|
778
|
-
id: `user-${name}`,
|
|
779
|
-
name,
|
|
780
|
-
displayName: name,
|
|
781
|
-
platform: "twitch",
|
|
782
|
-
role: rolePrefix,
|
|
783
|
-
isMod: rolePrefix === "mod",
|
|
784
|
-
isVip: rolePrefix === "vip",
|
|
785
|
-
isSub: ["sub", "vip", "mod", "broadcaster"].includes(rolePrefix)
|
|
786
|
-
},
|
|
787
|
-
message
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
async function runBotModuleDevServer(config) {
|
|
791
|
-
console.log(chalk.bold("\n\u{1F916} Bot Module Dev Server"));
|
|
792
|
-
console.log(`Extension: ${config.name}`);
|
|
793
|
-
console.log(`Platforms: ${config.platforms?.join(", ") ?? "none"}`);
|
|
794
|
-
console.log(chalk.dim("\nType chat messages. Prefix with @username or @role:username."));
|
|
795
|
-
console.log(chalk.dim("Examples: !hello | @sub:viewer !points 100 | @mod:admin gg"));
|
|
796
|
-
console.log(chalk.dim("Type !quit to exit.\n"));
|
|
797
|
-
const rl = readline.createInterface({
|
|
798
|
-
input: process.stdin,
|
|
799
|
-
output: process.stdout,
|
|
800
|
-
prompt: chalk.cyan("> ")
|
|
801
|
-
});
|
|
802
|
-
rl.prompt();
|
|
803
|
-
rl.on("line", async (line) => {
|
|
804
|
-
const trimmed = line.trim();
|
|
805
|
-
if (!trimmed) {
|
|
806
|
-
rl.prompt();
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
if (trimmed === "!quit") {
|
|
810
|
-
rl.close();
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
const { user, message } = parseUserPrefix(trimmed);
|
|
814
|
-
console.log(chalk.dim(` [${user.role}:${user.name}] ${message}`));
|
|
815
|
-
console.log(chalk.yellow(" \u2192 Handler execution not yet wired (requires esbuild + V8)"));
|
|
816
|
-
rl.prompt();
|
|
817
|
-
});
|
|
818
|
-
rl.on("close", () => {
|
|
819
|
-
console.log(chalk.dim("\nBot Module Dev Server stopped."));
|
|
820
|
-
process.exit(0);
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// src/commands/dev-automation-node.ts
|
|
825
|
-
import * as readline2 from "readline";
|
|
826
|
-
import chalk2 from "chalk";
|
|
827
844
|
async function runAutomationNodeDevServer(config) {
|
|
828
845
|
const nodeType = config.nodeType ?? "action";
|
|
829
|
-
console.log(
|
|
846
|
+
console.log(chalk.bold("\n\u2699\uFE0F Automation Node Dev Server"));
|
|
830
847
|
console.log(`Extension: ${config.name}`);
|
|
831
848
|
console.log(`Node type: ${nodeType}`);
|
|
832
849
|
if (nodeType === "trigger" && config.node?.triggerMode) {
|
|
833
850
|
console.log(`Trigger mode: ${config.node.triggerMode}`);
|
|
834
851
|
}
|
|
835
|
-
console.log(
|
|
836
|
-
console.log(
|
|
852
|
+
console.log(chalk.dim("\nCommands:"));
|
|
853
|
+
console.log(chalk.dim(" /execute [json] \u2014 Execute the node with input data"));
|
|
837
854
|
if (nodeType === "trigger") {
|
|
838
|
-
console.log(
|
|
839
|
-
console.log(
|
|
855
|
+
console.log(chalk.dim(" /webhook [json] \u2014 Simulate a webhook POST payload"));
|
|
856
|
+
console.log(chalk.dim(" /poll \u2014 Trigger a poll cycle"));
|
|
840
857
|
}
|
|
841
|
-
console.log(
|
|
842
|
-
console.log(
|
|
843
|
-
console.log(
|
|
858
|
+
console.log(chalk.dim(" /config [json] \u2014 Set node instance config"));
|
|
859
|
+
console.log(chalk.dim(" /install-config [j] \u2014 Set extension install config"));
|
|
860
|
+
console.log(chalk.dim(" /quit \u2014 Exit\n"));
|
|
844
861
|
let nodeConfig = {};
|
|
845
862
|
let installConfig = {};
|
|
846
|
-
const rl =
|
|
863
|
+
const rl = readline.createInterface({
|
|
847
864
|
input: process.stdin,
|
|
848
865
|
output: process.stdout,
|
|
849
|
-
prompt:
|
|
866
|
+
prompt: chalk.cyan("> ")
|
|
850
867
|
});
|
|
851
868
|
rl.prompt();
|
|
852
869
|
rl.on("line", async (line) => {
|
|
@@ -862,9 +879,9 @@ async function runAutomationNodeDevServer(config) {
|
|
|
862
879
|
if (trimmed.startsWith("/config ")) {
|
|
863
880
|
try {
|
|
864
881
|
nodeConfig = JSON.parse(trimmed.slice("/config ".length));
|
|
865
|
-
console.log(
|
|
882
|
+
console.log(chalk.green(" Node config updated."));
|
|
866
883
|
} catch {
|
|
867
|
-
console.log(
|
|
884
|
+
console.log(chalk.red(" Invalid JSON."));
|
|
868
885
|
}
|
|
869
886
|
rl.prompt();
|
|
870
887
|
return;
|
|
@@ -872,9 +889,9 @@ async function runAutomationNodeDevServer(config) {
|
|
|
872
889
|
if (trimmed.startsWith("/install-config ")) {
|
|
873
890
|
try {
|
|
874
891
|
installConfig = JSON.parse(trimmed.slice("/install-config ".length));
|
|
875
|
-
console.log(
|
|
892
|
+
console.log(chalk.green(" Install config updated."));
|
|
876
893
|
} catch {
|
|
877
|
-
console.log(
|
|
894
|
+
console.log(chalk.red(" Invalid JSON."));
|
|
878
895
|
}
|
|
879
896
|
rl.prompt();
|
|
880
897
|
return;
|
|
@@ -886,15 +903,15 @@ async function runAutomationNodeDevServer(config) {
|
|
|
886
903
|
try {
|
|
887
904
|
inputData = JSON.parse(jsonStr);
|
|
888
905
|
} catch {
|
|
889
|
-
console.log(
|
|
906
|
+
console.log(chalk.red(" Invalid JSON."));
|
|
890
907
|
rl.prompt();
|
|
891
908
|
return;
|
|
892
909
|
}
|
|
893
910
|
}
|
|
894
|
-
console.log(
|
|
895
|
-
console.log(
|
|
896
|
-
console.log(
|
|
897
|
-
console.log(
|
|
911
|
+
console.log(chalk.dim(` [${nodeType}] Executing with input: ${JSON.stringify(inputData)}`));
|
|
912
|
+
console.log(chalk.dim(` [${nodeType}] Node config: ${JSON.stringify(nodeConfig)}`));
|
|
913
|
+
console.log(chalk.dim(` [${nodeType}] Install config: ${JSON.stringify(installConfig)}`));
|
|
914
|
+
console.log(chalk.yellow(" \u2192 Handler execution not yet wired (requires esbuild + V8)"));
|
|
898
915
|
rl.prompt();
|
|
899
916
|
return;
|
|
900
917
|
}
|
|
@@ -905,242 +922,2977 @@ async function runAutomationNodeDevServer(config) {
|
|
|
905
922
|
try {
|
|
906
923
|
body = JSON.parse(jsonStr);
|
|
907
924
|
} catch {
|
|
908
|
-
console.log(
|
|
925
|
+
console.log(chalk.red(" Invalid JSON."));
|
|
909
926
|
rl.prompt();
|
|
910
927
|
return;
|
|
911
928
|
}
|
|
912
929
|
}
|
|
913
|
-
console.log(
|
|
914
|
-
console.log(
|
|
930
|
+
console.log(chalk.dim(` [trigger:webhook] Received payload: ${JSON.stringify(body)}`));
|
|
931
|
+
console.log(chalk.yellow(" \u2192 Handler execution not yet wired (requires esbuild + V8)"));
|
|
915
932
|
rl.prompt();
|
|
916
933
|
return;
|
|
917
934
|
}
|
|
918
935
|
if (nodeType === "trigger" && trimmed === "/poll") {
|
|
919
|
-
console.log(
|
|
920
|
-
console.log(
|
|
936
|
+
console.log(chalk.dim(" [trigger:poll] Polling..."));
|
|
937
|
+
console.log(chalk.yellow(" \u2192 Handler execution not yet wired (requires esbuild + V8)"));
|
|
921
938
|
rl.prompt();
|
|
922
939
|
return;
|
|
923
940
|
}
|
|
924
|
-
console.log(
|
|
941
|
+
console.log(chalk.dim(" Unknown command. Type /execute, /webhook, /poll, /config, or /quit."));
|
|
925
942
|
rl.prompt();
|
|
926
943
|
});
|
|
927
944
|
rl.on("close", () => {
|
|
928
|
-
console.log(
|
|
945
|
+
console.log(chalk.dim("\nAutomation Node Dev Server stopped."));
|
|
929
946
|
process.exit(0);
|
|
930
947
|
});
|
|
931
948
|
}
|
|
932
949
|
|
|
933
|
-
// src/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
950
|
+
// src/dev/builder.ts
|
|
951
|
+
import * as fs6 from "fs";
|
|
952
|
+
import * as path6 from "path";
|
|
953
|
+
|
|
954
|
+
// src/build/pipeline.ts
|
|
955
|
+
import * as fs5 from "fs";
|
|
956
|
+
import * as path5 from "path";
|
|
957
|
+
import { execFile } from "child_process";
|
|
958
|
+
import { promisify } from "util";
|
|
959
|
+
import { createRequire } from "module";
|
|
960
|
+
|
|
961
|
+
// src/commands/data-schema.ts
|
|
962
|
+
import * as fs3 from "fs";
|
|
963
|
+
import * as path3 from "path";
|
|
964
|
+
var SCHEMA_FILES = ["server/schema.ts", "server/schema.js", "server/schema.mjs"];
|
|
965
|
+
function findSchemaFile(projectDir) {
|
|
966
|
+
return SCHEMA_FILES.find((f) => fs3.existsSync(path3.join(projectDir, f))) ?? null;
|
|
967
|
+
}
|
|
968
|
+
async function loadDataSchema(projectDir) {
|
|
969
|
+
const schemaFile = findSchemaFile(projectDir);
|
|
970
|
+
if (!schemaFile) return null;
|
|
971
|
+
const { build } = await import("esbuild");
|
|
972
|
+
const result = await build({
|
|
973
|
+
entryPoints: [path3.join(projectDir, schemaFile)],
|
|
974
|
+
absWorkingDir: projectDir,
|
|
975
|
+
bundle: true,
|
|
976
|
+
write: false,
|
|
977
|
+
format: "esm",
|
|
978
|
+
platform: "node",
|
|
979
|
+
target: "es2022",
|
|
980
|
+
logLevel: "silent"
|
|
959
981
|
});
|
|
982
|
+
const code = result.outputFiles[0]?.text ?? "";
|
|
983
|
+
const url = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
|
|
984
|
+
const mod = await import(url);
|
|
985
|
+
return toDataSchemaJson(mod.default, schemaFile);
|
|
986
|
+
}
|
|
987
|
+
function toDataSchemaJson(value, schemaFile = "server/schema.ts") {
|
|
988
|
+
const tables = value?.tables;
|
|
989
|
+
if (typeof tables !== "object" || tables === null || Array.isArray(tables)) {
|
|
990
|
+
throw new Error(
|
|
991
|
+
`${schemaFile} must \`export default defineSchema({ ... })\` from "@zaflun/lumio-sdk/server".`
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
return JSON.parse(JSON.stringify(value));
|
|
995
|
+
}
|
|
996
|
+
function formatDataSchemaError(body) {
|
|
997
|
+
let parsed;
|
|
998
|
+
try {
|
|
999
|
+
parsed = JSON.parse(body);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
const message2 = typeof parsed.message === "string" ? parsed.message : "";
|
|
1004
|
+
if (parsed.error === "INVALID_DATA_SCHEMA") {
|
|
1005
|
+
return [`Invalid data schema (server/schema.ts): ${message2}`];
|
|
1006
|
+
}
|
|
1007
|
+
if (parsed.error === "DATA_SCHEMA_NOT_ADDITIVE") {
|
|
1008
|
+
const violations = Array.isArray(parsed.details?.violations) ? parsed.details.violations : [];
|
|
1009
|
+
if (violations.length === 0) return [message2];
|
|
1010
|
+
return [
|
|
1011
|
+
"Data schema change is not additive. Published tables only allow new tables, new optional columns and relaxing NOT NULL:",
|
|
1012
|
+
...violations.map((v) => {
|
|
1013
|
+
const target = v.column ? `${v.table}.${v.column}` : v.table;
|
|
1014
|
+
return ` - ${target}: ${v.rule} (applied: ${v.applied}, declared: ${v.declared})`;
|
|
1015
|
+
})
|
|
1016
|
+
];
|
|
1017
|
+
}
|
|
1018
|
+
return null;
|
|
960
1019
|
}
|
|
961
1020
|
|
|
962
|
-
// src/commands/
|
|
1021
|
+
// src/commands/functions-manifest.ts
|
|
963
1022
|
import * as fs4 from "fs";
|
|
964
1023
|
import * as path4 from "path";
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
var
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1024
|
+
var FUNCTIONS_FILES = ["server/functions.ts", "server/functions.js", "server/functions.mjs"];
|
|
1025
|
+
var FUNCTIONS_MANIFEST_VERSION = 1;
|
|
1026
|
+
var FUNCTIONS_MANIFEST_FILE = "functions.json";
|
|
1027
|
+
var FILTER_OPERATORS = /* @__PURE__ */ new Set(["=", "!=", ">", ">=", "<", "<=", "like", "in"]);
|
|
1028
|
+
var NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
1029
|
+
var FunctionsManifestError = class extends Error {
|
|
1030
|
+
constructor(message2) {
|
|
1031
|
+
super(message2);
|
|
1032
|
+
this.name = "FunctionsManifestError";
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
function findFunctionsFile(projectDir) {
|
|
1036
|
+
return FUNCTIONS_FILES.find((f) => fs4.existsSync(path4.join(projectDir, f))) ?? null;
|
|
1037
|
+
}
|
|
1038
|
+
function combinedServerEntry(handlerEntry, functionsFile, names) {
|
|
1039
|
+
const spec = (file) => JSON.stringify(`./${file.replace(/\\/g, "/")}`);
|
|
1040
|
+
const lines = [`export * from ${spec(handlerEntry)};`];
|
|
1041
|
+
if (names.length > 0) {
|
|
1042
|
+
lines.push(`export { ${names.join(", ")} } from ${spec(functionsFile)};`);
|
|
1043
|
+
}
|
|
1044
|
+
return `${lines.join("\n")}
|
|
1045
|
+
`;
|
|
1046
|
+
}
|
|
1047
|
+
function handlerNames(manifest) {
|
|
1048
|
+
return Object.entries(manifest.functions).filter(([, entry]) => entry.kind === "handler").map(([name]) => name);
|
|
1049
|
+
}
|
|
1050
|
+
async function loadFunctionExports(projectDir) {
|
|
1051
|
+
const file = findFunctionsFile(projectDir);
|
|
1052
|
+
if (!file) return null;
|
|
1053
|
+
const { build } = await import("esbuild");
|
|
1054
|
+
const result = await build({
|
|
1055
|
+
entryPoints: [path4.join(projectDir, file)],
|
|
1056
|
+
absWorkingDir: projectDir,
|
|
1057
|
+
bundle: true,
|
|
1058
|
+
write: false,
|
|
1059
|
+
format: "esm",
|
|
1060
|
+
platform: "node",
|
|
1061
|
+
target: "es2022",
|
|
1062
|
+
logLevel: "silent"
|
|
1063
|
+
});
|
|
1064
|
+
const code = result.outputFiles[0]?.text ?? "";
|
|
1065
|
+
const url = `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`;
|
|
1066
|
+
const mod = await import(url);
|
|
1067
|
+
return { file, exports: { ...mod } };
|
|
1068
|
+
}
|
|
1069
|
+
function buildFunctionsManifest(exports, schema, file = "server/functions.ts") {
|
|
1070
|
+
const functions = {};
|
|
1071
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1072
|
+
for (const name of Object.keys(exports).sort()) {
|
|
1073
|
+
const value = exports[name];
|
|
1074
|
+
const where = `${file}: export "${name}"`;
|
|
1075
|
+
if (name === "default") {
|
|
1076
|
+
throw new FunctionsManifestError(
|
|
1077
|
+
`${file}: a default export is not allowed. Export each function under its name (\`export const getRules = queryRows("rules")\`).`
|
|
1078
|
+
);
|
|
974
1079
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
if (!fs4.existsSync(changelogPath)) {
|
|
980
|
-
console.warn("Warning: No CHANGELOG.md found. It is recommended to maintain a changelog.");
|
|
1080
|
+
if (!NAME_PATTERN.test(name)) {
|
|
1081
|
+
throw new FunctionsManifestError(
|
|
1082
|
+
`${where}: function names may only use letters, digits and "_" (at most 64 characters).`
|
|
1083
|
+
);
|
|
981
1084
|
}
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1085
|
+
const folded = name.toLowerCase();
|
|
1086
|
+
const other = seen.get(folded);
|
|
1087
|
+
if (other !== void 0) {
|
|
1088
|
+
throw new FunctionsManifestError(
|
|
1089
|
+
`${where}: duplicate function name \u2014 it differs from "${other}" only in case.`
|
|
1090
|
+
);
|
|
988
1091
|
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
if (
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1092
|
+
seen.set(folded, name);
|
|
1093
|
+
const entry = toEntry(value, where);
|
|
1094
|
+
if (entry.table !== void 0) checkTable(entry.table, schema, where);
|
|
1095
|
+
functions[name] = entry;
|
|
1096
|
+
}
|
|
1097
|
+
return { version: FUNCTIONS_MANIFEST_VERSION, functions };
|
|
1098
|
+
}
|
|
1099
|
+
function toEntry(value, where) {
|
|
1100
|
+
const type = value?.__type;
|
|
1101
|
+
switch (type) {
|
|
1102
|
+
case "queryRows": {
|
|
1103
|
+
const def = value;
|
|
1104
|
+
const entry = {
|
|
1105
|
+
kind: "queryRows",
|
|
1106
|
+
category: "query",
|
|
1107
|
+
editorOnly: false,
|
|
1108
|
+
table: tableName(def.table, where)
|
|
1109
|
+
};
|
|
1110
|
+
if (def.filter !== void 0) entry.filter = filters(def.filter, where);
|
|
1111
|
+
if (def.orderBy !== void 0) {
|
|
1112
|
+
if (typeof def.orderBy !== "string" || def.orderBy === "") {
|
|
1113
|
+
throw new FunctionsManifestError(`${where}: orderBy must be a column name.`);
|
|
1010
1114
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1115
|
+
entry.orderBy = def.orderBy;
|
|
1116
|
+
}
|
|
1117
|
+
if (def.orderDir !== void 0) {
|
|
1118
|
+
if (def.orderDir !== "asc" && def.orderDir !== "desc") {
|
|
1119
|
+
throw new FunctionsManifestError(`${where}: orderDir must be "asc" or "desc".`);
|
|
1120
|
+
}
|
|
1121
|
+
entry.orderDir = def.orderDir;
|
|
1015
1122
|
}
|
|
1123
|
+
if (def.limit !== void 0) entry.limit = count(def.limit, "limit", where);
|
|
1124
|
+
if (def.offset !== void 0) entry.offset = count(def.offset, "offset", where);
|
|
1125
|
+
return entry;
|
|
1016
1126
|
}
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1127
|
+
case "insertRow": {
|
|
1128
|
+
const def = value;
|
|
1129
|
+
return {
|
|
1130
|
+
kind: "insertRow",
|
|
1131
|
+
category: "mutation",
|
|
1132
|
+
editorOnly: editorOnly(def.editorOnly, where),
|
|
1133
|
+
table: tableName(def.table, where),
|
|
1134
|
+
values: rowValues(def.values, where)
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
case "patchRow": {
|
|
1138
|
+
const def = value;
|
|
1139
|
+
return {
|
|
1140
|
+
kind: "patchRow",
|
|
1141
|
+
category: "mutation",
|
|
1142
|
+
editorOnly: editorOnly(def.editorOnly, where),
|
|
1143
|
+
table: tableName(def.table, where),
|
|
1144
|
+
rowId: rowId(def.rowId, where),
|
|
1145
|
+
values: rowValues(def.values, where)
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
case "deleteRow": {
|
|
1149
|
+
const def = value;
|
|
1150
|
+
return {
|
|
1151
|
+
kind: "deleteRow",
|
|
1152
|
+
category: "mutation",
|
|
1153
|
+
editorOnly: editorOnly(def.editorOnly, where),
|
|
1154
|
+
table: tableName(def.table, where),
|
|
1155
|
+
rowId: rowId(def.rowId, where)
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
case "query":
|
|
1159
|
+
case "mutation":
|
|
1160
|
+
case "action": {
|
|
1161
|
+
if (typeof value.handler !== "function") {
|
|
1162
|
+
throw new FunctionsManifestError(`${where}: ${type}() needs a handler function.`);
|
|
1022
1163
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
bundle: true,
|
|
1026
|
-
minify: true,
|
|
1027
|
-
format: "iife",
|
|
1028
|
-
globalName: "LumioExtension",
|
|
1029
|
-
footer: {
|
|
1030
|
-
js: "if(typeof LumioExtension!=='undefined'&&typeof LumioExtension.default==='function'){var _L=(typeof require!=='undefined'?require('@zaflun/lumio-sdk'):{}).Lumio;if(_L)_L.render(LumioExtension.default);}"
|
|
1031
|
-
},
|
|
1032
|
-
outdir: distDir,
|
|
1033
|
-
entryNames: `${target}`,
|
|
1034
|
-
jsx: "automatic",
|
|
1035
|
-
target: "es2022",
|
|
1036
|
-
loader: {
|
|
1037
|
-
".css": "css",
|
|
1038
|
-
// Asset file loaders — copies to dist/assets/ with content hash
|
|
1039
|
-
".png": "file",
|
|
1040
|
-
".jpg": "file",
|
|
1041
|
-
".jpeg": "file",
|
|
1042
|
-
".gif": "file",
|
|
1043
|
-
".svg": "file",
|
|
1044
|
-
".webp": "file",
|
|
1045
|
-
".avif": "file",
|
|
1046
|
-
".ico": "file",
|
|
1047
|
-
".mp4": "file",
|
|
1048
|
-
".webm": "file",
|
|
1049
|
-
".ogg": "file",
|
|
1050
|
-
".mp3": "file",
|
|
1051
|
-
".wav": "file",
|
|
1052
|
-
".woff": "file",
|
|
1053
|
-
".woff2": "file",
|
|
1054
|
-
".ttf": "file",
|
|
1055
|
-
".otf": "file",
|
|
1056
|
-
".lottie": "file"
|
|
1057
|
-
},
|
|
1058
|
-
assetNames: "assets/[name]-[hash]",
|
|
1059
|
-
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "@zaflun/lumio-sdk", "@zaflun/lumio-protocol"],
|
|
1060
|
-
define: {
|
|
1061
|
-
"process.env.NODE_ENV": '"production"'
|
|
1062
|
-
}
|
|
1063
|
-
});
|
|
1064
|
-
console.log(` Built: dist/${target}.js`);
|
|
1164
|
+
const flag = editorOnly(value.editorOnly, where);
|
|
1165
|
+
return { kind: "handler", category: type, editorOnly: flag };
|
|
1065
1166
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1167
|
+
case "fetchAction":
|
|
1168
|
+
throw new FunctionsManifestError(
|
|
1169
|
+
`${where}: fetchAction() is not supported in server functions. Call ctx.fetch() from an action() handler instead.`
|
|
1170
|
+
);
|
|
1171
|
+
default:
|
|
1172
|
+
throw new FunctionsManifestError(
|
|
1173
|
+
`${where}: not a server function definition. server/functions.ts may only export queryRows, insertRow, patchRow, deleteRow, query, mutation and action definitions \u2014 move other values into a separate module.`
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
function tableName(value, where) {
|
|
1178
|
+
if (typeof value !== "string" || value === "") {
|
|
1179
|
+
throw new FunctionsManifestError(`${where}: the table name must be a non-empty string.`);
|
|
1180
|
+
}
|
|
1181
|
+
return value;
|
|
1182
|
+
}
|
|
1183
|
+
function checkTable(table, schema, where) {
|
|
1184
|
+
if (!schema) {
|
|
1185
|
+
throw new FunctionsManifestError(
|
|
1186
|
+
`${where}: uses table "${table}", but the extension has no server/schema.ts. Declare the table with defineSchema().`
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
if (!Object.prototype.hasOwnProperty.call(schema.tables, table)) {
|
|
1190
|
+
const known = Object.keys(schema.tables);
|
|
1191
|
+
throw new FunctionsManifestError(
|
|
1192
|
+
`${where}: unknown table "${table}" (server/schema.ts declares ${known.length > 0 ? known.map((t) => `"${t}"`).join(", ") : "no tables"}).`
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function editorOnly(value, where) {
|
|
1197
|
+
if (value === void 0) return false;
|
|
1198
|
+
if (typeof value !== "boolean") {
|
|
1199
|
+
throw new FunctionsManifestError(`${where}: editorOnly must be true or false.`);
|
|
1200
|
+
}
|
|
1201
|
+
return value;
|
|
1202
|
+
}
|
|
1203
|
+
function rowId(value, where) {
|
|
1204
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1205
|
+
const expr = expression(value, where);
|
|
1206
|
+
if (expr?.__expr === "arg") return expr;
|
|
1207
|
+
throw new FunctionsManifestError(`${where}: the row id must be a string or arg("\u2026").`);
|
|
1208
|
+
}
|
|
1209
|
+
function count(value, field, where) {
|
|
1210
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value;
|
|
1211
|
+
const expr = expression(value, where);
|
|
1212
|
+
if (expr?.__expr === "arg") return expr;
|
|
1213
|
+
throw new FunctionsManifestError(`${where}: ${field} must be a non-negative integer or arg("\u2026").`);
|
|
1214
|
+
}
|
|
1215
|
+
function filters(value, where) {
|
|
1216
|
+
const list = Array.isArray(value) ? value : [value];
|
|
1217
|
+
return list.map((item) => {
|
|
1218
|
+
const f = item;
|
|
1219
|
+
if (typeof f !== "object" || f === null || f.__expr !== "filter") {
|
|
1220
|
+
throw new FunctionsManifestError(`${where}: filter must be filter(field, op, value) or a list of them.`);
|
|
1221
|
+
}
|
|
1222
|
+
if (typeof f.field !== "string" || f.field === "") {
|
|
1223
|
+
throw new FunctionsManifestError(`${where}: filter field must be a column name.`);
|
|
1224
|
+
}
|
|
1225
|
+
if (typeof f.op !== "string" || !FILTER_OPERATORS.has(f.op)) {
|
|
1226
|
+
throw new FunctionsManifestError(
|
|
1227
|
+
`${where}: unsupported filter operator ${JSON.stringify(f.op)} (use ${[...FILTER_OPERATORS].map((o) => `"${o}"`).join(", ")}).`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
return { field: f.field, op: f.op, value: jsonValue(f.value, `${where} (filter "${f.field}")`) };
|
|
1087
1231
|
});
|
|
1088
1232
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
for (const line of lines) {
|
|
1102
|
-
const match = line.match(VERSION_HEADER_REGEX);
|
|
1103
|
-
if (match) {
|
|
1104
|
-
if (currentEntry) {
|
|
1105
|
-
currentEntry.content = contentLines.join("\n").trim();
|
|
1106
|
-
entries.push(currentEntry);
|
|
1107
|
-
contentLines.length = 0;
|
|
1108
|
-
}
|
|
1109
|
-
currentEntry = {
|
|
1110
|
-
version: match[1],
|
|
1111
|
-
date: match[2] ?? null,
|
|
1112
|
-
content: ""
|
|
1113
|
-
};
|
|
1114
|
-
} else if (currentEntry) {
|
|
1115
|
-
contentLines.push(line);
|
|
1233
|
+
function rowValues(value, where) {
|
|
1234
|
+
if (!isPlainObject(value)) {
|
|
1235
|
+
throw new FunctionsManifestError(`${where}: row values must be an object of column \u2192 value.`);
|
|
1236
|
+
}
|
|
1237
|
+
return jsonValue(value, where);
|
|
1238
|
+
}
|
|
1239
|
+
function expression(value, where) {
|
|
1240
|
+
if (!isPlainObject(value) || !("__expr" in value)) return null;
|
|
1241
|
+
const expr = value.__expr;
|
|
1242
|
+
if (expr === "arg") {
|
|
1243
|
+
if (typeof value.name !== "string" || value.name === "") {
|
|
1244
|
+
throw new FunctionsManifestError(`${where}: arg() needs a parameter name.`);
|
|
1116
1245
|
}
|
|
1246
|
+
return { __expr: "arg", name: value.name };
|
|
1117
1247
|
}
|
|
1118
|
-
if (
|
|
1119
|
-
|
|
1120
|
-
|
|
1248
|
+
if (expr === "identity") return { __expr: "identity" };
|
|
1249
|
+
if (expr === "secret") {
|
|
1250
|
+
throw new FunctionsManifestError(
|
|
1251
|
+
`${where}: secret() is only available in action() handlers, not in declarative functions.`
|
|
1252
|
+
);
|
|
1121
1253
|
}
|
|
1122
|
-
|
|
1254
|
+
throw new FunctionsManifestError(`${where}: ${JSON.stringify(expr)} expression is not allowed here.`);
|
|
1123
1255
|
}
|
|
1124
|
-
function
|
|
1125
|
-
|
|
1126
|
-
|
|
1256
|
+
function jsonValue(value, where, stack = []) {
|
|
1257
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
1258
|
+
if (typeof value === "number") {
|
|
1259
|
+
if (!Number.isFinite(value)) {
|
|
1260
|
+
throw new FunctionsManifestError(`${where}: ${value} is not a JSON number.`);
|
|
1261
|
+
}
|
|
1262
|
+
return value;
|
|
1263
|
+
}
|
|
1264
|
+
if (value === void 0) {
|
|
1265
|
+
throw new FunctionsManifestError(`${where}: undefined is not a JSON value; use null.`);
|
|
1266
|
+
}
|
|
1267
|
+
if (typeof value !== "object") {
|
|
1268
|
+
throw new FunctionsManifestError(`${where}: a ${typeof value} value cannot be serialised.`);
|
|
1269
|
+
}
|
|
1270
|
+
if (stack.includes(value)) {
|
|
1271
|
+
throw new FunctionsManifestError(`${where}: circular value cannot be serialised.`);
|
|
1272
|
+
}
|
|
1273
|
+
if (Array.isArray(value)) {
|
|
1274
|
+
return value.map((item) => jsonValue(item, where, [...stack, value]));
|
|
1275
|
+
}
|
|
1276
|
+
if (!isPlainObject(value)) {
|
|
1277
|
+
const ctor = value.constructor?.name ?? "object";
|
|
1278
|
+
throw new FunctionsManifestError(`${where}: a ${ctor} value cannot be serialised; use plain JSON.`);
|
|
1279
|
+
}
|
|
1280
|
+
const expr = expression(value, where);
|
|
1281
|
+
if (expr) return expr;
|
|
1282
|
+
const out = {};
|
|
1283
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1284
|
+
out[key] = jsonValue(item, where, [...stack, value]);
|
|
1285
|
+
}
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
function isPlainObject(value) {
|
|
1289
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1290
|
+
const proto = Object.getPrototypeOf(value);
|
|
1291
|
+
return proto === Object.prototype || proto === null;
|
|
1127
1292
|
}
|
|
1128
1293
|
|
|
1129
|
-
// src/
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1294
|
+
// src/build/pipeline.ts
|
|
1295
|
+
var execFileAsync = promisify(execFile);
|
|
1296
|
+
var HANDLER_FILES = ["server/handler.ts", "server/handler.js", "server/index.ts", "server/index.js"];
|
|
1297
|
+
var CSS_ENTRY_FILES = ["src/globals.css", "src/style.css", "src/index.css"];
|
|
1298
|
+
var TARGET_FOOTER = "if(typeof LumioExtension!=='undefined'&&typeof LumioExtension.default==='function'){var _L=(typeof require!=='undefined'?require('@zaflun/lumio-sdk'):{}).Lumio;if(_L)_L.render(LumioExtension.default);}";
|
|
1299
|
+
var ASSET_LOADERS = Object.fromEntries(
|
|
1300
|
+
[".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico", ".mp4", ".webm", ".ogg", ".mp3", ".wav", ".woff", ".woff2", ".ttf", ".otf", ".lottie"].map((ext) => [ext, "file"])
|
|
1301
|
+
);
|
|
1302
|
+
function define(mode) {
|
|
1303
|
+
return { "process.env.NODE_ENV": JSON.stringify(mode.dev ? "development" : "production") };
|
|
1304
|
+
}
|
|
1305
|
+
function targetEntry(projectDir, target) {
|
|
1306
|
+
const entry = path5.join(projectDir, "src", `${target}.tsx`);
|
|
1307
|
+
return fs5.existsSync(entry) ? entry : null;
|
|
1308
|
+
}
|
|
1309
|
+
function targetBuildOptions(entryPoint, outdir, target, mode = {}) {
|
|
1310
|
+
return {
|
|
1311
|
+
entryPoints: [entryPoint],
|
|
1312
|
+
bundle: true,
|
|
1313
|
+
minify: !mode.dev,
|
|
1314
|
+
format: "iife",
|
|
1315
|
+
globalName: "LumioExtension",
|
|
1316
|
+
footer: { js: TARGET_FOOTER },
|
|
1317
|
+
// outdir instead of outfile so esbuild can emit asset files alongside JS;
|
|
1318
|
+
// entryNames keeps the target name.
|
|
1319
|
+
outdir,
|
|
1320
|
+
entryNames: target,
|
|
1321
|
+
jsx: "automatic",
|
|
1322
|
+
target: "es2022",
|
|
1323
|
+
loader: { ".css": "css", ...ASSET_LOADERS },
|
|
1324
|
+
assetNames: "assets/[name]-[hash]",
|
|
1325
|
+
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "@zaflun/lumio-sdk", "@zaflun/lumio-protocol"],
|
|
1326
|
+
define: define(mode)
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
async function planServerBuild(projectDir, config, outfile, mode = {}) {
|
|
1330
|
+
const functionsFile = config.category !== "automation_node" ? findFunctionsFile(projectDir) : null;
|
|
1331
|
+
const schema = await loadDataSchema(projectDir);
|
|
1332
|
+
let manifest = null;
|
|
1333
|
+
if (functionsFile) {
|
|
1334
|
+
const loaded = await loadFunctionExports(projectDir);
|
|
1335
|
+
manifest = buildFunctionsManifest(loaded?.exports ?? {}, schema, functionsFile);
|
|
1336
|
+
}
|
|
1337
|
+
const handlerEntry = HANDLER_FILES.find((f) => fs5.existsSync(path5.join(projectDir, f))) ?? null;
|
|
1338
|
+
const sfHandlers = manifest ? handlerNames(manifest) : [];
|
|
1339
|
+
const entry = handlerEntry && functionsFile && sfHandlers.length > 0 ? {
|
|
1340
|
+
stdin: {
|
|
1341
|
+
contents: combinedServerEntry(handlerEntry, functionsFile, sfHandlers),
|
|
1342
|
+
resolveDir: projectDir,
|
|
1343
|
+
sourcefile: "lumio-server-entry.ts",
|
|
1344
|
+
loader: "ts"
|
|
1139
1345
|
}
|
|
1346
|
+
} : handlerEntry || functionsFile ? { entryPoints: [path5.join(projectDir, handlerEntry ?? functionsFile)] } : null;
|
|
1347
|
+
const options = entry ? {
|
|
1348
|
+
...entry,
|
|
1349
|
+
absWorkingDir: projectDir,
|
|
1350
|
+
bundle: true,
|
|
1351
|
+
// Minified in dev too: the isolate's ESM → script transform (backend
|
|
1352
|
+
// and devkit replica alike) reads esbuild's minified import/export form.
|
|
1353
|
+
minify: true,
|
|
1354
|
+
format: "esm",
|
|
1355
|
+
outfile,
|
|
1356
|
+
platform: "neutral",
|
|
1357
|
+
target: "es2022",
|
|
1358
|
+
external: ["@zaflun/lumio-sdk", "@zaflun/lumio-protocol"],
|
|
1359
|
+
define: define(mode)
|
|
1360
|
+
} : null;
|
|
1361
|
+
return { options, manifest, schema, handlerEntry, functionsFile };
|
|
1362
|
+
}
|
|
1363
|
+
function findCssEntry(projectDir) {
|
|
1364
|
+
return CSS_ENTRY_FILES.find((f) => fs5.existsSync(path5.join(projectDir, f))) ?? null;
|
|
1365
|
+
}
|
|
1366
|
+
async function compileCss(projectDir, outDir, mode = {}) {
|
|
1367
|
+
const cssEntryFile = findCssEntry(projectDir);
|
|
1368
|
+
if (!cssEntryFile) return null;
|
|
1369
|
+
const input = path5.join(projectDir, cssEntryFile);
|
|
1370
|
+
const output = path5.join(outDir, "styles.css");
|
|
1371
|
+
const cssContent = fs5.readFileSync(input, "utf-8");
|
|
1372
|
+
const usesTailwind = cssContent.includes("tailwindcss") || cssContent.includes("@tailwind");
|
|
1373
|
+
if (!usesTailwind) {
|
|
1374
|
+
fs5.copyFileSync(input, output);
|
|
1375
|
+
return { file: output, tailwind: false };
|
|
1140
1376
|
}
|
|
1141
|
-
|
|
1377
|
+
try {
|
|
1378
|
+
const projectRequire = createRequire(path5.join(projectDir, "package.json"));
|
|
1379
|
+
const cliPkgPath = projectRequire.resolve("@tailwindcss/cli/package.json");
|
|
1380
|
+
const cliPkg = JSON.parse(fs5.readFileSync(cliPkgPath, "utf-8"));
|
|
1381
|
+
const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin?.tailwindcss ?? Object.values(cliPkg.bin ?? {})[0];
|
|
1382
|
+
if (!binRel) throw new Error("@tailwindcss/cli has no bin entry");
|
|
1383
|
+
const binPath = path5.join(path5.dirname(cliPkgPath), binRel);
|
|
1384
|
+
await execFileAsync(process.execPath, [binPath, "-i", input, "-o", output, ...mode.dev ? [] : ["--minify"]], { cwd: projectDir });
|
|
1385
|
+
} catch (err) {
|
|
1386
|
+
throw new CssBuildError(err instanceof Error ? err.message : String(err));
|
|
1387
|
+
}
|
|
1388
|
+
return { file: output, tailwind: true };
|
|
1142
1389
|
}
|
|
1143
|
-
var
|
|
1390
|
+
var CssBuildError = class extends Error {
|
|
1391
|
+
hint = "Install tailwindcss v4: npm add -D tailwindcss@^4.3 @tailwindcss/cli@^4.3";
|
|
1392
|
+
constructor(message2) {
|
|
1393
|
+
super(message2);
|
|
1394
|
+
this.name = "CssBuildError";
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
// src/dev/builder.ts
|
|
1399
|
+
var DEV_OUT_DIR = path6.join(".lumio", "dev");
|
|
1400
|
+
var DEV_TARGETS = ["editor", "layer"];
|
|
1401
|
+
function devTargets(projectDir, config) {
|
|
1402
|
+
const targets = [];
|
|
1403
|
+
const skipped = [];
|
|
1404
|
+
for (const t of config.targets) {
|
|
1405
|
+
if (!DEV_TARGETS.includes(t)) {
|
|
1406
|
+
skipped.push(`target "${t}" is not simulated by lumio dev`);
|
|
1407
|
+
} else if (!targetEntry(projectDir, t)) {
|
|
1408
|
+
skipped.push(`target "${t}": src/${t}.tsx not found`);
|
|
1409
|
+
} else targets.push(t);
|
|
1410
|
+
}
|
|
1411
|
+
return { targets, skipped };
|
|
1412
|
+
}
|
|
1413
|
+
function formatMessages(messages) {
|
|
1414
|
+
return messages.map((m) => m.location ? `${m.location.file}:${m.location.line}:${m.location.column}: ${m.text}` : m.text);
|
|
1415
|
+
}
|
|
1416
|
+
var DevBuilder = class {
|
|
1417
|
+
constructor(projectDir, config, targets, handlers) {
|
|
1418
|
+
this.projectDir = projectDir;
|
|
1419
|
+
this.config = config;
|
|
1420
|
+
this.targets = targets;
|
|
1421
|
+
this.handlers = handlers;
|
|
1422
|
+
this.outDir = path6.join(projectDir, DEV_OUT_DIR);
|
|
1423
|
+
}
|
|
1424
|
+
projectDir;
|
|
1425
|
+
config;
|
|
1426
|
+
targets;
|
|
1427
|
+
handlers;
|
|
1428
|
+
contexts = [];
|
|
1429
|
+
serverContext = null;
|
|
1430
|
+
serverEntryKey = "";
|
|
1431
|
+
watchers = [];
|
|
1432
|
+
serverTimer = null;
|
|
1433
|
+
serverBusy = false;
|
|
1434
|
+
lastTarget = /* @__PURE__ */ new Map();
|
|
1435
|
+
lastServer = "";
|
|
1436
|
+
cssRunning = null;
|
|
1437
|
+
stopped = false;
|
|
1438
|
+
outDir;
|
|
1439
|
+
/** Builds everything once and starts watching. Resolves to the first bundles. */
|
|
1440
|
+
async start() {
|
|
1441
|
+
fs6.mkdirSync(this.outDir, { recursive: true });
|
|
1442
|
+
const { context } = await import("esbuild");
|
|
1443
|
+
const bundles = {};
|
|
1444
|
+
let first = true;
|
|
1445
|
+
for (const target of this.targets) {
|
|
1446
|
+
const entry = targetEntry(this.projectDir, target);
|
|
1447
|
+
const outFile = path6.join(this.outDir, `${target}.js`);
|
|
1448
|
+
const ctx = await context({
|
|
1449
|
+
...targetBuildOptions(entry, this.outDir, target, { dev: true }),
|
|
1450
|
+
absWorkingDir: this.projectDir,
|
|
1451
|
+
logLevel: "silent",
|
|
1452
|
+
plugins: [
|
|
1453
|
+
this.onEnd(`${target}.js`, (ok) => {
|
|
1454
|
+
if (!ok) return;
|
|
1455
|
+
const code = fs6.readFileSync(outFile, "utf8");
|
|
1456
|
+
if (this.lastTarget.get(target) === code) return;
|
|
1457
|
+
this.lastTarget.set(target, code);
|
|
1458
|
+
if (first) bundles[target] = code;
|
|
1459
|
+
else this.handlers.onTarget(target, code);
|
|
1460
|
+
void this.css();
|
|
1461
|
+
})
|
|
1462
|
+
]
|
|
1463
|
+
});
|
|
1464
|
+
this.contexts.push(ctx);
|
|
1465
|
+
await ctx.rebuild().catch(() => {
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
const server = await this.buildServer(true);
|
|
1469
|
+
await this.css();
|
|
1470
|
+
first = false;
|
|
1471
|
+
for (const ctx of this.contexts) await ctx.watch();
|
|
1472
|
+
if (this.serverContext) await this.serverContext.watch();
|
|
1473
|
+
this.watch(path6.join(this.projectDir, "server"), () => this.scheduleServer());
|
|
1474
|
+
return { bundles, server };
|
|
1475
|
+
}
|
|
1476
|
+
onEnd(key, done) {
|
|
1477
|
+
return {
|
|
1478
|
+
name: "lumio-dev",
|
|
1479
|
+
setup: (build) => {
|
|
1480
|
+
build.onEnd(async (result) => {
|
|
1481
|
+
if (this.stopped) return;
|
|
1482
|
+
const ok = result.errors.length === 0;
|
|
1483
|
+
this.handlers.onStatus(key, ok ? null : formatMessages(result.errors).map((m) => `${key}: ${m}`));
|
|
1484
|
+
await done(ok, result);
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
watch(dir, onChange) {
|
|
1490
|
+
if (!fs6.existsSync(dir)) return;
|
|
1491
|
+
try {
|
|
1492
|
+
this.watchers.push(fs6.watch(dir, { recursive: true }, () => onChange()));
|
|
1493
|
+
} catch {
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
scheduleServer() {
|
|
1497
|
+
if (this.stopped) return;
|
|
1498
|
+
if (this.serverTimer) clearTimeout(this.serverTimer);
|
|
1499
|
+
this.serverTimer = setTimeout(() => {
|
|
1500
|
+
this.serverTimer = null;
|
|
1501
|
+
void this.buildServer(false);
|
|
1502
|
+
}, 120);
|
|
1503
|
+
}
|
|
1504
|
+
/** Plans the server build (functions.json + schema), (re)creates its context when the entry changed, and builds. */
|
|
1505
|
+
async buildServer(first) {
|
|
1506
|
+
const empty = { bundle: null, functions: null, schema: null };
|
|
1507
|
+
if (!this.config.server) return empty;
|
|
1508
|
+
this.serverBusy = true;
|
|
1509
|
+
try {
|
|
1510
|
+
const outfile = path6.join(this.outDir, "server.js");
|
|
1511
|
+
let plan;
|
|
1512
|
+
try {
|
|
1513
|
+
plan = await planServerBuild(this.projectDir, this.config, outfile, { dev: true });
|
|
1514
|
+
this.handlers.onStatus("server", null);
|
|
1515
|
+
} catch (e) {
|
|
1516
|
+
this.handlers.onStatus("server", [`server: ${e instanceof Error ? e.message : String(e)}`]);
|
|
1517
|
+
return empty;
|
|
1518
|
+
}
|
|
1519
|
+
const result = { bundle: null, functions: plan.manifest ? JSON.stringify(plan.manifest) : null, schema: plan.schema };
|
|
1520
|
+
if (!plan.options) {
|
|
1521
|
+
await this.serverContext?.dispose();
|
|
1522
|
+
this.serverContext = null;
|
|
1523
|
+
} else {
|
|
1524
|
+
const key = JSON.stringify({ e: plan.options.entryPoints, s: plan.options.stdin });
|
|
1525
|
+
if (!this.serverContext || key !== this.serverEntryKey) {
|
|
1526
|
+
await this.serverContext?.dispose();
|
|
1527
|
+
const { context } = await import("esbuild");
|
|
1528
|
+
this.serverContext = await context({
|
|
1529
|
+
...plan.options,
|
|
1530
|
+
logLevel: "silent",
|
|
1531
|
+
// A change inside the bundle graph re-plans: functions.ts may be part of it.
|
|
1532
|
+
plugins: [this.onEnd("server.js", () => {
|
|
1533
|
+
if (!this.serverBusy) this.scheduleServer();
|
|
1534
|
+
})]
|
|
1535
|
+
});
|
|
1536
|
+
this.serverEntryKey = key;
|
|
1537
|
+
if (!first) await this.serverContext.watch();
|
|
1538
|
+
}
|
|
1539
|
+
try {
|
|
1540
|
+
await this.serverContext.rebuild();
|
|
1541
|
+
} catch {
|
|
1542
|
+
if (!first) return result;
|
|
1543
|
+
}
|
|
1544
|
+
result.bundle = this.readServer(outfile);
|
|
1545
|
+
}
|
|
1546
|
+
const fingerprint = JSON.stringify(result);
|
|
1547
|
+
if (!first && fingerprint !== this.lastServer) this.handlers.onServer(result);
|
|
1548
|
+
this.lastServer = fingerprint;
|
|
1549
|
+
return result;
|
|
1550
|
+
} finally {
|
|
1551
|
+
this.serverBusy = false;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
readServer(outfile) {
|
|
1555
|
+
return fs6.existsSync(outfile) ? fs6.readFileSync(outfile, "utf8") : null;
|
|
1556
|
+
}
|
|
1557
|
+
/** Tailwind scans the sources, so the stylesheet follows every target build. */
|
|
1558
|
+
css() {
|
|
1559
|
+
if (this.cssRunning) return this.cssRunning;
|
|
1560
|
+
this.cssRunning = (async () => {
|
|
1561
|
+
try {
|
|
1562
|
+
const css = await compileCss(this.projectDir, this.outDir, { dev: true });
|
|
1563
|
+
this.handlers.onStatus("styles.css", null);
|
|
1564
|
+
if (css) this.handlers.onCss();
|
|
1565
|
+
} catch (e) {
|
|
1566
|
+
const hint = e instanceof CssBuildError ? ` (${e.hint})` : "";
|
|
1567
|
+
this.handlers.onStatus("styles.css", [`styles.css: ${e instanceof Error ? e.message : String(e)}${hint}`]);
|
|
1568
|
+
} finally {
|
|
1569
|
+
this.cssRunning = null;
|
|
1570
|
+
}
|
|
1571
|
+
})();
|
|
1572
|
+
return this.cssRunning;
|
|
1573
|
+
}
|
|
1574
|
+
/** The built stylesheet, or `null`. */
|
|
1575
|
+
stylesheet() {
|
|
1576
|
+
const file = path6.join(this.outDir, "styles.css");
|
|
1577
|
+
return fs6.existsSync(file) ? fs6.readFileSync(file, "utf8") : null;
|
|
1578
|
+
}
|
|
1579
|
+
async stop() {
|
|
1580
|
+
this.stopped = true;
|
|
1581
|
+
if (this.serverTimer) clearTimeout(this.serverTimer);
|
|
1582
|
+
for (const w of this.watchers.splice(0)) w.close();
|
|
1583
|
+
for (const ctx of this.contexts.splice(0)) await ctx.dispose();
|
|
1584
|
+
await this.serverContext?.dispose();
|
|
1585
|
+
this.serverContext = null;
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
// src/dev/project-modules.ts
|
|
1590
|
+
import * as vm from "vm";
|
|
1591
|
+
import { createRequire as createRequire2 } from "module";
|
|
1592
|
+
import * as path8 from "path";
|
|
1593
|
+
|
|
1594
|
+
// src/dev/resolve.ts
|
|
1595
|
+
import * as fs7 from "fs";
|
|
1596
|
+
import * as path7 from "path";
|
|
1597
|
+
import { pathToFileURL } from "url";
|
|
1598
|
+
function findPackage(fromDir, name) {
|
|
1599
|
+
let dir = path7.resolve(fromDir);
|
|
1600
|
+
for (; ; ) {
|
|
1601
|
+
const candidate = path7.join(dir, "node_modules", name, "package.json");
|
|
1602
|
+
if (fs7.existsSync(candidate)) {
|
|
1603
|
+
const manifest = JSON.parse(fs7.readFileSync(candidate, "utf8"));
|
|
1604
|
+
return {
|
|
1605
|
+
name,
|
|
1606
|
+
version: typeof manifest.version === "string" ? manifest.version : "0.0.0",
|
|
1607
|
+
dir: fs7.realpathSync(path7.dirname(candidate)),
|
|
1608
|
+
manifest
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
const parent = path7.dirname(dir);
|
|
1612
|
+
if (parent === dir) return null;
|
|
1613
|
+
dir = parent;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function entryOf(pkg, subpath = ".") {
|
|
1617
|
+
const exportsField = pkg.manifest.exports;
|
|
1618
|
+
let target;
|
|
1619
|
+
if (typeof exportsField === "string") {
|
|
1620
|
+
target = subpath === "." ? exportsField : void 0;
|
|
1621
|
+
} else if (exportsField && typeof exportsField === "object") {
|
|
1622
|
+
const map = exportsField;
|
|
1623
|
+
const isSubpathMap = Object.keys(map).some((k) => k.startsWith("."));
|
|
1624
|
+
const entry = isSubpathMap ? map[subpath] : subpath === "." ? map : void 0;
|
|
1625
|
+
target = pickCondition(entry);
|
|
1626
|
+
}
|
|
1627
|
+
if (target === void 0 && subpath === ".") {
|
|
1628
|
+
target = pkg.manifest.module ?? pkg.manifest.main ?? "index.js";
|
|
1629
|
+
}
|
|
1630
|
+
if (typeof target !== "string") {
|
|
1631
|
+
throw new Error(`${pkg.name} does not export "${subpath}" (installed ${pkg.version} at ${pkg.dir})`);
|
|
1632
|
+
}
|
|
1633
|
+
return path7.join(pkg.dir, target);
|
|
1634
|
+
}
|
|
1635
|
+
function pickCondition(entry) {
|
|
1636
|
+
if (typeof entry === "string" || entry === void 0 || entry === null) return entry ?? void 0;
|
|
1637
|
+
if (typeof entry !== "object") return void 0;
|
|
1638
|
+
const conditions = entry;
|
|
1639
|
+
for (const key of ["import", "node", "default"]) {
|
|
1640
|
+
if (key in conditions) return pickCondition(conditions[key]);
|
|
1641
|
+
}
|
|
1642
|
+
return void 0;
|
|
1643
|
+
}
|
|
1644
|
+
async function importFile(file) {
|
|
1645
|
+
return await import(pathToFileURL(file).href);
|
|
1646
|
+
}
|
|
1647
|
+
function compareVersions(a, b) {
|
|
1648
|
+
const parse = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1649
|
+
const [x, y] = [parse(a), parse(b)];
|
|
1650
|
+
for (let i = 0; i < 3; i++) {
|
|
1651
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
1652
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
1653
|
+
}
|
|
1654
|
+
return 0;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/dev/version.ts
|
|
1658
|
+
var CLI_PROTOCOL_VERSION = "0.9.0";
|
|
1659
|
+
|
|
1660
|
+
// src/dev/project-modules.ts
|
|
1661
|
+
var MissingPackageError = class extends Error {
|
|
1662
|
+
constructor(packageName) {
|
|
1663
|
+
super(`${packageName} is not installed in this project. Run: npm install --save-dev ${packageName}`);
|
|
1664
|
+
this.packageName = packageName;
|
|
1665
|
+
this.name = "MissingPackageError";
|
|
1666
|
+
}
|
|
1667
|
+
packageName;
|
|
1668
|
+
};
|
|
1669
|
+
function need(fromDir, name) {
|
|
1670
|
+
const pkg = findPackage(fromDir, name);
|
|
1671
|
+
if (!pkg) throw new MissingPackageError(name);
|
|
1672
|
+
return pkg;
|
|
1673
|
+
}
|
|
1674
|
+
async function loadProjectModules(projectDir) {
|
|
1675
|
+
const devkitPkg = need(projectDir, "@zaflun/lumio-devkit");
|
|
1676
|
+
const sdkPkg = findPackage(devkitPkg.dir, "@zaflun/lumio-sdk") ?? need(projectDir, "@zaflun/lumio-sdk");
|
|
1677
|
+
const protocolPkg = findPackage(sdkPkg.dir, "@zaflun/lumio-protocol") ?? need(projectDir, "@zaflun/lumio-protocol");
|
|
1678
|
+
const devkit = await importFile(entryOf(devkitPkg));
|
|
1679
|
+
const devkitNode = await importFile(entryOf(devkitPkg, "./node"));
|
|
1680
|
+
const sdk = await importFile(entryOf(sdkPkg));
|
|
1681
|
+
const sdkServer = await importFile(entryOf(sdkPkg, "./server"));
|
|
1682
|
+
const protocol = await importFile(entryOf(protocolPkg));
|
|
1683
|
+
const sdkRequire = createRequire2(path8.join(sdkPkg.dir, "package.json"));
|
|
1684
|
+
const react = sdkRequire("react");
|
|
1685
|
+
const jsxRuntime = sdkRequire("react/jsx-runtime");
|
|
1686
|
+
const warnings = [];
|
|
1687
|
+
if (compareVersions(protocolPkg.version, CLI_PROTOCOL_VERSION) < 0) {
|
|
1688
|
+
warnings.push(
|
|
1689
|
+
`@zaflun/lumio-sdk ${sdkPkg.version} of this project speaks protocol ${protocolPkg.version}, older than ${CLI_PROTOCOL_VERSION} of this CLI. Update: npm install @zaflun/lumio-sdk@latest && npm install --save-dev @zaflun/lumio-devkit@latest`
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
const devkitProtocol = findPackage(devkitPkg.dir, "@zaflun/lumio-protocol");
|
|
1693
|
+
if (devkitProtocol && compareVersions(devkitProtocol.version, CLI_PROTOCOL_VERSION) < 0) {
|
|
1694
|
+
warnings.push(
|
|
1695
|
+
`@zaflun/lumio-devkit ${devkitPkg.version} mocks protocol ${devkitProtocol.version}, older than ${CLI_PROTOCOL_VERSION} of this CLI: events and actions added since are missing. Update: npm install --save-dev @zaflun/lumio-devkit@latest`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
devkit,
|
|
1700
|
+
devkitNode,
|
|
1701
|
+
react,
|
|
1702
|
+
registry: {
|
|
1703
|
+
react,
|
|
1704
|
+
"react/jsx-runtime": jsxRuntime,
|
|
1705
|
+
"@zaflun/lumio-sdk": sdk,
|
|
1706
|
+
"@zaflun/lumio-sdk/server": sdkServer,
|
|
1707
|
+
"@zaflun/lumio-protocol": protocol
|
|
1708
|
+
},
|
|
1709
|
+
versions: { devkit: devkitPkg.version, sdk: sdkPkg.version, protocol: protocolPkg.version },
|
|
1710
|
+
warnings
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
var BundleError = class extends Error {
|
|
1714
|
+
constructor(message2) {
|
|
1715
|
+
super(message2);
|
|
1716
|
+
this.name = "BundleError";
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
function evaluateTargetBundle(code, file, modules) {
|
|
1720
|
+
const react = modules.react;
|
|
1721
|
+
let rendered = null;
|
|
1722
|
+
const lumio = {
|
|
1723
|
+
render(input) {
|
|
1724
|
+
if (rendered) throw new Error("Lumio.render() can only be called once");
|
|
1725
|
+
rendered = typeof input === "function" ? react.createElement(input) : input;
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
const registry = {
|
|
1729
|
+
...modules.registry,
|
|
1730
|
+
"@zaflun/lumio-sdk": { ...modules.registry["@zaflun/lumio-sdk"], Lumio: lumio }
|
|
1731
|
+
};
|
|
1732
|
+
const lumioRequire = (id) => {
|
|
1733
|
+
if (!(id in registry)) throw new Error(`[Lumio Runtime] Module "${id}" is not available in the extension sandbox`);
|
|
1734
|
+
return registry[id];
|
|
1735
|
+
};
|
|
1736
|
+
try {
|
|
1737
|
+
const fn = vm.runInThisContext(`(function (require, self) {
|
|
1738
|
+
${code}
|
|
1739
|
+
})`, { filename: file });
|
|
1740
|
+
fn(lumioRequire, globalThis);
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
throw new BundleError(`${path8.basename(file)} failed to evaluate: ${e instanceof Error ? e.message : String(e)}`);
|
|
1743
|
+
}
|
|
1744
|
+
if (!rendered) {
|
|
1745
|
+
throw new BundleError(`${path8.basename(file)} rendered nothing: export a default component or call Lumio.render(<App />)`);
|
|
1746
|
+
}
|
|
1747
|
+
return rendered;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/dev/repl.ts
|
|
1751
|
+
import * as readline2 from "readline";
|
|
1752
|
+
import chalk2 from "chalk";
|
|
1753
|
+
|
|
1754
|
+
// src/dev/errors.ts
|
|
1755
|
+
var CommandSyntaxError = class extends Error {
|
|
1756
|
+
constructor(message2) {
|
|
1757
|
+
super(message2);
|
|
1758
|
+
this.name = "CommandSyntaxError";
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
// src/dev/load.ts
|
|
1763
|
+
var MAX_COUNT = 1e4;
|
|
1764
|
+
var MAX_RATE_PER_SECOND = 1e3;
|
|
1765
|
+
function parseCount(raw) {
|
|
1766
|
+
const n = Number(raw);
|
|
1767
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_COUNT) throw new CommandSyntaxError(`--count must be an integer between 1 and ${MAX_COUNT}, got "${raw}"`);
|
|
1768
|
+
return n;
|
|
1769
|
+
}
|
|
1770
|
+
function parseRate(raw) {
|
|
1771
|
+
const m = /^(\d+(?:\.\d+)?)(?:\/(s|sec|m|min|h))?$/.exec(raw.trim());
|
|
1772
|
+
if (!m) throw new CommandSyntaxError(`--rate is a count per time, e.g. 5/s, 120/m or 600/h, got "${raw}"`);
|
|
1773
|
+
const per = { s: 1, sec: 1, m: 60, min: 60, h: 3600 }[m[2] ?? "s"];
|
|
1774
|
+
const perSecond = Number(m[1]) / per;
|
|
1775
|
+
if (!(perSecond > 0) || perSecond > MAX_RATE_PER_SECOND) {
|
|
1776
|
+
throw new CommandSyntaxError(`--rate must be above 0 and at most ${MAX_RATE_PER_SECOND}/s, got "${raw}"`);
|
|
1777
|
+
}
|
|
1778
|
+
return 1e3 / perSecond;
|
|
1779
|
+
}
|
|
1780
|
+
function describeRate(intervalMs) {
|
|
1781
|
+
if (intervalMs <= 0) return "back to back";
|
|
1782
|
+
const perSecond = 1e3 / intervalMs;
|
|
1783
|
+
return perSecond >= 1 ? `${round(perSecond)}/s` : `${round(perSecond * 60)}/m`;
|
|
1784
|
+
}
|
|
1785
|
+
function round(n) {
|
|
1786
|
+
return Math.round(n * 100) / 100;
|
|
1787
|
+
}
|
|
1788
|
+
function seededPicker(seed) {
|
|
1789
|
+
let state = seed >>> 0;
|
|
1790
|
+
return (items) => {
|
|
1791
|
+
if (items.length === 0) throw new CommandSyntaxError("Nothing to pick from");
|
|
1792
|
+
state = state + 1831565813 >>> 0;
|
|
1793
|
+
let t = state;
|
|
1794
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
1795
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
1796
|
+
const r = ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1797
|
+
return items[Math.floor(r * items.length)];
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
function freshSeed() {
|
|
1801
|
+
return Math.floor(Math.random() * 2 ** 31);
|
|
1802
|
+
}
|
|
1803
|
+
function randomEventPool(eventTypes, platform) {
|
|
1804
|
+
const pool = eventTypes.filter((e) => e.forwarded && (platform === void 0 || e.type.startsWith(`${platform}:`))).map((e) => e.type);
|
|
1805
|
+
if (pool.length === 0) {
|
|
1806
|
+
throw new CommandSyntaxError(platform ? `No ${platform}:* event type is forwarded to extensions` : "No event type is forwarded to extensions");
|
|
1807
|
+
}
|
|
1808
|
+
return pool;
|
|
1809
|
+
}
|
|
1810
|
+
function repeatSpec(cmd, defaultSeed) {
|
|
1811
|
+
const spec = { count: cmd.count ?? 1, intervalMs: cmd.intervalMs ?? 0, random: cmd.random === true, seed: cmd.seed ?? defaultSeed };
|
|
1812
|
+
if (!Number.isInteger(spec.count) || spec.count < 1 || spec.count > MAX_COUNT) throw new CommandSyntaxError(`count must be an integer between 1 and ${MAX_COUNT}`);
|
|
1813
|
+
if (typeof spec.intervalMs !== "number" || !(spec.intervalMs >= 0)) throw new CommandSyntaxError("intervalMs must be 0 or more");
|
|
1814
|
+
const inner = cmd.command;
|
|
1815
|
+
if (typeof inner !== "object" || inner === null || inner.kind !== "event" && inner.kind !== "scenario" && inner.kind !== "chat") {
|
|
1816
|
+
throw new CommandSyntaxError("repeat runs an event, scenario or chat command");
|
|
1817
|
+
}
|
|
1818
|
+
return spec;
|
|
1819
|
+
}
|
|
1820
|
+
function repeatVariants(cmd, spec, pools) {
|
|
1821
|
+
const inner = cmd.command;
|
|
1822
|
+
if (!spec.random) return () => inner;
|
|
1823
|
+
const pick = seededPicker(spec.seed);
|
|
1824
|
+
if (inner.kind === "scenario") {
|
|
1825
|
+
if (pools.scenarios.length === 0) throw new CommandSyntaxError("This devkit has no scenarios");
|
|
1826
|
+
return () => ({ ...inner, name: pick(pools.scenarios) });
|
|
1827
|
+
}
|
|
1828
|
+
if (inner.kind !== "event") throw new CommandSyntaxError("--random picks an event type or a scenario");
|
|
1829
|
+
const pool = randomEventPool(pools.eventTypes, cmd.platform);
|
|
1830
|
+
return () => ({ ...inner, type: pick(pool) });
|
|
1831
|
+
}
|
|
1832
|
+
function defaultSleep(ms, signal) {
|
|
1833
|
+
return new Promise((resolve13) => {
|
|
1834
|
+
if (signal?.aborted) return resolve13();
|
|
1835
|
+
const timer = setTimeout(done, ms);
|
|
1836
|
+
function done() {
|
|
1837
|
+
clearTimeout(timer);
|
|
1838
|
+
signal?.removeEventListener("abort", done);
|
|
1839
|
+
resolve13();
|
|
1840
|
+
}
|
|
1841
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
async function runLoad(spec, run, hooks = {}) {
|
|
1845
|
+
const now = hooks.now ?? Date.now;
|
|
1846
|
+
const sleep = hooks.sleep ?? defaultSleep;
|
|
1847
|
+
const results = [];
|
|
1848
|
+
const start = now();
|
|
1849
|
+
for (let i = 0; i < spec.count; i++) {
|
|
1850
|
+
if (hooks.signal?.aborted) return { results, failed: null, stopped: true };
|
|
1851
|
+
if (i > 0 && spec.intervalMs > 0) {
|
|
1852
|
+
const wait = start + i * spec.intervalMs - now();
|
|
1853
|
+
if (wait > 0) await sleep(wait, hooks.signal);
|
|
1854
|
+
if (hooks.signal?.aborted) return { results, failed: null, stopped: true };
|
|
1855
|
+
}
|
|
1856
|
+
const r = await run(i);
|
|
1857
|
+
results.push(r);
|
|
1858
|
+
if (!r.ok) return { results, failed: r, stopped: false };
|
|
1859
|
+
}
|
|
1860
|
+
return { results, failed: null, stopped: false };
|
|
1861
|
+
}
|
|
1862
|
+
function summarizeLoad(spec, result) {
|
|
1863
|
+
const ok = result.results.filter((r) => r.ok).length;
|
|
1864
|
+
const pace = spec.count > 1 ? ` (${describeRate(spec.intervalMs)})` : "";
|
|
1865
|
+
const random = spec.random ? ` \xB7 random picks, seed ${spec.seed}` : "";
|
|
1866
|
+
if (result.failed) return `failed at ${result.results.length}/${spec.count}, ${ok} sent${pace}${random}`;
|
|
1867
|
+
if (result.stopped) return `stopped after ${ok}/${spec.count}${pace}${random}`;
|
|
1868
|
+
return `sent ${ok}/${spec.count}${pace}${random}`;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// src/dev/commands.ts
|
|
1872
|
+
var REPL_HELP = [
|
|
1873
|
+
"Commands (plain text without a / is a chat message):",
|
|
1874
|
+
" /event <type> [field=value \u2026] [--json '{\u2026}'] [--user name] send an event (fields are dotted paths)",
|
|
1875
|
+
" /event @<preset> send a preset from lumio/mocks/events/",
|
|
1876
|
+
" /chat [@role:name] <text> [--platform kick] a chat message (commands, keywords, moderation)",
|
|
1877
|
+
' /reward "<title>" [--user name] [--input "text"] [--platform kick] redeem a channel-point reward',
|
|
1878
|
+
" /reward-status <redemption-id> <status> fulfilled|canceled (Twitch), accepted|rejected (Kick)",
|
|
1879
|
+
" /music play|pause|skip [n]|seek 1:30|volume 50|shuffle on|repeat track|queue <uri>|device <name>|advance 30s",
|
|
1880
|
+
" /timer <name> fire a declared timer /defer run run queued ctx.defer jobs",
|
|
1881
|
+
" /scenario <name> [key=value \u2026] e.g. /scenario raid viewers=500",
|
|
1882
|
+
" load flags on /event, /scenario and chat: --count N --rate 5/s --random [--platform kick] --seed n",
|
|
1883
|
+
" e.g. /event twitch:follower --count 50 --rate 5/s \xB7 /event --random --count 20 \xB7 /scenario --random",
|
|
1884
|
+
" /storage [key] [json] show or set storage /db [table] [--reset] show ctx.db rows",
|
|
1885
|
+
" /config [key=value \u2026] show or change settings /theme light|dark",
|
|
1886
|
+
" /view editor|layer switch the sandbox view /stop stop running loads /status /help /quit"
|
|
1887
|
+
];
|
|
1888
|
+
function tokenize(line) {
|
|
1889
|
+
const out = [];
|
|
1890
|
+
let current = "";
|
|
1891
|
+
let quote = null;
|
|
1892
|
+
let inToken = false;
|
|
1893
|
+
for (let i = 0; i < line.length; i++) {
|
|
1894
|
+
const c = line[i];
|
|
1895
|
+
if (quote) {
|
|
1896
|
+
if (c === quote) quote = null;
|
|
1897
|
+
else if (c === "\\" && quote === '"' && i + 1 < line.length) current += line[++i];
|
|
1898
|
+
else current += c;
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
if (c === '"' || c === "'") {
|
|
1902
|
+
quote = c;
|
|
1903
|
+
inToken = true;
|
|
1904
|
+
} else if (c === "\\" && i + 1 < line.length) {
|
|
1905
|
+
current += line[++i];
|
|
1906
|
+
inToken = true;
|
|
1907
|
+
} else if (/\s/.test(c)) {
|
|
1908
|
+
if (inToken) out.push(current);
|
|
1909
|
+
current = "";
|
|
1910
|
+
inToken = false;
|
|
1911
|
+
} else {
|
|
1912
|
+
current += c;
|
|
1913
|
+
inToken = true;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
if (quote) throw new CommandSyntaxError(`Unclosed ${quote} quote`);
|
|
1917
|
+
if (inToken) out.push(current);
|
|
1918
|
+
return out;
|
|
1919
|
+
}
|
|
1920
|
+
var LOAD_FLAGS = ["count", "rate", "seed"];
|
|
1921
|
+
function splitFlags(tokens, flags, booleans = []) {
|
|
1922
|
+
const words = [];
|
|
1923
|
+
const found = {};
|
|
1924
|
+
const bools = /* @__PURE__ */ new Set();
|
|
1925
|
+
const all = [...flags, ...booleans];
|
|
1926
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1927
|
+
const t = tokens[i];
|
|
1928
|
+
if (t.startsWith("--")) {
|
|
1929
|
+
const name = t.slice(2);
|
|
1930
|
+
if (!all.includes(name)) throw new CommandSyntaxError(`Unknown option ${t} (options: ${all.map((f) => `--${f}`).join(", ") || "none"})`);
|
|
1931
|
+
if (booleans.includes(name)) {
|
|
1932
|
+
bools.add(name);
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
if (i + 1 >= tokens.length) throw new CommandSyntaxError(`${t} needs a value`);
|
|
1936
|
+
found[name] = tokens[++i];
|
|
1937
|
+
} else words.push(t);
|
|
1938
|
+
}
|
|
1939
|
+
return { words, flags: found, bools };
|
|
1940
|
+
}
|
|
1941
|
+
function loadFlags(flags, bools) {
|
|
1942
|
+
return { count: flags.count, rate: flags.rate, seed: flags.seed, random: bools.has("random") };
|
|
1943
|
+
}
|
|
1944
|
+
function withLoad(inner, load) {
|
|
1945
|
+
const random = load.random === true;
|
|
1946
|
+
if (load.platform !== void 0 && !random) throw new CommandSyntaxError("--platform filters --random; the event type already names its platform");
|
|
1947
|
+
if (load.count === void 0 && load.rate === void 0 && load.seed === void 0 && !random) return inner;
|
|
1948
|
+
if (load.rate !== void 0 && load.count === void 0) throw new CommandSyntaxError("--rate paces --count runs: add --count N");
|
|
1949
|
+
if (load.seed !== void 0 && !random) throw new CommandSyntaxError("--seed sets the --random picks; event fields follow the session seed (lumio dev --seed)");
|
|
1950
|
+
if (inner.kind !== "event" && inner.kind !== "scenario" && inner.kind !== "chat") {
|
|
1951
|
+
throw new CommandSyntaxError("--count, --rate and --random apply to events, scenarios and chat messages");
|
|
1952
|
+
}
|
|
1953
|
+
if (random) {
|
|
1954
|
+
if (inner.kind === "chat") throw new CommandSyntaxError("--random picks an event type or a scenario, not a chat message");
|
|
1955
|
+
if (inner.kind === "event" && (inner.type !== void 0 || inner.preset !== void 0 || Object.keys(inner.data ?? {}).length > 0)) {
|
|
1956
|
+
throw new CommandSyntaxError("--random picks the event type itself: drop the type, preset and fields (every event gets fresh random fields anyway)");
|
|
1957
|
+
}
|
|
1958
|
+
if (inner.kind === "scenario" && (inner.name !== "" || Object.keys(inner.options ?? {}).length > 0)) {
|
|
1959
|
+
throw new CommandSyntaxError("--random picks the scenario itself: drop the name and options");
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
if (random && load.platform !== void 0 && inner.kind !== "event") throw new CommandSyntaxError("--platform filters random event types only");
|
|
1963
|
+
let seed;
|
|
1964
|
+
if (load.seed !== void 0) {
|
|
1965
|
+
seed = Number(load.seed);
|
|
1966
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 2 ** 31 - 1) throw new CommandSyntaxError(`--seed must be an integer between 0 and ${2 ** 31 - 1}`);
|
|
1967
|
+
}
|
|
1968
|
+
return {
|
|
1969
|
+
kind: "repeat",
|
|
1970
|
+
command: inner,
|
|
1971
|
+
count: load.count === void 0 ? 1 : parseCount(load.count),
|
|
1972
|
+
intervalMs: load.rate === void 0 ? 0 : parseRate(load.rate),
|
|
1973
|
+
...random ? { random: true } : {},
|
|
1974
|
+
...load.platform !== void 0 ? { platform: load.platform } : {},
|
|
1975
|
+
...seed !== void 0 ? { seed } : {}
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
function parseValue(raw) {
|
|
1979
|
+
if (raw === "true") return true;
|
|
1980
|
+
if (raw === "false") return false;
|
|
1981
|
+
if (raw === "null") return null;
|
|
1982
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
1983
|
+
if (/^[[{]/.test(raw)) {
|
|
1984
|
+
try {
|
|
1985
|
+
return JSON.parse(raw);
|
|
1986
|
+
} catch {
|
|
1987
|
+
throw new CommandSyntaxError(`Not valid JSON: ${raw}`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return raw;
|
|
1991
|
+
}
|
|
1992
|
+
function setPath(target, dotted, value) {
|
|
1993
|
+
const parts = dotted.split(".");
|
|
1994
|
+
if (parts.some((p) => p === "" || p === "__proto__" || p === "constructor" || p === "prototype")) {
|
|
1995
|
+
throw new CommandSyntaxError(`Invalid field path "${dotted}"`);
|
|
1996
|
+
}
|
|
1997
|
+
let node = target;
|
|
1998
|
+
for (const part of parts.slice(0, -1)) {
|
|
1999
|
+
const next = node[part];
|
|
2000
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) node[part] = {};
|
|
2001
|
+
node = node[part];
|
|
2002
|
+
}
|
|
2003
|
+
node[parts[parts.length - 1]] = value;
|
|
2004
|
+
}
|
|
2005
|
+
function assignments(words, what) {
|
|
2006
|
+
const out = {};
|
|
2007
|
+
for (const w of words) {
|
|
2008
|
+
const eq = w.indexOf("=");
|
|
2009
|
+
if (eq <= 0) throw new CommandSyntaxError(`Expected ${what} as key=value, got "${w}"`);
|
|
2010
|
+
setPath(out, w.slice(0, eq), parseValue(w.slice(eq + 1)));
|
|
2011
|
+
}
|
|
2012
|
+
return out;
|
|
2013
|
+
}
|
|
2014
|
+
var ROLE_ALIASES = {
|
|
2015
|
+
mod: "moderator",
|
|
2016
|
+
moderator: "moderator",
|
|
2017
|
+
sub: "subscriber",
|
|
2018
|
+
subscriber: "subscriber",
|
|
2019
|
+
vip: "vip",
|
|
2020
|
+
broadcaster: "broadcaster",
|
|
2021
|
+
everyone: "everyone",
|
|
2022
|
+
viewer: "everyone",
|
|
2023
|
+
follower: "follower"
|
|
2024
|
+
};
|
|
2025
|
+
function parseUserPrefix(word) {
|
|
2026
|
+
const body = word.slice(1);
|
|
2027
|
+
const colon = body.indexOf(":");
|
|
2028
|
+
if (colon === -1) return { name: body };
|
|
2029
|
+
const role = ROLE_ALIASES[body.slice(0, colon).toLowerCase()];
|
|
2030
|
+
if (!role) throw new CommandSyntaxError(`Unknown role "${body.slice(0, colon)}" (use ${Object.keys(ROLE_ALIASES).join(", ")})`);
|
|
2031
|
+
return { role, name: body.slice(colon + 1) };
|
|
2032
|
+
}
|
|
2033
|
+
function userFlag(value) {
|
|
2034
|
+
if (value === void 0) return void 0;
|
|
2035
|
+
return value.startsWith("@") ? parseUserPrefix(value) : { name: value };
|
|
2036
|
+
}
|
|
2037
|
+
function parseReplLine(line) {
|
|
2038
|
+
const trimmed = line.trim();
|
|
2039
|
+
if (trimmed === "") throw new CommandSyntaxError("Empty input");
|
|
2040
|
+
if (!trimmed.startsWith("/")) return parseChat(tokenize(trimmed));
|
|
2041
|
+
const [head, ...rest] = tokenize(trimmed);
|
|
2042
|
+
switch (head) {
|
|
2043
|
+
case "/quit":
|
|
2044
|
+
case "/exit":
|
|
2045
|
+
return null;
|
|
2046
|
+
case "/help":
|
|
2047
|
+
return { kind: "help" };
|
|
2048
|
+
case "/status":
|
|
2049
|
+
return { kind: "status" };
|
|
2050
|
+
case "/stop":
|
|
2051
|
+
return { kind: "stop" };
|
|
2052
|
+
case "/event": {
|
|
2053
|
+
const { words, flags, bools } = splitFlags(rest, ["json", "user", "platform", ...LOAD_FLAGS], ["random"]);
|
|
2054
|
+
const load = { ...loadFlags(flags, bools), platform: flags.platform };
|
|
2055
|
+
const [type, ...fields] = words;
|
|
2056
|
+
if (!type && load.random) {
|
|
2057
|
+
if (flags.json !== void 0) throw new CommandSyntaxError("--random picks the event type itself: drop --json");
|
|
2058
|
+
return withLoad({ kind: "event", user: userFlag(flags.user) }, load);
|
|
2059
|
+
}
|
|
2060
|
+
if (!type) throw new CommandSyntaxError("Usage: /event <type> [field=value \u2026] | /event @<preset> | /event --random");
|
|
2061
|
+
if (type.startsWith("@")) {
|
|
2062
|
+
if (fields.length || flags.json) throw new CommandSyntaxError("A preset takes no fields");
|
|
2063
|
+
return withLoad({ kind: "event", preset: type.slice(1), user: userFlag(flags.user) }, load);
|
|
2064
|
+
}
|
|
2065
|
+
const data = assignments(fields, "event fields");
|
|
2066
|
+
if (flags.json !== void 0) {
|
|
2067
|
+
const json = parseValue(flags.json);
|
|
2068
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) throw new CommandSyntaxError("--json needs a JSON object");
|
|
2069
|
+
Object.assign(data, json);
|
|
2070
|
+
}
|
|
2071
|
+
return withLoad({ kind: "event", type, data, user: userFlag(flags.user) }, load);
|
|
2072
|
+
}
|
|
2073
|
+
case "/chat":
|
|
2074
|
+
return parseChat(rest);
|
|
2075
|
+
case "/reward": {
|
|
2076
|
+
const { words, flags } = splitFlags(rest, ["user", "input", "platform"]);
|
|
2077
|
+
if (words.length !== 1) throw new CommandSyntaxError('Usage: /reward "<title or id>" [--user name] [--input "text"] [--platform twitch|kick]');
|
|
2078
|
+
return { kind: "reward", reward: words[0], user: userFlag(flags.user), input: flags.input, platform: flags.platform };
|
|
2079
|
+
}
|
|
2080
|
+
case "/reward-status": {
|
|
2081
|
+
if (rest.length !== 2) throw new CommandSyntaxError("Usage: /reward-status <redemption-id> <status>");
|
|
2082
|
+
return { kind: "reward-status", id: rest[0], status: rest[1] };
|
|
2083
|
+
}
|
|
2084
|
+
case "/music": {
|
|
2085
|
+
const [op, ...args] = rest;
|
|
2086
|
+
if (!op) throw new CommandSyntaxError("Usage: /music play|pause|skip|seek|volume|shuffle|repeat|queue|device|advance \u2026");
|
|
2087
|
+
return { kind: "music", op, args };
|
|
2088
|
+
}
|
|
2089
|
+
case "/timer":
|
|
2090
|
+
if (rest.length !== 1) throw new CommandSyntaxError("Usage: /timer <name>");
|
|
2091
|
+
return { kind: "timer", name: rest[0] };
|
|
2092
|
+
case "/defer":
|
|
2093
|
+
if (rest[0] !== "run" || rest.length !== 1) throw new CommandSyntaxError("Usage: /defer run");
|
|
2094
|
+
return { kind: "defer-run" };
|
|
2095
|
+
case "/scenario": {
|
|
2096
|
+
const { words, flags, bools } = splitFlags(rest, [...LOAD_FLAGS], ["random"]);
|
|
2097
|
+
const load = loadFlags(flags, bools);
|
|
2098
|
+
const [name, ...opts] = words;
|
|
2099
|
+
if (!name && !load.random) throw new CommandSyntaxError("Usage: /scenario <name> [key=value \u2026] | /scenario --random");
|
|
2100
|
+
return withLoad({ kind: "scenario", name: name ?? "", options: assignments(opts, "scenario options") }, load);
|
|
2101
|
+
}
|
|
2102
|
+
case "/storage": {
|
|
2103
|
+
if (rest.length === 0) return { kind: "storage" };
|
|
2104
|
+
if (rest.length > 2) throw new CommandSyntaxError("Usage: /storage [key] ['<json>']");
|
|
2105
|
+
if (rest.length === 1) return { kind: "storage", key: rest[0] };
|
|
2106
|
+
let value;
|
|
2107
|
+
try {
|
|
2108
|
+
value = JSON.parse(rest[1]);
|
|
2109
|
+
} catch {
|
|
2110
|
+
throw new CommandSyntaxError(`Storage values are JSON: ${rest[1]}`);
|
|
2111
|
+
}
|
|
2112
|
+
return { kind: "storage", key: rest[0], value };
|
|
2113
|
+
}
|
|
2114
|
+
case "/db": {
|
|
2115
|
+
const reset = rest.includes("--reset");
|
|
2116
|
+
const words = rest.filter((w) => w !== "--reset");
|
|
2117
|
+
if (words.length > 1) throw new CommandSyntaxError("Usage: /db [table] [--reset]");
|
|
2118
|
+
return { kind: "db", table: words[0], reset };
|
|
2119
|
+
}
|
|
2120
|
+
case "/config":
|
|
2121
|
+
return rest.length ? { kind: "config", settings: assignments(rest, "settings") } : { kind: "config" };
|
|
2122
|
+
case "/theme":
|
|
2123
|
+
if (rest[0] !== "light" && rest[0] !== "dark") throw new CommandSyntaxError("Usage: /theme light|dark");
|
|
2124
|
+
return { kind: "theme", mode: rest[0] };
|
|
2125
|
+
case "/view":
|
|
2126
|
+
if (rest[0] !== "editor" && rest[0] !== "layer") throw new CommandSyntaxError("Usage: /view editor|layer");
|
|
2127
|
+
return { kind: "view", target: rest[0] };
|
|
2128
|
+
default:
|
|
2129
|
+
throw new CommandSyntaxError(`Unknown command ${head}. Type /help.`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
function parseChat(tokens) {
|
|
2133
|
+
const { words, flags } = splitFlags(tokens, ["platform", "user", "count", "rate"]);
|
|
2134
|
+
let user = userFlag(flags.user);
|
|
2135
|
+
if (words[0]?.startsWith("@") && words.length > 1) {
|
|
2136
|
+
user = parseUserPrefix(words[0]);
|
|
2137
|
+
words.shift();
|
|
2138
|
+
}
|
|
2139
|
+
const text = words.join(" ");
|
|
2140
|
+
if (text === "") throw new CommandSyntaxError("Usage: /chat [@role:name] <text>");
|
|
2141
|
+
return withLoad({ kind: "chat", text, user, platform: flags.platform }, { count: flags.count, rate: flags.rate });
|
|
2142
|
+
}
|
|
2143
|
+
var KINDS = /* @__PURE__ */ new Set([
|
|
2144
|
+
"event",
|
|
2145
|
+
"chat",
|
|
2146
|
+
"reward",
|
|
2147
|
+
"reward-status",
|
|
2148
|
+
"music",
|
|
2149
|
+
"timer",
|
|
2150
|
+
"defer-run",
|
|
2151
|
+
"scenario",
|
|
2152
|
+
"storage",
|
|
2153
|
+
"db",
|
|
2154
|
+
"config",
|
|
2155
|
+
"theme",
|
|
2156
|
+
"view",
|
|
2157
|
+
"callback",
|
|
2158
|
+
"server-override",
|
|
2159
|
+
"repeat",
|
|
2160
|
+
"stop",
|
|
2161
|
+
"status",
|
|
2162
|
+
"help"
|
|
2163
|
+
]);
|
|
2164
|
+
function commandFromJson(body) {
|
|
2165
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2166
|
+
throw new CommandSyntaxError('Send { "line": "/event twitch:follower" } or a command object { "kind": \u2026 }');
|
|
2167
|
+
}
|
|
2168
|
+
const b = body;
|
|
2169
|
+
if (typeof b.line === "string") return parseReplLine(b.line);
|
|
2170
|
+
if (typeof b.kind !== "string" || !KINDS.has(b.kind)) {
|
|
2171
|
+
throw new CommandSyntaxError(`Unknown command kind ${JSON.stringify(b.kind)} (one of: ${[...KINDS].join(", ")})`);
|
|
2172
|
+
}
|
|
2173
|
+
return b;
|
|
2174
|
+
}
|
|
2175
|
+
function parseDuration(raw) {
|
|
2176
|
+
const m = /^(?:(\d+):)?(\d+(?:\.\d+)?)(s|ms)?$/.exec(raw);
|
|
2177
|
+
if (!m) throw new CommandSyntaxError(`Not a time: ${raw} (use 1:30, 90 or 90s)`);
|
|
2178
|
+
if (m[3] === "ms") return Number(m[2]) + Number(m[1] ?? 0) * 6e4;
|
|
2179
|
+
return Math.round((Number(m[1] ?? 0) * 60 + Number(m[2])) * 1e3);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// src/dev/repl.ts
|
|
2183
|
+
var PRINT_OUTPUT = /* @__PURE__ */ new Set([
|
|
2184
|
+
"help",
|
|
2185
|
+
"status",
|
|
2186
|
+
"storage",
|
|
2187
|
+
"db",
|
|
2188
|
+
"config",
|
|
2189
|
+
"music",
|
|
2190
|
+
"scenario",
|
|
2191
|
+
"timer",
|
|
2192
|
+
"defer-run",
|
|
2193
|
+
"view",
|
|
2194
|
+
"reward",
|
|
2195
|
+
"reward-status",
|
|
2196
|
+
"server-override",
|
|
2197
|
+
"theme",
|
|
2198
|
+
"stop"
|
|
2199
|
+
]);
|
|
2200
|
+
var KIND_STYLE = {
|
|
2201
|
+
input: chalk2.dim,
|
|
2202
|
+
event: chalk2.cyan,
|
|
2203
|
+
chat: chalk2.white,
|
|
2204
|
+
bot: chalk2.green,
|
|
2205
|
+
action: chalk2.magenta,
|
|
2206
|
+
server: chalk2.blue,
|
|
2207
|
+
storage: chalk2.blue,
|
|
2208
|
+
handler: chalk2.green,
|
|
2209
|
+
error: chalk2.red,
|
|
2210
|
+
warning: chalk2.yellow,
|
|
2211
|
+
build: chalk2.dim,
|
|
2212
|
+
info: chalk2.dim
|
|
2213
|
+
};
|
|
2214
|
+
function formatLogEntry(entry) {
|
|
2215
|
+
const style = KIND_STYLE[entry.kind];
|
|
2216
|
+
const text = entry.kind === "event" ? `\u2190 ${entry.text}` : entry.text;
|
|
2217
|
+
const payload = entry.payload === void 0 ? "" : ` ${chalk2.dim(JSON.stringify(entry.payload))}`;
|
|
2218
|
+
return `${style(text)}${payload}`;
|
|
2219
|
+
}
|
|
2220
|
+
function startRepl(session, onQuit) {
|
|
2221
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout, prompt: chalk2.cyan("lumio> ") });
|
|
2222
|
+
let closed = false;
|
|
2223
|
+
const above = (text) => {
|
|
2224
|
+
if (closed) {
|
|
2225
|
+
console.log(text);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
readline2.clearLine(process.stdout, 0);
|
|
2229
|
+
readline2.cursorTo(process.stdout, 0);
|
|
2230
|
+
console.log(text);
|
|
2231
|
+
rl.prompt(true);
|
|
2232
|
+
};
|
|
2233
|
+
rl.on("line", (line) => {
|
|
2234
|
+
const trimmed = line.trim();
|
|
2235
|
+
if (trimmed === "") {
|
|
2236
|
+
rl.prompt();
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
let cmd;
|
|
2240
|
+
try {
|
|
2241
|
+
cmd = parseReplLine(trimmed);
|
|
2242
|
+
} catch (e) {
|
|
2243
|
+
above(chalk2.red(e instanceof CommandSyntaxError ? e.message : String(e)));
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (cmd === null) {
|
|
2247
|
+
rl.close();
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
const kind = cmd.kind;
|
|
2251
|
+
void session.run(cmd, "repl").then((result) => {
|
|
2252
|
+
if (result.ok && PRINT_OUTPUT.has(kind)) for (const l of result.output) above(l);
|
|
2253
|
+
else rl.prompt(true);
|
|
2254
|
+
});
|
|
2255
|
+
});
|
|
2256
|
+
rl.on("close", () => {
|
|
2257
|
+
closed = true;
|
|
2258
|
+
onQuit();
|
|
2259
|
+
});
|
|
2260
|
+
rl.prompt();
|
|
2261
|
+
return {
|
|
2262
|
+
print(entry) {
|
|
2263
|
+
if (entry.kind === "input" && entry.text.startsWith("repl: ")) return;
|
|
2264
|
+
above(formatLogEntry(entry));
|
|
2265
|
+
},
|
|
2266
|
+
close() {
|
|
2267
|
+
if (!closed) rl.close();
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/dev/session.ts
|
|
2273
|
+
var LOG_LIMIT = 500;
|
|
2274
|
+
var CALL_LIMIT = 100;
|
|
2275
|
+
var DEFAULT_USER = "dev_user";
|
|
2276
|
+
var ACCOUNT_ROLES = ["owner", "administrator", "moderator"];
|
|
2277
|
+
var THEMES = {
|
|
2278
|
+
dark: {
|
|
2279
|
+
colors: {
|
|
2280
|
+
primary: "#6366f1",
|
|
2281
|
+
secondary: "#8b5cf6",
|
|
2282
|
+
accent: "#06b6d4",
|
|
2283
|
+
background: "transparent",
|
|
2284
|
+
surface: "rgba(15, 23, 42, 0.8)",
|
|
2285
|
+
text: "#f8fafc",
|
|
2286
|
+
muted: "#94a3b8",
|
|
2287
|
+
border: "rgba(148, 163, 184, 0.2)"
|
|
2288
|
+
}
|
|
2289
|
+
},
|
|
2290
|
+
light: {
|
|
2291
|
+
colors: {
|
|
2292
|
+
primary: "#4f46e5",
|
|
2293
|
+
secondary: "#7c3aed",
|
|
2294
|
+
accent: "#0891b2",
|
|
2295
|
+
background: "transparent",
|
|
2296
|
+
surface: "rgba(255, 255, 255, 0.9)",
|
|
2297
|
+
text: "#0f172a",
|
|
2298
|
+
muted: "#475569",
|
|
2299
|
+
border: "rgba(15, 23, 42, 0.15)"
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
};
|
|
2303
|
+
var MUSIC_OPS = ["play", "pause", "skip", "seek", "volume", "shuffle", "repeat", "queue", "device", "advance"];
|
|
2304
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2305
|
+
function compact(value) {
|
|
2306
|
+
try {
|
|
2307
|
+
return JSON.stringify(value) ?? "undefined";
|
|
2308
|
+
} catch {
|
|
2309
|
+
return String(value);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
function message(e) {
|
|
2313
|
+
return e instanceof Error ? e.message : String(e);
|
|
2314
|
+
}
|
|
2315
|
+
var DevSession = class {
|
|
2316
|
+
host;
|
|
2317
|
+
runtime = null;
|
|
2318
|
+
opts;
|
|
2319
|
+
rawConfig;
|
|
2320
|
+
bundles = /* @__PURE__ */ new Map();
|
|
2321
|
+
server = { bundle: null, functions: null, schema: null };
|
|
2322
|
+
overrides = /* @__PURE__ */ new Map();
|
|
2323
|
+
viewTarget;
|
|
2324
|
+
themeMode = "dark";
|
|
2325
|
+
mountError = null;
|
|
2326
|
+
buildStatus = { ok: true, errors: [], at: null };
|
|
2327
|
+
cssVersion = 0;
|
|
2328
|
+
logEntries = [];
|
|
2329
|
+
nextLogId = 1;
|
|
2330
|
+
serverCalls = [];
|
|
2331
|
+
seen = { events: 0, bot: 0, calls: 0, errors: 0, warnings: 0, invocations: 0, runtimeWarnings: 0 };
|
|
2332
|
+
lastTree = null;
|
|
2333
|
+
pumpTimer = null;
|
|
2334
|
+
queue = Promise.resolve();
|
|
2335
|
+
closed = false;
|
|
2336
|
+
/** Running `--count` / `--rate` loads; `/stop` and `close()` abort them. */
|
|
2337
|
+
loads = /* @__PURE__ */ new Set();
|
|
2338
|
+
constructor(options) {
|
|
2339
|
+
this.opts = options;
|
|
2340
|
+
this.rawConfig = options.rawConfig;
|
|
2341
|
+
this.viewTarget = options.view && options.targets.includes(options.view) ? options.view : options.targets[0] ?? null;
|
|
2342
|
+
}
|
|
2343
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
2344
|
+
/** Creates the host (and runtime) from the current bundles and mounts the view. */
|
|
2345
|
+
async start(server, bundles) {
|
|
2346
|
+
this.server = server;
|
|
2347
|
+
for (const [t, code] of Object.entries(bundles)) if (code !== void 0) this.bundles.set(t, code);
|
|
2348
|
+
this.createHost({});
|
|
2349
|
+
await this.mountView();
|
|
2350
|
+
this.pump();
|
|
2351
|
+
this.pumpTimer = setInterval(() => this.pump(), 200);
|
|
2352
|
+
this.pumpTimer.unref?.();
|
|
2353
|
+
}
|
|
2354
|
+
createHost(carry) {
|
|
2355
|
+
const { devkit, devkitNode } = this.opts.modules;
|
|
2356
|
+
const hasServer = this.server.bundle !== null || this.server.functions !== null;
|
|
2357
|
+
this.runtime = hasServer ? devkitNode.createBotRuntime({
|
|
2358
|
+
bundle: this.server.bundle ?? void 0,
|
|
2359
|
+
functions: this.server.functions ?? void 0,
|
|
2360
|
+
schema: this.server.schema ?? void 0,
|
|
2361
|
+
cooldowns: this.opts.cooldowns ?? true
|
|
2362
|
+
}) : null;
|
|
2363
|
+
const role = this.opts.role;
|
|
2364
|
+
const mocks = this.opts.mocks;
|
|
2365
|
+
this.host = devkit.createMockHost({
|
|
2366
|
+
seed: this.opts.seed ?? 1,
|
|
2367
|
+
context: {
|
|
2368
|
+
target: this.viewTarget ?? "editor",
|
|
2369
|
+
platform: this.opts.platform ?? "twitch",
|
|
2370
|
+
...role && ACCOUNT_ROLES.includes(role) ? { role } : {}
|
|
2371
|
+
},
|
|
2372
|
+
config: this.rawConfig,
|
|
2373
|
+
mocks,
|
|
2374
|
+
services: this.opts.services,
|
|
2375
|
+
storage: carry.storage,
|
|
2376
|
+
theme: THEMES[this.themeMode],
|
|
2377
|
+
runtime: this.runtime ?? void 0
|
|
2378
|
+
});
|
|
2379
|
+
for (const [fn, answer] of this.overrides) this.host.mockServer(fn, answer);
|
|
2380
|
+
this.host.viewers.resolve({ name: DEFAULT_USER, role: this.defaultRole() });
|
|
2381
|
+
this.seen = { events: 0, bot: 0, calls: 0, errors: 0, warnings: 0, invocations: 0, runtimeWarnings: 0 };
|
|
2382
|
+
this.lastTree = null;
|
|
2383
|
+
}
|
|
2384
|
+
defaultRole() {
|
|
2385
|
+
const role = this.opts.role;
|
|
2386
|
+
if (role && this.opts.modules.devkit.VIEWER_ROLES.includes(role)) return role;
|
|
2387
|
+
return role === "owner" || role === "administrator" ? "broadcaster" : role === void 0 ? "everyone" : role;
|
|
2388
|
+
}
|
|
2389
|
+
async mountView() {
|
|
2390
|
+
this.mountError = null;
|
|
2391
|
+
if (this.host.mounted) await this.host.unmount();
|
|
2392
|
+
const target = this.viewTarget;
|
|
2393
|
+
if (!target) return;
|
|
2394
|
+
this.host.setTarget(target);
|
|
2395
|
+
const code = this.bundles.get(target);
|
|
2396
|
+
if (code === void 0) {
|
|
2397
|
+
this.mountError = `src/${target}.tsx is not built yet`;
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
try {
|
|
2401
|
+
const element = evaluateTargetBundle(code, `${target}.js`, this.opts.modules);
|
|
2402
|
+
await this.host.mount(element);
|
|
2403
|
+
} catch (e) {
|
|
2404
|
+
this.mountError = message(e);
|
|
2405
|
+
this.addLog("error", `${target}: ${this.mountError}`);
|
|
2406
|
+
if (this.host.mounted) await this.host.unmount().catch(() => {
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
async close() {
|
|
2411
|
+
if (this.closed) return;
|
|
2412
|
+
this.closed = true;
|
|
2413
|
+
this.stopLoads();
|
|
2414
|
+
if (this.pumpTimer) clearInterval(this.pumpTimer);
|
|
2415
|
+
this.host.destroy();
|
|
2416
|
+
await this.runtime?.close();
|
|
2417
|
+
}
|
|
2418
|
+
/** Commands run one after another, like typed input. */
|
|
2419
|
+
serial(fn) {
|
|
2420
|
+
const next = this.queue.then(fn, fn);
|
|
2421
|
+
this.queue = next.catch(() => {
|
|
2422
|
+
});
|
|
2423
|
+
return next;
|
|
2424
|
+
}
|
|
2425
|
+
// ── Rebuilds (SSE → re-init the surface / reload the handler bundle) ──
|
|
2426
|
+
/** A rebuilt target bundle: the surface re-initialises; storage, ctx.db and the player stay. */
|
|
2427
|
+
updateTarget(target, code) {
|
|
2428
|
+
return this.serial(async () => {
|
|
2429
|
+
this.bundles.set(target, code);
|
|
2430
|
+
if (target === this.viewTarget) {
|
|
2431
|
+
await this.mountView();
|
|
2432
|
+
this.addLog("build", `${target}.js rebuilt: surface re-initialised (storage, ctx.db and player kept)`);
|
|
2433
|
+
} else this.addLog("build", `${target}.js rebuilt`);
|
|
2434
|
+
this.pump();
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
/** A rebuilt `server.js` / `functions.json` / schema. */
|
|
2438
|
+
updateServer(next) {
|
|
2439
|
+
return this.serial(async () => {
|
|
2440
|
+
const hadServer = this.runtime !== null;
|
|
2441
|
+
const hasServer = next.bundle !== null || next.functions !== null;
|
|
2442
|
+
if (!hadServer || !hasServer) {
|
|
2443
|
+
this.server = next;
|
|
2444
|
+
await this.restartHost("server code changed");
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
try {
|
|
2448
|
+
const { dbReset } = this.runtime.reload({
|
|
2449
|
+
bundle: next.bundle ?? void 0,
|
|
2450
|
+
functions: next.functions ?? void 0,
|
|
2451
|
+
schema: next.schema ?? void 0
|
|
2452
|
+
});
|
|
2453
|
+
this.server = next;
|
|
2454
|
+
this.addLog("build", dbReset ? "server.js reloaded; server/schema.ts changed, so ctx.db starts empty" : "server.js reloaded (ctx.db, cache and cooldowns kept)");
|
|
2455
|
+
} catch (e) {
|
|
2456
|
+
this.addLog("error", `server reload refused, previous version keeps running: ${message(e)}`);
|
|
2457
|
+
}
|
|
2458
|
+
this.pump();
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
/** `lumio.config.json` changed: permissions and triggers are read once, so the host restarts. */
|
|
2462
|
+
updateConfig(rawConfig, mocks) {
|
|
2463
|
+
return this.serial(async () => {
|
|
2464
|
+
this.rawConfig = rawConfig;
|
|
2465
|
+
if (mocks !== void 0) this.opts.mocks = mocks;
|
|
2466
|
+
await this.restartHost("lumio.config.json or lumio/mocks/ changed");
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
async restartHost(reason) {
|
|
2470
|
+
const storage = this.host.storage.entries();
|
|
2471
|
+
this.host.destroy();
|
|
2472
|
+
await this.runtime?.close();
|
|
2473
|
+
this.createHost({ storage });
|
|
2474
|
+
await this.mountView();
|
|
2475
|
+
this.addLog("build", `${reason}: session restarted (storage kept; ctx.db, player and rewards start fresh)`);
|
|
2476
|
+
this.pump();
|
|
2477
|
+
}
|
|
2478
|
+
/** The current build errors of every part; new ones are logged once. */
|
|
2479
|
+
setBuild(status) {
|
|
2480
|
+
const known = new Set(this.buildStatus.errors);
|
|
2481
|
+
this.buildStatus = { ...status, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2482
|
+
for (const e of status.errors) if (!known.has(e)) this.addLog("error", e);
|
|
2483
|
+
this.opts.onChange?.();
|
|
2484
|
+
}
|
|
2485
|
+
/** The extension's stylesheet changed. */
|
|
2486
|
+
cssChanged() {
|
|
2487
|
+
this.cssVersion += 1;
|
|
2488
|
+
this.opts.onChange?.();
|
|
2489
|
+
}
|
|
2490
|
+
setTargets(targets) {
|
|
2491
|
+
this.opts.targets = targets;
|
|
2492
|
+
}
|
|
2493
|
+
// ── Log ─────────────────────────────────────────────────────────────
|
|
2494
|
+
addLog(kind, text, payload) {
|
|
2495
|
+
const entry = { id: this.nextLogId++, at: (/* @__PURE__ */ new Date()).toISOString(), kind, text, ...payload === void 0 ? {} : { payload } };
|
|
2496
|
+
this.logEntries.push(entry);
|
|
2497
|
+
if (this.logEntries.length > LOG_LIMIT) this.logEntries.splice(0, this.logEntries.length - LOG_LIMIT);
|
|
2498
|
+
this.opts.onLog?.(entry);
|
|
2499
|
+
this.opts.onChange?.();
|
|
2500
|
+
return entry;
|
|
2501
|
+
}
|
|
2502
|
+
/** Picks up everything the host and the runtime recorded since the last call. */
|
|
2503
|
+
pump() {
|
|
2504
|
+
if (this.closed || this.host.destroyed) return;
|
|
2505
|
+
const h = this.host;
|
|
2506
|
+
const events = h.events;
|
|
2507
|
+
for (; this.seen.events < events.length; this.seen.events++) {
|
|
2508
|
+
const e = events[this.seen.events];
|
|
2509
|
+
this.addLog("event", `${e.type} \u2192 ${e.delivered ? "extension" : "no surface mounted"}`, e.data);
|
|
2510
|
+
}
|
|
2511
|
+
const bot = h.botMessages;
|
|
2512
|
+
for (; this.seen.bot < bot.length; this.seen.bot++) {
|
|
2513
|
+
this.addLog("bot", `bot (${bot[this.seen.bot].platform}): ${bot[this.seen.bot].message}`);
|
|
2514
|
+
}
|
|
2515
|
+
const calls = h.calls;
|
|
2516
|
+
for (; this.seen.calls < calls.length; this.seen.calls++) {
|
|
2517
|
+
const call = calls[this.seen.calls];
|
|
2518
|
+
if (call.kind === "request" && !("result" in call) && !call.error) break;
|
|
2519
|
+
this.logCall(call);
|
|
2520
|
+
}
|
|
2521
|
+
for (; this.seen.errors < h.errors.length; this.seen.errors++) {
|
|
2522
|
+
const e = h.errors[this.seen.errors];
|
|
2523
|
+
this.addLog("error", `${e.code}: ${e.message}`);
|
|
2524
|
+
}
|
|
2525
|
+
for (; this.seen.warnings < h.warnings.length; this.seen.warnings++) this.addLog("warning", h.warnings[this.seen.warnings]);
|
|
2526
|
+
const rt = this.runtime;
|
|
2527
|
+
if (rt) {
|
|
2528
|
+
const inv = rt.invocations;
|
|
2529
|
+
for (; this.seen.invocations < inv.length; this.seen.invocations++) {
|
|
2530
|
+
const log = inv[this.seen.invocations];
|
|
2531
|
+
if (log.ms === 0 && !log.error && log.value === void 0) break;
|
|
2532
|
+
this.logInvocation(log);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
if (h.tree !== this.lastTree) {
|
|
2536
|
+
this.lastTree = h.tree;
|
|
2537
|
+
this.opts.onChange?.();
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
logCall(call) {
|
|
2541
|
+
const p = isObject(call.params) ? call.params : {};
|
|
2542
|
+
const answer = call.error ? { error: call.error } : { result: call.result };
|
|
2543
|
+
switch (call.method) {
|
|
2544
|
+
case "action.execute":
|
|
2545
|
+
this.addLog("action", `action ${String(p.action)}: ${call.error ? `${call.error.message} (${call.error.code})` : "ok"}`, { params: p.params, ...answer });
|
|
2546
|
+
break;
|
|
2547
|
+
case "server.query":
|
|
2548
|
+
case "server.mutation": {
|
|
2549
|
+
const fn = String(p.fn);
|
|
2550
|
+
const entry = {
|
|
2551
|
+
id: this.seen.calls,
|
|
2552
|
+
method: call.method,
|
|
2553
|
+
fn,
|
|
2554
|
+
params: p.params ?? {},
|
|
2555
|
+
...call.error ? { error: call.error } : { result: call.result },
|
|
2556
|
+
overridden: this.overrides.has(fn)
|
|
2557
|
+
};
|
|
2558
|
+
this.serverCalls.push(entry);
|
|
2559
|
+
if (this.serverCalls.length > CALL_LIMIT) this.serverCalls.shift();
|
|
2560
|
+
this.addLog("server", `${call.method} ${fn}${entry.overridden ? " (override)" : ""}: ${call.error ? call.error.message : compact(call.result)}`, { params: entry.params, ...answer });
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
case "storage.set":
|
|
2564
|
+
this.addLog("storage", `storage.set ${String(p.key)}`, p.value);
|
|
2565
|
+
break;
|
|
2566
|
+
case "ui.action":
|
|
2567
|
+
this.addLog("action", "ui.action", call.params);
|
|
2568
|
+
break;
|
|
2569
|
+
case "sound.play":
|
|
2570
|
+
case "sound.stop":
|
|
2571
|
+
case "sound.list":
|
|
2572
|
+
this.addLog("action", `${call.method}: ${call.error?.message ?? "ok"}`, call.params);
|
|
2573
|
+
break;
|
|
2574
|
+
default:
|
|
2575
|
+
break;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
logInvocation(log) {
|
|
2579
|
+
if (log.trigger === "command" || log.trigger === "keyword" || log.trigger === "pattern" || log.trigger === "moderate") {
|
|
2580
|
+
for (const l of log.logs) this.addLog("handler", `${log.name} console.${l.level}: ${l.text}`);
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
const parts = [`${log.trigger} ${log.name} (${Math.round(log.ms)} ms)`];
|
|
2584
|
+
if (log.error) parts.push(`failed: ${log.error.message}`);
|
|
2585
|
+
for (const r of log.replies) parts.push(`reply: ${r}`);
|
|
2586
|
+
for (const a of log.actions) parts.push(`action ${a.action}: ${a.success ? "ok" : a.error}`);
|
|
2587
|
+
for (const r of log.realtime) parts.push(`realtime ${r.topic}`);
|
|
2588
|
+
this.addLog(log.error ? "error" : "handler", `handler ${parts.join(" \xB7 ")}`, log.value);
|
|
2589
|
+
for (const l of log.logs) this.addLog("handler", `${log.name} console.${l.level}: ${l.text}`);
|
|
2590
|
+
}
|
|
2591
|
+
// ── State ───────────────────────────────────────────────────────────
|
|
2592
|
+
state() {
|
|
2593
|
+
const h = this.host;
|
|
2594
|
+
const rt = this.runtime;
|
|
2595
|
+
const cfg = this.rawConfig;
|
|
2596
|
+
return {
|
|
2597
|
+
project: {
|
|
2598
|
+
name: String(cfg.name ?? ""),
|
|
2599
|
+
category: String(cfg.category ?? "widget"),
|
|
2600
|
+
version: String(cfg.version ?? ""),
|
|
2601
|
+
extensionId: String(cfg.extensionId ?? cfg.extension_id ?? "")
|
|
2602
|
+
},
|
|
2603
|
+
versions: this.opts.modules.versions,
|
|
2604
|
+
view: this.viewTarget,
|
|
2605
|
+
targets: this.opts.targets,
|
|
2606
|
+
chatFirst: this.opts.chatFirst ?? false,
|
|
2607
|
+
mounted: h.mounted,
|
|
2608
|
+
mountError: this.mountError,
|
|
2609
|
+
tree: h.tree,
|
|
2610
|
+
themeMode: this.themeMode,
|
|
2611
|
+
cssVersion: this.cssVersion,
|
|
2612
|
+
build: this.buildStatus,
|
|
2613
|
+
storage: h.storage.entries(),
|
|
2614
|
+
settings: h.settings,
|
|
2615
|
+
db: rt ? rt.db.tables().map((table) => ({ table, rows: rt.db.rows(table) })) : null,
|
|
2616
|
+
player: h.player.state,
|
|
2617
|
+
rewards: h.rewards.list().map((r) => ({ id: r.id, platform: r.platform, title: r.title, cost: r.cost, prompt: r.prompt, inputRequired: r.inputRequired })),
|
|
2618
|
+
redemptions: h.rewards.history().slice(-50),
|
|
2619
|
+
viewers: h.viewers.list().map((v) => ({ login: v.login, displayName: v.displayName, role: v.role })),
|
|
2620
|
+
live: h.live,
|
|
2621
|
+
timers: rt ? [...rt.timers] : [],
|
|
2622
|
+
pendingDeferred: rt?.pendingDeferred ?? 0,
|
|
2623
|
+
functions: this.functionNames(),
|
|
2624
|
+
serverCalls: [...this.serverCalls],
|
|
2625
|
+
overrides: Object.fromEntries(this.overrides),
|
|
2626
|
+
presets: Object.keys(this.opts.mocks?.events ?? {}),
|
|
2627
|
+
defaults: { platform: h.context.platform, user: DEFAULT_USER, role: this.defaultRole() },
|
|
2628
|
+
log: [...this.logEntries]
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
functionNames() {
|
|
2632
|
+
const f = this.server.functions;
|
|
2633
|
+
const parsed = typeof f === "string" ? JSON.parse(f) : f;
|
|
2634
|
+
return isObject(parsed) && isObject(parsed.functions) ? Object.keys(parsed.functions) : [];
|
|
2635
|
+
}
|
|
2636
|
+
meta() {
|
|
2637
|
+
const { devkit } = this.opts.modules;
|
|
2638
|
+
return {
|
|
2639
|
+
eventTypes: devkit.EVENT_TYPES.map((type) => ({ type, ...devkit.eventDelivery(type) })).map(({ type, forwarded, reason }) => ({ type, forwarded, reason })),
|
|
2640
|
+
scenarios: Object.keys(devkit.SCENARIOS),
|
|
2641
|
+
platforms: [...devkit.CHAT_PLATFORMS],
|
|
2642
|
+
rewardPlatforms: [...devkit.REWARD_PLATFORMS],
|
|
2643
|
+
roles: [...devkit.VIEWER_ROLES],
|
|
2644
|
+
musicOps: MUSIC_OPS,
|
|
2645
|
+
rewardStatuses: { twitch: ["fulfilled", "canceled"], kick: ["pending", "accepted", "rejected"] }
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
/** A payload for the panel's form and JSON editor (`createEvent`, not sent). */
|
|
2649
|
+
eventTemplate(type) {
|
|
2650
|
+
const { devkit } = this.opts.modules;
|
|
2651
|
+
if (!devkit.isLumioEventType(type)) throw new CommandSyntaxError(`Unknown event type "${type}"`);
|
|
2652
|
+
const payload = devkit.createEvent(type, void 0, { seed: (this.opts.seed ?? 1) + this.nextLogId });
|
|
2653
|
+
return { payload, schema: null };
|
|
2654
|
+
}
|
|
2655
|
+
/** The tree node that registered `handlerId` and the prop it belongs to. */
|
|
2656
|
+
findHandler(handlerId) {
|
|
2657
|
+
const stack = [...this.host.tree];
|
|
2658
|
+
while (stack.length) {
|
|
2659
|
+
const node = stack.pop();
|
|
2660
|
+
for (const [key, value] of Object.entries(node.props ?? {})) {
|
|
2661
|
+
if (key.startsWith("__handler_") && value === handlerId) return { node, prop: key.slice("__handler_".length) };
|
|
2662
|
+
}
|
|
2663
|
+
for (const child of node.children ?? []) if (typeof child === "object" && child !== null) stack.push(child);
|
|
2664
|
+
}
|
|
2665
|
+
return null;
|
|
2666
|
+
}
|
|
2667
|
+
// ── Commands ────────────────────────────────────────────────────────
|
|
2668
|
+
/** Runs one command; every input is echoed with what the extension received. */
|
|
2669
|
+
run(cmd, source = "repl") {
|
|
2670
|
+
if (cmd.kind === "repeat") return this.repeat(cmd, source);
|
|
2671
|
+
if (cmd.kind === "stop") {
|
|
2672
|
+
const n = this.stopLoads();
|
|
2673
|
+
this.addLog("input", `${source}: /stop`);
|
|
2674
|
+
return Promise.resolve({ ok: true, output: [n ? `stopped ${n} running load(s)` : "no load is running"] });
|
|
2675
|
+
}
|
|
2676
|
+
return this.serial(async () => {
|
|
2677
|
+
if (cmd.kind !== "help" && cmd.kind !== "status" && cmd.kind !== "callback") {
|
|
2678
|
+
this.addLog("input", `${source}: ${describe(cmd)}`);
|
|
2679
|
+
}
|
|
2680
|
+
const from = this.nextLogId;
|
|
2681
|
+
const since = () => this.logEntries.filter((e) => e.id >= from);
|
|
2682
|
+
try {
|
|
2683
|
+
const result = await this.execute(cmd);
|
|
2684
|
+
await this.host.settle();
|
|
2685
|
+
this.pump();
|
|
2686
|
+
return { ...result, log: since() };
|
|
2687
|
+
} catch (e) {
|
|
2688
|
+
this.pump();
|
|
2689
|
+
const error = message(e);
|
|
2690
|
+
this.addLog("error", error);
|
|
2691
|
+
return { ok: false, output: [`error: ${error}`], error, log: since() };
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
stopLoads() {
|
|
2696
|
+
const n = this.loads.size;
|
|
2697
|
+
for (const ac of this.loads) ac.abort();
|
|
2698
|
+
this.loads.clear();
|
|
2699
|
+
return n;
|
|
2700
|
+
}
|
|
2701
|
+
/** `--count` / `--rate` / `--random`: the inner command, once per run, paced; the first failure stops it. */
|
|
2702
|
+
async repeat(cmd, source) {
|
|
2703
|
+
const fail = (error) => {
|
|
2704
|
+
this.addLog("error", error);
|
|
2705
|
+
return { ok: false, output: [`error: ${error}`], error };
|
|
2706
|
+
};
|
|
2707
|
+
let spec;
|
|
2708
|
+
let variant;
|
|
2709
|
+
try {
|
|
2710
|
+
spec = repeatSpec(cmd, (this.opts.seed ?? 1) + this.nextLogId);
|
|
2711
|
+
variant = repeatVariants(cmd, spec, { eventTypes: this.meta().eventTypes, scenarios: Object.keys(this.opts.modules.devkit.SCENARIOS) });
|
|
2712
|
+
} catch (e) {
|
|
2713
|
+
return fail(message(e));
|
|
2714
|
+
}
|
|
2715
|
+
this.addLog("input", `${source}: ${describe(cmd)}`);
|
|
2716
|
+
const ac = new AbortController();
|
|
2717
|
+
this.loads.add(ac);
|
|
2718
|
+
const from = this.nextLogId;
|
|
2719
|
+
let result;
|
|
2720
|
+
try {
|
|
2721
|
+
result = await runLoad(spec, () => this.closed ? Promise.resolve({ ok: false, output: [], error: "session closed" }) : this.run(variant(), source), { signal: ac.signal });
|
|
2722
|
+
} finally {
|
|
2723
|
+
this.loads.delete(ac);
|
|
2724
|
+
}
|
|
2725
|
+
const summary = summarizeLoad(spec, result);
|
|
2726
|
+
this.addLog(result.failed ? "error" : "info", summary);
|
|
2727
|
+
return {
|
|
2728
|
+
ok: result.failed === null,
|
|
2729
|
+
output: [summary, ...result.failed?.error ? [`error: ${result.failed.error}`] : []],
|
|
2730
|
+
payload: result.results.map((r) => r.payload),
|
|
2731
|
+
...result.failed?.error ? { error: result.failed.error } : {},
|
|
2732
|
+
log: this.logEntries.filter((e) => e.id >= from)
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
user(u) {
|
|
2736
|
+
const name = u?.name?.replace(/^@/, "") || DEFAULT_USER;
|
|
2737
|
+
return u?.role ? { name, role: u.role } : { name };
|
|
2738
|
+
}
|
|
2739
|
+
async execute(cmd) {
|
|
2740
|
+
const h = this.host;
|
|
2741
|
+
switch (cmd.kind) {
|
|
2742
|
+
case "help":
|
|
2743
|
+
return { ok: true, output: REPL_HELP };
|
|
2744
|
+
case "status": {
|
|
2745
|
+
const s = this.state();
|
|
2746
|
+
return {
|
|
2747
|
+
ok: true,
|
|
2748
|
+
output: [
|
|
2749
|
+
`${s.project.name} (${s.project.category}) \xB7 view ${s.view ?? "none"}${s.mountError ? ` (${s.mountError})` : ""}`,
|
|
2750
|
+
`surfaces: ${s.targets.join(", ") || "none"} \xB7 bot runtime: ${this.runtime ? "yes" : "no"} \xB7 functions: ${s.functions.join(", ") || "none"}`,
|
|
2751
|
+
`timers: ${s.timers.map((t) => t.name).join(", ") || "none"} \xB7 presets: ${s.presets.join(", ") || "none"} \xB7 deferred jobs: ${s.pendingDeferred}`,
|
|
2752
|
+
`devkit ${s.versions.devkit} \xB7 sdk ${s.versions.sdk} \xB7 protocol ${s.versions.protocol}`
|
|
2753
|
+
]
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
case "event": {
|
|
2757
|
+
let data;
|
|
2758
|
+
if (cmd.preset !== void 0) {
|
|
2759
|
+
const preset = h.preset(cmd.preset);
|
|
2760
|
+
if (cmd.user) preset.user = this.user(cmd.user);
|
|
2761
|
+
data = h.emit(preset);
|
|
2762
|
+
return { ok: true, output: [`${preset.type} ${compact(data)}`], payload: data };
|
|
2763
|
+
}
|
|
2764
|
+
if (!cmd.type) throw new CommandSyntaxError("An event needs a type or a preset");
|
|
2765
|
+
const type = cmd.type;
|
|
2766
|
+
const overrides = this.coerce(type, cmd.data ?? {});
|
|
2767
|
+
data = h.emit(type, overrides, cmd.user ? { user: this.user(cmd.user) } : void 0);
|
|
2768
|
+
return { ok: true, output: [`${type} ${compact(data)}`], payload: data };
|
|
2769
|
+
}
|
|
2770
|
+
case "chat": {
|
|
2771
|
+
const platform = cmd.platform ?? h.context.platform;
|
|
2772
|
+
const r = await h.chat(cmd.text, { user: this.user(cmd.user), platform });
|
|
2773
|
+
const out = [`${platform}:chat ${r.viewer.login} (${r.viewer.role}): ${r.message.message}`];
|
|
2774
|
+
const bot = r;
|
|
2775
|
+
if (bot.log) out.push(...bot.log.map((l) => ` ${l}`));
|
|
2776
|
+
else if (this.runtime === null) out.push(" (no bot module in this project: delivered as chat events only)");
|
|
2777
|
+
this.addLog("chat", out.join("\n"), r.message);
|
|
2778
|
+
return { ok: true, output: out, payload: r.message };
|
|
2779
|
+
}
|
|
2780
|
+
case "reward": {
|
|
2781
|
+
const platform = cmd.platform ?? (h.context.platform === "kick" ? "kick" : "twitch");
|
|
2782
|
+
const r = h.redeem({ reward: cmd.reward, platform, user: this.user(cmd.user), input: cmd.input });
|
|
2783
|
+
const ev = h.events.at(-1);
|
|
2784
|
+
return { ok: true, output: [`redeemed ${r.rewardId} as ${r.viewer} (redemption ${r.id}, ${r.status})`, `${ev?.type} ${compact(ev?.data)}`], payload: ev?.data };
|
|
2785
|
+
}
|
|
2786
|
+
case "reward-status": {
|
|
2787
|
+
const r = h.rewards.setStatus(cmd.id, cmd.status);
|
|
2788
|
+
const ev = h.events.at(-1);
|
|
2789
|
+
return { ok: true, output: [`redemption ${r.id} \u2192 ${r.status}`], payload: ev?.data };
|
|
2790
|
+
}
|
|
2791
|
+
case "music":
|
|
2792
|
+
return this.music(cmd.op, cmd.args ?? []);
|
|
2793
|
+
case "timer": {
|
|
2794
|
+
if (!this.runtime) throw new CommandSyntaxError("This project has no bot module (server/handler.ts), so it has no timers");
|
|
2795
|
+
const log = await this.runtime.fireTimer(cmd.name);
|
|
2796
|
+
return { ok: true, output: [log ? `timer ${cmd.name} ran (${Math.round(log.ms)} ms)` : `timer ${cmd.name} skipped (see warnings)`] };
|
|
2797
|
+
}
|
|
2798
|
+
case "defer-run": {
|
|
2799
|
+
if (!this.runtime) throw new CommandSyntaxError("This project has no server code, so ctx.defer has nothing queued");
|
|
2800
|
+
const pending = this.runtime.pendingDeferred;
|
|
2801
|
+
const errors = await this.runtime.runDeferred();
|
|
2802
|
+
return { ok: errors.length === 0, output: [`ran ${pending} deferred job batch(es)${errors.length ? `; errors: ${errors.join("; ")}` : ""}`] };
|
|
2803
|
+
}
|
|
2804
|
+
case "scenario":
|
|
2805
|
+
return this.scenario(cmd.name, cmd.options ?? {});
|
|
2806
|
+
case "storage": {
|
|
2807
|
+
if (cmd.key === void 0) return { ok: true, output: [compact(h.storage.entries())], payload: h.storage.entries() };
|
|
2808
|
+
if (!("value" in cmd) || cmd.value === void 0) return { ok: true, output: [`${cmd.key} = ${compact(h.storage.get(cmd.key))}`], payload: h.storage.get(cmd.key) };
|
|
2809
|
+
h.storage.set(cmd.key, cmd.value);
|
|
2810
|
+
return { ok: true, output: [`storage.update ${cmd.key} = ${compact(cmd.value)}`], payload: cmd.value };
|
|
2811
|
+
}
|
|
2812
|
+
case "db": {
|
|
2813
|
+
const rt = this.runtime;
|
|
2814
|
+
if (!rt) throw new CommandSyntaxError("This project has no server code, so it has no ctx.db");
|
|
2815
|
+
if (cmd.reset) {
|
|
2816
|
+
rt.db.reset();
|
|
2817
|
+
return { ok: true, output: ["ctx.db cleared"] };
|
|
2818
|
+
}
|
|
2819
|
+
const tables = cmd.table ? [cmd.table] : rt.db.tables();
|
|
2820
|
+
if (cmd.table && !rt.db.tables().includes(cmd.table)) {
|
|
2821
|
+
throw new CommandSyntaxError(`server/schema.ts declares no table "${cmd.table}" (tables: ${rt.db.tables().join(", ") || "none"})`);
|
|
2822
|
+
}
|
|
2823
|
+
const out = tables.length ? tables.map((t) => `${t} (${rt.db.rows(t).length}): ${compact(rt.db.rows(t))}`) : ["server/schema.ts declares no tables"];
|
|
2824
|
+
return { ok: true, output: out, payload: Object.fromEntries(tables.map((t) => [t, rt.db.rows(t)])) };
|
|
2825
|
+
}
|
|
2826
|
+
case "config": {
|
|
2827
|
+
if (!cmd.settings) return { ok: true, output: [compact(h.settings)], payload: h.settings };
|
|
2828
|
+
if (!isObject(cmd.settings)) throw new CommandSyntaxError("config needs an object of settings");
|
|
2829
|
+
const settings = { ...h.settings, ...cmd.settings };
|
|
2830
|
+
h.setConfig(settings);
|
|
2831
|
+
return { ok: true, output: [`config.update ${compact(settings)}`], payload: settings };
|
|
2832
|
+
}
|
|
2833
|
+
case "theme": {
|
|
2834
|
+
if (cmd.mode !== "light" && cmd.mode !== "dark") throw new CommandSyntaxError('theme is "light" or "dark"');
|
|
2835
|
+
this.themeMode = cmd.mode;
|
|
2836
|
+
h.setTheme(THEMES[cmd.mode]);
|
|
2837
|
+
return { ok: true, output: [`theme.change (${cmd.mode})`], payload: h.theme };
|
|
2838
|
+
}
|
|
2839
|
+
case "view": {
|
|
2840
|
+
if (cmd.target !== "editor" && cmd.target !== "layer") throw new CommandSyntaxError('view is "editor" or "layer"');
|
|
2841
|
+
if (!this.opts.targets.includes(cmd.target)) {
|
|
2842
|
+
throw new CommandSyntaxError(`This project has no ${cmd.target} surface (lumio.config.json targets + src/${cmd.target}.tsx)`);
|
|
2843
|
+
}
|
|
2844
|
+
this.viewTarget = cmd.target;
|
|
2845
|
+
await this.mountView();
|
|
2846
|
+
return { ok: this.mountError === null, output: [this.mountError ? `${cmd.target}: ${this.mountError}` : `view ${cmd.target} mounted`] };
|
|
2847
|
+
}
|
|
2848
|
+
case "callback": {
|
|
2849
|
+
if (typeof cmd.handlerId !== "string") throw new CommandSyntaxError("callback needs a handlerId");
|
|
2850
|
+
const found = this.findHandler(cmd.handlerId);
|
|
2851
|
+
if (!found) throw new CommandSyntaxError("That control is no longer on screen");
|
|
2852
|
+
await h.fire(found.node, found.prop, ...Array.isArray(cmd.args) ? cmd.args : []);
|
|
2853
|
+
return { ok: true, output: [`event.callback ${found.node.type}.${found.prop}`] };
|
|
2854
|
+
}
|
|
2855
|
+
case "server-override": {
|
|
2856
|
+
if (typeof cmd.fn !== "string" || cmd.fn === "") throw new CommandSyntaxError("server-override needs fn");
|
|
2857
|
+
if (cmd.answer === null) {
|
|
2858
|
+
this.overrides.delete(cmd.fn);
|
|
2859
|
+
h.clearMockServer(cmd.fn);
|
|
2860
|
+
return { ok: true, output: [`${cmd.fn}: override removed, the local function answers again`] };
|
|
2861
|
+
}
|
|
2862
|
+
h.mockServer(cmd.fn, cmd.answer);
|
|
2863
|
+
this.overrides.set(cmd.fn, cmd.answer);
|
|
2864
|
+
return { ok: true, output: [`${cmd.fn}: answered by override ${compact(cmd.answer)}`] };
|
|
2865
|
+
}
|
|
2866
|
+
default:
|
|
2867
|
+
throw new CommandSyntaxError(`Unknown command ${compact(cmd.kind)}`);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* REPL fields arrive as `tier=2`: a value keeps the type of the field in the
|
|
2872
|
+
* factory output (a string field stays a string, a number field becomes a
|
|
2873
|
+
* number), so the schema sees what you meant.
|
|
2874
|
+
*/
|
|
2875
|
+
coerce(type, data) {
|
|
2876
|
+
const { devkit } = this.opts.modules;
|
|
2877
|
+
if (!devkit.isLumioEventType(type)) return data;
|
|
2878
|
+
const base = devkit.createEvent(type, void 0, { seed: 1 });
|
|
2879
|
+
const walk = (value, template, key = "") => {
|
|
2880
|
+
if (isObject(value)) {
|
|
2881
|
+
const t = isObject(template) ? template : {};
|
|
2882
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v, t[k], k)]));
|
|
2883
|
+
}
|
|
2884
|
+
if (/tier$/.test(key) && typeof template === "string" && /^[123]000$/.test(template) && /^[123]$/.test(String(value))) return `${String(value)}000`;
|
|
2885
|
+
if (typeof template === "string" && (typeof value === "number" || typeof value === "boolean")) return String(value);
|
|
2886
|
+
if (typeof template === "number" && typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return Number(value);
|
|
2887
|
+
return value;
|
|
2888
|
+
};
|
|
2889
|
+
return walk(data, base);
|
|
2890
|
+
}
|
|
2891
|
+
music(op, args) {
|
|
2892
|
+
const p = this.host.player;
|
|
2893
|
+
const arg = args[0];
|
|
2894
|
+
const needArg = (what) => {
|
|
2895
|
+
if (arg === void 0) throw new CommandSyntaxError(`/music ${op} needs ${what}`);
|
|
2896
|
+
return arg;
|
|
2897
|
+
};
|
|
2898
|
+
switch (op) {
|
|
2899
|
+
case "play":
|
|
2900
|
+
p.play(arg);
|
|
2901
|
+
break;
|
|
2902
|
+
case "pause":
|
|
2903
|
+
p.pause();
|
|
2904
|
+
break;
|
|
2905
|
+
case "skip":
|
|
2906
|
+
p.skip(arg === void 0 ? 1 : Number(arg));
|
|
2907
|
+
break;
|
|
2908
|
+
case "seek":
|
|
2909
|
+
p.seek(parseDuration(needArg("a time (1:30)")));
|
|
2910
|
+
break;
|
|
2911
|
+
case "volume":
|
|
2912
|
+
p.setVolume(Number(needArg("a percentage")));
|
|
2913
|
+
break;
|
|
2914
|
+
case "shuffle":
|
|
2915
|
+
p.setShuffle(needArg("on|off") === "on");
|
|
2916
|
+
break;
|
|
2917
|
+
case "repeat":
|
|
2918
|
+
p.setRepeat(needArg("off|context|track"));
|
|
2919
|
+
break;
|
|
2920
|
+
case "queue":
|
|
2921
|
+
p.queueAdd(needArg("a track URI"));
|
|
2922
|
+
break;
|
|
2923
|
+
case "device":
|
|
2924
|
+
p.transfer(args.join(" ") || needArg("a device name"));
|
|
2925
|
+
break;
|
|
2926
|
+
case "advance":
|
|
2927
|
+
p.advance(parseDuration(needArg("a duration (30s)")));
|
|
2928
|
+
break;
|
|
2929
|
+
default:
|
|
2930
|
+
throw new CommandSyntaxError(`Unknown /music command "${op}" (${MUSIC_OPS.join(", ")})`);
|
|
2931
|
+
}
|
|
2932
|
+
const s = p.state;
|
|
2933
|
+
return {
|
|
2934
|
+
ok: true,
|
|
2935
|
+
output: [`player: ${s.track ? `${s.track.name}` : "nothing"} \xB7 ${s.is_playing ? "playing" : "paused"} \xB7 ${Math.round(s.progress_ms / 1e3)} s \xB7 volume ${s.device.volume_percent}%`],
|
|
2936
|
+
payload: s
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
async scenario(name, options) {
|
|
2940
|
+
const { devkit } = this.opts.modules;
|
|
2941
|
+
const factories = devkit.SCENARIOS;
|
|
2942
|
+
const make = factories[name];
|
|
2943
|
+
if (!make) throw new CommandSyntaxError(`Unknown scenario "${name}" (scenarios: ${Object.keys(factories).join(", ")})`);
|
|
2944
|
+
const { instant, ...rest } = options;
|
|
2945
|
+
for (const key of ["users", "lines"]) {
|
|
2946
|
+
if (typeof rest[key] === "string") rest[key] = rest[key].split(",").map((s) => s.trim()).filter(Boolean);
|
|
2947
|
+
}
|
|
2948
|
+
const run = await this.host.play(make(rest), { instant: instant === true });
|
|
2949
|
+
return { ok: true, output: [`scenario ${run.name}: ${run.events.length} event(s)`], payload: run.events.map((e) => ({ type: e.type, data: e.data })) };
|
|
2950
|
+
}
|
|
2951
|
+
};
|
|
2952
|
+
function describe(cmd) {
|
|
2953
|
+
switch (cmd.kind) {
|
|
2954
|
+
case "event":
|
|
2955
|
+
return cmd.preset !== void 0 ? `/event @${cmd.preset}` : `/event ${cmd.type ?? "?"}${cmd.data && Object.keys(cmd.data).length ? ` ${compact(cmd.data)}` : ""}`;
|
|
2956
|
+
case "chat":
|
|
2957
|
+
return `/chat ${cmd.user?.role ? `@${cmd.user.role}:` : cmd.user?.name ? "@" : ""}${cmd.user?.name ?? ""}${cmd.user?.name || cmd.user?.role ? " " : ""}${cmd.text}${cmd.platform ? ` --platform ${cmd.platform}` : ""}`;
|
|
2958
|
+
case "reward":
|
|
2959
|
+
return `/reward ${JSON.stringify(cmd.reward)}${cmd.user?.name ? ` --user ${cmd.user.name}` : ""}${cmd.input ? ` --input ${JSON.stringify(cmd.input)}` : ""}${cmd.platform ? ` --platform ${cmd.platform}` : ""}`;
|
|
2960
|
+
case "reward-status":
|
|
2961
|
+
return `/reward-status ${cmd.id} ${cmd.status}`;
|
|
2962
|
+
case "music":
|
|
2963
|
+
return `/music ${cmd.op}${cmd.args?.length ? ` ${cmd.args.join(" ")}` : ""}`;
|
|
2964
|
+
case "timer":
|
|
2965
|
+
return `/timer ${cmd.name}`;
|
|
2966
|
+
case "defer-run":
|
|
2967
|
+
return "/defer run";
|
|
2968
|
+
case "scenario":
|
|
2969
|
+
return `/scenario ${cmd.name}${cmd.options && Object.keys(cmd.options).length ? ` ${compact(cmd.options)}` : ""}`;
|
|
2970
|
+
case "storage":
|
|
2971
|
+
return `/storage${cmd.key ? ` ${cmd.key}` : ""}${cmd.value !== void 0 ? ` ${compact(cmd.value)}` : ""}`;
|
|
2972
|
+
case "db":
|
|
2973
|
+
return `/db${cmd.table ? ` ${cmd.table}` : ""}${cmd.reset ? " --reset" : ""}`;
|
|
2974
|
+
case "config":
|
|
2975
|
+
return `/config${cmd.settings ? ` ${compact(cmd.settings)}` : ""}`;
|
|
2976
|
+
case "theme":
|
|
2977
|
+
return `/theme ${cmd.mode}`;
|
|
2978
|
+
case "view":
|
|
2979
|
+
return `/view ${cmd.target}`;
|
|
2980
|
+
case "callback":
|
|
2981
|
+
return `callback ${cmd.handlerId}`;
|
|
2982
|
+
case "server-override":
|
|
2983
|
+
return `server override ${cmd.fn}${cmd.answer === null ? " (removed)" : ` ${compact(cmd.answer)}`}`;
|
|
2984
|
+
case "repeat": {
|
|
2985
|
+
const inner = cmd.random ? cmd.command.kind === "scenario" ? "/scenario --random" : `/event --random${cmd.platform ? ` --platform ${cmd.platform}` : ""}` : describe(cmd.command);
|
|
2986
|
+
return `${inner} --count ${cmd.count ?? 1}${cmd.intervalMs ? ` --rate ${describeRate(cmd.intervalMs)}` : ""}${cmd.seed !== void 0 ? ` --seed ${cmd.seed}` : ""}`;
|
|
2987
|
+
}
|
|
2988
|
+
default:
|
|
2989
|
+
return `/${cmd.kind}`;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// src/dev/server.ts
|
|
2994
|
+
import * as fs8 from "fs";
|
|
2995
|
+
import * as http2 from "http";
|
|
2996
|
+
import * as path9 from "path";
|
|
2997
|
+
import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
2998
|
+
import { fileURLToPath } from "url";
|
|
2999
|
+
var DEFAULT_DEV_PORT = 3100;
|
|
3000
|
+
var TOKEN_HEADER = "x-lumio-dev-token";
|
|
3001
|
+
var DEV_SERVER_FILE = path9.join(".lumio", "dev-server.json");
|
|
3002
|
+
var MAX_BODY = 1024 * 1024;
|
|
3003
|
+
var CONTENT_TYPES = {
|
|
3004
|
+
".html": "text/html; charset=utf-8",
|
|
3005
|
+
".js": "text/javascript; charset=utf-8",
|
|
3006
|
+
".css": "text/css; charset=utf-8",
|
|
3007
|
+
".json": "application/json; charset=utf-8",
|
|
3008
|
+
".map": "application/json; charset=utf-8",
|
|
3009
|
+
".svg": "image/svg+xml",
|
|
3010
|
+
".png": "image/png",
|
|
3011
|
+
".jpg": "image/jpeg",
|
|
3012
|
+
".jpeg": "image/jpeg",
|
|
3013
|
+
".gif": "image/gif",
|
|
3014
|
+
".webp": "image/webp",
|
|
3015
|
+
".avif": "image/avif",
|
|
3016
|
+
".ico": "image/x-icon",
|
|
3017
|
+
".mp3": "audio/mpeg",
|
|
3018
|
+
".wav": "audio/wav",
|
|
3019
|
+
".ogg": "audio/ogg",
|
|
3020
|
+
".mp4": "video/mp4",
|
|
3021
|
+
".webm": "video/webm",
|
|
3022
|
+
".woff": "font/woff",
|
|
3023
|
+
".woff2": "font/woff2",
|
|
3024
|
+
".ttf": "font/ttf",
|
|
3025
|
+
".otf": "font/otf"
|
|
3026
|
+
};
|
|
3027
|
+
var PAGE_CSP = [
|
|
3028
|
+
"default-src 'self'",
|
|
3029
|
+
"script-src 'self'",
|
|
3030
|
+
"style-src 'self' 'unsafe-inline'",
|
|
3031
|
+
"img-src 'self' data: blob: https: http:",
|
|
3032
|
+
"media-src 'self' data: blob: https: http:",
|
|
3033
|
+
"font-src 'self' data: https:",
|
|
3034
|
+
"connect-src 'self'",
|
|
3035
|
+
"frame-ancestors 'none'",
|
|
3036
|
+
"base-uri 'none'",
|
|
3037
|
+
"form-action 'none'"
|
|
3038
|
+
].join("; ");
|
|
3039
|
+
function defaultSandboxDir() {
|
|
3040
|
+
const here = path9.dirname(fileURLToPath(import.meta.url));
|
|
3041
|
+
const candidates = [path9.join(here, "sandbox"), path9.join(here, "..", "..", "dist", "sandbox")];
|
|
3042
|
+
return candidates.find((c) => fs8.existsSync(path9.join(c, "index.html"))) ?? candidates[0];
|
|
3043
|
+
}
|
|
3044
|
+
function createToken() {
|
|
3045
|
+
return randomBytes2(24).toString("base64url");
|
|
3046
|
+
}
|
|
3047
|
+
function writeDevServerFile(projectDir, info) {
|
|
3048
|
+
const file = path9.join(projectDir, DEV_SERVER_FILE);
|
|
3049
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
3050
|
+
fs8.writeFileSync(file, `${JSON.stringify(info, null, 2)}
|
|
3051
|
+
`, { mode: 384 });
|
|
3052
|
+
fs8.chmodSync(file, 384);
|
|
3053
|
+
ensureGitignored(projectDir);
|
|
3054
|
+
return file;
|
|
3055
|
+
}
|
|
3056
|
+
function ensureGitignored(projectDir) {
|
|
3057
|
+
const file = path9.join(projectDir, ".gitignore");
|
|
3058
|
+
const current = fs8.existsSync(file) ? fs8.readFileSync(file, "utf8") : "";
|
|
3059
|
+
const listed = current.split(/\r?\n/).some((l) => [".lumio", ".lumio/", "/.lumio", "/.lumio/", ".lumio/**"].includes(l.trim()));
|
|
3060
|
+
if (listed) return false;
|
|
3061
|
+
const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
3062
|
+
fs8.writeFileSync(file, `${current}${prefix}# lumio dev (session token, dev bundles)
|
|
3063
|
+
.lumio/
|
|
3064
|
+
`);
|
|
3065
|
+
return true;
|
|
3066
|
+
}
|
|
3067
|
+
function removeDevServerFile(projectDir, token) {
|
|
3068
|
+
const file = path9.join(projectDir, DEV_SERVER_FILE);
|
|
3069
|
+
try {
|
|
3070
|
+
const info = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
3071
|
+
if (info.token === token) fs8.rmSync(file, { force: true });
|
|
3072
|
+
} catch {
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
function safeEqual(a, b) {
|
|
3076
|
+
const x = Buffer.from(a);
|
|
3077
|
+
const y = Buffer.from(b);
|
|
3078
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
3079
|
+
}
|
|
3080
|
+
function within(root, rel) {
|
|
3081
|
+
let decoded;
|
|
3082
|
+
try {
|
|
3083
|
+
decoded = decodeURIComponent(rel);
|
|
3084
|
+
} catch {
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
3087
|
+
if (decoded.includes("\0")) return null;
|
|
3088
|
+
const full = path9.resolve(root, `.${path9.posix.sep}${decoded.replace(/^\/+/, "")}`);
|
|
3089
|
+
const base = path9.resolve(root);
|
|
3090
|
+
return full === base || full.startsWith(base + path9.sep) ? full : null;
|
|
3091
|
+
}
|
|
3092
|
+
async function startDevServer(options) {
|
|
3093
|
+
const { session, outDir } = options;
|
|
3094
|
+
const sandboxDir = options.sandboxDir ?? defaultSandboxDir();
|
|
3095
|
+
const token = options.token ?? createToken();
|
|
3096
|
+
const requestedPort = options.port ?? DEFAULT_DEV_PORT;
|
|
3097
|
+
const streams = /* @__PURE__ */ new Set();
|
|
3098
|
+
let port = requestedPort;
|
|
3099
|
+
let notifyTimer = null;
|
|
3100
|
+
const allowedHosts = () => /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
3101
|
+
const send = (res, status, body, type, extra = {}) => {
|
|
3102
|
+
res.writeHead(status, {
|
|
3103
|
+
"Content-Type": type,
|
|
3104
|
+
"Cache-Control": "no-store",
|
|
3105
|
+
"X-Content-Type-Options": "nosniff",
|
|
3106
|
+
"Referrer-Policy": "no-referrer",
|
|
3107
|
+
...extra
|
|
3108
|
+
});
|
|
3109
|
+
res.end(body);
|
|
3110
|
+
};
|
|
3111
|
+
const json = (res, status, body) => send(res, status, JSON.stringify(body), CONTENT_TYPES[".json"]);
|
|
3112
|
+
const serveFile = (res, file, extra = {}) => {
|
|
3113
|
+
if (!file || !fs8.existsSync(file) || !fs8.statSync(file).isFile()) return json(res, 404, { error: "NOT_FOUND" });
|
|
3114
|
+
send(res, 200, fs8.readFileSync(file), CONTENT_TYPES[path9.extname(file).toLowerCase()] ?? "application/octet-stream", extra);
|
|
3115
|
+
};
|
|
3116
|
+
const readBody = (req) => new Promise((resolve13, reject) => {
|
|
3117
|
+
let size = 0;
|
|
3118
|
+
const chunks = [];
|
|
3119
|
+
req.on("data", (c) => {
|
|
3120
|
+
size += c.length;
|
|
3121
|
+
if (size > MAX_BODY) {
|
|
3122
|
+
reject(new Error("Body larger than 1 MiB"));
|
|
3123
|
+
req.destroy();
|
|
3124
|
+
} else chunks.push(c);
|
|
3125
|
+
});
|
|
3126
|
+
req.on("end", () => resolve13(Buffer.concat(chunks).toString("utf8")));
|
|
3127
|
+
req.on("error", reject);
|
|
3128
|
+
});
|
|
3129
|
+
const broadcast = () => {
|
|
3130
|
+
notifyTimer = null;
|
|
3131
|
+
if (streams.size === 0) return;
|
|
3132
|
+
const data = `event: state
|
|
3133
|
+
data: ${JSON.stringify(session.state())}
|
|
3134
|
+
|
|
3135
|
+
`;
|
|
3136
|
+
for (const res of streams) res.write(data);
|
|
3137
|
+
};
|
|
3138
|
+
const handler = async (req, res) => {
|
|
3139
|
+
if (!allowedHosts().has(req.headers.host ?? "")) return json(res, 421, { error: "WRONG_HOST", message: "Use the URL lumio dev printed" });
|
|
3140
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
3141
|
+
const p = url.pathname;
|
|
3142
|
+
if (p.startsWith("/__lumio/")) {
|
|
3143
|
+
const given = req.headers[TOKEN_HEADER] ?? (p === "/__lumio/stream" ? url.searchParams.get("token") ?? void 0 : void 0);
|
|
3144
|
+
if (!given || !safeEqual(given, token)) {
|
|
3145
|
+
return json(res, 401, { error: "UNAUTHORIZED", message: `Send the token from ${DEV_SERVER_FILE} in the ${TOKEN_HEADER} header` });
|
|
3146
|
+
}
|
|
3147
|
+
if (req.method === "GET" && p === "/__lumio/state") return json(res, 200, session.state());
|
|
3148
|
+
if (req.method === "GET" && p === "/__lumio/meta") return json(res, 200, session.meta());
|
|
3149
|
+
if (req.method === "GET" && p === "/__lumio/event-template") {
|
|
3150
|
+
try {
|
|
3151
|
+
return json(res, 200, session.eventTemplate(url.searchParams.get("type") ?? ""));
|
|
3152
|
+
} catch (e) {
|
|
3153
|
+
return json(res, 400, { error: "BAD_REQUEST", message: e.message });
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
if (req.method === "GET" && p === "/__lumio/stream") {
|
|
3157
|
+
res.writeHead(200, {
|
|
3158
|
+
"Content-Type": "text/event-stream",
|
|
3159
|
+
"Cache-Control": "no-store",
|
|
3160
|
+
Connection: "keep-alive",
|
|
3161
|
+
"X-Content-Type-Options": "nosniff"
|
|
3162
|
+
});
|
|
3163
|
+
res.write(`retry: 1000
|
|
3164
|
+
event: state
|
|
3165
|
+
data: ${JSON.stringify(session.state())}
|
|
3166
|
+
|
|
3167
|
+
`);
|
|
3168
|
+
streams.add(res);
|
|
3169
|
+
req.on("close", () => streams.delete(res));
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
if (req.method === "POST" && p === "/__lumio/events") {
|
|
3173
|
+
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
3174
|
+
return json(res, 415, { error: "UNSUPPORTED_MEDIA_TYPE", message: "Send application/json" });
|
|
3175
|
+
}
|
|
3176
|
+
let cmd;
|
|
3177
|
+
try {
|
|
3178
|
+
cmd = commandFromJson(JSON.parse(await readBody(req)));
|
|
3179
|
+
} catch (e) {
|
|
3180
|
+
const status = e instanceof CommandSyntaxError || e instanceof SyntaxError ? 400 : 413;
|
|
3181
|
+
return json(res, status, { ok: false, error: e.message });
|
|
3182
|
+
}
|
|
3183
|
+
if (cmd === null) return json(res, 400, { ok: false, error: "Stop the session in its terminal (Ctrl+C or /quit)" });
|
|
3184
|
+
const header = req.headers["x-lumio-source"];
|
|
3185
|
+
const source = header === "panel" ? "panel" : header === "trigger" ? "trigger" : "http";
|
|
3186
|
+
const result = await session.run(cmd, source);
|
|
3187
|
+
return json(res, result.ok ? 200 : 422, result);
|
|
3188
|
+
}
|
|
3189
|
+
return json(res, 404, { error: "NOT_FOUND" });
|
|
3190
|
+
}
|
|
3191
|
+
if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "METHOD_NOT_ALLOWED" });
|
|
3192
|
+
if (p === "/" || p === "/index.html") {
|
|
3193
|
+
const file = path9.join(sandboxDir, "index.html");
|
|
3194
|
+
if (!fs8.existsSync(file)) {
|
|
3195
|
+
return send(res, 500, `The sandbox is missing from this CLI install (${file}). Reinstall @zaflun/lumio-cli.`, "text/plain; charset=utf-8");
|
|
3196
|
+
}
|
|
3197
|
+
const html = fs8.readFileSync(file, "utf8").replace("%LUMIO_DEV_TOKEN%", token);
|
|
3198
|
+
return send(res, 200, html, CONTENT_TYPES[".html"], { "Content-Security-Policy": PAGE_CSP });
|
|
3199
|
+
}
|
|
3200
|
+
if (p.startsWith("/sandbox/")) return serveFile(res, within(sandboxDir, p.slice("/sandbox/".length)));
|
|
3201
|
+
if (p.startsWith("/bundles/")) return serveFile(res, within(outDir, p.slice("/bundles/".length)));
|
|
3202
|
+
if (p.startsWith("/assets/")) return serveFile(res, within(path9.join(outDir, "assets"), p.slice("/assets/".length)));
|
|
3203
|
+
return json(res, 404, { error: "NOT_FOUND" });
|
|
3204
|
+
};
|
|
3205
|
+
const server = http2.createServer((req, res) => {
|
|
3206
|
+
handler(req, res).catch((e) => {
|
|
3207
|
+
if (!res.headersSent) json(res, 500, { error: "INTERNAL", message: e instanceof Error ? e.message : String(e) });
|
|
3208
|
+
else res.end();
|
|
3209
|
+
});
|
|
3210
|
+
});
|
|
3211
|
+
await new Promise((resolve13, reject) => {
|
|
3212
|
+
server.once("error", (e) => {
|
|
3213
|
+
reject(e.code === "EADDRINUSE" ? new Error(`Port ${requestedPort} is in use. Stop the other server or pass --port <port>.`) : e);
|
|
3214
|
+
});
|
|
3215
|
+
server.listen(requestedPort, "127.0.0.1", () => resolve13());
|
|
3216
|
+
});
|
|
3217
|
+
const address = server.address();
|
|
3218
|
+
if (address && typeof address === "object") port = address.port;
|
|
3219
|
+
const keepAlive = setInterval(() => {
|
|
3220
|
+
for (const res of streams) res.write(": ping\n\n");
|
|
3221
|
+
}, 15e3);
|
|
3222
|
+
keepAlive.unref();
|
|
3223
|
+
return {
|
|
3224
|
+
url: `http://127.0.0.1:${port}/`,
|
|
3225
|
+
port,
|
|
3226
|
+
token,
|
|
3227
|
+
notify() {
|
|
3228
|
+
if (!notifyTimer) notifyTimer = setTimeout(broadcast, 40);
|
|
3229
|
+
},
|
|
3230
|
+
async close() {
|
|
3231
|
+
clearInterval(keepAlive);
|
|
3232
|
+
if (notifyTimer) clearTimeout(notifyTimer);
|
|
3233
|
+
for (const res of streams) res.end();
|
|
3234
|
+
streams.clear();
|
|
3235
|
+
await new Promise((resolve13) => server.close(() => resolve13()));
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/commands/dev.ts
|
|
3241
|
+
var PLATFORMS = ["twitch", "youtube", "kick"];
|
|
3242
|
+
var ROLES = ["everyone", "follower", "subscriber", "vip", "moderator", "broadcaster", "owner", "administrator"];
|
|
3243
|
+
function intOption(name, min, max) {
|
|
3244
|
+
return (value) => {
|
|
3245
|
+
const n = Number(value);
|
|
3246
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new InvalidArgumentError(`${name} must be an integer between ${min} and ${max}.`);
|
|
3247
|
+
return value;
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
3250
|
+
function registerDev(program2) {
|
|
3251
|
+
program2.command("dev").description("Run the extension locally: sandbox page, REPL and bot-module runtime in one session").option("-p, --port <port>", "Dev server port", intOption("--port", 0, 65535), String(DEFAULT_DEV_PORT)).option("--bot-module", "Open the sandbox with the chat first (the session is the same)").option("--automation-node", "Start the automation node dev simulator").option("--platform <platform>", `Default chat platform (${PLATFORMS.join(", ")})`, "twitch").option("--role <role>", `Role of the default chat user (${ROLES.slice(0, 6).join(", ")})`).option("--seed <n>", "Seed of every random event field", intOption("--seed", 0, 2 ** 31 - 1), "1").option("--no-cooldown", "Do not enforce command cooldowns").option("--view <target>", "Surface to open first (editor, layer)").option("--open", "Open the sandbox in the browser").action(async (options) => {
|
|
3252
|
+
const configPath = path10.resolve("lumio.config.json");
|
|
3253
|
+
if (!fs9.existsSync(configPath)) {
|
|
3254
|
+
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
3255
|
+
process.exit(1);
|
|
3256
|
+
}
|
|
3257
|
+
const config = parseConfigFile(fs9.readFileSync(configPath, "utf-8"));
|
|
3258
|
+
if (options.automationNode || config.category === "automation_node") {
|
|
3259
|
+
await runAutomationNodeDevServer(config);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
try {
|
|
3263
|
+
await runDevSession(process.cwd(), config, options);
|
|
3264
|
+
} catch (e) {
|
|
3265
|
+
console.error(chalk3.red(e instanceof Error ? e.message : String(e)));
|
|
3266
|
+
process.exit(1);
|
|
3267
|
+
}
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
function readRawConfig(projectDir) {
|
|
3271
|
+
return JSON.parse(fs9.readFileSync(path10.join(projectDir, "lumio.config.json"), "utf8"));
|
|
3272
|
+
}
|
|
3273
|
+
async function runDevSession(projectDir, config, options) {
|
|
3274
|
+
if (!PLATFORMS.includes(options.platform)) throw new Error(`--platform must be one of ${PLATFORMS.join(", ")}`);
|
|
3275
|
+
if (options.role !== void 0 && !ROLES.includes(options.role)) throw new Error(`--role must be one of ${ROLES.join(", ")}`);
|
|
3276
|
+
if (options.view !== void 0 && options.view !== "editor" && options.view !== "layer") throw new Error("--view must be editor or layer");
|
|
3277
|
+
let modules;
|
|
3278
|
+
try {
|
|
3279
|
+
modules = await loadProjectModules(projectDir);
|
|
3280
|
+
} catch (e) {
|
|
3281
|
+
if (e instanceof MissingPackageError) throw new Error(`${e.message}
|
|
3282
|
+
lumio dev runs your extension with the devkit's mock host.`);
|
|
3283
|
+
throw e;
|
|
3284
|
+
}
|
|
3285
|
+
const mocks = await modules.devkitNode.loadMockFiles(projectDir);
|
|
3286
|
+
const { targets, skipped } = devTargets(projectDir, config);
|
|
3287
|
+
if (targets.length === 0 && !config.server) {
|
|
3288
|
+
throw new Error('Nothing to run: lumio.config.json has no editor/layer target with a src/<target>.tsx and no "server": true.');
|
|
3289
|
+
}
|
|
3290
|
+
console.log(chalk3.bold(`
|
|
3291
|
+
Lumio dev \xB7 ${config.name}`) + chalk3.dim(` (${config.category})`));
|
|
3292
|
+
for (const w of modules.warnings) console.log(chalk3.yellow(`warning: ${w}`));
|
|
3293
|
+
for (const s of skipped) console.log(chalk3.dim(`note: ${s}`));
|
|
3294
|
+
let repl = null;
|
|
3295
|
+
let server = null;
|
|
3296
|
+
const pending = [];
|
|
3297
|
+
const session = new DevSession({
|
|
3298
|
+
projectDir,
|
|
3299
|
+
rawConfig: readRawConfig(projectDir),
|
|
3300
|
+
modules,
|
|
3301
|
+
targets,
|
|
3302
|
+
seed: Number(options.seed),
|
|
3303
|
+
platform: options.platform,
|
|
3304
|
+
role: options.role,
|
|
3305
|
+
cooldowns: options.cooldown,
|
|
3306
|
+
view: options.view ?? void 0,
|
|
3307
|
+
chatFirst: options.botModule ?? false,
|
|
3308
|
+
mocks,
|
|
3309
|
+
onLog: (entry) => repl ? repl.print(entry) : pending.push(entry),
|
|
3310
|
+
onChange: () => server?.notify()
|
|
3311
|
+
});
|
|
3312
|
+
const buildErrors = /* @__PURE__ */ new Map();
|
|
3313
|
+
const builder = new DevBuilder(projectDir, config, targets, {
|
|
3314
|
+
onTarget: (target, code) => void session.updateTarget(target, code),
|
|
3315
|
+
onServer: (next) => void session.updateServer(next),
|
|
3316
|
+
onCss: () => session.cssChanged(),
|
|
3317
|
+
onStatus: (key, errors) => {
|
|
3318
|
+
const before = JSON.stringify([...buildErrors]);
|
|
3319
|
+
if (errors) buildErrors.set(key, errors);
|
|
3320
|
+
else buildErrors.delete(key);
|
|
3321
|
+
if (JSON.stringify([...buildErrors]) === before) return;
|
|
3322
|
+
const all = [...buildErrors.values()].flat();
|
|
3323
|
+
session.setBuild({ ok: all.length === 0, errors: all });
|
|
3324
|
+
if (!errors && all.length === 0) session.addLog("build", "build ok");
|
|
3325
|
+
}
|
|
3326
|
+
});
|
|
3327
|
+
const first = await builder.start();
|
|
3328
|
+
await session.start(first.server, first.bundles);
|
|
3329
|
+
server = await startDevServer({ session, outDir: builder.outDir, port: Number(options.port) });
|
|
3330
|
+
writeDevServerFile(projectDir, {
|
|
3331
|
+
url: server.url,
|
|
3332
|
+
port: server.port,
|
|
3333
|
+
token: server.token,
|
|
3334
|
+
pid: process.pid,
|
|
3335
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3336
|
+
});
|
|
3337
|
+
const state = session.state();
|
|
3338
|
+
console.log(`Sandbox: ${chalk3.cyan(server.url)}`);
|
|
3339
|
+
console.log(chalk3.dim(`Surfaces: ${state.targets.join(", ") || "none"} \xB7 bot runtime: ${config.server ? "on" : "off"} \xB7 seed ${options.seed} \xB7 platform ${options.platform}`));
|
|
3340
|
+
console.log(chalk3.dim("Type /help for commands. Plain text is a chat message.\n"));
|
|
3341
|
+
if (options.open) {
|
|
3342
|
+
const { default: open } = await import("open");
|
|
3343
|
+
await open(server.url).catch(() => {
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
let configTimer = null;
|
|
3347
|
+
const reloadConfig = () => {
|
|
3348
|
+
if (configTimer) clearTimeout(configTimer);
|
|
3349
|
+
configTimer = setTimeout(() => {
|
|
3350
|
+
void (async () => {
|
|
3351
|
+
try {
|
|
3352
|
+
const raw = readRawConfig(projectDir);
|
|
3353
|
+
const nextMocks = await modules.devkitNode.loadMockFiles(projectDir);
|
|
3354
|
+
await session.updateConfig(raw, nextMocks);
|
|
3355
|
+
} catch (e) {
|
|
3356
|
+
session.addLog("error", `lumio.config.json / lumio/mocks/ not reloaded: ${e instanceof Error ? e.message : String(e)}`);
|
|
3357
|
+
}
|
|
3358
|
+
})();
|
|
3359
|
+
}, 150);
|
|
3360
|
+
};
|
|
3361
|
+
const watchers = [fs9.watch(path10.join(projectDir, "lumio.config.json"), reloadConfig)];
|
|
3362
|
+
const mocksDir = path10.join(projectDir, "lumio", "mocks");
|
|
3363
|
+
if (fs9.existsSync(mocksDir)) {
|
|
3364
|
+
try {
|
|
3365
|
+
watchers.push(fs9.watch(mocksDir, { recursive: true }, reloadConfig));
|
|
3366
|
+
} catch {
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
await new Promise((resolve13) => {
|
|
3370
|
+
let stopping = false;
|
|
3371
|
+
const stop = () => {
|
|
3372
|
+
if (stopping) return;
|
|
3373
|
+
stopping = true;
|
|
3374
|
+
void (async () => {
|
|
3375
|
+
for (const w of watchers) w.close();
|
|
3376
|
+
if (configTimer) clearTimeout(configTimer);
|
|
3377
|
+
removeDevServerFile(projectDir, server?.token ?? "");
|
|
3378
|
+
await server?.close();
|
|
3379
|
+
await builder.stop();
|
|
3380
|
+
await session.close();
|
|
3381
|
+
repl?.close();
|
|
3382
|
+
console.log(chalk3.dim("\nlumio dev stopped."));
|
|
3383
|
+
resolve13();
|
|
3384
|
+
})();
|
|
3385
|
+
};
|
|
3386
|
+
process.once("SIGINT", stop);
|
|
3387
|
+
process.once("SIGTERM", stop);
|
|
3388
|
+
repl = startRepl(session, stop);
|
|
3389
|
+
for (const entry of pending.splice(0)) repl.print(entry);
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// src/commands/trigger.ts
|
|
3394
|
+
import chalk4 from "chalk";
|
|
3395
|
+
|
|
3396
|
+
// src/dev/trigger.ts
|
|
3397
|
+
import * as fs10 from "fs";
|
|
3398
|
+
import * as path11 from "path";
|
|
3399
|
+
var VALUE_FLAGS = ["field", "data", "json", "file", "user", "role", "platform", "input", "timer", "storage", "count", "rate", "seed"];
|
|
3400
|
+
var REPEATABLE = /* @__PURE__ */ new Set(["field", "storage"]);
|
|
3401
|
+
var BOOLEAN_FLAGS = ["random", "quiet"];
|
|
3402
|
+
var TRIGGER_USAGE = [
|
|
3403
|
+
"lumio trigger <type> [field=value \u2026] [--field a.b=value] [--<field> value] [--data '{\u2026}'] [--file payload.json] [--user name] [--role role]",
|
|
3404
|
+
"lumio trigger @<preset> [--user name]",
|
|
3405
|
+
'lumio trigger chat "<text>" [--user name] [--role moderator] [--platform kick]',
|
|
3406
|
+
'lumio trigger reward "<title or id>" [--user name] [--input "text"] [--platform kick]',
|
|
3407
|
+
"lumio trigger reward-status <redemption-id> <status>",
|
|
3408
|
+
"lumio trigger music play|pause|skip|seek 1:30|volume 50|\u2026",
|
|
3409
|
+
"lumio trigger scenario <name> [key=value \u2026]",
|
|
3410
|
+
"lumio trigger --timer <name> lumio trigger --storage key='<json>'",
|
|
3411
|
+
"load: --count N --rate 5/s --random [--platform twitch] --seed n --quiet"
|
|
3412
|
+
];
|
|
3413
|
+
function isObject2(v) {
|
|
3414
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3415
|
+
}
|
|
3416
|
+
function deepMerge(target, source) {
|
|
3417
|
+
for (const [k, v] of Object.entries(source)) {
|
|
3418
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") throw new CommandSyntaxError(`Invalid field "${k}"`);
|
|
3419
|
+
target[k] = isObject2(v) && isObject2(target[k]) ? deepMerge(target[k], v) : v;
|
|
3420
|
+
}
|
|
3421
|
+
return target;
|
|
3422
|
+
}
|
|
3423
|
+
function jsonObject(raw, what) {
|
|
3424
|
+
let value;
|
|
3425
|
+
try {
|
|
3426
|
+
value = JSON.parse(raw);
|
|
3427
|
+
} catch (e) {
|
|
3428
|
+
throw new CommandSyntaxError(`${what} is not valid JSON: ${e.message}`);
|
|
3429
|
+
}
|
|
3430
|
+
if (!isObject2(value)) throw new CommandSyntaxError(`${what} must be a JSON object`);
|
|
3431
|
+
return value;
|
|
3432
|
+
}
|
|
3433
|
+
function roleOf(raw) {
|
|
3434
|
+
const role = ROLE_ALIASES[raw.toLowerCase()];
|
|
3435
|
+
if (!role) throw new CommandSyntaxError(`Unknown role "${raw}" (use ${Object.keys(ROLE_ALIASES).join(", ")})`);
|
|
3436
|
+
return role;
|
|
3437
|
+
}
|
|
3438
|
+
function storageCommand(raw) {
|
|
3439
|
+
const eq = raw.indexOf("=");
|
|
3440
|
+
if (eq <= 0) throw new CommandSyntaxError(`--storage needs key=value, got "${raw}"`);
|
|
3441
|
+
const key = raw.slice(0, eq);
|
|
3442
|
+
const text = raw.slice(eq + 1);
|
|
3443
|
+
let value;
|
|
3444
|
+
try {
|
|
3445
|
+
value = JSON.parse(text);
|
|
3446
|
+
} catch {
|
|
3447
|
+
if (/^\s*[[{"]/.test(text)) throw new CommandSyntaxError(`--storage ${key}: not valid JSON: ${text}`);
|
|
3448
|
+
value = text;
|
|
3449
|
+
}
|
|
3450
|
+
return { kind: "storage", key, value };
|
|
3451
|
+
}
|
|
3452
|
+
function parseTriggerArgs(argv, cwd = process.cwd()) {
|
|
3453
|
+
const words = [];
|
|
3454
|
+
const flags = /* @__PURE__ */ new Map();
|
|
3455
|
+
const bools = /* @__PURE__ */ new Set();
|
|
3456
|
+
const shortcuts = [];
|
|
3457
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3458
|
+
const t = argv[i];
|
|
3459
|
+
if (!t.startsWith("--") || t === "--") {
|
|
3460
|
+
if (t !== "--") words.push(t);
|
|
3461
|
+
continue;
|
|
3462
|
+
}
|
|
3463
|
+
const eq = t.indexOf("=");
|
|
3464
|
+
const name = eq === -1 ? t.slice(2) : t.slice(2, eq);
|
|
3465
|
+
if (name === "") throw new CommandSyntaxError(`Invalid option ${t}`);
|
|
3466
|
+
if (BOOLEAN_FLAGS.includes(name)) {
|
|
3467
|
+
if (eq !== -1) throw new CommandSyntaxError(`--${name} takes no value`);
|
|
3468
|
+
bools.add(name);
|
|
3469
|
+
continue;
|
|
3470
|
+
}
|
|
3471
|
+
let value;
|
|
3472
|
+
if (eq !== -1) value = t.slice(eq + 1);
|
|
3473
|
+
else {
|
|
3474
|
+
if (i + 1 >= argv.length) throw new CommandSyntaxError(`--${name} needs a value`);
|
|
3475
|
+
value = argv[++i];
|
|
3476
|
+
}
|
|
3477
|
+
if (VALUE_FLAGS.includes(name)) {
|
|
3478
|
+
const list = flags.get(name) ?? [];
|
|
3479
|
+
if (list.length && !REPEATABLE.has(name)) throw new CommandSyntaxError(`--${name} is given twice`);
|
|
3480
|
+
flags.set(name, [...list, value]);
|
|
3481
|
+
} else shortcuts.push([name, value]);
|
|
3482
|
+
}
|
|
3483
|
+
const one = (name) => flags.get(name)?.[0];
|
|
3484
|
+
if (one("json") !== void 0 && one("data") !== void 0) throw new CommandSyntaxError("--json is --data: give one of them");
|
|
3485
|
+
const setup = [...(flags.get("storage") ?? []).map(storageCommand)];
|
|
3486
|
+
const timer = one("timer");
|
|
3487
|
+
if (timer !== void 0) setup.push({ kind: "timer", name: timer });
|
|
3488
|
+
let user;
|
|
3489
|
+
const userFlag2 = one("user");
|
|
3490
|
+
const role = one("role");
|
|
3491
|
+
if (userFlag2 !== void 0) user = userFlag2.startsWith("@") ? parseUserPrefix(userFlag2) : { name: userFlag2 };
|
|
3492
|
+
if (role !== void 0) user = { ...user, role: roleOf(role) };
|
|
3493
|
+
const load = { count: one("count"), rate: one("rate"), seed: one("seed"), random: bools.has("random") };
|
|
3494
|
+
const platform = one("platform");
|
|
3495
|
+
const only = (allowed, what) => {
|
|
3496
|
+
for (const name of ["field", "data", "json", "file", "input"]) {
|
|
3497
|
+
if (flags.has(name) && !allowed.includes(name)) throw new CommandSyntaxError(`--${name} does not apply to ${what}`);
|
|
3498
|
+
}
|
|
3499
|
+
if (shortcuts.length && !allowed.includes("field")) throw new CommandSyntaxError(`Unknown option --${shortcuts[0][0]} (${what} takes no fields)`);
|
|
3500
|
+
};
|
|
3501
|
+
const [head, ...rest] = words;
|
|
3502
|
+
let main;
|
|
3503
|
+
if (head === void 0) {
|
|
3504
|
+
if (load.random) {
|
|
3505
|
+
only([], "--random");
|
|
3506
|
+
main = withLoad({ kind: "event", user }, { ...load, platform });
|
|
3507
|
+
} else {
|
|
3508
|
+
if (setup.length === 0) throw new CommandSyntaxError(`Nothing to trigger. Usage:
|
|
3509
|
+
${TRIGGER_USAGE.join("\n ")}`);
|
|
3510
|
+
only([], "--timer / --storage");
|
|
3511
|
+
if (user || platform || load.count || load.rate || load.seed) throw new CommandSyntaxError("--user, --role, --platform and load flags need an event, chat message or scenario");
|
|
3512
|
+
main = null;
|
|
3513
|
+
}
|
|
3514
|
+
} else if (head === "chat") {
|
|
3515
|
+
only([], "a chat message");
|
|
3516
|
+
const text = rest.join(" ");
|
|
3517
|
+
if (text === "") throw new CommandSyntaxError('Usage: lumio trigger chat "<text>" [--user name] [--role role] [--platform kick]');
|
|
3518
|
+
main = withLoad({ kind: "chat", text, user, platform }, load);
|
|
3519
|
+
} else if (head === "reward") {
|
|
3520
|
+
only(["input"], "a reward");
|
|
3521
|
+
if (rest.length !== 1) throw new CommandSyntaxError('Usage: lumio trigger reward "<title or id>" [--user name] [--input "text"] [--platform kick]');
|
|
3522
|
+
main = withLoad({ kind: "reward", reward: rest[0], user, input: one("input"), platform }, load);
|
|
3523
|
+
} else if (head === "reward-status") {
|
|
3524
|
+
only([], "a reward status");
|
|
3525
|
+
if (rest.length !== 2) throw new CommandSyntaxError("Usage: lumio trigger reward-status <redemption-id> <status>");
|
|
3526
|
+
main = withLoad({ kind: "reward-status", id: rest[0], status: rest[1] }, load);
|
|
3527
|
+
} else if (head === "music") {
|
|
3528
|
+
only([], "the music player");
|
|
3529
|
+
const [op, ...args] = rest;
|
|
3530
|
+
if (!op) throw new CommandSyntaxError("Usage: lumio trigger music play|pause|skip [n]|seek 1:30|volume 50|shuffle on|repeat track|queue <uri>|device <name>|advance 30s");
|
|
3531
|
+
main = withLoad({ kind: "music", op, args }, load);
|
|
3532
|
+
} else if (head === "scenario") {
|
|
3533
|
+
only([], "a scenario (options are key=value words)");
|
|
3534
|
+
const [name, ...opts] = rest;
|
|
3535
|
+
if (!name && !load.random) throw new CommandSyntaxError("Usage: lumio trigger scenario <name> [key=value \u2026] | lumio trigger scenario --random");
|
|
3536
|
+
main = withLoad({ kind: "scenario", name: name ?? "", options: assignments(opts, "scenario options") }, load);
|
|
3537
|
+
} else if (head.startsWith("@")) {
|
|
3538
|
+
only([], "a preset");
|
|
3539
|
+
if (rest.length) throw new CommandSyntaxError("A preset takes no fields");
|
|
3540
|
+
main = withLoad({ kind: "event", preset: head.slice(1), user }, load);
|
|
3541
|
+
} else if (head.includes(":")) {
|
|
3542
|
+
if (load.random) throw new CommandSyntaxError("--random picks the event type itself: drop the type (every event gets fresh random fields anyway)");
|
|
3543
|
+
const data = {};
|
|
3544
|
+
const file = one("file");
|
|
3545
|
+
if (file !== void 0) {
|
|
3546
|
+
const full = path11.resolve(cwd, file);
|
|
3547
|
+
if (!fs10.existsSync(full)) throw new CommandSyntaxError(`--file ${file}: no such file`);
|
|
3548
|
+
deepMerge(data, jsonObject(fs10.readFileSync(full, "utf8"), `--file ${file}`));
|
|
3549
|
+
}
|
|
3550
|
+
const raw = one("data") ?? one("json");
|
|
3551
|
+
if (raw !== void 0) deepMerge(data, jsonObject(raw, "--data"));
|
|
3552
|
+
deepMerge(data, assignments(rest, "event fields"));
|
|
3553
|
+
for (const f of flags.get("field") ?? []) deepMerge(data, assignments([f], "--field"));
|
|
3554
|
+
for (const [name, value] of shortcuts) setPath(data, name, parseValue(value));
|
|
3555
|
+
main = withLoad({ kind: "event", type: head, data, user }, { ...load, platform });
|
|
3556
|
+
} else {
|
|
3557
|
+
throw new CommandSyntaxError(`Unknown trigger "${head}": use an event type (twitch:follower), @preset, chat, reward, music or scenario`);
|
|
3558
|
+
}
|
|
3559
|
+
return { setup, main, quiet: bools.has("quiet") };
|
|
3560
|
+
}
|
|
3561
|
+
var DevServerError = class extends Error {
|
|
3562
|
+
code;
|
|
3563
|
+
constructor(code, message2) {
|
|
3564
|
+
super(message2);
|
|
3565
|
+
this.name = "DevServerError";
|
|
3566
|
+
this.code = code;
|
|
3567
|
+
}
|
|
3568
|
+
};
|
|
3569
|
+
function findProjectDir(start) {
|
|
3570
|
+
let dir = path11.resolve(start);
|
|
3571
|
+
for (; ; ) {
|
|
3572
|
+
if (fs10.existsSync(path11.join(dir, DEV_SERVER_FILE)) || fs10.existsSync(path11.join(dir, "lumio.config.json"))) return dir;
|
|
3573
|
+
const parent = path11.dirname(dir);
|
|
3574
|
+
if (parent === dir) return null;
|
|
3575
|
+
dir = parent;
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
function readDevServerFile(projectDir) {
|
|
3579
|
+
const file = path11.join(projectDir, DEV_SERVER_FILE);
|
|
3580
|
+
if (!fs10.existsSync(file)) {
|
|
3581
|
+
throw new DevServerError("NO_SESSION", `No lumio dev session is running in ${projectDir} (no ${DEV_SERVER_FILE}). Start \`lumio dev\` in another terminal first.`);
|
|
3582
|
+
}
|
|
3583
|
+
let info;
|
|
3584
|
+
try {
|
|
3585
|
+
info = JSON.parse(fs10.readFileSync(file, "utf8"));
|
|
3586
|
+
} catch {
|
|
3587
|
+
throw new DevServerError("BAD_FILE", `${DEV_SERVER_FILE} is not valid JSON. Restart \`lumio dev\` to rewrite it.`);
|
|
3588
|
+
}
|
|
3589
|
+
let url = null;
|
|
3590
|
+
try {
|
|
3591
|
+
url = typeof info.url === "string" ? new URL(info.url) : null;
|
|
3592
|
+
} catch {
|
|
3593
|
+
url = null;
|
|
3594
|
+
}
|
|
3595
|
+
if (!url || url.protocol !== "http:" || url.hostname !== "127.0.0.1" || typeof info.token !== "string" || info.token === "" || typeof info.port !== "number") {
|
|
3596
|
+
throw new DevServerError("BAD_FILE", `${DEV_SERVER_FILE} does not describe a local lumio dev session (http://127.0.0.1:<port>/ and a token). Restart \`lumio dev\` to rewrite it.`);
|
|
3597
|
+
}
|
|
3598
|
+
return info;
|
|
3599
|
+
}
|
|
3600
|
+
function connectDevServer(projectDir, fetchImpl = fetch) {
|
|
3601
|
+
const info = readDevServerFile(projectDir);
|
|
3602
|
+
const base = new URL(info.url);
|
|
3603
|
+
const request = async (route, init = {}) => {
|
|
3604
|
+
let res;
|
|
3605
|
+
try {
|
|
3606
|
+
res = await fetchImpl(new URL(route, base), { ...init, headers: { ...init.headers, [TOKEN_HEADER]: info.token } });
|
|
3607
|
+
} catch {
|
|
3608
|
+
throw new DevServerError(
|
|
3609
|
+
"NOT_RUNNING",
|
|
3610
|
+
`The lumio dev session from ${DEV_SERVER_FILE} (port ${info.port}, pid ${info.pid}) is not answering \u2014 it was stopped or crashed. Start \`lumio dev\` again.`
|
|
3611
|
+
);
|
|
3612
|
+
}
|
|
3613
|
+
if (res.status === 401 || res.status === 421) {
|
|
3614
|
+
throw new DevServerError(
|
|
3615
|
+
"BAD_TOKEN",
|
|
3616
|
+
`The server on port ${info.port} refused the token from ${DEV_SERVER_FILE}: it is another lumio dev session (another project?) or the file is stale. Restart \`lumio dev\` in this project.`
|
|
3617
|
+
);
|
|
3618
|
+
}
|
|
3619
|
+
return res;
|
|
3620
|
+
};
|
|
3621
|
+
const body = async (res) => {
|
|
3622
|
+
try {
|
|
3623
|
+
return await res.json();
|
|
3624
|
+
} catch {
|
|
3625
|
+
throw new DevServerError("BAD_RESPONSE", `The server on port ${info.port} did not answer like lumio dev (HTTP ${res.status}). Is something else using the port?`);
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
return {
|
|
3629
|
+
url: info.url,
|
|
3630
|
+
async meta() {
|
|
3631
|
+
const res = await request("/__lumio/meta");
|
|
3632
|
+
if (!res.ok) throw new DevServerError("BAD_RESPONSE", `GET /__lumio/meta answered HTTP ${res.status}`);
|
|
3633
|
+
return body(res);
|
|
3634
|
+
},
|
|
3635
|
+
async send(cmd) {
|
|
3636
|
+
const res = await request("/__lumio/events", {
|
|
3637
|
+
method: "POST",
|
|
3638
|
+
headers: { "Content-Type": "application/json", "X-Lumio-Source": "trigger" },
|
|
3639
|
+
body: JSON.stringify(cmd)
|
|
3640
|
+
});
|
|
3641
|
+
const r = await body(res);
|
|
3642
|
+
if (res.ok || res.status === 422) return { ok: r.ok === true, output: r.output ?? [], log: r.log, payload: r.payload, error: r.error };
|
|
3643
|
+
const error = r.error && r.message ? `${r.error}: ${r.message}` : r.error ?? r.message ?? `HTTP ${res.status}`;
|
|
3644
|
+
return { ok: false, output: [], error };
|
|
3645
|
+
}
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
async function runTrigger(client, plan, hooks = {}) {
|
|
3649
|
+
const results = [];
|
|
3650
|
+
const send = async (cmd) => {
|
|
3651
|
+
const r = await client.send(cmd);
|
|
3652
|
+
results.push(r);
|
|
3653
|
+
hooks.onResult?.(r, cmd);
|
|
3654
|
+
return r;
|
|
3655
|
+
};
|
|
3656
|
+
for (const cmd of plan.setup) {
|
|
3657
|
+
if (!(await send(cmd)).ok) return { results, ok: false };
|
|
3658
|
+
}
|
|
3659
|
+
if (!plan.main) return { results, ok: true };
|
|
3660
|
+
if (plan.main.kind !== "repeat") return { results, ok: (await send(plan.main)).ok };
|
|
3661
|
+
const repeat = plan.main;
|
|
3662
|
+
const spec = repeatSpec(repeat, repeat.seed ?? (hooks.seed ?? freshSeed)());
|
|
3663
|
+
const meta = spec.random ? await client.meta() : { eventTypes: [], scenarios: [] };
|
|
3664
|
+
const variant = repeatVariants(repeat, spec, meta);
|
|
3665
|
+
const result = await runLoad(spec, () => send(variant()), { signal: hooks.signal });
|
|
3666
|
+
return { results, load: { spec, result, summary: summarizeLoad(spec, result) }, ok: result.failed === null };
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// src/commands/trigger.ts
|
|
3670
|
+
function formatResult(r) {
|
|
3671
|
+
const log = (r.log ?? []).filter((e) => e.kind !== "input");
|
|
3672
|
+
const lines = log.length ? log.map(formatLogEntry) : r.output.map((l) => r.ok ? l : chalk4.red(l));
|
|
3673
|
+
if (!r.ok && r.error && !log.some((e) => e.kind === "error")) lines.push(chalk4.red(`error: ${r.error}`));
|
|
3674
|
+
return lines;
|
|
3675
|
+
}
|
|
3676
|
+
function registerTrigger(program2) {
|
|
3677
|
+
program2.command("trigger").description("Send an event, chat message, reward, music command, timer or scenario to the running `lumio dev`").argument("[args...]", "what to trigger (see below)").allowUnknownOption().allowExcessArguments().helpOption("-h, --help", "Show help").addHelpText(
|
|
3678
|
+
"after",
|
|
3679
|
+
[
|
|
3680
|
+
"",
|
|
3681
|
+
"Usage:",
|
|
3682
|
+
...TRIGGER_USAGE.map((l) => ` ${l}`),
|
|
3683
|
+
"",
|
|
3684
|
+
"Examples:",
|
|
3685
|
+
" lumio trigger twitch:follower",
|
|
3686
|
+
" lumio trigger twitch:subscribe --tier 2 --field user_name=Alice",
|
|
3687
|
+
" lumio trigger kick:reward --field reward.title=Hydrate",
|
|
3688
|
+
' lumio trigger chat "!points 100" --user alice --role moderator',
|
|
3689
|
+
" lumio trigger twitch:follower --count 50 --rate 5/s",
|
|
3690
|
+
" lumio trigger --random --count 20 --rate 2/s --platform kick",
|
|
3691
|
+
" lumio trigger music skip",
|
|
3692
|
+
" lumio trigger --timer reminder",
|
|
3693
|
+
` lumio trigger --storage counter='{"value":3}'`,
|
|
3694
|
+
"",
|
|
3695
|
+
"Talks to the lumio dev session of this project through .lumio/dev-server.json."
|
|
3696
|
+
].join("\n")
|
|
3697
|
+
).action(async (args) => {
|
|
3698
|
+
let plan;
|
|
3699
|
+
try {
|
|
3700
|
+
plan = parseTriggerArgs(args);
|
|
3701
|
+
} catch (e) {
|
|
3702
|
+
console.error(chalk4.red(e instanceof Error ? e.message : String(e)));
|
|
3703
|
+
process.exit(2);
|
|
3704
|
+
}
|
|
3705
|
+
const projectDir = findProjectDir(process.cwd()) ?? process.cwd();
|
|
3706
|
+
const abort = new AbortController();
|
|
3707
|
+
const onSigint = () => {
|
|
3708
|
+
if (abort.signal.aborted) process.exit(130);
|
|
3709
|
+
abort.abort();
|
|
3710
|
+
};
|
|
3711
|
+
process.on("SIGINT", onSigint);
|
|
3712
|
+
try {
|
|
3713
|
+
const client = connectDevServer(projectDir);
|
|
3714
|
+
const quiet = plan.quiet;
|
|
3715
|
+
const run = await runTrigger(client, plan, {
|
|
3716
|
+
signal: abort.signal,
|
|
3717
|
+
onResult: (r) => {
|
|
3718
|
+
if (quiet && r.ok) return;
|
|
3719
|
+
for (const line of formatResult(r)) console.log(line);
|
|
3720
|
+
}
|
|
3721
|
+
});
|
|
3722
|
+
if (run.load) console.log((run.ok ? chalk4.dim : chalk4.red)(run.load.summary));
|
|
3723
|
+
process.exitCode = run.ok ? 0 : 1;
|
|
3724
|
+
} catch (e) {
|
|
3725
|
+
const known = e instanceof DevServerError || e instanceof CommandSyntaxError;
|
|
3726
|
+
console.error(chalk4.red(known ? e.message : `lumio trigger failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
3727
|
+
process.exitCode = 1;
|
|
3728
|
+
} finally {
|
|
3729
|
+
process.off("SIGINT", onSigint);
|
|
3730
|
+
}
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
// src/commands/build.ts
|
|
3735
|
+
import * as fs11 from "fs";
|
|
3736
|
+
import * as path12 from "path";
|
|
3737
|
+
function registerBuild(program2) {
|
|
3738
|
+
program2.command("build").description("Bundle extension as standalone HTML + source archive").action(async () => {
|
|
3739
|
+
const configPath = path12.resolve("lumio.config.json");
|
|
3740
|
+
if (!fs11.existsSync(configPath)) {
|
|
3741
|
+
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
3742
|
+
process.exit(1);
|
|
3743
|
+
}
|
|
3744
|
+
const configJson = fs11.readFileSync(configPath, "utf-8");
|
|
3745
|
+
const config = parseConfigFile(configJson);
|
|
3746
|
+
validateConfig(config);
|
|
3747
|
+
const changelogPath = path12.resolve("CHANGELOG.md");
|
|
3748
|
+
if (!fs11.existsSync(changelogPath)) {
|
|
3749
|
+
console.warn("Warning: No CHANGELOG.md found. It is recommended to maintain a changelog.");
|
|
3750
|
+
}
|
|
3751
|
+
console.log(`Building ${config.name} v${config.version}...`);
|
|
3752
|
+
const distDir = path12.resolve("dist");
|
|
3753
|
+
fs11.mkdirSync(distDir, { recursive: true });
|
|
3754
|
+
const { build } = await import("esbuild");
|
|
3755
|
+
const projectDir = process.cwd();
|
|
3756
|
+
if (config.category === "bot_module" && config.targets.length === 0) {
|
|
3757
|
+
console.log(" Bot module \u2014 no UI targets to bundle.");
|
|
3758
|
+
}
|
|
3759
|
+
const manifestPath = path12.join(distDir, FUNCTIONS_MANIFEST_FILE);
|
|
3760
|
+
fs11.rmSync(manifestPath, { force: true });
|
|
3761
|
+
const functionsFile = config.category !== "automation_node" ? findFunctionsFile(projectDir) : null;
|
|
3762
|
+
if (functionsFile && !config.server) {
|
|
3763
|
+
console.warn(` Warning: ${functionsFile} found but lumio.config.json has no "server": true \u2014 it is not built.`);
|
|
3764
|
+
}
|
|
3765
|
+
if (config.server) {
|
|
3766
|
+
let plan;
|
|
3767
|
+
try {
|
|
3768
|
+
plan = await planServerBuild(projectDir, config, path12.join(distDir, "server.js"));
|
|
3769
|
+
} catch (err) {
|
|
3770
|
+
console.error(` Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
3771
|
+
process.exit(1);
|
|
3772
|
+
}
|
|
3773
|
+
if (plan.options) {
|
|
3774
|
+
await build(plan.options);
|
|
3775
|
+
console.log(" Built: dist/server.js");
|
|
3776
|
+
} else {
|
|
3777
|
+
console.warn(" Warning: server: true but no server/handler.ts, server/index.ts or server/functions.ts found.");
|
|
3778
|
+
}
|
|
3779
|
+
if (plan.manifest) {
|
|
3780
|
+
fs11.writeFileSync(manifestPath, `${JSON.stringify(plan.manifest, null, 2)}
|
|
3781
|
+
`);
|
|
3782
|
+
console.log(` Built: dist/${FUNCTIONS_MANIFEST_FILE} (${Object.keys(plan.manifest.functions).length} function(s))`);
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
const cssEntryFile = findCssEntry(projectDir);
|
|
3786
|
+
try {
|
|
3787
|
+
const css = await compileCss(projectDir, distDir);
|
|
3788
|
+
if (css?.tailwind) console.log(" Tailwind CSS v4 detected \u2014 compiled dist/styles.css");
|
|
3789
|
+
else if (css) console.log(` Copied: ${cssEntryFile} \u2192 dist/styles.css`);
|
|
3790
|
+
} catch (err) {
|
|
3791
|
+
console.error(` Error: Tailwind CSS compilation failed. ${err instanceof CssBuildError ? err.hint : ""}`);
|
|
3792
|
+
console.error(` ${err instanceof Error ? err.message : String(err)}`);
|
|
3793
|
+
process.exit(1);
|
|
3794
|
+
}
|
|
3795
|
+
for (const target of config.targets) {
|
|
3796
|
+
const entryPoint = targetEntry(projectDir, target);
|
|
3797
|
+
if (!entryPoint) {
|
|
3798
|
+
console.warn(`Warning: Entry point ${path12.resolve("src", `${target}.tsx`)} not found, skipping target "${target}".`);
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3801
|
+
await build(targetBuildOptions(entryPoint, distDir, target));
|
|
3802
|
+
console.log(` Built: dist/${target}.js`);
|
|
3803
|
+
}
|
|
3804
|
+
const { TarArchive } = await import("archiver");
|
|
3805
|
+
const sourceArchivePath = path12.join(distDir, "source.tar.gz");
|
|
3806
|
+
const output = fs11.createWriteStream(sourceArchivePath);
|
|
3807
|
+
const archive = new TarArchive({ gzip: true });
|
|
3808
|
+
archive.pipe(output);
|
|
3809
|
+
archive.glob("**/*", {
|
|
3810
|
+
cwd: path12.resolve("."),
|
|
3811
|
+
ignore: [
|
|
3812
|
+
"node_modules/**",
|
|
3813
|
+
"dist/**",
|
|
3814
|
+
".git/**",
|
|
3815
|
+
".git",
|
|
3816
|
+
".next/**",
|
|
3817
|
+
".lumio-dist/**",
|
|
3818
|
+
"*.tgz",
|
|
3819
|
+
"*.tar.gz",
|
|
3820
|
+
// Secrets & credentials must never be uploaded to the store: the
|
|
3821
|
+
// archive is visible to reviewers. Exclude env files (except the
|
|
3822
|
+
// example), npm/yarn auth, and local CLI state.
|
|
3823
|
+
// archiver 8's readdir-glob@3 dropped the `!pattern` un-ignore
|
|
3824
|
+
// feature (each ignore entry is now an independent minimatch, so a
|
|
3825
|
+
// leading `!` inverts that one matcher and ignores everything else).
|
|
3826
|
+
// Keep `.env.example` via an extglob instead of a negated entry.
|
|
3827
|
+
"**/.env",
|
|
3828
|
+
"**/.env.!(example)",
|
|
3829
|
+
"**/.npmrc",
|
|
3830
|
+
"**/.yarnrc",
|
|
3831
|
+
"**/.yarnrc.yml",
|
|
3832
|
+
"**/.lumio/**"
|
|
3833
|
+
],
|
|
3834
|
+
dot: true
|
|
3835
|
+
});
|
|
3836
|
+
await archive.finalize();
|
|
3837
|
+
console.log(" Archive: dist/source.tar.gz");
|
|
3838
|
+
console.log("Build complete.");
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
// src/commands/deploy.ts
|
|
3843
|
+
import * as fs12 from "fs";
|
|
3844
|
+
import * as path13 from "path";
|
|
3845
|
+
|
|
3846
|
+
// src/changelog/parser.ts
|
|
3847
|
+
var VERSION_HEADER_REGEX = /^## \[([^\]]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}))?/;
|
|
3848
|
+
function parseChangelog(markdown) {
|
|
3849
|
+
const lines = markdown.split("\n");
|
|
3850
|
+
const entries = [];
|
|
3851
|
+
let currentEntry = null;
|
|
3852
|
+
const contentLines = [];
|
|
3853
|
+
for (const line of lines) {
|
|
3854
|
+
const match = line.match(VERSION_HEADER_REGEX);
|
|
3855
|
+
if (match) {
|
|
3856
|
+
if (currentEntry) {
|
|
3857
|
+
currentEntry.content = contentLines.join("\n").trim();
|
|
3858
|
+
entries.push(currentEntry);
|
|
3859
|
+
contentLines.length = 0;
|
|
3860
|
+
}
|
|
3861
|
+
currentEntry = {
|
|
3862
|
+
version: match[1],
|
|
3863
|
+
date: match[2] ?? null,
|
|
3864
|
+
content: ""
|
|
3865
|
+
};
|
|
3866
|
+
} else if (currentEntry) {
|
|
3867
|
+
contentLines.push(line);
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
if (currentEntry) {
|
|
3871
|
+
currentEntry.content = contentLines.join("\n").trim();
|
|
3872
|
+
entries.push(currentEntry);
|
|
3873
|
+
}
|
|
3874
|
+
return entries;
|
|
3875
|
+
}
|
|
3876
|
+
function getVersionEntry(changelog, version) {
|
|
3877
|
+
const entries = parseChangelog(changelog);
|
|
3878
|
+
return entries.find((e) => e.version === version) ?? null;
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
// src/commands/deploy.ts
|
|
3882
|
+
function collectFiles(dir, base = dir) {
|
|
3883
|
+
const entries = fs12.readdirSync(dir, { withFileTypes: true });
|
|
3884
|
+
const files = [];
|
|
3885
|
+
for (const entry of entries) {
|
|
3886
|
+
const fullPath = path13.join(dir, entry.name);
|
|
3887
|
+
if (entry.isDirectory()) {
|
|
3888
|
+
files.push(...collectFiles(fullPath, base));
|
|
3889
|
+
} else if (entry.isFile()) {
|
|
3890
|
+
files.push(path13.relative(base, fullPath));
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
return files;
|
|
3894
|
+
}
|
|
3895
|
+
var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1144
3896
|
"js",
|
|
1145
3897
|
"css",
|
|
1146
3898
|
"html",
|
|
@@ -1175,22 +3927,22 @@ var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
|
1175
3927
|
var MAX_TOTAL_SIZE = 50 * 1024 * 1024;
|
|
1176
3928
|
var MAX_FILES = 200;
|
|
1177
3929
|
function registerDeploy(program2) {
|
|
1178
|
-
program2.command("deploy").description("Upload extension to Lumio (status: draft)").option("-v, --version <version>", "Version override").option("-d, --description <text>", "Release notes override").option("--breaking", "Mark as breaking change").option("--draft", "Explicitly mark as draft").option("--token <token>", "API token (for CI, skips login)").option("--system", "Deploy as system extension (no developer profile required)").action(async (options) => {
|
|
3930
|
+
program2.command("deploy").description("Upload extension to Lumio (status: draft)").option("-v, --version <version>", "Version override").option("-d, --description <text>", "Release notes override").option("--breaking", "Mark as breaking change").option("--draft", "Explicitly mark as draft").option("--token <token>", "API token (for CI, skips login). Use a developer key (lm_dev_*) minted under Account \u2192 Developer for publishing.").option("--system", "Deploy as system extension (no developer profile required)").action(async (options) => {
|
|
1179
3931
|
const store = new CredentialStore();
|
|
1180
3932
|
const token = options.token ?? store.getToken();
|
|
1181
3933
|
if (!token) {
|
|
1182
3934
|
console.error("Not logged in. Run `lumio login` or provide --token.");
|
|
1183
3935
|
process.exit(1);
|
|
1184
3936
|
}
|
|
1185
|
-
const configPath =
|
|
1186
|
-
if (!
|
|
3937
|
+
const configPath = path13.resolve("lumio.config.json");
|
|
3938
|
+
if (!fs12.existsSync(configPath)) {
|
|
1187
3939
|
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
1188
3940
|
process.exit(1);
|
|
1189
3941
|
}
|
|
1190
|
-
const configJson =
|
|
3942
|
+
const configJson = fs12.readFileSync(configPath, "utf-8");
|
|
1191
3943
|
const config = parseConfigFile(configJson);
|
|
1192
3944
|
validateConfig(config);
|
|
1193
|
-
const distDir =
|
|
3945
|
+
const distDir = path13.resolve("dist");
|
|
1194
3946
|
if (config.sounds && config.sounds.length > 0) {
|
|
1195
3947
|
for (const sound of config.sounds) {
|
|
1196
3948
|
const soundFile = sound.file;
|
|
@@ -1201,15 +3953,15 @@ function registerDeploy(program2) {
|
|
|
1201
3953
|
);
|
|
1202
3954
|
process.exit(1);
|
|
1203
3955
|
}
|
|
1204
|
-
const soundAbsPath =
|
|
1205
|
-
if (
|
|
3956
|
+
const soundAbsPath = path13.join(distDir, "sounds", soundFile);
|
|
3957
|
+
if (fs12.existsSync(distDir) && !fs12.existsSync(soundAbsPath)) {
|
|
1206
3958
|
console.error(
|
|
1207
3959
|
`Error: Sound file "${soundFile}" declared in lumio.config.json was not found at dist/sounds/${soundFile}. Run \`lumio build\` first.`
|
|
1208
3960
|
);
|
|
1209
3961
|
process.exit(1);
|
|
1210
3962
|
}
|
|
1211
|
-
if (
|
|
1212
|
-
const stat =
|
|
3963
|
+
if (fs12.existsSync(soundAbsPath)) {
|
|
3964
|
+
const stat = fs12.statSync(soundAbsPath);
|
|
1213
3965
|
if (stat.size > LARGE_SOUND_THRESHOLD) {
|
|
1214
3966
|
console.warn(
|
|
1215
3967
|
` Warning: Sound file "${soundFile}" is ${(stat.size / 1024 / 1024).toFixed(1)} MB. Consider compressing large audio files to reduce load times.`
|
|
@@ -1218,12 +3970,70 @@ function registerDeploy(program2) {
|
|
|
1218
3970
|
}
|
|
1219
3971
|
}
|
|
1220
3972
|
}
|
|
3973
|
+
if (config.sounds && config.sounds.length > 0) {
|
|
3974
|
+
const limitsApiUrl = process.env.LUMIO_API_URL ?? store.getApiUrl();
|
|
3975
|
+
try {
|
|
3976
|
+
const limitsRes = await fetch(
|
|
3977
|
+
`${limitsApiUrl}/v1/developer/extensions/${config.extensionId}/limits`,
|
|
3978
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
3979
|
+
);
|
|
3980
|
+
if (limitsRes.ok) {
|
|
3981
|
+
const limitsJson = await limitsRes.json();
|
|
3982
|
+
const maxSounds = limitsJson.data?.max_sounds ?? 50;
|
|
3983
|
+
const maxFileSize = limitsJson.data?.max_sound_file_size ?? 10485760;
|
|
3984
|
+
const maxStorage = limitsJson.data?.max_sound_storage_bytes ?? 209715200;
|
|
3985
|
+
if (config.sounds.length > maxSounds) {
|
|
3986
|
+
console.error(
|
|
3987
|
+
`Error: Extension declares ${config.sounds.length} sounds, but your limit is ${maxSounds}.`
|
|
3988
|
+
);
|
|
3989
|
+
console.error(
|
|
3990
|
+
"Request a limit increase at /dashboard/settings/developer/limits or contact support."
|
|
3991
|
+
);
|
|
3992
|
+
process.exit(1);
|
|
3993
|
+
}
|
|
3994
|
+
let totalSize2 = 0;
|
|
3995
|
+
for (const sound of config.sounds) {
|
|
3996
|
+
const soundPath = path13.resolve("dist", "sounds", sound.file);
|
|
3997
|
+
if (fs12.existsSync(soundPath)) {
|
|
3998
|
+
const size = fs12.statSync(soundPath).size;
|
|
3999
|
+
totalSize2 += size;
|
|
4000
|
+
if (size > maxFileSize) {
|
|
4001
|
+
console.error(
|
|
4002
|
+
`Error: Sound file "${sound.file}" is ${(size / 1024 / 1024).toFixed(1)} MB but your per-file limit is ${(maxFileSize / 1024 / 1024).toFixed(1)} MB.`
|
|
4003
|
+
);
|
|
4004
|
+
console.error(
|
|
4005
|
+
"Request a limit increase at /dashboard/settings/developer/limits or contact support."
|
|
4006
|
+
);
|
|
4007
|
+
process.exit(1);
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
if (totalSize2 > maxStorage) {
|
|
4012
|
+
console.error(
|
|
4013
|
+
`Error: Total sound size is ${(totalSize2 / 1024 / 1024).toFixed(1)} MB but your storage limit is ${(maxStorage / 1024 / 1024).toFixed(1)} MB.`
|
|
4014
|
+
);
|
|
4015
|
+
console.error(
|
|
4016
|
+
"Request a limit increase at /dashboard/settings/developer/limits or contact support."
|
|
4017
|
+
);
|
|
4018
|
+
process.exit(1);
|
|
4019
|
+
}
|
|
4020
|
+
} else if (limitsRes.status !== 404) {
|
|
4021
|
+
console.warn(
|
|
4022
|
+
` Warning: Could not fetch sound limits (HTTP ${limitsRes.status}), skipping pre-flight check.`
|
|
4023
|
+
);
|
|
4024
|
+
}
|
|
4025
|
+
} catch {
|
|
4026
|
+
console.warn(
|
|
4027
|
+
" Warning: Could not reach API for sound limit check, skipping pre-flight check."
|
|
4028
|
+
);
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
1221
4031
|
const version = options.version ?? config.version;
|
|
1222
4032
|
let releaseNotes = options.description ?? "";
|
|
1223
4033
|
if (!releaseNotes) {
|
|
1224
|
-
const changelogPath =
|
|
1225
|
-
if (
|
|
1226
|
-
const changelog =
|
|
4034
|
+
const changelogPath = path13.resolve("CHANGELOG.md");
|
|
4035
|
+
if (fs12.existsSync(changelogPath)) {
|
|
4036
|
+
const changelog = fs12.readFileSync(changelogPath, "utf-8");
|
|
1227
4037
|
const entry = getVersionEntry(changelog, version);
|
|
1228
4038
|
if (entry) {
|
|
1229
4039
|
releaseNotes = entry.content;
|
|
@@ -1234,10 +4044,23 @@ function registerDeploy(program2) {
|
|
|
1234
4044
|
}
|
|
1235
4045
|
}
|
|
1236
4046
|
}
|
|
1237
|
-
|
|
4047
|
+
let dataSchema;
|
|
4048
|
+
try {
|
|
4049
|
+
dataSchema = await loadDataSchema(process.cwd());
|
|
4050
|
+
} catch (err) {
|
|
4051
|
+
console.error(`Error: Could not load server/schema.ts: ${err.message ?? String(err)}`);
|
|
4052
|
+
process.exit(1);
|
|
4053
|
+
}
|
|
4054
|
+
if (!fs12.existsSync(distDir)) {
|
|
1238
4055
|
console.error("No dist/ directory found. Run `lumio build` first.");
|
|
1239
4056
|
process.exit(1);
|
|
1240
4057
|
}
|
|
4058
|
+
const exposesFunctions = config.server && config.category !== "automation_node";
|
|
4059
|
+
const functionsFile = exposesFunctions ? findFunctionsFile(process.cwd()) : null;
|
|
4060
|
+
if (functionsFile && !fs12.existsSync(path13.join(distDir, FUNCTIONS_MANIFEST_FILE))) {
|
|
4061
|
+
console.error(`Error: ${functionsFile} exists but dist/${FUNCTIONS_MANIFEST_FILE} is missing. Run \`lumio build\` first.`);
|
|
4062
|
+
process.exit(1);
|
|
4063
|
+
}
|
|
1241
4064
|
console.log(`Deploying ${config.name} v${version}...`);
|
|
1242
4065
|
const apiUrl = process.env.LUMIO_API_URL ?? store.getApiUrl();
|
|
1243
4066
|
const client = new LumioApiClient(apiUrl, token);
|
|
@@ -1257,8 +4080,8 @@ function registerDeploy(program2) {
|
|
|
1257
4080
|
console.error(`Too many files (${bundleFiles.length}). Maximum is ${MAX_FILES}.`);
|
|
1258
4081
|
process.exit(1);
|
|
1259
4082
|
}
|
|
1260
|
-
const sourceArchive =
|
|
1261
|
-
if (!
|
|
4083
|
+
const sourceArchive = path13.join(distDir, sourceFile);
|
|
4084
|
+
if (!fs12.existsSync(sourceArchive)) {
|
|
1262
4085
|
console.error("Error: source.tar.gz not found in dist/. Run `lumio build` first.");
|
|
1263
4086
|
process.exit(1);
|
|
1264
4087
|
}
|
|
@@ -1269,7 +4092,11 @@ function registerDeploy(program2) {
|
|
|
1269
4092
|
formData.append("manifest", JSON.stringify(manifest));
|
|
1270
4093
|
formData.append("release_notes", releaseNotes);
|
|
1271
4094
|
formData.append("is_breaking", String(!!options.breaking));
|
|
1272
|
-
|
|
4095
|
+
if (dataSchema) {
|
|
4096
|
+
formData.append("data_schema", JSON.stringify(dataSchema));
|
|
4097
|
+
console.log(` Data schema: ${Object.keys(dataSchema.tables).length} table(s)`);
|
|
4098
|
+
}
|
|
4099
|
+
const sourceBuffer = fs12.readFileSync(sourceArchive);
|
|
1273
4100
|
if (sourceBuffer.length === 0) {
|
|
1274
4101
|
console.error("Error: source.tar.gz is empty. Run `lumio build` to regenerate.");
|
|
1275
4102
|
process.exit(1);
|
|
@@ -1278,8 +4105,8 @@ function registerDeploy(program2) {
|
|
|
1278
4105
|
console.log(` Source: source.tar.gz (${(sourceBuffer.length / 1024).toFixed(1)} KB)`);
|
|
1279
4106
|
let totalSize = 0;
|
|
1280
4107
|
for (const relPath of bundleFiles) {
|
|
1281
|
-
const absPath =
|
|
1282
|
-
const buffer =
|
|
4108
|
+
const absPath = path13.join(distDir, relPath);
|
|
4109
|
+
const buffer = fs12.readFileSync(absPath);
|
|
1283
4110
|
if (buffer.length === 0) {
|
|
1284
4111
|
console.warn(` Warning: Skipping empty file: ${relPath}`);
|
|
1285
4112
|
continue;
|
|
@@ -1308,9 +4135,16 @@ function registerDeploy(program2) {
|
|
|
1308
4135
|
}
|
|
1309
4136
|
} catch (err) {
|
|
1310
4137
|
const status = err.status;
|
|
1311
|
-
const
|
|
4138
|
+
const message2 = err.message ?? String(err);
|
|
4139
|
+
const schemaLines = err instanceof ApiError && status === 422 ? formatDataSchemaError(err.body) : null;
|
|
4140
|
+
if (schemaLines) {
|
|
4141
|
+
console.error(`
|
|
4142
|
+
Deploy failed (HTTP ${status}):`);
|
|
4143
|
+
for (const line of schemaLines) console.error(` ${line}`);
|
|
4144
|
+
process.exit(1);
|
|
4145
|
+
}
|
|
1312
4146
|
console.error(`
|
|
1313
|
-
Deploy failed${status ? ` (HTTP ${status})` : ""}: ${
|
|
4147
|
+
Deploy failed${status ? ` (HTTP ${status})` : ""}: ${message2}`);
|
|
1314
4148
|
if (status === 409) {
|
|
1315
4149
|
console.error(" This version already exists. Bump the version in lumio.config.json.");
|
|
1316
4150
|
} else if (status === 413) {
|
|
@@ -1324,8 +4158,8 @@ function registerDeploy(program2) {
|
|
|
1324
4158
|
}
|
|
1325
4159
|
|
|
1326
4160
|
// src/commands/status.ts
|
|
1327
|
-
import * as
|
|
1328
|
-
import * as
|
|
4161
|
+
import * as fs13 from "fs";
|
|
4162
|
+
import * as path14 from "path";
|
|
1329
4163
|
function registerStatus(program2) {
|
|
1330
4164
|
program2.command("status").description("Show review status of the latest version").action(async () => {
|
|
1331
4165
|
const store = new CredentialStore();
|
|
@@ -1334,12 +4168,12 @@ function registerStatus(program2) {
|
|
|
1334
4168
|
console.error("Not logged in. Run `lumio login` first.");
|
|
1335
4169
|
process.exit(1);
|
|
1336
4170
|
}
|
|
1337
|
-
const configPath =
|
|
1338
|
-
if (!
|
|
4171
|
+
const configPath = path14.resolve("lumio.config.json");
|
|
4172
|
+
if (!fs13.existsSync(configPath)) {
|
|
1339
4173
|
console.error("lumio.config.json not found.");
|
|
1340
4174
|
process.exit(1);
|
|
1341
4175
|
}
|
|
1342
|
-
const config = parseConfigFile(
|
|
4176
|
+
const config = parseConfigFile(fs13.readFileSync(configPath, "utf-8"));
|
|
1343
4177
|
const client = new LumioApiClient(store.getApiUrl(), token);
|
|
1344
4178
|
try {
|
|
1345
4179
|
const versions = await client.get(
|
|
@@ -1368,8 +4202,8 @@ function registerStatus(program2) {
|
|
|
1368
4202
|
}
|
|
1369
4203
|
|
|
1370
4204
|
// src/commands/logs.ts
|
|
1371
|
-
import * as
|
|
1372
|
-
import * as
|
|
4205
|
+
import * as fs14 from "fs";
|
|
4206
|
+
import * as path15 from "path";
|
|
1373
4207
|
function registerLogs(program2) {
|
|
1374
4208
|
program2.command("logs").description("Stream server function logs via SSE").option("-f, --follow", "Follow log output", true).option("-n, --lines <count>", "Number of recent lines", "50").action(async (options) => {
|
|
1375
4209
|
const store = new CredentialStore();
|
|
@@ -1378,12 +4212,12 @@ function registerLogs(program2) {
|
|
|
1378
4212
|
console.error("Not logged in. Run `lumio login` first.");
|
|
1379
4213
|
process.exit(1);
|
|
1380
4214
|
}
|
|
1381
|
-
const configPath =
|
|
1382
|
-
if (!
|
|
4215
|
+
const configPath = path15.resolve("lumio.config.json");
|
|
4216
|
+
if (!fs14.existsSync(configPath)) {
|
|
1383
4217
|
console.error("lumio.config.json not found.");
|
|
1384
4218
|
process.exit(1);
|
|
1385
4219
|
}
|
|
1386
|
-
const config = parseConfigFile(
|
|
4220
|
+
const config = parseConfigFile(fs14.readFileSync(configPath, "utf-8"));
|
|
1387
4221
|
const apiUrl = store.getApiUrl();
|
|
1388
4222
|
const logsUrl = `${apiUrl}/v1/developer/extensions/${config.extensionId}/logs?lines=${options.lines}`;
|
|
1389
4223
|
console.log(`Streaming logs for ${config.name}...`);
|
|
@@ -1420,22 +4254,31 @@ function registerLogs(program2) {
|
|
|
1420
4254
|
}
|
|
1421
4255
|
|
|
1422
4256
|
// src/commands/env.ts
|
|
1423
|
-
import * as
|
|
1424
|
-
import * as
|
|
4257
|
+
import * as fs15 from "fs";
|
|
4258
|
+
import * as path16 from "path";
|
|
4259
|
+
var MAX_SECRET_KEY_LEN = 64;
|
|
4260
|
+
var MAX_SECRET_VALUE_BYTES = 4096;
|
|
1425
4261
|
function validateSecretKey(key) {
|
|
1426
|
-
return
|
|
4262
|
+
return /^[A-Z0-9_]{1,64}$/.test(key);
|
|
4263
|
+
}
|
|
4264
|
+
function validateSecretValue(value) {
|
|
4265
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
4266
|
+
return bytes > 0 && bytes <= MAX_SECRET_VALUE_BYTES;
|
|
4267
|
+
}
|
|
4268
|
+
function secretEndpoint(extensionId, key) {
|
|
4269
|
+
return `/v1/developer/extensions/${extensionId}/secrets/${encodeURIComponent(key)}`;
|
|
1427
4270
|
}
|
|
1428
4271
|
function formatSecretList(secrets) {
|
|
1429
4272
|
if (secrets.length === 0) return "No secrets configured.";
|
|
1430
4273
|
return secrets.map((s) => ` ${s.key} (created ${s.created_at})`).join("\n");
|
|
1431
4274
|
}
|
|
1432
4275
|
function loadConfig() {
|
|
1433
|
-
const configPath =
|
|
1434
|
-
if (!
|
|
4276
|
+
const configPath = path16.resolve("lumio.config.json");
|
|
4277
|
+
if (!fs15.existsSync(configPath)) {
|
|
1435
4278
|
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
1436
4279
|
process.exit(1);
|
|
1437
4280
|
}
|
|
1438
|
-
return parseConfigFile(
|
|
4281
|
+
return parseConfigFile(fs15.readFileSync(configPath, "utf-8"));
|
|
1439
4282
|
}
|
|
1440
4283
|
function requireClient() {
|
|
1441
4284
|
const store = new CredentialStore();
|
|
@@ -1448,16 +4291,31 @@ function requireClient() {
|
|
|
1448
4291
|
}
|
|
1449
4292
|
function registerEnv(program2) {
|
|
1450
4293
|
const envCmd = program2.command("env").description("Manage server-side secrets for the extension");
|
|
1451
|
-
envCmd.command("set <key>
|
|
4294
|
+
envCmd.command("set <key> [value]").description("Set a secret (PUT /v1/developer/extensions/{id}/secrets/{key}). Omit <value> to be prompted (hidden) or read it from the LUMIO_SECRET env var \u2014 avoids leaking the secret into shell history / the process list.").action(async (key, value) => {
|
|
1452
4295
|
if (!validateSecretKey(key)) {
|
|
1453
4296
|
console.error(
|
|
1454
|
-
|
|
4297
|
+
`Secret key must match ^[A-Z0-9_]{1,${MAX_SECRET_KEY_LEN}}$ (uppercase letters, digits, underscores; e.g. API_KEY).`
|
|
1455
4298
|
);
|
|
1456
4299
|
process.exit(1);
|
|
1457
4300
|
}
|
|
4301
|
+
let secretValue = value ?? process.env.LUMIO_SECRET;
|
|
4302
|
+
if (secretValue === void 0) {
|
|
4303
|
+
const { default: inquirer } = await import("inquirer");
|
|
4304
|
+
const answer = await inquirer.prompt([{
|
|
4305
|
+
type: "password",
|
|
4306
|
+
name: "value",
|
|
4307
|
+
mask: "*",
|
|
4308
|
+
message: `Value for ${key}:`
|
|
4309
|
+
}]);
|
|
4310
|
+
secretValue = answer.value;
|
|
4311
|
+
}
|
|
4312
|
+
if (!validateSecretValue(secretValue)) {
|
|
4313
|
+
console.error(`Secret value must be between 1 and ${MAX_SECRET_VALUE_BYTES} bytes.`);
|
|
4314
|
+
process.exit(1);
|
|
4315
|
+
}
|
|
1458
4316
|
const config = loadConfig();
|
|
1459
4317
|
const client = requireClient();
|
|
1460
|
-
await client.
|
|
4318
|
+
await client.put(secretEndpoint(config.extensionId, key), { value: secretValue });
|
|
1461
4319
|
console.log(`Secret ${key} set.`);
|
|
1462
4320
|
});
|
|
1463
4321
|
envCmd.command("list").description("List all secrets (keys only, no values)").action(async () => {
|
|
@@ -1471,14 +4329,14 @@ function registerEnv(program2) {
|
|
|
1471
4329
|
envCmd.command("delete <key>").description("Delete a secret").action(async (key) => {
|
|
1472
4330
|
const config = loadConfig();
|
|
1473
4331
|
const client = requireClient();
|
|
1474
|
-
await client.delete(
|
|
4332
|
+
await client.delete(secretEndpoint(config.extensionId, key));
|
|
1475
4333
|
console.log(`Secret ${key} deleted.`);
|
|
1476
4334
|
});
|
|
1477
4335
|
}
|
|
1478
4336
|
|
|
1479
4337
|
// src/commands/run.ts
|
|
1480
|
-
import * as
|
|
1481
|
-
import * as
|
|
4338
|
+
import * as fs16 from "fs";
|
|
4339
|
+
import * as path17 from "path";
|
|
1482
4340
|
var VALID_FUNCTION_TYPES = [
|
|
1483
4341
|
"query",
|
|
1484
4342
|
"mutation",
|
|
@@ -1515,12 +4373,12 @@ function parseFunctionRef(ref) {
|
|
|
1515
4373
|
function registerRun(program2) {
|
|
1516
4374
|
program2.command("run <type:name> [args]").description("Execute a server function locally (e.g. lumio run query:getPlayers)").option("--user <name>", "Mock user name for bot handlers", "testuser").option("--role <role>", "Mock user role for bot handlers", "everyone").option("--message <text>", "Mock chat message for bot handlers").option("--platform <platform>", "Mock platform for bot handlers", "twitch").action(async (typeAndName, argsJson, options) => {
|
|
1517
4375
|
const fnRef = parseFunctionRef(typeAndName);
|
|
1518
|
-
const configPath =
|
|
1519
|
-
if (!
|
|
4376
|
+
const configPath = path17.resolve("lumio.config.json");
|
|
4377
|
+
if (!fs16.existsSync(configPath)) {
|
|
1520
4378
|
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
1521
4379
|
process.exit(1);
|
|
1522
4380
|
}
|
|
1523
|
-
const config = parseConfigFile(
|
|
4381
|
+
const config = parseConfigFile(fs16.readFileSync(configPath, "utf-8"));
|
|
1524
4382
|
const store = new CredentialStore();
|
|
1525
4383
|
const token = store.getToken();
|
|
1526
4384
|
if (!token) {
|
|
@@ -1528,7 +4386,17 @@ function registerRun(program2) {
|
|
|
1528
4386
|
process.exit(1);
|
|
1529
4387
|
}
|
|
1530
4388
|
const client = new LumioApiClient(store.getApiUrl(), token);
|
|
1531
|
-
|
|
4389
|
+
let args = {};
|
|
4390
|
+
if (argsJson) {
|
|
4391
|
+
try {
|
|
4392
|
+
args = JSON.parse(argsJson);
|
|
4393
|
+
} catch (err) {
|
|
4394
|
+
console.error(
|
|
4395
|
+
`Invalid JSON for args: ${err instanceof Error ? err.message : String(err)}`
|
|
4396
|
+
);
|
|
4397
|
+
process.exit(1);
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
1532
4400
|
const body = { type: fnRef.type, args };
|
|
1533
4401
|
if (BOT_HANDLER_TYPES.includes(fnRef.type)) {
|
|
1534
4402
|
body.user = {
|
|
@@ -1547,7 +4415,7 @@ function registerRun(program2) {
|
|
|
1547
4415
|
}
|
|
1548
4416
|
try {
|
|
1549
4417
|
const result = await client.post(
|
|
1550
|
-
`/v1/extension-installs/${config.extensionId}/execute/${fnRef.name}`,
|
|
4418
|
+
`/v1/extension-installs/${config.extensionId}/execute/${encodeURIComponent(fnRef.name)}`,
|
|
1551
4419
|
body
|
|
1552
4420
|
);
|
|
1553
4421
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -1559,16 +4427,16 @@ function registerRun(program2) {
|
|
|
1559
4427
|
}
|
|
1560
4428
|
|
|
1561
4429
|
// src/commands/dashboard.ts
|
|
1562
|
-
import * as
|
|
1563
|
-
import * as
|
|
4430
|
+
import * as fs17 from "fs";
|
|
4431
|
+
import * as path18 from "path";
|
|
1564
4432
|
function registerDashboard(program2) {
|
|
1565
4433
|
program2.command("dashboard").description("Open the extension dashboard in browser").action(async () => {
|
|
1566
|
-
const configPath =
|
|
1567
|
-
if (!
|
|
4434
|
+
const configPath = path18.resolve("lumio.config.json");
|
|
4435
|
+
if (!fs17.existsSync(configPath)) {
|
|
1568
4436
|
console.error("lumio.config.json not found. Run `lumio init` first.");
|
|
1569
4437
|
process.exit(1);
|
|
1570
4438
|
}
|
|
1571
|
-
const config = parseConfigFile(
|
|
4439
|
+
const config = parseConfigFile(fs17.readFileSync(configPath, "utf-8"));
|
|
1572
4440
|
const baseUrl = process.env.LUMIO_WEB_URL ?? "https://lumio.vision";
|
|
1573
4441
|
const url = `${baseUrl}/dashboard/extensions/${config.extensionId}`;
|
|
1574
4442
|
console.log(`Opening ${url}...`);
|
|
@@ -1579,15 +4447,18 @@ function registerDashboard(program2) {
|
|
|
1579
4447
|
|
|
1580
4448
|
// src/program.ts
|
|
1581
4449
|
function createProgram() {
|
|
1582
|
-
const program2 = new
|
|
4450
|
+
const program2 = new Command2();
|
|
1583
4451
|
program2.name("lumio");
|
|
1584
4452
|
program2.description("Lumio Extension CLI -- Build, test, and deploy Lumio extensions");
|
|
1585
|
-
|
|
4453
|
+
const require2 = createRequire3(import.meta.url);
|
|
4454
|
+
const { version } = require2("../package.json");
|
|
4455
|
+
program2.version(version);
|
|
1586
4456
|
registerLogin(program2);
|
|
1587
4457
|
registerLogout(program2);
|
|
1588
4458
|
registerWhoami(program2);
|
|
1589
4459
|
registerInit(program2);
|
|
1590
4460
|
registerDev(program2);
|
|
4461
|
+
registerTrigger(program2);
|
|
1591
4462
|
registerBuild(program2);
|
|
1592
4463
|
registerDeploy(program2);
|
|
1593
4464
|
registerStatus(program2);
|