create-opentray 0.19.0 → 0.20.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.
Files changed (49) hide show
  1. package/README.md +136 -133
  2. package/assets/create-opentray-logo.png +0 -0
  3. package/dist/bin-sZB5nWq0.mjs +2142 -0
  4. package/dist/bin-sZB5nWq0.mjs.map +1 -0
  5. package/dist/bin.d.mts +15 -2
  6. package/dist/bin.d.mts.map +1 -0
  7. package/dist/bin.mjs +1 -1
  8. package/dist/index.d.mts +62 -351
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/shell/assets/browse.js +1 -1
  12. package/dist/shell/assets/index.css +1 -1
  13. package/dist/shell/assets/index.js +7 -7
  14. package/dist/shell/assets/input.js +1 -1
  15. package/dist/shell/assets/main.js +21 -43
  16. package/dist/shell/assets/terminal-pane.js +1 -1
  17. package/dist/shell/browse.html +3 -0
  18. package/dist/shell/favicon-16.png +0 -0
  19. package/dist/shell/favicon-32.png +0 -0
  20. package/dist/shell/favicon-64.png +0 -0
  21. package/dist/shell/index.html +3 -0
  22. package/dist/shell/logo.png +0 -0
  23. package/dist/shell/terminal.html +3 -0
  24. package/dist/skill/SKILL.md +156 -0
  25. package/dist/skill/references/cli-reference.md +77 -0
  26. package/dist/skill/references/how-it-works.md +49 -0
  27. package/dist/webui/assets/browse.js +1 -1
  28. package/dist/webui/assets/index.css +1 -1
  29. package/dist/webui/assets/index.js +7 -7
  30. package/dist/webui/assets/input.js +1 -1
  31. package/dist/webui/assets/main.js +21 -43
  32. package/dist/webui/assets/terminal-pane.js +1 -1
  33. package/dist/webui/browse.html +3 -0
  34. package/dist/webui/favicon-16.png +0 -0
  35. package/dist/webui/favicon-32.png +0 -0
  36. package/dist/webui/favicon-64.png +0 -0
  37. package/dist/webui/index.html +3 -0
  38. package/dist/webui/logo.png +0 -0
  39. package/dist/webui/terminal.html +3 -0
  40. package/package.json +12 -6
  41. package/skill/SKILL.md +156 -0
  42. package/skill/references/cli-reference.md +77 -0
  43. package/skill/references/how-it-works.md +49 -0
  44. package/dist/bin-BT_kcLuP.mjs +0 -3823
  45. package/dist/bin-BT_kcLuP.mjs.map +0 -1
  46. package/dist/bin-CeFW1wxR.d.mts +0 -15
  47. package/dist/bin-CeFW1wxR.d.mts.map +0 -1
  48. package/dist/icon-codec-BQ3cGV1n.mjs +0 -13
  49. package/dist/icon-codec-BQ3cGV1n.mjs.map +0 -1
@@ -0,0 +1,2142 @@
1
+ import { createRequire } from "node:module";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { access, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
6
+ import { autoBackground, buildExportPlan, buildScriptExport, collectProcessTreePids, composeAppIcon, compositionCacheKey, createPortDiscovery, deriveDefaultAppId, deriveDefaultAppName, detectImageFormat, detectPackageManager, ensureLoopbackNoProxy, findCreateEntry, foregroundStats, formatPosixCommandLine, listCreateEntries, listListeningPorts, materialize, openMaterializedApp, pinningHint, readResourceBytes, readWizardProjectIcon, resolveLaunchVector, scrapeService, startCommandRun, stopRunningApp, toProjectDirectoryName, tokenizeCommandLine, uninstallApp, uninstallWizardProject } from "@create-opentray/core";
7
+ import { createServer } from "node:http";
8
+ import { fileURLToPath } from "node:url";
9
+ import { spawn } from "node:child_process";
10
+ import { realpathSync } from "node:fs";
11
+ import { consoleStreams, dispatchCli } from "@create-opentray/cli";
12
+ //#region src/wizard.ts
13
+ const DEFAULT_ICON_SCALE = .8;
14
+ const DEFAULT_COMMAND_OPTIONS = {
15
+ cwd: "",
16
+ env: [],
17
+ argsMode: "string"
18
+ };
19
+ const createWizardSession = (options) => {
20
+ let state = "idle";
21
+ let services = [];
22
+ let selectedPort;
23
+ let run;
24
+ let discovery = createPortDiscovery({
25
+ baseline: /* @__PURE__ */ new Set(),
26
+ ...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
27
+ ...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp }
28
+ });
29
+ let discoveryTimer;
30
+ let scrapeTimer;
31
+ let scraping = false;
32
+ let tempIconDir;
33
+ let currentIconPath;
34
+ let currentIconUrl;
35
+ let currentTrayIconPath;
36
+ let currentTrayIconUrl;
37
+ let trayIconIsSolid = false;
38
+ let iconCandidates = [];
39
+ let iconPort;
40
+ let currentTokens = [];
41
+ let currentCommand = "";
42
+ let scrapedTitle;
43
+ let interactive = true;
44
+ let resolvedVector;
45
+ let frozenForm;
46
+ let resolvedServicePort;
47
+ let resolvedTargetDir;
48
+ let frozenIconPath;
49
+ let frozenIconUrl;
50
+ let submitting = false;
51
+ let runAlive = false;
52
+ let commandOptions = { ...DEFAULT_COMMAND_OPTIONS };
53
+ let composeIconDir;
54
+ const iconCompositions = /* @__PURE__ */ new Map();
55
+ let iconBackground;
56
+ let iconScale;
57
+ const touched = {
58
+ appId: false,
59
+ appName: false,
60
+ iconPath: false,
61
+ iconBackground: false,
62
+ iconScale: false,
63
+ trayIconPath: false,
64
+ pm: false,
65
+ force: false,
66
+ showStartupTerminal: false,
67
+ showAddressBar: false,
68
+ imageSmoothingEnabled: false,
69
+ developerMode: false
70
+ };
71
+ let form = {
72
+ appId: "",
73
+ appName: "",
74
+ iconPath: "",
75
+ iconBackground: "transparent",
76
+ iconScale: DEFAULT_ICON_SCALE,
77
+ trayIconPath: "",
78
+ force: options.force === true,
79
+ showStartupTerminal: false,
80
+ showAddressBar: false,
81
+ imageSmoothingEnabled: true,
82
+ developerMode: false,
83
+ pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
84
+ };
85
+ let result;
86
+ const emit = options.emit;
87
+ const setState = (next, reason) => {
88
+ state = next;
89
+ emit({
90
+ type: "state",
91
+ state: next,
92
+ ...reason === void 0 ? {} : { reason }
93
+ });
94
+ };
95
+ const stopTimers = () => {
96
+ if (discoveryTimer !== void 0) {
97
+ clearInterval(discoveryTimer);
98
+ discoveryTimer = void 0;
99
+ }
100
+ if (scrapeTimer !== void 0) {
101
+ clearInterval(scrapeTimer);
102
+ scrapeTimer = void 0;
103
+ }
104
+ };
105
+ const publishServices = () => {
106
+ emit({
107
+ type: "services",
108
+ services: [...services],
109
+ selectedPort
110
+ });
111
+ };
112
+ /**
113
+ * Placeholder defaults: scrapes and derivations update the *suggestions*,
114
+ * never the user's values. Empty values mean "use the default".
115
+ */
116
+ const currentDefaults = () => {
117
+ const effectiveAppId = form.appId.trim().length > 0 ? form.appId : deriveDefaultAppId(currentTokens);
118
+ return {
119
+ appId: deriveDefaultAppId(currentTokens),
120
+ appName: scrapedTitle ?? deriveDefaultAppName(currentTokens),
121
+ iconPath: currentIconPath ?? "",
122
+ targetDir: options.targetDir ?? join(homeDir, ".opentray", "create", toProjectDirectoryName(effectiveAppId))
123
+ };
124
+ };
125
+ const publishForm = () => {
126
+ emit({
127
+ type: "form",
128
+ values: form,
129
+ defaults: currentDefaults(),
130
+ targetDirExists
131
+ });
132
+ };
133
+ /** Track whether the resolved target directory is already occupied so the
134
+ * UI can warn and offer the force toggle. */
135
+ let targetDirExists = false;
136
+ let targetDirProbe = 0;
137
+ const refreshTargetDirExists = () => {
138
+ const probe = ++targetDirProbe;
139
+ const target = currentDefaults().targetDir;
140
+ (async () => {
141
+ let occupied = false;
142
+ try {
143
+ occupied = (await stat(target)).isDirectory();
144
+ } catch {
145
+ occupied = false;
146
+ }
147
+ if (probe !== targetDirProbe) return;
148
+ if (occupied !== targetDirExists) {
149
+ targetDirExists = occupied;
150
+ publishForm();
151
+ }
152
+ })();
153
+ };
154
+ const scrapeOnce = async () => {
155
+ if (scraping || selectedPort === void 0 || state === "frozen" || state === "materializing") return;
156
+ scraping = true;
157
+ try {
158
+ const port = selectedPort;
159
+ const scraped = await (options.scrape ?? scrapeService)(port, tempIconDir === void 0 ? {} : { tempDir: tempIconDir });
160
+ if (selectedPort !== port) return;
161
+ const stateNow = state;
162
+ if (stateNow === "frozen" || stateNow === "materializing" || stateNow === "success") return;
163
+ iconCandidates = scraped.icons;
164
+ iconPort = port;
165
+ currentIconPath = scraped.icons[0]?.path;
166
+ currentIconUrl = scraped.icons[0]?.url;
167
+ if (scraped.title !== void 0) scrapedTitle = scraped.title;
168
+ emit({
169
+ type: "icons",
170
+ port,
171
+ icons: scraped.icons
172
+ });
173
+ emit({
174
+ type: "scrape",
175
+ port,
176
+ ...scraped.title === void 0 ? {} : { title: scraped.title },
177
+ hasIcon: scraped.icons.length > 0
178
+ });
179
+ publishForm();
180
+ } finally {
181
+ scraping = false;
182
+ }
183
+ };
184
+ const startScrapePolling = () => {
185
+ if (scrapeTimer !== void 0) clearInterval(scrapeTimer);
186
+ scrapeOnce();
187
+ scrapeTimer = setInterval(() => {
188
+ scrapeOnce();
189
+ }, options.scrapeIntervalMs ?? 1500);
190
+ };
191
+ const selectDefaultService = () => {
192
+ if (selectedPort === void 0 && services.length > 0) selectedPort = services[0]?.port;
193
+ if (selectedPort !== void 0 && state === "running") {
194
+ setState("discovered");
195
+ startScrapePolling();
196
+ }
197
+ };
198
+ const startDiscoveryPolling = () => {
199
+ if (discoveryTimer !== void 0) clearInterval(discoveryTimer);
200
+ (async () => {
201
+ const found = await discovery.poll();
202
+ if (found.length > 0) {
203
+ const known = new Set(services.map((service) => service.port));
204
+ services = [...services, ...found.filter((s) => !known.has(s.port))];
205
+ selectDefaultService();
206
+ publishServices();
207
+ }
208
+ })();
209
+ discoveryTimer = setInterval(() => {
210
+ (async () => {
211
+ const found = await discovery.poll();
212
+ if (found.length > 0) {
213
+ const known = new Set(services.map((service) => service.port));
214
+ services = [...services, ...found.filter((s) => !known.has(s.port))];
215
+ selectDefaultService();
216
+ publishServices();
217
+ }
218
+ })();
219
+ }, options.pollIntervalMs ?? 1e3);
220
+ };
221
+ /** Default command cwd is the USER_HOME directory (owner round-10 law):
222
+ * empty input means home, and relative paths resolve against home. */
223
+ const homeDir = options.homeDir ?? homedir();
224
+ const effectiveCwd = () => {
225
+ const custom = commandOptions.cwd.trim();
226
+ return custom.length > 0 ? resolve(homeDir, custom) : homeDir;
227
+ };
228
+ /** Build the env overlay from configured entries (empty keys skipped). */
229
+ const commandEnv = () => {
230
+ const env = {};
231
+ for (const entry of commandOptions.env) if (entry.key.trim().length > 0) env[entry.key.trim()] = entry.value;
232
+ return env;
233
+ };
234
+ const publishCommandOptions = () => {
235
+ emit({
236
+ type: "command-options",
237
+ options: commandOptions,
238
+ defaultCwd: homeDir
239
+ });
240
+ };
241
+ /** Stable dir for composed preview assets, created on demand. */
242
+ const ensureIconComposeDir = async () => {
243
+ if (composeIconDir === void 0) composeIconDir = await mkdtemp(join(tmpdir(), "create-opentray-compose-"));
244
+ return composeIconDir;
245
+ };
246
+ const session = {
247
+ get state() {
248
+ return state;
249
+ },
250
+ snapshot() {
251
+ return {
252
+ type: "snapshot",
253
+ state,
254
+ runAlive,
255
+ command: currentCommand,
256
+ commandOptions: {
257
+ ...commandOptions,
258
+ env: [...commandOptions.env]
259
+ },
260
+ form: { ...form },
261
+ defaults: currentDefaults(),
262
+ targetDirExists,
263
+ services: [...services],
264
+ ...selectedPort === void 0 ? {} : { selectedPort },
265
+ iconCandidates: [...iconCandidates],
266
+ ...iconPort === void 0 ? {} : { iconsPort: iconPort },
267
+ interactive
268
+ };
269
+ },
270
+ get services() {
271
+ return services;
272
+ },
273
+ get selectedPort() {
274
+ return selectedPort;
275
+ },
276
+ get runAlive() {
277
+ return runAlive;
278
+ },
279
+ get iconCandidates() {
280
+ return iconCandidates;
281
+ },
282
+ iconCandidate(port, index) {
283
+ if (iconPort !== port) return;
284
+ return iconCandidates.find((icon) => icon.index === index);
285
+ },
286
+ replaceIconCandidates(port, icons) {
287
+ iconPort = port;
288
+ iconCandidates = icons;
289
+ },
290
+ selectTrayIconCandidate(port, index) {
291
+ if (state === "frozen" || state === "materializing" || state === "success") return false;
292
+ const candidate = session.iconCandidate(port, index);
293
+ if (candidate === void 0) return false;
294
+ touched.trayIconPath = true;
295
+ currentTrayIconPath = candidate.path;
296
+ currentTrayIconUrl = candidate.url;
297
+ trayIconIsSolid = candidate.variant !== "original";
298
+ form = {
299
+ ...form,
300
+ trayIconPath: candidate.path
301
+ };
302
+ publishForm();
303
+ return true;
304
+ },
305
+ selectIconCandidate(port, index) {
306
+ if (state === "frozen" || state === "materializing" || state === "success") return false;
307
+ const candidate = session.iconCandidate(port, index);
308
+ if (candidate === void 0) return false;
309
+ touched.iconPath = true;
310
+ currentIconPath = candidate.path;
311
+ currentIconUrl = candidate.url;
312
+ if (!touched.trayIconPath) {
313
+ currentTrayIconPath = candidate.path;
314
+ currentTrayIconUrl = candidate.url;
315
+ trayIconIsSolid = candidate.variant !== "original";
316
+ form = {
317
+ ...form,
318
+ iconPath: candidate.path,
319
+ trayIconPath: candidate.path
320
+ };
321
+ } else form = {
322
+ ...form,
323
+ iconPath: candidate.path
324
+ };
325
+ publishForm();
326
+ return true;
327
+ },
328
+ get form() {
329
+ return form;
330
+ },
331
+ get result() {
332
+ return result;
333
+ },
334
+ async submitCommand(command) {
335
+ if (state !== "idle" && state !== "failed" && state !== "running" && state !== "discovered") throw new Error(`cannot submit a command while ${state}`);
336
+ if (submitting) throw new Error("a command submission is already in flight");
337
+ submitting = true;
338
+ const tokens = typeof command === "string" ? void 0 : command;
339
+ let tokenized;
340
+ if (tokens === void 0) {
341
+ tokenized = tokenizeCommandLine(command);
342
+ if (!tokenized.ok) {
343
+ submitting = false;
344
+ setState("failed", tokenized.error);
345
+ return;
346
+ }
347
+ } else if (tokens.length === 0 || tokens[0].trim().length === 0) {
348
+ submitting = false;
349
+ setState("failed", "数组模式至少需要程序元素(第一个参数)");
350
+ return;
351
+ }
352
+ await session.stop();
353
+ runAlive = false;
354
+ services = [];
355
+ selectedPort = void 0;
356
+ currentTokens = tokens ?? tokenized.tokens;
357
+ refreshTargetDirExists();
358
+ currentIconPath = void 0;
359
+ currentIconUrl = void 0;
360
+ currentTrayIconPath = void 0;
361
+ currentTrayIconUrl = void 0;
362
+ trayIconIsSolid = false;
363
+ iconCandidates = [];
364
+ touched.appId = false;
365
+ touched.appName = false;
366
+ touched.pm = false;
367
+ touched.trayIconPath = false;
368
+ form = {
369
+ appId: "",
370
+ appName: "",
371
+ iconPath: "",
372
+ iconBackground: iconBackground ?? "transparent",
373
+ iconScale: iconScale ?? .8,
374
+ ...touched.force ? { force: form.force } : { force: options.force === true },
375
+ ...touched.trayIconPath ? { trayIconPath: form.trayIconPath } : { trayIconPath: "" },
376
+ ...touched.showStartupTerminal ? { showStartupTerminal: form.showStartupTerminal } : { showStartupTerminal: false },
377
+ ...touched.showAddressBar ? { showAddressBar: form.showAddressBar } : { showAddressBar: false },
378
+ ...touched.imageSmoothingEnabled ? { imageSmoothingEnabled: form.imageSmoothingEnabled } : { imageSmoothingEnabled: true },
379
+ ...touched.developerMode ? { developerMode: form.developerMode } : { developerMode: false },
380
+ pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
381
+ };
382
+ currentCommand = typeof command === "string" ? command : command.join(" ");
383
+ scrapedTitle = void 0;
384
+ emit({
385
+ type: "command-display",
386
+ command: currentCommand
387
+ });
388
+ tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"));
389
+ const baseline = await (options.listListeners ?? (() => listListeningPorts(process.platform)))().catch(() => /* @__PURE__ */ new Set());
390
+ setState("running");
391
+ publishForm();
392
+ const spawnRun = options.spawnRun ?? startCommandRun;
393
+ const envOverlay = commandEnv();
394
+ run = await spawnRun({
395
+ tokens: currentTokens,
396
+ cwd: effectiveCwd(),
397
+ ...Object.keys(envOverlay).length === 0 ? {} : { env: envOverlay },
398
+ onEvent: (event) => {
399
+ if (event.type === "stdout" || event.type === "stderr") {
400
+ emit({
401
+ type: "log",
402
+ stream: event.type,
403
+ chunk: event.chunk ?? ""
404
+ });
405
+ return;
406
+ }
407
+ if (event.type === "pty-ready") {
408
+ interactive = true;
409
+ emit({
410
+ type: "term-mode",
411
+ interactive: true
412
+ });
413
+ return;
414
+ }
415
+ if (event.type === "pty-unavailable") {
416
+ interactive = false;
417
+ emit({
418
+ type: "term-mode",
419
+ interactive: false,
420
+ ...event.message === void 0 ? {} : { message: event.message }
421
+ });
422
+ return;
423
+ }
424
+ if (event.type === "spawn-error") {
425
+ stopTimers();
426
+ setState("failed", event.message ?? "spawn failed");
427
+ return;
428
+ }
429
+ if (event.type === "exit") {
430
+ runAlive = false;
431
+ stopTimers();
432
+ emit({
433
+ type: "run-status",
434
+ running: false,
435
+ ...event.code === void 0 ? {} : { code: event.code }
436
+ });
437
+ if (state === "running" && services.length === 0) setState("failed", `command exited with ${event.code ?? "signal"} before any service appeared`);
438
+ }
439
+ }
440
+ });
441
+ submitting = false;
442
+ run.exited.then(async ({ code, spawnError }) => {
443
+ if (state === "running" && services.length === 0 && spawnError !== void 0) {
444
+ stopTimers();
445
+ setState("failed", spawnError);
446
+ }
447
+ });
448
+ discovery = createPortDiscovery({
449
+ baseline,
450
+ ...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
451
+ ...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp },
452
+ ...options.listPortOwners === void 0 ? {} : { listOwners: options.listPortOwners },
453
+ ...run.pid === void 0 ? {} : { resolveOwnerPids: () => collectProcessTreePids(run?.pid ?? 0, options.platform ?? process.platform) }
454
+ });
455
+ runAlive = true;
456
+ emit({
457
+ type: "run-status",
458
+ running: true
459
+ });
460
+ startDiscoveryPolling();
461
+ },
462
+ prime(command) {
463
+ if (state === "frozen" || state === "materializing" || state === "success") return;
464
+ if (typeof command !== "string") {
465
+ if (command.length === 0 || command[0].trim().length === 0) return;
466
+ currentTokens = command;
467
+ currentCommand = command.join(" ");
468
+ emit({
469
+ type: "command-display",
470
+ command: currentCommand
471
+ });
472
+ refreshTargetDirExists();
473
+ publishForm();
474
+ return;
475
+ }
476
+ const tokenized = tokenizeCommandLine(command);
477
+ if (!tokenized.ok) return;
478
+ currentTokens = tokenized.tokens;
479
+ currentCommand = command;
480
+ emit({
481
+ type: "command-display",
482
+ command
483
+ });
484
+ refreshTargetDirExists();
485
+ publishForm();
486
+ },
487
+ get commandOptions() {
488
+ return commandOptions;
489
+ },
490
+ updateCommandOptions(patch) {
491
+ if (state === "frozen" || state === "materializing" || state === "success") return;
492
+ commandOptions = {
493
+ ...commandOptions,
494
+ ...patch
495
+ };
496
+ publishCommandOptions();
497
+ },
498
+ async analyzeIconForeground(foregroundPath) {
499
+ const stats = await foregroundStats(foregroundPath).catch(() => ({
500
+ luminance: void 0,
501
+ coverage: 0
502
+ }));
503
+ return {
504
+ ...stats,
505
+ suggested: autoBackground(stats)
506
+ };
507
+ },
508
+ async composeIcon(options) {
509
+ if (state === "frozen" || state === "materializing" || state === "success") throw new Error("cannot compose while frozen");
510
+ const background = options.background ?? iconBackground ?? "transparent";
511
+ const scale = options.scale ?? iconScale ?? .8;
512
+ const composed = await composeAppIcon({
513
+ foregroundPath: options.foregroundPath,
514
+ background,
515
+ scale,
516
+ outputDir: await ensureIconComposeDir()
517
+ });
518
+ const composition = {
519
+ key: compositionCacheKey({
520
+ foregroundPath: options.foregroundPath,
521
+ background,
522
+ scale
523
+ }),
524
+ ...composed
525
+ };
526
+ iconCompositions.set(composition.key, composition);
527
+ return composition;
528
+ },
529
+ trackIconComposition(composition) {
530
+ iconCompositions.set(composition.key, composition);
531
+ },
532
+ iconSourceRoots() {
533
+ return [...tempIconDir !== void 0 ? [tempIconDir] : [], ...composeIconDir !== void 0 ? [composeIconDir] : []];
534
+ },
535
+ iconComposition(key) {
536
+ return iconCompositions.get(key);
537
+ },
538
+ async saveIconUpload(bytes) {
539
+ const path = join(tempIconDir ?? (tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"))), `upload-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}.bin`);
540
+ await writeFile(path, bytes);
541
+ return path;
542
+ },
543
+ selectService(port) {
544
+ if (state !== "discovered" && state !== "running") return;
545
+ if (!services.some((service) => service.port === port)) return;
546
+ selectedPort = port;
547
+ currentIconPath = void 0;
548
+ currentIconUrl = void 0;
549
+ publishServices();
550
+ if (state === "discovered") startScrapePolling();
551
+ },
552
+ updateForm(patch) {
553
+ if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") return;
554
+ if (patch.iconBackground !== void 0) iconBackground = patch.iconBackground;
555
+ if (patch.iconScale !== void 0) iconScale = patch.iconScale;
556
+ for (const key of Object.keys(patch)) if (patch[key] !== void 0 && patch[key] !== form[key]) {
557
+ touched[key] = true;
558
+ if (key === "trayIconPath") trayIconIsSolid = false;
559
+ }
560
+ form = {
561
+ ...form,
562
+ ...patch
563
+ };
564
+ publishForm();
565
+ },
566
+ /** Forward base64-encoded terminal keystroke bytes to the preview command. */
567
+ terminalInput(data) {
568
+ if (state !== "running" && state !== "discovered") return;
569
+ run?.write(data);
570
+ },
571
+ /** Forward terminal dimensions to the pseudo-terminal. */
572
+ terminalResize(size) {
573
+ if (state !== "running" && state !== "discovered") return;
574
+ run?.resize(size);
575
+ },
576
+ confirm() {
577
+ if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") throw new Error(`cannot confirm while ${state}`);
578
+ resolvedServicePort = selectedPort ?? 0;
579
+ resolvedTargetDir = currentDefaults().targetDir;
580
+ const defaults = currentDefaults();
581
+ let resolvedForm = {
582
+ ...form,
583
+ appId: form.appId.trim().length > 0 ? form.appId : defaults.appId,
584
+ appName: form.appName.trim().length > 0 ? form.appName : defaults.appName
585
+ };
586
+ if (resolvedForm.iconPath.trim().length > 0) {
587
+ currentIconPath = resolvedForm.iconPath.trim();
588
+ currentIconUrl = void 0;
589
+ }
590
+ frozenIconPath = currentIconPath;
591
+ frozenIconUrl = currentIconUrl;
592
+ const resolvedTrayIconPath = resolvedForm.trayIconPath.trim().length > 0 ? resolvedForm.trayIconPath.trim() : resolvedForm.iconPath.trim().length > 0 ? resolvedForm.iconPath.trim() : currentIconPath ?? "";
593
+ resolvedForm = {
594
+ ...resolvedForm,
595
+ trayIconPath: resolvedTrayIconPath
596
+ };
597
+ currentTrayIconPath = resolvedTrayIconPath;
598
+ form = resolvedForm;
599
+ stopTimers();
600
+ frozenForm = { ...form };
601
+ setState("frozen");
602
+ publishForm();
603
+ },
604
+ cancel() {
605
+ if (state !== "frozen") throw new Error(`cannot cancel while ${state}`);
606
+ frozenForm = void 0;
607
+ frozenIconPath = void 0;
608
+ frozenIconUrl = void 0;
609
+ resolvedServicePort = void 0;
610
+ resolvedTargetDir = void 0;
611
+ setState(selectedPort === void 0 ? "running" : "discovered");
612
+ publishForm();
613
+ if (runAlive && discovery !== void 0) startDiscoveryPolling();
614
+ },
615
+ async exportFrozen(exportOptions) {
616
+ const frozen = frozenForm;
617
+ if (frozen === void 0) return {
618
+ ok: false,
619
+ code: "state_error",
620
+ message: "share requires a confirmed (frozen) parameter set"
621
+ };
622
+ if (currentTokens.length === 0) return {
623
+ ok: false,
624
+ code: "state_error",
625
+ message: "no command recorded"
626
+ };
627
+ let vector;
628
+ try {
629
+ vector = await (options.resolveVector ?? resolveLaunchVector)({
630
+ tokens: currentTokens,
631
+ cwd: effectiveCwd()
632
+ });
633
+ } catch (error) {
634
+ return {
635
+ ok: false,
636
+ code: "resolve_failed",
637
+ message: `failed to resolve the launch vector: ${error instanceof Error ? error.message : String(error)}`
638
+ };
639
+ }
640
+ const envOverlay = commandEnv();
641
+ const env = Object.keys(envOverlay).length > 0 ? envOverlay : vector.env;
642
+ const embedded = [];
643
+ const iconRef = async (path, url, flag) => {
644
+ if (path === void 0 || path.trim().length === 0) return { mode: "none" };
645
+ const hasUrl = url !== void 0 && /^https?:\/\//u.test(url);
646
+ if (!(exportOptions.inlineIcon ?? !hasUrl)) {
647
+ if (hasUrl) return {
648
+ ref: {
649
+ path: "icon.png",
650
+ format: "png",
651
+ sha256: "",
652
+ source: {
653
+ kind: "http",
654
+ ref: url
655
+ }
656
+ },
657
+ mode: "url"
658
+ };
659
+ return {
660
+ ref: {
661
+ path: basename(path),
662
+ format: "png",
663
+ sha256: "",
664
+ source: {
665
+ kind: "file",
666
+ ref: path
667
+ }
668
+ },
669
+ mode: "local"
670
+ };
671
+ }
672
+ try {
673
+ const bytes = new Uint8Array(await readFile(path));
674
+ const format = detectImageFormat(bytes);
675
+ if (format === void 0) return { mode: "none" };
676
+ embedded.push({
677
+ flag,
678
+ filename: basename(path),
679
+ bytes
680
+ });
681
+ return {
682
+ ref: {
683
+ path: basename(path),
684
+ format,
685
+ sha256: createHash("sha256").update(bytes).digest("hex"),
686
+ source: {
687
+ kind: "file",
688
+ ref: path
689
+ }
690
+ },
691
+ mode: "embedded"
692
+ };
693
+ } catch {
694
+ return { mode: "none" };
695
+ }
696
+ };
697
+ const appIconSource = frozenIconPath ?? currentIconPath;
698
+ const appIconUrl = frozenIconUrl ?? currentIconUrl;
699
+ const trayIconSource = currentTrayIconPath;
700
+ const trayIconUrl = currentTrayIconUrl;
701
+ const appIconRef = await iconRef(appIconSource, appIconUrl, "app-icon");
702
+ const appIcon = appIconRef.ref;
703
+ const trayIcon = (trayIconSource !== void 0 && trayIconSource !== appIconSource ? await iconRef(trayIconSource, trayIconUrl, "tray-icon") : {
704
+ ref: void 0,
705
+ mode: "none"
706
+ }).ref;
707
+ const config = {
708
+ schemaVersion: 1,
709
+ appId: frozen.appId,
710
+ appName: frozen.appName,
711
+ command: {
712
+ executable: vector.command,
713
+ args: [...vector.args],
714
+ cwd: vector.cwd,
715
+ ...env === void 0 ? {} : { env }
716
+ },
717
+ packageManager: frozen.pm,
718
+ icons: {
719
+ imageSmoothingEnabled: frozen.imageSmoothingEnabled !== false,
720
+ background: frozen.iconBackground ?? "transparent",
721
+ scale: frozen.iconScale ?? .8,
722
+ ...trayIconIsSolid === true ? { trayTemplate: true } : {},
723
+ ...appIcon === void 0 ? {} : { appIcon },
724
+ ...trayIcon === void 0 ? {} : { trayIcon }
725
+ },
726
+ window: {
727
+ width: 1200,
728
+ height: 800
729
+ },
730
+ developerMode: frozen.developerMode === true
731
+ };
732
+ if (Object.keys(config.command.env ?? {}).length > 0 && exportOptions.acknowledgeEnv !== true) return {
733
+ ok: false,
734
+ code: "env_ack_required",
735
+ message: "environment entries present; acknowledgement required"
736
+ };
737
+ if (exportOptions.format === "command") {
738
+ const plan = buildExportPlan({
739
+ config,
740
+ embeddedResources: embedded,
741
+ forceCopy: exportOptions.forceCopy === true
742
+ });
743
+ if (!plan.ok) return {
744
+ ok: false,
745
+ code: "export_unsafe",
746
+ message: plan.error.message
747
+ };
748
+ if (plan.value.directCommand === null) return {
749
+ ok: false,
750
+ code: "export_unsafe",
751
+ message: plan.value.directCommandBlockedReason ?? "direct copy requires force-copy"
752
+ };
753
+ return {
754
+ ok: true,
755
+ kind: "command",
756
+ command: formatPosixCommandLine(plan.value.directCommand.command)
757
+ };
758
+ }
759
+ const script = buildScriptExport({
760
+ config,
761
+ embeddedResources: embedded
762
+ }, exportOptions.format === "sh" ? "sh" : "powershell");
763
+ if (!script.ok) return {
764
+ ok: false,
765
+ code: "export_unsafe",
766
+ message: script.error.message
767
+ };
768
+ const iconReference = appIconUrl !== void 0 && /^https?:\/\//u.test(appIconUrl) ? "url" : appIconSource !== void 0 && appIconSource.trim().length > 0 ? "local" : "none";
769
+ return {
770
+ ok: true,
771
+ kind: "script",
772
+ filename: script.value.filename,
773
+ content: script.value.content,
774
+ commandLine: script.value.commandLine,
775
+ requiresEnvAcknowledgement: script.value.requiresEnvAcknowledgement,
776
+ iconSharedAs: appIconRef.mode,
777
+ iconReference
778
+ };
779
+ },
780
+ async create() {
781
+ if (state !== "frozen") throw new Error(`cannot create while ${state}`);
782
+ const frozen = frozenForm ?? form;
783
+ if (currentTokens.length === 0) throw new Error("no command recorded");
784
+ setState("materializing");
785
+ if (run !== void 0) {
786
+ await run.kill();
787
+ run = void 0;
788
+ }
789
+ try {
790
+ resolvedVector = await (options.resolveVector ?? resolveLaunchVector)({
791
+ tokens: currentTokens,
792
+ cwd: effectiveCwd()
793
+ });
794
+ const envOverlay = commandEnv();
795
+ if (Object.keys(envOverlay).length > 0) resolvedVector = {
796
+ ...resolvedVector,
797
+ env: envOverlay
798
+ };
799
+ } catch (error) {
800
+ setState("failed", error instanceof Error ? error.message : String(error));
801
+ return;
802
+ }
803
+ try {
804
+ result = await materialize({
805
+ config: {
806
+ schemaVersion: 1,
807
+ appId: frozen.appId,
808
+ appName: frozen.appName,
809
+ command: resolvedVector,
810
+ service: { port: resolvedServicePort ?? 0 },
811
+ window: {
812
+ width: 1200,
813
+ height: 800
814
+ },
815
+ ...frozen.developerMode === true ? { developerMode: true } : {}
816
+ },
817
+ targetDir: resolvedTargetDir ?? currentDefaults().targetDir,
818
+ dependencyRange: options.dependencyRange,
819
+ iconSourcePath: frozenIconPath ?? currentIconPath,
820
+ ...frozen.iconBackground === void 0 ? {} : { iconBackground: frozen.iconBackground },
821
+ ...frozen.iconScale === void 0 ? {} : { iconScale: frozen.iconScale },
822
+ ...currentTrayIconPath === void 0 ? {} : { trayIconSourcePath: currentTrayIconPath },
823
+ ...trayIconIsSolid ? { trayIconIsSolid: true } : {},
824
+ shell: {
825
+ showTerminal: frozen.showStartupTerminal,
826
+ showAddressBar: frozen.showAddressBar
827
+ },
828
+ ...frozen.imageSmoothingEnabled === false ? { imageSmoothingEnabled: false } : {},
829
+ ...frozen.developerMode === true ? { developerMode: true } : {},
830
+ packageManager: frozen.pm,
831
+ skipInstall: options.skipInstall,
832
+ force: frozen.force
833
+ }, {
834
+ log: (event) => {
835
+ if (event.type === "step") {
836
+ emit({
837
+ type: "materialize-step",
838
+ step: event.step,
839
+ message: event.message
840
+ });
841
+ return;
842
+ }
843
+ emit({
844
+ type: "materialize-log",
845
+ message: event.message
846
+ });
847
+ },
848
+ ...options.platform === void 0 ? {} : { platform: options.platform },
849
+ ...options.materializeContext ?? {}
850
+ });
851
+ setState("success");
852
+ emit({
853
+ type: "success",
854
+ projectDir: result.projectDir,
855
+ pinHint: pinningHint()
856
+ });
857
+ } catch (error) {
858
+ const message = error instanceof Error ? error.message : String(error);
859
+ const occupied = message.includes("target directory is not empty");
860
+ setState("failed", occupied ? `${message};可在「高级选项」中开启 强制覆盖 后重试` : message);
861
+ }
862
+ },
863
+ async stop() {
864
+ stopTimers();
865
+ discovery.stop();
866
+ if (run !== void 0) {
867
+ await run.kill();
868
+ run = void 0;
869
+ }
870
+ }
871
+ };
872
+ publishCommandOptions();
873
+ return session;
874
+ };
875
+ //#endregion
876
+ //#region src/workbench-api.ts
877
+ const exists = async (path) => {
878
+ try {
879
+ await access(path);
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ };
885
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
886
+ /** Resolve the packaged skill root served to the WebUI help center. */
887
+ const resolveSkillRoot = async () => {
888
+ const candidates = [
889
+ join(moduleDirectory, "skill"),
890
+ join(moduleDirectory, "..", "..", "skill"),
891
+ join(moduleDirectory, "..", "skill")
892
+ ];
893
+ for (const candidate of candidates) try {
894
+ await readdir(candidate);
895
+ return resolve(candidate);
896
+ } catch {}
897
+ };
898
+ const SKILL_PATH_PATTERN = /^(?!-_)[A-Za-z0-9._-]+(?:\/(?!_)[A-Za-z0-9._-]+)*$/u;
899
+ const containedSkillPath = (logical) => {
900
+ if (!SKILL_PATH_PATTERN.test(logical) || logical.includes("..") || logical.includes("\0")) return;
901
+ return logical;
902
+ };
903
+ /**
904
+ * Handle one workbench API request. Returns undefined when the pathname is
905
+ * not a workbench route (the caller falls through to its own handling).
906
+ */
907
+ const handleWorkbenchApi = async (request) => {
908
+ const { pathname } = request;
909
+ if (pathname === "/api/apps" && request.method === "GET") {
910
+ const entries = await listCreateEntries();
911
+ return {
912
+ status: 200,
913
+ body: await Promise.all(entries.map(async (entry) => {
914
+ if (entry.source === "wizard") {
915
+ const projectDir = entry.dir;
916
+ return {
917
+ key: entry.key,
918
+ source: "wizard",
919
+ appId: entry.config?.appId,
920
+ appName: entry.config?.appName,
921
+ status: "healthy",
922
+ registrationDir: entry.dir,
923
+ payloadPath: entry.dir,
924
+ projectDir,
925
+ isLink: false,
926
+ hasEnv: entry.config !== void 0 && Object.keys(entry.config.command.env ?? {}).length > 0,
927
+ hasIcon: await exists(join(projectDir, "app-icon", "app-icon.png")),
928
+ ...entry.config === void 0 ? { error: {
929
+ code: "invalid_config",
930
+ message: "unreadable wizard project config"
931
+ } } : {}
932
+ };
933
+ }
934
+ const projectDir = entry.record.payloadPath ?? entry.record.appDir;
935
+ return {
936
+ key: entry.key,
937
+ source: "registered",
938
+ appId: entry.record.config?.appId,
939
+ appName: entry.record.config?.appName,
940
+ status: entry.record.status,
941
+ registrationDir: entry.record.dir,
942
+ payloadPath: entry.record.payloadPath,
943
+ projectDir,
944
+ isLink: entry.record.isLink,
945
+ hasEnv: entry.record.config !== void 0 && Object.keys(entry.record.config.command.env ?? {}).length > 0,
946
+ hasIcon: await exists(join(projectDir, "app-icon", "app-icon.png")),
947
+ ...entry.record.error === void 0 ? {} : { error: {
948
+ code: entry.record.error.code,
949
+ message: entry.record.error.message
950
+ } }
951
+ };
952
+ }))
953
+ };
954
+ }
955
+ const iconMatch = /^\/api\/apps\/([^/]+)\/icon$/.exec(pathname);
956
+ if (iconMatch !== null && request.method === "GET") {
957
+ const key = decodeURIComponent(iconMatch[1]);
958
+ const entry = await findCreateEntry(key);
959
+ if (entry === void 0) return {
960
+ status: 404,
961
+ body: {
962
+ code: "not_found",
963
+ message: `no application at key ${key}`
964
+ }
965
+ };
966
+ const projectDir = entry.source === "wizard" ? entry.dir : entry.record.payloadPath;
967
+ if (projectDir === void 0) return {
968
+ status: 404,
969
+ body: {
970
+ code: "missing_payload",
971
+ message: "application payload is unavailable"
972
+ }
973
+ };
974
+ try {
975
+ const bytes = new Uint8Array(await readFile(join(projectDir, "app-icon", "app-icon.png")));
976
+ return {
977
+ status: 200,
978
+ body: { dataUrl: `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` }
979
+ };
980
+ } catch {
981
+ return {
982
+ status: 404,
983
+ body: {
984
+ code: "not_found",
985
+ message: "no app icon asset"
986
+ }
987
+ };
988
+ }
989
+ }
990
+ const readMatch = /^\/api\/apps\/([^/]+)\/config$/.exec(pathname);
991
+ if (readMatch !== null && request.method === "GET") {
992
+ const key = decodeURIComponent(readMatch[1]);
993
+ const entry = await findCreateEntry(key);
994
+ if (entry === void 0) return {
995
+ status: 404,
996
+ body: {
997
+ code: "not_found",
998
+ message: `no application at key ${key}`
999
+ }
1000
+ };
1001
+ if (entry.source === "wizard") {
1002
+ if (entry.config === void 0) return {
1003
+ status: 500,
1004
+ body: {
1005
+ code: "invalid_config",
1006
+ message: "unreadable wizard project config"
1007
+ }
1008
+ };
1009
+ return {
1010
+ status: 200,
1011
+ body: {
1012
+ schemaVersion: 1,
1013
+ appId: entry.config.appId,
1014
+ appName: entry.config.appName,
1015
+ command: {
1016
+ executable: entry.config.command.executable,
1017
+ args: [...entry.config.command.args],
1018
+ cwd: entry.config.command.cwd,
1019
+ ...entry.config.command.env === void 0 ? {} : { env: { ...entry.config.command.env } }
1020
+ },
1021
+ packageManager: entry.config.packageManager,
1022
+ icons: {
1023
+ imageSmoothingEnabled: true,
1024
+ background: "transparent",
1025
+ scale: .8
1026
+ },
1027
+ window: entry.config.window,
1028
+ developerMode: entry.config.developerMode
1029
+ }
1030
+ };
1031
+ }
1032
+ if (entry.record.config === void 0) return {
1033
+ status: 500,
1034
+ body: {
1035
+ code: "invalid_config",
1036
+ message: "unreadable registration"
1037
+ }
1038
+ };
1039
+ return {
1040
+ status: 200,
1041
+ body: entry.record.config
1042
+ };
1043
+ }
1044
+ const openMatch = /^\/api\/apps\/([^/]+)\/open$/.exec(pathname);
1045
+ if (openMatch !== null && request.method === "POST") {
1046
+ const key = decodeURIComponent(openMatch[1]);
1047
+ const entry = await findCreateEntry(key);
1048
+ if (entry === void 0) return {
1049
+ status: 404,
1050
+ body: {
1051
+ code: "not_found",
1052
+ message: `no application at key ${key}`
1053
+ }
1054
+ };
1055
+ const projectDir = entry.source === "wizard" ? entry.dir : entry.record.payloadPath;
1056
+ if (projectDir === void 0) return {
1057
+ status: 409,
1058
+ body: {
1059
+ code: "missing_payload",
1060
+ message: "application payload is unavailable"
1061
+ }
1062
+ };
1063
+ const opened = await openMaterializedApp({
1064
+ projectDir,
1065
+ bundlePath: void 0
1066
+ });
1067
+ return {
1068
+ status: opened.ok ? 200 : 500,
1069
+ body: opened
1070
+ };
1071
+ }
1072
+ const uninstallMatch = /^\/api\/apps\/([^/]+)\/uninstall$/.exec(pathname);
1073
+ if (uninstallMatch !== null && request.method === "POST") {
1074
+ const key = decodeURIComponent(uninstallMatch[1]);
1075
+ const entry = await findCreateEntry(key);
1076
+ if (entry === void 0) return {
1077
+ status: 404,
1078
+ body: {
1079
+ code: "not_found",
1080
+ message: `no application at key ${key}`
1081
+ }
1082
+ };
1083
+ if (entry.source === "wizard") {
1084
+ const result = await uninstallWizardProject({
1085
+ key,
1086
+ stopRunning: request.body.stopRunning === true
1087
+ });
1088
+ if (!result.ok) return {
1089
+ status: result.error.code === "not_found" ? 404 : result.error.code === "app_running" ? 409 : 500,
1090
+ body: result.error
1091
+ };
1092
+ return {
1093
+ status: 200,
1094
+ body: result.value
1095
+ };
1096
+ }
1097
+ const appId = entry?.source === "registered" && entry.record.config !== void 0 ? entry.record.config.appId : typeof request.body.appId === "string" ? request.body.appId : void 0;
1098
+ if (appId === void 0) return {
1099
+ status: 400,
1100
+ body: {
1101
+ code: "invalid_config",
1102
+ message: "appId is required"
1103
+ }
1104
+ };
1105
+ const result = await uninstallApp({
1106
+ appId,
1107
+ stopRunning: request.body.stopRunning === true,
1108
+ purgeTarget: request.body.purgeTarget === true
1109
+ });
1110
+ if (!result.ok) return {
1111
+ status: result.error.code === "not_found" ? 404 : result.error.code === "app_running" ? 409 : 500,
1112
+ body: result.error
1113
+ };
1114
+ return {
1115
+ status: 200,
1116
+ body: result.value
1117
+ };
1118
+ }
1119
+ if (/^\/api\/apps\/([^/]+)\/stop$/.exec(pathname) !== null && request.method === "POST") {
1120
+ const appId = typeof request.body.appId === "string" ? request.body.appId : void 0;
1121
+ if (appId === void 0) return {
1122
+ status: 400,
1123
+ body: {
1124
+ code: "invalid_config",
1125
+ message: "appId is required"
1126
+ }
1127
+ };
1128
+ const result = await stopRunningApp({ appId });
1129
+ if (!result.ok) return {
1130
+ status: 500,
1131
+ body: result.error
1132
+ };
1133
+ return {
1134
+ status: 200,
1135
+ body: result.value
1136
+ };
1137
+ }
1138
+ const exportMatch = /^\/api\/apps\/([^/]+)\/export$/.exec(pathname);
1139
+ if (exportMatch !== null && request.method === "POST") {
1140
+ const key = decodeURIComponent(exportMatch[1]);
1141
+ const entry = await findCreateEntry(key);
1142
+ if (entry === void 0) return {
1143
+ status: 404,
1144
+ body: {
1145
+ code: "not_found",
1146
+ message: `no application at key ${key}`
1147
+ }
1148
+ };
1149
+ let config;
1150
+ const embedded = [];
1151
+ let iconSharedAs = "none";
1152
+ let iconReference = "none";
1153
+ const inlineIcon = request.body.inlineIcon;
1154
+ if (entry.source === "wizard") {
1155
+ if (entry.config === void 0) return {
1156
+ status: 500,
1157
+ body: {
1158
+ code: "invalid_config",
1159
+ message: "unreadable wizard project config"
1160
+ }
1161
+ };
1162
+ const icon = await readWizardProjectIcon(entry.dir);
1163
+ config = {
1164
+ schemaVersion: 1,
1165
+ appId: entry.config.appId,
1166
+ appName: entry.config.appName,
1167
+ command: {
1168
+ executable: entry.config.command.executable,
1169
+ args: [...entry.config.command.args],
1170
+ cwd: entry.config.command.cwd,
1171
+ ...entry.config.command.env === void 0 ? {} : { env: { ...entry.config.command.env } }
1172
+ },
1173
+ packageManager: entry.config.packageManager,
1174
+ icons: {
1175
+ imageSmoothingEnabled: true,
1176
+ background: "transparent",
1177
+ scale: .8,
1178
+ ...icon === void 0 ? {} : { appIcon: {
1179
+ path: "app-icon.png",
1180
+ format: "png",
1181
+ sha256: icon.sha256,
1182
+ source: {
1183
+ kind: "file",
1184
+ ref: icon.path
1185
+ }
1186
+ } }
1187
+ },
1188
+ window: entry.config.window,
1189
+ developerMode: entry.config.developerMode
1190
+ };
1191
+ if (icon !== void 0) {
1192
+ iconReference = "local";
1193
+ if (inlineIcon === false) {
1194
+ config = {
1195
+ ...config,
1196
+ icons: {
1197
+ ...config.icons,
1198
+ appIcon: {
1199
+ path: "app-icon.png",
1200
+ format: "png",
1201
+ sha256: "",
1202
+ source: {
1203
+ kind: "file",
1204
+ ref: icon.path
1205
+ }
1206
+ }
1207
+ }
1208
+ };
1209
+ iconSharedAs = "local";
1210
+ } else {
1211
+ embedded.push({
1212
+ flag: "app-icon",
1213
+ filename: "app-icon.png",
1214
+ bytes: icon.bytes
1215
+ });
1216
+ iconSharedAs = "embedded";
1217
+ }
1218
+ }
1219
+ } else {
1220
+ if (entry.record.config === void 0) return {
1221
+ status: 500,
1222
+ body: {
1223
+ code: "invalid_config",
1224
+ message: "unreadable registration"
1225
+ }
1226
+ };
1227
+ config = entry.record.config;
1228
+ for (const ref of [config.icons.appIcon, config.icons.trayIcon]) {
1229
+ if (ref === void 0) continue;
1230
+ const isHttp = ref.source.kind === "http";
1231
+ const isApp = ref === config.icons.appIcon;
1232
+ if (isApp) iconReference = isHttp ? "url" : "local";
1233
+ if (!(inlineIcon ?? !isHttp)) {
1234
+ if (isApp) iconSharedAs = isHttp ? "url" : "local";
1235
+ continue;
1236
+ }
1237
+ const bytes = await readResourceBytes(entry.record.dir, ref);
1238
+ if (bytes.ok) {
1239
+ embedded.push({
1240
+ flag: isApp ? "app-icon" : "tray-icon",
1241
+ filename: ref.path.split("/").pop() ?? "icon.png",
1242
+ bytes: bytes.value
1243
+ });
1244
+ if (isApp) iconSharedAs = "embedded";
1245
+ }
1246
+ }
1247
+ }
1248
+ const envCount = Object.keys(config.command.env ?? {}).length;
1249
+ if (envCount > 0 && request.body.acknowledgeEnv !== true) return {
1250
+ status: 409,
1251
+ body: {
1252
+ code: "env_ack_required",
1253
+ message: "environment entries present; acknowledgement required",
1254
+ envCount
1255
+ }
1256
+ };
1257
+ const format = request.body.format === "sh" || request.body.format === "ps1" || request.body.format === "command" ? request.body.format : "command";
1258
+ if (format === "command") {
1259
+ const plan = buildExportPlan({
1260
+ config,
1261
+ embeddedResources: embedded,
1262
+ forceCopy: request.body.forceCopy === true
1263
+ });
1264
+ if (!plan.ok) return {
1265
+ status: 500,
1266
+ body: plan.error
1267
+ };
1268
+ if (plan.value.directCommand === null) return {
1269
+ status: 409,
1270
+ body: {
1271
+ code: "export_unsafe",
1272
+ message: plan.value.directCommandBlockedReason ?? "direct copy requires force-copy"
1273
+ }
1274
+ };
1275
+ return {
1276
+ status: 200,
1277
+ body: { command: formatPosixCommandLine(plan.value.directCommand.command) }
1278
+ };
1279
+ }
1280
+ const script = buildScriptExport({
1281
+ config,
1282
+ embeddedResources: embedded
1283
+ }, format === "sh" ? "sh" : "powershell");
1284
+ if (!script.ok) return {
1285
+ status: 500,
1286
+ body: script.error
1287
+ };
1288
+ return {
1289
+ status: 200,
1290
+ body: {
1291
+ filename: script.value.filename,
1292
+ content: script.value.content,
1293
+ commandLine: script.value.commandLine,
1294
+ requiresEnvAcknowledgement: script.value.requiresEnvAcknowledgement,
1295
+ iconSharedAs,
1296
+ iconReference
1297
+ }
1298
+ };
1299
+ }
1300
+ if (pathname === "/api/skill" && request.method === "GET") {
1301
+ const root = await resolveSkillRoot();
1302
+ if (root === void 0) return {
1303
+ status: 404,
1304
+ body: {
1305
+ code: "not_found",
1306
+ message: "packaged skill not found"
1307
+ }
1308
+ };
1309
+ const requested = request.query.get("path");
1310
+ const logical = containedSkillPath(requested !== null && requested.length > 0 ? requested : "SKILL.md");
1311
+ if (logical === void 0) return {
1312
+ status: 400,
1313
+ body: {
1314
+ code: "path_escape",
1315
+ message: "invalid skill path"
1316
+ }
1317
+ };
1318
+ try {
1319
+ return {
1320
+ status: 200,
1321
+ body: {
1322
+ path: logical,
1323
+ content: await readFile(join(root, logical), "utf8")
1324
+ }
1325
+ };
1326
+ } catch {
1327
+ return {
1328
+ status: 404,
1329
+ body: {
1330
+ code: "not_found",
1331
+ message: `skill file not found: ${logical}`
1332
+ }
1333
+ };
1334
+ }
1335
+ }
1336
+ if (pathname === "/api/skill/list" && request.method === "GET") {
1337
+ const root = await resolveSkillRoot();
1338
+ if (root === void 0) return {
1339
+ status: 404,
1340
+ body: {
1341
+ code: "not_found",
1342
+ message: "packaged skill not found"
1343
+ }
1344
+ };
1345
+ const walk = async (prefix) => {
1346
+ const entries = await readdir(join(root, prefix), { withFileTypes: true });
1347
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1348
+ const out = [];
1349
+ for (const entry of entries) {
1350
+ const logical = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
1351
+ if (entry.isDirectory()) {
1352
+ out.push({
1353
+ path: logical,
1354
+ type: "directory"
1355
+ });
1356
+ out.push(...await walk(logical));
1357
+ } else if (entry.isFile()) out.push({
1358
+ path: logical,
1359
+ type: "file"
1360
+ });
1361
+ }
1362
+ return out;
1363
+ };
1364
+ return {
1365
+ status: 200,
1366
+ body: await walk("")
1367
+ };
1368
+ }
1369
+ };
1370
+ //#endregion
1371
+ //#region src/server.ts
1372
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1373
+ "127.0.0.1",
1374
+ "localhost",
1375
+ "[::1]",
1376
+ "::1"
1377
+ ]);
1378
+ const createWizardServer = async (createSession, options = {}) => {
1379
+ const token = randomBytes(16).toString("hex");
1380
+ const clients = /* @__PURE__ */ new Set();
1381
+ const eventLog = [];
1382
+ const EVENT_LOG_LIMIT = 200;
1383
+ const draftPath = join(tmpdir(), `create-opentray-draft-${options.port ?? 0}.json`);
1384
+ const writeDraft = (event) => {
1385
+ if (event.type !== "form" && event.type !== "command-display" && event.type !== "command-options") return;
1386
+ (async () => {
1387
+ try {
1388
+ const next = {
1389
+ ...await readFile(draftPath, "utf8").then((raw) => JSON.parse(raw)).catch(() => ({})),
1390
+ ...event.type === "form" ? {
1391
+ form: event.values,
1392
+ defaults: event.defaults
1393
+ } : {},
1394
+ ...event.type === "command-display" ? { command: event.command } : {},
1395
+ ...event.type === "command-options" ? { commandOptions: event.options } : {},
1396
+ savedAt: Date.now()
1397
+ };
1398
+ await writeFile(draftPath, JSON.stringify(next), "utf8");
1399
+ } catch {}
1400
+ })();
1401
+ };
1402
+ const emit = (event) => {
1403
+ if (event.type !== "log") {
1404
+ eventLog.push(event);
1405
+ if (eventLog.length > EVENT_LOG_LIMIT) eventLog.splice(0, eventLog.length - EVENT_LOG_LIMIT);
1406
+ }
1407
+ writeDraft(event);
1408
+ const frame = `data: ${JSON.stringify(event)}\n\n`;
1409
+ for (const client of clients) client.write(frame);
1410
+ };
1411
+ const session = createSession(emit);
1412
+ const indexHtml = await readWebUiIndex();
1413
+ const server = createServer((request, response) => {
1414
+ handle(request, response).catch((error) => {
1415
+ respond(response, 500, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
1416
+ });
1417
+ });
1418
+ const handle = async (request, response) => {
1419
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
1420
+ if (url.pathname === "/" || url.pathname === "/index.html") {
1421
+ if (!isAuthorized(request, url, token)) {
1422
+ respond(response, 401, "text/plain", "invalid wizard token\n");
1423
+ return;
1424
+ }
1425
+ if (indexHtml === void 0) {
1426
+ respond(response, 500, "text/plain", "wizard page is missing from this installation\n");
1427
+ return;
1428
+ }
1429
+ respond(response, 200, "text/html; charset=utf-8", indexHtml);
1430
+ return;
1431
+ }
1432
+ if (url.pathname === "/api/events") {
1433
+ if (!isAuthorized(request, url, token)) {
1434
+ respond(response, 401, "text/plain", "unauthorized\n");
1435
+ return;
1436
+ }
1437
+ response.writeHead(200, {
1438
+ "content-type": "text/event-stream",
1439
+ "cache-control": "no-cache",
1440
+ connection: "keep-alive"
1441
+ });
1442
+ response.write(": connected\n\n");
1443
+ response.write(`data: ${JSON.stringify(session.snapshot())}\n\n`);
1444
+ for (const event of eventLog) response.write(`data: ${JSON.stringify(event)}\n\n`);
1445
+ clients.add(response);
1446
+ request.once("close", () => {
1447
+ clients.delete(response);
1448
+ });
1449
+ return;
1450
+ }
1451
+ if (url.pathname.startsWith("/assets/")) {
1452
+ await handleAssetFile(url.pathname, response);
1453
+ return;
1454
+ }
1455
+ if (url.pathname === "/logo.png" || FAVICON_PATHS.has(url.pathname)) {
1456
+ await handleAssetFile(url.pathname, response);
1457
+ return;
1458
+ }
1459
+ if (url.pathname === "/ghostty-vt.wasm") {
1460
+ await handleAssetFile("/ghostty-vt.wasm", response);
1461
+ return;
1462
+ }
1463
+ if (url.pathname.startsWith("/vendor/")) {
1464
+ await handleVendorAsset(url.pathname, response);
1465
+ return;
1466
+ }
1467
+ const composedMatch = /^\/api\/icon-composed\/([a-f0-9]+)$/.exec(url.pathname);
1468
+ if (composedMatch !== null) {
1469
+ if (!isAuthorized(request, url, token)) {
1470
+ respond(response, 401, "text/plain", "unauthorized\n");
1471
+ return;
1472
+ }
1473
+ const key = composedMatch[1];
1474
+ const composed = session.iconComposition(key);
1475
+ if (composed === void 0) {
1476
+ respond(response, 404, "text/plain", "not found\n");
1477
+ return;
1478
+ }
1479
+ try {
1480
+ const bytes = await readFile(composed.compositePath);
1481
+ response.writeHead(200, {
1482
+ "content-type": "image/png",
1483
+ "cache-control": "no-store",
1484
+ "content-length": bytes.byteLength
1485
+ });
1486
+ response.end(bytes);
1487
+ return;
1488
+ } catch {
1489
+ respond(response, 404, "text/plain", "not found\n");
1490
+ return;
1491
+ }
1492
+ }
1493
+ const iconDataMatch = /^\/api\/icon-data\/(\d+)\/(\d+)$/.exec(url.pathname);
1494
+ if (iconDataMatch !== null) {
1495
+ if (!isAuthorized(request, url, token)) {
1496
+ respond(response, 401, "text/plain", "unauthorized\n");
1497
+ return;
1498
+ }
1499
+ const port = Number.parseInt(iconDataMatch[1], 10);
1500
+ const index = Number.parseInt(iconDataMatch[2], 10);
1501
+ const candidate = session.iconCandidate(port, index);
1502
+ if (candidate === void 0) {
1503
+ respond(response, 404, "text/plain", "not found\n");
1504
+ return;
1505
+ }
1506
+ const bytes = await readFile(candidate.path).catch(() => void 0);
1507
+ if (bytes === void 0) {
1508
+ respond(response, 404, "text/plain", "not found\n");
1509
+ return;
1510
+ }
1511
+ response.writeHead(200, {
1512
+ "content-type": ICON_CONTENT_TYPES[candidate.format] ?? "application/octet-stream",
1513
+ "content-length": bytes.length,
1514
+ "cache-control": "no-store"
1515
+ });
1516
+ response.end(bytes);
1517
+ return;
1518
+ }
1519
+ if (url.pathname.startsWith("/api/")) {
1520
+ if (!isAuthorized(request, url, token)) {
1521
+ respond(response, 401, "application/json", "{\"error\":\"unauthorized\"}\n");
1522
+ return;
1523
+ }
1524
+ if (!isLoopbackHost(request)) {
1525
+ respond(response, 403, "application/json", "{\"error\":\"forbidden host\"}\n");
1526
+ return;
1527
+ }
1528
+ if (url.pathname === "/api/apps" || url.pathname.startsWith("/api/apps/") || url.pathname === "/api/skill" || url.pathname === "/api/skill/list") {
1529
+ let body = {};
1530
+ if (request.method === "POST") body = await readJsonBody(request);
1531
+ const workbench = await handleWorkbenchApi({
1532
+ method: request.method ?? "GET",
1533
+ pathname: url.pathname,
1534
+ query: url.searchParams,
1535
+ body
1536
+ });
1537
+ if (workbench !== void 0) {
1538
+ respond(response, workbench.status, "application/json", `${JSON.stringify(workbench.body)}\n`);
1539
+ return;
1540
+ }
1541
+ }
1542
+ await handleApi(url.pathname, request, response, session);
1543
+ return;
1544
+ }
1545
+ respond(response, 404, "text/plain", "not found\n");
1546
+ };
1547
+ await listen(server, options.port);
1548
+ const address = server.address();
1549
+ if (address === null || typeof address === "string") {
1550
+ server.close();
1551
+ throw new Error("wizard server did not bind a loopback port");
1552
+ }
1553
+ return {
1554
+ url: `http://127.0.0.1:${address.port}/?token=${token}`,
1555
+ port: address.port,
1556
+ token,
1557
+ session,
1558
+ close: () => new Promise((resolve) => {
1559
+ for (const client of clients) client.end();
1560
+ clients.clear();
1561
+ server.close(() => resolve());
1562
+ })
1563
+ };
1564
+ };
1565
+ /** Whitelisted static terminal-renderer assets; exact names only, no traversal. */
1566
+ const VENDOR_ASSETS = {
1567
+ "/vendor/ghostty-web.js": {
1568
+ contentType: "text/javascript; charset=utf-8",
1569
+ candidates: () => [join(moduleDir(), "webui", "vendor", "ghostty-web.js"), ghosttyPackageFile("dist/ghostty-web.js")]
1570
+ },
1571
+ "/vendor/ghostty-vt.wasm": {
1572
+ contentType: "application/wasm",
1573
+ candidates: () => [
1574
+ join(moduleDir(), "webui", "vendor", "ghostty-vt.wasm"),
1575
+ ghosttyPackageFile("ghostty-vt.wasm"),
1576
+ ghosttyPackageFile("dist/ghostty-vt.wasm")
1577
+ ]
1578
+ }
1579
+ };
1580
+ const moduleDir = () => dirname(fileURLToPath(import.meta.url));
1581
+ /**
1582
+ * Resolve a file inside the installed ghostty-web package. Deep paths are
1583
+ * blocked by the package `exports` map, so resolve the exported main module
1584
+ * and walk from the package root instead.
1585
+ */
1586
+ const ghosttyPackageFile = (relative) => {
1587
+ try {
1588
+ return join(dirname(dirname(createRequire(import.meta.url).resolve("ghostty-web"))), relative);
1589
+ } catch {
1590
+ return "";
1591
+ }
1592
+ };
1593
+ const ICON_CONTENT_TYPES = {
1594
+ png: "image/png",
1595
+ svg: "image/svg+xml",
1596
+ jpeg: "image/jpeg",
1597
+ webp: "image/webp",
1598
+ gif: "image/gif",
1599
+ ico: "image/x-icon"
1600
+ };
1601
+ const ASSET_CONTENT_TYPES = {
1602
+ ".js": "text/javascript; charset=utf-8",
1603
+ ".css": "text/css; charset=utf-8",
1604
+ ".wasm": "application/wasm",
1605
+ ".html": "text/html; charset=utf-8",
1606
+ ".svg": "image/svg+xml",
1607
+ ".png": "image/png",
1608
+ ".json": "application/json"
1609
+ };
1610
+ /**
1611
+ * Serve one file from the built webui directory (dist/webui). Only simple
1612
+ * relative names are accepted: resolve first, then require the resolved path
1613
+ * to stay inside the webui root, so traversal cannot escape.
1614
+ */
1615
+ const handleAssetFile = async (pathname, response) => {
1616
+ const relative = pathname.replace(/^\/+/, "");
1617
+ if (relative.length === 0 || relative.includes("\0")) {
1618
+ respond(response, 404, "text/plain", "not found\n");
1619
+ return;
1620
+ }
1621
+ const roots = [join(moduleDir(), "webui"), join(moduleDir(), "..", "..", "create-webui", "dist")];
1622
+ for (const root of roots) {
1623
+ const resolved = resolve(root, relative);
1624
+ if (!(resolved === root || resolved.startsWith(`${root}${sep}`))) continue;
1625
+ const bytes = await readFile(resolved).catch(() => void 0);
1626
+ if (bytes === void 0) continue;
1627
+ const extension = extname(resolved).toLowerCase();
1628
+ response.writeHead(200, {
1629
+ "content-type": ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
1630
+ "content-length": bytes.length,
1631
+ "cache-control": "no-store"
1632
+ });
1633
+ response.end(bytes);
1634
+ return;
1635
+ }
1636
+ respond(response, 404, "text/plain", "not found\n");
1637
+ };
1638
+ const handleVendorAsset = async (pathname, response) => {
1639
+ const asset = VENDOR_ASSETS[pathname];
1640
+ if (asset === void 0) {
1641
+ respond(response, 404, "text/plain", "not found\n");
1642
+ return;
1643
+ }
1644
+ for (const candidate of asset.candidates()) {
1645
+ if (candidate.length === 0) continue;
1646
+ const bytes = await readFile(candidate).catch(() => void 0);
1647
+ if (bytes !== void 0) {
1648
+ response.writeHead(200, {
1649
+ "content-type": asset.contentType,
1650
+ "content-length": bytes.length,
1651
+ "cache-control": "no-store"
1652
+ });
1653
+ response.end(bytes);
1654
+ return;
1655
+ }
1656
+ }
1657
+ respond(response, 404, "text/plain", "terminal renderer asset is missing\n");
1658
+ };
1659
+ /** Containment: icon routes must only read sources the wizard itself
1660
+ * produced (its temp dirs / saved uploads), never arbitrary paths. */
1661
+ const isWizardOwnedIconPath = (session, target) => {
1662
+ for (const root of session.iconSourceRoots()) if (root.length > 0 && target.startsWith(root + sep)) return true;
1663
+ return false;
1664
+ };
1665
+ const handleApi = async (pathname, request, response, session, preReadBody) => {
1666
+ if (request.method !== "POST") {
1667
+ respond(response, 405, "application/json", "{\"error\":\"method not allowed\"}\n");
1668
+ return;
1669
+ }
1670
+ if (pathname === "/api/icon-upload") {
1671
+ const chunks = [];
1672
+ for await (const chunk of request) {
1673
+ const piece = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
1674
+ chunks.push(piece);
1675
+ }
1676
+ const bytes = Buffer.concat(chunks);
1677
+ if (bytes.length < 64) {
1678
+ respond(response, 400, "application/json", "{\"error\":\"image bytes are required\"}\n");
1679
+ return;
1680
+ }
1681
+ const path = await session.saveIconUpload(bytes);
1682
+ respond(response, 200, "application/json", JSON.stringify({ path }) + "\n");
1683
+ return;
1684
+ }
1685
+ const body = preReadBody ?? await readJsonBody(request);
1686
+ switch (pathname) {
1687
+ case "/api/command": {
1688
+ if (Array.isArray(body.argv)) {
1689
+ const argv = body.argv.filter((element) => typeof element === "string");
1690
+ if (argv.length === 0 || (argv[0] ?? "").trim().length === 0) {
1691
+ respond(response, 400, "application/json", "{\"error\":\"argv requires the program element\"}\n");
1692
+ return;
1693
+ }
1694
+ await session.submitCommand(argv);
1695
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1696
+ return;
1697
+ }
1698
+ const command = typeof body.command === "string" ? body.command : "";
1699
+ if (command.trim().length === 0) {
1700
+ respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
1701
+ return;
1702
+ }
1703
+ await session.submitCommand(command);
1704
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1705
+ return;
1706
+ }
1707
+ case "/api/command-options": {
1708
+ const patch = {};
1709
+ if (typeof body.cwd === "string") patch.cwd = body.cwd;
1710
+ if (body.argsMode === "string" || body.argsMode === "array") patch.argsMode = body.argsMode;
1711
+ if (Array.isArray(body.env)) {
1712
+ const entries = [];
1713
+ for (const entry of body.env) if (typeof entry === "object" && entry !== null && typeof entry.key === "string" && typeof entry.value === "string") entries.push({
1714
+ key: entry.key,
1715
+ value: entry.value
1716
+ });
1717
+ patch.env = entries;
1718
+ }
1719
+ session.updateCommandOptions(patch);
1720
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1721
+ return;
1722
+ }
1723
+ case "/api/prime": {
1724
+ const command = typeof body.command === "string" ? body.command : "";
1725
+ if (command.trim().length === 0) {
1726
+ respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
1727
+ return;
1728
+ }
1729
+ session.prime(command);
1730
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1731
+ return;
1732
+ }
1733
+ case "/api/select-service": {
1734
+ const port = Number(body.port);
1735
+ if (!Number.isInteger(port) || port <= 0) {
1736
+ respond(response, 400, "application/json", "{\"error\":\"port is required\"}\n");
1737
+ return;
1738
+ }
1739
+ session.selectService(port);
1740
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1741
+ return;
1742
+ }
1743
+ case "/api/form": {
1744
+ const patch = {};
1745
+ for (const key of [
1746
+ "appId",
1747
+ "appName",
1748
+ "iconPath",
1749
+ "trayIconPath"
1750
+ ]) {
1751
+ const value = body[key];
1752
+ if (typeof value === "string") patch[key] = value;
1753
+ }
1754
+ if (body.pm === "npm" || body.pm === "pnpm" || body.pm === "bun") patch.pm = body.pm;
1755
+ if (body.iconBackground === "black" || body.iconBackground === "white" || body.iconBackground === "transparent") patch.iconBackground = body.iconBackground;
1756
+ if (typeof body.iconScale === "number" && body.iconScale >= .5 && body.iconScale <= .95) patch.iconScale = body.iconScale;
1757
+ for (const key of [
1758
+ "showStartupTerminal",
1759
+ "showAddressBar",
1760
+ "imageSmoothingEnabled",
1761
+ "developerMode",
1762
+ "force"
1763
+ ]) {
1764
+ const value = body[key];
1765
+ if (typeof value === "boolean") patch[key] = value;
1766
+ }
1767
+ session.updateForm(patch);
1768
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1769
+ return;
1770
+ }
1771
+ case "/api/cancel":
1772
+ try {
1773
+ session.cancel();
1774
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1775
+ } catch (error) {
1776
+ respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
1777
+ }
1778
+ return;
1779
+ case "/api/confirm":
1780
+ try {
1781
+ session.confirm();
1782
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1783
+ } catch (error) {
1784
+ respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
1785
+ }
1786
+ return;
1787
+ case "/api/export": {
1788
+ const format = body.format === "sh" || body.format === "ps1" || body.format === "command" ? body.format : void 0;
1789
+ if (format === void 0) {
1790
+ respond(response, 400, "application/json", "{\"error\":\"format must be command, sh, or ps1\"}\n");
1791
+ return;
1792
+ }
1793
+ const result = await session.exportFrozen({
1794
+ format,
1795
+ acknowledgeEnv: body.acknowledgeEnv === true,
1796
+ forceCopy: body.forceCopy === true,
1797
+ inlineIcon: body.inlineIcon === true
1798
+ });
1799
+ if (!result.ok) {
1800
+ const status = result.code === "env_ack_required" || result.code === "export_unsafe" ? 409 : 400;
1801
+ respond(response, status, "application/json", `${JSON.stringify({
1802
+ code: result.code,
1803
+ message: result.message
1804
+ })}\n`);
1805
+ return;
1806
+ }
1807
+ respond(response, 200, "application/json", `${JSON.stringify(result)}\n`);
1808
+ return;
1809
+ }
1810
+ case "/api/create":
1811
+ try {
1812
+ await session.create();
1813
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1814
+ } catch (error) {
1815
+ respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
1816
+ }
1817
+ return;
1818
+ case "/api/terminal-input": {
1819
+ const data = typeof body.data === "string" ? body.data : void 0;
1820
+ if (data === void 0) {
1821
+ respond(response, 400, "application/json", "{\"error\":\"data is required\"}\n");
1822
+ return;
1823
+ }
1824
+ session.terminalInput(data);
1825
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1826
+ return;
1827
+ }
1828
+ case "/api/icon-analyze": {
1829
+ const path = typeof body.path === "string" ? body.path.trim() : "";
1830
+ if (path.length === 0) {
1831
+ respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
1832
+ return;
1833
+ }
1834
+ if (!isWizardOwnedIconPath(session, path)) {
1835
+ respond(response, 403, "application/json", "{\"error\":\"path is not a wizard icon source\"}\n");
1836
+ return;
1837
+ }
1838
+ try {
1839
+ const analysis = await session.analyzeIconForeground(path);
1840
+ respond(response, 200, "application/json", JSON.stringify(analysis) + "\n");
1841
+ } catch (error) {
1842
+ respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
1843
+ }
1844
+ return;
1845
+ }
1846
+ case "/api/icon-compose": {
1847
+ const foregroundPath = typeof body.foregroundPath === "string" ? body.foregroundPath.trim() : "";
1848
+ if (foregroundPath.length === 0) {
1849
+ respond(response, 400, "application/json", "{\"error\":\"foregroundPath is required\"}\n");
1850
+ return;
1851
+ }
1852
+ if (!isWizardOwnedIconPath(session, foregroundPath)) {
1853
+ respond(response, 403, "application/json", "{\"error\":\"foregroundPath is not a wizard icon source\"}\n");
1854
+ return;
1855
+ }
1856
+ const background = body.background === "black" || body.background === "white" || body.background === "transparent" ? body.background : void 0;
1857
+ const scale = typeof body.scale === "number" && body.scale >= .5 && body.scale <= .95 ? body.scale : void 0;
1858
+ try {
1859
+ const composed = await session.composeIcon({
1860
+ foregroundPath,
1861
+ ...background === void 0 ? {} : { background },
1862
+ ...scale === void 0 ? {} : { scale }
1863
+ });
1864
+ respond(response, 200, "application/json", JSON.stringify(composed) + "\n");
1865
+ } catch (error) {
1866
+ respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
1867
+ }
1868
+ return;
1869
+ }
1870
+ case "/api/tray-icon-select": {
1871
+ const port = typeof body.port === "number" ? body.port : NaN;
1872
+ const index = typeof body.index === "number" ? body.index : NaN;
1873
+ if (!Number.isInteger(port) || !Number.isInteger(index)) {
1874
+ respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
1875
+ return;
1876
+ }
1877
+ const ok = session.selectTrayIconCandidate(port, index);
1878
+ respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
1879
+ return;
1880
+ }
1881
+ case "/api/icon-select": {
1882
+ const port = typeof body.port === "number" ? body.port : NaN;
1883
+ const index = typeof body.index === "number" ? body.index : NaN;
1884
+ if (!Number.isInteger(port) || !Number.isInteger(index)) {
1885
+ respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
1886
+ return;
1887
+ }
1888
+ const ok = session.selectIconCandidate(port, index);
1889
+ respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
1890
+ return;
1891
+ }
1892
+ case "/api/icon-source": {
1893
+ const path = typeof body.path === "string" ? body.path.trim() : "";
1894
+ if (path.length === 0) {
1895
+ respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
1896
+ return;
1897
+ }
1898
+ session.updateForm({ iconPath: path });
1899
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1900
+ return;
1901
+ }
1902
+ case "/api/terminal-resize": {
1903
+ const cols = Number(body.cols);
1904
+ const rows = Number(body.rows);
1905
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
1906
+ respond(response, 400, "application/json", "{\"error\":\"cols and rows are required\"}\n");
1907
+ return;
1908
+ }
1909
+ session.terminalResize({
1910
+ cols,
1911
+ rows
1912
+ });
1913
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1914
+ return;
1915
+ }
1916
+ case "/api/stop":
1917
+ await session.stop();
1918
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
1919
+ return;
1920
+ case "/api/open-app": {
1921
+ const result = session.result;
1922
+ if (result === void 0) {
1923
+ respond(response, 409, "application/json", "{\"error\":\"no materialized app\"}\n");
1924
+ return;
1925
+ }
1926
+ const opened = await openMaterializedApp({
1927
+ projectDir: result.projectDir,
1928
+ bundlePath: void 0
1929
+ });
1930
+ respond(response, opened.ok ? 200 : 500, "application/json", `${JSON.stringify({
1931
+ ok: opened.ok,
1932
+ detail: opened.detail
1933
+ })}\n`);
1934
+ return;
1935
+ }
1936
+ default: respond(response, 404, "application/json", "{\"error\":\"unknown endpoint\"}\n");
1937
+ }
1938
+ };
1939
+ const listen = (server, port) => new Promise((resolve, reject) => {
1940
+ server.once("error", reject);
1941
+ server.listen(port ?? 0, "127.0.0.1", () => {
1942
+ server.off("error", reject);
1943
+ resolve();
1944
+ });
1945
+ });
1946
+ const isAuthorized = (request, url, token) => {
1947
+ if (request.headers.authorization === `Bearer ${token}`) return true;
1948
+ return url.searchParams.get("token") === token;
1949
+ };
1950
+ const isLoopbackHost = (request) => {
1951
+ const hostname = (request.headers.host ?? "").replace(/:\d+$/u, "").toLowerCase();
1952
+ return LOOPBACK_HOSTS.has(hostname);
1953
+ };
1954
+ const readJsonBody = async (request) => {
1955
+ const chunks = [];
1956
+ let size = 0;
1957
+ for await (const chunk of request) {
1958
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
1959
+ size += buffer.length;
1960
+ if (size > 1 << 20) throw new Error("request body too large");
1961
+ chunks.push(buffer);
1962
+ }
1963
+ if (chunks.length === 0) return {};
1964
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1965
+ if (typeof parsed !== "object" || parsed === null) return {};
1966
+ return parsed;
1967
+ };
1968
+ const respond = (response, status, contentType, body) => {
1969
+ response.writeHead(status, {
1970
+ "content-type": contentType,
1971
+ "content-length": Buffer.byteLength(body),
1972
+ "cache-control": "no-store"
1973
+ });
1974
+ response.end(body);
1975
+ };
1976
+ /** Built package: dist/webui/index.html; source checkout falls back to the create-webui vite output. */
1977
+ const webUiIndexPaths = () => [join(moduleDir(), "webui", "index.html"), join(moduleDir(), "..", "..", "create-webui", "dist", "index.html")];
1978
+ const readWebUiIndex = async () => {
1979
+ for (const candidate of webUiIndexPaths()) {
1980
+ const html = await readFile(candidate, "utf8").catch(() => void 0);
1981
+ if (html !== void 0) return html;
1982
+ }
1983
+ };
1984
+ /** Favicon variants served from the webui root (see create-webui/public). */
1985
+ const FAVICON_PATHS = /* @__PURE__ */ new Set([
1986
+ "/favicon-64.png",
1987
+ "/favicon-32.png",
1988
+ "/favicon-16.png"
1989
+ ]);
1990
+ //#endregion
1991
+ //#region src/bin.ts
1992
+ const parseWizardCli = (argv) => {
1993
+ const options = {
1994
+ open: true,
1995
+ skipInstall: false,
1996
+ force: false
1997
+ };
1998
+ const positional = [];
1999
+ for (let index = 0; index < argv.length; index += 1) {
2000
+ const arg = argv[index];
2001
+ if (arg === void 0) continue;
2002
+ if (arg === "--no-open") options.open = false;
2003
+ else if (arg === "--skip-install") options.skipInstall = true;
2004
+ else if (arg === "--force") options.force = true;
2005
+ else if (arg === "--port") {
2006
+ const value = argv[index + 1];
2007
+ const port = Number.parseInt(value ?? "", 10);
2008
+ if (Number.isInteger(port) && port > 0 && port < 65536) {
2009
+ options.port = port;
2010
+ index += 1;
2011
+ }
2012
+ } else if (arg === "--pm") {
2013
+ const value = argv[index + 1];
2014
+ if (value === "npm" || value === "pnpm" || value === "bun") {
2015
+ options.pm = value;
2016
+ index += 1;
2017
+ }
2018
+ } else if (arg === "--help" || arg === "-h") {
2019
+ options.open = false;
2020
+ positional.length = 0;
2021
+ break;
2022
+ } else if (!arg.startsWith("--")) positional.push(arg);
2023
+ }
2024
+ const [target] = positional;
2025
+ return {
2026
+ open: options.open,
2027
+ port: options.port,
2028
+ pm: options.pm,
2029
+ skipInstall: options.skipInstall,
2030
+ force: options.force,
2031
+ targetDir: target
2032
+ };
2033
+ };
2034
+ const readDependencyRange = async () => {
2035
+ const packageJsonUrl = new URL("../package.json", import.meta.url);
2036
+ try {
2037
+ const parsed = JSON.parse(await readFile(packageJsonUrl, "utf8"));
2038
+ if (typeof parsed.version === "string" && /^\d/u.test(parsed.version)) return `^${parsed.version}`;
2039
+ } catch {}
2040
+ return "latest";
2041
+ };
2042
+ const openBrowser = async (url) => {
2043
+ const platform = process.platform;
2044
+ spawn(platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open", platform === "win32" ? [
2045
+ "/c",
2046
+ "start",
2047
+ "",
2048
+ url
2049
+ ] : platform === "darwin" ? [url] : [url], {
2050
+ stdio: "ignore",
2051
+ detached: true,
2052
+ windowsHide: true
2053
+ }).unref();
2054
+ };
2055
+ const runWebAdapter = async (options) => {
2056
+ ensureLoopbackNoProxy();
2057
+ const invocationDir = process.env.INIT_CWD || process.cwd();
2058
+ /** Read a persisted wizard draft for a port (best-effort). */
2059
+ const readDraft = async (port) => {
2060
+ try {
2061
+ const raw = await readFile(join(tmpdir(), `create-opentray-draft-${port ?? 0}.json`), "utf8");
2062
+ const parsed = JSON.parse(raw);
2063
+ if (parsed.form === void 0) return void 0;
2064
+ return {
2065
+ form: parsed.form,
2066
+ command: parsed.command
2067
+ };
2068
+ } catch {
2069
+ return;
2070
+ }
2071
+ };
2072
+ const dependencyRange = await readDependencyRange();
2073
+ const draftSeed = await readDraft(options.port);
2074
+ const server = await createWizardServer((emit) => {
2075
+ const session = createWizardSession({
2076
+ cwd: invocationDir,
2077
+ skipInstall: false,
2078
+ force: false,
2079
+ dependencyRange,
2080
+ emit
2081
+ });
2082
+ if (draftSeed !== void 0) {
2083
+ session.updateForm(draftSeed.form);
2084
+ if (draftSeed.command !== void 0 && draftSeed.command.length > 0) session.prime(draftSeed.command);
2085
+ }
2086
+ return session;
2087
+ }, options.port === void 0 ? {} : { port: options.port });
2088
+ console.log(`create-opentray wizard: ${server.url}`);
2089
+ console.log(`working directory: ${invocationDir}`);
2090
+ if (options.open) await openBrowser(server.url);
2091
+ const shutdown = () => {
2092
+ (async () => {
2093
+ await server.session.stop();
2094
+ await server.close();
2095
+ process.exit(0);
2096
+ })();
2097
+ };
2098
+ process.once("SIGINT", shutdown);
2099
+ process.once("SIGTERM", shutdown);
2100
+ await new Promise(() => {});
2101
+ return 0;
2102
+ };
2103
+ const main = async (argv) => {
2104
+ const legacy = parseWizardCli(argv.filter((arg) => !arg.startsWith("--") || [
2105
+ "--no-open",
2106
+ "--port",
2107
+ "--pm",
2108
+ "--skip-install",
2109
+ "--force",
2110
+ "--help",
2111
+ "-h"
2112
+ ].includes(arg)));
2113
+ return dispatchCli(argv, {
2114
+ streams: consoleStreams,
2115
+ runWeb: async (webOptions) => runWebAdapter({
2116
+ ...webOptions,
2117
+ ...webOptions.port === void 0 && legacy.port !== void 0 ? { port: legacy.port } : {},
2118
+ open: webOptions.open && legacy.open
2119
+ })
2120
+ });
2121
+ };
2122
+ const isMainModule = () => {
2123
+ const entryPath = process.argv[1];
2124
+ if (entryPath === void 0) return false;
2125
+ const sameFile = (a, b) => {
2126
+ try {
2127
+ return realpathSync(resolve(a)) === realpathSync(resolve(b));
2128
+ } catch {
2129
+ return resolve(a) === resolve(b);
2130
+ }
2131
+ };
2132
+ const modulePath = fileURLToPath(import.meta.url);
2133
+ if (sameFile(entryPath, modulePath)) return true;
2134
+ return sameFile(entryPath, modulePath.replace(/bin-[^/]*\.mjs$/u, "bin.mjs"));
2135
+ };
2136
+ if (isMainModule()) main(process.argv.slice(2)).then((code) => {
2137
+ process.exitCode = code;
2138
+ });
2139
+ //#endregion
2140
+ export { isAuthorized as a, createWizardServer as i, openBrowser as n, isLoopbackHost as o, parseWizardCli as r, createWizardSession as s, main as t };
2141
+
2142
+ //# sourceMappingURL=bin-sZB5nWq0.mjs.map