@agentage/cli 0.38.1 → 0.39.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/dist/commands/plugins/status-print.d.ts +2 -0
- package/dist/commands/plugins/status-print.js +44 -0
- package/dist/commands/plugins/status-rows.d.ts +51 -0
- package/dist/commands/plugins/status-rows.js +58 -0
- package/dist/commands/status/status.d.ts +6 -1
- package/dist/commands/status/status.js +50 -6
- package/dist/core/loader/discover.d.ts +33 -0
- package/dist/core/loader/discover.js +103 -0
- package/dist/core/loader/manifest.d.ts +16 -0
- package/dist/core/loader/manifest.js +66 -0
- package/dist/core/loader/state.d.ts +12 -0
- package/dist/core/loader/state.js +21 -0
- package/dist/core/loader/states.d.ts +7 -0
- package/dist/core/loader/states.js +52 -0
- package/dist/core/paths.d.ts +4 -0
- package/dist/core/paths.js +6 -0
- package/dist/core/profile/machine.d.ts +18 -0
- package/dist/core/profile/machine.js +60 -0
- package/dist/core/profile/merge.d.ts +11 -0
- package/dist/core/profile/merge.js +16 -0
- package/dist/core/profile/profile.d.ts +6 -0
- package/dist/core/profile/profile.js +34 -0
- package/dist/core/profile/schema.d.ts +26 -0
- package/dist/core/profile/schema.js +143 -0
- package/dist/core/semver-range.d.ts +16 -0
- package/dist/core/semver-range.js +116 -0
- 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/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import {} from '../../core/loader/states.js';
|
|
3
|
+
import {} from './status-rows.js';
|
|
4
|
+
const COLOR = {
|
|
5
|
+
installed: chalk.green,
|
|
6
|
+
off: chalk.dim,
|
|
7
|
+
blocked: chalk.yellow,
|
|
8
|
+
invalid: chalk.red,
|
|
9
|
+
missing: chalk.yellow,
|
|
10
|
+
};
|
|
11
|
+
const STATE_WIDTH = Math.max(...Object.keys(COLOR).map((state) => state.length));
|
|
12
|
+
const line = (label, value) => `${label.padEnd(10)} ${value}`;
|
|
13
|
+
const profileText = (profile) => {
|
|
14
|
+
if (profile.state === 'missing')
|
|
15
|
+
return 'no profile';
|
|
16
|
+
if (profile.state === 'invalid')
|
|
17
|
+
return chalk.red('profile.json is invalid');
|
|
18
|
+
const readOnly = profile.readOnly ? chalk.yellow(', read-only') : '';
|
|
19
|
+
return `version ${profile.version} (source: ${profile.source}${readOnly})`;
|
|
20
|
+
};
|
|
21
|
+
const lastRunText = (run) => {
|
|
22
|
+
if (run === null)
|
|
23
|
+
return '';
|
|
24
|
+
const error = run.error === null ? '' : ` "${run.error}"`;
|
|
25
|
+
return ` last run: ${run.state}${error} at ${run.at}`;
|
|
26
|
+
};
|
|
27
|
+
const pluginLine = (row, idWidth) => {
|
|
28
|
+
const id = (row.id ?? '?').padEnd(idWidth);
|
|
29
|
+
const state = COLOR[row.state](row.state.padEnd(STATE_WIDTH));
|
|
30
|
+
const wanted = row.mode === 'dev' ? 'dev' : `wants ${row.wanted ?? '?'}`;
|
|
31
|
+
const pkg = `${row.package} ${row.installed ?? '-'} (${wanted})`;
|
|
32
|
+
const commands = row.commands.length > 0 ? ` commands: ${row.commands.join(', ')}` : '';
|
|
33
|
+
const error = row.error === null ? '' : chalk.dim(` ${row.error}`);
|
|
34
|
+
return ` ${id} ${state} ${pkg}${commands}${lastRunText(row.lastRun)}${error}`;
|
|
35
|
+
};
|
|
36
|
+
export const pluginStatusLines = (status) => {
|
|
37
|
+
const idWidth = Math.max(0, ...status.plugins.map((row) => (row.id ?? '?').length));
|
|
38
|
+
return [
|
|
39
|
+
line('machine', `${status.machine.id} (${status.machine.name})`),
|
|
40
|
+
line('profile', profileText(status.profile)),
|
|
41
|
+
line('plugins', status.plugins.length === 0 ? 'none' : String(status.plugins.length)),
|
|
42
|
+
...status.plugins.map((row) => pluginLine(row, idWidth)),
|
|
43
|
+
];
|
|
44
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type DiscoverInput, type Discovery } from '../../core/loader/discover.js';
|
|
2
|
+
import { type RunState } from '../../core/loader/state.js';
|
|
3
|
+
import { type StaticState } from '../../core/loader/states.js';
|
|
4
|
+
import { type MachineFile, type ProfileSource } from '../../core/profile/machine.js';
|
|
5
|
+
import { type ProfileParse } from '../../core/profile/schema.js';
|
|
6
|
+
export interface PluginRow {
|
|
7
|
+
id: string | null;
|
|
8
|
+
package: string;
|
|
9
|
+
mode: 'installed' | 'dev';
|
|
10
|
+
wanted: string | null;
|
|
11
|
+
installed: string | null;
|
|
12
|
+
state: StaticState;
|
|
13
|
+
error: string | null;
|
|
14
|
+
commands: string[];
|
|
15
|
+
lastRun: {
|
|
16
|
+
at: string;
|
|
17
|
+
state: string;
|
|
18
|
+
error: string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
export type ProfileStatus = {
|
|
22
|
+
state: 'missing';
|
|
23
|
+
} | {
|
|
24
|
+
state: 'invalid';
|
|
25
|
+
errors: string[];
|
|
26
|
+
} | {
|
|
27
|
+
state: 'ok';
|
|
28
|
+
version: number;
|
|
29
|
+
source: ProfileSource;
|
|
30
|
+
readOnly: boolean;
|
|
31
|
+
};
|
|
32
|
+
export interface PluginsStatus {
|
|
33
|
+
machine: {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
};
|
|
37
|
+
profile: ProfileStatus;
|
|
38
|
+
plugins: PluginRow[];
|
|
39
|
+
}
|
|
40
|
+
export interface PluginsGather {
|
|
41
|
+
status: PluginsStatus;
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface StatusRowsDeps {
|
|
45
|
+
loadMachine: () => Promise<MachineFile>;
|
|
46
|
+
readProfile: () => ProfileParse | null;
|
|
47
|
+
discover: (input: DiscoverInput) => Discovery;
|
|
48
|
+
readRunState: (id: string) => RunState | null;
|
|
49
|
+
pluginsDir: () => string;
|
|
50
|
+
}
|
|
51
|
+
export declare const gatherPluginStatus: (deps?: StatusRowsDeps) => Promise<PluginsGather>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { discover, } from '../../core/loader/discover.js';
|
|
2
|
+
import { readRunState } from '../../core/loader/state.js';
|
|
3
|
+
import { staticStates } from '../../core/loader/states.js';
|
|
4
|
+
import { pluginsDir } from '../../core/paths.js';
|
|
5
|
+
import { loadMachine } from '../../core/profile/machine.js';
|
|
6
|
+
import { effective } from '../../core/profile/merge.js';
|
|
7
|
+
import { readProfile } from '../../core/profile/profile.js';
|
|
8
|
+
import {} from '../../core/profile/schema.js';
|
|
9
|
+
const defaultDeps = {
|
|
10
|
+
loadMachine: () => loadMachine(),
|
|
11
|
+
readProfile: () => readProfile(),
|
|
12
|
+
discover: (input) => discover(input),
|
|
13
|
+
readRunState: (id) => readRunState(id),
|
|
14
|
+
pluginsDir,
|
|
15
|
+
};
|
|
16
|
+
const profileStatus = (parsed, machine) => {
|
|
17
|
+
if (parsed === null)
|
|
18
|
+
return { state: 'missing' };
|
|
19
|
+
if (!parsed.ok)
|
|
20
|
+
return { state: 'invalid', errors: parsed.errors };
|
|
21
|
+
const { version } = parsed.profile;
|
|
22
|
+
return { state: 'ok', version, source: machine.profileSource, readOnly: parsed.readOnly };
|
|
23
|
+
};
|
|
24
|
+
// A row for an invalid manifest has no trustworthy id, so it never shows another plugin's run.
|
|
25
|
+
const lastRunOf = (p, s, deps) => {
|
|
26
|
+
const run = p.id === null || s.state === 'invalid' ? null : deps.readRunState(p.id);
|
|
27
|
+
return run && { at: run.at, state: run.state, error: run.error };
|
|
28
|
+
};
|
|
29
|
+
const toRow = (p, eff, s, deps) => ({
|
|
30
|
+
id: p.id,
|
|
31
|
+
package: p.pkg,
|
|
32
|
+
mode: p.source === 'dev' ? 'dev' : 'installed',
|
|
33
|
+
wanted: eff.install[p.pkg] ?? null,
|
|
34
|
+
installed: p.version,
|
|
35
|
+
state: s.state,
|
|
36
|
+
error: s.error,
|
|
37
|
+
commands: p.commands,
|
|
38
|
+
lastRun: lastRunOf(p, s, deps),
|
|
39
|
+
});
|
|
40
|
+
const UNKNOWN = { state: 'invalid', error: null };
|
|
41
|
+
// Reads manifests, profile and state files; writes only a missing machine.json.
|
|
42
|
+
export const gatherPluginStatus = async (deps = defaultDeps) => {
|
|
43
|
+
const machine = await deps.loadMachine();
|
|
44
|
+
const parsed = deps.readProfile();
|
|
45
|
+
const eff = effective(parsed?.ok ? parsed.profile : null, machine);
|
|
46
|
+
const names = Object.keys(eff.install);
|
|
47
|
+
const found = deps.discover({ pluginsDir: deps.pluginsDir(), names, dev: eff.dev });
|
|
48
|
+
const states = staticStates(found.plugins, eff.off);
|
|
49
|
+
const plugins = found.plugins
|
|
50
|
+
.map((p) => toRow(p, eff, states.get(p.pkg) ?? UNKNOWN, deps))
|
|
51
|
+
.sort((a, b) => (a.package < b.package ? -1 : 1));
|
|
52
|
+
const status = {
|
|
53
|
+
machine: { id: machine.id, name: machine.name },
|
|
54
|
+
profile: profileStatus(parsed, machine),
|
|
55
|
+
plugins,
|
|
56
|
+
};
|
|
57
|
+
return { status, warnings: [...(parsed?.ok ? parsed.warnings : []), ...found.warnings] };
|
|
58
|
+
};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { type Command } from 'commander';
|
|
2
2
|
import { type StatusReport } from '../../lib/status/status-info.js';
|
|
3
|
+
import { type PluginsGather } from '../plugins/status-rows.js';
|
|
3
4
|
export declare const printStatus: (report: StatusReport) => void;
|
|
4
5
|
export interface StatusOptions {
|
|
5
6
|
json?: boolean;
|
|
6
7
|
token?: string;
|
|
7
8
|
}
|
|
8
|
-
export
|
|
9
|
+
export interface StatusDeps {
|
|
10
|
+
gather: (opts: StatusOptions) => Promise<StatusReport>;
|
|
11
|
+
plugins: () => Promise<PluginsGather | null>;
|
|
12
|
+
}
|
|
13
|
+
export declare const runStatus: (opts?: StatusOptions, deps?: StatusDeps) => Promise<void>;
|
|
9
14
|
export declare const registerStatus: (program: Command, action: (opts: StatusOptions) => Promise<void>) => void;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import {} from 'commander';
|
|
3
|
+
import { pluginsEnabled } from '../../core/flag.js';
|
|
3
4
|
import { resolveAuth } from '../../lib/auth/credentials.js';
|
|
4
5
|
import { siteFqdn } from '../../lib/net/origins.js';
|
|
5
6
|
import { gatherStatus } from '../../lib/status/status-info.js';
|
|
6
7
|
import { INSTALL_HINT } from '../../lib/update/update-check.js';
|
|
8
|
+
import { pluginStatusLines } from '../plugins/status-print.js';
|
|
9
|
+
import { gatherPluginStatus } from '../plugins/status-rows.js';
|
|
7
10
|
const mark = (good) => (good ? chalk.green('✓') : chalk.red('✗'));
|
|
8
11
|
const row = (label, value) => {
|
|
9
12
|
console.log(`${label.padEnd(10)} ${value}`);
|
|
@@ -47,19 +50,60 @@ export const printStatus = (report) => {
|
|
|
47
50
|
if (report.update.message)
|
|
48
51
|
console.log(chalk.yellow(`\n${report.update.message}`));
|
|
49
52
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const defaultDeps = {
|
|
54
|
+
gather: (opts) => gatherStatus(resolveAuth({ token: opts.token }), siteFqdn()),
|
|
55
|
+
plugins: () => (pluginsEnabled() ? gatherPluginStatus() : Promise.resolve(null)),
|
|
56
|
+
};
|
|
57
|
+
const settle = async (deps) => {
|
|
58
|
+
try {
|
|
59
|
+
return { gathered: await deps.plugins(), error: null };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return { gathered: null, error };
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const emit = (report, gathered, json) => {
|
|
66
|
+
if (json) {
|
|
67
|
+
console.log(JSON.stringify(gathered ? { ...report, ...gathered.status } : report, null, 2));
|
|
54
68
|
return;
|
|
55
69
|
}
|
|
56
70
|
printStatus(report);
|
|
71
|
+
for (const text of gathered ? pluginStatusLines(gathered.status) : [])
|
|
72
|
+
console.log(text);
|
|
73
|
+
};
|
|
74
|
+
const profileFailure = (gathered) => {
|
|
75
|
+
const profile = gathered?.status.profile;
|
|
76
|
+
if (profile?.state !== 'invalid')
|
|
77
|
+
return null;
|
|
78
|
+
return new Error(`profile.json is invalid:\n${profile.errors.map((e) => ` ${e}`).join('\n')}`);
|
|
79
|
+
};
|
|
80
|
+
// The report always prints; a plugin-side failure then fails the step (exit 1) on stderr.
|
|
81
|
+
export const runStatus = async (opts = {}, deps = defaultDeps) => {
|
|
82
|
+
const report = await deps.gather(opts);
|
|
83
|
+
const { gathered, error } = await settle(deps);
|
|
84
|
+
emit(report, gathered, opts.json);
|
|
85
|
+
for (const warning of gathered?.warnings ?? [])
|
|
86
|
+
console.error(chalk.yellow(`warning: ${warning}`));
|
|
87
|
+
if (error)
|
|
88
|
+
throw error;
|
|
89
|
+
const failure = profileFailure(gathered);
|
|
90
|
+
if (failure)
|
|
91
|
+
throw failure;
|
|
57
92
|
};
|
|
93
|
+
const PLUGINS_HELP = `
|
|
94
|
+
Plugins: one row per plugin in the profile or a dev path: id, state (installed, off,
|
|
95
|
+
blocked, invalid, missing), package, installed version, wanted range, contributed commands,
|
|
96
|
+
the last run from state/<id>.json, and the reason when it cannot load. Plugin code is never
|
|
97
|
+
loaded; the only file written is machine.json.`;
|
|
58
98
|
export const registerStatus = (program, action) => {
|
|
59
|
-
program
|
|
99
|
+
const command = program
|
|
60
100
|
.command('status')
|
|
61
|
-
.description(
|
|
101
|
+
.description(pluginsEnabled()
|
|
102
|
+
? 'Show CLI, account, endpoint, machine, profile, and plugin status'
|
|
103
|
+
: 'Show CLI, account, and endpoint status')
|
|
62
104
|
.option('--json', 'machine-readable output')
|
|
63
105
|
.option('--token <token>', 'authenticate with a personal access token (aga_...); or set AGENTAGE_TOKEN')
|
|
64
106
|
.action(action);
|
|
107
|
+
if (pluginsEnabled())
|
|
108
|
+
command.addHelpText('after', PLUGINS_HELP);
|
|
65
109
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type PluginSource = 'tree' | 'dev';
|
|
2
|
+
export interface DiscoveredPlugin {
|
|
3
|
+
pkg: string;
|
|
4
|
+
source: PluginSource;
|
|
5
|
+
dir: string;
|
|
6
|
+
version: string | null;
|
|
7
|
+
id: string | null;
|
|
8
|
+
entry: string | null;
|
|
9
|
+
commands: string[];
|
|
10
|
+
peers: string[];
|
|
11
|
+
optionalPeers: string[];
|
|
12
|
+
problem: string | null;
|
|
13
|
+
missing: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface DiscoverInput {
|
|
16
|
+
pluginsDir: string;
|
|
17
|
+
names: string[];
|
|
18
|
+
dev: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export interface Discovery {
|
|
21
|
+
plugins: DiscoveredPlugin[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface CurrentTree {
|
|
25
|
+
dir: string | null;
|
|
26
|
+
warning: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface DiscoverDeps {
|
|
29
|
+
readText: (path: string) => string | null;
|
|
30
|
+
exists: (path: string) => boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare const currentTree: (pluginsDir: string, deps?: DiscoverDeps) => CurrentTree;
|
|
33
|
+
export declare const discover: (input: DiscoverInput, deps?: DiscoverDeps) => Discovery;
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { manifestInfo, manifestProblem } from './manifest.js';
|
|
5
|
+
const readText = (path) => {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(path, 'utf-8');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const defaultDeps = { readText, exists: existsSync };
|
|
14
|
+
const TREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
const readManifest = (dir, deps) => {
|
|
16
|
+
const raw = deps.readText(join(dir, 'package.json'));
|
|
17
|
+
try {
|
|
18
|
+
const value = raw === null ? null : JSON.parse(raw);
|
|
19
|
+
return isRecord(value) ? value : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// `plugins/current` names the tree; one without its `.complete` marker was never finished.
|
|
26
|
+
export const currentTree = (pluginsDir, deps = defaultDeps) => {
|
|
27
|
+
const name = deps.readText(join(pluginsDir, 'current'))?.trim() ?? '';
|
|
28
|
+
if (name === '')
|
|
29
|
+
return { dir: null, warning: null };
|
|
30
|
+
if (!TREE_NAME.test(name))
|
|
31
|
+
return { dir: null, warning: `plugins/current: bad tree "${name}"` };
|
|
32
|
+
const dir = join(pluginsDir, 'trees', name);
|
|
33
|
+
if (deps.exists(join(dir, '.complete')))
|
|
34
|
+
return { dir, warning: null };
|
|
35
|
+
return { dir: null, warning: `plugins tree "${name}" is incomplete and was ignored` };
|
|
36
|
+
};
|
|
37
|
+
const inside = (dir, entry) => {
|
|
38
|
+
const path = resolve(dir, entry);
|
|
39
|
+
const rel = relative(dir, path);
|
|
40
|
+
return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? null : path;
|
|
41
|
+
};
|
|
42
|
+
const entryProblem = (info, path, deps) => {
|
|
43
|
+
if (info.entry === null)
|
|
44
|
+
return null;
|
|
45
|
+
if (path === null)
|
|
46
|
+
return `entry ${info.entry} is outside the package`;
|
|
47
|
+
return deps.exists(path) ? null : `entry ${info.entry} not found`;
|
|
48
|
+
};
|
|
49
|
+
const blank = (pkg, source, dir) => ({
|
|
50
|
+
pkg,
|
|
51
|
+
source,
|
|
52
|
+
dir,
|
|
53
|
+
version: null,
|
|
54
|
+
id: null,
|
|
55
|
+
entry: null,
|
|
56
|
+
commands: [],
|
|
57
|
+
peers: [],
|
|
58
|
+
optionalPeers: [],
|
|
59
|
+
problem: null,
|
|
60
|
+
missing: false,
|
|
61
|
+
});
|
|
62
|
+
const inspect = (pkg, source, dir, deps) => {
|
|
63
|
+
const base = blank(pkg, source, dir ?? '');
|
|
64
|
+
if (source === 'dev' && !isAbsolute(base.dir))
|
|
65
|
+
return { ...base, problem: 'dev path not absolute' };
|
|
66
|
+
const manifest = dir === null ? null : readManifest(dir, deps);
|
|
67
|
+
if (dir === null || manifest === null)
|
|
68
|
+
return { ...base, missing: true };
|
|
69
|
+
const info = manifestInfo(manifest);
|
|
70
|
+
const entry = info.entry === null ? null : inside(dir, info.entry);
|
|
71
|
+
const problem = manifestProblem(pkg, info) ?? entryProblem(info, entry, deps);
|
|
72
|
+
const { version, id, commands, peers, optionalPeers } = info;
|
|
73
|
+
return { ...base, version, id, entry, commands, peers, optionalPeers, problem };
|
|
74
|
+
};
|
|
75
|
+
// Every claimant of a shared id is invalid; a dev copy of the same package never reaches here.
|
|
76
|
+
const markDuplicates = (plugins) => plugins.map((p) => {
|
|
77
|
+
const others = plugins.filter((o) => o !== p && p.id !== null && o.id === p.id);
|
|
78
|
+
if (others.length === 0 || p.problem)
|
|
79
|
+
return p;
|
|
80
|
+
return {
|
|
81
|
+
...p,
|
|
82
|
+
problem: `duplicate id "${p.id}" (also ${others.map((o) => o.pkg).join(', ')})`,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
// A peer counts as a plugin unless the tree holds it as a plain library without `agentage`.
|
|
86
|
+
const pluginPeers = (p, known, modules, deps) => p.peers.filter((peer) => {
|
|
87
|
+
if (known.has(peer) || modules === null)
|
|
88
|
+
return true;
|
|
89
|
+
const manifest = readManifest(join(modules, peer), deps);
|
|
90
|
+
return manifest === null || isRecord(manifest['agentage']);
|
|
91
|
+
});
|
|
92
|
+
export const discover = (input, deps = defaultDeps) => {
|
|
93
|
+
const tree = currentTree(input.pluginsDir, deps);
|
|
94
|
+
const modules = tree.dir === null ? null : join(tree.dir, 'node_modules');
|
|
95
|
+
const installed = input.names.filter((name) => !(name in input.dev));
|
|
96
|
+
const found = markDuplicates([
|
|
97
|
+
...installed.map((name) => inspect(name, 'tree', modules && join(modules, name), deps)),
|
|
98
|
+
...Object.entries(input.dev).map(([name, path]) => inspect(name, 'dev', path, deps)),
|
|
99
|
+
]);
|
|
100
|
+
const known = new Set(found.map((p) => p.pkg));
|
|
101
|
+
const plugins = found.map((p) => ({ ...p, peers: pluginPeers(p, known, modules, deps) }));
|
|
102
|
+
return { plugins, warnings: tree.warning === null ? [] : [tree.warning] };
|
|
103
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const ID_PATTERN: RegExp;
|
|
2
|
+
export declare const DEFAULT_ENGINE = "^1";
|
|
3
|
+
export declare const RESERVED_IDS: ReadonlySet<string>;
|
|
4
|
+
export interface ManifestInfo {
|
|
5
|
+
name: string | null;
|
|
6
|
+
version: string | null;
|
|
7
|
+
id: string | null;
|
|
8
|
+
engine: string;
|
|
9
|
+
entry: string | null;
|
|
10
|
+
commands: string[];
|
|
11
|
+
peers: string[];
|
|
12
|
+
optionalPeers: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare const entryOf: (pkg: Record<string, unknown>) => string | null;
|
|
15
|
+
export declare const manifestInfo: (pkg: Record<string, unknown>) => ManifestInfo;
|
|
16
|
+
export declare const manifestProblem: (pkg: string, info: ManifestInfo, api?: string) => string | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
2
|
+
import { PLUGIN_API_VERSION } from '../../plugin.js';
|
|
3
|
+
import { isRange, satisfies } from '../semver-range.js';
|
|
4
|
+
export const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
5
|
+
export const DEFAULT_ENGINE = '^1';
|
|
6
|
+
const CORE_COMMANDS = ['setup', 'status', 'update', 'add', 'remove', 'help'];
|
|
7
|
+
const DEVICE_NAMES = ['con', 'prn', 'aux', 'nul'].concat(...Array.from({ length: 10 }, (_, n) => [`com${n}`, `lpt${n}`]));
|
|
8
|
+
export const RESERVED_IDS = new Set([...CORE_COMMANDS, ...DEVICE_NAMES]);
|
|
9
|
+
const str = (value) => (typeof value === 'string' ? value : null);
|
|
10
|
+
const record = (value) => (isRecord(value) ? value : {});
|
|
11
|
+
const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
12
|
+
const fromConditions = (value, depth = 0) => {
|
|
13
|
+
if (typeof value === 'string')
|
|
14
|
+
return value;
|
|
15
|
+
if (!isRecord(value) || depth > 3)
|
|
16
|
+
return null;
|
|
17
|
+
return fromConditions(value['import'], depth + 1) ?? fromConditions(value['default'], depth + 1);
|
|
18
|
+
};
|
|
19
|
+
// Node semantics: a present `exports` field is authoritative, `main` only applies without it.
|
|
20
|
+
export const entryOf = (pkg) => {
|
|
21
|
+
const exp = pkg['exports'];
|
|
22
|
+
if (exp === undefined)
|
|
23
|
+
return str(pkg['main']);
|
|
24
|
+
if (isRecord(exp) && '.' in exp)
|
|
25
|
+
return fromConditions(exp['.']);
|
|
26
|
+
const subpathsOnly = isRecord(exp) && Object.keys(exp).some((key) => key.startsWith('.'));
|
|
27
|
+
return subpathsOnly ? null : fromConditions(exp);
|
|
28
|
+
};
|
|
29
|
+
const optionalPeersOf = (meta) => Object.entries(meta)
|
|
30
|
+
.filter(([, m]) => isRecord(m) && m['optional'] === true)
|
|
31
|
+
.map(([name]) => name);
|
|
32
|
+
export const manifestInfo = (pkg) => {
|
|
33
|
+
const agentage = record(pkg['agentage']);
|
|
34
|
+
return {
|
|
35
|
+
name: str(pkg['name']),
|
|
36
|
+
version: str(pkg['version']),
|
|
37
|
+
id: str(agentage['id']),
|
|
38
|
+
engine: str(record(pkg['engines'])['agentage']) ?? DEFAULT_ENGINE,
|
|
39
|
+
entry: entryOf(pkg),
|
|
40
|
+
commands: strings(record(agentage['contributes'])['commands']),
|
|
41
|
+
peers: Object.keys(record(pkg['peerDependencies'])),
|
|
42
|
+
optionalPeers: optionalPeersOf(record(pkg['peerDependenciesMeta'])),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const idProblem = (id) => {
|
|
46
|
+
if (id === null)
|
|
47
|
+
return 'agentage.id is missing';
|
|
48
|
+
if (!ID_PATTERN.test(id))
|
|
49
|
+
return `agentage.id "${id}" must match [a-z][a-z0-9-]*`;
|
|
50
|
+
return RESERVED_IDS.has(id) ? `agentage.id "${id}" is reserved` : null;
|
|
51
|
+
};
|
|
52
|
+
const engineProblem = (engine, api) => {
|
|
53
|
+
if (!isRange(engine))
|
|
54
|
+
return `engines.agentage "${engine}" is not a supported range`;
|
|
55
|
+
if (satisfies(api, engine))
|
|
56
|
+
return null;
|
|
57
|
+
return `engines.agentage "${engine}" does not match plugin API ${api}`;
|
|
58
|
+
};
|
|
59
|
+
// Static checks only: the manifest is read as data and the entry is never imported.
|
|
60
|
+
export const manifestProblem = (pkg, info, api = PLUGIN_API_VERSION) => {
|
|
61
|
+
if (info.name !== pkg)
|
|
62
|
+
return `package.json name ${JSON.stringify(info.name)} is not ${pkg}`;
|
|
63
|
+
return (idProblem(info.id) ??
|
|
64
|
+
engineProblem(info.engine, api) ??
|
|
65
|
+
(info.entry === null ? 'no entry point (exports or main)' : null));
|
|
66
|
+
};
|
|
@@ -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
|
+
}
|
|
9
|
+
export interface RunStateDeps {
|
|
10
|
+
dir: () => string;
|
|
11
|
+
}
|
|
12
|
+
export declare const readRunState: (id: string, deps?: RunStateDeps) => RunState | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isRecord, readJson } from '../../lib/fs/json-file.js';
|
|
3
|
+
import { stateDir } from '../paths.js';
|
|
4
|
+
import { ID_PATTERN } from './manifest.js';
|
|
5
|
+
export const RUN_STATES = ['ok', 'failed', 'invalid', 'off', 'blocked', 'installed'];
|
|
6
|
+
const defaultDeps = { dir: stateDir };
|
|
7
|
+
const isRunStateName = (value) => RUN_STATES.some((name) => name === value);
|
|
8
|
+
const isRunState = (value) => isRecord(value) &&
|
|
9
|
+
typeof value['at'] === 'string' &&
|
|
10
|
+
typeof value['pid'] === 'number' &&
|
|
11
|
+
isRunStateName(value['state']) &&
|
|
12
|
+
(value['error'] === undefined || value['error'] === null || typeof value['error'] === 'string');
|
|
13
|
+
// Reads the core's own record of the last run; plugin logs are never consulted.
|
|
14
|
+
export const readRunState = (id, deps = defaultDeps) => {
|
|
15
|
+
if (!ID_PATTERN.test(id))
|
|
16
|
+
return null;
|
|
17
|
+
const found = readJson(join(deps.dir(), `${id}.json`), isRunState);
|
|
18
|
+
if (!found)
|
|
19
|
+
return null;
|
|
20
|
+
return { at: found.at, pid: found.pid, state: found.state, error: found.error ?? null };
|
|
21
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DiscoveredPlugin } 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: (plugins: DiscoveredPlugin[], off: string[]) => Map<string, StaticResult>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {} from './discover.js';
|
|
2
|
+
const reaches = (from, target, byPkg, seen) => (byPkg.get(from)?.peers ?? []).some((peer) => {
|
|
3
|
+
if (peer === target)
|
|
4
|
+
return true;
|
|
5
|
+
if (seen.has(peer))
|
|
6
|
+
return false;
|
|
7
|
+
seen.add(peer);
|
|
8
|
+
return reaches(peer, target, byPkg, seen);
|
|
9
|
+
});
|
|
10
|
+
const inCycle = (p, byPkg) => reaches(p.pkg, p.pkg, byPkg, new Set());
|
|
11
|
+
const missingReason = (p) => p.source === 'dev' ? `no package.json at ${p.dir}` : 'not installed in the current tree';
|
|
12
|
+
const ownState = (p, off, byPkg) => {
|
|
13
|
+
if (p.problem)
|
|
14
|
+
return { state: 'invalid', error: p.problem };
|
|
15
|
+
if (p.missing)
|
|
16
|
+
return { state: 'missing', error: missingReason(p) };
|
|
17
|
+
if (inCycle(p, byPkg))
|
|
18
|
+
return { state: 'invalid', error: 'peer dependency cycle' };
|
|
19
|
+
if (p.source === 'tree' && off.includes(p.pkg))
|
|
20
|
+
return { state: 'off', error: null };
|
|
21
|
+
return { state: 'installed', error: null };
|
|
22
|
+
};
|
|
23
|
+
const peerBlock = (p, walk) => {
|
|
24
|
+
for (const peer of p.peers.filter((name) => !p.optionalPeers.includes(name))) {
|
|
25
|
+
const target = walk.byPkg.get(peer);
|
|
26
|
+
if (!target)
|
|
27
|
+
return { state: 'blocked', error: `peer ${peer} is not in the profile` };
|
|
28
|
+
const { state } = resolveState(target, walk);
|
|
29
|
+
if (state !== 'installed')
|
|
30
|
+
return { state: 'blocked', error: `peer ${peer} is ${state}` };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
// Cycles are already invalid, so the recursion through peers always terminates.
|
|
35
|
+
const resolveState = (p, walk) => {
|
|
36
|
+
const cached = walk.done.get(p.pkg);
|
|
37
|
+
if (cached)
|
|
38
|
+
return cached;
|
|
39
|
+
const own = walk.own.get(p.pkg) ?? { state: 'invalid', error: null };
|
|
40
|
+
const result = own.state === 'installed' ? (peerBlock(p, walk) ?? own) : own;
|
|
41
|
+
walk.done.set(p.pkg, result);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
// The states `status` can know without running plugin code; `ok` and `failed` need a load.
|
|
45
|
+
export const staticStates = (plugins, off) => {
|
|
46
|
+
const byPkg = new Map(plugins.map((p) => [p.pkg, p]));
|
|
47
|
+
const own = new Map(plugins.map((p) => [p.pkg, ownState(p, off, byPkg)]));
|
|
48
|
+
const walk = { byPkg, own, done: new Map() };
|
|
49
|
+
for (const p of plugins)
|
|
50
|
+
resolveState(p, walk);
|
|
51
|
+
return walk.done;
|
|
52
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { getConfigDir } from '../lib/fs/config.js';
|
|
3
|
+
export const profilePath = () => join(getConfigDir(), 'profile.json');
|
|
4
|
+
export const machinePath = () => join(getConfigDir(), 'machine.json');
|
|
5
|
+
export const stateDir = () => join(getConfigDir(), 'state');
|
|
6
|
+
export const pluginsDir = () => process.env['AGENTAGE_PLUGINS_DIR'] || join(getConfigDir(), 'plugins');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ProfileSource = 'cloud' | 'file' | 'default';
|
|
2
|
+
export interface MachineFile {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
dev: Record<string, string>;
|
|
6
|
+
profileSource: ProfileSource;
|
|
7
|
+
}
|
|
8
|
+
export interface MachineDeps {
|
|
9
|
+
path: () => string;
|
|
10
|
+
read: (path: string) => unknown;
|
|
11
|
+
write: (path: string, machine: MachineFile) => void;
|
|
12
|
+
lock: <T>(path: string, fn: () => T) => Promise<T>;
|
|
13
|
+
hostname: () => string;
|
|
14
|
+
newId: () => string;
|
|
15
|
+
}
|
|
16
|
+
export declare const MACHINE_ID: RegExp;
|
|
17
|
+
export declare const parseMachine: (raw: unknown) => MachineFile | null;
|
|
18
|
+
export declare const loadMachine: (deps?: MachineDeps) => Promise<MachineFile>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { withFileLock } from '../../lib/fs/file-lock.js';
|
|
6
|
+
import { isRecord, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
7
|
+
import { machinePath } from '../paths.js';
|
|
8
|
+
export const MACHINE_ID = /^m_[0-9a-f]{8}$/;
|
|
9
|
+
const SOURCES = ['cloud', 'file', 'default'];
|
|
10
|
+
const readRaw = (path) => {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const defaultDeps = {
|
|
19
|
+
path: machinePath,
|
|
20
|
+
read: readRaw,
|
|
21
|
+
write: (path, machine) => {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
writeJsonAtomic(path, machine);
|
|
24
|
+
},
|
|
25
|
+
lock: (path, fn) => withFileLock(path, fn),
|
|
26
|
+
hostname: () => hostname(),
|
|
27
|
+
newId: () => `m_${randomBytes(4).toString('hex')}`,
|
|
28
|
+
};
|
|
29
|
+
const isDevMap = (value) => isRecord(value) && Object.values(value).every((path) => typeof path === 'string');
|
|
30
|
+
// Strict: anything but the exact v1 shape is treated as absent, so no old file is migrated.
|
|
31
|
+
export const parseMachine = (raw) => {
|
|
32
|
+
if (!isRecord(raw) || Object.keys(raw).length !== 4)
|
|
33
|
+
return null;
|
|
34
|
+
const { id, name, dev, profileSource } = raw;
|
|
35
|
+
if (typeof id !== 'string' || !MACHINE_ID.test(id))
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof name !== 'string' || name === '')
|
|
38
|
+
return null;
|
|
39
|
+
if (!isDevMap(dev) || typeof profileSource !== 'string' || !SOURCES.includes(profileSource))
|
|
40
|
+
return null;
|
|
41
|
+
return { id, name, dev, profileSource: profileSource };
|
|
42
|
+
};
|
|
43
|
+
// Re-read under the lock so two first runs agree on one id.
|
|
44
|
+
const create = (deps, path) => {
|
|
45
|
+
const fresh = parseMachine(deps.read(path));
|
|
46
|
+
if (fresh)
|
|
47
|
+
return fresh;
|
|
48
|
+
const machine = {
|
|
49
|
+
id: deps.newId(),
|
|
50
|
+
name: deps.hostname(),
|
|
51
|
+
dev: {},
|
|
52
|
+
profileSource: 'default',
|
|
53
|
+
};
|
|
54
|
+
deps.write(path, machine);
|
|
55
|
+
return machine;
|
|
56
|
+
};
|
|
57
|
+
export const loadMachine = async (deps = defaultDeps) => {
|
|
58
|
+
const path = deps.path();
|
|
59
|
+
return parseMachine(deps.read(path)) ?? deps.lock(path, () => create(deps, path));
|
|
60
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type MachineFile } from './machine.js';
|
|
2
|
+
import { type ConfigMap, type Profile } from './schema.js';
|
|
3
|
+
export interface Effective {
|
|
4
|
+
install: Record<string, string>;
|
|
5
|
+
load: string[];
|
|
6
|
+
off: string[];
|
|
7
|
+
dev: Record<string, string>;
|
|
8
|
+
config: ConfigMap;
|
|
9
|
+
}
|
|
10
|
+
export declare const mergeConfig: (shared: ConfigMap, local: ConfigMap) => ConfigMap;
|
|
11
|
+
export declare const effective: (profile: Profile | null, machine: MachineFile) => Effective;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {} from './machine.js';
|
|
2
|
+
import {} from './schema.js';
|
|
3
|
+
// Machine values replace shared ones per key; no deep merge, so arrays are replaced whole.
|
|
4
|
+
export const mergeConfig = (shared, local) => {
|
|
5
|
+
const ids = [...new Set([...Object.keys(shared), ...Object.keys(local)])].sort();
|
|
6
|
+
return Object.fromEntries(ids.map((id) => [id, { ...shared[id], ...local[id] }]));
|
|
7
|
+
};
|
|
8
|
+
export const effective = (profile, machine) => {
|
|
9
|
+
const install = { ...profile?.plugins };
|
|
10
|
+
const self = profile?.machines[machine.id];
|
|
11
|
+
const off = self?.off ?? [];
|
|
12
|
+
const kept = Object.keys(install).filter((name) => !off.includes(name));
|
|
13
|
+
const load = [...new Set([...kept, ...Object.keys(machine.dev)])].sort();
|
|
14
|
+
const config = mergeConfig(profile?.config ?? {}, self?.config ?? {});
|
|
15
|
+
return { install, load, off, dev: { ...machine.dev }, config };
|
|
16
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { profilePath } from '../paths.js';
|
|
3
|
+
import { parseProfile } from './schema.js';
|
|
4
|
+
const readText = (path) => {
|
|
5
|
+
try {
|
|
6
|
+
return readFileSync(path, 'utf-8');
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
if (err.code === 'ENOENT')
|
|
10
|
+
return null;
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const defaultDeps = { path: profilePath, readText };
|
|
15
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
16
|
+
const parseText = (raw) => {
|
|
17
|
+
try {
|
|
18
|
+
return parseProfile(JSON.parse(raw));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ok: false, errors: ['profile.json: not valid JSON'] };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// null only when profile.json is absent; an unreadable or invalid file reports its errors.
|
|
25
|
+
export const readProfile = (deps = defaultDeps) => {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = deps.readText(deps.path());
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ok: false, errors: [`profile.json: ${messageOf(err)}`] };
|
|
32
|
+
}
|
|
33
|
+
return raw === null ? null : parseText(raw);
|
|
34
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const SCHEMA_VERSION = 1;
|
|
2
|
+
export type ConfigMap = Record<string, Record<string, unknown>>;
|
|
3
|
+
export interface MachineEntry {
|
|
4
|
+
off: string[];
|
|
5
|
+
config: ConfigMap;
|
|
6
|
+
extra: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface Profile {
|
|
9
|
+
version: number;
|
|
10
|
+
plugins: Record<string, string>;
|
|
11
|
+
config: ConfigMap;
|
|
12
|
+
machines: Record<string, MachineEntry>;
|
|
13
|
+
extra: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export type ProfileParse = {
|
|
16
|
+
ok: true;
|
|
17
|
+
profile: Profile;
|
|
18
|
+
readOnly: boolean;
|
|
19
|
+
warnings: string[];
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
errors: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare const specProblem: (spec: string) => string | null;
|
|
25
|
+
export declare const parseProfile: (doc: unknown) => ProfileParse;
|
|
26
|
+
export declare const toDocument: (profile: Profile) => Record<string, unknown>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
2
|
+
import { isRange } from '../semver-range.js';
|
|
3
|
+
export const SCHEMA_VERSION = 1;
|
|
4
|
+
const TOP_KEYS = ['version', 'plugins', 'config', 'machines'];
|
|
5
|
+
const MACHINE_KEYS = ['off', 'config'];
|
|
6
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
7
|
+
const LOOPBACK_HOST = /^(?:localhost|.+\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\])$/i;
|
|
8
|
+
const REFUSED_PREFIX = /^(npm|github|file|http):/i;
|
|
9
|
+
const OTHER_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;
|
|
10
|
+
const LOCAL_PATH = /^(?:\.{1,2}[/\\]|~?[/\\])/;
|
|
11
|
+
const EMPTY_MACHINE = { off: [], config: {}, extra: {} };
|
|
12
|
+
const USE_INSTEAD = 'use a semver range or an https: tarball URL';
|
|
13
|
+
const at = (name) => `[${JSON.stringify(name)}]`;
|
|
14
|
+
const fail = (issues, message, fallback) => {
|
|
15
|
+
issues.errors.push(message);
|
|
16
|
+
return fallback;
|
|
17
|
+
};
|
|
18
|
+
const httpsProblem = (spec) => {
|
|
19
|
+
try {
|
|
20
|
+
const host = new URL(spec).hostname;
|
|
21
|
+
return LOOPBACK_HOST.test(host) ? 'loopback URLs are refused' : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'is not a valid https: URL';
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export const specProblem = (spec) => {
|
|
28
|
+
const refused = REFUSED_PREFIX.exec(spec)?.[1];
|
|
29
|
+
if (refused)
|
|
30
|
+
return `${refused.toLowerCase()}: specs are refused; ${USE_INSTEAD}`;
|
|
31
|
+
if (/^https:/i.test(spec))
|
|
32
|
+
return httpsProblem(spec);
|
|
33
|
+
if (LOCAL_PATH.test(spec))
|
|
34
|
+
return 'local paths are dev-only and belong in machine.json';
|
|
35
|
+
if (OTHER_PROTOCOL.test(spec))
|
|
36
|
+
return `unsupported spec; ${USE_INSTEAD}`;
|
|
37
|
+
return isRange(spec) ? null : `is not a semver range; ${USE_INSTEAD}`;
|
|
38
|
+
};
|
|
39
|
+
const pluginProblem = (name, spec) => {
|
|
40
|
+
if (!PACKAGE_NAME.test(name))
|
|
41
|
+
return 'is not a valid package name';
|
|
42
|
+
if (typeof spec !== 'string' || spec.trim() === '')
|
|
43
|
+
return 'must be a non-empty string';
|
|
44
|
+
return specProblem(spec.trim());
|
|
45
|
+
};
|
|
46
|
+
const parsePlugins = (value, issues) => {
|
|
47
|
+
if (value === undefined)
|
|
48
|
+
return {};
|
|
49
|
+
if (!isRecord(value))
|
|
50
|
+
return fail(issues, 'plugins: must be an object', {});
|
|
51
|
+
const plugins = {};
|
|
52
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
53
|
+
const problem = pluginProblem(name, spec);
|
|
54
|
+
if (problem)
|
|
55
|
+
issues.errors.push(`plugins${at(name)}: ${problem}`);
|
|
56
|
+
else
|
|
57
|
+
plugins[name] = String(spec);
|
|
58
|
+
}
|
|
59
|
+
return plugins;
|
|
60
|
+
};
|
|
61
|
+
const parseConfig = (value, path, issues) => {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return {};
|
|
64
|
+
if (!isRecord(value))
|
|
65
|
+
return fail(issues, `${path}: must be an object`, {});
|
|
66
|
+
const config = {};
|
|
67
|
+
for (const [id, slice] of Object.entries(value)) {
|
|
68
|
+
if (isRecord(slice))
|
|
69
|
+
config[id] = slice;
|
|
70
|
+
else
|
|
71
|
+
issues.errors.push(`${path}${at(id)}: must be an object`);
|
|
72
|
+
}
|
|
73
|
+
return config;
|
|
74
|
+
};
|
|
75
|
+
const extraKeys = (doc, known, path, issues) => {
|
|
76
|
+
const extra = Object.fromEntries(Object.entries(doc).filter(([key]) => !known.includes(key)));
|
|
77
|
+
for (const key of Object.keys(extra))
|
|
78
|
+
issues.warnings.push(`${path}: unknown key "${key}" kept`);
|
|
79
|
+
return extra;
|
|
80
|
+
};
|
|
81
|
+
const parseOff = (value, path, issues) => {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return [];
|
|
84
|
+
if (Array.isArray(value) && value.every((name) => typeof name === 'string'))
|
|
85
|
+
return value;
|
|
86
|
+
return fail(issues, `${path}.off: must be an array of package names`, []);
|
|
87
|
+
};
|
|
88
|
+
const parseMachine = (value, path, issues) => {
|
|
89
|
+
if (!isRecord(value))
|
|
90
|
+
return fail(issues, `${path}: must be an object`, EMPTY_MACHINE);
|
|
91
|
+
return {
|
|
92
|
+
off: parseOff(value['off'], path, issues),
|
|
93
|
+
config: parseConfig(value['config'], `${path}.config`, issues),
|
|
94
|
+
extra: extraKeys(value, MACHINE_KEYS, path, issues),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const parseMachines = (value, issues) => {
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return {};
|
|
100
|
+
if (!isRecord(value))
|
|
101
|
+
return fail(issues, 'machines: must be an object', {});
|
|
102
|
+
return Object.fromEntries(Object.entries(value).map(([id, entry]) => [
|
|
103
|
+
id,
|
|
104
|
+
parseMachine(entry, `machines${at(id)}`, issues),
|
|
105
|
+
]));
|
|
106
|
+
};
|
|
107
|
+
const parseVersionField = (value, issues) => {
|
|
108
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 1)
|
|
109
|
+
return value;
|
|
110
|
+
const got = value === undefined ? 'missing' : JSON.stringify(value);
|
|
111
|
+
issues.errors.push(`version: must be ${SCHEMA_VERSION} (got ${got})`);
|
|
112
|
+
return SCHEMA_VERSION;
|
|
113
|
+
};
|
|
114
|
+
const readOnlyWarning = (version) => `version: ${version} is newer than this CLI understands (${SCHEMA_VERSION}); read-only`;
|
|
115
|
+
export const parseProfile = (doc) => {
|
|
116
|
+
if (!isRecord(doc))
|
|
117
|
+
return { ok: false, errors: ['profile.json: must be a JSON object'] };
|
|
118
|
+
const issues = { errors: [], warnings: [] };
|
|
119
|
+
const profile = {
|
|
120
|
+
version: parseVersionField(doc['version'], issues),
|
|
121
|
+
plugins: parsePlugins(doc['plugins'], issues),
|
|
122
|
+
config: parseConfig(doc['config'], 'config', issues),
|
|
123
|
+
machines: parseMachines(doc['machines'], issues),
|
|
124
|
+
extra: extraKeys(doc, TOP_KEYS, 'profile.json', issues),
|
|
125
|
+
};
|
|
126
|
+
if (issues.errors.length > 0)
|
|
127
|
+
return { ok: false, errors: issues.errors };
|
|
128
|
+
const readOnly = profile.version > SCHEMA_VERSION;
|
|
129
|
+
if (readOnly)
|
|
130
|
+
issues.warnings.push(readOnlyWarning(profile.version));
|
|
131
|
+
return { ok: true, profile, readOnly, warnings: issues.warnings };
|
|
132
|
+
};
|
|
133
|
+
// The inverse of parseProfile: unknown keys go back where they were found.
|
|
134
|
+
export const toDocument = (profile) => ({
|
|
135
|
+
version: profile.version,
|
|
136
|
+
plugins: profile.plugins,
|
|
137
|
+
config: profile.config,
|
|
138
|
+
machines: Object.fromEntries(Object.entries(profile.machines).map(([id, m]) => [
|
|
139
|
+
id,
|
|
140
|
+
{ off: m.off, config: m.config, ...m.extra },
|
|
141
|
+
])),
|
|
142
|
+
...profile.extra,
|
|
143
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface Version {
|
|
2
|
+
major: number;
|
|
3
|
+
minor: number;
|
|
4
|
+
patch: number;
|
|
5
|
+
pre: string | null;
|
|
6
|
+
}
|
|
7
|
+
type Op = '<' | '<=' | '>' | '>=' | '=';
|
|
8
|
+
interface Comparator {
|
|
9
|
+
op: Op;
|
|
10
|
+
v: Version;
|
|
11
|
+
}
|
|
12
|
+
export declare const parseVersion: (text: string) => Version | null;
|
|
13
|
+
export declare const parseRange: (range: string) => Comparator[][] | null;
|
|
14
|
+
export declare const isRange: (range: string) => boolean;
|
|
15
|
+
export declare const satisfies: (version: string, range: string) => boolean;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const WILD = /^[xX*]$/;
|
|
2
|
+
const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
3
|
+
const PARTIAL_RE = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
4
|
+
const COMPARATOR_RE = /^(\^|~|>=|<=|>|<|=)?(.+)$/;
|
|
5
|
+
const ver = (major, minor, patch, pre = null) => ({
|
|
6
|
+
major,
|
|
7
|
+
minor,
|
|
8
|
+
patch,
|
|
9
|
+
pre,
|
|
10
|
+
});
|
|
11
|
+
export const parseVersion = (text) => {
|
|
12
|
+
const m = VERSION_RE.exec(text.trim());
|
|
13
|
+
if (!m)
|
|
14
|
+
return null;
|
|
15
|
+
return ver(Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? null);
|
|
16
|
+
};
|
|
17
|
+
const parsePartial = (text) => {
|
|
18
|
+
const m = PARTIAL_RE.exec(text);
|
|
19
|
+
if (!m)
|
|
20
|
+
return null;
|
|
21
|
+
const raw = [m[1], m[2], m[3]];
|
|
22
|
+
const nums = [];
|
|
23
|
+
for (const part of raw) {
|
|
24
|
+
if (part === undefined || WILD.test(part))
|
|
25
|
+
break;
|
|
26
|
+
nums.push(Number(part));
|
|
27
|
+
}
|
|
28
|
+
const concreteAfterWild = raw.slice(nums.length).some((p) => p !== undefined && !WILD.test(p));
|
|
29
|
+
if (concreteAfterWild || (m[4] !== undefined && nums.length < 3))
|
|
30
|
+
return null;
|
|
31
|
+
return { nums, pre: m[4] ?? null };
|
|
32
|
+
};
|
|
33
|
+
const lower = (p) => ver(p.nums[0] ?? 0, p.nums[1] ?? 0, p.nums[2] ?? 0, p.pre);
|
|
34
|
+
// Exclusive upper bound one step above part `i`; the `-0` keeps that version's prereleases out.
|
|
35
|
+
const bump = (p, i) => {
|
|
36
|
+
const [a = 0, b = 0, c = 0] = p.nums;
|
|
37
|
+
if (i <= 0)
|
|
38
|
+
return ver(a + 1, 0, 0, '0');
|
|
39
|
+
return i === 1 ? ver(a, b + 1, 0, '0') : ver(a, b, c + 1, '0');
|
|
40
|
+
};
|
|
41
|
+
const span = (p, i) => [
|
|
42
|
+
{ op: '>=', v: lower(p) },
|
|
43
|
+
{ op: '<', v: bump(p, i) },
|
|
44
|
+
];
|
|
45
|
+
const exact = (p) => p.nums.length === 3 ? [{ op: '=', v: lower(p) }] : span(p, p.nums.length - 1);
|
|
46
|
+
const caretIndex = (nums) => {
|
|
47
|
+
const i = nums.findIndex((n) => n !== 0);
|
|
48
|
+
return i === -1 ? nums.length - 1 : i;
|
|
49
|
+
};
|
|
50
|
+
const bounded = (op, p) => {
|
|
51
|
+
const n = p.nums.length;
|
|
52
|
+
if (op === '>=')
|
|
53
|
+
return [{ op, v: lower(p) }];
|
|
54
|
+
if (op === '>')
|
|
55
|
+
return n === 3 ? [{ op, v: lower(p) }] : [{ op: '>=', v: bump(p, n - 1) }];
|
|
56
|
+
if (op === '<')
|
|
57
|
+
return [{ op, v: n === 3 ? lower(p) : { ...lower(p), pre: '0' } }];
|
|
58
|
+
return n === 3 ? [{ op, v: lower(p) }] : [{ op: '<', v: bump(p, n - 1) }];
|
|
59
|
+
};
|
|
60
|
+
const desugar = (op, p) => {
|
|
61
|
+
if (p.nums.length === 0)
|
|
62
|
+
return op === '>' || op === '<' ? null : [];
|
|
63
|
+
if (op === undefined || op === '=')
|
|
64
|
+
return exact(p);
|
|
65
|
+
if (op === '~')
|
|
66
|
+
return span(p, Math.min(p.nums.length - 1, 1));
|
|
67
|
+
if (op === '^')
|
|
68
|
+
return span(p, caretIndex(p.nums));
|
|
69
|
+
return bounded(op, p);
|
|
70
|
+
};
|
|
71
|
+
const parseComparator = (token) => {
|
|
72
|
+
const m = COMPARATOR_RE.exec(token);
|
|
73
|
+
const partial = m?.[2] === undefined ? null : parsePartial(m[2]);
|
|
74
|
+
return m && partial ? desugar(m[1], partial) : null;
|
|
75
|
+
};
|
|
76
|
+
const parseSet = (set) => {
|
|
77
|
+
const tokens = set
|
|
78
|
+
.replace(/(\^|~|>=|<=|>|<|=)\s+/g, '$1')
|
|
79
|
+
.trim()
|
|
80
|
+
.split(/\s+/);
|
|
81
|
+
if (tokens.length === 0 || tokens[0] === '')
|
|
82
|
+
return null;
|
|
83
|
+
const parsed = tokens.map(parseComparator);
|
|
84
|
+
return parsed.some((c) => c === null) ? null : parsed.flatMap((c) => c ?? []);
|
|
85
|
+
};
|
|
86
|
+
export const parseRange = (range) => {
|
|
87
|
+
const sets = range.split('||').map(parseSet);
|
|
88
|
+
return sets.some((s) => s === null) ? null : sets.map((s) => s ?? []);
|
|
89
|
+
};
|
|
90
|
+
export const isRange = (range) => parseRange(range) !== null;
|
|
91
|
+
// Prereleases sort below their release, compared as plain strings (no identifier splitting).
|
|
92
|
+
const comparePre = (a, b) => {
|
|
93
|
+
if (a === b)
|
|
94
|
+
return 0;
|
|
95
|
+
if (a === null || b === null)
|
|
96
|
+
return a === null ? 1 : -1;
|
|
97
|
+
return a < b ? -1 : 1;
|
|
98
|
+
};
|
|
99
|
+
const compareVersions = (a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch || comparePre(a.pre, b.pre);
|
|
100
|
+
const holds = (c, v) => {
|
|
101
|
+
const d = compareVersions(v, c.v);
|
|
102
|
+
if (c.op === '=')
|
|
103
|
+
return d === 0;
|
|
104
|
+
if (c.op === '<')
|
|
105
|
+
return d < 0;
|
|
106
|
+
if (c.op === '<=')
|
|
107
|
+
return d <= 0;
|
|
108
|
+
return c.op === '>' ? d > 0 : d >= 0;
|
|
109
|
+
};
|
|
110
|
+
export const satisfies = (version, range) => {
|
|
111
|
+
const v = parseVersion(version);
|
|
112
|
+
const sets = parseRange(range);
|
|
113
|
+
if (!v || !sets)
|
|
114
|
+
return false;
|
|
115
|
+
return sets.some((set) => set.every((c) => holds(c, v)));
|
|
116
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The one page the CLI ever serves: the sign-in result, on the design-system dark tokens.
|
|
2
|
+
const LOGO = 'M143.8 33.7a13.2 13.2 0 00-23.6 0l-92.4 184.8a13.2 13.2 0 0015.4 18.6l66-18.9a13.2 13.2 0 ' +
|
|
3
|
+
'009.5-12.7v-60.3a13.2 13.2 0 1126.4 0v60.3a13.2 13.2 0 009.6 12.7l66 18.9a13.2 13.2 0 ' +
|
|
4
|
+
'0015.4-18.6l-92.3-184.8z';
|
|
5
|
+
const STYLE = `
|
|
6
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
7
|
+
html{color-scheme:dark}
|
|
8
|
+
body{background:oklch(0.085 0.013 277);color:oklch(0.97 0.003 277);
|
|
9
|
+
font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
10
|
+
-webkit-font-smoothing:antialiased;min-height:100vh;display:grid;place-items:center;padding:24px}
|
|
11
|
+
.card{background:oklch(0.17 0.018 277);border:1px solid oklch(0.237 0.017 277);border-radius:.75rem;
|
|
12
|
+
padding:40px 32px;width:100%;max-width:400px;text-align:center;
|
|
13
|
+
box-shadow:0 10px 15px -3px oklch(0 0 0/.1),0 4px 6px -4px oklch(0 0 0/.1)}
|
|
14
|
+
.brand{display:inline-flex;align-items:center;gap:10px;margin-bottom:28px;font-weight:600;font-size:1rem}
|
|
15
|
+
.brand svg{width:28px;height:28px}
|
|
16
|
+
.icon{width:48px;height:48px;border-radius:9999px;margin:0 auto 20px;display:grid;place-items:center}
|
|
17
|
+
.ok .icon{background:oklch(0.723 0.191 149.6/.15);color:oklch(0.723 0.191 149.6)}
|
|
18
|
+
.err .icon{background:oklch(0.628 0.208 25.6/.15);color:oklch(0.628 0.208 25.6)}
|
|
19
|
+
h1{font-size:1.25rem;font-weight:600;letter-spacing:-.02em;margin-bottom:8px}
|
|
20
|
+
p{color:oklch(0.59 0.014 256);font-size:.875rem;line-height:1.5}
|
|
21
|
+
.hint{margin-top:24px;font-size:.75rem;color:oklch(0.59 0.014 256)}
|
|
22
|
+
`;
|
|
23
|
+
const ICON = {
|
|
24
|
+
ok: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
25
|
+
err: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
|
26
|
+
};
|
|
27
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
28
|
+
// The error text comes from the authorization server's redirect: never trust it as markup.
|
|
29
|
+
export const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ESCAPES[c] ?? c);
|
|
30
|
+
export const callbackPage = (ok, detail) => {
|
|
31
|
+
const kind = ok ? 'ok' : 'err';
|
|
32
|
+
const title = ok ? 'Signed in' : 'Sign-in failed';
|
|
33
|
+
const hint = ok ? 'You can close this tab.' : 'Run agentage setup again to retry.';
|
|
34
|
+
return (`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
35
|
+
`<title>${title} - agentage</title><style>${STYLE}</style></head><body><main class="card ${kind}">` +
|
|
36
|
+
`<div class="brand"><svg viewBox="0 0 416 416" aria-hidden="true"><rect width="416" height="416" rx="83" fill="#f3a52b"/>` +
|
|
37
|
+
`<g transform="translate(25.7 25.7) scale(1.3812)"><path d="${LOGO}" fill="#000"/></g></svg>agentage</div>` +
|
|
38
|
+
`<div class="icon">${ICON[kind]}</div><h1>${title}</h1><p>${escapeHtml(detail)}</p>` +
|
|
39
|
+
`<p class="hint">${hint}</p></main></body></html>`);
|
|
40
|
+
};
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
|
|
3
|
-
'<body style="font-family:system-ui;display:grid;place-items:center;min-height:80vh">' +
|
|
4
|
-
`<div style="text-align:center"><h1>${ok ? 'Signed in' : 'Sign-in failed'}</h1>` +
|
|
5
|
-
`<p>${detail}</p></div></body></html>`;
|
|
2
|
+
import { callbackPage as page } from './callback-page.js';
|
|
6
3
|
export const startCallbackServer = (expectedState) => new Promise((resolveServer) => {
|
|
7
4
|
let settle = null;
|
|
8
5
|
let result = null;
|