@north-light/api 0.3.589 → 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 (52) 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/spawn.js +5 -5
  43. package/dist/core/runtime/warm-pool.js +1 -0
  44. package/dist/core/scoped-state/legacy-profiles.js +1 -1
  45. package/dist/core/scoped-state/person-rows.js +38 -35
  46. package/dist/core/scoped-state/person-store.d.ts +23 -0
  47. package/dist/core/scoped-state/person-store.js +5 -1
  48. package/package.json +1 -1
  49. package/dist/core/graph/package-kinds.d.ts +0 -22
  50. package/dist/core/graph/package-kinds.js +0 -49
  51. package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
  52. package/dist/core/scoped-state/materialized-plugins.js +0 -105
@@ -340,7 +340,10 @@ export declare class CrtrClient {
340
340
  /** Package (builtin and plugin) documents. */
341
341
  readonly documents: {
342
342
  browserAccess: () => Promise<DocumentAccessDTO>;
343
- reconcilePackages: () => Promise<ReconcilePackagesDTO>;
343
+ /** `plugins` names the installs a cloud role resolves (a taken name then answers `409 name_taken`); omitted, it resolves all of the app's. */
344
+ reconcilePackages: (opts?: {
345
+ plugins?: string[];
346
+ }) => Promise<ReconcilePackagesDTO>;
344
347
  };
345
348
  /** A plugin's `crtr_app_` credential and the headers crtrd adds to its local HTTP calls. */
346
349
  readonly plugins: {
@@ -620,7 +620,8 @@ export class CrtrClient {
620
620
  /** Package (builtin and plugin) documents. */
621
621
  documents = {
622
622
  browserAccess: () => this.request('GET', routes.documentsBrowserAccess()),
623
- reconcilePackages: () => this.request('POST', routes.documentsReconcilePackages()),
623
+ /** `plugins` names the installs a cloud role resolves (a taken name then answers `409 name_taken`); omitted, it resolves all of the app's. */
624
+ reconcilePackages: (opts) => this.request('POST', routes.documentsReconcilePackages(), opts),
624
625
  };
625
626
  /** A plugin's `crtr_app_` credential and the headers crtrd adds to its local HTTP calls. */
626
627
  plugins = {
@@ -1,5 +1,5 @@
1
1
  /** The contract level this build serves and requires. Written by `scripts/contract.mjs release`; never edit by hand. */
2
- export declare const CONTRACT_LEVEL = "1.43";
2
+ export declare const CONTRACT_LEVEL = "1.44";
3
3
  /** Request header naming the one contract level the caller needs, e.g. `1.3`. Optional: absent means no check. */
4
4
  export declare const REQUIRED_CONTRACT_HEADER = "Crouter-Required-Contract";
5
5
  /** Response header listing the contract levels the server serves, comma-separated, one per major, e.g. `1.3`. */
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // PURITY (spec §3.1): Node built-ins + `src/api/*` only.
14
14
  /** The contract level this build serves and requires. Written by `scripts/contract.mjs release`; never edit by hand. */
15
- export const CONTRACT_LEVEL = '1.43';
15
+ export const CONTRACT_LEVEL = '1.44';
16
16
  /** Request header naming the one contract level the caller needs, e.g. `1.3`. Optional: absent means no check. */
17
17
  export const REQUIRED_CONTRACT_HEADER = 'Crouter-Required-Contract';
18
18
  /** Response header listing the contract levels the server serves, comma-separated, one per major, e.g. `1.3`. */
@@ -173,6 +173,11 @@ export interface ReconcilePackagesDTO {
173
173
  error: string;
174
174
  }>;
175
175
  };
176
+ /** cloud role: installed plugins whose documents are not on the canvas — `not_public` (the directory reads public marketplaces only), `not_found` (the directory does not list it) or `directory_unreachable` (the next sync publishes them) */
177
+ unavailable?: Array<{
178
+ plugin: string;
179
+ reason: 'not_public' | 'not_found' | 'directory_unreachable';
180
+ }>;
176
181
  /** the `crtr_app_` tokens after this pass: who holds one (with its scopes) and whose was removed */
177
182
  tokens?: {
178
183
  granted: Array<{
@@ -23,12 +23,33 @@ export interface PersonSettingsSnapshotDTO {
23
23
  /** Owner key (`user`, `profile:<id>`) → raw settings, with the `plugins` and `marketplaces` blocks rebuilt from their rows. */
24
24
  settings: Record<string, Record<string, unknown>>;
25
25
  state: Record<string, SettingsScopeStateDTO>;
26
+ /** App id (`app:<id>`) → that app's plugin and marketplace installs; `revision` covers only that block. */
27
+ apps: Record<string, SettingsAppInstallsDTO>;
28
+ }
29
+ export interface SettingsAppInstallsDTO {
30
+ plugins: Record<string, {
31
+ enabled?: boolean;
32
+ source_marketplace?: string;
33
+ version?: string;
34
+ }>;
35
+ marketplaces: Record<string, {
36
+ url?: string;
37
+ ref?: string;
38
+ installed_at?: string;
39
+ }>;
40
+ revision: string;
26
41
  }
27
42
  export type SettingsOpDTO = {
28
43
  op: 'settings.replace';
29
44
  owner: string;
30
45
  raw: Record<string, unknown>;
31
46
  ifRevision: string;
47
+ } | {
48
+ op: 'app-plugins.replace';
49
+ app: string;
50
+ plugins: SettingsAppInstallsDTO['plugins'];
51
+ marketplaces: SettingsAppInstallsDTO['marketplaces'];
52
+ ifRevision: string;
32
53
  } | {
33
54
  op: 'state.write';
34
55
  owner: string;
@@ -23,6 +23,8 @@ export declare function personOnlyInvocation(argv: readonly string[]): string |
23
23
  /** Select a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
24
24
  export declare function isDaemonControlInvocation(argv: readonly string[]): boolean;
25
25
  /** `sys signin` and `sys signout` run when the stored sign-in is gone or a move is unfinished, the states in which the settings load would refuse. */
26
+ /** `pkg plugin …` and `pkg market …` read and write the calling node's app install block, so a sandboxed node loads the settings snapshot for them. */
27
+ export declare function isPkgInstallInvocation(argv: readonly string[]): boolean;
26
28
  export declare function isSignInInvocation(argv: readonly string[]): boolean;
27
29
  export declare function isJoinInvocation(argv: readonly string[]): boolean;
28
30
  export declare function isLayoutMigrationInvocation(argv: readonly string[]): boolean;
@@ -268,7 +268,7 @@ const PERSON_ONLY_PATHS = [
268
268
  ['canvas', 'chord'], ['canvas', 'config'], ['canvas', 'use'], ['canvas', 'browse'], ['canvas', 'issue'],
269
269
  ];
270
270
  /** Leaves inside a person-only subtree that an app's node may still run: the command a node runs when the harness itself misbehaves. */
271
- const NODE_CALLABLE_PATHS = [['sys', 'feedback']];
271
+ const NODE_CALLABLE_PATHS = [['sys', 'feedback'], ['pkg', 'plugin'], ['pkg', 'market']];
272
272
  function startsWithPath(tokens, path) {
273
273
  return path.every((name, index) => tokens[index] === name);
274
274
  }
@@ -283,6 +283,11 @@ export function personOnlyInvocation(argv) {
283
283
  /** Select a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
284
284
  export function isDaemonControlInvocation(argv) { return isSysInvocation(argv, 'daemon'); }
285
285
  /** `sys signin` and `sys signout` run when the stored sign-in is gone or a move is unfinished, the states in which the settings load would refuse. */
286
+ /** `pkg plugin …` and `pkg market …` read and write the calling node's app install block, so a sandboxed node loads the settings snapshot for them. */
287
+ export function isPkgInstallInvocation(argv) {
288
+ const tokens = argv.slice(2).filter((token) => !GLOBAL_TOKENS.has(token));
289
+ return tokens[0] === 'pkg' && (tokens[1] === 'plugin' || tokens[1] === 'market');
290
+ }
286
291
  export function isSignInInvocation(argv) { return isSysInvocation(argv, 'signin') || isSysInvocation(argv, 'signout'); }
287
292
  export function isJoinInvocation(argv) { return isSysInvocation(argv, 'join'); }
288
293
  export function isLayoutMigrationInvocation(argv) { return isSysInvocation(argv, 'migrate'); }
@@ -0,0 +1,53 @@
1
+ export declare const appId: () => string;
2
+ export declare function installAppPlugin(ref: string): Promise<{
3
+ name: string;
4
+ app: string;
5
+ warnings?: string[];
6
+ }>;
7
+ export declare function removeAppPlugin(name: string): Promise<{
8
+ app: string;
9
+ }>;
10
+ export declare function setAppPluginEnabled(name: string, enabled: boolean): Promise<{
11
+ name: string;
12
+ app: string;
13
+ enabled: boolean;
14
+ warnings?: string[];
15
+ }>;
16
+ /** Refresh the documents of one install, or all of the app's: the directory re-reads its marketplaces hourly and the control plane converges on its next sync. */
17
+ export declare function updateAppPlugins(name: string | undefined): Promise<{
18
+ updated: Array<{
19
+ name: string;
20
+ warnings?: string[];
21
+ }>;
22
+ }>;
23
+ /** The app's installs. Each says why it has no documents when the directory cannot serve it (a name another source holds, a private marketplace, a directory that is down). */
24
+ export declare function listAppPlugins(includeDisabled: boolean): Promise<{
25
+ items: Array<{
26
+ name: string;
27
+ scope: string;
28
+ enabled: boolean;
29
+ source_marketplace?: string;
30
+ version?: string;
31
+ documents?: string;
32
+ }>;
33
+ next_cursor: null;
34
+ total: number;
35
+ }>;
36
+ export declare function addAppMarketplace(url: string, ref: string | undefined): Promise<{
37
+ name: string;
38
+ app: string;
39
+ }>;
40
+ /** The marketplaces the app has registered, in the shape `pkg market list` prints (no clone path: a sandbox holds none). */
41
+ export declare function listAppMarketplaces(): {
42
+ items: Array<{
43
+ name: string;
44
+ scope: string;
45
+ url: string | undefined;
46
+ ref: string | undefined;
47
+ }>;
48
+ next_cursor: null;
49
+ total: number;
50
+ };
51
+ export declare function removeAppMarketplace(name: string): Promise<{
52
+ app: string;
53
+ }>;
@@ -0,0 +1,125 @@
1
+ // `crtr pkg plugin …` and `crtr pkg market …` in a sandboxed node: the node's plugins are its app's install block on the control plane
2
+ // (`app-plugins.replace`), not a directory on disk. The sandbox clones the plugin code itself at its next start; the documents come from the
3
+ // directory through `reconcile-packages`.
4
+ import { ApiError } from '../../api/errors.js';
5
+ import { envGrantee } from '../../shared/env.js';
6
+ import { deriveNameFromUrl } from '../../core/git.js';
7
+ import { notFound, usage, CrtrError } from '../../core/errors.js';
8
+ import { nowIso } from '../../core/fs-utils.js';
9
+ import { appRevisionOf, currentSnapshot, writeSettings } from '../../core/scoped-state/person-store.js';
10
+ import { ExitCode } from '../../types.js';
11
+ import { cliClient, rethrowAsCliError } from '../api-client.js';
12
+ const OFFICIAL = { name: 'crouter-official-marketplace', url: 'https://github.com/crouton-labs/crouter-official-marketplace.git' };
13
+ export const appId = () => envGrantee() ?? 'app:terminal';
14
+ const block = () => currentSnapshot().apps[appId()] ?? { plugins: {}, marketplaces: {}, revision: 'absent' };
15
+ async function change(mutate) {
16
+ const app = appId();
17
+ await writeSettings((snapshot) => {
18
+ const current = snapshot.apps[app];
19
+ const next = structuredClone({ plugins: current?.plugins ?? {}, marketplaces: current?.marketplaces ?? {} });
20
+ mutate(next);
21
+ return [{ op: 'app-plugins.replace', app, plugins: next.plugins, marketplaces: next.marketplaces, ifRevision: appRevisionOf(current) }];
22
+ });
23
+ }
24
+ /** Ask the control plane to resolve the named installs against the directory. A taken name or an unlisted plugin refuses the install: the caller removes its row. */
25
+ async function resolve(plugins) {
26
+ return await resolveRaw(plugins).catch((err) => rethrowAsCliError(err));
27
+ }
28
+ async function resolveRaw(plugins) {
29
+ return await cliClient().documents.reconcilePackages({ plugins });
30
+ }
31
+ /** Resolve all of the app's installs: a taken or unlisted name is reported, never refused. */
32
+ async function resolveAll() {
33
+ return await cliClient().documents.reconcilePackages().catch((err) => rethrowAsCliError(err));
34
+ }
35
+ function unavailableWarnings(report, name) {
36
+ const why = { not_public: 'installed without documents: the directory reads public marketplaces only', not_found: 'installed without documents: the directory does not list it', directory_unreachable: 'installed; its documents arrive on the next sync because the directory is unreachable' };
37
+ const unavailable = (report.unavailable ?? []).filter((u) => name === undefined || u.plugin === name).map((u) => `${u.plugin}: ${why[u.reason]}`);
38
+ // A published package can be partial: files that did not parse (paths start with the plugin's name) and kinds another owner holds.
39
+ const invalid = report.invalid.filter((i) => name === undefined || i.path.startsWith(`${name}/`)).map((i) => `${i.path.split('/')[0]}: ${i.path} was not published: ${i.error}`);
40
+ const refused = (report.kinds?.refused ?? []).filter((k) => name === undefined || k.plugin === name).map((k) => `${k.plugin}: kind ${k.kind} not registered: ${k.message}`);
41
+ return [...unavailable, ...invalid, ...refused];
42
+ }
43
+ export async function installAppPlugin(ref) {
44
+ const parts = ref.split('/');
45
+ if (parts.length !== 2 || parts[0] === '' || parts[1] === '')
46
+ throw usage(`invalid plugin install ref: ${ref}\nA sandboxed node installs marketplace/plugin from a marketplace added with \`crtr pkg market add\`.`);
47
+ const [market, name] = parts;
48
+ const before = block();
49
+ if (before.plugins[name] !== undefined)
50
+ throw new CrtrError('conflict', `plugin "${name}" is already installed in ${appId()}`, ExitCode.GENERAL);
51
+ const known = before.marketplaces[market] ?? (market === OFFICIAL.name ? { url: OFFICIAL.url, ref: 'main', installed_at: nowIso() } : undefined);
52
+ if (known === undefined)
53
+ throw notFound(`marketplace not found: ${market}`);
54
+ await change((b) => { b.marketplaces[market] = known; b.plugins[name] = { enabled: true, source_marketplace: market }; });
55
+ try {
56
+ const report = await resolveRaw([name]);
57
+ const warnings = unavailableWarnings(report, name);
58
+ return { name, app: appId(), ...(warnings.length > 0 ? { warnings } : {}) };
59
+ }
60
+ catch (error) {
61
+ // Only the directory's `name_taken` and `not_found` refuse the install (the row was written ahead of its answer); anything else leaves it standing.
62
+ if (!(error instanceof ApiError) || (error.code !== 'name_taken' && error.code !== 'not_found'))
63
+ return rethrowAsCliError(error);
64
+ await change((b) => { delete b.plugins[name]; if (before.marketplaces[market] === undefined && !Object.values(b.plugins).some((p) => p.source_marketplace === market))
65
+ delete b.marketplaces[market]; });
66
+ if (error.code === 'not_found')
67
+ throw notFound(error.message);
68
+ throw new CrtrError('conflict', `${error.message}; uninstall that marketplace's plugin or pick another`, ExitCode.GENERAL, { plugin: name });
69
+ }
70
+ }
71
+ export async function removeAppPlugin(name) {
72
+ if (block().plugins[name] === undefined)
73
+ throw notFound(`plugin not found: ${name}`);
74
+ await change((b) => { delete b.plugins[name]; });
75
+ return { app: appId() };
76
+ }
77
+ export async function setAppPluginEnabled(name, enabled) {
78
+ if (block().plugins[name] === undefined)
79
+ throw notFound(`plugin not found: ${name}`);
80
+ await change((b) => { b.plugins[name] = { ...b.plugins[name], enabled }; });
81
+ const warnings = enabled ? unavailableWarnings(await resolve([name]), name) : [];
82
+ return { name, app: appId(), enabled, ...(warnings.length > 0 ? { warnings } : {}) };
83
+ }
84
+ /** Refresh the documents of one install, or all of the app's: the directory re-reads its marketplaces hourly and the control plane converges on its next sync. */
85
+ export async function updateAppPlugins(name) {
86
+ const installed = Object.keys(block().plugins);
87
+ if (name !== undefined && !installed.includes(name))
88
+ throw notFound(`plugin not found: ${name}`);
89
+ const names = name === undefined ? installed : [name];
90
+ const report = names.length === 0 ? undefined : name === undefined ? await resolveAll() : await resolve(names);
91
+ const conflicted = (n) => (report?.conflicts ?? []).filter((c) => c.plugin === n).map((c) => `${n}: no documents: ${c.message}`);
92
+ return { updated: names.map((n) => { const warnings = report === undefined ? [] : [...conflicted(n), ...unavailableWarnings(report, n)]; return { name: n, ...(warnings.length > 0 ? { warnings } : {}) }; }) };
93
+ }
94
+ /** The app's installs. Each says why it has no documents when the directory cannot serve it (a name another source holds, a private marketplace, a directory that is down). */
95
+ export async function listAppPlugins(includeDisabled) {
96
+ const entries = Object.entries(block().plugins).filter(([, p]) => includeDisabled || p.enabled !== false).sort(([a], [b]) => a.localeCompare(b));
97
+ const enabled = entries.filter(([, p]) => p.enabled !== false).map(([n]) => n);
98
+ const report = enabled.length === 0 ? undefined : await resolveAll();
99
+ const why = (name) => {
100
+ const conflict = report?.conflicts.find((c) => c.plugin === name);
101
+ if (conflict !== undefined)
102
+ return `no documents: ${conflict.message}`;
103
+ return report === undefined ? undefined : unavailableWarnings(report, name)[0];
104
+ };
105
+ const items = entries.map(([name, p]) => ({ name, scope: appId(), enabled: p.enabled !== false, ...(p.source_marketplace === undefined ? {} : { source_marketplace: p.source_marketplace }), ...(p.version === undefined ? {} : { version: p.version }), ...(why(name) === undefined ? {} : { documents: why(name) }) }));
106
+ return { items, next_cursor: null, total: items.length };
107
+ }
108
+ export async function addAppMarketplace(url, ref) {
109
+ const name = deriveNameFromUrl(url);
110
+ await change((b) => { b.marketplaces[name] = { url, ref: ref ?? 'main', installed_at: nowIso() }; });
111
+ return { name, app: appId() };
112
+ }
113
+ /** The marketplaces the app has registered, in the shape `pkg market list` prints (no clone path: a sandbox holds none). */
114
+ export function listAppMarketplaces() {
115
+ const items = Object.entries(block().marketplaces).sort(([a], [b]) => a.localeCompare(b)).map(([name, m]) => ({ name, scope: appId(), url: m.url, ref: m.ref }));
116
+ return { items, next_cursor: null, total: items.length };
117
+ }
118
+ export async function removeAppMarketplace(name) {
119
+ if (block().marketplaces[name] === undefined)
120
+ throw notFound(`marketplace not found: ${name}`);
121
+ await change((b) => { delete b.marketplaces[name]; for (const [p, e] of Object.entries(b.plugins))
122
+ if (e.source_marketplace === name)
123
+ delete b.plugins[p]; });
124
+ return { app: appId() };
125
+ }
@@ -3,6 +3,8 @@ import { notFound } from '../../core/errors.js';
3
3
  import { paginate } from '../../core/pagination.js';
4
4
  import { listInstalledPlugins, listInstalledMarketplaces, listAllMarketplaces, findMarketplaceByName, } from '../../core/resolver.js';
5
5
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
6
+ import { isSandboxedNode } from '../../shared/env.js';
7
+ import { listAppMarketplaces } from './app-installs.js';
6
8
  export const marketList = defineLeaf({
7
9
  name: 'list',
8
10
  description: 'list registered marketplaces',
@@ -24,6 +26,8 @@ export const marketList = defineLeaf({
24
26
  effects: ['None. Read-only.'],
25
27
  },
26
28
  run: async (input) => {
29
+ if (isSandboxedNode())
30
+ return listAppMarketplaces();
27
31
  const scopeInput = input['scope'];
28
32
  const limitRaw = input['limit'];
29
33
  const limit = limitRaw !== undefined ? Math.min(Math.max(1, limitRaw), 200) : 50;
@@ -4,6 +4,8 @@ import { defineLeaf } from '../../core/command.js';
4
4
  import { general, notFound } from '../../core/errors.js';
5
5
  import { listAllMarketplaces, listAllPlugins, findMarketplaceByName } from '../../core/resolver.js';
6
6
  import { resolveScopeArg, requireScopeRoot, marketplacesDir, pluginsDir } from '../../core/scope.js';
7
+ import { isSandboxedNode } from '../../shared/env.js';
8
+ import { addAppMarketplace, removeAppMarketplace } from './app-installs.js';
7
9
  import { updateConfig, updateState, ensureScopeInitialized, readConfig } from '../../core/config.js';
8
10
  import { pathExists, ensureDir, removePath, isSymlink, nowIso, readSymlinkTarget } from '../../core/fs-utils.js';
9
11
  import { clone, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
@@ -72,6 +74,8 @@ export const marketAdd = defineLeaf({
72
74
  const url = input['url'];
73
75
  const ref = input['ref'];
74
76
  const scopeInput = input['scope'];
77
+ if (isSandboxedNode())
78
+ return await addAppMarketplace(url, ref);
75
79
  return addMarketplace({ url, ref, scope: resolveInstallScope(scopeInput) });
76
80
  },
77
81
  });
@@ -92,6 +96,8 @@ export const marketRemove = defineLeaf({
92
96
  },
93
97
  run: async (input) => {
94
98
  const name = input['name'];
99
+ if (isSandboxedNode())
100
+ return await removeAppMarketplace(name);
95
101
  const scopeInput = input['scope'];
96
102
  let targetScope;
97
103
  let mktRoot;
@@ -1,5 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { notFound } from '../../core/errors.js';
3
+ import { isSandboxedNode } from '../../shared/env.js';
4
+ import { listAppPlugins } from './app-installs.js';
3
5
  import { paginate } from '../../core/pagination.js';
4
6
  import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
5
7
  import { packageFiles } from '../../core/graph/package-files.js';
@@ -34,16 +36,18 @@ export const pluginList = defineLeaf({
34
36
  { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
35
37
  ],
36
38
  output: [
37
- { name: 'items', type: 'object[]', required: true, constraint: 'Each: {name, scope, version?, enabled, transport, source_marketplace?, path}. transport is exec or http for command plugins, null for docs-only plugins. Sorted by scope then name ascending.' },
39
+ { name: 'items', type: 'object[]', required: true, constraint: 'Each: {name, scope, version?, enabled, transport, source_marketplace?, path, documents?}. documents, on a sandboxed node, says why an install has no documents. transport is exec or http for command plugins, null for docs-only plugins. Sorted by scope then name ascending.' },
38
40
  { name: 'next_cursor', type: 'string | null', required: true, constraint: 'null means no more items.' },
39
41
  { name: 'total', type: 'integer | null', required: true, constraint: 'Exact when cheap; null otherwise.' },
40
42
  ],
41
43
  outputKind: 'object',
42
- effects: ['None. Read-only.'],
44
+ effects: ['None on a Mac. On a sandboxed node it asks the directory about each enabled install, which publishes what the directory serves.'],
43
45
  },
44
46
  run: async (input) => {
45
47
  const scopeInput = input['scope'];
46
48
  const includeDisabled = input['includeDisabled'] ?? false;
49
+ if (isSandboxedNode())
50
+ return await listAppPlugins(includeDisabled);
47
51
  const limitRaw = input['limit'];
48
52
  const limit = limitRaw !== undefined ? Math.min(Math.max(1, limitRaw), 200) : 50;
49
53
  const cursor = input['cursor'];
@@ -5,6 +5,8 @@ import { dirname, join, resolve, isAbsolute, relative } from 'node:path';
5
5
  import { renameSync } from 'node:fs';
6
6
  import { defineLeaf } from '../../core/command.js';
7
7
  import { cliClient, rethrowAsCliError } from '../api-client.js';
8
+ import { isSandboxedNode } from '../../shared/env.js';
9
+ import { installAppPlugin, removeAppPlugin, setAppPluginEnabled, updateAppPlugins } from './app-installs.js';
8
10
  import { notFound, usage, general, network, CrtrError } from '../../core/errors.js';
9
11
  import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
10
12
  import { pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
@@ -810,6 +812,11 @@ export const pluginInstall = defineLeaf({
810
812
  const authEnv = input['authEnv'];
811
813
  const scopeInput = input['scope'];
812
814
  const gitRef = input['ref'];
815
+ if (isSandboxedNode()) {
816
+ if (rawRef === undefined || endpoint !== undefined || name !== undefined || authEnv !== undefined || gitRef !== undefined || scopeInput !== undefined)
817
+ throw usage('a sandboxed node installs only INSTALL-REF as marketplace/plugin');
818
+ return await installAppPlugin(rawRef);
819
+ }
813
820
  const scope = resolveInstallScope(scopeInput);
814
821
  if ((rawRef === undefined) === (endpoint === undefined)) {
815
822
  throw usage('provide exactly one of INSTALL-REF or --endpoint');
@@ -867,6 +874,8 @@ export const pluginRemove = defineLeaf({
867
874
  if (name === 'crtr') {
868
875
  throw usage('cannot remove builtin plugin "crtr" — it ships with the binary');
869
876
  }
877
+ if (isSandboxedNode())
878
+ return await removeAppPlugin(name);
870
879
  let scopes;
871
880
  if (scopeInput !== undefined) {
872
881
  const resolved = resolveScopeArg(scopeInput);
@@ -930,6 +939,8 @@ export const pluginEnable = defineLeaf({
930
939
  },
931
940
  run: async (input) => {
932
941
  const name = input['name'];
942
+ if (isSandboxedNode())
943
+ return await setAppPluginEnabled(name, true);
933
944
  const target = resolvePluginConfigTarget(name, input['scope']);
934
945
  if (target === null)
935
946
  throw notFound(`plugin not found: ${name}`);
@@ -961,6 +972,8 @@ export const pluginDisable = defineLeaf({
961
972
  effects: ['Sets plugin enabled=false in config.json.'],
962
973
  },
963
974
  run: async (input) => {
975
+ if (isSandboxedNode())
976
+ return await setAppPluginEnabled(input['name'], false);
964
977
  const result = await setPluginEnabled(input, false);
965
978
  await reconcilePluginDocuments();
966
979
  return { scope: result['scope'] };
@@ -989,6 +1002,8 @@ export const pluginUpdate = defineLeaf({
989
1002
  },
990
1003
  run: async (input) => {
991
1004
  const name = input['name'];
1005
+ if (isSandboxedNode())
1006
+ return await updateAppPlugins(name);
992
1007
  const scopeInput = input['scope'];
993
1008
  const scope = scopeInput === undefined ? undefined : resolveScopeArg(scopeInput);
994
1009
  const marketplaceCache = new Map();
@@ -1,7 +1,7 @@
1
1
  /** The canvas schema version this build supports: the number of the highest delta in `schema-pg/` (the baseline
2
2
  * `schema-pg.sql` is version 1). `scripts/install-runtime.mjs` reads this module from a candidate generation to refuse
3
3
  * one below the live database's `schema_min_reader`. */
4
- export declare const CANVAS_SCHEMA_VERSION = 10;
4
+ export declare const CANVAS_SCHEMA_VERSION = 11;
5
5
  /** The Postgres schema holding the canvas tables: `CRTR_DATABASE_SCHEMA` (`crtr` in the cloud deploy). Unset or empty
6
6
  * means the role's own search_path (`public` on a Mac and on a VM). A name that is not a plain identifier is an error:
7
7
  * it is put in a `search_path` connection option. */
@@ -6,7 +6,7 @@
6
6
  /** The canvas schema version this build supports: the number of the highest delta in `schema-pg/` (the baseline
7
7
  * `schema-pg.sql` is version 1). `scripts/install-runtime.mjs` reads this module from a candidate generation to refuse
8
8
  * one below the live database's `schema_min_reader`. */
9
- export const CANVAS_SCHEMA_VERSION = 10;
9
+ export const CANVAS_SCHEMA_VERSION = 11;
10
10
  /** The Postgres schema holding the canvas tables: `CRTR_DATABASE_SCHEMA` (`crtr` in the cloud deploy). Unset or empty
11
11
  * means the role's own search_path (`public` on a Mac and on a VM). A name that is not a plain identifier is an error:
12
12
  * it is put in a `search_path` connection option. */
@@ -85,6 +85,7 @@ export declare function normalizeRequires(raw: unknown): Record<string, string>;
85
85
  /** STRICT install-time validation for a plugin's advisory `requires` block.
86
86
  * A missing executable is advisory; only malformed declarations fail install. */
87
87
  export declare function invalidPluginRequiresReasons(raw: unknown): string[];
88
+ export declare function setAppPluginKinds(kinds: Record<string, unknown> | undefined): void;
88
89
  /** The ACTIVE page-component catalog every authoring, validation, daemon,
89
90
  * inbox, and TUI consumer resolves: the user scope's own `page_components`
90
91
  * entries plus the contributions of every ENABLED plugin installed under that
@@ -630,6 +630,10 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
630
630
  }
631
631
  return out;
632
632
  }
633
+ /** The launch kinds of the plugins a cloud role's node has enabled in its app, by plugin name (`appLaunchKinds`). A cloud role holds no plugin
634
+ * directories, so when this is set the user scope layers these instead of listing a directory. The launch-config subprocess sets it from its request. */
635
+ let appPluginKinds;
636
+ export function setAppPluginKinds(kinds) { appPluginKinds = kinds; }
633
637
  /** Layer the `kinds` contributions of every ENABLED plugin installed under
634
638
  * `scopeRootPath` over `base`, plugin name-sorted for determinism (directory
635
639
  * listing order is filesystem-dependent). Each plugin's block goes through
@@ -638,6 +642,12 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
638
642
  * `readMergedLaunchConfig` BELOW the host scope's own raw `kinds`, so the
639
643
  * scope's config.json always overrides its plugins. */
640
644
  function layerPluginKinds(scope, scopeRootPath, base) {
645
+ if (scope === 'user' && appPluginKinds !== undefined) {
646
+ let out = base;
647
+ for (const name of Object.keys(appPluginKinds).sort((a, b) => a.localeCompare(b)))
648
+ out = mergeKinds(appPluginKinds[name], out);
649
+ return out;
650
+ }
641
651
  if (scopeRootPath === null)
642
652
  return base;
643
653
  const plugins = listInstalledPluginsInRoot(scope, scopeRootPath)
@@ -1,6 +1,6 @@
1
1
  import { isSafeNodeId } from '../canvas/paths.js';
2
2
  import { OBJECT_TYPE } from '../permissions/builtin.js';
3
- import { ceilingFrag, resolveCeiling } from '../permissions/ceiling.js';
3
+ import { ceilingFrag, readablePackageApps, resolveCeiling } from '../permissions/ceiling.js';
4
4
  import { compilePermission } from '../permissions/compile.js';
5
5
  import { permits, readerUser, resolveReader } from '../permissions/evaluator.js';
6
6
  import { loadDefinitions } from '../permissions/schemas.js';
@@ -14,11 +14,12 @@ export async function viewOf(db, reader) {
14
14
  const user = await readerUser(db, reader);
15
15
  const person = user === null ? undefined : await db.prepare(`SELECT o.* FROM objects o JOIN identities i ON i.object = o.id AND i.scheme = 'user'
16
16
  WHERE o.type = 'person' AND o.home_machine IS NULL AND regexp_replace(i.external_id, '^user:', '') = ?`).get(user);
17
- // The apps that ship documents (installed plugins), `crtr` last: ranking only; what a reader may read is decided in each query.
18
- const tools = await db.prepare(`SELECT a.* FROM objects a WHERE a.type = 'app' AND a.deleted_at IS NULL AND EXISTS (
17
+ // The apps whose shipped documents the reader may read (the read rule), `crtr` last. A person reads every published package.
18
+ const eligible = await readablePackageApps(db, reader, user);
19
+ const tools = await db.prepare(`SELECT a.* FROM objects a WHERE a.type = 'app' AND a.deleted_at IS NULL AND ${eligible === null ? `EXISTS (
19
20
  SELECT 1 FROM objects o JOIN documents d ON d.object = o.id
20
- WHERE o.owner = a.id AND o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL)
21
- ORDER BY a.name = 'crtr', a.name`).all();
21
+ WHERE o.owner = a.id AND o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL)` : 'a.id = ANY(?::text[])'}
22
+ ORDER BY a.name = 'crtr', a.name`).all(...(eligible === null ? [] : [eligible]));
22
23
  const head = reader.class === 'node' ? [reader.node, ...reader.repos, ...reader.projectRepos, reader.profile, reader.app]
23
24
  : reader.class === 'app' ? [reader.app]
24
25
  : reader.class === 'object' ? [reader.owner, ...reader.repos, reader.app]
@@ -32,7 +33,7 @@ export async function viewOf(db, reader) {
32
33
  * schema-typed object has had its facts removed, so only the `permits` branch answers it: its own namespace's app. */
33
34
  export async function readableOrDeleted(db, ev, alias) {
34
35
  const live = await permits(db, ev, 'read', alias);
35
- const ceiling = await resolveCeiling(db, ev.reader, 'read');
36
+ const ceiling = await resolveCeiling(db, ev.reader, ev.userId, 'read');
36
37
  const grants = compilePermission(await loadDefinitions(db), ev.subjects, OBJECT_TYPE, 'read', raw('gr.id'));
37
38
  const chain = { sql: `EXISTS (WITH RECURSIVE up(id, depth) AS (
38
39
  SELECT ${alias}.id, 0
@@ -1,14 +1,8 @@
1
- import { type QuarantinedPlugin } from '../plugin-quarantine.js';
2
1
  import { type PackageFile } from './package-files.js';
3
2
  import type { ReposFn } from './reader.js';
4
3
  import type { ObjectRow } from './types.js';
4
+ import type { QuarantinedPlugin } from '../plugin-quarantine.js';
5
5
  import type { CanvasDb } from '../canvas/db.js';
6
- export interface PackageSource {
7
- plugin: string;
8
- dir: string;
9
- }
10
- /** The builtin corpus (app `crtr`), then every enabled user-scope plugin's `memory/` folder. */
11
- export declare function packageSources(): PackageSource[];
12
6
  export interface PackageReconcileReport {
13
7
  created: number;
14
8
  updated: number;
@@ -25,17 +19,14 @@ export interface PackageReconcileReport {
25
19
  /** plugin directories with an unreadable manifest or command file, moved aside before reconciling */
26
20
  quarantined: QuarantinedPlugin[];
27
21
  }
28
- /** Converge every package's documents onto its files. Run inside the caller's transaction.
29
- * Idempotent: a second run over unchanged files changes no document, revision or rule (it re-derives
30
- * each document's link edges, which land the same). A changed body replaces the
31
- * document's single revision and publishes `edited`; a vanished file (or plugin) marks its
32
- * document deleted and publishes `deleted`. */
33
- export declare function reconcilePackageDocuments(db: CanvasDb, explicitSources?: readonly PackageSource[]): Promise<PackageReconcileReport>;
34
22
  /** Record a package document's `links-to` edges the way a runtime document gets them, so delivery
35
- * attaches the link previews. Each `[[name]]` resolves in its own app's view and links only when it
36
- * names another shipped document (builtin or plugin): a package never links to a user's or a
23
+ * attaches the link previews. Each `[[name]]` links only when it names a shipped document of any
24
+ * published package (builtin, provider or plugin): a package never links to a user's or a
37
25
  * runtime document that happens to share a bare name. A name that resolves to nothing stays text.
38
26
  * Edges are rewritten only when they differ, so an unchanged pass writes nothing. */
39
27
  export declare function deriveLinks(db: CanvasDb, owner: ObjectRow, doc: ObjectRow, body: string): Promise<void>;
28
+ /** Re-derive the links of every live shipped document of every app. A package published before the one its documents name has no edge to it yet;
29
+ * publishing the missing one calls this, so the edges do not depend on which package arrived first. */
30
+ export declare function relinkAllShipped(db: CanvasDb): Promise<void>;
40
31
  export declare function convergeFile(db: CanvasDb, owner: ObjectRow, file: PackageFile, report: PackageReconcileReport, reposFn: ReposFn): Promise<string>;
41
32
  export declare function deleteMissing(db: CanvasDb, owner: ObjectRow, keep: ReadonlySet<string>, report: PackageReconcileReport, reposFn: ReposFn): Promise<void>;