aptly 0.1.1 → 0.1.3

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,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, linkSync, lstatSync, openSync, unlinkSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ import { assertNoRedirectedAncestors, assertPrivateStatePath } from './files.js';
6
+ const require = createRequire(import.meta.url);
7
+ const applicationId = 0x4150544c;
8
+ const userVersion = 1;
9
+ function fail(code) { throw Object.assign(new Error(code), { code }); }
10
+ const codeOf = (error) => error && typeof error === 'object' && 'code' in error ? error.code : undefined;
11
+ const sqliteCode = (error) => {
12
+ if (!error || typeof error !== 'object' || !('errcode' in error) || typeof error.errcode !== 'number')
13
+ return;
14
+ return error.errcode & 0xff;
15
+ };
16
+ function identity(path, directory = false) {
17
+ const stat = lstatSync(path, { bigint: true });
18
+ if ((directory ? !stat.isDirectory() : !stat.isFile()) || stat.isSymbolicLink() || stat.ino === 0n)
19
+ fail('ledger_error');
20
+ if (process.platform !== 'win32' && ((stat.mode & 63n) !== 0n || (process.getuid && stat.uid !== BigInt(process.getuid()))))
21
+ fail('ledger_error');
22
+ return { dev: stat.dev, ino: stat.ino, uid: stat.uid, mode: stat.mode };
23
+ }
24
+ function same(actual, expected) {
25
+ return actual.dev === expected.dev && actual.ino === expected.ino && actual.uid === expected.uid && actual.mode === expected.mode;
26
+ }
27
+ function close(connection, transaction) {
28
+ if (!connection)
29
+ return true;
30
+ let success = true;
31
+ if (transaction) {
32
+ try {
33
+ connection.exec('ROLLBACK');
34
+ }
35
+ catch {
36
+ success = false;
37
+ }
38
+ }
39
+ try {
40
+ connection.close();
41
+ }
42
+ catch {
43
+ success = false;
44
+ }
45
+ return success;
46
+ }
47
+ /**
48
+ * Publish a completely initialized gate with an atomic no-replace hard link.
49
+ * Existing empty/unknown locks belong to the legacy protocol and are untouched.
50
+ * Never open an old stage alias: a crash after link may leave it sharing the gate
51
+ * inode, and an ordinary fs close could cancel this process's SQLite POSIX lock.
52
+ */
53
+ function initialize(path, Database) {
54
+ const stage = join(dirname(path), `.aptly-lock-init-${randomUUID()}`);
55
+ let created = false, connection, transaction = false;
56
+ try {
57
+ const fd = openSync(stage, 'wx', 0o600);
58
+ created = true;
59
+ closeSync(fd);
60
+ assertPrivateStatePath(stage);
61
+ identity(stage);
62
+ connection = new Database(stage);
63
+ connection.exec('PRAGMA journal_mode=DELETE; PRAGMA busy_timeout=0; BEGIN IMMEDIATE');
64
+ transaction = true;
65
+ connection.exec(`PRAGMA application_id=${applicationId}; PRAGMA user_version=${userVersion}; COMMIT`);
66
+ transaction = false;
67
+ connection.close();
68
+ connection = undefined;
69
+ try {
70
+ linkSync(stage, path);
71
+ }
72
+ catch (error) {
73
+ if (codeOf(error) !== 'EEXIST')
74
+ throw error;
75
+ }
76
+ }
77
+ finally {
78
+ close(connection, transaction);
79
+ // Only our exclusive, unique stage is removed; the canonical gate persists.
80
+ if (created)
81
+ unlinkSync(stage);
82
+ }
83
+ }
84
+ /** Local, hard-link-capable filesystems only; no PID or stale-age recovery. */
85
+ export function acquireLedgerLock(ledgerPath) {
86
+ let connection, transaction = false;
87
+ try {
88
+ const path = `${ledgerPath}.lock`;
89
+ assertPrivateStatePath(ledgerPath);
90
+ assertPrivateStatePath(path);
91
+ const parent = identity(dirname(path), true);
92
+ // Load only for persistent ledgers. Fixture-only callers need no SQLite.
93
+ const { DatabaseSync } = require('node:sqlite');
94
+ try {
95
+ lstatSync(path);
96
+ }
97
+ catch (error) {
98
+ if (codeOf(error) !== 'ENOENT')
99
+ throw error;
100
+ initialize(path, DatabaseSync);
101
+ }
102
+ assertPrivateStatePath(path);
103
+ const before = identity(path);
104
+ // Do not let SQLite initialize an existing empty legacy ownership marker.
105
+ if (lstatSync(path).size < 512)
106
+ fail('ledger_locked');
107
+ connection = new DatabaseSync(path);
108
+ const assertIdentity = () => {
109
+ // ACLs are checked before opening; while held use only path metadata.
110
+ assertNoRedirectedAncestors(dirname(path));
111
+ if (!same(identity(dirname(path), true), parent) || !same(identity(path), before))
112
+ fail('ledger_error');
113
+ };
114
+ assertIdentity();
115
+ connection.exec('PRAGMA busy_timeout=0; BEGIN IMMEDIATE');
116
+ transaction = true;
117
+ assertIdentity();
118
+ const app = connection.prepare('PRAGMA application_id').get();
119
+ const version = connection.prepare('PRAGMA user_version').get();
120
+ const journal = connection.prepare('PRAGMA journal_mode').get();
121
+ if (app?.application_id !== applicationId || version?.user_version !== userVersion || journal?.journal_mode !== 'delete')
122
+ fail('ledger_locked');
123
+ assertIdentity();
124
+ const owned = connection;
125
+ let released = false;
126
+ return {
127
+ assertHeld() {
128
+ if (released)
129
+ fail('ledger_error');
130
+ try {
131
+ assertIdentity();
132
+ }
133
+ catch {
134
+ fail('ledger_error');
135
+ }
136
+ },
137
+ release() {
138
+ if (released)
139
+ return;
140
+ released = true;
141
+ if (!close(owned, true))
142
+ fail('ledger_error');
143
+ },
144
+ };
145
+ }
146
+ catch (error) {
147
+ close(connection, transaction);
148
+ const code = codeOf(error), sqlite = sqliteCode(error);
149
+ if (code === 'ledger_locked' || sqlite === 5 || sqlite === 6 || sqlite === 11 || sqlite === 26)
150
+ fail('ledger_locked');
151
+ fail('ledger_error');
152
+ }
153
+ }
@@ -9,7 +9,13 @@ export function decisionsProvider({ key, fetchImpl = fetch, descriptions = {} }
9
9
  preflight();
10
10
  const payload = {
11
11
  model: 'gpt-6-luna',
12
- input: JSON.stringify({ task: input.task, client: input.client }),
12
+ // Keep the wire boundary explicit even for internal callers that bypass
13
+ // Router validation. Unrelated task/native metadata is never serialized.
14
+ input: JSON.stringify({ task: {
15
+ summary: input.task.summary,
16
+ operation: input.task.operation,
17
+ context_sufficient: input.task.context_sufficient,
18
+ }, client: input.client }),
13
19
  questions: [{ type: 'choice', name: 'profile', instructions, choices: [
14
20
  ...profiles.map(p => ({ value: p.id, description: descriptions[p.id] ?? `${p.model}${p.reasoning_effort ? ' / effort ' + p.reasoning_effort : ''}. Select only for an appropriate task; capabilities not supplied.` })),
15
21
  { value: 'keep_current', description: 'Preserve the existing selection; no confident supported choice.' },
@@ -12,6 +12,7 @@ export interface SetupClientResult {
12
12
  status: 'configured' | 'unchanged' | 'removed' | 'skipped' | 'error';
13
13
  codes: string[];
14
14
  requires_hook_review?: boolean;
15
+ recovery?: string[];
15
16
  }
16
17
  export interface SetupResult {
17
18
  status: 'complete' | 'partial' | 'skipped';
@@ -24,4 +25,12 @@ export declare function setDefaults(options?: SetupOptions): SetupResult;
24
25
  export declare function setEnvironment(key: string, value: string, options?: SetupOptions): SetupResult;
25
26
  /** Store only the nonsecret source reference; credential values never enter setup. */
26
27
  export declare function setCredentialReference(options?: SetupOptions): SetupResult;
28
+ /** Snapshot-only plan. No file writes, credential access, paths or values in diagnostics. */
29
+ export declare function prepareCredentialReference(options: SetupOptions, client: Client): {
30
+ result: SetupClientResult;
31
+ check: () => SetupClientResult;
32
+ commit: () => SetupClientResult;
33
+ };
34
+ /** Preserved standard registrations require a separate explicit opt-in; ownership stays unchanged. */
35
+ export declare function migrateLauncher(options: SetupOptions, dryRun?: boolean, includeExisting?: boolean): SetupResult;
27
36
  export {};
package/dist/src/setup.js CHANGED
@@ -188,7 +188,7 @@ function editEnvironment(current, edit, client) {
188
188
  updates.APTLY_SECRET_BACKEND = 'os';
189
189
  updates.APTLY_SECRET_CLIENT = client;
190
190
  }
191
- else {
191
+ else if (!('migrate' in edit)) {
192
192
  validateEnvironmentValue(edit.key, edit.value);
193
193
  updates[edit.key] = edit.value;
194
194
  }
@@ -234,6 +234,34 @@ function updateTomlEnv(text, updates) {
234
234
  fail('unsupported_toml_layout');
235
235
  return text;
236
236
  }
237
+ /** Replace only unambiguous single-line launcher assignments in an existing table. */
238
+ function updateTomlLauncher(text, entry) {
239
+ const original = toml(text), expected = structuredClone(original);
240
+ expected.mcp_servers.aptly.command = entry.command;
241
+ expected.mcp_servers.aptly.args = entry.args;
242
+ const headers = [...text.matchAll(/^\s*\[([^\n]+)\][ \t]*(?:#[^\n]*)?\r?$/gm)];
243
+ const header = headers.find(h => { try {
244
+ return toml(h[0] + '\naptly_probe = true\n').mcp_servers?.aptly?.aptly_probe === true;
245
+ }
246
+ catch {
247
+ return false;
248
+ } });
249
+ if (!header)
250
+ fail('unsupported_toml_layout');
251
+ const start = header.index + header[0].length, end = headers.find(h => h.index > header.index)?.index ?? text.length;
252
+ let body = text.slice(start, end);
253
+ for (const key of ['command', 'args']) {
254
+ const assignment = new RegExp('^[ \\t]*' + key + '[ \\t]*=[^\\n]*(?:\\n|$)', 'gm');
255
+ const matches = [...body.matchAll(assignment)];
256
+ if (matches.length !== 1 || !isDeepStrictEqual(toml(matches[0][0])[key], original.mcp_servers.aptly[key]))
257
+ fail('unsupported_toml_layout');
258
+ body = body.replace(assignment, () => key + ' = ' + JSON.stringify(entry[key]) + (matches[0][0].endsWith('\r\n') ? '\r\n' : matches[0][0].endsWith('\n') ? '\n' : ''));
259
+ }
260
+ const rendered = text.slice(0, start) + body + text.slice(end);
261
+ if (!isDeepStrictEqual(toml(rendered), expected))
262
+ fail('unsupported_toml_layout');
263
+ return rendered;
264
+ }
237
265
  function resolved(options, client) {
238
266
  const home = resolve(options.home ?? homedir()), env = options.env ?? process.env;
239
267
  const override = client === 'codex' ? env.CODEX_HOME : env.CLAUDE_CONFIG_DIR;
@@ -251,8 +279,11 @@ function ownership(value, client) {
251
279
  /** Compatibility restriction only: this does not authenticate arbitrary launcher code. */
252
280
  function requireCredentialRuntime(entry, root) {
253
281
  const allowed = [join(root, 'dist', 'src', 'server.js'), join(root, 'src', 'server.ts')];
254
- if (entry.command !== process.execPath || !Array.isArray(entry.args) || entry.args.length !== 1 || !allowed.includes(entry.args[0]))
255
- fail('credential_launcher_unverified');
282
+ const incompatible = (reason) => { throw Object.assign(new Error('credential_launcher_unverified'), { reason }); };
283
+ if (entry.command !== process.execPath)
284
+ incompatible('credential_node_mismatch');
285
+ if (!Array.isArray(entry.args) || entry.args.length !== 1 || !allowed.includes(entry.args[0]))
286
+ incompatible('credential_server_mismatch');
256
287
  try {
257
288
  const extension = entry.args[0].endsWith('.ts') ? 'ts' : 'js';
258
289
  const server = read(entry.args[0]).text;
@@ -261,12 +292,12 @@ function requireCredentialRuntime(entry, root) {
261
292
  fail('credential_launcher_unverified');
262
293
  }
263
294
  catch {
264
- fail('credential_launcher_unverified');
295
+ incompatible('credential_runtime_unsupported');
265
296
  }
266
297
  }
267
298
  function prepare(options, client, removing, edit) {
268
299
  const env = options.env ?? process.env, paths = resolved(options, client), { dir, mcpPath, hooksPath, privateDir } = paths;
269
- const changes = [], dirs = [];
300
+ const changes = [], observations = [], dirs = [];
270
301
  noSymlinks(dir);
271
302
  noSymlinks(privateDir);
272
303
  for (const path of [privateDir, join(privateDir, 'state')]) {
@@ -290,16 +321,17 @@ function prepare(options, client, removing, edit) {
290
321
  } })();
291
322
  const result = { client, status: 'unchanged', codes: [] };
292
323
  if (!hasDir && (options.automatic || removing))
293
- return { changes, dirs, result: { client, status: 'skipped', codes: ['host_not_configured'] } };
324
+ return { changes, observations, dirs, result: { client, status: 'skipped', codes: ['host_not_configured'] } };
294
325
  const policyPath = join(privateDir, 'auto-policy.json'), manifestPath = join(privateDir, 'setup-ownership.json'), stateDir = join(privateDir, 'state');
295
326
  const snap = (path) => { const source = read(path); return { path, before: source.text, after: source.text, mode: source.mode }; };
296
327
  const mcp = snap(mcpPath), hooks = snap(hooksPath), policy = snap(policyPath), manifest = snap(manifestPath);
297
328
  for (const privateFile of [policy, manifest])
298
329
  if (privateFile.before !== null)
299
330
  privatePermissions(privateFile.path, 'private_file_permissions');
331
+ observations.push(mcp, hooks, policy, manifest);
300
332
  const oldOwner = manifest.before === null ? undefined : ownership(json(manifest.before), client);
301
333
  if (removing && !oldOwner)
302
- return { changes, dirs, result: { client, status: 'skipped', codes: ['no_owned_setup'] } };
334
+ return { changes, observations, dirs, result: { client, status: 'skipped', codes: ['no_owned_setup'] } };
303
335
  const hookValue = hook(client), hooksObject = json(hooks.before), entries = hooksArray(hooksObject);
304
336
  const config = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before), servers = map(config, client === 'codex' ? 'mcp_servers' : 'mcpServers');
305
337
  if (removing) {
@@ -354,6 +386,18 @@ function prepare(options, client, removing, edit) {
354
386
  const root = resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..', ...(import.meta.url.includes('/dist/src/') ? ['..'] : [])));
355
387
  const additions = { APTLY_HOOK_POLICY_PATH: policyPath, ROUTER_LEDGER_PATH: join(stateDir, 'ledger.json') };
356
388
  let created = false, tomlBlock;
389
+ if (edit && 'migrate' in edit) {
390
+ if (!oldOwner || !object(servers.aptly) || (oldOwner.mcp_created ? !isDeepStrictEqual(servers.aptly, oldOwner.mcp) : !edit.includeExisting))
391
+ fail('launcher_migration_not_owned');
392
+ const entry = servers.aptly;
393
+ if (typeof entry.command !== 'string' || !isAbsolute(entry.command) || !['node', 'node.exe'].includes(basename(entry.command).toLowerCase()) || !Array.isArray(entry.args) || entry.args.length !== 1 || typeof entry.args[0] !== 'string' || !isAbsolute(entry.args[0]) || !/(?:^|[\\/])(?:dist[\\/]src[\\/]server\.js|src[\\/]server\.ts)$/.test(entry.args[0]))
394
+ fail('launcher_migration_not_standard');
395
+ if (!entries.some(entry => isDeepStrictEqual(entry, oldOwner.hook)) || !isDeepStrictEqual(json(policy.before), oldOwner.policy))
396
+ fail('managed_entry_changed');
397
+ for (const [key, value] of Object.entries(oldOwner.env_added))
398
+ if (entry.env?.[key] !== value)
399
+ fail('managed_entry_changed');
400
+ }
357
401
  if (servers.aptly === undefined) {
358
402
  created = true;
359
403
  servers.aptly = { ...(client === 'claude' ? { type: 'stdio' } : {}), command: process.execPath, args: [join(root, 'dist', 'src', 'server.js')], ...(client === 'codex' ? { env_vars: ['OPENAI_API_KEY'] } : {}), env: { ROUTER_PROVIDER: edit && 'defaults' in edit ? 'decisions' : env.ROUTER_PROVIDER === 'fixture' ? 'fixture' : 'decisions', ...additions } };
@@ -370,7 +414,11 @@ function prepare(options, client, removing, edit) {
370
414
  Object.assign(currentEnv, missing);
371
415
  mcp.after = client === 'codex' ? addTomlEnv(mcp.before ?? '', missing) : stringify(config);
372
416
  }
373
- if (edit && 'credential' in edit)
417
+ if (edit && 'migrate' in edit) {
418
+ servers.aptly.command = process.execPath;
419
+ servers.aptly.args = [join(root, 'dist', 'src', 'server.js')];
420
+ }
421
+ if (edit && ('credential' in edit || 'migrate' in edit))
374
422
  requireCredentialRuntime(servers.aptly, root);
375
423
  const currentEnv = map(servers.aptly, 'env');
376
424
  const updates = edit ? editEnvironment(currentEnv, edit, client) : {};
@@ -383,14 +431,17 @@ function prepare(options, client, removing, edit) {
383
431
  else
384
432
  mcp.after = stringify(config);
385
433
  }
386
- else if (edit)
434
+ else if (edit) {
387
435
  mcp.after = client === 'codex' ? updateTomlEnv(mcp.after, updates) : stringify(config);
436
+ if (client === 'codex' && 'migrate' in edit && !oldOwner?.mcp_created)
437
+ mcp.after = updateTomlLauncher(mcp.after, servers.aptly);
438
+ }
388
439
  const existingRoute = entries.filter(routeHook);
389
440
  if (existingRoute.some(entry => !isDeepStrictEqual(entry, hookValue)))
390
441
  fail('existing_routing_hook_conflict');
391
442
  if (existingRoute.length === 0)
392
443
  entries.push(hookValue);
393
- hooks.after = stringify(hooksObject);
444
+ hooks.after = edit && 'migrate' in edit ? hooks.before : stringify(hooksObject);
394
445
  const preservedMode = edit && policy.before !== null ? json(policy.before).mode : undefined;
395
446
  const policyValue = { schema_version: 1, kind: 'aptly-auto-hook', mode: options.mode ?? (['defaults', 'advisory'].includes(preservedMode) ? preservedMode : 'defaults'), issuer: 'operator_installation', enabled: true, state_directory: stateDir };
396
447
  if (policy.before !== null) {
@@ -451,7 +502,7 @@ function prepare(options, client, removing, edit) {
451
502
  changes.push(snapshot);
452
503
  if (!removing && changes.length)
453
504
  result.status = 'configured';
454
- return { changes, dirs, result };
505
+ return { changes, observations, dirs, result };
455
506
  }
456
507
  /** Secure sibling staging is short-lived and never a backup; original settings stay in memory. */
457
508
  function replace(snapshot, forward) {
@@ -496,19 +547,19 @@ function replace(snapshot, forward) {
496
547
  }
497
548
  }
498
549
  }
499
- function execute(options, removing, edit) {
550
+ function execute(options, removing, edit, captured) {
500
551
  const clients = [...new Set(options.clients ?? ['codex', 'claude'])];
501
552
  if (clients.some(client => client !== 'codex' && client !== 'claude'))
502
553
  return { status: 'partial', clients: [] };
503
554
  const env = options.env ?? process.env;
504
555
  if (!removing && options.automatic && (env.APTLY_SKIP_SETUP === '1' || !!env.CI))
505
556
  return { status: 'skipped', clients: clients.map(client => ({ client, status: 'skipped', codes: [env.APTLY_SKIP_SETUP === '1' ? 'setup_disabled' : 'ci_environment'] })) };
506
- const prepared = clients.map(client => {
557
+ const prepared = captured ?? clients.map(client => {
507
558
  try {
508
559
  return prepare(options, client, removing, edit);
509
560
  }
510
561
  catch (e) {
511
- return { changes: [], dirs: [], result: { client, status: 'error', codes: [safeCode(e)] } };
562
+ return { changes: [], observations: [], dirs: [], result: { client, status: 'error', codes: [safeCode(e)] } };
512
563
  }
513
564
  });
514
565
  const results = prepared.map(plan => plan.result);
@@ -520,6 +571,7 @@ function execute(options, removing, edit) {
520
571
  continue;
521
572
  const written = [], madeDirs = [];
522
573
  try {
574
+ validateObservations(plan);
523
575
  for (const path of plan.dirs) {
524
576
  noSymlinks(path);
525
577
  try {
@@ -543,9 +595,11 @@ function execute(options, removing, edit) {
543
595
  privatePermissions(path, 'private_directory_permissions');
544
596
  }
545
597
  for (const snapshot of plan.changes) {
598
+ validateObservations(plan, written);
546
599
  replace(snapshot, true);
547
600
  written.push(snapshot);
548
601
  }
602
+ validateObservations(plan, written);
549
603
  }
550
604
  catch (e) {
551
605
  let rollbackConflict = false;
@@ -569,7 +623,7 @@ function execute(options, removing, edit) {
569
623
  }
570
624
  return { status: results.some(result => result.status === 'error') ? 'partial' : results.every(result => result.status === 'skipped') ? 'skipped' : 'complete', clients: results };
571
625
  }
572
- const codes = new Set(['credential_launcher_unverified', 'environment_invalid_value', 'environment_sensitive_value', 'environment_key_unsupported', 'environment_secret_key_unsupported', 'unsafe_directory', 'unsafe_symlink', 'unsafe_configuration_file', 'invalid_json', 'invalid_json_object', 'configuration_shape_conflict', 'hook_shape_conflict', 'invalid_toml', 'unsupported_toml_layout', 'relative_configuration_directory', 'invalid_ownership', 'managed_hook_changed', 'managed_mcp_changed', 'managed_entry_changed', 'managed_policy_changed', 'mcp_entry_conflict', 'mcp_environment_conflict', 'existing_routing_hook_conflict', 'policy_conflict', 'existing_hook_not_owned', 'concurrent_configuration_change', 'private_directory_permissions', 'private_file_permissions']);
626
+ const codes = new Set(['launcher_migration_not_owned', 'launcher_migration_not_standard', 'credential_launcher_unverified', 'environment_invalid_value', 'environment_sensitive_value', 'environment_key_unsupported', 'environment_secret_key_unsupported', 'unsafe_directory', 'unsafe_symlink', 'unsafe_configuration_file', 'invalid_json', 'invalid_json_object', 'configuration_shape_conflict', 'hook_shape_conflict', 'invalid_toml', 'unsupported_toml_layout', 'relative_configuration_directory', 'invalid_ownership', 'managed_hook_changed', 'managed_mcp_changed', 'managed_entry_changed', 'managed_policy_changed', 'mcp_entry_conflict', 'mcp_environment_conflict', 'existing_routing_hook_conflict', 'policy_conflict', 'existing_hook_not_owned', 'concurrent_configuration_change', 'private_directory_permissions', 'private_file_permissions']);
573
627
  function safeCode(error) { return error instanceof Error && codes.has(error.message) ? error.message : 'configuration_io_error'; }
574
628
  export function setupHosts(options = {}) { return execute(options, false); }
575
629
  export function removeSetup(options = {}) { return execute(options, true); }
@@ -585,3 +639,73 @@ export function setEnvironment(key, value, options = {}) {
585
639
  }
586
640
  /** Store only the nonsecret source reference; credential values never enter setup. */
587
641
  export function setCredentialReference(options = {}) { return execute(options, false, { credential: true }); }
642
+ /** Include unchanged files: a no-op reference update must still detect prompt-time edits. */
643
+ function validateObservations(plan, written = []) {
644
+ for (const snapshot of plan.observations) {
645
+ const current = read(snapshot.path);
646
+ const expected = written.includes(snapshot) ? snapshot.after : snapshot.before;
647
+ if (current.text !== expected || (process.platform !== 'win32' && expected !== null && current.mode !== snapshot.mode))
648
+ fail('concurrent_configuration_change');
649
+ }
650
+ }
651
+ function recovery(client) {
652
+ return [`aptly set env OPENAI_API_KEY --client ${client} --check`,
653
+ `aptly setup --client ${client} --migrate-launcher --dry-run`,
654
+ `aptly setup --client ${client} --migrate-launcher`,
655
+ `aptly set env OPENAI_API_KEY --client ${client}`];
656
+ }
657
+ /** Snapshot-only plan. No file writes, credential access, paths or values in diagnostics. */
658
+ export function prepareCredentialReference(options, client) {
659
+ let plan;
660
+ let result;
661
+ try {
662
+ plan = prepare({ ...options, clients: [client] }, client, false, { credential: true });
663
+ result = { client, status: 'unchanged', codes: ['credential_preflight_ok'] };
664
+ }
665
+ catch (error) {
666
+ const reason = error?.reason;
667
+ const reasons = ['credential_node_mismatch', 'credential_server_mismatch', 'credential_runtime_unsupported'];
668
+ const migration = migrateLauncher({ ...options, clients: [client] }, true);
669
+ const canMigrate = migration.status === 'complete';
670
+ result = { client, status: 'error', codes: [safeCode(error), ...(reason && reasons.includes(reason) ? [reason] : []), ...(canMigrate ? ['launcher_migration_available'] : [])],
671
+ recovery: canMigrate ? recovery(client) : [`aptly set env OPENAI_API_KEY --client ${client} --check`, 'Keep custom or modified launchers unchanged. Review the host registration locally; use its secure credential environment or a separately reviewed standard registration.'] };
672
+ }
673
+ const check = () => {
674
+ if (!plan)
675
+ return result;
676
+ try {
677
+ validateObservations(plan);
678
+ // Recheck runtime support, symlinks, private permissions and policy before storage.
679
+ prepare({ ...options, clients: [client] }, client, false, { credential: true });
680
+ return result;
681
+ }
682
+ catch (error) {
683
+ return { client, status: 'error', codes: [safeCode(error)], recovery: recovery(client) };
684
+ }
685
+ };
686
+ return { result, check, commit: () => {
687
+ const checked = check();
688
+ if (checked.status === 'error' || !plan)
689
+ return checked;
690
+ return execute({ ...options, clients: [client] }, false, { credential: true }, [plan]).clients[0];
691
+ } };
692
+ }
693
+ /** Preserved standard registrations require a separate explicit opt-in; ownership stays unchanged. */
694
+ export function migrateLauncher(options, dryRun = false, includeExisting = false) {
695
+ const client = options.clients?.[0];
696
+ if (options.clients?.length !== 1 || !client || !['codex', 'claude'].includes(client) || options.automatic || options.mode)
697
+ return { status: 'partial', clients: [] };
698
+ try {
699
+ const edit = { migrate: true, includeExisting };
700
+ const plan = prepare(options, client, false, edit);
701
+ if (dryRun)
702
+ return { status: 'complete', clients: [{ client, status: 'unchanged', codes: [plan.changes.length ? 'launcher_migration_available' : 'launcher_current'], requires_hook_review: client === 'codex' }] };
703
+ const result = execute(options, false, edit, [plan]);
704
+ if (result.status === 'complete')
705
+ result.clients[0].codes.push('launcher_migrated', 'mcp_restart_required');
706
+ return result;
707
+ }
708
+ catch (error) {
709
+ return { status: 'partial', clients: [{ client, status: 'error', codes: [safeCode(error)] }] };
710
+ }
711
+ }
@@ -0,0 +1,14 @@
1
+ export interface UpdateNotificationOptions {
2
+ command: string;
3
+ args: readonly string[];
4
+ env: NodeJS.ProcessEnv;
5
+ stderrIsTTY: boolean;
6
+ stdoutIsTTY?: boolean;
7
+ stdinIsTTY: boolean;
8
+ currentVersion: string;
9
+ cachePath?: string;
10
+ fetch?: typeof globalThis.fetch;
11
+ now?: number;
12
+ }
13
+ /** Returns one stderr hint; never prints, reads credentials, or changes installed code. */
14
+ export declare function updateNotification(options: UpdateNotificationOptions): Promise<string | null>;
@@ -0,0 +1,179 @@
1
+ // Optional interactive CLI hint only. Never used by MCP, routing, or provider code.
2
+ import { mkdirSync, lstatSync, openSync, closeSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
3
+ import { dirname, join, isAbsolute, parse } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { assertNoRedirectedAncestors, assertPrivateStatePath, readRegularFile } from './files.js';
7
+ import { protectNewWindowsPrivateDirectory } from './windows-private.js';
8
+ const registry = 'https://registry.npmjs.org/aptly/latest';
9
+ const ttl = 24 * 60 * 60 * 1000, cooldown = 60 * 60 * 1000, maximumBody = 16 * 1024, timeout = 800;
10
+ function version(value) {
11
+ if (typeof value !== 'string' || value.length > 64 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value))
12
+ return null;
13
+ const parts = value.split('.').map(Number);
14
+ return parts.every(Number.isSafeInteger) ? parts : null;
15
+ }
16
+ function newer(candidate, current) {
17
+ const a = version(candidate), b = version(current);
18
+ for (let i = 0; i < 3; i++) {
19
+ if (a[i] !== b[i])
20
+ return a[i] > b[i];
21
+ }
22
+ return false;
23
+ }
24
+ function eligible(o) {
25
+ return o.stdinIsTTY && o.stderrIsTTY && o.stdoutIsTTY !== false && !o.env.CI && o.env.APTLY_NO_UPDATE_NOTIFIER !== '1' && !!version(o.currentVersion) &&
26
+ ['help', '--help', '-h', '--version', 'config', 'setup', 'remove', 'set', 'report'].includes(o.command) && !o.args.includes('--automatic');
27
+ }
28
+ function directory(path) {
29
+ if (!isAbsolute(path))
30
+ throw Error('cache_path');
31
+ const parent = dirname(path);
32
+ if (parent !== path) {
33
+ try {
34
+ assertNoRedirectedAncestors(parent);
35
+ }
36
+ catch (error) {
37
+ if (error.code !== 'ENOENT')
38
+ throw error;
39
+ directory(parent);
40
+ }
41
+ }
42
+ let created = false;
43
+ try {
44
+ mkdirSync(path, { mode: 0o700 });
45
+ created = true;
46
+ }
47
+ catch (error) {
48
+ if (error.code !== 'EEXIST')
49
+ throw error;
50
+ }
51
+ assertNoRedirectedAncestors(path);
52
+ if (!lstatSync(path).isDirectory())
53
+ throw Error('cache_path');
54
+ if (created && process.platform === 'win32')
55
+ protectNewWindowsPrivateDirectory(path);
56
+ }
57
+ function readCache(path, now) {
58
+ assertPrivateStatePath(path);
59
+ try {
60
+ const c = JSON.parse(readRegularFile(path, { privateFile: true, maxBytes: 1024 }).toString('utf8'));
61
+ if (!c || Array.isArray(c) || Object.keys(c).sort().join(',') !== 'checked_at,failed,latest,schema_version' || c.schema_version !== 1 ||
62
+ !Number.isSafeInteger(c.checked_at) || c.checked_at < 0 || c.checked_at > now || typeof c.failed !== 'boolean' ||
63
+ (c.latest !== null && !version(c.latest)) || (!c.failed && c.latest === null))
64
+ return null;
65
+ return c;
66
+ }
67
+ catch (error) {
68
+ if (error.code === 'ENOENT' || error instanceof SyntaxError)
69
+ return null;
70
+ throw error;
71
+ }
72
+ }
73
+ function writeCache(path, c) {
74
+ let stage;
75
+ try {
76
+ assertPrivateStatePath(path);
77
+ stage = join(dirname(path), '.aptly-update-' + randomUUID());
78
+ const fd = openSync(stage, 'wx', 0o600);
79
+ try {
80
+ writeFileSync(fd, JSON.stringify(c) + '\n');
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ assertPrivateStatePath(stage);
86
+ assertPrivateStatePath(path);
87
+ renameSync(stage, path);
88
+ stage = undefined;
89
+ }
90
+ finally {
91
+ if (stage)
92
+ try {
93
+ unlinkSync(stage);
94
+ }
95
+ catch { /* Best-effort cleanup of our stage only. */ }
96
+ }
97
+ }
98
+ async function latestStable(fetcher) {
99
+ const controller = new AbortController();
100
+ let timer;
101
+ let reader;
102
+ const work = async () => {
103
+ const response = await fetcher(registry, { signal: controller.signal, redirect: 'error', credentials: 'omit', headers: { accept: 'application/json' } });
104
+ if (!response.ok || !response.body)
105
+ throw Error('registry_response');
106
+ const size = response.headers.get('content-length');
107
+ if (size !== null && (!/^\d+$/.test(size) || Number(size) > maximumBody))
108
+ throw Error('registry_size');
109
+ reader = response.body.getReader();
110
+ let total = 0;
111
+ const chunks = [];
112
+ for (;;) {
113
+ const chunk = await reader.read();
114
+ if (chunk.done)
115
+ break;
116
+ total += chunk.value.byteLength;
117
+ if (total > maximumBody)
118
+ throw Error('registry_size');
119
+ chunks.push(chunk.value);
120
+ }
121
+ const bytes = new Uint8Array(total);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.byteLength;
126
+ }
127
+ const metadata = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
128
+ if (!metadata || metadata.name !== 'aptly' || !version(metadata.version))
129
+ throw Error('registry_metadata');
130
+ return metadata.version;
131
+ };
132
+ try {
133
+ return await Promise.race([work(), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(Error('registry_timeout')); }, timeout); })]);
134
+ }
135
+ finally {
136
+ if (timer)
137
+ clearTimeout(timer);
138
+ controller.abort();
139
+ if (reader)
140
+ void reader.cancel().catch(() => { });
141
+ }
142
+ }
143
+ /** Returns one stderr hint; never prints, reads credentials, or changes installed code. */
144
+ export async function updateNotification(options) {
145
+ if (!eligible(options))
146
+ return null;
147
+ const now = options.now ?? Date.now();
148
+ if (!Number.isSafeInteger(now) || now < 0)
149
+ return null;
150
+ const path = options.cachePath ?? join(homedir(), '.cache', 'aptly', 'update-check.json');
151
+ let cached;
152
+ try {
153
+ if (!isAbsolute(path) || parse(path).root === path)
154
+ return null;
155
+ directory(dirname(path));
156
+ cached = readCache(path, now);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ if (cached && now - cached.checked_at < (cached.failed ? cooldown : ttl))
162
+ return !cached.failed && cached.latest && newer(cached.latest, options.currentVersion) ? hint(cached.latest, options.currentVersion) : null;
163
+ try {
164
+ const latest = await latestStable(options.fetch ?? globalThis.fetch);
165
+ try {
166
+ writeCache(path, { schema_version: 1, checked_at: now, latest, failed: false });
167
+ }
168
+ catch { /* Notification must not make CLI commands fail. */ }
169
+ return newer(latest, options.currentVersion) ? hint(latest, options.currentVersion) : null;
170
+ }
171
+ catch {
172
+ try {
173
+ writeCache(path, { schema_version: 1, checked_at: now, latest: null, failed: true });
174
+ }
175
+ catch { /* Silent best effort; no provider/ledger fallback. */ }
176
+ return null;
177
+ }
178
+ }
179
+ function hint(latest, current) { return `Aptly ${latest} is available (installed: ${current}). Run aptly update to update this global installation; use the installation guide for local installs.`; }