@uniqbit/mate-core 0.15.3 → 0.15.4-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/claude-plugin/.claude-plugin/plugin.json +4 -0
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
- package/claude-plugin/hooks/hooks.json +37 -0
- package/claude-plugin/hooks/session-banner.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +28 -0
- package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
- package/package.json +4 -2
- package/src/cli/commands/artifact/finish/openspec.ts +1 -5
- package/src/cli/commands/companion/hub.ts +99 -0
- package/src/cli/commands/companion/link.ts +0 -5
- package/src/cli/commands/companion/tui.ts +3 -2
- package/src/cli/commands/doctor.ts +254 -170
- package/src/cli/commands/install.ts +18 -14
- package/src/cli/commands/launch/shared.ts +4 -4
- package/src/cli/commands/plugin/install.ts +94 -0
- package/src/cli/commands/plugin/plugin.ts +17 -0
- package/src/cli/commands/setup.ts +4 -6
- package/src/cli/commands/update.ts +9 -11
- package/src/cli/companion-link-wizard.tsx +22 -78
- package/src/cli/main.ts +57 -13
- package/src/cli/plugin-commands.ts +4 -4
- package/src/cli/repo-list-table.tsx +2 -7
- package/src/cli/usage.ts +7 -3
- package/src/distribution.ts +3 -5
- package/src/framework.ts +4 -36
- package/src/hooks/artifact-finish-nudge.ts +212 -0
- package/src/hooks/session-banner.ts +25 -0
- package/src/hooks/validate-artifact-path.ts +236 -0
- package/src/index.ts +4 -0
- package/src/lib/context-mode-package.ts +5 -3
- package/src/lib/install.ts +31 -43
- package/src/lib/orchestrator/adapters/base.ts +7 -8
- package/src/lib/orchestrator/adapters/claude.ts +16 -0
- package/src/lib/orchestrator/adapters/opencode.ts +10 -60
- package/src/lib/orchestrator/companion-hub.ts +380 -0
- package/src/lib/orchestrator/companion-store.ts +1 -35
- package/src/lib/orchestrator/config-store.ts +94 -7
- package/src/lib/orchestrator/editor.ts +4 -20
- package/src/lib/orchestrator/framework-context.ts +119 -24
- package/src/lib/orchestrator/global-config-store.ts +1 -5
- package/src/lib/orchestrator/launcher.ts +3 -9
- package/src/lib/orchestrator/migration.ts +0 -23
- package/src/lib/orchestrator/opencode-guidance.ts +1 -2
- package/src/lib/orchestrator/repo-local-registry.ts +3 -1
- package/src/lib/orchestrator/root-context.ts +117 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +5 -18
- package/src/lib/orchestrator/types.ts +27 -19
- package/src/lib/orchestrator/working-repo-store.ts +9 -3
- package/src/lib/package-paths.ts +37 -0
- package/src/lib/update-checker.ts +5 -5
- package/src/playbooks/companion-guidance.ts +7 -7
- package/src/runtime/env.ts +0 -4
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3541 -0
- package/src/tools/setup/capabilities/context-mode.ts +57 -83
- package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
- package/src/tools/setup/capabilities/graphify.ts +86 -295
- package/src/tools/setup/capabilities/openspec.ts +27 -30
- package/src/tools/setup/capabilities/react-doctor.ts +37 -37
- package/src/tools/setup/capabilities/rtk.ts +4 -1
- package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
- package/src/tools/setup/capabilities/tokensave.ts +50 -71
- package/src/tools/setup/context-services.ts +29 -0
- package/src/tools/setup/dynamic-plugins/host.ts +2 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
- package/src/tools/setup/dynamic-plugins/install.ts +124 -109
- package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
- package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
- package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
- package/src/tools/setup/engine.ts +65 -1
- package/src/tools/setup/mate.ts +28 -31
- package/src/tools/setup/plugin.ts +81 -0
- package/src/tools/setup/plugins/gitignore.ts +19 -9
- package/src/tools/setup/plugins/guidance.ts +2 -6
- package/src/tools/setup/policy.ts +4 -7
- package/src/tools/setup/providers/agent-file-sections.ts +78 -0
- package/src/tools/setup/providers/claude-format.ts +159 -0
- package/src/tools/setup/providers/claude.ts +283 -292
- package/src/tools/setup/providers/opencode-format.ts +146 -0
- package/src/tools/setup/providers/opencode.ts +171 -66
- package/src/tools/setup/providers/skill-tree.ts +38 -0
- package/src/tools/setup.ts +12 -7
- package/src/tui.ts +5 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
- package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
- package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
- package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
|
|
7
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
8
|
+
import { ConfigStore, validateHubConfig } from "./config-store";
|
|
9
|
+
import { GlobalConfigStore } from "./global-config-store";
|
|
10
|
+
import type { FrameworkConfig, HubMember, HubMemberSource } from "./types";
|
|
11
|
+
import {
|
|
12
|
+
installDeclaredPlugins,
|
|
13
|
+
type PluginInstallDeps,
|
|
14
|
+
type PluginInstallResult,
|
|
15
|
+
} from "../../tools/setup/dynamic-plugins/install";
|
|
16
|
+
import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
|
|
17
|
+
import { findRepoLocalRegistryFile } from "./repo-local-registry";
|
|
18
|
+
|
|
19
|
+
export interface GitCommandResult {
|
|
20
|
+
status: number | null;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type GitCommand = (cwd: string, args: string[]) => GitCommandResult;
|
|
26
|
+
|
|
27
|
+
export interface HubSource {
|
|
28
|
+
kind: "git" | "local";
|
|
29
|
+
path?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
ref?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HubSyncResult {
|
|
35
|
+
id: string;
|
|
36
|
+
status: "updated" | "up-to-date" | "local-only" | "dirty" | "divergent" | "failed";
|
|
37
|
+
message: string;
|
|
38
|
+
materializedCommit?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HubPluginSyncDeps {
|
|
42
|
+
installDeps?: PluginInstallDeps;
|
|
43
|
+
hydrate?: (options: { companionPath: string }) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function defaultGitCommand(cwd: string, args: string[]): GitCommandResult {
|
|
47
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
status: result.status,
|
|
53
|
+
stdout: String(result.stdout ?? "").trim(),
|
|
54
|
+
stderr: String(result.stderr ?? "").trim(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isInsideDir(parent: string, candidate: string): boolean {
|
|
59
|
+
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
60
|
+
return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeHubMemberId(value: string): string {
|
|
64
|
+
const normalized = value
|
|
65
|
+
.replace(/\.git$/i, "")
|
|
66
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
67
|
+
.replace(/^-+|-+$/g, "");
|
|
68
|
+
if (!normalized) throw new Error(`Cannot derive a hub member id from: ${value}`);
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateHubMemberPath(hubPath: string, memberPath: string): string {
|
|
73
|
+
if (!memberPath || path.isAbsolute(memberPath)) {
|
|
74
|
+
throw new Error("Hub member path must be relative to the hub root.");
|
|
75
|
+
}
|
|
76
|
+
const resolved = path.resolve(hubPath, memberPath);
|
|
77
|
+
if (!isInsideDir(hubPath, resolved)) {
|
|
78
|
+
throw new Error(`Hub member path resolves outside the hub root: ${memberPath}`);
|
|
79
|
+
}
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function frameworkConfigPath(root: string): string {
|
|
84
|
+
return path.join(root, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function memberPathForId(id: string): string {
|
|
88
|
+
return path.join("companions", id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function assertHubRoot(
|
|
92
|
+
hubPath: string,
|
|
93
|
+
): Promise<{ config: FrameworkConfig; store: ConfigStore }> {
|
|
94
|
+
const resolved = path.resolve(hubPath);
|
|
95
|
+
const configStore = new ConfigStore(frameworkConfigPath(resolved));
|
|
96
|
+
const config = await configStore.load();
|
|
97
|
+
if (config.type !== "hub") {
|
|
98
|
+
throw new Error(`Not a companion hub: ${resolved}`);
|
|
99
|
+
}
|
|
100
|
+
validateHubConfig(config);
|
|
101
|
+
return { config, store: configStore };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function initializeCompanionHub(
|
|
105
|
+
folder: string,
|
|
106
|
+
globalConfigStore = new GlobalConfigStore(),
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const hubPath = path.resolve(folder);
|
|
109
|
+
const linkedRepo = await findRepoLocalRegistryFile(hubPath);
|
|
110
|
+
if (linkedRepo) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Cannot initialize a hub inside linked working repository: ${linkedRepo.repoRoot}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
await fs.mkdir(hubPath, { recursive: true });
|
|
116
|
+
const configStore = new ConfigStore(frameworkConfigPath(hubPath));
|
|
117
|
+
const existing = await fs.stat(configStore.configPath).catch(() => null);
|
|
118
|
+
if (existing) {
|
|
119
|
+
const current = await configStore.load();
|
|
120
|
+
if (current.type !== "hub") {
|
|
121
|
+
throw new Error(`Cannot initialize hub over an existing non-hub framework: ${hubPath}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const config: FrameworkConfig = {
|
|
125
|
+
type: "hub",
|
|
126
|
+
allowedAgents: [],
|
|
127
|
+
packageManagers: [],
|
|
128
|
+
capabilities: [],
|
|
129
|
+
hub: { companions: [] },
|
|
130
|
+
};
|
|
131
|
+
await configStore.save(config);
|
|
132
|
+
}
|
|
133
|
+
await globalConfigStore.register(hubPath);
|
|
134
|
+
return hubPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function discoverGitSource(
|
|
138
|
+
sourcePath: string,
|
|
139
|
+
git: GitCommand = defaultGitCommand,
|
|
140
|
+
): HubSource {
|
|
141
|
+
const root = git(sourcePath, ["rev-parse", "--show-toplevel"]);
|
|
142
|
+
if (root.status !== 0 || !root.stdout) return { kind: "local", path: path.resolve(sourcePath) };
|
|
143
|
+
|
|
144
|
+
const remote = git(sourcePath, ["config", "--get", "remote.origin.url"]);
|
|
145
|
+
if (remote.status !== 0 || !remote.stdout)
|
|
146
|
+
return { kind: "local", path: path.resolve(sourcePath) };
|
|
147
|
+
|
|
148
|
+
const branch = git(sourcePath, ["symbolic-ref", "--short", "HEAD"]);
|
|
149
|
+
return {
|
|
150
|
+
kind: "git",
|
|
151
|
+
path: path.resolve(sourcePath),
|
|
152
|
+
url: remote.stdout,
|
|
153
|
+
ref: branch.status === 0 && branch.stdout ? branch.stdout : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function discoverHubSource(source: string, git: GitCommand = defaultGitCommand): HubSource {
|
|
158
|
+
const trimmed = source.trim();
|
|
159
|
+
if (/^(?:https?|ssh|git):\/\//i.test(trimmed) || trimmed.startsWith("git@")) {
|
|
160
|
+
return { kind: "git", url: trimmed };
|
|
161
|
+
}
|
|
162
|
+
return discoverGitSource(path.resolve(trimmed), git);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sourceForManifest(source: HubSource, fallbackPath: string): HubMemberSource {
|
|
166
|
+
if (source.kind === "git") {
|
|
167
|
+
return { kind: "git", url: source.url, ref: source.ref };
|
|
168
|
+
}
|
|
169
|
+
return { kind: "local", path: source.path ?? fallbackPath };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gitOutputOrThrow(result: GitCommandResult, operation: string): string {
|
|
173
|
+
if (result.status !== 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${operation} failed: ${result.stderr || result.stdout || "unknown Git error"}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return result.stdout;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function copyWithoutGit(sourcePath: string, destination: string): Promise<void> {
|
|
182
|
+
await fs.cp(sourcePath, destination, {
|
|
183
|
+
recursive: true,
|
|
184
|
+
filter: (candidate) => path.basename(candidate) !== ".git",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function assertMaterializedCompanion(memberPath: string): Promise<void> {
|
|
189
|
+
const configPath = frameworkConfigPath(memberPath);
|
|
190
|
+
const raw = await fs.readFile(configPath, "utf8").catch(() => "");
|
|
191
|
+
const config = parse(raw) as { type?: unknown } | null;
|
|
192
|
+
if (config?.type !== "companion") {
|
|
193
|
+
throw new Error(`Materialized child must declare type: companion: ${memberPath}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function currentCommit(memberPath: string, git: GitCommand): Promise<string> {
|
|
198
|
+
return gitOutputOrThrow(git(memberPath, ["rev-parse", "HEAD"]), `Reading ${memberPath} commit`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function materializeHubMember(
|
|
202
|
+
hubPath: string,
|
|
203
|
+
source: HubSource,
|
|
204
|
+
options: { id?: string; memberPath?: string; git?: GitCommand } = {},
|
|
205
|
+
): Promise<HubMember> {
|
|
206
|
+
const git = options.git ?? defaultGitCommand;
|
|
207
|
+
const sourceName = source.url ?? source.path ?? "companion";
|
|
208
|
+
const id = normalizeHubMemberId(options.id ?? path.basename(sourceName));
|
|
209
|
+
const relativePath = options.memberPath ?? memberPathForId(id);
|
|
210
|
+
const destination = validateHubMemberPath(hubPath, relativePath);
|
|
211
|
+
const target = await fs.stat(destination).catch(() => null);
|
|
212
|
+
if (target) throw new Error(`Hub member destination already exists: ${relativePath}`);
|
|
213
|
+
|
|
214
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
215
|
+
try {
|
|
216
|
+
if (source.kind === "git") {
|
|
217
|
+
const args = ["clone"];
|
|
218
|
+
if (source.ref) args.push("--branch", source.ref);
|
|
219
|
+
args.push(source.url!, destination);
|
|
220
|
+
gitOutputOrThrow(git(hubPath, args), `Cloning ${source.url}`);
|
|
221
|
+
} else {
|
|
222
|
+
await copyWithoutGit(source.path!, destination);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await assertMaterializedCompanion(destination);
|
|
226
|
+
const member: HubMember = {
|
|
227
|
+
id,
|
|
228
|
+
path: relativePath,
|
|
229
|
+
source: sourceForManifest(source, source.path ?? relativePath),
|
|
230
|
+
};
|
|
231
|
+
if (source.kind === "git") member.materializedCommit = await currentCommit(destination, git);
|
|
232
|
+
return member;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
await fs.rm(destination, { recursive: true, force: true });
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function addHubMember(
|
|
240
|
+
hubPath: string,
|
|
241
|
+
source: HubSource,
|
|
242
|
+
options: { id?: string; memberPath?: string; git?: GitCommand } = {},
|
|
243
|
+
): Promise<HubMember> {
|
|
244
|
+
const { config, store } = await assertHubRoot(hubPath);
|
|
245
|
+
const member = await materializeHubMember(hubPath, source, options);
|
|
246
|
+
if (config.hub!.companions.some((candidate) => candidate.id === member.id)) {
|
|
247
|
+
await fs.rm(path.resolve(hubPath, member.path), { recursive: true, force: true });
|
|
248
|
+
throw new Error(`Hub member id already exists: ${member.id}`);
|
|
249
|
+
}
|
|
250
|
+
config.hub!.companions.push(member);
|
|
251
|
+
await store.save(config);
|
|
252
|
+
return member;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function syncTarget(memberPath: string, git: GitCommand): string | null {
|
|
256
|
+
const upstream = git(memberPath, [
|
|
257
|
+
"rev-parse",
|
|
258
|
+
"--abbrev-ref",
|
|
259
|
+
"--symbolic-full-name",
|
|
260
|
+
"@{upstream}",
|
|
261
|
+
]);
|
|
262
|
+
if (upstream.status !== 0 || !upstream.stdout) return null;
|
|
263
|
+
return upstream.stdout;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function syncHubMember(
|
|
267
|
+
hubPath: string,
|
|
268
|
+
member: HubMember,
|
|
269
|
+
git: GitCommand = defaultGitCommand,
|
|
270
|
+
): Promise<HubSyncResult> {
|
|
271
|
+
const memberPath = path.resolve(hubPath, member.path);
|
|
272
|
+
if (member.source.kind === "local") {
|
|
273
|
+
return { id: member.id, status: "local-only", message: "local-only child has no Git source" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const dirty = git(memberPath, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
277
|
+
if (dirty.status !== 0) {
|
|
278
|
+
return {
|
|
279
|
+
id: member.id,
|
|
280
|
+
status: "failed",
|
|
281
|
+
message: dirty.stderr || "unable to inspect Git state",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (dirty.stdout) {
|
|
285
|
+
return { id: member.id, status: "dirty", message: "local changes must be resolved first" };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const fetch = git(memberPath, ["fetch", "origin"]);
|
|
289
|
+
if (fetch.status !== 0) {
|
|
290
|
+
return { id: member.id, status: "failed", message: fetch.stderr || "fetch failed" };
|
|
291
|
+
}
|
|
292
|
+
const target = syncTarget(memberPath, git);
|
|
293
|
+
if (!target) {
|
|
294
|
+
return { id: member.id, status: "failed", message: "no tracked upstream branch" };
|
|
295
|
+
}
|
|
296
|
+
const counts = git(memberPath, ["rev-list", "--left-right", "--count", `HEAD...${target}`]);
|
|
297
|
+
if (counts.status !== 0) {
|
|
298
|
+
return {
|
|
299
|
+
id: member.id,
|
|
300
|
+
status: "failed",
|
|
301
|
+
message: counts.stderr || "unable to compare branches",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const [ahead, behind] = counts.stdout.split(/\s+/).map(Number);
|
|
305
|
+
if (ahead > 0 && behind > 0) {
|
|
306
|
+
return {
|
|
307
|
+
id: member.id,
|
|
308
|
+
status: "divergent",
|
|
309
|
+
message: "local and remote branches have diverged",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (behind === 0) {
|
|
313
|
+
const commit = await currentCommit(memberPath, git);
|
|
314
|
+
return {
|
|
315
|
+
id: member.id,
|
|
316
|
+
status: "up-to-date",
|
|
317
|
+
message: "already up to date",
|
|
318
|
+
materializedCommit: commit,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const readTree = git(memberPath, ["read-tree", "-u", "-m", target]);
|
|
323
|
+
if (readTree.status !== 0) {
|
|
324
|
+
return {
|
|
325
|
+
id: member.id,
|
|
326
|
+
status: "failed",
|
|
327
|
+
message: readTree.stderr || "fast-forward worktree update failed",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const branch = git(memberPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
331
|
+
if (branch.status !== 0 || !branch.stdout) {
|
|
332
|
+
return { id: member.id, status: "failed", message: "cannot advance a detached HEAD" };
|
|
333
|
+
}
|
|
334
|
+
const updateRef = git(memberPath, ["update-ref", `refs/heads/${branch.stdout}`, target, "HEAD"]);
|
|
335
|
+
if (updateRef.status !== 0) {
|
|
336
|
+
return {
|
|
337
|
+
id: member.id,
|
|
338
|
+
status: "failed",
|
|
339
|
+
message: updateRef.stderr || "fast-forward ref update failed",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const commit = await currentCommit(memberPath, git);
|
|
343
|
+
return {
|
|
344
|
+
id: member.id,
|
|
345
|
+
status: "updated",
|
|
346
|
+
message: `fast-forwarded to ${commit}`,
|
|
347
|
+
materializedCommit: commit,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function syncHub(
|
|
352
|
+
hubPath: string,
|
|
353
|
+
git: GitCommand = defaultGitCommand,
|
|
354
|
+
): Promise<HubSyncResult[]> {
|
|
355
|
+
const { config, store } = await assertHubRoot(hubPath);
|
|
356
|
+
const results: HubSyncResult[] = [];
|
|
357
|
+
for (const member of config.hub!.companions) {
|
|
358
|
+
const result = await syncHubMember(hubPath, member, git);
|
|
359
|
+
results.push(result);
|
|
360
|
+
if (result.materializedCommit && result.status === "updated") {
|
|
361
|
+
member.materializedCommit = result.materializedCommit;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (results.some((result) => result.status === "updated")) await store.save(config);
|
|
365
|
+
return results;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function updateHubPlugins(
|
|
369
|
+
hubPath: string,
|
|
370
|
+
deps: HubPluginSyncDeps = {},
|
|
371
|
+
): Promise<PluginInstallResult[]> {
|
|
372
|
+
const { config } = await assertHubRoot(hubPath);
|
|
373
|
+
const declarations = config.plugins ?? [];
|
|
374
|
+
if (declarations.length === 0) return [];
|
|
375
|
+
|
|
376
|
+
const resolvedHubPath = path.resolve(hubPath);
|
|
377
|
+
const results = await installDeclaredPlugins(resolvedHubPath, declarations, deps.installDeps);
|
|
378
|
+
await (deps.hydrate ?? hydrateDynamicPlugins)({ companionPath: resolvedHubPath });
|
|
379
|
+
return results;
|
|
380
|
+
}
|
|
@@ -3,13 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { ConfigStore } from "./config-store";
|
|
5
5
|
import { writeRepoLocalRegistryEntry } from "./repo-local-registry";
|
|
6
|
-
import {
|
|
7
|
-
type CompanionSource,
|
|
8
|
-
ConfigError,
|
|
9
|
-
type FrameworkConfig,
|
|
10
|
-
type LinkedRepository,
|
|
11
|
-
type PolicySettings,
|
|
12
|
-
} from "./types";
|
|
6
|
+
import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
|
|
13
7
|
import { WorkingRepoStore } from "./working-repo-store";
|
|
14
8
|
|
|
15
9
|
export class CompanionStore {
|
|
@@ -68,32 +62,4 @@ export class CompanionStore {
|
|
|
68
62
|
const working = await this.workingRepoStore.load();
|
|
69
63
|
return working.repos;
|
|
70
64
|
}
|
|
71
|
-
|
|
72
|
-
async resolvePolicy(repositoryId: string): Promise<PolicySettings> {
|
|
73
|
-
const [config, working] = await Promise.all([
|
|
74
|
-
this.configStore.load(),
|
|
75
|
-
this.workingRepoStore.load(),
|
|
76
|
-
]);
|
|
77
|
-
return resolvePolicyFromConfig(config, working.repos, repositoryId);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function resolvePolicyFromConfig(
|
|
82
|
-
config: FrameworkConfig,
|
|
83
|
-
repos: LinkedRepository[],
|
|
84
|
-
repositoryId: string,
|
|
85
|
-
): PolicySettings {
|
|
86
|
-
const repository = repos.find((r) => r.id === repositoryId);
|
|
87
|
-
if (!repository) {
|
|
88
|
-
throw new ConfigError(`Unknown linked repository: ${repositoryId}`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const profile = config.profiles[repository.profile];
|
|
92
|
-
if (!profile) {
|
|
93
|
-
throw new ConfigError(`Unknown policy profile: ${repository.profile}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
allowedAgents: repository.overrides?.allowedAgents ?? profile.allowedAgents,
|
|
98
|
-
};
|
|
99
65
|
}
|
|
@@ -7,6 +7,8 @@ import { ConfigError, type FrameworkConfig } from "./types";
|
|
|
7
7
|
|
|
8
8
|
export const RTK_CAPABILITY_SPLIT_MIGRATION = "rtk-capability-split-v1";
|
|
9
9
|
|
|
10
|
+
const HUB_MEMBER_SOURCE_KINDS = ["git", "local"] as const;
|
|
11
|
+
|
|
10
12
|
function defaultConfigPath(): string {
|
|
11
13
|
return `.${FRAMEWORK_NAME}/config/framework.yaml`;
|
|
12
14
|
}
|
|
@@ -15,22 +17,37 @@ export function defaultConfig(): FrameworkConfig {
|
|
|
15
17
|
const defaults = getDefaultSetupSelections();
|
|
16
18
|
return {
|
|
17
19
|
type: "companion",
|
|
18
|
-
|
|
19
|
-
default: {
|
|
20
|
-
name: "default",
|
|
21
|
-
allowedAgents: defaults.allowedAgents,
|
|
22
|
-
},
|
|
23
|
-
},
|
|
20
|
+
allowedAgents: defaults.allowedAgents,
|
|
24
21
|
packageManagers: defaults.packageManagers,
|
|
25
22
|
capabilities: defaults.capabilities,
|
|
26
23
|
};
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
interface LegacyProfilesShape {
|
|
27
|
+
profiles?: Record<string, { name?: string; allowedAgents?: string[] }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Silently collapses the legacy `profiles` map into the flat `allowedAgents`
|
|
32
|
+
* list (`profiles.default.allowedAgents` wins; other profiles are dropped).
|
|
33
|
+
* The new shape persists on the next save.
|
|
34
|
+
*/
|
|
35
|
+
export function migrateProfilesToAllowedAgents(config: FrameworkConfig): FrameworkConfig {
|
|
36
|
+
const legacy = config as FrameworkConfig & LegacyProfilesShape;
|
|
37
|
+
if (!legacy.profiles) return config;
|
|
38
|
+
const { profiles, ...rest } = legacy;
|
|
39
|
+
return {
|
|
40
|
+
...rest,
|
|
41
|
+
allowedAgents: rest.allowedAgents ?? profiles.default?.allowedAgents ?? [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
export function mergeWithDefaults(existing: FrameworkConfig): FrameworkConfig {
|
|
30
46
|
const defaults = defaultConfig();
|
|
31
47
|
return {
|
|
32
48
|
...existing,
|
|
33
49
|
type: existing.type ?? defaults.type,
|
|
50
|
+
allowedAgents: existing.allowedAgents ?? [],
|
|
34
51
|
packageManagers: existing.packageManagers ?? defaults.packageManagers,
|
|
35
52
|
// Only backfill capabilities for legacy configs that predate the field
|
|
36
53
|
// entirely. Once a capabilities array has been persisted, it reflects the
|
|
@@ -54,6 +71,75 @@ export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkCon
|
|
|
54
71
|
};
|
|
55
72
|
}
|
|
56
73
|
|
|
74
|
+
/** Validates the shape of a hub manifest before it is used. */
|
|
75
|
+
export function validateHubConfig(config: FrameworkConfig): void {
|
|
76
|
+
if (config.type !== "hub") {
|
|
77
|
+
if (config.hub !== undefined) {
|
|
78
|
+
throw new ConfigError('Only a framework with type "hub" may define hub.companions.');
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const members = config.hub?.companions;
|
|
84
|
+
if (!Array.isArray(members)) {
|
|
85
|
+
throw new ConfigError('A "hub" framework requires a hub.companions array.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ids = new Set<string>();
|
|
89
|
+
for (const member of members) {
|
|
90
|
+
if (!member || typeof member !== "object") {
|
|
91
|
+
throw new ConfigError("Each hub member must be an object.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof member.id !== "string" || member.id.trim() === "") {
|
|
95
|
+
throw new ConfigError("Each hub member requires a non-empty id.");
|
|
96
|
+
}
|
|
97
|
+
if (ids.has(member.id)) {
|
|
98
|
+
throw new ConfigError(`Hub member id is duplicated: ${member.id}`);
|
|
99
|
+
}
|
|
100
|
+
ids.add(member.id);
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
typeof member.path !== "string" ||
|
|
104
|
+
member.path.trim() === "" ||
|
|
105
|
+
path.isAbsolute(member.path) ||
|
|
106
|
+
path.normalize(member.path) === "." ||
|
|
107
|
+
path.normalize(member.path) === ".." ||
|
|
108
|
+
path.normalize(member.path).startsWith(`..${path.sep}`)
|
|
109
|
+
) {
|
|
110
|
+
throw new ConfigError(`Hub member "${member.id}" path must be a relative child path.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const source = member.source;
|
|
114
|
+
if (!source || typeof source !== "object") {
|
|
115
|
+
throw new ConfigError(`Hub member "${member.id}" requires source provenance.`);
|
|
116
|
+
}
|
|
117
|
+
if (!HUB_MEMBER_SOURCE_KINDS.includes(source.kind)) {
|
|
118
|
+
throw new ConfigError(
|
|
119
|
+
`Hub member "${member.id}" source kind must be one of: ${HUB_MEMBER_SOURCE_KINDS.join(", ")}.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (source.kind === "git" && (typeof source.url !== "string" || source.url.trim() === "")) {
|
|
123
|
+
throw new ConfigError(`Git-backed hub member "${member.id}" requires a source URL.`);
|
|
124
|
+
}
|
|
125
|
+
if (source.ref !== undefined && (typeof source.ref !== "string" || source.ref.trim() === "")) {
|
|
126
|
+
throw new ConfigError(`Hub member "${member.id}" source ref must be a non-empty string.`);
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
source.kind === "git" &&
|
|
130
|
+
(typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
|
|
131
|
+
) {
|
|
132
|
+
throw new ConfigError(`Git-backed hub member "${member.id}" requires materializedCommit.`);
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
member.materializedCommit !== undefined &&
|
|
136
|
+
(typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
|
|
137
|
+
) {
|
|
138
|
+
throw new ConfigError(`Hub member "${member.id}" materializedCommit must be non-empty.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
57
143
|
export const PLUGIN_DECLARATION_POLICIES = ["default", "optional"] as const;
|
|
58
144
|
|
|
59
145
|
/**
|
|
@@ -77,7 +163,8 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
|
77
163
|
}
|
|
78
164
|
|
|
79
165
|
override async load(): Promise<FrameworkConfig> {
|
|
80
|
-
const merged = mergeWithDefaults(await super.load());
|
|
166
|
+
const merged = mergeWithDefaults(migrateProfilesToAllowedAgents(await super.load()));
|
|
167
|
+
validateHubConfig(merged);
|
|
81
168
|
validatePluginDeclarations(merged);
|
|
82
169
|
const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
|
|
83
170
|
const config = migrateRtkCapabilitySplit(merged);
|
|
@@ -141,7 +141,7 @@ export function detectEditorWorkspaceState(
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
export async function injectEditorFolder(
|
|
144
|
-
companionPath: string
|
|
144
|
+
companionPath: string,
|
|
145
145
|
repoPath: string,
|
|
146
146
|
cli = "code",
|
|
147
147
|
spawnProcess: SpawnLike = spawn,
|
|
@@ -163,9 +163,7 @@ export async function injectEditorFolder(
|
|
|
163
163
|
|
|
164
164
|
const { cli: resolvedCli, binary } = resolved;
|
|
165
165
|
|
|
166
|
-
const
|
|
167
|
-
(candidate) => path.resolve(candidate),
|
|
168
|
-
);
|
|
166
|
+
const workspaceFolders = [path.resolve(repoPath), path.resolve(companionPath)];
|
|
169
167
|
|
|
170
168
|
const workspacePath = editorWorkspacePath(repoPath);
|
|
171
169
|
await fs.mkdir(path.dirname(workspacePath), { recursive: true });
|
|
@@ -173,10 +171,7 @@ export async function injectEditorFolder(
|
|
|
173
171
|
workspacePath,
|
|
174
172
|
`${JSON.stringify(
|
|
175
173
|
{
|
|
176
|
-
folders:
|
|
177
|
-
{ path: path.resolve(repoPath) },
|
|
178
|
-
...companionPaths.map((candidate) => ({ path: candidate })),
|
|
179
|
-
],
|
|
174
|
+
folders: workspaceFolders.map((candidate) => ({ path: candidate })),
|
|
180
175
|
},
|
|
181
176
|
null,
|
|
182
177
|
2,
|
|
@@ -186,7 +181,7 @@ export async function injectEditorFolder(
|
|
|
186
181
|
|
|
187
182
|
if (detectWorkspace(workspacePath, resolvedCli) === "open") return true;
|
|
188
183
|
|
|
189
|
-
const child = spawnProcess(binary, ["--add",
|
|
184
|
+
const child = spawnProcess(binary, ["--add", ...workspaceFolders], {
|
|
190
185
|
stdio: "ignore",
|
|
191
186
|
detached: true,
|
|
192
187
|
});
|
|
@@ -197,14 +192,3 @@ export async function injectEditorFolder(
|
|
|
197
192
|
|
|
198
193
|
return true;
|
|
199
194
|
}
|
|
200
|
-
|
|
201
|
-
export function openEditorWorkspace(paths: string[], cli: string): boolean {
|
|
202
|
-
const binary = resolveEditorBinary(cli);
|
|
203
|
-
if (!binary) {
|
|
204
|
-
writeMissingEditorCliGuidance(cli);
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
execFileSync(binary, paths, { stdio: "ignore" });
|
|
209
|
-
return true;
|
|
210
|
-
}
|