@agentage/cli 0.38.1 → 0.40.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.
- package/README.md +111 -25
- package/dist/commands/auth/setup.d.ts +8 -2
- package/dist/commands/auth/setup.js +38 -7
- package/dist/commands/extension/account.d.ts +5 -0
- package/dist/commands/extension/account.js +18 -0
- package/dist/commands/extension/check.d.ts +10 -0
- package/dist/commands/extension/check.js +35 -0
- package/dist/commands/extension/delegate.d.ts +4 -0
- package/dist/commands/extension/delegate.js +45 -0
- package/dist/commands/extension/install-load.d.ts +7 -0
- package/dist/commands/extension/install-load.js +33 -0
- package/dist/commands/extension/install.d.ts +10 -0
- package/dist/commands/extension/install.js +35 -0
- package/dist/commands/extension/latest.d.ts +2 -0
- package/dist/commands/extension/latest.js +70 -0
- package/dist/commands/extension/list.d.ts +17 -0
- package/dist/commands/extension/list.js +102 -0
- package/dist/commands/extension/plan.d.ts +8 -0
- package/dist/commands/extension/plan.js +67 -0
- package/dist/commands/extension/remove.d.ts +15 -0
- package/dist/commands/extension/remove.js +86 -0
- package/dist/commands/extension/row-lines.d.ts +4 -0
- package/dist/commands/extension/row-lines.js +68 -0
- package/dist/commands/extension/rows.d.ts +56 -0
- package/dist/commands/extension/rows.js +61 -0
- package/dist/commands/extension/secrets-prompt.d.ts +6 -0
- package/dist/commands/extension/secrets-prompt.js +47 -0
- package/dist/commands/extension/setup-cloud.d.ts +6 -0
- package/dist/commands/extension/setup-cloud.js +98 -0
- package/dist/commands/extension/setup.d.ts +5 -0
- package/dist/commands/extension/setup.js +68 -0
- package/dist/commands/extension/sync.d.ts +22 -0
- package/dist/commands/extension/sync.js +81 -0
- package/dist/commands/status/account-status.d.ts +3 -0
- package/dist/commands/status/account-status.js +27 -0
- package/dist/commands/status/status.d.ts +7 -1
- package/dist/commands/status/status.js +50 -6
- package/dist/core/installer/compare-lock.d.ts +8 -0
- package/dist/core/installer/compare-lock.js +51 -0
- package/dist/core/installer/compat.d.ts +12 -0
- package/dist/core/installer/compat.js +35 -0
- package/dist/core/installer/installer.d.ts +28 -0
- package/dist/core/installer/installer.js +87 -0
- package/dist/core/installer/latest.d.ts +26 -0
- package/dist/core/installer/latest.js +88 -0
- package/dist/core/installer/npm.d.ts +11 -0
- package/dist/core/installer/npm.js +76 -0
- package/dist/core/installer/plan.d.ts +25 -0
- package/dist/core/installer/plan.js +52 -0
- package/dist/core/installer/resolve.d.ts +25 -0
- package/dist/core/installer/resolve.js +139 -0
- package/dist/core/installer/tree.d.ts +21 -0
- package/dist/core/installer/tree.js +100 -0
- package/dist/core/loader/config.d.ts +7 -0
- package/dist/core/loader/config.js +59 -0
- package/dist/core/loader/ctx.d.ts +16 -0
- package/dist/core/loader/ctx.js +76 -0
- package/dist/core/loader/dev-path.d.ts +1 -0
- package/dist/core/loader/dev-path.js +20 -0
- package/dist/core/loader/discover.d.ts +35 -0
- package/dist/core/loader/discover.js +118 -0
- package/dist/core/loader/dispose.d.ts +4 -0
- package/dist/core/loader/dispose.js +24 -0
- package/dist/core/loader/load.d.ts +13 -0
- package/dist/core/loader/load.js +61 -0
- package/dist/core/loader/log.d.ts +3 -0
- package/dist/core/loader/log.js +23 -0
- package/dist/core/loader/manifest.d.ts +14 -0
- package/dist/core/loader/manifest.js +77 -0
- package/dist/core/loader/order.d.ts +2 -0
- package/dist/core/loader/order.js +20 -0
- package/dist/core/loader/register.d.ts +19 -0
- package/dist/core/loader/register.js +39 -0
- package/dist/core/loader/scan.d.ts +18 -0
- package/dist/core/loader/scan.js +34 -0
- package/dist/core/loader/secrets.d.ts +9 -0
- package/dist/core/loader/secrets.js +28 -0
- package/dist/core/loader/state.d.ts +12 -0
- package/dist/core/loader/state.js +31 -0
- package/dist/core/loader/states.d.ts +7 -0
- package/dist/core/loader/states.js +52 -0
- package/dist/core/loader/timeout.d.ts +1 -0
- package/dist/core/loader/timeout.js +8 -0
- package/dist/core/loader/use.d.ts +8 -0
- package/dist/core/loader/use.js +18 -0
- package/dist/core/paths.d.ts +8 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/profile/cloud.d.ts +53 -0
- package/dist/core/profile/cloud.js +109 -0
- package/dist/core/profile/edit.d.ts +4 -0
- package/dist/core/profile/edit.js +27 -0
- package/dist/core/profile/machine.d.ts +18 -0
- package/dist/core/profile/machine.js +83 -0
- package/dist/core/profile/merge.d.ts +11 -0
- package/dist/core/profile/merge.js +21 -0
- package/dist/core/profile/profile.d.ts +6 -0
- package/dist/core/profile/profile.js +68 -0
- package/dist/core/profile/push.d.ts +16 -0
- package/dist/core/profile/push.js +49 -0
- package/dist/core/profile/register.d.ts +5 -0
- package/dist/core/profile/register.js +57 -0
- package/dist/core/profile/schema.d.ts +28 -0
- package/dist/core/profile/schema.js +165 -0
- package/dist/core/semver-range.d.ts +17 -0
- package/dist/core/semver-range.js +124 -0
- package/dist/extension-test.d.ts +7 -0
- package/dist/extension-test.js +49 -0
- package/dist/extension.d.ts +83 -0
- package/dist/extension.js +6 -0
- package/dist/lib/auth/api.d.ts +2 -2
- package/dist/lib/auth/api.js +3 -3
- package/dist/lib/auth/callback-page.d.ts +2 -0
- package/dist/lib/auth/callback-page.js +40 -0
- package/dist/lib/auth/callback-server.js +1 -4
- package/dist/lib/fs/file-lock.d.ts +3 -1
- package/dist/lib/fs/file-lock.js +26 -11
- package/dist/lib/fs/json-file.d.ts +1 -0
- package/dist/lib/fs/json-file.js +3 -2
- package/dist/lib/net/fetch.js +1 -1
- package/dist/lib/net/origins.d.ts +1 -0
- package/dist/lib/net/origins.js +11 -11
- package/dist/lib/npm/npm-cli.d.ts +2 -0
- package/dist/lib/npm/npm-cli.js +4 -4
- package/dist/program.d.ts +9 -1
- package/dist/program.js +44 -10
- package/package.json +8 -4
- package/dist/core/flag.d.ts +0 -1
- package/dist/core/flag.js +0 -1
- package/dist/plugin.d.ts +0 -1
- package/dist/plugin.js +0 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ConfigMap } from '../profile/schema.js';
|
|
2
|
+
export type ExtensionSource = 'tree' | 'dev';
|
|
3
|
+
export interface DiscoveredExtension {
|
|
4
|
+
pkg: string;
|
|
5
|
+
source: ExtensionSource;
|
|
6
|
+
dir: string;
|
|
7
|
+
version: string | null;
|
|
8
|
+
id: string | null;
|
|
9
|
+
entry: string | null;
|
|
10
|
+
commands: string[];
|
|
11
|
+
peers: string[];
|
|
12
|
+
optionalPeers: string[];
|
|
13
|
+
config: Record<string, unknown>;
|
|
14
|
+
secrets: string[];
|
|
15
|
+
descriptions: Record<string, string>;
|
|
16
|
+
problem: string | null;
|
|
17
|
+
missing: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface DiscoverInput {
|
|
20
|
+
extensionsDir: string;
|
|
21
|
+
names: string[];
|
|
22
|
+
dev: Record<string, string>;
|
|
23
|
+
config: ConfigMap;
|
|
24
|
+
tree?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Discovery {
|
|
27
|
+
extensions: DiscoveredExtension[];
|
|
28
|
+
warnings: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface CurrentTree {
|
|
31
|
+
dir: string | null;
|
|
32
|
+
warning: string | null;
|
|
33
|
+
}
|
|
34
|
+
export declare const currentTree: (extensionsDir: string) => CurrentTree;
|
|
35
|
+
export declare const discover: (input: DiscoverInput) => Discovery;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
4
|
+
import {} from '../profile/schema.js';
|
|
5
|
+
import { checkConfig } from './config.js';
|
|
6
|
+
import { unsafeDevPath } from './dev-path.js';
|
|
7
|
+
import { manifestInfo, manifestProblem } from './manifest.js';
|
|
8
|
+
const readText = (path) => {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(path, 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const TREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
17
|
+
const readManifest = (dir) => {
|
|
18
|
+
const raw = readText(join(dir, 'package.json'));
|
|
19
|
+
try {
|
|
20
|
+
const value = raw === null ? null : JSON.parse(raw);
|
|
21
|
+
return isRecord(value) ? value : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
// `extensions/current` names the tree; one without its `.complete` marker was never finished.
|
|
28
|
+
export const currentTree = (extensionsDir) => {
|
|
29
|
+
const name = readText(join(extensionsDir, 'current'))?.trim() ?? '';
|
|
30
|
+
if (name === '')
|
|
31
|
+
return { dir: null, warning: null };
|
|
32
|
+
if (!TREE_NAME.test(name))
|
|
33
|
+
return { dir: null, warning: `extensions/current: bad tree "${name}"` };
|
|
34
|
+
const dir = join(extensionsDir, 'trees', name);
|
|
35
|
+
if (existsSync(join(dir, '.complete')))
|
|
36
|
+
return { dir, warning: null };
|
|
37
|
+
return { dir: null, warning: `extensions tree "${name}" is incomplete and was ignored` };
|
|
38
|
+
};
|
|
39
|
+
const inside = (dir, entry) => {
|
|
40
|
+
const path = resolve(dir, entry);
|
|
41
|
+
const rel = relative(dir, path);
|
|
42
|
+
return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? null : path;
|
|
43
|
+
};
|
|
44
|
+
const entryProblem = (info, path) => {
|
|
45
|
+
if (info.entry === null)
|
|
46
|
+
return null;
|
|
47
|
+
if (path === null)
|
|
48
|
+
return `entry ${info.entry} is outside the package`;
|
|
49
|
+
return existsSync(path) ? null : `entry ${info.entry} not found`;
|
|
50
|
+
};
|
|
51
|
+
const blank = (pkg, source, dir) => ({
|
|
52
|
+
pkg,
|
|
53
|
+
source,
|
|
54
|
+
dir,
|
|
55
|
+
version: null,
|
|
56
|
+
id: null,
|
|
57
|
+
entry: null,
|
|
58
|
+
commands: [],
|
|
59
|
+
peers: [],
|
|
60
|
+
optionalPeers: [],
|
|
61
|
+
config: {},
|
|
62
|
+
secrets: [],
|
|
63
|
+
descriptions: {},
|
|
64
|
+
problem: null,
|
|
65
|
+
missing: false,
|
|
66
|
+
});
|
|
67
|
+
const inspect = ({ pkg, source, dir }, configs) => {
|
|
68
|
+
const base = blank(pkg, source, dir ?? '');
|
|
69
|
+
if (source === 'dev' && !isAbsolute(base.dir))
|
|
70
|
+
return { ...base, problem: 'dev path not absolute' };
|
|
71
|
+
const manifest = dir === null ? null : readManifest(dir);
|
|
72
|
+
if (dir === null || manifest === null)
|
|
73
|
+
return { ...base, missing: true };
|
|
74
|
+
const info = manifestInfo(manifest);
|
|
75
|
+
const entry = info.entry === null ? null : inside(dir, info.entry);
|
|
76
|
+
const { version, id, commands, peers, optionalPeers } = info;
|
|
77
|
+
const slice = configs[id ?? ''] ?? {};
|
|
78
|
+
const { problem: badConfig, ...declared } = checkConfig(id ?? '', info.configuration, slice);
|
|
79
|
+
const unsafe = source === 'dev' ? unsafeDevPath(dir, process.getuid?.()) : null;
|
|
80
|
+
const problem = unsafe ?? manifestProblem(pkg, info) ?? entryProblem(info, entry) ?? badConfig;
|
|
81
|
+
return { ...base, version, id, entry, commands, peers, optionalPeers, ...declared, problem };
|
|
82
|
+
};
|
|
83
|
+
// Every claimant of a shared id is invalid; a dev copy of the same package never reaches here.
|
|
84
|
+
const markDuplicates = (extensions) => extensions.map((p) => {
|
|
85
|
+
const others = extensions.filter((o) => o !== p && p.id !== null && o.id === p.id);
|
|
86
|
+
if (others.length === 0 || p.problem)
|
|
87
|
+
return p;
|
|
88
|
+
return {
|
|
89
|
+
...p,
|
|
90
|
+
problem: `duplicate id "${p.id}" (also ${others.map((o) => o.pkg).join(', ')})`,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
// A peer counts as an extension unless the tree holds it as a plain library without `agentage`.
|
|
94
|
+
const extensionPeers = (p, known, nodeModules) => p.peers.filter((peer) => {
|
|
95
|
+
if (known.has(peer) || nodeModules === null)
|
|
96
|
+
return true;
|
|
97
|
+
const manifest = readManifest(join(nodeModules, peer));
|
|
98
|
+
return manifest === null || isRecord(manifest['agentage']);
|
|
99
|
+
});
|
|
100
|
+
export const discover = (input) => {
|
|
101
|
+
const tree = input.tree ? { dir: input.tree, warning: null } : currentTree(input.extensionsDir);
|
|
102
|
+
const nodeModules = tree.dir === null ? null : join(tree.dir, 'node_modules');
|
|
103
|
+
const installed = input.names.filter((name) => !(name in input.dev));
|
|
104
|
+
const found = markDuplicates([
|
|
105
|
+
...installed.map((pkg) => ({
|
|
106
|
+
pkg,
|
|
107
|
+
source: 'tree',
|
|
108
|
+
dir: nodeModules && join(nodeModules, pkg),
|
|
109
|
+
})),
|
|
110
|
+
...Object.entries(input.dev).map(([pkg, dir]) => ({ pkg, source: 'dev', dir })),
|
|
111
|
+
].map((candidate) => inspect(candidate, input.config)));
|
|
112
|
+
const known = new Set(found.map((p) => p.pkg));
|
|
113
|
+
const extensions = found.map((p) => ({
|
|
114
|
+
...p,
|
|
115
|
+
peers: extensionPeers(p, known, nodeModules),
|
|
116
|
+
}));
|
|
117
|
+
return { extensions, warnings: tree.warning === null ? [] : [tree.warning] };
|
|
118
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Disposable } from '../../extension.js';
|
|
2
|
+
export declare const DISPOSE_TIMEOUT_MS = 5000;
|
|
3
|
+
export declare const disposeAll: (id: string, subscriptions: Disposable[]) => Promise<void>;
|
|
4
|
+
export declare const holdSignals: (dispose: () => Promise<void>) => (() => void);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {} from '../../extension.js';
|
|
2
|
+
import { extensionLog } from './log.js';
|
|
3
|
+
import { withTimeout } from './timeout.js';
|
|
4
|
+
export const DISPOSE_TIMEOUT_MS = 5_000;
|
|
5
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
6
|
+
// Each subscription gets its 5 s at once; one that hangs is abandoned, and every result is logged.
|
|
7
|
+
export const disposeAll = async (id, subscriptions) => {
|
|
8
|
+
const log = extensionLog(id);
|
|
9
|
+
await Promise.all(subscriptions.map(async (subscription, index) => {
|
|
10
|
+
const disposing = Promise.resolve().then(() => subscription.dispose());
|
|
11
|
+
const result = await withTimeout(disposing, DISPOSE_TIMEOUT_MS).then(() => 'done', messageOf);
|
|
12
|
+
log('dispose', { subscription: index, result });
|
|
13
|
+
}));
|
|
14
|
+
};
|
|
15
|
+
const SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
16
|
+
// The signal waits for disposal, then is raised again with no handler: the exit it would have had.
|
|
17
|
+
export const holdSignals = (dispose) => {
|
|
18
|
+
const handlers = SIGNALS.map((signal) => {
|
|
19
|
+
const handler = () => void dispose().finally(() => process.kill(process.pid, signal));
|
|
20
|
+
process.once(signal, handler);
|
|
21
|
+
return { signal, handler };
|
|
22
|
+
});
|
|
23
|
+
return () => handlers.forEach(({ signal, handler }) => process.off(signal, handler));
|
|
24
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
3
|
+
import { type LoadedExtension } from './register.js';
|
|
4
|
+
export interface LoadOutcome {
|
|
5
|
+
state: 'ok' | 'failed' | 'blocked';
|
|
6
|
+
error: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface LoadSession {
|
|
9
|
+
outcomes: Map<string, LoadOutcome>;
|
|
10
|
+
loaded: Map<string, LoadedExtension>;
|
|
11
|
+
dispose: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare const loadExtensions: (extensions: DiscoveredExtension[], template?: Command) => Promise<LoadSession>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import {} from './discover.js';
|
|
3
|
+
import { disposeAll, holdSignals } from './dispose.js';
|
|
4
|
+
import { loadOrder } from './order.js';
|
|
5
|
+
import { registerExtension } from './register.js';
|
|
6
|
+
import { writeRunState } from './state.js';
|
|
7
|
+
import { createUse } from './use.js';
|
|
8
|
+
const blockedBy = (ext, outcomes) => {
|
|
9
|
+
const required = ext.peers.filter((peer) => !ext.optionalPeers.includes(peer));
|
|
10
|
+
const peer = required.find((name) => outcomes.get(name)?.state !== 'ok');
|
|
11
|
+
return peer === undefined ? null : `peer ${peer} is ${outcomes.get(peer)?.state ?? 'not loaded'}`;
|
|
12
|
+
};
|
|
13
|
+
const attempt = async (ext, session, template) => {
|
|
14
|
+
const blocked = blockedBy(ext, session.outcomes);
|
|
15
|
+
if (blocked)
|
|
16
|
+
return { state: 'blocked', error: blocked };
|
|
17
|
+
const result = await registerExtension(ext, createUse(ext, session.loaded), template);
|
|
18
|
+
if (!result.ok)
|
|
19
|
+
return { state: 'failed', error: result.error };
|
|
20
|
+
session.loaded.set(ext.id ?? '', result.loaded);
|
|
21
|
+
return { state: 'ok', error: null };
|
|
22
|
+
};
|
|
23
|
+
// What an extension prints while it loads goes to stderr, so stdout stays the command's own.
|
|
24
|
+
const toStderr = async (fn) => {
|
|
25
|
+
const saved = { log: console.log, info: console.info, debug: console.debug };
|
|
26
|
+
console.log = console.info = console.debug = console.error;
|
|
27
|
+
try {
|
|
28
|
+
await fn();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
Object.assign(console, saved);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const newSession = () => {
|
|
35
|
+
const loaded = new Map();
|
|
36
|
+
let disposed;
|
|
37
|
+
const disposeOnce = async () => {
|
|
38
|
+
disposed ??= Promise.all([...loaded.values()].map((l) => disposeAll(l.ext.id ?? '', l.subscriptions)));
|
|
39
|
+
await disposed;
|
|
40
|
+
};
|
|
41
|
+
const release = holdSignals(disposeOnce);
|
|
42
|
+
const dispose = () => {
|
|
43
|
+
release();
|
|
44
|
+
return disposeOnce();
|
|
45
|
+
};
|
|
46
|
+
return { outcomes: new Map(), loaded, dispose };
|
|
47
|
+
};
|
|
48
|
+
// Loads in peer order; one extension failing never stops the others, only what depends on it.
|
|
49
|
+
export const loadExtensions = async (extensions, template = new Command('agentage')) => {
|
|
50
|
+
// Not a sandbox, but the token never reaches extension code through the environment.
|
|
51
|
+
delete process.env['AGENTAGE_TOKEN'];
|
|
52
|
+
const session = newSession();
|
|
53
|
+
await toStderr(async () => {
|
|
54
|
+
for (const ext of loadOrder(extensions)) {
|
|
55
|
+
const outcome = await attempt(ext, session, template);
|
|
56
|
+
session.outcomes.set(ext.pkg, outcome);
|
|
57
|
+
writeRunState(ext.id ?? '', { ...outcome, version: ext.version });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return session;
|
|
61
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {} from '../../extension.js';
|
|
4
|
+
import { writeTextAtomic } from '../../lib/fs/json-file.js';
|
|
5
|
+
import { logsDir } from '../paths.js';
|
|
6
|
+
export const LOG_CAP_BYTES = 1024 * 1024;
|
|
7
|
+
// Past the cap, keep the newest half from its first whole line, so the file stays small.
|
|
8
|
+
const trim = (path) => {
|
|
9
|
+
if (statSync(path).size <= LOG_CAP_BYTES)
|
|
10
|
+
return;
|
|
11
|
+
const tail = readFileSync(path)
|
|
12
|
+
.subarray(-LOG_CAP_BYTES / 2)
|
|
13
|
+
.toString('utf-8');
|
|
14
|
+
writeTextAtomic(path, tail.slice(tail.indexOf('\n') + 1));
|
|
15
|
+
};
|
|
16
|
+
// The extension's own log; the core never reads it, its run state lives in state/<id>.json.
|
|
17
|
+
export const extensionLog = (id) => (message, fields = {}) => {
|
|
18
|
+
const path = join(logsDir(), `${id}.log`);
|
|
19
|
+
mkdirSync(logsDir(), { recursive: true, mode: 0o700 });
|
|
20
|
+
const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
|
|
21
|
+
appendFileSync(path, `${line}\n`, { mode: 0o600 });
|
|
22
|
+
trim(path);
|
|
23
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const ID_PATTERN: RegExp;
|
|
2
|
+
export interface ManifestInfo {
|
|
3
|
+
name: string | null;
|
|
4
|
+
version: string | null;
|
|
5
|
+
id: string | null;
|
|
6
|
+
engine: string;
|
|
7
|
+
entry: string | null;
|
|
8
|
+
commands: string[];
|
|
9
|
+
peers: string[];
|
|
10
|
+
optionalPeers: string[];
|
|
11
|
+
configuration: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare const manifestInfo: (pkg: Record<string, unknown>) => ManifestInfo;
|
|
14
|
+
export declare const manifestProblem: (pkg: string, info: ManifestInfo, api?: string) => string | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
2
|
+
import { EXTENSION_API_VERSION } from '../../extension.js';
|
|
3
|
+
import { isRange, satisfies } from '../semver-range.js';
|
|
4
|
+
export const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
5
|
+
const DEFAULT_ENGINE = '^1';
|
|
6
|
+
const CORE_COMMANDS = ['setup', 'status', 'update', 'extension', 'add', 'remove', 'help'];
|
|
7
|
+
const DEVICE_NAMES = ['con', 'prn', 'aux', 'nul'].concat(...Array.from({ length: 10 }, (_, n) => [`com${n}`, `lpt${n}`]));
|
|
8
|
+
const RESERVED_IDS = new Set([...CORE_COMMANDS, ...DEVICE_NAMES]);
|
|
9
|
+
const SUBCOMMAND = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
|
+
const str = (value) => (typeof value === 'string' ? value : null);
|
|
11
|
+
const record = (value) => (isRecord(value) ? value : {});
|
|
12
|
+
const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
13
|
+
const fromConditions = (value, depth = 0) => {
|
|
14
|
+
if (typeof value === 'string')
|
|
15
|
+
return value;
|
|
16
|
+
if (!isRecord(value) || depth > 3)
|
|
17
|
+
return null;
|
|
18
|
+
return fromConditions(value['import'], depth + 1) ?? fromConditions(value['default'], depth + 1);
|
|
19
|
+
};
|
|
20
|
+
// Node semantics: a present `exports` field is authoritative, `main` only applies without it.
|
|
21
|
+
const entryOf = (pkg) => {
|
|
22
|
+
const exp = pkg['exports'];
|
|
23
|
+
if (exp === undefined)
|
|
24
|
+
return str(pkg['main']);
|
|
25
|
+
if (isRecord(exp) && '.' in exp)
|
|
26
|
+
return fromConditions(exp['.']);
|
|
27
|
+
const subpathsOnly = isRecord(exp) && Object.keys(exp).some((key) => key.startsWith('.'));
|
|
28
|
+
return subpathsOnly ? null : fromConditions(exp);
|
|
29
|
+
};
|
|
30
|
+
const optionalPeersOf = (meta) => Object.entries(meta)
|
|
31
|
+
.filter(([, m]) => isRecord(m) && m['optional'] === true)
|
|
32
|
+
.map(([name]) => name);
|
|
33
|
+
export const manifestInfo = (pkg) => {
|
|
34
|
+
const agentage = record(pkg['agentage']);
|
|
35
|
+
return {
|
|
36
|
+
name: str(pkg['name']),
|
|
37
|
+
version: str(pkg['version']),
|
|
38
|
+
id: str(agentage['id']),
|
|
39
|
+
engine: str(record(pkg['engines'])['agentage']) ?? DEFAULT_ENGINE,
|
|
40
|
+
entry: entryOf(pkg),
|
|
41
|
+
commands: [...new Set(strings(record(agentage['contributes'])['commands']))],
|
|
42
|
+
peers: Object.keys(record(pkg['peerDependencies'])),
|
|
43
|
+
optionalPeers: optionalPeersOf(record(pkg['peerDependenciesMeta'])),
|
|
44
|
+
configuration: record(agentage['configuration']),
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const idProblem = (id) => {
|
|
48
|
+
if (id === null)
|
|
49
|
+
return 'agentage.id is missing';
|
|
50
|
+
if (!ID_PATTERN.test(id))
|
|
51
|
+
return `agentage.id "${id}" must match [a-z][a-z0-9-]*`;
|
|
52
|
+
return RESERVED_IDS.has(id) ? `agentage.id "${id}" is reserved` : null;
|
|
53
|
+
};
|
|
54
|
+
const engineProblem = (engine, api) => {
|
|
55
|
+
if (!isRange(engine))
|
|
56
|
+
return `engines.agentage "${engine}" is not a supported range`;
|
|
57
|
+
if (satisfies(api, engine))
|
|
58
|
+
return null;
|
|
59
|
+
return `engines.agentage "${engine}" does not match extension API ${api}`;
|
|
60
|
+
};
|
|
61
|
+
// Commands are named after the id, so no two extensions, nor the core, can claim one.
|
|
62
|
+
const commandsProblem = (id, commands) => {
|
|
63
|
+
const ownSub = (name) => name.startsWith(`${id}:`) && SUBCOMMAND.test(name.slice(id.length + 1));
|
|
64
|
+
const bad = commands.find((name) => name !== id && !ownSub(name));
|
|
65
|
+
if (bad === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
return `agentage.contributes.commands: "${bad}" must be "${id}" or "${id}:<name>"`;
|
|
68
|
+
};
|
|
69
|
+
// Static checks only: the manifest is read as data and the entry is never imported.
|
|
70
|
+
export const manifestProblem = (pkg, info, api = EXTENSION_API_VERSION) => {
|
|
71
|
+
if (info.name !== pkg)
|
|
72
|
+
return `package.json name ${JSON.stringify(info.name)} is not ${pkg}`;
|
|
73
|
+
return (idProblem(info.id) ??
|
|
74
|
+
engineProblem(info.engine, api) ??
|
|
75
|
+
(info.entry === null ? 'no entry point (exports or main)' : null) ??
|
|
76
|
+
commandsProblem(info.id ?? '', info.commands));
|
|
77
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {} from './discover.js';
|
|
2
|
+
// Peers first, from `roots` through `all`; cycles are invalid already, `seen` ends the walk anyway.
|
|
3
|
+
export const loadOrder = (roots, all = roots) => {
|
|
4
|
+
const byPkg = new Map(all.map((ext) => [ext.pkg, ext]));
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const ordered = [];
|
|
7
|
+
const visit = (ext) => {
|
|
8
|
+
if (seen.has(ext.pkg))
|
|
9
|
+
return;
|
|
10
|
+
seen.add(ext.pkg);
|
|
11
|
+
for (const peer of [...ext.peers, ...ext.optionalPeers]) {
|
|
12
|
+
const found = byPkg.get(peer);
|
|
13
|
+
if (found)
|
|
14
|
+
visit(found);
|
|
15
|
+
}
|
|
16
|
+
ordered.push(ext);
|
|
17
|
+
};
|
|
18
|
+
roots.forEach(visit);
|
|
19
|
+
return ordered;
|
|
20
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Command } from 'commander';
|
|
2
|
+
import { type Disposable, type ExtensionApi, type ExtensionUse } from '../../extension.js';
|
|
3
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
4
|
+
export declare const DEFAULT_REGISTER_TIMEOUT_MS = 10000;
|
|
5
|
+
export interface LoadedExtension {
|
|
6
|
+
ext: DiscoveredExtension;
|
|
7
|
+
program: Command;
|
|
8
|
+
subscriptions: Disposable[];
|
|
9
|
+
facade: ExtensionApi;
|
|
10
|
+
}
|
|
11
|
+
export type Attempt = {
|
|
12
|
+
ok: true;
|
|
13
|
+
loaded: LoadedExtension;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const registerTimeoutMs: () => number;
|
|
19
|
+
export declare const registerExtension: (ext: DiscoveredExtension, use: ExtensionUse, template: Command) => Promise<Attempt>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import {} from 'commander';
|
|
3
|
+
import {} from '../../extension.js';
|
|
4
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
5
|
+
import { createContext } from './ctx.js';
|
|
6
|
+
import {} from './discover.js';
|
|
7
|
+
import { disposeAll } from './dispose.js';
|
|
8
|
+
import { withTimeout } from './timeout.js';
|
|
9
|
+
import { facadeOf } from './use.js';
|
|
10
|
+
export const DEFAULT_REGISTER_TIMEOUT_MS = 10_000;
|
|
11
|
+
// Tests shorten the wait; anything but a positive integer keeps the default.
|
|
12
|
+
export const registerTimeoutMs = () => {
|
|
13
|
+
const raw = process.env['AGENTAGE_REGISTER_TIMEOUT_MS'] ?? '';
|
|
14
|
+
return /^[1-9]\d{0,8}$/.test(raw) ? Number(raw) : DEFAULT_REGISTER_TIMEOUT_MS;
|
|
15
|
+
};
|
|
16
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
17
|
+
const isRegister = (value) => typeof value === 'function';
|
|
18
|
+
const importAndRegister = async (entry, ctx) => {
|
|
19
|
+
const exported = await import(pathToFileURL(entry).href);
|
|
20
|
+
const register = isRecord(exported) ? exported['register'] : undefined;
|
|
21
|
+
if (!isRegister(register))
|
|
22
|
+
throw new Error('the entry exports no register function');
|
|
23
|
+
return register(ctx);
|
|
24
|
+
};
|
|
25
|
+
// The import counts toward the timeout too: a top-level await can hang like register can.
|
|
26
|
+
export const registerExtension = async (ext, use, template) => {
|
|
27
|
+
const staged = createContext({ ext, use, template });
|
|
28
|
+
try {
|
|
29
|
+
const api = await withTimeout(importAndRegister(ext.entry ?? '', staged.ctx), registerTimeoutMs());
|
|
30
|
+
staged.close();
|
|
31
|
+
const { program, subscriptions } = staged;
|
|
32
|
+
return { ok: true, loaded: { ext, program, subscriptions, facade: facadeOf(api) } };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
staged.revoke();
|
|
36
|
+
await disposeAll(ext.id ?? '', staged.subscriptions);
|
|
37
|
+
return { ok: false, error: messageOf(err) };
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Plan } from '../installer/plan.js';
|
|
2
|
+
import { type MachineFile } from '../profile/machine.js';
|
|
3
|
+
import { type Effective } from '../profile/merge.js';
|
|
4
|
+
import { type ProfileParse } from '../profile/schema.js';
|
|
5
|
+
import { type DiscoveredExtension, type Discovery } from './discover.js';
|
|
6
|
+
import { type StaticResult } from './states.js';
|
|
7
|
+
export interface Scan {
|
|
8
|
+
machine: MachineFile;
|
|
9
|
+
parsed: ProfileParse | null;
|
|
10
|
+
eff: Effective;
|
|
11
|
+
found: Discovery;
|
|
12
|
+
states: Map<string, StaticResult>;
|
|
13
|
+
plan: Plan;
|
|
14
|
+
removed: DiscoveredExtension[];
|
|
15
|
+
drift: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare const scan: (machine: MachineFile, tree?: string) => Scan;
|
|
18
|
+
export declare const loadSet: (found: Scan) => DiscoveredExtension[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { currentDependencies, lockDrift, planChanges } from '../installer/plan.js';
|
|
2
|
+
import {} from '../profile/machine.js';
|
|
3
|
+
import { effective } from '../profile/merge.js';
|
|
4
|
+
import { readProfile } from '../profile/profile.js';
|
|
5
|
+
import {} from '../profile/schema.js';
|
|
6
|
+
import { extensionsDir } from '../paths.js';
|
|
7
|
+
import { discover } from './discover.js';
|
|
8
|
+
import { staticStates } from './states.js';
|
|
9
|
+
// A tree extension whose bytes the two locks disagree on is never trusted, so never imported.
|
|
10
|
+
const withDrift = (found, drift) => ({
|
|
11
|
+
...found,
|
|
12
|
+
extensions: found.extensions.map((p) => {
|
|
13
|
+
const key = `${p.pkg}@${p.version}`;
|
|
14
|
+
const drifted = p.source === 'tree' && p.problem === null && drift.includes(key);
|
|
15
|
+
return drifted ? { ...p, problem: `integrity drift: ${key}` } : p;
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
// Everything known about this machine's extensions without running any of them; `tree` reads
|
|
19
|
+
// that tree instead of the current one.
|
|
20
|
+
export const scan = (machine, tree) => {
|
|
21
|
+
const parsed = readProfile();
|
|
22
|
+
const eff = effective(parsed?.ok ? parsed.profile : null, machine);
|
|
23
|
+
const dir = extensionsDir();
|
|
24
|
+
const drift = lockDrift(dir).integrity;
|
|
25
|
+
const input = { extensionsDir: dir, dev: eff.dev, config: eff.config, tree };
|
|
26
|
+
const found = withDrift(discover({ ...input, names: Object.keys(eff.install) }), drift);
|
|
27
|
+
const plan = planChanges(eff, currentDependencies(dir));
|
|
28
|
+
const gone = plan.extensions.filter((e) => e.verb === 'remove' && !(e.name in eff.dev));
|
|
29
|
+
const removed = discover({ ...input, dev: {}, names: gone.map((e) => e.name) }).extensions;
|
|
30
|
+
const states = staticStates(found.extensions, eff.off);
|
|
31
|
+
return { machine, parsed, eff, found, states, plan, removed, drift };
|
|
32
|
+
};
|
|
33
|
+
// What can load: a valid manifest, installed, not off here, and peers that can load too.
|
|
34
|
+
export const loadSet = (found) => found.found.extensions.filter((ext) => found.states.get(ext.pkg)?.state === 'installed');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ExtensionSecrets } from '../../extension.js';
|
|
2
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
3
|
+
export interface SecretKey {
|
|
4
|
+
id: string;
|
|
5
|
+
key: string;
|
|
6
|
+
description: string | null;
|
|
7
|
+
}
|
|
8
|
+
export declare const secretsFor: (id: string) => ExtensionSecrets;
|
|
9
|
+
export declare const missingSecrets: (extensions: DiscoveredExtension[]) => SecretKey[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {} from '../../extension.js';
|
|
2
|
+
import { withFileLock } from '../../lib/fs/file-lock.js';
|
|
3
|
+
import { isRecord, readJson, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
4
|
+
import { secretsPath } from '../paths.js';
|
|
5
|
+
import {} from './discover.js';
|
|
6
|
+
const isStrings = (value) => isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
|
|
7
|
+
const isSecretsFile = (value) => isRecord(value) && Object.values(value).every(isStrings);
|
|
8
|
+
const readSecrets = () => readJson(secretsPath(), isSecretsFile) ?? {};
|
|
9
|
+
// Re-read under the lock, so two processes never drop each other's secret.
|
|
10
|
+
const change = (id, fn) => withFileLock(secretsPath(), () => {
|
|
11
|
+
const all = readSecrets();
|
|
12
|
+
writeJsonAtomic(secretsPath(), { ...all, [id]: fn(all[id] ?? {}) });
|
|
13
|
+
});
|
|
14
|
+
export const secretsFor = (id) => ({
|
|
15
|
+
get: async (key) => readSecrets()[id]?.[key],
|
|
16
|
+
set: (key, value) => change(id, (own) => ({ ...own, [key]: value })),
|
|
17
|
+
delete: (key) => change(id, (own) => Object.fromEntries(Object.entries(own).filter(([k]) => k !== key))),
|
|
18
|
+
});
|
|
19
|
+
// The keys extensions declare `secret: true` that this machine has no value for yet.
|
|
20
|
+
export const missingSecrets = (extensions) => {
|
|
21
|
+
const stored = readSecrets();
|
|
22
|
+
return extensions.flatMap((ext) => {
|
|
23
|
+
const id = ext.id ?? '';
|
|
24
|
+
return ext.secrets
|
|
25
|
+
.filter((key) => stored[id]?.[key] === undefined)
|
|
26
|
+
.map((key) => ({ id, key, description: ext.descriptions[key] ?? null }));
|
|
27
|
+
});
|
|
28
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const RUN_STATES: readonly ["ok", "failed", "invalid", "off", "blocked", "installed"];
|
|
2
|
+
export type RunStateName = (typeof RUN_STATES)[number];
|
|
3
|
+
export interface RunState {
|
|
4
|
+
at: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
state: RunStateName;
|
|
7
|
+
error: string | null;
|
|
8
|
+
version: string | null;
|
|
9
|
+
lastOk: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare const readRunState: (id: string) => RunState | null;
|
|
12
|
+
export declare const writeRunState: (id: string, run: Pick<RunState, "state" | "error" | "version">) => void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord, readJson, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
4
|
+
import { stateDir } from '../paths.js';
|
|
5
|
+
import { ID_PATTERN } from './manifest.js';
|
|
6
|
+
export const RUN_STATES = ['ok', 'failed', 'invalid', 'off', 'blocked', 'installed'];
|
|
7
|
+
const isRunStateName = (value) => RUN_STATES.some((name) => name === value);
|
|
8
|
+
// Absent in files written before the field existed.
|
|
9
|
+
const optionalText = (value) => value === undefined || value === null || typeof value === 'string';
|
|
10
|
+
const isRunState = (value) => isRecord(value) &&
|
|
11
|
+
typeof value['at'] === 'string' &&
|
|
12
|
+
typeof value['pid'] === 'number' &&
|
|
13
|
+
isRunStateName(value['state']) &&
|
|
14
|
+
['error', 'version', 'lastOk'].every((key) => optionalText(value[key]));
|
|
15
|
+
// Reads the core's own record of the last run; extension logs are never consulted.
|
|
16
|
+
export const readRunState = (id) => {
|
|
17
|
+
if (!ID_PATTERN.test(id))
|
|
18
|
+
return null;
|
|
19
|
+
const found = readJson(join(stateDir(), `${id}.json`), isRunState);
|
|
20
|
+
if (!found)
|
|
21
|
+
return null;
|
|
22
|
+
const { at, pid, state, error = null, version = null, lastOk = null } = found;
|
|
23
|
+
return { at, pid, state, error, version, lastOk };
|
|
24
|
+
};
|
|
25
|
+
// Written by the core after each load attempt; a failed one keeps the last ok version.
|
|
26
|
+
export const writeRunState = (id, run) => {
|
|
27
|
+
const lastOk = run.state === 'ok' ? run.version : (readRunState(id)?.lastOk ?? null);
|
|
28
|
+
mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
|
|
29
|
+
const state = { at: new Date().toISOString(), pid: process.pid, ...run, lastOk };
|
|
30
|
+
writeJsonAtomic(join(stateDir(), `${id}.json`), state);
|
|
31
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
2
|
+
export type StaticState = 'installed' | 'off' | 'blocked' | 'invalid' | 'missing';
|
|
3
|
+
export interface StaticResult {
|
|
4
|
+
state: StaticState;
|
|
5
|
+
error: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const staticStates: (extensions: DiscoveredExtension[], off: string[]) => Map<string, StaticResult>;
|