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.
- package/README.en.md +6 -4
- package/README.ja.md +6 -4
- package/README.md +6 -4
- package/README.zh-CN.md +6 -4
- package/dist/bin/aptly.js +72 -16
- package/dist/src/core.d.ts +1 -0
- package/dist/src/core.js +15 -9
- package/dist/src/credential-setup.d.ts +4 -0
- package/dist/src/credential-setup.js +67 -34
- package/dist/src/ledger-lock.d.ts +8 -0
- package/dist/src/ledger-lock.js +153 -0
- package/dist/src/provider.js +7 -1
- package/dist/src/setup.d.ts +9 -0
- package/dist/src/setup.js +139 -15
- package/dist/src/update-notifier.d.ts +14 -0
- package/dist/src/update-notifier.js +179 -0
- package/dist/src/update.d.ts +13 -0
- package/dist/src/update.js +68 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/guide/en.md +92 -5
- package/docs/guide/ja.md +92 -5
- package/docs/guide/ko.md +92 -5
- package/docs/guide/zh-CN.md +92 -5
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/src/provider.js
CHANGED
|
@@ -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
|
-
|
|
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.' },
|
package/dist/src/setup.d.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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 && '
|
|
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.`; }
|