@1agh/maude 0.60.6 → 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.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +190 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -6
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +919 -214
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +90 -16
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +99 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
package/cli/lib/design-link.mjs
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|