@uniqbit/mate-core 0.18.3-canary.1 → 0.18.3
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/claude-plugin/hooks/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/companion.ts +47 -12
- package/opencode-plugin/src/server.ts +39 -4
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +1 -1
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +53 -59
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +3 -1
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +43 -8
- package/src/lib/orchestrator/adapters/claude.ts +130 -29
- package/src/lib/orchestrator/adapters/opencode.ts +17 -19
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +9 -0
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +54 -22
- package/src/lib/orchestrator/projection-entries.ts +78 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +38 -33
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +425 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/working-repo-projection.ts +71 -159
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +131 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +24 -132
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +335 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +90 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -1
- package/src/tools/setup/engine.ts +15 -4
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +23 -6
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/working-repo-local-state.ts +82 -13
- package/src/tools/setup.ts +2 -2
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- package/src/templates/report-assets/mermaid.min.js +0 -4376
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
3
|
|
|
6
4
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
|
-
import { gitEnvironment } from "../../runtime/companion-git";
|
|
8
5
|
import { readProjectionFile } from "../../runtime/projection";
|
|
6
|
+
import { GlobalConfigStore } from "./global-config-store";
|
|
9
7
|
import { repoLocalDirPath } from "../../runtime/repo-local";
|
|
10
|
-
import type { GlobalConfigStore } from "./global-config-store";
|
|
11
8
|
import { projectionEntries } from "./projection-entries";
|
|
12
9
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "
|
|
10
|
+
copies as recordedCopies,
|
|
11
|
+
documents as recordedDocuments,
|
|
12
|
+
readManifest,
|
|
13
|
+
} from "../../runtime/runtime-document-manifest";
|
|
14
|
+
import { removeRuntimeDocuments } from "./projection-runtime-documents";
|
|
17
15
|
import {
|
|
18
16
|
firstFailure,
|
|
19
|
-
type RenderedRuntimeDocument,
|
|
20
17
|
type ProjectionDescription,
|
|
21
18
|
type ProjectionEntry,
|
|
22
19
|
type ProjectionEntryOutcome,
|
|
@@ -26,10 +23,10 @@ import {
|
|
|
26
23
|
type ProjectionRemovalResult,
|
|
27
24
|
type ProjectionResult,
|
|
28
25
|
type ProjectionScope,
|
|
26
|
+
type ProjectionWriteReport,
|
|
27
|
+
type ProjectionWriteState,
|
|
29
28
|
} from "./projection-types";
|
|
30
|
-
import type {
|
|
31
|
-
|
|
32
|
-
const execFileAsync = promisify(execFile);
|
|
29
|
+
import type { LinkedRepository } from "./types";
|
|
33
30
|
|
|
34
31
|
/**
|
|
35
32
|
* The owner of the Managed Projection: every path Mate places inside a Working
|
|
@@ -60,6 +57,22 @@ function outcome(
|
|
|
60
57
|
state,
|
|
61
58
|
...(error ? { error } : {}),
|
|
62
59
|
...(entry.degradable ? { degradable: true } : {}),
|
|
60
|
+
...(entry.runtime ? { runtime: entry.runtime } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One outcome shape for both forms a write may return; empty reports are omitted. */
|
|
65
|
+
function writeOutcome(
|
|
66
|
+
entry: ProjectionEntry,
|
|
67
|
+
written: ProjectionWriteState | ProjectionWriteReport,
|
|
68
|
+
): ProjectionEntryOutcome {
|
|
69
|
+
if (typeof written === "string") return outcome(entry, written);
|
|
70
|
+
const { state, warnings = [], changed } = written;
|
|
71
|
+
const anyChanged = changed && (changed.written.length > 0 || changed.removed.length > 0);
|
|
72
|
+
return {
|
|
73
|
+
...outcome(entry, state),
|
|
74
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
75
|
+
...(anyChanged ? { changed } : {}),
|
|
63
76
|
};
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -74,11 +87,13 @@ export async function project(
|
|
|
74
87
|
input: ProjectionInput,
|
|
75
88
|
): Promise<ProjectionResult> {
|
|
76
89
|
const outcomes: ProjectionEntryOutcome[] = [];
|
|
90
|
+
const runtimes = input.runtimes ? new Set(input.runtimes) : null;
|
|
77
91
|
for (const entry of projectionEntries()) {
|
|
78
92
|
if (!entry.scopes.includes(scope)) continue;
|
|
93
|
+
if (entry.runtime && runtimes && !runtimes.has(entry.runtime)) continue;
|
|
79
94
|
try {
|
|
80
95
|
// oxlint-disable-next-line no-await-in-loop -- catalogue order is the contract
|
|
81
|
-
outcomes.push(
|
|
96
|
+
outcomes.push(writeOutcome(entry, await entry.write(input))); // react-doctor-disable-line react-doctor/async-await-in-loop
|
|
82
97
|
} catch (error) {
|
|
83
98
|
outcomes.push(outcome(entry, "failed", toError(error)));
|
|
84
99
|
}
|
|
@@ -93,7 +108,7 @@ export async function project(
|
|
|
93
108
|
*/
|
|
94
109
|
export async function unproject(input: ProjectionRemovalInput): Promise<ProjectionRemovalResult> {
|
|
95
110
|
const outcomes: ProjectionEntryOutcome[] = [];
|
|
96
|
-
for (const entry of
|
|
111
|
+
for (const entry of projectionEntries().toReversed()) {
|
|
97
112
|
const { removal } = entry;
|
|
98
113
|
if (removal.by === "entry") {
|
|
99
114
|
outcomes.push(outcome(entry, "covered"));
|
|
@@ -113,39 +128,42 @@ export async function unproject(input: ProjectionRemovalInput): Promise<Projecti
|
|
|
113
128
|
return { root: repoLocalDirPath(input.repoPath), outcomes };
|
|
114
129
|
}
|
|
115
130
|
|
|
116
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Removes entries that wrap owns but an unwrap must withdraw independently.
|
|
133
|
+
* Sequential: several of them rewrite the manifest, and concurrent rewrites
|
|
134
|
+
* would restore each other's withdrawn records.
|
|
135
|
+
*/
|
|
117
136
|
async function removeWorkingRepositoryUnwrapEntries(
|
|
118
137
|
repoPath: string,
|
|
138
|
+
registeredCompanionPaths: string[],
|
|
119
139
|
): Promise<ProjectionEntryOutcome[]> {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}),
|
|
133
|
-
);
|
|
140
|
+
const outcomes: ProjectionEntryOutcome[] = [];
|
|
141
|
+
for (const entry of projectionEntries()) {
|
|
142
|
+
const removal = entry.removal;
|
|
143
|
+
if (!entry.removeOnUnwrap || removal.by !== "self") continue;
|
|
144
|
+
try {
|
|
145
|
+
// oxlint-disable-next-line no-await-in-loop -- each removal rewrites the manifest
|
|
146
|
+
outcomes.push(outcome(entry, await removal.remove({ repoPath, registeredCompanionPaths }))); // react-doctor-disable-line react-doctor/async-await-in-loop
|
|
147
|
+
} catch (error) {
|
|
148
|
+
outcomes.push(outcome(entry, "failed", toError(error)));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return outcomes;
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/** Reads and reports; writes nothing. Freshness is a verdict above this module. */
|
|
137
155
|
export async function describe(repoPath: string): Promise<ProjectionDescription> {
|
|
138
|
-
const entries: ProjectionEntryPresence[] =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
156
|
+
const entries: ProjectionEntryPresence[] = await Promise.all(
|
|
157
|
+
projectionEntries().map(async (entry) => {
|
|
158
|
+
const present = await (entry.present
|
|
159
|
+
? entry.present(repoPath)
|
|
160
|
+
: fs
|
|
161
|
+
.access(path.join(path.resolve(repoPath), entry.path))
|
|
162
|
+
.then(() => true)
|
|
163
|
+
.catch(() => false));
|
|
164
|
+
return { id: entry.id, path: entry.path, kind: entry.kind, present };
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
149
167
|
return {
|
|
150
168
|
root: repoLocalDirPath(repoPath),
|
|
151
169
|
entries,
|
|
@@ -183,123 +201,12 @@ export async function projectWorkingRepository(
|
|
|
183
201
|
};
|
|
184
202
|
}
|
|
185
203
|
|
|
186
|
-
/**
|
|
187
|
-
* The rendered runtime documents, placed. Reported per document because the
|
|
188
|
-
* operator asked for these by name: a failure has to say which one failed while
|
|
189
|
-
* the projection already written stays where it is.
|
|
190
|
-
*/
|
|
191
|
-
export type RuntimeDocumentsResult =
|
|
192
|
-
| { kind: "written" | "current"; documents: string[] }
|
|
193
|
-
| { kind: "failed"; document: string; error: Error };
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Which of the given repo-relative paths Git already has in its index.
|
|
197
|
-
* Best-effort by construction: a directory that is no repository, a Git that is
|
|
198
|
-
* not installed, and a `ls-files` that fails for any other reason all answer
|
|
199
|
-
* "nothing tracked" — this feeds a warning, and a warning may not fail a wrap.
|
|
200
|
-
*/
|
|
201
|
-
async function trackedDocuments(repoPath: string, documentPaths: string[]): Promise<string[]> {
|
|
202
|
-
if (documentPaths.length === 0) return [];
|
|
203
|
-
try {
|
|
204
|
-
const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", ...documentPaths], {
|
|
205
|
-
cwd: path.resolve(repoPath),
|
|
206
|
-
encoding: "utf8",
|
|
207
|
-
env: gitEnvironment(),
|
|
208
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
209
|
-
});
|
|
210
|
-
return stdout.split("\0").filter(Boolean);
|
|
211
|
-
} catch {
|
|
212
|
-
return [];
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The managed exclude block only hides a file Git does not already track, so a
|
|
218
|
-
* repository that commits one of these documents keeps it in the index and the
|
|
219
|
-
* regions land in a tracked file. Those regions carry machine-absolute
|
|
220
|
-
* companion paths — keys naming directories under this user's home — so a
|
|
221
|
-
* routine `git add -A` pushes one machine's layout to everyone else. Warn and
|
|
222
|
-
* write anyway: refusing would silently drop the projection the repository was
|
|
223
|
-
* wrapped for.
|
|
224
|
-
*
|
|
225
|
-
* Asked here rather than while rendering because the render also runs on every
|
|
226
|
-
* launch; the wrap pass is the one that is a deliberate act, so this is the
|
|
227
|
-
* scope where saying it once is information rather than noise.
|
|
228
|
-
*/
|
|
229
|
-
async function warnAboutTrackedDocuments(
|
|
230
|
-
repository: LinkedRepository,
|
|
231
|
-
runtimeDocuments: readonly RenderedRuntimeDocument[],
|
|
232
|
-
): Promise<void> {
|
|
233
|
-
const tracked = await trackedDocuments(
|
|
234
|
-
repository.path,
|
|
235
|
-
runtimeDocuments
|
|
236
|
-
.map((document) => document.path)
|
|
237
|
-
.filter((documentPath) => !isExternalDocument(documentPath)),
|
|
238
|
-
);
|
|
239
|
-
for (const documentPath of tracked) {
|
|
240
|
-
console.error(
|
|
241
|
-
`${FRAMEWORK_NAME}: warning: ${documentPath} is tracked by Git in ${repository.id}; ${FRAMEWORK_NAME} writes machine-absolute companion paths into it, so committing it would push this machine's paths to everyone else on the repository.`,
|
|
242
|
-
);
|
|
243
|
-
console.error(
|
|
244
|
-
`Untrack it with \`git rm --cached ${documentPath}\` if those paths should stay local.`,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export async function projectWorkingRuntimeDocuments(
|
|
250
|
-
companionPath: string,
|
|
251
|
-
repository: LinkedRepository,
|
|
252
|
-
config: FrameworkConfig,
|
|
253
|
-
runtimeDocuments: readonly RenderedRuntimeDocument[],
|
|
254
|
-
globalConfigStore?: GlobalConfigStore,
|
|
255
|
-
): Promise<RuntimeDocumentsResult> {
|
|
256
|
-
await warnAboutTrackedDocuments(repository, runtimeDocuments);
|
|
257
|
-
const result = await project("wrap", {
|
|
258
|
-
repoPath: repository.path,
|
|
259
|
-
companionPath: path.resolve(companionPath),
|
|
260
|
-
repository,
|
|
261
|
-
config,
|
|
262
|
-
runtimeDocuments,
|
|
263
|
-
globalConfigStore,
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
const failure = firstFailure(result.outcomes);
|
|
267
|
-
if (failure) {
|
|
268
|
-
return {
|
|
269
|
-
kind: "failed",
|
|
270
|
-
document: failure.path,
|
|
271
|
-
error: failure.error ?? new Error("runtime document write failed"),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** The destinations the caller handed over; the rest of the pass is scaffolding. */
|
|
276
|
-
const destinations = new Set(
|
|
277
|
-
runtimeDocuments.map((document) => document.path.split("/").join(path.sep)),
|
|
278
|
-
);
|
|
279
|
-
const outcomes = result.outcomes.filter((outcome) => destinations.has(outcome.path));
|
|
280
|
-
return {
|
|
281
|
-
kind: outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
|
|
282
|
-
documents: [...new Set(outcomes.map((outcome) => outcome.path))],
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Whether `mate wrap` configured this Working Repository. Asked of the runtime
|
|
288
|
-
* document manifest rather than of the Projection Root, because the root is not
|
|
289
|
-
* evidence of a wrap: a launch and every Capability command write the
|
|
290
|
-
* projection pair too. The manifest is written only by a pass that placed a
|
|
291
|
-
* runtime document, and wrapping is the only pass that places one.
|
|
292
|
-
*/
|
|
293
|
-
export async function isWorkingRepositoryWrapped(repoPath: string): Promise<boolean> {
|
|
294
|
-
return (await recordedRuntimeDocuments(repoPath)).length > 0;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
204
|
export type UnwrapResult =
|
|
298
|
-
| { kind: "unwrapped" | "absent"; documents: string[] }
|
|
205
|
+
| { kind: "unwrapped" | "absent"; documents: string[]; copies: string[] }
|
|
299
206
|
| { kind: "failed"; document: string; error: Error };
|
|
300
207
|
|
|
301
208
|
/**
|
|
302
|
-
* The inverse of
|
|
209
|
+
* The inverse of a wrap's runtime documents: runtime documents and
|
|
303
210
|
* projected Claude skill links are withdrawn while the Projection Root, the
|
|
304
211
|
* Repository Link and the companion link are left exactly as they were. That
|
|
305
212
|
* asymmetry with `mate working cleanup` is the point — cleanup removes Mate's
|
|
@@ -311,10 +218,15 @@ export type UnwrapResult =
|
|
|
311
218
|
* Driven by the manifest, so it withdraws whatever the wrap that ran recorded,
|
|
312
219
|
* including a destination this release no longer renders.
|
|
313
220
|
*/
|
|
314
|
-
export async function unwrapWorkingRuntimeDocuments(
|
|
315
|
-
|
|
221
|
+
export async function unwrapWorkingRuntimeDocuments(
|
|
222
|
+
repoPath: string,
|
|
223
|
+
globalConfigStore: GlobalConfigStore = new GlobalConfigStore(),
|
|
224
|
+
): Promise<UnwrapResult> {
|
|
225
|
+
const manifest = await readManifest(repoPath);
|
|
226
|
+
const documents = recordedDocuments(manifest);
|
|
227
|
+
const copies = Object.keys(recordedCopies(manifest));
|
|
316
228
|
const removeWrapEntries = async (): Promise<ProjectionEntryOutcome[]> =>
|
|
317
|
-
removeWorkingRepositoryUnwrapEntries(repoPath);
|
|
229
|
+
removeWorkingRepositoryUnwrapEntries(repoPath, await globalConfigStore.list());
|
|
318
230
|
if (documents.length === 0) {
|
|
319
231
|
const outcomes = await removeWrapEntries();
|
|
320
232
|
const failure = firstFailure(outcomes);
|
|
@@ -326,8 +238,8 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
|
|
|
326
238
|
};
|
|
327
239
|
}
|
|
328
240
|
return outcomes.some((entry) => entry.state === "removed")
|
|
329
|
-
? { kind: "unwrapped", documents: [] }
|
|
330
|
-
: { kind: "absent", documents: [] };
|
|
241
|
+
? { kind: "unwrapped", documents: [], copies }
|
|
242
|
+
: { kind: "absent", documents: [], copies: [] };
|
|
331
243
|
}
|
|
332
244
|
|
|
333
245
|
/**
|
|
@@ -349,7 +261,7 @@ export async function unwrapWorkingRuntimeDocuments(repoPath: string): Promise<U
|
|
|
349
261
|
error: failure.error ?? new Error("wrap entry removal failed"),
|
|
350
262
|
};
|
|
351
263
|
}
|
|
352
|
-
return { kind: "unwrapped", documents: removed };
|
|
264
|
+
return { kind: "unwrapped", documents: removed, copies };
|
|
353
265
|
}
|
|
354
266
|
|
|
355
267
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { pathIsDirectory } from "
|
|
4
|
-
import { readCompanionRegistry } from "./
|
|
3
|
+
import { pathIsDirectory } from "../fs-utils";
|
|
4
|
+
import { readCompanionRegistry } from "./repository-link";
|
|
5
5
|
import { GlobalConfigStore } from "./global-config-store";
|
|
6
6
|
import type { LinkedRepository } from "./types";
|
|
7
7
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { renderWorkingRuntimeDocuments } from "../../tools/setup";
|
|
4
|
+
import { readCompanionConfig } from "./config-store";
|
|
5
|
+
import { GlobalConfigStore } from "./global-config-store";
|
|
6
|
+
import { entryFailureMessage, firstFailure, type ProjectionChangedPaths } from "./projection-types";
|
|
7
|
+
import type { WrapRuntime } from "../../runtime/runtime-document-manifest";
|
|
8
|
+
import { readWrapState, type WrapState } from "../../runtime/wrap-state";
|
|
9
|
+
import type { FrameworkConfig, LinkedRepository } from "./types";
|
|
10
|
+
import { project } from "./working-repo-projection";
|
|
11
|
+
|
|
12
|
+
export interface WrapPassDependencies {
|
|
13
|
+
renderWorkingRuntimeDocuments: typeof renderWorkingRuntimeDocuments;
|
|
14
|
+
readCompanionConfig: typeof readCompanionConfig;
|
|
15
|
+
project: typeof project;
|
|
16
|
+
/** The registered companions, whose stale skill links a re-pin withdraws; the user's registry when absent. */
|
|
17
|
+
globalConfigStore?: GlobalConfigStore;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const wrapPassDeps: WrapPassDependencies = {
|
|
21
|
+
renderWorkingRuntimeDocuments,
|
|
22
|
+
readCompanionConfig,
|
|
23
|
+
project,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface WrapPassOptions {
|
|
27
|
+
/** Reconcile only these runtimes; every active runtime when absent. */
|
|
28
|
+
runtimes?: readonly WrapRuntime[];
|
|
29
|
+
/** The companion config the caller already read; read from files otherwise. */
|
|
30
|
+
config?: FrameworkConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type WrapPassResult =
|
|
34
|
+
| {
|
|
35
|
+
kind: "written" | "current";
|
|
36
|
+
projectionRoot: string;
|
|
37
|
+
companionPath: string;
|
|
38
|
+
/** The runtime documents this pass answered for, written or current. */
|
|
39
|
+
documents: string[];
|
|
40
|
+
/** The subset of `documents` this pass wrote. */
|
|
41
|
+
writtenDocuments: string[];
|
|
42
|
+
/** Every path an entry reported writing or withdrawing one by one. */
|
|
43
|
+
changed: ProjectionChangedPaths;
|
|
44
|
+
/** The render's warnings, then every entry's, for the caller to present. */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
/** Active runtimes whose tagged entries this pass ran; never inferred from paths. */
|
|
47
|
+
runtimes: ReadonlySet<WrapRuntime>;
|
|
48
|
+
/** The wrap state as this pass left it. */
|
|
49
|
+
wrapState: WrapState;
|
|
50
|
+
}
|
|
51
|
+
| { kind: "failed"; message: string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One pass of the `wrap` scope, restricted to the requested runtimes by each
|
|
55
|
+
* entry's runtime tag. Best-effort per entry like every projection: a failed
|
|
56
|
+
* entry fails the pass while whatever the other entries wrote stays in place.
|
|
57
|
+
*/
|
|
58
|
+
export async function runWrapPass(
|
|
59
|
+
companionPath: string,
|
|
60
|
+
repository: LinkedRepository,
|
|
61
|
+
options: WrapPassOptions = {},
|
|
62
|
+
deps: WrapPassDependencies = wrapPassDeps,
|
|
63
|
+
): Promise<WrapPassResult> {
|
|
64
|
+
const resolvedCompanionPath = path.resolve(companionPath);
|
|
65
|
+
const config = options.config ?? (await deps.readCompanionConfig(resolvedCompanionPath));
|
|
66
|
+
const rendered = await deps.renderWorkingRuntimeDocuments(
|
|
67
|
+
resolvedCompanionPath,
|
|
68
|
+
config,
|
|
69
|
+
repository.path,
|
|
70
|
+
);
|
|
71
|
+
const result = await deps.project("wrap", {
|
|
72
|
+
repoPath: repository.path,
|
|
73
|
+
companionPath: resolvedCompanionPath,
|
|
74
|
+
repository,
|
|
75
|
+
config,
|
|
76
|
+
runtimeDocuments: rendered.documents,
|
|
77
|
+
globalConfigStore: deps.globalConfigStore ?? new GlobalConfigStore(),
|
|
78
|
+
...(options.runtimes ? { runtimes: options.runtimes } : {}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const failure = firstFailure(result.outcomes);
|
|
82
|
+
if (failure) return { kind: "failed", message: entryFailureMessage(failure, repository.id) };
|
|
83
|
+
|
|
84
|
+
/** Read once under the caller's lock, so it is exactly what the pass recorded. */
|
|
85
|
+
let wrapState: WrapState;
|
|
86
|
+
try {
|
|
87
|
+
wrapState = await readWrapState(repository.path);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "failed",
|
|
91
|
+
message: `could not read the runtime document manifest for ${repository.id}: ${(error as Error).message}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Inactive runtimes' entries run too, to withdraw; that is not covering them. */
|
|
95
|
+
const active = new Set(config.allowedAgents ?? []);
|
|
96
|
+
const runtimes = new Set(
|
|
97
|
+
result.outcomes.flatMap((outcome) =>
|
|
98
|
+
outcome.runtime && outcome.state !== "skipped" && active.has(outcome.runtime)
|
|
99
|
+
? [outcome.runtime]
|
|
100
|
+
: [],
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** The destinations the surfaces rendered; the rest of the pass is scaffolding. */
|
|
105
|
+
const destinations = new Set(
|
|
106
|
+
rendered.documents.map((document) => document.path.split("/").join(path.sep)),
|
|
107
|
+
);
|
|
108
|
+
const placed = result.outcomes.filter((outcome) => destinations.has(outcome.path));
|
|
109
|
+
const documents = placed.map((outcome) => outcome.path);
|
|
110
|
+
const writtenDocuments = placed
|
|
111
|
+
.filter((outcome) => outcome.state === "written")
|
|
112
|
+
.map((outcome) => outcome.path);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
kind: result.outcomes.some((outcome) => outcome.state === "written") ? "written" : "current",
|
|
116
|
+
projectionRoot: result.root,
|
|
117
|
+
companionPath: resolvedCompanionPath,
|
|
118
|
+
documents: [...new Set(documents)],
|
|
119
|
+
writtenDocuments: [...new Set(writtenDocuments)],
|
|
120
|
+
changed: {
|
|
121
|
+
written: result.outcomes.flatMap((outcome) => outcome.changed?.written ?? []),
|
|
122
|
+
removed: result.outcomes.flatMap((outcome) => outcome.changed?.removed ?? []),
|
|
123
|
+
},
|
|
124
|
+
warnings: [
|
|
125
|
+
...rendered.warnings,
|
|
126
|
+
...result.outcomes.flatMap((outcome) => outcome.warnings ?? []),
|
|
127
|
+
],
|
|
128
|
+
runtimes,
|
|
129
|
+
wrapState,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../framework";
|
|
7
|
+
import {
|
|
8
|
+
runtimeDirectoryPath,
|
|
9
|
+
runtimeClaudePluginRoot,
|
|
10
|
+
runtimeMateBin,
|
|
11
|
+
runtimeOpenCodePluginRoot,
|
|
12
|
+
runtimeWrapperBinPath,
|
|
13
|
+
} from "../runtime/runtime-directory";
|
|
14
|
+
import { writeAtomic } from "../runtime/write-atomic";
|
|
15
|
+
import { realLinkTarget, realOrResolved } from "./fs-utils";
|
|
16
|
+
import { getClaudePluginRoot, getOpenCodePluginRoot, getWrapperBinPath } from "./package-paths";
|
|
17
|
+
|
|
18
|
+
export interface RuntimeDirectoryTargets {
|
|
19
|
+
pluginRoot: string;
|
|
20
|
+
claudePluginRoot: string;
|
|
21
|
+
/** The resolved `wrappers/bin`; its parent is what the `wrappers` link names. */
|
|
22
|
+
wrapperBinPath: string;
|
|
23
|
+
/** The interpreter running this CLI, written into the launcher. */
|
|
24
|
+
execPath: string;
|
|
25
|
+
cliEntry: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RuntimeDirectoryRefresh {
|
|
29
|
+
/** Entries rewritten by this run, relative to the runtime directory. */
|
|
30
|
+
changed: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `process.execPath` is the resolved, often versioned, binary (Homebrew's
|
|
35
|
+
* `Cellar/bun/<version>/bin/bun`), which an upgrade deletes. A `PATH` entry of
|
|
36
|
+
* the same name that is a link to it survives the upgrade; without one the
|
|
37
|
+
* resolved path is all there is.
|
|
38
|
+
*/
|
|
39
|
+
export function stableInterpreterPath(
|
|
40
|
+
execPath: string,
|
|
41
|
+
pathValue: string | undefined = process.env.PATH,
|
|
42
|
+
): string {
|
|
43
|
+
let real: string;
|
|
44
|
+
try {
|
|
45
|
+
real = fsSync.realpathSync(execPath);
|
|
46
|
+
} catch {
|
|
47
|
+
return execPath;
|
|
48
|
+
}
|
|
49
|
+
const name = path.basename(execPath);
|
|
50
|
+
for (const dir of (pathValue ?? "").split(path.delimiter).filter(Boolean)) {
|
|
51
|
+
const candidate = path.join(dir, name);
|
|
52
|
+
if (candidate === real) continue;
|
|
53
|
+
try {
|
|
54
|
+
if (fsSync.realpathSync(candidate) === real) return candidate;
|
|
55
|
+
} catch {
|
|
56
|
+
/* not on this PATH entry */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return execPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function currentRuntimeDirectoryTargets(): RuntimeDirectoryTargets {
|
|
63
|
+
return {
|
|
64
|
+
pluginRoot: getOpenCodePluginRoot(),
|
|
65
|
+
claudePluginRoot: getClaudePluginRoot(),
|
|
66
|
+
wrapperBinPath: getWrapperBinPath(),
|
|
67
|
+
execPath: stableInterpreterPath(process.execPath),
|
|
68
|
+
cliEntry: path.resolve(process.argv[1] ?? ""),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The link's {@link realLinkTarget}; `""` when not a link, `null` when absent. */
|
|
73
|
+
async function readLinkTarget(linkPath: string): Promise<string | null> {
|
|
74
|
+
try {
|
|
75
|
+
const stat = await fs.lstat(linkPath);
|
|
76
|
+
if (!stat.isSymbolicLink()) return "";
|
|
77
|
+
return await realLinkTarget(linkPath, await fs.readlink(linkPath));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Junctions on Windows need no admin rights or Developer Mode. On POSIX a
|
|
86
|
+
* sibling link renamed over the old one keeps concurrent runs from ever seeing
|
|
87
|
+
* the entry missing.
|
|
88
|
+
*/
|
|
89
|
+
async function pointLink(linkPath: string, target: string): Promise<boolean> {
|
|
90
|
+
const [current, wanted] = await Promise.all([readLinkTarget(linkPath), realOrResolved(target)]);
|
|
91
|
+
if (current === wanted) return false;
|
|
92
|
+
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
93
|
+
if (process.platform === "win32") {
|
|
94
|
+
await fs.rm(linkPath, { recursive: true, force: true });
|
|
95
|
+
await fs.symlink(target, linkPath, "junction");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/** A rename cannot replace a real directory or file. */
|
|
99
|
+
if (current === "") await fs.rm(linkPath, { recursive: true, force: true });
|
|
100
|
+
const tempPath = `${linkPath}.${crypto.randomUUID()}.tmp`;
|
|
101
|
+
try {
|
|
102
|
+
await fs.symlink(target, tempPath, "dir");
|
|
103
|
+
await fs.rename(tempPath, linkPath);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shellQuote(value: string): string {
|
|
112
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function launcherContent(targets: RuntimeDirectoryTargets, platform: NodeJS.Platform): string {
|
|
116
|
+
if (platform === "win32") {
|
|
117
|
+
return `@echo off\r\n"${targets.execPath}" "${targets.cliEntry}" %*\r\n`;
|
|
118
|
+
}
|
|
119
|
+
return `#!/bin/sh\nexec ${shellQuote(targets.execPath)} ${shellQuote(targets.cliEntry)} "$@"\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function writeLauncher(
|
|
123
|
+
launcherPath: string,
|
|
124
|
+
targets: RuntimeDirectoryTargets,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
const next = launcherContent(targets, process.platform);
|
|
127
|
+
const existing = await fs.readFile(launcherPath, "utf8").catch(() => null);
|
|
128
|
+
if (existing === next) return false;
|
|
129
|
+
await fs.mkdir(path.dirname(launcherPath), { recursive: true });
|
|
130
|
+
await writeAtomic(launcherPath, next, 0o755);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Re-points or rewrites only the entries whose target differs from `targets`. */
|
|
135
|
+
export async function refreshRuntimeDirectory(
|
|
136
|
+
targets: RuntimeDirectoryTargets = currentRuntimeDirectoryTargets(),
|
|
137
|
+
homeDir?: string,
|
|
138
|
+
): Promise<RuntimeDirectoryRefresh> {
|
|
139
|
+
const root = runtimeDirectoryPath(homeDir);
|
|
140
|
+
const launcherPath = runtimeMateBin(homeDir);
|
|
141
|
+
const changed: string[] = [];
|
|
142
|
+
if (await pointLink(runtimeOpenCodePluginRoot(homeDir), targets.pluginRoot)) {
|
|
143
|
+
changed.push("opencode-plugin");
|
|
144
|
+
}
|
|
145
|
+
if (await pointLink(runtimeClaudePluginRoot(homeDir), targets.claudePluginRoot)) {
|
|
146
|
+
changed.push("claude-plugin");
|
|
147
|
+
}
|
|
148
|
+
const wrappersLink = path.dirname(runtimeWrapperBinPath(homeDir));
|
|
149
|
+
if (await pointLink(wrappersLink, path.dirname(targets.wrapperBinPath))) {
|
|
150
|
+
changed.push("wrappers");
|
|
151
|
+
}
|
|
152
|
+
if (await writeLauncher(launcherPath, targets)) {
|
|
153
|
+
changed.push(path.relative(root, launcherPath).split(path.sep).join("/"));
|
|
154
|
+
}
|
|
155
|
+
return { changed };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Commands whose output names runtime-directory paths cannot run without it. */
|
|
159
|
+
const RUNTIME_DIRECTORY_COMMANDS = new Set(["wrap", "opencode", "session"]);
|
|
160
|
+
|
|
161
|
+
function runtimeDirectoryRequired(command: string | undefined): boolean {
|
|
162
|
+
return command !== undefined && RUNTIME_DIRECTORY_COMMANDS.has(command);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Startup refresh: a failure warns, except for the commands that write or
|
|
167
|
+
* consume runtime-directory paths, where it is returned as fatal.
|
|
168
|
+
*/
|
|
169
|
+
export async function ensureRuntimeDirectory(
|
|
170
|
+
command: string | undefined,
|
|
171
|
+
refresh: () => Promise<RuntimeDirectoryRefresh> = () => refreshRuntimeDirectory(),
|
|
172
|
+
): Promise<boolean> {
|
|
173
|
+
try {
|
|
174
|
+
await refresh();
|
|
175
|
+
return true;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
178
|
+
const message = `failed to update ${runtimeDirectoryPath()}: ${detail}`;
|
|
179
|
+
if (runtimeDirectoryRequired(command)) {
|
|
180
|
+
console.error(`${FRAMEWORK_NAME}: ${message}`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
console.error(`${FRAMEWORK_NAME}: warning: ${message}`);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -99,6 +99,11 @@ export function isCanaryVersion(version: string = getCurrentVersion()): boolean
|
|
|
99
99
|
return version.includes("-canary");
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Versions packed by `pack-local.sh` (`x.y.z-local.n`) are local test builds. */
|
|
103
|
+
export function isLocalVersion(version: string = getCurrentVersion()): boolean {
|
|
104
|
+
return version.includes("-local");
|
|
105
|
+
}
|
|
106
|
+
|
|
102
107
|
export type UpdateChannel = "latest" | "canary";
|
|
103
108
|
|
|
104
109
|
export function getUpdateChannel(version: string = getCurrentVersion()): UpdateChannel {
|
|
@@ -134,6 +139,7 @@ export async function showUpdateBannerIfAvailable(store: UpdateStateStore): Prom
|
|
|
134
139
|
export async function enforceUpdateIfRequired(store: UpdateStateStore): Promise<boolean> {
|
|
135
140
|
if (isPinnedDeployment()) return false;
|
|
136
141
|
if (!getUpdateConfig().enforce) return false;
|
|
142
|
+
if (isLocalVersion()) return false;
|
|
137
143
|
try {
|
|
138
144
|
const state = await store.load();
|
|
139
145
|
if (!state.latestVersion) return false;
|