@awak-app/simy-cli 0.1.3 → 0.1.5

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,631 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { EventEmitter } from "node:events";
4
+ import {
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ realpath,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { promisify } from "node:util";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ export const CLI_PACKAGE_NAME = "@awak-app/simy-cli";
21
+ export const AUTO_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000;
22
+ const AUTO_UPDATE_INITIAL_DELAY_MS = 15_000;
23
+ const AUTO_UPDATE_IDLE_RETRY_MS = 30_000;
24
+ const UPDATE_LOCK_STALE_MS = 30 * 60 * 1_000;
25
+ const RESTART_HANDOFF_TIMEOUT_MS = 30_000;
26
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+ const NON_IDLE_STATES = new Set([
28
+ "queued",
29
+ "risk_classifying",
30
+ "chartering",
31
+ "dispatching",
32
+ "coding",
33
+ "collecting_evidence",
34
+ "auditing",
35
+ "independent_auditing",
36
+ "checking_pr",
37
+ "re_instructing",
38
+ "waiting_human",
39
+ "blocked",
40
+ ]);
41
+ const NON_IDLE_CONTROL_STATES = new Set([
42
+ "queued",
43
+ "running",
44
+ "paused",
45
+ "waiting_human",
46
+ "stopping",
47
+ ]);
48
+
49
+ export class CliAutoUpdater extends EventEmitter {
50
+ #snapshot;
51
+ #options;
52
+ #registry = null;
53
+ #restartDaemon = null;
54
+ #checkTimer = null;
55
+ #initialTimer = null;
56
+ #idleTimer = null;
57
+ #checking = null;
58
+ #applying = null;
59
+ #stopped = false;
60
+ #registryListener = null;
61
+ #activeWork = 0;
62
+
63
+ constructor(options) {
64
+ super();
65
+ this.#options = options;
66
+ this.#snapshot = {
67
+ state: options.disabled ? "disabled" : "idle",
68
+ current_version: options.currentVersion,
69
+ target_version: null,
70
+ install_mode: options.installMode,
71
+ automatic: Boolean(
72
+ !options.disabled &&
73
+ options.daemon &&
74
+ options.installMode === "global_npm" &&
75
+ options.requestedPort === 0
76
+ ),
77
+ checked_at: null,
78
+ message: options.disabled
79
+ ? "Automatic update checks are disabled."
80
+ : "SIMY will check for CLI updates in the background.",
81
+ action: null,
82
+ error: null,
83
+ };
84
+ }
85
+
86
+ snapshot() {
87
+ return structuredClone(this.#snapshot);
88
+ }
89
+
90
+ acceptsNewWork() {
91
+ return !["updating", "restarting"].includes(this.#snapshot.state);
92
+ }
93
+
94
+ beginWork() {
95
+ if (!this.acceptsNewWork()) return null;
96
+ this.#activeWork += 1;
97
+ let released = false;
98
+ return () => {
99
+ if (released) return;
100
+ released = true;
101
+ this.#activeWork = Math.max(0, this.#activeWork - 1);
102
+ if (this.#activeWork === 0 && this.#snapshot.state === "waiting_for_idle") {
103
+ void this.maybeApply();
104
+ }
105
+ };
106
+ }
107
+
108
+ start({ registry, restartDaemon } = {}) {
109
+ if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
110
+ this.#registry = registry;
111
+ this.#restartDaemon = restartDaemon;
112
+ this.#registryListener = () => {
113
+ if (this.#snapshot.state === "waiting_for_idle") void this.maybeApply();
114
+ };
115
+ this.#registry?.on("change", this.#registryListener);
116
+ this.#initialTimer = setTimeout(() => void this.checkNow(), this.#options.initialDelayMs);
117
+ this.#initialTimer.unref?.();
118
+ this.#checkTimer = setInterval(() => void this.checkNow(), this.#options.checkIntervalMs);
119
+ this.#checkTimer.unref?.();
120
+ return this;
121
+ }
122
+
123
+ stop() {
124
+ this.#stopped = true;
125
+ if (this.#initialTimer) clearTimeout(this.#initialTimer);
126
+ if (this.#checkTimer) clearInterval(this.#checkTimer);
127
+ if (this.#idleTimer) clearInterval(this.#idleTimer);
128
+ if (this.#registryListener) this.#registry?.off("change", this.#registryListener);
129
+ this.#initialTimer = null;
130
+ this.#checkTimer = null;
131
+ this.#idleTimer = null;
132
+ }
133
+
134
+ async checkNow() {
135
+ if (this.#stopped || this.#snapshot.state === "disabled") return this.snapshot();
136
+ if (this.#checking) return this.#checking;
137
+ if (["updating", "restarting"].includes(this.#snapshot.state)) return this.snapshot();
138
+
139
+ this.#checking = this.#performCheck().finally(() => {
140
+ this.#checking = null;
141
+ });
142
+ return this.#checking;
143
+ }
144
+
145
+ async #performCheck() {
146
+ this.#setState("checking", {
147
+ message: "Checking for a newer SIMY CLI version...",
148
+ error: null,
149
+ });
150
+ try {
151
+ const release = await this.#options.checkLatestVersion();
152
+ const targetVersion = String(release?.version || "").trim();
153
+ if (!parseStableVersion(targetVersion)) {
154
+ throw new Error("The npm registry returned an invalid stable CLI version.");
155
+ }
156
+ const checkedAt = new Date(this.#options.now()).toISOString();
157
+ if (compareStableVersions(targetVersion, this.#options.currentVersion) <= 0) {
158
+ this.#clearIdleTimer();
159
+ this.#setState("up_to_date", {
160
+ target_version: null,
161
+ checked_at: checkedAt,
162
+ message: `SIMY CLI ${this.#options.currentVersion} is up to date.`,
163
+ action: null,
164
+ error: null,
165
+ });
166
+ return this.snapshot();
167
+ }
168
+
169
+ const automatic = this.#snapshot.automatic && isPatchUpgrade(
170
+ this.#options.currentVersion,
171
+ targetVersion,
172
+ );
173
+ const action = updateAction(targetVersion);
174
+ if (!automatic) {
175
+ this.#clearIdleTimer();
176
+ this.#setState("update_available", {
177
+ target_version: targetVersion,
178
+ checked_at: checkedAt,
179
+ message: manualUpdateMessage({
180
+ currentVersion: this.#options.currentVersion,
181
+ targetVersion,
182
+ installMode: this.#options.installMode,
183
+ daemon: this.#options.daemon,
184
+ requestedPort: this.#options.requestedPort,
185
+ }),
186
+ action,
187
+ error: null,
188
+ });
189
+ this.#announce();
190
+ return this.snapshot();
191
+ }
192
+
193
+ this.#setState("waiting_for_idle", {
194
+ target_version: targetVersion,
195
+ checked_at: checkedAt,
196
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
197
+ action,
198
+ error: null,
199
+ });
200
+ this.#startIdleTimer();
201
+ this.#announce();
202
+ return this.maybeApply();
203
+ } catch (error) {
204
+ this.#clearIdleTimer();
205
+ this.#setState("check_failed", {
206
+ checked_at: new Date(this.#options.now()).toISOString(),
207
+ message: "SIMY could not check for updates. It will try again later.",
208
+ error: errorMessage(error),
209
+ });
210
+ this.#announce();
211
+ return this.snapshot();
212
+ }
213
+ }
214
+
215
+ async maybeApply() {
216
+ if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
217
+ return this.snapshot();
218
+ }
219
+ if (this.#applying) return this.#applying;
220
+ if (!this.#isIdle()) return this.snapshot();
221
+
222
+ this.#applying = this.#performUpdate().finally(() => {
223
+ this.#applying = null;
224
+ });
225
+ return this.#applying;
226
+ }
227
+
228
+ async #performUpdate() {
229
+ const targetVersion = this.#snapshot.target_version;
230
+ this.#setState("updating", {
231
+ message: `Updating SIMY CLI ${this.#options.currentVersion} to ${targetVersion}...`,
232
+ error: null,
233
+ });
234
+ this.#announce();
235
+
236
+ if (!this.#isIdle()) {
237
+ this.#setState("waiting_for_idle", {
238
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
239
+ });
240
+ return this.snapshot();
241
+ }
242
+
243
+ let releaseLock = null;
244
+ try {
245
+ releaseLock = await this.#options.acquireUpdateLock();
246
+ if (!releaseLock) {
247
+ this.#setState("waiting_for_idle", {
248
+ message: "Another SIMY process is updating the CLI. This process will check again shortly.",
249
+ });
250
+ return this.snapshot();
251
+ }
252
+ if (!this.#isIdle()) {
253
+ this.#setState("waiting_for_idle", {
254
+ message: `SIMY CLI ${targetVersion} is ready and will install when no task is active.`,
255
+ });
256
+ return this.snapshot();
257
+ }
258
+
259
+ await this.#options.installVersion(targetVersion);
260
+ if (typeof this.#restartDaemon !== "function") {
261
+ throw new Error("The daemon restart handoff is unavailable.");
262
+ }
263
+ this.#setState("restarting", {
264
+ message: `SIMY CLI ${targetVersion} is installed. Verifying the restarted daemon...`,
265
+ });
266
+ this.#announce();
267
+ await this.#restartDaemon?.(targetVersion);
268
+ this.#clearIdleTimer();
269
+ this.#setState("updated", {
270
+ message: `SIMY CLI ${targetVersion} is running.`,
271
+ action: null,
272
+ error: null,
273
+ });
274
+ return this.snapshot();
275
+ } catch (error) {
276
+ this.#clearIdleTimer();
277
+ this.#setState("failed", {
278
+ message: `Automatic update failed. SIMY ${this.#options.currentVersion} is still running.`,
279
+ action: updateAction(targetVersion),
280
+ error: errorMessage(error),
281
+ });
282
+ this.#announce();
283
+ return this.snapshot();
284
+ } finally {
285
+ await releaseLock?.();
286
+ }
287
+ }
288
+
289
+ #startIdleTimer() {
290
+ if (this.#idleTimer) return;
291
+ this.#idleTimer = setInterval(() => void this.maybeApply(), this.#options.idleRetryMs);
292
+ this.#idleTimer.unref?.();
293
+ }
294
+
295
+ #isIdle() {
296
+ return this.#activeWork === 0 && isRegistryIdle(this.#registry);
297
+ }
298
+
299
+ #clearIdleTimer() {
300
+ if (this.#idleTimer) clearInterval(this.#idleTimer);
301
+ this.#idleTimer = null;
302
+ }
303
+
304
+ #setState(state, values = {}) {
305
+ this.#snapshot = { ...this.#snapshot, ...values, state };
306
+ this.emit("change", this.snapshot());
307
+ }
308
+
309
+ #announce() {
310
+ if (this.#options.quiet) return;
311
+ const suffix =
312
+ ["update_available", "failed"].includes(this.#snapshot.state) &&
313
+ this.#snapshot.action?.command
314
+ ? ` Run manually: ${this.#snapshot.action.command}`
315
+ : "";
316
+ const detail = this.#snapshot.error ? ` (${this.#snapshot.error})` : "";
317
+ this.#options.logger(`${this.#snapshot.message}${suffix}${detail}`);
318
+ }
319
+ }
320
+
321
+ export async function createCliAutoUpdater({
322
+ daemon,
323
+ interactive,
324
+ requestedPort = 0,
325
+ disabled = false,
326
+ packageRoot = PACKAGE_ROOT,
327
+ dependencies = {},
328
+ } = {}) {
329
+ const packageInfo = dependencies.packageInfo || (await readCliPackageInfo(packageRoot));
330
+ const installMode = dependencies.installMode || (await detectInstallMode({
331
+ packageRoot,
332
+ execFileCommand: dependencies.execFileCommand,
333
+ }));
334
+ const stateRoot = dependencies.stateRoot || defaultStateRoot();
335
+ return new CliAutoUpdater({
336
+ daemon: Boolean(daemon),
337
+ interactive: Boolean(interactive),
338
+ requestedPort,
339
+ disabled,
340
+ currentVersion: packageInfo.version,
341
+ installMode,
342
+ checkLatestVersion: dependencies.checkLatestVersion || checkLatestVersion,
343
+ installVersion:
344
+ dependencies.installVersion ||
345
+ ((version) => installGlobalNpmVersion(version, { execFileCommand: dependencies.execFileCommand })),
346
+ acquireUpdateLock:
347
+ dependencies.acquireUpdateLock || (() => acquireUpdateLock(stateRoot, dependencies)),
348
+ logger: dependencies.logger || console.log,
349
+ quiet: Boolean(interactive),
350
+ now: dependencies.now || Date.now,
351
+ initialDelayMs: dependencies.initialDelayMs ?? AUTO_UPDATE_INITIAL_DELAY_MS,
352
+ checkIntervalMs: dependencies.checkIntervalMs ?? AUTO_UPDATE_CHECK_INTERVAL_MS,
353
+ idleRetryMs: dependencies.idleRetryMs ?? AUTO_UPDATE_IDLE_RETRY_MS,
354
+ });
355
+ }
356
+
357
+ export async function readCliPackageInfo(packageRoot = PACKAGE_ROOT) {
358
+ const payload = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
359
+ const version = String(payload.version || "").trim();
360
+ if (!parseStableVersion(version)) throw new Error("SIMY CLI package.json has an invalid version.");
361
+ return { name: String(payload.name || ""), version };
362
+ }
363
+
364
+ export async function detectInstallMode({
365
+ packageRoot = PACKAGE_ROOT,
366
+ execFileCommand = execFileAsync,
367
+ } = {}) {
368
+ const resolvedRoot = await realpathOrResolve(packageRoot);
369
+ if (resolvedRoot.split(path.sep).includes("_npx")) return "npx";
370
+
371
+ try {
372
+ const { stdout } = await execFileCommand("npm", ["root", "--global"], {
373
+ timeout: 10_000,
374
+ encoding: "utf8",
375
+ });
376
+ const globalRoot = await realpathOrResolve(String(stdout || "").trim());
377
+ if (isPathInside(globalRoot, resolvedRoot)) return "global_npm";
378
+ } catch {
379
+ // The actionable fallback is the same for source checkouts and unknown package managers.
380
+ }
381
+
382
+ return resolvedRoot.split(path.sep).includes("node_modules") ? "package" : "source";
383
+ }
384
+
385
+ export async function checkLatestVersion({ fetchImpl = fetch } = {}) {
386
+ const response = await fetchImpl(
387
+ "https://registry.npmjs.org/@awak-app%2fsimy-cli/latest",
388
+ {
389
+ headers: { Accept: "application/vnd.npm.install-v1+json" },
390
+ signal: AbortSignal.timeout(10_000),
391
+ },
392
+ );
393
+ if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`);
394
+ const payload = await response.json();
395
+ return {
396
+ version: payload?.version,
397
+ integrity: payload?.dist?.integrity || null,
398
+ };
399
+ }
400
+
401
+ export async function installGlobalNpmVersion(
402
+ version,
403
+ { execFileCommand = execFileAsync } = {},
404
+ ) {
405
+ if (!parseStableVersion(version)) throw new Error("Refusing to install an invalid CLI version.");
406
+ await execFileCommand(
407
+ "npm",
408
+ [
409
+ "install",
410
+ "--global",
411
+ `${CLI_PACKAGE_NAME}@${version}`,
412
+ "--no-audit",
413
+ "--no-fund",
414
+ ],
415
+ { timeout: 10 * 60_000, encoding: "utf8" },
416
+ );
417
+ }
418
+
419
+ export function isRegistryIdle(registry) {
420
+ const runs = registry?.list?.() || [];
421
+ return runs.every((run) => {
422
+ if (run?.operation || run?.child || run?.pendingLedgerUpdate || run?.ledgerUpdateRunning) {
423
+ return false;
424
+ }
425
+ if (NON_IDLE_STATES.has(run?.status)) return false;
426
+ if (NON_IDLE_CONTROL_STATES.has(run?.controlState)) return false;
427
+ return true;
428
+ });
429
+ }
430
+
431
+ export function compareStableVersions(left, right) {
432
+ const leftParts = parseStableVersion(left);
433
+ const rightParts = parseStableVersion(right);
434
+ if (!leftParts || !rightParts) throw new Error("A stable semantic version is required.");
435
+ for (let index = 0; index < 3; index += 1) {
436
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
437
+ }
438
+ return 0;
439
+ }
440
+
441
+ export function isPatchUpgrade(currentVersion, targetVersion) {
442
+ const current = parseStableVersion(currentVersion);
443
+ const target = parseStableVersion(targetVersion);
444
+ return Boolean(
445
+ current &&
446
+ target &&
447
+ current[0] === target[0] &&
448
+ current[1] === target[1] &&
449
+ target[2] > current[2]
450
+ );
451
+ }
452
+
453
+ export async function restartDaemonProcess({
454
+ entryPath,
455
+ argv,
456
+ targetVersion,
457
+ stateRoot = defaultStateRoot(),
458
+ spawnProcess = spawn,
459
+ timeoutMs = RESTART_HANDOFF_TIMEOUT_MS,
460
+ env = process.env,
461
+ } = {}) {
462
+ const handoffDirectory = path.join(stateRoot, "updates", "handoff");
463
+ await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
464
+ const nonce = randomBytes(24).toString("hex");
465
+ const handoffPath = path.join(handoffDirectory, `${process.pid}-${nonce}.json`);
466
+ const child = spawnProcess(process.execPath, [entryPath, ...argv], {
467
+ detached: true,
468
+ stdio: "ignore",
469
+ env: {
470
+ ...env,
471
+ SIMY_DAEMON_CHILD: "1",
472
+ SIMY_UPDATE_HANDOFF_FILE: handoffPath,
473
+ SIMY_UPDATE_HANDOFF_NONCE: nonce,
474
+ },
475
+ });
476
+ child.unref?.();
477
+ const spawnFailure = new Promise((_, reject) => {
478
+ child.once?.("error", reject);
479
+ });
480
+
481
+ try {
482
+ const handoff = await Promise.race([
483
+ waitForHandoff(handoffPath, nonce, timeoutMs),
484
+ spawnFailure,
485
+ ]);
486
+ if (handoff.version !== targetVersion) {
487
+ throw new Error(
488
+ `Restarted SIMY reported version ${handoff.version || "unknown"}, expected ${targetVersion}.`,
489
+ );
490
+ }
491
+ return handoff;
492
+ } finally {
493
+ await rm(handoffPath, { force: true });
494
+ }
495
+ }
496
+
497
+ export async function writeUpdateHandoffFromEnvironment({
498
+ version,
499
+ port,
500
+ env = process.env,
501
+ stateRoot = defaultStateRoot(),
502
+ } = {}) {
503
+ const handoffPath = String(env.SIMY_UPDATE_HANDOFF_FILE || "").trim();
504
+ const nonce = String(env.SIMY_UPDATE_HANDOFF_NONCE || "").trim();
505
+ if (!handoffPath || !nonce) return false;
506
+ const handoffDirectory = path.resolve(stateRoot, "updates", "handoff");
507
+ if (!isPathInside(handoffDirectory, path.resolve(handoffPath))) {
508
+ throw new Error("Refusing to write an update handoff outside the SIMY state directory.");
509
+ }
510
+ await mkdir(handoffDirectory, { recursive: true, mode: 0o700 });
511
+ await writeFile(
512
+ handoffPath,
513
+ `${JSON.stringify({ nonce, version, port, pid: process.pid, ready_at: new Date().toISOString() })}\n`,
514
+ { encoding: "utf8", mode: 0o600 },
515
+ );
516
+ return true;
517
+ }
518
+
519
+ export function autoUpdateHelpText() {
520
+ return [
521
+ "Recommended installation (enables daemon auto-update):",
522
+ ` npm install -g ${CLI_PACKAGE_NAME}`,
523
+ " simy --daemon",
524
+ ].join("\n");
525
+ }
526
+
527
+ function manualUpdateMessage({
528
+ currentVersion,
529
+ targetVersion,
530
+ installMode,
531
+ daemon,
532
+ requestedPort,
533
+ }) {
534
+ if (installMode !== "global_npm") {
535
+ return `SIMY CLI ${targetVersion} is available. Install the global version to enable automatic updates.`;
536
+ }
537
+ if (!daemon) {
538
+ return `SIMY CLI ${targetVersion} is available. Update it after leaving this foreground session.`;
539
+ }
540
+ if (requestedPort !== 0) {
541
+ return `SIMY CLI ${targetVersion} is available. A daemon using a fixed port requires a manual restart.`;
542
+ }
543
+ return `SIMY CLI ${targetVersion} is available. This version change requires manual confirmation.`;
544
+ }
545
+
546
+ function updateAction(version) {
547
+ return {
548
+ id: "install_global_cli",
549
+ label: "Install and restart SIMY",
550
+ command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && simy --daemon`,
551
+ };
552
+ }
553
+
554
+ function parseStableVersion(value) {
555
+ const match = String(value || "").match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
556
+ return match ? match.slice(1).map(Number) : null;
557
+ }
558
+
559
+ async function acquireUpdateLock(stateRoot, dependencies = {}) {
560
+ const now = dependencies.now || Date.now;
561
+ const lockDirectory = path.join(stateRoot, "updates");
562
+ const lockPath = path.join(lockDirectory, "global-npm-update.lock");
563
+ await mkdir(lockDirectory, { recursive: true, mode: 0o700 });
564
+
565
+ for (let attempt = 0; attempt < 2; attempt += 1) {
566
+ try {
567
+ const handle = await open(lockPath, "wx", 0o600);
568
+ const token = randomBytes(16).toString("hex");
569
+ await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, created_at: now() })}\n`);
570
+ await handle.close();
571
+ return async () => {
572
+ try {
573
+ const payload = JSON.parse(await readFile(lockPath, "utf8"));
574
+ if (payload.token === token) await rm(lockPath, { force: true });
575
+ } catch (error) {
576
+ if (error?.code !== "ENOENT") throw error;
577
+ }
578
+ };
579
+ } catch (error) {
580
+ if (error?.code !== "EEXIST") throw error;
581
+ if (!(await isStaleLock(lockPath, now))) return null;
582
+ await rm(lockPath, { force: true });
583
+ }
584
+ }
585
+ return null;
586
+ }
587
+
588
+ async function isStaleLock(lockPath, now) {
589
+ try {
590
+ const details = await stat(lockPath);
591
+ return now() - details.mtimeMs > UPDATE_LOCK_STALE_MS;
592
+ } catch (error) {
593
+ if (error?.code === "ENOENT") return true;
594
+ throw error;
595
+ }
596
+ }
597
+
598
+ async function waitForHandoff(handoffPath, nonce, timeoutMs) {
599
+ const startedAt = Date.now();
600
+ while (Date.now() - startedAt < timeoutMs) {
601
+ try {
602
+ const payload = JSON.parse(await readFile(handoffPath, "utf8"));
603
+ if (payload.nonce === nonce) return payload;
604
+ } catch (error) {
605
+ if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
606
+ }
607
+ await new Promise((resolve) => setTimeout(resolve, 100));
608
+ }
609
+ throw new Error("The updated SIMY daemon did not become healthy in time.");
610
+ }
611
+
612
+ async function realpathOrResolve(value) {
613
+ try {
614
+ return await realpath(value);
615
+ } catch {
616
+ return path.resolve(value);
617
+ }
618
+ }
619
+
620
+ function isPathInside(parent, child) {
621
+ const relative = path.relative(parent, child);
622
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
623
+ }
624
+
625
+ function defaultStateRoot() {
626
+ return process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy");
627
+ }
628
+
629
+ function errorMessage(error) {
630
+ return error instanceof Error ? error.message : String(error);
631
+ }