@byokit/openclaw 0.14.2 → 0.14.5
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/CHANGELOG.md +24 -0
- package/README.md +8 -1
- package/dist/accounts.js +7 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -1
- package/dist/engine.d.ts +7 -0
- package/dist/engine.js +23 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keys.d.ts +10 -1
- package/dist/keys.js +36 -6
- package/dist/kit.d.ts +13 -2
- package/dist/kit.js +72 -7
- package/dist/members.d.ts +3 -0
- package/dist/members.js +10 -0
- package/dist/plugins.d.ts +17 -0
- package/dist/plugins.js +40 -0
- package/dist/routes.json +288 -288
- package/dist/signin.d.ts +5 -0
- package/dist/signin.js +50 -0
- package/dist/testing/fake-gateway.js +8 -4
- package/engine/plugins.json +150 -0
- package/package.json +6 -6
- package/policy/policy.mjs +43 -14
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.14.5 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
- FIX: adding a Go plan key that names no workspace (a bound service key the console refuses to list) now clears the standing workspace header before its probe, instead of carrying the previous workspace on its calls. A failed add's cleanup now drops the workspace header with the key, instead of leaving it behind for the next key.
|
|
8
|
+
- FEAT: `kit.installPlugin(pluginId)` installs the external provider plugin a route needs (`readiness: 'needs_plugin'`, such as Z.AI's GLM Coding Plan routes) into the kit's own engine, at exactly the engine's version and with the pinned npm integrity (`engine/plugins.json`). The plugin joins `plugins.allow` across restarts and `routes()` then reports its routes ready, so `addKey` reaches the engine's key test. A version or integrity mismatch rejects with `PluginInstallError` (`code: 'version' | 'integrity'`) and installs nothing; discovery never installs. The GLM Coding Plan sign-ins are subscription; the plain Z.AI key rows stay API key (billed per use), explicit opt-in.
|
|
9
|
+
- FIX: the install policy no longer blocks every `@openclaw/` npm plugin at its dependency-tree check; plugin installs are allowed only at a pinned spec and integrity.
|
|
10
|
+
- FIX: `removeAccount` and a cancelled/failed `addAccount` no longer resolve while the removed agent is still on the roster. The engine answers `agents.delete` before its roster hot-reload applies (the same lag as after `agents.create`), so an immediate `agents.list` could still show the gone agent and a cancelled ChatGPT add flaked as "the cancelled agent is deleted". Both delete paths now wait until the roster drops the agent (`untilUnlisted`, the `untilListed` mirror: 25 ms poll, ~5 s bound, plain error naming the agent on timeout). The fake gateway's `rosterApplyMs` now lags the delete roster the same way for engine parity.
|
|
11
|
+
- FIX: a Go plan key pasted over the link signs in with its workspace. The wizard sign-in listed no workspaces, so a key acting for several was refused on the activation probe and every run (403 Workspace selection is required). The sign-in now lists the key's workspaces the way `addKey` does (one used unasked; several asked through the same hidden paste; refused or unreadable listing fails before anything changes) and writes the pick as the provider header before activation.
|
|
12
|
+
|
|
13
|
+
## 0.14.4 (2026-10-17)
|
|
14
|
+
|
|
15
|
+
- Dependency update: pins @byokit/relay 0.8.0.
|
|
16
|
+
- Dependency update: pins @byokit/pair 0.10.2.
|
|
17
|
+
- Dependency update: pins @byokit/accounts 0.24.3.
|
|
18
|
+
|
|
19
|
+
- FIX: `routes()` now describes the engine the kit actually pins. The generated table was still built from the 2026.8.1 pin while `ENGINE_VERSION` is 2026.8.35, so every row said `source: "... (2026.8.1)"` and `revision: "2026.8.1"`. `scripts/gen-routes.ts` pins 2026.8.35 (upstream commit from `engine/patches.json`), reads manifest directories only (the 2026.8.35 source tree keeps an `extensions/.npmignore` file), and a new public-API journey test fails whenever the table names anything but `ENGINE_VERSION`. All 96 rows re-verified against the 2026.8.35 manifests: the choice set is identical (nothing added or removed); every row now carries `(2026.8.35)`.
|
|
20
|
+
|
|
21
|
+
## 0.14.3 (2026-10-17)
|
|
22
|
+
|
|
23
|
+
- Dependency update: pins @byokit/ui 0.9.0.
|
|
24
|
+
- Dependency update: pins @byokit/discover 0.8.3.
|
|
25
|
+
- Dependency update: pins @byokit/accounts 0.24.2.
|
|
26
|
+
|
|
27
|
+
- FIX: `addKey` for an OpenCode Go plan key with no `orgId` no longer saves a key whose every call is refused with `403 Workspace selection is required`. The add lists the key's workspaces first (`openCodeWorkspaces` from `@byokit/accounts/opencode-go`): one is used as the `x-opencode-org-id` header; several resolve the new `AddKeyResult` `{ status: 'needs_workspace', workspaces: [{ id, name }] }` for the app's pick (add again with `orgId`); a key the console refuses adds with no header. A listing that could not be read resolves `{ status: 'retry', reason: 'network' }` and changes nothing. An explicit `orgId` is used as before.
|
|
28
|
+
|
|
5
29
|
## 0.14.2 (2026-10-16)
|
|
6
30
|
|
|
7
31
|
- Dependency update: pins @byokit/relay 0.7.1.
|
package/README.md
CHANGED
|
@@ -157,6 +157,13 @@ register (`retry`). `network` is a timeout or transport failure; `inference` is
|
|
|
157
157
|
probe failure, or a typed request-validation refusal; `engine` is a control or preparation failure; `busy` is a rate limit or a
|
|
158
158
|
restarting engine. `not_included` covers an unsupported route, an unknown chosen model and a probe reporting the route unavailable;
|
|
159
159
|
every other failure is `retry`. Migrate from the old string result with `result.status === 'ok'`, not `result === 'ok'`.
|
|
160
|
+
An OpenCode Go plan login can act for several workspaces, and the Go gateway refuses its every call until one is named
|
|
161
|
+
(`403 Workspace selection is required`). Pass the workspace as `orgId` when you have it; it rides the probe and every run as
|
|
162
|
+
`x-opencode-org-id`. Without `orgId` the add lists the key's workspaces first (the console listing `@byokit/accounts` uses):
|
|
163
|
+
one is used unasked; several resolve `{ status: 'needs_workspace', workspaces: [{ id, name }] }` with nothing saved, so show
|
|
164
|
+
the pick (the names) and add again with `orgId: '<picked id>'`; a key the console refuses (a workspace-bound service key) adds
|
|
165
|
+
with no workspace. A listing that could not be read (offline, a server error) resolves `{ status: 'retry', reason: 'network' }`
|
|
166
|
+
and changes nothing. Workspace ids are row ids, never secrets. Another route refuses an `orgId` with `not_included`/`model`.
|
|
160
167
|
A member that is not your own throws. `retry` means the engine may have taken the key but it was not kept
|
|
161
168
|
sealed (for example a gateway restart ran late); no usable key was kept, so add it again. Failures never contain keys, key fragments, headers or raw engine diagnostics. Label the option **API key (billed per use)**. Select only `routes()` rows with
|
|
162
169
|
`keyEntry: true`; API rows remain `offer: false`. Each route also carries its engine `revision`, `checked` date,
|
|
@@ -381,7 +388,7 @@ streams, [`examples/openclaw-kit`](../../examples/openclaw-kit) for a whole app,
|
|
|
381
388
|
Subscription sign-ins are offered by default. API-billed routes remain in `kit.routes()` with `offer: false`;
|
|
382
389
|
apps can offer them when the app or person opts in, labelled API key (billed per use). Each `Route` carries `billing` (`subscription`, `api` or
|
|
383
390
|
`local`) so the app can label what is billed. Proxy routes, compatibility aliases, local runtimes stay off. Native Claude Code sign-in is offered by default. A plan key (OpenCode Go: `kit.addAccount(member, { authChoice: 'opencode-go' }, on)`) asks for `Plan key`; `paste` it and the engine live-tests it before `done`. A paired device starts the same sign-in with either `via` and pastes the key over the sealed link, which keeps it on the host. A run reaches the pinned engine's static Go models (default `opencode-go/deepseek-v4-pro`); the kit keeps OpenCode Go's `x-opencode-session` header on every request, the setup probe included. A live-only Go model such as Muse needs no app config: the kit registers the plan's live catalog with each model's API, so `run({ model: 'opencode-go/muse-spark-1.3-contributor' })` runs on the account (or with `auth: 'apiKey'` on a Go key); an app that sets `config.models.providers['opencode-go'].models` keeps its own list. A choice the pinned Gateway cannot set up has readiness `no_upstream_flow` and is never offered. Pass the accounts the app offers in `KitOptions.offered` (the provider, auth choice or alias each route carries, e.g. `'openrouter'`); the kit allow-lists the plugin of every route those name, so an opted-in sign-in is not blocked. An app can still name a plugin directly in `config.plugins.allow`, and pass the route's `choice` explicitly to `kit.signIn()`.
|
|
384
|
-
Offered provider plugins are allowed by default alongside the app's plugin ids.
|
|
391
|
+
Offered provider plugins are allowed by default alongside the app's plugin ids. A route whose provider plugin ships outside the engine (Z.AI's GLM Coding Plan, for one) has readiness `needs_plugin` until the app calls `await kit.installPlugin(route.needs.plugin)`: it installs that plugin into the kit's own engine at exactly the engine's version and pinned npm integrity, keeps it allowed across restarts, and rejects `PluginInstallError` (`code` `version` or `integrity`) with nothing installed on a mismatch. Discovery never installs.
|
|
385
392
|
|
|
386
393
|
## API at a glance
|
|
387
394
|
|
package/dist/accounts.js
CHANGED
|
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { DatabaseSync } from 'node:sqlite';
|
|
13
13
|
import { providerOf, resolveSelection } from './pick.js';
|
|
14
14
|
import { agentOf, memberOf as baseMemberOf } from './attribution.js';
|
|
15
|
-
import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from './members.js';
|
|
15
|
+
import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed, untilUnlisted } from './members.js';
|
|
16
16
|
import { routes, routeFor } from './routes.js';
|
|
17
17
|
import { words } from './words.js';
|
|
18
18
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -1007,6 +1007,9 @@ export function createAccounts(ctx) {
|
|
|
1007
1007
|
if (listed.profiles.length)
|
|
1008
1008
|
throw new Error('removeAccount: the account is still signing in');
|
|
1009
1009
|
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
1010
|
+
// The roster hot-reload applies a beat after the delete answer: wait until the agent is gone, so a
|
|
1011
|
+
// roster read right after the removal no longer sees it.
|
|
1012
|
+
await untilUnlisted(ctx.request, agent);
|
|
1010
1013
|
}
|
|
1011
1014
|
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
1012
1015
|
// Already holding the index lock: write directly instead of `edit`, which would wait on itself.
|
|
@@ -1027,6 +1030,9 @@ export function createAccounts(ctx) {
|
|
|
1027
1030
|
if (created) {
|
|
1028
1031
|
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
1029
1032
|
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
1033
|
+
// The roster hot-reload applies a beat after the delete answer: `done` resolves only once the agent is
|
|
1034
|
+
// gone, so a roster read right after a cancelled sign-in no longer sees it.
|
|
1035
|
+
await untilUnlisted(ctx.request, agent);
|
|
1030
1036
|
}
|
|
1031
1037
|
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
1032
1038
|
await edit(member, (file) => { delete file.accounts[id]; });
|
package/dist/config.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare function reconcileConfig(saved: object | undefined, o: {
|
|
|
12
12
|
app?: object;
|
|
13
13
|
installPolicy?: KitOptions['installPolicy'];
|
|
14
14
|
offered?: readonly string[];
|
|
15
|
+
plugins?: readonly string[];
|
|
15
16
|
browser?: {
|
|
16
17
|
profiles: Record<string, {
|
|
17
18
|
cdpUrl: string;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from 'node:path';
|
|
2
2
|
import { routes } from './routes.js';
|
|
3
|
+
import { pluginPins } from './plugins.js';
|
|
3
4
|
import { MEMBER_ID, KEY_PREFIX, BASE_AGENT, keyAgentId } from './members.js';
|
|
4
5
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
5
6
|
const merge = (a, b) => {
|
|
@@ -84,7 +85,9 @@ export function reconcileConfig(saved, o) {
|
|
|
84
85
|
...routes().filter(route => route.offerPolicy === 'default' && !route.needs?.plugin && route.plugin)
|
|
85
86
|
.map(route => route.plugin),
|
|
86
87
|
// Plus every explicitly offered route the app names, so an opted-in sign-in (OpenRouter) is not blocked.
|
|
87
|
-
...offeredPlugins(o.offered)
|
|
88
|
+
...offeredPlugins(o.offered),
|
|
89
|
+
// Plus every external plugin the kit installed (B7): an app's own allow list must not disable it at the next boot.
|
|
90
|
+
...o.plugins ?? []])];
|
|
88
91
|
c.plugins.entries ??= {};
|
|
89
92
|
c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 } } });
|
|
90
93
|
if (o.browser) {
|
|
@@ -112,6 +115,7 @@ export function reconcileConfig(saved, o) {
|
|
|
112
115
|
passEnv: ['OPENCLAW_STATE_DIR'], env: {
|
|
113
116
|
BYOKIT_TRUSTED_SKILLS: o.installPolicy?.trustedSkills ?? '',
|
|
114
117
|
BYOKIT_OWN_ROOTS: JSON.stringify(o.installPolicy?.ownRoots ?? []),
|
|
118
|
+
BYOKIT_PLUGIN_PINS: JSON.stringify(Object.values(pluginPins())),
|
|
115
119
|
},
|
|
116
120
|
} };
|
|
117
121
|
return c;
|
package/dist/engine.d.ts
CHANGED
|
@@ -53,6 +53,13 @@ export declare class Engine {
|
|
|
53
53
|
private freePort;
|
|
54
54
|
prepare(): Promise<void>;
|
|
55
55
|
private prepareOnce;
|
|
56
|
+
/** B7: the engine's own installer for one pinned external plugin, in the isolated engine env with the kit's npm
|
|
57
|
+
* first on PATH (the installer runs `npm`). The explicit kit call is the consent; the install policy holds the pin and
|
|
58
|
+
* its integrity. Resolves the installer's exit code and output tail; the kit then restarts the gateway to load it. */
|
|
59
|
+
installPlugin(spec: string): Promise<{
|
|
60
|
+
code: number | null;
|
|
61
|
+
output: string;
|
|
62
|
+
}>;
|
|
56
63
|
doctorContext(): {
|
|
57
64
|
entry: string;
|
|
58
65
|
env: Record<string, string>;
|
package/dist/engine.js
CHANGED
|
@@ -14,6 +14,7 @@ import { appendUsageBoot } from './usage-boots.js';
|
|
|
14
14
|
import { reconcileConfig, appRecoveryPrefixes } from './config.js';
|
|
15
15
|
import { writePlugin, resolveBridge } from './bridge.js';
|
|
16
16
|
import { sweepOrphans } from './accounts.js';
|
|
17
|
+
import { installedPlugins } from './plugins.js';
|
|
17
18
|
/** The app's stateDir as an absolute path with no symlinked ancestor (macOS /tmp and /var are symlinks):
|
|
18
19
|
* the native Claude login's accounts store refuses any other, so a relative or symlinked stateDir failed it. */
|
|
19
20
|
export function canonicalStateDir(dir) {
|
|
@@ -290,9 +291,30 @@ export class Engine {
|
|
|
290
291
|
const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
|
|
291
292
|
const config = reconcileConfig(saved, { root: this.root, stateDir: this.o.stateDir, port: this.port, pluginId: this.o.pluginId,
|
|
292
293
|
pluginDir, policyPath: join(kitDir, 'policy', 'policy.mjs'), app: this.o.config, offered: this.o.offered,
|
|
293
|
-
installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.() });
|
|
294
|
+
installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.(), plugins: installedPlugins(this.root) });
|
|
294
295
|
putChanged(path, JSON.stringify(config, null, 2) + '\n');
|
|
295
296
|
}
|
|
297
|
+
/** B7: the engine's own installer for one pinned external plugin, in the isolated engine env with the kit's npm
|
|
298
|
+
* first on PATH (the installer runs `npm`). The explicit kit call is the consent; the install policy holds the pin and
|
|
299
|
+
* its integrity. Resolves the installer's exit code and output tail; the kit then restarts the gateway to load it. */
|
|
300
|
+
installPlugin(spec) {
|
|
301
|
+
const { entry, env } = this.doctorContext();
|
|
302
|
+
const npm = this.o.npmPath && this.o.npmPath.includes('/') ? [dirname(this.o.npmPath)] : (process.env.PATH ?? '').split(':');
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
const child = spawn(process.execPath, [entry, 'plugins', 'install', spec, '--force', '--accept-capabilities'], {
|
|
305
|
+
cwd: env.HOME, env: { ...env, PATH: [...npm, env.PATH].join(':'), npm_config_cache: join(this.root, 'npm-cache') },
|
|
306
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
+
});
|
|
308
|
+
let output = '';
|
|
309
|
+
this.started.add(child);
|
|
310
|
+
const keep = (chunk) => { output = (output + chunk).slice(-2000); };
|
|
311
|
+
child.stdout.on('data', keep);
|
|
312
|
+
child.stderr.on('data', keep);
|
|
313
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 300_000);
|
|
314
|
+
child.once('error', error => { clearTimeout(timer); this.started.forget(child); reject(error); });
|
|
315
|
+
child.once('close', code => { clearTimeout(timer); this.started.forget(child); resolve({ code, output }); });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
296
318
|
doctorContext() {
|
|
297
319
|
const home = join(this.root, 'home');
|
|
298
320
|
return { entry: this.entry, env: {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { stateWords, toAccountView, words, type AccountView, type WordKey } from
|
|
|
9
9
|
export { chooseAccount, consider, resolveSelection, type RoomOf } from './pick.ts';
|
|
10
10
|
export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.ts';
|
|
11
11
|
export { EngineAlreadyRunningError } from './engine-status.ts';
|
|
12
|
+
export { PluginInstallError, type PluginInstallFailure } from './plugins.ts';
|
|
12
13
|
export { readAgentDayUsage } from './day-usage.ts';
|
|
13
14
|
export type { AgentDayUsage, DayUsageClient, EngineStartedCharge, EngineStartedKind } from './day-usage.ts';
|
|
14
15
|
export { readAgentUsage, agentUsageOf } from './usage.ts';
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,6 @@ export { stateWords, toAccountView, words } from './words.js';
|
|
|
6
6
|
export { chooseAccount, consider, resolveSelection } from './pick.js';
|
|
7
7
|
export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.js';
|
|
8
8
|
export { EngineAlreadyRunningError } from './engine-status.js';
|
|
9
|
+
export { PluginInstallError } from './plugins.js';
|
|
9
10
|
export { readAgentDayUsage } from './day-usage.js';
|
|
10
11
|
export { readAgentUsage, agentUsageOf } from './usage.js';
|
package/dist/keys.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { type ActivationCtx } from './activation.ts';
|
|
2
|
-
import {
|
|
2
|
+
import type { RouteView } from './routes.ts';
|
|
3
3
|
import type { Member, ModelInfo } from './types.ts';
|
|
4
4
|
export type KeyFailureReason = 'auth' | 'billing' | 'empty' | 'model' | 'network' | 'engine' | 'inference' | 'busy';
|
|
5
|
+
/** `needs_workspace`: the Go plan login acts for several workspaces (console orgs); show the pick and add again with
|
|
6
|
+
* its `orgId`. Nothing was saved or sent to the engine. Ids are row ids, never secrets. */
|
|
5
7
|
export type AddKeyResult = {
|
|
6
8
|
status: 'ok';
|
|
7
9
|
} | {
|
|
8
10
|
status: 'invalid' | 'not_included' | 'retry';
|
|
9
11
|
reason: KeyFailureReason;
|
|
12
|
+
} | {
|
|
13
|
+
status: 'needs_workspace';
|
|
14
|
+
workspaces: {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}[];
|
|
10
18
|
};
|
|
11
19
|
/** The add resolves like a Promise; `cancel()` stops a pending add and clears any partial key (retry/busy). */
|
|
12
20
|
export type AddKeyHandle = Promise<AddKeyResult> & {
|
|
@@ -14,6 +22,7 @@ export type AddKeyHandle = Promise<AddKeyResult> & {
|
|
|
14
22
|
};
|
|
15
23
|
export declare function createKeys(ctx: ActivationCtx & {
|
|
16
24
|
root: string;
|
|
25
|
+
routes(): RouteView[];
|
|
17
26
|
ensure(member: Member): Promise<{
|
|
18
27
|
agentId: string;
|
|
19
28
|
}>;
|
package/dist/keys.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
|
|
2
3
|
import { activate } from './activation.js';
|
|
3
4
|
import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from './members.js';
|
|
4
5
|
import { providerOf } from './pick.js';
|
|
5
|
-
import { routes } from './routes.js';
|
|
6
6
|
import { modelsOf } from './accounts.js';
|
|
7
7
|
import { LIVE_CATALOGS } from './constants.js';
|
|
8
8
|
export function createKeys(ctx) {
|
|
@@ -60,7 +60,7 @@ export function createKeys(ctx) {
|
|
|
60
60
|
const models = async (member, o) => {
|
|
61
61
|
if (!check(member))
|
|
62
62
|
throw new Error('Choose your own member.');
|
|
63
|
-
const route = routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
|
|
63
|
+
const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
|
|
64
64
|
if (!route || !LIVE_CATALOGS.includes(route.provider))
|
|
65
65
|
return [];
|
|
66
66
|
await ctx.ensure(member);
|
|
@@ -83,18 +83,36 @@ export function createKeys(ctx) {
|
|
|
83
83
|
throw new Error('Choose your own member.');
|
|
84
84
|
if (!o.apiKey.trim())
|
|
85
85
|
return failure('invalid', 'empty');
|
|
86
|
-
const route = routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
|
|
86
|
+
const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
|
|
87
87
|
if (!route || (o.model !== undefined && !LIVE_CATALOGS.includes(route.provider)))
|
|
88
88
|
return failure('not_included', 'model');
|
|
89
89
|
// A workspace names a live-catalog login's own workspace only; another route takes no header, and a
|
|
90
90
|
// refusal here leaves the member's lanes untouched (it lands before any prepare or activation).
|
|
91
|
-
|
|
91
|
+
let orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
|
|
92
92
|
if (orgId) {
|
|
93
93
|
if (orgId.length > 256)
|
|
94
94
|
return failure('invalid', 'auth');
|
|
95
95
|
if (!LIVE_CATALOGS.includes(route.provider))
|
|
96
96
|
return failure('not_included', 'model');
|
|
97
97
|
}
|
|
98
|
+
else if (route.provider === 'opencode-go') {
|
|
99
|
+
// The Go gateway refuses a login acting for several workspaces until one is named (403 Workspace selection
|
|
100
|
+
// is required), so an add without `orgId` lists the key's own (the console listing `@byokit/accounts` uses):
|
|
101
|
+
// one is used, several come back for the app's pick, and a key the console refuses (a workspace-bound
|
|
102
|
+
// service key) needs none. A listing that could not be read fails the add before anything changes.
|
|
103
|
+
let workspaces;
|
|
104
|
+
try {
|
|
105
|
+
workspaces = await openCodeWorkspaces(o.apiKey, (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, ac.signal]) : ac.signal }));
|
|
106
|
+
}
|
|
107
|
+
catch { // `workspaces_unread`: offline, timeout, a server error or a body that is no list
|
|
108
|
+
return ac.signal.aborted ? failure('retry', 'busy') : failure('retry', 'network');
|
|
109
|
+
}
|
|
110
|
+
if (ac.signal.aborted)
|
|
111
|
+
return failure('retry', 'busy');
|
|
112
|
+
if (workspaces.length > 1)
|
|
113
|
+
return { status: 'needs_workspace', workspaces };
|
|
114
|
+
orgId = workspaces[0]?.id ?? '';
|
|
115
|
+
}
|
|
98
116
|
if (ac.signal.aborted)
|
|
99
117
|
return failure('retry', 'busy');
|
|
100
118
|
return exclusive(member, async () => {
|
|
@@ -102,8 +120,13 @@ export function createKeys(ctx) {
|
|
|
102
120
|
// A key the engine may have taken but not sealed is cleared (prepare already emptied the agent); if the clear
|
|
103
121
|
// does not reach the engine, the key stays unsealed and unusable until the next key add or kit start, so runs
|
|
104
122
|
// fail closed.
|
|
123
|
+
let wroteHeader = false;
|
|
105
124
|
const abandon = async (result) => {
|
|
106
125
|
await control(member, 'clear').catch(() => undefined);
|
|
126
|
+
// A header this add wrote must not outlive its abandoned key; a clear that fails never throws past
|
|
127
|
+
// the abandon, like the key clear itself.
|
|
128
|
+
if (wroteHeader)
|
|
129
|
+
await ctx.setProviderHeaders(route.provider, null).catch(() => undefined);
|
|
107
130
|
return result;
|
|
108
131
|
};
|
|
109
132
|
const cancelled = () => failure('retry', 'busy');
|
|
@@ -123,9 +146,16 @@ export function createKeys(ctx) {
|
|
|
123
146
|
return failure('retry', 'engine');
|
|
124
147
|
// The workspace rides the provider's probe and every run from its config; it is written before the
|
|
125
148
|
// activation, so the probe itself carries it. The slot is provider-wide (shared by every member's
|
|
126
|
-
// key on this engine), so a
|
|
127
|
-
|
|
149
|
+
// key on this engine), so a workspace-scoped add that names none clears the standing one before the
|
|
150
|
+
// probe: a bound service key never carries another workspace, and `removeKey` drops it.
|
|
151
|
+
if (orgId) {
|
|
128
152
|
await ctx.setProviderHeaders(route.provider, { 'x-opencode-org-id': orgId });
|
|
153
|
+
wroteHeader = true;
|
|
154
|
+
}
|
|
155
|
+
else if (LIVE_CATALOGS.includes(route.provider)) {
|
|
156
|
+
await ctx.setProviderHeaders(route.provider, null);
|
|
157
|
+
wroteHeader = true;
|
|
158
|
+
}
|
|
129
159
|
// The public pin's typed API-key path, never a CLI-login import. Discard the engine's lines/errors.
|
|
130
160
|
sent = true;
|
|
131
161
|
if (ac.signal.aborted)
|
package/dist/kit.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export type KitOptions = {
|
|
|
41
41
|
spawnEngine?: boolean;
|
|
42
42
|
onState?: (s: KitState) => void;
|
|
43
43
|
log?: (line: string) => void;
|
|
44
|
+
listWorkspaces?: (key: string, signal: AbortSignal) => Promise<{
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
}[]>;
|
|
44
48
|
};
|
|
45
49
|
export type RetainedLogin = {
|
|
46
50
|
path: string;
|
|
@@ -56,6 +60,7 @@ export declare class OpenClawKit {
|
|
|
56
60
|
private members?;
|
|
57
61
|
private readonly locks;
|
|
58
62
|
private readonly routeMap;
|
|
63
|
+
private installs;
|
|
59
64
|
private accountsCtl?;
|
|
60
65
|
private readonly bridge;
|
|
61
66
|
private readonly keys;
|
|
@@ -110,6 +115,11 @@ export declare class OpenClawKit {
|
|
|
110
115
|
}>;
|
|
111
116
|
private signInCtx;
|
|
112
117
|
routes(): RouteView[];
|
|
118
|
+
/** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
|
|
119
|
+
* pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
|
|
120
|
+
* concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
|
|
121
|
+
installPlugin(pluginId: string): Promise<void>;
|
|
122
|
+
private runInstall;
|
|
113
123
|
providerStatus(member: Member): Promise<string[] | undefined>;
|
|
114
124
|
providers(member: Member): Promise<string[]>;
|
|
115
125
|
/** A member sign-in to `provider`, or a ready key (5.8) whose saved model is that provider's. */
|
|
@@ -154,8 +164,9 @@ export declare class OpenClawKit {
|
|
|
154
164
|
defaults(member: Member): Promise<Defaults>;
|
|
155
165
|
setDefaults(member: Member, d: Defaults): Promise<void>;
|
|
156
166
|
/** Save an explicitly supplied API key (billed per use) in this member's separate key agent.
|
|
157
|
-
* `orgId` names the workspace a Go plan login acts for
|
|
158
|
-
* it
|
|
167
|
+
* `orgId` names the workspace a Go plan login acts for; it rides every probe and run as `x-opencode-org-id`,
|
|
168
|
+
* and replacing the key rewrites it. Without it a Go key's workspaces are listed: one is used, several resolve
|
|
169
|
+
* `needs_workspace` with the rows for the app's pick (add again with `orgId`). */
|
|
159
170
|
addKey(agentId: Member, o: {
|
|
160
171
|
authChoice: string;
|
|
161
172
|
apiKey: string;
|
package/dist/kit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -20,6 +21,7 @@ import { authStatus, createAuthStatus } from './auth-status.js';
|
|
|
20
21
|
import { outputSchema } from './output.js';
|
|
21
22
|
import { createKeys } from './keys.js';
|
|
22
23
|
import { routes as routeTable } from './routes.js';
|
|
24
|
+
import { installedPlugins, pinnedSpec, recordPlugin, PluginInstallError } from './plugins.js';
|
|
23
25
|
import { providerOf } from './pick.js';
|
|
24
26
|
import { signIn as startSignIn, signOut as engineSignOut } from './signin.js';
|
|
25
27
|
import { reconcileConfig, browserToolPolicySafe, browserProfileAcknowledged, browserSessionMayRun, memoryLimited as configMemoryLimited, configKeyPath, readConfigKey, writeConfigKey } from './config.js';
|
|
@@ -436,6 +438,7 @@ export class OpenClawKit {
|
|
|
436
438
|
members;
|
|
437
439
|
locks = createLocks();
|
|
438
440
|
routeMap = new Map();
|
|
441
|
+
installs = Promise.resolve();
|
|
439
442
|
accountsCtl;
|
|
440
443
|
// One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
|
|
441
444
|
// Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
|
|
@@ -668,6 +671,7 @@ export class OpenClawKit {
|
|
|
668
671
|
dropLane: (member, provider) => this.dropKeyLane(member, provider),
|
|
669
672
|
backfillLane: (member, model) => this.backfillKeyLane(member, model),
|
|
670
673
|
restarting: () => this.engineRestarting(),
|
|
674
|
+
routes: () => this.routes(),
|
|
671
675
|
request: (method, params, options) => this.request()(method, params, options),
|
|
672
676
|
ensure: (member) => this.ensureMember(member),
|
|
673
677
|
authConfig: () => this.authSnapshot(),
|
|
@@ -961,10 +965,69 @@ export class OpenClawKit {
|
|
|
961
965
|
onDisconnect: (fn) => { this.signInDisconnects.add(fn); return () => { this.signInDisconnects.delete(fn); }; },
|
|
962
966
|
stopSignal: this.engine.stopSignal,
|
|
963
967
|
authConfig: () => this.authSnapshot(),
|
|
964
|
-
restarted: (authBefore, signal) => this.restarted(authBefore, signal)
|
|
968
|
+
restarted: (authBefore, signal) => this.restarted(authBefore, signal),
|
|
969
|
+
setProviderHeaders: (provider, headers) => this.patchConfig({ models: { providers: { [provider]: { headers: headers ?? null } } } }),
|
|
970
|
+
listWorkspaces: (key, signal) => this.o.listWorkspaces
|
|
971
|
+
? this.o.listWorkspaces(key, signal)
|
|
972
|
+
: openCodeWorkspaces(key, (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal })) };
|
|
965
973
|
}
|
|
966
974
|
routes() {
|
|
967
|
-
return routeTable();
|
|
975
|
+
return routeTable({ plugins: installedPlugins(this.engine.root) });
|
|
976
|
+
}
|
|
977
|
+
/** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
|
|
978
|
+
* pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
|
|
979
|
+
* concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
|
|
980
|
+
installPlugin(pluginId) {
|
|
981
|
+
const run = this.installs.then(() => this.runInstall(pluginId));
|
|
982
|
+
this.installs = run.catch(() => { });
|
|
983
|
+
return run;
|
|
984
|
+
}
|
|
985
|
+
async runInstall(pluginId) {
|
|
986
|
+
const pin = pinnedSpec(pluginId);
|
|
987
|
+
const version = pin.spec.slice(pin.spec.lastIndexOf('@') + 1);
|
|
988
|
+
const loaded = async () => {
|
|
989
|
+
const { plugins } = await this.request()('plugins.list', {});
|
|
990
|
+
return plugins.some((p) => p.id === pluginId && p.enabled && p.version === version);
|
|
991
|
+
};
|
|
992
|
+
if (!(await loaded())) {
|
|
993
|
+
// The installer enables a plugin only when the (restrictive once non-empty) allow list already names it.
|
|
994
|
+
const allow = (this.getConfigKey('plugins.allow') ?? []);
|
|
995
|
+
const added = !allow.includes(pluginId);
|
|
996
|
+
if (added)
|
|
997
|
+
this.setConfigKey('plugins.allow', [...allow, pluginId]);
|
|
998
|
+
let code;
|
|
999
|
+
let output;
|
|
1000
|
+
try {
|
|
1001
|
+
({ code, output } = await this.engine.installPlugin(pin.spec));
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
if (added)
|
|
1005
|
+
this.setConfigKey('plugins.allow', allow);
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
if (code !== 0) {
|
|
1009
|
+
if (added)
|
|
1010
|
+
this.setConfigKey('plugins.allow', allow);
|
|
1011
|
+
if (output.includes('pinned plugin integrity mismatch'))
|
|
1012
|
+
throw new PluginInstallError('integrity', `${pin.spec} does not match its pinned integrity; nothing was installed.`);
|
|
1013
|
+
if (output.includes('not a pinned plugin version'))
|
|
1014
|
+
throw new PluginInstallError('version', `The registry does not serve ${pin.spec} as pinned; nothing was installed.`);
|
|
1015
|
+
const reason = output.trim().split('\n').filter((line) => line.trim()).slice(-3).join(' ');
|
|
1016
|
+
throw new PluginInstallError('install', `${pin.spec} did not install: ${reason || `the installer exited ${code}`}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// The running gateway hot-reloads the installer's config write without loading the plugin (verified live on
|
|
1020
|
+
// 2026.8.35); a safe restart (after in-flight requests) loads it, and the kit reconnects. Reads fail while it is down.
|
|
1021
|
+
if (!(await loaded()))
|
|
1022
|
+
await this.request()('gateway.restart.request', { reason: `byokit installed plugin ${pluginId}` });
|
|
1023
|
+
const deadline = Date.now() + RESTART_MS;
|
|
1024
|
+
while (!(await loaded().catch(() => false))) {
|
|
1025
|
+
if (Date.now() >= deadline)
|
|
1026
|
+
throw new PluginInstallError('install', `${pin.spec} installed, but the engine did not load it.`);
|
|
1027
|
+
await delay(500);
|
|
1028
|
+
}
|
|
1029
|
+
// Readiness (routes(), the keys gates) tracks this record, so it names the plugin only once the engine lists it loaded.
|
|
1030
|
+
recordPlugin(this.engine.root, pluginId);
|
|
968
1031
|
}
|
|
969
1032
|
async providerStatus(member) {
|
|
970
1033
|
const { agentId } = await this.ensureMember(member);
|
|
@@ -1070,8 +1133,9 @@ export class OpenClawKit {
|
|
|
1070
1133
|
return this.accountsCtl.setDefaults(member, d);
|
|
1071
1134
|
}
|
|
1072
1135
|
/** Save an explicitly supplied API key (billed per use) in this member's separate key agent.
|
|
1073
|
-
* `orgId` names the workspace a Go plan login acts for
|
|
1074
|
-
* it
|
|
1136
|
+
* `orgId` names the workspace a Go plan login acts for; it rides every probe and run as `x-opencode-org-id`,
|
|
1137
|
+
* and replacing the key rewrites it. Without it a Go key's workspaces are listed: one is used, several resolve
|
|
1138
|
+
* `needs_workspace` with the rows for the app's pick (add again with `orgId`). */
|
|
1075
1139
|
addKey(agentId, o) {
|
|
1076
1140
|
return this.keys.add(agentId, o);
|
|
1077
1141
|
}
|
|
@@ -1098,7 +1162,7 @@ export class OpenClawKit {
|
|
|
1098
1162
|
await this.accountsCtl.unkey(member, provider).catch((error) => this.o.log?.(`removeKey: the key lane entry is not dropped yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
1099
1163
|
}
|
|
1100
1164
|
async backfillKeyLane(member, model) {
|
|
1101
|
-
const found = model === undefined ? [] :
|
|
1165
|
+
const found = model === undefined ? [] : this.routes().filter((r) => r.keyEntry && r.readiness === 'ready' && r.provider === providerOf(model));
|
|
1102
1166
|
await this.accountsCtl?.backfill(member, found).catch((error) => this.o.log?.(`key lane ${member}: not indexed yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
1103
1167
|
}
|
|
1104
1168
|
async prepareKeyOwner(agentId) {
|
|
@@ -1140,7 +1204,7 @@ export class OpenClawKit {
|
|
|
1140
1204
|
return confirmLogin({ ...this.signInCtx(), seal: this.o.authSeal, log: this.o.log }, member, source);
|
|
1141
1205
|
}
|
|
1142
1206
|
// runs (5.8): the facade owns no run state; every call delegates to the O8 module
|
|
1143
|
-
// with this kit's live transport, member table and bridge (
|
|
1207
|
+
// with this kit's live transport, member table and bridge (spec item O9).
|
|
1144
1208
|
runs() {
|
|
1145
1209
|
return createRuns({
|
|
1146
1210
|
request: (method, params, o) => this.request()(method, params, o),
|
|
@@ -1233,7 +1297,8 @@ export class OpenClawKit {
|
|
|
1233
1297
|
const safe = reconcileConfig(response.config, { root: this.engine.root, stateDir: this.o.stateDir,
|
|
1234
1298
|
port: Number(readFileSync(join(this.engine.root, 'port'), 'utf8')), pluginId: this.o.plugin?.id ?? 'byokit',
|
|
1235
1299
|
pluginDir: join(this.engine.root, 'plugin'), policyPath: fileURLToPath(new URL('../policy/policy.mjs', import.meta.url)), app: patch,
|
|
1236
|
-
offered: this.o.offered, installPolicy: this.o.installPolicy,
|
|
1300
|
+
offered: this.o.offered, installPolicy: this.o.installPolicy, plugins: installedPlugins(this.engine.root),
|
|
1301
|
+
...(this.o.browser ? { browser: this.browserConfig() } : {}) });
|
|
1237
1302
|
if (this.o.browser && !browserToolPolicySafe(safe, this.toolNames()))
|
|
1238
1303
|
throw new Error('browser tool policy refused');
|
|
1239
1304
|
if (this.o.browser) {
|
package/dist/members.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export declare const BASE_AGENT = "byokit-base";
|
|
|
6
6
|
export declare const keyAgentId: (member: Member) => string;
|
|
7
7
|
/** Resolves once agents.list shows `id`, which agents.create answers before. */
|
|
8
8
|
export declare function untilListed(request: GatewayTransport['request'], id: string): Promise<void>;
|
|
9
|
+
/** Resolves once agents.list no longer shows `id`, which agents.delete answers before: the engine's roster
|
|
10
|
+
* hot-reload applies a beat after the delete answer, the same lag as after agents.create. */
|
|
11
|
+
export declare function untilUnlisted(request: GatewayTransport['request'], id: string): Promise<void>;
|
|
9
12
|
export declare function createMembers(ctx: {
|
|
10
13
|
request: GatewayTransport['request'];
|
|
11
14
|
root: string;
|
package/dist/members.js
CHANGED
|
@@ -23,6 +23,16 @@ export async function untilListed(request, id) {
|
|
|
23
23
|
await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
+
/** Resolves once agents.list no longer shows `id`, which agents.delete answers before: the engine's roster
|
|
27
|
+
* hot-reload applies a beat after the delete answer, the same lag as after agents.create. */
|
|
28
|
+
export async function untilUnlisted(request, id) {
|
|
29
|
+
const deadline = Date.now() + VISIBLE_TIMEOUT_MS;
|
|
30
|
+
while (await listed(request, id)) {
|
|
31
|
+
if (Date.now() >= deadline)
|
|
32
|
+
throw new Error(`agent ${id} is still listed by agents.list after agents.delete`);
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
26
36
|
export function createMembers(ctx) {
|
|
27
37
|
const cache = new Map();
|
|
28
38
|
return { ensure(member) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PluginInstallFailure = 'unknown_plugin' | 'version' | 'integrity' | 'install';
|
|
2
|
+
export declare class PluginInstallError extends Error {
|
|
3
|
+
readonly code: PluginInstallFailure;
|
|
4
|
+
constructor(code: PluginInstallFailure, message: string);
|
|
5
|
+
}
|
|
6
|
+
type Pin = {
|
|
7
|
+
spec: string;
|
|
8
|
+
integrity: string;
|
|
9
|
+
};
|
|
10
|
+
/** engine/plugins.json: `{ "<plugin id>": { spec: '<package>@<engine version>', integrity } }`. */
|
|
11
|
+
export declare const pluginPins: () => Record<string, Pin>;
|
|
12
|
+
/** The exact spec to install for a plugin a route needs: its `install.npmSpec` at the engine pin, and pinned so. */
|
|
13
|
+
export declare function pinnedSpec(pluginId: string): Pin;
|
|
14
|
+
/** The external plugins this kit installed into its own engine. */
|
|
15
|
+
export declare function installedPlugins(root: string): string[];
|
|
16
|
+
export declare function recordPlugin(root: string, pluginId: string): void;
|
|
17
|
+
export {};
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// External provider plugins (5.7, B7): the pins the kit installs at, and the ids it installed. Installing is the
|
|
2
|
+
// kit's explicit call; discovery only reads the record.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { atomic } from './engine-patches.js';
|
|
7
|
+
import { ENGINE_VERSION } from './constants.js';
|
|
8
|
+
import { routes } from './routes.js';
|
|
9
|
+
export class PluginInstallError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'PluginInstallError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
/** engine/plugins.json: `{ "<plugin id>": { spec: '<package>@<engine version>', integrity } }`. */
|
|
19
|
+
export const pluginPins = () => JSON.parse(readFileSync(join(kitDir, 'engine', 'plugins.json'), 'utf8'));
|
|
20
|
+
/** The exact spec to install for a plugin a route needs: its `install.npmSpec` at the engine pin, and pinned so. */
|
|
21
|
+
export function pinnedSpec(pluginId) {
|
|
22
|
+
const npmSpec = routes().find(route => route.needs?.plugin === pluginId && route.install?.npmSpec)?.install?.npmSpec;
|
|
23
|
+
if (!npmSpec)
|
|
24
|
+
throw new PluginInstallError('unknown_plugin', `No route needs a plugin named ${pluginId}.`);
|
|
25
|
+
const pin = pluginPins()[pluginId];
|
|
26
|
+
if (pin?.spec !== `${npmSpec}@${ENGINE_VERSION}`)
|
|
27
|
+
throw new PluginInstallError('version', `${pluginId} has no pin for engine ${ENGINE_VERSION}.`);
|
|
28
|
+
return pin;
|
|
29
|
+
}
|
|
30
|
+
const recordOf = (root) => join(root, 'installed-plugins.json');
|
|
31
|
+
/** The external plugins this kit installed into its own engine. */
|
|
32
|
+
export function installedPlugins(root) {
|
|
33
|
+
const path = recordOf(root);
|
|
34
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : [];
|
|
35
|
+
}
|
|
36
|
+
export function recordPlugin(root, pluginId) {
|
|
37
|
+
const installed = installedPlugins(root);
|
|
38
|
+
if (!installed.includes(pluginId))
|
|
39
|
+
atomic(recordOf(root), JSON.stringify([...installed, pluginId]) + '\n');
|
|
40
|
+
}
|