@edspencer/paddock 0.63.0 → 0.65.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/packages/server/dist/adoptable.d.ts +18 -0
- package/packages/server/dist/adoptable.js +1 -1
- package/packages/server/dist/app.js +3 -1
- package/packages/server/dist/claude-home.js +9 -1
- 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/management-ops.js +42 -2
- package/packages/server/dist/management-policy.d.ts +4 -3
- package/packages/server/dist/management-policy.js +5 -2
- 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 +170 -3
- package/packages/server/dist/projects.js +477 -66
- package/packages/server/dist/routes/git.js +6 -3
- package/packages/server/dist/routes/projects.js +16 -2
- package/packages/server/dist/self-mcp-descriptions.d.ts +1 -0
- package/packages/server/dist/self-mcp-descriptions.js +14 -1
- package/packages/server/dist/self-mcp-types.d.ts +32 -8
- package/packages/server/dist/self-mcp-write.d.ts +21 -0
- package/packages/server/dist/self-mcp-write.js +44 -0
- package/packages/server/dist/self-mcp.d.ts +7 -6
- package/packages/server/dist/self-mcp.js +36 -10
- package/packages/server/dist/sweep.js +10 -5
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CCk2hVT9.js → InstanceConfigPage-DcprTH2W.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-B6gST_0U.js → MarkdownRenderer-Ri1LwAUN.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-BCmKT4mf.js → ProjectRedirect-Dze5H8Xy.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-CCN98MWu.js → ProjectView-PrA8purE.js} +9 -9
- package/packages/web/dist/assets/{ProjectsGrid-CYzuN_yD.js → ProjectsGrid-BidsM7ah.js} +1 -1
- package/packages/web/dist/assets/{arc-BxvtMCZQ.js → arc-eQ3ryE9c.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Bp6ffJ_u.js → architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BIY2X8nW.js → blockDiagram-GPEHLZMM-DBX5pma_.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Br9stEJS.js → c4Diagram-AAUBKEIU-CnWrtTpa.js} +1 -1
- package/packages/web/dist/assets/channel-B4sED4OP.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Djc7uqMV.js → chunk-2J33WTMH-2qmLvtsr.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-yEVMLksS.js → chunk-4BX2VUAB-Dn-LUDMV.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-tJaEdPZg.js → chunk-55IACEB6-DB0augUY.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-bWEqh672.js → chunk-727SXJPM-BPQQM0i8.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-B8U9fB-T.js → chunk-AQP2D5EJ-DOhfwA-x.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BeEITYig.js → chunk-FMBD7UC4-CatHHdFD.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-L2rpLaoK.js → chunk-ND2GUHAM-B645aAlG.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-qqzPMZL_.js → chunk-QZHKN3VN-D1fAaPSZ.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +2 -0
- package/packages/web/dist/assets/{core-CSaNbmLO.js → core-B4t7D8Om.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BRF7Nech.js → cose-bilkent-S5V4N54A-BLdKDAwa.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-D10rJWOR.js → dagre-BM42HDAG-CbTbTVKv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DGjT5mAO.js → diagram-2AECGRRQ-CorSS29_.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DaH3bxHh.js → diagram-5GNKFQAL-PLKivts0.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-QrLW9Wn6.js → diagram-KO2AKTUF-CUSIBacZ.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BX15wgYN.js → diagram-LMA3HP47-CZ69mAZs.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DXQ_p63s.js → diagram-OG6HWLK6-Cmf042Rh.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C_ioRruE.js → erDiagram-TEJ5UH35-DyIKGj6e.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-7d-xLXLM.js → flowDiagram-I6XJVG4X-B79pLdLD.js} +1 -1
- package/packages/web/dist/assets/{format-BSDZezwD.js → format-C5nhFL8q.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DZYx5xcj.js → ganttDiagram-6RSMTGT7-BFTR_oNA.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-dimo-osm.js → gitGraphDiagram-PVQCEYII-DdGUlJxS.js} +1 -1
- package/packages/web/dist/assets/{index-CUnFKMga.js → index-DVSsta3B.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BzApY25x.js → infoDiagram-5YYISTIA-B80FyUOy.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DtX47_9p.js → ishikawaDiagram-YF4QCWOH-_wRlphn9.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C7fITKg1.js → journeyDiagram-JHISSGLW-qu4yvjW8.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BOrWLf_W.js → kanban-definition-UN3LZRKU-D2DEUp2-.js} +1 -1
- package/packages/web/dist/assets/{linear-DuyAaBu6.js → linear-D-CLRUEW.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BQlzuVvO.js → mermaid.core-DdihBJl4.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CmP748B5.js → mindmap-definition-RKZ34NQL-BEh_TDBf.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CRd5Pdfq.js → pieDiagram-4H26LBE5-BbrmLVaZ.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DpJm0Yw_.js → quadrantDiagram-W4KKPZXB-DfVuygAv.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-B0o2PRMC.js → requirementDiagram-4Y6WPE33-BHp2QpZt.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CBi3Ek3v.js → sankeyDiagram-5OEKKPKP-D2G6lRMY.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C1stxfQg.js → sequenceDiagram-3UESZ5HK-Dj-jzR-c.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-lCkiETWK.js → stateDiagram-AJRCARHV-C5D6bT13.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D3DkWJMC.js → timeline-definition-PNZ67QCA-DE4gGjhP.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CO2-yC7K.js → vennDiagram-CIIHVFJN-C9QNrl92.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dfjp0kqX.js → wardley-L42UT6IY-U2fSGy-d.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CIl0OPwS.js → wardleyDiagram-YWT4CUSO-B9RhObdH.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-CuvlQuup.js → xychartDiagram-2RQKCTM6-BnlPrtQ9.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-CMdgNeFH.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.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,222 @@ 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. Every
|
|
206
|
+
* caller already only passes in-root paths — this exists so that stays true
|
|
207
|
+
* when someone later adds another one.
|
|
208
|
+
*
|
|
209
|
+
* `promote()`'s two rollbacks route through here as well. Their target is
|
|
210
|
+
* `<projectsRoot>/<slug>/<checkoutName>`, which is in-root by construction, so
|
|
211
|
+
* the guard is inert there today — that is the point. This is the choke point
|
|
212
|
+
* the comment above promised, and a recursive delete that skips it is exactly
|
|
213
|
+
* the drift it exists to prevent. {@link rollback} is the deliberate exception:
|
|
214
|
+
* it deletes paths that may legitimately sit OUTSIDE the root (a user-nominated
|
|
215
|
+
* clone target), and is safe by a different mechanism — it only ever removes
|
|
216
|
+
* directories this operation created.
|
|
217
|
+
*/
|
|
218
|
+
async rmInsideRoot(target) {
|
|
219
|
+
const resolved = path.resolve(target);
|
|
220
|
+
const root = path.resolve(this.root);
|
|
221
|
+
if (resolved === root || !isPathInside(resolved, root)) {
|
|
222
|
+
throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
|
|
223
|
+
}
|
|
224
|
+
await fs.rm(resolved, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Undo a failed {@link create} by removing the directories it created — and
|
|
228
|
+
* ONLY those.
|
|
229
|
+
*
|
|
230
|
+
* This is the non-negotiable half of the acquisition rules (issue #206). The
|
|
231
|
+
* previous rollback was an unconditional `fs.rm(dir, {recursive, force})` on the
|
|
232
|
+
* slug dir, safe only because everything a project owned was nested inside it.
|
|
233
|
+
* Once `path:` lets a user nominate a clone TARGET, an unconditional recursive
|
|
234
|
+
* delete is a footgun aimed at whatever they typed — a home directory, say. So
|
|
235
|
+
* the caller records what it brought into being and this removes exactly that
|
|
236
|
+
* list; a pre-existing directory is never in it and therefore can never be
|
|
237
|
+
* deleted, no matter how the create failed.
|
|
238
|
+
*
|
|
239
|
+
* Removed deepest-first so a nested pair (slug dir + its checkout) unwinds
|
|
240
|
+
* cleanly. Failures are swallowed: the original error is the one worth
|
|
241
|
+
* reporting, and a rollback that can't finish must not mask it.
|
|
242
|
+
*/
|
|
243
|
+
async rollback(created) {
|
|
244
|
+
for (const target of [...created].reverse()) {
|
|
245
|
+
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Validate a `path:` (issue #206) and return it canonicalised, along with
|
|
250
|
+
* whether it already exists.
|
|
251
|
+
*
|
|
252
|
+
* This is the ONLY gate between a user-supplied string and a directory Paddock
|
|
253
|
+
* will hand an agent as its cwd (and may `git clone` into), so it is
|
|
254
|
+
* deliberately strict and ordered cheapest-first. It rejects:
|
|
255
|
+
*
|
|
256
|
+
* - a relative path (a cwd must be unambiguous — it is baked into every
|
|
257
|
+
* transcript path, and resolving it against the server's cwd is a footgun);
|
|
258
|
+
* - a path that exists but isn't a directory;
|
|
259
|
+
* - a path inside `projectsRoot` or the data dir — that is Paddock's own
|
|
260
|
+
* state, and pointing at it re-creates the nesting this feature exists to
|
|
261
|
+
* avoid (plus `remove()` would then really delete it);
|
|
262
|
+
* - a path that is, contains, or sits inside another project's working
|
|
263
|
+
* directory — two keepers sharing one cwd collide on transcripts (they are
|
|
264
|
+
* keyed by cwd) and on the working tree itself.
|
|
265
|
+
*
|
|
266
|
+
* It deliberately does NOT reject a directory for not being a git repository.
|
|
267
|
+
* That would be the only mandatory git check in the codebase: everywhere else
|
|
268
|
+
* paddock PROBES and degrades (`GitService.isRepo()` → false, the Changes tab
|
|
269
|
+
* removes itself, commit/push return `{error: "not a repo"}`), and the default
|
|
270
|
+
* E2E server runs on a non-git directory on purpose.
|
|
271
|
+
*
|
|
272
|
+
* A MISSING path is not an error here — it is an acquisition instruction, which
|
|
273
|
+
* {@link acquirePath} resolves. Only the existing-path branch can be
|
|
274
|
+
* canonicalised by `realpath`, so for a missing one the containment checks run
|
|
275
|
+
* against the resolved-but-not-real path plus its nearest existing ancestor —
|
|
276
|
+
* otherwise `/tmp/link -> <projectsRoot>` with a missing leaf would slip past.
|
|
277
|
+
*/
|
|
278
|
+
async validatePath(raw) {
|
|
279
|
+
const input = raw.trim();
|
|
280
|
+
if (!path.isAbsolute(input)) {
|
|
281
|
+
throw new ProjectError(`Project path must be absolute: ${input}`, "invalid");
|
|
282
|
+
}
|
|
283
|
+
// Resolve symlinks before ANY containment check — otherwise
|
|
284
|
+
// `/tmp/innocent -> <projectsRoot>/victim` would sail through them all. A
|
|
285
|
+
// path that doesn't exist yet can't be realpath'd, so fall back to resolving
|
|
286
|
+
// its nearest existing ancestor and re-appending the missing tail; that keeps
|
|
287
|
+
// a symlinked PARENT from smuggling the target back inside our own state.
|
|
288
|
+
const direct = await fs.realpath(input).catch(() => null);
|
|
289
|
+
const exists = direct !== null;
|
|
290
|
+
const resolved = direct ?? (await this.resolveViaExistingAncestor(input));
|
|
291
|
+
if (exists) {
|
|
292
|
+
const st = await fs.stat(resolved).catch(() => null);
|
|
293
|
+
if (!st?.isDirectory()) {
|
|
294
|
+
throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (isPathInside(resolved, this.root)) {
|
|
298
|
+
throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
|
|
299
|
+
}
|
|
300
|
+
if (this.dataDir && isPathInside(resolved, this.dataDir)) {
|
|
301
|
+
throw new ProjectError(`Project path must be outside the Paddock data dir (${this.dataDir}): ${input}`, "invalid");
|
|
302
|
+
}
|
|
303
|
+
// Reject overlap with an existing project's cwd in EITHER direction: pointing
|
|
304
|
+
// at a parent of another project's working dir is just as broken as pointing
|
|
305
|
+
// at a child of one.
|
|
306
|
+
for (const other of await this.list()) {
|
|
307
|
+
const cwd = other.workingDir;
|
|
308
|
+
if (isPathInside(resolved, cwd) || isPathInside(cwd, resolved)) {
|
|
309
|
+
throw new ProjectError(`Project path overlaps the working directory of project "${other.slug}" (${cwd}): ${input}`, "invalid");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { resolved, exists };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Canonicalise a path that does not exist yet, by realpath-ing the deepest
|
|
316
|
+
* ancestor that DOES exist and re-appending the missing segments. Used only by
|
|
317
|
+
* {@link validatePath}, so a clone target under a symlinked parent still gets
|
|
318
|
+
* containment-checked against its real location.
|
|
319
|
+
*/
|
|
320
|
+
async resolveViaExistingAncestor(input) {
|
|
321
|
+
let head = path.resolve(input);
|
|
322
|
+
const tail = [];
|
|
323
|
+
for (;;) {
|
|
324
|
+
const real = await fs.realpath(head).catch(() => null);
|
|
325
|
+
if (real)
|
|
326
|
+
return path.join(real, ...tail.reverse());
|
|
327
|
+
const parent = path.dirname(head);
|
|
328
|
+
// Hit the filesystem root without finding anything real: nothing left to
|
|
329
|
+
// resolve against, so the already-normalised path is the best answer.
|
|
330
|
+
if (parent === head)
|
|
331
|
+
return path.resolve(input);
|
|
332
|
+
tail.push(path.basename(head));
|
|
333
|
+
head = parent;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Bring a project's `path` into existence, returning the directories THIS call
|
|
338
|
+
* created so a failure can roll back exactly those and nothing else.
|
|
339
|
+
*
|
|
340
|
+
* The acquisition rules (issue #206):
|
|
341
|
+
*
|
|
342
|
+
* - path exists ⇒ use it. With `repo` also set, verify the remote matches and
|
|
343
|
+
* WARN on a mismatch rather than silently ignoring `repo` — that is the #659
|
|
344
|
+
* bug class (a directory that is a clone of a *different* repo).
|
|
345
|
+
* - path missing + `repo` ⇒ clone the repo to that path.
|
|
346
|
+
* - path missing, managed ⇒ create the directory; the user is nominating where
|
|
347
|
+
* their notes should live, and an empty dir is a valid notebook.
|
|
348
|
+
* - path missing, unmanaged, no `repo` ⇒ error. There is nothing to acquire
|
|
349
|
+
* from and an empty directory is not a codebase.
|
|
350
|
+
*
|
|
351
|
+
* **Cleanup rule:** `created` only ever gains directories this call brought into
|
|
352
|
+
* being. A pre-existing directory is never appended, so no rollback can delete
|
|
353
|
+
* one — the target is user-nominated and may be a home directory.
|
|
354
|
+
*
|
|
355
|
+
* `created` is an OUT-parameter rather than a return value on purpose. Returning
|
|
356
|
+
* the list would mean the caller only learns what to clean up on the success
|
|
357
|
+
* path: an `acquirePath` that threw would never hand back the directory it had
|
|
358
|
+
* already made, so a clone that died partway through would orphan it. Recording
|
|
359
|
+
* before the risky step is what makes the rollback complete.
|
|
360
|
+
*/
|
|
361
|
+
async acquirePath(args) {
|
|
362
|
+
const { resolved, exists, repo, managed, warn, created } = args;
|
|
363
|
+
if (exists) {
|
|
364
|
+
// Used as-is. Nothing is appended to `created` — this directory is the
|
|
365
|
+
// user's, and it must survive any failure that follows.
|
|
366
|
+
if (repo)
|
|
367
|
+
await this.warnOnRemoteMismatch(resolved, repo, warn);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!repo && !managed) {
|
|
371
|
+
throw new ProjectError(`Project path does not exist and there is no repo to clone from: ${resolved}`, "invalid");
|
|
372
|
+
}
|
|
373
|
+
// From here the directory is ours: record it BEFORE creating it, so a failure
|
|
374
|
+
// midway (a clone that dies after writing some objects) still rolls back.
|
|
375
|
+
created.push(resolved);
|
|
376
|
+
if (repo) {
|
|
377
|
+
try {
|
|
378
|
+
await cloneRepo(repo, resolved);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo} to ${resolved}`, "invalid");
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Warn when a directory's git remotes don't include `repo`.
|
|
389
|
+
*
|
|
390
|
+
* Deliberately a warning, not a rejection: the directory the user named is the
|
|
391
|
+
* one they meant, and plenty of legitimate setups (a fork, a mirror, an
|
|
392
|
+
* ssh-vs-https spelling) won't match exactly. But silently ignoring `repo` is
|
|
393
|
+
* how #659 happened, so the mismatch has to be said out loud. A directory with
|
|
394
|
+
* no git at all is not a mismatch — it is just not a repo, which is allowed.
|
|
395
|
+
*
|
|
396
|
+
* Reuses `adoptable.ts`'s reader, which parses `.git/config` directly (no
|
|
397
|
+
* subprocess) and already follows a linked worktree's `gitdir:`/`commondir`
|
|
398
|
+
* pointers, and returns URLs already through {@link normalizeRemote}.
|
|
399
|
+
*/
|
|
400
|
+
async warnOnRemoteMismatch(dir, repo, warn) {
|
|
401
|
+
const remotes = await gitRemotes(dir);
|
|
402
|
+
if (remotes.length === 0)
|
|
403
|
+
return;
|
|
404
|
+
if (remotes.includes(normalizeRemote(repo)))
|
|
405
|
+
return;
|
|
406
|
+
warn(`Project path ${dir} is a git repo whose remotes (${remotes.join(", ")}) ` +
|
|
407
|
+
`do not include the declared repo ${repo}. Using the directory as given; ` +
|
|
408
|
+
`the repo URL is recorded as a hint only.`);
|
|
409
|
+
}
|
|
177
410
|
/**
|
|
178
411
|
* Create a project: mkdir + write project.yaml (from template) + seed
|
|
179
412
|
* CHANGELOG.md. Idempotency: throws ProjectError("exists") if the slug
|
|
@@ -190,12 +423,32 @@ export class ProjectStore {
|
|
|
190
423
|
if (await this.exists(slug)) {
|
|
191
424
|
throw new ProjectError(`Project already exists: ${slug}`, "exists");
|
|
192
425
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
426
|
+
// Validate the repo URL up front so a bad value never reaches `git clone`
|
|
427
|
+
// and the project isn't half-created (issue #187).
|
|
195
428
|
const repo = input.repo?.trim() || undefined;
|
|
196
429
|
if (repo && !isValidRepoUrl(repo)) {
|
|
197
430
|
throw new ProjectError(`Invalid repo URL: ${repo}`, "invalid");
|
|
198
431
|
}
|
|
432
|
+
const rawPath = input.path?.trim() || undefined;
|
|
433
|
+
// The managed axis (issue #206). Absent input derives the same way a legacy
|
|
434
|
+
// file does, so "what you get by default" is one rule everywhere.
|
|
435
|
+
const managed = isManaged({ managed: input.managed, repo, path: rawPath });
|
|
436
|
+
// Paddock curating its own files INTO a repo it also clones has no sensible
|
|
437
|
+
// meaning, so refuse rather than silently dropping one half. Refusing is the
|
|
438
|
+
// reversible choice: accepting it now and changing what it means later would
|
|
439
|
+
// be a migration.
|
|
440
|
+
if (managed && repo) {
|
|
441
|
+
throw new ProjectError("A managed project cannot have a repo — Paddock would be curating files " +
|
|
442
|
+
"into a checkout it clones. Use managed: false to work in the repo, or " +
|
|
443
|
+
"drop `repo` for a notebook.", "invalid");
|
|
444
|
+
}
|
|
445
|
+
if (!managed && !repo && !rawPath) {
|
|
446
|
+
throw new ProjectError("An unmanaged project needs a `path` (a directory to work in) or a `repo` " +
|
|
447
|
+
"(one to clone), or both.", "invalid");
|
|
448
|
+
}
|
|
449
|
+
// Validate BEFORE anything is created, so a bad path leaves nothing behind.
|
|
450
|
+
const checked = rawPath ? await this.validatePath(rawPath) : undefined;
|
|
451
|
+
const linkedPath = checked?.resolved;
|
|
199
452
|
const now = today();
|
|
200
453
|
const yaml = {
|
|
201
454
|
name,
|
|
@@ -211,51 +464,121 @@ export class ProjectStore {
|
|
|
211
464
|
summary: input.summary ?? "",
|
|
212
465
|
links: input.links ?? [],
|
|
213
466
|
pinned: [],
|
|
214
|
-
//
|
|
467
|
+
// ALWAYS written explicitly, even though it is optional on disk: the
|
|
468
|
+
// derived default exists to give LEGACY files their old meaning, not to be
|
|
469
|
+
// relied on by files we write ourselves.
|
|
470
|
+
managed,
|
|
471
|
+
// Carry `repo` only when set (same round-trip discipline as model).
|
|
215
472
|
...(repo ? { repo } : {}),
|
|
473
|
+
// Carry the canonicalised path only when set (issue #206).
|
|
474
|
+
...(linkedPath ? { path: linkedPath } : {}),
|
|
216
475
|
};
|
|
476
|
+
// Everything this call brings into being, newest last — the ONLY things a
|
|
477
|
+
// failure below is allowed to remove. A pre-existing directory is never in
|
|
478
|
+
// here, which is what stops a rollback deleting a user-nominated target that
|
|
479
|
+
// might be their home directory.
|
|
480
|
+
const created = [];
|
|
217
481
|
const dir = this.dirFor(slug);
|
|
482
|
+
if (!(await fs.stat(dir).then(() => true).catch(() => false)))
|
|
483
|
+
created.push(dir);
|
|
218
484
|
await fs.mkdir(dir, { recursive: true });
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
485
|
+
try {
|
|
486
|
+
if (linkedPath && checked) {
|
|
487
|
+
// Acquire the nominated path: use it, clone into it, or create it.
|
|
488
|
+
// `created` is threaded in so a mid-acquisition failure still rolls back
|
|
489
|
+
// what was made — see acquirePath.
|
|
490
|
+
await this.acquirePath({
|
|
491
|
+
resolved: linkedPath,
|
|
492
|
+
exists: checked.exists,
|
|
493
|
+
repo,
|
|
494
|
+
managed,
|
|
495
|
+
warn: (message) => this.log.warn(message),
|
|
496
|
+
created,
|
|
497
|
+
});
|
|
227
498
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
499
|
+
else if (repo) {
|
|
500
|
+
// No path nominated: clone into the nested checkout, as before (#187).
|
|
501
|
+
const checkoutName = repoCheckoutName(repo);
|
|
502
|
+
const checkoutDir = path.join(dir, checkoutName);
|
|
503
|
+
created.push(checkoutDir);
|
|
504
|
+
try {
|
|
505
|
+
await cloneRepo(repo, checkoutDir);
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
509
|
+
}
|
|
510
|
+
// Keep the nested checkout (a full git repo) OUT of the instance data
|
|
511
|
+
// repo: a sidecar `.gitignore` ignores the checkout dir (git-in-git per
|
|
512
|
+
// #187's option A). `.chats/` is likewise data-repo noise.
|
|
513
|
+
await this.ensureSidecarGitignore(dir, checkoutName);
|
|
231
514
|
}
|
|
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);
|
|
515
|
+
await this.finishCreate({ slug, yaml, dir, name, now, managed });
|
|
236
516
|
}
|
|
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");
|
|
517
|
+
catch (err) {
|
|
518
|
+
// ONE rollback covering everything after the slug dir, not just the
|
|
519
|
+
// acquisition: a `writeYaml` or seed-file failure used to leave the
|
|
520
|
+
// directories we had just made (a clone target, a created notes dir) behind
|
|
521
|
+
// with no project to own them.
|
|
522
|
+
await this.rollback(created);
|
|
523
|
+
throw err;
|
|
255
524
|
}
|
|
256
525
|
// No OVERVIEW.md at creation — the first sweep writes it.
|
|
257
526
|
return this.toDto(dir, yaml, false);
|
|
258
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Write a new project's `project.yaml` and seed its content files. Split out of
|
|
530
|
+
* {@link create} so the whole sequence sits inside that method's single
|
|
531
|
+
* try/rollback rather than trailing off the end of it.
|
|
532
|
+
*/
|
|
533
|
+
async finishCreate(args) {
|
|
534
|
+
const { slug, yaml, dir, name, now, managed } = args;
|
|
535
|
+
await this.writeYaml(slug, yaml);
|
|
536
|
+
const contentDir = contentDirFor(dir, yaml);
|
|
537
|
+
// The curated trio follows the content (issue #206): for a managed project
|
|
538
|
+
// with an external `path` these land out there rather than in the data dir.
|
|
539
|
+
// `dir` is already made; `contentDir` may be the same directory or the
|
|
540
|
+
// freshly-acquired path, both of which exist by now.
|
|
541
|
+
//
|
|
542
|
+
// Never clobber: a managed `path` may point at a folder of notes that already
|
|
543
|
+
// has a CHANGELOG.md, and seeding "Project opened." over the user's history
|
|
544
|
+
// would be the worst kind of data loss — silent and plausible. For a fresh
|
|
545
|
+
// project dir the file is always absent, so this only bites in the new case.
|
|
546
|
+
const changelogFile = path.join(contentDir, CHANGELOG_FILE);
|
|
547
|
+
const changelogExists = await fs
|
|
548
|
+
.access(changelogFile)
|
|
549
|
+
.then(() => true)
|
|
550
|
+
.catch(() => false);
|
|
551
|
+
if (!changelogExists) {
|
|
552
|
+
await fs.writeFile(changelogFile, [
|
|
553
|
+
`# Changelog — ${name}`,
|
|
554
|
+
"",
|
|
555
|
+
"<!--",
|
|
556
|
+
"Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
|
|
557
|
+
"-->",
|
|
558
|
+
"",
|
|
559
|
+
`## ${now}`,
|
|
560
|
+
"- Project opened.",
|
|
561
|
+
"",
|
|
562
|
+
].join("\n"), "utf8");
|
|
563
|
+
}
|
|
564
|
+
// Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a MANAGED
|
|
565
|
+
// project, and never over an existing one. An UNMANAGED project's working
|
|
566
|
+
// directory owns its own `CLAUDE.md` (upstream's, in a checkout the user
|
|
567
|
+
// source-controls); the sweeper must never write that file, so we don't seed a
|
|
568
|
+
// competing one. A managed project with an external `path` DOES get one, out
|
|
569
|
+
// there with the rest of its content — but if that directory already has a
|
|
570
|
+
// `CLAUDE.md` (a pre-existing notes folder), it is the user's and stays.
|
|
571
|
+
if (managed) {
|
|
572
|
+
const claudeFile = path.join(contentDir, CLAUDE_FILE);
|
|
573
|
+
const claudeExists = await fs
|
|
574
|
+
.access(claudeFile)
|
|
575
|
+
.then(() => true)
|
|
576
|
+
.catch(() => false);
|
|
577
|
+
if (!claudeExists) {
|
|
578
|
+
await fs.writeFile(claudeFile, claudeTemplate(name, yaml.summary), "utf8");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
259
582
|
/**
|
|
260
583
|
* Promote an existing NOTEBOOK project into a REPO-BACKED one IN PLACE (issue
|
|
261
584
|
* #213), preserving its chats + sidecar metadata. This relaxes #187's create-time
|
|
@@ -293,8 +616,18 @@ export class ProjectStore {
|
|
|
293
616
|
throw new ProjectError("The root workspace cannot be repo-backed", "invalid");
|
|
294
617
|
}
|
|
295
618
|
const current = await this.get(slug); // throws not_found
|
|
296
|
-
|
|
297
|
-
|
|
619
|
+
// Promotion means "start working in a clone of this repo", so it only makes
|
|
620
|
+
// sense for a project Paddock currently manages. An UNMANAGED project already
|
|
621
|
+
// works in code somebody source-controls (issue #206).
|
|
622
|
+
if (!current.managed) {
|
|
623
|
+
throw new ProjectError(`Project is already backed by a repo or directory: ${slug}`, "invalid");
|
|
624
|
+
}
|
|
625
|
+
// A managed project with its own `path` is a notes folder the user nominated;
|
|
626
|
+
// promoting it would clone into that directory. Say so specifically — the
|
|
627
|
+
// generic message above would be misleading.
|
|
628
|
+
if (current.path) {
|
|
629
|
+
throw new ProjectError(`Project "${slug}" keeps its content in ${current.path} and cannot be promoted; ` +
|
|
630
|
+
`create an unmanaged project pointing at the repo instead`, "invalid");
|
|
298
631
|
}
|
|
299
632
|
const repo = repoUrl?.trim();
|
|
300
633
|
if (!repo || !isValidRepoUrl(repo)) {
|
|
@@ -317,7 +650,7 @@ export class ProjectStore {
|
|
|
317
650
|
await cloneRepo(repo, checkoutDir);
|
|
318
651
|
}
|
|
319
652
|
catch (err) {
|
|
320
|
-
await
|
|
653
|
+
await this.rmInsideRoot(checkoutDir).catch(() => undefined);
|
|
321
654
|
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
322
655
|
}
|
|
323
656
|
// `writeYaml` is the atomic COMMIT point: until it succeeds the notebook is
|
|
@@ -330,13 +663,19 @@ export class ProjectStore {
|
|
|
330
663
|
const next = {
|
|
331
664
|
...this.stripDto(current),
|
|
332
665
|
repo,
|
|
666
|
+
// Promotion crosses the managed axis (issue #206): the keeper's cwd becomes
|
|
667
|
+
// a checkout that owns its own CLAUDE.md, which is exactly what unmanaged
|
|
668
|
+
// means. Set explicitly — `stripDto` carries the notebook's concrete
|
|
669
|
+
// `managed: true` forward, and leaving it would persist the one combination
|
|
670
|
+
// `create()` rejects (managed + repo).
|
|
671
|
+
managed: false,
|
|
333
672
|
updated: today(),
|
|
334
673
|
};
|
|
335
674
|
try {
|
|
336
675
|
await this.writeYaml(slug, next);
|
|
337
676
|
}
|
|
338
677
|
catch (err) {
|
|
339
|
-
await
|
|
678
|
+
await this.rmInsideRoot(checkoutDir).catch(() => undefined);
|
|
340
679
|
throw err;
|
|
341
680
|
}
|
|
342
681
|
// Post-commit finalization — the project is now validly repo-backed; these
|
|
@@ -364,6 +703,24 @@ export class ProjectStore {
|
|
|
364
703
|
started: current.started, // immutable
|
|
365
704
|
updated: today(),
|
|
366
705
|
};
|
|
706
|
+
// `path` and `managed` are immutable (issue #206), enforced here rather than
|
|
707
|
+
// by their absence from UpdateProjectInput — that type is a compile-time shape
|
|
708
|
+
// while `rest` is spread from a request body.
|
|
709
|
+
//
|
|
710
|
+
// For `path` both directions matter: a project must not be RE-POINTED (its cwd
|
|
711
|
+
// is baked into every transcript path, so moving it strands the history), and
|
|
712
|
+
// one must not be given a path by a stray key, which would silently hand its
|
|
713
|
+
// keeper a cwd somewhere else on the box that never went through
|
|
714
|
+
// `validatePath`. `managed` is re-asserted from `current` for the same reason
|
|
715
|
+
// — flipping it would move where the curated trio lives and either start the
|
|
716
|
+
// sweeper writing into a checkout or orphan the existing notes. (Changing it
|
|
717
|
+
// deliberately is a migration, not a PATCH; see #708 for what a silent
|
|
718
|
+
// location flip does to history.)
|
|
719
|
+
if (current.path)
|
|
720
|
+
next.path = current.path;
|
|
721
|
+
else
|
|
722
|
+
delete next.path;
|
|
723
|
+
next.managed = current.managed;
|
|
367
724
|
if (driveModePatch === null) {
|
|
368
725
|
// Clear the per-project override -> inherit the global default (issue #122).
|
|
369
726
|
delete next.driveMode;
|
|
@@ -440,7 +797,7 @@ export class ProjectStore {
|
|
|
440
797
|
/** Read OVERVIEW.md, or "" if it doesn't exist yet. */
|
|
441
798
|
async readOverview(slug) {
|
|
442
799
|
try {
|
|
443
|
-
return await fs.readFile(path.join(this.
|
|
800
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), "utf8");
|
|
444
801
|
}
|
|
445
802
|
catch (err) {
|
|
446
803
|
if (err.code === "ENOENT")
|
|
@@ -450,12 +807,12 @@ export class ProjectStore {
|
|
|
450
807
|
}
|
|
451
808
|
/** Replace OVERVIEW.md wholesale (the sweep regenerates it each time). */
|
|
452
809
|
async writeOverview(slug, content) {
|
|
453
|
-
await fs.writeFile(path.join(this.
|
|
810
|
+
await fs.writeFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), content, "utf8");
|
|
454
811
|
}
|
|
455
812
|
/** Read CHANGELOG.md, or "" if it doesn't exist yet (issue #188). */
|
|
456
813
|
async readChangelog(slug) {
|
|
457
814
|
try {
|
|
458
|
-
return await fs.readFile(path.join(this.
|
|
815
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), CHANGELOG_FILE), "utf8");
|
|
459
816
|
}
|
|
460
817
|
catch (err) {
|
|
461
818
|
if (err.code === "ENOENT")
|
|
@@ -465,19 +822,39 @@ export class ProjectStore {
|
|
|
465
822
|
}
|
|
466
823
|
/** Whether OVERVIEW.md exists for this project. */
|
|
467
824
|
async overviewExists(slug) {
|
|
825
|
+
return this.overviewExistsIn(await this.contentDirOf(slug));
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* {@link overviewExists} for a content dir already in hand.
|
|
829
|
+
*
|
|
830
|
+
* `readSafe` needs this: it has just parsed the yaml, so it can derive the
|
|
831
|
+
* content dir for free, whereas going through the slug would re-read
|
|
832
|
+
* `project.yaml` on every project of every `list()`.
|
|
833
|
+
*/
|
|
834
|
+
async overviewExistsIn(contentDir) {
|
|
468
835
|
try {
|
|
469
|
-
await fs.access(path.join(
|
|
836
|
+
await fs.access(path.join(contentDir, OVERVIEW_FILE));
|
|
470
837
|
return true;
|
|
471
838
|
}
|
|
472
839
|
catch {
|
|
473
840
|
return false;
|
|
474
841
|
}
|
|
475
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Where this project's curated trio lives (issue #206) — see
|
|
845
|
+
* {@link contentDirFor}. Falls back to the metadata dir for a slug that names
|
|
846
|
+
* no project, so a caller reading a missing project still gets "" rather than
|
|
847
|
+
* throwing.
|
|
848
|
+
*/
|
|
849
|
+
async contentDirOf(slug) {
|
|
850
|
+
const project = await this.readSafe(slug);
|
|
851
|
+
return project?.contentDir ?? this.dirFor(slug);
|
|
852
|
+
}
|
|
476
853
|
// --- CLAUDE.md (durable identity & conventions, sweep-amended) ----------
|
|
477
854
|
/** Read CLAUDE.md, or "" if it doesn't exist yet (issue #177). */
|
|
478
855
|
async readClaudeMd(slug) {
|
|
479
856
|
try {
|
|
480
|
-
return await fs.readFile(path.join(this.
|
|
857
|
+
return await fs.readFile(path.join(await this.contentDirOf(slug), CLAUDE_FILE), "utf8");
|
|
481
858
|
}
|
|
482
859
|
catch (err) {
|
|
483
860
|
if (err.code === "ENOENT")
|
|
@@ -502,8 +879,7 @@ export class ProjectStore {
|
|
|
502
879
|
const trimmed = curatedBody.trim();
|
|
503
880
|
if (!trimmed)
|
|
504
881
|
return;
|
|
505
|
-
const
|
|
506
|
-
const file = path.join(dir, CLAUDE_FILE);
|
|
882
|
+
const file = path.join(await this.contentDirOf(slug), CLAUDE_FILE);
|
|
507
883
|
let body;
|
|
508
884
|
try {
|
|
509
885
|
body = await fs.readFile(file, "utf8");
|
|
@@ -602,13 +978,12 @@ export class ProjectStore {
|
|
|
602
978
|
throw new ProjectError("Refusing to delete the root workspace", "invalid");
|
|
603
979
|
}
|
|
604
980
|
const project = await this.get(slug); // throws not_found
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await fs.rm(dir, { recursive: true, force: true });
|
|
981
|
+
// Only ever the METADATA dir — never `project.workingDir`. For a LINKED
|
|
982
|
+
// project (issue #206) those differ and the working dir is the user's real
|
|
983
|
+
// clone: deleting the project unlinks it, it does not delete their work.
|
|
984
|
+
// `rmInsideRoot` enforces that structurally (a linked path is outside the
|
|
985
|
+
// root by validation, so it cannot be reached from here at all).
|
|
986
|
+
await this.rmInsideRoot(this.dirFor(slug));
|
|
612
987
|
return project;
|
|
613
988
|
}
|
|
614
989
|
/**
|
|
@@ -626,7 +1001,7 @@ export class ProjectStore {
|
|
|
626
1001
|
const trimmed = body.trim();
|
|
627
1002
|
if (!trimmed)
|
|
628
1003
|
return;
|
|
629
|
-
const file = path.join(this.
|
|
1004
|
+
const file = path.join(await this.contentDirOf(slug), CHANGELOG_FILE);
|
|
630
1005
|
const title = `# Changelog — ${slug}`;
|
|
631
1006
|
// Drop a leading top-level heading the model may have emitted, so the
|
|
632
1007
|
// Paddock-owned title isn't duplicated.
|
|
@@ -695,8 +1070,11 @@ export class ProjectStore {
|
|
|
695
1070
|
return null;
|
|
696
1071
|
yaml = this.normalize({}, key);
|
|
697
1072
|
}
|
|
698
|
-
//
|
|
699
|
-
|
|
1073
|
+
// A cheap fs.access, done after the yaml parse succeeds. Deliberately the
|
|
1074
|
+
// content-dir-in-hand variant: the slug-taking `overviewExists` would re-read
|
|
1075
|
+
// this very `project.yaml` to find the content dir, once per project of every
|
|
1076
|
+
// `list()`.
|
|
1077
|
+
const hasOverview = await this.overviewExistsIn(contentDirFor(dir, yaml));
|
|
700
1078
|
return this.toDto(dir, yaml, hasOverview);
|
|
701
1079
|
}
|
|
702
1080
|
/** Fill defaults / coerce a parsed project.yaml into a complete ProjectYaml. */
|
|
@@ -776,6 +1154,25 @@ export class ProjectStore {
|
|
|
776
1154
|
// repo (issue #187): carried only when present — its presence is what marks
|
|
777
1155
|
// the project repo-backed and drives the workingDir resolution in toDto.
|
|
778
1156
|
...(typeof p.repo === "string" && p.repo.trim() ? { repo: p.repo.trim() } : {}),
|
|
1157
|
+
// path (issue #206): carried only when present AND absolute — its presence
|
|
1158
|
+
// is what makes workingDirFor return the cwd verbatim. The absolute check is
|
|
1159
|
+
// a read-boundary backstop against a hand-edited relative value, which would
|
|
1160
|
+
// otherwise resolve against the server's cwd; dropping it degrades the
|
|
1161
|
+
// project to an unbacked one rather than pointing an agent somewhere
|
|
1162
|
+
// arbitrary. Existence/containment were checked at create time and are NOT
|
|
1163
|
+
// re-checked here — `normalize` runs on every list() and must stay pure +
|
|
1164
|
+
// cheap, and a path that has since gone away should surface as a broken cwd,
|
|
1165
|
+
// not silently mutate into a different kind of project.
|
|
1166
|
+
...(typeof p.path === "string" && path.isAbsolute(p.path.trim())
|
|
1167
|
+
? { path: p.path.trim() }
|
|
1168
|
+
: {}),
|
|
1169
|
+
// managed (issue #206): carried ONLY when explicitly set on disk. An absent
|
|
1170
|
+
// value must stay absent so it keeps deriving from the backing
|
|
1171
|
+
// (`!(repo || path)`) — baking a concrete `true` in here would flip every
|
|
1172
|
+
// legacy repo-backed project to managed the first time its yaml was
|
|
1173
|
+
// re-written, and the sweeper would start writing CLAUDE.md into a checkout.
|
|
1174
|
+
// Same round-trip discipline as `driveMode`, and for a much sharper reason.
|
|
1175
|
+
...(typeof p.managed === "boolean" ? { managed: p.managed } : {}),
|
|
779
1176
|
// schedules (issue #265): carried only when at least one well-formed entry
|
|
780
1177
|
// survives sanitization — an absent/empty map stays absent on disk, so files
|
|
781
1178
|
// without schedules round-trip unchanged. A malformed entry is dropped (not
|
|
@@ -875,18 +1272,32 @@ export class ProjectStore {
|
|
|
875
1272
|
// sanitised so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#384).
|
|
876
1273
|
curation: sanitizeCurationOverride(yaml.curation),
|
|
877
1274
|
dir,
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
workingDir: workingDirFor(dir, yaml.repo),
|
|
881
|
-
|
|
1275
|
+
// The keeper's cwd: `path` verbatim (#206), else the nested checkout for a
|
|
1276
|
+
// repo-backed project (#187), else the metadata dir.
|
|
1277
|
+
workingDir: workingDirFor(dir, yaml.repo, yaml.path),
|
|
1278
|
+
// Where the curated trio lives, and whether Paddock curates at all — the
|
|
1279
|
+
// two facts that replaced `repoBacked` (#206). Both ALWAYS concrete here,
|
|
1280
|
+
// with `managed` resolved through the derived default so a legacy file
|
|
1281
|
+
// without the key keeps the meaning it has always had.
|
|
1282
|
+
contentDir: contentDirFor(dir, yaml),
|
|
1283
|
+
managed: isManaged(yaml),
|
|
882
1284
|
hasOverview,
|
|
883
1285
|
};
|
|
884
1286
|
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Drop the derived DTO-only fields, leaving the on-disk shape.
|
|
1289
|
+
*
|
|
1290
|
+
* `managed` deliberately SURVIVES: it is a real `project.yaml` key, and because
|
|
1291
|
+
* the DTO's value is always concrete, the first `update()` of a legacy project
|
|
1292
|
+
* pins its derived value explicitly on disk. That is the desired direction —
|
|
1293
|
+
* the file stops depending on a default — and it is safe precisely because the
|
|
1294
|
+
* value written is the one the derivation already produced.
|
|
1295
|
+
*/
|
|
885
1296
|
stripDto(p) {
|
|
886
|
-
const { dir: _dir, workingDir: _workingDir,
|
|
1297
|
+
const { dir: _dir, workingDir: _workingDir, contentDir: _contentDir, hasOverview: _hasOverview, group, ...rest } = p;
|
|
887
1298
|
void _dir;
|
|
888
1299
|
void _workingDir;
|
|
889
|
-
void
|
|
1300
|
+
void _contentDir;
|
|
890
1301
|
void _hasOverview;
|
|
891
1302
|
// Keep an empty area off the yaml so it isn't persisted as `group: ""`.
|
|
892
1303
|
return group?.trim() ? { ...rest, group } : rest;
|