@vantaloom/cli 0.6.0 → 0.13.7

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,590 @@
1
+ import {
2
+ appendFileSync,
3
+ chmodSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs"
13
+ import { cp, writeFile } from "node:fs/promises"
14
+ import { spawnSync } from "node:child_process"
15
+ import os from "node:os"
16
+ import path from "node:path"
17
+ import {
18
+ LOCK_ERROR_CODES,
19
+ cliRoot,
20
+ repoCandidate,
21
+ installedConfigPath,
22
+ defaultReleaseTag,
23
+ defaultRepo,
24
+ defaultNpmRegistry,
25
+ } from "./constants.mjs"
26
+ import {
27
+ binaryName,
28
+ platformId,
29
+ runtimePackageName,
30
+ parsePlatformId,
31
+ readJSONIfExists,
32
+ removeKnownPath,
33
+ writeText,
34
+ run,
35
+ runPnpm,
36
+ platformToGoEnv,
37
+ } from "./platform.mjs"
38
+ import {
39
+ killTrayProcess,
40
+ uninstallLegacyMeshOnce,
41
+ } from "./legacy-cleanup.mjs"
42
+ import {
43
+ enableRuntimeAutostart,
44
+ } from "./lifecycle.mjs"
45
+
46
+ // sleepSync blocks for ms milliseconds without async (install runs top-to-bottom
47
+ // and must not race the file copy against a process that is still releasing its
48
+ // handles). Uses Atomics.wait on a throwaway buffer — no busy spin.
49
+ export function sleepSync(ms) {
50
+ try {
51
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms))
52
+ } catch {
53
+ const end = Date.now() + ms
54
+ while (Date.now() < end) {} // fallback busy-wait if SAB is unavailable
55
+ }
56
+ }
57
+
58
+ // copyFileResilient overwrites dst with src, surviving the Windows case where dst
59
+ // is a running/locked executable. Windows refuses to OVERWRITE or DELETE an in-use
60
+ // .exe (EPERM/EBUSY) but DOES allow RENAMING it — the file handle tracks the file
61
+ // object, not its path. So on a lock error we move the locked file aside and copy
62
+ // the new one into the freed path; the runtime picks up the new binary on its next
63
+ // start, and the moved-aside `.old-*` file is reaped on a later install. This is
64
+ // the fix for the EPERM that blocked update/restart when a prior process (api,
65
+ // agent, or an orphaned easytier/mesh) still held a binary open.
66
+ export function copyFileResilient(src, dst) {
67
+ for (let attempt = 0; ; attempt += 1) {
68
+ try {
69
+ copyFileSync(src, dst)
70
+ return
71
+ } catch (err) {
72
+ if (!LOCK_ERROR_CODES.has(err.code) || !existsSync(dst)) {
73
+ if (attempt >= 4) throw err
74
+ sleepSync(250)
75
+ continue
76
+ }
77
+ try {
78
+ const aside = `${dst}.old-${process.pid}-${attempt}`
79
+ renameSync(dst, aside)
80
+ copyFileSync(src, dst)
81
+ try { rmSync(aside, { force: true }) } catch {} // best-effort; may still be locked
82
+ return
83
+ } catch (moveErr) {
84
+ if (attempt >= 4) throw moveErr
85
+ sleepSync(300)
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // copyDirResilient recursively copies src→dst using copyFileResilient for every
92
+ // file, so a single locked binary can't abort the whole bin/ refresh. filter(srcPath)
93
+ // gates which entries are copied (mirrors fs.cp's filter).
94
+ export function copyDirResilient(src, dst, filter) {
95
+ mkdirSync(dst, { recursive: true })
96
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
97
+ const s = path.join(src, entry.name)
98
+ if (filter && !filter(s)) continue
99
+ const d = path.join(dst, entry.name)
100
+ if (entry.isDirectory()) {
101
+ copyDirResilient(s, d, filter)
102
+ } else {
103
+ copyFileResilient(s, d)
104
+ }
105
+ }
106
+ }
107
+
108
+ // cleanupStaleReplacements deletes the `.old-*` files left behind by a prior
109
+ // lock-safe replace once their handles have been released (best-effort).
110
+ export function cleanupStaleReplacements(dir) {
111
+ if (!existsSync(dir)) return
112
+ for (const entry of readdirSync(dir)) {
113
+ if (/\.old-\d+-\d+$/.test(entry)) {
114
+ try { rmSync(path.join(dir, entry), { force: true }) } catch {}
115
+ }
116
+ }
117
+ }
118
+
119
+ export async function applyPackage(packageRoot, prefix, options) {
120
+ assertRuntimePackage(packageRoot)
121
+
122
+ const existingConfig = readInstalledConfig(prefix)
123
+ const packageConfig = readJSONIfExists(path.join(packageRoot, "cli", "config.json"))
124
+ const mergedConfig = mergeRuntimeConfig(packageConfig, existingConfig, {
125
+ sourceRoot: options.sourceRoot,
126
+ })
127
+ const version = readVersion(packageRoot)
128
+
129
+ mkdirSync(prefix, { recursive: true })
130
+ const existingCtl = path.join(prefix, "bin", binaryName("vantaloomctl"))
131
+ if (existsSync(existingCtl)) {
132
+ spawnSync(existingCtl, ["stop", "--prefix", prefix], { stdio: "inherit", windowsHide: true })
133
+ }
134
+
135
+ // Kill lingering tray process that may hold locks on bin/ (older versions
136
+ // don't write tray.pid, so vantaloomctl stop won't find them).
137
+ killTrayProcess(prefix)
138
+
139
+ // Windows releases a stopped process's file handles asynchronously; copying
140
+ // bin/ the instant after `stop` can still hit the old exe's lock (EPERM). Give
141
+ // the OS a moment to close handles, and sweep any `.old-*` files a previous
142
+ // lock-safe replace left behind (now that those handles are likely released).
143
+ // copyFileResilient below is the real safety net if a handle is still open.
144
+ if (existsSync(path.join(prefix, "bin"))) {
145
+ sleepSync(600)
146
+ cleanupStaleReplacements(path.join(prefix, "bin"))
147
+ }
148
+
149
+ // Copy package contents to install prefix
150
+ const optionalDirs = new Set()
151
+ for (const name of ["bin", "web", "cli"]) {
152
+ const src = path.join(packageRoot, name)
153
+ const dst = path.join(prefix, name)
154
+ if (!existsSync(src)) {
155
+ if (!optionalDirs.has(name)) {
156
+ console.error(` warning: package missing ${name}/ directory`)
157
+ }
158
+ continue
159
+ }
160
+ const copyOpts = { recursive: true, force: true, dereference: false }
161
+ if (name === "bin") {
162
+ // Never wipe bin/: overwrite in place (unconditional overlay). bin/ holds
163
+ // the long-lived executables (api, agent, ctl, browser). Copy each file
164
+ // with the lock-safe move-aside replace so a still-locked binary from a
165
+ // not-fully-exited prior process never aborts the update with EPERM (the
166
+ // bug that wedged update/restart).
167
+ copyDirResilient(src, dst)
168
+ continue
169
+ } else {
170
+ removeKnownPath(dst, prefix)
171
+ }
172
+ await cp(src, dst, copyOpts)
173
+ }
174
+
175
+ // Ensure binaries are executable on Unix (cross-compiled from Windows they lose +x)
176
+ const binDir = path.join(prefix, "bin")
177
+ if (process.platform !== "win32" && existsSync(binDir)) {
178
+ for (const entry of readdirSync(binDir)) {
179
+ const binPath = path.join(binDir, entry)
180
+ try { chmodSync(binPath, 0o755) } catch {}
181
+ }
182
+ }
183
+
184
+ // One-time migration: an install/update from before 0.13 may still have the
185
+ // privileged VantaloomMesh service (EasyTier P2P sidecar) registered on
186
+ // disk, plus its binaries in bin/. The new package no longer bundles
187
+ // vantaloom-mesh/easytier-core/the support DLLs, and the bin/ copy above
188
+ // only OVERLAYS files (never deletes) — so without this step the old
189
+ // service + stale binaries would linger forever and could even
190
+ // restart-loop easytier-core. Idempotent (a done-marker skips all future
191
+ // installs/updates) and never blocks install on a declined/failed
192
+ // elevation — see uninstallLegacyMeshOnce.
193
+ if (!options.noStart) {
194
+ await uninstallLegacyMeshOnce(prefix, options)
195
+ }
196
+
197
+ // Make the runtime start at login/boot (per-user, no elevation). Idempotent.
198
+ if (!options.noStart && !options.skipAutostart) {
199
+ enableRuntimeAutostart(prefix)
200
+ }
201
+
202
+ // Verify vantaloomctl binary exists before trying to run it
203
+ const ctlBin = path.join(prefix, "bin", binaryName("vantaloomctl"))
204
+ if (!existsSync(ctlBin)) {
205
+ const binContents = existsSync(binDir) ? readdirSync(binDir) : []
206
+ const srcBinContents = existsSync(path.join(packageRoot, "bin")) ? readdirSync(path.join(packageRoot, "bin")) : []
207
+ throw new Error(
208
+ `vantaloomctl binary not found at ${ctlBin}\n` +
209
+ ` installed bin/: [${binContents.join(", ")}]\n` +
210
+ ` package bin/: [${srcBinContents.join(", ")}]\n` +
211
+ ` platform: ${platformId()}\n` +
212
+ ` This may indicate a corrupt download. Try again or install from source.`
213
+ )
214
+ }
215
+
216
+ await writeLauncher(prefix)
217
+ await writeText(path.join(prefix, "cli", "config.json"), `${JSON.stringify(mergedConfig, null, 2)}\n`)
218
+ await writeText(path.join(prefix, "VERSION"), `${version}\n`)
219
+ await cp(path.join(packageRoot, "manifest.json"), path.join(prefix, "manifest.json"), {
220
+ force: true,
221
+ })
222
+
223
+ run(ctlBin, [
224
+ "install",
225
+ "--prefix",
226
+ prefix,
227
+ "--version",
228
+ version,
229
+ ])
230
+
231
+ if (!options.noStart) {
232
+ run(ctlBin, [
233
+ "start",
234
+ "--prefix",
235
+ prefix,
236
+ ])
237
+ }
238
+
239
+ return version
240
+ }
241
+
242
+ export function assertRuntimePackage(packageRoot) {
243
+ for (const name of ["bin", "web", "cli", "manifest.json"]) {
244
+ if (!existsSync(path.join(packageRoot, name))) {
245
+ throw new Error(`invalid Vantaloom package, missing ${name}: ${packageRoot}`)
246
+ }
247
+ }
248
+ }
249
+
250
+ export function readVersion(packageRoot) {
251
+ const versionPath = path.join(packageRoot, "VERSION")
252
+ if (existsSync(versionPath)) {
253
+ return readFileSync(versionPath, "utf8").trim() || "dev"
254
+ }
255
+ const manifest = readJSONIfExists(path.join(packageRoot, "manifest.json"))
256
+ return manifest.version ?? "dev"
257
+ }
258
+
259
+ export function readInstalledConfig(prefix) {
260
+ return readJSONIfExists(path.join(prefix, "cli", "config.json"))
261
+ }
262
+
263
+ export function mergeRuntimeConfig(packageConfig, existingConfig, overrides) {
264
+ const merged = { ...packageConfig }
265
+ for (const key of ["sourceRoot", "remote", "repo", "releaseTag", "runtimePackage", "runtimeVersion", "npmRegistry"]) {
266
+ if (!merged[key] && existingConfig[key]) {
267
+ merged[key] = existingConfig[key]
268
+ }
269
+ }
270
+ if (overrides.sourceRoot) {
271
+ merged.sourceRoot = overrides.sourceRoot
272
+ }
273
+ if (overrides.runtimePackage) {
274
+ merged.runtimePackage = overrides.runtimePackage
275
+ }
276
+ if (overrides.runtimeVersion) {
277
+ merged.runtimeVersion = overrides.runtimeVersion
278
+ }
279
+ if (overrides.npmRegistry) {
280
+ merged.npmRegistry = overrides.npmRegistry
281
+ }
282
+ if (!merged.repo) {
283
+ merged.repo = defaultRepo
284
+ }
285
+ if (!merged.releaseTag) {
286
+ merged.releaseTag = defaultReleaseTag
287
+ }
288
+ // Always force runtimePackage to match the running platform — a cross-compiled
289
+ // package may carry a config for a different platform (e.g. win32 inside darwin).
290
+ merged.runtimePackage = runtimePackageName(platformId())
291
+ if (!merged.runtimeVersion) {
292
+ merged.runtimeVersion = "latest"
293
+ }
294
+ if (!merged.npmRegistry) {
295
+ merged.npmRegistry = defaultNpmRegistry
296
+ }
297
+ return merged
298
+ }
299
+
300
+ export function runtimeConfigFromSource(sourceRoot) {
301
+ const remote = gitRemoteUrl(sourceRoot)
302
+ const repo = inferGitHubRepo(remote) || defaultRepo
303
+ // Inline registry detection to avoid circular dep with registry.mjs
304
+ let npmRegistry = ""
305
+ if (process.env.NPM_CONFIG_REGISTRY) {
306
+ npmRegistry = process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY
307
+ } else if (process.env.npm_config_registry) {
308
+ npmRegistry = process.env.npm_config_registry
309
+ } else {
310
+ try {
311
+ const npmrcPath = path.join(os.homedir(), ".npmrc")
312
+ if (existsSync(npmrcPath)) {
313
+ const content = readFileSync(npmrcPath, "utf8")
314
+ const match = content.match(/^\s*registry\s*=\s*(.+)/m)
315
+ if (match) npmRegistry = match[1].trim()
316
+ }
317
+ } catch {}
318
+ }
319
+ return {
320
+ ...(process.env.GITHUB_ACTIONS ? {} : { sourceRoot }),
321
+ ...(remote ? { remote } : {}),
322
+ repo,
323
+ releaseTag: defaultReleaseTag,
324
+ runtimePackage: runtimePackageName(platformId()),
325
+ runtimeVersion: "latest",
326
+ npmRegistry: npmRegistry || defaultNpmRegistry,
327
+ }
328
+ }
329
+
330
+ export function findSourceRoot(sourceOption) {
331
+ if (sourceOption) {
332
+ return assertSourceRoot(path.resolve(sourceOption))
333
+ }
334
+ if (process.env.VANTALOOM_SOURCE) {
335
+ return assertSourceRoot(path.resolve(process.env.VANTALOOM_SOURCE))
336
+ }
337
+ if (existsSync(installedConfigPath)) {
338
+ const config = JSON.parse(readFileSync(installedConfigPath, "utf8"))
339
+ if (config.sourceRoot) {
340
+ return assertSourceRoot(path.resolve(config.sourceRoot))
341
+ }
342
+ }
343
+ return assertSourceRoot(repoCandidate)
344
+ }
345
+
346
+ export function tryFindSourceRoot() {
347
+ try {
348
+ return findSourceRoot()
349
+ } catch {
350
+ return ""
351
+ }
352
+ }
353
+
354
+ export function tryAssertSourceRoot(sourceRoot) {
355
+ try {
356
+ return assertSourceRoot(sourceRoot)
357
+ } catch {
358
+ return ""
359
+ }
360
+ }
361
+
362
+ export function assertSourceRoot(sourceRoot) {
363
+ if (!existsSync(path.join(sourceRoot, "apps", "api", "go.mod"))) {
364
+ throw new Error(`not a Vantaloom source root: ${sourceRoot}`)
365
+ }
366
+ return sourceRoot
367
+ }
368
+
369
+ export function gitRemoteUrl(sourceRoot) {
370
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
371
+ cwd: sourceRoot,
372
+ encoding: "utf8",
373
+ windowsHide: true,
374
+ })
375
+ if (result.status === 0) {
376
+ return result.stdout.trim()
377
+ }
378
+ return ""
379
+ }
380
+
381
+ export function inferGitHubRepo(remote) {
382
+ if (!remote) {
383
+ return ""
384
+ }
385
+ const normalized = remote.replace(/\.git$/, "")
386
+ const httpsMatch = normalized.match(/github\.com[:/]([^/]+\/[^/]+)$/)
387
+ if (httpsMatch) {
388
+ return httpsMatch[1]
389
+ }
390
+ const sshMatch = normalized.match(/^[^:]+:([^/]+\/[^/]+)$/)
391
+ return sshMatch?.[1] ?? ""
392
+ }
393
+
394
+ export async function copyStaticWeb(sourceRoot, buildWeb) {
395
+ const exportRoot = path.join(sourceRoot, "apps", "vantaloom", "out")
396
+ if (!existsSync(exportRoot)) {
397
+ throw new Error(
398
+ "missing Next static export output; let GitHub CI run production build, or pass --build-web for a local one-off build"
399
+ )
400
+ }
401
+
402
+ removeKnownPath(buildWeb, path.dirname(buildWeb))
403
+ await copyDir(exportRoot, buildWeb)
404
+ }
405
+
406
+ export async function copyCliDirectory(target, sourceRoot, config) {
407
+ const sourceCliRoot = path.join(sourceRoot, "packages", "cli")
408
+ if (!existsSync(path.join(sourceCliRoot, "bin", "vantaloom.mjs"))) {
409
+ throw new Error(`missing source CLI package: ${sourceCliRoot}`)
410
+ }
411
+ removeKnownPath(target, path.dirname(target))
412
+ mkdirSync(target, { recursive: true })
413
+ await copyDir(sourceCliRoot, target)
414
+ await writeText(
415
+ path.join(target, "config.json"),
416
+ `${JSON.stringify(config, null, 2)}\n`
417
+ )
418
+ }
419
+
420
+ // writeLauncher writes the OS launcher script to the install prefix.
421
+ // NOTE: mirrored in Go at apps/desktop/internal/runtime/
422
+ export async function writeLauncher(prefix) {
423
+ if (process.platform === "win32") {
424
+ await writeText(
425
+ path.join(prefix, "vantaloom.cmd"),
426
+ `@echo off\r\nnode "%~dp0cli\\bin\\vantaloom.mjs" %*\r\n`
427
+ )
428
+ } else {
429
+ const launcher = `#!/usr/bin/env sh\nexec node "$(dirname "$0")/cli/bin/vantaloom.mjs" "$@"\n`
430
+ const launcherPath = path.join(prefix, "vantaloom")
431
+ await writeText(launcherPath, launcher)
432
+ chmodSync(launcherPath, 0o755)
433
+ }
434
+ }
435
+
436
+ export async function copyDir(source, destination, options = {}) {
437
+ if (!existsSync(source)) {
438
+ throw new Error(`missing source directory: ${source}`)
439
+ }
440
+ removeKnownPath(destination, path.dirname(destination))
441
+ mkdirSync(destination, { recursive: true })
442
+ await cp(source, destination, {
443
+ recursive: true,
444
+ force: true,
445
+ dereference: options.dereference ?? true,
446
+ })
447
+ }
448
+
449
+ export function writeBuildManifest(buildRoot, version, platform, commit) {
450
+ const components = ["api", "agent", "web", "ctl"]
451
+ writeFileSync(path.join(buildRoot, "VERSION"), `${version}\n`)
452
+ writeFileSync(
453
+ path.join(buildRoot, "manifest.json"),
454
+ `${JSON.stringify(
455
+ {
456
+ name: "Vantaloom Local Runtime",
457
+ // The ONE product version: always the npm semver. The source git hash
458
+ // rides along as `commit` for diagnostics — it must never leak into
459
+ // `version` (pre-0.13.5 it did, splitting the update checks).
460
+ version,
461
+ platform,
462
+ ...(commit ? { commit } : {}),
463
+ updatedAt: new Date().toISOString(),
464
+ components,
465
+ },
466
+ null,
467
+ 2
468
+ )}\n`
469
+ )
470
+ }
471
+
472
+ export function writeRuntimePackageMetadata(packageRoot, sourceRoot, platform) {
473
+ const version = npmPackageVersion(sourceRoot)
474
+ const { os: runtimeOS, cpu } = parsePlatformId(platform)
475
+ const name = runtimePackageName(platform)
476
+ writeFileSync(
477
+ path.join(packageRoot, "package.json"),
478
+ `${JSON.stringify(
479
+ {
480
+ name,
481
+ version,
482
+ private: false,
483
+ description: `Vantaloom local runtime for ${platform}.`,
484
+ type: "module",
485
+ os: [runtimeOS],
486
+ cpu: [cpu],
487
+ files: ["bin", "web", "cli", "manifest.json", "VERSION", "README.md"],
488
+ publishConfig: {
489
+ access: "public",
490
+ },
491
+ engines: {
492
+ node: ">=20",
493
+ },
494
+ },
495
+ null,
496
+ 2
497
+ )}\n`
498
+ )
499
+ writeFileSync(
500
+ path.join(packageRoot, "README.md"),
501
+ `# ${name}\n\nPlatform runtime package for Vantaloom ${platform}. Install @vantaloom/cli instead of this package directly.\n`
502
+ )
503
+ }
504
+
505
+ export function npmPackageVersion(sourceRoot) {
506
+ const packageJSON = readJSONIfExists(path.join(sourceRoot, "packages", "cli", "package.json"))
507
+ return packageJSON.version ?? "0.0.0"
508
+ }
509
+
510
+ export function gitVersion(sourceRoot) {
511
+ const result = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
512
+ cwd: sourceRoot,
513
+ encoding: "utf8",
514
+ windowsHide: true,
515
+ })
516
+ if (result.status === 0) {
517
+ return result.stdout.trim() || "dev"
518
+ }
519
+ return "dev"
520
+ }
521
+
522
+ export function buildGo(sourceRoot, buildBin, name, targetPlatform) {
523
+ const goEnv = targetPlatform ? platformToGoEnv(targetPlatform) : {}
524
+ const isWindowsTarget = targetPlatform
525
+ ? targetPlatform.startsWith("win32")
526
+ : process.platform === "win32"
527
+ const ext = isWindowsTarget ? ".exe" : ""
528
+ let ldflags = "-s -w"
529
+ const args = ["build"]
530
+ args.push(
531
+ "-ldflags", ldflags,
532
+ "-o", path.join(buildBin, `${name}${ext}`),
533
+ `./apps/api/cmd/${name}`
534
+ )
535
+ run("go", args, { cwd: sourceRoot, env: { ...process.env, ...goEnv } })
536
+ }
537
+
538
+ // ensureInPath adds the Vantaloom install directory to the user's shell PATH
539
+ // on macOS and Linux, so `vantaloom` can be run directly after install.
540
+ // On Windows this is not needed (vantaloom.cmd is run by full path or added via installer).
541
+ export function ensureInPath(prefix) {
542
+ if (process.platform === "win32") return
543
+
544
+ // Check if already in PATH
545
+ const pathDirs = (process.env.PATH || "").split(":")
546
+ if (pathDirs.includes(prefix)) return
547
+
548
+ // Determine shell profile file
549
+ const home = os.homedir()
550
+ const shell = process.env.SHELL || ""
551
+ let profilePath
552
+ if (shell.endsWith("/zsh") || existsSync(path.join(home, ".zshrc"))) {
553
+ profilePath = path.join(home, ".zshrc")
554
+ } else if (shell.endsWith("/bash")) {
555
+ // On macOS, bash uses .bash_profile; on Linux, .bashrc
556
+ profilePath = process.platform === "darwin"
557
+ ? path.join(home, ".bash_profile")
558
+ : path.join(home, ".bashrc")
559
+ } else if (existsSync(path.join(home, ".profile"))) {
560
+ profilePath = path.join(home, ".profile")
561
+ } else {
562
+ profilePath = path.join(home, ".profile")
563
+ }
564
+
565
+ // Use $HOME-relative path for portability
566
+ const homeRelative = prefix.startsWith(home)
567
+ ? `$HOME${prefix.slice(home.length)}`
568
+ : prefix
569
+ const exportLine = `export PATH="${homeRelative}:$PATH"`
570
+ const marker = "# vantaloom"
571
+
572
+ // Check if already added to profile
573
+ try {
574
+ if (existsSync(profilePath)) {
575
+ const content = readFileSync(profilePath, "utf8")
576
+ if (content.includes("vantaloom") && content.includes("PATH")) return
577
+ }
578
+ } catch {}
579
+
580
+ // Append to profile
581
+ try {
582
+ const entry = `\n${marker}\n${exportLine}\n`
583
+ appendFileSync(profilePath, entry)
584
+ console.log(`PATH: added ${prefix} to ${profilePath}`)
585
+ console.log(` run: source ${profilePath} (or open a new terminal)`)
586
+ } catch (error) {
587
+ console.log(`PATH: could not update ${profilePath}: ${error.message}`)
588
+ console.log(` add manually: ${exportLine}`)
589
+ }
590
+ }