aios-dashboard 0.2.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.
@@ -0,0 +1,500 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { once } from "node:events";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { open, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ import { parseEnv } from "./env.mjs";
9
+ import { browserCommand, dashboardPath, portableCommand } from "./paths.mjs";
10
+ import { choosePackageManager } from "./prerequisites.mjs";
11
+
12
+ const STATE_DIRECTORY = ".aios-dashboard";
13
+ const STATE_FILE = "runtime.json";
14
+ const LOG_FILE = "dashboard.log";
15
+
16
+ export function parsePort(value = "8080") {
17
+ const port = Number(value);
18
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
19
+ throw new Error(`Invalid port: ${value}`);
20
+ }
21
+ return port;
22
+ }
23
+
24
+ export async function installedDashboardPort(workspace) {
25
+ try {
26
+ const contents = await readFile(
27
+ path.join(dashboardPath(workspace), ".env"),
28
+ "utf8",
29
+ );
30
+ const value = parseEnv(contents).PORT;
31
+ return value ? parsePort(value) : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export async function resolveDashboardPort({
38
+ workspace,
39
+ explicit,
40
+ runtime,
41
+ } = {}) {
42
+ if (explicit !== undefined && explicit !== null) return parsePort(explicit);
43
+ if (runtime?.port !== undefined && runtime?.port !== null)
44
+ return parsePort(runtime.port);
45
+ return (await installedDashboardPort(workspace)) ?? 8080;
46
+ }
47
+
48
+ export function runtimePaths(workspace) {
49
+ const directory = path.join(workspace, STATE_DIRECTORY);
50
+ return {
51
+ directory,
52
+ state: path.join(directory, STATE_FILE),
53
+ log: path.join(directory, LOG_FILE),
54
+ };
55
+ }
56
+
57
+ export async function readRuntime(workspace) {
58
+ try {
59
+ const value = JSON.parse(
60
+ await readFile(runtimePaths(workspace).state, "utf8"),
61
+ );
62
+ if (
63
+ !Number.isInteger(value.pid) ||
64
+ value.pid <= 0 ||
65
+ !Number.isInteger(value.port) ||
66
+ value.port < 1 ||
67
+ value.port > 65_535 ||
68
+ value.url !== `http://127.0.0.1:${value.port}` ||
69
+ typeof value.processIdentity !== "string" ||
70
+ !value.processIdentity ||
71
+ !/^[a-f0-9]{64}$/.test(value.token)
72
+ )
73
+ return null;
74
+ return value;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function processAlive(pid, kill = process.kill) {
81
+ if (!Number.isInteger(pid) || pid <= 0) return false;
82
+ try {
83
+ kill(pid, 0);
84
+ if (process.platform === "linux" && kill === process.kill) {
85
+ try {
86
+ const value = readFileSync(`/proc/${pid}/stat`, "utf8");
87
+ const state = value
88
+ .slice(value.lastIndexOf(")") + 2)
89
+ .trim()
90
+ .split(/\s+/)[0];
91
+ if (state === "Z") return false;
92
+ } catch {
93
+ // A process can exit between kill(0) and reading /proc.
94
+ return false;
95
+ }
96
+ }
97
+ return true;
98
+ } catch (error) {
99
+ return error?.code === "EPERM";
100
+ }
101
+ }
102
+
103
+ export function processIdentity(
104
+ pid,
105
+ { platform = process.platform, read = readFileSync, run = spawnSync } = {},
106
+ ) {
107
+ try {
108
+ if (platform === "linux") {
109
+ const value = read(`/proc/${pid}/stat`, "utf8");
110
+ const fields = value
111
+ .slice(value.lastIndexOf(")") + 2)
112
+ .trim()
113
+ .split(/\s+/);
114
+ const startedAtClockTick = fields[19];
115
+ return startedAtClockTick ? `linux:${startedAtClockTick}` : null;
116
+ }
117
+ if (platform === "win32") {
118
+ const result = run(
119
+ "powershell.exe",
120
+ [
121
+ "-NoProfile",
122
+ "-NonInteractive",
123
+ "-Command",
124
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CreationDate.ToUniversalTime().Ticks`,
125
+ ],
126
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], shell: false },
127
+ );
128
+ const value = result.status === 0 ? result.stdout.trim() : "";
129
+ return value ? `win32:${value}` : null;
130
+ }
131
+ const result = run("ps", ["-p", String(pid), "-o", "lstart="], {
132
+ encoding: "utf8",
133
+ stdio: ["ignore", "pipe", "ignore"],
134
+ shell: false,
135
+ });
136
+ const value = result.status === 0 ? result.stdout.trim() : "";
137
+ return value ? `${platform}:${value}` : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ export async function dashboardEndpointReady(url, fetchImpl = fetch) {
144
+ try {
145
+ const healthUrl = new URL("/_agent-native/health", url);
146
+ const response = await fetchImpl(healthUrl, {
147
+ method: "GET",
148
+ redirect: "manual",
149
+ signal: AbortSignal.timeout(1_500),
150
+ });
151
+ if (!response.ok) return false;
152
+ const result = await response.json();
153
+ return (
154
+ result?.ok === true &&
155
+ result.ready === true &&
156
+ result.db === true &&
157
+ typeof result.ms === "number" &&
158
+ typeof result.database?.configured === "boolean" &&
159
+ typeof result.database?.source === "string" &&
160
+ typeof result.database?.dialect === "string"
161
+ );
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ function tokensMatch(actual, expected) {
168
+ if (typeof actual !== "string" || typeof expected !== "string") return false;
169
+ const left = Buffer.from(actual);
170
+ const right = Buffer.from(expected);
171
+ return left.length === right.length && timingSafeEqual(left, right);
172
+ }
173
+
174
+ export async function dashboardRuntimeOwned(url, token, fetchImpl = fetch) {
175
+ try {
176
+ const identityUrl = new URL("/api/aios-dashboard-runtime", url);
177
+ const response = await fetchImpl(identityUrl, {
178
+ method: "GET",
179
+ redirect: "manual",
180
+ signal: AbortSignal.timeout(1_500),
181
+ });
182
+ if (!response.ok) return false;
183
+ const result = await response.json();
184
+ return tokensMatch(result?.token, token);
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ export async function dashboardStatus(
191
+ workspace,
192
+ {
193
+ kill = process.kill,
194
+ fetchImpl = fetch,
195
+ identityForPid = processIdentity,
196
+ } = {},
197
+ ) {
198
+ const runtime = await readRuntime(workspace);
199
+ if (!runtime) return { running: false, healthy: false, runtime: null };
200
+ const alive = processAlive(runtime.pid, kill);
201
+ const identityMatches =
202
+ alive && identityForPid(runtime.pid) === runtime.processIdentity;
203
+ const endpointOwned = await dashboardRuntimeOwned(
204
+ runtime.url,
205
+ runtime.token,
206
+ fetchImpl,
207
+ );
208
+ // A package-manager wrapper can exit while its Nitro child remains in the
209
+ // detached process group. The runtime token still proves ownership in that
210
+ // case; a live PID must additionally retain its OS process identity.
211
+ const running = endpointOwned && (!alive || identityMatches);
212
+ const healthy = running
213
+ ? await dashboardEndpointReady(runtime.url, fetchImpl)
214
+ : false;
215
+ return {
216
+ running,
217
+ healthy,
218
+ runtime,
219
+ identityMismatch: alive && !identityMatches,
220
+ tokenMismatch: alive && identityMatches && !endpointOwned,
221
+ endpointOwned,
222
+ };
223
+ }
224
+
225
+ export async function unmanagedDashboardReachable(
226
+ workspace,
227
+ { status = null, fetchImpl = fetch, port: explicitPort } = {},
228
+ ) {
229
+ const current = status ?? (await dashboardStatus(workspace, { fetchImpl }));
230
+ if (current.running) return false;
231
+ const port = await resolveDashboardPort({
232
+ workspace,
233
+ explicit: explicitPort,
234
+ runtime: current.runtime,
235
+ });
236
+ return dashboardEndpointReady(`http://127.0.0.1:${port}`, fetchImpl);
237
+ }
238
+
239
+ async function waitUntilReady({ url, pid, token, timeoutMs = 60_000 }) {
240
+ const deadline = Date.now() + timeoutMs;
241
+ while (Date.now() < deadline) {
242
+ if (!processAlive(pid)) {
243
+ throw new Error("Dashboard stopped before it became ready.");
244
+ }
245
+ if (
246
+ (await dashboardRuntimeOwned(url, token)) &&
247
+ (await dashboardEndpointReady(url))
248
+ ) {
249
+ // Confirm the process survived readiness. This prevents another service
250
+ // already on the port from making an EADDRINUSE child look healthy.
251
+ await new Promise((resolve) => setTimeout(resolve, 300));
252
+ if (!processAlive(pid)) {
253
+ throw new Error(
254
+ "Dashboard stopped immediately after the health check.",
255
+ );
256
+ }
257
+ if (
258
+ (await dashboardRuntimeOwned(url, token)) &&
259
+ (await dashboardEndpointReady(url))
260
+ )
261
+ return;
262
+ }
263
+ await new Promise((resolve) => setTimeout(resolve, 400));
264
+ }
265
+ throw new Error(
266
+ `Dashboard did not become ready within ${Math.ceil(timeoutMs / 1_000)} seconds.`,
267
+ );
268
+ }
269
+
270
+ export function killProcessTree(
271
+ pid,
272
+ signal = "SIGTERM",
273
+ { platform = process.platform, kill = process.kill, run = spawnSync } = {},
274
+ ) {
275
+ if (platform === "win32") {
276
+ const result = run("taskkill", ["/pid", String(pid), "/t", "/f"], {
277
+ stdio: "ignore",
278
+ shell: false,
279
+ });
280
+ return result.status === 0;
281
+ }
282
+ try {
283
+ // The process is launched as a detached process group, so stopping the
284
+ // group also stops the package-manager wrapper and the server it spawned.
285
+ kill(-pid, signal);
286
+ return true;
287
+ } catch {
288
+ try {
289
+ kill(pid, signal);
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+ }
296
+
297
+ async function waitForProcessExit(
298
+ runtime,
299
+ {
300
+ attempts,
301
+ kill = process.kill,
302
+ fetchImpl = fetch,
303
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
304
+ },
305
+ ) {
306
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
307
+ const processStopped = !processAlive(runtime.pid, kill);
308
+ const endpointStopped = !(await dashboardRuntimeOwned(
309
+ runtime.url,
310
+ runtime.token,
311
+ fetchImpl,
312
+ ));
313
+ if (processStopped && endpointStopped) return true;
314
+ await sleep(100);
315
+ }
316
+ return (
317
+ !processAlive(runtime.pid, kill) &&
318
+ !(await dashboardRuntimeOwned(runtime.url, runtime.token, fetchImpl))
319
+ );
320
+ }
321
+
322
+ export async function stopDashboard(
323
+ workspace,
324
+ {
325
+ kill = process.kill,
326
+ stopTree = killProcessTree,
327
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
328
+ identityForPid = processIdentity,
329
+ fetchImpl = fetch,
330
+ } = {},
331
+ ) {
332
+ const paths = runtimePaths(workspace);
333
+ const runtime = await readRuntime(workspace);
334
+ if (!runtime) return { stopped: false, runtime: null };
335
+ let stopped = false;
336
+ const alive = processAlive(runtime.pid, kill);
337
+ if (alive && identityForPid(runtime.pid) !== runtime.processIdentity) {
338
+ throw new Error(
339
+ `Refusing to stop PID ${runtime.pid}: it no longer matches the Dashboard process identity. Runtime state was kept for inspection.`,
340
+ );
341
+ }
342
+ const endpointOwned = await dashboardRuntimeOwned(
343
+ runtime.url,
344
+ runtime.token,
345
+ fetchImpl,
346
+ );
347
+ if (alive && !endpointOwned) {
348
+ throw new Error(
349
+ `Refusing to stop PID ${runtime.pid}: the loopback Dashboard runtime token does not match. Runtime state was kept for inspection.`,
350
+ );
351
+ }
352
+ if (alive || endpointOwned) {
353
+ stopped = stopTree(runtime.pid, "SIGTERM", { kill });
354
+ let exited = await waitForProcessExit(runtime, {
355
+ attempts: 50,
356
+ kill,
357
+ fetchImpl,
358
+ sleep,
359
+ });
360
+ if (!exited) {
361
+ stopTree(runtime.pid, "SIGKILL", { kill });
362
+ exited = await waitForProcessExit(runtime, {
363
+ attempts: 20,
364
+ kill,
365
+ fetchImpl,
366
+ sleep,
367
+ });
368
+ }
369
+ if (!exited) {
370
+ throw new Error(
371
+ `Could not stop AIOS Dashboard process ${runtime.pid}. Runtime state was kept; stop that process and retry.`,
372
+ );
373
+ }
374
+ }
375
+ await rm(paths.state, { force: true });
376
+ return { stopped, runtime };
377
+ }
378
+
379
+ export async function startDashboard({
380
+ workspace,
381
+ port: portInput,
382
+ packageManager = choosePackageManager(),
383
+ timeoutMs = 60_000,
384
+ writeState = writeFile,
385
+ }) {
386
+ const existing = await dashboardStatus(workspace);
387
+ if (existing.running) return { alreadyRunning: true, ...existing.runtime };
388
+
389
+ const target = dashboardPath(workspace);
390
+ if (!existsSync(path.join(target, "package.json"))) {
391
+ throw new Error(
392
+ `No installed Dashboard was found at ${target}. Run \`aios-dashboard init\` first.`,
393
+ );
394
+ }
395
+ const port = await resolveDashboardPort({
396
+ workspace,
397
+ explicit: portInput,
398
+ runtime: existing.runtime,
399
+ });
400
+ if (
401
+ await unmanagedDashboardReachable(workspace, {
402
+ status: existing,
403
+ port,
404
+ })
405
+ ) {
406
+ throw new Error(
407
+ "An AIOS Dashboard is already reachable on the configured port but is not owned by this runtime. Stop it or choose a different --port.",
408
+ );
409
+ }
410
+
411
+ const url = `http://127.0.0.1:${port}`;
412
+ const paths = runtimePaths(workspace);
413
+ await mkdir(paths.directory, { recursive: true });
414
+ const logHandle = await open(paths.log, "a", 0o600);
415
+ const args = packageManager.name === "pnpm" ? ["start"] : ["run", "start"];
416
+ const invocation = portableCommand(packageManager.command, [
417
+ ...(packageManager.commandArgs || []),
418
+ ...args,
419
+ ]);
420
+ const token = randomBytes(32).toString("hex");
421
+ let child;
422
+ try {
423
+ child = spawn(invocation.command, invocation.args, {
424
+ cwd: target,
425
+ detached: true,
426
+ stdio: ["ignore", logHandle.fd, logHandle.fd],
427
+ shell: false,
428
+ env: {
429
+ ...process.env,
430
+ AIOS_DASHBOARD_RUNTIME_TOKEN: token,
431
+ AUTH_DISABLED: "true",
432
+ NODE_ENV: "production",
433
+ HOST: "127.0.0.1",
434
+ NITRO_HOST: "127.0.0.1",
435
+ NITRO_PORT: String(port),
436
+ PORT: String(port),
437
+ },
438
+ });
439
+ await Promise.race([
440
+ once(child, "spawn"),
441
+ once(child, "error").then(([error]) => Promise.reject(error)),
442
+ ]);
443
+ } finally {
444
+ await logHandle.close();
445
+ }
446
+
447
+ if (!child?.pid)
448
+ throw new Error("Dashboard process did not report a process ID.");
449
+ const identity = processIdentity(child.pid);
450
+ if (!identity) {
451
+ killProcessTree(child.pid, "SIGKILL");
452
+ throw new Error(
453
+ "Could not verify the Dashboard process identity; it was stopped safely.",
454
+ );
455
+ }
456
+ child.unref();
457
+ const runtime = {
458
+ pid: child.pid,
459
+ port,
460
+ url,
461
+ startedAt: new Date().toISOString(),
462
+ log: paths.log,
463
+ processIdentity: identity,
464
+ token,
465
+ };
466
+ let stateWritten = false;
467
+ try {
468
+ await writeState(paths.state, `${JSON.stringify(runtime, null, 2)}\n`, {
469
+ mode: 0o600,
470
+ });
471
+ stateWritten = true;
472
+ await waitUntilReady({ url, pid: child.pid, token, timeoutMs });
473
+ } catch (error) {
474
+ // This is the exact child created above, so it is safe to tear the group
475
+ // down even when the server never became ready enough to prove its token.
476
+ killProcessTree(child.pid, "SIGKILL");
477
+ const stopped = await waitForProcessExit(runtime, {
478
+ attempts: 20,
479
+ });
480
+ if (!stopped) {
481
+ throw new Error(
482
+ `${error.message} The failed Dashboard process could not be stopped; ${stateWritten ? `runtime state was kept at ${paths.state}` : "no runtime state could be written"}.`,
483
+ );
484
+ }
485
+ if (stateWritten) await rm(paths.state, { force: true });
486
+ throw new Error(`${error.message} See ${paths.log}`);
487
+ }
488
+ return { alreadyRunning: false, ...runtime };
489
+ }
490
+
491
+ export function openDashboard(url) {
492
+ const invocation = browserCommand(url);
493
+ const child = spawn(invocation.command, invocation.args, {
494
+ detached: true,
495
+ stdio: "ignore",
496
+ shell: false,
497
+ });
498
+ child.once("error", () => undefined);
499
+ child.unref();
500
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The pairing secret, CLI side.
3
+ *
4
+ * This is a deliberate duplicate of `shared/agent-runner-pairing.ts`: the CLI
5
+ * is its own npm package shipped without a build step, so it cannot import the
6
+ * app's TypeScript. `shared/agent-runner-pairing.test.ts` loads both modules
7
+ * and asserts they encode and decode identically, so the copy cannot drift
8
+ * silently.
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ export const PAIRING_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13
+ export const PAIRING_SECRET_BYTES = 30;
14
+ export const PAIRING_CODE_LENGTH = 48;
15
+ export const PAIRING_GROUP_SIZE = 6;
16
+ export const RUNNER_TOKEN_LENGTH = PAIRING_SECRET_BYTES * 2;
17
+ export const PAIRING_LINK_SCHEME = "aios://connect";
18
+
19
+ export function groupPairingCode(code) {
20
+ const groups = [];
21
+ for (let index = 0; index < code.length; index += PAIRING_GROUP_SIZE) {
22
+ groups.push(code.slice(index, index + PAIRING_GROUP_SIZE));
23
+ }
24
+ return groups.join("-");
25
+ }
26
+
27
+ export function normalizePairingCode(input) {
28
+ return String(input ?? "")
29
+ .toUpperCase()
30
+ .replace(/[^0-9A-Z]/g, "")
31
+ .replace(/O/g, "0")
32
+ .replace(/[IL]/g, "1");
33
+ }
34
+
35
+ export function encodePairingCode(bytes) {
36
+ let bits = 0;
37
+ let value = 0;
38
+ let out = "";
39
+ for (const byte of bytes) {
40
+ value = (value << 8) | byte;
41
+ bits += 8;
42
+ while (bits >= 5) {
43
+ out += PAIRING_ALPHABET[(value >>> (bits - 5)) & 31];
44
+ bits -= 5;
45
+ }
46
+ }
47
+ if (bits > 0) out += PAIRING_ALPHABET[(value << (5 - bits)) & 31];
48
+ return out;
49
+ }
50
+
51
+ export function decodePairingCode(input) {
52
+ const normalized = normalizePairingCode(input);
53
+ if (normalized.length !== PAIRING_CODE_LENGTH) return null;
54
+ let bits = 0;
55
+ let value = 0;
56
+ const bytes = [];
57
+ for (const character of normalized) {
58
+ const index = PAIRING_ALPHABET.indexOf(character);
59
+ if (index < 0) return null;
60
+ value = (value << 5) | index;
61
+ bits += 5;
62
+ if (bits >= 8) {
63
+ bytes.push((value >>> (bits - 8)) & 0xff);
64
+ bits -= 8;
65
+ }
66
+ }
67
+ return bytes.length === PAIRING_SECRET_BYTES ? Uint8Array.from(bytes) : null;
68
+ }
69
+
70
+ export function bytesToToken(bytes) {
71
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
72
+ }
73
+
74
+ export function tokenToBytes(token) {
75
+ const trimmed = String(token ?? "").trim().toLowerCase();
76
+ if (!/^[0-9a-f]+$/.test(trimmed) || trimmed.length !== RUNNER_TOKEN_LENGTH) {
77
+ return null;
78
+ }
79
+ const bytes = new Uint8Array(PAIRING_SECRET_BYTES);
80
+ for (let index = 0; index < PAIRING_SECRET_BYTES; index++) {
81
+ bytes[index] = Number.parseInt(trimmed.slice(index * 2, index * 2 + 2), 16);
82
+ }
83
+ return bytes;
84
+ }
85
+
86
+ export function tokenFromPairingCode(code) {
87
+ const bytes = decodePairingCode(code);
88
+ return bytes ? bytesToToken(bytes) : null;
89
+ }
90
+
91
+ export function pairingCodeFromToken(token) {
92
+ const bytes = tokenToBytes(token);
93
+ return bytes ? groupPairingCode(encodePairingCode(bytes)) : null;
94
+ }
95
+
96
+ export function formatPairingLink(origin, code) {
97
+ return `${PAIRING_LINK_SCHEME}?origin=${encodeURIComponent(origin)}&code=${normalizePairingCode(code)}`;
98
+ }
99
+
100
+ /** A fresh runner credential in both spellings. */
101
+ export function generatePairingSecret(random = randomBytes) {
102
+ const bytes = Uint8Array.from(random(PAIRING_SECRET_BYTES));
103
+ return {
104
+ token: bytesToToken(bytes),
105
+ code: groupPairingCode(encodePairingCode(bytes)),
106
+ };
107
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+
3
+ export function workspacePath(input, cwd = process.cwd(), pathApi = path) {
4
+ return pathApi.resolve(cwd, input || ".");
5
+ }
6
+
7
+ export function dashboardPath(workspace, pathApi = path) {
8
+ return pathApi.join(workspace, "dashboard");
9
+ }
10
+
11
+ export function pathEntries(value, pathApi = path) {
12
+ return String(value || "")
13
+ .split(pathApi.delimiter)
14
+ .filter(Boolean);
15
+ }
16
+
17
+ export function executableNames(
18
+ command,
19
+ platform = process.platform,
20
+ // guard:allow-env-credential — executable suffix lookup for the local installer, not a credential
21
+ pathExt = process.env.PATHEXT,
22
+ ) {
23
+ if (platform !== "win32") return [command];
24
+ if (/\.[A-Za-z0-9]+$/.test(command)) return [command];
25
+ const extensions = String(pathExt || ".COM;.EXE;.BAT;.CMD")
26
+ .split(";")
27
+ .filter(Boolean);
28
+ return extensions.map((extension) => `${command}${extension.toLowerCase()}`);
29
+ }
30
+
31
+ export function browserCommand(
32
+ url,
33
+ platform = process.platform,
34
+ env = process.env,
35
+ ) {
36
+ if (platform === "darwin") return { command: "open", args: [url] };
37
+ if (platform === "win32") {
38
+ return {
39
+ command: env.ComSpec || env.COMSPEC || "cmd.exe",
40
+ args: ["/d", "/s", "/c", "start", "", url],
41
+ };
42
+ }
43
+ return { command: "xdg-open", args: [url] };
44
+ }
45
+
46
+ export function portableCommand(
47
+ command,
48
+ args = [],
49
+ platform = process.platform,
50
+ env = process.env,
51
+ ) {
52
+ if (platform !== "win32" || !/\.(?:cmd|bat)$/i.test(command)) {
53
+ return { command, args };
54
+ }
55
+ return {
56
+ command: env.ComSpec || env.COMSPEC || "cmd.exe",
57
+ args: ["/d", "/s", "/c", command, ...args],
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Where `connect` keeps things that must survive a reinstall.
63
+ *
64
+ * The runner credential and the runner's own auth secret live here rather than
65
+ * in the dashboard's `.env`, because `aios-dashboard init` replaces the app
66
+ * directory wholesale on update. A member who reinstalls should not have to
67
+ * re-pair.
68
+ */
69
+ export function dataDir(
70
+ platform = process.platform,
71
+ env = process.env,
72
+ homedir = "",
73
+ ) {
74
+ const home = homedir || env.HOME || env.USERPROFILE || ".";
75
+ if (platform === "win32") {
76
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "aios-dashboard");
77
+ }
78
+ if (platform === "darwin") {
79
+ return path.join(home, "Library", "Application Support", "aios-dashboard");
80
+ }
81
+ return path.join(
82
+ env.XDG_DATA_HOME || path.join(home, ".local", "share"),
83
+ "aios-dashboard",
84
+ );
85
+ }
86
+
87
+ /** GitHub's release asset name for this machine, or null when unsupported. */
88
+ export function cloudflaredAssetName(
89
+ platform = process.platform,
90
+ arch = process.arch,
91
+ ) {
92
+ const architectures = { x64: "amd64", arm64: "arm64", arm: "arm" };
93
+ const cpu = architectures[arch];
94
+ if (!cpu) return null;
95
+ if (platform === "linux") return `cloudflared-linux-${cpu}`;
96
+ if (platform === "darwin") return `cloudflared-darwin-${cpu}.tgz`;
97
+ if (platform === "win32") return `cloudflared-windows-${cpu === "amd64" ? "amd64" : cpu}.exe`;
98
+ return null;
99
+ }
100
+
101
+ /** An absolute candidate path, when something is actually there. */
102
+ export function findExecutableOrNull(candidate, exists) {
103
+ return exists(candidate) ? candidate : null;
104
+ }