@agentage/cli 0.39.0 → 0.41.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.
Files changed (145) hide show
  1. package/README.md +126 -25
  2. package/dist/commands/auth/setup.d.ts +8 -2
  3. package/dist/commands/auth/setup.js +38 -7
  4. package/dist/commands/extension/account.d.ts +5 -0
  5. package/dist/commands/extension/account.js +18 -0
  6. package/dist/commands/extension/build.d.ts +10 -0
  7. package/dist/commands/extension/build.js +27 -0
  8. package/dist/commands/extension/delegate.d.ts +6 -0
  9. package/dist/commands/extension/delegate.js +57 -0
  10. package/dist/commands/extension/doctor.d.ts +13 -0
  11. package/dist/commands/extension/doctor.js +44 -0
  12. package/dist/commands/extension/install-load.d.ts +7 -0
  13. package/dist/commands/extension/install-load.js +42 -0
  14. package/dist/commands/extension/install.d.ts +10 -0
  15. package/dist/commands/extension/install.js +38 -0
  16. package/dist/commands/extension/kit-commands.d.ts +13 -0
  17. package/dist/commands/extension/kit-commands.js +42 -0
  18. package/dist/commands/extension/kit-tool.d.ts +31 -0
  19. package/dist/commands/extension/kit-tool.js +5 -0
  20. package/dist/commands/extension/latest.d.ts +2 -0
  21. package/dist/commands/extension/latest.js +71 -0
  22. package/dist/commands/extension/list.d.ts +22 -0
  23. package/dist/commands/extension/list.js +116 -0
  24. package/dist/commands/extension/plan.d.ts +8 -0
  25. package/dist/commands/extension/plan.js +67 -0
  26. package/dist/commands/extension/remove.d.ts +15 -0
  27. package/dist/commands/extension/remove.js +86 -0
  28. package/dist/commands/extension/row-lines.d.ts +4 -0
  29. package/dist/commands/extension/row-lines.js +68 -0
  30. package/dist/commands/extension/rows.d.ts +58 -0
  31. package/dist/commands/extension/rows.js +67 -0
  32. package/dist/commands/extension/secrets-prompt.d.ts +6 -0
  33. package/dist/commands/extension/secrets-prompt.js +47 -0
  34. package/dist/commands/extension/setup-cloud.d.ts +6 -0
  35. package/dist/commands/extension/setup-cloud.js +98 -0
  36. package/dist/commands/extension/setup.d.ts +5 -0
  37. package/dist/commands/extension/setup.js +68 -0
  38. package/dist/commands/extension/sync.d.ts +22 -0
  39. package/dist/commands/extension/sync.js +81 -0
  40. package/dist/commands/extension/up.d.ts +22 -0
  41. package/dist/commands/extension/up.js +76 -0
  42. package/dist/commands/status/account-status.d.ts +3 -0
  43. package/dist/commands/status/account-status.js +27 -0
  44. package/dist/commands/status/status.d.ts +3 -2
  45. package/dist/commands/status/status.js +36 -36
  46. package/dist/core/installer/compare-lock.d.ts +8 -0
  47. package/dist/core/installer/compare-lock.js +51 -0
  48. package/dist/core/installer/compat.d.ts +20 -0
  49. package/dist/core/installer/compat.js +44 -0
  50. package/dist/core/installer/installer.d.ts +28 -0
  51. package/dist/core/installer/installer.js +87 -0
  52. package/dist/core/installer/latest.d.ts +27 -0
  53. package/dist/core/installer/latest.js +90 -0
  54. package/dist/core/installer/npm.d.ts +11 -0
  55. package/dist/core/installer/npm.js +76 -0
  56. package/dist/core/installer/plan.d.ts +25 -0
  57. package/dist/core/installer/plan.js +52 -0
  58. package/dist/core/installer/resolve.d.ts +26 -0
  59. package/dist/core/installer/resolve.js +140 -0
  60. package/dist/core/installer/tree.d.ts +21 -0
  61. package/dist/core/installer/tree.js +100 -0
  62. package/dist/core/kit-version.d.ts +1 -0
  63. package/dist/core/kit-version.js +15 -0
  64. package/dist/core/loader/config.d.ts +7 -0
  65. package/dist/core/loader/config.js +59 -0
  66. package/dist/core/loader/ctx.d.ts +16 -0
  67. package/dist/core/loader/ctx.js +76 -0
  68. package/dist/core/loader/dev-path.d.ts +1 -0
  69. package/dist/core/loader/dev-path.js +20 -0
  70. package/dist/core/loader/discover.d.ts +17 -11
  71. package/dist/core/loader/discover.js +50 -31
  72. package/dist/core/loader/dispose.d.ts +4 -0
  73. package/dist/core/loader/dispose.js +24 -0
  74. package/dist/core/loader/kit-manifest.d.ts +9 -0
  75. package/dist/core/loader/kit-manifest.js +52 -0
  76. package/dist/core/loader/load.d.ts +14 -0
  77. package/dist/core/loader/load.js +78 -0
  78. package/dist/core/loader/log.d.ts +3 -0
  79. package/dist/core/loader/log.js +23 -0
  80. package/dist/core/loader/manifest.d.ts +8 -4
  81. package/dist/core/loader/manifest.js +54 -12
  82. package/dist/core/loader/order.d.ts +2 -0
  83. package/dist/core/loader/order.js +20 -0
  84. package/dist/core/loader/register.d.ts +19 -0
  85. package/dist/core/loader/register.js +39 -0
  86. package/dist/core/loader/scan.d.ts +18 -0
  87. package/dist/core/loader/scan.js +34 -0
  88. package/dist/core/loader/secrets.d.ts +9 -0
  89. package/dist/core/loader/secrets.js +28 -0
  90. package/dist/core/loader/state.d.ts +4 -4
  91. package/dist/core/loader/state.js +17 -7
  92. package/dist/core/loader/states.d.ts +2 -2
  93. package/dist/core/loader/states.js +5 -5
  94. package/dist/core/loader/timeout.d.ts +1 -0
  95. package/dist/core/loader/timeout.js +8 -0
  96. package/dist/core/loader/use.d.ts +8 -0
  97. package/dist/core/loader/use.js +18 -0
  98. package/dist/core/loader/verbs.d.ts +15 -0
  99. package/dist/core/loader/verbs.js +57 -0
  100. package/dist/core/paths.d.ts +6 -1
  101. package/dist/core/paths.js +6 -1
  102. package/dist/core/profile/cloud.d.ts +53 -0
  103. package/dist/core/profile/cloud.js +109 -0
  104. package/dist/core/profile/edit.d.ts +4 -0
  105. package/dist/core/profile/edit.js +27 -0
  106. package/dist/core/profile/machine.d.ts +11 -11
  107. package/dist/core/profile/machine.js +66 -43
  108. package/dist/core/profile/merge.d.ts +1 -1
  109. package/dist/core/profile/merge.js +7 -2
  110. package/dist/core/profile/profile.d.ts +6 -6
  111. package/dist/core/profile/profile.js +50 -16
  112. package/dist/core/profile/push.d.ts +16 -0
  113. package/dist/core/profile/push.js +49 -0
  114. package/dist/core/profile/register.d.ts +5 -0
  115. package/dist/core/profile/register.js +57 -0
  116. package/dist/core/profile/schema.d.ts +3 -1
  117. package/dist/core/profile/schema.js +39 -17
  118. package/dist/core/semver-range.d.ts +1 -0
  119. package/dist/core/semver-range.js +8 -0
  120. package/dist/extension-test.d.ts +7 -0
  121. package/dist/extension-test.js +49 -0
  122. package/dist/extension.d.ts +83 -0
  123. package/dist/extension.js +6 -0
  124. package/dist/lib/auth/api.d.ts +2 -2
  125. package/dist/lib/auth/api.js +3 -3
  126. package/dist/lib/fs/file-lock.d.ts +3 -1
  127. package/dist/lib/fs/file-lock.js +26 -11
  128. package/dist/lib/fs/json-file.d.ts +1 -0
  129. package/dist/lib/fs/json-file.js +3 -2
  130. package/dist/lib/net/fetch.js +1 -1
  131. package/dist/lib/net/origins.d.ts +1 -0
  132. package/dist/lib/net/origins.js +11 -11
  133. package/dist/lib/npm/npm-cli.d.ts +2 -0
  134. package/dist/lib/npm/npm-cli.js +4 -4
  135. package/dist/program.d.ts +17 -1
  136. package/dist/program.js +61 -11
  137. package/package.json +9 -4
  138. package/dist/commands/plugins/status-print.d.ts +0 -2
  139. package/dist/commands/plugins/status-print.js +0 -44
  140. package/dist/commands/plugins/status-rows.d.ts +0 -51
  141. package/dist/commands/plugins/status-rows.js +0 -58
  142. package/dist/core/flag.d.ts +0 -1
  143. package/dist/core/flag.js +0 -1
  144. package/dist/plugin.d.ts +0 -1
  145. package/dist/plugin.js +0 -2
@@ -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
- export const mergeConfig = (shared, local) => {
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?.plugins };
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 interface ProfileDeps {
3
- path: () => string;
4
- readText: (path: string) => string | null;
5
- }
6
- export declare const readProfile: (deps?: ProfileDeps) => ProfileParse | null;
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>;
@@ -1,6 +1,26 @@
1
- import { readFileSync } from 'node:fs';
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';
2
5
  import { profilePath } from '../paths.js';
3
- import { parseProfile } from './schema.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
+ };
4
24
  const readText = (path) => {
5
25
  try {
6
26
  return readFileSync(path, 'utf-8');
@@ -11,24 +31,38 @@ const readText = (path) => {
11
31
  throw err;
12
32
  }
13
33
  };
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
34
  // null only when profile.json is absent; an unreadable or invalid file reports its errors.
25
- export const readProfile = (deps = defaultDeps) => {
35
+ export const readProfile = () => {
26
36
  let raw;
27
37
  try {
28
- raw = deps.readText(deps.path());
38
+ raw = readText(profilePath());
29
39
  }
30
40
  catch (err) {
31
- return { ok: false, errors: [`profile.json: ${messageOf(err)}`] };
41
+ return { ok: false, errors: [err.message] };
32
42
  }
33
- return raw === null ? null : parseText(raw);
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
+ });
34
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>;
@@ -0,0 +1,57 @@
1
+ import { VERSION } from '../../utils/version.js';
2
+ import { errorCode } from './cloud.js';
3
+ import {} from './machine.js';
4
+ // Fire and forget: never longer than this, never a failure, silent unless AGENTAGE_DEBUG=1.
5
+ const BUDGET_MS = 2_000;
6
+ const NAME_MAX = 128;
7
+ const routeOf = (id) => `/v1/machines/${id}`;
8
+ const debug = (text) => {
9
+ if (process.env['AGENTAGE_DEBUG'] === '1')
10
+ console.error(`machines: ${text}`);
11
+ };
12
+ // A 404's body is read for its code; any other body is dropped unread.
13
+ const answerOf = async (res) => {
14
+ if (res.status === 404)
15
+ return { status: 404, code: await errorCode(res) };
16
+ await res.body?.cancel();
17
+ return { status: res.status, code: null };
18
+ };
19
+ // What the account answered, or null when it did not.
20
+ const send = async (fetch, call, timeoutMs = BUDGET_MS) => {
21
+ const headers = call.body ? { 'content-type': 'application/json' } : {};
22
+ const body = call.body ? JSON.stringify(call.body) : undefined;
23
+ try {
24
+ const answer = await answerOf(await fetch(call.path, { method: call.method, headers, body, timeoutMs }));
25
+ debug(`${call.method} ${call.path} ${answer.status}${answer.code ? ` ${answer.code}` : ''}`);
26
+ return answer;
27
+ }
28
+ catch (err) {
29
+ const reason = err.name === 'TimeoutError' ? 'timed out' : err.message;
30
+ debug(`${call.method} ${call.path} failed: ${reason}`);
31
+ return null;
32
+ }
33
+ };
34
+ const registration = (machine) => ({
35
+ name: machine.name.slice(0, NAME_MAX),
36
+ platform: `${process.platform}-${process.arch}`,
37
+ cliVersion: VERSION,
38
+ state: 'connected',
39
+ });
40
+ // Signed-in setup: create or refresh the record, then mark it seen, both inside one budget.
41
+ export const registerMachine = async (fetch, machine) => {
42
+ const deadline = Date.now() + BUDGET_MS;
43
+ const path = routeOf(machine.id);
44
+ await send(fetch, { method: 'PUT', path, body: registration(machine) });
45
+ await send(fetch, { method: 'POST', path: `${path}/seen` }, Math.max(1, deadline - Date.now()));
46
+ };
47
+ // true when the account knows this machine, false on MACHINE_NOT_FOUND, null when it did not
48
+ // say: any other 404 is an API without the route, as unknown as no answer.
49
+ export const seenMachine = async (fetch, id) => {
50
+ const answer = await send(fetch, { method: 'POST', path: `${routeOf(id)}/seen` });
51
+ if (answer?.code === 'MACHINE_NOT_FOUND')
52
+ return false;
53
+ return answer !== null && answer.status >= 200 && answer.status < 300 ? true : null;
54
+ };
55
+ export const disconnectMachine = async (fetch, id) => {
56
+ await send(fetch, { method: 'PATCH', path: routeOf(id), body: { state: 'disconnected' } });
57
+ };
@@ -7,7 +7,8 @@ export interface MachineEntry {
7
7
  }
8
8
  export interface Profile {
9
9
  version: number;
10
- plugins: Record<string, string>;
10
+ extensions: Record<string, string>;
11
+ registries: Record<string, string>;
11
12
  config: ConfigMap;
12
13
  machines: Record<string, MachineEntry>;
13
14
  extra: Record<string, unknown>;
@@ -21,6 +22,7 @@ export type ProfileParse = {
21
22
  ok: false;
22
23
  errors: string[];
23
24
  };
25
+ export declare const PACKAGE_NAME: RegExp;
24
26
  export declare const specProblem: (spec: string) => string | null;
25
27
  export declare const parseProfile: (doc: unknown) => ProfileParse;
26
28
  export declare const toDocument: (profile: Profile) => Record<string, unknown>;
@@ -1,9 +1,10 @@
1
1
  import { isRecord } from '../../lib/fs/json-file.js';
2
2
  import { isRange } from '../semver-range.js';
3
3
  export const SCHEMA_VERSION = 1;
4
- const TOP_KEYS = ['version', 'plugins', 'config', 'machines'];
4
+ const TOP_KEYS = ['version', 'extensions', 'registries', 'config', 'machines'];
5
5
  const MACHINE_KEYS = ['off', 'config'];
6
- const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
6
+ export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
7
+ const SCOPE = /^@[a-z0-9][a-z0-9._~-]*$/;
7
8
  const LOOPBACK_HOST = /^(?:localhost|.+\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\])$/i;
8
9
  const REFUSED_PREFIX = /^(npm|github|file|http):/i;
9
10
  const OTHER_PROTOCOL = /^[a-z][a-z0-9+.-]*:/i;
@@ -15,15 +16,20 @@ const fail = (issues, message, fallback) => {
15
16
  issues.errors.push(message);
16
17
  return fallback;
17
18
  };
18
- const httpsProblem = (spec) => {
19
+ const urlOf = (text) => {
19
20
  try {
20
- const host = new URL(spec).hostname;
21
- return LOOPBACK_HOST.test(host) ? 'loopback URLs are refused' : null;
21
+ return new URL(text);
22
22
  }
23
23
  catch {
24
- return 'is not a valid https: URL';
24
+ return null;
25
25
  }
26
26
  };
27
+ const httpsProblem = (spec) => {
28
+ const url = urlOf(spec);
29
+ if (url === null)
30
+ return 'is not a valid https: URL';
31
+ return LOOPBACK_HOST.test(url.hostname) ? 'loopback URLs are refused' : null;
32
+ };
27
33
  export const specProblem = (spec) => {
28
34
  const refused = REFUSED_PREFIX.exec(spec)?.[1];
29
35
  if (refused)
@@ -36,27 +42,41 @@ export const specProblem = (spec) => {
36
42
  return `unsupported spec; ${USE_INSTEAD}`;
37
43
  return isRange(spec) ? null : `is not a semver range; ${USE_INSTEAD}`;
38
44
  };
39
- const pluginProblem = (name, spec) => {
45
+ const extensionProblem = (name, spec) => {
40
46
  if (!PACKAGE_NAME.test(name))
41
47
  return 'is not a valid package name';
42
48
  if (typeof spec !== 'string' || spec.trim() === '')
43
49
  return 'must be a non-empty string';
44
50
  return specProblem(spec.trim());
45
51
  };
46
- const parsePlugins = (value, issues) => {
52
+ // Whitespace is refused outright: the URL lands on one line of the tree's .npmrc.
53
+ const registryProblem = (scope, url) => {
54
+ if (!SCOPE.test(scope))
55
+ return 'is not an npm scope (@name)';
56
+ const parsed = typeof url === 'string' && !/\s/.test(url) ? urlOf(url) : null;
57
+ if (parsed && (parsed.username || parsed.password))
58
+ return 'must not carry credentials';
59
+ if (parsed?.protocol === 'https:')
60
+ return null;
61
+ if (parsed?.protocol === 'http:' && LOOPBACK_HOST.test(parsed.hostname))
62
+ return null;
63
+ return 'must be an https: URL, or http: on a loopback host';
64
+ };
65
+ // A map of strings: each entry is checked on its own and reported by its path.
66
+ const parseMap = (value, path, problemOf, issues) => {
47
67
  if (value === undefined)
48
68
  return {};
49
69
  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);
70
+ return fail(issues, `${path}: must be an object`, {});
71
+ const map = {};
72
+ for (const [key, entry] of Object.entries(value)) {
73
+ const problem = problemOf(key, entry);
54
74
  if (problem)
55
- issues.errors.push(`plugins${at(name)}: ${problem}`);
75
+ issues.errors.push(`${path}${at(key)}: ${problem}`);
56
76
  else
57
- plugins[name] = String(spec);
77
+ map[key] = String(entry);
58
78
  }
59
- return plugins;
79
+ return map;
60
80
  };
61
81
  const parseConfig = (value, path, issues) => {
62
82
  if (value === undefined)
@@ -118,7 +138,8 @@ export const parseProfile = (doc) => {
118
138
  const issues = { errors: [], warnings: [] };
119
139
  const profile = {
120
140
  version: parseVersionField(doc['version'], issues),
121
- plugins: parsePlugins(doc['plugins'], issues),
141
+ extensions: parseMap(doc['extensions'], 'extensions', extensionProblem, issues),
142
+ registries: parseMap(doc['registries'], 'registries', registryProblem, issues),
122
143
  config: parseConfig(doc['config'], 'config', issues),
123
144
  machines: parseMachines(doc['machines'], issues),
124
145
  extra: extraKeys(doc, TOP_KEYS, 'profile.json', issues),
@@ -133,7 +154,8 @@ export const parseProfile = (doc) => {
133
154
  // The inverse of parseProfile: unknown keys go back where they were found.
134
155
  export const toDocument = (profile) => ({
135
156
  version: profile.version,
136
- plugins: profile.plugins,
157
+ extensions: profile.extensions,
158
+ ...(Object.keys(profile.registries).length > 0 ? { registries: profile.registries } : {}),
137
159
  config: profile.config,
138
160
  machines: Object.fromEntries(Object.entries(profile.machines).map(([id, m]) => [
139
161
  id,
@@ -13,4 +13,5 @@ export declare const parseVersion: (text: string) => Version | null;
13
13
  export declare const parseRange: (range: string) => Comparator[][] | null;
14
14
  export declare const isRange: (range: string) => boolean;
15
15
  export declare const satisfies: (version: string, range: string) => boolean;
16
+ export declare const newestFirst: (a: string, b: string) => number;
16
17
  export {};
@@ -114,3 +114,11 @@ export const satisfies = (version, range) => {
114
114
  return false;
115
115
  return sets.some((set) => set.every((c) => holds(c, v)));
116
116
  };
117
+ // For sort(): newest version first; text that is not a version goes last.
118
+ export const newestFirst = (a, b) => {
119
+ const x = parseVersion(a);
120
+ const y = parseVersion(b);
121
+ if (x && y)
122
+ return compareVersions(y, x);
123
+ return (x ? -1 : 0) + (y ? 1 : 0);
124
+ };
@@ -0,0 +1,7 @@
1
+ import { Command } from 'commander';
2
+ import { type ExtensionApi, type ExtensionContext } from './extension.js';
3
+ export interface TestContextOverrides extends Partial<Omit<ExtensionContext, 'mode' | 'use'>> {
4
+ use?: Record<string, ExtensionApi>;
5
+ program?: Command;
6
+ }
7
+ export declare const createTestContext: (overrides?: TestContextOverrides) => ExtensionContext;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The `@agentage/cli/extension/test` subpath export: `createTestContext` builds the `ctx` an
3
+ * extension's own unit tests hand to its `register`, over a temp dir, with `use` from fakes.
4
+ */
5
+ import { mkdtempSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { Command } from 'commander';
9
+ import {} from './extension.js';
10
+ const memorySecrets = () => {
11
+ const values = new Map();
12
+ return {
13
+ get: async (key) => values.get(key),
14
+ set: async (key, value) => void values.set(key, value),
15
+ delete: async (key) => void values.delete(key),
16
+ };
17
+ };
18
+ const fakeUse = (fakes) => ((id, options = {}) => {
19
+ const fake = fakes[id];
20
+ if (fake !== undefined || options.optional)
21
+ return fake;
22
+ throw new Error(`use("${id}"): no fake for it in createTestContext({ use })`);
23
+ });
24
+ // A fresh temp dir per context, laid out like the CLI's config dir.
25
+ const folders = (id) => {
26
+ const root = mkdtempSync(join(tmpdir(), 'agentage-test-'));
27
+ return { data: join(root, 'data', id), cache: join(root, 'cache', id) };
28
+ };
29
+ // Same members, in the same order, as the ctx the CLI builds; mode is always 'test'.
30
+ export const createTestContext = (overrides = {}) => {
31
+ const { use = {}, program = new Command('agentage'), config = {}, ...rest } = overrides;
32
+ const id = rest.id ?? 'test';
33
+ const subscriptions = [];
34
+ const ctx = {
35
+ id,
36
+ version: '0.0.0',
37
+ previousVersion: null,
38
+ mode: 'test',
39
+ config: Object.freeze({ ...config }),
40
+ secrets: memorySecrets(),
41
+ ...folders(id),
42
+ log: () => undefined,
43
+ commands: { add: (...registrars) => registrars.forEach((register) => register(program)) },
44
+ use: fakeUse(use),
45
+ subscriptions: { push: (...items) => void subscriptions.push(...items) },
46
+ ...rest,
47
+ };
48
+ return Object.freeze(ctx);
49
+ };
@@ -0,0 +1,83 @@
1
+ import { type Command } from 'commander';
2
+ export declare const EXTENSION_API_VERSION = "1.0.0";
3
+ export type ConfigType = 'string' | 'number' | 'boolean' | 'array' | 'object';
4
+ export interface ConfigurationEntry {
5
+ type: ConfigType;
6
+ default?: unknown;
7
+ description?: string;
8
+ enum?: unknown[];
9
+ secret?: boolean;
10
+ scope?: 'shared' | 'machine';
11
+ }
12
+ export interface ExtensionManifest {
13
+ name: string;
14
+ version: string;
15
+ keywords?: string[];
16
+ engines?: {
17
+ agentage?: string;
18
+ };
19
+ peerDependencies?: Record<string, string>;
20
+ peerDependenciesMeta?: Record<string, {
21
+ optional?: boolean;
22
+ }>;
23
+ agentage: {
24
+ id: string;
25
+ contributes?: {
26
+ commands?: string[];
27
+ };
28
+ configuration?: Record<string, ConfigurationEntry>;
29
+ };
30
+ }
31
+ export interface Disposable {
32
+ dispose: () => void | Promise<void>;
33
+ }
34
+ export type ExtensionApi = Readonly<Record<string, (...args: never[]) => unknown>>;
35
+ export interface ExtensionApis {
36
+ }
37
+ export interface ExtensionUse {
38
+ <K extends keyof ExtensionApis>(id: K, options?: {
39
+ optional?: false;
40
+ }): ExtensionApis[K];
41
+ <K extends keyof ExtensionApis>(id: K, options: {
42
+ optional: true;
43
+ }): ExtensionApis[K] | undefined;
44
+ (id: string, options?: {
45
+ optional?: false;
46
+ }): ExtensionApi;
47
+ (id: string, options: {
48
+ optional: true;
49
+ }): ExtensionApi | undefined;
50
+ }
51
+ export interface ExtensionSecrets {
52
+ get: (key: string) => Promise<string | undefined>;
53
+ set: (key: string, value: string) => Promise<void>;
54
+ delete: (key: string) => Promise<void>;
55
+ }
56
+ export type ExtensionLog = (message: string, fields?: Record<string, unknown>) => void;
57
+ export type CommandRegistrar = (program: Command) => void;
58
+ export interface ExtensionCommands {
59
+ add: (...registrars: CommandRegistrar[]) => void;
60
+ }
61
+ export interface ExtensionSubscriptions {
62
+ push: (...disposables: Disposable[]) => void;
63
+ }
64
+ export type ExtensionMode = 'installed' | 'dev' | 'test';
65
+ export interface ExtensionContext {
66
+ readonly id: string;
67
+ readonly version: string;
68
+ readonly previousVersion: string | null;
69
+ readonly mode: ExtensionMode;
70
+ readonly config: Readonly<Record<string, unknown>>;
71
+ readonly secrets: ExtensionSecrets;
72
+ readonly data: string;
73
+ readonly cache: string;
74
+ readonly log: ExtensionLog;
75
+ readonly commands: ExtensionCommands;
76
+ readonly use: ExtensionUse;
77
+ readonly subscriptions: ExtensionSubscriptions;
78
+ }
79
+ export type Register = (ctx: ExtensionContext) => ExtensionApi | void | Promise<ExtensionApi | void>;
80
+ export interface Extension {
81
+ register: Register;
82
+ }
83
+ export declare const defineExtension: <T extends Extension>(extension: T) => T;
@@ -0,0 +1,6 @@
1
+ // Types for an extension's entry; at runtime an extension gets everything through ctx, never this.
2
+ import {} from 'commander';
3
+ // The extension contract is versioned apart from the CLI, so extensions pin it, not a CLI release.
4
+ export const EXTENSION_API_VERSION = '1.0.0';
5
+ // Identity at runtime: it only gives an entry its types.
6
+ export const defineExtension = (extension) => extension;
@@ -1,8 +1,8 @@
1
1
  import { type AuthState } from '../fs/config.js';
2
- import { type CliRequestInit } from '../net/fetch.js';
2
+ import { type CliFetchOptions, type CliRequestInit } from '../net/fetch.js';
3
3
  import { type Links } from '../net/origins.js';
4
4
  export { AuthRequiredError, TransientAuthError } from './auth-errors.js';
5
5
  export declare const refreshOrThrow: (auth: AuthState, links: Links) => Promise<void>;
6
- export declare const authedFetch: (auth: AuthState, links: Links, url: string, init?: CliRequestInit) => Promise<Response>;
6
+ export declare const authedFetch: (auth: AuthState, links: Links, url: string, init?: CliRequestInit, opts?: CliFetchOptions) => Promise<Response>;
7
7
  export declare const authedJson: <T>(auth: AuthState, links: Links, url: string, init?: CliRequestInit) => Promise<T>;
8
8
  export { introspectToken, type TokenSession } from './introspect.js';
@@ -61,12 +61,12 @@ const withBearer = (auth, init) => ({
61
61
  headers: { ...init.headers, authorization: `Bearer ${auth.tokens.accessToken}` },
62
62
  });
63
63
  // The raw Response after at most one refresh-and-retry on 401; callers branch on the status.
64
- export const authedFetch = async (auth, links, url, init = {}) => {
65
- const res = await cliFetch(url, withBearer(auth, init));
64
+ export const authedFetch = async (auth, links, url, init = {}, opts = {}) => {
65
+ const res = await cliFetch(url, withBearer(auth, init), opts);
66
66
  if (res.status !== 401 || !(await tryRefresh(auth, links)))
67
67
  return res;
68
68
  await res.body?.cancel();
69
- return cliFetch(url, withBearer(auth, init));
69
+ return cliFetch(url, withBearer(auth, init), opts);
70
70
  };
71
71
  export const authedJson = async (auth, links, url, init = {}) => {
72
72
  const method = init.method ?? 'GET';
@@ -2,7 +2,9 @@ export interface LockOptions {
2
2
  ttlMs?: number;
3
3
  hardStaleMs?: number;
4
4
  maxWaitMs?: number;
5
+ onWait?: () => void;
5
6
  }
6
7
  export declare const acquireFileLock: (target: string, now?: number, opts?: LockOptions) => boolean;
7
8
  export declare const releaseFileLock: (target: string) => void;
8
- export declare const withFileLock: <T>(target: string, fn: () => T, opts?: LockOptions) => Promise<T>;
9
+ export declare const refreshFileLock: (target: string, now?: number) => boolean;
10
+ export declare const withFileLock: <T>(target: string, fn: () => T | Promise<T>, opts?: LockOptions) => Promise<T>;
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  // Sized for sub-millisecond config read-modify-writes; the wait outlives the TTL on purpose.
5
5
  const DEFAULTS = { ttlMs: 10_000, hardStaleMs: 60_000, maxWaitMs: 15_000 };
@@ -132,22 +132,37 @@ export const releaseFileLock = (target) => {
132
132
  if (holderOf(path)?.pid === process.pid)
133
133
  unlinkQuiet(path);
134
134
  };
135
+ // Heartbeat for a long holder: a fresh stamp keeps waiters from ever reading it as crashed.
136
+ export const refreshFileLock = (target, now = Date.now()) => {
137
+ const path = lockPath(target);
138
+ if (holderOf(path)?.pid !== process.pid)
139
+ return false;
140
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.new`;
141
+ writeFileSync(tmp, `${process.pid} ${now}`);
142
+ try {
143
+ renameSync(tmp, path);
144
+ }
145
+ finally {
146
+ unlinkQuiet(tmp);
147
+ }
148
+ return true;
149
+ };
135
150
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
136
- // `fn` MUST be synchronous and hold the whole read-modify-write; released in a finally.
151
+ // Held for all of `fn`, sync or async; a holder past hardStaleMs keeps it with refreshFileLock.
137
152
  export const withFileLock = async (target, fn, opts = {}) => {
138
153
  const { maxWaitMs } = limitsOf(opts);
139
154
  const deadline = Date.now() + maxWaitMs;
140
- for (;;) {
141
- if (acquireFileLock(target, Date.now(), opts)) {
142
- try {
143
- return fn();
144
- }
145
- finally {
146
- releaseFileLock(target);
147
- }
148
- }
155
+ for (let tries = 0; !acquireFileLock(target, Date.now(), opts); tries++) {
156
+ if (tries === 0)
157
+ opts.onWait?.();
149
158
  if (Date.now() >= deadline)
150
159
  throw new Error(`could not acquire lock for ${target} within ${maxWaitMs}ms`);
151
160
  await sleep(STEP_MS + Math.floor(Math.random() * STEP_MS));
152
161
  }
162
+ try {
163
+ return await fn();
164
+ }
165
+ finally {
166
+ releaseFileLock(target);
167
+ }
153
168
  };
@@ -1,3 +1,4 @@
1
1
  export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
2
2
  export declare const readJson: <T>(path: string, guard: (value: unknown) => value is T) => T | null;
3
+ export declare const writeTextAtomic: (path: string, text: string, mode?: number) => void;
3
4
  export declare const writeJsonAtomic: (path: string, value: unknown, mode?: number) => void;