@uniqbit/mate-core 0.18.4 → 0.18.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/opencode-plugin/src/add-dir.ts +76 -10
- package/opencode-plugin/src/companion.ts +1 -8
- package/opencode-plugin/src/server.ts +2 -15
- package/package.json +1 -1
- package/src/cli/commands/artifact/finish/openspec.ts +40 -8
- package/src/cli/commands/doctor.ts +92 -86
- package/src/cli/commands/install.ts +0 -8
- package/src/cli/commands/studio/terminal-launch.ts +7 -16
- package/src/cli/commands/wrap.ts +4 -29
- package/src/cli/usage.ts +1 -2
- package/src/hooks/session-guidance.ts +2 -20
- package/src/lib/orchestrator/adapters/claude.ts +20 -130
- package/src/lib/orchestrator/adapters/opencode.ts +7 -24
- package/src/lib/orchestrator/config-store.ts +2 -70
- package/src/lib/orchestrator/projection-claude-skills.ts +35 -88
- package/src/lib/orchestrator/projection-entries.ts +0 -1
- package/src/lib/orchestrator/projection-types.ts +0 -2
- package/src/lib/orchestrator/session-prepare.ts +4 -82
- package/src/lib/orchestrator/wrap-pass.ts +1 -88
- package/src/runtime/guidance.ts +0 -17
- package/src/runtime/policy.ts +1 -14
- package/src/runtime/runtime-document-manifest.ts +0 -17
- package/src/runtime/wrap-state.ts +0 -4
- package/src/tools/setup/capabilities/openspec.ts +140 -18
- package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
- package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
- package/src/tools/setup/engine.ts +10 -141
- package/src/tools/setup/plugin.ts +4 -19
- package/src/tools/setup/plugins/gitignore.ts +1 -7
- package/src/tools/setup/providers/claude.ts +2 -27
- package/src/tools/setup/providers/opencode.ts +0 -10
- package/src/tools/setup/registry.ts +2 -24
- package/src/tools/setup/surface-target.ts +4 -13
- package/src/tools/setup.ts +6 -9
- package/opencode-plugin/src/audience-layer.ts +0 -91
- package/src/runtime/audience-layer.ts +0 -84
- package/src/runtime/opencode-audience-layer.ts +0 -176
- package/src/tools/setup/audience-isolation.fixture.ts +0 -325
- package/src/tools/setup/audience-layer.ts +0 -207
- package/src/tools/setup/audience-registrations.ts +0 -52
- package/src/tools/setup/contribution-ledger.ts +0 -207
- package/src/tools/setup/isolation-diagnostics.ts +0 -139
- package/src/tools/setup/upgrade-sweep.ts +0 -153
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
// oxlint-disable no-await-in-loop
|
|
2
|
-
import type { Dirent } from "node:fs";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
|
|
7
|
-
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
8
|
-
import { pluginPackageRoot } from "./dynamic-plugins/paths";
|
|
9
|
-
import type {
|
|
10
|
-
CapabilityPlugin,
|
|
11
|
-
PluginRegistration,
|
|
12
|
-
ProviderPlugin,
|
|
13
|
-
RuntimeContributions,
|
|
14
|
-
} from "./plugin";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Audience isolation harness: one companion whose base plugin and audience
|
|
18
|
-
* `ba` and `qa` plugins each declare every contribution kind, every value
|
|
19
|
-
* carrying its owner's marker. A test runs a pass, then asserts which markers
|
|
20
|
-
* a surface holds — a marker found where it must not be is a leak.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
export type FixtureOwner = "base" | "ba" | "qa";
|
|
24
|
-
|
|
25
|
-
export const AUDIENCE_MARKERS: Record<FixtureOwner, string> = {
|
|
26
|
-
base: "BASEMARK7",
|
|
27
|
-
ba: "BAMARK7",
|
|
28
|
-
qa: "QAMARK7",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const FIXTURE_AUDIENCES = ["ba", "qa"] as const;
|
|
32
|
-
|
|
33
|
-
export function fixturePluginId(owner: FixtureOwner): string {
|
|
34
|
-
return `${owner}-plugin`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function fixturePackage(owner: FixtureOwner): string {
|
|
38
|
-
return `@fixture/${owner}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface FixtureCapabilityOptions {
|
|
42
|
-
apply?: () => Promise<void>;
|
|
43
|
-
teardown?: () => Promise<void>;
|
|
44
|
-
forProvider?: CapabilityPlugin["forProvider"];
|
|
45
|
-
pluginReference?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function contributionsFor(
|
|
49
|
-
owner: FixtureOwner,
|
|
50
|
-
skillSourceDir: string,
|
|
51
|
-
runtime: "claude" | "opencode",
|
|
52
|
-
options: FixtureCapabilityOptions,
|
|
53
|
-
): RuntimeContributions {
|
|
54
|
-
const marker = AUDIENCE_MARKERS[owner];
|
|
55
|
-
const contributions: RuntimeContributions = {
|
|
56
|
-
mcpServers: [{ name: `${owner}-mcp`, command: `${marker}-server`, args: ["serve"] }],
|
|
57
|
-
guidanceSections: [{ content: `${marker} guidance for ${owner}.` }],
|
|
58
|
-
skillTrees: [{ name: `${owner}-skill`, sourceDir: skillSourceDir }],
|
|
59
|
-
agentDefinitions: [
|
|
60
|
-
{
|
|
61
|
-
name: `${owner}-agent`,
|
|
62
|
-
content: `---\nname: ${owner}-agent\ndescription: ${owner} agent\n---\n${marker} agent prompt.\n`,
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
permissionEntries: [`Bash(${marker}:*)`],
|
|
66
|
-
};
|
|
67
|
-
if (runtime === "claude") {
|
|
68
|
-
contributions.hookGroups = [
|
|
69
|
-
{
|
|
70
|
-
event: "PreToolUse",
|
|
71
|
-
marker: `${marker}-hook.sh`,
|
|
72
|
-
group: { matcher: "Bash", hooks: [{ type: "command", command: `${marker}-hook.sh` }] },
|
|
73
|
-
},
|
|
74
|
-
];
|
|
75
|
-
}
|
|
76
|
-
if (runtime === "opencode" && options.pluginReference) {
|
|
77
|
-
const reference = options.pluginReference;
|
|
78
|
-
contributions.pluginReferences = [
|
|
79
|
-
{ reference, isManagedReference: (entry) => entry === reference },
|
|
80
|
-
];
|
|
81
|
-
}
|
|
82
|
-
return contributions;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** A capability declaring every contribution kind, each value carrying the owner's marker. */
|
|
86
|
-
export function markedCapability(
|
|
87
|
-
owner: FixtureOwner,
|
|
88
|
-
skillSourceDir: string,
|
|
89
|
-
options: FixtureCapabilityOptions = {},
|
|
90
|
-
): CapabilityPlugin {
|
|
91
|
-
return {
|
|
92
|
-
id: fixturePluginId(owner),
|
|
93
|
-
kind: "capability",
|
|
94
|
-
label: owner,
|
|
95
|
-
description: `${owner} fixture capability`,
|
|
96
|
-
defaultSelected: true,
|
|
97
|
-
isEnabled: () => true,
|
|
98
|
-
apply: options.apply ?? (async () => {}),
|
|
99
|
-
teardown: options.teardown ?? (async () => {}),
|
|
100
|
-
...(options.forProvider ? { forProvider: options.forProvider } : {}),
|
|
101
|
-
getRuntimeContributions() {
|
|
102
|
-
return {
|
|
103
|
-
claude: contributionsFor(owner, skillSourceDir, "claude", options),
|
|
104
|
-
opencode: contributionsFor(owner, skillSourceDir, "opencode", options),
|
|
105
|
-
};
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function fixtureProvider(id: "claude" | "opencode"): ProviderPlugin {
|
|
111
|
-
return {
|
|
112
|
-
id,
|
|
113
|
-
kind: "provider",
|
|
114
|
-
label: id,
|
|
115
|
-
description: id,
|
|
116
|
-
defaultSelected: true,
|
|
117
|
-
isEnabled: () => true,
|
|
118
|
-
async apply() {},
|
|
119
|
-
async teardown() {},
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface AudienceFixture {
|
|
124
|
-
root: string;
|
|
125
|
-
companionPath: string;
|
|
126
|
-
repoPath: string;
|
|
127
|
-
config: FrameworkConfig;
|
|
128
|
-
skillSources: Record<FixtureOwner, string>;
|
|
129
|
-
/** Providers, the base plugin, and both audiences' plugins, tagged with their source. */
|
|
130
|
-
registrations(
|
|
131
|
-
options?: Partial<Record<FixtureOwner, FixtureCapabilityOptions>>,
|
|
132
|
-
): PluginRegistration[];
|
|
133
|
-
cleanup(): Promise<void>;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const FRAMEWORK_YAML = [
|
|
137
|
-
"type: companion",
|
|
138
|
-
"allowedAgents:",
|
|
139
|
-
" - claude",
|
|
140
|
-
" - opencode",
|
|
141
|
-
"plugins:",
|
|
142
|
-
' - package: "@fixture/base"',
|
|
143
|
-
' version: "1.0.0"',
|
|
144
|
-
"audiences:",
|
|
145
|
-
" ba:",
|
|
146
|
-
" plugins:",
|
|
147
|
-
' - package: "@fixture/ba"',
|
|
148
|
-
' version: "1.0.0"',
|
|
149
|
-
" qa:",
|
|
150
|
-
" plugins:",
|
|
151
|
-
' - package: "@fixture/qa"',
|
|
152
|
-
' version: "1.0.0"',
|
|
153
|
-
"",
|
|
154
|
-
].join("\n");
|
|
155
|
-
|
|
156
|
-
export async function makeAudienceFixture(): Promise<AudienceFixture> {
|
|
157
|
-
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "audience-isolation-")));
|
|
158
|
-
const companionPath = path.join(root, "companion");
|
|
159
|
-
const repoPath = path.join(root, "repo");
|
|
160
|
-
await fs.mkdir(path.join(companionPath, ".mate", "config"), { recursive: true });
|
|
161
|
-
await fs.mkdir(repoPath, { recursive: true });
|
|
162
|
-
await fs.writeFile(
|
|
163
|
-
path.join(companionPath, ".mate", "config", "framework.yaml"),
|
|
164
|
-
FRAMEWORK_YAML,
|
|
165
|
-
"utf8",
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
const skillSources = {} as Record<FixtureOwner, string>;
|
|
169
|
-
for (const owner of ["base", "ba", "qa"] as const) {
|
|
170
|
-
const dir = path.join(root, "skill-sources", `${owner}-skill`);
|
|
171
|
-
await fs.mkdir(dir, { recursive: true });
|
|
172
|
-
await fs.writeFile(
|
|
173
|
-
path.join(dir, "SKILL.md"),
|
|
174
|
-
`---\nname: ${owner}-skill\ndescription: ${owner} skill\n---\n${AUDIENCE_MARKERS[owner]} skill body.\n`,
|
|
175
|
-
"utf8",
|
|
176
|
-
);
|
|
177
|
-
skillSources[owner] = dir;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const config: FrameworkConfig = {
|
|
181
|
-
type: "companion",
|
|
182
|
-
allowedAgents: ["claude", "opencode"],
|
|
183
|
-
capabilities: [],
|
|
184
|
-
plugins: [{ package: "@fixture/base", version: "1.0.0" }],
|
|
185
|
-
audiences: {
|
|
186
|
-
ba: { plugins: [{ package: "@fixture/ba", version: "1.0.0" }] },
|
|
187
|
-
qa: { plugins: [{ package: "@fixture/qa", version: "1.0.0" }] },
|
|
188
|
-
},
|
|
189
|
-
} as FrameworkConfig;
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
root,
|
|
193
|
-
companionPath,
|
|
194
|
-
repoPath,
|
|
195
|
-
config,
|
|
196
|
-
skillSources,
|
|
197
|
-
registrations(options = {}) {
|
|
198
|
-
return [
|
|
199
|
-
{ plugin: fixtureProvider("claude"), policy: "required" },
|
|
200
|
-
{ plugin: fixtureProvider("opencode"), policy: "required" },
|
|
201
|
-
{
|
|
202
|
-
plugin: markedCapability("base", skillSources.base, options.base),
|
|
203
|
-
policy: "required",
|
|
204
|
-
audience: null,
|
|
205
|
-
packageName: fixturePackage("base"),
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
plugin: markedCapability("ba", skillSources.ba, options.ba),
|
|
209
|
-
policy: "optional",
|
|
210
|
-
audience: "ba",
|
|
211
|
-
packageName: fixturePackage("ba"),
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
plugin: markedCapability("qa", skillSources.qa, options.qa),
|
|
215
|
-
policy: "optional",
|
|
216
|
-
audience: "qa",
|
|
217
|
-
packageName: fixturePackage("qa"),
|
|
218
|
-
},
|
|
219
|
-
];
|
|
220
|
-
},
|
|
221
|
-
cleanup: () => fs.rm(root, { recursive: true, force: true }),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Every regular file under `root` (symlinks followed), as relative path → text. Missing root: none. */
|
|
226
|
-
export async function readTree(root: string): Promise<Map<string, string>> {
|
|
227
|
-
const files = new Map<string, string>();
|
|
228
|
-
async function walk(dir: string): Promise<void> {
|
|
229
|
-
let entries: Dirent[];
|
|
230
|
-
try {
|
|
231
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
232
|
-
} catch {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
for (const entry of entries) {
|
|
236
|
-
const full = path.join(dir, entry.name);
|
|
237
|
-
const stat = await fs.stat(full).catch(() => null);
|
|
238
|
-
if (!stat) continue;
|
|
239
|
-
if (stat.isDirectory()) await walk(full);
|
|
240
|
-
else if (stat.isFile()) files.set(path.relative(root, full), await fs.readFile(full, "utf8"));
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
await walk(root);
|
|
244
|
-
return files;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** `<where>: <marker>` for every marker of `owners` found in `text`. */
|
|
248
|
-
export function markerHits(where: string, text: string, owners: readonly FixtureOwner[]): string[] {
|
|
249
|
-
return owners
|
|
250
|
-
.filter((owner) => text.includes(AUDIENCE_MARKERS[owner]) || ownerIdentityIn(text, owner))
|
|
251
|
-
.map((owner) => `${where}: ${owner}`);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Identities without the marker (MCP, skill, agent names) still count as the owner's. */
|
|
255
|
-
function ownerIdentityIn(text: string, owner: FixtureOwner): boolean {
|
|
256
|
-
return [`${owner}-mcp`, `${owner}-skill`, `${owner}-agent`, `${owner}-plugin`].some((identity) =>
|
|
257
|
-
text.includes(identity),
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** The installed plugin packages: what a plugin is, not what a session loads. */
|
|
262
|
-
const PLUGIN_WORKSPACE = `${path.join(".mate", "plugins")}${path.sep}`;
|
|
263
|
-
|
|
264
|
-
/** Marker hits of `owners` in every file (path and content) under `root`, the plugin workspace aside. */
|
|
265
|
-
export async function markerHitsInTree(
|
|
266
|
-
root: string,
|
|
267
|
-
owners: readonly FixtureOwner[],
|
|
268
|
-
skip: readonly string[] = [],
|
|
269
|
-
): Promise<string[]> {
|
|
270
|
-
const hits: string[] = [];
|
|
271
|
-
for (const [relative, text] of await readTree(root)) {
|
|
272
|
-
if (relative.startsWith(PLUGIN_WORKSPACE)) continue;
|
|
273
|
-
if (skip.some((prefix) => relative.startsWith(prefix))) continue;
|
|
274
|
-
hits.push(...markerHits(relative, `${relative}\n${text}`, owners));
|
|
275
|
-
}
|
|
276
|
-
return hits;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Marker hits of `owners` in any JSON-serializable value: rendered documents, launch arguments. */
|
|
280
|
-
export function markerHitsInValue(
|
|
281
|
-
where: string,
|
|
282
|
-
value: unknown,
|
|
283
|
-
owners: readonly FixtureOwner[],
|
|
284
|
-
): string[] {
|
|
285
|
-
return markerHits(where, JSON.stringify(value), owners);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Installs `owner`'s fixture plugin into the companion's plugin workspace, so
|
|
290
|
-
* a pass can load it from its declaration alone. Declares exactly what
|
|
291
|
-
* `markedCapability` declares, hook groups and all.
|
|
292
|
-
*/
|
|
293
|
-
export async function installFixturePackage(
|
|
294
|
-
fixture: AudienceFixture,
|
|
295
|
-
owner: FixtureOwner,
|
|
296
|
-
): Promise<void> {
|
|
297
|
-
const name = fixturePackage(owner);
|
|
298
|
-
const root = pluginPackageRoot(fixture.companionPath, name);
|
|
299
|
-
await fs.mkdir(root, { recursive: true });
|
|
300
|
-
await fs.writeFile(
|
|
301
|
-
path.join(root, "package.json"),
|
|
302
|
-
JSON.stringify({ name, version: "1.0.0", type: "module", main: "index.js" }),
|
|
303
|
-
"utf8",
|
|
304
|
-
);
|
|
305
|
-
const declared = markedCapability(owner, fixture.skillSources[owner]).getRuntimeContributions!(
|
|
306
|
-
{} as never,
|
|
307
|
-
);
|
|
308
|
-
const source = `
|
|
309
|
-
const contributions = ${JSON.stringify(declared)};
|
|
310
|
-
export default function createPlugin() {
|
|
311
|
-
return {
|
|
312
|
-
id: ${JSON.stringify(fixturePluginId(owner))},
|
|
313
|
-
kind: "capability",
|
|
314
|
-
label: ${JSON.stringify(owner)},
|
|
315
|
-
description: "installed fixture",
|
|
316
|
-
defaultSelected: true,
|
|
317
|
-
isEnabled: () => true,
|
|
318
|
-
apply: async () => {},
|
|
319
|
-
teardown: async () => {},
|
|
320
|
-
getRuntimeContributions: () => contributions,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
`;
|
|
324
|
-
await fs.writeFile(path.join(root, "index.js"), source, "utf8");
|
|
325
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// oxlint-disable no-await-in-loop
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
import { getActiveDistribution } from "../../distribution";
|
|
7
|
-
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
8
|
-
import {
|
|
9
|
-
audienceLayerDir,
|
|
10
|
-
audienceLayersRoot,
|
|
11
|
-
CURRENT_LAYER_LINK,
|
|
12
|
-
} from "../../runtime/audience-layer";
|
|
13
|
-
import {
|
|
14
|
-
dynamicPluginsWorkspaceRoot,
|
|
15
|
-
pluginLocalOverridesPath,
|
|
16
|
-
pluginPackageRoot,
|
|
17
|
-
} from "./dynamic-plugins/paths";
|
|
18
|
-
import { collectAudienceInputs, RUNTIME_SURFACE_RECONCILERS } from "./engine";
|
|
19
|
-
import type { PluginRegistration, SetupContext } from "./plugin";
|
|
20
|
-
import { projectionTarget } from "./surface-target";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Writes the active audience's layer through each runtime's Runtime Surface,
|
|
24
|
-
* under the pass's layer root only. Refuses any other target, and a pass that
|
|
25
|
-
* names no audience.
|
|
26
|
-
*/
|
|
27
|
-
export async function reconcileAudienceLayer(
|
|
28
|
-
ctx: SetupContext,
|
|
29
|
-
registrations: PluginRegistration[],
|
|
30
|
-
): Promise<void> {
|
|
31
|
-
if (projectionTarget(ctx) !== "audience-layer") {
|
|
32
|
-
throw new Error("an audience layer is reconciled with the audience-layer target only");
|
|
33
|
-
}
|
|
34
|
-
for (const [runtimeId, inputs] of await collectAudienceInputs(
|
|
35
|
-
ctx,
|
|
36
|
-
registrations,
|
|
37
|
-
ctx.audience!,
|
|
38
|
-
)) {
|
|
39
|
-
const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
|
|
40
|
-
if (!reconcile || inputs.length === 0) continue;
|
|
41
|
-
await reconcile(ctx, inputs);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Bumped when the layout a layer pass writes changes, so old layers are re-rendered. */
|
|
46
|
-
const LAYER_FORMAT = 1;
|
|
47
|
-
|
|
48
|
-
/** A render that never finished, or a superseded layer, is left this long for sessions still reading it. */
|
|
49
|
-
const STALE_LAYER_MS = 24 * 60 * 60 * 1000;
|
|
50
|
-
|
|
51
|
-
const FINGERPRINT_FILE = ".fingerprint";
|
|
52
|
-
|
|
53
|
-
export interface AudienceLayerRequest {
|
|
54
|
-
companionPath: string;
|
|
55
|
-
config: FrameworkConfig;
|
|
56
|
-
audience: string;
|
|
57
|
-
/** The Agent Runtimes the layer is rendered for. */
|
|
58
|
-
activeProviders: string[];
|
|
59
|
-
/** The process's registrations; only the audience's own are rendered. */
|
|
60
|
-
registrations: PluginRegistration[];
|
|
61
|
-
homeDir?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface RenderedAudienceLayer {
|
|
65
|
-
audience: string;
|
|
66
|
-
/** The complete layer a launch attaches; never changed once in place. */
|
|
67
|
-
root: string;
|
|
68
|
-
/** False when an unchanged layer was reused. */
|
|
69
|
-
rendered: boolean;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The installed version of each package, from the committed lockfile, else the installed manifest. */
|
|
77
|
-
async function lockedVersions(
|
|
78
|
-
companionPath: string,
|
|
79
|
-
packages: string[],
|
|
80
|
-
): Promise<Record<string, string | null>> {
|
|
81
|
-
const lock = await fs
|
|
82
|
-
.readFile(path.join(dynamicPluginsWorkspaceRoot(companionPath), "package-lock.json"), "utf8")
|
|
83
|
-
.then((text) => JSON.parse(text) as unknown)
|
|
84
|
-
.catch(() => null);
|
|
85
|
-
const lockPackages = isRecord(lock) && isRecord(lock.packages) ? lock.packages : {};
|
|
86
|
-
const versions: Record<string, string | null> = {};
|
|
87
|
-
for (const name of packages.toSorted()) {
|
|
88
|
-
const locked = lockPackages[`node_modules/${name}`];
|
|
89
|
-
if (isRecord(locked) && typeof locked.version === "string") {
|
|
90
|
-
versions[name] = locked.version;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
const manifest = await fs
|
|
94
|
-
.readFile(path.join(pluginPackageRoot(companionPath, name), "package.json"), "utf8")
|
|
95
|
-
.then((text) => JSON.parse(text) as unknown)
|
|
96
|
-
.catch(() => null);
|
|
97
|
-
versions[name] =
|
|
98
|
-
isRecord(manifest) && typeof manifest.version === "string" ? manifest.version : null;
|
|
99
|
-
}
|
|
100
|
-
return versions;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* What a layer depends on: the audience's declaration, the installed versions
|
|
105
|
-
* of its packages, the local plugin overrides, the Mate version and the active
|
|
106
|
-
* runtimes. Anything else changing leaves the layer as it is.
|
|
107
|
-
*/
|
|
108
|
-
export async function audienceLayerFingerprint(request: AudienceLayerRequest): Promise<string> {
|
|
109
|
-
const entry = request.config.audiences?.[request.audience] ?? null;
|
|
110
|
-
const packages = (entry?.plugins ?? []).map((declaration) => declaration.package);
|
|
111
|
-
const overrides = await fs
|
|
112
|
-
.readFile(pluginLocalOverridesPath(request.companionPath), "utf8")
|
|
113
|
-
.catch(() => null);
|
|
114
|
-
const inputs = {
|
|
115
|
-
format: LAYER_FORMAT,
|
|
116
|
-
audience: request.audience,
|
|
117
|
-
entry,
|
|
118
|
-
versions: await lockedVersions(request.companionPath, packages),
|
|
119
|
-
overrides,
|
|
120
|
-
mate: getActiveDistribution().config.version,
|
|
121
|
-
runtimes: [...new Set(request.activeProviders)].toSorted(),
|
|
122
|
-
};
|
|
123
|
-
return crypto.createHash("sha256").update(JSON.stringify(inputs)).digest("hex").slice(0, 24);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function exists(target: string): Promise<boolean> {
|
|
127
|
-
return fs
|
|
128
|
-
.access(target)
|
|
129
|
-
.then(() => true)
|
|
130
|
-
.catch(() => false);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Points `current` at `fingerprint` with one rename, so a reader never finds it missing. */
|
|
134
|
-
async function pointCurrentAt(dir: string, fingerprint: string): Promise<void> {
|
|
135
|
-
const link = path.join(dir, CURRENT_LAYER_LINK);
|
|
136
|
-
if ((await fs.readlink(link).catch(() => null)) === fingerprint) return;
|
|
137
|
-
const staged = path.join(dir, `.${CURRENT_LAYER_LINK}-${crypto.randomUUID()}`);
|
|
138
|
-
await fs.symlink(fingerprint, staged);
|
|
139
|
-
await fs.rename(staged, link);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Superseded layers and abandoned renders, once no session can still be starting from them. */
|
|
143
|
-
async function pruneStaleLayers(dir: string, keep: string): Promise<void> {
|
|
144
|
-
const now = Date.now();
|
|
145
|
-
for (const name of await fs.readdir(dir).catch(() => [])) {
|
|
146
|
-
/** Launch settings files live here too; each launch rewrites its own. */
|
|
147
|
-
if (name === keep || name === CURRENT_LAYER_LINK || name.startsWith("launch-")) continue;
|
|
148
|
-
const target = path.join(dir, name);
|
|
149
|
-
const stat = await fs.lstat(target).catch(() => null);
|
|
150
|
-
if (!stat || now - stat.mtimeMs < STALE_LAYER_MS) continue;
|
|
151
|
-
await fs.rm(target, { recursive: true, force: true });
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Creates the layers root and every level below it for the current user only. */
|
|
156
|
-
async function ensurePrivateDir(root: string, dir: string): Promise<void> {
|
|
157
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
158
|
-
for (let level = dir; level.startsWith(root); level = path.dirname(level)) {
|
|
159
|
-
await fs.chmod(level, 0o700);
|
|
160
|
-
if (level === root) break;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Renders the audience's layer, or reuses it when nothing it depends on
|
|
166
|
-
* changed. A render is built in a private staging directory and renamed into a
|
|
167
|
-
* directory named by its fingerprint, so a reader sees a complete layer or
|
|
168
|
-
* none; concurrent renders of the same inputs agree on one result.
|
|
169
|
-
*/
|
|
170
|
-
export async function renderAudienceLayer(
|
|
171
|
-
request: AudienceLayerRequest,
|
|
172
|
-
): Promise<RenderedAudienceLayer> {
|
|
173
|
-
const dir = audienceLayerDir(request.companionPath, request.audience, request.homeDir);
|
|
174
|
-
const fingerprint = await audienceLayerFingerprint(request);
|
|
175
|
-
const root = path.join(dir, fingerprint);
|
|
176
|
-
|
|
177
|
-
await ensurePrivateDir(audienceLayersRoot(request.homeDir), dir);
|
|
178
|
-
if (await exists(path.join(root, FINGERPRINT_FILE))) {
|
|
179
|
-
await pointCurrentAt(dir, fingerprint);
|
|
180
|
-
return { audience: request.audience, root, rendered: false };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const staging = await fs.mkdtemp(path.join(dir, ".render-"));
|
|
184
|
-
try {
|
|
185
|
-
await reconcileAudienceLayer(
|
|
186
|
-
{
|
|
187
|
-
companionPath: request.companionPath,
|
|
188
|
-
config: request.config,
|
|
189
|
-
mode: "sync",
|
|
190
|
-
activeProviders: request.activeProviders,
|
|
191
|
-
target: "audience-layer",
|
|
192
|
-
audience: request.audience,
|
|
193
|
-
layerRoot: staging,
|
|
194
|
-
},
|
|
195
|
-
request.registrations,
|
|
196
|
-
);
|
|
197
|
-
await fs.writeFile(path.join(staging, FINGERPRINT_FILE), `${fingerprint}\n`, "utf8");
|
|
198
|
-
await fs.rename(staging, root);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
await fs.rm(staging, { recursive: true, force: true });
|
|
201
|
-
/** Another launch renamed the same inputs into place first: its layer is this one. */
|
|
202
|
-
if (!(await exists(path.join(root, FINGERPRINT_FILE)))) throw error;
|
|
203
|
-
}
|
|
204
|
-
await pointCurrentAt(dir, fingerprint);
|
|
205
|
-
await pruneStaleLayers(dir, fingerprint);
|
|
206
|
-
return { audience: request.audience, root, rendered: true };
|
|
207
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// oxlint-disable no-await-in-loop
|
|
2
|
-
import { getActiveDistribution } from "../../distribution";
|
|
3
|
-
import type { FrameworkConfig } from "../../lib/orchestrator/types";
|
|
4
|
-
import { validateDeclaration } from "./dynamic-plugins/declarations";
|
|
5
|
-
import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./dynamic-plugins/loader";
|
|
6
|
-
import type { PluginRegistry, NormalizedRegistration } from "./registry";
|
|
7
|
-
|
|
8
|
-
export interface LoadedAudience {
|
|
9
|
-
registrations: NormalizedRegistration[];
|
|
10
|
-
warnings: string[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The registrations of one named audience, independent of `MATE_AUDIENCE`: a
|
|
15
|
-
* plugin this process already hydrated is reused, any other is loaded for its
|
|
16
|
-
* declarations only — never activated, no lifecycle hook run. Used where the
|
|
17
|
-
* audience comes from a checkout's wrap rather than the environment.
|
|
18
|
-
*/
|
|
19
|
-
export async function loadAudienceRegistrations(
|
|
20
|
-
companionPath: string,
|
|
21
|
-
config: FrameworkConfig,
|
|
22
|
-
audience: string,
|
|
23
|
-
registry: PluginRegistry = getActiveDistribution().registry,
|
|
24
|
-
deps: DynamicPluginLoadDeps = {},
|
|
25
|
-
): Promise<LoadedAudience> {
|
|
26
|
-
const loaded: LoadedAudience = { registrations: [], warnings: [] };
|
|
27
|
-
const hydrated = registry.getEntries();
|
|
28
|
-
for (const entry of config.audiences?.[audience]?.plugins ?? []) {
|
|
29
|
-
const { declaration, error } = validateDeclaration(entry);
|
|
30
|
-
if (!declaration) {
|
|
31
|
-
if (error) loaded.warnings.push(error);
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const known = hydrated.find((candidate) => candidate.packageName === declaration.package);
|
|
35
|
-
if (known) {
|
|
36
|
-
loaded.registrations.push({ ...known, audience });
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
const result = await loadDynamicPlugin(companionPath, declaration, deps);
|
|
40
|
-
if (!result.ok) {
|
|
41
|
-
loaded.warnings.push(`audience "${audience}": ${result.warning}`);
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
loaded.registrations.push({
|
|
45
|
-
plugin: result.plugin,
|
|
46
|
-
policy: "required",
|
|
47
|
-
audience,
|
|
48
|
-
packageName: declaration.package,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
return loaded;
|
|
52
|
-
}
|