@agentage/cli 0.39.0 → 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 +3 -2
- package/dist/commands/status/status.js +36 -36
- 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 +13 -11
- package/dist/core/loader/discover.js +45 -30
- 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 +1 -3
- package/dist/core/loader/manifest.js +20 -9
- 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 +4 -4
- package/dist/core/loader/state.js +17 -7
- package/dist/core/loader/states.d.ts +2 -2
- package/dist/core/loader/states.js +5 -5
- 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 +5 -1
- package/dist/core/paths.js +5 -1
- 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 +11 -11
- package/dist/core/profile/machine.js +66 -43
- package/dist/core/profile/merge.d.ts +1 -1
- package/dist/core/profile/merge.js +7 -2
- package/dist/core/profile/profile.d.ts +6 -6
- package/dist/core/profile/profile.js +50 -16
- 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 +3 -1
- package/dist/core/profile/schema.js +39 -17
- package/dist/core/semver-range.d.ts +1 -0
- package/dist/core/semver-range.js +8 -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/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/commands/plugins/status-print.d.ts +0 -2
- package/dist/commands/plugins/status-print.js +0 -44
- package/dist/commands/plugins/status-rows.d.ts +0 -51
- package/dist/commands/plugins/status-rows.js +0 -58
- 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,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
|
+
};
|
|
@@ -5,8 +5,8 @@ export interface RunState {
|
|
|
5
5
|
pid: number;
|
|
6
6
|
state: RunStateName;
|
|
7
7
|
error: string | null;
|
|
8
|
+
version: string | null;
|
|
9
|
+
lastOk: string | null;
|
|
8
10
|
}
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
export declare const readRunState: (id: string, deps?: RunStateDeps) => RunState | null;
|
|
11
|
+
export declare const readRunState: (id: string) => RunState | null;
|
|
12
|
+
export declare const writeRunState: (id: string, run: Pick<RunState, "state" | "error" | "version">) => void;
|
|
@@ -1,21 +1,31 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
|
-
import { isRecord, readJson } from '../../lib/fs/json-file.js';
|
|
3
|
+
import { isRecord, readJson, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
3
4
|
import { stateDir } from '../paths.js';
|
|
4
5
|
import { ID_PATTERN } from './manifest.js';
|
|
5
6
|
export const RUN_STATES = ['ok', 'failed', 'invalid', 'off', 'blocked', 'installed'];
|
|
6
|
-
const defaultDeps = { dir: stateDir };
|
|
7
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';
|
|
8
10
|
const isRunState = (value) => isRecord(value) &&
|
|
9
11
|
typeof value['at'] === 'string' &&
|
|
10
12
|
typeof value['pid'] === 'number' &&
|
|
11
13
|
isRunStateName(value['state']) &&
|
|
12
|
-
|
|
13
|
-
// Reads the core's own record of the last run;
|
|
14
|
-
export const readRunState = (id
|
|
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) => {
|
|
15
17
|
if (!ID_PATTERN.test(id))
|
|
16
18
|
return null;
|
|
17
|
-
const found = readJson(join(
|
|
19
|
+
const found = readJson(join(stateDir(), `${id}.json`), isRunState);
|
|
18
20
|
if (!found)
|
|
19
21
|
return null;
|
|
20
|
-
|
|
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);
|
|
21
31
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type DiscoveredExtension } from './discover.js';
|
|
2
2
|
export type StaticState = 'installed' | 'off' | 'blocked' | 'invalid' | 'missing';
|
|
3
3
|
export interface StaticResult {
|
|
4
4
|
state: StaticState;
|
|
5
5
|
error: string | null;
|
|
6
6
|
}
|
|
7
|
-
export declare const staticStates: (
|
|
7
|
+
export declare const staticStates: (extensions: DiscoveredExtension[], off: string[]) => Map<string, StaticResult>;
|
|
@@ -41,12 +41,12 @@ const resolveState = (p, walk) => {
|
|
|
41
41
|
walk.done.set(p.pkg, result);
|
|
42
42
|
return result;
|
|
43
43
|
};
|
|
44
|
-
// The states `status` can know without running
|
|
45
|
-
export const staticStates = (
|
|
46
|
-
const byPkg = new Map(
|
|
47
|
-
const own = new Map(
|
|
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
48
|
const walk = { byPkg, own, done: new Map() };
|
|
49
|
-
for (const p of
|
|
49
|
+
for (const p of extensions)
|
|
50
50
|
resolveState(p, walk);
|
|
51
51
|
return walk.done;
|
|
52
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
|
+
});
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export declare const profilePath: () => string;
|
|
2
2
|
export declare const machinePath: () => string;
|
|
3
3
|
export declare const stateDir: () => string;
|
|
4
|
-
export declare const
|
|
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;
|
package/dist/core/paths.js
CHANGED
|
@@ -3,4 +3,8 @@ import { getConfigDir } from '../lib/fs/config.js';
|
|
|
3
3
|
export const profilePath = () => join(getConfigDir(), 'profile.json');
|
|
4
4
|
export const machinePath = () => join(getConfigDir(), 'machine.json');
|
|
5
5
|
export const stateDir = () => join(getConfigDir(), 'state');
|
|
6
|
-
export const
|
|
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
|
+
};
|
|
@@ -4,15 +4,15 @@ export interface MachineFile {
|
|
|
4
4
|
name: string;
|
|
5
5
|
dev: Record<string, string>;
|
|
6
6
|
profileSource: ProfileSource;
|
|
7
|
+
accountId: string | null;
|
|
8
|
+
profileEtag: string | null;
|
|
7
9
|
}
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export declare const
|
|
17
|
-
export declare const parseMachine: (raw: unknown) => MachineFile | null;
|
|
18
|
-
export declare const loadMachine: (deps?: MachineDeps) => Promise<MachineFile>;
|
|
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;
|
|
@@ -2,59 +2,82 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { withFileLock } from '../../lib/fs/file-lock.js';
|
|
6
7
|
import { isRecord, writeJsonAtomic } from '../../lib/fs/json-file.js';
|
|
7
8
|
import { machinePath } from '../paths.js';
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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')}`,
|
|
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,
|
|
28
17
|
};
|
|
18
|
+
const MACHINE_ID = /^m_[0-9a-f]{8}$/;
|
|
19
|
+
const SOURCES = ['cloud', 'file', 'default'];
|
|
20
|
+
const KEYS = Object.keys(NO_MACHINE).length;
|
|
29
21
|
const isDevMap = (value) => isRecord(value) && Object.values(value).every((path) => typeof path === 'string');
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)
|
|
33
26
|
return null;
|
|
34
|
-
const { id, name, dev, profileSource } = raw;
|
|
27
|
+
const { id, name, dev, profileSource, accountId, profileEtag } = raw;
|
|
35
28
|
if (typeof id !== 'string' || !MACHINE_ID.test(id))
|
|
36
29
|
return null;
|
|
37
|
-
if (typeof name !== 'string' || name === '')
|
|
30
|
+
if (typeof name !== 'string' || name === '' || !isDevMap(dev))
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof profileSource !== 'string' || !SOURCES.includes(profileSource))
|
|
38
33
|
return null;
|
|
39
|
-
if (!
|
|
34
|
+
if (!isTextOrNull(accountId) || !isTextOrNull(profileEtag))
|
|
40
35
|
return null;
|
|
41
|
-
|
|
36
|
+
const source = profileSource;
|
|
37
|
+
return { id, name, dev, profileSource: source, accountId, profileEtag };
|
|
42
38
|
};
|
|
43
|
-
//
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
dev: {},
|
|
52
|
-
profileSource: 'default',
|
|
53
|
-
};
|
|
54
|
-
deps.write(path, machine);
|
|
55
|
-
return machine;
|
|
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
|
+
}
|
|
56
47
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
const save = (machine) => {
|
|
49
|
+
mkdirSync(dirname(machinePath()), { recursive: true, mode: 0o700 });
|
|
50
|
+
writeJsonAtomic(machinePath(), machine);
|
|
51
|
+
return machine;
|
|
60
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`);
|
|
@@ -7,5 +7,5 @@ export interface Effective {
|
|
|
7
7
|
dev: Record<string, string>;
|
|
8
8
|
config: ConfigMap;
|
|
9
9
|
}
|
|
10
|
-
export declare const mergeConfig: (shared: ConfigMap, local: ConfigMap) => ConfigMap;
|
|
11
10
|
export declare const effective: (profile: Profile | null, machine: MachineFile) => Effective;
|
|
11
|
+
export declare const keepMachine: (next: Profile, current: Profile | null, machineId: string) => Profile;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {} from './machine.js';
|
|
2
2
|
import {} from './schema.js';
|
|
3
3
|
// Machine values replace shared ones per key; no deep merge, so arrays are replaced whole.
|
|
4
|
-
|
|
4
|
+
const mergeConfig = (shared, local) => {
|
|
5
5
|
const ids = [...new Set([...Object.keys(shared), ...Object.keys(local)])].sort();
|
|
6
6
|
return Object.fromEntries(ids.map((id) => [id, { ...shared[id], ...local[id] }]));
|
|
7
7
|
};
|
|
8
8
|
export const effective = (profile, machine) => {
|
|
9
|
-
const install = { ...profile?.
|
|
9
|
+
const install = { ...profile?.extensions };
|
|
10
10
|
const self = profile?.machines[machine.id];
|
|
11
11
|
const off = self?.off ?? [];
|
|
12
12
|
const kept = Object.keys(install).filter((name) => !off.includes(name));
|
|
@@ -14,3 +14,8 @@ export const effective = (profile, machine) => {
|
|
|
14
14
|
const config = mergeConfig(profile?.config ?? {}, self?.config ?? {});
|
|
15
15
|
return { install, load, off, dev: { ...machine.dev }, config };
|
|
16
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
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type ProfileParse } from './schema.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export declare const
|
|
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>;
|