@north-light/api 0.3.589 → 0.3.591
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +4 -1
- package/dist/api/client.js +2 -1
- package/dist/api/contract.d.ts +1 -1
- package/dist/api/contract.js +1 -1
- package/dist/api/dto/docs.d.ts +5 -0
- package/dist/api/dto/settings.d.ts +21 -0
- package/dist/build-root.d.ts +2 -0
- package/dist/build-root.js +6 -1
- package/dist/commands/pkg/app-installs.d.ts +53 -0
- package/dist/commands/pkg/app-installs.js +125 -0
- package/dist/commands/pkg/market-inspect.js +4 -0
- package/dist/commands/pkg/market-manage.js +6 -0
- package/dist/commands/pkg/plugin-inspect.js +6 -2
- package/dist/commands/pkg/plugin-manage.js +15 -0
- package/dist/core/canvas/schema-version.d.ts +1 -1
- package/dist/core/canvas/schema-version.js +1 -1
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +10 -0
- package/dist/core/graph/names.js +7 -6
- package/dist/core/graph/package-docs.d.ts +6 -15
- package/dist/core/graph/package-docs.js +33 -83
- package/dist/core/graph/package-publish.d.ts +60 -0
- package/dist/core/graph/package-publish.js +158 -0
- package/dist/core/graph/package-reconcile.d.ts +20 -1
- package/dist/core/graph/package-reconcile.js +83 -15
- package/dist/core/import/export/dump.js +12 -36
- package/dist/core/installed-plugins.js +5 -7
- package/dist/core/packages/eligibility.d.ts +16 -0
- package/dist/core/packages/eligibility.js +34 -0
- package/dist/core/packages/launch-kinds.d.ts +6 -0
- package/dist/core/packages/launch-kinds.js +20 -0
- package/dist/core/packages/source-url.d.ts +3 -0
- package/dist/core/packages/source-url.js +17 -0
- package/dist/core/permissions/ceiling.d.ts +9 -2
- package/dist/core/permissions/ceiling.js +23 -9
- package/dist/core/permissions/evaluator.js +1 -1
- package/dist/core/providers/bundles.js +2 -3
- package/dist/core/runtime/launch-target.d.ts +6 -2
- package/dist/core/runtime/launch-target.js +8 -4
- package/dist/core/runtime/launch.d.ts +8 -1
- package/dist/core/runtime/launch.js +13 -6
- package/dist/core/runtime/spawn.js +5 -5
- package/dist/core/runtime/warm-pool.js +1 -0
- package/dist/core/scoped-state/legacy-profiles.js +1 -1
- package/dist/core/scoped-state/person-rows.js +38 -35
- package/dist/core/scoped-state/person-store.d.ts +23 -0
- package/dist/core/scoped-state/person-store.js +5 -1
- package/package.json +1 -1
- package/dist/core/graph/package-kinds.d.ts +0 -22
- package/dist/core/graph/package-kinds.js +0 -49
- package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
- package/dist/core/scoped-state/materialized-plugins.js +0 -105
|
@@ -12,8 +12,7 @@ import { CONFIG_FILE } from '../types.js';
|
|
|
12
12
|
import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
|
|
13
13
|
import { readPluginManifest } from './manifest.js';
|
|
14
14
|
import { awaitBundleSwap, bundleSwapInFlightElsewhere } from './plugin-swap-lock.js';
|
|
15
|
-
import { isCloudRole
|
|
16
|
-
import { materializedPluginsDir } from './scoped-state/materialized-plugins.js';
|
|
15
|
+
import { isCloudRole } from './canvas/person-state.js';
|
|
17
16
|
import { readCachedSettings } from './scoped-state/person-store.js';
|
|
18
17
|
import { pluginsDir } from './scope.js';
|
|
19
18
|
function pluginConfigForRoot(scope, root) {
|
|
@@ -41,12 +40,11 @@ function swapsToWaitOut(scopeRootPath, cfg, present) {
|
|
|
41
40
|
return names.filter((name) => !present.has(name) && bundleSwapInFlightElsewhere(scopeRootPath, name));
|
|
42
41
|
}
|
|
43
42
|
export function listInstalledPluginsInRoot(scope, scopeRootPath) {
|
|
44
|
-
// A cloud-role process serves many people and holds no plugin directories
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
if (scope === 'user' && isCloudRole() && person === undefined)
|
|
43
|
+
// A cloud-role process serves many people and holds no plugin directories: a node's plugin kinds come from publication rows
|
|
44
|
+
// (`appLaunchKinds`) and its plugin code from the sandbox that runs it.
|
|
45
|
+
if (scope === 'user' && isCloudRole())
|
|
48
46
|
return [];
|
|
49
|
-
const dir = scope !== 'user' ? join(scopeRootPath, 'plugins') :
|
|
47
|
+
const dir = scope !== 'user' ? join(scopeRootPath, 'plugins') : pluginsDir('user');
|
|
50
48
|
if (dir === undefined || !pathExists(dir))
|
|
51
49
|
return [];
|
|
52
50
|
const cfg = pluginConfigForRoot(scope, scopeRootPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The read rule for shipped package documents: which published package apps a reader may read the documents of.
|
|
2
|
+
* `crtr` (builtin) always; a provider when the reader's scopes hold a scope of it; a plugin on a Mac always (the local install set is the Mac's
|
|
3
|
+
* own), on a cloud role when the reader's app has it enabled from the marketplace the publication was read from. The ceiling applies the set to
|
|
4
|
+
* every shipped document whichever root admitted it. */
|
|
5
|
+
import type { CanvasDb } from '../canvas/db.js';
|
|
6
|
+
export interface EligibilityInput {
|
|
7
|
+
/** whether the reader's scopes hold a scope of the provider app with this external id (`app:<id>`) */
|
|
8
|
+
holdsProvider: (appExt: string) => boolean;
|
|
9
|
+
cloud: boolean;
|
|
10
|
+
/** the reader's app external id (`app:<id>`), null when the app has none */
|
|
11
|
+
ownExt: string | null;
|
|
12
|
+
/** the bare user id the reader acts for; null for a reader that acts for no person */
|
|
13
|
+
user: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** Object ids of the live published package apps the reader may read the shipped documents of. */
|
|
16
|
+
export declare function eligiblePackageApps(db: CanvasDb, input: EligibilityInput): Promise<string[]>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { normalizeSourceUrl } from './source-url.js';
|
|
2
|
+
/** Object ids of the live published package apps the reader may read the shipped documents of. */
|
|
3
|
+
export async function eligiblePackageApps(db, input) {
|
|
4
|
+
const rows = await db.prepare(`SELECT p.app, p.origin, p.source, i.external_id FROM package_publications p
|
|
5
|
+
JOIN identities i ON i.object = p.app AND i.scheme = 'app' WHERE p.withdrawn_at IS NULL`).all();
|
|
6
|
+
let installed = null;
|
|
7
|
+
const installs = async () => {
|
|
8
|
+
if (installed !== null)
|
|
9
|
+
return installed;
|
|
10
|
+
installed = new Map();
|
|
11
|
+
if (input.user === null || input.ownExt === null)
|
|
12
|
+
return installed;
|
|
13
|
+
const found = await db.prepare(`SELECT p.name, m.url FROM scope_plugins p JOIN scope_marketplaces m
|
|
14
|
+
ON m.person = p.person AND m.owner_key = p.owner_key AND m.name = p.source_marketplace
|
|
15
|
+
WHERE p.person = ? AND p.owner_key = ? AND p.enabled = 1 AND m.url IS NOT NULL`).all(`user:${input.user}`, input.ownExt);
|
|
16
|
+
for (const r of found)
|
|
17
|
+
installed.set(r.name, normalizeSourceUrl(r.url));
|
|
18
|
+
return installed;
|
|
19
|
+
};
|
|
20
|
+
const eligible = [];
|
|
21
|
+
for (const r of rows) {
|
|
22
|
+
if (r.origin === 'builtin')
|
|
23
|
+
eligible.push(r.app);
|
|
24
|
+
else if (r.origin === 'provider') {
|
|
25
|
+
if (input.holdsProvider(r.external_id))
|
|
26
|
+
eligible.push(r.app);
|
|
27
|
+
}
|
|
28
|
+
else if (!input.cloud)
|
|
29
|
+
eligible.push(r.app);
|
|
30
|
+
else if (r.source !== null && (await installs()).get(r.external_id.replace(/^app:/, '')) === r.source)
|
|
31
|
+
eligible.push(r.app);
|
|
32
|
+
}
|
|
33
|
+
return eligible;
|
|
34
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The launch `kinds` a cloud role layers into a node's launch config: those of the plugins enabled in the node's app whose published package was
|
|
2
|
+
* read from the marketplace the app installed it from. A cloud role holds no plugin directories, so the publication rows are the only source. */
|
|
3
|
+
import type { CanvasDb } from '../canvas/db.js';
|
|
4
|
+
import type { PersonSnapshot } from '../scoped-state/person-store.js';
|
|
5
|
+
/** Launch kinds by plugin name for the plugins enabled in `app`, from live publication rows whose `source` is the install's marketplace. */
|
|
6
|
+
export declare function appLaunchKinds(db: CanvasDb, snapshot: PersonSnapshot, app: string): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { normalizeSourceUrl } from './source-url.js';
|
|
2
|
+
/** Launch kinds by plugin name for the plugins enabled in `app`, from live publication rows whose `source` is the install's marketplace. */
|
|
3
|
+
export async function appLaunchKinds(db, snapshot, app) {
|
|
4
|
+
const block = snapshot.apps[app];
|
|
5
|
+
if (block === undefined)
|
|
6
|
+
return {};
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const [name, entry] of Object.entries(block.plugins)) {
|
|
9
|
+
if (entry.enabled === false || entry.source_marketplace === undefined)
|
|
10
|
+
continue;
|
|
11
|
+
const url = block.marketplaces[entry.source_marketplace]?.url;
|
|
12
|
+
if (url === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
const row = await db.prepare(`SELECT p.source, p.launch_kinds_json FROM package_publications p JOIN identities i ON i.object = p.app AND i.scheme = 'app'
|
|
15
|
+
WHERE i.external_id = ? AND p.origin = 'plugin' AND p.withdrawn_at IS NULL AND p.launch_kinds_json IS NOT NULL`).get(`app:${name}`);
|
|
16
|
+
if (row !== undefined && row.source === normalizeSourceUrl(url))
|
|
17
|
+
out[name] = JSON.parse(row.launch_kinds_json);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** A marketplace git URL as the directory normalizes it: lowercased host, no `.git` suffix, no trailing slash. A plugin package is published
|
|
2
|
+
* with this URL as its `source`, and the read rule matches an install's marketplace URL against it. */
|
|
3
|
+
export declare function normalizeSourceUrl(url: string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A marketplace git URL as the directory normalizes it: lowercased host, no `.git` suffix, no trailing slash. A plugin package is published
|
|
2
|
+
* with this URL as its `source`, and the read rule matches an install's marketplace URL against it. */
|
|
3
|
+
export function normalizeSourceUrl(url) {
|
|
4
|
+
const trimmed = url.trim();
|
|
5
|
+
let out;
|
|
6
|
+
try {
|
|
7
|
+
const u = new URL(trimmed);
|
|
8
|
+
u.hostname = u.hostname.toLowerCase();
|
|
9
|
+
out = u.toString();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// scp-style `git@host:owner/repo`: lowercase the host part only
|
|
13
|
+
const m = /^([^@\s]+@)?([^:/\s]+):(.+)$/.exec(trimmed);
|
|
14
|
+
out = m === null ? trimmed : `${m[1] ?? ''}${m[2].toLowerCase()}:${m[3]}`;
|
|
15
|
+
}
|
|
16
|
+
return out.replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '');
|
|
17
|
+
}
|
|
@@ -28,6 +28,9 @@ export interface Ceiling {
|
|
|
28
28
|
/** App object ids for the `authored:` match (first-revision author rule): the caller's app and every first-party app, one whose grant holds
|
|
29
29
|
* `crtr:memory:manage:user` always. A revoked first-party grant still counts: revocation stops its calls, not who wrote the document. */
|
|
30
30
|
authorApps: string[];
|
|
31
|
+
/** The read rule for shipped package documents: the published package apps (object ids) whose shipped documents the reader may read. A shipped document
|
|
32
|
+
* (`documents.source_path` set) passes only when its owner is in this set, whichever root admitted it. Null: no read is asked, nothing is narrowed. */
|
|
33
|
+
packageApps: string[] | null;
|
|
31
34
|
}
|
|
32
35
|
export type Access = 'read' | 'write' | 'message';
|
|
33
36
|
export interface CeilingOptions {
|
|
@@ -38,8 +41,12 @@ export interface CeilingOptions {
|
|
|
38
41
|
export declare function personObjectIds(db: CanvasDb): Promise<string[]>;
|
|
39
42
|
/** Whether `scopes` hold a provider-group scope `<provider>:<group>` of the app whose external id is `appExt`: what lets a run read that app's private objects. */
|
|
40
43
|
export declare function holdsProviderGroup(scopes: readonly string[], appExt: string): boolean;
|
|
41
|
-
/** The
|
|
42
|
-
|
|
44
|
+
/** The published package apps whose shipped documents `reader` may read (the read rule); null for a person reader, who reads every package. `user` is the
|
|
45
|
+
* bare user id the reader acts for, null for a reader that acts for no person. A revoked grant (null scopes) holds no provider scope. */
|
|
46
|
+
export declare function readablePackageApps(db: CanvasDb, reader: Reader, user: string | null): Promise<string[] | null>;
|
|
47
|
+
/** The ceiling of `reader` for `access`; null for a person reader (no ceiling). `user` is the bare user id the reader acts for (`readerUser`), null for a
|
|
48
|
+
* reader that acts for no person: it reads the package apps every reader reads and the providers its scopes hold, never a plugin. */
|
|
49
|
+
export declare function resolveCeiling(db: CanvasDb, reader: Reader, user: string | null, access: Access, options?: CeilingOptions): Promise<Ceiling | null>;
|
|
43
50
|
export interface RunPlace {
|
|
44
51
|
run: string;
|
|
45
52
|
isolated: boolean;
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// then applied inside the same query as the schema check (`ceilingFrag`). A person reader has no ceiling. Privacy (`private` per root), run
|
|
3
3
|
// confinement (`place`) and the launched own-memory narrowing are all rules here; none of them is schema text.
|
|
4
4
|
import { covers } from '@north-light/identity';
|
|
5
|
+
import { isCloudRole } from '../canvas/person-state.js';
|
|
6
|
+
import { eligiblePackageApps } from '../packages/eligibility.js';
|
|
5
7
|
import { fileRoot } from '../spaces/file-root.js';
|
|
6
8
|
import { FALSE, TRUE, and, f, or, raw } from './sql.js';
|
|
7
|
-
const empty = (ownApp) => ({ ownApp, roots: [], namespaces: [], place: null, predicates: { allNodes: false, spaces: false }, authorApps: [] });
|
|
9
|
+
const empty = (ownApp) => ({ ownApp, roots: [], namespaces: [], place: null, predicates: { allNodes: false, spaces: false }, authorApps: [], packageApps: null });
|
|
8
10
|
async function externalId(db, objectId) {
|
|
9
11
|
const row = await db.prepare('SELECT external_id FROM identities WHERE object = ? ORDER BY scheme LIMIT 1').get(objectId);
|
|
10
12
|
return row?.external_id ?? null;
|
|
@@ -60,8 +62,17 @@ async function repoRoots(db, reader, access) {
|
|
|
60
62
|
ids.add(c.repo.id);
|
|
61
63
|
return [...ids];
|
|
62
64
|
}
|
|
63
|
-
/** The
|
|
64
|
-
|
|
65
|
+
/** The published package apps whose shipped documents `reader` may read (the read rule); null for a person reader, who reads every package. `user` is the
|
|
66
|
+
* bare user id the reader acts for, null for a reader that acts for no person. A revoked grant (null scopes) holds no provider scope. */
|
|
67
|
+
export async function readablePackageApps(db, reader, user) {
|
|
68
|
+
if (reader.class === 'person')
|
|
69
|
+
return null;
|
|
70
|
+
const held = reader.scopes;
|
|
71
|
+
return await eligiblePackageApps(db, { holdsProvider: (ext) => held !== null && holdsProviderGroup(held, ext), cloud: isCloudRole(), ownExt: await externalId(db, reader.app.id), user });
|
|
72
|
+
}
|
|
73
|
+
/** The ceiling of `reader` for `access`; null for a person reader (no ceiling). `user` is the bare user id the reader acts for (`readerUser`), null for a
|
|
74
|
+
* reader that acts for no person: it reads the package apps every reader reads and the providers its scopes hold, never a plugin. */
|
|
75
|
+
export async function resolveCeiling(db, reader, user, access, options = {}) {
|
|
65
76
|
if (reader.class === 'person')
|
|
66
77
|
return null;
|
|
67
78
|
const c = empty(reader.app.id);
|
|
@@ -90,6 +101,9 @@ export async function resolveCeiling(db, reader, access, options = {}) {
|
|
|
90
101
|
own(p.id, 'no-node');
|
|
91
102
|
}
|
|
92
103
|
}
|
|
104
|
+
// The shipped-document filter applies to every reader, a revoked grant's included: it holds no provider scope, so it reads only the package apps eligible without one.
|
|
105
|
+
if (access === 'read')
|
|
106
|
+
c.packageApps = await readablePackageApps(db, reader, user) ?? [];
|
|
93
107
|
if (held === null)
|
|
94
108
|
return c; // a revoked grant reaches only its own app
|
|
95
109
|
c.predicates.spaces = true;
|
|
@@ -176,11 +190,9 @@ export async function resolveCeiling(db, reader, access, options = {}) {
|
|
|
176
190
|
}
|
|
177
191
|
// ---- tool-usable apps (read): their shipped documents and custom objects
|
|
178
192
|
if (access === 'read') {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (a.id !== reader.app.id)
|
|
183
|
-
c.roots.push({ id: a.id, private: false, match: 'shipped' });
|
|
193
|
+
for (const id of c.packageApps ?? [])
|
|
194
|
+
if (id !== reader.app.id)
|
|
195
|
+
c.roots.push({ id, private: false, match: 'shipped' });
|
|
184
196
|
for (const a of await db.prepare("SELECT o.id, i.external_id FROM objects o JOIN identities i ON i.object = o.id AND i.scheme = 'app' WHERE o.type = 'app' AND o.deleted_at IS NULL").all()) {
|
|
185
197
|
if (a.id !== reader.app.id)
|
|
186
198
|
c.roots.push({ id: a.id, private: holdsProviderGroup(held, a.external_id), match: 'custom' });
|
|
@@ -256,7 +268,9 @@ export function ceilingFrag(c, o) {
|
|
|
256
268
|
parts.push(raw(`(${o}.type = 'node' AND ${o}.privacy = 'public')`));
|
|
257
269
|
if (c.predicates.spaces)
|
|
258
270
|
parts.push(raw(`(${o}.owner IS NULL AND ${o}.type IN ('app','person','repo'))`));
|
|
259
|
-
|
|
271
|
+
const shipped = c.packageApps === null ? TRUE
|
|
272
|
+
: f `(NOT EXISTS (SELECT 1 FROM documents sd WHERE sd.object = ${raw(o)}.id AND sd.source_path IS NOT NULL) OR ${raw(o)}.owner = ANY(${c.packageApps}::text[]))`;
|
|
273
|
+
return and([or(parts), placeFrag(c, o), shipped]);
|
|
260
274
|
}
|
|
261
275
|
/** Run confinement, for every object below the reader's own app regardless of the alternative that passed it (a root, an own namespace, `objects:read:node`):
|
|
262
276
|
* the nearest node of the object (below the app) must be in the reader's run, or neither it nor the reader's run is isolated. An object not
|
|
@@ -71,7 +71,7 @@ export async function permits(db, reader, permission, alias = 'o', options = {})
|
|
|
71
71
|
}
|
|
72
72
|
const schemaPart = cases.length === 0 ? f `(CASE WHEN ${raw(alias)}.schema_type IS NULL THEN ${builtin} ELSE FALSE END)`
|
|
73
73
|
: f `(CASE WHEN ${raw(alias)}.schema_type IS NULL THEN ${builtin} ${{ sql: cases.map((c) => c.sql).join(' '), params: cases.flatMap((c) => c.params) }} ELSE FALSE END)`;
|
|
74
|
-
const ceiling = await resolveCeiling(db, ev.reader, ACCESS[permission], options);
|
|
74
|
+
const ceiling = await resolveCeiling(db, ev.reader, ev.userId, ACCESS[permission], options);
|
|
75
75
|
if (ceiling === null)
|
|
76
76
|
return schemaPart;
|
|
77
77
|
return and([ceilingFrag(ceiling, alias), schemaPart]);
|
|
@@ -83,9 +83,8 @@ async function recordEtag(id, etag) {
|
|
|
83
83
|
else
|
|
84
84
|
atomicWriteText(etagFile(id), etag);
|
|
85
85
|
}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// deployment-wide. Provider documents are therefore not published on a cloud role.
|
|
86
|
+
// A Mac publishes a bundle's `memory/**` documents with the rest of its local packages. A cloud role has no per-person plugin list at grant sync and holds
|
|
87
|
+
// no provider document text: the directory serves each provider package and the api role's package sync publishes it. The pod-disk bundle here is for commands.
|
|
89
88
|
async function reconcileProviderDocs(id) {
|
|
90
89
|
if (isCloudRole())
|
|
91
90
|
return;
|
|
@@ -32,12 +32,16 @@ export declare function isLaunchKind(kind: string): boolean;
|
|
|
32
32
|
/** Validate a kind against the same cwd/profile configuration used to build its launch.
|
|
33
33
|
* Resolution is subprocess-isolated: crtrd is single-threaded and a config read can
|
|
34
34
|
* block indefinitely on a network filesystem. */
|
|
35
|
-
export declare function assertLaunchKind(kind: string, cwd?: string, profileId?: string | null
|
|
35
|
+
export declare function assertLaunchKind(kind: string, cwd?: string, profileId?: string | null,
|
|
36
|
+
/** The app the kind is launched in. */
|
|
37
|
+
app?: string): Promise<void>;
|
|
36
38
|
/** Resolve the exact profile, kind, cwd, mode and launch recipe a create will use. */
|
|
37
39
|
export declare function resolveLaunchTarget(req: CreateNodeRequest, options?: {
|
|
38
40
|
readOnlyProfileSelection?: boolean;
|
|
39
41
|
/** Preserve an existing caller's validation boundary before target resolution. */
|
|
40
|
-
validateKind?: (kind: string) => void | Promise<void>;
|
|
42
|
+
validateKind?: (kind: string, app: string) => void | Promise<void>;
|
|
43
|
+
/** The app the node runs in, when its caller is an app's broker or cron; else its parent's, else `app:terminal`. */
|
|
44
|
+
app?: string;
|
|
41
45
|
}): Promise<ResolvedLaunchTarget>;
|
|
42
46
|
/** The directory a new node is pinned to. */
|
|
43
47
|
export declare function resolveNodeCwd(req: CreateNodeRequest, parent: string | undefined, _profileId: string | null, contextCwd: string): Promise<string>;
|
|
@@ -39,8 +39,10 @@ export function isLaunchKind(kind) {
|
|
|
39
39
|
/** Validate a kind against the same cwd/profile configuration used to build its launch.
|
|
40
40
|
* Resolution is subprocess-isolated: crtrd is single-threaded and a config read can
|
|
41
41
|
* block indefinitely on a network filesystem. */
|
|
42
|
-
export async function assertLaunchKind(kind, cwd = process.cwd(), profileId = envProfileId() || null
|
|
43
|
-
|
|
42
|
+
export async function assertLaunchKind(kind, cwd = process.cwd(), profileId = envProfileId() || null,
|
|
43
|
+
/** The app the kind is launched in. */
|
|
44
|
+
app = 'app:terminal') {
|
|
45
|
+
const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId, app)).kinds);
|
|
44
46
|
if (!kinds.includes(kind)) {
|
|
45
47
|
throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
|
|
46
48
|
}
|
|
@@ -54,10 +56,11 @@ export async function resolveLaunchTarget(req, options = {}) {
|
|
|
54
56
|
});
|
|
55
57
|
const profile = assertProfileActive(profileId);
|
|
56
58
|
const kind = resolveCreateKind(req.kind, profile);
|
|
57
|
-
|
|
59
|
+
const app = options.app ?? (parent === undefined ? undefined : (await getNode(parent))?.grantee) ?? 'app:terminal';
|
|
60
|
+
await options.validateKind?.(kind, app);
|
|
58
61
|
const cwd = await resolveNodeCwd({ ...req, kind }, parent, profileId, contextCwd);
|
|
59
62
|
if (options.validateKind === undefined)
|
|
60
|
-
await assertLaunchKind(kind, cwd, profileId);
|
|
63
|
+
await assertLaunchKind(kind, cwd, profileId, app);
|
|
61
64
|
const mode = (req.mode ?? 'base');
|
|
62
65
|
const launch = (await buildLaunchSpecAsync(kind, mode, {
|
|
63
66
|
lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
|
|
@@ -65,6 +68,7 @@ export async function resolveLaunchTarget(req, options = {}) {
|
|
|
65
68
|
...(req.model !== undefined ? { model: req.model, modelOverride: true } : {}),
|
|
66
69
|
cwd,
|
|
67
70
|
profileId,
|
|
71
|
+
app,
|
|
68
72
|
})).launch;
|
|
69
73
|
return { profileId, profile, kind, mode, cwd, launch };
|
|
70
74
|
}
|
|
@@ -94,6 +94,9 @@ export interface LaunchBuildOptions {
|
|
|
94
94
|
* exactly the path that creates the user's sessions — so callers that
|
|
95
95
|
* know the node's profile must pass it explicitly (null = no profile). */
|
|
96
96
|
profileId?: string | null;
|
|
97
|
+
/** The app the launched node runs in: a cloud role layers in the launch kinds of the plugins enabled in THIS app. Required by the async
|
|
98
|
+
* builder (the only one a cloud role reaches); a launch for an existing or soon-to-exist node passes that node's own app. */
|
|
99
|
+
app?: string;
|
|
97
100
|
/** Preserved intent from an existing launch recipe when rebuilding it. */
|
|
98
101
|
modelIntent?: ModelIntent;
|
|
99
102
|
/** Exact-consult launch: pass the captured `provider/id:thinking` spec
|
|
@@ -115,7 +118,9 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: LaunchBu
|
|
|
115
118
|
* filesystem, so even an ordinary synchronous config read must never run on
|
|
116
119
|
* its event loop.
|
|
117
120
|
*/
|
|
118
|
-
export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions
|
|
121
|
+
export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions & {
|
|
122
|
+
app: string;
|
|
123
|
+
}): Promise<{
|
|
119
124
|
launch: LaunchSpec;
|
|
120
125
|
lifecycle: 'terminal' | 'resident';
|
|
121
126
|
}>;
|
|
@@ -124,6 +129,8 @@ export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: Lau
|
|
|
124
129
|
* failure, an uncaught exception). Exported for tests. */
|
|
125
130
|
export declare function describeLaunchConfigExit(code: number | null, signal: NodeJS.Signals | null, stderr: string): string;
|
|
126
131
|
export declare function readMergedLaunchConfigAsync(cwd: string, profileId: string | null,
|
|
132
|
+
/** The app the launch runs in. */
|
|
133
|
+
app: string,
|
|
127
134
|
/** Test seam: the interpreter and worker script to fork with. */
|
|
128
135
|
seam?: {
|
|
129
136
|
execPath?: string;
|
|
@@ -12,7 +12,9 @@ import { envProfileId } from '../../shared/env.js';
|
|
|
12
12
|
import { readMergedLaunchConfig } from '../config.js';
|
|
13
13
|
import { fork } from 'node:child_process';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { personId, PersonUnresolvedError, PersonOutsideRequestError } from '../canvas/person-state.js';
|
|
15
|
+
import { isCloudRole, personId, PersonUnresolvedError, PersonOutsideRequestError } from '../canvas/person-state.js';
|
|
16
|
+
import { openDb } from '../canvas/db.js';
|
|
17
|
+
import { appLaunchKinds } from '../packages/launch-kinds.js';
|
|
16
18
|
import { currentSnapshot, PersonSettingsNotLoadedError } from '../scoped-state/person-store.js';
|
|
17
19
|
import { freshChildNodeExecPath, recheckChildNode } from './branded-host.js';
|
|
18
20
|
import { LAUNCH_SYSTEM_PROMPT } from './launch-prompt.js';
|
|
@@ -277,7 +279,7 @@ export function buildLaunchSpec(kind, mode, opts) {
|
|
|
277
279
|
export async function buildLaunchSpecAsync(kind, mode, opts) {
|
|
278
280
|
const cwd = opts.cwd ?? process.cwd();
|
|
279
281
|
const profileId = opts.profileId !== undefined ? opts.profileId : (envProfileId() || null);
|
|
280
|
-
const merged = await readMergedLaunchConfigAsync(cwd, profileId);
|
|
282
|
+
const merged = await readMergedLaunchConfigAsync(cwd, profileId, opts.app);
|
|
281
283
|
return buildLaunchSpecFromMerged(kind, mode, opts, merged);
|
|
282
284
|
}
|
|
283
285
|
const LAUNCH_CONFIG_TIMEOUT_MS = 15_000;
|
|
@@ -293,12 +295,17 @@ export function describeLaunchConfigExit(code, signal, stderr) {
|
|
|
293
295
|
class LaunchConfigWorkerCrash extends Error {
|
|
294
296
|
}
|
|
295
297
|
export async function readMergedLaunchConfigAsync(cwd, profileId,
|
|
298
|
+
/** The app the launch runs in. */
|
|
299
|
+
app,
|
|
296
300
|
/** Test seam: the interpreter and worker script to fork with. */
|
|
297
301
|
seam = {}) {
|
|
298
302
|
const execPath = seam.execPath ?? await freshChildNodeExecPath();
|
|
299
303
|
const workerPath = seam.workerPath ?? fileURLToPath(new URL('./launch-config-worker.js', import.meta.url));
|
|
304
|
+
// A cloud role holds no plugin directories: the worker gets the launch kinds of the plugins enabled in the launch's app, read here from publication rows.
|
|
305
|
+
const held = heldPersonSettings();
|
|
306
|
+
const pluginKinds = !isCloudRole() ? undefined : 'snapshot' in held ? await appLaunchKinds(await openDb(), held.snapshot, app) : {};
|
|
300
307
|
try {
|
|
301
|
-
return await runLaunchConfigWorker(cwd, profileId, execPath, workerPath);
|
|
308
|
+
return await runLaunchConfigWorker(cwd, profileId, execPath, workerPath, held, pluginKinds);
|
|
302
309
|
}
|
|
303
310
|
catch (error) {
|
|
304
311
|
if (!(error instanceof LaunchConfigWorkerCrash))
|
|
@@ -309,7 +316,7 @@ seam = {}) {
|
|
|
309
316
|
const next = await recheckChildNode(execPath);
|
|
310
317
|
if (next === execPath)
|
|
311
318
|
throw error;
|
|
312
|
-
return runLaunchConfigWorker(cwd, profileId, next, workerPath);
|
|
319
|
+
return runLaunchConfigWorker(cwd, profileId, next, workerPath, held, pluginKinds);
|
|
313
320
|
}
|
|
314
321
|
}
|
|
315
322
|
/** The calling process's person and settings snapshot, when it holds them (a process with none reads defaults, as the worker will). */
|
|
@@ -323,7 +330,7 @@ function heldPersonSettings() {
|
|
|
323
330
|
throw error;
|
|
324
331
|
}
|
|
325
332
|
}
|
|
326
|
-
function runLaunchConfigWorker(cwd, profileId, execPath, workerPath) {
|
|
333
|
+
function runLaunchConfigWorker(cwd, profileId, execPath, workerPath, held, appPluginKinds) {
|
|
327
334
|
return new Promise((resolve, reject) => {
|
|
328
335
|
const child = fork(workerPath, [], {
|
|
329
336
|
execPath,
|
|
@@ -332,7 +339,7 @@ function runLaunchConfigWorker(cwd, profileId, execPath, workerPath) {
|
|
|
332
339
|
});
|
|
333
340
|
let stderr = '';
|
|
334
341
|
child.stderr?.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-8_192); });
|
|
335
|
-
child.send({ cwd, profileId, ...
|
|
342
|
+
child.send({ cwd, profileId, ...held, appPluginKinds });
|
|
336
343
|
let settled = false;
|
|
337
344
|
const finish = (fn) => {
|
|
338
345
|
if (settled)
|
|
@@ -313,13 +313,13 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCrea
|
|
|
313
313
|
// before node creation remains the sole authoritative race decider.
|
|
314
314
|
const profileId = await resolveProfileId(opts.profile, spawner, birthCwd);
|
|
315
315
|
const launchGrant = opts.run?.grant_id ?? spawnerNode?.grant_id ?? null;
|
|
316
|
+
// The identity the node will carry (a run's own, else its creator's or spawner's, else the terminal person) pays for it and picks the app it launches in.
|
|
317
|
+
const payerSource = opts.run ?? creatorNode ?? spawnerNode ?? forkSourceMeta;
|
|
316
318
|
const preflightLaunch = await appPaidLaunch((await buildLaunchSpecAsync(opts.kind, mode, {
|
|
317
|
-
lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: birthCwd, profileId,
|
|
319
|
+
lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: birthCwd, profileId, app: payerSource?.grantee ?? 'app:terminal',
|
|
318
320
|
})).launch, launchGrant, opts.model);
|
|
319
321
|
await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: birthCwd, profileId });
|
|
320
|
-
// The start check, before any worktree or node row exists: a refusal leaves nothing behind.
|
|
321
|
-
// node will carry (a run's own, else its creator's or spawner's, else the terminal person).
|
|
322
|
-
const payerSource = opts.run ?? creatorNode ?? spawnerNode ?? forkSourceMeta;
|
|
322
|
+
// The start check, before any worktree or node row exists: a refusal leaves nothing behind.
|
|
323
323
|
await checkStart({ grantee: payerSource?.grantee ?? 'app:terminal', sub: payerSource?.sub, grant_id: payerSource?.grant_id });
|
|
324
324
|
const forkSourceNodeId = forkSourceMeta?.node_id ?? null;
|
|
325
325
|
let managedWorktree;
|
|
@@ -340,7 +340,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCrea
|
|
|
340
340
|
// which silently hid every profile-scope kind override).
|
|
341
341
|
const launch = wantsWorktree
|
|
342
342
|
? await appPaidLaunch((await buildLaunchSpecAsync(opts.kind, mode, {
|
|
343
|
-
lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: spawnCwd, profileId,
|
|
343
|
+
lifecycle, hasManager: !root, model: opts.model, modelOverride: opts.model !== undefined, cwd: spawnCwd, profileId, app: payerSource?.grantee ?? 'app:terminal',
|
|
344
344
|
})).launch, launchGrant, opts.model)
|
|
345
345
|
: preflightLaunch;
|
|
346
346
|
if (sandboxWorktree !== undefined)
|
|
@@ -113,6 +113,7 @@ async function resolveRecipe(request) {
|
|
|
113
113
|
...(request.model !== null ? { model: request.model } : {}),
|
|
114
114
|
cwd: request.cwd,
|
|
115
115
|
profileId: request.profileId,
|
|
116
|
+
app: 'app:terminal',
|
|
116
117
|
});
|
|
117
118
|
return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
|
|
118
119
|
}
|
|
@@ -42,7 +42,7 @@ export function readLegacyUserSnapshot(root) {
|
|
|
42
42
|
for (const r of cachedStatement(db, "SELECT owner_key, name, last_updated, etag, checked_at FROM scope_plugins WHERE owner_key != 'project'").all()) {
|
|
43
43
|
stateOf(r.owner_key).plugins[r.name] = { ...(r.last_updated === null ? {} : { last_updated: r.last_updated }), ...(r.etag === null ? {} : { etag: r.etag }), ...(r.checked_at === null ? {} : { checked_at: r.checked_at }) };
|
|
44
44
|
}
|
|
45
|
-
return { profiles, defaults, settings, state };
|
|
45
|
+
return { profiles, defaults, settings, state, apps: {} };
|
|
46
46
|
}
|
|
47
47
|
/** Pre-canvas per-project memory caps (`none|name|preview`) the canvas import has not converted yet. */
|
|
48
48
|
export function listLegacyProjectCaps(root) {
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await db.prepare('
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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;
|