@north-light/api 0.3.588 → 0.3.590

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 (57) hide show
  1. package/dist/api/client.d.ts +4 -1
  2. package/dist/api/client.js +2 -1
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/docs.d.ts +5 -0
  6. package/dist/api/dto/settings.d.ts +21 -0
  7. package/dist/build-root.d.ts +2 -0
  8. package/dist/build-root.js +6 -1
  9. package/dist/commands/pkg/app-installs.d.ts +53 -0
  10. package/dist/commands/pkg/app-installs.js +125 -0
  11. package/dist/commands/pkg/market-inspect.js +4 -0
  12. package/dist/commands/pkg/market-manage.js +6 -0
  13. package/dist/commands/pkg/plugin-inspect.js +6 -2
  14. package/dist/commands/pkg/plugin-manage.js +15 -0
  15. package/dist/core/canvas/schema-version.d.ts +1 -1
  16. package/dist/core/canvas/schema-version.js +1 -1
  17. package/dist/core/config.d.ts +1 -0
  18. package/dist/core/config.js +10 -0
  19. package/dist/core/graph/names.js +7 -6
  20. package/dist/core/graph/package-docs.d.ts +6 -15
  21. package/dist/core/graph/package-docs.js +33 -83
  22. package/dist/core/graph/package-publish.d.ts +60 -0
  23. package/dist/core/graph/package-publish.js +158 -0
  24. package/dist/core/graph/package-reconcile.d.ts +20 -1
  25. package/dist/core/graph/package-reconcile.js +83 -15
  26. package/dist/core/import/export/dump.js +12 -36
  27. package/dist/core/installed-plugins.js +5 -7
  28. package/dist/core/packages/eligibility.d.ts +16 -0
  29. package/dist/core/packages/eligibility.js +34 -0
  30. package/dist/core/packages/launch-kinds.d.ts +6 -0
  31. package/dist/core/packages/launch-kinds.js +20 -0
  32. package/dist/core/packages/source-url.d.ts +3 -0
  33. package/dist/core/packages/source-url.js +17 -0
  34. package/dist/core/permissions/ceiling.d.ts +9 -2
  35. package/dist/core/permissions/ceiling.js +23 -9
  36. package/dist/core/permissions/evaluator.js +1 -1
  37. package/dist/core/providers/bundles.js +2 -3
  38. package/dist/core/runtime/launch-target.d.ts +6 -2
  39. package/dist/core/runtime/launch-target.js +8 -4
  40. package/dist/core/runtime/launch.d.ts +8 -1
  41. package/dist/core/runtime/launch.js +13 -6
  42. package/dist/core/runtime/runner/types.d.ts +7 -1
  43. package/dist/core/runtime/spawn.js +5 -5
  44. package/dist/core/runtime/view-socket.d.ts +2 -1
  45. package/dist/core/runtime/view-socket.js +3 -2
  46. package/dist/core/runtime/warm-pool.js +1 -0
  47. package/dist/core/scoped-state/legacy-profiles.js +1 -1
  48. package/dist/core/scoped-state/person-rows.js +38 -35
  49. package/dist/core/scoped-state/person-store.d.ts +23 -0
  50. package/dist/core/scoped-state/person-store.js +5 -1
  51. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.d.ts +2 -1
  52. package/dist/daemon/reconcilers/node-lifecycle/remote-wake.js +3 -2
  53. package/package.json +1 -1
  54. package/dist/core/graph/package-kinds.d.ts +0 -22
  55. package/dist/core/graph/package-kinds.js +0 -49
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
  57. package/dist/core/scoped-state/materialized-plugins.js +0 -105
@@ -2,10 +2,7 @@
2
2
  // inside one transaction. Only a process that holds the database imports this (crtrd, tests); a CLI or viewer process reaches the
3
3
  // same ops over `PUT /v1/settings`. Importing this module makes the rows the process's settings transport and reloader.
4
4
  import { openDb, withCanvasWrite } from '../canvas/db.js';
5
- import { isCloudRole } from '../canvas/person-state.js';
6
- import { grantedProviderIds, isProviderBundleRow } from '../grants/mirror.js';
7
- import { materializePlugins } from './materialized-plugins.js';
8
- import { revisionOf, setPersonSnapshot, setSettingsReloader, setSettingsTransport, SettingsRevisionConflictError } from './person-store.js';
5
+ import { appRevisionOf, revisionOf, setPersonSnapshot, setSettingsReloader, setSettingsTransport, SettingsRevisionConflictError } from './person-store.js';
9
6
  const s = (v) => (typeof v === 'string' ? v : undefined);
10
7
  export async function readSnapshotFromRows(db, person) {
11
8
  const profileRows = await db.prepare('SELECT * FROM profiles WHERE person = ? AND deleting_since IS NULL ORDER BY created_at, id').all(person);
@@ -34,6 +31,8 @@ export async function readSnapshotFromRows(db, person) {
34
31
  for (const r of await db.prepare('SELECT owner_key, raw_json FROM scope_settings WHERE person = ?').all(person))
35
32
  settings[r.owner_key] = JSON.parse(r.raw_json);
36
33
  const state = {};
34
+ const apps = {};
35
+ const appOf = (owner) => (apps[owner] ??= { plugins: {}, marketplaces: {}, revision: '' });
37
36
  const stateOf = (owner) => (state[owner] ??= { marketplaces: {}, plugins: {}, activeCanvas: null });
38
37
  for (const r of await db.prepare('SELECT owner_key, last_self_check, active_canvas FROM scope_bookkeeping WHERE person = ?').all(person)) {
39
38
  const st = stateOf(r.owner_key);
@@ -44,6 +43,10 @@ export async function readSnapshotFromRows(db, person) {
44
43
  for (const r of await db.prepare('SELECT * FROM scope_marketplaces WHERE person = ? ORDER BY owner_key, name').all(person)) {
45
44
  const owner = String(r['owner_key']);
46
45
  const name = String(r['name']);
46
+ if (owner.startsWith('app:')) {
47
+ appOf(owner).marketplaces[name] = { url: s(r['url']), ref: s(r['ref']) ?? '', installed_at: s(r['installed_at']) ?? '' };
48
+ continue;
49
+ }
47
50
  stateOf(owner).marketplaces[name] = s(r['last_updated']) === undefined ? {} : { last_updated: s(r['last_updated']) };
48
51
  if (s(r['url']) !== undefined) {
49
52
  const raw = (settings[owner] ??= {});
@@ -54,38 +57,26 @@ export async function readSnapshotFromRows(db, person) {
54
57
  for (const r of await db.prepare('SELECT * FROM scope_plugins WHERE person = ? ORDER BY owner_key, name').all(person)) {
55
58
  const owner = String(r['owner_key']);
56
59
  const name = String(r['name']);
60
+ if (owner.startsWith('app:')) {
61
+ appOf(owner).plugins[name] = { enabled: Number(r['enabled']) === 1, ...(s(r['source_marketplace']) === undefined ? {} : { source_marketplace: s(r['source_marketplace']) }), ...(s(r['version']) === undefined ? {} : { version: s(r['version']) }) };
62
+ continue;
63
+ }
57
64
  stateOf(owner).plugins[name] = { ...(s(r['last_updated']) === undefined ? {} : { last_updated: s(r['last_updated']) }), ...(s(r['etag']) === undefined ? {} : { etag: s(r['etag']) }), ...(s(r['checked_at']) === undefined ? {} : { checked_at: s(r['checked_at']) }) };
58
65
  const raw = (settings[owner] ??= {});
59
66
  const block = (raw['plugins'] ??= {});
60
67
  block[name] = { enabled: Number(r['enabled']) === 1, ...(s(r['source_marketplace']) === undefined ? {} : { source_marketplace: s(r['source_marketplace']) }), ...(s(r['version']) === undefined ? {} : { version: s(r['version']) }) };
61
68
  }
62
- return { profiles, defaults, settings, state };
69
+ for (const block of Object.values(apps))
70
+ block.revision = appRevisionOf(block);
71
+ return { profiles, defaults, settings, state, apps };
63
72
  }
64
73
  /** Load (or reload) one person's snapshot from the rows into this process. */
65
74
  export async function loadPersonSettings(person) {
66
75
  const db = await openDb();
67
76
  const snapshot = await readSnapshotFromRows(db, person);
68
- // A cloud-role process has no plugin directories: it installs the person's enabled plugins (D84) before the snapshot is usable, so
69
- // a request that cannot have its plugins fails here instead of reading an empty list.
70
- if (isCloudRole())
71
- await materializePersonPlugins(db, person);
72
77
  setPersonSnapshot(person, snapshot);
73
78
  return snapshot;
74
79
  }
75
- /** Install the person's enabled user-scope plugins from their marketplaces, for the current `plugins_revision`. */
76
- async function materializePersonPlugins(db, person) {
77
- const plugins = await db.prepare(`SELECT p.name, p.version, p.source_path, p.source_marketplace, m.url, m.ref FROM scope_plugins p
78
- LEFT JOIN scope_marketplaces m ON m.person = p.person AND m.owner_key = p.owner_key AND m.name = p.source_marketplace
79
- WHERE p.person = ? AND p.owner_key = 'user' AND p.enabled = 1 ORDER BY p.name`).all(person);
80
- const revisionRow = await db.prepare('SELECT plugins_revision FROM person_settings WHERE person = ?').get(person);
81
- const granted = await grantedProviderIds(person);
82
- const installs = plugins.map((r) => ({
83
- name: String(r['name']), version: s(r['version']) ?? null, sourcePath: s(r['source_path']) ?? null,
84
- marketplace: s(r['source_marketplace']) === undefined ? null : { name: s(r['source_marketplace']), url: s(r['url']) ?? null, ref: s(r['ref']) ?? null },
85
- providerBundle: isProviderBundleRow({ name: String(r['name']), source_marketplace: s(r['source_marketplace']) ?? null, source_path: s(r['source_path']) ?? null }, granted),
86
- }));
87
- await materializePlugins(person, revisionRow === undefined ? 0 : Number(revisionRow.plugins_revision), installs);
88
- }
89
80
  // ------------------------------------------------------------------ writing rows
90
81
  async function currentRaw(db, person, owner) {
91
82
  return (await readSnapshotFromRows(db, person)).settings[owner];
@@ -102,21 +93,21 @@ export async function applyOpsToRows(db, person, ops) {
102
93
  const { plugins, marketplaces, ...rest } = op.raw;
103
94
  const rawJson = JSON.stringify(rest);
104
95
  await db.prepare('INSERT INTO scope_settings (person, owner_key, raw_json) VALUES (?, ?, ?) ON CONFLICT (person, owner_key) DO UPDATE SET raw_json = EXCLUDED.raw_json').run(person, op.owner, rawJson);
105
- const pluginBlock = (plugins ?? {});
106
- const marketBlock = (marketplaces ?? {});
107
96
  const had = JSON.stringify([before?.['plugins'], before?.['marketplaces']]);
108
97
  if (had !== JSON.stringify([plugins, marketplaces]) && !(had === '[null,null]' && plugins === undefined && marketplaces === undefined))
109
98
  pluginsChanged = true;
110
- await db.prepare('DELETE FROM scope_plugins WHERE person = ? AND owner_key = ? AND NOT (name IN (SELECT jsonb_array_elements_text(?::jsonb)))').run(person, op.owner, JSON.stringify(Object.keys(pluginBlock)));
111
- for (const [name, e] of Object.entries(pluginBlock)) {
112
- await db.prepare(`INSERT INTO scope_plugins (person, owner_key, name, enabled, source_marketplace, version) VALUES (?, ?, ?, ?, ?, ?)
113
- ON CONFLICT (person, owner_key, name) DO UPDATE SET enabled = EXCLUDED.enabled, source_marketplace = EXCLUDED.source_marketplace, version = EXCLUDED.version`).run(person, op.owner, name, e.enabled === false ? 0 : 1, e.source_marketplace ?? null, e.version ?? null);
114
- }
115
- await db.prepare('DELETE FROM scope_marketplaces WHERE person = ? AND owner_key = ? AND NOT (name IN (SELECT jsonb_array_elements_text(?::jsonb)))').run(person, op.owner, JSON.stringify(Object.keys(marketBlock)));
116
- for (const [name, e] of Object.entries(marketBlock)) {
117
- await db.prepare(`INSERT INTO scope_marketplaces (person, owner_key, name, url, ref, installed_at) VALUES (?, ?, ?, ?, ?, ?)
118
- ON CONFLICT (person, owner_key, name) DO UPDATE SET url = EXCLUDED.url, ref = EXCLUDED.ref, installed_at = EXCLUDED.installed_at`).run(person, op.owner, name, e.url ?? null, e.ref ?? null, e.installed_at ?? null);
119
- }
99
+ await writeInstallRows(db, person, op.owner, plugins ?? {}, marketplaces ?? {});
100
+ }
101
+ else if (op.op === 'app-plugins.replace') {
102
+ // Serialize writers of one app block (also when the block has no rows yet) before the revision is read, so two overlapping
103
+ // replaces cannot both pass `ifRevision`: the later one waits, re-reads the committed rows and is rejected.
104
+ await db.prepare('SELECT pg_advisory_xact_lock(hashtext(?))').get(`app-plugins|${person}|${op.app}`);
105
+ const before = (await readSnapshotFromRows(db, person)).apps[op.app];
106
+ if (op.ifRevision !== appRevisionOf(before))
107
+ throw new SettingsRevisionConflictError();
108
+ if (appRevisionOf(op) !== appRevisionOf(before))
109
+ pluginsChanged = true;
110
+ await writeInstallRows(db, person, op.app, op.plugins, op.marketplaces);
120
111
  }
121
112
  else if (op.op === 'state.write') {
122
113
  await db.prepare(`INSERT INTO scope_bookkeeping (person, owner_key, last_self_check, active_canvas) VALUES (?, ?, ?, ?)
@@ -158,6 +149,18 @@ export async function applyOpsToRows(db, person, ops) {
158
149
  await db.prepare('INSERT INTO person_settings (person, plugins_revision) VALUES (?, 1) ON CONFLICT (person) DO UPDATE SET plugins_revision = person_settings.plugins_revision + 1').run(person);
159
150
  await db.prepare("SELECT pg_notify('crtr_signal', ?)").get(`settings:${person}`);
160
151
  }
152
+ async function writeInstallRows(db, person, owner, pluginBlock, marketBlock) {
153
+ await db.prepare('DELETE FROM scope_plugins WHERE person = ? AND owner_key = ? AND NOT (name IN (SELECT jsonb_array_elements_text(?::jsonb)))').run(person, owner, JSON.stringify(Object.keys(pluginBlock)));
154
+ for (const [name, e] of Object.entries(pluginBlock)) {
155
+ await db.prepare(`INSERT INTO scope_plugins (person, owner_key, name, enabled, source_marketplace, version) VALUES (?, ?, ?, ?, ?, ?)
156
+ ON CONFLICT (person, owner_key, name) DO UPDATE SET enabled = EXCLUDED.enabled, source_marketplace = EXCLUDED.source_marketplace, version = EXCLUDED.version`).run(person, owner, name, e.enabled === false ? 0 : 1, e.source_marketplace ?? null, e.version ?? null);
157
+ }
158
+ await db.prepare('DELETE FROM scope_marketplaces WHERE person = ? AND owner_key = ? AND NOT (name IN (SELECT jsonb_array_elements_text(?::jsonb)))').run(person, owner, JSON.stringify(Object.keys(marketBlock)));
159
+ for (const [name, e] of Object.entries(marketBlock)) {
160
+ await db.prepare(`INSERT INTO scope_marketplaces (person, owner_key, name, url, ref, installed_at) VALUES (?, ?, ?, ?, ?, ?)
161
+ ON CONFLICT (person, owner_key, name) DO UPDATE SET url = EXCLUDED.url, ref = EXCLUDED.ref, installed_at = EXCLUDED.installed_at`).run(person, owner, name, e.url ?? null, e.ref ?? null, e.installed_at ?? null);
162
+ }
163
+ }
161
164
  async function replaceProjects(db, profile) {
162
165
  await db.prepare('DELETE FROM profile_projects WHERE profile_id = ?').run(profile.profileId);
163
166
  let position = 0;
@@ -10,12 +10,33 @@ export interface PersonSnapshot {
10
10
  defaults: Record<string, string>;
11
11
  settings: Record<string, Record<string, unknown>>;
12
12
  state: Record<string, ScopeState>;
13
+ /** App installs (`app:<id>` → plugins, marketplaces) with a revision covering only that app's block. Cloud roles write plugins here, never to `settings`. */
14
+ apps: Record<string, AppInstallBlock>;
15
+ }
16
+ export interface AppInstallBlock {
17
+ plugins: Record<string, {
18
+ enabled?: boolean;
19
+ source_marketplace?: string;
20
+ version?: string;
21
+ }>;
22
+ marketplaces: Record<string, {
23
+ url?: string;
24
+ ref?: string;
25
+ installed_at?: string;
26
+ }>;
27
+ revision: string;
13
28
  }
14
29
  export type SettingsOp = {
15
30
  op: 'settings.replace';
16
31
  owner: string;
17
32
  raw: Record<string, unknown>;
18
33
  ifRevision: string;
34
+ } | {
35
+ op: 'app-plugins.replace';
36
+ app: string;
37
+ plugins: AppInstallBlock['plugins'];
38
+ marketplaces: AppInstallBlock['marketplaces'];
39
+ ifRevision: string;
19
40
  } | {
20
41
  op: 'state.write';
21
42
  owner: string;
@@ -40,6 +61,8 @@ export declare class PersonSettingsNotLoadedError extends Error {
40
61
  constructor(person: string);
41
62
  }
42
63
  declare const BLOCKS: readonly ["plugins", "marketplaces"];
64
+ /** The revision of an app's install block; `absent` for an app with no rows. */
65
+ export declare function appRevisionOf(block: Pick<AppInstallBlock, 'plugins' | 'marketplaces'> | undefined): string;
43
66
  export declare function revisionOf(raw: Record<string, unknown> | undefined): string;
44
67
  export declare function installPersonSnapshot(person: string, snapshot: PersonSnapshot): void;
45
68
  export declare function heldPersons(): string[];
@@ -13,6 +13,10 @@ export class PersonSettingsNotLoadedError extends Error {
13
13
  }
14
14
  const BLOCKS = ['plugins', 'marketplaces'];
15
15
  const caches = new Map();
16
+ /** The revision of an app's install block; `absent` for an app with no rows. */
17
+ export function appRevisionOf(block) {
18
+ return block === undefined ? 'absent' : revisionOf({ plugins: block.plugins, marketplaces: block.marketplaces });
19
+ }
16
20
  export function revisionOf(raw) {
17
21
  return raw === undefined ? 'absent' : createHash('sha256').update(JSON.stringify(raw)).digest('hex');
18
22
  }
@@ -20,7 +24,7 @@ export function revisionOf(raw) {
20
24
  export function installPersonSnapshot(person, snapshot) { caches.set(person, snapshot); }
21
25
  export function heldPersons() { return [...caches.keys()]; }
22
26
  export function dropPersonSnapshots() { caches.clear(); }
23
- const emptySnapshot = () => ({ profiles: [], defaults: {}, settings: {}, state: {} });
27
+ const emptySnapshot = () => ({ profiles: [], defaults: {}, settings: {}, state: {}, apps: {} });
24
28
  let prelude;
25
29
  /** Before the install's person is resolved (a Mac's first boot reads its config to resolve it), reads answer from this snapshot of
26
30
  * the old local store; `loadPersonSettings` replaces it once the person is known. */
@@ -26,7 +26,8 @@ export declare function imageForWake(environmentId: string): Promise<Environment
26
26
  * a new attempt on the accepted one. `lost` means no broker answered: the execution is ended and the caller starts a new attempt. */
27
27
  export declare function wakeResident(execution: Execution): Promise<'woken' | 'lost'>;
28
28
  /** A `running` remote execution is live while its runner reports the broker process running, frozen or not. When the runner
29
- * reports it gone (or ended non-zero), that exact execution is ended; a runner that cannot tell leaves it as it is. */
29
+ * reports it gone (or ended non-zero), that exact execution is ended; a runner that cannot tell leaves it as it is. An idle
30
+ * execution is asked without touching its workload (`RunnerStatusOptions.idle`), so a frozen sandbox is not woken by the question. */
30
31
  export declare function endRemoteExecutionIfGone(execution: Execution): Promise<boolean>;
31
32
  /** The recovery table for a `launching` Blaxel execution found by a claim after its starter is gone. */
32
33
  export declare function recoverLaunching(nodeId: string, now: number): Promise<void>;
@@ -77,9 +77,10 @@ export async function wakeResident(execution) {
77
77
  return 'woken';
78
78
  }
79
79
  /** A `running` remote execution is live while its runner reports the broker process running, frozen or not. When the runner
80
- * reports it gone (or ended non-zero), that exact execution is ended; a runner that cannot tell leaves it as it is. */
80
+ * reports it gone (or ended non-zero), that exact execution is ended; a runner that cannot tell leaves it as it is. An idle
81
+ * execution is asked without touching its workload (`RunnerStatusOptions.idle`), so a frozen sandbox is not woken by the question. */
81
82
  export async function endRemoteExecutionIfGone(execution) {
82
- const status = await runnerForExecution(execution.runner).status(execution);
83
+ const status = await runnerForExecution(execution.runner).status(execution, { idle: execution.idleAt !== null });
83
84
  if (status.state !== 'gone' && status.state !== 'failed')
84
85
  return false;
85
86
  const ended = await endExecution(execution.executionId, { reason: 'lost' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.588",
3
+ "version": "0.3.590",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -1,22 +0,0 @@
1
- import type { CanvasDb } from '../canvas/db.js';
2
- export interface KindsReport {
3
- /** every kind a valid plugin declares that now has its row, and what upserting it did */
4
- applied: Array<{
5
- plugin: string;
6
- kind: string;
7
- outcome: 'inserted' | 'updated' | 'unchanged';
8
- }>;
9
- /** kinds another owner holds; the plugin's other kinds still applied */
10
- refused: Array<{
11
- plugin: string;
12
- kind: string;
13
- code: 'kind_owned';
14
- message: string;
15
- }>;
16
- /** enabled plugins whose manifest or commands are invalid (no kind update, no token); a provider bundle's kinds reconcile without a token, and plugins whose app handle another owner holds */
17
- invalid: Array<{
18
- plugin: string;
19
- error: string;
20
- }>;
21
- }
22
- export declare function reconcilePackageKinds(db: CanvasDb): Promise<KindsReport>;
@@ -1,49 +0,0 @@
1
- /** Installed plugins' `object_kinds` blocks → `object_kinds` rows. Runs in the caller's write transaction.
2
- * A kind is never deleted and its catalog is never altered when undeclared: only `declared` flips, so the
3
- * instances of a dropped kind go inert instead of vanishing. */
4
- import { CrtrError } from '../errors.js';
5
- import { invalidAppPlugins, kindPlugins } from '../plugin-app.js';
6
- import { upsertKind } from './custom.js';
7
- import { ensureOwner } from './objects.js';
8
- export async function reconcilePackageKinds(db) {
9
- const report = { applied: [], refused: [], invalid: invalidAppPlugins().map((i) => ({ plugin: i.plugin.name, error: i.error })) };
10
- const declared = new Set();
11
- for (const plugin of kindPlugins()) {
12
- const kinds = Object.entries(plugin.manifest.object_kinds ?? {});
13
- if (kinds.length === 0)
14
- continue;
15
- let owner;
16
- try {
17
- owner = await ensureOwner(db, 'app', 'app', `app:${plugin.name}`, plugin.name);
18
- }
19
- catch (error) {
20
- if (!(error instanceof CrtrError) || error.code !== 'conflict')
21
- throw error;
22
- report.invalid.push({ plugin: plugin.name, error: error.message });
23
- continue;
24
- }
25
- for (const [kind, decl] of kinds) {
26
- try {
27
- const result = await upsertKind(db, {
28
- name: kind, owner, description: decl.description, events: decl.events,
29
- read: decl.read === undefined ? null : { command: decl.read.command }, source: 'manifest',
30
- });
31
- if (result.outcome === 'skipped')
32
- continue;
33
- declared.add(kind);
34
- report.applied.push({ plugin: plugin.name, kind, outcome: result.outcome });
35
- }
36
- catch (error) {
37
- if (!(error instanceof CrtrError) || error.code !== 'kind_owned')
38
- throw error;
39
- report.refused.push({ plugin: plugin.name, kind, code: 'kind_owned', message: error.message });
40
- }
41
- }
42
- }
43
- const rows = await db.prepare("SELECT name FROM object_kinds WHERE source = 'manifest' AND declared = 1").all();
44
- for (const { name } of rows) {
45
- if (!declared.has(name))
46
- await db.prepare('UPDATE object_kinds SET declared = 0 WHERE name = ?').run(name);
47
- }
48
- return report;
49
- }
@@ -1,20 +0,0 @@
1
- /** One enabled plugin row of the person's user scope, with its marketplace's url and ref. */
2
- export interface PluginInstall {
3
- name: string;
4
- version: string | null;
5
- sourcePath: string | null;
6
- marketplace: {
7
- name: string;
8
- url: string | null;
9
- ref: string | null;
10
- } | null;
11
- /** A directory provider bundle (`isProviderBundleRow`): a provider call installs it into this process's own plugin directory, so there is nothing to fetch. */
12
- providerBundle: boolean;
13
- }
14
- /** The directory holding this person's materialized plugins as `<dir>/<plugin name>`; undefined when none were materialized. */
15
- export declare function materializedPluginsDir(person: string): string | undefined;
16
- /** Install `installs` for `person` at `revision` into this process's plugin cache, once per (person, revision). When a plugin cannot
17
- * be installed — a source on another machine, a marketplace that does not list it, a failed clone — the failure is held for that person
18
- * and thrown by the next read of their plugins (`materializedPluginsDir`), so a request that never reads plugins, such as the one that
19
- * fixes the settings, still works; the next load retries. */
20
- export declare function materializePlugins(person: string, revision: number, installs: readonly PluginInstall[]): Promise<void>;
@@ -1,105 +0,0 @@
1
- // materialized-plugins.ts — the plugin bytes a cloud-role process holds for the people it serves (D84). A person's enabled plugins are
2
- // rows (`scope_plugins`, `scope_marketplaces`); a fresh API process has no plugin directories, so before it reads plugin manifests it
3
- // installs each enabled plugin from its marketplace into a process-local directory keyed by (person, plugins_revision). Leaf-safe:
4
- // the installed-plugin reader imports this, so it imports no database. `loadPersonSettings` (person-rows.ts) fills it.
5
- import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { isAbsolute, join, relative, resolve } from 'node:path';
8
- import { CrtrError } from '../errors.js';
9
- import { gitAsync } from '../git.js';
10
- import { readMarketplaceManifest } from '../manifest.js';
11
- const materialized = new Map();
12
- const inFlight = new Map();
13
- /** The directory holding this person's materialized plugins as `<dir>/<plugin name>`; undefined when none were materialized. */
14
- export function materializedPluginsDir(person) {
15
- const held = materialized.get(person);
16
- if (held !== undefined && 'failure' in held)
17
- throw held.failure;
18
- return held?.dir;
19
- }
20
- function cacheRoot(person) { return join(tmpdir(), 'crtr-plugin-cache', person.replace(/[^A-Za-z0-9._-]/g, '_')); }
21
- async function cloneInto(url, ref, dest, what) {
22
- const res = await gitAsync(['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
23
- if (res.status !== 0)
24
- throw new CrtrError('plugin_install_failed', `${what}: git clone ${url} failed: ${res.stderr.trim()}`);
25
- }
26
- async function installAll(staging, installs) {
27
- const marketRoots = new Map();
28
- mkdirSync(join(staging, 'plugins'), { recursive: true });
29
- for (const plugin of installs) {
30
- if (plugin.sourcePath !== null)
31
- throw new CrtrError('plugin_source_unreachable', `${plugin.name}: its source is ${plugin.sourcePath} on the machine that registered it, which the control plane cannot read`);
32
- const market = plugin.marketplace;
33
- if (plugin.providerBundle)
34
- continue;
35
- if (market === null)
36
- throw new CrtrError('plugin_source_unreachable', `${plugin.name}: it names neither a marketplace nor a path`);
37
- if (market.url === null || market.url === '')
38
- throw new CrtrError('plugin_source_unreachable', `${plugin.name}: its marketplace ${market.name} has no url`);
39
- const key = `${market.url}\0${market.ref ?? ''}`;
40
- let root = marketRoots.get(key);
41
- if (root === undefined) {
42
- root = join(staging, 'marketplaces', String(marketRoots.size));
43
- await cloneInto(market.url, market.ref, root, `${plugin.name}: marketplace ${market.name}`);
44
- marketRoots.set(key, root);
45
- }
46
- const entry = readMarketplaceManifest(root)?.plugins.find((p) => p.name === plugin.name);
47
- if (entry === undefined)
48
- throw new CrtrError('plugin_install_failed', `${plugin.name}: marketplace ${market.name} (${market.url}) does not list it`);
49
- const dest = join(staging, 'plugins', plugin.name);
50
- if (isAbsolute(entry.source) || /^[a-z][a-z0-9+.-]*:|^git@/i.test(entry.source) || entry.source.endsWith('.git')) {
51
- await cloneInto(entry.source, null, dest, plugin.name);
52
- continue;
53
- }
54
- const from = resolve(root, entry.source);
55
- const inside = relative(root, from);
56
- if (inside.startsWith('..') || isAbsolute(inside))
57
- throw new CrtrError('plugin_install_failed', `${plugin.name}: its source ${entry.source} leaves the marketplace`);
58
- if (!existsSync(from))
59
- throw new CrtrError('plugin_install_failed', `${plugin.name}: its source ${entry.source} is not in marketplace ${market.name}`);
60
- cpSync(from, dest, { recursive: true });
61
- }
62
- rmSync(join(staging, 'marketplaces'), { recursive: true, force: true });
63
- }
64
- /** Install `installs` for `person` at `revision` into this process's plugin cache, once per (person, revision). When a plugin cannot
65
- * be installed — a source on another machine, a marketplace that does not list it, a failed clone — the failure is held for that person
66
- * and thrown by the next read of their plugins (`materializedPluginsDir`), so a request that never reads plugins, such as the one that
67
- * fixes the settings, still works; the next load retries. */
68
- export async function materializePlugins(person, revision, installs) {
69
- const held = materialized.get(person);
70
- if (held?.revision === revision && !('failure' in held))
71
- return;
72
- const final = join(cacheRoot(person), String(revision));
73
- const key = `${person}\0${revision}`;
74
- let pending = inFlight.get(key);
75
- if (pending === undefined) {
76
- pending = (async () => {
77
- if (!existsSync(join(final, 'plugins'))) {
78
- const staging = join(cacheRoot(person), `${revision}.${process.pid}.${Date.now()}`);
79
- try {
80
- await installAll(staging, installs);
81
- try {
82
- renameSync(staging, final);
83
- }
84
- catch (error) {
85
- if (!existsSync(join(final, 'plugins')))
86
- throw error;
87
- }
88
- }
89
- finally {
90
- rmSync(staging, { recursive: true, force: true });
91
- }
92
- }
93
- return join(final, 'plugins');
94
- })().finally(() => inFlight.delete(key));
95
- inFlight.set(key, pending);
96
- }
97
- try {
98
- materialized.set(person, { revision, dir: await pending });
99
- }
100
- catch (error) {
101
- if (!(error instanceof CrtrError))
102
- throw error;
103
- materialized.set(person, { revision, failure: error });
104
- }
105
- }