@uniqbit/mate-core 0.16.0 → 0.17.0-canary.10
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/package.json +2 -2
- package/src/cli/commands/companion/companion.ts +8 -0
- package/src/cli/commands/companion/prepare.ts +76 -0
- package/src/cli/commands/companion/register.ts +47 -0
- package/src/cli/commands/launch/claude.ts +4 -3
- package/src/cli/commands/launch/opencode.ts +4 -3
- package/src/cli/commands/launch/shared.ts +41 -28
- package/src/cli/commands/shared/companion-selection.ts +3 -1
- package/src/cli/commands/studio/index.ts +85 -6
- package/src/cli/commands/studio/routes.ts +1 -1
- package/src/cli/commands/studio/selection.ts +11 -2
- package/src/cli/commands/studio/server.ts +212 -17
- package/src/cli/commands/studio/vault.ts +607 -0
- package/src/cli/commands/studio/views/client.ts +141 -1
- package/src/cli/commands/studio/views/companion-selector.tsx +1 -1
- package/src/cli/commands/studio/views/document.tsx +137 -3
- package/src/cli/commands/studio/views/model.ts +13 -0
- package/src/cli/commands/studio/views/styles.ts +17 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +6 -6
- package/src/cli/main.ts +56 -4
- package/src/cli/usage.ts +6 -3
- package/src/index.ts +1 -0
- package/src/lib/context-mode-package.ts +7 -4
- package/src/lib/node-engine-version.ts +15 -0
- package/src/lib/opencode-plugin-package.ts +10 -4
- package/src/lib/orchestrator/adapters/base.ts +65 -10
- package/src/lib/orchestrator/adapters/opencode.ts +27 -2
- package/src/lib/orchestrator/companion-registration.ts +73 -0
- package/src/lib/orchestrator/framework-context.ts +29 -3
- package/src/lib/orchestrator/launcher.ts +46 -22
- package/src/lib/orchestrator/projection-record.ts +24 -0
- package/src/lib/orchestrator/types.ts +11 -0
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace-prepare.ts +152 -0
- package/src/lib/prebuilt-workspace.ts +246 -0
- package/src/lib/preinstalled-plugins.ts +106 -0
- package/src/lib/update-checker.ts +39 -0
- package/src/opencode/companion-hooks.ts +45 -20
- package/src/playbooks/companion-guidance.ts +1 -0
- package/src/runtime/companion-guidance.ts +61 -15
- package/src/runtime/env.ts +3 -3
- package/src/runtime/index.ts +1 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +127 -77
- package/src/tools/setup/capabilities/context-mode.ts +34 -2
- package/src/tools/setup/capabilities/openspec.ts +12 -2
- package/src/tools/setup/plugin.ts +7 -1
- package/src/tools/setup/providers/opencode.ts +24 -5
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { PREBUILT_BUNDLE_MARKER as BUNDLE_MARKER } from "./preinstalled-plugins";
|
|
6
|
+
import {
|
|
7
|
+
currentTarget,
|
|
8
|
+
getLocalWorkspaceDir,
|
|
9
|
+
validatePrebuiltWorkspace,
|
|
10
|
+
type PrebuiltWorkspaceTarget,
|
|
11
|
+
} from "./prebuilt-workspace";
|
|
12
|
+
|
|
13
|
+
export type PreparePrebuiltWorkspaceResult =
|
|
14
|
+
| { ok: true; workspacePath: string; reused: boolean; packages: Record<string, string> }
|
|
15
|
+
| { ok: false; workspacePath: string; failures: string[] };
|
|
16
|
+
|
|
17
|
+
export const prebuiltWorkspaceDeps = {
|
|
18
|
+
/** Filesystem-only: a plain recursive copy, never a package manager or an install script. */
|
|
19
|
+
copy: (source: string, destination: string) =>
|
|
20
|
+
fs.cp(source, destination, { recursive: true, force: true, errorOnExist: false }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Content identity of an installed tree: a version match is not enough, since
|
|
25
|
+
* two bundles can pin the same versions and still differ.
|
|
26
|
+
*/
|
|
27
|
+
export async function fingerprintWorkspace(root: string): Promise<string> {
|
|
28
|
+
const hash = crypto.createHash("sha256");
|
|
29
|
+
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
30
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
31
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
32
|
+
if (prefix === "" && entry.name === BUNDLE_MARKER) continue;
|
|
33
|
+
const full = path.join(dir, entry.name);
|
|
34
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
hash.update(`d:${relative}\n`);
|
|
37
|
+
await walk(full, relative);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (entry.isSymbolicLink()) {
|
|
41
|
+
hash.update(`l:${relative}:${await fs.readlink(full).catch(() => "")}\n`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
hash.update(`f:${relative}:`);
|
|
45
|
+
hash.update(await fs.readFile(full));
|
|
46
|
+
hash.update("\n");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
await walk(path.resolve(root), "");
|
|
50
|
+
return hash.digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readBundleMarker(workspacePath: string): Promise<string | null> {
|
|
54
|
+
try {
|
|
55
|
+
const marker = JSON.parse(
|
|
56
|
+
await fs.readFile(path.join(workspacePath, BUNDLE_MARKER), "utf8"),
|
|
57
|
+
) as { fingerprint?: string };
|
|
58
|
+
return marker.fingerprint ?? null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function samePackages(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
65
|
+
const names = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
66
|
+
for (const name of names) if (a[name] !== b[name]) return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function exists(target: string): Promise<boolean> {
|
|
71
|
+
return fs.access(target).then(
|
|
72
|
+
() => true,
|
|
73
|
+
() => false,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Copies a validated prebuilt workspace into the companion's machine-local
|
|
79
|
+
* workspace. Presents nothing, waits for nothing, reads no selection and
|
|
80
|
+
* writes none. A changed bundle is staged and validated before it replaces the
|
|
81
|
+
* framework-owned generated workspace; any failure leaves the existing
|
|
82
|
+
* workspace exactly as it was.
|
|
83
|
+
*/
|
|
84
|
+
export async function preparePrebuiltWorkspace(
|
|
85
|
+
companionPath: string,
|
|
86
|
+
bundlePath: string,
|
|
87
|
+
target: PrebuiltWorkspaceTarget = currentTarget(),
|
|
88
|
+
): Promise<PreparePrebuiltWorkspaceResult> {
|
|
89
|
+
const workspacePath = getLocalWorkspaceDir(companionPath);
|
|
90
|
+
const source = path.resolve(bundlePath);
|
|
91
|
+
|
|
92
|
+
const bundle = await validatePrebuiltWorkspace(source, target);
|
|
93
|
+
if (!bundle.ok) return { ok: false, workspacePath, failures: bundle.failures };
|
|
94
|
+
|
|
95
|
+
const fingerprint = await fingerprintWorkspace(source);
|
|
96
|
+
if (await exists(workspacePath)) {
|
|
97
|
+
const existing = await validatePrebuiltWorkspace(workspacePath, target);
|
|
98
|
+
if (
|
|
99
|
+
existing.ok &&
|
|
100
|
+
samePackages(existing.packages, bundle.packages) &&
|
|
101
|
+
(await readBundleMarker(workspacePath)) === fingerprint
|
|
102
|
+
) {
|
|
103
|
+
return { ok: true, workspacePath, reused: true, packages: existing.packages };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const staging = `${workspacePath}.staging`;
|
|
108
|
+
const previous = `${workspacePath}.previous`;
|
|
109
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
110
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await fs.mkdir(path.dirname(workspacePath), { recursive: true });
|
|
114
|
+
await prebuiltWorkspaceDeps.copy(source, staging);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
workspacePath,
|
|
120
|
+
failures: [`${source}: copying the bundle failed: ${(error as Error).message}`],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const staged = await validatePrebuiltWorkspace(staging, target);
|
|
125
|
+
if (!staged.ok) {
|
|
126
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
127
|
+
return { ok: false, workspacePath, failures: staged.failures };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await fs.writeFile(
|
|
131
|
+
path.join(staging, BUNDLE_MARKER),
|
|
132
|
+
`${JSON.stringify({ fingerprint, packages: staged.packages }, null, 2)}\n`,
|
|
133
|
+
"utf8",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const hadExisting = await exists(workspacePath);
|
|
137
|
+
try {
|
|
138
|
+
if (hadExisting) await fs.rename(workspacePath, previous);
|
|
139
|
+
await fs.rename(staging, workspacePath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
142
|
+
if (hadExisting && !(await exists(workspacePath))) await fs.rename(previous, workspacePath);
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
workspacePath,
|
|
146
|
+
failures: [`${workspacePath}: replacing the workspace failed: ${(error as Error).message}`],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
await fs.rm(previous, { recursive: true, force: true });
|
|
150
|
+
|
|
151
|
+
return { ok: true, workspacePath, reused: false, packages: staged.packages };
|
|
152
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import semver from "semver";
|
|
5
|
+
|
|
6
|
+
import { OPENCODE_PLUGIN_PACKAGE_NAME } from "./opencode-plugin-package";
|
|
7
|
+
import { getCurrentVersion } from "./update-checker";
|
|
8
|
+
import {
|
|
9
|
+
CONTEXT_MODE_NODE_REQUIREMENT,
|
|
10
|
+
CONTEXT_MODE_PACKAGE_NAME,
|
|
11
|
+
CONTEXT_MODE_VERSION,
|
|
12
|
+
} from "./context-mode-package";
|
|
13
|
+
import { enforceableNodeVersion } from "./node-engine-version";
|
|
14
|
+
import { getLocalWorkspaceDir } from "./preinstalled-plugins";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Packages the distribution selects and pins for the machine-local workspace.
|
|
18
|
+
* A bundle may carry any subset; it may carry nothing else.
|
|
19
|
+
*/
|
|
20
|
+
export function getDistributionLocalDependencies(): Record<string, string> {
|
|
21
|
+
return {
|
|
22
|
+
[CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_VERSION,
|
|
23
|
+
[OPENCODE_PLUGIN_PACKAGE_NAME]: getCurrentVersion(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Assets a package must carry to count as fully installed rather than merely present. */
|
|
28
|
+
const REQUIRED_PACKAGE_ASSETS: Record<string, string[]> = {
|
|
29
|
+
[CONTEXT_MODE_PACKAGE_NAME]: [
|
|
30
|
+
".claude-plugin/plugin.json",
|
|
31
|
+
"hooks/hooks.json",
|
|
32
|
+
"skills/context-mode/SKILL.md",
|
|
33
|
+
"build/adapters/opencode/plugin.js",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Runtime requirement the distribution knows independently of the installed manifest. */
|
|
38
|
+
const DECLARED_NODE_REQUIREMENTS: Record<string, string> = {
|
|
39
|
+
[CONTEXT_MODE_PACKAGE_NAME]: CONTEXT_MODE_NODE_REQUIREMENT,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface PrebuiltWorkspaceTarget {
|
|
43
|
+
platform: string;
|
|
44
|
+
arch: string;
|
|
45
|
+
/** Null where no installed Node release backs the runtime, which leaves `engines.node` unenforced. */
|
|
46
|
+
nodeVersion: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PrebuiltWorkspaceValidation {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
/** One entry per failure, each naming the package or file it is about. */
|
|
52
|
+
failures: string[];
|
|
53
|
+
/** Installed versions keyed by package name, for comparing one bundle with another. */
|
|
54
|
+
packages: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function currentTarget(): PrebuiltWorkspaceTarget {
|
|
58
|
+
return { platform: process.platform, arch: process.arch, nodeVersion: enforceableNodeVersion() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface InstalledManifest {
|
|
62
|
+
name?: string;
|
|
63
|
+
version?: string;
|
|
64
|
+
dependencies?: Record<string, string>;
|
|
65
|
+
optionalDependencies?: Record<string, string>;
|
|
66
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
67
|
+
engines?: { node?: string };
|
|
68
|
+
os?: string[];
|
|
69
|
+
cpu?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readJson<T>(file: string): Promise<T | null> {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(await fs.readFile(file, "utf8")) as T;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Node's own resolution: nearest `node_modules` first, then each ancestor up to the bundle root. */
|
|
81
|
+
async function resolveFromBundle(
|
|
82
|
+
nodeModules: string,
|
|
83
|
+
fromDir: string,
|
|
84
|
+
dependency: string,
|
|
85
|
+
): Promise<InstalledManifest | null> {
|
|
86
|
+
let dir = fromDir;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const candidate = path.join(dir, "node_modules", ...dependency.split("/"), "package.json");
|
|
89
|
+
const manifest = await readJson<InstalledManifest>(candidate);
|
|
90
|
+
if (manifest) return manifest;
|
|
91
|
+
if (path.resolve(dir, "node_modules") === path.resolve(nodeModules)) return null;
|
|
92
|
+
const parent = path.dirname(dir);
|
|
93
|
+
if (parent === dir) return null;
|
|
94
|
+
dir = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listInstalledPackages(nodeModules: string): Promise<string[]> {
|
|
99
|
+
const entries = await fs.readdir(nodeModules, { withFileTypes: true }).catch(() => []);
|
|
100
|
+
const names: string[] = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
103
|
+
if (entry.name.startsWith(".")) continue;
|
|
104
|
+
if (entry.name.startsWith("@")) {
|
|
105
|
+
const scoped = await fs
|
|
106
|
+
.readdir(path.join(nodeModules, entry.name), { withFileTypes: true })
|
|
107
|
+
.catch(() => []);
|
|
108
|
+
for (const child of scoped) {
|
|
109
|
+
if (child.isDirectory() || child.isSymbolicLink())
|
|
110
|
+
names.push(`${entry.name}/${child.name}`);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
names.push(entry.name);
|
|
115
|
+
}
|
|
116
|
+
return names;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates an explicitly supplied, fully installed dependency bundle before
|
|
121
|
+
* anything is copied: distribution-owned versions, a complete dependency
|
|
122
|
+
* graph, integrity, target architecture, and runtime compatibility. Nothing is
|
|
123
|
+
* installed, resolved, or downloaded — every answer comes off the filesystem.
|
|
124
|
+
*/
|
|
125
|
+
export async function validatePrebuiltWorkspace(
|
|
126
|
+
bundlePath: string,
|
|
127
|
+
target: PrebuiltWorkspaceTarget = currentTarget(),
|
|
128
|
+
): Promise<PrebuiltWorkspaceValidation> {
|
|
129
|
+
const root = path.resolve(bundlePath);
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
const packages: Record<string, string> = {};
|
|
132
|
+
|
|
133
|
+
const manifest = await readJson<InstalledManifest>(path.join(root, "package.json"));
|
|
134
|
+
if (!manifest || typeof manifest.dependencies !== "object" || manifest.dependencies === null) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
failures: [`${root}: no framework-generated package.json with a dependencies map.`],
|
|
138
|
+
packages,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const owned = getDistributionLocalDependencies();
|
|
143
|
+
const nodeModules = path.join(root, "node_modules");
|
|
144
|
+
|
|
145
|
+
for (const [name, version] of Object.entries(manifest.dependencies)) {
|
|
146
|
+
const pinned = owned[name];
|
|
147
|
+
if (pinned === undefined) {
|
|
148
|
+
failures.push(`${name}: not a distribution-owned dependency; the bundle may not carry it.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (version !== pinned) {
|
|
152
|
+
failures.push(`${name}: the distribution pins ${pinned}; the bundle declares ${version}.`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const installed = await readJson<InstalledManifest>(
|
|
157
|
+
path.join(nodeModules, ...name.split("/"), "package.json"),
|
|
158
|
+
);
|
|
159
|
+
if (!installed) {
|
|
160
|
+
failures.push(`${name}: declared by the bundle but not installed in its node_modules.`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (installed.version !== pinned) {
|
|
164
|
+
failures.push(
|
|
165
|
+
`${name}: expected ${pinned} installed; found ${installed.version ?? "an unknown version"}.`,
|
|
166
|
+
);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
packages[name] = pinned;
|
|
170
|
+
|
|
171
|
+
const missingAssets: string[] = [];
|
|
172
|
+
for (const asset of REQUIRED_PACKAGE_ASSETS[name] ?? []) {
|
|
173
|
+
const assetPath = path.join(nodeModules, ...name.split("/"), ...asset.split("/"));
|
|
174
|
+
if (
|
|
175
|
+
!(await fs.access(assetPath).then(
|
|
176
|
+
() => true,
|
|
177
|
+
() => false,
|
|
178
|
+
))
|
|
179
|
+
)
|
|
180
|
+
missingAssets.push(asset);
|
|
181
|
+
}
|
|
182
|
+
if (missingAssets.length > 0) {
|
|
183
|
+
failures.push(`${name}: installed copy is incomplete; missing ${missingAssets.join(", ")}.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const declaredRequirement = DECLARED_NODE_REQUIREMENTS[name];
|
|
187
|
+
if (
|
|
188
|
+
declaredRequirement &&
|
|
189
|
+
target.nodeVersion !== null &&
|
|
190
|
+
!semver.satisfies(target.nodeVersion, declaredRequirement)
|
|
191
|
+
) {
|
|
192
|
+
failures.push(
|
|
193
|
+
`${name}: requires Node.js ${declaredRequirement}; the target runs ${target.nodeVersion}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const name of await listInstalledPackages(nodeModules)) {
|
|
199
|
+
const packageDir = path.join(nodeModules, ...name.split("/"));
|
|
200
|
+
const installed = await readJson<InstalledManifest>(path.join(packageDir, "package.json"));
|
|
201
|
+
if (!installed) {
|
|
202
|
+
failures.push(`${name}: installed directory has no readable package.json.`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
Array.isArray(installed.os) &&
|
|
208
|
+
installed.os.length > 0 &&
|
|
209
|
+
!installed.os.includes(target.platform)
|
|
210
|
+
) {
|
|
211
|
+
failures.push(
|
|
212
|
+
`${name}: built for ${installed.os.join(", ")}; the target platform is ${target.platform}.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
Array.isArray(installed.cpu) &&
|
|
217
|
+
installed.cpu.length > 0 &&
|
|
218
|
+
!installed.cpu.includes(target.arch)
|
|
219
|
+
) {
|
|
220
|
+
failures.push(
|
|
221
|
+
`${name}: built for ${installed.cpu.join(", ")}; the target architecture is ${target.arch}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
installed.engines?.node &&
|
|
226
|
+
target.nodeVersion !== null &&
|
|
227
|
+
!semver.satisfies(target.nodeVersion, installed.engines.node)
|
|
228
|
+
) {
|
|
229
|
+
failures.push(
|
|
230
|
+
`${name}: requires Node.js ${installed.engines.node}; the target runs ${target.nodeVersion}.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const dependency of Object.keys(installed.dependencies ?? {})) {
|
|
235
|
+
if (installed.optionalDependencies?.[dependency] !== undefined) continue;
|
|
236
|
+
const resolved = await resolveFromBundle(nodeModules, packageDir, dependency);
|
|
237
|
+
if (!resolved) {
|
|
238
|
+
failures.push(`${dependency}: required by ${name} but not installed in the bundle.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ok: failures.length === 0, failures, packages };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export { getLocalWorkspaceDir };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import semver from "semver";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../framework";
|
|
7
|
+
import { enforceableNodeVersion } from "./node-engine-version";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A plugin reference bound to installed files is an absolute path to the
|
|
11
|
+
* installed package root, so the Agent Runtime loads it instead of resolving,
|
|
12
|
+
* fetching, or installing the published package.
|
|
13
|
+
*/
|
|
14
|
+
/** The framework-owned machine-local workspace; a leaf definition, since the
|
|
15
|
+
* managed-reference predicates depend on it. */
|
|
16
|
+
export function getLocalWorkspaceDir(companionPath: string): string {
|
|
17
|
+
return path.join(companionPath, `.${FRAMEWORK_NAME}`, "plugins", ".local");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Written by prebuilt preparation, and the only mark that the distribution
|
|
22
|
+
* *supplied* an installed workspace. Ordinary setup installs into the same
|
|
23
|
+
* location, and that must keep writing the published reference.
|
|
24
|
+
*/
|
|
25
|
+
export const PREBUILT_BUNDLE_MARKER = ".mate-prebuilt.json";
|
|
26
|
+
|
|
27
|
+
export function getPreinstalledPluginDir(companionPath: string, packageName: string): string {
|
|
28
|
+
return path.join(getLocalWorkspaceDir(companionPath), "node_modules", ...packageName.split("/"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True for a reference that is a path whose last segment is the package itself. */
|
|
32
|
+
export function isPreinstalledPluginPath(entry: unknown, packageName: string): boolean {
|
|
33
|
+
if (typeof entry !== "string") return false;
|
|
34
|
+
if (!entry.includes("/") && !entry.includes("\\")) return false;
|
|
35
|
+
const normalized = entry.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
36
|
+
return normalized.endsWith(`/${packageName}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The version behind a reference bound to an installed copy.
|
|
41
|
+
*
|
|
42
|
+
* A published reference carries its version in the string; a bound one carries
|
|
43
|
+
* it in the package it points at, so staleness has to be read off disk. Shared
|
|
44
|
+
* by every site that validates a bound reference, so they cannot disagree about
|
|
45
|
+
* what "stale" means.
|
|
46
|
+
*/
|
|
47
|
+
export async function installedVersionAt(packageRoot: string): Promise<string | undefined> {
|
|
48
|
+
try {
|
|
49
|
+
const manifest = JSON.parse(
|
|
50
|
+
await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
|
|
51
|
+
) as { version?: string };
|
|
52
|
+
return manifest.version;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PreinstalledPluginMismatchError extends Error {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves an installed copy of a declared plugin package from a workspace the
|
|
62
|
+
* distribution supplied. Returns null where none was supplied — the ordinary
|
|
63
|
+
* workstation, whose setup-installed workspace carries no bundle marker, so the
|
|
64
|
+
* published reference is written exactly as before. A supplied copy that does
|
|
65
|
+
* not match what the Capability declares is reported by name rather than bound,
|
|
66
|
+
* and nothing is installed to repair it.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolvePreinstalledPluginReference(
|
|
69
|
+
companionPath: string,
|
|
70
|
+
packageName: string,
|
|
71
|
+
expectedVersion: string,
|
|
72
|
+
nodeVersion: string | null = enforceableNodeVersion(),
|
|
73
|
+
): Promise<string | null> {
|
|
74
|
+
const workspace = getLocalWorkspaceDir(companionPath);
|
|
75
|
+
const supplied = await fs.access(path.join(workspace, PREBUILT_BUNDLE_MARKER)).then(
|
|
76
|
+
() => true,
|
|
77
|
+
() => false,
|
|
78
|
+
);
|
|
79
|
+
if (!supplied) return null;
|
|
80
|
+
|
|
81
|
+
const dir = getPreinstalledPluginDir(companionPath, packageName);
|
|
82
|
+
let manifest: { version?: string; engines?: { node?: string } };
|
|
83
|
+
try {
|
|
84
|
+
manifest = JSON.parse(
|
|
85
|
+
await fs.readFile(path.join(dir, "package.json"), "utf8"),
|
|
86
|
+
) as typeof manifest;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (manifest.version !== expectedVersion) {
|
|
92
|
+
throw new PreinstalledPluginMismatchError(
|
|
93
|
+
`${packageName}: the installed copy at ${dir} is ${manifest.version ?? "an unknown version"}; the capability declares ${expectedVersion}.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
manifest.engines?.node &&
|
|
98
|
+
nodeVersion !== null &&
|
|
99
|
+
!semver.satisfies(nodeVersion, manifest.engines.node)
|
|
100
|
+
) {
|
|
101
|
+
throw new PreinstalledPluginMismatchError(
|
|
102
|
+
`${packageName}: the installed copy at ${dir} requires Node.js ${manifest.engines.node}; this runtime is ${nodeVersion}.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return dir;
|
|
106
|
+
}
|
|
@@ -20,6 +20,42 @@ export const updateCheckerDeps = {
|
|
|
20
20
|
toIsoString: () => new Date().toISOString(),
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/** Declares that the installation's version is fixed by the artifact it was built into. */
|
|
24
|
+
export const UPDATE_POLICY_ENV = "MATE_UPDATE_POLICY";
|
|
25
|
+
|
|
26
|
+
export type UpdatePolicy = "default" | "pinned";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reported at most once per process: the three automatic-update entry points
|
|
30
|
+
* each consult the policy, and an unrecognized value must not be repeated.
|
|
31
|
+
*/
|
|
32
|
+
let unrecognizedPolicyReported = false;
|
|
33
|
+
|
|
34
|
+
/** Test seam — the policy is process-wide, so its report latch must be resettable. */
|
|
35
|
+
export function resetUpdatePolicyReport(): void {
|
|
36
|
+
unrecognizedPolicyReported = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getUpdatePolicy(env: NodeJS.ProcessEnv = process.env): UpdatePolicy {
|
|
40
|
+
const raw = env[UPDATE_POLICY_ENV];
|
|
41
|
+
if (raw === undefined || raw.trim() === "") return "default";
|
|
42
|
+
|
|
43
|
+
const value = raw.trim();
|
|
44
|
+
if (value === "pinned") return "pinned";
|
|
45
|
+
|
|
46
|
+
if (!unrecognizedPolicyReported) {
|
|
47
|
+
unrecognizedPolicyReported = true;
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
`${FRAMEWORK_NAME}: unrecognized ${UPDATE_POLICY_ENV} value \`${value}\`; automatic update handling is unchanged.\n`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return "default";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isPinnedDeployment(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
56
|
+
return getUpdatePolicy(env) === "pinned";
|
|
57
|
+
}
|
|
58
|
+
|
|
23
59
|
const updateStateFileSlug = (packageName: string): string =>
|
|
24
60
|
packageName.replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
25
61
|
|
|
@@ -75,6 +111,7 @@ export function isNewer(latest: string, current: string): boolean {
|
|
|
75
111
|
}
|
|
76
112
|
|
|
77
113
|
export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Promise<void> {
|
|
114
|
+
if (isPinnedDeployment()) return;
|
|
78
115
|
try {
|
|
79
116
|
const state = await store.load();
|
|
80
117
|
if (!state.latestVersion) return;
|
|
@@ -95,6 +132,7 @@ export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Prom
|
|
|
95
132
|
* must stop; state-load failures never block.
|
|
96
133
|
*/
|
|
97
134
|
export async function enforceUpdateIfRequired(store: UpdateStateStore): Promise<boolean> {
|
|
135
|
+
if (isPinnedDeployment()) return false;
|
|
98
136
|
if (!getUpdateConfig().enforce) return false;
|
|
99
137
|
try {
|
|
100
138
|
const state = await store.load();
|
|
@@ -119,6 +157,7 @@ export async function fetchLatestVersion(): Promise<string> {
|
|
|
119
157
|
}
|
|
120
158
|
|
|
121
159
|
export function scheduleBackgroundCheck(store: UpdateStateStore): Promise<void> {
|
|
160
|
+
if (isPinnedDeployment()) return Promise.resolve();
|
|
122
161
|
return (async () => {
|
|
123
162
|
try {
|
|
124
163
|
const state = await store.load();
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
unattendedSyncStalenessLines,
|
|
10
10
|
} from "../runtime/companion-sync";
|
|
11
11
|
import { hasLaunchEnvironment } from "../runtime/env";
|
|
12
|
+
import { MATE_ENV } from "../runtime/env-names";
|
|
12
13
|
import {
|
|
13
14
|
buildArtifactError,
|
|
14
15
|
extractPatchPaths,
|
|
@@ -122,7 +123,12 @@ export async function repairCompanionGitOnce(
|
|
|
122
123
|
client: PluginInput["client"] | undefined,
|
|
123
124
|
env: Record<string, string | undefined> = process.env,
|
|
124
125
|
): Promise<string[]> {
|
|
125
|
-
if (
|
|
126
|
+
if (
|
|
127
|
+
!context.companionPath ||
|
|
128
|
+
(hasLaunchEnvironment(env) &&
|
|
129
|
+
(env[MATE_ENV.repositoryPath] !== undefined || env[MATE_ENV.gitAutoMode] !== "1"))
|
|
130
|
+
)
|
|
131
|
+
return [];
|
|
126
132
|
if (repairedCompanions.has(context.companionPath)) return [];
|
|
127
133
|
repairedCompanions.add(context.companionPath);
|
|
128
134
|
|
|
@@ -182,7 +188,7 @@ type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
|
|
|
182
188
|
export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
|
|
183
189
|
const { client, $ } = pluginInput;
|
|
184
190
|
const context = readContext();
|
|
185
|
-
if (!context.companionPath
|
|
191
|
+
if (!context.companionPath) return {};
|
|
186
192
|
|
|
187
193
|
/**
|
|
188
194
|
* The repair sits above the returned hooks, so anything escaping it would
|
|
@@ -195,29 +201,33 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
|
|
|
195
201
|
const reactDoctorScansInFlight = new Set<string>();
|
|
196
202
|
|
|
197
203
|
return {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
204
|
+
...(context.repositoryPath
|
|
205
|
+
? {
|
|
206
|
+
event: async ({ event }: PluginEventInput) => {
|
|
207
|
+
if (event.type !== "session.idle") return;
|
|
208
|
+
const sessionID = event.properties.sessionID;
|
|
209
|
+
if (
|
|
210
|
+
!context.reactDoctorEnabled ||
|
|
211
|
+
reactDoctorScansInFlight.has(sessionID) ||
|
|
212
|
+
!dirtyReactDoctorSessions.delete(sessionID)
|
|
213
|
+
) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await runReactDoctorScan(context, client, $, sessionID, reactDoctorScansInFlight);
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
: {}),
|
|
210
220
|
"tool.execute.before": async (input: ToolBeforeInput, output: ToolBeforeOutput) => {
|
|
211
221
|
const toolName = String(input.tool ?? "");
|
|
212
222
|
const args = output.args ?? {};
|
|
213
|
-
if (["write", "edit"].includes(toolName)) {
|
|
223
|
+
if (context.repositoryPath && ["write", "edit"].includes(toolName)) {
|
|
214
224
|
const filePath = String(args.filePath ?? "");
|
|
215
225
|
if (filePath && shouldBlockArtifactWrite(context, filePath)) {
|
|
216
226
|
throw new Error(buildArtifactError(context, filePath));
|
|
217
227
|
}
|
|
218
228
|
refuseForkedCompanionWrite(context, filePath);
|
|
219
229
|
}
|
|
220
|
-
if (toolName === "apply_patch") {
|
|
230
|
+
if (context.repositoryPath && toolName === "apply_patch") {
|
|
221
231
|
for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
|
|
222
232
|
if (shouldBlockArtifactWrite(context, filePath)) {
|
|
223
233
|
throw new Error(buildArtifactError(context, filePath));
|
|
@@ -225,12 +235,27 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
|
|
|
225
235
|
refuseForkedCompanionWrite(context, filePath);
|
|
226
236
|
}
|
|
227
237
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
238
|
+
if (!context.repositoryPath && toolName === "write") {
|
|
239
|
+
refuseForkedCompanionWrite(context, String(args.filePath ?? ""));
|
|
240
|
+
}
|
|
241
|
+
if (!context.repositoryPath && toolName === "edit") {
|
|
242
|
+
refuseForkedCompanionWrite(context, String(args.filePath ?? ""));
|
|
243
|
+
}
|
|
244
|
+
if (!context.repositoryPath && toolName === "apply_patch") {
|
|
245
|
+
for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
|
|
246
|
+
refuseForkedCompanionWrite(context, filePath);
|
|
247
|
+
}
|
|
232
248
|
}
|
|
233
249
|
},
|
|
250
|
+
...(context.repositoryPath
|
|
251
|
+
? {
|
|
252
|
+
"tool.execute.after": async (input: ToolAfterInput) => {
|
|
253
|
+
if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
|
|
254
|
+
dirtyReactDoctorSessions.add(input.sessionID);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
: {}),
|
|
234
259
|
};
|
|
235
260
|
};
|
|
236
261
|
|