@uniqbit/mate-core 0.15.3 → 0.15.4-canary.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.
Files changed (52) hide show
  1. package/package.json +3 -2
  2. package/src/cli/commands/companion/hub.ts +99 -0
  3. package/src/cli/commands/companion/tui.ts +3 -1
  4. package/src/cli/commands/doctor.ts +3 -6
  5. package/src/cli/commands/install.ts +10 -10
  6. package/src/cli/commands/launch/shared.ts +4 -4
  7. package/src/cli/commands/plugin/install.ts +94 -0
  8. package/src/cli/commands/plugin/plugin.ts +17 -0
  9. package/src/cli/commands/setup.ts +2 -2
  10. package/src/cli/commands/update.ts +9 -11
  11. package/src/cli/main.ts +25 -9
  12. package/src/cli/plugin-commands.ts +4 -4
  13. package/src/cli/usage.ts +7 -3
  14. package/src/distribution.ts +3 -5
  15. package/src/framework.ts +4 -36
  16. package/src/index.ts +4 -0
  17. package/src/lib/install.ts +1 -5
  18. package/src/lib/orchestrator/adapters/base.ts +3 -4
  19. package/src/lib/orchestrator/companion-hub.ts +374 -0
  20. package/src/lib/orchestrator/config-store.ts +72 -0
  21. package/src/lib/orchestrator/editor.ts +4 -20
  22. package/src/lib/orchestrator/framework-context.ts +119 -23
  23. package/src/lib/orchestrator/global-config-store.ts +1 -5
  24. package/src/lib/orchestrator/migration.ts +0 -23
  25. package/src/lib/orchestrator/setup-preflight.ts +4 -17
  26. package/src/lib/orchestrator/types.ts +26 -4
  27. package/src/lib/orchestrator/working-repo-store.ts +2 -2
  28. package/src/lib/update-checker.ts +5 -5
  29. package/src/playbooks/companion-guidance.ts +6 -6
  30. package/src/runtime/env.ts +0 -1
  31. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +79 -3
  32. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  33. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  34. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  35. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  36. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +5 -5
  37. package/src/tools/setup/capabilities/openspec.ts +1 -1
  38. package/src/tools/setup/capabilities/tokensave.ts +4 -4
  39. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  40. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  41. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  42. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  43. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  44. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  45. package/src/tools/setup/mate.ts +28 -31
  46. package/src/tools/setup/plugins/gitignore.ts +15 -8
  47. package/src/tools/setup/plugins/guidance.ts +1 -5
  48. package/src/tools/setup/providers/claude.ts +20 -8
  49. package/src/tools/setup/providers/opencode.ts +3 -4
  50. package/src/tools/setup.ts +2 -3
  51. package/src/tui.ts +5 -0
  52. package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
package/src/framework.ts CHANGED
@@ -1,39 +1,7 @@
1
- import { getActiveDistribution } from "./distribution";
2
-
3
1
  /**
4
- * Framework identity: names everything identity-shaped regardless of how the
5
- * CLI is invoked — state directories (`~/.mate`, `.mate/`), managed-block
6
- * markers, `MATE_*` env values, and the `companion-policy framework`
7
- * attribute. A whitelabel distribution changes the invocation name only;
8
- * identity stays `mate`. Use `frameworkCommandName()` for anything the user
9
- * or an agent types.
2
+ * Framework identity: names everything identity-shaped — state directories
3
+ * (`~/.mate`, `.mate/`), managed-block markers, `MATE_*` env values, usage
4
+ * output, command hints, error prefixes, agent guidance, and permission
5
+ * entries. The distribution is always invoked as `mate`.
10
6
  */
11
7
  export const FRAMEWORK_NAME = "mate";
12
-
13
- /**
14
- * Invocation name: how this distribution's CLI is invoked, derived from the
15
- * package's `bin` key (`config.name`). Drives usage output, command hints,
16
- * error prefixes, agent guidance, and permission entries — never paths,
17
- * markers, or env values (use `FRAMEWORK_NAME` for those).
18
- */
19
- export function frameworkCommandName(): string {
20
- return getActiveDistribution().config.name;
21
- }
22
-
23
- /**
24
- * Live view of the active distribution's identity. Framework modules read
25
- * identity through this object; the values always come from the config the
26
- * distribution passed to `createMate` — the framework itself carries no
27
- * hardcoded distribution name.
28
- */
29
- export const frameworkConfig = {
30
- get name(): string {
31
- return getActiveDistribution().config.name;
32
- },
33
- get legacyNames(): string[] {
34
- return getActiveDistribution().config.legacyNames ?? [];
35
- },
36
- get runtime(): string {
37
- return getActiveDistribution().config.runtime;
38
- },
39
- };
package/src/index.ts CHANGED
@@ -47,7 +47,11 @@ export type {
47
47
  TemplatesService,
48
48
  } from "./tools/setup/plugin";
49
49
  export type {
50
+ FrameworkType,
50
51
  FrameworkConfig,
52
+ HubConfig,
53
+ HubMember,
54
+ HubMemberSource,
51
55
  PluginDeclaration,
52
56
  PluginDeclarationPolicy,
53
57
  } from "./lib/orchestrator/types";
@@ -292,11 +292,7 @@ export async function inspectInstallPreflight(
292
292
  ): Promise<InstallPreflightResult> {
293
293
  const context = await resolveInstallContext(cwd, deps);
294
294
  const distribution = getActiveDistribution().config;
295
- const engineCheck = checkEngineRequirement(
296
- context.config,
297
- distribution.name,
298
- distribution.version,
299
- );
295
+ const engineCheck = checkEngineRequirement(context.config, FRAMEWORK_NAME, distribution.version);
300
296
  if (!engineCheck.ok) return { ok: false, reason: engineCheck.reason };
301
297
  const plan = await inspectInstallPlan(buildInstallPlan(context));
302
298
  if (context.kind === "ambiguous") return { ok: false, reason: context.message, plan };
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { version } from "../../../../package.json";
5
5
  import { isCommandOnPath } from "../../fs-utils";
6
- import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
6
+ import { FRAMEWORK_NAME } from "../../../framework";
7
7
  import { getReactDoctorBinPath, getWrapperBinPath } from "../../package-paths";
8
8
  import {
9
9
  GRAPHIFY_OUTPUT_SUBDIR,
@@ -78,7 +78,6 @@ export abstract class LaunchAdapter {
78
78
  const env: NodeJS.ProcessEnv = {
79
79
  ...process.env,
80
80
  MATE_NAME: FRAMEWORK_NAME,
81
- MATE_COMMAND: frameworkCommandName(),
82
81
  MATE_VERSION: version,
83
82
  MATE_ARTIFACT_PATH: context.companionPath,
84
83
  MATE_WRAPPER_BIN_PATH: wrapperBinPath,
@@ -149,7 +148,7 @@ export abstract class LaunchAdapter {
149
148
  command: this.toolName,
150
149
  args: builtArgs,
151
150
  env,
152
- warning: `${frameworkCommandName()}: headroom capability enabled but \`headroom\` was not found on PATH; install with \`uv tool install "headroom-ai[all]"\`; launching ${this.toolName} directly\n`,
151
+ warning: `${FRAMEWORK_NAME}: headroom capability enabled but \`headroom\` was not found on PATH; install with \`uv tool install "headroom-ai[all]"\`; launching ${this.toolName} directly\n`,
153
152
  };
154
153
  }
155
154
 
@@ -183,7 +182,7 @@ export abstract class LaunchAdapter {
183
182
  command: this.toolName,
184
183
  args: builtArgs,
185
184
  env,
186
- warning: `${frameworkCommandName()}: headroom proxy failed to become ready on port ${port} after ${maxProxyStartAttempts} attempts; launching ${this.toolName} directly${errorDetail}\n`,
185
+ warning: `${FRAMEWORK_NAME}: headroom proxy failed to become ready on port ${port} after ${maxProxyStartAttempts} attempts; launching ${this.toolName} directly${errorDetail}\n`,
187
186
  };
188
187
  }
189
188
  }
@@ -0,0 +1,374 @@
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, defaultConfig, 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 = { ...defaultConfig(), type: "hub" as const, hub: { companions: [] } };
125
+ await configStore.save(config);
126
+ }
127
+ await globalConfigStore.register(hubPath);
128
+ return hubPath;
129
+ }
130
+
131
+ export function discoverGitSource(
132
+ sourcePath: string,
133
+ git: GitCommand = defaultGitCommand,
134
+ ): HubSource {
135
+ const root = git(sourcePath, ["rev-parse", "--show-toplevel"]);
136
+ if (root.status !== 0 || !root.stdout) return { kind: "local", path: path.resolve(sourcePath) };
137
+
138
+ const remote = git(sourcePath, ["config", "--get", "remote.origin.url"]);
139
+ if (remote.status !== 0 || !remote.stdout)
140
+ return { kind: "local", path: path.resolve(sourcePath) };
141
+
142
+ const branch = git(sourcePath, ["symbolic-ref", "--short", "HEAD"]);
143
+ return {
144
+ kind: "git",
145
+ path: path.resolve(sourcePath),
146
+ url: remote.stdout,
147
+ ref: branch.status === 0 && branch.stdout ? branch.stdout : undefined,
148
+ };
149
+ }
150
+
151
+ export function discoverHubSource(source: string, git: GitCommand = defaultGitCommand): HubSource {
152
+ const trimmed = source.trim();
153
+ if (/^(?:https?|ssh|git):\/\//i.test(trimmed) || trimmed.startsWith("git@")) {
154
+ return { kind: "git", url: trimmed };
155
+ }
156
+ return discoverGitSource(path.resolve(trimmed), git);
157
+ }
158
+
159
+ function sourceForManifest(source: HubSource, fallbackPath: string): HubMemberSource {
160
+ if (source.kind === "git") {
161
+ return { kind: "git", url: source.url, ref: source.ref };
162
+ }
163
+ return { kind: "local", path: source.path ?? fallbackPath };
164
+ }
165
+
166
+ function gitOutputOrThrow(result: GitCommandResult, operation: string): string {
167
+ if (result.status !== 0) {
168
+ throw new Error(
169
+ `${operation} failed: ${result.stderr || result.stdout || "unknown Git error"}`,
170
+ );
171
+ }
172
+ return result.stdout;
173
+ }
174
+
175
+ async function copyWithoutGit(sourcePath: string, destination: string): Promise<void> {
176
+ await fs.cp(sourcePath, destination, {
177
+ recursive: true,
178
+ filter: (candidate) => path.basename(candidate) !== ".git",
179
+ });
180
+ }
181
+
182
+ async function assertMaterializedCompanion(memberPath: string): Promise<void> {
183
+ const configPath = frameworkConfigPath(memberPath);
184
+ const raw = await fs.readFile(configPath, "utf8").catch(() => "");
185
+ const config = parse(raw) as { type?: unknown } | null;
186
+ if (config?.type !== "companion") {
187
+ throw new Error(`Materialized child must declare type: companion: ${memberPath}`);
188
+ }
189
+ }
190
+
191
+ async function currentCommit(memberPath: string, git: GitCommand): Promise<string> {
192
+ return gitOutputOrThrow(git(memberPath, ["rev-parse", "HEAD"]), `Reading ${memberPath} commit`);
193
+ }
194
+
195
+ export async function materializeHubMember(
196
+ hubPath: string,
197
+ source: HubSource,
198
+ options: { id?: string; memberPath?: string; git?: GitCommand } = {},
199
+ ): Promise<HubMember> {
200
+ const git = options.git ?? defaultGitCommand;
201
+ const sourceName = source.url ?? source.path ?? "companion";
202
+ const id = normalizeHubMemberId(options.id ?? path.basename(sourceName));
203
+ const relativePath = options.memberPath ?? memberPathForId(id);
204
+ const destination = validateHubMemberPath(hubPath, relativePath);
205
+ const target = await fs.stat(destination).catch(() => null);
206
+ if (target) throw new Error(`Hub member destination already exists: ${relativePath}`);
207
+
208
+ await fs.mkdir(path.dirname(destination), { recursive: true });
209
+ try {
210
+ if (source.kind === "git") {
211
+ const args = ["clone"];
212
+ if (source.ref) args.push("--branch", source.ref);
213
+ args.push(source.url!, destination);
214
+ gitOutputOrThrow(git(hubPath, args), `Cloning ${source.url}`);
215
+ } else {
216
+ await copyWithoutGit(source.path!, destination);
217
+ }
218
+
219
+ await assertMaterializedCompanion(destination);
220
+ const member: HubMember = {
221
+ id,
222
+ path: relativePath,
223
+ source: sourceForManifest(source, source.path ?? relativePath),
224
+ };
225
+ if (source.kind === "git") member.materializedCommit = await currentCommit(destination, git);
226
+ return member;
227
+ } catch (error) {
228
+ await fs.rm(destination, { recursive: true, force: true });
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ export async function addHubMember(
234
+ hubPath: string,
235
+ source: HubSource,
236
+ options: { id?: string; memberPath?: string; git?: GitCommand } = {},
237
+ ): Promise<HubMember> {
238
+ const { config, store } = await assertHubRoot(hubPath);
239
+ const member = await materializeHubMember(hubPath, source, options);
240
+ if (config.hub!.companions.some((candidate) => candidate.id === member.id)) {
241
+ await fs.rm(path.resolve(hubPath, member.path), { recursive: true, force: true });
242
+ throw new Error(`Hub member id already exists: ${member.id}`);
243
+ }
244
+ config.hub!.companions.push(member);
245
+ await store.save(config);
246
+ return member;
247
+ }
248
+
249
+ function syncTarget(memberPath: string, git: GitCommand): string | null {
250
+ const upstream = git(memberPath, [
251
+ "rev-parse",
252
+ "--abbrev-ref",
253
+ "--symbolic-full-name",
254
+ "@{upstream}",
255
+ ]);
256
+ if (upstream.status !== 0 || !upstream.stdout) return null;
257
+ return upstream.stdout;
258
+ }
259
+
260
+ export async function syncHubMember(
261
+ hubPath: string,
262
+ member: HubMember,
263
+ git: GitCommand = defaultGitCommand,
264
+ ): Promise<HubSyncResult> {
265
+ const memberPath = path.resolve(hubPath, member.path);
266
+ if (member.source.kind === "local") {
267
+ return { id: member.id, status: "local-only", message: "local-only child has no Git source" };
268
+ }
269
+
270
+ const dirty = git(memberPath, ["status", "--porcelain=v1", "--untracked-files=all"]);
271
+ if (dirty.status !== 0) {
272
+ return {
273
+ id: member.id,
274
+ status: "failed",
275
+ message: dirty.stderr || "unable to inspect Git state",
276
+ };
277
+ }
278
+ if (dirty.stdout) {
279
+ return { id: member.id, status: "dirty", message: "local changes must be resolved first" };
280
+ }
281
+
282
+ const fetch = git(memberPath, ["fetch", "origin"]);
283
+ if (fetch.status !== 0) {
284
+ return { id: member.id, status: "failed", message: fetch.stderr || "fetch failed" };
285
+ }
286
+ const target = syncTarget(memberPath, git);
287
+ if (!target) {
288
+ return { id: member.id, status: "failed", message: "no tracked upstream branch" };
289
+ }
290
+ const counts = git(memberPath, ["rev-list", "--left-right", "--count", `HEAD...${target}`]);
291
+ if (counts.status !== 0) {
292
+ return {
293
+ id: member.id,
294
+ status: "failed",
295
+ message: counts.stderr || "unable to compare branches",
296
+ };
297
+ }
298
+ const [ahead, behind] = counts.stdout.split(/\s+/).map(Number);
299
+ if (ahead > 0 && behind > 0) {
300
+ return {
301
+ id: member.id,
302
+ status: "divergent",
303
+ message: "local and remote branches have diverged",
304
+ };
305
+ }
306
+ if (behind === 0) {
307
+ const commit = await currentCommit(memberPath, git);
308
+ return {
309
+ id: member.id,
310
+ status: "up-to-date",
311
+ message: "already up to date",
312
+ materializedCommit: commit,
313
+ };
314
+ }
315
+
316
+ const readTree = git(memberPath, ["read-tree", "-u", "-m", target]);
317
+ if (readTree.status !== 0) {
318
+ return {
319
+ id: member.id,
320
+ status: "failed",
321
+ message: readTree.stderr || "fast-forward worktree update failed",
322
+ };
323
+ }
324
+ const branch = git(memberPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
325
+ if (branch.status !== 0 || !branch.stdout) {
326
+ return { id: member.id, status: "failed", message: "cannot advance a detached HEAD" };
327
+ }
328
+ const updateRef = git(memberPath, ["update-ref", `refs/heads/${branch.stdout}`, target, "HEAD"]);
329
+ if (updateRef.status !== 0) {
330
+ return {
331
+ id: member.id,
332
+ status: "failed",
333
+ message: updateRef.stderr || "fast-forward ref update failed",
334
+ };
335
+ }
336
+ const commit = await currentCommit(memberPath, git);
337
+ return {
338
+ id: member.id,
339
+ status: "updated",
340
+ message: `fast-forwarded to ${commit}`,
341
+ materializedCommit: commit,
342
+ };
343
+ }
344
+
345
+ export async function syncHub(
346
+ hubPath: string,
347
+ git: GitCommand = defaultGitCommand,
348
+ ): Promise<HubSyncResult[]> {
349
+ const { config, store } = await assertHubRoot(hubPath);
350
+ const results: HubSyncResult[] = [];
351
+ for (const member of config.hub!.companions) {
352
+ const result = await syncHubMember(hubPath, member, git);
353
+ results.push(result);
354
+ if (result.materializedCommit && result.status === "updated") {
355
+ member.materializedCommit = result.materializedCommit;
356
+ }
357
+ }
358
+ if (results.some((result) => result.status === "updated")) await store.save(config);
359
+ return results;
360
+ }
361
+
362
+ export async function updateHubPlugins(
363
+ hubPath: string,
364
+ deps: HubPluginSyncDeps = {},
365
+ ): Promise<PluginInstallResult[]> {
366
+ const { config } = await assertHubRoot(hubPath);
367
+ const declarations = config.plugins ?? [];
368
+ if (declarations.length === 0) return [];
369
+
370
+ const resolvedHubPath = path.resolve(hubPath);
371
+ const results = await installDeclaredPlugins(resolvedHubPath, declarations, deps.installDeps);
372
+ await (deps.hydrate ?? hydrateDynamicPlugins)({ companionPath: resolvedHubPath });
373
+ return results;
374
+ }
@@ -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
  }
@@ -54,6 +56,75 @@ export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkCon
54
56
  };
55
57
  }
56
58
 
59
+ /** Validates the shape of a hub manifest before it is used. */
60
+ export function validateHubConfig(config: FrameworkConfig): void {
61
+ if (config.type !== "hub") {
62
+ if (config.hub !== undefined) {
63
+ throw new ConfigError('Only a framework with type "hub" may define hub.companions.');
64
+ }
65
+ return;
66
+ }
67
+
68
+ const members = config.hub?.companions;
69
+ if (!Array.isArray(members)) {
70
+ throw new ConfigError('A "hub" framework requires a hub.companions array.');
71
+ }
72
+
73
+ const ids = new Set<string>();
74
+ for (const member of members) {
75
+ if (!member || typeof member !== "object") {
76
+ throw new ConfigError("Each hub member must be an object.");
77
+ }
78
+
79
+ if (typeof member.id !== "string" || member.id.trim() === "") {
80
+ throw new ConfigError("Each hub member requires a non-empty id.");
81
+ }
82
+ if (ids.has(member.id)) {
83
+ throw new ConfigError(`Hub member id is duplicated: ${member.id}`);
84
+ }
85
+ ids.add(member.id);
86
+
87
+ if (
88
+ typeof member.path !== "string" ||
89
+ member.path.trim() === "" ||
90
+ path.isAbsolute(member.path) ||
91
+ path.normalize(member.path) === "." ||
92
+ path.normalize(member.path) === ".." ||
93
+ path.normalize(member.path).startsWith(`..${path.sep}`)
94
+ ) {
95
+ throw new ConfigError(`Hub member "${member.id}" path must be a relative child path.`);
96
+ }
97
+
98
+ const source = member.source;
99
+ if (!source || typeof source !== "object") {
100
+ throw new ConfigError(`Hub member "${member.id}" requires source provenance.`);
101
+ }
102
+ if (!HUB_MEMBER_SOURCE_KINDS.includes(source.kind)) {
103
+ throw new ConfigError(
104
+ `Hub member "${member.id}" source kind must be one of: ${HUB_MEMBER_SOURCE_KINDS.join(", ")}.`,
105
+ );
106
+ }
107
+ if (source.kind === "git" && (typeof source.url !== "string" || source.url.trim() === "")) {
108
+ throw new ConfigError(`Git-backed hub member "${member.id}" requires a source URL.`);
109
+ }
110
+ if (source.ref !== undefined && (typeof source.ref !== "string" || source.ref.trim() === "")) {
111
+ throw new ConfigError(`Hub member "${member.id}" source ref must be a non-empty string.`);
112
+ }
113
+ if (
114
+ source.kind === "git" &&
115
+ (typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
116
+ ) {
117
+ throw new ConfigError(`Git-backed hub member "${member.id}" requires materializedCommit.`);
118
+ }
119
+ if (
120
+ member.materializedCommit !== undefined &&
121
+ (typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
122
+ ) {
123
+ throw new ConfigError(`Hub member "${member.id}" materializedCommit must be non-empty.`);
124
+ }
125
+ }
126
+ }
127
+
57
128
  export const PLUGIN_DECLARATION_POLICIES = ["default", "optional"] as const;
58
129
 
59
130
  /**
@@ -78,6 +149,7 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
78
149
 
79
150
  override async load(): Promise<FrameworkConfig> {
80
151
  const merged = mergeWithDefaults(await super.load());
152
+ validateHubConfig(merged);
81
153
  validatePluginDeclarations(merged);
82
154
  const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
83
155
  const config = migrateRtkCapabilitySplit(merged);
@@ -141,7 +141,7 @@ export function detectEditorWorkspaceState(
141
141
  }
142
142
 
143
143
  export async function injectEditorFolder(
144
- companionPath: string | 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 companionPaths = (Array.isArray(companionPath) ? companionPath : [companionPath]).map(
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", path.resolve(repoPath), ...companionPaths], {
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
- }