@edspencer/paddock 0.62.0 → 0.64.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 +32 -4
- package/package.json +2 -2
- package/packages/server/dist/adoptable.d.ts +18 -0
- package/packages/server/dist/adoptable.js +1 -1
- package/packages/server/dist/app.js +17 -2
- package/packages/server/dist/claude-home.js +9 -1
- package/packages/server/dist/claude-mcp.d.ts +16 -4
- package/packages/server/dist/claude-mcp.js +83 -61
- package/packages/server/dist/claude-plugins.d.ts +131 -0
- package/packages/server/dist/claude-plugins.js +394 -0
- package/packages/server/dist/cli/args.d.ts +1 -1
- package/packages/server/dist/cli/args.js +9 -0
- package/packages/server/dist/cli/import-chats.js +1 -1
- package/packages/server/dist/git.d.ts +44 -1
- package/packages/server/dist/git.js +79 -11
- package/packages/server/dist/herdctl-agent-config.d.ts +7 -1
- package/packages/server/dist/herdctl-agent-config.js +20 -4
- package/packages/server/dist/herdctl.d.ts +7 -1
- package/packages/server/dist/herdctl.js +9 -2
- package/packages/server/dist/management-ops.js +2 -1
- package/packages/server/dist/mcp-servers.d.ts +31 -12
- package/packages/server/dist/mcp-servers.js +127 -33
- package/packages/server/dist/project-paths.d.ts +72 -4
- package/packages/server/dist/project-paths.js +76 -4
- package/packages/server/dist/project-types.d.ts +109 -11
- package/packages/server/dist/projects.d.ts +161 -3
- package/packages/server/dist/projects.js +466 -64
- package/packages/server/dist/routes/git.js +6 -3
- package/packages/server/dist/routes/projects.js +16 -2
- package/packages/server/dist/self-mcp-types.d.ts +6 -4
- package/packages/server/dist/sweep.js +10 -5
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-Ca905oey.js → InstanceConfigPage-Blr1a-hx.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-ChlC1ezH.js → MarkdownRenderer-R6YzfI9Y.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CslqbhUb.js → ProjectRedirect-Cy-7Thc-.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DkNdilGo.js → ProjectView-bMX4GEau.js} +9 -9
- package/packages/web/dist/assets/{ProjectsGrid-BrlvLc-Y.js → ProjectsGrid-C9XHf4eS.js} +1 -1
- package/packages/web/dist/assets/{arc-CGqCI5nQ.js → arc-B_42F7Yk.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Cp4KTxE2.js → architectureDiagram-3BPJPVTR-BAXzSOyw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-yFxaTv04.js → blockDiagram-GPEHLZMM-BdYEdA-3.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-59lhWjRt.js → c4Diagram-AAUBKEIU-Cc4o15R5.js} +1 -1
- package/packages/web/dist/assets/channel-BZnRb_8C.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BrpXGnJg.js → chunk-2J33WTMH-CeuxHjIm.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-UxBDb5pi.js → chunk-4BX2VUAB-DnZfrAaV.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-D4ZJyUHW.js → chunk-55IACEB6-DFE6UYOT.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DWdPj_8E.js → chunk-727SXJPM-YVHQ5cHc.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BIpNbflY.js → chunk-AQP2D5EJ-5BYSbg-F.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-f6iJDfdI.js → chunk-FMBD7UC4-HBcelndR.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-Bz3TKIJk.js → chunk-ND2GUHAM-2AwEsdxJ.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-buQmkafK.js → chunk-QZHKN3VN-BPpaZ6ct.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +2 -0
- package/packages/web/dist/assets/{core-TyFI6p0n.js → core-Cu_4vw1U.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CIkdi0XF.js → cose-bilkent-S5V4N54A-DWkF0NrF.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BnLJCfNs.js → dagre-BM42HDAG-DTgNmO9I.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D0OqfDB9.js → diagram-2AECGRRQ-DC28b7YS.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DQ2spGrK.js → diagram-5GNKFQAL-_WvuLno6.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-RDIVQ_S3.js → diagram-KO2AKTUF-BEYxPfWq.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DKoTfh2C.js → diagram-LMA3HP47-Biyev7x0.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-6k0OuSAL.js → diagram-OG6HWLK6-u47NLW3S.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-B-oQaYAC.js → erDiagram-TEJ5UH35-Bud2ISlK.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B7qpYe9C.js → flowDiagram-I6XJVG4X-D-18vLQ1.js} +1 -1
- package/packages/web/dist/assets/{format-PHdEBNOi.js → format-CQ2BDlby.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-SfBsqMmx.js → ganttDiagram-6RSMTGT7-CzyRX39N.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CuM4Rl_i.js → gitGraphDiagram-PVQCEYII-DLogfrjg.js} +1 -1
- package/packages/web/dist/assets/{index-D_1ogxg1.js → index-B0gJ0qWe.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-Dq-32Oj4.js → infoDiagram-5YYISTIA-OgNPt6RG.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BLFHCj_o.js → ishikawaDiagram-YF4QCWOH-CrM0t0_X.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CHP-Yu63.js → journeyDiagram-JHISSGLW-xbxfHfwR.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DRuehSqn.js → kanban-definition-UN3LZRKU-DpszBaZb.js} +1 -1
- package/packages/web/dist/assets/{linear-Bwz2vnH7.js → linear-BLujdbjQ.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-CeMMnCip.js → mermaid.core-DzEhy9Qq.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CEdqeoRW.js → mindmap-definition-RKZ34NQL-DaH3Lmoo.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BdDXBx87.js → pieDiagram-4H26LBE5-BxeW1kuf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bovd1Zhx.js → quadrantDiagram-W4KKPZXB-BKs2CT0r.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DiyrKnJ2.js → requirementDiagram-4Y6WPE33-CVdO-hXD.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BuX7TRVw.js → sankeyDiagram-5OEKKPKP-yusj_kJC.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-BjetZWJo.js → sequenceDiagram-3UESZ5HK-C0h-9c8Y.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-4AUBV6dF.js → stateDiagram-AJRCARHV-BSBY8Psb.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BpQ8g8Bm.js → timeline-definition-PNZ67QCA-DEFUdNb8.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Cj99BWER.js → vennDiagram-CIIHVFJN-DunhMqxK.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-S7AGTOWw.js → wardley-L42UT6IY-CXJsBGKl.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-LNXkUA7S.js → wardleyDiagram-YWT4CUSO-DfT_-dqX.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DGdTh4Xk.js → xychartDiagram-2RQKCTM6-BysTtULW.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/channel-CZs6Z1i5.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +0 -2
|
@@ -35,8 +35,9 @@ import { sanitizeAttachmentsOverride } from "./attachments-config.js";
|
|
|
35
35
|
// them at `./projects.js` — the same one-place-to-import discipline as before.
|
|
36
36
|
import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
|
|
37
37
|
export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
|
|
38
|
-
import { SLUG_RE, ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
39
|
-
export { ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
38
|
+
import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
39
|
+
export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
40
|
+
import { gitRemotes, normalizeRemote } from "./adoptable.js";
|
|
40
41
|
import { normalizeLinks, } from "./project-types.js";
|
|
41
42
|
import * as projectFiles from "./project-files.js";
|
|
42
43
|
// Barrel: re-export all type declarations + normalizeLinks + the config-type
|
|
@@ -78,8 +79,24 @@ function sanitizeModelsOverride(raw) {
|
|
|
78
79
|
}
|
|
79
80
|
export class ProjectStore {
|
|
80
81
|
root;
|
|
81
|
-
|
|
82
|
+
dataDir;
|
|
83
|
+
log;
|
|
84
|
+
/**
|
|
85
|
+
* @param root The projects root (`cfg.projectsRoot`) — every project's metadata dir.
|
|
86
|
+
* @param dataDir The instance data dir (`cfg.dataDir`), used ONLY to reject a
|
|
87
|
+
* `path:` that points inside Paddock's own state (issue #206). Optional so the
|
|
88
|
+
* many tests that construct a bare store keep compiling; when omitted, `root`
|
|
89
|
+
* alone is the containment check (in the default layout `projectsRoot` is
|
|
90
|
+
* `<dataDir>/projects`, so it is the tighter of the two anyway).
|
|
91
|
+
* @param log Where non-fatal advisories go — currently only the declared-repo /
|
|
92
|
+
* actual-remote mismatch on an existing `path` (issue #206), which must be
|
|
93
|
+
* said out loud rather than silently ignored (#659) but must not fail the
|
|
94
|
+
* create. Defaults to a no-op so a bare `new ProjectStore(root)` is unchanged.
|
|
95
|
+
*/
|
|
96
|
+
constructor(root, dataDir, log = { warn: () => { } }) {
|
|
82
97
|
this.root = root;
|
|
98
|
+
this.dataDir = dataDir;
|
|
99
|
+
this.log = log;
|
|
83
100
|
}
|
|
84
101
|
/** Ensure the projects root exists. Call once at startup. */
|
|
85
102
|
async init() {
|
|
@@ -174,6 +191,213 @@ export class ProjectStore {
|
|
|
174
191
|
return false;
|
|
175
192
|
}
|
|
176
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Recursively delete `target`, but ONLY if it is strictly inside `projectsRoot`.
|
|
196
|
+
*
|
|
197
|
+
* The single choke point for every recursive delete in this store. Before issue
|
|
198
|
+
* #206 the containment was implicit: everything a project owned — including a
|
|
199
|
+
* repo-backed project's checkout — was nested under `<projectsRoot>/<slug>/`, so
|
|
200
|
+
* `fs.rm(dir, {recursive: true})` could not reach anything that wasn't ours.
|
|
201
|
+
* A LINKED project's working directory is the user's real work repo, outside the
|
|
202
|
+
* root, and that implicit safety is gone.
|
|
203
|
+
*
|
|
204
|
+
* So the check is made explicit and centralised here rather than re-derived at
|
|
205
|
+
* each call site: an `fs.rm` that escapes throws instead of deleting. Both
|
|
206
|
+
* current callers already only pass in-root paths — this exists so that stays
|
|
207
|
+
* true when someone later adds a third one.
|
|
208
|
+
*/
|
|
209
|
+
async rmInsideRoot(target) {
|
|
210
|
+
const resolved = path.resolve(target);
|
|
211
|
+
const root = path.resolve(this.root);
|
|
212
|
+
if (resolved === root || !isPathInside(resolved, root)) {
|
|
213
|
+
throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
|
|
214
|
+
}
|
|
215
|
+
await fs.rm(resolved, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Undo a failed {@link create} by removing the directories it created — and
|
|
219
|
+
* ONLY those.
|
|
220
|
+
*
|
|
221
|
+
* This is the non-negotiable half of the acquisition rules (issue #206). The
|
|
222
|
+
* previous rollback was an unconditional `fs.rm(dir, {recursive, force})` on the
|
|
223
|
+
* slug dir, safe only because everything a project owned was nested inside it.
|
|
224
|
+
* Once `path:` lets a user nominate a clone TARGET, an unconditional recursive
|
|
225
|
+
* delete is a footgun aimed at whatever they typed — a home directory, say. So
|
|
226
|
+
* the caller records what it brought into being and this removes exactly that
|
|
227
|
+
* list; a pre-existing directory is never in it and therefore can never be
|
|
228
|
+
* deleted, no matter how the create failed.
|
|
229
|
+
*
|
|
230
|
+
* Removed deepest-first so a nested pair (slug dir + its checkout) unwinds
|
|
231
|
+
* cleanly. Failures are swallowed: the original error is the one worth
|
|
232
|
+
* reporting, and a rollback that can't finish must not mask it.
|
|
233
|
+
*/
|
|
234
|
+
async rollback(created) {
|
|
235
|
+
for (const target of [...created].reverse()) {
|
|
236
|
+
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Validate a `path:` (issue #206) and return it canonicalised, along with
|
|
241
|
+
* whether it already exists.
|
|
242
|
+
*
|
|
243
|
+
* This is the ONLY gate between a user-supplied string and a directory Paddock
|
|
244
|
+
* will hand an agent as its cwd (and may `git clone` into), so it is
|
|
245
|
+
* deliberately strict and ordered cheapest-first. It rejects:
|
|
246
|
+
*
|
|
247
|
+
* - a relative path (a cwd must be unambiguous — it is baked into every
|
|
248
|
+
* transcript path, and resolving it against the server's cwd is a footgun);
|
|
249
|
+
* - a path that exists but isn't a directory;
|
|
250
|
+
* - a path inside `projectsRoot` or the data dir — that is Paddock's own
|
|
251
|
+
* state, and pointing at it re-creates the nesting this feature exists to
|
|
252
|
+
* avoid (plus `remove()` would then really delete it);
|
|
253
|
+
* - a path that is, contains, or sits inside another project's working
|
|
254
|
+
* directory — two keepers sharing one cwd collide on transcripts (they are
|
|
255
|
+
* keyed by cwd) and on the working tree itself.
|
|
256
|
+
*
|
|
257
|
+
* It deliberately does NOT reject a directory for not being a git repository.
|
|
258
|
+
* That would be the only mandatory git check in the codebase: everywhere else
|
|
259
|
+
* paddock PROBES and degrades (`GitService.isRepo()` → false, the Changes tab
|
|
260
|
+
* removes itself, commit/push return `{error: "not a repo"}`), and the default
|
|
261
|
+
* E2E server runs on a non-git directory on purpose.
|
|
262
|
+
*
|
|
263
|
+
* A MISSING path is not an error here — it is an acquisition instruction, which
|
|
264
|
+
* {@link acquirePath} resolves. Only the existing-path branch can be
|
|
265
|
+
* canonicalised by `realpath`, so for a missing one the containment checks run
|
|
266
|
+
* against the resolved-but-not-real path plus its nearest existing ancestor —
|
|
267
|
+
* otherwise `/tmp/link -> <projectsRoot>` with a missing leaf would slip past.
|
|
268
|
+
*/
|
|
269
|
+
async validatePath(raw) {
|
|
270
|
+
const input = raw.trim();
|
|
271
|
+
if (!path.isAbsolute(input)) {
|
|
272
|
+
throw new ProjectError(`Project path must be absolute: ${input}`, "invalid");
|
|
273
|
+
}
|
|
274
|
+
// Resolve symlinks before ANY containment check — otherwise
|
|
275
|
+
// `/tmp/innocent -> <projectsRoot>/victim` would sail through them all. A
|
|
276
|
+
// path that doesn't exist yet can't be realpath'd, so fall back to resolving
|
|
277
|
+
// its nearest existing ancestor and re-appending the missing tail; that keeps
|
|
278
|
+
// a symlinked PARENT from smuggling the target back inside our own state.
|
|
279
|
+
const direct = await fs.realpath(input).catch(() => null);
|
|
280
|
+
const exists = direct !== null;
|
|
281
|
+
const resolved = direct ?? (await this.resolveViaExistingAncestor(input));
|
|
282
|
+
if (exists) {
|
|
283
|
+
const st = await fs.stat(resolved).catch(() => null);
|
|
284
|
+
if (!st?.isDirectory()) {
|
|
285
|
+
throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (isPathInside(resolved, this.root)) {
|
|
289
|
+
throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
|
|
290
|
+
}
|
|
291
|
+
if (this.dataDir && isPathInside(resolved, this.dataDir)) {
|
|
292
|
+
throw new ProjectError(`Project path must be outside the Paddock data dir (${this.dataDir}): ${input}`, "invalid");
|
|
293
|
+
}
|
|
294
|
+
// Reject overlap with an existing project's cwd in EITHER direction: pointing
|
|
295
|
+
// at a parent of another project's working dir is just as broken as pointing
|
|
296
|
+
// at a child of one.
|
|
297
|
+
for (const other of await this.list()) {
|
|
298
|
+
const cwd = other.workingDir;
|
|
299
|
+
if (isPathInside(resolved, cwd) || isPathInside(cwd, resolved)) {
|
|
300
|
+
throw new ProjectError(`Project path overlaps the working directory of project "${other.slug}" (${cwd}): ${input}`, "invalid");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { resolved, exists };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Canonicalise a path that does not exist yet, by realpath-ing the deepest
|
|
307
|
+
* ancestor that DOES exist and re-appending the missing segments. Used only by
|
|
308
|
+
* {@link validatePath}, so a clone target under a symlinked parent still gets
|
|
309
|
+
* containment-checked against its real location.
|
|
310
|
+
*/
|
|
311
|
+
async resolveViaExistingAncestor(input) {
|
|
312
|
+
let head = path.resolve(input);
|
|
313
|
+
const tail = [];
|
|
314
|
+
for (;;) {
|
|
315
|
+
const real = await fs.realpath(head).catch(() => null);
|
|
316
|
+
if (real)
|
|
317
|
+
return path.join(real, ...tail.reverse());
|
|
318
|
+
const parent = path.dirname(head);
|
|
319
|
+
// Hit the filesystem root without finding anything real: nothing left to
|
|
320
|
+
// resolve against, so the already-normalised path is the best answer.
|
|
321
|
+
if (parent === head)
|
|
322
|
+
return path.resolve(input);
|
|
323
|
+
tail.push(path.basename(head));
|
|
324
|
+
head = parent;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Bring a project's `path` into existence, returning the directories THIS call
|
|
329
|
+
* created so a failure can roll back exactly those and nothing else.
|
|
330
|
+
*
|
|
331
|
+
* The acquisition rules (issue #206):
|
|
332
|
+
*
|
|
333
|
+
* - path exists ⇒ use it. With `repo` also set, verify the remote matches and
|
|
334
|
+
* WARN on a mismatch rather than silently ignoring `repo` — that is the #659
|
|
335
|
+
* bug class (a directory that is a clone of a *different* repo).
|
|
336
|
+
* - path missing + `repo` ⇒ clone the repo to that path.
|
|
337
|
+
* - path missing, managed ⇒ create the directory; the user is nominating where
|
|
338
|
+
* their notes should live, and an empty dir is a valid notebook.
|
|
339
|
+
* - path missing, unmanaged, no `repo` ⇒ error. There is nothing to acquire
|
|
340
|
+
* from and an empty directory is not a codebase.
|
|
341
|
+
*
|
|
342
|
+
* **Cleanup rule:** `created` only ever gains directories this call brought into
|
|
343
|
+
* being. A pre-existing directory is never appended, so no rollback can delete
|
|
344
|
+
* one — the target is user-nominated and may be a home directory.
|
|
345
|
+
*
|
|
346
|
+
* `created` is an OUT-parameter rather than a return value on purpose. Returning
|
|
347
|
+
* the list would mean the caller only learns what to clean up on the success
|
|
348
|
+
* path: an `acquirePath` that threw would never hand back the directory it had
|
|
349
|
+
* already made, so a clone that died partway through would orphan it. Recording
|
|
350
|
+
* before the risky step is what makes the rollback complete.
|
|
351
|
+
*/
|
|
352
|
+
async acquirePath(args) {
|
|
353
|
+
const { resolved, exists, repo, managed, warn, created } = args;
|
|
354
|
+
if (exists) {
|
|
355
|
+
// Used as-is. Nothing is appended to `created` — this directory is the
|
|
356
|
+
// user's, and it must survive any failure that follows.
|
|
357
|
+
if (repo)
|
|
358
|
+
await this.warnOnRemoteMismatch(resolved, repo, warn);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!repo && !managed) {
|
|
362
|
+
throw new ProjectError(`Project path does not exist and there is no repo to clone from: ${resolved}`, "invalid");
|
|
363
|
+
}
|
|
364
|
+
// From here the directory is ours: record it BEFORE creating it, so a failure
|
|
365
|
+
// midway (a clone that dies after writing some objects) still rolls back.
|
|
366
|
+
created.push(resolved);
|
|
367
|
+
if (repo) {
|
|
368
|
+
try {
|
|
369
|
+
await cloneRepo(repo, resolved);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo} to ${resolved}`, "invalid");
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Warn when a directory's git remotes don't include `repo`.
|
|
380
|
+
*
|
|
381
|
+
* Deliberately a warning, not a rejection: the directory the user named is the
|
|
382
|
+
* one they meant, and plenty of legitimate setups (a fork, a mirror, an
|
|
383
|
+
* ssh-vs-https spelling) won't match exactly. But silently ignoring `repo` is
|
|
384
|
+
* how #659 happened, so the mismatch has to be said out loud. A directory with
|
|
385
|
+
* no git at all is not a mismatch — it is just not a repo, which is allowed.
|
|
386
|
+
*
|
|
387
|
+
* Reuses `adoptable.ts`'s reader, which parses `.git/config` directly (no
|
|
388
|
+
* subprocess) and already follows a linked worktree's `gitdir:`/`commondir`
|
|
389
|
+
* pointers, and returns URLs already through {@link normalizeRemote}.
|
|
390
|
+
*/
|
|
391
|
+
async warnOnRemoteMismatch(dir, repo, warn) {
|
|
392
|
+
const remotes = await gitRemotes(dir);
|
|
393
|
+
if (remotes.length === 0)
|
|
394
|
+
return;
|
|
395
|
+
if (remotes.includes(normalizeRemote(repo)))
|
|
396
|
+
return;
|
|
397
|
+
warn(`Project path ${dir} is a git repo whose remotes (${remotes.join(", ")}) ` +
|
|
398
|
+
`do not include the declared repo ${repo}. Using the directory as given; ` +
|
|
399
|
+
`the repo URL is recorded as a hint only.`);
|
|
400
|
+
}
|
|
177
401
|
/**
|
|
178
402
|
* Create a project: mkdir + write project.yaml (from template) + seed
|
|
179
403
|
* CHANGELOG.md. Idempotency: throws ProjectError("exists") if the slug
|
|
@@ -190,12 +414,32 @@ export class ProjectStore {
|
|
|
190
414
|
if (await this.exists(slug)) {
|
|
191
415
|
throw new ProjectError(`Project already exists: ${slug}`, "exists");
|
|
192
416
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
417
|
+
// Validate the repo URL up front so a bad value never reaches `git clone`
|
|
418
|
+
// and the project isn't half-created (issue #187).
|
|
195
419
|
const repo = input.repo?.trim() || undefined;
|
|
196
420
|
if (repo && !isValidRepoUrl(repo)) {
|
|
197
421
|
throw new ProjectError(`Invalid repo URL: ${repo}`, "invalid");
|
|
198
422
|
}
|
|
423
|
+
const rawPath = input.path?.trim() || undefined;
|
|
424
|
+
// The managed axis (issue #206). Absent input derives the same way a legacy
|
|
425
|
+
// file does, so "what you get by default" is one rule everywhere.
|
|
426
|
+
const managed = isManaged({ managed: input.managed, repo, path: rawPath });
|
|
427
|
+
// Paddock curating its own files INTO a repo it also clones has no sensible
|
|
428
|
+
// meaning, so refuse rather than silently dropping one half. Refusing is the
|
|
429
|
+
// reversible choice: accepting it now and changing what it means later would
|
|
430
|
+
// be a migration.
|
|
431
|
+
if (managed && repo) {
|
|
432
|
+
throw new ProjectError("A managed project cannot have a repo — Paddock would be curating files " +
|
|
433
|
+
"into a checkout it clones. Use managed: false to work in the repo, or " +
|
|
434
|
+
"drop `repo` for a notebook.", "invalid");
|
|
435
|
+
}
|
|
436
|
+
if (!managed && !repo && !rawPath) {
|
|
437
|
+
throw new ProjectError("An unmanaged project needs a `path` (a directory to work in) or a `repo` " +
|
|
438
|
+
"(one to clone), or both.", "invalid");
|
|
439
|
+
}
|
|
440
|
+
// Validate BEFORE anything is created, so a bad path leaves nothing behind.
|
|
441
|
+
const checked = rawPath ? await this.validatePath(rawPath) : undefined;
|
|
442
|
+
const linkedPath = checked?.resolved;
|
|
199
443
|
const now = today();
|
|
200
444
|
const yaml = {
|
|
201
445
|
name,
|
|
@@ -211,51 +455,121 @@ export class ProjectStore {
|
|
|
211
455
|
summary: input.summary ?? "",
|
|
212
456
|
links: input.links ?? [],
|
|
213
457
|
pinned: [],
|
|
214
|
-
//
|
|
458
|
+
// ALWAYS written explicitly, even though it is optional on disk: the
|
|
459
|
+
// derived default exists to give LEGACY files their old meaning, not to be
|
|
460
|
+
// relied on by files we write ourselves.
|
|
461
|
+
managed,
|
|
462
|
+
// Carry `repo` only when set (same round-trip discipline as model).
|
|
215
463
|
...(repo ? { repo } : {}),
|
|
464
|
+
// Carry the canonicalised path only when set (issue #206).
|
|
465
|
+
...(linkedPath ? { path: linkedPath } : {}),
|
|
216
466
|
};
|
|
467
|
+
// Everything this call brings into being, newest last — the ONLY things a
|
|
468
|
+
// failure below is allowed to remove. A pre-existing directory is never in
|
|
469
|
+
// here, which is what stops a rollback deleting a user-nominated target that
|
|
470
|
+
// might be their home directory.
|
|
471
|
+
const created = [];
|
|
217
472
|
const dir = this.dirFor(slug);
|
|
473
|
+
if (!(await fs.stat(dir).then(() => true).catch(() => false)))
|
|
474
|
+
created.push(dir);
|
|
218
475
|
await fs.mkdir(dir, { recursive: true });
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
476
|
+
try {
|
|
477
|
+
if (linkedPath && checked) {
|
|
478
|
+
// Acquire the nominated path: use it, clone into it, or create it.
|
|
479
|
+
// `created` is threaded in so a mid-acquisition failure still rolls back
|
|
480
|
+
// what was made — see acquirePath.
|
|
481
|
+
await this.acquirePath({
|
|
482
|
+
resolved: linkedPath,
|
|
483
|
+
exists: checked.exists,
|
|
484
|
+
repo,
|
|
485
|
+
managed,
|
|
486
|
+
warn: (message) => this.log.warn(message),
|
|
487
|
+
created,
|
|
488
|
+
});
|
|
227
489
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
490
|
+
else if (repo) {
|
|
491
|
+
// No path nominated: clone into the nested checkout, as before (#187).
|
|
492
|
+
const checkoutName = repoCheckoutName(repo);
|
|
493
|
+
const checkoutDir = path.join(dir, checkoutName);
|
|
494
|
+
created.push(checkoutDir);
|
|
495
|
+
try {
|
|
496
|
+
await cloneRepo(repo, checkoutDir);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
500
|
+
}
|
|
501
|
+
// Keep the nested checkout (a full git repo) OUT of the instance data
|
|
502
|
+
// repo: a sidecar `.gitignore` ignores the checkout dir (git-in-git per
|
|
503
|
+
// #187's option A). `.chats/` is likewise data-repo noise.
|
|
504
|
+
await this.ensureSidecarGitignore(dir, checkoutName);
|
|
231
505
|
}
|
|
232
|
-
|
|
233
|
-
// a sidecar `.gitignore` ignores the checkout dir (git-in-git per #187's
|
|
234
|
-
// option A). `.chats/` (our transcript store) is likewise data-repo noise.
|
|
235
|
-
await this.ensureSidecarGitignore(dir, checkoutName);
|
|
506
|
+
await this.finishCreate({ slug, yaml, dir, name, now, managed });
|
|
236
507
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"",
|
|
245
|
-
`## ${now}`,
|
|
246
|
-
"- Project opened.",
|
|
247
|
-
"",
|
|
248
|
-
].join("\n"), "utf8");
|
|
249
|
-
// Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a NOTEBOOK
|
|
250
|
-
// project. A repo-backed project defers to the repo's OWN CLAUDE.md (loaded
|
|
251
|
-
// natively via the cwd walk-up from the checkout); the sweeper must never
|
|
252
|
-
// touch that upstream-owned file, so we don't seed a competing one (#187).
|
|
253
|
-
if (!repo) {
|
|
254
|
-
await fs.writeFile(path.join(dir, CLAUDE_FILE), claudeTemplate(name, yaml.summary), "utf8");
|
|
508
|
+
catch (err) {
|
|
509
|
+
// ONE rollback covering everything after the slug dir, not just the
|
|
510
|
+
// acquisition: a `writeYaml` or seed-file failure used to leave the
|
|
511
|
+
// directories we had just made (a clone target, a created notes dir) behind
|
|
512
|
+
// with no project to own them.
|
|
513
|
+
await this.rollback(created);
|
|
514
|
+
throw err;
|
|
255
515
|
}
|
|
256
516
|
// No OVERVIEW.md at creation — the first sweep writes it.
|
|
257
517
|
return this.toDto(dir, yaml, false);
|
|
258
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Write a new project's `project.yaml` and seed its content files. Split out of
|
|
521
|
+
* {@link create} so the whole sequence sits inside that method's single
|
|
522
|
+
* try/rollback rather than trailing off the end of it.
|
|
523
|
+
*/
|
|
524
|
+
async finishCreate(args) {
|
|
525
|
+
const { slug, yaml, dir, name, now, managed } = args;
|
|
526
|
+
await this.writeYaml(slug, yaml);
|
|
527
|
+
const contentDir = contentDirFor(dir, yaml);
|
|
528
|
+
// The curated trio follows the content (issue #206): for a managed project
|
|
529
|
+
// with an external `path` these land out there rather than in the data dir.
|
|
530
|
+
// `dir` is already made; `contentDir` may be the same directory or the
|
|
531
|
+
// freshly-acquired path, both of which exist by now.
|
|
532
|
+
//
|
|
533
|
+
// Never clobber: a managed `path` may point at a folder of notes that already
|
|
534
|
+
// has a CHANGELOG.md, and seeding "Project opened." over the user's history
|
|
535
|
+
// would be the worst kind of data loss — silent and plausible. For a fresh
|
|
536
|
+
// project dir the file is always absent, so this only bites in the new case.
|
|
537
|
+
const changelogFile = path.join(contentDir, CHANGELOG_FILE);
|
|
538
|
+
const changelogExists = await fs
|
|
539
|
+
.access(changelogFile)
|
|
540
|
+
.then(() => true)
|
|
541
|
+
.catch(() => false);
|
|
542
|
+
if (!changelogExists) {
|
|
543
|
+
await fs.writeFile(changelogFile, [
|
|
544
|
+
`# Changelog — ${name}`,
|
|
545
|
+
"",
|
|
546
|
+
"<!--",
|
|
547
|
+
"Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
|
|
548
|
+
"-->",
|
|
549
|
+
"",
|
|
550
|
+
`## ${now}`,
|
|
551
|
+
"- Project opened.",
|
|
552
|
+
"",
|
|
553
|
+
].join("\n"), "utf8");
|
|
554
|
+
}
|
|
555
|
+
// Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a MANAGED
|
|
556
|
+
// project, and never over an existing one. An UNMANAGED project's working
|
|
557
|
+
// directory owns its own `CLAUDE.md` (upstream's, in a checkout the user
|
|
558
|
+
// source-controls); the sweeper must never write that file, so we don't seed a
|
|
559
|
+
// competing one. A managed project with an external `path` DOES get one, out
|
|
560
|
+
// there with the rest of its content — but if that directory already has a
|
|
561
|
+
// `CLAUDE.md` (a pre-existing notes folder), it is the user's and stays.
|
|
562
|
+
if (managed) {
|
|
563
|
+
const claudeFile = path.join(contentDir, CLAUDE_FILE);
|
|
564
|
+
const claudeExists = await fs
|
|
565
|
+
.access(claudeFile)
|
|
566
|
+
.then(() => true)
|
|
567
|
+
.catch(() => false);
|
|
568
|
+
if (!claudeExists) {
|
|
569
|
+
await fs.writeFile(claudeFile, claudeTemplate(name, yaml.summary), "utf8");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
259
573
|
/**
|
|
260
574
|
* Promote an existing NOTEBOOK project into a REPO-BACKED one IN PLACE (issue
|
|
261
575
|
* #213), preserving its chats + sidecar metadata. This relaxes #187's create-time
|
|
@@ -293,8 +607,18 @@ export class ProjectStore {
|
|
|
293
607
|
throw new ProjectError("The root workspace cannot be repo-backed", "invalid");
|
|
294
608
|
}
|
|
295
609
|
const current = await this.get(slug); // throws not_found
|
|
296
|
-
|
|
297
|
-
|
|
610
|
+
// Promotion means "start working in a clone of this repo", so it only makes
|
|
611
|
+
// sense for a project Paddock currently manages. An UNMANAGED project already
|
|
612
|
+
// works in code somebody source-controls (issue #206).
|
|
613
|
+
if (!current.managed) {
|
|
614
|
+
throw new ProjectError(`Project is already backed by a repo or directory: ${slug}`, "invalid");
|
|
615
|
+
}
|
|
616
|
+
// A managed project with its own `path` is a notes folder the user nominated;
|
|
617
|
+
// promoting it would clone into that directory. Say so specifically — the
|
|
618
|
+
// generic message above would be misleading.
|
|
619
|
+
if (current.path) {
|
|
620
|
+
throw new ProjectError(`Project "${slug}" keeps its content in ${current.path} and cannot be promoted; ` +
|
|
621
|
+
`create an unmanaged project pointing at the repo instead`, "invalid");
|
|
298
622
|
}
|
|
299
623
|
const repo = repoUrl?.trim();
|
|
300
624
|
if (!repo || !isValidRepoUrl(repo)) {
|
|
@@ -330,6 +654,12 @@ export class ProjectStore {
|
|
|
330
654
|
const next = {
|
|
331
655
|
...this.stripDto(current),
|
|
332
656
|
repo,
|
|
657
|
+
// Promotion crosses the managed axis (issue #206): the keeper's cwd becomes
|
|
658
|
+
// a checkout that owns its own CLAUDE.md, which is exactly what unmanaged
|
|
659
|
+
// means. Set explicitly — `stripDto` carries the notebook's concrete
|
|
660
|
+
// `managed: true` forward, and leaving it would persist the one combination
|
|
661
|
+
// `create()` rejects (managed + repo).
|
|
662
|
+
managed: false,
|
|
333
663
|
updated: today(),
|
|
334
664
|
};
|
|
335
665
|
try {
|
|
@@ -364,6 +694,24 @@ export class ProjectStore {
|
|
|
364
694
|
started: current.started, // immutable
|
|
365
695
|
updated: today(),
|
|
366
696
|
};
|
|
697
|
+
// `path` and `managed` are immutable (issue #206), enforced here rather than
|
|
698
|
+
// by their absence from UpdateProjectInput — that type is a compile-time shape
|
|
699
|
+
// while `rest` is spread from a request body.
|
|
700
|
+
//
|
|
701
|
+
// For `path` both directions matter: a project must not be RE-POINTED (its cwd
|
|
702
|
+
// is baked into every transcript path, so moving it strands the history), and
|
|
703
|
+
// one must not be given a path by a stray key, which would silently hand its
|
|
704
|
+
// keeper a cwd somewhere else on the box that never went through
|
|
705
|
+
// `validatePath`. `managed` is re-asserted from `current` for the same reason
|
|
706
|
+
// — flipping it would move where the curated trio lives and either start the
|
|
707
|
+
// sweeper writing into a checkout or orphan the existing notes. (Changing it
|
|
708
|
+
// deliberately is a migration, not a PATCH; see #708 for what a silent
|
|
709
|
+
// location flip does to history.)
|
|
710
|
+
if (current.path)
|
|
711
|
+
next.path = current.path;
|
|
712
|
+
else
|
|
713
|
+
delete next.path;
|
|
714
|
+
next.managed = current.managed;
|
|
367
715
|
if (driveModePatch === null) {
|
|
368
716
|
// Clear the per-project override -> inherit the global default (issue #122).
|
|
369
717
|
delete next.driveMode;
|
|
@@ -440,7 +788,7 @@ export class ProjectStore {
|
|
|
440
788
|
/** Read OVERVIEW.md, or "" if it doesn't exist yet. */
|
|
441
789
|
async readOverview(slug) {
|
|
442
790
|
try {
|
|
443
|
-
return await fs.readFile(path.join(this.
|
|
791
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), "utf8");
|
|
444
792
|
}
|
|
445
793
|
catch (err) {
|
|
446
794
|
if (err.code === "ENOENT")
|
|
@@ -450,12 +798,12 @@ export class ProjectStore {
|
|
|
450
798
|
}
|
|
451
799
|
/** Replace OVERVIEW.md wholesale (the sweep regenerates it each time). */
|
|
452
800
|
async writeOverview(slug, content) {
|
|
453
|
-
await fs.writeFile(path.join(this.
|
|
801
|
+
await fs.writeFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), content, "utf8");
|
|
454
802
|
}
|
|
455
803
|
/** Read CHANGELOG.md, or "" if it doesn't exist yet (issue #188). */
|
|
456
804
|
async readChangelog(slug) {
|
|
457
805
|
try {
|
|
458
|
-
return await fs.readFile(path.join(this.
|
|
806
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), CHANGELOG_FILE), "utf8");
|
|
459
807
|
}
|
|
460
808
|
catch (err) {
|
|
461
809
|
if (err.code === "ENOENT")
|
|
@@ -465,19 +813,39 @@ export class ProjectStore {
|
|
|
465
813
|
}
|
|
466
814
|
/** Whether OVERVIEW.md exists for this project. */
|
|
467
815
|
async overviewExists(slug) {
|
|
816
|
+
return this.overviewExistsIn(await this.contentDirOf(slug));
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* {@link overviewExists} for a content dir already in hand.
|
|
820
|
+
*
|
|
821
|
+
* `readSafe` needs this: it has just parsed the yaml, so it can derive the
|
|
822
|
+
* content dir for free, whereas going through the slug would re-read
|
|
823
|
+
* `project.yaml` on every project of every `list()`.
|
|
824
|
+
*/
|
|
825
|
+
async overviewExistsIn(contentDir) {
|
|
468
826
|
try {
|
|
469
|
-
await fs.access(path.join(
|
|
827
|
+
await fs.access(path.join(contentDir, OVERVIEW_FILE));
|
|
470
828
|
return true;
|
|
471
829
|
}
|
|
472
830
|
catch {
|
|
473
831
|
return false;
|
|
474
832
|
}
|
|
475
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Where this project's curated trio lives (issue #206) — see
|
|
836
|
+
* {@link contentDirFor}. Falls back to the metadata dir for a slug that names
|
|
837
|
+
* no project, so a caller reading a missing project still gets "" rather than
|
|
838
|
+
* throwing.
|
|
839
|
+
*/
|
|
840
|
+
async contentDirOf(slug) {
|
|
841
|
+
const project = await this.readSafe(slug);
|
|
842
|
+
return project?.contentDir ?? this.dirFor(slug);
|
|
843
|
+
}
|
|
476
844
|
// --- CLAUDE.md (durable identity & conventions, sweep-amended) ----------
|
|
477
845
|
/** Read CLAUDE.md, or "" if it doesn't exist yet (issue #177). */
|
|
478
846
|
async readClaudeMd(slug) {
|
|
479
847
|
try {
|
|
480
|
-
return await fs.readFile(path.join(this.
|
|
848
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), CLAUDE_FILE), "utf8");
|
|
481
849
|
}
|
|
482
850
|
catch (err) {
|
|
483
851
|
if (err.code === "ENOENT")
|
|
@@ -502,8 +870,7 @@ export class ProjectStore {
|
|
|
502
870
|
const trimmed = curatedBody.trim();
|
|
503
871
|
if (!trimmed)
|
|
504
872
|
return;
|
|
505
|
-
const
|
|
506
|
-
const file = path.join(dir, CLAUDE_FILE);
|
|
873
|
+
const file = path.join(await this.contentDirOf(slug), CLAUDE_FILE);
|
|
507
874
|
let body;
|
|
508
875
|
try {
|
|
509
876
|
body = await fs.readFile(file, "utf8");
|
|
@@ -602,13 +969,12 @@ export class ProjectStore {
|
|
|
602
969
|
throw new ProjectError("Refusing to delete the root workspace", "invalid");
|
|
603
970
|
}
|
|
604
971
|
const project = await this.get(slug); // throws not_found
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
972
|
+
// Only ever the METADATA dir — never `project.workingDir`. For a LINKED
|
|
973
|
+
// project (issue #206) those differ and the working dir is the user's real
|
|
974
|
+
// clone: deleting the project unlinks it, it does not delete their work.
|
|
975
|
+
// `rmInsideRoot` enforces that structurally (a linked path is outside the
|
|
976
|
+
// root by validation, so it cannot be reached from here at all).
|
|
977
|
+
await this.rmInsideRoot(this.dirFor(slug));
|
|
612
978
|
return project;
|
|
613
979
|
}
|
|
614
980
|
/**
|
|
@@ -626,7 +992,7 @@ export class ProjectStore {
|
|
|
626
992
|
const trimmed = body.trim();
|
|
627
993
|
if (!trimmed)
|
|
628
994
|
return;
|
|
629
|
-
const file = path.join(this.
|
|
995
|
+
const file = path.join(await this.contentDirOf(slug), CHANGELOG_FILE);
|
|
630
996
|
const title = `# Changelog — ${slug}`;
|
|
631
997
|
// Drop a leading top-level heading the model may have emitted, so the
|
|
632
998
|
// Paddock-owned title isn't duplicated.
|
|
@@ -695,8 +1061,11 @@ export class ProjectStore {
|
|
|
695
1061
|
return null;
|
|
696
1062
|
yaml = this.normalize({}, key);
|
|
697
1063
|
}
|
|
698
|
-
//
|
|
699
|
-
|
|
1064
|
+
// A cheap fs.access, done after the yaml parse succeeds. Deliberately the
|
|
1065
|
+
// content-dir-in-hand variant: the slug-taking `overviewExists` would re-read
|
|
1066
|
+
// this very `project.yaml` to find the content dir, once per project of every
|
|
1067
|
+
// `list()`.
|
|
1068
|
+
const hasOverview = await this.overviewExistsIn(contentDirFor(dir, yaml));
|
|
700
1069
|
return this.toDto(dir, yaml, hasOverview);
|
|
701
1070
|
}
|
|
702
1071
|
/** Fill defaults / coerce a parsed project.yaml into a complete ProjectYaml. */
|
|
@@ -776,6 +1145,25 @@ export class ProjectStore {
|
|
|
776
1145
|
// repo (issue #187): carried only when present — its presence is what marks
|
|
777
1146
|
// the project repo-backed and drives the workingDir resolution in toDto.
|
|
778
1147
|
...(typeof p.repo === "string" && p.repo.trim() ? { repo: p.repo.trim() } : {}),
|
|
1148
|
+
// path (issue #206): carried only when present AND absolute — its presence
|
|
1149
|
+
// is what makes workingDirFor return the cwd verbatim. The absolute check is
|
|
1150
|
+
// a read-boundary backstop against a hand-edited relative value, which would
|
|
1151
|
+
// otherwise resolve against the server's cwd; dropping it degrades the
|
|
1152
|
+
// project to an unbacked one rather than pointing an agent somewhere
|
|
1153
|
+
// arbitrary. Existence/containment were checked at create time and are NOT
|
|
1154
|
+
// re-checked here — `normalize` runs on every list() and must stay pure +
|
|
1155
|
+
// cheap, and a path that has since gone away should surface as a broken cwd,
|
|
1156
|
+
// not silently mutate into a different kind of project.
|
|
1157
|
+
...(typeof p.path === "string" && path.isAbsolute(p.path.trim())
|
|
1158
|
+
? { path: p.path.trim() }
|
|
1159
|
+
: {}),
|
|
1160
|
+
// managed (issue #206): carried ONLY when explicitly set on disk. An absent
|
|
1161
|
+
// value must stay absent so it keeps deriving from the backing
|
|
1162
|
+
// (`!(repo || path)`) — baking a concrete `true` in here would flip every
|
|
1163
|
+
// legacy repo-backed project to managed the first time its yaml was
|
|
1164
|
+
// re-written, and the sweeper would start writing CLAUDE.md into a checkout.
|
|
1165
|
+
// Same round-trip discipline as `driveMode`, and for a much sharper reason.
|
|
1166
|
+
...(typeof p.managed === "boolean" ? { managed: p.managed } : {}),
|
|
779
1167
|
// schedules (issue #265): carried only when at least one well-formed entry
|
|
780
1168
|
// survives sanitization — an absent/empty map stays absent on disk, so files
|
|
781
1169
|
// without schedules round-trip unchanged. A malformed entry is dropped (not
|
|
@@ -875,18 +1263,32 @@ export class ProjectStore {
|
|
|
875
1263
|
// sanitised so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#384).
|
|
876
1264
|
curation: sanitizeCurationOverride(yaml.curation),
|
|
877
1265
|
dir,
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
workingDir: workingDirFor(dir, yaml.repo),
|
|
881
|
-
|
|
1266
|
+
// The keeper's cwd: `path` verbatim (#206), else the nested checkout for a
|
|
1267
|
+
// repo-backed project (#187), else the metadata dir.
|
|
1268
|
+
workingDir: workingDirFor(dir, yaml.repo, yaml.path),
|
|
1269
|
+
// Where the curated trio lives, and whether Paddock curates at all — the
|
|
1270
|
+
// two facts that replaced `repoBacked` (#206). Both ALWAYS concrete here,
|
|
1271
|
+
// with `managed` resolved through the derived default so a legacy file
|
|
1272
|
+
// without the key keeps the meaning it has always had.
|
|
1273
|
+
contentDir: contentDirFor(dir, yaml),
|
|
1274
|
+
managed: isManaged(yaml),
|
|
882
1275
|
hasOverview,
|
|
883
1276
|
};
|
|
884
1277
|
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Drop the derived DTO-only fields, leaving the on-disk shape.
|
|
1280
|
+
*
|
|
1281
|
+
* `managed` deliberately SURVIVES: it is a real `project.yaml` key, and because
|
|
1282
|
+
* the DTO's value is always concrete, the first `update()` of a legacy project
|
|
1283
|
+
* pins its derived value explicitly on disk. That is the desired direction —
|
|
1284
|
+
* the file stops depending on a default — and it is safe precisely because the
|
|
1285
|
+
* value written is the one the derivation already produced.
|
|
1286
|
+
*/
|
|
885
1287
|
stripDto(p) {
|
|
886
|
-
const { dir: _dir, workingDir: _workingDir,
|
|
1288
|
+
const { dir: _dir, workingDir: _workingDir, contentDir: _contentDir, hasOverview: _hasOverview, group, ...rest } = p;
|
|
887
1289
|
void _dir;
|
|
888
1290
|
void _workingDir;
|
|
889
|
-
void
|
|
1291
|
+
void _contentDir;
|
|
890
1292
|
void _hasOverview;
|
|
891
1293
|
// Keep an empty area off the yaml so it isn't persisted as `group: ""`.
|
|
892
1294
|
return group?.trim() ? { ...rest, group } : rest;
|