@brainervirus/workit-core 1.2.1 → 1.3.0
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/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +6 -1
- package/src/core/auto-approval.ts +34 -14
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +886 -172
- package/src/core/external-action.ts +214 -63
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +7 -1
- package/src/core/task-engine.ts +47 -2
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { resolveWorkspaceFrom, type WorkspaceConfig } from "./workspaces";
|
|
4
5
|
import { configDir } from "./config";
|
|
5
6
|
import { verifyPushIdentity } from "./branch";
|
|
@@ -101,6 +102,18 @@ export const autoApproves = (root: string, cls: unknown, dir?: string): boolean
|
|
|
101
102
|
const isStandingReceipt = (value: unknown): value is StandingReceipt =>
|
|
102
103
|
typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "standing";
|
|
103
104
|
|
|
105
|
+
const targetOf = (root: string, descriptor: unknown): string | null => {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = typeof descriptor === "string" ? JSON.parse(descriptor) : descriptor;
|
|
108
|
+
const cwd = (parsed as { payload?: { cwd?: unknown } })?.payload?.cwd;
|
|
109
|
+
if (cwd === undefined) return root;
|
|
110
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
|
|
111
|
+
return realpathSync(cwd);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
104
117
|
/**
|
|
105
118
|
* A standing receipt authorizes only while the referenced workspace rule
|
|
106
119
|
* still covers the class. Removing the flag (or narrowing the list) fails
|
|
@@ -111,11 +124,14 @@ export const standingApprovalLive = (
|
|
|
111
124
|
receipt: unknown,
|
|
112
125
|
cls: unknown,
|
|
113
126
|
dir?: string,
|
|
127
|
+
descriptor?: unknown,
|
|
114
128
|
): boolean => {
|
|
115
129
|
if (!isStandingReceipt(receipt)) return false;
|
|
116
130
|
if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
|
|
117
131
|
if (receipt.class !== cls) return false;
|
|
118
|
-
const
|
|
132
|
+
const target = targetOf(root, descriptor);
|
|
133
|
+
if (!target) return false;
|
|
134
|
+
const resolved = resolveAutoApproval(target, dir);
|
|
119
135
|
if (resolved.status !== "on") return false;
|
|
120
136
|
if (resolved.workspace !== receipt.workspace) return false;
|
|
121
137
|
return resolved.classes.includes(cls as AutoClass);
|
|
@@ -174,7 +190,9 @@ export const verifyStandingApproval = (
|
|
|
174
190
|
const cls = operationAutoClass(operation);
|
|
175
191
|
if (!cls || cls !== standing.class)
|
|
176
192
|
return failure("permission_denied", "standing approval does not cover this operation");
|
|
177
|
-
const
|
|
193
|
+
const target = targetOf(root, binding.approvedContent);
|
|
194
|
+
if (!target) return failure("permission_denied", "standing action target is invalid");
|
|
195
|
+
const resolved = resolveAutoApproval(target);
|
|
178
196
|
if (
|
|
179
197
|
resolved.status !== "on" ||
|
|
180
198
|
resolved.workspace !== standing.workspace ||
|
|
@@ -182,7 +200,7 @@ export const verifyStandingApproval = (
|
|
|
182
200
|
)
|
|
183
201
|
return failure("permission_denied", "standing auto-approval is not live for this operation");
|
|
184
202
|
if (cls === "push") {
|
|
185
|
-
const workspace = resolveWorkspaceFrom(
|
|
203
|
+
const workspace = resolveWorkspaceFrom(target, configDir());
|
|
186
204
|
const provider = workspace?.vcs?.provider;
|
|
187
205
|
const account = resolved.account;
|
|
188
206
|
if (!provider || !account)
|
|
@@ -190,7 +208,7 @@ export const verifyStandingApproval = (
|
|
|
190
208
|
"permission_denied",
|
|
191
209
|
"auto-push requires a workspace provider and area account",
|
|
192
210
|
);
|
|
193
|
-
const identity = verifyPushIdentity(
|
|
211
|
+
const identity = verifyPushIdentity(target, provider, account);
|
|
194
212
|
if (!identity.ok) return failure("permission_denied", identity.error);
|
|
195
213
|
}
|
|
196
214
|
return success(null, null, {
|
|
@@ -198,7 +216,7 @@ export const verifyStandingApproval = (
|
|
|
198
216
|
host: caller.host,
|
|
199
217
|
session: { kind: "host", host: caller.host, handle: caller.actor },
|
|
200
218
|
workerId: null,
|
|
201
|
-
receipts: [standingReceiptFor(
|
|
219
|
+
receipts: [standingReceiptFor(target, standing.workspace, cls)],
|
|
202
220
|
} as Provenance);
|
|
203
221
|
};
|
|
204
222
|
|
|
@@ -229,14 +247,15 @@ export const standingAutoApplies = (
|
|
|
229
247
|
actor: string,
|
|
230
248
|
operation: string,
|
|
231
249
|
): StandingMatch | null => {
|
|
232
|
-
let descriptor: { operation?: unknown };
|
|
250
|
+
let descriptor: { operation?: unknown; payload?: unknown };
|
|
233
251
|
try {
|
|
234
|
-
descriptor = JSON.parse(operation) as { operation?: unknown };
|
|
252
|
+
descriptor = JSON.parse(operation) as { operation?: unknown; payload?: unknown };
|
|
235
253
|
} catch {
|
|
236
254
|
return null;
|
|
237
255
|
}
|
|
238
256
|
const cls = operationAutoClass(descriptor.operation);
|
|
239
|
-
|
|
257
|
+
const target = targetOf(store.root, descriptor);
|
|
258
|
+
if (!cls || !target || !autoApproves(target, cls)) return null;
|
|
240
259
|
const listed = store.listTasks();
|
|
241
260
|
const workspace = store.readWorkspace();
|
|
242
261
|
if (!listed.ok || !workspace.ok || !workspace.data) return null;
|
|
@@ -244,7 +263,7 @@ export const standingAutoApplies = (
|
|
|
244
263
|
currentWriterOwnsTask(task, workspace.data!, { host, actor }),
|
|
245
264
|
);
|
|
246
265
|
if (!task) return null;
|
|
247
|
-
const resolved = resolveAutoApproval(
|
|
266
|
+
const resolved = resolveAutoApproval(target);
|
|
248
267
|
if (resolved.status !== "on") return null;
|
|
249
268
|
return {
|
|
250
269
|
task,
|
|
@@ -271,18 +290,19 @@ export const standingAutoBinding = (
|
|
|
271
290
|
actor: string,
|
|
272
291
|
operation: string,
|
|
273
292
|
): StandingAutoBinding | null => {
|
|
274
|
-
let descriptor: { operation?: unknown };
|
|
293
|
+
let descriptor: { operation?: unknown; payload?: unknown };
|
|
275
294
|
try {
|
|
276
|
-
descriptor = JSON.parse(operation) as { operation?: unknown };
|
|
295
|
+
descriptor = JSON.parse(operation) as { operation?: unknown; payload?: unknown };
|
|
277
296
|
} catch {
|
|
278
297
|
return null;
|
|
279
298
|
}
|
|
280
299
|
const cls = operationAutoClass(descriptor.operation);
|
|
281
|
-
|
|
300
|
+
const target = targetOf(store.root, descriptor);
|
|
301
|
+
if (!cls || !target || !autoApproves(target, cls)) return null;
|
|
282
302
|
const applies = standingAutoApplies(store, host, actor, operation);
|
|
283
303
|
if (!applies) return null;
|
|
284
304
|
const { task } = applies;
|
|
285
|
-
const resolved = resolveAutoApproval(
|
|
305
|
+
const resolved = resolveAutoApproval(target);
|
|
286
306
|
if (resolved.status !== "on") return null;
|
|
287
307
|
const presented =
|
|
288
308
|
`Workit decision: action — auto-approved ${String(descriptor.operation)} ` +
|
package/src/core/branch.ts
CHANGED
|
@@ -1,18 +1,229 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { matchCommitFlavor } from "./commit-flavors";
|
|
4
6
|
import { gitContext } from "./git";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import {
|
|
8
|
+
readConfig,
|
|
9
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
10
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
11
|
+
} from "./config";
|
|
12
|
+
import {
|
|
13
|
+
resolveRuntimeWorkspacePolicy,
|
|
14
|
+
type WorkspaceBranchPolicy,
|
|
15
|
+
type WorkspaceCommitPolicy,
|
|
16
|
+
} from "./workspaces";
|
|
17
|
+
import { vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
function effectiveWorkspacePolicy(
|
|
20
|
+
workspaceRoot: string,
|
|
21
|
+
kind: "branch",
|
|
22
|
+
profileName?: string,
|
|
23
|
+
): {
|
|
24
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
25
|
+
provenance: { branchPolicy: string };
|
|
26
|
+
};
|
|
27
|
+
function effectiveWorkspacePolicy(
|
|
28
|
+
workspaceRoot: string,
|
|
29
|
+
kind: "commit",
|
|
30
|
+
profileName?: string,
|
|
31
|
+
): {
|
|
32
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
33
|
+
provenance: { commitPolicy: string };
|
|
34
|
+
};
|
|
35
|
+
function effectiveWorkspacePolicy(
|
|
36
|
+
workspaceRoot: string,
|
|
37
|
+
kind: "branch" | "commit",
|
|
38
|
+
profileName?: string,
|
|
39
|
+
):
|
|
40
|
+
| {
|
|
41
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
42
|
+
provenance: { branchPolicy: string };
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
46
|
+
provenance: { commitPolicy: string };
|
|
47
|
+
} {
|
|
48
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
49
|
+
workspaceRoot,
|
|
50
|
+
kind,
|
|
51
|
+
profileName?.trim() || process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
52
|
+
);
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
return kind === "branch"
|
|
55
|
+
? {
|
|
56
|
+
branchPolicy: resolveConfiguredBranchPolicy(
|
|
57
|
+
config,
|
|
58
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
59
|
+
),
|
|
60
|
+
provenance: { branchPolicy: selected.source },
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
commitPolicy: resolveConfiguredCommitPolicy(
|
|
64
|
+
config,
|
|
65
|
+
selected.policy ? { commitPolicy: selected.policy as WorkspaceCommitPolicy } : null,
|
|
66
|
+
),
|
|
67
|
+
provenance: { commitPolicy: selected.source },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type BranchNamePolicy = {
|
|
72
|
+
allowed: readonly RegExp[];
|
|
73
|
+
protected: ReadonlySet<string>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type BranchNamePolicyCheck =
|
|
77
|
+
| { ok: true }
|
|
78
|
+
| {
|
|
79
|
+
ok: false;
|
|
80
|
+
rule: "protected_ref" | "allowed_pattern" | "policy_unavailable";
|
|
81
|
+
source: string;
|
|
82
|
+
attempted: string;
|
|
83
|
+
correction: string;
|
|
84
|
+
error: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type CommitMessagePolicyCheck =
|
|
88
|
+
| { ok: true }
|
|
89
|
+
| {
|
|
90
|
+
ok: false;
|
|
91
|
+
rule: "commit_style" | "policy_unavailable";
|
|
92
|
+
source: string;
|
|
93
|
+
attempted: string;
|
|
94
|
+
correction: string;
|
|
95
|
+
error: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const validateBranchNamePolicy = (
|
|
99
|
+
name: string,
|
|
100
|
+
policy: BranchNamePolicy,
|
|
101
|
+
source: string,
|
|
102
|
+
): BranchNamePolicyCheck => {
|
|
103
|
+
const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
|
|
104
|
+
if (policy.protected.has(name.toLowerCase())) {
|
|
105
|
+
const correction = `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
rule: "protected_ref",
|
|
109
|
+
source,
|
|
110
|
+
attempted: name,
|
|
111
|
+
correction,
|
|
112
|
+
error: `protected_ref: branch ${JSON.stringify(name)} is protected by ${source}; ${correction}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!policy.allowed.some((pattern) => pattern.test(name))) {
|
|
116
|
+
const correction = `choose a branch matching one of: ${allowed.join(", ")}`;
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
rule: "allowed_pattern",
|
|
120
|
+
source,
|
|
121
|
+
attempted: name,
|
|
122
|
+
correction,
|
|
123
|
+
error: `allowed_pattern: branch ${JSON.stringify(name)} violates ${source} policy; ${correction}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const branchPolicySnapshotFor = (workspaceRoot: string) => {
|
|
130
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "branch");
|
|
131
|
+
const branchPolicy = resolved.branchPolicy;
|
|
132
|
+
const source = resolved.provenance.branchPolicy;
|
|
133
|
+
const allowed = branchPolicy.allowed
|
|
134
|
+
.map((pattern) => `${pattern.source}/${pattern.flags}`)
|
|
135
|
+
.sort();
|
|
136
|
+
const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).sort();
|
|
137
|
+
const fingerprint = createHash("sha256")
|
|
138
|
+
.update(JSON.stringify({ allowed, protectedRefs, source }))
|
|
139
|
+
.digest("hex");
|
|
140
|
+
return {
|
|
141
|
+
branchPolicy,
|
|
142
|
+
namePolicy: { allowed: branchPolicy.allowed, protected: branchPolicy.protected },
|
|
143
|
+
source,
|
|
144
|
+
fingerprint,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const commitPolicySnapshotFor = (workspaceRoot: string) => {
|
|
149
|
+
const resolved = effectiveWorkspacePolicy(workspaceRoot, "commit");
|
|
150
|
+
const policy = resolved.commitPolicy;
|
|
151
|
+
const source = resolved.provenance.commitPolicy;
|
|
152
|
+
const fingerprint = createHash("sha256")
|
|
153
|
+
.update(JSON.stringify({ preset: policy.preset, pattern: policy.pattern ?? null, source }))
|
|
154
|
+
.digest("hex");
|
|
155
|
+
return { policy, source, fingerprint };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const validateCommitMessagePolicy = (
|
|
159
|
+
message: string,
|
|
160
|
+
policy: { preset: Parameters<typeof matchCommitFlavor>[1]; pattern?: string },
|
|
161
|
+
source: string,
|
|
162
|
+
): CommitMessagePolicyCheck => {
|
|
163
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
164
|
+
if (matchCommitFlavor(message, policy.preset, policy.pattern)) return { ok: true };
|
|
165
|
+
const correction =
|
|
166
|
+
policy.preset === "custom" && policy.pattern
|
|
167
|
+
? `use a commit subject matching ${JSON.stringify(policy.pattern)}`
|
|
168
|
+
: `use a ${policy.preset} commit subject`;
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
rule: "commit_style",
|
|
172
|
+
source,
|
|
173
|
+
attempted,
|
|
174
|
+
correction,
|
|
175
|
+
error: `commit_style: commit subject ${JSON.stringify(attempted)} violates ${source} ${policy.preset} policy; ${correction}`,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
12
178
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
179
|
+
export const validateBranchNameFor = (
|
|
180
|
+
workspaceRoot: string,
|
|
181
|
+
name: string,
|
|
182
|
+
): BranchNamePolicyCheck => {
|
|
183
|
+
try {
|
|
184
|
+
const resolved = branchPolicySnapshotFor(workspaceRoot);
|
|
185
|
+
return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const correction = "repair or select a valid workspace policy before creating a branch";
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
rule: "policy_unavailable",
|
|
191
|
+
source: "workspaces.json",
|
|
192
|
+
attempted: name,
|
|
193
|
+
correction,
|
|
194
|
+
error: `policy_unavailable: cannot validate branch ${JSON.stringify(name)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const validateCommitMessageFor = (
|
|
200
|
+
workspaceRoot: string,
|
|
201
|
+
message: string,
|
|
202
|
+
): CommitMessagePolicyCheck => {
|
|
203
|
+
const attempted = message.split("\n", 1)[0] ?? "";
|
|
204
|
+
try {
|
|
205
|
+
const resolved = commitPolicySnapshotFor(workspaceRoot);
|
|
206
|
+
return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const correction = "repair or select a valid workspace policy before committing";
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
rule: "policy_unavailable",
|
|
212
|
+
source: "workspaces.json",
|
|
213
|
+
attempted,
|
|
214
|
+
correction,
|
|
215
|
+
error: `policy_unavailable: cannot validate commit subject ${JSON.stringify(attempted)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** CA-09: one policy resolver; profiles layer above the matched workspace. */
|
|
221
|
+
export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
222
|
+
effectiveWorkspacePolicy(workspaceRoot, "branch", profileName).branchPolicy;
|
|
223
|
+
|
|
224
|
+
/** Commit-flavor equivalent using the same profile and provenance chain. */
|
|
225
|
+
export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
226
|
+
effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
|
|
16
227
|
|
|
17
228
|
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
18
229
|
const allowedBranch = (root: string, name: string) =>
|
|
@@ -40,25 +251,13 @@ export const verifyPushIdentity = (
|
|
|
40
251
|
): { ok: true } | { ok: false; error: string } => {
|
|
41
252
|
if (!account || typeof account !== "string")
|
|
42
253
|
return { ok: false, error: "push identity requires a configured area account" };
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const bin = provider === "gitlab" ? "glab" : "gh";
|
|
48
|
-
let login: string;
|
|
49
|
-
try {
|
|
50
|
-
login = execFileSync(bin, [...probe], {
|
|
51
|
-
cwd: root,
|
|
52
|
-
encoding: "utf8",
|
|
53
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
54
|
-
}).trim();
|
|
55
|
-
} catch {
|
|
56
|
-
return { ok: false, error: `${bin} identity could not be resolved` };
|
|
57
|
-
}
|
|
58
|
-
if (!login || login.toLowerCase() !== account.toLowerCase())
|
|
254
|
+
const identity = vcsCliIdentity(root);
|
|
255
|
+
if (!identity.ok || identity.provider !== provider)
|
|
256
|
+
return { ok: false, error: identity.error ?? "hosting CLI identity could not be resolved" };
|
|
257
|
+
if (identity.username?.toLowerCase() !== account.toLowerCase())
|
|
59
258
|
return {
|
|
60
259
|
ok: false,
|
|
61
|
-
error: `push identity ${
|
|
260
|
+
error: `push identity ${identity.username || "(unknown)"} does not match area account ${account}`,
|
|
62
261
|
};
|
|
63
262
|
return { ok: true };
|
|
64
263
|
};
|
|
@@ -83,8 +282,7 @@ const readSafe = (p: string): string | null => {
|
|
|
83
282
|
|
|
84
283
|
const normalizeBranch = (root: string, name: string): string | null => {
|
|
85
284
|
const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
|
|
86
|
-
if (
|
|
87
|
-
if (!allowedBranch(root, n)) return null;
|
|
285
|
+
if (!validateBranchNameFor(root, n).ok) return null;
|
|
88
286
|
const parts = n
|
|
89
287
|
.toLowerCase()
|
|
90
288
|
.split("/")
|
|
@@ -155,9 +353,10 @@ export const resolveBranch = ({
|
|
|
155
353
|
const text = readSafe(file);
|
|
156
354
|
if (!text) continue;
|
|
157
355
|
if (USE_CURRENT_RE.test(text)) {
|
|
158
|
-
if (!current
|
|
159
|
-
|
|
160
|
-
|
|
356
|
+
if (!current) return { error: `use-current but HEAD ${current} is not an allowed branch` };
|
|
357
|
+
const policyCheck = validateBranchNameFor(cwd, current);
|
|
358
|
+
if (!policyCheck.ok)
|
|
359
|
+
return { error: `use-current but HEAD ${current} is not allowed: ${policyCheck.error}` };
|
|
161
360
|
return finish(current, "use-current");
|
|
162
361
|
}
|
|
163
362
|
}
|
|
@@ -176,8 +375,11 @@ export const resolveBranch = ({
|
|
|
176
375
|
}
|
|
177
376
|
}
|
|
178
377
|
if (declaredButInvalid) {
|
|
378
|
+
const policyCheck = validateBranchNameFor(cwd, declaredButInvalid);
|
|
179
379
|
return {
|
|
180
|
-
error:
|
|
380
|
+
error: policyCheck.ok
|
|
381
|
+
? `declared branch ${JSON.stringify(declaredButInvalid)} is not a valid branch name`
|
|
382
|
+
: policyCheck.error,
|
|
181
383
|
};
|
|
182
384
|
}
|
|
183
385
|
|
|
@@ -271,7 +473,12 @@ const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: stri
|
|
|
271
473
|
|
|
272
474
|
// Mutating half of ensureBaseBranch: fast-forwards the local base (creating
|
|
273
475
|
// it from origin/<base> if needed). Only safe on a clean tree.
|
|
274
|
-
const fastForwardBase = (
|
|
476
|
+
const fastForwardBase = (
|
|
477
|
+
cwd: string,
|
|
478
|
+
base: string,
|
|
479
|
+
expectedRemoteBase?: string,
|
|
480
|
+
expectedLocalBase?: string | null,
|
|
481
|
+
): { ok: boolean; error?: string } => {
|
|
275
482
|
const run = (args: string[]) =>
|
|
276
483
|
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
277
484
|
try {
|
|
@@ -284,13 +491,26 @@ const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: stri
|
|
|
284
491
|
} catch {
|
|
285
492
|
hasLocalBase = false;
|
|
286
493
|
}
|
|
494
|
+
if (expectedLocalBase !== undefined) {
|
|
495
|
+
let localBase: string | null = null;
|
|
496
|
+
try {
|
|
497
|
+
localBase = run(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
498
|
+
} catch {}
|
|
499
|
+
if (localBase !== expectedLocalBase)
|
|
500
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
501
|
+
}
|
|
287
502
|
if (hasLocalBase) {
|
|
288
503
|
run(["checkout", base]);
|
|
504
|
+
if (expectedLocalBase && run(["rev-parse", "HEAD"]).trim() !== expectedLocalBase)
|
|
505
|
+
return { ok: false, error: "approved local base changed before branch setup" };
|
|
289
506
|
try {
|
|
290
|
-
run(["merge", "--ff-only", `origin/${base}`]);
|
|
507
|
+
run(["merge", "--ff-only", expectedRemoteBase ?? `origin/${base}`]);
|
|
291
508
|
} catch {
|
|
292
509
|
/* non-fast-forward: keep local */
|
|
293
510
|
}
|
|
511
|
+
} else if (expectedRemoteBase) {
|
|
512
|
+
run(["checkout", "-b", base, expectedRemoteBase]);
|
|
513
|
+
run(["branch", "--set-upstream-to", `origin/${base}`, base]);
|
|
294
514
|
} else {
|
|
295
515
|
run(["checkout", "-b", base, "--track", `origin/${base}`]);
|
|
296
516
|
}
|
|
@@ -369,6 +589,8 @@ export const branchSetup = ({
|
|
|
369
589
|
stash,
|
|
370
590
|
workspace_root,
|
|
371
591
|
log,
|
|
592
|
+
expected_remote_base,
|
|
593
|
+
expected_local_base,
|
|
372
594
|
}: {
|
|
373
595
|
action?: string;
|
|
374
596
|
sdd_dir?: string;
|
|
@@ -376,6 +598,8 @@ export const branchSetup = ({
|
|
|
376
598
|
stash?: string;
|
|
377
599
|
workspace_root: string;
|
|
378
600
|
log?: (message: string) => void;
|
|
601
|
+
expected_remote_base?: string;
|
|
602
|
+
expected_local_base?: string | null;
|
|
379
603
|
}): BranchSetupResult => {
|
|
380
604
|
const cwd = path.resolve(workspace_root);
|
|
381
605
|
const exec = (args: string[]): string =>
|
|
@@ -432,12 +656,13 @@ export const branchSetup = ({
|
|
|
432
656
|
error: "target_branch (the working branch to create or switch to) is required for setup",
|
|
433
657
|
phase: "preflight",
|
|
434
658
|
};
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
};
|
|
659
|
+
if (expected_remote_base && !/^[a-f0-9]{40,64}$/i.test(expected_remote_base))
|
|
660
|
+
return { error: "approved remote base commit is invalid", phase: "preflight" };
|
|
661
|
+
try {
|
|
662
|
+
exec(["check-ref-format", "--branch", target]);
|
|
663
|
+
} catch {
|
|
664
|
+
return { error: `invalid Git branch name ${target}`, phase: "preflight" };
|
|
665
|
+
}
|
|
441
666
|
|
|
442
667
|
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
443
668
|
// any mutation. The origin/<base> validation runs after the stash gate
|
|
@@ -449,6 +674,10 @@ export const branchSetup = ({
|
|
|
449
674
|
} catch {
|
|
450
675
|
targetExists = false;
|
|
451
676
|
}
|
|
677
|
+
if (!targetExists) {
|
|
678
|
+
const policy = validateBranchNameFor(cwd, target);
|
|
679
|
+
if (!policy.ok) return { error: policy.error, phase: "preflight" };
|
|
680
|
+
}
|
|
452
681
|
if (!targetExists) {
|
|
453
682
|
const baseResolved = baseBranch(cwd);
|
|
454
683
|
if ("error" in baseResolved) return { error: baseResolved.error, phase: "preflight" };
|
|
@@ -491,10 +720,25 @@ export const branchSetup = ({
|
|
|
491
720
|
// tree untouched. The mutating fast-forward stays below: it checks out
|
|
492
721
|
// the base branch — unsafe on a dirty tree.
|
|
493
722
|
let validatedBase: string | undefined;
|
|
723
|
+
let validatedRemoteBase: string | undefined;
|
|
724
|
+
let validatedLocalBase: string | null | undefined;
|
|
494
725
|
if (!targetExists && base !== undefined) {
|
|
495
726
|
const ready = originBaseReady(cwd, base);
|
|
496
727
|
if (!ready.ok)
|
|
497
728
|
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
729
|
+
const fetchedBase = exec(["rev-parse", "--verify", `refs/remotes/origin/${base}`]).trim();
|
|
730
|
+
if (expected_remote_base && fetchedBase !== expected_remote_base)
|
|
731
|
+
return { error: "approved remote base changed before branch setup", phase: "preflight" };
|
|
732
|
+
if (expected_local_base !== undefined) {
|
|
733
|
+
let localBase: string | null = null;
|
|
734
|
+
try {
|
|
735
|
+
localBase = exec(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
|
|
736
|
+
} catch {}
|
|
737
|
+
if (localBase !== expected_local_base)
|
|
738
|
+
return { error: "approved local base changed before branch setup", phase: "preflight" };
|
|
739
|
+
validatedLocalBase = expected_local_base;
|
|
740
|
+
}
|
|
741
|
+
if (expected_remote_base) validatedRemoteBase = expected_remote_base;
|
|
498
742
|
validatedBase = base;
|
|
499
743
|
}
|
|
500
744
|
if (dirty && stash === "yes") {
|
|
@@ -531,10 +775,17 @@ export const branchSetup = ({
|
|
|
531
775
|
// fast-forward mutation remains post-stash.
|
|
532
776
|
const baseResult =
|
|
533
777
|
validatedBase !== undefined
|
|
534
|
-
? fastForwardBase(cwd, effectiveBase)
|
|
778
|
+
? fastForwardBase(cwd, effectiveBase, validatedRemoteBase, validatedLocalBase)
|
|
535
779
|
: ensureBaseBranch(cwd, effectiveBase);
|
|
536
780
|
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
537
|
-
exec(["
|
|
781
|
+
const targetBase = exec(["rev-parse", "HEAD"]).trim();
|
|
782
|
+
if (
|
|
783
|
+
validatedRemoteBase &&
|
|
784
|
+
validatedLocalBase !== undefined &&
|
|
785
|
+
![validatedRemoteBase, validatedLocalBase].includes(targetBase)
|
|
786
|
+
)
|
|
787
|
+
return failAfterStash("approved branch base changed before branch creation", "preflight");
|
|
788
|
+
exec(["checkout", "-b", target, targetBase]);
|
|
538
789
|
journal("post-create");
|
|
539
790
|
} catch (createError) {
|
|
540
791
|
return failAfterStash(
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isConfigObject, mergePreset, type BranchPreset, type ToolkitConfig } from "./config";
|
|
5
|
+
import { writeFileAtomic } from "./safe-write";
|
|
5
6
|
|
|
6
7
|
export type ConversionInput = {
|
|
7
8
|
configDir: string;
|
|
@@ -170,11 +171,10 @@ export function applyConversionConfig(
|
|
|
170
171
|
delete next.workflowMode;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
|
|
174
|
-
writeFileSync(configFile, JSON.stringify(next, null, 2) + "\n");
|
|
174
|
+
writeFileAtomic(configFile, JSON.stringify(next, null, 2) + "\n");
|
|
175
175
|
|
|
176
176
|
const choicesPath = path.join(configDir, "cutover-choices.json");
|
|
177
|
-
|
|
177
|
+
writeFileAtomic(
|
|
178
178
|
choicesPath,
|
|
179
179
|
JSON.stringify({ resolutions, applied_at: new Date().toISOString() }, null, 2) + "\n",
|
|
180
180
|
);
|
package/src/core/config-guard.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { initStatus } from "./init";
|
|
2
2
|
|
|
3
|
-
export const ALL_ITEM_IDS = [
|
|
4
|
-
"youtrack_json",
|
|
5
|
-
"youtrack_token",
|
|
6
|
-
"vcs_json",
|
|
7
|
-
"gitlab_token",
|
|
8
|
-
"github_token",
|
|
9
|
-
];
|
|
3
|
+
export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json"];
|
|
10
4
|
export const CONFIG_GAP_MARKER = "workflow config missing";
|
|
11
5
|
|
|
12
6
|
export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
|