@1agh/maude 0.60.7 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -15,8 +15,17 @@ import { dirname, resolve } from 'node:path';
15
15
  import { createInterface } from 'node:readline';
16
16
 
17
17
  import { parseArgs } from './argv.mjs';
18
+ import { adoptToHub, detachToRepo, ownershipState } from './design-ownership.mjs';
18
19
  import { BEGIN_MARKER, writeGitignoreBlock } from './gitignore-block.mjs';
19
- import { addHub, getHub, isHubTrusted, normalizeUrl, removeHub, trustHub } from './hubs-config.mjs';
20
+ import {
21
+ addHub,
22
+ getHub,
23
+ isHubTrusted,
24
+ normalizeUrl,
25
+ removeHub,
26
+ setHubCodeModules,
27
+ trustHub,
28
+ } from './hubs-config.mjs';
20
29
 
21
30
  const DESIGN_CONFIG_PATH = '.design/config.json';
22
31
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -164,6 +173,25 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
164
173
  await maybeWriteGitignoreBlock(cwd, !!flags.yes);
165
174
  }
166
175
 
176
+ // The code-module consent (DDR-226 §9 / review finding A5). This is the
177
+ // ONLY writer: the receiver reads `codeModulesAllowed` from the stored hub
178
+ // record and nothing else may set it — least of all a sign-in response,
179
+ // which is the hub telling you what your own role is.
180
+ //
181
+ // Without a writer the field could never be true, so `code-module` had no
182
+ // transport at all in hub-owned mode: an owner could push one through the
183
+ // door and no peer would ever accept it. Closed harder than intended is
184
+ // still closed wrong.
185
+ await askCodeModuleConsent(normUrl, { assumeYes: !!flags.yes, loopback });
186
+
187
+ // DDR-228 — a link must land in ONE of the two ownership modes.
188
+ //
189
+ // Linked-and-committed is not a lighter version of hub-owned; it is two
190
+ // systems owning the same bytes with different merge rules, where a `git
191
+ // pull` and a sync pass can each undo the other and which wins is timing.
192
+ // It also reads as extra safety right up until they disagree.
193
+ await settleOwnership(cwd, { assumeYes: !!flags.yes, adopt });
194
+
167
195
  const tsxSyncLine =
168
196
  syncTsx === false
169
197
  ? 'off (opted out — linkedHub.syncTsx: false)'
@@ -211,6 +239,148 @@ export async function runUnlink({ args, cwd = process.cwd() }) {
211
239
  );
212
240
  }
213
241
 
242
+ /**
243
+ * Ask, once per hub, whether it may deliver executable modules.
244
+ *
245
+ * `.ts`/`.mjs` outside a canvas body are read by the AGENT and by every
246
+ * `maude design *` helper — a different blast radius from a `.tsx` rendering
247
+ * in the sandboxed canvas origin. Default NO on every non-answer (non-TTY,
248
+ * declined, anything unparsed): the pessimistic branch, same as every other
249
+ * default in this lane.
250
+ */
251
+ async function askCodeModuleConsent(normUrl, { assumeYes = false, loopback = false } = {}) {
252
+ // A loopback pairing is this machine talking to itself; there is no remote
253
+ // party to consent about.
254
+ if (loopback) return;
255
+ const existing = getHub(normUrl);
256
+ if (existing && typeof existing.codeModulesAllowed === 'boolean') return;
257
+
258
+ let allow = false;
259
+ if (assumeYes) {
260
+ allow = false; // --yes accepts DEFAULTS, and this default is no.
261
+ } else if (process.stdin.isTTY) {
262
+ process.stdout.write(
263
+ '\n Shared code (.ts / .mjs outside a canvas) is read by Claude and by the\n' +
264
+ ' maude helpers on this machine, not just rendered in a preview.\n'
265
+ );
266
+ allow = await promptYesNo(` Let ${normUrl} deliver those to this machine? [y/N] `, false);
267
+ }
268
+ setHubCodeModules(normUrl, allow);
269
+ process.stdout.write(
270
+ allow
271
+ ? '[design link] this hub may deliver shared code modules to this machine.\n'
272
+ : `[design link] shared code modules from ${normUrl} will NOT be written here (design files still sync). Re-link and answer yes to change it.\n`
273
+ );
274
+ }
275
+
276
+ // ------------------------------------------------------- ownership (DDR-228)
277
+
278
+ /**
279
+ * Put this repo in exactly one ownership mode, asking once if it is unclear.
280
+ *
281
+ * Called at the END of a link, so the hub already has the credential and the
282
+ * push is about to happen — the folder is not orphaned for a moment in
283
+ * between. Non-TTY takes the safe branch (leave it committed, say so) rather
284
+ * than untracking a person's files with nobody watching.
285
+ */
286
+ async function settleOwnership(cwd, { assumeYes = false, adopt = false } = {}) {
287
+ const st = ownershipState(cwd, { linked: true });
288
+ // No repo ⇒ no second owner ⇒ nothing to settle.
289
+ if (!st.git || st.mode === 'hub-owned') return;
290
+ if (st.trackedCount === 0 && !st.ignored) {
291
+ // A fresh clone of a Mode-B repo: nothing tracked because there is nothing
292
+ // here yet. Declare the mode now so the first pull lands ignored.
293
+ adoptToHub(cwd);
294
+ process.stdout.write(
295
+ '[design link] this project is hub-owned — .design/ is gitignored and mirrored by the hub.\n'
296
+ );
297
+ warnSyncthing(st);
298
+ return;
299
+ }
300
+
301
+ process.stdout.write(
302
+ `\n This repo currently commits .design/ (${st.trackedCount} file${st.trackedCount === 1 ? '' : 's'}) AND is now linked to a hub.\n` +
303
+ ' Those are two owners for the same files, which is the one state Maude does not support:\n' +
304
+ ' a git pull and a sync pass can each undo the other.\n\n' +
305
+ ' Hub-owned — stop committing .design/, let the hub mirror it (nothing is deleted).\n' +
306
+ ' Repo-owned — keep committing it, and unlink the hub.\n\n'
307
+ );
308
+
309
+ const goHub =
310
+ assumeYes || !process.stdin.isTTY
311
+ ? assumeYes
312
+ : await promptYesNo(' Make this project hub-owned? [Y/n] ', true);
313
+
314
+ if (!goHub) {
315
+ process.stdout.write(
316
+ '[design link] left as-is. Run `maude design adopt` to hand .design/ to the hub, or `maude design unlink` to keep it in git.\n'
317
+ );
318
+ return;
319
+ }
320
+
321
+ const res = adoptToHub(cwd);
322
+ process.stdout.write(
323
+ `[design link] hub-owned: .gitignore ${res.action}, ${res.untracked} file(s) untracked (still on disk, staged as deletions — commit when ready).\n`
324
+ );
325
+ warnSyncthing(st);
326
+ }
327
+
328
+ /**
329
+ * Syncthing does not read `.gitignore`.
330
+ *
331
+ * So a hub-owned project inside a synced tree rides two transports with
332
+ * different conflict rules — the same double-ownership one layer down, through
333
+ * a door the ignore cannot close.
334
+ */
335
+ function warnSyncthing(st) {
336
+ if (!st.syncthingRoot) return;
337
+ process.stdout.write(
338
+ `\n NOTE: this repo is inside a Syncthing folder (${st.syncthingRoot}).\n` +
339
+ ' Syncthing ignores .gitignore, so it will keep syncing .design/ alongside the hub.\n' +
340
+ ` Add this line to ${st.syncthingRoot}/.stignore to stop that:\n\n ${st.stignoreLine}\n\n`
341
+ );
342
+ }
343
+
344
+ /** `maude design detach` — B back to A. */
345
+ export async function runDetach({ args, cwd = process.cwd() }) {
346
+ const tail = args.slice(args.indexOf('detach') + 1);
347
+ const { flags } = parseArgs(tail, { booleans: ['yes', 'keep-token'] });
348
+ const designConfigPath = resolve(cwd, DESIGN_CONFIG_PATH);
349
+
350
+ if (!existsSync(designConfigPath)) {
351
+ process.stderr.write(`maude design detach: no ${DESIGN_CONFIG_PATH} in ${cwd}.\n`);
352
+ process.exit(1);
353
+ }
354
+ const cfg = readDesignConfig(designConfigPath);
355
+ const linked = !!cfg.linkedHub;
356
+ const st = ownershipState(cwd, { linked });
357
+
358
+ if (!linked && st.mode === 'repo-owned' && !st.ignored) {
359
+ process.stdout.write('[design detach] already repo-owned — nothing to do.\n');
360
+ return;
361
+ }
362
+
363
+ const ok =
364
+ flags.yes || !process.stdin.isTTY
365
+ ? true
366
+ : await promptYesNo(' Take .design/ back into this repo and stop syncing it? [Y/n] ', true);
367
+ if (!ok) return;
368
+
369
+ // Unlink first: the mirror is a FULL copy, so there is nothing to fetch and
370
+ // no window where the folder belongs to neither owner.
371
+ if (linked) {
372
+ const url = cfg.linkedHub.url;
373
+ cfg.linkedHub = undefined;
374
+ writeDesignConfig(designConfigPath, cfg);
375
+ if (!flags['keep-token']) removeHub(url);
376
+ process.stdout.write(`[design detach] unlinked from ${url}.\n`);
377
+ }
378
+ const res = detachToRepo(cwd);
379
+ process.stdout.write(
380
+ `[design detach] repo-owned: .gitignore ${res.action}. Every file is already on disk — commit .design/ when you are ready:\n\n git add .design && git commit -m "take the design folder back into the repo"\n\n`
381
+ );
382
+ }
383
+
214
384
  // ---------------------------------------------------------------- status
215
385
 
216
386
  export async function runStatus({ args, cwd = process.cwd() }) {
@@ -264,6 +434,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
264
434
  // Task 8 — the running sync agent writes `.design/_sync.json` with the live
265
435
  // offline/online state, queued-op count, last sync, and conflict log.
266
436
  sync: sync ?? { agent: 'idle', detail: 'no _sync.json — sync agent not running' },
437
+ // DDR-228 — which of the two ownership modes this project is in, or
438
+ // `hybrid` for a project linked before the model existed.
439
+ ownership: ownershipState(cwd, { linked: true }),
267
440
  };
268
441
 
269
442
  if (flags.json) {
@@ -271,6 +444,18 @@ export async function runStatus({ args, cwd = process.cwd() }) {
271
444
  return;
272
445
  }
273
446
 
447
+ // The legacy-hybrid notice. Persistent rather than one-shot on purpose: it
448
+ // describes a state that is still true every time you look, and the answer
449
+ // is a decision only the person can make.
450
+ if (payload.ownership.mode === 'hybrid') {
451
+ process.stdout.write(
452
+ `\n ⚠ legacy hybrid — this project is linked to a hub AND still commits .design/ (${payload.ownership.trackedCount} file(s)).\n` +
453
+ ' Two owners, different merge rules: a git pull and a sync pass can each undo the other.\n' +
454
+ ' Pick one: `maude design adopt <url> --token <hex>` (hub-owned, nothing deleted)\n' +
455
+ ' `maude design detach` (repo-owned, stops syncing)\n\n'
456
+ );
457
+ }
458
+
274
459
  const uptimeS = Math.round((probe.uptimeMs ?? 0) / 1000);
275
460
  // DDR-102 — per-doc rollup (old payloads without `docs` render unchanged).
276
461
  const docsLine = sync?.docs
@@ -0,0 +1,330 @@
1
+ // Two ownership modes for `.design/`, and nothing between them — DDR-228,
2
+ // Sync v2 Increment 4.5.
3
+ //
4
+ // Mode A — repo-owned. The folder is committed. Your git is the truth,
5
+ // collaboration is `git pull`, Maude is not involved.
6
+ // Mode B — hub-owned. The folder is GITIGNORED and mirrored by the hub.
7
+ // The hub's own history plus object storage is the
8
+ // truth, collaboration is live, and the local copy is
9
+ // a full working mirror rather than the original.
10
+ //
11
+ // The hybrid — linked AND committed — is the state this module exists to end.
12
+ // It looks harmless and is not: two systems own the same bytes with different
13
+ // merge rules, so a `git pull` and a sync pass can each undo the other, and
14
+ // which one wins depends on timing. Worse, it reads as extra safety ("it's in
15
+ // git AND in the cloud") right up until the two disagree.
16
+ //
17
+ // Transitions are one-shot, explicit, and confirmed. They are NOT a sync mode:
18
+ //
19
+ // adopt (A → B) push what is here, then ignore + untrack it.
20
+ // detach (B → A) stop syncing, un-ignore, and let the person commit.
21
+ //
22
+ // ── What this module deliberately does not touch ────────────────────────────
23
+ //
24
+ // DDR-115's runtime-state lists govern what syncs INSIDE `.design/`, and the
25
+ // design-runtime `.gitignore` block (`gitignore-block.mjs`) governs which of
26
+ // those per-machine files git should skip. Both remain exactly as they are.
27
+ // This is a FIFTH, different concern: whether the design root as a WHOLE is
28
+ // the repo's business at all. Conflating them would mean a mode switch
29
+ // silently rewriting the taxonomy.
30
+
31
+ import { execFileSync } from 'node:child_process';
32
+ import { existsSync, lstatSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
33
+ import { dirname, join, resolve } from 'node:path';
34
+
35
+ /**
36
+ * Write `.gitignore` only when it is a REGULAR FILE, and atomically.
37
+ *
38
+ * Git can carry a symlink named `.gitignore`, and a clone of a repo somebody
39
+ * else influences is exactly the surface this module runs against. Following
40
+ * one turns a mode switch into an arbitrary-path overwrite. Temp + rename
41
+ * also means a crash mid-write cannot leave a repo with half an ignore file.
42
+ */
43
+ function writeGitignoreSafely(path, contents) {
44
+ if (existsSync(path) && !lstatSync(path).isFile()) {
45
+ throw new Error(`${path} is not a regular file — refusing to write through it`);
46
+ }
47
+ const tmp = `${path}.maude-${process.pid}.tmp`;
48
+ writeFileSync(tmp, contents, 'utf8');
49
+ renameSync(tmp, path);
50
+ }
51
+
52
+ /** The marker pair around the whole-folder ignore, so it can be removed exactly. */
53
+ export const OWNERSHIP_BEGIN = '# maude:hub-owned:begin';
54
+ export const OWNERSHIP_END = '# maude:hub-owned:end';
55
+
56
+ /**
57
+ * The block that makes a design root hub-owned.
58
+ *
59
+ * A trailing `/` so it matches the directory and everything under it, and an
60
+ * explicit un-ignore of nothing — the block is deliberately one rule. A
61
+ * cleverer block (ignore the folder but keep `config.json`) is the hybrid
62
+ * wearing a disguise: `config.json` names the hub, so a committed one is how
63
+ * a teammate discovers the project, and that is `maude design link`'s job.
64
+ */
65
+ export function buildOwnershipBlock(designRel = '.design') {
66
+ const rel = designRel.replace(/^\.\//, '').replace(/\/+$/, '');
67
+ return [
68
+ OWNERSHIP_BEGIN,
69
+ '# This project is hub-owned: the design folder is mirrored by your Maude hub,',
70
+ '# not by git. Run `maude design detach` to take it back into the repo.',
71
+ `/${rel}/`,
72
+ OWNERSHIP_END,
73
+ '',
74
+ ].join('\n');
75
+ }
76
+
77
+ /**
78
+ * The marker pair, matched as WHOLE LINES and only when well-formed.
79
+ *
80
+ * A substring test was not enough, and the gap had teeth: a `.gitignore` is a
81
+ * committed file in a shared repo, which DDR-054 says peers can write. Put a
82
+ * BEGIN marker near the top, an END marker at the bottom, and the victim's
83
+ * real rules in between — `.env`, `*.pem`, `secrets/` — and the "update the
84
+ * existing block" path would replace the whole span with our five lines. Every
85
+ * one of those rules gone, staged, and on a cloud-managed repo mirrored
86
+ * onward. So: line-anchored, exactly one pair, BEGIN before END, or this is
87
+ * not our block and we do not touch it.
88
+ *
89
+ * @returns {{ ok: true, start: number, end: number } | { ok: false, reason: string }}
90
+ */
91
+ export function findOwnershipBlock(contents) {
92
+ if (typeof contents !== 'string') return { ok: false, reason: 'absent' };
93
+ const lines = contents.split('\n');
94
+ const begins = [];
95
+ const ends = [];
96
+ lines.forEach((line, i) => {
97
+ if (line.trim() === OWNERSHIP_BEGIN) begins.push(i);
98
+ if (line.trim() === OWNERSHIP_END) ends.push(i);
99
+ });
100
+ if (begins.length === 0 && ends.length === 0) return { ok: false, reason: 'absent' };
101
+ if (begins.length !== 1 || ends.length !== 1) return { ok: false, reason: 'malformed' };
102
+ if (ends[0] < begins[0]) return { ok: false, reason: 'malformed' };
103
+ // A well-formed block is OURS, and ours has a fixed SHAPE: comment lines,
104
+ // plus exactly one rule, and that rule is an anchored directory path. A line
105
+ // count is not enough — a hostile block wrapping four of the victim's real
106
+ // rules is the same length as the one we write.
107
+ const inner = lines.slice(begins[0] + 1, ends[0]).filter((l) => l.trim().length > 0);
108
+ const rules = inner.filter((l) => !l.trim().startsWith('#'));
109
+ if (rules.length !== 1 || !/^\/[^/].*\/$/.test(rules[0].trim())) {
110
+ return { ok: false, reason: 'malformed' };
111
+ }
112
+ return { ok: true, start: begins[0], end: ends[0] };
113
+ }
114
+
115
+ /** Is this repo already declared hub-owned, by a block we recognise? */
116
+ export function isHubOwned(gitignoreContents) {
117
+ return findOwnershipBlock(gitignoreContents).ok === true;
118
+ }
119
+
120
+ /**
121
+ * Add the block, idempotently. Returns `{ contents, action }`.
122
+ *
123
+ * Appended at the END on purpose: gitignore is last-match-wins, so a rule that
124
+ * has to hold cannot sit above a broader pattern that would re-include the
125
+ * path. This is the same reasoning the store-layout note in CLAUDE.md records
126
+ * for `.kgai/`, and it was learned the same way.
127
+ */
128
+ export function applyOwnershipBlock(contents, designRel = '.design') {
129
+ const block = buildOwnershipBlock(designRel);
130
+ const found = findOwnershipBlock(contents);
131
+ if (found.ok) {
132
+ const lines = contents.split('\n');
133
+ const replaced = [
134
+ ...lines.slice(0, found.start),
135
+ ...block.split('\n').slice(0, -1),
136
+ ...lines.slice(found.end + 1),
137
+ ];
138
+ return { contents: replaced.join('\n'), action: 'updated' };
139
+ }
140
+ if (found.reason === 'malformed') {
141
+ // REFUSE, never rewrite. Markers we did not write are somebody else's
142
+ // content, and guessing at its extent is how a rewrite eats real rules.
143
+ return { contents, action: 'refused-malformed' };
144
+ }
145
+ const base = contents.length === 0 || contents.endsWith('\n') ? contents : `${contents}\n`;
146
+ return { contents: `${base}${base.length > 0 ? '\n' : ''}${block}`, action: 'added' };
147
+ }
148
+
149
+ /** Remove the block, idempotently. Returns `{ contents, action }`. */
150
+ export function removeOwnershipBlock(contents) {
151
+ const found = findOwnershipBlock(contents);
152
+ if (!found.ok) {
153
+ return { contents, action: found.reason === 'malformed' ? 'refused-malformed' : 'absent' };
154
+ }
155
+ const lines = contents.split('\n');
156
+ const kept = [...lines.slice(0, found.start), ...lines.slice(found.end + 1)];
157
+ return { contents: kept.join('\n').replace(/\n{3,}/g, '\n\n'), action: 'removed' };
158
+ }
159
+
160
+ /**
161
+ * Is `dir` inside a git work tree at all?
162
+ *
163
+ * A design root outside a repo is perfectly ordinary — Maude does not require
164
+ * git — and in that case the whole ownership question is moot: there is no
165
+ * second owner to be in conflict with, so every transition here is a no-op
166
+ * rather than an error.
167
+ */
168
+ export function isGitRepo(dir) {
169
+ try {
170
+ const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
171
+ cwd: dir,
172
+ encoding: 'utf8',
173
+ stdio: ['ignore', 'pipe', 'ignore'],
174
+ });
175
+ return out.trim() === 'true';
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Which paths under `designRel` git currently TRACKS.
183
+ *
184
+ * `git rm --cached` on an untracked path exits non-zero, and a mode switch
185
+ * that fails halfway is worse than one that refuses — so the caller asks first
186
+ * and acts on the answer. Empty list on any git failure: a repo git cannot
187
+ * read is one this must not start mutating.
188
+ */
189
+ export function trackedDesignPaths(repoRoot, designRel = '.design') {
190
+ try {
191
+ const out = execFileSync('git', ['ls-files', '-z', '--', designRel], {
192
+ cwd: repoRoot,
193
+ encoding: 'utf8',
194
+ stdio: ['ignore', 'pipe', 'ignore'],
195
+ });
196
+ return out.split('\0').filter((p) => p.length > 0);
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Is this design root inside a Syncthing-managed folder?
204
+ *
205
+ * Syncthing does not read `.gitignore`, so a hub-owned project inside a synced
206
+ * tree rides TWO transports with different conflict rules — the exact
207
+ * double-ownership this module exists to prevent, arriving through a door
208
+ * gitignore cannot close. `~/git` is such a tree, so this is the maintainer's
209
+ * own setup, not a hypothetical.
210
+ */
211
+ export function syncthingFolderRoot(startDir) {
212
+ let cur = resolve(startDir);
213
+ for (;;) {
214
+ if (existsSync(join(cur, '.stfolder'))) return cur;
215
+ const up = dirname(cur);
216
+ if (up === cur) return null;
217
+ cur = up;
218
+ }
219
+ }
220
+
221
+ /** The `.stignore` line that keeps Syncthing out of a hub-owned design root. */
222
+ export function stignoreLineFor(repoRoot, designRel, syncthingRoot) {
223
+ const abs = join(resolve(repoRoot), designRel);
224
+ const rel = abs.slice(resolve(syncthingRoot).length + 1);
225
+ return `/${rel.split('\\').join('/')}`;
226
+ }
227
+
228
+ /**
229
+ * Everything a caller needs to describe the current state without changing it.
230
+ *
231
+ * Read-only by design: both the CLI and the desktop dialog ask this first, so
232
+ * the two surfaces cannot disagree about what mode a project is in.
233
+ */
234
+ export function ownershipState(repoRoot, { designRel = '.design', linked = false } = {}) {
235
+ const gitignorePath = resolve(repoRoot, '.gitignore');
236
+ const contents = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
237
+ const ignored = isHubOwned(contents);
238
+ const tracked = trackedDesignPaths(repoRoot, designRel);
239
+ const stRoot = syncthingFolderRoot(repoRoot);
240
+ const git = isGitRepo(repoRoot);
241
+
242
+ // The hybrid is precisely: linked, and git still carries the folder. With no
243
+ // repo there is no second owner, so a linked project is simply hub-owned.
244
+ const mode = !git
245
+ ? linked
246
+ ? 'hub-owned'
247
+ : 'repo-owned'
248
+ : !linked
249
+ ? 'repo-owned'
250
+ : ignored && tracked.length === 0
251
+ ? 'hub-owned'
252
+ : 'hybrid';
253
+
254
+ return {
255
+ mode,
256
+ git,
257
+ ignored,
258
+ trackedCount: tracked.length,
259
+ tracked,
260
+ syncthingRoot: stRoot,
261
+ ...(stRoot ? { stignoreLine: stignoreLineFor(repoRoot, designRel, stRoot) } : {}),
262
+ };
263
+ }
264
+
265
+ /**
266
+ * A → B. Ignore the folder and stop tracking it. Returns what changed.
267
+ *
268
+ * The working tree is NOT touched — `--cached` removes the index entry and
269
+ * leaves every byte on disk. That is the whole safety property: if this is the
270
+ * wrong call, `maude design detach` puts it back with nothing lost, because
271
+ * nothing was ever deleted.
272
+ *
273
+ * Staging is explicit and narrow (the removals plus `.gitignore`), never
274
+ * `git add -A`: this runs in whatever state the person's tree happens to be
275
+ * in, and a mode switch that sweeps up their unrelated work is a mode switch
276
+ * nobody will trust again.
277
+ */
278
+ export function adoptToHub(repoRoot, { designRel = '.design', dryRun = false } = {}) {
279
+ if (!isGitRepo(repoRoot)) return { action: 'no-git', untracked: 0, tracked: [], dryRun };
280
+ const gitignorePath = resolve(repoRoot, '.gitignore');
281
+ const before = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
282
+ const { contents, action } = applyOwnershipBlock(before, designRel);
283
+ const tracked = trackedDesignPaths(repoRoot, designRel);
284
+
285
+ if (dryRun) return { action, untracked: tracked.length, tracked, dryRun: true };
286
+ // A `.gitignore` carrying markers we did not write is not ours to edit, and
287
+ // untracking the design root against one would be acting on a state we
288
+ // could not read. Refuse the whole transition, not just the write.
289
+ if (action === 'refused-malformed') {
290
+ return { action, untracked: 0, tracked, dryRun: false };
291
+ }
292
+
293
+ writeGitignoreSafely(gitignorePath, contents);
294
+ if (tracked.length > 0) {
295
+ // `-r --cached` in one call; `--ignore-unmatch` so a concurrent removal
296
+ // between the listing and here is not a failed mode switch.
297
+ execFileSync('git', ['rm', '-r', '--cached', '--quiet', '--ignore-unmatch', '--', designRel], {
298
+ cwd: repoRoot,
299
+ stdio: ['ignore', 'ignore', 'pipe'],
300
+ });
301
+ }
302
+ execFileSync('git', ['add', '--', '.gitignore'], {
303
+ cwd: repoRoot,
304
+ stdio: ['ignore', 'ignore', 'pipe'],
305
+ });
306
+ return { action, untracked: tracked.length, tracked, dryRun: false };
307
+ }
308
+
309
+ /**
310
+ * B → A. Un-ignore the folder so the person can commit it again.
311
+ *
312
+ * Deliberately does NOT commit, and does not `git add` the design root: what
313
+ * to commit and when is theirs. The bytes are already on disk in full — the
314
+ * mirror is a complete copy, not a cache — so there is nothing to fetch and
315
+ * nothing that can be lost by waiting.
316
+ */
317
+ export function detachToRepo(repoRoot, { dryRun = false } = {}) {
318
+ if (!isGitRepo(repoRoot)) return { action: 'no-git', dryRun };
319
+ const gitignorePath = resolve(repoRoot, '.gitignore');
320
+ if (!existsSync(gitignorePath)) return { action: 'absent', dryRun };
321
+ const before = readFileSync(gitignorePath, 'utf8');
322
+ const { contents, action } = removeOwnershipBlock(before);
323
+ if (dryRun || action === 'absent' || action === 'refused-malformed') return { action, dryRun };
324
+ writeGitignoreSafely(gitignorePath, contents);
325
+ execFileSync('git', ['add', '--', '.gitignore'], {
326
+ cwd: repoRoot,
327
+ stdio: ['ignore', 'ignore', 'pipe'],
328
+ });
329
+ return { action, dryRun: false };
330
+ }