@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/hooks/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +70 -18
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +60 -8
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +87 -5
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +79 -60
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +5 -2
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-guidance.ts +20 -2
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +45 -9
- package/src/lib/orchestrator/adapters/claude.ts +241 -30
- package/src/lib/orchestrator/adapters/opencode.ts +72 -34
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +83 -1
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
- package/src/lib/orchestrator/projection-entries.ts +124 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +47 -34
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +503 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/working-repo-projection.ts +73 -160
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +222 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +30 -135
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +352 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +94 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
- package/src/tools/setup/engine.ts +152 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +50 -8
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +91 -14
- package/src/tools/setup.ts +11 -8
- package/wrappers/bin/openspec +42 -1
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- package/src/templates/report-assets/mermaid.min.js +0 -4376
|
@@ -5,8 +5,17 @@ import { resolveGitInfoExcludePath } from "./git-utils";
|
|
|
5
5
|
|
|
6
6
|
const MANAGED_BLOCK_START = "# mate managed: start";
|
|
7
7
|
const MANAGED_BLOCK_END = "# mate managed: end";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Documents no directory entry covers. `.opencode/` is per file: the repository's
|
|
10
|
+
* own files there stay visible; copies get the copies block.
|
|
11
|
+
*/
|
|
12
|
+
const CORE_EXCLUDE_ENTRIES = [
|
|
13
|
+
"/.mate/",
|
|
14
|
+
"/.claude/",
|
|
15
|
+
"/.opencode/opencode.json",
|
|
16
|
+
"/.agents/",
|
|
17
|
+
"/.mcp.json",
|
|
18
|
+
];
|
|
10
19
|
const LEGACY_CORE_EXCLUDE_ENTRIES = new Set([".mate/", ".claude/settings.local.json"]);
|
|
11
20
|
|
|
12
21
|
async function readExclude(excludePath: string): Promise<string> {
|
|
@@ -60,16 +69,43 @@ async function writeExclude(
|
|
|
60
69
|
return true;
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
/** The first `startMarker`…`endMarker` pair, inclusive; `null` when either is missing. */
|
|
73
|
+
function findBlock(
|
|
74
|
+
lines: string[],
|
|
75
|
+
startMarker: string,
|
|
76
|
+
endMarker: string,
|
|
77
|
+
): { start: number; end: number } | null {
|
|
78
|
+
const start = lines.findIndex((line) => line.trim() === startMarker);
|
|
79
|
+
if (start === -1) return null;
|
|
80
|
+
const end = lines.findIndex((line, index) => index > start && line.trim() === endMarker);
|
|
81
|
+
return end === -1 ? null : { start, end };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withoutLegacy(lines: string[]): string[] {
|
|
85
|
+
return withoutManagedBlocks(lines).filter(
|
|
86
|
+
(line) => !LEGACY_CORE_EXCLUDE_ENTRIES.has(line.trim()),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export async function ensureWorkingRepoLocalExcludes(repoPath: string): Promise<boolean> {
|
|
64
91
|
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
65
92
|
if (!excludePath) return false;
|
|
66
93
|
const existing = await readExclude(excludePath);
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
const current = contentLines(existing);
|
|
95
|
+
const block = [MANAGED_BLOCK_START, ...CORE_EXCLUDE_ENTRIES, MANAGED_BLOCK_END];
|
|
96
|
+
/**
|
|
97
|
+
* Rewritten where it stands, not moved to the end: a block that follows it,
|
|
98
|
+
* like the companion copies', would otherwise trade places with it on every
|
|
99
|
+
* pass and turn an unchanged wrap into a write.
|
|
100
|
+
*/
|
|
101
|
+
const managed = findBlock(current, MANAGED_BLOCK_START, MANAGED_BLOCK_END);
|
|
102
|
+
const lines = managed
|
|
103
|
+
? trimTrailingEmptyLines([
|
|
104
|
+
...withoutLegacy(current.slice(0, managed.start)),
|
|
105
|
+
...block,
|
|
106
|
+
...withoutLegacy(current.slice(managed.end + 1)),
|
|
107
|
+
])
|
|
108
|
+
: [...trimTrailingEmptyLines(withoutLegacy(current)), ...block];
|
|
73
109
|
return writeExclude(excludePath, existing, lines);
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -82,8 +118,16 @@ export async function reconcileWorkingRepoCapabilityExcludes(
|
|
|
82
118
|
if (!excludePath) return false;
|
|
83
119
|
const existing = await readExclude(excludePath);
|
|
84
120
|
const managed = new Set(managedEntries);
|
|
121
|
+
const desired = new Set(desiredEntries);
|
|
122
|
+
/**
|
|
123
|
+
* A desired line stays where it stands: re-appending it would trade places
|
|
124
|
+
* with a block written after it, like the companion copies', on every pass.
|
|
125
|
+
*/
|
|
85
126
|
const lines = trimTrailingEmptyLines(
|
|
86
|
-
contentLines(existing).filter((line) =>
|
|
127
|
+
contentLines(existing).filter((line) => {
|
|
128
|
+
const entry = line.trim();
|
|
129
|
+
return desired.has(entry) || !managed.has(entry);
|
|
130
|
+
}),
|
|
87
131
|
);
|
|
88
132
|
for (const entry of desiredEntries) {
|
|
89
133
|
if (!lines.some((line) => line.trim() === entry)) lines.push(entry);
|
|
@@ -114,10 +158,43 @@ export async function removeWorkingRepoLocalExcludes(repoPath: string): Promise<
|
|
|
114
158
|
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
115
159
|
if (!excludePath) return false;
|
|
116
160
|
const existing = await readExclude(excludePath);
|
|
117
|
-
const lines = trimTrailingEmptyLines(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
const lines = trimTrailingEmptyLines(withoutLegacy(contentLines(existing)));
|
|
162
|
+
return writeExclude(excludePath, existing, lines);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const COPIES_BLOCK_START = "# mate managed copies: start";
|
|
166
|
+
const COPIES_BLOCK_END = "# mate managed copies: end";
|
|
167
|
+
|
|
168
|
+
/** A literal repo-relative path as an anchored exclude pattern. */
|
|
169
|
+
function literalExcludeEntry(relativePath: string): string {
|
|
170
|
+
const escaped = relativePath.replace(/[\\*?[\]]/g, "\\$&");
|
|
171
|
+
return `/${escaped}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function withoutCopiesBlock(lines: string[]): string[] {
|
|
175
|
+
const block = findBlock(lines, COPIES_BLOCK_START, COPIES_BLOCK_END);
|
|
176
|
+
return block ? [...lines.slice(0, block.start), ...lines.slice(block.end + 1)] : lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One exclude line per owned companion copy, in a block of its own so the core
|
|
181
|
+
* block's rewrite leaves it alone. Per file rather than `/.opencode/`, so a file
|
|
182
|
+
* the repository adds there itself stays visible to Git.
|
|
183
|
+
*/
|
|
184
|
+
export async function reconcileWorkingRepoCopyExcludes(
|
|
185
|
+
repoPath: string,
|
|
186
|
+
relativePaths: readonly string[],
|
|
187
|
+
): Promise<boolean> {
|
|
188
|
+
const excludePath = await resolveGitInfoExcludePath(repoPath);
|
|
189
|
+
if (!excludePath) return false;
|
|
190
|
+
const existing = await readExclude(excludePath);
|
|
191
|
+
const lines = trimTrailingEmptyLines(withoutCopiesBlock(contentLines(existing)));
|
|
192
|
+
if (relativePaths.length > 0) {
|
|
193
|
+
lines.push(
|
|
194
|
+
COPIES_BLOCK_START,
|
|
195
|
+
...relativePaths.toSorted().map(literalExcludeEntry),
|
|
196
|
+
COPIES_BLOCK_END,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
122
199
|
return writeExclude(excludePath, existing, lines);
|
|
123
200
|
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -40,9 +40,9 @@ import {
|
|
|
40
40
|
buildSetupInstallationPlan,
|
|
41
41
|
executeSetupInstallationPlan,
|
|
42
42
|
renderRuntimeDocuments,
|
|
43
|
+
type RenderedRuntimeDocuments,
|
|
43
44
|
type SetupInstallationOutcome,
|
|
44
45
|
} from "./setup/engine";
|
|
45
|
-
import type { RenderedRuntimeDocument } from "../lib/orchestrator/projection-types";
|
|
46
46
|
|
|
47
47
|
export interface SetupInput {
|
|
48
48
|
allowedAgents?: string[];
|
|
@@ -61,7 +61,7 @@ export async function applySetupCompatibilities(
|
|
|
61
61
|
companionPath: string,
|
|
62
62
|
config: FrameworkConfig,
|
|
63
63
|
mode: "setup" | "sync",
|
|
64
|
-
plugins: PluginRegistration[] = getActiveDistribution().registry.
|
|
64
|
+
plugins: PluginRegistration[] = getActiveDistribution().registry.baseRegistrations(),
|
|
65
65
|
repoPath?: string,
|
|
66
66
|
scope: SetupScope = config.type === "hub" ? "hub" : "companion",
|
|
67
67
|
): Promise<SetupInstallationOutcome> {
|
|
@@ -73,15 +73,16 @@ export async function applySetupCompatibilities(
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* The active Agent Runtimes' documents for a Working Repository, rendered
|
|
76
|
-
* without a write. Runs no plugin phase: the working target reconciles
|
|
76
|
+
* without a write: base, plus the audience the checkout is wrapped for. Runs no plugin phase: the working target reconciles
|
|
77
77
|
* declarations only, so nothing is written to the companion on its behalf.
|
|
78
78
|
*/
|
|
79
79
|
export async function renderWorkingRuntimeDocuments(
|
|
80
80
|
companionPath: string,
|
|
81
81
|
config: FrameworkConfig,
|
|
82
82
|
repoPath: string,
|
|
83
|
-
plugins: PluginRegistration[] = getActiveDistribution().registry.
|
|
84
|
-
|
|
83
|
+
plugins: PluginRegistration[] = getActiveDistribution().registry.baseRegistrations(),
|
|
84
|
+
wrapped?: { audience: string; registrations: PluginRegistration[] },
|
|
85
|
+
): Promise<RenderedRuntimeDocuments> {
|
|
85
86
|
const plan = buildSetupInstallationPlan(config, plugins);
|
|
86
87
|
const ctx: SetupContext = {
|
|
87
88
|
companionPath,
|
|
@@ -91,7 +92,7 @@ export async function renderWorkingRuntimeDocuments(
|
|
|
91
92
|
repoPath,
|
|
92
93
|
target: "working",
|
|
93
94
|
};
|
|
94
|
-
return renderRuntimeDocuments(ctx, plugins, plan);
|
|
95
|
+
return renderRuntimeDocuments(ctx, plugins, plan, wrapped);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
export async function updateProjectGitignore(
|
|
@@ -99,7 +100,9 @@ export async function updateProjectGitignore(
|
|
|
99
100
|
config: FrameworkConfig,
|
|
100
101
|
): Promise<void> {
|
|
101
102
|
const ctx: SetupContext = { companionPath, config, mode: "sync", activeProviders: [] };
|
|
102
|
-
const plugins = getActiveDistribution()
|
|
103
|
+
const plugins = getActiveDistribution()
|
|
104
|
+
.registry.baseRegistrations()
|
|
105
|
+
.map((entry) => entry.plugin);
|
|
103
106
|
const entries = collectManagedGitignoreEntries(ctx, plugins);
|
|
104
107
|
await writeManagedGitignoreBlock(path.join(companionPath, ".gitignore"), FRAMEWORK_NAME, entries);
|
|
105
108
|
}
|
|
@@ -113,7 +116,7 @@ export async function syncCompanionFiles(
|
|
|
113
116
|
companionPath,
|
|
114
117
|
config,
|
|
115
118
|
"sync",
|
|
116
|
-
getActiveDistribution().registry.
|
|
119
|
+
getActiveDistribution().registry.baseRegistrations(),
|
|
117
120
|
repoPath,
|
|
118
121
|
);
|
|
119
122
|
}
|
package/wrappers/bin/openspec
CHANGED
|
@@ -30,10 +30,12 @@ _mate_same_dir() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
_companion="$MATE_ARTIFACT_PATH"
|
|
33
|
+
_repo="$MATE_REPO_PATH"
|
|
33
34
|
if [ -z "$_companion" ]; then
|
|
34
35
|
_projection="$(_mate_projection_file)"
|
|
35
36
|
if [ -n "$_projection" ]; then
|
|
36
37
|
_companion="$(_mate_projected "$_projection" MATE_ARTIFACT_PATH)"
|
|
38
|
+
_repo="$(_mate_projected "$_projection" MATE_REPO_PATH)"
|
|
37
39
|
_projected_wrapper_dir="$(_mate_projected "$_projection" MATE_WRAPPER_BIN_PATH)"
|
|
38
40
|
if [ -n "$_companion" ] && ! _mate_same_dir "$_projected_wrapper_dir" "$_this_dir"; then
|
|
39
41
|
{
|
|
@@ -92,4 +94,43 @@ if [ "$command" = "templates" ]; then
|
|
|
92
94
|
fi
|
|
93
95
|
fi
|
|
94
96
|
fi
|
|
95
|
-
|
|
97
|
+
|
|
98
|
+
_mate_is_under() {
|
|
99
|
+
case "$1/" in "$2"/*) return 0 ;; esac
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Moves _cwd to the Working Repository root when the caller is inside that
|
|
104
|
+
# repository, outside the companion, and the root carries Mate's pointer.
|
|
105
|
+
_mate_use_store_pointer() {
|
|
106
|
+
[ -n "$_repo" ] && [ -d "$_repo" ] || return 0
|
|
107
|
+
local repo_root here companion_root pointer first
|
|
108
|
+
repo_root="$(cd "$_repo" && pwd -P)"
|
|
109
|
+
here="$(pwd -P)"
|
|
110
|
+
companion_root="$(cd "$_companion" 2>/dev/null && pwd -P)"
|
|
111
|
+
_mate_is_under "$here" "$repo_root" || return 0
|
|
112
|
+
[ -n "$companion_root" ] && _mate_is_under "$here" "$companion_root" && return 0
|
|
113
|
+
pointer="$repo_root/openspec/config.yaml"
|
|
114
|
+
if [ -f "$pointer" ]; then
|
|
115
|
+
IFS= read -r first < "$pointer" || true
|
|
116
|
+
case "$first" in
|
|
117
|
+
"# generated by "*" — do not edit") _cwd="$repo_root" ;;
|
|
118
|
+
esac
|
|
119
|
+
elif [ ! -f "$repo_root/openspec/config.yml" ]; then
|
|
120
|
+
echo "openspec: no OpenSpec store pointer in $repo_root, so edits are scoped to the companion — run \`mate wrap\` to enable Working Repository edit roots" >&2
|
|
121
|
+
fi
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Commands that resolve an OpenSpec root run from the Working Repository root
|
|
125
|
+
# when it carries Mate's store pointer: OpenSpec then plans in the companion's
|
|
126
|
+
# store and names the repository as the edit root. The root, not the caller's
|
|
127
|
+
# cwd, so a nested openspec/ cannot win. Every other case — including
|
|
128
|
+
# templates, init and update, which do not resolve stores — stays on the
|
|
129
|
+
# companion, exactly as before.
|
|
130
|
+
_cwd="$_companion"
|
|
131
|
+
case "$command" in
|
|
132
|
+
new|status|instructions|list|show|validate|archive|doctor|context|schemas|view)
|
|
133
|
+
_mate_use_store_pointer
|
|
134
|
+
;;
|
|
135
|
+
esac
|
|
136
|
+
exec env -C "$_cwd" "$_real" "$@"
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
|
|
6
|
-
import { FRAMEWORK_NAME } from "../../framework";
|
|
7
|
-
import type { LinkedRepository } from "./types";
|
|
8
|
-
|
|
9
|
-
export function companionRegistryPath(companionPath: string): string {
|
|
10
|
-
return path.join(path.resolve(companionPath), `.${FRAMEWORK_NAME}`, "config", "registry.yaml");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface CompanionRegistryReadResult {
|
|
14
|
-
repos: LinkedRepository[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Parses a companion's `registry.yaml` without the writes {@link
|
|
19
|
-
* CompanionRegistryStore.load} performs (legacy-field migration, stale-pointer
|
|
20
|
-
* cleanup). Callers that must not mutate registry state on a read (e.g.
|
|
21
|
-
* inventory, materialization) use this instead. Throws ENOENT when the file
|
|
22
|
-
* doesn't exist; throws on unparsable YAML.
|
|
23
|
-
*/
|
|
24
|
-
export async function readCompanionRegistry(
|
|
25
|
-
companionPath: string,
|
|
26
|
-
): Promise<CompanionRegistryReadResult> {
|
|
27
|
-
const raw = await fs.readFile(companionRegistryPath(companionPath), "utf8");
|
|
28
|
-
const parsed = parse(raw) as { repos?: unknown } | null;
|
|
29
|
-
const rawRepos = Array.isArray(parsed?.repos) ? parsed.repos : [];
|
|
30
|
-
const repos = rawRepos
|
|
31
|
-
.filter(
|
|
32
|
-
(entry): entry is LinkedRepository =>
|
|
33
|
-
Boolean(entry) && typeof entry.id === "string" && typeof entry.path === "string",
|
|
34
|
-
)
|
|
35
|
-
.map(({ id, path: repoPath }) => ({ id, path: repoPath }));
|
|
36
|
-
return { repos };
|
|
37
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
|
|
6
|
-
import { readCompanionRegistry } from "./companion-registry-reader";
|
|
7
|
-
import type { GlobalConfigStore } from "./global-config-store";
|
|
8
|
-
import {
|
|
9
|
-
findRepoLocalRegistryFile,
|
|
10
|
-
pathIsDirectory,
|
|
11
|
-
repoLocalRegistryPath,
|
|
12
|
-
type RepoLocalCompanionPointer,
|
|
13
|
-
RepoLocalRegistryStore,
|
|
14
|
-
} from "./repo-local-registry";
|
|
15
|
-
|
|
16
|
-
export interface CompanionMatch {
|
|
17
|
-
companionPath: string;
|
|
18
|
-
repositoryId: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface CompanionResolutionFailure {
|
|
22
|
-
companionPath: string;
|
|
23
|
-
message: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface CompanionResolutionResult {
|
|
27
|
-
match: CompanionMatch | null;
|
|
28
|
-
/**
|
|
29
|
-
* All companions whose registry links the same repo path at the best
|
|
30
|
-
* (longest) match length as `match`. Populated with more than one entry
|
|
31
|
-
* only when the same working repo is linked from more than one distinct
|
|
32
|
-
* companion, so callers that must pick a single companion (e.g. launch
|
|
33
|
-
* commands) can detect the ambiguity and prompt the user instead of
|
|
34
|
-
* silently using whichever companion happened to be listed first.
|
|
35
|
-
*/
|
|
36
|
-
ambiguousMatches: CompanionMatch[];
|
|
37
|
-
failures: CompanionResolutionFailure[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class CompanionResolver {
|
|
41
|
-
constructor(private readonly globalConfigStore: GlobalConfigStore) {}
|
|
42
|
-
|
|
43
|
-
async resolve(cwd: string): Promise<CompanionMatch | null> {
|
|
44
|
-
return (await this.resolveWithDiagnostics(cwd, { logFailures: true })).match;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async resolveWithDiagnostics(
|
|
48
|
-
cwd: string,
|
|
49
|
-
options: { logFailures?: boolean } = {},
|
|
50
|
-
): Promise<CompanionResolutionResult> {
|
|
51
|
-
const resolvedCwd = path.resolve(cwd);
|
|
52
|
-
|
|
53
|
-
const repoLocalResult = await this.resolveFromRepoLocalRegistry(resolvedCwd, options);
|
|
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
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Fast path: reads the working repo's own `.mate/config/registry.yaml`
|
|
105
|
-
* before scanning every registered companion. Treats the file as the
|
|
106
|
-
* working repo's source of truth for companion pointers: existing
|
|
107
|
-
* directories are accepted immediately, stale paths are dropped, and
|
|
108
|
-
* absence of usable local data means the repo is not linked.
|
|
109
|
-
*/
|
|
110
|
-
private async resolveFromRepoLocalRegistry(
|
|
111
|
-
resolvedCwd: string,
|
|
112
|
-
options: { logFailures?: boolean },
|
|
113
|
-
): Promise<CompanionResolutionResult | null> {
|
|
114
|
-
const found = await findRepoLocalRegistryFile(resolvedCwd);
|
|
115
|
-
if (!found) return null;
|
|
116
|
-
|
|
117
|
-
let pointers: RepoLocalCompanionPointer[];
|
|
118
|
-
try {
|
|
119
|
-
const raw = await fs.readFile(found.registryPath, "utf8");
|
|
120
|
-
const parsed = parse(raw) as { companions?: unknown } | null;
|
|
121
|
-
if (!parsed || !Array.isArray(parsed.companions)) return null;
|
|
122
|
-
pointers = parsed.companions as RepoLocalCompanionPointer[];
|
|
123
|
-
} catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (pointers.length === 0) return null;
|
|
128
|
-
|
|
129
|
-
const survivors: RepoLocalCompanionPointer[] = [];
|
|
130
|
-
const validMatches: CompanionMatch[] = [];
|
|
131
|
-
let changed = false;
|
|
132
|
-
|
|
133
|
-
const checkedPointers = await Promise.all(
|
|
134
|
-
pointers.map(async (pointer) => {
|
|
135
|
-
const companionPath = path.resolve(pointer.path);
|
|
136
|
-
return { pointer, companionPath, exists: await pathIsDirectory(companionPath) };
|
|
137
|
-
}),
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
for (const { pointer, companionPath, exists } of checkedPointers) {
|
|
141
|
-
if (exists) {
|
|
142
|
-
survivors.push({
|
|
143
|
-
path: companionPath,
|
|
144
|
-
repositoryId: pointer.repositoryId,
|
|
145
|
-
source: pointer.source,
|
|
146
|
-
});
|
|
147
|
-
validMatches.push({ companionPath, repositoryId: pointer.repositoryId });
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
changed = true;
|
|
152
|
-
if (options.logFailures) {
|
|
153
|
-
console.error(
|
|
154
|
-
`mate: dropping stale repo-local companion pointer (no longer exists): ${companionPath}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (changed) {
|
|
160
|
-
const store = new RepoLocalRegistryStore(found.registryPath);
|
|
161
|
-
await store.save({ companions: survivors });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (validMatches.length === 0) return null;
|
|
165
|
-
|
|
166
|
-
const seenCompanionPaths = new Set<string>();
|
|
167
|
-
const ambiguousMatches = validMatches.filter((match) => {
|
|
168
|
-
if (seenCompanionPaths.has(match.companionPath)) return false;
|
|
169
|
-
seenCompanionPaths.add(match.companionPath);
|
|
170
|
-
return true;
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
match: ambiguousMatches[0]!,
|
|
175
|
-
ambiguousMatches: ambiguousMatches.length > 1 ? ambiguousMatches : [],
|
|
176
|
-
failures: [],
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { FRAMEWORK_NAME } from "../../framework";
|
|
5
|
-
import { CompanionRegistryStore } from "./companion-registry-store";
|
|
6
|
-
import { ConfigStore } from "./config-store";
|
|
7
|
-
import { pathIsDirectory, writeRepoLocalRegistryEntry } from "./repo-local-registry";
|
|
8
|
-
import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
|
|
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
|
-
}
|
|
18
|
-
|
|
19
|
-
export class CompanionStore {
|
|
20
|
-
constructor(
|
|
21
|
-
private readonly configStore = new ConfigStore(),
|
|
22
|
-
private readonly companionRegistryStore = new CompanionRegistryStore(),
|
|
23
|
-
) {}
|
|
24
|
-
|
|
25
|
-
async registerRepository(
|
|
26
|
-
repository: LinkedRepository,
|
|
27
|
-
options: { companionSource?: CompanionSource } = {},
|
|
28
|
-
): Promise<LinkedRepository> {
|
|
29
|
-
const stats = await fs.stat(repository.path).catch(() => null);
|
|
30
|
-
if (!stats?.isDirectory()) {
|
|
31
|
-
throw new ConfigError(
|
|
32
|
-
`Linked repository path must exist and be a directory: ${repository.path}`,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
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);
|
|
56
|
-
if (existing >= 0) {
|
|
57
|
-
survivors[existing] = repository;
|
|
58
|
-
} else {
|
|
59
|
-
survivors.push(repository);
|
|
60
|
-
}
|
|
61
|
-
working.repos = survivors;
|
|
62
|
-
await this.companionRegistryStore.save(working);
|
|
63
|
-
|
|
64
|
-
// Repo-local registry write is a cache, not the source of truth: the
|
|
65
|
-
// companion-side write above already succeeded, so a failure here (e.g. a
|
|
66
|
-
// read-only working tree) must not fail the overall link operation.
|
|
67
|
-
try {
|
|
68
|
-
const companionRoot = path.resolve(path.dirname(this.configStore.configPath), "..", "..");
|
|
69
|
-
await writeRepoLocalRegistryEntry(
|
|
70
|
-
repository.path,
|
|
71
|
-
companionRoot,
|
|
72
|
-
repository,
|
|
73
|
-
options.companionSource ?? "git",
|
|
74
|
-
);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
-
console.error(
|
|
78
|
-
`mate: warning: failed to write repo-local registry for ${repository.path}: ${message}`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return repository;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async getRepository(repositoryId: string): Promise<LinkedRepository | undefined> {
|
|
86
|
-
const working = await this.companionRegistryStore.load();
|
|
87
|
-
return working.repos.find((r) => r.id === repositoryId);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async listRepositories(): Promise<LinkedRepository[]> {
|
|
91
|
-
const working = await this.companionRegistryStore.load();
|
|
92
|
-
return working.repos;
|
|
93
|
-
}
|
|
94
|
-
}
|