@uniqbit/mate-core 0.15.4 → 0.15.5-canary.1
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 +1 -1
- package/src/cli/commands/artifact/finish/openspec.ts +180 -12
- package/src/cli/commands/companion/link.ts +9 -13
- package/src/cli/commands/working/cleanup.ts +125 -0
- package/src/cli/commands/working/working.ts +15 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/materialize.ts +46 -0
- package/src/cli/commands/workspace/workspace.ts +22 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/usage.ts +8 -0
- package/src/cli/write-json-stdout.ts +14 -0
- package/src/lib/orchestrator/companion-registry-reader.ts +37 -0
- package/src/lib/orchestrator/{working-repo-store.ts → companion-registry-store.ts} +14 -7
- package/src/lib/orchestrator/companion-resolver.ts +50 -2
- package/src/lib/orchestrator/companion-store.ts +39 -10
- package/src/lib/orchestrator/editor.ts +36 -14
- package/src/lib/orchestrator/framework-context.ts +33 -3
- package/src/lib/orchestrator/global-config-store.ts +25 -0
- package/src/lib/orchestrator/repo-local-registry.ts +2 -22
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +149 -0
- package/src/lib/orchestrator/workspace-materialize.ts +80 -0
- package/src/lib/orchestrator/yaml-file-store.ts +10 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +40 -14
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +9 -6
- package/src/templates/root/TEMPLATE_AGENTS.md +0 -8
- package/src/templates/root/TEMPLATE_CLAUDE.md +0 -10
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +44 -380
- package/src/tools/setup/plugins/gitignore.ts +3 -0
- package/src/tools/setup/providers/claude.ts +68 -56
- package/src/tools/setup/working-repo-cleanup.ts +40 -0
- package/src/tools/setup/working-repo-local-state.ts +103 -0
|
@@ -3,18 +3,25 @@ import path from "node:path";
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
4
|
import { migrateRegistryData } from "./migration";
|
|
5
5
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
|
-
import { ConfigError, type
|
|
6
|
+
import { ConfigError, type CompanionRegistryConfig } from "./types";
|
|
7
7
|
|
|
8
|
-
export function
|
|
8
|
+
export function getDefaultCompanionRegistryPath(): string {
|
|
9
9
|
return `.${FRAMEWORK_NAME}/config/registry.yaml`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Companion-side reverse index (`{ repos: [...] }`) at
|
|
14
|
+
* `<companion>/.mate/config/registry.yaml` — distinct from the repo-local
|
|
15
|
+
* cache ({@link RepoLocalRegistryStore}) despite the shared "registry.yaml"
|
|
16
|
+
* filename. Renamed from `WorkingRepoStore`: every call site pointed this
|
|
17
|
+
* at a companion's config dir, never an actual working repo.
|
|
18
|
+
*/
|
|
19
|
+
export class CompanionRegistryStore extends YamlFileStore<CompanionRegistryConfig> {
|
|
20
|
+
constructor(configPath = getDefaultCompanionRegistryPath()) {
|
|
14
21
|
super(path.resolve(configPath));
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
override async load(): Promise<
|
|
24
|
+
override async load(): Promise<CompanionRegistryConfig> {
|
|
18
25
|
await migrateRegistryData(this.configPath);
|
|
19
26
|
const config = await super.load();
|
|
20
27
|
// Silently drop legacy per-repo policy fields (profile/overrides); the
|
|
@@ -25,13 +32,13 @@ export class WorkingRepoStore extends YamlFileStore<WorkingRepoConfig> {
|
|
|
25
32
|
};
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
protected async onMissing(): Promise<
|
|
35
|
+
protected async onMissing(): Promise<CompanionRegistryConfig> {
|
|
29
36
|
throw new ConfigError(
|
|
30
37
|
`Working repo config not found. Please run \`${FRAMEWORK_NAME} companion setup\` to initialize the framework.`,
|
|
31
38
|
);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
static defaultConfig():
|
|
41
|
+
static defaultConfig(): CompanionRegistryConfig {
|
|
35
42
|
return { repos: [] };
|
|
36
43
|
}
|
|
37
44
|
}
|
|
@@ -3,10 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
|
|
6
|
+
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
6
7
|
import type { GlobalConfigStore } from "./global-config-store";
|
|
7
8
|
import {
|
|
8
9
|
findRepoLocalRegistryFile,
|
|
9
10
|
pathIsDirectory,
|
|
11
|
+
repoLocalRegistryPath,
|
|
10
12
|
type RepoLocalCompanionPointer,
|
|
11
13
|
RepoLocalRegistryStore,
|
|
12
14
|
} from "./repo-local-registry";
|
|
@@ -36,7 +38,7 @@ export interface CompanionResolutionResult {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export class CompanionResolver {
|
|
39
|
-
constructor(
|
|
41
|
+
constructor(private readonly globalConfigStore: GlobalConfigStore) {}
|
|
40
42
|
|
|
41
43
|
async resolve(cwd: string): Promise<CompanionMatch | null> {
|
|
42
44
|
return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
|
|
@@ -49,7 +51,53 @@ export class CompanionResolver {
|
|
|
49
51
|
const resolvedCwd = path.resolve(cwd);
|
|
50
52
|
|
|
51
53
|
const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
|
|
52
|
-
|
|
54
|
+
if (repoLocalResult) return repoLocalResult;
|
|
55
|
+
|
|
56
|
+
return this.resolveFromGlobalRegistry(resolvedCwd);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fallback for when the repo-local cache is missing or unusable: scans
|
|
61
|
+
* every companion the global registry knows about and matches by
|
|
62
|
+
* repository path. A single match self-heals the repo-local cache so the
|
|
63
|
+
* fast path succeeds on the next resolution without repeating this scan.
|
|
64
|
+
*/
|
|
65
|
+
private async resolveFromGlobalRegistry(resolvedCwd: string): Promise<CompanionResolutionResult> {
|
|
66
|
+
const candidates = await this.globalConfigStore.list();
|
|
67
|
+
const found = await Promise.all(
|
|
68
|
+
candidates.map(async (candidate) => {
|
|
69
|
+
const companionPath = path.resolve(candidate);
|
|
70
|
+
try {
|
|
71
|
+
const { repos } = await readCompanionRegistry(companionPath);
|
|
72
|
+
const repo = repos.find((r) => path.resolve(r.path) === resolvedCwd);
|
|
73
|
+
return repo ? { companionPath, repositoryId: repo.id } : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const seenCompanionPaths = new Set<string>();
|
|
81
|
+
const matches = found.filter((match): match is CompanionMatch => {
|
|
82
|
+
if (!match || seenCompanionPaths.has(match.companionPath)) return false;
|
|
83
|
+
seenCompanionPaths.add(match.companionPath);
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (matches.length === 0) return { match: null, ambiguousMatches: [], failures: [] };
|
|
88
|
+
|
|
89
|
+
if (matches.length === 1) {
|
|
90
|
+
const store = new RepoLocalRegistryStore(repoLocalRegistryPath(resolvedCwd));
|
|
91
|
+
await store.save({
|
|
92
|
+
companions: [{ path: matches[0]!.companionPath, repositoryId: matches[0]!.repositoryId }],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
match: matches[0]!,
|
|
98
|
+
ambiguousMatches: matches.length > 1 ? matches : [],
|
|
99
|
+
failures: [],
|
|
100
|
+
};
|
|
53
101
|
}
|
|
54
102
|
|
|
55
103
|
/**
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
+
import { CompanionRegistryStore } from "./companion-registry-store";
|
|
4
6
|
import { ConfigStore } from "./config-store";
|
|
5
|
-
import { writeRepoLocalRegistryEntry } from "./repo-local-registry";
|
|
7
|
+
import { pathIsDirectory, writeRepoLocalRegistryEntry } from "./repo-local-registry";
|
|
6
8
|
import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
/** Builds a {@link CompanionStore} whose config/registry files live under `<companionPath>/.mate/config`, regardless of `process.cwd()`. */
|
|
11
|
+
export function companionRootedStore(companionPath: string): CompanionStore {
|
|
12
|
+
const configDir = path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config");
|
|
13
|
+
return new CompanionStore(
|
|
14
|
+
new ConfigStore(path.join(configDir, "framework.yaml")),
|
|
15
|
+
new CompanionRegistryStore(path.join(configDir, "registry.yaml")),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export class CompanionStore {
|
|
10
20
|
constructor(
|
|
11
21
|
private readonly configStore = new ConfigStore(),
|
|
12
|
-
private readonly
|
|
22
|
+
private readonly companionRegistryStore = new CompanionRegistryStore(),
|
|
13
23
|
) {}
|
|
14
24
|
|
|
15
25
|
async registerRepository(
|
|
@@ -23,14 +33,33 @@ export class CompanionStore {
|
|
|
23
33
|
);
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
// A companion's registry.yaml may not exist yet (its first-ever link):
|
|
37
|
+
// that is not a real error here, unlike a working repo reading its own
|
|
38
|
+
// missing config, so fall back to an empty registry instead of throwing.
|
|
39
|
+
const working = await this.companionRegistryStore.load().catch((error) => {
|
|
40
|
+
if (error instanceof ConfigError) return CompanionRegistryStore.defaultConfig();
|
|
41
|
+
throw error;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Prune dead entries (path no longer a directory) as part of this same
|
|
45
|
+
// write, piggybacking on the load/save that registration already does
|
|
46
|
+
// rather than adding a separate scan; never runs on a read-only list.
|
|
47
|
+
const checked = await Promise.all(
|
|
48
|
+
working.repos.map(async (repo) => ({ repo, exists: await pathIsDirectory(repo.path) })),
|
|
49
|
+
);
|
|
50
|
+
const survivors: LinkedRepository[] = [];
|
|
51
|
+
for (const entry of checked) {
|
|
52
|
+
if (entry.exists) survivors.push(entry.repo);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const existing = survivors.findIndex((r) => r.id === repository.id);
|
|
28
56
|
if (existing >= 0) {
|
|
29
|
-
|
|
57
|
+
survivors[existing] = repository;
|
|
30
58
|
} else {
|
|
31
|
-
|
|
59
|
+
survivors.push(repository);
|
|
32
60
|
}
|
|
33
|
-
|
|
61
|
+
working.repos = survivors;
|
|
62
|
+
await this.companionRegistryStore.save(working);
|
|
34
63
|
|
|
35
64
|
// Repo-local registry write is a cache, not the source of truth: the
|
|
36
65
|
// companion-side write above already succeeded, so a failure here (e.g. a
|
|
@@ -54,12 +83,12 @@ export class CompanionStore {
|
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
async getRepository(repositoryId: string): Promise<LinkedRepository | undefined> {
|
|
57
|
-
const working = await this.
|
|
86
|
+
const working = await this.companionRegistryStore.load();
|
|
58
87
|
return working.repos.find((r) => r.id === repositoryId);
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
async listRepositories(): Promise<LinkedRepository[]> {
|
|
62
|
-
const working = await this.
|
|
91
|
+
const working = await this.companionRegistryStore.load();
|
|
63
92
|
return working.repos;
|
|
64
93
|
}
|
|
65
94
|
}
|
|
@@ -140,6 +140,39 @@ export function detectEditorWorkspaceState(
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
export interface WorkspaceDocument {
|
|
144
|
+
workspacePath: string;
|
|
145
|
+
folders: [string, string];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Writes the generated `.mate/workspace.code-workspace` file (working
|
|
150
|
+
* repository first, companion second) and returns its path and folder
|
|
151
|
+
* order. Shared by editor-launching `injectEditorFolder` and the
|
|
152
|
+
* non-launching `workspace materialize` command — both must produce the
|
|
153
|
+
* same document for the same pairing.
|
|
154
|
+
*/
|
|
155
|
+
export async function writeWorkspaceDocument(
|
|
156
|
+
companionPath: string,
|
|
157
|
+
repoPath: string,
|
|
158
|
+
): Promise<WorkspaceDocument> {
|
|
159
|
+
const folders: [string, string] = [path.resolve(repoPath), path.resolve(companionPath)];
|
|
160
|
+
const workspacePath = editorWorkspacePath(repoPath);
|
|
161
|
+
await fs.mkdir(path.dirname(workspacePath), { recursive: true });
|
|
162
|
+
await fs.writeFile(
|
|
163
|
+
workspacePath,
|
|
164
|
+
`${JSON.stringify(
|
|
165
|
+
{
|
|
166
|
+
folders: folders.map((candidate) => ({ path: candidate })),
|
|
167
|
+
},
|
|
168
|
+
null,
|
|
169
|
+
2,
|
|
170
|
+
)}\n`,
|
|
171
|
+
"utf8",
|
|
172
|
+
);
|
|
173
|
+
return { workspacePath, folders };
|
|
174
|
+
}
|
|
175
|
+
|
|
143
176
|
export async function injectEditorFolder(
|
|
144
177
|
companionPath: string,
|
|
145
178
|
repoPath: string,
|
|
@@ -163,20 +196,9 @@ export async function injectEditorFolder(
|
|
|
163
196
|
|
|
164
197
|
const { cli: resolvedCli, binary } = resolved;
|
|
165
198
|
|
|
166
|
-
const workspaceFolders =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await fs.mkdir(path.dirname(workspacePath), { recursive: true });
|
|
170
|
-
await fs.writeFile(
|
|
171
|
-
workspacePath,
|
|
172
|
-
`${JSON.stringify(
|
|
173
|
-
{
|
|
174
|
-
folders: workspaceFolders.map((candidate) => ({ path: candidate })),
|
|
175
|
-
},
|
|
176
|
-
null,
|
|
177
|
-
2,
|
|
178
|
-
)}\n`,
|
|
179
|
-
"utf8",
|
|
199
|
+
const { workspacePath, folders: workspaceFolders } = await writeWorkspaceDocument(
|
|
200
|
+
companionPath,
|
|
201
|
+
repoPath,
|
|
180
202
|
);
|
|
181
203
|
|
|
182
204
|
if (detectWorkspace(workspacePath, resolvedCli) === "open") return true;
|
|
@@ -3,7 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
5
|
import { parse } from "yaml";
|
|
6
|
+
import { CompanionRegistryStore } from "./companion-registry-store";
|
|
6
7
|
import { CompanionResolver } from "./companion-resolver";
|
|
8
|
+
import { companionRootedStore } from "./companion-store";
|
|
9
|
+
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
7
10
|
import { ConfigStore } from "./config-store";
|
|
8
11
|
import { GlobalConfigStore } from "./global-config-store";
|
|
9
12
|
import { findRepoLocalLinkedRepository } from "./repo-local-registry";
|
|
@@ -16,11 +19,10 @@ import {
|
|
|
16
19
|
RepositoryNotFoundError,
|
|
17
20
|
WorkingRepoRequiredError,
|
|
18
21
|
} from "./types";
|
|
19
|
-
import { WorkingRepoStore } from "./working-repo-store";
|
|
20
22
|
|
|
21
23
|
export interface FrameworkContext {
|
|
22
24
|
configStore: ConfigStore;
|
|
23
|
-
workingRepoStore:
|
|
25
|
+
workingRepoStore: CompanionRegistryStore;
|
|
24
26
|
companionPath: string;
|
|
25
27
|
repository?: LinkedRepository;
|
|
26
28
|
contextKind: "env" | "working-repo" | "companion-root" | "hub";
|
|
@@ -52,7 +54,7 @@ function makeContext(
|
|
|
52
54
|
const configDir = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config");
|
|
53
55
|
return {
|
|
54
56
|
configStore: new ConfigStore(path.join(configDir, "framework.yaml")),
|
|
55
|
-
workingRepoStore: new
|
|
57
|
+
workingRepoStore: new CompanionRegistryStore(path.join(configDir, "registry.yaml")),
|
|
56
58
|
companionPath,
|
|
57
59
|
repository,
|
|
58
60
|
contextKind,
|
|
@@ -212,6 +214,9 @@ export async function resolveForLaunch(
|
|
|
212
214
|
|
|
213
215
|
if (resolution.match) {
|
|
214
216
|
const repository = (await findRepoLocalLinkedRepository(cwd)) ?? undefined;
|
|
217
|
+
if (repository) {
|
|
218
|
+
await backfillCompanionRegistration(resolution.match.companionPath, repository);
|
|
219
|
+
}
|
|
215
220
|
const context = await withResolvedHub(
|
|
216
221
|
makeContext(resolution.match.companionPath, "working-repo", repository),
|
|
217
222
|
);
|
|
@@ -224,6 +229,31 @@ export async function resolveForLaunch(
|
|
|
224
229
|
throw new WorkingRepoRequiredError();
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Self-heals repos linked before the companion-side registry write existed
|
|
234
|
+
* (or whose companion registry was reset): backfills the companion-side
|
|
235
|
+
* entry from the already-trusted repo-local pointer on every launch
|
|
236
|
+
* resolution. Best-effort — never blocks a launch.
|
|
237
|
+
*/
|
|
238
|
+
async function backfillCompanionRegistration(
|
|
239
|
+
companionPath: string,
|
|
240
|
+
repository: LinkedRepository,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
try {
|
|
243
|
+
const { repos } = await readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
|
|
244
|
+
const existing = repos.find((repo) => repo.id === repository.id);
|
|
245
|
+
if (existing?.path === repository.path) return;
|
|
246
|
+
|
|
247
|
+
await companionRootedStore(companionPath).registerRepository(repository);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error(
|
|
250
|
+
`${FRAMEWORK_NAME}: warning: failed to backfill companion registry for ${repository.id}: ${
|
|
251
|
+
error instanceof Error ? error.message : String(error)
|
|
252
|
+
}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
227
257
|
// Resolves context for `mate cap` commands. Unlike resolveForLaunch, this allows
|
|
228
258
|
// running from the companion directory itself — cap commands manage companion state
|
|
229
259
|
// and don't need a working-repo cwd. resolveForLaunch intentionally lacks this
|
|
@@ -2,6 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
+
import { pathIsDirectory } from "./repo-local-registry";
|
|
5
6
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
7
|
|
|
7
8
|
interface CompanionEntry {
|
|
@@ -50,4 +51,28 @@ export class GlobalConfigStore extends YamlFileStore<GlobalConfig> {
|
|
|
50
51
|
await this.save(config);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One-time/idempotent hygiene pass: drops entries whose path is not a
|
|
57
|
+
* directory on disk (stale test-fixture pollution, removed companions).
|
|
58
|
+
* Safe to call repeatedly — a clean registry is left unchanged and unsaved.
|
|
59
|
+
*/
|
|
60
|
+
async prune(): Promise<{ removed: string[] }> {
|
|
61
|
+
const config = await this.load();
|
|
62
|
+
const checked = await Promise.all(
|
|
63
|
+
config.companions.map(async (c) => ({ path: c.path, exists: await pathIsDirectory(c.path) })),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const removed: string[] = [];
|
|
67
|
+
const kept: CompanionEntry[] = [];
|
|
68
|
+
for (const entry of checked) {
|
|
69
|
+
if (entry.exists) kept.push({ path: entry.path });
|
|
70
|
+
else removed.push(entry.path);
|
|
71
|
+
}
|
|
72
|
+
if (removed.length === 0) return { removed };
|
|
73
|
+
|
|
74
|
+
config.companions = kept;
|
|
75
|
+
await this.save(config);
|
|
76
|
+
return { removed };
|
|
77
|
+
}
|
|
53
78
|
}
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
|
|
6
6
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
|
-
import {
|
|
7
|
+
import { ensureWorkingRepoLocalExcludes } from "../../tools/setup/working-repo-local-state";
|
|
8
8
|
import { YamlFileStore } from "./yaml-file-store";
|
|
9
9
|
import type { CompanionSource, LinkedRepository } from "./types";
|
|
10
10
|
|
|
@@ -20,7 +20,6 @@ export interface RepoLocalRegistry {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const repoLocalDirName = () => `.${FRAMEWORK_NAME}`;
|
|
23
|
-
const repoLocalExcludeEntry = () => `${repoLocalDirName()}/`;
|
|
24
23
|
const repoLocalScanSkipDirNames = () =>
|
|
25
24
|
new Set([
|
|
26
25
|
".git",
|
|
@@ -59,26 +58,7 @@ export class RepoLocalRegistryStore extends YamlFileStore<RepoLocalRegistry> {
|
|
|
59
58
|
* never needs a tracked `.gitignore` entry, mirroring `ensureTokensaveStoreExcluded`.
|
|
60
59
|
*/
|
|
61
60
|
export async function ensureRepoLocalDirExcluded(repoPath: string): Promise<void> {
|
|
62
|
-
|
|
63
|
-
if (!excludePath) return;
|
|
64
|
-
|
|
65
|
-
let existing = "";
|
|
66
|
-
try {
|
|
67
|
-
existing = await fs.readFile(excludePath, "utf8");
|
|
68
|
-
} catch {
|
|
69
|
-
// Fresh repos may not have created info/exclude yet.
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const lines = existing
|
|
73
|
-
.split(/\r?\n/)
|
|
74
|
-
.map((line) => line.trim())
|
|
75
|
-
.filter(Boolean);
|
|
76
|
-
|
|
77
|
-
if (lines.includes(repoLocalExcludeEntry())) return;
|
|
78
|
-
|
|
79
|
-
lines.push(repoLocalExcludeEntry());
|
|
80
|
-
await fs.mkdir(path.dirname(excludePath), { recursive: true });
|
|
81
|
-
await fs.writeFile(excludePath, lines.join("\n") + "\n", "utf8");
|
|
61
|
+
await ensureWorkingRepoLocalExcludes(repoPath);
|
|
82
62
|
}
|
|
83
63
|
|
|
84
64
|
/** Writes `.mate/config/framework.yaml` with `type: "working"` if it does not already exist. */
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { pathIsDirectory } from "./repo-local-registry";
|
|
4
|
+
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
5
|
+
import { GlobalConfigStore } from "./global-config-store";
|
|
6
|
+
import type { LinkedRepository } from "./types";
|
|
7
|
+
|
|
8
|
+
/** Envelope version for `mate workspace list --json`; bump on any breaking shape change. */
|
|
9
|
+
export const WORKSPACE_INVENTORY_SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export type CompanionInventoryHealth = "ready" | "missing" | "unreadable";
|
|
12
|
+
export type PairingInventoryHealth =
|
|
13
|
+
| "ready"
|
|
14
|
+
| "missing-companion"
|
|
15
|
+
| "missing-repository"
|
|
16
|
+
| "unreadable";
|
|
17
|
+
|
|
18
|
+
export interface WorkspaceInventoryCompanionEntry {
|
|
19
|
+
path: string;
|
|
20
|
+
health: CompanionInventoryHealth;
|
|
21
|
+
diagnostic?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceInventoryPairingEntry {
|
|
25
|
+
companionPath: string;
|
|
26
|
+
repository: LinkedRepository;
|
|
27
|
+
health: PairingInventoryHealth;
|
|
28
|
+
ambiguous: boolean;
|
|
29
|
+
diagnostic?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkspaceInventoryV1 {
|
|
33
|
+
schemaVersion: 1;
|
|
34
|
+
companions: WorkspaceInventoryCompanionEntry[];
|
|
35
|
+
pairings: WorkspaceInventoryPairingEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceInventoryDeps {
|
|
39
|
+
listCompanionPaths: () => Promise<string[]>;
|
|
40
|
+
isDirectory: (candidatePath: string) => Promise<boolean>;
|
|
41
|
+
readCompanionRegistry: typeof readCompanionRegistry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defaultWorkspaceInventoryDeps(
|
|
45
|
+
globalConfigStore = new GlobalConfigStore(),
|
|
46
|
+
): WorkspaceInventoryDeps {
|
|
47
|
+
return {
|
|
48
|
+
listCompanionPaths: () => globalConfigStore.list(),
|
|
49
|
+
isDirectory: pathIsDirectory,
|
|
50
|
+
readCompanionRegistry,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CompanionScanResult {
|
|
55
|
+
companionPath: string;
|
|
56
|
+
health: CompanionInventoryHealth;
|
|
57
|
+
diagnostic?: string;
|
|
58
|
+
repos: LinkedRepository[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function scanCompanion(
|
|
62
|
+
companionPath: string,
|
|
63
|
+
deps: WorkspaceInventoryDeps,
|
|
64
|
+
): Promise<CompanionScanResult> {
|
|
65
|
+
if (!(await deps.isDirectory(companionPath))) {
|
|
66
|
+
return { companionPath, health: "missing", repos: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const { repos } = await deps.readCompanionRegistry(companionPath);
|
|
71
|
+
return { companionPath, health: "ready", repos };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
74
|
+
return { companionPath, health: "ready", repos: [] };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
companionPath,
|
|
78
|
+
health: "unreadable",
|
|
79
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
80
|
+
repos: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read-only aggregate over the global companion registry and each
|
|
87
|
+
* companion's own linked-repository registry. Never migrates, repairs, or
|
|
88
|
+
* removes registry state — partial failures are reported as data.
|
|
89
|
+
*/
|
|
90
|
+
export async function collectWorkspaceInventory(
|
|
91
|
+
deps: WorkspaceInventoryDeps = defaultWorkspaceInventoryDeps(),
|
|
92
|
+
): Promise<WorkspaceInventoryV1> {
|
|
93
|
+
const companionPaths = [
|
|
94
|
+
...new Set((await deps.listCompanionPaths()).map((p) => path.resolve(p))),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const scans = await Promise.all(
|
|
98
|
+
companionPaths.map((companionPath) => scanCompanion(companionPath, deps)),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const repoPathsByCompanion = new Map<string, Set<string>>();
|
|
102
|
+
for (const scan of scans) {
|
|
103
|
+
for (const repo of scan.repos) {
|
|
104
|
+
const repoPath = path.resolve(repo.path);
|
|
105
|
+
const companions = repoPathsByCompanion.get(repoPath) ?? new Set<string>();
|
|
106
|
+
companions.add(scan.companionPath);
|
|
107
|
+
repoPathsByCompanion.set(repoPath, companions);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const repoExistence = new Map<string, boolean>();
|
|
112
|
+
await Promise.all(
|
|
113
|
+
[...repoPathsByCompanion.keys()].map(async (repoPath) => {
|
|
114
|
+
repoExistence.set(repoPath, await deps.isDirectory(repoPath));
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const companions: WorkspaceInventoryCompanionEntry[] = scans.map((scan) => ({
|
|
119
|
+
path: scan.companionPath,
|
|
120
|
+
health: scan.health,
|
|
121
|
+
...(scan.diagnostic ? { diagnostic: scan.diagnostic } : {}),
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
const pairings: WorkspaceInventoryPairingEntry[] = scans.flatMap((scan) =>
|
|
125
|
+
scan.repos.map((repo) => {
|
|
126
|
+
const repoPath = path.resolve(repo.path);
|
|
127
|
+
const ambiguous = (repoPathsByCompanion.get(repoPath)?.size ?? 0) > 1;
|
|
128
|
+
const health: PairingInventoryHealth = repoExistence.get(repoPath)
|
|
129
|
+
? "ready"
|
|
130
|
+
: "missing-repository";
|
|
131
|
+
return {
|
|
132
|
+
companionPath: scan.companionPath,
|
|
133
|
+
repository: { id: repo.id, path: repoPath },
|
|
134
|
+
health,
|
|
135
|
+
ambiguous,
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
companions.sort((a, b) => a.path.localeCompare(b.path));
|
|
141
|
+
pairings.sort(
|
|
142
|
+
(a, b) =>
|
|
143
|
+
a.companionPath.localeCompare(b.companionPath) ||
|
|
144
|
+
a.repository.id.localeCompare(b.repository.id) ||
|
|
145
|
+
a.repository.path.localeCompare(b.repository.path),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return { schemaVersion: WORKSPACE_INVENTORY_SCHEMA_VERSION, companions, pairings };
|
|
149
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
4
|
+
import { pathIsDirectory } from "./repo-local-registry";
|
|
5
|
+
import { MateError } from "./types";
|
|
6
|
+
import { writeWorkspaceDocument } from "./editor";
|
|
7
|
+
|
|
8
|
+
/** Envelope version for `mate workspace materialize --json`. */
|
|
9
|
+
export const MATERIALIZED_WORKSPACE_SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export interface MaterializedWorkspaceV1 {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
workspacePath: string;
|
|
14
|
+
folders: [string, string];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WorkspacePairingNotFoundError extends MateError {
|
|
18
|
+
constructor(repositoryId: string, companionPath: string) {
|
|
19
|
+
super(`mate: repository "${repositoryId}" is not linked by companion: ${companionPath}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WorkspaceRootUnavailableError extends MateError {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly root: "companion" | "repository",
|
|
26
|
+
readonly rootPath: string,
|
|
27
|
+
) {
|
|
28
|
+
super(`mate: ${root} path does not exist: ${rootPath}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MaterializeWorkspaceRequest {
|
|
33
|
+
repositoryId: string;
|
|
34
|
+
companionPath: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MaterializeWorkspaceDeps {
|
|
38
|
+
isDirectory: (candidatePath: string) => Promise<boolean>;
|
|
39
|
+
readCompanionRegistry: typeof readCompanionRegistry;
|
|
40
|
+
writeWorkspaceDocument: typeof writeWorkspaceDocument;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function defaultMaterializeWorkspaceDeps(): MaterializeWorkspaceDeps {
|
|
44
|
+
return { isDirectory: pathIsDirectory, readCompanionRegistry, writeWorkspaceDocument };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves an explicit companion-path/repository-id pairing, validates both
|
|
49
|
+
* roots exist, and writes the shared workspace document — without touching
|
|
50
|
+
* the registry or spawning an editor. Distinct from `injectEditorFolder`,
|
|
51
|
+
* which additionally launches/reloads an editor CLI.
|
|
52
|
+
*/
|
|
53
|
+
export async function materializeWorkspace(
|
|
54
|
+
request: MaterializeWorkspaceRequest,
|
|
55
|
+
deps: MaterializeWorkspaceDeps = defaultMaterializeWorkspaceDeps(),
|
|
56
|
+
): Promise<MaterializedWorkspaceV1> {
|
|
57
|
+
const companionPath = path.resolve(request.companionPath);
|
|
58
|
+
|
|
59
|
+
if (!(await deps.isDirectory(companionPath))) {
|
|
60
|
+
throw new WorkspaceRootUnavailableError("companion", companionPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { repos } = await deps.readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
|
|
64
|
+
const repository = repos.find((repo) => repo.id === request.repositoryId);
|
|
65
|
+
if (!repository) {
|
|
66
|
+
throw new WorkspacePairingNotFoundError(request.repositoryId, companionPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const repositoryPath = path.resolve(repository.path);
|
|
70
|
+
if (!(await deps.isDirectory(repositoryPath))) {
|
|
71
|
+
throw new WorkspaceRootUnavailableError("repository", repositoryPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { workspacePath, folders } = await deps.writeWorkspaceDocument(
|
|
75
|
+
companionPath,
|
|
76
|
+
repositoryPath,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { schemaVersion: MATERIALIZED_WORKSPACE_SCHEMA_VERSION, workspacePath, folders };
|
|
80
|
+
}
|