code-workspace-zhuiyi 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/README.zh-CN.md +168 -0
- package/artifacts/manifest.json +183 -0
- package/artifacts/templates/USER_GUIDE.template.md +1 -0
- package/artifacts/templates/agents/WORKSPACE_GUARD.md.template +73 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/SKILL.md +88 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/evals/evals.json +45 -0
- package/artifacts/templates/claude/commands/code-workspace/add-projects.md +23 -0
- package/artifacts/templates/codex/hooks.json +86 -0
- package/artifacts/templates/codex/skills/code-workspace-add-projects/SKILL.md +28 -0
- package/artifacts/templates/user-guide/en-US.md +97 -0
- package/artifacts/templates/user-guide/zh-CN.md +97 -0
- package/assets/i18n-icon.svg +1 -0
- package/assets/logo-vector.svg +30 -0
- package/assets/request_tip.mp3 +0 -0
- package/assets/session_finish.mp3 +0 -0
- package/bin/code-workspace.js +11 -0
- package/docs/extension-architecture.zh-CN.md +429 -0
- package/docs/extensions.md +64 -0
- package/docs/extensions.zh-CN.md +64 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/claude/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/codex/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/init.js +54 -0
- package/extensions/openspec-workspace/1.0.0/manifest.json +27 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/claude/server.json +14 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/codex/config.toml +11 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/init.js +53 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/lib/archive.js +196 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/manifest.json +38 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/release.json +15 -0
- package/package.json +58 -0
- package/schemas/extension-init-context-v1.json +39 -0
- package/schemas/extension-init-result-v1.json +42 -0
- package/schemas/extension-manifest-v1.json +55 -0
- package/schemas/extension-manifest-v2.json +84 -0
- package/schemas/extension-manifest-v3.json +84 -0
- package/spec/extension/v1/specification.en-US.md +171 -0
- package/spec/extension/v1/specification.zh-CN.md +170 -0
- package/src/cli/commands/completion.js +203 -0
- package/src/cli/commands/extension.js +193 -0
- package/src/cli/commands/help.js +36 -0
- package/src/cli/commands/init.js +200 -0
- package/src/cli/commands/monitor.js +62 -0
- package/src/cli/commands/permissions.js +33 -0
- package/src/cli/commands/project-branch-update.js +107 -0
- package/src/cli/commands/project-branch.js +433 -0
- package/src/cli/commands/project.js +259 -0
- package/src/cli/commands/update.js +146 -0
- package/src/cli/commands/workspace.js +24 -0
- package/src/cli/confirmation.js +22 -0
- package/src/cli/parser.js +108 -0
- package/src/cli/registry.js +108 -0
- package/src/cli/renderer.js +20 -0
- package/src/cli/result.js +118 -0
- package/src/cli.js +76 -0
- package/src/core/assets.js +83 -0
- package/src/core/config.js +378 -0
- package/src/core/diagnostics.js +20 -0
- package/src/core/directory-digest.js +34 -0
- package/src/core/doctor.js +115 -0
- package/src/core/errors.js +10 -0
- package/src/core/extension-artifacts-legacy.js +180 -0
- package/src/core/extension-artifacts.js +310 -0
- package/src/core/extensions.js +1216 -0
- package/src/core/fs.js +32 -0
- package/src/core/init-lock-holder.js +30 -0
- package/src/core/init-lock.js +159 -0
- package/src/core/init.js +160 -0
- package/src/core/initializer.js +233 -0
- package/src/core/language.js +102 -0
- package/src/core/managed-files.js +334 -0
- package/src/core/migration.js +68 -0
- package/src/core/permissions/claude.js +92 -0
- package/src/core/permissions/codex.js +126 -0
- package/src/core/permissions/common.js +44 -0
- package/src/core/permissions/index.js +163 -0
- package/src/core/project-branch-update.js +424 -0
- package/src/core/project-configuration.js +55 -0
- package/src/core/project.js +674 -0
- package/src/core/tools.js +34 -0
- package/src/core/transaction.js +132 -0
- package/src/core/validation.js +186 -0
- package/src/i18n/index.js +11 -0
- package/src/i18n/interpolate.js +7 -0
- package/src/i18n/locales/en-US.js +10 -0
- package/src/i18n/locales/zh-CN.js +11 -0
- package/src/i18n/registry.js +42 -0
- package/src/index.js +25 -0
- package/src/init/plan.js +12 -0
- package/src/init/ui.js +61 -0
- package/src/init/wizard.js +97 -0
- package/src/monitor/i18n/index.js +33 -0
- package/src/monitor/i18n/locales/en-US.js +85 -0
- package/src/monitor/i18n/locales/zh-CN.js +79 -0
- package/src/monitor/index.js +344 -0
- package/src/monitor/page.js +118 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
const { configPath, loadConfigProjection, updateProjectBranch } = require("../../core/config");
|
|
2
|
+
const { WorkspaceError } = require("../../core/errors");
|
|
3
|
+
const {
|
|
4
|
+
assertRegisteredBranchSwitchAvailable,
|
|
5
|
+
canonicalBranchState,
|
|
6
|
+
inspectProjectBranch,
|
|
7
|
+
inspectProjectBranchMatch,
|
|
8
|
+
planRegisteredBranchAcquisition,
|
|
9
|
+
switchProjectToRegisteredBranch,
|
|
10
|
+
} = require("../../core/project");
|
|
11
|
+
const { createFileTransaction } = require("../../core/transaction");
|
|
12
|
+
const { confirm } = require("../confirmation");
|
|
13
|
+
const { diagnosticFromError, fromDiagnostics, selectionResult, success } = require("../result");
|
|
14
|
+
|
|
15
|
+
function branchError(code, message, details = {}) {
|
|
16
|
+
return new WorkspaceError(code, message, details);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function planAcceptActual(config, name, options = {}) {
|
|
20
|
+
const inspected = (options.inspectProjectBranch || inspectProjectBranch)(config, name, options);
|
|
21
|
+
return {
|
|
22
|
+
action: inspected.matches ? "skip" : "update",
|
|
23
|
+
project: inspected.project,
|
|
24
|
+
before: canonicalBranchState(inspected),
|
|
25
|
+
after: {
|
|
26
|
+
registeredBranch: inspected.actualBranch,
|
|
27
|
+
actualBranch: inspected.actualBranch,
|
|
28
|
+
},
|
|
29
|
+
worktreeClean: inspected.worktreeClean,
|
|
30
|
+
registeredBranchExists: inspected.registeredBranchExists,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertAcceptPlanCurrent(plan, observed) {
|
|
35
|
+
const observedState = canonicalBranchState(observed);
|
|
36
|
+
if (plan.before.registeredBranch === observedState.registeredBranch
|
|
37
|
+
&& plan.before.actualBranch === observedState.actualBranch) return;
|
|
38
|
+
throw branchError(
|
|
39
|
+
"PROJECT_BRANCH_PLAN_STALE",
|
|
40
|
+
`Branch state changed while preparing to accept the actual branch for project ${plan.project.name}.`,
|
|
41
|
+
{
|
|
42
|
+
project: plan.project.name,
|
|
43
|
+
location: plan.project.location,
|
|
44
|
+
expectedState: plan.before,
|
|
45
|
+
observedState,
|
|
46
|
+
remediation: "Inspect the selected project branch state again before choosing a direction.",
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyAcceptActual(root, plan, options = {}) {
|
|
52
|
+
const load = options.loadConfigProjection || loadConfigProjection;
|
|
53
|
+
const inspect = options.inspectProjectBranch || inspectProjectBranch;
|
|
54
|
+
const currentConfig = load(root, ["projects"]);
|
|
55
|
+
assertAcceptPlanCurrent(plan, inspect(currentConfig, plan.project.name, options));
|
|
56
|
+
|
|
57
|
+
const transaction = createFileTransaction([configPath(root)]);
|
|
58
|
+
try {
|
|
59
|
+
(options.updateProjectBranch || updateProjectBranch)(root, {
|
|
60
|
+
name: plan.project.name,
|
|
61
|
+
before: plan.before,
|
|
62
|
+
after: plan.after,
|
|
63
|
+
}, options);
|
|
64
|
+
options.injectFailure?.("after-config-save", plan);
|
|
65
|
+
const persisted = load(root, ["projects"]);
|
|
66
|
+
const observed = inspect(persisted, plan.project.name, options);
|
|
67
|
+
const observedState = canonicalBranchState(observed);
|
|
68
|
+
if (observedState.registeredBranch !== plan.after.registeredBranch
|
|
69
|
+
|| observedState.actualBranch !== plan.after.actualBranch) {
|
|
70
|
+
throw branchError(
|
|
71
|
+
"PROJECT_BRANCH_ACCEPT_VERIFY_FAILED",
|
|
72
|
+
`Project ${plan.project.name} did not retain the planned registered and actual branch state.`,
|
|
73
|
+
{
|
|
74
|
+
project: plan.project.name,
|
|
75
|
+
location: plan.project.location,
|
|
76
|
+
expectedState: plan.after,
|
|
77
|
+
observedState,
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
options.injectFailure?.("after-verify", observed);
|
|
82
|
+
transaction.commit();
|
|
83
|
+
return {
|
|
84
|
+
action: "update",
|
|
85
|
+
project: observed.project,
|
|
86
|
+
before: plan.before,
|
|
87
|
+
after: observedState,
|
|
88
|
+
worktreeClean: observed.worktreeClean,
|
|
89
|
+
registeredBranchExists: observed.registeredBranchExists,
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code === "PROJECT_BRANCH_ACCEPT_CONFLICT") {
|
|
93
|
+
transaction.commit();
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const failure = ["PROJECT_BRANCH_ACCEPT_CONFLICT", "PROJECT_BRANCH_ACCEPT_VERIFY_FAILED"].includes(error.code)
|
|
97
|
+
? error
|
|
98
|
+
: branchError(
|
|
99
|
+
"PROJECT_BRANCH_ACCEPT_FAILED",
|
|
100
|
+
`Accepting the actual branch for project ${plan.project.name} was rolled back: ${error.message}`,
|
|
101
|
+
{
|
|
102
|
+
...(error.details || {}),
|
|
103
|
+
project: plan.project.name,
|
|
104
|
+
location: plan.project.location,
|
|
105
|
+
cause: error.code || error.name,
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
transaction.rollback(failure);
|
|
109
|
+
throw failure;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function uniqueProjectSelection(args) {
|
|
114
|
+
const requested = [...args];
|
|
115
|
+
const names = [];
|
|
116
|
+
const diagnostics = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const name of requested) {
|
|
119
|
+
if (!seen.has(name)) {
|
|
120
|
+
seen.add(name);
|
|
121
|
+
names.push(name);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
code: "CLI_DUPLICATE_ARGUMENT",
|
|
126
|
+
severity: "warning",
|
|
127
|
+
message: `Project ${name} was provided more than once; later occurrences were ignored.`,
|
|
128
|
+
project: name,
|
|
129
|
+
argument: "name",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return { requested, names, diagnostics };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function batchEntry(project, ok, action, data, message) {
|
|
136
|
+
return { project, ok, action, data: data || null, message };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function batchText(results) {
|
|
140
|
+
const lines = results.map((entry) => `${entry.ok ? (entry.action === "skip" ? "SKIP" : "OK") : "FAILED"}\t${entry.project}\t${entry.message}`);
|
|
141
|
+
const succeeded = results.filter((entry) => entry.ok && entry.action !== "skip").length;
|
|
142
|
+
const skipped = results.filter((entry) => entry.ok && entry.action === "skip").length;
|
|
143
|
+
const failed = results.filter((entry) => !entry.ok).length;
|
|
144
|
+
return [...lines, `Summary\ttotal=${results.length}\tsucceeded=${succeeded}\tskipped=${skipped}\tfailed=${failed}`].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function branchOperationOptions(options = {}, dependencies = {}) {
|
|
148
|
+
return {
|
|
149
|
+
...dependencies,
|
|
150
|
+
includeRemoteBranchCandidates: true,
|
|
151
|
+
allowRemote: options.allowRemote ?? options["allow-remote"] ?? dependencies.allowRemote,
|
|
152
|
+
remote: options.remote ?? dependencies.remote,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function planUseRegistered(config, name, options = {}) {
|
|
157
|
+
const inspected = (options.inspectProjectBranch || inspectProjectBranch)(config, name, { ...options, includeRemoteBranchCandidates: true });
|
|
158
|
+
const acquisition = (options.planRegisteredBranchAcquisition || planRegisteredBranchAcquisition)(inspected, options);
|
|
159
|
+
return { ...inspected, acquisition };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function useRegisteredConfirmation(plan) {
|
|
163
|
+
const from = `${plan.project.name} from actual branch ${plan.actualBranch}`;
|
|
164
|
+
if (plan.acquisition?.mode === "remote-tracking") {
|
|
165
|
+
return `Create local branch ${plan.registeredBranch} tracking ${plan.acquisition.remoteBranch}, then switch ${from} to ${plan.registeredBranch}?`;
|
|
166
|
+
}
|
|
167
|
+
if (plan.acquisition?.mode === "fetched") {
|
|
168
|
+
return `Fetch ${plan.acquisition.remote}/${plan.registeredBranch}, create local branch ${plan.registeredBranch} tracking ${plan.acquisition.remoteBranch}, then switch ${from} to ${plan.registeredBranch}?`;
|
|
169
|
+
}
|
|
170
|
+
return `Switch ${from} to registered branch ${plan.registeredBranch}?`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recordBatchFailure(slot, error, diagnostics) {
|
|
174
|
+
diagnostics.push(diagnosticFromError(error, { project: slot.name }));
|
|
175
|
+
slot.result = batchEntry(slot.name, false, "failed", null, error.message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function branchMismatchError(inspected) {
|
|
179
|
+
return branchError(
|
|
180
|
+
"PROJECT_BRANCH_MISMATCH",
|
|
181
|
+
`Project ${inspected.project.name} registered branch ${inspected.registeredBranch} does not match actual branch ${inspected.actualBranch}.`,
|
|
182
|
+
{
|
|
183
|
+
project: inspected.project.name,
|
|
184
|
+
location: inspected.project.location,
|
|
185
|
+
registeredBranch: inspected.registeredBranch,
|
|
186
|
+
actualBranch: inspected.actualBranch,
|
|
187
|
+
remediation: "Choose a branch reconciliation direction for the selected project, then retry branch verification.",
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function batchBranchInspection(config, names, dependencies, diagnostics) {
|
|
193
|
+
const slots = names.map((name) => ({ name }));
|
|
194
|
+
for (const slot of slots) {
|
|
195
|
+
try {
|
|
196
|
+
const inspected = (dependencies.inspectProjectBranch || inspectProjectBranch)(config, slot.name, dependencies);
|
|
197
|
+
slot.result = batchEntry(
|
|
198
|
+
slot.name,
|
|
199
|
+
true,
|
|
200
|
+
"inspect",
|
|
201
|
+
inspected,
|
|
202
|
+
`${inspected.registeredBranch} -> ${inspected.actualBranch} (${inspected.matches ? "match" : "mismatch"})`
|
|
203
|
+
);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return slots.map((slot) => slot.result);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function batchBranchVerification(config, names, dependencies, diagnostics) {
|
|
212
|
+
const slots = names.map((name) => ({ name }));
|
|
213
|
+
for (const slot of slots) {
|
|
214
|
+
try {
|
|
215
|
+
const inspected = (dependencies.inspectProjectBranchMatch || inspectProjectBranchMatch)(config, slot.name, dependencies);
|
|
216
|
+
if (!inspected.matches) {
|
|
217
|
+
const error = branchMismatchError(inspected);
|
|
218
|
+
diagnostics.push(diagnosticFromError(error, { project: slot.name }));
|
|
219
|
+
slot.result = batchEntry(slot.name, false, "failed", inspected, error.message);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
slot.result = batchEntry(
|
|
223
|
+
slot.name,
|
|
224
|
+
true,
|
|
225
|
+
"verify",
|
|
226
|
+
inspected,
|
|
227
|
+
`registered and actual branches match: ${inspected.actualBranch}`
|
|
228
|
+
);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return slots.map((slot) => slot.result);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function batchAcceptActual(root, config, names, options, dependencies, diagnostics) {
|
|
237
|
+
const slots = names.map((name) => ({ name }));
|
|
238
|
+
for (const slot of slots) {
|
|
239
|
+
try {
|
|
240
|
+
slot.plan = planAcceptActual(config, slot.name, dependencies);
|
|
241
|
+
if (slot.plan.action === "skip") {
|
|
242
|
+
slot.result = batchEntry(
|
|
243
|
+
slot.name,
|
|
244
|
+
true,
|
|
245
|
+
"skip",
|
|
246
|
+
slot.plan,
|
|
247
|
+
`registered and actual branches already match: ${slot.plan.after.actualBranch}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const applicable = slots.filter((slot) => slot.plan?.action === "update");
|
|
255
|
+
if (applicable.length > 0 && !(await confirm(
|
|
256
|
+
[
|
|
257
|
+
"Accept actual branches by updating these registered branches?",
|
|
258
|
+
...applicable.map((slot) => `- ${slot.name}: ${slot.plan.before.registeredBranch} -> ${slot.plan.after.registeredBranch}`),
|
|
259
|
+
].join("\n"),
|
|
260
|
+
options
|
|
261
|
+
))) {
|
|
262
|
+
throw branchError("CLI_CANCELLED", "Accepting the actual project branches was cancelled.");
|
|
263
|
+
}
|
|
264
|
+
for (const slot of applicable) {
|
|
265
|
+
try {
|
|
266
|
+
const applied = applyAcceptActual(root, slot.plan, dependencies);
|
|
267
|
+
slot.result = batchEntry(
|
|
268
|
+
slot.name,
|
|
269
|
+
true,
|
|
270
|
+
applied.action,
|
|
271
|
+
applied,
|
|
272
|
+
`accepted actual branch: ${slot.plan.before.registeredBranch} -> ${slot.plan.after.registeredBranch}`
|
|
273
|
+
);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return slots.map((slot) => slot.result);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function batchUseRegistered(config, names, options, dependencies, diagnostics) {
|
|
282
|
+
const operationOptions = branchOperationOptions(options, dependencies);
|
|
283
|
+
const slots = names.map((name) => ({ name }));
|
|
284
|
+
for (const slot of slots) {
|
|
285
|
+
try {
|
|
286
|
+
slot.plan = planUseRegistered(config, slot.name, operationOptions);
|
|
287
|
+
if (slot.plan.matches) {
|
|
288
|
+
const skipped = {
|
|
289
|
+
action: "skip",
|
|
290
|
+
project: slot.plan.project,
|
|
291
|
+
before: canonicalBranchState(slot.plan),
|
|
292
|
+
after: canonicalBranchState(slot.plan),
|
|
293
|
+
worktreeClean: slot.plan.worktreeClean,
|
|
294
|
+
registeredBranchExists: slot.plan.registeredBranchExists,
|
|
295
|
+
};
|
|
296
|
+
slot.result = batchEntry(
|
|
297
|
+
slot.name,
|
|
298
|
+
true,
|
|
299
|
+
"skip",
|
|
300
|
+
skipped,
|
|
301
|
+
`registered and actual branches already match: ${slot.plan.actualBranch}`
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
if (slot.plan.acquisition?.mode === "local") assertRegisteredBranchSwitchAvailable(slot.plan);
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const applicable = slots.filter((slot) => slot.plan && !slot.result);
|
|
311
|
+
if (applicable.length > 0 && !(await confirm(
|
|
312
|
+
[
|
|
313
|
+
"Switch these projects to their registered branches?",
|
|
314
|
+
...applicable.map((slot) => `- ${slot.name}: ${useRegisteredConfirmation(slot.plan)}`),
|
|
315
|
+
].join("\n"),
|
|
316
|
+
options
|
|
317
|
+
))) {
|
|
318
|
+
throw branchError("CLI_CANCELLED", "Using the registered project branches was cancelled.");
|
|
319
|
+
}
|
|
320
|
+
for (const slot of applicable) {
|
|
321
|
+
try {
|
|
322
|
+
const applied = (dependencies.switchProjectToRegisteredBranch || switchProjectToRegisteredBranch)(slot.plan, operationOptions);
|
|
323
|
+
slot.result = batchEntry(
|
|
324
|
+
slot.name,
|
|
325
|
+
true,
|
|
326
|
+
applied.action,
|
|
327
|
+
applied,
|
|
328
|
+
`switched to registered branch: ${slot.plan.registeredBranch}`
|
|
329
|
+
);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
recordBatchFailure(slot, error, diagnostics);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return slots.map((slot) => slot.result);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function executeProjectBranchSelection(invocation, action, command, dependencies) {
|
|
338
|
+
const { config, options, root } = invocation;
|
|
339
|
+
const selection = uniqueProjectSelection(invocation.args);
|
|
340
|
+
const diagnostics = [...selection.diagnostics];
|
|
341
|
+
let results;
|
|
342
|
+
if (action === "inspect") {
|
|
343
|
+
results = batchBranchInspection(config, selection.names, { ...dependencies, includeRemoteBranchCandidates: true }, diagnostics);
|
|
344
|
+
} else if (action === "verify") {
|
|
345
|
+
results = batchBranchVerification(config, selection.names, dependencies, diagnostics);
|
|
346
|
+
} else if (action === "accept-actual") {
|
|
347
|
+
results = await batchAcceptActual(root, config, selection.names, options, dependencies, diagnostics);
|
|
348
|
+
} else if (action === "use-registered") {
|
|
349
|
+
results = await batchUseRegistered(config, selection.names, options, dependencies, diagnostics);
|
|
350
|
+
} else {
|
|
351
|
+
throw branchError("CLI_UNKNOWN_COMMAND", `Unknown project branch command: ${action || "<missing>"}`);
|
|
352
|
+
}
|
|
353
|
+
return selectionResult(command, selection.requested, results, {
|
|
354
|
+
diagnostics,
|
|
355
|
+
text: batchText(results),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function executeProjectBranch(invocation) {
|
|
360
|
+
const { args, config, options, root } = invocation;
|
|
361
|
+
const action = invocation.definition.path[2];
|
|
362
|
+
const command = `project.branch.${action}`;
|
|
363
|
+
const dependencies = options.dependencies || {};
|
|
364
|
+
const operationOptions = branchOperationOptions(options, dependencies);
|
|
365
|
+
if (args.length > 1) return executeProjectBranchSelection(invocation, action, command, dependencies);
|
|
366
|
+
if (action === "inspect") {
|
|
367
|
+
const inspected = (dependencies.inspectProjectBranch || inspectProjectBranch)(config, args[0], { ...dependencies, includeRemoteBranchCandidates: true });
|
|
368
|
+
return success(command, inspected,
|
|
369
|
+
`${inspected.project.name}\t${inspected.registeredBranch}\t${inspected.actualBranch}\t${inspected.matches ? "match" : "mismatch"}`);
|
|
370
|
+
}
|
|
371
|
+
if (action === "verify") {
|
|
372
|
+
const inspected = (dependencies.inspectProjectBranchMatch || inspectProjectBranchMatch)(config, args[0], dependencies);
|
|
373
|
+
if (inspected.matches) {
|
|
374
|
+
return success(command, inspected, `Branch verification passed for ${inspected.project.name}: ${inspected.actualBranch}`);
|
|
375
|
+
}
|
|
376
|
+
return fromDiagnostics(command, {
|
|
377
|
+
diagnostics: [diagnosticFromError(branchMismatchError(inspected))],
|
|
378
|
+
}, inspected, `Branch verification failed for ${inspected.project.name}.`);
|
|
379
|
+
}
|
|
380
|
+
if (action === "accept-actual") {
|
|
381
|
+
const plan = planAcceptActual(config, args[0], dependencies);
|
|
382
|
+
if (plan.action === "skip") {
|
|
383
|
+
return success(command, plan, `Registered and actual branches already match for ${plan.project.name}: ${plan.after.actualBranch}`);
|
|
384
|
+
}
|
|
385
|
+
if (!(await confirm(
|
|
386
|
+
`Accept actual branch for ${plan.project.name} by updating the registered branch from ${plan.before.registeredBranch} to ${plan.after.registeredBranch}?`,
|
|
387
|
+
options
|
|
388
|
+
))) {
|
|
389
|
+
throw branchError("CLI_CANCELLED", "Accepting the actual project branch was cancelled.");
|
|
390
|
+
}
|
|
391
|
+
const applied = applyAcceptActual(root, plan, dependencies);
|
|
392
|
+
return success(command, applied,
|
|
393
|
+
`Accepted actual branch for ${plan.project.name}: ${plan.before.registeredBranch} -> ${plan.after.registeredBranch}`);
|
|
394
|
+
}
|
|
395
|
+
if (action === "use-registered") {
|
|
396
|
+
const inspected = planUseRegistered(config, args[0], operationOptions);
|
|
397
|
+
if (inspected.matches) {
|
|
398
|
+
const skipped = {
|
|
399
|
+
action: "skip",
|
|
400
|
+
project: inspected.project,
|
|
401
|
+
before: canonicalBranchState(inspected),
|
|
402
|
+
after: canonicalBranchState(inspected),
|
|
403
|
+
worktreeClean: inspected.worktreeClean,
|
|
404
|
+
registeredBranchExists: inspected.registeredBranchExists,
|
|
405
|
+
};
|
|
406
|
+
return success(command, skipped, `Registered and actual branches already match for ${inspected.project.name}: ${inspected.actualBranch}`);
|
|
407
|
+
}
|
|
408
|
+
if (inspected.acquisition?.mode === "local") assertRegisteredBranchSwitchAvailable(inspected);
|
|
409
|
+
if (!(await confirm(
|
|
410
|
+
useRegisteredConfirmation(inspected),
|
|
411
|
+
options
|
|
412
|
+
))) {
|
|
413
|
+
throw branchError("CLI_CANCELLED", "Using the registered project branch was cancelled.");
|
|
414
|
+
}
|
|
415
|
+
const applied = (dependencies.switchProjectToRegisteredBranch || switchProjectToRegisteredBranch)(inspected, operationOptions);
|
|
416
|
+
return success(command, applied,
|
|
417
|
+
`Switched ${inspected.project.name} to registered branch ${inspected.registeredBranch}`);
|
|
418
|
+
}
|
|
419
|
+
throw branchError("CLI_UNKNOWN_COMMAND", `Unknown project branch command: ${action || "<missing>"}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = {
|
|
423
|
+
applyAcceptActual,
|
|
424
|
+
assertAcceptPlanCurrent,
|
|
425
|
+
batchAcceptActual,
|
|
426
|
+
batchBranchInspection,
|
|
427
|
+
batchBranchVerification,
|
|
428
|
+
batchUseRegistered,
|
|
429
|
+
executeProjectBranch,
|
|
430
|
+
executeProjectBranchSelection,
|
|
431
|
+
planAcceptActual,
|
|
432
|
+
uniqueProjectSelection,
|
|
433
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const yaml = require("js-yaml");
|
|
4
|
+
|
|
5
|
+
const { WorkspaceError } = require("../../core/errors");
|
|
6
|
+
const { formatPermissionPlan, planPermissionChanges } = require("../../core/permissions");
|
|
7
|
+
const { inspectGitWorktree, inspectProject } = require("../../core/project");
|
|
8
|
+
const { applyProjectConfiguration, projectPermissionTools } = require("../../core/project-configuration");
|
|
9
|
+
const { validateProject, validateProjects } = require("../../core/validation");
|
|
10
|
+
const { confirm } = require("../confirmation");
|
|
11
|
+
const { fromDiagnostics, selectionResult, success } = require("../result");
|
|
12
|
+
|
|
13
|
+
const PROJECT_FIELDS = ["name", "location", "branch", "type", "context"];
|
|
14
|
+
|
|
15
|
+
function projectError(code, message, details = {}) {
|
|
16
|
+
return new WorkspaceError(code, message, details);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertNoProjectConflict(config, project) {
|
|
20
|
+
const conflict = config.projects.find((current) =>
|
|
21
|
+
current.name === project.name || path.resolve(current.location) === project.location
|
|
22
|
+
);
|
|
23
|
+
if (!conflict) return;
|
|
24
|
+
if (PROJECT_FIELDS.every((field) => conflict[field] === project[field])) return "skip";
|
|
25
|
+
throw projectError("PROJECT_CONFLICT", `Project conflicts with existing local entry: ${conflict.name}`, { project: project.name, conflict: conflict.name });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readJsonFile(input, label) {
|
|
29
|
+
const file = path.resolve(input);
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw projectError("PROJECT_INPUT_READ_FAILED", `Cannot read ${label} ${file}: ${error.message}`, { file });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readTextFile(input, label) {
|
|
38
|
+
const file = path.resolve(input);
|
|
39
|
+
try {
|
|
40
|
+
return fs.readFileSync(file, "utf8").trim();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw projectError("PROJECT_INPUT_READ_FAILED", `Cannot read ${label} ${file}: ${error.message}`, { file });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeProjectRecord(value) {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
48
|
+
throw projectError("PROJECT_INPUT_INVALID", "Project input must be a JSON object.");
|
|
49
|
+
}
|
|
50
|
+
const unknown = Object.keys(value).filter((field) => !PROJECT_FIELDS.includes(field));
|
|
51
|
+
if (unknown.length > 0) throw projectError("PROJECT_INPUT_UNKNOWN_FIELD", `Project input contains unknown fields: ${unknown.join(", ")}`, { fields: unknown });
|
|
52
|
+
const project = {};
|
|
53
|
+
for (const field of PROJECT_FIELDS) {
|
|
54
|
+
if (typeof value[field] !== "string" || !value[field].trim()) {
|
|
55
|
+
throw projectError("PROJECT_INPUT_FIELD_REQUIRED", `Project input requires a non-empty ${field}.`, { field });
|
|
56
|
+
}
|
|
57
|
+
project[field] = value[field].trim();
|
|
58
|
+
}
|
|
59
|
+
if (!path.isAbsolute(project.location)) {
|
|
60
|
+
throw projectError("PROJECT_LOCATION_NOT_ABSOLUTE", `Project location must be absolute: ${project.location}`, { location: project.location });
|
|
61
|
+
}
|
|
62
|
+
const actual = inspectGitWorktree(project.location);
|
|
63
|
+
if (project.branch !== actual.branch) {
|
|
64
|
+
throw projectError("PROJECT_BRANCH_MISMATCH", `Project ${project.name} registered branch ${project.branch} does not match actual branch ${actual.branch}.`, {
|
|
65
|
+
project: project.name,
|
|
66
|
+
registeredBranch: project.branch,
|
|
67
|
+
actualBranch: actual.branch,
|
|
68
|
+
location: actual.realPath,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
project.location = actual.realPath;
|
|
72
|
+
return project;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectRecordsFromInput(args, options) {
|
|
76
|
+
if (options["project-file"] && options["projects-file"]) {
|
|
77
|
+
throw projectError("PROJECT_INPUT_MODE_CONFLICT", "Use only one of --project-file or --projects-file.");
|
|
78
|
+
}
|
|
79
|
+
if (options["project-file"]) {
|
|
80
|
+
if (args.length > 0) throw projectError("PROJECT_INPUT_MODE_CONFLICT", "project add does not accept a path with --project-file.");
|
|
81
|
+
const input = readJsonFile(options["project-file"], "--project-file");
|
|
82
|
+
if (input.schemaVersion != null && input.schemaVersion !== 1) {
|
|
83
|
+
throw projectError("PROJECT_INPUT_SCHEMA_UNSUPPORTED", `Unsupported project input schemaVersion: ${input.schemaVersion}`, { version: input.schemaVersion });
|
|
84
|
+
}
|
|
85
|
+
const project = input.project || (input.schemaVersion == null ? input : Object.fromEntries(
|
|
86
|
+
Object.entries(input).filter(([field]) => field !== "schemaVersion")
|
|
87
|
+
));
|
|
88
|
+
return [normalizeProjectRecord(project)];
|
|
89
|
+
}
|
|
90
|
+
if (options["projects-file"]) {
|
|
91
|
+
if (args.length > 0) throw projectError("PROJECT_INPUT_MODE_CONFLICT", "project add does not accept paths with --projects-file.");
|
|
92
|
+
const input = readJsonFile(options["projects-file"], "--projects-file");
|
|
93
|
+
if (input.schemaVersion != null && input.schemaVersion !== 1) {
|
|
94
|
+
throw projectError("PROJECT_INPUT_SCHEMA_UNSUPPORTED", `Unsupported projects input schemaVersion: ${input.schemaVersion}`, { version: input.schemaVersion });
|
|
95
|
+
}
|
|
96
|
+
const projects = Array.isArray(input) ? input : input.projects;
|
|
97
|
+
if (!Array.isArray(projects) || projects.length === 0) {
|
|
98
|
+
throw projectError("PROJECT_INPUT_INVALID", "--projects-file must contain a non-empty projects array.");
|
|
99
|
+
}
|
|
100
|
+
return projects.map(normalizeProjectRecord);
|
|
101
|
+
}
|
|
102
|
+
const location = args[0];
|
|
103
|
+
if (!location) throw projectError("PROJECT_PATH_REQUIRED", "project add requires a project path or project JSON file.");
|
|
104
|
+
if (options.context && options["context-file"]) throw projectError("PROJECT_INPUT_MODE_CONFLICT", "Use only one of --context or --context-file.");
|
|
105
|
+
const context = options["context-file"] ? readTextFile(options["context-file"], "--context-file") : options.context;
|
|
106
|
+
const actual = inspectGitWorktree(location);
|
|
107
|
+
return [normalizeProjectRecord({
|
|
108
|
+
name: options.name,
|
|
109
|
+
location: actual.realPath,
|
|
110
|
+
branch: actual.branch,
|
|
111
|
+
type: options.type,
|
|
112
|
+
context,
|
|
113
|
+
})];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function verifyProjectSelection(root, config, requested) {
|
|
117
|
+
const names = [];
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const diagnostics = [];
|
|
120
|
+
for (const name of requested) {
|
|
121
|
+
if (!seen.has(name)) {
|
|
122
|
+
seen.add(name);
|
|
123
|
+
names.push(name);
|
|
124
|
+
} else {
|
|
125
|
+
diagnostics.push({
|
|
126
|
+
code: "CLI_DUPLICATE_ARGUMENT",
|
|
127
|
+
severity: "warning",
|
|
128
|
+
message: `Project ${name} was provided more than once; later occurrences were ignored.`,
|
|
129
|
+
project: name,
|
|
130
|
+
argument: "name",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const results = names.map((name) => {
|
|
135
|
+
const project = config.projects.find((entry) => entry?.name === name) || null;
|
|
136
|
+
const output = validateProject(root, config, name);
|
|
137
|
+
diagnostics.push(...output.diagnostics.map((entry) => ({
|
|
138
|
+
...entry,
|
|
139
|
+
project: entry.project || entry.projectName || name,
|
|
140
|
+
})));
|
|
141
|
+
const ok = output.errors.length === 0;
|
|
142
|
+
return {
|
|
143
|
+
project: name,
|
|
144
|
+
ok,
|
|
145
|
+
action: ok ? "verify" : "failed",
|
|
146
|
+
data: project ? { project } : null,
|
|
147
|
+
message: ok ? "verification passed" : output.errors[0] || "verification failed",
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
const succeeded = results.filter((entry) => entry.ok).length;
|
|
151
|
+
const failed = results.length - succeeded;
|
|
152
|
+
const text = [
|
|
153
|
+
...results.map((entry) => `${entry.ok ? "OK" : "FAILED"}\t${entry.project}\t${entry.message}`),
|
|
154
|
+
`Summary\ttotal=${results.length}\tsucceeded=${succeeded}\tskipped=0\tfailed=${failed}`,
|
|
155
|
+
].join("\n");
|
|
156
|
+
return selectionResult("project.verify", requested, results, { diagnostics, text });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function executeProject(invocation) {
|
|
160
|
+
const { args, config, options, root } = invocation;
|
|
161
|
+
const action = invocation.definition.path[1];
|
|
162
|
+
const command = `project.${action}`;
|
|
163
|
+
if (action === "inspect") {
|
|
164
|
+
const inspection = inspectProject(args[0]);
|
|
165
|
+
return success(command, { kind: "project-inspection", project: inspection }, yaml.dump(inspection, { lineWidth: -1, noRefs: true, sortKeys: false }));
|
|
166
|
+
}
|
|
167
|
+
if (action === "list") {
|
|
168
|
+
const text = config.projects.length === 0
|
|
169
|
+
? "No local projects configured."
|
|
170
|
+
: config.projects.map((project) => `${project.name}\t${project.type}\t${project.branch}\t${project.location}`).join("\n");
|
|
171
|
+
return success(command, { projects: config.projects }, text);
|
|
172
|
+
}
|
|
173
|
+
if (action === "show") {
|
|
174
|
+
const name = options.name || args[0];
|
|
175
|
+
const project = config.projects.find((entry) => entry.name === name);
|
|
176
|
+
if (!project) throw projectError("PROJECT_NOT_FOUND", `Unknown local project: ${name || "<missing>"}`, { project: name || null });
|
|
177
|
+
return success(command, { project }, yaml.dump(project, { lineWidth: -1, noRefs: true, sortKeys: false, styles: { "!!str": "literal" } }));
|
|
178
|
+
}
|
|
179
|
+
if (action === "verify") {
|
|
180
|
+
if (args.length === 0) {
|
|
181
|
+
const output = validateProjects(root, config);
|
|
182
|
+
return fromDiagnostics(command, output, {
|
|
183
|
+
scope: "workspace",
|
|
184
|
+
project: null,
|
|
185
|
+
projects: config.projects,
|
|
186
|
+
}, "Local projects verification passed.");
|
|
187
|
+
}
|
|
188
|
+
if (args.length > 1) return verifyProjectSelection(root, config, args);
|
|
189
|
+
const name = args[0];
|
|
190
|
+
const project = config.projects.find((entry) => entry?.name === name);
|
|
191
|
+
if (!project) throw projectError("PROJECT_NOT_FOUND", `Unknown local project: ${name}`, { project: name });
|
|
192
|
+
const output = validateProject(root, config, name);
|
|
193
|
+
return fromDiagnostics(command, output, {
|
|
194
|
+
scope: "project",
|
|
195
|
+
project,
|
|
196
|
+
projects: [project],
|
|
197
|
+
}, `Local project verification passed: ${name}.`);
|
|
198
|
+
}
|
|
199
|
+
if (action === "add") {
|
|
200
|
+
const candidates = projectRecordsFromInput(args, options);
|
|
201
|
+
const additions = [];
|
|
202
|
+
const skipped = [];
|
|
203
|
+
const planned = [...config.projects];
|
|
204
|
+
for (const project of candidates) {
|
|
205
|
+
const status = assertNoProjectConflict(config, project);
|
|
206
|
+
if (status === "skip") skipped.push(project);
|
|
207
|
+
else {
|
|
208
|
+
additions.push(project);
|
|
209
|
+
planned.push(project);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (additions.length === 0) {
|
|
213
|
+
return success(command, { action: "skip", project: skipped.length === 1 ? skipped[0] : null, projects: [], skipped, permissions: null },
|
|
214
|
+
skipped.map((project) => `Project already current: ${project.name}`).join("\n"));
|
|
215
|
+
}
|
|
216
|
+
const next = { ...config, projects: planned };
|
|
217
|
+
const output = validateProjects(root, next);
|
|
218
|
+
if (output.errors.length > 0) return fromDiagnostics(command, output, { projects: additions, skipped });
|
|
219
|
+
const toolSelection = projectPermissionTools(root, options);
|
|
220
|
+
const permissionPlan = planPermissionChanges({
|
|
221
|
+
root,
|
|
222
|
+
tools: toolSelection.tools,
|
|
223
|
+
grants: additions.map((project) => project.location),
|
|
224
|
+
}, options.dependencies);
|
|
225
|
+
const names = additions.map((project) => project.name).join(", ");
|
|
226
|
+
if (!(await confirm(`Add local project${additions.length === 1 ? "" : "s"} ${names}?\n${formatPermissionPlan(permissionPlan)}`, options))) {
|
|
227
|
+
throw projectError("CLI_CANCELLED", "Project addition cancelled.");
|
|
228
|
+
}
|
|
229
|
+
const permissions = applyProjectConfiguration(root, next, permissionPlan, options.dependencies);
|
|
230
|
+
return success(command, { action: "add", project: additions.length === 1 ? additions[0] : null, projects: additions, skipped, permissions, toolSelection },
|
|
231
|
+
[...additions.map((project) => `Added local project: ${project.name}`), ...skipped.map((project) => `Project already current: ${project.name}`)].join("\n"));
|
|
232
|
+
}
|
|
233
|
+
if (action === "remove") {
|
|
234
|
+
const name = args[0];
|
|
235
|
+
const existing = config.projects.find((entry) => entry.name === name);
|
|
236
|
+
if (!existing) throw projectError("PROJECT_NOT_FOUND", `Unknown local project: ${name}`, { project: name });
|
|
237
|
+
const next = { ...config, projects: config.projects.filter((entry) => entry.name !== name) };
|
|
238
|
+
const toolSelection = projectPermissionTools(root, options);
|
|
239
|
+
const permissionPlan = planPermissionChanges({
|
|
240
|
+
root,
|
|
241
|
+
tools: toolSelection.tools,
|
|
242
|
+
revokes: [existing.location],
|
|
243
|
+
}, options.dependencies);
|
|
244
|
+
if (!(await confirm(`Remove local project ${name}?\n${formatPermissionPlan(permissionPlan)}`, options))) throw projectError("CLI_CANCELLED", "Project removal cancelled.");
|
|
245
|
+
const permissions = applyProjectConfiguration(root, next, permissionPlan, options.dependencies);
|
|
246
|
+
return success(command, { action: "remove", project: existing, permissions, toolSelection }, `Removed local project: ${name}`);
|
|
247
|
+
}
|
|
248
|
+
throw projectError("CLI_UNKNOWN_COMMAND", `Unknown project command: ${action || "<missing>"}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
PROJECT_FIELDS,
|
|
253
|
+
applyProjectConfiguration,
|
|
254
|
+
executeProject,
|
|
255
|
+
normalizeProjectRecord,
|
|
256
|
+
projectRecordsFromInput,
|
|
257
|
+
projectPermissionTools,
|
|
258
|
+
verifyProjectSelection,
|
|
259
|
+
};
|