@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,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 extension code; `ok` and `failed` need a load.
|
|
45
|
+
export const staticStates = (extensions, off) => {
|
|
46
|
+
const byPkg = new Map(extensions.map((p) => [p.pkg, p]));
|
|
47
|
+
const own = new Map(extensions.map((p) => [p.pkg, ownState(p, off, byPkg)]));
|
|
48
|
+
const walk = { byPkg, own, done: new Map() };
|
|
49
|
+
for (const p of extensions)
|
|
50
|
+
resolveState(p, walk);
|
|
51
|
+
return walk.done;
|
|
52
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const withTimeout: <T>(promise: Promise<T>, ms: number) => Promise<T>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The timer is always cleared; the promise given up on runs on, as loaded code cannot be stopped.
|
|
2
|
+
export const withTimeout = (promise, ms) => {
|
|
3
|
+
let timer;
|
|
4
|
+
const deadline = new Promise((_, reject) => {
|
|
5
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
6
|
+
});
|
|
7
|
+
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
|
|
8
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ExtensionApi, type ExtensionUse } from '../../extension.js';
|
|
2
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
3
|
+
export interface LoadedPeer {
|
|
4
|
+
ext: DiscoveredExtension;
|
|
5
|
+
facade: ExtensionApi;
|
|
6
|
+
}
|
|
7
|
+
export declare const facadeOf: (api: unknown) => ExtensionApi;
|
|
8
|
+
export declare const createUse: (self: DiscoveredExtension, loaded: ReadonlyMap<string, LoadedPeer>) => ExtensionUse;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {} from '../../extension.js';
|
|
2
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
3
|
+
import {} from './discover.js';
|
|
4
|
+
// Only own function members cross, bound to the original, in a frozen object of their own.
|
|
5
|
+
export const facadeOf = (api) => {
|
|
6
|
+
const methods = Object.entries(isRecord(api) ? api : {}).filter((entry) => typeof entry[1] === 'function');
|
|
7
|
+
return Object.freeze(Object.fromEntries(methods.map(([name, fn]) => [name, fn.bind(api)])));
|
|
8
|
+
};
|
|
9
|
+
// `loaded` is keyed by id; only an extension `self` peer-depends on is reachable.
|
|
10
|
+
export const createUse = (self, loaded) => ((id, options = {}) => {
|
|
11
|
+
const peer = loaded.get(id);
|
|
12
|
+
const peers = [...self.peers, ...self.optionalPeers];
|
|
13
|
+
if (peer && peers.includes(peer.ext.pkg))
|
|
14
|
+
return peer.facade;
|
|
15
|
+
if (options.optional)
|
|
16
|
+
return undefined;
|
|
17
|
+
throw new Error(`use("${id}"): not a loaded peer of ${self.id}`);
|
|
18
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const profilePath: () => string;
|
|
2
|
+
export declare const machinePath: () => string;
|
|
3
|
+
export declare const stateDir: () => string;
|
|
4
|
+
export declare const extensionsDir: () => string;
|
|
5
|
+
export declare const secretsPath: () => string;
|
|
6
|
+
export declare const dataDir: (id: string) => string;
|
|
7
|
+
export declare const cacheDir: (id: string) => string;
|
|
8
|
+
export declare const logsDir: () => string;
|
|
@@ -0,0 +1,10 @@
|
|
|
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 extensionsDir = () => process.env['AGENTAGE_EXTENSIONS_DIR'] || join(getConfigDir(), 'extensions');
|
|
7
|
+
export const secretsPath = () => join(getConfigDir(), 'secrets.json');
|
|
8
|
+
export const dataDir = (id) => join(getConfigDir(), 'data', id);
|
|
9
|
+
export const cacheDir = (id) => join(getConfigDir(), 'cache', id);
|
|
10
|
+
export const logsDir = () => join(getConfigDir(), 'logs');
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Profile } from './schema.js';
|
|
2
|
+
export declare const PROFILE_PATH = "/v1/profile";
|
|
3
|
+
export type JsonObject = Record<string, unknown>;
|
|
4
|
+
export interface CloudInit {
|
|
5
|
+
method?: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
body?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export type CloudFetch = (path: string, init?: CloudInit) => Promise<Response>;
|
|
11
|
+
export interface Account {
|
|
12
|
+
fetch: CloudFetch;
|
|
13
|
+
id: () => Promise<string | null>;
|
|
14
|
+
}
|
|
15
|
+
export interface ProfileWrite {
|
|
16
|
+
version: number;
|
|
17
|
+
profile: JsonObject;
|
|
18
|
+
lock: JsonObject | null;
|
|
19
|
+
npmMajor: number | null;
|
|
20
|
+
}
|
|
21
|
+
export interface CloudProfile {
|
|
22
|
+
profile: Profile;
|
|
23
|
+
readOnly: boolean;
|
|
24
|
+
lock: JsonObject | null;
|
|
25
|
+
npmMajor: number | null;
|
|
26
|
+
etag: string | null;
|
|
27
|
+
}
|
|
28
|
+
export type Pull = {
|
|
29
|
+
kind: 'current';
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'found';
|
|
32
|
+
doc: CloudProfile & {
|
|
33
|
+
etag: string;
|
|
34
|
+
};
|
|
35
|
+
} | {
|
|
36
|
+
kind: 'none';
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'unavailable';
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
export type Push = {
|
|
42
|
+
kind: 'stored';
|
|
43
|
+
etag: string;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'conflict';
|
|
46
|
+
current: CloudProfile | null;
|
|
47
|
+
};
|
|
48
|
+
export declare class NotPushedError extends Error {
|
|
49
|
+
}
|
|
50
|
+
export declare const localProfile: (profile: Profile) => CloudProfile;
|
|
51
|
+
export declare const errorCode: (res: Response) => Promise<string | null>;
|
|
52
|
+
export declare const pullProfile: (fetch: CloudFetch, etag: string | null, timeoutMs?: number) => Promise<Pull>;
|
|
53
|
+
export declare const pushProfile: (fetch: CloudFetch, body: ProfileWrite, etag: string | null) => Promise<Push>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { isRecord } from '../../lib/fs/json-file.js';
|
|
2
|
+
import { parseProfile } from './schema.js';
|
|
3
|
+
// One resource per account: the profile, npm's lock and the npm major that wrote it, one ETag.
|
|
4
|
+
export const PROFILE_PATH = '/v1/profile';
|
|
5
|
+
// The account answered a push with something other than a new ETag or a 412.
|
|
6
|
+
export class NotPushedError extends Error {
|
|
7
|
+
}
|
|
8
|
+
// A profile that is not in the account yet: pushing it creates it.
|
|
9
|
+
export const localProfile = (profile) => ({
|
|
10
|
+
profile,
|
|
11
|
+
readOnly: profile.version > 1,
|
|
12
|
+
lock: null,
|
|
13
|
+
npmMajor: null,
|
|
14
|
+
etag: null,
|
|
15
|
+
});
|
|
16
|
+
// What a request ended with: the Response, or why there was none.
|
|
17
|
+
const attempt = async (request) => {
|
|
18
|
+
try {
|
|
19
|
+
return await request();
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return err.name === 'TimeoutError' ? 'timed out' : err.message;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const bodyOf = async (res) => {
|
|
26
|
+
try {
|
|
27
|
+
return (await res.json());
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// The code of the house error envelope, e.g. PROFILE_NOT_FOUND; null without one. Reads the body.
|
|
34
|
+
export const errorCode = async (res) => {
|
|
35
|
+
const body = await bodyOf(res);
|
|
36
|
+
const error = isRecord(body) && isRecord(body['error']) ? body['error'] : {};
|
|
37
|
+
return typeof error['code'] === 'string' ? error['code'] : null;
|
|
38
|
+
};
|
|
39
|
+
// e.g. "503 AUTH_UNAVAILABLE": the status, and the envelope's code when there is one.
|
|
40
|
+
const failureOf = async (res) => {
|
|
41
|
+
const code = await errorCode(res);
|
|
42
|
+
return code === null ? `${res.status}` : `${res.status} ${code}`;
|
|
43
|
+
};
|
|
44
|
+
const isMajor = (value) => value === null || (typeof value === 'number' && Number.isInteger(value));
|
|
45
|
+
// A served resource and its etag, parsed; a string says what is wrong with it.
|
|
46
|
+
const resourceOf = (data, etag) => {
|
|
47
|
+
if (!isRecord(data) || typeof etag !== 'string')
|
|
48
|
+
return 'unexpected answer';
|
|
49
|
+
const { lock, npmMajor } = data;
|
|
50
|
+
if (!(lock === null || isRecord(lock)) || !isMajor(npmMajor))
|
|
51
|
+
return 'unexpected answer';
|
|
52
|
+
const parsed = parseProfile(data['profile']);
|
|
53
|
+
if (!parsed.ok)
|
|
54
|
+
return `the profile in your account is invalid: ${parsed.errors.join('; ')}`;
|
|
55
|
+
return { profile: parsed.profile, readOnly: parsed.readOnly, lock, npmMajor, etag };
|
|
56
|
+
};
|
|
57
|
+
// meta.etag equals the ETag header on a 2xx; either will do.
|
|
58
|
+
const etagOf = (body, header) => {
|
|
59
|
+
const meta = isRecord(body) && isRecord(body['meta']) ? body['meta'] : {};
|
|
60
|
+
return meta['etag'] ?? header;
|
|
61
|
+
};
|
|
62
|
+
const served = (body, header) => resourceOf(isRecord(body) ? body['data'] : null, etagOf(body, header));
|
|
63
|
+
const unavailable = (reason) => ({ kind: 'unavailable', reason });
|
|
64
|
+
const NO_PROFILE = '404 PROFILE_NOT_FOUND';
|
|
65
|
+
// GET, conditional on the local copy's etag: 304 keeps it, 200 replaces it, 404 means none yet.
|
|
66
|
+
export const pullProfile = async (fetch, etag, timeoutMs) => {
|
|
67
|
+
const headers = etag === null ? {} : { 'if-none-match': etag };
|
|
68
|
+
const res = await attempt(() => fetch(PROFILE_PATH, { headers, timeoutMs }));
|
|
69
|
+
if (typeof res === 'string')
|
|
70
|
+
return unavailable(res);
|
|
71
|
+
if (res.status === 304)
|
|
72
|
+
return { kind: 'current' };
|
|
73
|
+
if (res.status !== 200) {
|
|
74
|
+
// Any other 404 is an API without the profile route, not an account without a profile.
|
|
75
|
+
const failure = await failureOf(res);
|
|
76
|
+
return failure === NO_PROFILE ? { kind: 'none' } : unavailable(failure);
|
|
77
|
+
}
|
|
78
|
+
const doc = served(await bodyOf(res), res.headers.get('etag'));
|
|
79
|
+
return typeof doc === 'string' ? unavailable(doc) : { kind: 'found', doc };
|
|
80
|
+
};
|
|
81
|
+
// A 412 carries the account's current resource and etag (both null when it holds none).
|
|
82
|
+
const conflictOf = (body) => {
|
|
83
|
+
const error = isRecord(body) && isRecord(body['error']) ? body['error'] : {};
|
|
84
|
+
const details = isRecord(error['details']) ? error['details'] : {};
|
|
85
|
+
if (details['current'] === null)
|
|
86
|
+
return { kind: 'conflict', current: null };
|
|
87
|
+
const current = resourceOf(details['current'], details['etag']);
|
|
88
|
+
if (typeof current === 'string')
|
|
89
|
+
throw new NotPushedError(`not pushed to your account (412: ${current})`);
|
|
90
|
+
return { kind: 'conflict', current };
|
|
91
|
+
};
|
|
92
|
+
const notPushed = (reason) => new NotPushedError(`not pushed to your account (${reason})`);
|
|
93
|
+
// PUT the whole resource: If-Match replaces the copy it was pulled at, no etag creates it.
|
|
94
|
+
export const pushProfile = async (fetch, body, etag) => {
|
|
95
|
+
const precondition = etag === null ? { 'if-none-match': '*' } : { 'if-match': etag };
|
|
96
|
+
const headers = { 'content-type': 'application/json', ...precondition };
|
|
97
|
+
const init = { method: 'PUT', headers, body: JSON.stringify(body) };
|
|
98
|
+
const res = await attempt(() => fetch(PROFILE_PATH, init));
|
|
99
|
+
if (typeof res === 'string')
|
|
100
|
+
throw notPushed(res);
|
|
101
|
+
if (res.status === 412)
|
|
102
|
+
return conflictOf(await bodyOf(res));
|
|
103
|
+
if (res.status !== 200 && res.status !== 201)
|
|
104
|
+
throw notPushed(await failureOf(res));
|
|
105
|
+
const stored = etagOf(await bodyOf(res), res.headers.get('etag'));
|
|
106
|
+
if (typeof stored !== 'string')
|
|
107
|
+
throw notPushed('unexpected answer');
|
|
108
|
+
return { kind: 'stored', etag: stored };
|
|
109
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Profile } from './schema.js';
|
|
2
|
+
export declare const addExtension: (profile: Profile, name: string, spec: string, machineId: string, off: boolean) => Profile;
|
|
3
|
+
export declare const dropExtension: (profile: Profile, name: string, machineId: string) => Profile;
|
|
4
|
+
export declare const changeProfile: (change: (profile: Profile) => Profile) => Promise<Profile>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadMachine, setProfileSource } from './machine.js';
|
|
2
|
+
import { editableProfile, mutateProfile } from './profile.js';
|
|
3
|
+
import {} from './schema.js';
|
|
4
|
+
const NO_ENTRY = { off: [], config: {}, extra: {} };
|
|
5
|
+
// This machine's off list with `name` in or out; an entry is only created to turn one off.
|
|
6
|
+
const withOff = (profile, machineId, name, off) => {
|
|
7
|
+
const self = profile.machines[machineId];
|
|
8
|
+
if (!self && !off)
|
|
9
|
+
return profile;
|
|
10
|
+
const rest = (self?.off ?? []).filter((pkg) => pkg !== name);
|
|
11
|
+
const entry = { ...NO_ENTRY, ...self, off: off ? [...rest, name] : rest };
|
|
12
|
+
return { ...profile, machines: { ...profile.machines, [machineId]: entry } };
|
|
13
|
+
};
|
|
14
|
+
// Installing a name again replaces its spec; without --off it loads on this machine again.
|
|
15
|
+
export const addExtension = (profile, name, spec, machineId, off) => withOff({ ...profile, extensions: { ...profile.extensions, [name]: spec } }, machineId, name, off);
|
|
16
|
+
// Other machines' off lists are theirs to keep.
|
|
17
|
+
export const dropExtension = (profile, name, machineId) => {
|
|
18
|
+
const extensions = Object.fromEntries(Object.entries(profile.extensions).filter(([pkg]) => pkg !== name));
|
|
19
|
+
return withOff({ ...profile, extensions }, machineId, name, false);
|
|
20
|
+
};
|
|
21
|
+
// A file source stays `file`; any other profile changed here is now this machine's own copy.
|
|
22
|
+
export const changeProfile = async (change) => {
|
|
23
|
+
const next = await mutateProfile(() => change(editableProfile()));
|
|
24
|
+
if ((await loadMachine()).profileSource !== 'file')
|
|
25
|
+
await setProfileSource('default');
|
|
26
|
+
return next;
|
|
27
|
+
};
|
|
@@ -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
|
+
accountId: string | null;
|
|
8
|
+
profileEtag: string | null;
|
|
9
|
+
}
|
|
10
|
+
export declare const NO_MACHINE: MachineFile;
|
|
11
|
+
export declare const readMachine: () => MachineFile | null;
|
|
12
|
+
export declare const loadMachine: () => Promise<MachineFile>;
|
|
13
|
+
export declare const setProfileSource: (source: ProfileSource) => Promise<MachineFile>;
|
|
14
|
+
export declare const setDev: (name: string, path: string | null) => Promise<MachineFile>;
|
|
15
|
+
export declare const setCloud: (etag: string) => Promise<MachineFile>;
|
|
16
|
+
export declare const setAccount: (accountId: string) => Promise<MachineFile>;
|
|
17
|
+
export declare const forgetAccount: () => Promise<MachineFile>;
|
|
18
|
+
export declare const accountMismatch: (machine: MachineFile, userId: string | null) => Error | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { withFileLock } from '../../lib/fs/file-lock.js';
|
|
7
|
+
import { isRecord, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
8
|
+
import { machinePath } from '../paths.js';
|
|
9
|
+
// Without machine.json: no dev paths and no `off` list; reading never creates it.
|
|
10
|
+
export const NO_MACHINE = {
|
|
11
|
+
id: '',
|
|
12
|
+
name: '',
|
|
13
|
+
dev: {},
|
|
14
|
+
profileSource: 'default',
|
|
15
|
+
accountId: null,
|
|
16
|
+
profileEtag: null,
|
|
17
|
+
};
|
|
18
|
+
const MACHINE_ID = /^m_[0-9a-f]{8}$/;
|
|
19
|
+
const SOURCES = ['cloud', 'file', 'default'];
|
|
20
|
+
const KEYS = Object.keys(NO_MACHINE).length;
|
|
21
|
+
const isDevMap = (value) => isRecord(value) && Object.values(value).every((path) => typeof path === 'string');
|
|
22
|
+
const isTextOrNull = (value) => value === null || (typeof value === 'string' && value !== '');
|
|
23
|
+
// Strict: anything but the exact shape is treated as absent, so no old file is migrated.
|
|
24
|
+
const parseMachine = (raw) => {
|
|
25
|
+
if (!isRecord(raw) || Object.keys(raw).length !== KEYS)
|
|
26
|
+
return null;
|
|
27
|
+
const { id, name, dev, profileSource, accountId, profileEtag } = raw;
|
|
28
|
+
if (typeof id !== 'string' || !MACHINE_ID.test(id))
|
|
29
|
+
return null;
|
|
30
|
+
if (typeof name !== 'string' || name === '' || !isDevMap(dev))
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof profileSource !== 'string' || !SOURCES.includes(profileSource))
|
|
33
|
+
return null;
|
|
34
|
+
if (!isTextOrNull(accountId) || !isTextOrNull(profileEtag))
|
|
35
|
+
return null;
|
|
36
|
+
const source = profileSource;
|
|
37
|
+
return { id, name, dev, profileSource: source, accountId, profileEtag };
|
|
38
|
+
};
|
|
39
|
+
// Read only: null when absent or not exactly the current shape, and nothing is written.
|
|
40
|
+
export const readMachine = () => {
|
|
41
|
+
try {
|
|
42
|
+
return parseMachine(JSON.parse(readFileSync(machinePath(), 'utf-8')));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const save = (machine) => {
|
|
49
|
+
mkdirSync(dirname(machinePath()), { recursive: true, mode: 0o700 });
|
|
50
|
+
writeJsonAtomic(machinePath(), machine);
|
|
51
|
+
return machine;
|
|
52
|
+
};
|
|
53
|
+
// Re-read under the lock so two first runs agree on one id.
|
|
54
|
+
const readOrCreate = () => readMachine() ??
|
|
55
|
+
save({ ...NO_MACHINE, id: `m_${randomBytes(4).toString('hex')}`, name: hostname() });
|
|
56
|
+
export const loadMachine = async () => readMachine() ?? withFileLock(machinePath(), readOrCreate);
|
|
57
|
+
// Under the lock; written only when `change` changed something.
|
|
58
|
+
const update = async (change) => withFileLock(machinePath(), () => {
|
|
59
|
+
const machine = readOrCreate();
|
|
60
|
+
const next = change(machine);
|
|
61
|
+
return isDeepStrictEqual(next, machine) ? machine : save(next);
|
|
62
|
+
});
|
|
63
|
+
export const setProfileSource = async (source) => update((machine) => ({ ...machine, profileSource: source }));
|
|
64
|
+
// One dev entry by package name: a real path adds or replaces it, null removes it.
|
|
65
|
+
export const setDev = async (name, path) => update((machine) => {
|
|
66
|
+
const dev = Object.fromEntries(Object.entries(machine.dev).filter(([pkg]) => pkg !== name));
|
|
67
|
+
return { ...machine, dev: path === null ? dev : { ...dev, [name]: path } };
|
|
68
|
+
});
|
|
69
|
+
// After a pull or a push: profile.json mirrors the account's profile at `etag`.
|
|
70
|
+
export const setCloud = async (etag) => update((machine) => ({ ...machine, profileSource: 'cloud', profileEtag: etag }));
|
|
71
|
+
export const setAccount = async (accountId) => update((machine) => ({ ...machine, accountId }));
|
|
72
|
+
// `setup --disconnect`: no account; a copy of the account's profile becomes this machine's own.
|
|
73
|
+
export const forgetAccount = async () => update((machine) => ({
|
|
74
|
+
...machine,
|
|
75
|
+
accountId: null,
|
|
76
|
+
profileEtag: null,
|
|
77
|
+
profileSource: machine.profileSource === 'cloud' ? 'default' : machine.profileSource,
|
|
78
|
+
}));
|
|
79
|
+
// A machine stays with the account it was first set up with until `setup --disconnect`.
|
|
80
|
+
export const accountMismatch = (machine, userId) => machine.accountId === null || userId === null || machine.accountId === userId
|
|
81
|
+
? null
|
|
82
|
+
: new Error(`this machine is connected to account ${machine.accountId}, but you are signed in as ` +
|
|
83
|
+
`${userId}; run agentage setup --disconnect, then agentage setup`);
|
|
@@ -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 effective: (profile: Profile | null, machine: MachineFile) => Effective;
|
|
11
|
+
export declare const keepMachine: (next: Profile, current: Profile | null, machineId: string) => Profile;
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
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?.extensions };
|
|
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
|
+
};
|
|
17
|
+
// `setup --profile` replaces the shared fields; this machine's own `off` and `config` survive.
|
|
18
|
+
export const keepMachine = (next, current, machineId) => {
|
|
19
|
+
const self = current?.machines[machineId];
|
|
20
|
+
return self ? { ...next, machines: { ...next.machines, [machineId]: self } } : next;
|
|
21
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Profile, type ProfileParse } from './schema.js';
|
|
2
|
+
export declare const defaultProfile: () => Profile;
|
|
3
|
+
export declare const parseProfileText: (raw: string) => ProfileParse;
|
|
4
|
+
export declare const readProfile: () => ProfileParse | null;
|
|
5
|
+
export declare const editableProfile: () => Profile;
|
|
6
|
+
export declare const mutateProfile: (fn: (current: Profile | null) => Profile) => Promise<Profile>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { withFileLock } from '../../lib/fs/file-lock.js';
|
|
4
|
+
import { writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
5
|
+
import { profilePath } from '../paths.js';
|
|
6
|
+
import { parseProfile, SCHEMA_VERSION, toDocument, } from './schema.js';
|
|
7
|
+
// No extension is published yet, so a machine without a profile starts with none.
|
|
8
|
+
export const defaultProfile = () => ({
|
|
9
|
+
version: SCHEMA_VERSION,
|
|
10
|
+
extensions: {},
|
|
11
|
+
registries: {},
|
|
12
|
+
config: {},
|
|
13
|
+
machines: {},
|
|
14
|
+
extra: {},
|
|
15
|
+
});
|
|
16
|
+
export const parseProfileText = (raw) => {
|
|
17
|
+
try {
|
|
18
|
+
return parseProfile(JSON.parse(raw));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ok: false, errors: ['not valid JSON'] };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const readText = (path) => {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(path, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === 'ENOENT')
|
|
30
|
+
return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
// null only when profile.json is absent; an unreadable or invalid file reports its errors.
|
|
35
|
+
export const readProfile = () => {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = readText(profilePath());
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return { ok: false, errors: [err.message] };
|
|
42
|
+
}
|
|
43
|
+
return raw === null ? null : parseProfileText(raw);
|
|
44
|
+
};
|
|
45
|
+
const listed = (errors) => errors.map((e) => ` ${e}`).join('\n');
|
|
46
|
+
// What `extension install` and `remove` change: the default when absent; never an invalid or newer file.
|
|
47
|
+
export const editableProfile = () => {
|
|
48
|
+
const parsed = readProfile();
|
|
49
|
+
if (parsed === null)
|
|
50
|
+
return defaultProfile();
|
|
51
|
+
if (!parsed.ok)
|
|
52
|
+
throw new Error(`profile.json is invalid:\n${listed(parsed.errors)}`);
|
|
53
|
+
if (!parsed.readOnly)
|
|
54
|
+
return parsed.profile;
|
|
55
|
+
const { version } = parsed.profile;
|
|
56
|
+
throw new Error(`profile.json is version ${version}, newer than this CLI understands; it was not changed`);
|
|
57
|
+
};
|
|
58
|
+
// Re-read under the lock: `fn` sees the newest local copy (null when absent or invalid).
|
|
59
|
+
export const mutateProfile = async (fn) => {
|
|
60
|
+
const path = profilePath();
|
|
61
|
+
return withFileLock(path, () => {
|
|
62
|
+
const current = readProfile();
|
|
63
|
+
const next = fn(current?.ok ? current.profile : null);
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
65
|
+
writeJsonAtomic(path, toDocument(next));
|
|
66
|
+
return next;
|
|
67
|
+
});
|
|
68
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CloudFetch, type CloudProfile, type JsonObject, type ProfileWrite } from './cloud.js';
|
|
2
|
+
import { type Profile } from './schema.js';
|
|
3
|
+
export type Apply = (base: CloudProfile) => Promise<ProfileWrite>;
|
|
4
|
+
export interface Pushed {
|
|
5
|
+
etag: string;
|
|
6
|
+
pushed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface LockPair {
|
|
9
|
+
lock: JsonObject | null;
|
|
10
|
+
npmMajor: number | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ReplayOptions {
|
|
13
|
+
lockCounts?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare const pushBody: (next: Profile, base: CloudProfile, local: LockPair) => ProfileWrite;
|
|
16
|
+
export declare const pushWithReplay: (fetch: CloudFetch, base: CloudProfile, apply: Apply, { lockCounts }?: ReplayOptions) => Promise<Pushed>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { pushProfile, } from './cloud.js';
|
|
3
|
+
import { toDocument } from './schema.js';
|
|
4
|
+
// A lock from another npm major than the account's is not pushed: the account keeps its own.
|
|
5
|
+
export const pushBody = (next, base, local) => {
|
|
6
|
+
const other = base.npmMajor !== null && local.npmMajor !== null;
|
|
7
|
+
const keep = other && base.npmMajor !== local.npmMajor;
|
|
8
|
+
const pair = keep ? { lock: base.lock, npmMajor: base.npmMajor } : local;
|
|
9
|
+
return { version: next.version, profile: toDocument(next), ...pair };
|
|
10
|
+
};
|
|
11
|
+
const refuseNewer = (base) => {
|
|
12
|
+
if (!base.readOnly)
|
|
13
|
+
return;
|
|
14
|
+
const newer = `version ${base.profile.version}, newer than this CLI understands`;
|
|
15
|
+
throw new Error(`the profile in your account is ${newer}; it was not changed`);
|
|
16
|
+
};
|
|
17
|
+
// The account holds this already: the same profile and, when the lock counts, the same lock.
|
|
18
|
+
const held = (body, base, lockCounts) => base.etag !== null &&
|
|
19
|
+
isDeepStrictEqual(body.profile, toDocument(base.profile)) &&
|
|
20
|
+
(!lockCounts || isDeepStrictEqual(body.lock, base.lock));
|
|
21
|
+
const attemptOn = async (fetch, base, apply, lockCounts) => {
|
|
22
|
+
refuseNewer(base);
|
|
23
|
+
const body = await apply(base);
|
|
24
|
+
if (held(body, base, lockCounts))
|
|
25
|
+
return { kind: 'done', etag: base.etag, pushed: false };
|
|
26
|
+
const res = await pushProfile(fetch, body, base.etag);
|
|
27
|
+
if (res.kind === 'stored')
|
|
28
|
+
return { kind: 'done', etag: res.etag, pushed: true };
|
|
29
|
+
return { kind: 'conflict', body, current: res.current };
|
|
30
|
+
};
|
|
31
|
+
const shown = (doc) => JSON.stringify(doc, null, 2);
|
|
32
|
+
const conflictError = (body, current) => {
|
|
33
|
+
const theirs = current
|
|
34
|
+
? `in your account (etag ${current.etag}):\n${shown(toDocument(current.profile))}`
|
|
35
|
+
: 'in your account: none';
|
|
36
|
+
return new Error('not pushed: the profile in your account changed again while this change was pushed\n' +
|
|
37
|
+
`yours:\n${shown(body.profile)}\n${theirs}`);
|
|
38
|
+
};
|
|
39
|
+
// On a 412 the change is replayed once on the account's document; a second 412 shows both.
|
|
40
|
+
export const pushWithReplay = async (fetch, base, apply, { lockCounts = false } = {}) => {
|
|
41
|
+
const first = await attemptOn(fetch, base, apply, lockCounts);
|
|
42
|
+
if (first.kind === 'done')
|
|
43
|
+
return { etag: first.etag, pushed: first.pushed };
|
|
44
|
+
const retry = first.current ?? { ...base, etag: null };
|
|
45
|
+
const second = await attemptOn(fetch, retry, apply, lockCounts);
|
|
46
|
+
if (second.kind === 'done')
|
|
47
|
+
return { etag: second.etag, pushed: second.pushed };
|
|
48
|
+
throw conflictError(second.body, second.current);
|
|
49
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CloudFetch } from './cloud.js';
|
|
2
|
+
import { type MachineFile } from './machine.js';
|
|
3
|
+
export declare const registerMachine: (fetch: CloudFetch, machine: Pick<MachineFile, "id" | "name">) => Promise<void>;
|
|
4
|
+
export declare const seenMachine: (fetch: CloudFetch, id: string) => Promise<boolean | null>;
|
|
5
|
+
export declare const disconnectMachine: (fetch: CloudFetch, id: string) => Promise<void>;
|