phronesis 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/act.js +62 -1
  3. package/src/adapter.js +366 -61
  4. package/src/cli.js +527 -32
  5. package/src/compile.js +141 -10
  6. package/src/doctor.js +63 -2
  7. package/src/hook.js +312 -0
  8. package/src/hooks-refresh.js +1363 -0
  9. package/src/hooks.js +48 -33
  10. package/src/init.js +37 -4
  11. package/src/launcher.js +2105 -0
  12. package/src/layout.js +51 -7
  13. package/src/profile.js +169 -0
  14. package/src/prompts.js +88 -0
  15. package/src/skills-bump.js +88 -20
  16. package/src/skills.js +103 -10
  17. package/templates/codex/INDEX.md +4 -2
  18. package/templates/codex/seed/ai-practice/the-workspace-is-part-of-the-thinking.md +96 -0
  19. package/templates/codex-surface/README.md +15 -16
  20. package/templates/codex-surface/hooks/_resolve.sh +25 -24
  21. package/templates/codex-surface/hooks/recall-guard.sh +5 -30
  22. package/templates/codex-surface/hooks/session-start.sh +5 -18
  23. package/templates/codex-surface/hooks/session-sweep-precompact.sh +5 -20
  24. package/templates/codex-surface/hooks/session-sweep-subagent.sh +5 -23
  25. package/templates/codex-surface/hooks/session-sweep.sh +5 -19
  26. package/templates/launcher/phronesis +95 -0
  27. package/templates/phronesis-hooks/compile-active-context.sh +12 -38
  28. package/templates/phronesis-hooks/recall-guard.sh +12 -103
  29. package/templates/phronesis-hooks/session-start.sh +10 -88
  30. package/templates/phronesis-hooks/session-sweep.sh +16 -139
  31. package/templates/phronesis-hooks/skill-lifecycle.sh +12 -37
  32. package/templates/skills/lint/SKILL.md +7 -2
  33. package/templates/skills/onboard/SKILL.md +47 -9
  34. package/templates/skills/onboard/evals/rubric.md +5 -3
  35. package/templates/skills/prd-draft/SKILL.md +34 -26
  36. package/templates/skills/prd-draft/evals/rubric.md +1 -1
  37. package/vendor/core/src/action-registry.js +6 -3
  38. package/vendor/core/src/actions.js +120 -16
  39. package/vendor/core/src/index.js +3 -0
  40. package/vendor/core/src/lint.js +22 -5
  41. package/vendor/core/src/skill-lifecycle.js +67 -7
@@ -0,0 +1,2105 @@
1
+ // Shared launcher.env contract (change 0065 D1). Doctor consumes the read-only
2
+ // inspector without executing a candidate; install/remove below reuse the same paths,
3
+ // parser, serializer, and token rules for atomic no-follow writes.
4
+
5
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, basename } from 'node:path';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+ import { constants, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
9
+ import {
10
+ lstat,
11
+ link,
12
+ mkdir,
13
+ mkdtemp,
14
+ open,
15
+ readFile,
16
+ readdir,
17
+ rename,
18
+ rm,
19
+ stat,
20
+ unlink,
21
+ writeFile,
22
+ } from 'node:fs/promises';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ export const LAUNCHER_FIELDS = Object.freeze([
27
+ 'PHR_BACKEND',
28
+ 'PHR_VERSION',
29
+ 'PHR_TARGET',
30
+ 'PHR_RUNTIME',
31
+ 'PHR_GENERATION',
32
+ ]);
33
+ export const LAUNCHER_BACKENDS = Object.freeze(['app', 'self-copy']);
34
+ const TOKEN_RE = /^[A-Za-z0-9._+-]+$/;
35
+ const STAGING_DIR_RE = /\.staging-[A-Za-z0-9]+$/;
36
+ const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
37
+ const DISPATCHER_TEMPLATE = join(CLI_PACKAGE_ROOT, 'templates', 'launcher', 'phronesis');
38
+ export const LAUNCHER_PATH_LINE = 'export PATH="$PATH:$HOME/.phronesis/bin" # phronesis';
39
+ export const FISH_LAUNCHER_PATH_LINE = 'fish_add_path ~/.phronesis/bin';
40
+
41
+ export function launcherPaths({ home = homedir() } = {}) {
42
+ const managedDir = join(home, '.phronesis');
43
+ const binDir = join(managedDir, 'bin');
44
+ return {
45
+ managedDir,
46
+ binDir,
47
+ dispatcher: join(binDir, 'phronesis'),
48
+ manifest: join(binDir, 'launcher.env'),
49
+ lock: join(binDir, '.launcher.lock'),
50
+ generations: join(home, '.phronesis', 'cli'),
51
+ };
52
+ }
53
+
54
+ function decodeValue(raw, field, errors) {
55
+ if (raw.startsWith('"') || raw.endsWith('"')) {
56
+ if (!(raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2)) {
57
+ errors.push(`${field} has mismatched double quotes`);
58
+ return null;
59
+ }
60
+ const inner = raw.slice(1, -1);
61
+ let out = '';
62
+ for (let i = 0; i < inner.length; i += 1) {
63
+ const ch = inner[i];
64
+ if (ch === '$' || ch === '`') {
65
+ errors.push(`${field} contains an unescaped shell expansion character`);
66
+ return null;
67
+ }
68
+ if (ch !== '\\') {
69
+ out += ch;
70
+ continue;
71
+ }
72
+ const next = inner[i + 1];
73
+ if (!['\\', '"', '$', '`'].includes(next)) {
74
+ errors.push(`${field} has an invalid escape`);
75
+ return null;
76
+ }
77
+ out += next;
78
+ i += 1;
79
+ }
80
+ return out;
81
+ }
82
+ if (raw.startsWith("'") || raw.endsWith("'")) {
83
+ if (!(raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2)) {
84
+ errors.push(`${field} has mismatched single quotes`);
85
+ return null;
86
+ }
87
+ const inner = raw.slice(1, -1);
88
+ if (inner.includes("'")) {
89
+ errors.push(`${field} has an invalid single-quoted value`);
90
+ return null;
91
+ }
92
+ return inner;
93
+ }
94
+ if (/[\s;&|<>$`"']/.test(raw)) {
95
+ errors.push(`${field} must quote whitespace and shell metacharacters`);
96
+ return null;
97
+ }
98
+ return raw;
99
+ }
100
+
101
+ export function parseLauncherManifest(text) {
102
+ const errors = [];
103
+ const manifest = {};
104
+ const allowed = new Set(LAUNCHER_FIELDS);
105
+ for (const [index, line] of String(text ?? '').split(/\r?\n/).entries()) {
106
+ if (!line || line.startsWith('#')) continue;
107
+ const eq = line.indexOf('=');
108
+ if (eq <= 0) {
109
+ errors.push(`line ${index + 1} is not KEY=value`);
110
+ continue;
111
+ }
112
+ const key = line.slice(0, eq);
113
+ if (!allowed.has(key)) {
114
+ errors.push(`line ${index + 1} has unknown field ${key}`);
115
+ continue;
116
+ }
117
+ if (Object.hasOwn(manifest, key)) {
118
+ errors.push(`line ${index + 1} repeats ${key}`);
119
+ continue;
120
+ }
121
+ const value = decodeValue(line.slice(eq + 1), key, errors);
122
+ if (value !== null) manifest[key] = value;
123
+ }
124
+
125
+ for (const field of LAUNCHER_FIELDS) {
126
+ if (!Object.hasOwn(manifest, field) || manifest[field] === '') errors.push(`${field} is required`);
127
+ }
128
+ if (manifest.PHR_BACKEND && !LAUNCHER_BACKENDS.includes(manifest.PHR_BACKEND)) {
129
+ errors.push(`PHR_BACKEND must be ${LAUNCHER_BACKENDS.join(' | ')}`);
130
+ }
131
+ for (const field of ['PHR_VERSION', 'PHR_GENERATION']) {
132
+ if (manifest[field] && !TOKEN_RE.test(manifest[field])) {
133
+ errors.push(`${field} must be a non-empty version/generation token`);
134
+ }
135
+ }
136
+ if (manifest.PHR_GENERATION && /^\.+$/.test(manifest.PHR_GENERATION)) {
137
+ errors.push('PHR_GENERATION must not be a dot-only token');
138
+ }
139
+ for (const field of ['PHR_TARGET', 'PHR_RUNTIME']) {
140
+ if (manifest[field] && !isAbsolute(manifest[field])) errors.push(`${field} must be an absolute path`);
141
+ }
142
+ return { ok: errors.length === 0, manifest, errors };
143
+ }
144
+
145
+ function quoteValue(value) {
146
+ const raw = String(value);
147
+ if (raw && !/[\s;&|<>$`"']/.test(raw)) return raw;
148
+ return `"${raw.replace(/[\\"$`]/g, (ch) => `\\${ch}`)}"`;
149
+ }
150
+
151
+ export function serializeLauncherManifest(manifest) {
152
+ const validation = parseLauncherManifest(
153
+ LAUNCHER_FIELDS.map((field) => `${field}=${quoteValue(manifest?.[field] ?? '')}`).join('\n'),
154
+ );
155
+ if (!validation.ok) throw new Error(`invalid launcher manifest: ${validation.errors.join('; ')}`);
156
+ return `${LAUNCHER_FIELDS.map((field) => `${field}=${quoteValue(manifest[field])}`).join('\n')}\n`;
157
+ }
158
+
159
+ function pathState(path) {
160
+ try {
161
+ const lst = lstatSync(path);
162
+ const symlink = lst.isSymbolicLink();
163
+ let target = lst;
164
+ if (symlink) {
165
+ try {
166
+ target = statSync(path);
167
+ } catch {
168
+ target = null;
169
+ }
170
+ }
171
+ return {
172
+ present: true,
173
+ symlink,
174
+ file: Boolean(target?.isFile()),
175
+ directory: Boolean(target?.isDirectory()),
176
+ executable: Boolean(target?.isFile() && (target.mode & 0o111)),
177
+ };
178
+ } catch {
179
+ return { present: false, symlink: false, file: false, directory: false, executable: false };
180
+ }
181
+ }
182
+
183
+ export function findPathPhronesis(pathEnv = process.env.PATH) {
184
+ for (const entry of String(pathEnv || '').split(delimiter)) {
185
+ if (!entry) continue;
186
+ const candidate = join(resolve(entry), 'phronesis');
187
+ const state = pathState(candidate);
188
+ if (state.file && state.executable) return candidate;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function nearestPackageForTarget(target) {
194
+ let dir = dirname(target);
195
+ for (let i = 0; i < 5; i += 1) {
196
+ const manifestPath = join(dir, 'package.json');
197
+ try {
198
+ const manifest = statSync(manifestPath);
199
+ if (!manifest.isFile()) return null;
200
+ return { root: dir, packageJson: JSON.parse(readFileSync(manifestPath, 'utf8')) };
201
+ } catch (error) {
202
+ if (error?.code !== 'ENOENT') return null;
203
+ }
204
+ const parent = dirname(dir);
205
+ if (parent === dir) break;
206
+ dir = parent;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function packageVersionForTarget(target) {
212
+ let dir = dirname(target);
213
+ for (let i = 0; i < 5; i += 1) {
214
+ try {
215
+ const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
216
+ if (typeof parsed.version === 'string' && parsed.version) return parsed.version;
217
+ } catch {
218
+ // Preserve the self-copy inspector's existing ancestor-search behavior.
219
+ }
220
+ const parent = dirname(dir);
221
+ if (parent === dir) break;
222
+ dir = parent;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ function phronesisVersionForTarget(target) {
228
+ const resolved = nearestPackageForTarget(target);
229
+ if (!resolved || resolved.packageJson?.name !== 'phronesis') return null;
230
+ const bin = resolved.packageJson.bin;
231
+ const entry = typeof bin === 'string' ? bin : bin?.phronesis;
232
+ if (typeof entry !== 'string' || !entry) return null;
233
+ const lexicalEntry = resolve(resolved.root, entry);
234
+ const rel = relative(resolved.root, lexicalEntry);
235
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null;
236
+ try {
237
+ if (realpathSync(lexicalEntry) !== realpathSync(target)) return null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ const version = resolved.packageJson.version;
242
+ return typeof version === 'string' && version ? version : null;
243
+ }
244
+
245
+ function strictlyContainedBy(parent, child) {
246
+ try {
247
+ const rel = relative(realpathSync(parent), realpathSync(child));
248
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ export function inspectLauncher({ home = homedir(), pathEnv = process.env.PATH } = {}) {
255
+ const paths = launcherPaths({ home });
256
+ const issues = [];
257
+ const managedDir = pathState(paths.managedDir);
258
+ const binDir = pathState(paths.binDir);
259
+ const dispatcher = pathState(paths.dispatcher);
260
+ const manifestFile = pathState(paths.manifest);
261
+ const pathCandidate = findPathPhronesis(pathEnv);
262
+
263
+ if (managedDir.symlink) issues.push({ code: 'managed-dir-symlink', detail: `${paths.managedDir} is a symlink (non-compliant managed path)` });
264
+ if (binDir.symlink) issues.push({ code: 'bin-dir-symlink', detail: `${paths.binDir} is a symlink (non-compliant managed path)` });
265
+
266
+ if (!dispatcher.present && !manifestFile.present) {
267
+ issues.push({ code: 'not-installed', detail: 'managed dispatcher and launcher.env are absent' });
268
+ return {
269
+ compliant: false,
270
+ paths,
271
+ pathCandidate,
272
+ managedDir,
273
+ binDir,
274
+ dispatcher,
275
+ manifestFile,
276
+ manifest: null,
277
+ target: null,
278
+ runtime: null,
279
+ generation: null,
280
+ issues,
281
+ };
282
+ }
283
+ if (!dispatcher.present) issues.push({ code: 'dispatcher-missing', detail: 'managed dispatcher is missing' });
284
+ else {
285
+ if (dispatcher.symlink) issues.push({ code: 'dispatcher-symlink', detail: 'managed dispatcher is a symlink (non-compliant)' });
286
+ if (!dispatcher.file || !dispatcher.executable) issues.push({ code: 'dispatcher-not-executable', detail: 'managed dispatcher is not a regular executable file' });
287
+ }
288
+ if (!manifestFile.present) issues.push({ code: 'manifest-missing', detail: 'launcher.env is missing' });
289
+ else if (manifestFile.symlink) issues.push({ code: 'manifest-symlink', detail: 'launcher.env is a symlink (non-compliant)' });
290
+ else if (!manifestFile.file) issues.push({ code: 'manifest-not-file', detail: 'launcher.env is not a regular file' });
291
+
292
+ let parsed = { ok: false, manifest: {}, errors: [] };
293
+ if (manifestFile.file) {
294
+ try {
295
+ parsed = parseLauncherManifest(readFileSync(paths.manifest, 'utf8'));
296
+ } catch (err) {
297
+ parsed = { ok: false, manifest: {}, errors: [`launcher.env unreadable: ${err.message}`] };
298
+ }
299
+ for (const error of parsed.errors) issues.push({ code: 'manifest-invalid', detail: error });
300
+ }
301
+
302
+ let target = null;
303
+ let runtime = null;
304
+ let generation = null;
305
+ if (parsed.ok) {
306
+ target = pathState(parsed.manifest.PHR_TARGET);
307
+ runtime = pathState(parsed.manifest.PHR_RUNTIME);
308
+ if (!target.file) issues.push({ code: 'target-missing', detail: `recorded target is missing: ${parsed.manifest.PHR_TARGET}` });
309
+ if (target.symlink) issues.push({ code: 'target-symlink', detail: `recorded target is a symlink (non-compliant): ${parsed.manifest.PHR_TARGET}` });
310
+ if (!runtime.file || !runtime.executable) {
311
+ issues.push({ code: 'runtime-stale', detail: `recorded runtime is missing or not executable: ${parsed.manifest.PHR_RUNTIME}` });
312
+ }
313
+
314
+ if (parsed.manifest.PHR_BACKEND === 'self-copy') {
315
+ const generationPath = join(paths.generations, parsed.manifest.PHR_GENERATION);
316
+ generation = { path: generationPath, ...pathState(generationPath) };
317
+ if (generation.symlink) {
318
+ issues.push({ code: 'generation-symlink', detail: `generation directory is a symlink (non-compliant): ${generationPath}` });
319
+ }
320
+ if (generation.present && !generation.directory) {
321
+ issues.push({ code: 'generation-not-directory', detail: `recorded generation is not a directory: ${generationPath}` });
322
+ }
323
+ if (!generation.present || !generation.directory || generation.symlink || !strictlyContainedBy(generationPath, parsed.manifest.PHR_TARGET)) {
324
+ issues.push({
325
+ code: 'torn-generation',
326
+ detail: `launcher.env names generation ${parsed.manifest.PHR_GENERATION}, but its generation directory/target is missing or inconsistent`,
327
+ });
328
+ }
329
+ }
330
+
331
+ const installedVersion = target?.file
332
+ ? parsed.manifest.PHR_BACKEND === 'app'
333
+ ? phronesisVersionForTarget(parsed.manifest.PHR_TARGET)
334
+ : packageVersionForTarget(parsed.manifest.PHR_TARGET)
335
+ : null;
336
+ if (parsed.manifest.PHR_BACKEND === 'app') {
337
+ const expectedGeneration = `app-${parsed.manifest.PHR_VERSION}`;
338
+ if (parsed.manifest.PHR_GENERATION !== expectedGeneration) {
339
+ issues.push({
340
+ code: 'app-generation-incoherent',
341
+ detail: `app launcher generation ${parsed.manifest.PHR_GENERATION} must equal ${expectedGeneration}`,
342
+ });
343
+ }
344
+ if (!installedVersion) {
345
+ issues.push({
346
+ code: 'app-target-version-underivable',
347
+ detail: `app target package version could not be derived: ${parsed.manifest.PHR_TARGET}`,
348
+ });
349
+ }
350
+ }
351
+ if (installedVersion && installedVersion !== parsed.manifest.PHR_VERSION) {
352
+ issues.push({
353
+ code: 'version-skew',
354
+ detail: `manifest version ${parsed.manifest.PHR_VERSION} differs from target package version ${installedVersion}`,
355
+ });
356
+ }
357
+ }
358
+
359
+ return {
360
+ compliant: issues.length === 0,
361
+ paths,
362
+ pathCandidate,
363
+ managedDir,
364
+ binDir,
365
+ dispatcher,
366
+ manifestFile,
367
+ manifest: parsed.ok ? parsed.manifest : null,
368
+ target,
369
+ runtime,
370
+ generation,
371
+ issues,
372
+ };
373
+ }
374
+
375
+ export function launcherStatus({ home = homedir(), pathEnv = process.env.PATH } = {}) {
376
+ const report = inspectLauncher({ home, pathEnv });
377
+ return {
378
+ exists: report.dispatcher.present || report.manifestFile.present,
379
+ owner: report.manifest?.PHR_BACKEND ?? null,
380
+ version: report.manifest?.PHR_VERSION ?? null,
381
+ compliant: report.compliant,
382
+ issues: report.issues,
383
+ };
384
+ }
385
+
386
+ // The write side of the launcher contract lives beside the read side above so every
387
+ // producer consumes the same field serializer, token rules, paths, and inspector.
388
+
389
+ function launcherWriteError(message) {
390
+ return new Error(`launcher: ${message}`);
391
+ }
392
+
393
+ async function stateFor(path) {
394
+ try {
395
+ const value = await lstat(path);
396
+ const testUid = process.env.NODE_ENV === 'test'
397
+ && process.env.PHRONESIS_TEST_LAUNCHER_UID_PATH === path
398
+ ? Number(process.env.PHRONESIS_TEST_LAUNCHER_UID)
399
+ : null;
400
+ return {
401
+ present: true,
402
+ symlink: value.isSymbolicLink(),
403
+ file: value.isFile(),
404
+ directory: value.isDirectory(),
405
+ mode: value.mode,
406
+ uid: Number.isSafeInteger(testUid) && testUid >= 0 ? testUid : value.uid,
407
+ mtimeMs: value.mtimeMs,
408
+ dev: value.dev,
409
+ ino: value.ino,
410
+ };
411
+ } catch (error) {
412
+ if (error?.code === 'ENOENT') {
413
+ return {
414
+ present: false,
415
+ symlink: false,
416
+ file: false,
417
+ directory: false,
418
+ mode: 0,
419
+ uid: null,
420
+ mtimeMs: null,
421
+ dev: null,
422
+ ino: null,
423
+ };
424
+ }
425
+ throw error;
426
+ }
427
+ }
428
+
429
+ function expectedUid() {
430
+ return typeof process.getuid === 'function' ? process.getuid() : null;
431
+ }
432
+
433
+ function assertCurrentOwner(path, current, label = 'managed path') {
434
+ const uid = expectedUid();
435
+ if (current.present && uid !== null && current.uid !== uid) {
436
+ const error = launcherWriteError(
437
+ `refusing ${label} owned by uid ${current.uid}; current uid is ${uid}. `
438
+ + `Ask uid ${current.uid} to remove it, or have an administrator chown it to uid ${uid}: ${path}`,
439
+ );
440
+ error.code = 'PHR_LAUNCHER_FOREIGN_OWNER';
441
+ throw error;
442
+ }
443
+ }
444
+
445
+ function assertAppMaterialOwner(path, current, label) {
446
+ const uid = expectedUid();
447
+ if (current.present && uid !== null && current.uid !== 0 && current.uid !== uid) {
448
+ const error = launcherWriteError(
449
+ `refusing ${label} owned by uid ${current.uid}; app read material must be owned by root (uid 0) or current uid ${uid}. `
450
+ + `Ask uid ${current.uid} or an administrator to reinstall the app for root or uid ${uid}: ${path}`,
451
+ );
452
+ error.code = 'PHR_LAUNCHER_FOREIGN_OWNER';
453
+ throw error;
454
+ }
455
+ }
456
+
457
+ function cleanupFailure(error, label) {
458
+ if (error?.code === 'PHR_LAUNCHER_FOREIGN_OWNER') throw error;
459
+ return { removed: [], warnings: [`${label} failed: ${error.message}`] };
460
+ }
461
+
462
+ async function assertTreeOwned(path, label) {
463
+ const root = await stateFor(path);
464
+ if (!root.present) return;
465
+ assertCurrentOwner(path, root, label);
466
+ if (root.symlink || !root.directory) return;
467
+ for (const entry of await readdir(path)) {
468
+ await assertTreeOwned(join(path, entry), label);
469
+ }
470
+ }
471
+
472
+ async function ensureDirectoryNoFollow(path, mode = 0o700) {
473
+ const before = await stateFor(path);
474
+ if (before.present) {
475
+ assertCurrentOwner(path, before, 'managed directory');
476
+ if (before.symlink) throw launcherWriteError(`refusing symlinked managed directory: ${path}`);
477
+ if (!before.directory) throw launcherWriteError(`managed directory path is not a directory: ${path}`);
478
+ return;
479
+ }
480
+ try {
481
+ await mkdir(path, { mode });
482
+ } catch (error) {
483
+ if (error?.code !== 'EEXIST') throw error;
484
+ }
485
+ const after = await stateFor(path);
486
+ assertCurrentOwner(path, after, 'managed directory');
487
+ if (after.symlink || !after.directory) {
488
+ throw launcherWriteError(`refusing non-directory or symlinked managed path: ${path}`);
489
+ }
490
+ }
491
+
492
+ async function ensureManagedTree(paths) {
493
+ await ensureDirectoryNoFollow(paths.managedDir);
494
+ await ensureDirectoryNoFollow(paths.binDir);
495
+ await ensureDirectoryNoFollow(paths.generations);
496
+ }
497
+
498
+ const UNPARSEABLE_LOCK_STALE_MS = 60 * 1000;
499
+ const LIVE_LOCK_GUIDANCE_MS = 60 * 60 * 1000;
500
+
501
+ function uniqueSuffix() {
502
+ return `${process.pid}-${randomBytes(8).toString('hex')}`;
503
+ }
504
+
505
+ function uniqueStagingSuffix() {
506
+ return `${process.pid}${randomBytes(8).toString('hex')}`;
507
+ }
508
+
509
+ function uniqueTempPath(path) {
510
+ return `${path}.tmp-${uniqueSuffix()}`;
511
+ }
512
+
513
+ function processIsAlive(pid) {
514
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
515
+ try {
516
+ process.kill(pid, 0);
517
+ return true;
518
+ } catch (error) {
519
+ if (error?.code === 'ESRCH') return false;
520
+ if (error?.code === 'EPERM') return true;
521
+ return null;
522
+ }
523
+ }
524
+
525
+ async function readLockRecordAt(path) {
526
+ const current = await stateFor(path);
527
+ if (!current.present) return { current, record: null, text: null };
528
+ assertCurrentOwner(path, current, 'launcher lifecycle lock');
529
+ if (current.symlink) throw launcherWriteError(`refusing symlinked launcher lifecycle lock: ${path}`);
530
+ if (!current.file) throw launcherWriteError(`launcher lifecycle lock is not a regular file: ${path}`);
531
+ let text;
532
+ try {
533
+ text = (await readRegularFileNoFollow(path, 'launcher lifecycle lock')).toString('utf8');
534
+ } catch (error) {
535
+ return { current, record: null, text: null, error };
536
+ }
537
+ try {
538
+ return { current, record: JSON.parse(text), text };
539
+ } catch (error) {
540
+ return { current, record: null, text, error };
541
+ }
542
+ }
543
+
544
+ async function readLockRecord(paths) {
545
+ return readLockRecordAt(paths.lock);
546
+ }
547
+
548
+ function lockAge(lock) {
549
+ const timestamp = Date.parse(lock.record?.created_at);
550
+ const basis = Number.isFinite(timestamp) ? timestamp : lock.current?.mtimeMs;
551
+ return Number.isFinite(basis) ? Math.max(0, Date.now() - basis) : null;
552
+ }
553
+
554
+ function lockDisposition(lock) {
555
+ const pid = lock.record?.pid;
556
+ const age = lockAge(lock);
557
+ if (Number.isSafeInteger(pid) && pid > 0) {
558
+ const alive = processIsAlive(pid);
559
+ if (alive === false) return { stale: true, age, alive, reason: `pid ${pid} is not running` };
560
+ return { stale: false, age, alive, longLived: alive === true && age !== null && age > LIVE_LOCK_GUIDANCE_MS };
561
+ }
562
+ return {
563
+ stale: age !== null && age > UNPARSEABLE_LOCK_STALE_MS,
564
+ age,
565
+ alive: null,
566
+ reason: 'the lock record is empty or unparseable',
567
+ };
568
+ }
569
+
570
+ function lockDescription(lock) {
571
+ const pid = Number.isSafeInteger(lock.record?.pid) ? lock.record.pid : 'unknown';
572
+ const created = typeof lock.record?.created_at === 'string' ? lock.record.created_at : 'unknown time';
573
+ return `pid ${pid} since ${created}`;
574
+ }
575
+
576
+ function lockIdentityMatches(left, right) {
577
+ return left.current?.dev === right.current?.dev
578
+ && left.current?.ino === right.current?.ino
579
+ && left.text === right.text;
580
+ }
581
+
582
+ function lockHeldError(paths, lock, disposition) {
583
+ const base = `lifecycle lock is held by ${lockDescription(lock)}: ${paths.lock}`;
584
+ if (!disposition.longLived) return launcherWriteError(base);
585
+ const minutes = Math.floor(disposition.age / 60_000);
586
+ return launcherWriteError(
587
+ `${base} (alive for about ${minutes} minutes). Verify or stop pid ${lock.record.pid}; `
588
+ + 'remove the lock manually only after confirming no launcher operation is running.',
589
+ );
590
+ }
591
+
592
+ async function restoreRenamedLock(paths, aside) {
593
+ try {
594
+ await link(aside, paths.lock);
595
+ await unlink(aside);
596
+ await syncDirectory(paths.binDir);
597
+ } catch (error) {
598
+ if (error?.code !== 'EEXIST') throw error;
599
+ throw launcherWriteError(
600
+ `lifecycle lock changed during stale-lock takeover; preserved displaced lock at ${aside}`,
601
+ );
602
+ }
603
+ }
604
+
605
+ async function acquireLauncherLock(paths) {
606
+ const token = uniqueSuffix();
607
+ const record = { pid: process.pid, created_at: new Date().toISOString(), token };
608
+ for (let attempt = 0; attempt < 20; attempt += 1) {
609
+ let handle;
610
+ let created = false;
611
+ try {
612
+ handle = await open(
613
+ paths.lock,
614
+ noFollowFlags(constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY),
615
+ 0o600,
616
+ );
617
+ created = true;
618
+ await handle.writeFile(`${JSON.stringify(record)}\n`);
619
+ await handle.sync();
620
+ await handle.close();
621
+ await syncDirectory(paths.binDir);
622
+ await runTestPhase('lock-acquired');
623
+ return { paths, record };
624
+ } catch (error) {
625
+ await handle?.close().catch(() => {});
626
+ if (created) {
627
+ const own = await readLockRecord(paths).catch(() => null);
628
+ if (own?.record?.token === record.token) await unlink(paths.lock).catch(() => {});
629
+ throw error;
630
+ }
631
+ if (error?.code !== 'EEXIST') throw error;
632
+ const prior = await readLockRecord(paths);
633
+ const disposition = lockDisposition(prior);
634
+ if (!disposition.stale) throw lockHeldError(paths, prior, disposition);
635
+ await runTestPhase('stale-lock-judged');
636
+ const aside = `${paths.lock}.reaping-${uniqueSuffix()}`;
637
+ try {
638
+ await rename(paths.lock, aside);
639
+ } catch (renameError) {
640
+ if (renameError?.code === 'ENOENT') continue;
641
+ throw renameError;
642
+ }
643
+ const moved = await readLockRecordAt(aside);
644
+ if (!lockIdentityMatches(prior, moved)) {
645
+ await restoreRenamedLock(paths, aside);
646
+ continue;
647
+ }
648
+ console.log(
649
+ `launcher: taking over stale lifecycle lock held by ${lockDescription(prior)} (${disposition.reason}).`,
650
+ );
651
+ await unlink(aside);
652
+ await syncDirectory(paths.binDir);
653
+ }
654
+ }
655
+ throw launcherWriteError(`could not acquire lifecycle lock after stale-lock takeover: ${paths.lock}`);
656
+ }
657
+
658
+ async function releaseLauncherLock(lock) {
659
+ try {
660
+ const current = await readLockRecord(lock.paths);
661
+ if (current.record?.token === lock.record.token) {
662
+ await unlink(lock.paths.lock);
663
+ await syncDirectory(lock.paths.binDir);
664
+ }
665
+ } catch (error) {
666
+ console.error(`launcher: warning — could not release lifecycle lock: ${error.message}`);
667
+ }
668
+ }
669
+
670
+ async function withLauncherLock(paths, operation) {
671
+ const lock = await acquireLauncherLock(paths);
672
+ try {
673
+ return await operation();
674
+ } finally {
675
+ await releaseLauncherLock(lock);
676
+ }
677
+ }
678
+
679
+ async function runTestPhase(phase) {
680
+ if (process.env.NODE_ENV !== 'test') return;
681
+ if (process.env.PHRONESIS_TEST_LAUNCHER_SIGKILL_PHASE === phase) {
682
+ process.kill(process.pid, 'SIGKILL');
683
+ }
684
+ const holdPhases = (process.env.PHRONESIS_TEST_LAUNCHER_HOLD_PHASES || '')
685
+ .split(',')
686
+ .filter(Boolean);
687
+ if (holdPhases.includes(phase)) {
688
+ const readyDir = process.env.PHRONESIS_TEST_LAUNCHER_READY_DIR;
689
+ const releaseDir = process.env.PHRONESIS_TEST_LAUNCHER_RELEASE_DIR;
690
+ if (!readyDir || !releaseDir) {
691
+ throw launcherWriteError('test hold phases require ready and release directories');
692
+ }
693
+ const ready = join(readyDir, `${phase}-${process.pid}`);
694
+ const release = join(releaseDir, `${phase}-${process.pid}`);
695
+ await writeFile(ready, `${process.pid}\n`);
696
+ for (;;) {
697
+ const released = await stateFor(release);
698
+ if (released.present) return;
699
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10));
700
+ }
701
+ }
702
+ if (process.env.PHRONESIS_TEST_LAUNCHER_HOLD_PHASE !== phase) return;
703
+ const ready = process.env.PHRONESIS_TEST_LAUNCHER_READY_FILE;
704
+ const release = process.env.PHRONESIS_TEST_LAUNCHER_RELEASE_FILE;
705
+ if (!ready || !release) throw launcherWriteError('test hold phase requires ready and release files');
706
+ await writeFile(ready, `${process.pid}\n`);
707
+ for (;;) {
708
+ const released = await stateFor(release);
709
+ if (released.present) return;
710
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10));
711
+ }
712
+ }
713
+
714
+ async function notifyPhase(onPhase, phase) {
715
+ await onPhase?.(phase);
716
+ await runTestPhase(phase);
717
+ }
718
+
719
+ async function syncDirectory(path) {
720
+ let handle;
721
+ try {
722
+ handle = await open(path, constants.O_RDONLY);
723
+ await handle.sync();
724
+ } catch (error) {
725
+ // Some supported filesystems do not expose directory fsync. The payload/manifest
726
+ // file itself is still fsynced before rename; only explicitly unsupported directory
727
+ // sync is tolerated.
728
+ if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR'].includes(error?.code)) throw error;
729
+ } finally {
730
+ await handle?.close().catch(() => {});
731
+ }
732
+ }
733
+
734
+ function noFollowFlags(base) {
735
+ return base | (constants.O_NOFOLLOW || 0);
736
+ }
737
+
738
+ export async function readRegularFileNoFollow(path, label = path) {
739
+ const before = await stateFor(path);
740
+ if (before.symlink) throw launcherWriteError(`refusing symlinked ${label}: ${path}`);
741
+ if (!before.file) throw launcherWriteError(`${label} is not a regular file: ${path}`);
742
+ const handle = await open(path, noFollowFlags(constants.O_RDONLY));
743
+ try {
744
+ return await handle.readFile();
745
+ } finally {
746
+ await handle.close();
747
+ }
748
+ }
749
+
750
+ async function writeAtomicFile({
751
+ path,
752
+ tempPath,
753
+ bytes,
754
+ mode,
755
+ exclusiveTarget = false,
756
+ onBeforeRename,
757
+ onBeforeExclusiveCreate,
758
+ onAfterRename,
759
+ commitRename,
760
+ }) {
761
+ if (exclusiveTarget) {
762
+ // Missing targets are written through the O_EXCL handle itself. There is no
763
+ // placeholder and no later rename-over: a concurrent creator wins with
764
+ // EEXIST, while normal write failures remove only the inode created here.
765
+ await onBeforeRename?.();
766
+ await onBeforeExclusiveCreate?.();
767
+ let exclusiveHandle;
768
+ let createdTarget = null;
769
+ let completed = false;
770
+ try {
771
+ try {
772
+ exclusiveHandle = await open(
773
+ path,
774
+ noFollowFlags(constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY),
775
+ mode,
776
+ );
777
+ createdTarget = await exclusiveHandle.stat();
778
+ } catch (error) {
779
+ if (error?.code !== 'EEXIST') throw error;
780
+ const conflict = launcherWriteError(`exclusive create refused because target appeared: ${path}`);
781
+ conflict.code = 'PHR_ATOMIC_TARGET_EXISTS';
782
+ throw conflict;
783
+ }
784
+ await exclusiveHandle.writeFile(bytes);
785
+ await exclusiveHandle.chmod(mode);
786
+ await exclusiveHandle.sync();
787
+ await exclusiveHandle.close();
788
+ completed = true;
789
+ await onAfterRename?.();
790
+ await syncDirectory(dirname(path));
791
+ return;
792
+ } finally {
793
+ await exclusiveHandle?.close().catch(() => {});
794
+ if (!completed && createdTarget) {
795
+ const latest = await stateFor(path).catch(() => null);
796
+ if (latest?.dev === createdTarget.dev && latest?.ino === createdTarget.ino) {
797
+ await unlink(path).catch(() => {});
798
+ }
799
+ }
800
+ }
801
+ }
802
+
803
+ const handle = await open(
804
+ tempPath,
805
+ noFollowFlags(constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY),
806
+ mode,
807
+ );
808
+ let renamed = false;
809
+ try {
810
+ await handle.writeFile(bytes);
811
+ await handle.chmod(mode);
812
+ await handle.sync();
813
+
814
+ const target = await stateFor(path);
815
+ assertCurrentOwner(path, target, 'managed artifact');
816
+ if (target.symlink) throw launcherWriteError(`refusing symlinked managed artifact: ${path}`);
817
+ if (target.present && !target.file) throw launcherWriteError(`managed artifact is not a regular file: ${path}`);
818
+ if (commitRename) await commitRename();
819
+ else {
820
+ await onBeforeRename?.();
821
+ await rename(tempPath, path);
822
+ }
823
+ renamed = true;
824
+ await onAfterRename?.();
825
+ await handle.close();
826
+ await syncDirectory(dirname(path));
827
+ } finally {
828
+ await handle.close().catch(() => {});
829
+ if (!renamed) await unlink(tempPath).catch(() => {});
830
+ }
831
+ }
832
+
833
+ // Shared atomic/no-follow primitive for managed files outside the launcher tree.
834
+ // Callers still preflight their own parent-directory ownership/containment rules;
835
+ // this helper owns the final-file race check, fsync, existing-target rename or
836
+ // missing-target exclusive creation, and partial cleanup.
837
+ export async function writeAtomicFileNoFollow({
838
+ path,
839
+ bytes,
840
+ mode = 0o600,
841
+ exclusiveTarget = false,
842
+ onBeforeRename,
843
+ onBeforeExclusiveCreate,
844
+ onAfterRename,
845
+ }) {
846
+ return writeAtomicFile({
847
+ path,
848
+ tempPath: uniqueTempPath(path),
849
+ bytes,
850
+ mode,
851
+ exclusiveTarget,
852
+ onBeforeRename,
853
+ onBeforeExclusiveCreate,
854
+ onAfterRename,
855
+ });
856
+ }
857
+
858
+ async function cleanupStaleLauncherArtifacts(paths) {
859
+ const removed = [];
860
+ const warnings = [];
861
+ const bin = await stateFor(paths.binDir);
862
+ if (bin.present) {
863
+ assertCurrentOwner(paths.binDir, bin, 'managed bin directory');
864
+ for (const entry of await readdir(paths.binDir, { withFileTypes: true })) {
865
+ if (!/^(?:(?:phronesis|launcher\.env)\.tmp(?:-|$)|\.launcher\.lock\.reaping-)/.test(entry.name)) continue;
866
+ const candidate = join(paths.binDir, entry.name);
867
+ try {
868
+ const current = await stateFor(candidate);
869
+ assertCurrentOwner(candidate, current, 'stale launcher temporary file');
870
+ if (current.symlink || !current.file) {
871
+ warnings.push(`stale cleanup skipped non-file or symlinked artifact: ${candidate}`);
872
+ continue;
873
+ }
874
+ await unlink(candidate);
875
+ removed.push(candidate);
876
+ } catch (error) {
877
+ if (error?.code === 'PHR_LAUNCHER_FOREIGN_OWNER') throw error;
878
+ warnings.push(`stale cleanup could not remove ${candidate}: ${error.message}`);
879
+ }
880
+ }
881
+ }
882
+
883
+ const generations = await stateFor(paths.generations);
884
+ if (generations.present) {
885
+ assertCurrentOwner(paths.generations, generations, 'generation directory');
886
+ for (const entry of await readdir(paths.generations, { withFileTypes: true })) {
887
+ if (!STAGING_DIR_RE.test(entry.name)) continue;
888
+ const candidate = join(paths.generations, entry.name);
889
+ try {
890
+ const current = await stateFor(candidate);
891
+ assertCurrentOwner(candidate, current, 'stale staging generation');
892
+ if (current.symlink || !current.directory) {
893
+ warnings.push(`stale cleanup skipped non-directory or symlinked staging artifact: ${candidate}`);
894
+ continue;
895
+ }
896
+ await rm(candidate, { recursive: true, force: false });
897
+ removed.push(candidate);
898
+ } catch (error) {
899
+ if (error?.code === 'PHR_LAUNCHER_FOREIGN_OWNER') throw error;
900
+ warnings.push(`stale cleanup could not remove ${candidate}: ${error.message}`);
901
+ }
902
+ }
903
+ }
904
+ return { removed, warnings };
905
+ }
906
+
907
+ async function existingManifestRecord(paths) {
908
+ const current = await stateFor(paths.manifest);
909
+ if (!current.present) return { present: false, valid: false, manifest: null, errors: [] };
910
+ assertCurrentOwner(paths.manifest, current, 'launcher.env');
911
+ if (current.symlink) throw launcherWriteError(`refusing symlinked launcher.env: ${paths.manifest}`);
912
+ if (!current.file) throw launcherWriteError(`launcher.env is not a regular file: ${paths.manifest}`);
913
+ try {
914
+ const parsed = parseLauncherManifest((await readRegularFileNoFollow(paths.manifest, 'launcher.env')).toString('utf8'));
915
+ return { present: true, valid: parsed.ok, manifest: parsed.manifest, errors: parsed.errors };
916
+ } catch (error) {
917
+ return { present: true, valid: false, manifest: null, errors: [`launcher.env unreadable: ${error.message}`] };
918
+ }
919
+ }
920
+
921
+ function parseSemver(value) {
922
+ const match = String(value).match(
923
+ /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
924
+ );
925
+ if (!match) return null;
926
+ return {
927
+ core: match.slice(1, 4).map(Number),
928
+ prerelease: match[4] === undefined ? null : match[4].split('.'),
929
+ };
930
+ }
931
+
932
+ export function compareLauncherVersions(left, right) {
933
+ const a = parseSemver(left);
934
+ const b = parseSemver(right);
935
+ if (!a || !b) return null;
936
+ for (let i = 0; i < 3; i += 1) {
937
+ if (a.core[i] !== b.core[i]) return a.core[i] < b.core[i] ? -1 : 1;
938
+ }
939
+ if (a.prerelease === null && b.prerelease === null) return 0;
940
+ if (a.prerelease === null) return 1;
941
+ if (b.prerelease === null) return -1;
942
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
943
+ for (let i = 0; i < length; i += 1) {
944
+ const av = a.prerelease[i];
945
+ const bv = b.prerelease[i];
946
+ if (av === undefined) return -1;
947
+ if (bv === undefined) return 1;
948
+ if (av === bv) continue;
949
+ const an = /^\d+$/.test(av);
950
+ const bn = /^\d+$/.test(bv);
951
+ if (an && bn) {
952
+ const ai = BigInt(av);
953
+ const bi = BigInt(bv);
954
+ if (ai !== bi) return ai < bi ? -1 : 1;
955
+ continue;
956
+ }
957
+ if (an !== bn) return an ? -1 : 1;
958
+ return av < bv ? -1 : 1;
959
+ }
960
+ return 0;
961
+ }
962
+
963
+ async function packageDescriptor(packageRoot) {
964
+ const manifestPath = join(packageRoot, 'package.json');
965
+ const packageJson = JSON.parse((await readRegularFileNoFollow(manifestPath, 'CLI package.json')).toString('utf8'));
966
+ if (packageJson.name !== 'phronesis') {
967
+ throw launcherWriteError(`source package must be named phronesis (got ${JSON.stringify(packageJson.name)})`);
968
+ }
969
+ if (!TOKEN_RE.test(packageJson.version || '') || /^\.+$/.test(packageJson.version)) {
970
+ throw launcherWriteError(`source package has an invalid launcher version token: ${JSON.stringify(packageJson.version)}`);
971
+ }
972
+ const entry = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.phronesis;
973
+ if (typeof entry !== 'string' || !entry) {
974
+ throw launcherWriteError('source package does not declare bin.phronesis');
975
+ }
976
+ const lexicalEntry = resolve(packageRoot, entry);
977
+ const rel = relative(packageRoot, lexicalEntry);
978
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
979
+ throw launcherWriteError(`bin.phronesis escapes the source package: ${entry}`);
980
+ }
981
+ return { name: packageJson.name, version: packageJson.version, entry };
982
+ }
983
+
984
+ function runSubprocess(command, args, { cwd, env = {} } = {}) {
985
+ const result = spawnSync(command, args, {
986
+ cwd,
987
+ env: { ...process.env, ...env },
988
+ encoding: 'utf8',
989
+ maxBuffer: 16 * 1024 * 1024,
990
+ });
991
+ if (result.error || result.status !== 0) {
992
+ const detail = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join('\n').trim();
993
+ throw launcherWriteError(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
994
+ }
995
+ return result;
996
+ }
997
+
998
+ export async function resolveNpmCommand(runtime = process.execPath) {
999
+ const canonical = realpathSync(runtime);
1000
+ const adjacent = join(dirname(canonical), process.platform === 'win32' ? 'npm.cmd' : 'npm');
1001
+ try {
1002
+ const candidate = await stat(adjacent);
1003
+ if (candidate.isFile() && (process.platform === 'win32' || candidate.mode & 0o111)) return adjacent;
1004
+ } catch {
1005
+ // Fall through to PATH resolution when this runtime does not ship npm beside node.
1006
+ }
1007
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
1008
+ }
1009
+
1010
+ export async function materializePackageClosure({
1011
+ packageRoot,
1012
+ stagingDir,
1013
+ home = homedir(),
1014
+ npmCommand,
1015
+ runtime = process.execPath,
1016
+ } = {}) {
1017
+ const npm = npmCommand || await resolveNpmCommand(runtime);
1018
+ const runtimePath = [dirname(realpathSync(runtime)), process.env.PATH]
1019
+ .filter((entry) => typeof entry === 'string' && entry.length > 0)
1020
+ .join(delimiter);
1021
+ const packDir = await mkdtemp(join(tmpdir(), 'phronesis-launcher-pack-'));
1022
+ try {
1023
+ // This is the same npm-materialized closure used by verify-installability.mjs:
1024
+ // a local package tarball is installed into a clean prefix so npm resolves and
1025
+ // lays out the complete production dependency tree. Lifecycle scripts are not
1026
+ // needed by Phronesis's pure-JS closure and are disabled at both boundaries.
1027
+ runSubprocess(
1028
+ npm,
1029
+ ['pack', packageRoot, '--ignore-scripts', '--pack-destination', packDir],
1030
+ {
1031
+ cwd: packageRoot,
1032
+ env: {
1033
+ HOME: home,
1034
+ PATH: runtimePath,
1035
+ npm_config_audit: 'false',
1036
+ npm_config_fund: 'false',
1037
+ npm_config_update_notifier: 'false',
1038
+ },
1039
+ },
1040
+ );
1041
+ const tarballs = (await readdir(packDir)).filter((name) => name.endsWith('.tgz'));
1042
+ if (tarballs.length !== 1) {
1043
+ throw launcherWriteError(`npm pack produced ${tarballs.length} tarballs (expected exactly one)`);
1044
+ }
1045
+ runSubprocess(
1046
+ npm,
1047
+ [
1048
+ 'install',
1049
+ '--prefix',
1050
+ stagingDir,
1051
+ '--omit=dev',
1052
+ '--ignore-scripts',
1053
+ '--no-audit',
1054
+ '--no-fund',
1055
+ '--package-lock=false',
1056
+ join(packDir, tarballs[0]),
1057
+ ],
1058
+ {
1059
+ cwd: stagingDir,
1060
+ env: {
1061
+ HOME: home,
1062
+ PATH: runtimePath,
1063
+ npm_config_update_notifier: 'false',
1064
+ },
1065
+ },
1066
+ );
1067
+ } finally {
1068
+ await rm(packDir, { recursive: true, force: true });
1069
+ }
1070
+ }
1071
+
1072
+ async function validateGeneration({ generationDir, descriptor, runtime, home }) {
1073
+ const generationState = await stateFor(generationDir);
1074
+ assertCurrentOwner(generationDir, generationState, 'staged generation');
1075
+ if (generationState.symlink || !generationState.directory) {
1076
+ throw launcherWriteError(`staged generation is missing, non-directory, or symlinked: ${generationDir}`);
1077
+ }
1078
+ const packageRoot = join(generationDir, 'node_modules', descriptor.name);
1079
+ const packageFile = join(packageRoot, 'package.json');
1080
+ const entry = resolve(packageRoot, descriptor.entry);
1081
+ const rel = relative(packageRoot, entry);
1082
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
1083
+ throw launcherWriteError(`staged CLI entry escapes its package tree: ${entry}`);
1084
+ }
1085
+
1086
+ const packageState = await stateFor(packageFile);
1087
+ assertCurrentOwner(packageFile, packageState, 'staged CLI package.json');
1088
+ const packageJson = JSON.parse((await readRegularFileNoFollow(packageFile, 'staged CLI package.json')).toString('utf8'));
1089
+ if (packageJson.name !== descriptor.name || packageJson.version !== descriptor.version) {
1090
+ throw launcherWriteError(
1091
+ `staged CLI package mismatch (expected ${descriptor.name}@${descriptor.version}, got ${packageJson.name}@${packageJson.version})`,
1092
+ );
1093
+ }
1094
+ const entryState = await stateFor(entry);
1095
+ assertCurrentOwner(entry, entryState, 'staged CLI entry');
1096
+ if (entryState.symlink || !entryState.file) {
1097
+ throw launcherWriteError(`staged CLI entry is missing, non-regular, or symlinked: ${entry}`);
1098
+ }
1099
+ const probe = spawnSync(runtime, [entry, '--version'], {
1100
+ cwd: generationDir,
1101
+ env: { ...process.env, HOME: home, PATH: '', NO_COLOR: '1' },
1102
+ encoding: 'utf8',
1103
+ maxBuffer: 4 * 1024 * 1024,
1104
+ });
1105
+ if (probe.error || probe.status !== 0 || probe.stdout.trim() !== descriptor.version) {
1106
+ const detail = [probe.error?.message, probe.stdout, probe.stderr].filter(Boolean).join('\n').trim();
1107
+ throw launcherWriteError(
1108
+ `staged CLI integrity probe failed (expected --version ${descriptor.version})${detail ? `: ${detail}` : ''}`,
1109
+ );
1110
+ }
1111
+ return entry;
1112
+ }
1113
+
1114
+ async function canonicalRuntime(runtimePath) {
1115
+ let canonical;
1116
+ try {
1117
+ canonical = realpathSync(runtimePath);
1118
+ } catch (error) {
1119
+ throw launcherWriteError(`runtime cannot be canonicalized: ${runtimePath} (${error.message})`);
1120
+ }
1121
+ const runtimeState = await stat(canonical).catch(() => null);
1122
+ if (!runtimeState?.isFile() || !(runtimeState.mode & 0o111)) {
1123
+ throw launcherWriteError(`canonical runtime is missing or not executable: ${canonical}`);
1124
+ }
1125
+ return canonical;
1126
+ }
1127
+
1128
+ async function removeGenerationPath(path, paths, label = 'generation') {
1129
+ if (dirname(path) !== paths.generations) {
1130
+ throw launcherWriteError(`refusing to remove ${label} outside ${paths.generations}: ${path}`);
1131
+ }
1132
+ const current = await stateFor(path);
1133
+ if (!current.present) return false;
1134
+ assertCurrentOwner(path, current, label);
1135
+ if (current.symlink) throw launcherWriteError(`refusing symlinked ${label}: ${path}`);
1136
+ if (!current.directory) throw launcherWriteError(`${label} is not a directory: ${path}`);
1137
+ await rm(path, { recursive: true, force: false });
1138
+ return true;
1139
+ }
1140
+
1141
+ async function prepareGeneration({
1142
+ paths,
1143
+ descriptor,
1144
+ runtime,
1145
+ home,
1146
+ prior,
1147
+ onPhase,
1148
+ materialize,
1149
+ npmCommand,
1150
+ }) {
1151
+ const generationDir = join(paths.generations, descriptor.version);
1152
+ const suffix = uniqueSuffix();
1153
+ const stagingDir = join(paths.generations, `${descriptor.version}.staging-${uniqueStagingSuffix()}`);
1154
+ const payloadName = `payload-${suffix}`;
1155
+ const stagedPayload = join(stagingDir, payloadName);
1156
+ const priorGeneration = prior.valid && prior.manifest.PHR_BACKEND === 'self-copy'
1157
+ ? prior.manifest.PHR_GENERATION
1158
+ : null;
1159
+
1160
+ const existing = await stateFor(generationDir);
1161
+ if (existing.present) {
1162
+ assertCurrentOwner(generationDir, existing, 'generation path');
1163
+ if (existing.symlink) throw launcherWriteError(`refusing symlinked generation path: ${generationDir}`);
1164
+ if (!existing.directory) throw launcherWriteError(`generation path is not a directory: ${generationDir}`);
1165
+ if (priorGeneration !== descriptor.version) await removeGenerationPath(generationDir, paths);
1166
+ }
1167
+
1168
+ await notifyPhase(onPhase, 'before-stage-create');
1169
+ await mkdir(stagingDir, { mode: 0o700 });
1170
+ try {
1171
+ await mkdir(stagedPayload, { mode: 0o700 });
1172
+ await notifyPhase(onPhase, 'after-stage-create');
1173
+ await materialize({
1174
+ packageRoot: descriptor.packageRoot,
1175
+ stagingDir: stagedPayload,
1176
+ home,
1177
+ npmCommand,
1178
+ runtime,
1179
+ });
1180
+ await notifyPhase(onPhase, 'after-stage-materialize');
1181
+ await assertTreeOwned(stagingDir, 'staged generation artifact');
1182
+ const stagedTarget = await validateGeneration({
1183
+ generationDir: stagedPayload,
1184
+ descriptor,
1185
+ runtime,
1186
+ home,
1187
+ });
1188
+ await notifyPhase(onPhase, 'after-integrity-check');
1189
+
1190
+ const destination = await stateFor(generationDir);
1191
+ assertCurrentOwner(generationDir, destination, 'generation path');
1192
+ let target;
1193
+ if (!destination.present) {
1194
+ await rename(stagingDir, generationDir);
1195
+ target = join(generationDir, relative(stagingDir, stagedTarget));
1196
+ } else {
1197
+ if (destination.symlink || !destination.directory) {
1198
+ throw launcherWriteError(`generation path became symlinked or non-directory during staging: ${generationDir}`);
1199
+ }
1200
+ const finalPayload = join(generationDir, payloadName);
1201
+ const payloadState = await stateFor(finalPayload);
1202
+ if (payloadState.present) throw launcherWriteError(`generation payload appeared during staging: ${finalPayload}`);
1203
+ await rename(stagedPayload, finalPayload);
1204
+ await rm(stagingDir, { recursive: true, force: false });
1205
+ target = join(finalPayload, relative(stagedPayload, stagedTarget));
1206
+ }
1207
+ await syncDirectory(paths.generations);
1208
+ await notifyPhase(onPhase, 'after-generation-rename');
1209
+ return { generationDir, target, payloadName, reused: false };
1210
+ } catch (error) {
1211
+ await removeGenerationPath(stagingDir, paths, 'staging generation').catch(() => {});
1212
+ throw error;
1213
+ }
1214
+ }
1215
+
1216
+ async function cleanupOrphanPayloads(paths) {
1217
+ const removed = [];
1218
+ const warnings = [];
1219
+ const initial = await existingManifestRecord(paths);
1220
+ if (!initial.valid || initial.manifest.PHR_BACKEND !== 'self-copy') return { removed, warnings };
1221
+ const generationDir = join(paths.generations, initial.manifest.PHR_GENERATION);
1222
+ const current = await stateFor(generationDir);
1223
+ if (!current.present || current.symlink || !current.directory) return { removed, warnings };
1224
+ assertCurrentOwner(generationDir, current, 'generation path');
1225
+ for (const entry of await readdir(generationDir, { withFileTypes: true })) {
1226
+ if (!entry.name.startsWith('payload-')) continue;
1227
+ const candidate = join(generationDir, entry.name);
1228
+ try {
1229
+ // Re-read at the destructive edge even though the lifecycle lock excludes
1230
+ // cooperating writers. The manifest bytes being honored decide liveness.
1231
+ const latest = await existingManifestRecord(paths);
1232
+ if (!latest.valid || latest.manifest.PHR_BACKEND !== 'self-copy') {
1233
+ warnings.push('payload cleanup stopped because launcher.env is absent, invalid, or no longer self-copy');
1234
+ break;
1235
+ }
1236
+ const latestGeneration = join(paths.generations, latest.manifest.PHR_GENERATION);
1237
+ if (latestGeneration !== generationDir) break;
1238
+ if (strictlyContainedBy(candidate, latest.manifest.PHR_TARGET)) continue;
1239
+ const payload = await stateFor(candidate);
1240
+ assertCurrentOwner(candidate, payload, 'orphan generation payload');
1241
+ if (payload.symlink || !payload.directory) {
1242
+ warnings.push(`payload cleanup skipped non-directory or symlinked artifact: ${candidate}`);
1243
+ continue;
1244
+ }
1245
+ await rm(candidate, { recursive: true, force: false });
1246
+ removed.push(candidate);
1247
+ } catch (error) {
1248
+ if (error?.code === 'PHR_LAUNCHER_FOREIGN_OWNER') throw error;
1249
+ warnings.push(`payload cleanup could not remove ${candidate}: ${error.message}`);
1250
+ }
1251
+ }
1252
+ return { removed, warnings };
1253
+ }
1254
+
1255
+ function collisionRecord(prior, dispatcher) {
1256
+ if (!prior.present) {
1257
+ return dispatcher?.present
1258
+ ? { kind: 'unowned dispatcher', owner: 'none', version: 'unknown' }
1259
+ : null;
1260
+ }
1261
+ if (!prior.valid) {
1262
+ return {
1263
+ kind: 'unrecognized manifest',
1264
+ owner: prior.manifest?.PHR_BACKEND || 'unrecognized',
1265
+ version: prior.manifest?.PHR_VERSION || 'unknown',
1266
+ };
1267
+ }
1268
+ return {
1269
+ kind: 'recognized manifest',
1270
+ owner: prior.manifest?.PHR_BACKEND || 'invalid',
1271
+ version: prior.manifest?.PHR_VERSION || 'unknown',
1272
+ };
1273
+ }
1274
+
1275
+ async function assertExpectedPrior(paths, prior, expectedPrior) {
1276
+ if (!expectedPrior) return;
1277
+ const dispatcher = await stateFor(paths.dispatcher);
1278
+ if (expectedPrior.absent === true) {
1279
+ if (!prior.present && !dispatcher.present) return;
1280
+ const actual = prior.valid
1281
+ ? `${prior.manifest.PHR_BACKEND}@${prior.manifest.PHR_VERSION}`
1282
+ : prior.present ? 'unrecognized-manifest' : 'unowned-dispatcher';
1283
+ throw launcherWriteError(`slot-changed-since-disclosure: expected absent; found ${actual}`);
1284
+ }
1285
+ if (
1286
+ prior.valid
1287
+ && prior.manifest.PHR_BACKEND === expectedPrior.owner
1288
+ && prior.manifest.PHR_VERSION === expectedPrior.version
1289
+ ) return;
1290
+ const actual = prior.valid
1291
+ ? `${prior.manifest.PHR_BACKEND}@${prior.manifest.PHR_VERSION}`
1292
+ : prior.present ? 'unrecognized-manifest' : dispatcher.present ? 'unowned-dispatcher' : 'absent';
1293
+ throw launcherWriteError(
1294
+ `slot-changed-since-disclosure: expected ${expectedPrior.owner}@${expectedPrior.version}; found ${actual}`,
1295
+ );
1296
+ }
1297
+
1298
+ async function preflightInstall({
1299
+ paths,
1300
+ descriptor,
1301
+ backend = 'self-copy',
1302
+ prior,
1303
+ forceLauncher,
1304
+ reconcileAppOwner = false,
1305
+ dispatcherTemplate,
1306
+ }) {
1307
+ if (prior.present && !prior.valid && !forceLauncher) {
1308
+ throw launcherWriteError(`existing launcher.env is invalid (${prior.errors.join('; ')}); use --force-launcher to replace it`);
1309
+ }
1310
+ if (prior.valid && prior.manifest.PHR_BACKEND !== backend && !forceLauncher) {
1311
+ throw launcherWriteError(
1312
+ `launcher slot is owned by ${prior.manifest.PHR_BACKEND}@${prior.manifest.PHR_VERSION}; use --force-launcher to replace it`,
1313
+ );
1314
+ }
1315
+ if (prior.valid) {
1316
+ const order = compareLauncherVersions(descriptor.version, prior.manifest.PHR_VERSION);
1317
+ if (order === null && descriptor.version !== prior.manifest.PHR_VERSION && !forceLauncher && !reconcileAppOwner) {
1318
+ throw launcherWriteError(
1319
+ `cannot compare installed version ${prior.manifest.PHR_VERSION} with ${descriptor.version}; use --force-launcher to replace it`,
1320
+ );
1321
+ }
1322
+ if (order !== null && order < 0 && !forceLauncher && !reconcileAppOwner) {
1323
+ throw launcherWriteError(
1324
+ `downgrade from ${prior.manifest.PHR_VERSION} to ${descriptor.version} refused; use --force-launcher to continue`,
1325
+ );
1326
+ }
1327
+ }
1328
+
1329
+ const dispatcher = await stateFor(paths.dispatcher);
1330
+ assertCurrentOwner(paths.dispatcher, dispatcher, 'dispatcher');
1331
+ if (dispatcher.symlink) throw launcherWriteError(`refusing symlinked dispatcher: ${paths.dispatcher}`);
1332
+ if (dispatcher.present && !dispatcher.file) {
1333
+ throw launcherWriteError(`dispatcher path is not a regular file: ${paths.dispatcher}`);
1334
+ }
1335
+ if (dispatcher.file && !prior.present) {
1336
+ const existing = await readRegularFileNoFollow(paths.dispatcher, 'dispatcher');
1337
+ if (!existing.equals(dispatcherTemplate) && !forceLauncher) {
1338
+ throw launcherWriteError(`launcher slot contains an unowned dispatcher; use --force-launcher to replace it`);
1339
+ }
1340
+ }
1341
+ return dispatcher;
1342
+ }
1343
+
1344
+ async function installDispatcher({ paths, template, onPhase }) {
1345
+ await writeAtomicFile({
1346
+ path: paths.dispatcher,
1347
+ tempPath: uniqueTempPath(paths.dispatcher),
1348
+ bytes: template,
1349
+ mode: 0o755,
1350
+ onBeforeRename: async () => notifyPhase(onPhase, 'before-dispatcher-rename'),
1351
+ });
1352
+ await notifyPhase(onPhase, 'after-dispatcher-rename');
1353
+ }
1354
+
1355
+ async function verifyManifestTargetForCommit(paths, manifest) {
1356
+ if (manifest.PHR_BACKEND === 'app') {
1357
+ const target = await stateFor(manifest.PHR_TARGET);
1358
+ assertAppMaterialOwner(manifest.PHR_TARGET, target, 'app target');
1359
+ if (target.symlink || !target.file) {
1360
+ throw launcherWriteError(`pre-commit app target is missing, non-regular, or symlinked: ${manifest.PHR_TARGET}`);
1361
+ }
1362
+ const runtime = await stateFor(manifest.PHR_RUNTIME);
1363
+ assertAppMaterialOwner(manifest.PHR_RUNTIME, runtime, 'app runtime');
1364
+ if (runtime.symlink || !runtime.file || !(runtime.mode & 0o111)) {
1365
+ throw launcherWriteError(`pre-commit app runtime is missing, non-executable, non-regular, or symlinked: ${manifest.PHR_RUNTIME}`);
1366
+ }
1367
+ return;
1368
+ }
1369
+ const generationDir = join(paths.generations, manifest.PHR_GENERATION);
1370
+ const generation = await stateFor(generationDir);
1371
+ assertCurrentOwner(generationDir, generation, 'generation path');
1372
+ if (generation.symlink || !generation.directory) {
1373
+ throw launcherWriteError(`pre-commit generation is missing, non-directory, or symlinked: ${generationDir}`);
1374
+ }
1375
+ const target = await stateFor(manifest.PHR_TARGET);
1376
+ assertCurrentOwner(manifest.PHR_TARGET, target, 'staged CLI target');
1377
+ if (target.symlink || !target.file) {
1378
+ throw launcherWriteError(`pre-commit target is missing, non-regular, or symlinked: ${manifest.PHR_TARGET}`);
1379
+ }
1380
+ if (!strictlyContainedBy(generationDir, manifest.PHR_TARGET)) {
1381
+ throw launcherWriteError(`pre-commit target escapes generation ${generationDir}: ${manifest.PHR_TARGET}`);
1382
+ }
1383
+ }
1384
+
1385
+ async function commitManifest({ paths, manifest, onPhase }) {
1386
+ let committed = false;
1387
+ const manifestTmp = uniqueTempPath(paths.manifest);
1388
+ const handle = await open(
1389
+ manifestTmp,
1390
+ noFollowFlags(constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY),
1391
+ 0o600,
1392
+ );
1393
+ try {
1394
+ await handle.writeFile(serializeLauncherManifest(manifest));
1395
+ await handle.sync();
1396
+ await notifyPhase(onPhase, 'after-manifest-fsync');
1397
+ const current = await stateFor(paths.manifest);
1398
+ assertCurrentOwner(paths.manifest, current, 'launcher.env');
1399
+ if (current.symlink) throw launcherWriteError(`refusing symlinked launcher.env: ${paths.manifest}`);
1400
+ if (current.present && !current.file) throw launcherWriteError(`launcher.env is not a regular file: ${paths.manifest}`);
1401
+ await notifyPhase(onPhase, 'before-manifest-rename');
1402
+ await verifyManifestTargetForCommit(paths, manifest);
1403
+ await runTestPhase('immediately-before-manifest-rename');
1404
+ await rename(manifestTmp, paths.manifest);
1405
+ committed = true;
1406
+ await runTestPhase('immediately-after-manifest-rename');
1407
+ await handle.close();
1408
+ await syncDirectory(paths.binDir);
1409
+ await notifyPhase(onPhase, 'after-manifest-rename');
1410
+ } finally {
1411
+ await handle.close().catch(() => {});
1412
+ if (!committed) await unlink(manifestTmp).catch(() => {});
1413
+ }
1414
+ return committed;
1415
+ }
1416
+
1417
+ async function garbageCollectLauncherGenerationsUnlocked(paths, { cleanStale = false } = {}) {
1418
+ const removed = [];
1419
+ const warnings = [];
1420
+ await assertTreeOwned(paths.binDir, 'managed launcher artifact');
1421
+ await assertTreeOwned(paths.generations, 'managed generation artifact');
1422
+ if (cleanStale) {
1423
+ const stale = await cleanupStaleLauncherArtifacts(paths);
1424
+ removed.push(...stale.removed);
1425
+ warnings.push(...stale.warnings);
1426
+ }
1427
+ const generationsState = await stateFor(paths.generations);
1428
+ if (!generationsState.present) return { removed, warnings };
1429
+ assertCurrentOwner(paths.generations, generationsState, 'generation directory');
1430
+ if (generationsState.symlink || !generationsState.directory) {
1431
+ return { removed, warnings: [`refusing GC: ${paths.generations} is symlinked or not a directory`] };
1432
+ }
1433
+
1434
+ let prior;
1435
+ try {
1436
+ prior = await existingManifestRecord(paths);
1437
+ } catch (error) {
1438
+ return { removed, warnings: [`refusing GC: ${error.message}`] };
1439
+ }
1440
+ if (prior.present && !prior.valid) {
1441
+ return { removed, warnings: [`refusing GC while launcher.env is invalid: ${prior.errors.join('; ')}`] };
1442
+ }
1443
+ const payloadCleanup = await cleanupOrphanPayloads(paths);
1444
+ removed.push(...payloadCleanup.removed);
1445
+ warnings.push(...payloadCleanup.warnings);
1446
+ const referenced = prior.valid && prior.manifest.PHR_BACKEND === 'self-copy'
1447
+ ? prior.manifest.PHR_GENERATION
1448
+ : null;
1449
+
1450
+ for (const entry of await readdir(paths.generations, { withFileTypes: true })) {
1451
+ if (entry.name === referenced || STAGING_DIR_RE.test(entry.name)) continue;
1452
+ if (!TOKEN_RE.test(entry.name) || /^\.+$/.test(entry.name)) continue;
1453
+ const candidate = join(paths.generations, entry.name);
1454
+ try {
1455
+ const latest = await existingManifestRecord(paths);
1456
+ if (latest.present && !latest.valid) {
1457
+ warnings.push(`GC stopped because launcher.env became invalid: ${latest.errors.join('; ')}`);
1458
+ break;
1459
+ }
1460
+ const latestReference = latest.valid && latest.manifest.PHR_BACKEND === 'self-copy'
1461
+ ? latest.manifest.PHR_GENERATION
1462
+ : null;
1463
+ if (entry.name === latestReference) continue;
1464
+ const current = await stateFor(candidate);
1465
+ assertCurrentOwner(candidate, current, 'generation path');
1466
+ if (current.symlink || !current.directory) {
1467
+ warnings.push(`GC skipped non-directory or symlinked generation: ${candidate}`);
1468
+ continue;
1469
+ }
1470
+ await removeGenerationPath(candidate, paths);
1471
+ removed.push(candidate);
1472
+ } catch (error) {
1473
+ if (error?.code === 'PHR_LAUNCHER_FOREIGN_OWNER') throw error;
1474
+ warnings.push(`GC could not remove ${candidate}: ${error.message}`);
1475
+ }
1476
+ }
1477
+ return { removed, warnings };
1478
+ }
1479
+
1480
+ export async function garbageCollectLauncherGenerations({ home = homedir() } = {}) {
1481
+ const paths = launcherPaths({ home });
1482
+ const managed = await stateFor(paths.managedDir);
1483
+ if (!managed.present) return { removed: [], warnings: [] };
1484
+ await ensureManagedTree(paths);
1485
+ return withLauncherLock(paths, () => garbageCollectLauncherGenerationsUnlocked(paths, { cleanStale: true }));
1486
+ }
1487
+
1488
+ async function canonicalAppFile(path, { label, executable = false } = {}) {
1489
+ if (typeof path !== 'string' || !isAbsolute(path)) {
1490
+ throw launcherWriteError(`${label} must be an absolute path: ${String(path)}`);
1491
+ }
1492
+ const lexical = await stateFor(path);
1493
+ assertAppMaterialOwner(path, lexical, label);
1494
+ if (lexical.symlink || !lexical.file || (executable && !(lexical.mode & 0o111))) {
1495
+ const requirement = executable ? 'regular executable file' : 'regular file';
1496
+ throw launcherWriteError(`${label} is not a ${requirement}: ${path}`);
1497
+ }
1498
+ let canonical;
1499
+ try {
1500
+ canonical = realpathSync(path);
1501
+ } catch (error) {
1502
+ throw launcherWriteError(`${label} cannot be canonicalized: ${path} (${error.message})`);
1503
+ }
1504
+ const resolved = await stateFor(canonical);
1505
+ assertAppMaterialOwner(canonical, resolved, `canonical ${label}`);
1506
+ if (resolved.symlink || !resolved.file || (executable && !(resolved.mode & 0o111))) {
1507
+ const requirement = executable ? 'regular executable file' : 'regular file';
1508
+ throw launcherWriteError(`canonical ${label} is not a ${requirement}: ${canonical}`);
1509
+ }
1510
+ return canonical;
1511
+ }
1512
+
1513
+ function appVersionDescriptor(target, assertedVersion) {
1514
+ const version = phronesisVersionForTarget(target);
1515
+ if (!version) {
1516
+ throw launcherWriteError(`app-target-version-underivable: target is not the declared bin.phronesis of a readable phronesis package: ${target}`);
1517
+ }
1518
+ if (!TOKEN_RE.test(version) || /^\.+$/.test(version)) {
1519
+ throw launcherWriteError(`app target package has an invalid launcher version token: ${JSON.stringify(version)}`);
1520
+ }
1521
+ if (assertedVersion !== undefined && assertedVersion !== version) {
1522
+ throw launcherWriteError(
1523
+ `--app-version assertion ${JSON.stringify(assertedVersion)} does not match target package version ${JSON.stringify(version)}`,
1524
+ );
1525
+ }
1526
+ return { version };
1527
+ }
1528
+
1529
+ async function existingAppOwnerForRestamp(paths) {
1530
+ for (const [path, label] of [
1531
+ [paths.managedDir, 'managed directory'],
1532
+ [paths.binDir, 'managed bin directory'],
1533
+ ]) {
1534
+ const current = await stateFor(path);
1535
+ if (!current.present) return false;
1536
+ assertCurrentOwner(path, current, label);
1537
+ if (current.symlink) throw launcherWriteError(`refusing symlinked ${label}: ${path}`);
1538
+ if (!current.directory) throw launcherWriteError(`${label} is not a directory: ${path}`);
1539
+ }
1540
+ const prior = await existingManifestRecord(paths);
1541
+ return prior.valid && prior.manifest.PHR_BACKEND === 'app';
1542
+ }
1543
+
1544
+ function sameManifest(left, right) {
1545
+ return LAUNCHER_FIELDS.every((field) => left?.[field] === right?.[field]);
1546
+ }
1547
+
1548
+ export async function installLauncher({
1549
+ home = homedir(),
1550
+ forceLauncher = false,
1551
+ backend = 'self-copy',
1552
+ packageRoot = CLI_PACKAGE_ROOT,
1553
+ runtime = process.execPath,
1554
+ appTarget,
1555
+ appRuntime,
1556
+ appVersion,
1557
+ ifOwned = false,
1558
+ expectedPrior = null,
1559
+ onPhase,
1560
+ materialize = materializePackageClosure,
1561
+ } = {}) {
1562
+ if (!LAUNCHER_BACKENDS.includes(backend)) {
1563
+ throw launcherWriteError(`backend must be ${LAUNCHER_BACKENDS.join(' | ')} (got ${JSON.stringify(backend)})`);
1564
+ }
1565
+ if (ifOwned && backend !== 'app') {
1566
+ throw launcherWriteError('--if-owned is only valid for the app backend');
1567
+ }
1568
+ if (ifOwned && expectedPrior !== null) {
1569
+ throw launcherWriteError('--if-owned cannot be combined with expected prior disclosure binding');
1570
+ }
1571
+ if (expectedPrior !== null) {
1572
+ const expectsAbsent = expectedPrior?.absent === true
1573
+ && expectedPrior?.owner === undefined
1574
+ && expectedPrior?.version === undefined;
1575
+ const expectsIdentity = LAUNCHER_BACKENDS.includes(expectedPrior?.owner)
1576
+ && typeof expectedPrior?.version === 'string'
1577
+ && expectedPrior.version.length > 0;
1578
+ if (!expectsAbsent && !expectsIdentity) {
1579
+ throw launcherWriteError('expected prior must be { absent: true } or { owner: app|self-copy, version }');
1580
+ }
1581
+ }
1582
+ const paths = launcherPaths({ home });
1583
+ let descriptor;
1584
+ let canonicalPackageRoot;
1585
+ let canonicalRecordedRuntime;
1586
+ let canonicalTarget = null;
1587
+ if (backend === 'app') {
1588
+ canonicalTarget = await canonicalAppFile(appTarget, { label: 'app target' });
1589
+ canonicalRecordedRuntime = await canonicalAppFile(appRuntime, { label: 'app runtime', executable: true });
1590
+ descriptor = appVersionDescriptor(canonicalTarget, appVersion);
1591
+ if (ifOwned && !(await existingAppOwnerForRestamp(paths))) {
1592
+ return {
1593
+ paths,
1594
+ manifest: null,
1595
+ npmCommand: null,
1596
+ generationDir: null,
1597
+ reusedGeneration: false,
1598
+ replaced: null,
1599
+ gcRemoved: [],
1600
+ warnings: [],
1601
+ skipped: 'not-app-owned',
1602
+ };
1603
+ }
1604
+ } else {
1605
+ if (appTarget !== undefined || appRuntime !== undefined || appVersion !== undefined || ifOwned) {
1606
+ throw launcherWriteError('app backend options require --backend app');
1607
+ }
1608
+ }
1609
+ await ensureManagedTree(paths);
1610
+ return withLauncherLock(paths, async () => {
1611
+ await assertTreeOwned(paths.binDir, 'managed launcher artifact');
1612
+ await assertTreeOwned(paths.generations, 'managed generation artifact');
1613
+ const stale = await cleanupStaleLauncherArtifacts(paths);
1614
+ let npmCommand = null;
1615
+ if (backend === 'self-copy') {
1616
+ // Keep the core-0070 self-copy resolution and npm discovery inside the lifecycle
1617
+ // lock and in their original order. App mode never enters this staging path.
1618
+ canonicalPackageRoot = realpathSync(packageRoot);
1619
+ descriptor = { ...(await packageDescriptor(canonicalPackageRoot)), packageRoot: canonicalPackageRoot };
1620
+ canonicalRecordedRuntime = await canonicalRuntime(runtime);
1621
+ npmCommand = await resolveNpmCommand(canonicalRecordedRuntime);
1622
+ }
1623
+ const prior = await existingManifestRecord(paths);
1624
+ await assertExpectedPrior(paths, prior, expectedPrior);
1625
+ if (ifOwned && (!prior.valid || prior.manifest.PHR_BACKEND !== 'app')) {
1626
+ return {
1627
+ paths,
1628
+ manifest: null,
1629
+ npmCommand: null,
1630
+ generationDir: null,
1631
+ reusedGeneration: false,
1632
+ replaced: null,
1633
+ gcRemoved: [],
1634
+ warnings: stale.warnings,
1635
+ skipped: 'not-app-owned',
1636
+ };
1637
+ }
1638
+ const template = await readRegularFileNoFollow(DISPATCHER_TEMPLATE, 'shipped dispatcher template');
1639
+ const priorDispatcher = await preflightInstall({
1640
+ paths,
1641
+ descriptor,
1642
+ backend,
1643
+ prior,
1644
+ forceLauncher,
1645
+ reconcileAppOwner: Boolean(ifOwned && prior.valid && prior.manifest.PHR_BACKEND === 'app'),
1646
+ dispatcherTemplate: template,
1647
+ });
1648
+
1649
+ let generation = null;
1650
+ if (backend === 'self-copy') {
1651
+ generation = await prepareGeneration({
1652
+ paths,
1653
+ descriptor,
1654
+ runtime: canonicalRecordedRuntime,
1655
+ home,
1656
+ prior,
1657
+ onPhase,
1658
+ materialize,
1659
+ npmCommand,
1660
+ });
1661
+ }
1662
+ const manifest = backend === 'app'
1663
+ ? {
1664
+ PHR_BACKEND: 'app',
1665
+ PHR_VERSION: descriptor.version,
1666
+ PHR_TARGET: canonicalTarget,
1667
+ PHR_RUNTIME: canonicalRecordedRuntime,
1668
+ PHR_GENERATION: `app-${descriptor.version}`,
1669
+ }
1670
+ : {
1671
+ PHR_BACKEND: 'self-copy',
1672
+ PHR_VERSION: descriptor.version,
1673
+ PHR_TARGET: generation.target,
1674
+ PHR_RUNTIME: canonicalRecordedRuntime,
1675
+ PHR_GENERATION: descriptor.version,
1676
+ };
1677
+
1678
+ if (ifOwned && sameManifest(prior.manifest, manifest) && priorDispatcher.file && (priorDispatcher.mode & 0o111)) {
1679
+ const existingDispatcher = await readRegularFileNoFollow(paths.dispatcher, 'dispatcher');
1680
+ if (existingDispatcher.equals(template)) {
1681
+ return {
1682
+ paths,
1683
+ manifest,
1684
+ npmCommand: null,
1685
+ generationDir: null,
1686
+ reusedGeneration: false,
1687
+ replaced: null,
1688
+ gcRemoved: [],
1689
+ warnings: stale.warnings,
1690
+ skipped: 'current',
1691
+ };
1692
+ }
1693
+ }
1694
+ await installDispatcher({ paths, template, onPhase });
1695
+ await commitManifest({ paths, manifest, onPhase });
1696
+
1697
+ const payloadCleanup = await cleanupOrphanPayloads(paths)
1698
+ .catch((error) => cleanupFailure(error, 'payload cleanup'));
1699
+ const gc = await garbageCollectLauncherGenerationsUnlocked(paths)
1700
+ .catch((error) => cleanupFailure(error, 'GC'));
1701
+ return {
1702
+ paths,
1703
+ manifest,
1704
+ npmCommand,
1705
+ generationDir: generation?.generationDir ?? null,
1706
+ reusedGeneration: false,
1707
+ replaced: collisionRecord(prior, priorDispatcher),
1708
+ gcRemoved: [...payloadCleanup.removed, ...gc.removed],
1709
+ warnings: [...stale.warnings, ...payloadCleanup.warnings, ...gc.warnings],
1710
+ };
1711
+ });
1712
+ }
1713
+
1714
+ async function validateRemoveTree(paths) {
1715
+ for (const [path, label] of [
1716
+ [paths.managedDir, 'managed directory'],
1717
+ [paths.binDir, 'managed bin directory'],
1718
+ [paths.generations, 'generation directory'],
1719
+ ]) {
1720
+ const current = await stateFor(path);
1721
+ if (!current.present) continue;
1722
+ assertCurrentOwner(path, current, label);
1723
+ if (current.symlink) throw launcherWriteError(`refusing symlinked ${label}: ${path}`);
1724
+ if (!current.directory) throw launcherWriteError(`${label} is not a directory: ${path}`);
1725
+ }
1726
+ }
1727
+
1728
+ export async function removeLauncher({
1729
+ home = homedir(),
1730
+ forceLauncher = false,
1731
+ backend = 'self-copy',
1732
+ onPhase,
1733
+ } = {}) {
1734
+ if (!LAUNCHER_BACKENDS.includes(backend)) {
1735
+ throw launcherWriteError(`backend must be ${LAUNCHER_BACKENDS.join(' | ')} (got ${JSON.stringify(backend)})`);
1736
+ }
1737
+ const paths = launcherPaths({ home });
1738
+ await validateRemoveTree(paths);
1739
+ const bin = await stateFor(paths.binDir);
1740
+ if (!bin.present) {
1741
+ return { paths, removed: false, prior: null, gcRemoved: [], warnings: [] };
1742
+ }
1743
+ return withLauncherLock(paths, async () => {
1744
+ await assertTreeOwned(paths.binDir, 'managed launcher artifact');
1745
+ await assertTreeOwned(paths.generations, 'managed generation artifact');
1746
+ let prior = await existingManifestRecord(paths);
1747
+ let dispatcher = await stateFor(paths.dispatcher);
1748
+ assertCurrentOwner(paths.dispatcher, dispatcher, 'dispatcher');
1749
+ if (dispatcher.symlink) throw launcherWriteError(`refusing symlinked dispatcher: ${paths.dispatcher}`);
1750
+ if (dispatcher.present && !dispatcher.file) {
1751
+ throw launcherWriteError(`dispatcher path is not a regular file: ${paths.dispatcher}`);
1752
+ }
1753
+
1754
+ if (!prior.present && !dispatcher.present) {
1755
+ return { paths, removed: false, prior: null, gcRemoved: [], warnings: [] };
1756
+ }
1757
+ if (!prior.present && dispatcher.present && !forceLauncher) {
1758
+ throw launcherWriteError('dispatcher is present without launcher.env ownership; use --force-launcher to remove it');
1759
+ }
1760
+ if (prior.present && !prior.valid && !forceLauncher) {
1761
+ throw launcherWriteError(`launcher.env is invalid (${prior.errors.join('; ')}); use --force-launcher to remove it`);
1762
+ }
1763
+ if (prior.valid && prior.manifest.PHR_BACKEND !== backend && !forceLauncher) {
1764
+ throw launcherWriteError(
1765
+ `launcher remove refused: owner=${prior.manifest.PHR_BACKEND}, version=${prior.manifest.PHR_VERSION}; use --force-launcher to override`,
1766
+ );
1767
+ }
1768
+
1769
+ // Re-read under the lifecycle lock immediately before the unlink. This is
1770
+ // deliberately redundant with the earlier policy check: the bytes being
1771
+ // removed, not a stale inspection result, determine ownership.
1772
+ prior = await existingManifestRecord(paths);
1773
+ dispatcher = await stateFor(paths.dispatcher);
1774
+ assertCurrentOwner(paths.dispatcher, dispatcher, 'dispatcher');
1775
+ if (!prior.present && dispatcher.present && !forceLauncher) {
1776
+ throw launcherWriteError('launcher.env disappeared before removal; unowned dispatcher removal refused');
1777
+ }
1778
+ if (prior.present && !prior.valid && !forceLauncher) {
1779
+ throw launcherWriteError(`launcher.env changed and is invalid (${prior.errors.join('; ')}); removal refused`);
1780
+ }
1781
+ if (prior.valid && prior.manifest.PHR_BACKEND !== backend && !forceLauncher) {
1782
+ throw launcherWriteError(
1783
+ `launcher.env changed ownership to ${prior.manifest.PHR_BACKEND}@${prior.manifest.PHR_VERSION}; removal refused`,
1784
+ );
1785
+ }
1786
+ const replacement = collisionRecord(prior, dispatcher);
1787
+
1788
+ // Manifest removal is the uninstall commit boundary. If the next unlink is
1789
+ // interrupted, the static dispatcher remains and diagnoses the absent manifest.
1790
+ if (prior.present) {
1791
+ await unlink(paths.manifest);
1792
+ await syncDirectory(paths.binDir);
1793
+ await notifyPhase(onPhase, 'after-manifest-remove');
1794
+ }
1795
+ dispatcher = await stateFor(paths.dispatcher);
1796
+ assertCurrentOwner(paths.dispatcher, dispatcher, 'dispatcher');
1797
+ if (dispatcher.present) {
1798
+ if (dispatcher.symlink || !dispatcher.file) {
1799
+ throw launcherWriteError(`dispatcher changed before removal: ${paths.dispatcher}`);
1800
+ }
1801
+ await unlink(paths.dispatcher);
1802
+ await syncDirectory(paths.binDir);
1803
+ await notifyPhase(onPhase, 'after-dispatcher-remove');
1804
+ }
1805
+
1806
+ const gc = await garbageCollectLauncherGenerationsUnlocked(paths)
1807
+ .catch((error) => cleanupFailure(error, 'GC'));
1808
+ return {
1809
+ paths,
1810
+ removed: true,
1811
+ prior: replacement,
1812
+ gcRemoved: gc.removed,
1813
+ warnings: gc.warnings,
1814
+ };
1815
+ });
1816
+ }
1817
+
1818
+ export function launcherShellProfile({ home = homedir(), shell = process.env.SHELL } = {}) {
1819
+ switch (basename(shell || '')) {
1820
+ case 'zsh':
1821
+ return join(home, '.zprofile');
1822
+ case 'bash':
1823
+ return join(home, '.bash_profile');
1824
+ case 'fish':
1825
+ return null;
1826
+ default:
1827
+ return join(home, '.profile');
1828
+ }
1829
+ }
1830
+
1831
+ export function launcherPathPlan({ home = homedir(), shell = process.env.SHELL } = {}) {
1832
+ if (basename(shell || '') === 'fish') {
1833
+ return { profilePath: null, line: FISH_LAUNCHER_PATH_LINE, appendable: false, shell: 'fish' };
1834
+ }
1835
+ return {
1836
+ profilePath: launcherShellProfile({ home, shell }),
1837
+ line: LAUNCHER_PATH_LINE,
1838
+ appendable: true,
1839
+ shell: basename(shell || '') || 'POSIX shell',
1840
+ };
1841
+ }
1842
+
1843
+ function printPathInstruction(plan) {
1844
+ if (!plan.appendable) {
1845
+ console.log('Terminal PATH was not changed. Run this command in fish:');
1846
+ } else {
1847
+ console.log(`Terminal PATH was not changed. Add this line to ${plan.profilePath}:`);
1848
+ }
1849
+ console.log(` ${plan.line}`);
1850
+ }
1851
+
1852
+ function contentDigest(bytes) {
1853
+ return createHash('sha256').update(bytes).digest('hex');
1854
+ }
1855
+
1856
+ function profileChangedError(profilePath) {
1857
+ const error = launcherWriteError(`shell profile changed before PATH append commit: ${profilePath}`);
1858
+ error.code = 'PHR_LAUNCHER_PROFILE_CHANGED';
1859
+ return error;
1860
+ }
1861
+
1862
+ function sameFileIdentity(left, right) {
1863
+ return left.present === right.present
1864
+ && (!left.present || (left.file && right.file && left.dev === right.dev && left.ino === right.ino));
1865
+ }
1866
+
1867
+ async function createProfileExclusively(profilePath, { onBeforeProfileRename } = {}) {
1868
+ // The hook models another process creating the previously absent profile at the
1869
+ // last boundary. O_EXCL makes exactly one creator win without overwriting either.
1870
+ await onBeforeProfileRename?.();
1871
+ let handle;
1872
+ try {
1873
+ handle = await open(
1874
+ profilePath,
1875
+ noFollowFlags(constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY),
1876
+ 0o600,
1877
+ );
1878
+ } catch (error) {
1879
+ if (error?.code === 'EEXIST') throw profileChangedError(profilePath);
1880
+ throw error;
1881
+ }
1882
+ let complete = false;
1883
+ let created;
1884
+ try {
1885
+ created = await handle.stat();
1886
+ await handle.writeFile(`${LAUNCHER_PATH_LINE}\n`);
1887
+ await handle.chmod(0o600);
1888
+ await handle.sync();
1889
+ complete = true;
1890
+ } finally {
1891
+ await handle.close().catch(() => {});
1892
+ if (!complete && created) {
1893
+ const current = await stateFor(profilePath).catch(() => null);
1894
+ if (current?.file && current.dev === created.dev && current.ino === created.ino) {
1895
+ await unlink(profilePath).catch(() => {});
1896
+ }
1897
+ }
1898
+ }
1899
+ await syncDirectory(dirname(profilePath));
1900
+ }
1901
+
1902
+ async function appendPathLineAtomic(profilePath, { onBeforeProfileRename } = {}) {
1903
+ const current = await stateFor(profilePath);
1904
+ assertCurrentOwner(profilePath, current, 'shell profile');
1905
+ if (current.symlink) throw launcherWriteError(`refusing to edit symlinked shell profile: ${profilePath}`);
1906
+ if (current.present && !current.file) throw launcherWriteError(`shell profile is not a regular file: ${profilePath}`);
1907
+ if (!current.present) {
1908
+ await createProfileExclusively(profilePath, { onBeforeProfileRename });
1909
+ return true;
1910
+ }
1911
+ const existingBytes = await readRegularFileNoFollow(profilePath, 'shell profile');
1912
+ const captured = await stateFor(profilePath);
1913
+ if (!sameFileIdentity(current, captured)) throw profileChangedError(profilePath);
1914
+ const existing = existingBytes.toString('utf8');
1915
+ if (existing.split(/\r?\n/).includes(LAUNCHER_PATH_LINE)) return false;
1916
+ const prefix = existing && !existing.endsWith('\n') ? `${existing}\n` : existing;
1917
+ const tempPath = uniqueTempPath(`${profilePath}.phronesis`);
1918
+ await writeAtomicFile({
1919
+ path: profilePath,
1920
+ tempPath,
1921
+ bytes: `${prefix}${LAUNCHER_PATH_LINE}\n`,
1922
+ mode: current.mode & 0o777,
1923
+ commitRename: async () => {
1924
+ await onBeforeProfileRename?.();
1925
+ const latest = await stateFor(profilePath);
1926
+ assertCurrentOwner(profilePath, latest, 'shell profile');
1927
+ if (latest.symlink) throw launcherWriteError(`refusing to edit symlinked shell profile: ${profilePath}`);
1928
+ if (latest.present && !latest.file) throw launcherWriteError(`shell profile is not a regular file: ${profilePath}`);
1929
+ if (!sameFileIdentity(captured, latest)) throw profileChangedError(profilePath);
1930
+ if (latest.file) {
1931
+ const latestBytes = await readRegularFileNoFollow(profilePath, 'shell profile');
1932
+ const afterRead = await stateFor(profilePath);
1933
+ if (
1934
+ !sameFileIdentity(latest, afterRead)
1935
+ || contentDigest(latestBytes) !== contentDigest(existingBytes)
1936
+ ) throw profileChangedError(profilePath);
1937
+ }
1938
+ // POSIX rename still has no portable compare-and-swap form for an existing
1939
+ // destination on macOS. Keep the final check and rename invocation adjacent:
1940
+ // an external write after this check but before the kernel applies rename is
1941
+ // the bounded residual described in the core-0069 evidence.
1942
+ await rename(tempPath, profilePath);
1943
+ },
1944
+ });
1945
+ return true;
1946
+ }
1947
+
1948
+ export async function offerLauncherPath({
1949
+ home = homedir(),
1950
+ shell = process.env.SHELL,
1951
+ isTTY = Boolean(process.stdin.isTTY),
1952
+ prompt,
1953
+ onBeforeProfileRename,
1954
+ } = {}) {
1955
+ const plan = launcherPathPlan({ home, shell });
1956
+ const { profilePath, line } = plan;
1957
+ if (!isTTY || !plan.appendable) {
1958
+ printPathInstruction(plan);
1959
+ return { profilePath, line, written: false, declined: true };
1960
+ }
1961
+
1962
+ console.log(`PATH offer for ${profilePath}:`);
1963
+ console.log(` ${line}`);
1964
+ const current = await stateFor(profilePath);
1965
+ assertCurrentOwner(profilePath, current, 'shell profile');
1966
+ if (current.symlink) {
1967
+ console.error(`launcher: warning — refusing to edit symlinked shell profile: ${profilePath}`);
1968
+ printPathInstruction(plan);
1969
+ return { profilePath, line, written: false, declined: true };
1970
+ }
1971
+ if (current.file) {
1972
+ const body = (await readRegularFileNoFollow(profilePath, 'shell profile')).toString('utf8');
1973
+ if (body.split(/\r?\n/).includes(LAUNCHER_PATH_LINE)) {
1974
+ console.log(`PATH line already present in ${profilePath}.`);
1975
+ return { profilePath, line: LAUNCHER_PATH_LINE, written: false, alreadyPresent: true };
1976
+ }
1977
+ }
1978
+ const accepted = typeof prompt === 'function' ? await prompt({ profilePath, line }) : false;
1979
+ if (!accepted) {
1980
+ printPathInstruction(plan);
1981
+ return { profilePath, line, written: false, declined: true };
1982
+ }
1983
+ let written;
1984
+ try {
1985
+ written = await appendLauncherPath({ home, shell, onBeforeProfileRename });
1986
+ } catch (error) {
1987
+ if (error?.code !== 'PHR_LAUNCHER_PROFILE_CHANGED') throw error;
1988
+ console.error(`launcher: warning — ${error.message}`);
1989
+ printPathInstruction(plan);
1990
+ return { profilePath, line, written: false, declined: true, warning: error.message };
1991
+ }
1992
+ if (written) console.log(`Appended the disclosed PATH line to ${profilePath}.`);
1993
+ return { profilePath, line, written, alreadyPresent: !written };
1994
+ }
1995
+
1996
+ export async function appendLauncherPath({
1997
+ home = homedir(),
1998
+ shell = process.env.SHELL,
1999
+ onBeforeProfileRename,
2000
+ } = {}) {
2001
+ const plan = launcherPathPlan({ home, shell });
2002
+ if (!plan.appendable || !plan.profilePath) return false;
2003
+ return appendPathLineAtomic(plan.profilePath, { onBeforeProfileRename });
2004
+ }
2005
+
2006
+ export async function runLauncherPath({
2007
+ home = homedir(),
2008
+ shell = process.env.SHELL,
2009
+ append = false,
2010
+ surfaceConsented = false,
2011
+ json = false,
2012
+ isTTY = Boolean(process.stdin.isTTY),
2013
+ prompt,
2014
+ onBeforeProfileRename,
2015
+ } = {}) {
2016
+ const plan = launcherPathPlan({ home, shell });
2017
+ let result = { ...plan, written: false, alreadyPresent: false };
2018
+
2019
+ if (append && surfaceConsented && !isTTY) {
2020
+ try {
2021
+ const written = await appendLauncherPath({ home, shell, onBeforeProfileRename });
2022
+ result = { ...plan, written, alreadyPresent: Boolean(plan.appendable && !written) };
2023
+ console.log(`Surface-consented PATH append for ${plan.profilePath ?? `${plan.shell} configuration`}:`);
2024
+ console.log(` ${plan.line}`);
2025
+ } catch (error) {
2026
+ if (error?.code !== 'PHR_LAUNCHER_PROFILE_CHANGED') throw error;
2027
+ console.error(`launcher: warning — ${error.message}`);
2028
+ printPathInstruction(plan);
2029
+ result = { ...plan, written: false, alreadyPresent: false, warning: error.message };
2030
+ }
2031
+ } else if (append) {
2032
+ const offered = await offerLauncherPath({ home, shell, isTTY, prompt, onBeforeProfileRename });
2033
+ result = {
2034
+ ...plan,
2035
+ written: Boolean(offered.written),
2036
+ alreadyPresent: Boolean(offered.alreadyPresent),
2037
+ ...(offered.warning ? { warning: offered.warning } : {}),
2038
+ };
2039
+ if (!isTTY) {
2040
+ console.log('Automatic PATH append requires a TTY confirmation or a consenting surface; no profile was changed.');
2041
+ }
2042
+ }
2043
+
2044
+ if (json) {
2045
+ console.log(JSON.stringify(result));
2046
+ } else if (!append) {
2047
+ console.log(plan.appendable
2048
+ ? `Shell profile: ${plan.profilePath}`
2049
+ : `Shell profile: manual ${plan.shell} configuration`);
2050
+ console.log(`PATH line: ${plan.line}`);
2051
+ } else if (surfaceConsented && !isTTY) {
2052
+ if (!result.warning) {
2053
+ console.log(result.written ? 'PATH line appended.' : 'PATH line was already present or requires manual shell configuration.');
2054
+ }
2055
+ }
2056
+ return result;
2057
+ }
2058
+
2059
+ export async function runLauncherInstall(options = {}) {
2060
+ const result = await installLauncher(options);
2061
+ if (result.skipped) return result;
2062
+ console.log('Installed the `phronesis` command.');
2063
+ if (result.generationDir) console.log(` CLI generation: ${result.generationDir}`);
2064
+ else console.log(` App target: ${result.manifest.PHR_TARGET}`);
2065
+ console.log(` Dispatcher: ${result.paths.dispatcher}`);
2066
+ console.log(` Manifest: ${result.paths.manifest}`);
2067
+ console.log(` Runtime: ${result.manifest.PHR_RUNTIME}`);
2068
+ if (result.npmCommand) console.log(` npm used: ${result.npmCommand}`);
2069
+ if (result.replaced && (result.replaced.owner !== result.manifest.PHR_BACKEND || options.forceLauncher)) {
2070
+ console.log(
2071
+ ` Replaced ${result.replaced.kind}; prior owner=${result.replaced.owner}, version=${result.replaced.version}.`,
2072
+ );
2073
+ }
2074
+ for (const warning of result.warnings) console.error(`launcher: warning — ${warning}`);
2075
+
2076
+ if (options.offerPath !== false) {
2077
+ try {
2078
+ await offerLauncherPath({
2079
+ home: options.home,
2080
+ shell: options.shell,
2081
+ isTTY: options.isTTY,
2082
+ prompt: options.promptPath,
2083
+ });
2084
+ } catch (error) {
2085
+ console.error(`launcher installed; PATH offer could not complete: ${error.message}`);
2086
+ printPathInstruction(launcherPathPlan({ home: options.home, shell: options.shell }));
2087
+ result.pathOfferWarning = error.message;
2088
+ }
2089
+ }
2090
+ return result;
2091
+ }
2092
+
2093
+ export async function runLauncherRemove(options = {}) {
2094
+ const result = await removeLauncher(options);
2095
+ if (!result.removed) {
2096
+ console.log('Managed `phronesis` command is not installed.');
2097
+ return result;
2098
+ }
2099
+ console.log('Removed the managed `phronesis` command.');
2100
+ console.log(` Manifest: ${result.paths.manifest}`);
2101
+ console.log(` Dispatcher: ${result.paths.dispatcher}`);
2102
+ if (result.prior) console.log(` Prior owner=${result.prior.owner}, version=${result.prior.version}.`);
2103
+ for (const warning of result.warnings) console.error(`launcher: warning — ${warning}`);
2104
+ return result;
2105
+ }