@edspencer/paddock 0.63.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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/adoptable.d.ts +18 -0
  3. package/packages/server/dist/adoptable.js +1 -1
  4. package/packages/server/dist/app.js +3 -1
  5. package/packages/server/dist/claude-home.js +9 -1
  6. package/packages/server/dist/cli/import-chats.js +1 -1
  7. package/packages/server/dist/git.d.ts +44 -1
  8. package/packages/server/dist/git.js +79 -11
  9. package/packages/server/dist/management-ops.js +2 -1
  10. package/packages/server/dist/project-paths.d.ts +72 -4
  11. package/packages/server/dist/project-paths.js +76 -4
  12. package/packages/server/dist/project-types.d.ts +109 -11
  13. package/packages/server/dist/projects.d.ts +161 -3
  14. package/packages/server/dist/projects.js +466 -64
  15. package/packages/server/dist/routes/git.js +6 -3
  16. package/packages/server/dist/routes/projects.js +16 -2
  17. package/packages/server/dist/self-mcp-types.d.ts +6 -4
  18. package/packages/server/dist/sweep.js +10 -5
  19. package/packages/server/package.json +1 -1
  20. package/packages/web/dist/assets/{InstanceConfigPage-CCk2hVT9.js → InstanceConfigPage-Blr1a-hx.js} +1 -1
  21. package/packages/web/dist/assets/{MarkdownRenderer-B6gST_0U.js → MarkdownRenderer-R6YzfI9Y.js} +1 -1
  22. package/packages/web/dist/assets/{ProjectRedirect-BCmKT4mf.js → ProjectRedirect-Cy-7Thc-.js} +1 -1
  23. package/packages/web/dist/assets/{ProjectView-CCN98MWu.js → ProjectView-bMX4GEau.js} +9 -9
  24. package/packages/web/dist/assets/{ProjectsGrid-CYzuN_yD.js → ProjectsGrid-C9XHf4eS.js} +1 -1
  25. package/packages/web/dist/assets/{arc-BxvtMCZQ.js → arc-B_42F7Yk.js} +1 -1
  26. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Bp6ffJ_u.js → architectureDiagram-3BPJPVTR-BAXzSOyw.js} +1 -1
  27. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BIY2X8nW.js → blockDiagram-GPEHLZMM-BdYEdA-3.js} +1 -1
  28. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Br9stEJS.js → c4Diagram-AAUBKEIU-Cc4o15R5.js} +1 -1
  29. package/packages/web/dist/assets/channel-BZnRb_8C.js +2 -0
  30. package/packages/web/dist/assets/{chunk-2J33WTMH-Djc7uqMV.js → chunk-2J33WTMH-CeuxHjIm.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-4BX2VUAB-yEVMLksS.js → chunk-4BX2VUAB-DnZfrAaV.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-55IACEB6-tJaEdPZg.js → chunk-55IACEB6-DFE6UYOT.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-727SXJPM-bWEqh672.js → chunk-727SXJPM-YVHQ5cHc.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B8U9fB-T.js → chunk-AQP2D5EJ-5BYSbg-F.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-FMBD7UC4-BeEITYig.js → chunk-FMBD7UC4-HBcelndR.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-ND2GUHAM-L2rpLaoK.js → chunk-ND2GUHAM-2AwEsdxJ.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-QZHKN3VN-qqzPMZL_.js → chunk-QZHKN3VN-BPpaZ6ct.js} +1 -1
  38. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +2 -0
  39. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +2 -0
  40. package/packages/web/dist/assets/{core-CSaNbmLO.js → core-Cu_4vw1U.js} +1 -1
  41. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BRF7Nech.js → cose-bilkent-S5V4N54A-DWkF0NrF.js} +1 -1
  42. package/packages/web/dist/assets/{dagre-BM42HDAG-D10rJWOR.js → dagre-BM42HDAG-DTgNmO9I.js} +1 -1
  43. package/packages/web/dist/assets/{diagram-2AECGRRQ-DGjT5mAO.js → diagram-2AECGRRQ-DC28b7YS.js} +1 -1
  44. package/packages/web/dist/assets/{diagram-5GNKFQAL-DaH3bxHh.js → diagram-5GNKFQAL-_WvuLno6.js} +1 -1
  45. package/packages/web/dist/assets/{diagram-KO2AKTUF-QrLW9Wn6.js → diagram-KO2AKTUF-BEYxPfWq.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-LMA3HP47-BX15wgYN.js → diagram-LMA3HP47-Biyev7x0.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-OG6HWLK6-DXQ_p63s.js → diagram-OG6HWLK6-u47NLW3S.js} +1 -1
  48. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C_ioRruE.js → erDiagram-TEJ5UH35-Bud2ISlK.js} +1 -1
  49. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-7d-xLXLM.js → flowDiagram-I6XJVG4X-D-18vLQ1.js} +1 -1
  50. package/packages/web/dist/assets/{format-BSDZezwD.js → format-CQ2BDlby.js} +1 -1
  51. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DZYx5xcj.js → ganttDiagram-6RSMTGT7-CzyRX39N.js} +1 -1
  52. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-dimo-osm.js → gitGraphDiagram-PVQCEYII-DLogfrjg.js} +1 -1
  53. package/packages/web/dist/assets/{index-CUnFKMga.js → index-B0gJ0qWe.js} +14 -14
  54. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BzApY25x.js → infoDiagram-5YYISTIA-OgNPt6RG.js} +1 -1
  55. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DtX47_9p.js → ishikawaDiagram-YF4QCWOH-CrM0t0_X.js} +1 -1
  56. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C7fITKg1.js → journeyDiagram-JHISSGLW-xbxfHfwR.js} +1 -1
  57. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BOrWLf_W.js → kanban-definition-UN3LZRKU-DpszBaZb.js} +1 -1
  58. package/packages/web/dist/assets/{linear-DuyAaBu6.js → linear-BLujdbjQ.js} +1 -1
  59. package/packages/web/dist/assets/{mermaid.core-BQlzuVvO.js → mermaid.core-DzEhy9Qq.js} +4 -4
  60. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CmP748B5.js → mindmap-definition-RKZ34NQL-DaH3Lmoo.js} +1 -1
  61. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CRd5Pdfq.js → pieDiagram-4H26LBE5-BxeW1kuf.js} +1 -1
  62. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DpJm0Yw_.js → quadrantDiagram-W4KKPZXB-BKs2CT0r.js} +1 -1
  63. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-B0o2PRMC.js → requirementDiagram-4Y6WPE33-CVdO-hXD.js} +1 -1
  64. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CBi3Ek3v.js → sankeyDiagram-5OEKKPKP-yusj_kJC.js} +1 -1
  65. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C1stxfQg.js → sequenceDiagram-3UESZ5HK-C0h-9c8Y.js} +1 -1
  66. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-lCkiETWK.js → stateDiagram-AJRCARHV-BSBY8Psb.js} +1 -1
  67. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +2 -0
  68. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D3DkWJMC.js → timeline-definition-PNZ67QCA-DEFUdNb8.js} +1 -1
  69. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CO2-yC7K.js → vennDiagram-CIIHVFJN-DunhMqxK.js} +1 -1
  70. package/packages/web/dist/assets/{wardley-L42UT6IY-Dfjp0kqX.js → wardley-L42UT6IY-CXJsBGKl.js} +1 -1
  71. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CIl0OPwS.js → wardleyDiagram-YWT4CUSO-DfT_-dqX.js} +1 -1
  72. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-CuvlQuup.js → xychartDiagram-2RQKCTM6-BysTtULW.js} +1 -1
  73. package/packages/web/dist/index.html +1 -1
  74. package/packages/web/dist/sw.js +1 -1
  75. package/packages/web/dist/assets/channel-CMdgNeFH.js +0 -2
  76. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +0 -2
  77. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +0 -2
  78. 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
- constructor(root) {
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
- // Repo-backed project (issue #187): validate the URL up front so a bad value
194
- // never reaches `git clone` and the project isn't half-created.
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
- // Carry `repo` only when repo-backed (same round-trip discipline as model).
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
- // For a repo-backed project, clone the external repo into the nested checkout
220
- // BEFORE writing any metadata, so a clone failure rolls the whole thing back
221
- // (rm the freshly-made dir) rather than leaving a broken project.yaml behind.
222
- if (repo) {
223
- const checkoutName = repoCheckoutName(repo);
224
- const checkoutDir = path.join(dir, checkoutName);
225
- try {
226
- await cloneRepo(repo, checkoutDir);
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
- catch (err) {
229
- await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
230
- throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
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
- // Keep the nested checkout (a full git repo) OUT of the instance data repo:
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
- await this.writeYaml(slug, yaml);
238
- await fs.writeFile(path.join(dir, CHANGELOG_FILE), [
239
- `# Changelog — ${name}`,
240
- "",
241
- "<!--",
242
- "Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
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
- if (current.repoBacked) {
297
- throw new ProjectError(`Project is already repo-backed: ${slug}`, "invalid");
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.dirFor(slug), OVERVIEW_FILE), "utf8");
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.dirFor(slug), OVERVIEW_FILE), content, "utf8");
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.dirFor(slug), CHANGELOG_FILE), "utf8");
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(this.dirFor(slug), OVERVIEW_FILE));
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.dirFor(slug), CLAUDE_FILE), "utf8");
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 dir = this.dirFor(slug);
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
- const dir = this.dirFor(slug);
606
- // Guard against deleting the projects root itself if a bad slug slipped in.
607
- const resolved = path.resolve(dir);
608
- if (resolved === path.resolve(this.root) || !resolved.startsWith(path.resolve(this.root) + path.sep)) {
609
- throw new ProjectError("Refusing to delete outside the projects root", "invalid");
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.dirFor(slug), CHANGELOG_FILE);
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
- // overviewExists is a cheap fs.access; do it after the yaml parse succeeds.
699
- const hasOverview = await this.overviewExists(key);
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
- // Repo-backed (#187): the keeper's cwd is the nested checkout; a notebook's
879
- // cwd is the metadata dir itself. `repoBacked` is the presence of `repo`.
880
- workingDir: workingDirFor(dir, yaml.repo),
881
- repoBacked: Boolean(yaml.repo),
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, repoBacked: _repoBacked, hasOverview: _hasOverview, group, ...rest } = p;
1288
+ const { dir: _dir, workingDir: _workingDir, contentDir: _contentDir, hasOverview: _hasOverview, group, ...rest } = p;
887
1289
  void _dir;
888
1290
  void _workingDir;
889
- void _repoBacked;
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;