@uniqbit/mate-core 0.15.1-canary.2 → 0.15.1-canary.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/package.json +1 -1
- package/src/cli/commands/install.ts +26 -0
- package/src/cli/main.ts +80 -40
- package/src/index.ts +15 -1
- package/src/lib/orchestrator/config-store.ts +19 -1
- package/src/lib/orchestrator/types.ts +23 -0
- package/src/lib/update-checker.ts +17 -2
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +1 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -0
- package/src/tools/setup/capabilities/tokensave.ts +18 -44
- package/src/tools/setup/dynamic-plugins/config-resolution.ts +101 -0
- package/src/tools/setup/dynamic-plugins/host.ts +51 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +156 -0
- package/src/tools/setup/dynamic-plugins/install.ts +171 -0
- package/src/tools/setup/dynamic-plugins/loader.ts +171 -0
- package/src/tools/setup/dynamic-plugins/paths.ts +39 -0
- package/src/tools/setup/dynamic-plugins/pin-store.ts +30 -0
- package/src/tools/setup/plugins/gitignore.ts +20 -3
- package/src/tools/setup.ts +15 -0
package/package.json
CHANGED
|
@@ -9,6 +9,23 @@ import {
|
|
|
9
9
|
resolveInstallContext,
|
|
10
10
|
} from "../../lib/install";
|
|
11
11
|
import { renderInstallExecution } from "../install-plan";
|
|
12
|
+
import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
|
|
13
|
+
import {
|
|
14
|
+
installDeclaredPlugins,
|
|
15
|
+
type PluginInstallResult,
|
|
16
|
+
} from "../../tools/setup/dynamic-plugins/install";
|
|
17
|
+
|
|
18
|
+
export function reportPluginInstallResults(results: PluginInstallResult[]): void {
|
|
19
|
+
for (const result of results) {
|
|
20
|
+
if (result.status === "failed") {
|
|
21
|
+
process.stderr.write(
|
|
22
|
+
`${frameworkCommandName()}: plugin "${result.package}" failed to install: ${result.error ?? "unknown error"}\n`,
|
|
23
|
+
);
|
|
24
|
+
} else if (result.status === "installed") {
|
|
25
|
+
console.log(` installed plugin ${result.package}@${result.resolvedVersion}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
|
|
13
30
|
function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): void {
|
|
14
31
|
if (plan.context.message) process.stderr.write(`${plan.context.message}\n\n`);
|
|
@@ -37,6 +54,15 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
|
|
|
37
54
|
process.exitCode = 1;
|
|
38
55
|
return false;
|
|
39
56
|
}
|
|
57
|
+
// Companion-declared plugins install first and hydrate into the registry
|
|
58
|
+
// before the plan is built, so one run goes install → load → plan and their
|
|
59
|
+
// own install requirements are part of the plan.
|
|
60
|
+
if (context.kind === "companion" && context.companionPath && context.config.plugins?.length) {
|
|
61
|
+
reportPluginInstallResults(
|
|
62
|
+
await installDeclaredPlugins(context.companionPath, context.config.plugins),
|
|
63
|
+
);
|
|
64
|
+
await hydrateDynamicPlugins({ companionPath: context.companionPath });
|
|
65
|
+
}
|
|
40
66
|
const plan = await inspectInstallPlan(buildInstallPlan(context));
|
|
41
67
|
const missing = plan.requirements.filter((item) => !item.satisfied);
|
|
42
68
|
let execution: Awaited<ReturnType<typeof renderInstallExecution>>;
|
package/src/cli/main.ts
CHANGED
|
@@ -18,29 +18,29 @@ import { runUpdateCommand } from "./commands/update";
|
|
|
18
18
|
import { runInstallCommand } from "./commands/install";
|
|
19
19
|
import { inspectInstallPreflight } from "../lib/install";
|
|
20
20
|
import { ensureUnambiguousCompanion } from "./commands/shared/companion-selection";
|
|
21
|
+
import { hydrateDynamicPlugins } from "../tools/setup/dynamic-plugins/hydrate";
|
|
21
22
|
import { findPluginCliCommand } from "./plugin-commands";
|
|
22
23
|
import { usage } from "./usage";
|
|
23
24
|
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
command === "-h" ||
|
|
32
|
-
(command === "companion" && (subcommand === "setup" || subcommand === "link"))
|
|
33
|
-
);
|
|
25
|
+
export interface GateNeeds {
|
|
26
|
+
/** Block the command while an enforced update is pending. */
|
|
27
|
+
updateGuard?: boolean;
|
|
28
|
+
/** Require an unambiguous companion (selection wizard on ambiguity) before dispatch. */
|
|
29
|
+
companion?: boolean;
|
|
30
|
+
/** Require a complete installation (install preflight) before dispatch. */
|
|
31
|
+
install?: boolean;
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
export interface MainDeps {
|
|
37
35
|
ensureUnambiguousCompanion: typeof ensureUnambiguousCompanion;
|
|
38
36
|
inspectInstallPreflight: typeof inspectInstallPreflight;
|
|
37
|
+
hydrateDynamicPlugins: typeof hydrateDynamicPlugins;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
const mainDeps: MainDeps = {
|
|
42
41
|
ensureUnambiguousCompanion,
|
|
43
42
|
inspectInstallPreflight,
|
|
43
|
+
hydrateDynamicPlugins,
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
export async function main(argv = process.argv, deps: MainDeps = mainDeps): Promise<void> {
|
|
@@ -51,78 +51,118 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
55
|
+
console.log(usage());
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Companion-declared plugins register before cap-command detection so
|
|
60
|
+
// their commands route like compiled-in ones (including MCP servers whose
|
|
61
|
+
// command is their own cap subcommand). Diagnostics stay on stderr; a
|
|
62
|
+
// missing or ambiguous companion makes this a no-op.
|
|
63
|
+
await deps.hydrateDynamicPlugins();
|
|
64
|
+
|
|
54
65
|
// Plugin commands (`mate cap <namespace> <command>`) own their stdout (an
|
|
55
66
|
// MCP server speaks JSON-RPC over it), so banners and background chatter
|
|
56
67
|
// are suppressed for them.
|
|
57
68
|
const isPluginCommand =
|
|
58
69
|
command === "cap" && findPluginCliCommand(subcommand, rest[0]) !== undefined;
|
|
59
|
-
if (!isPluginCommand) {
|
|
60
|
-
const updateStore = new UpdateStateStore();
|
|
61
|
-
scheduleBackgroundCheck(updateStore);
|
|
62
|
-
if (
|
|
63
|
-
!isInstallRecoveryCommand(command, subcommand) &&
|
|
64
|
-
(await enforceUpdateIfRequired(updateStore))
|
|
65
|
-
) {
|
|
66
|
-
process.exitCode = 1;
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
await showUpdateBannerIfAvailable(updateStore);
|
|
70
|
-
}
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
// Every dispatch case opens with a gate() call declaring what the command
|
|
72
|
+
// needs before it may run. Declared gates run in fixed order — update
|
|
73
|
+
// enforcement, companion selection, install preflight — and a blocked gate
|
|
74
|
+
// sets the exit code, so callers only need `if (!(await gate(...))) return`.
|
|
75
|
+
const gate = async (needs: GateNeeds): Promise<boolean> => {
|
|
76
|
+
if (!isPluginCommand) {
|
|
77
|
+
const updateStore = new UpdateStateStore();
|
|
78
|
+
scheduleBackgroundCheck(updateStore);
|
|
79
|
+
if (needs.updateGuard && (await enforceUpdateIfRequired(updateStore))) {
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
await showUpdateBannerIfAvailable(updateStore);
|
|
84
|
+
}
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
const preflight = await deps.inspectInstallPreflight();
|
|
84
|
-
if (!preflight.ok) {
|
|
85
|
-
console.error(
|
|
86
|
-
`${frameworkCommandName()}: ${preflight.reason ?? "installation is incomplete"}`,
|
|
87
|
-
);
|
|
88
|
-
console.error(`Run \`${frameworkCommandName()} install\` before continuing.`);
|
|
86
|
+
if (needs.companion && !(await deps.ensureUnambiguousCompanion())) {
|
|
89
87
|
process.exitCode = 1;
|
|
90
|
-
return;
|
|
88
|
+
return false;
|
|
91
89
|
}
|
|
92
|
-
|
|
90
|
+
|
|
91
|
+
if (needs.install) {
|
|
92
|
+
const preflight = await deps.inspectInstallPreflight();
|
|
93
|
+
if (!preflight.ok) {
|
|
94
|
+
console.error(
|
|
95
|
+
`${frameworkCommandName()}: ${preflight.reason ?? "installation is incomplete"}`,
|
|
96
|
+
);
|
|
97
|
+
console.error(`Run \`${frameworkCommandName()} install\` before continuing.`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
93
105
|
|
|
94
106
|
switch (command) {
|
|
95
107
|
case "install":
|
|
108
|
+
// Builds a companion-scoped context, so it needs an unambiguous
|
|
109
|
+
// companion — but must stay runnable when installation is incomplete.
|
|
110
|
+
if (!(await gate({ companion: true }))) return;
|
|
96
111
|
await runInstallCommand(argv.slice(3));
|
|
97
112
|
return;
|
|
98
113
|
case "artifact":
|
|
114
|
+
if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
|
|
99
115
|
await runArtifactCommand(subcommand, rest);
|
|
100
116
|
return;
|
|
101
117
|
case "companion":
|
|
118
|
+
switch (subcommand) {
|
|
119
|
+
// setup/link are recovery paths and must never be gated on the
|
|
120
|
+
// state they exist to repair.
|
|
121
|
+
case "setup":
|
|
122
|
+
case "link":
|
|
123
|
+
if (!(await gate({}))) return;
|
|
124
|
+
break;
|
|
125
|
+
// open/tui consume a companion context.
|
|
126
|
+
case "open":
|
|
127
|
+
case "tui":
|
|
128
|
+
if (!(await gate({ updateGuard: true, companion: true }))) return;
|
|
129
|
+
break;
|
|
130
|
+
// list and unknown subcommands (which fail inside the command).
|
|
131
|
+
default:
|
|
132
|
+
if (!(await gate({ updateGuard: true }))) return;
|
|
133
|
+
}
|
|
102
134
|
await runCompanionCommand(subcommand, rest);
|
|
103
135
|
return;
|
|
104
136
|
case "claude":
|
|
137
|
+
if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
|
|
105
138
|
await runLaunchClaudeCommand(argv.slice(3), { directPassthrough: true });
|
|
106
139
|
return;
|
|
107
140
|
case "opencode":
|
|
141
|
+
if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
|
|
108
142
|
await runLaunchOpenCodeCommand(argv.slice(3), { directPassthrough: true });
|
|
109
143
|
return;
|
|
110
144
|
case "report":
|
|
145
|
+
if (!(await gate({ updateGuard: true }))) return;
|
|
111
146
|
await runReportCommand(argv.slice(3));
|
|
112
147
|
return;
|
|
113
148
|
case "config":
|
|
149
|
+
if (!(await gate({ updateGuard: true }))) return;
|
|
114
150
|
await runConfigCommand(argv.slice(3));
|
|
115
151
|
return;
|
|
116
152
|
case "doctor":
|
|
153
|
+
if (!(await gate({}))) return;
|
|
117
154
|
await runDoctorCommand(argv.slice(3));
|
|
118
155
|
return;
|
|
119
156
|
case "cap":
|
|
157
|
+
if (!(await gate({ updateGuard: true, companion: true, install: true }))) return;
|
|
120
158
|
await runCapCommand(subcommand, rest);
|
|
121
159
|
return;
|
|
122
160
|
case "update":
|
|
161
|
+
if (!(await gate({}))) return;
|
|
123
162
|
await runUpdateCommand(argv.slice(3));
|
|
124
163
|
return;
|
|
125
164
|
default:
|
|
165
|
+
// Unknown commands fail fast: no case matched, so no gate ever ran.
|
|
126
166
|
console.error(`Unknown command: ${command}`);
|
|
127
167
|
console.error(usage());
|
|
128
168
|
process.exitCode = 1;
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,16 @@ export {
|
|
|
21
21
|
type NormalizedRegistration,
|
|
22
22
|
} from "./tools/setup/registry";
|
|
23
23
|
export { ensureCapabilityEnabled, type EnsureCapabilityEnabledDeps } from "./cli/plugin-commands";
|
|
24
|
+
// Dynamic plugin authors: import from this package as TYPES ONLY. Runtime
|
|
25
|
+
// imports resolve a second, uninitialized copy of mate-core inside the
|
|
26
|
+
// plugin's own node_modules; all runtime behavior flows through PluginHost.
|
|
27
|
+
export {
|
|
28
|
+
createPluginHost,
|
|
29
|
+
PLUGIN_API_VERSION,
|
|
30
|
+
SUPPORTED_PLUGIN_API_VERSIONS,
|
|
31
|
+
type CreatePlugin,
|
|
32
|
+
type PluginHost,
|
|
33
|
+
} from "./tools/setup/dynamic-plugins/host";
|
|
24
34
|
export type {
|
|
25
35
|
CapabilityPlugin,
|
|
26
36
|
InstructionsService,
|
|
@@ -36,4 +46,8 @@ export type {
|
|
|
36
46
|
SetupContext,
|
|
37
47
|
TemplatesService,
|
|
38
48
|
} from "./tools/setup/plugin";
|
|
39
|
-
export type {
|
|
49
|
+
export type {
|
|
50
|
+
FrameworkConfig,
|
|
51
|
+
PluginDeclaration,
|
|
52
|
+
PluginDeclarationPolicy,
|
|
53
|
+
} from "./lib/orchestrator/types";
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
4
|
import { getDefaultSetupSelections } from "./setup-compatibilities";
|
|
5
5
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
|
-
import { type FrameworkConfig } from "./types";
|
|
6
|
+
import { ConfigError, type FrameworkConfig } from "./types";
|
|
7
7
|
|
|
8
8
|
export const RTK_CAPABILITY_SPLIT_MIGRATION = "rtk-capability-split-v1";
|
|
9
9
|
|
|
@@ -54,6 +54,23 @@ export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkCon
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export const PLUGIN_DECLARATION_POLICIES = ["default", "optional"] as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates the `plugins` list of a loaded config. Declared plugins may not
|
|
61
|
+
* claim `required` — required-ness is a distribution prerogative.
|
|
62
|
+
*/
|
|
63
|
+
export function validatePluginDeclarations(config: FrameworkConfig): void {
|
|
64
|
+
for (const declaration of config.plugins ?? []) {
|
|
65
|
+
const policy = declaration.policy as string | undefined;
|
|
66
|
+
if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
|
|
67
|
+
throw new ConfigError(
|
|
68
|
+
`plugins entry "${declaration.package}": policy "${policy}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
58
75
|
constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
|
|
59
76
|
super(path.resolve(configPath));
|
|
@@ -61,6 +78,7 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
|
61
78
|
|
|
62
79
|
override async load(): Promise<FrameworkConfig> {
|
|
63
80
|
const merged = mergeWithDefaults(await super.load());
|
|
81
|
+
validatePluginDeclarations(merged);
|
|
64
82
|
const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
|
|
65
83
|
const config = migrateRtkCapabilitySplit(merged);
|
|
66
84
|
if (needsMigration) await this.save(config);
|
|
@@ -81,11 +81,34 @@ export interface CapabilityConfig {
|
|
|
81
81
|
*/
|
|
82
82
|
export type EngineConstraints = Record<string, string>;
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Registration policy for a companion-declared plugin. `required` is a
|
|
86
|
+
* distribution prerogative and is rejected for declared plugins.
|
|
87
|
+
*/
|
|
88
|
+
export type PluginDeclarationPolicy = "default" | "optional";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A companion-declared npm plugin: installed by setup/install into
|
|
92
|
+
* `.mate/dependencies/plugins/` and loaded on every invocation. Declaration
|
|
93
|
+
* registers the plugin; enablement stays in the `capabilities` list.
|
|
94
|
+
*/
|
|
95
|
+
export interface PluginDeclaration {
|
|
96
|
+
/** npm package name (e.g. `@acme/custom-plugin`). */
|
|
97
|
+
package: string;
|
|
98
|
+
/** Exact version, semver range, or the literal `latest`. */
|
|
99
|
+
version: string;
|
|
100
|
+
/** Registration policy; absent means `optional`. */
|
|
101
|
+
policy?: PluginDeclarationPolicy;
|
|
102
|
+
/** Opaque plugin parameters, passed to the plugin factory after resolution. */
|
|
103
|
+
config?: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
export interface FrameworkConfig {
|
|
85
107
|
type?: "working" | "companion";
|
|
86
108
|
git?: GitModeProfile;
|
|
87
109
|
profiles: Record<string, PolicyProfile>;
|
|
88
110
|
capabilities?: CapabilityConfig[];
|
|
111
|
+
plugins?: PluginDeclaration[];
|
|
89
112
|
migrations?: string[];
|
|
90
113
|
cliTools?: CliToolConfig[];
|
|
91
114
|
packageManagers?: string[];
|
|
@@ -20,9 +20,24 @@ export const updateCheckerDeps = {
|
|
|
20
20
|
toIsoString: () => new Date().toISOString(),
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const updateStateFileSlug = (packageName: string): string =>
|
|
24
|
+
packageName.replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every distribution built on mate-core shares `~/.mate`, but each checks its
|
|
28
|
+
* own update package — so the cached state is scoped per package. A shared
|
|
29
|
+
* file would let the default distribution's public-npm check poison a custom
|
|
30
|
+
* distribution's banner with a version it can never install.
|
|
31
|
+
*/
|
|
23
32
|
export class UpdateStateStore extends YamlFileStore<UpdateState> {
|
|
24
|
-
constructor() {
|
|
25
|
-
super(
|
|
33
|
+
constructor(packageName: string = getUpdateConfig().packageName) {
|
|
34
|
+
super(
|
|
35
|
+
path.join(
|
|
36
|
+
os.homedir(),
|
|
37
|
+
`.${FRAMEWORK_NAME}`,
|
|
38
|
+
`update-state-${updateStateFileSlug(packageName)}.yaml`,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
protected onMissing(): Promise<UpdateState> {
|
|
@@ -272,4 +272,5 @@ apply:
|
|
|
272
272
|
tracks: tasks.md
|
|
273
273
|
instruction: |
|
|
274
274
|
Read context files and work through pending tasks only within the proposal's affected Areas. Mark complete as you go.
|
|
275
|
+
When reporting information to me, be extremely concise and sacrifice grammar for the sake of concision. Apply this same preference to JSDoc.
|
|
275
276
|
Pause if you hit blockers or need clarification.
|
|
@@ -8,6 +8,7 @@ This block is kept for AGENTS.md compatibility and must not restate that policy.
|
|
|
8
8
|
## Agent Notes
|
|
9
9
|
|
|
10
10
|
- Use absolute paths when a tool needs a file path. Do not create literal `$MATE_REPO_PATH` or `$MATE_ARTIFACT_PATH` directories.
|
|
11
|
+
- When reporting information to me, be extremely concise and sacrifice grammar for the sake of concision. Apply this same preference to JSDoc
|
|
11
12
|
- Never add a `Co-Authored-By: <model>` trailer or model-attribution footer to commit messages.
|
|
12
13
|
- Never commit, push, or open a pull request in the working repo (`$MATE_REPO_PATH`) unless the user explicitly asks for it.
|
|
13
14
|
|
|
@@ -8,6 +8,7 @@ This block is kept for Claude/AGENTS.md compatibility and must not restate that
|
|
|
8
8
|
## Claude Notes
|
|
9
9
|
|
|
10
10
|
- Use absolute paths when a tool needs a file path. Do not create literal `$MATE_REPO_PATH` or `$MATE_ARTIFACT_PATH` directories.
|
|
11
|
+
- When reporting information to me, be extremely concise and sacrifice grammar for the sake of concision. Apply this same preference to JSDoc.
|
|
11
12
|
- Claude Code receives `$MATE_ARTIFACT_PATH` via `--add-dir`; if `@` autocomplete does not show companion artifacts, reference them by absolute path.
|
|
12
13
|
- The root `CLAUDE.md` in the companion repo is intentional and loaded by Mate. Do not create another project-level `CLAUDE.md` in the working repo unless the user explicitly asks for one.
|
|
13
14
|
- Never add a `Co-Authored-By: <model>` trailer or model-attribution footer to commit messages.
|
|
@@ -16,10 +16,6 @@ const TOKENSAVE_CARGO_INSTALL_CMD = "cargo install tokensave";
|
|
|
16
16
|
const TOKENSAVE_SCOOP_INSTALL_CMD =
|
|
17
17
|
"scoop bucket add tokensave https://github.com/aovestdipaperino/scoop-bucket && scoop install tokensave";
|
|
18
18
|
|
|
19
|
-
function getTokensaveConfigPath(): string {
|
|
20
|
-
return path.join(process.env.HOME ?? "", ".tokensave", "config.toml");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
interface CompanionOpenCodeSettings {
|
|
24
20
|
mcp?: Record<string, unknown>;
|
|
25
21
|
[key: string]: unknown;
|
|
@@ -132,47 +128,23 @@ async function tokensaveInstalled(repoPath: string): Promise<boolean> {
|
|
|
132
128
|
return result.ok;
|
|
133
129
|
}
|
|
134
130
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function writeTokensaveConfig(content: string): Promise<void> {
|
|
144
|
-
await fs.mkdir(path.dirname(getTokensaveConfigPath()), { recursive: true });
|
|
145
|
-
await fs.writeFile(getTokensaveConfigPath(), content, "utf8");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function updateTokensaveInstalledAgents(providers: string[]): Promise<void> {
|
|
149
|
-
const content = await readTokensaveConfig();
|
|
150
|
-
if (!content) return;
|
|
151
|
-
|
|
131
|
+
// Global agent integration (MCP entry, session hooks, wildcard permission grant, and
|
|
132
|
+
// tokensave's own config bookkeeping) is owned by tokensave's installer — Mate never
|
|
133
|
+
// hand-edits ~/.tokensave/config.toml. Runs in setup mode only; the installer is
|
|
134
|
+
// idempotent, so re-running on every setup doubles as repair for stale global state.
|
|
135
|
+
function installTokensaveAgentIntegrations(providers: string[], cwd: string): void {
|
|
152
136
|
const agents = providers.filter((p) => TOKENSAVE_SUPPORTED_AGENTS.has(p)).sort();
|
|
153
|
-
if (agents.length === 0) return;
|
|
154
|
-
|
|
155
|
-
const existingLine = content.match(/^installed_agents\s*=\s*\[([^\]]*)\]/m);
|
|
156
|
-
if (!existingLine) return;
|
|
157
|
-
|
|
158
|
-
const existingStr = existingLine[1];
|
|
159
|
-
const existing = new Set(
|
|
160
|
-
existingStr
|
|
161
|
-
.split(",")
|
|
162
|
-
.map((s) => s.trim().replace(/^"|"$/g, ""))
|
|
163
|
-
.filter(Boolean),
|
|
164
|
-
);
|
|
165
|
-
|
|
166
137
|
for (const agent of agents) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
138
|
+
const result = tokensaveDeps.run(
|
|
139
|
+
["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
|
|
140
|
+
cwd,
|
|
141
|
+
);
|
|
142
|
+
if (!result.ok) {
|
|
143
|
+
const detail = result.stderr.trim();
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`tokensave: \`tokensave install --agent ${agent}\` failed${detail ? `: ${detail}` : ""} - continuing without global ${agent} integration\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
176
148
|
}
|
|
177
149
|
}
|
|
178
150
|
|
|
@@ -329,7 +301,9 @@ export function createTokensavePlugin(): CapabilityPlugin {
|
|
|
329
301
|
if (!(await ensureTokensaveInstalled(targetPath))) {
|
|
330
302
|
return;
|
|
331
303
|
}
|
|
332
|
-
|
|
304
|
+
if (ctx.mode === "setup") {
|
|
305
|
+
installTokensaveAgentIntegrations(ctx.activeProviders, targetPath);
|
|
306
|
+
}
|
|
333
307
|
},
|
|
334
308
|
async teardown(ctx) {
|
|
335
309
|
if (!ctx.repoPath) return;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
|
|
5
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
6
|
+
import { pluginLocalOverridesPath } from "./paths";
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deep-merges a local override over a committed plugin config: objects merge
|
|
14
|
+
* recursively, arrays and scalars from the override replace the committed
|
|
15
|
+
* value. Either side may be absent.
|
|
16
|
+
*/
|
|
17
|
+
export function deepMergePluginConfig(committed: unknown, override: unknown): unknown {
|
|
18
|
+
if (override === undefined) return committed;
|
|
19
|
+
if (!isRecord(committed) || !isRecord(override)) return override;
|
|
20
|
+
const merged: Record<string, unknown> = { ...committed };
|
|
21
|
+
for (const [key, value] of Object.entries(override)) {
|
|
22
|
+
merged[key] = deepMergePluginConfig(committed[key], value);
|
|
23
|
+
}
|
|
24
|
+
return merged;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
28
|
+
|
|
29
|
+
export interface InterpolationResult {
|
|
30
|
+
value: unknown;
|
|
31
|
+
/** Referenced-but-unset variable names, in first-seen order. */
|
|
32
|
+
missing: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replaces `${VAR}` occurrences in every string value with the corresponding
|
|
37
|
+
* environment value, recursively through objects and arrays. Unset variables
|
|
38
|
+
* are collected instead of substituted.
|
|
39
|
+
*/
|
|
40
|
+
export function interpolateEnvVars(
|
|
41
|
+
value: unknown,
|
|
42
|
+
env: Record<string, string | undefined>,
|
|
43
|
+
): InterpolationResult {
|
|
44
|
+
const missing: string[] = [];
|
|
45
|
+
const visit = (node: unknown): unknown => {
|
|
46
|
+
if (typeof node === "string") {
|
|
47
|
+
return node.replace(VAR_PATTERN, (placeholder, name: string) => {
|
|
48
|
+
const resolved = env[name];
|
|
49
|
+
if (resolved === undefined) {
|
|
50
|
+
if (!missing.includes(name)) missing.push(name);
|
|
51
|
+
return placeholder;
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(node)) return node.map(visit);
|
|
57
|
+
if (isRecord(node)) {
|
|
58
|
+
return Object.fromEntries(Object.entries(node).map(([key, entry]) => [key, visit(entry)]));
|
|
59
|
+
}
|
|
60
|
+
return node;
|
|
61
|
+
};
|
|
62
|
+
return { value: visit(value), missing };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the gitignored `.mate/config/plugins.local.yaml`, a `plugins:` map
|
|
67
|
+
* keyed by package name. A missing or malformed file yields no overrides.
|
|
68
|
+
*/
|
|
69
|
+
export async function readLocalPluginOverrides(
|
|
70
|
+
companionPath: string,
|
|
71
|
+
): Promise<Record<string, unknown>> {
|
|
72
|
+
try {
|
|
73
|
+
const raw = await fs.readFile(pluginLocalOverridesPath(companionPath), "utf8");
|
|
74
|
+
const parsed = parse(raw) as unknown;
|
|
75
|
+
if (!isRecord(parsed) || !isRecord(parsed.plugins)) return {};
|
|
76
|
+
return parsed.plugins;
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type EffectivePluginConfigResult =
|
|
83
|
+
| { ok: true; config: unknown }
|
|
84
|
+
| { ok: false; missingVariables: string[] };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Computes the config passed to a plugin factory: committed `config` block,
|
|
88
|
+
* deep-merged with the local override entry for the package, then `${VAR}`
|
|
89
|
+
* interpolated from the environment. Resolved values are never written back.
|
|
90
|
+
*/
|
|
91
|
+
export async function resolveEffectivePluginConfig(
|
|
92
|
+
companionPath: string,
|
|
93
|
+
declaration: PluginDeclaration,
|
|
94
|
+
env: Record<string, string | undefined> = process.env,
|
|
95
|
+
): Promise<EffectivePluginConfigResult> {
|
|
96
|
+
const overrides = await readLocalPluginOverrides(companionPath);
|
|
97
|
+
const merged = deepMergePluginConfig(declaration.config, overrides[declaration.package]);
|
|
98
|
+
const { value, missing } = interpolateEnvVars(merged, env);
|
|
99
|
+
if (missing.length > 0) return { ok: false, missingVariables: missing };
|
|
100
|
+
return { ok: true, config: value };
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getActiveDistribution } from "../../../distribution";
|
|
2
|
+
import { ensureCapabilityEnabled } from "../../../cli/plugin-commands";
|
|
3
|
+
import type { CapabilityPlugin, Plugin } from "../plugin";
|
|
4
|
+
|
|
5
|
+
/** Host API version this core constructs. Grows only on breaking changes. */
|
|
6
|
+
export const PLUGIN_API_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
/** Plugin API versions this core can load. */
|
|
9
|
+
export const SUPPORTED_PLUGIN_API_VERSIONS: readonly number[] = [PLUGIN_API_VERSION];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The sole runtime surface a dynamic plugin receives into the running core.
|
|
13
|
+
*
|
|
14
|
+
* Dynamic plugin packages must not import runtime values from
|
|
15
|
+
* `@uniqbit/mate-core` — a dynamically imported plugin would resolve a second
|
|
16
|
+
* bundled copy whose module-level state was never initialized. Type-only
|
|
17
|
+
* imports are safe (erased at build); all behavior flows through this host.
|
|
18
|
+
* The surface evolves additively within an `apiVersion`.
|
|
19
|
+
*/
|
|
20
|
+
export interface PluginHost {
|
|
21
|
+
apiVersion: typeof PLUGIN_API_VERSION;
|
|
22
|
+
/** Identity of the running distribution. */
|
|
23
|
+
distribution: { name: string; version: string };
|
|
24
|
+
/**
|
|
25
|
+
* Verifies the named capability is enabled for the active companion,
|
|
26
|
+
* reporting guidance and setting a failing exit code when it is not.
|
|
27
|
+
*/
|
|
28
|
+
ensureCapabilityEnabled(name: string): Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The factory a dynamic plugin package exports — as the default export, or as
|
|
33
|
+
* a named `createPlugin` export when no default function exists. Called once
|
|
34
|
+
* per invocation with the plugin's effective config (committed block merged
|
|
35
|
+
* with local overrides, env-interpolated). Config validation is the plugin's
|
|
36
|
+
* responsibility: throw with a descriptive message to reject it.
|
|
37
|
+
*/
|
|
38
|
+
export type CreatePlugin = (config: unknown, host: PluginHost) => Plugin | CapabilityPlugin;
|
|
39
|
+
|
|
40
|
+
/** Builds the host bound to the running core instance. */
|
|
41
|
+
export function createPluginHost(): PluginHost {
|
|
42
|
+
const distribution = getActiveDistribution();
|
|
43
|
+
return {
|
|
44
|
+
apiVersion: PLUGIN_API_VERSION,
|
|
45
|
+
distribution: {
|
|
46
|
+
name: distribution.config.name,
|
|
47
|
+
version: distribution.config.version,
|
|
48
|
+
},
|
|
49
|
+
ensureCapabilityEnabled: (name) => ensureCapabilityEnabled(name),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
|
|
6
|
+
import { getActiveDistribution } from "../../../distribution";
|
|
7
|
+
import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
|
|
8
|
+
import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
|
|
9
|
+
import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
|
|
10
|
+
import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
|
|
11
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
12
|
+
import type { PluginRegistry } from "../registry";
|
|
13
|
+
import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
|
|
14
|
+
import { PluginPinStore } from "./pin-store";
|
|
15
|
+
|
|
16
|
+
// Packages already registered in this process; re-hydration (e.g. right after
|
|
17
|
+
// an install inside the same run) only picks up plugins that failed before.
|
|
18
|
+
const hydratedPackages = new Set<string>();
|
|
19
|
+
|
|
20
|
+
export function resetDynamicPluginHydration(): void {
|
|
21
|
+
hydratedPackages.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HydrateDynamicPluginsDeps extends DynamicPluginLoadDeps {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** Explicit companion context; skips quiet resolution. */
|
|
27
|
+
companionPath?: string;
|
|
28
|
+
registry?: PluginRegistry;
|
|
29
|
+
warn?: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function commandName(): string {
|
|
33
|
+
try {
|
|
34
|
+
return frameworkCommandName();
|
|
35
|
+
} catch {
|
|
36
|
+
return FRAMEWORK_NAME;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Quiet variant of companion resolution: never prompts, never errors. An
|
|
46
|
+
* ambiguous or absent companion means hydration is a no-op —
|
|
47
|
+
* `ensureUnambiguousCompanion` later in `main()` stays the authoritative,
|
|
48
|
+
* user-facing resolution.
|
|
49
|
+
*/
|
|
50
|
+
async function resolveCompanionQuietly(
|
|
51
|
+
cwd: string,
|
|
52
|
+
env: Record<string, string | undefined>,
|
|
53
|
+
): Promise<string | null> {
|
|
54
|
+
const pinned = env.MATE_ARTIFACT_PATH;
|
|
55
|
+
if (pinned) return path.resolve(pinned);
|
|
56
|
+
const resolver = new CompanionResolver(new GlobalConfigStore());
|
|
57
|
+
const resolution = await resolver.resolveWithDiagnostics(path.resolve(cwd));
|
|
58
|
+
if (resolution.ambiguousMatches.length > 1) return null;
|
|
59
|
+
if (resolution.match) return resolution.match.companionPath;
|
|
60
|
+
try {
|
|
61
|
+
// Running inside a companion directory itself.
|
|
62
|
+
await fs.access(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
|
|
63
|
+
return path.resolve(cwd);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Raw read of the companion's `plugins:` list. Deliberately avoids
|
|
71
|
+
* `ConfigStore.load()` — hydration runs on every invocation and must never
|
|
72
|
+
* create or migrate config files as a side effect.
|
|
73
|
+
*/
|
|
74
|
+
async function readDeclarations(companionPath: string): Promise<unknown[]> {
|
|
75
|
+
try {
|
|
76
|
+
const raw = await fs.readFile(
|
|
77
|
+
path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
const parsed = parse(raw) as unknown;
|
|
81
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
|
|
82
|
+
return parsed.plugins;
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateDeclaration(entry: unknown): { declaration?: PluginDeclaration; error?: string } {
|
|
89
|
+
if (!isRecord(entry) || typeof entry.package !== "string" || typeof entry.version !== "string") {
|
|
90
|
+
return { error: `ignoring malformed plugins entry: ${JSON.stringify(entry)}` };
|
|
91
|
+
}
|
|
92
|
+
const policy = entry.policy;
|
|
93
|
+
if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
|
|
94
|
+
return {
|
|
95
|
+
error: `plugin "${entry.package}": policy "${String(policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { declaration: entry as unknown as PluginDeclaration };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Registers the companion's declared plugins into the active registry —
|
|
103
|
+
* compiled-in plugins first (they are already registered), declared order
|
|
104
|
+
* after. Runs before cap-command detection on every invocation. No-op
|
|
105
|
+
* without a companion or `plugins:` entries; all diagnostics go to stderr;
|
|
106
|
+
* never throws.
|
|
107
|
+
*/
|
|
108
|
+
export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}): Promise<void> {
|
|
109
|
+
const warn =
|
|
110
|
+
deps.warn ?? ((message: string) => process.stderr.write(`${commandName()}: ${message}\n`));
|
|
111
|
+
try {
|
|
112
|
+
const env = deps.env ?? process.env;
|
|
113
|
+
const companionPath =
|
|
114
|
+
deps.companionPath ?? (await resolveCompanionQuietly(deps.cwd ?? process.cwd(), env));
|
|
115
|
+
if (!companionPath) return;
|
|
116
|
+
|
|
117
|
+
const entries = await readDeclarations(companionPath);
|
|
118
|
+
if (entries.length === 0) return;
|
|
119
|
+
|
|
120
|
+
const pins = (await new PluginPinStore(companionPath).load()).plugins;
|
|
121
|
+
const registry = deps.registry ?? getActiveDistribution().registry;
|
|
122
|
+
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const { declaration, error } = validateDeclaration(entry);
|
|
125
|
+
if (!declaration) {
|
|
126
|
+
if (error) warn(error);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (hydratedPackages.has(declaration.package)) continue;
|
|
130
|
+
|
|
131
|
+
// oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
|
|
132
|
+
const result = await loadDynamicPlugin(companionPath, declaration, pins, deps);
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
warn(result.warning);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const namespace = result.plugin.cliNamespace ?? result.plugin.id;
|
|
139
|
+
const holder = registry
|
|
140
|
+
.getAll()
|
|
141
|
+
.find((existing) => (existing.cliNamespace ?? existing.id) === namespace);
|
|
142
|
+
if (holder) {
|
|
143
|
+
warn(
|
|
144
|
+
`plugin "${declaration.package}" ("${result.plugin.id}") claims cap namespace "${namespace}" already registered by "${holder.id}"; first registration wins.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
registry.register({ plugin: result.plugin, policy: declaration.policy ?? "optional" });
|
|
149
|
+
hydratedPackages.add(declaration.package);
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
warn(
|
|
153
|
+
`dynamic plugin hydration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
6
|
+
import { pluginInstallDir, pluginPackageRoot } from "./paths";
|
|
7
|
+
import { PluginPinStore, type PluginPin } from "./pin-store";
|
|
8
|
+
|
|
9
|
+
export type BunInstallRunner = (
|
|
10
|
+
installDir: string,
|
|
11
|
+
) => Promise<{ ok: boolean; detail?: string }> | { ok: boolean; detail?: string };
|
|
12
|
+
|
|
13
|
+
export interface PluginInstallDeps {
|
|
14
|
+
runBunInstall?: BunInstallRunner;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PluginInstallResult {
|
|
18
|
+
package: string;
|
|
19
|
+
status: "installed" | "unchanged" | "failed";
|
|
20
|
+
resolvedVersion?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Runs `bun install` in the plugin workspace, honoring the user's ambient registry/auth config. */
|
|
25
|
+
function defaultBunInstall(installDir: string): { ok: boolean; detail?: string } {
|
|
26
|
+
const result = spawnSync("bun", ["install", "--silent"], { cwd: installDir, encoding: "utf8" });
|
|
27
|
+
if (result.error || result.status !== 0) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
detail:
|
|
31
|
+
result.error?.message ??
|
|
32
|
+
result.stderr?.trim() ??
|
|
33
|
+
`bun install exited with ${result.status}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { ok: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readInstalledVersion(packageRoot: string): Promise<string | null> {
|
|
40
|
+
try {
|
|
41
|
+
const manifest = JSON.parse(
|
|
42
|
+
await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
|
|
43
|
+
) as { version?: string };
|
|
44
|
+
return manifest.version ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Best-effort integrity extraction from bun's text lockfile. The lockfile is
|
|
52
|
+
* JSONC (trailing commas); package entries are tuples whose last string is
|
|
53
|
+
* the registry integrity hash. Absence is tolerated per spec.
|
|
54
|
+
*/
|
|
55
|
+
async function readIntegrityFromBunLock(
|
|
56
|
+
installDir: string,
|
|
57
|
+
packageName: string,
|
|
58
|
+
): Promise<string | undefined> {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(path.join(installDir, "bun.lock"), "utf8");
|
|
61
|
+
const parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, "$1")) as {
|
|
62
|
+
packages?: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
const entry = parsed.packages?.[packageName];
|
|
65
|
+
if (!Array.isArray(entry)) return undefined;
|
|
66
|
+
return entry.find(
|
|
67
|
+
(element): element is string => typeof element === "string" && element.startsWith("sha"),
|
|
68
|
+
);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Installs each declared plugin into `.mate/dependencies/plugins/<sanitized>/`
|
|
76
|
+
* and records `{ package, declaredVersion, resolvedVersion, integrity }` in
|
|
77
|
+
* the committed pin file. Exact/range versions resolve once and stay pinned
|
|
78
|
+
* until the declaration changes; `latest` re-resolves on every run. Matching
|
|
79
|
+
* pinned installs are left untouched.
|
|
80
|
+
*/
|
|
81
|
+
export async function installDeclaredPlugins(
|
|
82
|
+
companionPath: string,
|
|
83
|
+
declarations: PluginDeclaration[],
|
|
84
|
+
deps: PluginInstallDeps = {},
|
|
85
|
+
): Promise<PluginInstallResult[]> {
|
|
86
|
+
const runBunInstall = deps.runBunInstall ?? defaultBunInstall;
|
|
87
|
+
const pinStore = new PluginPinStore(companionPath);
|
|
88
|
+
const previousPins = (await pinStore.load()).plugins;
|
|
89
|
+
const nextPins: PluginPin[] = [];
|
|
90
|
+
const results: PluginInstallResult[] = [];
|
|
91
|
+
|
|
92
|
+
for (const declaration of declarations) {
|
|
93
|
+
const pin = previousPins.find((candidate) => candidate.package === declaration.package);
|
|
94
|
+
const installDir = pluginInstallDir(companionPath, declaration.package);
|
|
95
|
+
const packageRoot = pluginPackageRoot(companionPath, declaration.package);
|
|
96
|
+
// oxlint-disable-next-line no-await-in-loop -- installs mutate a shared pin file sequentially
|
|
97
|
+
const installedVersion = await readInstalledVersion(packageRoot);
|
|
98
|
+
|
|
99
|
+
const pinMatches =
|
|
100
|
+
declaration.version !== "latest" &&
|
|
101
|
+
pin !== undefined &&
|
|
102
|
+
pin.declaredVersion === declaration.version &&
|
|
103
|
+
installedVersion === pin.resolvedVersion;
|
|
104
|
+
if (pinMatches) {
|
|
105
|
+
nextPins.push(pin);
|
|
106
|
+
results.push({
|
|
107
|
+
package: declaration.package,
|
|
108
|
+
status: "unchanged",
|
|
109
|
+
resolvedVersion: pin.resolvedVersion,
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
115
|
+
const result = await installOne(installDir, packageRoot, declaration, runBunInstall);
|
|
116
|
+
results.push(result);
|
|
117
|
+
if (result.status === "installed" && result.resolvedVersion) {
|
|
118
|
+
nextPins.push({
|
|
119
|
+
package: declaration.package,
|
|
120
|
+
declaredVersion: declaration.version,
|
|
121
|
+
resolvedVersion: result.resolvedVersion,
|
|
122
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
123
|
+
integrity: await readIntegrityFromBunLock(installDir, declaration.package),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Pins mirror the declaration list; undeclared packages drop out.
|
|
129
|
+
if (JSON.stringify(nextPins) !== JSON.stringify(previousPins)) {
|
|
130
|
+
await pinStore.save({ plugins: nextPins });
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function installOne(
|
|
136
|
+
installDir: string,
|
|
137
|
+
packageRoot: string,
|
|
138
|
+
declaration: PluginDeclaration,
|
|
139
|
+
runBunInstall: BunInstallRunner,
|
|
140
|
+
): Promise<PluginInstallResult> {
|
|
141
|
+
try {
|
|
142
|
+
// Fresh workspace per (re)install so `latest` and edited declarations
|
|
143
|
+
// actually re-resolve instead of reusing a stale lockfile.
|
|
144
|
+
await fs.rm(installDir, { recursive: true, force: true });
|
|
145
|
+
await fs.mkdir(installDir, { recursive: true });
|
|
146
|
+
await fs.writeFile(
|
|
147
|
+
path.join(installDir, "package.json"),
|
|
148
|
+
JSON.stringify(
|
|
149
|
+
{ private: true, dependencies: { [declaration.package]: declaration.version } },
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
) + "\n",
|
|
153
|
+
"utf8",
|
|
154
|
+
);
|
|
155
|
+
const outcome = await runBunInstall(installDir);
|
|
156
|
+
if (!outcome.ok) {
|
|
157
|
+
throw new Error(outcome.detail ?? "bun install failed");
|
|
158
|
+
}
|
|
159
|
+
const resolvedVersion = await readInstalledVersion(packageRoot);
|
|
160
|
+
if (!resolvedVersion) {
|
|
161
|
+
throw new Error(`installed tree is missing ${declaration.package}/package.json`);
|
|
162
|
+
}
|
|
163
|
+
return { package: declaration.package, status: "installed", resolvedVersion };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
package: declaration.package,
|
|
167
|
+
status: "failed",
|
|
168
|
+
error: error instanceof Error ? error.message : String(error),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
|
|
6
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
7
|
+
import type { Plugin } from "../plugin";
|
|
8
|
+
import { resolveEffectivePluginConfig } from "./config-resolution";
|
|
9
|
+
import {
|
|
10
|
+
createPluginHost,
|
|
11
|
+
SUPPORTED_PLUGIN_API_VERSIONS,
|
|
12
|
+
type CreatePlugin,
|
|
13
|
+
type PluginHost,
|
|
14
|
+
} from "./host";
|
|
15
|
+
import { pluginPackageRoot } from "./paths";
|
|
16
|
+
import type { PluginPin } from "./pin-store";
|
|
17
|
+
|
|
18
|
+
interface PluginManifest {
|
|
19
|
+
version?: string;
|
|
20
|
+
main?: string;
|
|
21
|
+
module?: string;
|
|
22
|
+
exports?: unknown;
|
|
23
|
+
mate?: { pluginApiVersion?: unknown };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DynamicPluginLoadResult = { ok: true; plugin: Plugin } | { ok: false; warning: string };
|
|
27
|
+
|
|
28
|
+
export interface DynamicPluginLoadDeps {
|
|
29
|
+
env?: Record<string, string | undefined>;
|
|
30
|
+
importModule?: (specifier: string) => Promise<Record<string, unknown>>;
|
|
31
|
+
host?: PluginHost;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function commandName(): string {
|
|
39
|
+
try {
|
|
40
|
+
return frameworkCommandName();
|
|
41
|
+
} catch {
|
|
42
|
+
return FRAMEWORK_NAME;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveEntryFromExports(exportsField: unknown): string | undefined {
|
|
47
|
+
if (typeof exportsField === "string") return exportsField;
|
|
48
|
+
if (!isRecord(exportsField)) return undefined;
|
|
49
|
+
const dot = "." in exportsField ? exportsField["."] : exportsField;
|
|
50
|
+
if (typeof dot === "string") return dot;
|
|
51
|
+
if (!isRecord(dot)) return undefined;
|
|
52
|
+
for (const condition of ["bun", "import", "default", "require"]) {
|
|
53
|
+
const candidate = dot[condition];
|
|
54
|
+
if (typeof candidate === "string") return candidate;
|
|
55
|
+
if (isRecord(candidate) && typeof candidate.default === "string") return candidate.default;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loads one declared plugin from its installed tree: pin verification, API
|
|
62
|
+
* version gate before import, dynamic import, factory resolution, effective
|
|
63
|
+
* config, factory invocation. Every failure class returns a single warning
|
|
64
|
+
* instead of throwing, so one broken plugin never takes down the CLI.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadDynamicPlugin(
|
|
67
|
+
companionPath: string,
|
|
68
|
+
declaration: PluginDeclaration,
|
|
69
|
+
pins: PluginPin[],
|
|
70
|
+
deps: DynamicPluginLoadDeps = {},
|
|
71
|
+
): Promise<DynamicPluginLoadResult> {
|
|
72
|
+
const name = declaration.package;
|
|
73
|
+
const packageRoot = pluginPackageRoot(companionPath, name);
|
|
74
|
+
|
|
75
|
+
let manifest: PluginManifest;
|
|
76
|
+
try {
|
|
77
|
+
manifest = JSON.parse(
|
|
78
|
+
await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
|
|
79
|
+
) as PluginManifest;
|
|
80
|
+
} catch {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
warning: `plugin "${name}" is not installed; run \`${commandName()} install\`.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pin = pins.find((candidate) => candidate.package === name);
|
|
88
|
+
if (!pin) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
warning: `plugin "${name}" has no recorded pin; run \`${commandName()} install\`.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (pin.declaredVersion !== declaration.version) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
warning: `plugin "${name}" declares version "${declaration.version}" but was pinned from "${pin.declaredVersion}"; run \`${commandName()} install\`.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (manifest.version !== pin.resolvedVersion) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
warning: `plugin "${name}" has version ${manifest.version ?? "unknown"} installed but ${pin.resolvedVersion} pinned; run \`${commandName()} install\`.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Version negotiation happens before any plugin code executes.
|
|
108
|
+
const apiVersion = manifest.mate?.pluginApiVersion ?? 1;
|
|
109
|
+
if (typeof apiVersion !== "number" || !SUPPORTED_PLUGIN_API_VERSIONS.includes(apiVersion)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
warning: `plugin "${name}" requires plugin API version ${String(apiVersion)}; this CLI supports: ${SUPPORTED_PLUGIN_API_VERSIONS.join(", ")}.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const configResult = await resolveEffectivePluginConfig(companionPath, declaration, deps.env);
|
|
117
|
+
if (!configResult.ok) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
warning: `plugin "${name}" references unset environment variable${configResult.missingVariables.length > 1 ? "s" : ""}: ${configResult.missingVariables.join(", ")}.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const entryRelative =
|
|
125
|
+
resolveEntryFromExports(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js";
|
|
126
|
+
const entryPath = path.resolve(packageRoot, entryRelative);
|
|
127
|
+
const importModule =
|
|
128
|
+
deps.importModule ??
|
|
129
|
+
((specifier: string) => import(specifier) as Promise<Record<string, unknown>>);
|
|
130
|
+
|
|
131
|
+
let moduleExports: Record<string, unknown>;
|
|
132
|
+
try {
|
|
133
|
+
moduleExports = await importModule(pathToFileURL(entryPath).href);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
warning: `plugin "${name}" could not be imported: ${error instanceof Error ? error.message : String(error)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const factory =
|
|
142
|
+
typeof moduleExports.default === "function"
|
|
143
|
+
? (moduleExports.default as CreatePlugin)
|
|
144
|
+
: typeof moduleExports.createPlugin === "function"
|
|
145
|
+
? (moduleExports.createPlugin as CreatePlugin)
|
|
146
|
+
: undefined;
|
|
147
|
+
if (!factory) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
warning: `plugin "${name}" has no factory export; expected a default-exported function or a named createPlugin export.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let plugin: Plugin;
|
|
155
|
+
try {
|
|
156
|
+
plugin = factory(configResult.config, deps.host ?? createPluginHost());
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
warning: `plugin "${name}" rejected its configuration: ${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!isRecord(plugin) || typeof plugin.id !== "string" || plugin.id.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
warning: `plugin "${name}" returned an invalid plugin object (missing id).`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, plugin };
|
|
171
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
4
|
+
|
|
5
|
+
/** Flattens an npm package name into a single directory segment. */
|
|
6
|
+
export function sanitizePluginDirName(packageName: string): string {
|
|
7
|
+
return packageName.replace(/^@/, "").replace(/\//g, "-");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function dynamicPluginsRoot(companionPath: string): string {
|
|
11
|
+
return path.join(companionPath, `.${FRAMEWORK_NAME}`, "dependencies", "plugins");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Per-plugin install workspace holding a private package.json and node_modules. */
|
|
15
|
+
export function pluginInstallDir(companionPath: string, packageName: string): string {
|
|
16
|
+
return path.join(dynamicPluginsRoot(companionPath), sanitizePluginDirName(packageName));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Root of the installed plugin package itself. */
|
|
20
|
+
export function pluginPackageRoot(companionPath: string, packageName: string): string {
|
|
21
|
+
return path.join(
|
|
22
|
+
pluginInstallDir(companionPath, packageName),
|
|
23
|
+
"node_modules",
|
|
24
|
+
...packageName.split("/"),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Committed pin file recording resolved plugin versions. */
|
|
29
|
+
export function pluginPinFilePath(companionPath: string): string {
|
|
30
|
+
return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "plugins.lock.yaml");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Gitignored per-machine override file deep-merged over committed plugin config. */
|
|
34
|
+
export function pluginLocalOverridesPath(companionPath: string): string {
|
|
35
|
+
return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "plugins.local.yaml");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Companion-relative gitignore entry for the local override file. */
|
|
39
|
+
export const PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY = `.${FRAMEWORK_NAME}/config/plugins.local.yaml`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { YamlFileStore } from "../../../lib/orchestrator/yaml-file-store";
|
|
2
|
+
import { pluginPinFilePath } from "./paths";
|
|
3
|
+
|
|
4
|
+
/** Committed record of one declared plugin's resolved install. */
|
|
5
|
+
export interface PluginPin {
|
|
6
|
+
package: string;
|
|
7
|
+
declaredVersion: string;
|
|
8
|
+
resolvedVersion: string;
|
|
9
|
+
integrity?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PluginPinFile {
|
|
13
|
+
plugins: PluginPin[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PluginPinStore extends YamlFileStore<PluginPinFile> {
|
|
17
|
+
constructor(companionPath: string) {
|
|
18
|
+
super(pluginPinFilePath(companionPath));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override async load(): Promise<PluginPinFile> {
|
|
22
|
+
const parsed = await super.load();
|
|
23
|
+
return { plugins: Array.isArray(parsed?.plugins) ? parsed.plugins : [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected onMissing(): Promise<PluginPinFile> {
|
|
27
|
+
// The pin file appears only once install records a resolved plugin.
|
|
28
|
+
return Promise.resolve({ plugins: [] });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getActiveDistribution } from "../../../distribution";
|
|
2
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
3
|
+
import { PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY } from "../dynamic-plugins/paths";
|
|
2
4
|
import type { Plugin, SetupContext } from "../plugin";
|
|
3
5
|
|
|
4
6
|
const MANAGED_START = (name: string) => `# ${name} managed: start`;
|
|
@@ -55,9 +57,24 @@ export async function writeManagedGitignoreBlock(
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugin[]): string[] {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
// Baseline for every companion: node_modules never versions wherever a tool
|
|
61
|
+
// materializes it, and everything under the dependencies tree (dynamic
|
|
62
|
+
// plugins, context-mode, future consumers) is regenerated by setup/install —
|
|
63
|
+
// version pins live in .mate/config and in code, never in the tree itself.
|
|
64
|
+
const entries = [
|
|
65
|
+
"node_modules/",
|
|
66
|
+
`.${FRAMEWORK_NAME}/dependencies/`,
|
|
67
|
+
...plugins
|
|
68
|
+
.filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
|
|
69
|
+
.flatMap((p) => p.gitignoreEntries?.(ctx) ?? []),
|
|
70
|
+
];
|
|
71
|
+
// Dynamic-plugin loading artifacts: the local override file never versions;
|
|
72
|
+
// the pin file (plugins.lock.yaml) stays committed and is deliberately not
|
|
73
|
+
// listed here.
|
|
74
|
+
if (ctx.config.plugins?.length) {
|
|
75
|
+
entries.push(PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY);
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
export function createGitignorePlugin(
|
package/src/tools/setup.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { createUvPlugin } from "./setup/package-managers/uv";
|
|
|
31
31
|
import type { PackageManagerSetupDeps } from "./setup/package-managers/uv";
|
|
32
32
|
import { applyRequiredSelectionsToConfig } from "./setup/policy";
|
|
33
33
|
import { invalidateInstallState } from "../lib/install";
|
|
34
|
+
import { hydrateDynamicPlugins } from "./setup/dynamic-plugins/hydrate";
|
|
35
|
+
import { installDeclaredPlugins } from "./setup/dynamic-plugins/install";
|
|
34
36
|
import {
|
|
35
37
|
buildSetupInstallationPlan,
|
|
36
38
|
executeSetupInstallationPlan,
|
|
@@ -181,6 +183,19 @@ export async function executeSetup(
|
|
|
181
183
|
await configStore.save(config);
|
|
182
184
|
|
|
183
185
|
const companionPath = path.resolve(cwd);
|
|
186
|
+
// Declared plugin packages install and hydrate before the setup plan runs,
|
|
187
|
+
// so a fresh clone reaches a fully applied state from one setup run
|
|
188
|
+
// (install → load → plan).
|
|
189
|
+
if (config.plugins?.length) {
|
|
190
|
+
for (const result of await installDeclaredPlugins(companionPath, config.plugins)) {
|
|
191
|
+
if (result.status === "failed") {
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
`${FRAMEWORK_NAME}: plugin "${result.package}" failed to install: ${result.error ?? "unknown error"}\n`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await hydrateDynamicPlugins({ companionPath });
|
|
198
|
+
}
|
|
184
199
|
await applySetupCompatibilities(companionPath, config, "setup");
|
|
185
200
|
await invalidateInstallState({ kind: "companion", companionPath });
|
|
186
201
|
|