@deksden-com/dd-flow-cli 0.1.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/README.md +274 -0
- package/dist/cli/help.js +308 -0
- package/dist/cli/run-cli.js +945 -0
- package/dist/cli.js +4 -0
- package/dist/domain/contracts.js +57 -0
- package/dist/domain/entity-ids.js +47 -0
- package/dist/domain/flow-contract.js +233 -0
- package/dist/domain/validation.js +91 -0
- package/dist/protocol/local-files.js +141 -0
- package/dist/runtime/context.js +11 -0
- package/dist/schemas/code-stage-report.schema.json +181 -0
- package/dist/schemas/flow-run-index.schema.json +129 -0
- package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
- package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
- package/dist/schemas/merge-stage-report.schema.json +135 -0
- package/dist/services/audit.js +19 -0
- package/dist/services/cleanup.js +310 -0
- package/dist/services/config.js +143 -0
- package/dist/services/dashboard.js +436 -0
- package/dist/services/hooks.js +929 -0
- package/dist/services/lanes.js +327 -0
- package/dist/services/memory-permissions.js +344 -0
- package/dist/services/merge-queue.js +333 -0
- package/dist/services/plans.js +149 -0
- package/dist/services/projects.js +286 -0
- package/dist/services/protocols.js +606 -0
- package/dist/services/runs.js +359 -0
- package/dist/services/schema-validation.js +185 -0
- package/dist/services/sessions.js +365 -0
- package/dist/services/worktrees.js +204 -0
- package/dist/shared/errors.js +14 -0
- package/dist/shared/json.js +17 -0
- package/dist/storage/database.js +325 -0
- package/dist/storage/paths.js +56 -0
- package/package.json +44 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { appendAudit } from "./audit.js";
|
|
7
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
8
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
|
+
const defaultTtlSeconds = 300;
|
|
10
|
+
const defaultPollIntervalSeconds = 10;
|
|
11
|
+
export function getLaneStatus(context, input) {
|
|
12
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
|
+
expireStaleLocks(context, project.id);
|
|
14
|
+
return {
|
|
15
|
+
ok: true,
|
|
16
|
+
lanes: lanesForProject(context, project.id, input.lane),
|
|
17
|
+
locks: laneLocksForProject(context, project.id, input.lane)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function setLaneWorkspace(context, input) {
|
|
21
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
22
|
+
const lane = normalizeLane(input.lane);
|
|
23
|
+
const workspacePath = resolveExistingPath(input.workspacePath);
|
|
24
|
+
const now = context.now();
|
|
25
|
+
const existing = laneRecord(context, project.id, lane);
|
|
26
|
+
context.db.run(`INSERT INTO lanes
|
|
27
|
+
(project_id, name, workspace_path, expected_branch, status, created_at, updated_at)
|
|
28
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?)
|
|
29
|
+
ON CONFLICT(project_id, name) DO UPDATE SET
|
|
30
|
+
workspace_path = excluded.workspace_path,
|
|
31
|
+
expected_branch = excluded.expected_branch,
|
|
32
|
+
status = 'active',
|
|
33
|
+
updated_at = excluded.updated_at`, [project.id, lane, workspacePath, input.branch ?? null, existing?.created_at ?? now, now]);
|
|
34
|
+
appendAudit(context, {
|
|
35
|
+
projectId: project.id,
|
|
36
|
+
eventType: "lane.workspace_set",
|
|
37
|
+
payload: { project_id: project.id, lane, workspace_path: workspacePath, expected_branch: input.branch ?? null }
|
|
38
|
+
});
|
|
39
|
+
return { ok: true, lane: laneRecord(context, project.id, lane) };
|
|
40
|
+
}
|
|
41
|
+
export function checkLaneWorkspace(context, input) {
|
|
42
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
43
|
+
const lane = requireLane(context, project.id, normalizeLane(input.lane));
|
|
44
|
+
const workspacePath = resolveExistingPath(input.workspacePath);
|
|
45
|
+
if (workspacePath !== lane.workspace_path) {
|
|
46
|
+
throw new AppError("lane_workspace_mismatch", "Workspace path does not match registered lane workspace", 1, {
|
|
47
|
+
lane: lane.name,
|
|
48
|
+
expected_path: lane.workspace_path,
|
|
49
|
+
actual_path: workspacePath
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const branch = detectBranch(workspacePath);
|
|
53
|
+
const branchMatches = lane.expected_branch && branch.current ? lane.expected_branch === branch.current : null;
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
lane,
|
|
57
|
+
workspace_path: workspacePath,
|
|
58
|
+
branch: {
|
|
59
|
+
...branch,
|
|
60
|
+
expected: lane.expected_branch,
|
|
61
|
+
matches_expected: branchMatches
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function acquireLaneLock(context, input) {
|
|
66
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
67
|
+
const lane = normalizeLane(input.lane);
|
|
68
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
69
|
+
assertWorkerMayAcquireLaneLock(context, project.id, lane, input.workerId);
|
|
70
|
+
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
71
|
+
expireStaleLocks(context, project.id);
|
|
72
|
+
const active = activeLaneLock(context, project.id, lane);
|
|
73
|
+
if (active) {
|
|
74
|
+
if (active.worker_id === input.workerId) {
|
|
75
|
+
const now = context.now();
|
|
76
|
+
context.db.run(`UPDATE lane_locks
|
|
77
|
+
SET heartbeat_at = ?, expires_at = ?, updated_at = ?
|
|
78
|
+
WHERE id = ?`, [now, expiresAt(now, ttlSeconds), now, active.id]);
|
|
79
|
+
appendAudit(context, {
|
|
80
|
+
projectId: project.id,
|
|
81
|
+
eventType: "lane_lock.reused",
|
|
82
|
+
payload: { project_id: project.id, lane, worker_id: input.workerId, lock_id: active.id }
|
|
83
|
+
});
|
|
84
|
+
return { ok: true, lock: activeLaneLock(context, project.id, lane), reused: true };
|
|
85
|
+
}
|
|
86
|
+
throw new AppError("lane_lock_active", "Lane already has an active lock", 1, {
|
|
87
|
+
lane,
|
|
88
|
+
worker_id: active.worker_id,
|
|
89
|
+
expires_at: active.expires_at
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const expired = latestExpiredLock(context, project.id, lane);
|
|
93
|
+
const now = context.now();
|
|
94
|
+
const leaseToken = crypto.randomUUID();
|
|
95
|
+
context.db.run(`INSERT INTO lane_locks
|
|
96
|
+
(project_id, lane, worker_id, status, lease_token, acquired_at, heartbeat_at,
|
|
97
|
+
expires_at, released_at, reason, metadata_json, created_at, updated_at)
|
|
98
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, NULL, ?, '{}', ?, ?)`, [project.id, lane, input.workerId, leaseToken, now, now, expiresAt(now, ttlSeconds), input.reason, now, now]);
|
|
99
|
+
appendAudit(context, {
|
|
100
|
+
projectId: project.id,
|
|
101
|
+
eventType: expired ? "lane_lock.expired_takeover" : "lane_lock.acquired",
|
|
102
|
+
reason: input.reason,
|
|
103
|
+
payload: { project_id: project.id, lane, worker_id: input.workerId, expired_lock_id: expired?.id ?? null }
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
lock: activeLaneLock(context, project.id, lane),
|
|
108
|
+
reused: false,
|
|
109
|
+
...(expired ? { expired_lock_id: expired.id } : {})
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function heartbeatLaneLock(context, input) {
|
|
113
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
114
|
+
const lane = normalizeLane(input.lane);
|
|
115
|
+
if (input.workspacePath) {
|
|
116
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
117
|
+
}
|
|
118
|
+
const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
|
|
119
|
+
const ttlSeconds = normalizePositiveNumber(input.ttlSeconds, defaultTtlSeconds, "ttl");
|
|
120
|
+
const now = context.now();
|
|
121
|
+
context.db.run(`UPDATE lane_locks SET heartbeat_at = ?, expires_at = ?, updated_at = ? WHERE id = ?`, [now, expiresAt(now, ttlSeconds), now, lock.id]);
|
|
122
|
+
appendAudit(context, {
|
|
123
|
+
projectId: project.id,
|
|
124
|
+
eventType: "lane_lock.heartbeat",
|
|
125
|
+
payload: { project_id: project.id, lane: lock.lane, worker_id: input.workerId, lock_id: lock.id }
|
|
126
|
+
});
|
|
127
|
+
return { ok: true, lock: activeLaneLock(context, project.id, lock.lane) };
|
|
128
|
+
}
|
|
129
|
+
export function releaseLaneLock(context, input) {
|
|
130
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
131
|
+
const lane = normalizeLane(input.lane);
|
|
132
|
+
if (input.workspacePath) {
|
|
133
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
134
|
+
}
|
|
135
|
+
const lock = requireOwnedActiveLock(context, project.id, lane, input.workerId, input.leaseToken);
|
|
136
|
+
const now = context.now();
|
|
137
|
+
context.db.run(`UPDATE lane_locks
|
|
138
|
+
SET status = 'released', released_at = ?, reason = ?, updated_at = ?
|
|
139
|
+
WHERE id = ?`, [now, input.reason, now, lock.id]);
|
|
140
|
+
appendAudit(context, {
|
|
141
|
+
projectId: project.id,
|
|
142
|
+
eventType: "lane_lock.released",
|
|
143
|
+
reason: input.reason,
|
|
144
|
+
payload: { project_id: project.id, lane: lock.lane, worker_id: input.workerId, lock_id: lock.id }
|
|
145
|
+
});
|
|
146
|
+
return { ok: true, lock: laneLockById(context, lock.id) };
|
|
147
|
+
}
|
|
148
|
+
export async function waitForLaneLock(context, input) {
|
|
149
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
150
|
+
const lane = normalizeLane(input.lane);
|
|
151
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
152
|
+
const timeoutSeconds = normalizeNonNegativeNumber(input.timeoutSeconds, 0, "timeout");
|
|
153
|
+
const pollIntervalSeconds = normalizePositiveNumber(input.pollIntervalSeconds, defaultPollIntervalSeconds, "poll-interval");
|
|
154
|
+
const start = Date.now();
|
|
155
|
+
while (true) {
|
|
156
|
+
expireStaleLocks(context, project.id);
|
|
157
|
+
const active = activeLaneLock(context, project.id, lane);
|
|
158
|
+
if (!active || active.worker_id === input.workerId) {
|
|
159
|
+
return { ok: true, available: true, lock: active ?? null };
|
|
160
|
+
}
|
|
161
|
+
if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
|
|
162
|
+
appendAudit(context, {
|
|
163
|
+
projectId: project.id,
|
|
164
|
+
eventType: "lane_lock.wait_timeout",
|
|
165
|
+
payload: { project_id: project.id, lane, worker_id: input.workerId, timeout_seconds: timeoutSeconds }
|
|
166
|
+
});
|
|
167
|
+
return { ok: true, available: false, timed_out: true, lock: active };
|
|
168
|
+
}
|
|
169
|
+
await delay(pollIntervalSeconds * 1000);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function requireLaneLockOwner(context, input) {
|
|
173
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
174
|
+
const lane = normalizeLane(input.lane);
|
|
175
|
+
if (input.workspacePath) {
|
|
176
|
+
requireMatchingLaneWorkspace(context, project.id, lane, input.workspacePath);
|
|
177
|
+
}
|
|
178
|
+
return requireOwnedActiveLock(context, project.id, lane, input.workerId);
|
|
179
|
+
}
|
|
180
|
+
export function ensureLaneWorkspace(context, input) {
|
|
181
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
182
|
+
const lane = normalizeLane(input.lane);
|
|
183
|
+
const existing = laneRecord(context, project.id, lane);
|
|
184
|
+
if (existing) {
|
|
185
|
+
return existing;
|
|
186
|
+
}
|
|
187
|
+
const workspacePath = resolveExistingPath(input.workspacePath ?? project.root);
|
|
188
|
+
const now = context.now();
|
|
189
|
+
context.db.run(`INSERT INTO lanes
|
|
190
|
+
(project_id, name, workspace_path, expected_branch, status, created_at, updated_at)
|
|
191
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?)`, [project.id, lane, workspacePath, input.branch ?? null, now, now]);
|
|
192
|
+
appendAudit(context, {
|
|
193
|
+
projectId: project.id,
|
|
194
|
+
eventType: "lane.workspace_set",
|
|
195
|
+
payload: { project_id: project.id, lane, workspace_path: workspacePath, expected_branch: input.branch ?? null }
|
|
196
|
+
});
|
|
197
|
+
return requireLane(context, project.id, lane);
|
|
198
|
+
}
|
|
199
|
+
export function laneRecord(context, projectId, lane) {
|
|
200
|
+
return context.db.get("SELECT * FROM lanes WHERE project_id = ? AND name = ?", [projectId, lane]);
|
|
201
|
+
}
|
|
202
|
+
function requireLane(context, projectId, lane) {
|
|
203
|
+
const record = laneRecord(context, projectId, lane);
|
|
204
|
+
if (!record) {
|
|
205
|
+
throw new AppError("lane_not_registered", `Lane is not registered: ${lane}`, 1, { lane });
|
|
206
|
+
}
|
|
207
|
+
return record;
|
|
208
|
+
}
|
|
209
|
+
function requireMatchingLaneWorkspace(context, projectId, lane, workspacePath) {
|
|
210
|
+
const record = requireLane(context, projectId, lane);
|
|
211
|
+
const actualPath = resolveExistingPath(workspacePath);
|
|
212
|
+
if (actualPath !== record.workspace_path) {
|
|
213
|
+
throw new AppError("lane_workspace_mismatch", "Workspace path does not match registered lane workspace", 1, {
|
|
214
|
+
lane: record.name,
|
|
215
|
+
expected_path: record.workspace_path,
|
|
216
|
+
actual_path: actualPath
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return record;
|
|
220
|
+
}
|
|
221
|
+
function lanesForProject(context, projectId, lane) {
|
|
222
|
+
if (lane) {
|
|
223
|
+
return context.db.all("SELECT * FROM lanes WHERE project_id = ? AND name = ? ORDER BY name ASC", [
|
|
224
|
+
projectId,
|
|
225
|
+
normalizeLane(lane)
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
return context.db.all("SELECT * FROM lanes WHERE project_id = ? ORDER BY name ASC", [projectId]);
|
|
229
|
+
}
|
|
230
|
+
function laneLocksForProject(context, projectId, lane) {
|
|
231
|
+
if (lane) {
|
|
232
|
+
return context.db.all(`SELECT * FROM lane_locks WHERE project_id = ? AND lane = ? ORDER BY updated_at DESC, id DESC LIMIT 20`, [projectId, normalizeLane(lane)]);
|
|
233
|
+
}
|
|
234
|
+
return context.db.all(`SELECT * FROM lane_locks WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 50`, [projectId]);
|
|
235
|
+
}
|
|
236
|
+
function activeLaneLock(context, projectId, lane) {
|
|
237
|
+
expireStaleLocks(context, projectId);
|
|
238
|
+
return context.db.get(`SELECT * FROM lane_locks
|
|
239
|
+
WHERE project_id = ? AND lane = ? AND status = 'active'
|
|
240
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
|
|
241
|
+
}
|
|
242
|
+
function requireOwnedActiveLock(context, projectId, lane, workerId, leaseToken) {
|
|
243
|
+
const lock = activeLaneLock(context, projectId, lane);
|
|
244
|
+
if (!lock) {
|
|
245
|
+
throw new AppError("lane_lock_not_active", "Lane has no active lock", 1, { lane });
|
|
246
|
+
}
|
|
247
|
+
if (lock.worker_id !== workerId) {
|
|
248
|
+
throw new AppError("lane_lock_owned_by_other_worker", "Lane lock is owned by another worker", 1, {
|
|
249
|
+
lane,
|
|
250
|
+
worker_id: lock.worker_id
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (leaseToken && lock.lease_token !== leaseToken) {
|
|
254
|
+
throw new AppError("lane_lock_token_mismatch", "Lane lock lease token does not match", 1, { lane });
|
|
255
|
+
}
|
|
256
|
+
return lock;
|
|
257
|
+
}
|
|
258
|
+
function latestExpiredLock(context, projectId, lane) {
|
|
259
|
+
return context.db.get(`SELECT * FROM lane_locks
|
|
260
|
+
WHERE project_id = ? AND lane = ? AND status = 'expired'
|
|
261
|
+
ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, lane]);
|
|
262
|
+
}
|
|
263
|
+
function assertWorkerMayAcquireLaneLock(context, projectId, lane, workerId) {
|
|
264
|
+
if (lane !== "merge") {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const latest = context.db.get(`SELECT status, stop_reason, flow_kind FROM flow_sessions
|
|
268
|
+
WHERE project_id = ? AND worker_id = ? AND flow_kind IN ('merge_worker', 'merge_job')
|
|
269
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
270
|
+
if (latest?.status === "stopped") {
|
|
271
|
+
throw new AppError("merge_worker_stopped", "Stopped merge worker cannot acquire the merge lane lock until it registers a new active session", 1, {
|
|
272
|
+
worker_id: workerId,
|
|
273
|
+
flow_kind: latest.flow_kind,
|
|
274
|
+
stop_reason: latest.stop_reason
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function laneLockById(context, id) {
|
|
279
|
+
return context.db.get("SELECT * FROM lane_locks WHERE id = ?", [id]);
|
|
280
|
+
}
|
|
281
|
+
function expireStaleLocks(context, projectId) {
|
|
282
|
+
const now = context.now();
|
|
283
|
+
context.db.run(`UPDATE lane_locks
|
|
284
|
+
SET status = 'expired', updated_at = ?
|
|
285
|
+
WHERE project_id = ? AND status = 'active' AND expires_at <= ?`, [now, projectId, now]);
|
|
286
|
+
}
|
|
287
|
+
function resolveExistingPath(value) {
|
|
288
|
+
const absolute = path.resolve(value);
|
|
289
|
+
if (!fs.existsSync(absolute)) {
|
|
290
|
+
throw new AppError("not_found", `Workspace path does not exist: ${absolute}`, 1);
|
|
291
|
+
}
|
|
292
|
+
return fs.realpathSync(absolute);
|
|
293
|
+
}
|
|
294
|
+
function detectBranch(workspacePath) {
|
|
295
|
+
const result = spawnSync("git", ["-C", workspacePath, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
296
|
+
if (result.status !== 0) {
|
|
297
|
+
return { checked: false, current: null, error: result.stderr.trim() || result.error?.message || "git branch unavailable" };
|
|
298
|
+
}
|
|
299
|
+
return { checked: true, current: result.stdout.trim(), error: null };
|
|
300
|
+
}
|
|
301
|
+
function expiresAt(now, ttlSeconds) {
|
|
302
|
+
return new Date(new Date(now).getTime() + ttlSeconds * 1000).toISOString();
|
|
303
|
+
}
|
|
304
|
+
function normalizeLane(value) {
|
|
305
|
+
const normalized = value.trim().toLowerCase();
|
|
306
|
+
if (!normalized || !/^[a-z0-9._-]+$/.test(normalized)) {
|
|
307
|
+
throw new AppError("validation", "Lane name must contain only lowercase letters, numbers, dot, underscore, or hyphen", 2);
|
|
308
|
+
}
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
function normalizePositiveNumber(value, fallback, label) {
|
|
312
|
+
const normalized = value ?? fallback;
|
|
313
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
314
|
+
throw new AppError("validation", `--${label} must be a positive number`, 2);
|
|
315
|
+
}
|
|
316
|
+
return normalized;
|
|
317
|
+
}
|
|
318
|
+
function normalizeNonNegativeNumber(value, fallback, label) {
|
|
319
|
+
const normalized = value ?? fallback;
|
|
320
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
321
|
+
throw new AppError("validation", `--${label} must be zero or a positive number`, 2);
|
|
322
|
+
}
|
|
323
|
+
return normalized;
|
|
324
|
+
}
|
|
325
|
+
async function delay(milliseconds) {
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
327
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
const schemaId = "dd-flow/memorybank-permissions-preflight@1";
|
|
7
|
+
const flows = ["mb-init", "mb-upgrade", "mb-audit", "mb-fix", "mb-upgrade-review", "custom"];
|
|
8
|
+
const modes = ["read", "write", "repair", "report_only"];
|
|
9
|
+
const maxScannedFiles = 5000;
|
|
10
|
+
export function preflightMemoryPermissions(options) {
|
|
11
|
+
const flow = requireEnum(options.flow, flows, "--flow");
|
|
12
|
+
const mode = requireEnum(options.mode, modes, "--mode");
|
|
13
|
+
const projectRoot = path.resolve(options.root);
|
|
14
|
+
const memoryBank = resolveInside(projectRoot, options.memoryBank);
|
|
15
|
+
const tasks = options.tasks ? resolveInside(projectRoot, options.tasks) : path.join(projectRoot, ".tasks");
|
|
16
|
+
const checks = [];
|
|
17
|
+
const platform = platformInfo(projectRoot);
|
|
18
|
+
checks.push(checkExistingDirectory("project-root-read", projectRoot, "read_existing_file", fs.constants.R_OK));
|
|
19
|
+
if (flow === "mb-init" && !fs.existsSync(memoryBank)) {
|
|
20
|
+
checks.push(checkCreateDirectory("memory-bank-create", memoryBank));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
checks.push(checkExistingDirectory("memory-bank-read", memoryBank, "read_existing_file", fs.constants.R_OK));
|
|
24
|
+
if (mode !== "read") {
|
|
25
|
+
checks.push(checkExistingDirectory("memory-bank-write", memoryBank, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
26
|
+
checks.push(...checkWritableTree(memoryBank, "memory-bank-existing-file"));
|
|
27
|
+
checks.push(probeDirectory("memory-bank-probe", memoryBank));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (mode === "write" || mode === "repair" || mode === "report_only") {
|
|
31
|
+
if (fs.existsSync(tasks)) {
|
|
32
|
+
checks.push(checkExistingDirectory("tasks-write", tasks, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
33
|
+
checks.push(probeDirectory("tasks-probe", tasks));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
checks.push(checkCreateDirectory("tasks-create", tasks));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
checks.push({
|
|
41
|
+
id: "tasks-read-mode-skipped",
|
|
42
|
+
severity: "info",
|
|
43
|
+
path: displayPath(projectRoot, tasks),
|
|
44
|
+
operation: "create_directory",
|
|
45
|
+
status: "skipped",
|
|
46
|
+
reason: "ok",
|
|
47
|
+
side_effect: "none"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const errors = checks.filter((check) => check.severity === "error" && check.status === "failed").length;
|
|
51
|
+
const warnings = checks.filter((check) => check.severity === "warning").length;
|
|
52
|
+
const ok = errors === 0;
|
|
53
|
+
return {
|
|
54
|
+
schema_id: schemaId,
|
|
55
|
+
ok,
|
|
56
|
+
exit_code: ok ? 0 : 1,
|
|
57
|
+
flow,
|
|
58
|
+
mode,
|
|
59
|
+
platform,
|
|
60
|
+
targets: {
|
|
61
|
+
project_root: displayPath(projectRoot, projectRoot),
|
|
62
|
+
memory_bank: displayPath(projectRoot, memoryBank),
|
|
63
|
+
tasks: displayPath(projectRoot, tasks)
|
|
64
|
+
},
|
|
65
|
+
checks,
|
|
66
|
+
summary: {
|
|
67
|
+
errors,
|
|
68
|
+
warnings,
|
|
69
|
+
can_continue: ok
|
|
70
|
+
},
|
|
71
|
+
remediation: remediationFor(platform.family, projectRoot, checks)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function requireEnum(value, allowed, label) {
|
|
75
|
+
if (!allowed.includes(value)) {
|
|
76
|
+
throw new AppError("validation", `${label} is not supported`, 2, { value, allowed });
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function resolveInside(projectRoot, input) {
|
|
81
|
+
return path.isAbsolute(input) ? path.resolve(input) : path.resolve(projectRoot, input);
|
|
82
|
+
}
|
|
83
|
+
function platformInfo(projectRoot) {
|
|
84
|
+
const user = safeUserInfo();
|
|
85
|
+
return {
|
|
86
|
+
os: os.platform(),
|
|
87
|
+
family: os.platform() === "win32" ? "windows" : "posix",
|
|
88
|
+
cwd: projectRoot,
|
|
89
|
+
user: user.username,
|
|
90
|
+
group: user.group
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function safeUserInfo() {
|
|
94
|
+
try {
|
|
95
|
+
const info = os.userInfo();
|
|
96
|
+
return { username: info.username, group: typeof info.gid === "number" ? String(info.gid) : "" };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { username: "", group: "" };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function checkExistingDirectory(id, target, operation, access) {
|
|
103
|
+
if (!fs.existsSync(target)) {
|
|
104
|
+
return failedCheck(id, target, operation, "not_found", "Path does not exist");
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const stat = fs.statSync(target);
|
|
108
|
+
if (!stat.isDirectory()) {
|
|
109
|
+
return failedCheck(id, target, operation, "unknown", "Path is not a directory", stat);
|
|
110
|
+
}
|
|
111
|
+
const immutable = detectImmutableFlag(target);
|
|
112
|
+
if (immutable) {
|
|
113
|
+
return failedCheck(id, target, "detect_flags", "immutable_flag", immutable, stat);
|
|
114
|
+
}
|
|
115
|
+
fs.accessSync(target, access);
|
|
116
|
+
return passedCheck(id, target, operation, stat);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return failedCheck(id, target, operation, accessReason(error, access), errorMessage(error), safeStat(target));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function checkCreateDirectory(id, target) {
|
|
123
|
+
const parent = path.dirname(target);
|
|
124
|
+
if (!fs.existsSync(parent)) {
|
|
125
|
+
return failedCheck(id, target, "create_directory", "not_found", `Parent directory does not exist: ${parent}`);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK);
|
|
129
|
+
return passedCheck(id, target, "create_directory", safeStat(parent));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return failedCheck(id, target, "create_directory", "not_writable", errorMessage(error), safeStat(parent));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function checkWritableTree(root, prefix) {
|
|
136
|
+
if (!fs.existsSync(root))
|
|
137
|
+
return [];
|
|
138
|
+
const checks = [];
|
|
139
|
+
const stack = [root];
|
|
140
|
+
let scanned = 0;
|
|
141
|
+
while (stack.length > 0 && scanned < maxScannedFiles) {
|
|
142
|
+
const current = stack.pop();
|
|
143
|
+
if (!current)
|
|
144
|
+
continue;
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
checks.push(failedCheck(`${prefix}-read-${checks.length + 1}`, current, "read_existing_file", "not_readable", errorMessage(error), safeStat(current)));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.name === ".git")
|
|
155
|
+
continue;
|
|
156
|
+
const entryPath = path.join(current, entry.name);
|
|
157
|
+
scanned += 1;
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
stack.push(entryPath);
|
|
160
|
+
checks.push(checkExistingDirectory(`${prefix}-dir-${checks.length + 1}`, entryPath, "create_file", fs.constants.W_OK | fs.constants.X_OK));
|
|
161
|
+
}
|
|
162
|
+
else if (entry.isFile()) {
|
|
163
|
+
checks.push(checkExistingFile(`${prefix}-${checks.length + 1}`, entryPath, fs.constants.R_OK | fs.constants.W_OK));
|
|
164
|
+
}
|
|
165
|
+
if (scanned >= maxScannedFiles)
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (scanned >= maxScannedFiles) {
|
|
170
|
+
checks.push({
|
|
171
|
+
id: `${prefix}-scan-limit`,
|
|
172
|
+
severity: "warning",
|
|
173
|
+
path: root,
|
|
174
|
+
operation: "stat_metadata",
|
|
175
|
+
status: "warning",
|
|
176
|
+
reason: "unknown",
|
|
177
|
+
fs_error: { message: `Stopped after ${maxScannedFiles} filesystem entries` },
|
|
178
|
+
side_effect: "none"
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return checks;
|
|
182
|
+
}
|
|
183
|
+
function checkExistingFile(id, target, access) {
|
|
184
|
+
try {
|
|
185
|
+
const stat = fs.statSync(target);
|
|
186
|
+
const immutable = detectImmutableFlag(target);
|
|
187
|
+
if (immutable) {
|
|
188
|
+
return failedCheck(id, target, "detect_flags", "immutable_flag", immutable, stat);
|
|
189
|
+
}
|
|
190
|
+
fs.accessSync(target, access);
|
|
191
|
+
const ownerWarning = ownerMismatchWarning(id, target, stat);
|
|
192
|
+
return ownerWarning ?? passedCheck(id, target, "write_existing_file", stat);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return failedCheck(id, target, "write_existing_file", accessReason(error, access), errorMessage(error), safeStat(target));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function ownerMismatchWarning(id, target, stat) {
|
|
199
|
+
if (os.platform() === "win32" || typeof process.getuid !== "function")
|
|
200
|
+
return undefined;
|
|
201
|
+
if (stat.uid === process.getuid())
|
|
202
|
+
return undefined;
|
|
203
|
+
return {
|
|
204
|
+
...metadata(id, target, "stat_metadata", stat),
|
|
205
|
+
severity: "warning",
|
|
206
|
+
status: "warning",
|
|
207
|
+
reason: "owner_mismatch",
|
|
208
|
+
fs_error: { message: "Current user has effective write access, but file owner differs from current uid" },
|
|
209
|
+
side_effect: "none"
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function probeDirectory(id, target) {
|
|
213
|
+
const probePath = path.join(target, `.dd-flow-permission-probe-${process.pid}-${Date.now()}`);
|
|
214
|
+
try {
|
|
215
|
+
fs.writeFileSync(probePath, "probe\n", { flag: "wx" });
|
|
216
|
+
fs.unlinkSync(probePath);
|
|
217
|
+
return {
|
|
218
|
+
...metadata(id, target, "create_file", safeStat(target)),
|
|
219
|
+
severity: "info",
|
|
220
|
+
status: "passed",
|
|
221
|
+
reason: "ok",
|
|
222
|
+
side_effect: "temporary_probe_created_and_removed"
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
try {
|
|
227
|
+
if (fs.existsSync(probePath))
|
|
228
|
+
fs.unlinkSync(probePath);
|
|
229
|
+
}
|
|
230
|
+
catch (cleanupError) {
|
|
231
|
+
return {
|
|
232
|
+
...failedCheck(id, target, "delete_probe", "probe_failed", errorMessage(cleanupError), safeStat(target)),
|
|
233
|
+
side_effect: "temporary_probe_cleanup_failed"
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return failedCheck(id, target, "create_file", "probe_failed", errorMessage(error), safeStat(target));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function passedCheck(id, target, operation, stat) {
|
|
240
|
+
return {
|
|
241
|
+
...metadata(id, target, operation, stat),
|
|
242
|
+
severity: "info",
|
|
243
|
+
status: "passed",
|
|
244
|
+
reason: "ok",
|
|
245
|
+
side_effect: "none"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function failedCheck(id, target, operation, reason, message, stat) {
|
|
249
|
+
return {
|
|
250
|
+
...metadata(id, target, operation, stat),
|
|
251
|
+
severity: "error",
|
|
252
|
+
status: "failed",
|
|
253
|
+
reason,
|
|
254
|
+
fs_error: { message },
|
|
255
|
+
side_effect: "none"
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function metadata(id, target, operation, stat) {
|
|
259
|
+
return {
|
|
260
|
+
id,
|
|
261
|
+
path: target,
|
|
262
|
+
operation,
|
|
263
|
+
...(stat ? statMetadata(stat) : {})
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function statMetadata(stat) {
|
|
267
|
+
if (os.platform() === "win32")
|
|
268
|
+
return {};
|
|
269
|
+
return {
|
|
270
|
+
owner: String(stat.uid),
|
|
271
|
+
group: String(stat.gid),
|
|
272
|
+
mode: `0${(stat.mode & 0o777).toString(8)}`
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function safeStat(target) {
|
|
276
|
+
try {
|
|
277
|
+
return fs.statSync(target);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function detectImmutableFlag(target) {
|
|
284
|
+
if (os.platform() === "win32")
|
|
285
|
+
return undefined;
|
|
286
|
+
if (os.platform() === "darwin" || os.platform() === "freebsd" || os.platform() === "openbsd") {
|
|
287
|
+
const result = spawnSync("ls", ["-ldO", target], { encoding: "utf8" });
|
|
288
|
+
if (result.status === 0 && /\b(uappnd|uchg|schg|sappnd)\b/.test(result.stdout)) {
|
|
289
|
+
return `immutable file flag detected: ${result.stdout.trim()}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (os.platform() === "linux") {
|
|
293
|
+
const result = spawnSync("lsattr", ["-d", target], { encoding: "utf8" });
|
|
294
|
+
if (result.status === 0 && /^[-a-zA-Z]*i[-a-zA-Z]*\s/.test(result.stdout)) {
|
|
295
|
+
return `immutable file attribute detected: ${result.stdout.trim()}`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
function accessReason(error, access) {
|
|
301
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
302
|
+
if (code === "ENOENT")
|
|
303
|
+
return "not_found";
|
|
304
|
+
if (access & fs.constants.W_OK)
|
|
305
|
+
return os.platform() === "win32" ? "acl_denied" : "not_writable";
|
|
306
|
+
if (access & fs.constants.R_OK)
|
|
307
|
+
return "not_readable";
|
|
308
|
+
return "unknown";
|
|
309
|
+
}
|
|
310
|
+
function errorMessage(error) {
|
|
311
|
+
return error instanceof Error ? error.message : String(error);
|
|
312
|
+
}
|
|
313
|
+
function remediationFor(family, projectRoot, checks) {
|
|
314
|
+
const failingPaths = checks.filter((check) => check.severity === "error" && check.status === "failed").map((check) => displayPath(projectRoot, check.path));
|
|
315
|
+
if (failingPaths.length === 0)
|
|
316
|
+
return [];
|
|
317
|
+
const scoped = Array.from(new Set(failingPaths.filter((item) => item !== ".")));
|
|
318
|
+
const targets = scoped.length <= 2 ? scoped.join(" ") : ".memory-bank .tasks";
|
|
319
|
+
if (family === "windows") {
|
|
320
|
+
return [
|
|
321
|
+
{
|
|
322
|
+
platform: "win32",
|
|
323
|
+
title: "Restore write permissions for the current Windows user",
|
|
324
|
+
commands: [
|
|
325
|
+
`attrib -R ${targets} /S /D`,
|
|
326
|
+
`icacls ${targets} /grant "%USERNAME%:(OI)(CI)M" /T`
|
|
327
|
+
],
|
|
328
|
+
requires_user_confirmation: true
|
|
329
|
+
}
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
return [
|
|
333
|
+
{
|
|
334
|
+
platform: os.platform(),
|
|
335
|
+
title: "Return Memory Bank ownership and write access to the current user",
|
|
336
|
+
commands: [`sudo chown -R "$USER:$(id -gn)" ${targets}`, `chmod -R u+rwX ${targets}`],
|
|
337
|
+
requires_user_confirmation: true
|
|
338
|
+
}
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
function displayPath(projectRoot, target) {
|
|
342
|
+
const relative = path.relative(projectRoot, target);
|
|
343
|
+
return relative === "" ? "." : relative;
|
|
344
|
+
}
|