@moneysiren/cli 0.1.0-alpha.0 → 0.1.0-alpha.10
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 +15 -4
- package/dist/apps/cli/src/cli.d.ts +4 -1
- package/dist/apps/cli/src/cli.js +20 -3
- package/dist/apps/cli/src/commands/install.js +134 -6
- package/dist/apps/cli/src/commands/modes.js +18 -10
- package/dist/apps/cli/src/commands/runtime.d.ts +5 -0
- package/dist/apps/cli/src/commands/runtime.js +366 -0
- package/dist/apps/cli/src/desktop-runtime.d.ts +54 -0
- package/dist/apps/cli/src/desktop-runtime.js +720 -0
- package/dist/apps/cli/src/home.js +27 -0
- package/dist/apps/cli/src/index.js +0 -0
- package/dist/apps/cli/src/postinstall.js +1 -1
- package/dist/apps/cli/src/release-installer.d.ts +57 -0
- package/dist/apps/cli/src/release-installer.js +432 -0
- package/dist/apps/cli/src/runtime-adapter.js +1 -1
- package/dist/apps/cli/src/slash.js +27 -0
- package/dist/apps/cli/src/version.d.ts +2 -0
- package/dist/apps/cli/src/version.js +2 -0
- package/dist/packages/config/src/load.js +3 -0
- package/dist/packages/config/src/schema.d.ts +3 -0
- package/dist/packages/config/src/schema.js +3 -0
- package/dist/packages/local-api/src/server.js +1 -1
- package/dist/packages/view-model/src/hud-model.d.ts +74 -0
- package/dist/packages/view-model/src/hud-model.js +295 -0
- package/dist/packages/view-model/src/index.d.ts +5 -2
- package/dist/packages/view-model/src/index.js +4 -1
- package/dist/packages/view-model/src/notification-preferences-model.d.ts +30 -2
- package/dist/packages/view-model/src/notification-preferences-model.js +183 -1
- package/dist/packages/view-model/src/notification-preferences.d.ts +1 -1
- package/dist/packages/view-model/src/notification-preferences.js +1 -1
- package/dist/packages/view-model/src/sync-state.d.ts +47 -0
- package/dist/packages/view-model/src/sync-state.js +140 -0
- package/dist/packages/view-model/src/usage-progress.d.ts +22 -0
- package/dist/packages/view-model/src/usage-progress.js +57 -0
- package/dist/packages/view-model/src/view-model.d.ts +22 -0
- package/dist/packages/view-model/src/view-model.js +142 -0
- package/package.json +3 -2
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, extname, join, posix, resolve, win32 } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { resolveReleaseInstallDir } from "./release-installer.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DEFAULT_WEB_PORT = 3000;
|
|
10
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 30_000;
|
|
11
|
+
const DESKTOP_STATE_ENV_KEY = "MONEYSIREN_DESKTOP_RUNTIME_STATE_PATH";
|
|
12
|
+
const STOP_TIMEOUT_MS = 3_000;
|
|
13
|
+
export function createFallbackDesktopRuntimeAdapter(context) {
|
|
14
|
+
return {
|
|
15
|
+
async startWebRuntime(options) {
|
|
16
|
+
const port = options.port ?? configuredPort(context.env);
|
|
17
|
+
const dashboardUrl = `http://127.0.0.1:${port}/ko/dashboard/overview`;
|
|
18
|
+
const healthUrl = `http://127.0.0.1:${port}/api/local/health`;
|
|
19
|
+
if (await isWebRuntimeHealthy(healthUrl, context.fetch)) {
|
|
20
|
+
const status = await readDesktopRuntimeStatus(context);
|
|
21
|
+
return {
|
|
22
|
+
status: "running",
|
|
23
|
+
dashboardUrl,
|
|
24
|
+
...(status.web.status === "running" && status.web.pid !== undefined ? { pid: status.web.pid } : {}),
|
|
25
|
+
notes: ["Existing local dashboard runtime is healthy."],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const startScript = await resolveWebRuntimeStartScript(context);
|
|
29
|
+
if (startScript.status === "unavailable") {
|
|
30
|
+
return startScript;
|
|
31
|
+
}
|
|
32
|
+
const child = spawn(process.execPath, [startScript.path], {
|
|
33
|
+
cwd: dirname(startScript.path),
|
|
34
|
+
detached: true,
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
...context.env,
|
|
38
|
+
HOSTNAME: "127.0.0.1",
|
|
39
|
+
PORT: String(port),
|
|
40
|
+
},
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
child.unref();
|
|
45
|
+
if (!await waitForWebRuntime(healthUrl, context.fetch, DEFAULT_HEALTH_TIMEOUT_MS)) {
|
|
46
|
+
return {
|
|
47
|
+
status: "unavailable",
|
|
48
|
+
reason: "Started the local web runtime, but the health check did not become ready.",
|
|
49
|
+
guidance: [
|
|
50
|
+
`Check ${dashboardUrl} in your browser.`,
|
|
51
|
+
"If the port is already in use, run `msiren start --port <port>`.",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (child.pid !== undefined) {
|
|
56
|
+
await updateDesktopRuntimeState(context, (state) => ({
|
|
57
|
+
...state,
|
|
58
|
+
web: {
|
|
59
|
+
pid: child.pid,
|
|
60
|
+
port,
|
|
61
|
+
dashboardUrl,
|
|
62
|
+
startedAt: new Date().toISOString(),
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
status: "started",
|
|
68
|
+
dashboardUrl,
|
|
69
|
+
...(child.pid === undefined ? {} : { pid: child.pid }),
|
|
70
|
+
notes: startScript.notes,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
async startHud(options) {
|
|
74
|
+
const status = await readDesktopRuntimeStatus(context);
|
|
75
|
+
if (status.hud.status === "running" && status.hud.pid !== undefined) {
|
|
76
|
+
return {
|
|
77
|
+
status: "opened",
|
|
78
|
+
executablePath: status.hud.detail,
|
|
79
|
+
pid: status.hud.pid,
|
|
80
|
+
notes: ["Existing desktop HUD shell is already running."],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const executable = await resolveDesktopExecutable(context);
|
|
84
|
+
if (isUnavailable(executable)) {
|
|
85
|
+
return executable;
|
|
86
|
+
}
|
|
87
|
+
const child = spawn(executable.command, executable.args, {
|
|
88
|
+
...(executable.cwd === undefined ? {} : { cwd: executable.cwd }),
|
|
89
|
+
detached: true,
|
|
90
|
+
env: {
|
|
91
|
+
...process.env,
|
|
92
|
+
...context.env,
|
|
93
|
+
MONEYSIREN_DESKTOP_MODE: "hud",
|
|
94
|
+
MONEYSIREN_WEB_URL: `http://127.0.0.1:${options.port ?? configuredPort(context.env)}`,
|
|
95
|
+
},
|
|
96
|
+
stdio: "ignore",
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
});
|
|
99
|
+
child.unref();
|
|
100
|
+
if (child.pid !== undefined) {
|
|
101
|
+
await updateDesktopRuntimeState(context, (state) => ({
|
|
102
|
+
...state,
|
|
103
|
+
hud: {
|
|
104
|
+
executablePath: executable.executablePath,
|
|
105
|
+
pid: child.pid,
|
|
106
|
+
startedAt: new Date().toISOString(),
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
status: "started",
|
|
112
|
+
executablePath: executable.executablePath,
|
|
113
|
+
...(child.pid === undefined ? {} : { pid: child.pid }),
|
|
114
|
+
notes: ["Desktop HUD shell launched with MONEYSIREN_DESKTOP_MODE=hud."],
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
async status() {
|
|
118
|
+
return readDesktopRuntimeStatus(context);
|
|
119
|
+
},
|
|
120
|
+
async stop(options) {
|
|
121
|
+
return stopDesktopRuntime(context, options);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function readDesktopRuntimeStatus(context) {
|
|
126
|
+
const statePath = resolveDesktopRuntimeStatePath(context);
|
|
127
|
+
const state = await readDesktopRuntimeState(context);
|
|
128
|
+
const port = configuredPort(context.env);
|
|
129
|
+
const webHealthUrl = `http://127.0.0.1:${port}/api/local/health`;
|
|
130
|
+
const web = await processStatus({
|
|
131
|
+
context,
|
|
132
|
+
detail: state?.web?.dashboardUrl ?? `http://127.0.0.1:${port}`,
|
|
133
|
+
healthUrl: webHealthUrl,
|
|
134
|
+
process: state?.web,
|
|
135
|
+
target: "web",
|
|
136
|
+
});
|
|
137
|
+
const hud = await processStatus({
|
|
138
|
+
context,
|
|
139
|
+
detail: state?.hud?.executablePath ?? "No managed HUD process recorded.",
|
|
140
|
+
process: state?.hud,
|
|
141
|
+
target: "hud",
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
statePath,
|
|
145
|
+
web,
|
|
146
|
+
hud,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function stopDesktopRuntime(context, options) {
|
|
150
|
+
const state = await readDesktopRuntimeState(context);
|
|
151
|
+
const results = [];
|
|
152
|
+
let nextState = state ?? emptyDesktopRuntimeState();
|
|
153
|
+
if (options.web) {
|
|
154
|
+
const result = state?.web === undefined && await isWebRuntimeHealthy(`http://127.0.0.1:${configuredPort(context.env)}/api/local/health`, context.fetch)
|
|
155
|
+
? {
|
|
156
|
+
target: "web",
|
|
157
|
+
status: "not-managed",
|
|
158
|
+
detail: "A local dashboard runtime is reachable, but no MoneySiren CLI PID is recorded. It was not stopped.",
|
|
159
|
+
}
|
|
160
|
+
: await stopManagedProcess("web", state?.web);
|
|
161
|
+
results.push(result);
|
|
162
|
+
if (result.status !== "failed") {
|
|
163
|
+
const { web: _web, ...rest } = nextState;
|
|
164
|
+
nextState = rest;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (options.hud) {
|
|
168
|
+
const result = await stopManagedProcess("hud", state?.hud);
|
|
169
|
+
results.push(result);
|
|
170
|
+
if (result.status !== "failed") {
|
|
171
|
+
const { hud: _hud, ...rest } = nextState;
|
|
172
|
+
nextState = rest;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await writeOrRemoveDesktopRuntimeState(context, nextState);
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
async function processStatus(input) {
|
|
179
|
+
if (input.process === undefined) {
|
|
180
|
+
if (input.target === "web" && input.healthUrl !== undefined && await isWebRuntimeHealthy(input.healthUrl, input.context.fetch)) {
|
|
181
|
+
return {
|
|
182
|
+
target: input.target,
|
|
183
|
+
status: "not-managed",
|
|
184
|
+
detail: `${input.detail} is reachable, but no MoneySiren CLI PID is recorded.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
target: input.target,
|
|
189
|
+
status: "not-running",
|
|
190
|
+
detail: input.detail,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!isProcessAlive(input.process.pid)) {
|
|
194
|
+
return {
|
|
195
|
+
target: input.target,
|
|
196
|
+
status: "stale",
|
|
197
|
+
pid: input.process.pid,
|
|
198
|
+
detail: input.detail,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
target: input.target,
|
|
203
|
+
status: "running",
|
|
204
|
+
pid: input.process.pid,
|
|
205
|
+
detail: input.detail,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async function stopManagedProcess(target, processRecord) {
|
|
209
|
+
if (processRecord === undefined) {
|
|
210
|
+
return {
|
|
211
|
+
target,
|
|
212
|
+
status: "not-running",
|
|
213
|
+
detail: `No managed ${target} process is recorded.`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (!isProcessAlive(processRecord.pid)) {
|
|
217
|
+
return {
|
|
218
|
+
target,
|
|
219
|
+
status: "stale",
|
|
220
|
+
pid: processRecord.pid,
|
|
221
|
+
detail: `Removed stale ${target} process record.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (processRecord.pid === process.pid) {
|
|
225
|
+
return {
|
|
226
|
+
target,
|
|
227
|
+
status: "failed",
|
|
228
|
+
pid: processRecord.pid,
|
|
229
|
+
detail: "Refusing to stop the current CLI process.",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
process.kill(processRecord.pid, "SIGTERM");
|
|
234
|
+
await waitForProcessExit(processRecord.pid, STOP_TIMEOUT_MS);
|
|
235
|
+
if (isProcessAlive(processRecord.pid)) {
|
|
236
|
+
return {
|
|
237
|
+
target,
|
|
238
|
+
status: "failed",
|
|
239
|
+
pid: processRecord.pid,
|
|
240
|
+
detail: `${target} process did not exit after SIGTERM.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
target,
|
|
245
|
+
status: "stopped",
|
|
246
|
+
pid: processRecord.pid,
|
|
247
|
+
detail: `${target} process stopped.`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (isNodeError(error) && error.code === "ESRCH") {
|
|
252
|
+
return {
|
|
253
|
+
target,
|
|
254
|
+
status: "stale",
|
|
255
|
+
pid: processRecord.pid,
|
|
256
|
+
detail: `Removed stale ${target} process record.`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
target,
|
|
261
|
+
status: "failed",
|
|
262
|
+
pid: processRecord.pid,
|
|
263
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function resolveWebRuntimeStartScript(context) {
|
|
268
|
+
const configured = trimToNull(context.env.MONEYSIREN_WEB_RUNTIME_DIR);
|
|
269
|
+
if (configured !== null) {
|
|
270
|
+
const configuredStart = await findStartScript(resolve(context.cwd, configured));
|
|
271
|
+
if (configuredStart !== null) {
|
|
272
|
+
return {
|
|
273
|
+
status: "ready",
|
|
274
|
+
path: configuredStart,
|
|
275
|
+
notes: ["Using MONEYSIREN_WEB_RUNTIME_DIR."],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
status: "unavailable",
|
|
280
|
+
reason: "MONEYSIREN_WEB_RUNTIME_DIR does not contain a MoneySiren web runtime.",
|
|
281
|
+
guidance: [
|
|
282
|
+
"Point MONEYSIREN_WEB_RUNTIME_DIR at the extracted moneysiren-web-runtime directory.",
|
|
283
|
+
"Or run `msiren install --web` and then `msiren start`.",
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const installDir = resolveReleaseInstallDir({ env: context.env });
|
|
288
|
+
const extractedRoot = join(installDir, "web-runtime");
|
|
289
|
+
const existingStart = await findStartScript(extractedRoot) ?? await findStartScript(join(installDir, "moneysiren-web-runtime"));
|
|
290
|
+
if (existingStart !== null) {
|
|
291
|
+
return {
|
|
292
|
+
status: "ready",
|
|
293
|
+
path: existingStart,
|
|
294
|
+
notes: ["Using previously extracted GitHub Release web runtime."],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const manifest = await readInstallManifest(installDir);
|
|
298
|
+
const webAsset = manifest?.assets.find((asset) => asset.surface === "web");
|
|
299
|
+
if (webAsset === undefined) {
|
|
300
|
+
return {
|
|
301
|
+
status: "unavailable",
|
|
302
|
+
reason: "No installed web runtime asset was found.",
|
|
303
|
+
guidance: [
|
|
304
|
+
"Install the release web runtime first: `msiren install --web` or `msiren install --all`.",
|
|
305
|
+
"If you already extracted it manually, set MONEYSIREN_WEB_RUNTIME_DIR to that directory.",
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (!await pathExists(webAsset.path)) {
|
|
310
|
+
return {
|
|
311
|
+
status: "unavailable",
|
|
312
|
+
reason: "The installed web runtime archive listed in the manifest is missing.",
|
|
313
|
+
guidance: [
|
|
314
|
+
"Run `msiren install --web` again.",
|
|
315
|
+
"If you moved the runtime, set MONEYSIREN_WEB_RUNTIME_DIR to the extracted directory.",
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await mkdir(extractedRoot, { recursive: true });
|
|
321
|
+
await execFileAsync("tar", ["-xzf", webAsset.path, "-C", extractedRoot], {
|
|
322
|
+
windowsHide: true,
|
|
323
|
+
timeout: 120_000,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
return {
|
|
328
|
+
status: "unavailable",
|
|
329
|
+
reason: `Could not extract the installed web runtime archive: ${errorMessage(error)}`,
|
|
330
|
+
guidance: [
|
|
331
|
+
"Install a system tar command, or extract the moneysiren-web-runtime archive manually.",
|
|
332
|
+
"Then set MONEYSIREN_WEB_RUNTIME_DIR to the extracted directory and rerun `msiren start`.",
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const extractedStart = await findStartScript(extractedRoot);
|
|
337
|
+
if (extractedStart === null) {
|
|
338
|
+
return {
|
|
339
|
+
status: "unavailable",
|
|
340
|
+
reason: "The extracted web runtime did not contain start.mjs.",
|
|
341
|
+
guidance: [
|
|
342
|
+
"Run `msiren install --web` again.",
|
|
343
|
+
"If this repeats, the GitHub Release web runtime asset is incomplete.",
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
status: "ready",
|
|
349
|
+
path: extractedStart,
|
|
350
|
+
notes: ["Extracted GitHub Release web runtime automatically."],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
async function resolveDesktopExecutable(context) {
|
|
354
|
+
const configured = trimToNull(context.env.MONEYSIREN_DESKTOP_APP);
|
|
355
|
+
if (configured !== null) {
|
|
356
|
+
const executable = await executableFromPath(resolve(context.cwd, configured), true);
|
|
357
|
+
if (executable !== null) {
|
|
358
|
+
return executable;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
status: "unavailable",
|
|
362
|
+
reason: "MONEYSIREN_DESKTOP_APP does not point to a runnable MoneySiren desktop app.",
|
|
363
|
+
guidance: [
|
|
364
|
+
"Set MONEYSIREN_DESKTOP_APP to the installed MoneySiren executable or macOS .app bundle.",
|
|
365
|
+
"Or run `msiren install --hud` and use a release desktop artifact.",
|
|
366
|
+
],
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const installedExecutable = await findInstalledDesktopApp(context.env);
|
|
370
|
+
if (installedExecutable !== null) {
|
|
371
|
+
return installedExecutable;
|
|
372
|
+
}
|
|
373
|
+
const installDir = resolveReleaseInstallDir({ env: context.env });
|
|
374
|
+
const manifest = await readInstallManifest(installDir);
|
|
375
|
+
const hudAsset = manifest?.assets.find((asset) => asset.surface === "hud");
|
|
376
|
+
if (hudAsset === undefined) {
|
|
377
|
+
return {
|
|
378
|
+
status: "unavailable",
|
|
379
|
+
reason: "No installed HUD desktop artifact was found.",
|
|
380
|
+
guidance: [
|
|
381
|
+
"Install the desktop artifact first: `msiren install --hud` or `msiren install --all`.",
|
|
382
|
+
"If MoneySiren is already installed, set MONEYSIREN_DESKTOP_APP to the app path.",
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (!await pathExists(hudAsset.path)) {
|
|
387
|
+
return {
|
|
388
|
+
status: "unavailable",
|
|
389
|
+
reason: "The installed HUD artifact listed in the manifest is missing.",
|
|
390
|
+
guidance: [
|
|
391
|
+
"Run `msiren install --hud` again.",
|
|
392
|
+
"If the desktop app is installed elsewhere, set MONEYSIREN_DESKTOP_APP to that path.",
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const executable = await executableFromPath(hudAsset.path, false);
|
|
397
|
+
if (executable !== null) {
|
|
398
|
+
return executable;
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
status: "unavailable",
|
|
402
|
+
reason: "The installed HUD artifact is an installer or archive, not a directly runnable desktop shell.",
|
|
403
|
+
guidance: [
|
|
404
|
+
"Run the downloaded desktop installer once, then rerun `msiren hud`.",
|
|
405
|
+
"If the installed app is not found automatically, set MONEYSIREN_DESKTOP_APP to the executable or .app path.",
|
|
406
|
+
"Portable desktop artifacts can be launched directly by `msiren hud` when published.",
|
|
407
|
+
],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
async function executableFromPath(path, allowInstaller) {
|
|
411
|
+
const pathStat = await stat(path).catch(() => null);
|
|
412
|
+
if (pathStat === null) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (process.platform === "darwin" && pathStat.isDirectory() && path.endsWith(".app")) {
|
|
416
|
+
const executable = await findMacAppExecutable(path);
|
|
417
|
+
return executable === null
|
|
418
|
+
? null
|
|
419
|
+
: {
|
|
420
|
+
command: executable,
|
|
421
|
+
args: [],
|
|
422
|
+
executablePath: path,
|
|
423
|
+
cwd: dirname(executable),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (pathStat.isDirectory()) {
|
|
427
|
+
const startScript = await findStartScript(path);
|
|
428
|
+
return startScript === null
|
|
429
|
+
? null
|
|
430
|
+
: {
|
|
431
|
+
command: process.execPath,
|
|
432
|
+
args: [startScript],
|
|
433
|
+
executablePath: startScript,
|
|
434
|
+
cwd: dirname(startScript),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
if (process.platform === "darwin" && /\.tar\.gz$/i.test(path)) {
|
|
438
|
+
const extractRoot = join(dirname(path), "desktop");
|
|
439
|
+
await mkdir(extractRoot, { recursive: true });
|
|
440
|
+
await execFileAsync("tar", ["-xzf", path, "-C", extractRoot], {
|
|
441
|
+
windowsHide: true,
|
|
442
|
+
timeout: 120_000,
|
|
443
|
+
}).catch(() => undefined);
|
|
444
|
+
const app = await findFirstMacApp(extractRoot);
|
|
445
|
+
return app === null ? null : executableFromPath(app, allowInstaller);
|
|
446
|
+
}
|
|
447
|
+
if (process.platform === "win32" && /\.(exe)$/i.test(path)) {
|
|
448
|
+
const fileName = basename(path).toLowerCase();
|
|
449
|
+
if (!allowInstaller && /(setup|install|installer)/i.test(fileName)) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
command: path,
|
|
454
|
+
args: [],
|
|
455
|
+
executablePath: path,
|
|
456
|
+
cwd: dirname(path),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
async function findInstalledDesktopApp(env) {
|
|
462
|
+
if (process.platform === "win32") {
|
|
463
|
+
const roots = [
|
|
464
|
+
trimToNull(env.LOCALAPPDATA),
|
|
465
|
+
trimToNull(env.ProgramFiles),
|
|
466
|
+
trimToNull(env["ProgramFiles(x86)"]),
|
|
467
|
+
].filter((value) => value !== null);
|
|
468
|
+
const candidates = roots.flatMap((root) => [
|
|
469
|
+
join(root, "Programs", "MoneySiren Tray", "MoneySiren Tray.exe"),
|
|
470
|
+
join(root, "Programs", "MoneySiren Tray", "moneysiren-tray.exe"),
|
|
471
|
+
join(root, "MoneySiren Tray", "MoneySiren Tray.exe"),
|
|
472
|
+
join(root, "MoneySiren Tray", "moneysiren-tray.exe"),
|
|
473
|
+
]);
|
|
474
|
+
for (const candidate of candidates) {
|
|
475
|
+
const executable = await executableFromPath(candidate, true);
|
|
476
|
+
if (executable !== null) {
|
|
477
|
+
return executable;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (process.platform === "darwin") {
|
|
482
|
+
return await executableFromPath("/Applications/MoneySiren Tray.app", true);
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
async function readInstallManifest(installDir) {
|
|
487
|
+
try {
|
|
488
|
+
const parsed = JSON.parse(await readFile(join(installDir, "install-manifest.json"), "utf8"));
|
|
489
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.assets)) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
assets: parsed.assets.filter(isInstallManifestAsset),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function isInstallManifestAsset(value) {
|
|
501
|
+
return isRecord(value) &&
|
|
502
|
+
(value.surface === "web" || value.surface === "hud") &&
|
|
503
|
+
typeof value.name === "string" &&
|
|
504
|
+
typeof value.path === "string";
|
|
505
|
+
}
|
|
506
|
+
function isUnavailable(value) {
|
|
507
|
+
return "status" in value && value.status === "unavailable";
|
|
508
|
+
}
|
|
509
|
+
async function findStartScript(root) {
|
|
510
|
+
const candidates = [
|
|
511
|
+
join(root, "start.mjs"),
|
|
512
|
+
join(root, "moneysiren-web-runtime", "start.mjs"),
|
|
513
|
+
];
|
|
514
|
+
for (const candidate of candidates) {
|
|
515
|
+
if (await isReadableFile(candidate)) {
|
|
516
|
+
return candidate;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
async function findFirstMacApp(root) {
|
|
522
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
const path = join(root, entry.name);
|
|
525
|
+
if (entry.isDirectory() && extname(entry.name) === ".app") {
|
|
526
|
+
return path;
|
|
527
|
+
}
|
|
528
|
+
if (entry.isDirectory()) {
|
|
529
|
+
const nested = await findFirstMacApp(path);
|
|
530
|
+
if (nested !== null) {
|
|
531
|
+
return nested;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
async function findMacAppExecutable(appPath) {
|
|
538
|
+
const macOsDir = join(appPath, "Contents", "MacOS");
|
|
539
|
+
const entries = await readdir(macOsDir, { withFileTypes: true }).catch(() => []);
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
const path = join(macOsDir, entry.name);
|
|
542
|
+
if (entry.isFile() && await isExecutableFile(path)) {
|
|
543
|
+
return path;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
async function waitForWebRuntime(url, fetchImpl, timeoutMs) {
|
|
549
|
+
const deadline = Date.now() + timeoutMs;
|
|
550
|
+
while (Date.now() < deadline) {
|
|
551
|
+
if (await isWebRuntimeHealthy(url, fetchImpl)) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 1_000));
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
async function isWebRuntimeHealthy(url, fetchImpl) {
|
|
559
|
+
const controller = new AbortController();
|
|
560
|
+
const timeout = setTimeout(() => controller.abort(), 2_000);
|
|
561
|
+
try {
|
|
562
|
+
const response = await fetchImpl(url, {
|
|
563
|
+
cache: "no-store",
|
|
564
|
+
signal: controller.signal,
|
|
565
|
+
});
|
|
566
|
+
return response.ok;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
clearTimeout(timeout);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function isReadableFile(path) {
|
|
576
|
+
try {
|
|
577
|
+
const pathStat = await stat(path);
|
|
578
|
+
if (!pathStat.isFile()) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
await access(path, constants.R_OK);
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function isExecutableFile(path) {
|
|
589
|
+
try {
|
|
590
|
+
const pathStat = await stat(path);
|
|
591
|
+
if (!pathStat.isFile()) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
await access(path, constants.X_OK);
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return process.platform === "win32" && /\.(exe|cmd|bat)$/i.test(path);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function pathExists(path) {
|
|
602
|
+
try {
|
|
603
|
+
await access(path, constants.F_OK);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function readDesktopRuntimeState(context) {
|
|
611
|
+
try {
|
|
612
|
+
const parsed = JSON.parse(await readFile(resolveDesktopRuntimeStatePath(context), "utf8"));
|
|
613
|
+
return parseDesktopRuntimeState(parsed);
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function updateDesktopRuntimeState(context, update) {
|
|
620
|
+
await writeOrRemoveDesktopRuntimeState(context, update(await readDesktopRuntimeState(context) ?? emptyDesktopRuntimeState()));
|
|
621
|
+
}
|
|
622
|
+
async function writeOrRemoveDesktopRuntimeState(context, state) {
|
|
623
|
+
const statePath = resolveDesktopRuntimeStatePath(context);
|
|
624
|
+
if (state.web === undefined && state.hud === undefined) {
|
|
625
|
+
await rm(statePath, { force: true });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const nextState = {
|
|
629
|
+
...state,
|
|
630
|
+
updatedAt: new Date().toISOString(),
|
|
631
|
+
};
|
|
632
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
633
|
+
await writeFile(statePath, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
634
|
+
}
|
|
635
|
+
function resolveDesktopRuntimeStatePath(context) {
|
|
636
|
+
const configured = trimToNull(context.env[DESKTOP_STATE_ENV_KEY]);
|
|
637
|
+
if (configured !== null) {
|
|
638
|
+
return resolve(context.cwd, configured);
|
|
639
|
+
}
|
|
640
|
+
if (process.platform === "win32") {
|
|
641
|
+
return win32.join(trimToNull(context.env.APPDATA) ?? win32.join(resolveHomeDirectory(context.env), "AppData", "Roaming"), "MoneySiren", "desktop-runtime.json");
|
|
642
|
+
}
|
|
643
|
+
if (process.platform === "darwin") {
|
|
644
|
+
return posix.join(resolveHomeDirectory(context.env), "Library", "Application Support", "MoneySiren", "desktop-runtime.json");
|
|
645
|
+
}
|
|
646
|
+
return posix.join(trimToNull(context.env.XDG_STATE_HOME) ?? posix.join(resolveHomeDirectory(context.env), ".local", "state"), "moneysiren", "desktop-runtime.json");
|
|
647
|
+
}
|
|
648
|
+
function parseDesktopRuntimeState(value) {
|
|
649
|
+
if (!isRecord(value) || value.version !== 1 || typeof value.updatedAt !== "string") {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const web = parseManagedDesktopProcess(value.web);
|
|
653
|
+
const hud = parseManagedDesktopProcess(value.hud);
|
|
654
|
+
return {
|
|
655
|
+
version: 1,
|
|
656
|
+
updatedAt: value.updatedAt,
|
|
657
|
+
...(web === undefined ? {} : { web }),
|
|
658
|
+
...(hud === undefined ? {} : { hud }),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function parseManagedDesktopProcess(value) {
|
|
662
|
+
if (!isRecord(value) || typeof value.pid !== "number" || typeof value.startedAt !== "string" || !Number.isSafeInteger(value.pid)) {
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
pid: value.pid,
|
|
667
|
+
startedAt: value.startedAt,
|
|
668
|
+
...(typeof value.port === "number" && Number.isSafeInteger(value.port) ? { port: value.port } : {}),
|
|
669
|
+
...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
|
|
670
|
+
...(typeof value.executablePath === "string" ? { executablePath: value.executablePath } : {}),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function emptyDesktopRuntimeState() {
|
|
674
|
+
return {
|
|
675
|
+
version: 1,
|
|
676
|
+
updatedAt: new Date().toISOString(),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function isProcessAlive(pid) {
|
|
680
|
+
if (pid <= 0) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
process.kill(pid, 0);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
return isNodeError(error) && error.code === "EPERM";
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
692
|
+
const deadline = Date.now() + timeoutMs;
|
|
693
|
+
while (Date.now() < deadline) {
|
|
694
|
+
if (!isProcessAlive(pid)) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 100));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function resolveHomeDirectory(env) {
|
|
701
|
+
return trimToNull(env.HOME) ?? trimToNull(env.USERPROFILE) ?? homedir();
|
|
702
|
+
}
|
|
703
|
+
function configuredPort(env) {
|
|
704
|
+
const parsed = Number.parseInt(env.PORT ?? "", 10);
|
|
705
|
+
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : DEFAULT_WEB_PORT;
|
|
706
|
+
}
|
|
707
|
+
function trimToNull(value) {
|
|
708
|
+
const trimmed = value?.trim();
|
|
709
|
+
return trimmed === undefined || trimmed.length === 0 ? null : trimmed;
|
|
710
|
+
}
|
|
711
|
+
function errorMessage(error) {
|
|
712
|
+
return error instanceof Error ? error.message : String(error);
|
|
713
|
+
}
|
|
714
|
+
function isRecord(value) {
|
|
715
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
716
|
+
}
|
|
717
|
+
function isNodeError(value) {
|
|
718
|
+
return value instanceof Error && "code" in value;
|
|
719
|
+
}
|
|
720
|
+
//# sourceMappingURL=desktop-runtime.js.map
|