@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.
- package/package.json +3 -2
- package/src/cli/commands/companion/hub.ts +99 -0
- package/src/cli/commands/companion/tui.ts +3 -1
- package/src/cli/commands/doctor.ts +3 -6
- package/src/cli/commands/install.ts +10 -10
- 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 +2 -2
- package/src/cli/commands/update.ts +9 -11
- package/src/cli/main.ts +25 -9
- package/src/cli/plugin-commands.ts +4 -4
- package/src/cli/usage.ts +7 -3
- package/src/distribution.ts +3 -5
- package/src/framework.ts +4 -36
- package/src/index.ts +4 -0
- package/src/lib/install.ts +1 -5
- package/src/lib/orchestrator/adapters/base.ts +3 -4
- package/src/lib/orchestrator/companion-hub.ts +374 -0
- package/src/lib/orchestrator/config-store.ts +72 -0
- package/src/lib/orchestrator/editor.ts +4 -20
- package/src/lib/orchestrator/framework-context.ts +119 -23
- package/src/lib/orchestrator/global-config-store.ts +1 -5
- package/src/lib/orchestrator/migration.ts +0 -23
- package/src/lib/orchestrator/setup-preflight.ts +4 -17
- package/src/lib/orchestrator/types.ts +26 -4
- package/src/lib/orchestrator/working-repo-store.ts +2 -2
- package/src/lib/update-checker.ts +5 -5
- package/src/playbooks/companion-guidance.ts +6 -6
- package/src/runtime/env.ts +0 -1
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +79 -3
- 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 +5 -5
- package/src/tools/setup/capabilities/openspec.ts +1 -1
- package/src/tools/setup/capabilities/tokensave.ts +4 -4
- 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/mate.ts +28 -31
- package/src/tools/setup/plugins/gitignore.ts +15 -8
- package/src/tools/setup/plugins/guidance.ts +1 -5
- package/src/tools/setup/providers/claude.ts +20 -8
- package/src/tools/setup/providers/opencode.ts +3 -4
- package/src/tools/setup.ts +2 -3
- package/src/tui.ts +5 -0
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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";
|
package/src/lib/install.ts
CHANGED
|
@@ -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
|
|
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: `${
|
|
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: `${
|
|
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
|
|
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
|
-
}
|