@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
@@ -0,0 +1,329 @@
1
+ // Two modes, no hybrid — DDR-228.
2
+ //
3
+ // What is pinned here is mostly the SAFETY of the transitions rather than
4
+ // their mechanics: a mode switch runs against whatever state a person's tree
5
+ // happens to be in, so the properties that matter are "nothing on disk moved",
6
+ // "nothing unrelated got staged", and "the reverse puts it back".
7
+
8
+ import assert from 'node:assert/strict';
9
+ import { execFileSync } from 'node:child_process';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ symlinkSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { after, describe, it } from 'node:test';
22
+
23
+ import {
24
+ adoptToHub,
25
+ applyOwnershipBlock,
26
+ buildOwnershipBlock,
27
+ detachToRepo,
28
+ findOwnershipBlock,
29
+ isGitRepo,
30
+ isHubOwned,
31
+ ownershipState,
32
+ removeOwnershipBlock,
33
+ stignoreLineFor,
34
+ syncthingFolderRoot,
35
+ trackedDesignPaths,
36
+ } from './design-ownership.mjs';
37
+
38
+ const made = [];
39
+ after(() => {
40
+ for (const d of made) rmSync(d, { recursive: true, force: true });
41
+ });
42
+
43
+ function gitAvailable() {
44
+ try {
45
+ execFileSync('git', ['--version'], { stdio: 'ignore' });
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ const gitOk = gitAvailable();
52
+
53
+ /** A real repo with a committed `.design/` — the Mode-A starting point. */
54
+ function repoWithDesign() {
55
+ const root = mkdtempSync(join(tmpdir(), 'ownership-'));
56
+ made.push(root);
57
+ const git = (...a) => execFileSync('git', a, { cwd: root, stdio: 'ignore' });
58
+ git('init', '-q');
59
+ git('config', 'user.email', 't@t.test');
60
+ git('config', 'user.name', 'T');
61
+ mkdirSync(join(root, '.design/system/ds'), { recursive: true });
62
+ writeFileSync(join(root, '.design/config.json'), '{}');
63
+ writeFileSync(join(root, '.design/system/ds/brand.css'), ':root{}');
64
+ writeFileSync(join(root, 'README.md'), '# repo');
65
+ git('add', '-A');
66
+ git('commit', '-qm', 'seed');
67
+ return root;
68
+ }
69
+
70
+ describe('the ignore block', () => {
71
+ it('is one rule, and it is idempotent', () => {
72
+ const first = applyOwnershipBlock('', '.design');
73
+ assert.equal(first.action, 'added');
74
+ assert.ok(isHubOwned(first.contents));
75
+ assert.match(first.contents, /^\/\.design\/$/m);
76
+
77
+ const second = applyOwnershipBlock(first.contents, '.design');
78
+ assert.equal(second.action, 'updated');
79
+ assert.equal(second.contents.match(/maude:hub-owned:begin/g).length, 1);
80
+ });
81
+
82
+ it('goes at the END — gitignore is last-match-wins', () => {
83
+ const existing = 'node_modules/\n!.design/keep-me\n';
84
+ const { contents } = applyOwnershipBlock(existing, '.design');
85
+ assert.ok(
86
+ contents.indexOf('/.design/') > contents.indexOf('!.design/keep-me'),
87
+ 'a rule that has to hold cannot sit above one that re-includes the path'
88
+ );
89
+ });
90
+
91
+ it('removes cleanly, leaving the rest of the file alone', () => {
92
+ const existing = 'node_modules/\ndist/\n';
93
+ const { contents: withBlock } = applyOwnershipBlock(existing, '.design');
94
+ const { contents: back, action } = removeOwnershipBlock(withBlock);
95
+ assert.equal(action, 'removed');
96
+ assert.ok(!isHubOwned(back));
97
+ assert.match(back, /node_modules\//);
98
+ assert.match(back, /dist\//);
99
+ });
100
+
101
+ it('removing when absent is not an error', () => {
102
+ assert.equal(removeOwnershipBlock('node_modules/\n').action, 'absent');
103
+ });
104
+
105
+ it('honours a non-default design root', () => {
106
+ assert.match(buildOwnershipBlock('design'), /^\/design\/$/m);
107
+ });
108
+ });
109
+
110
+ describe('adopt — A to B', { skip: gitOk ? false : 'git not available' }, () => {
111
+ it('untracks the folder and leaves every byte on disk', () => {
112
+ const root = repoWithDesign();
113
+ assert.equal(trackedDesignPaths(root).length, 2);
114
+
115
+ const res = adoptToHub(root);
116
+ assert.equal(res.untracked, 2);
117
+
118
+ // THE safety property: `--cached` touches the index, never the tree.
119
+ assert.equal(readFileSync(join(root, '.design/system/ds/brand.css'), 'utf8'), ':root{}');
120
+ assert.equal(existsSync(join(root, '.design/config.json')), true);
121
+ assert.equal(trackedDesignPaths(root).length, 0);
122
+ assert.ok(isHubOwned(readFileSync(join(root, '.gitignore'), 'utf8')));
123
+ });
124
+
125
+ it('stages the removals and .gitignore — and nothing else', () => {
126
+ const root = repoWithDesign();
127
+ // An unrelated edit, dirty in the tree exactly as a real person's would be.
128
+ writeFileSync(join(root, 'README.md'), '# repo, mid-edit');
129
+ writeFileSync(join(root, 'notes.txt'), 'scratch');
130
+
131
+ adoptToHub(root);
132
+
133
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], {
134
+ cwd: root,
135
+ encoding: 'utf8',
136
+ })
137
+ .split('\n')
138
+ .filter(Boolean)
139
+ .sort();
140
+ assert.deepEqual(staged, ['.design/config.json', '.design/system/ds/brand.css', '.gitignore']);
141
+ // Their work is untouched, staged or otherwise.
142
+ assert.equal(readFileSync(join(root, 'README.md'), 'utf8'), '# repo, mid-edit');
143
+ assert.equal(existsSync(join(root, 'notes.txt')), true);
144
+ });
145
+
146
+ it('is safe to run twice', () => {
147
+ const root = repoWithDesign();
148
+ adoptToHub(root);
149
+ const again = adoptToHub(root);
150
+ assert.equal(again.untracked, 0);
151
+ assert.equal(
152
+ readFileSync(join(root, '.gitignore'), 'utf8').match(/maude:hub-owned:begin/g).length,
153
+ 1
154
+ );
155
+ });
156
+
157
+ it('--dry-run says what it would do and does none of it', () => {
158
+ const root = repoWithDesign();
159
+ const res = adoptToHub(root, { dryRun: true });
160
+ assert.equal(res.untracked, 2);
161
+ assert.equal(existsSync(join(root, '.gitignore')), false);
162
+ assert.equal(trackedDesignPaths(root).length, 2);
163
+ });
164
+ });
165
+
166
+ describe('detach — B back to A', { skip: gitOk ? false : 'git not available' }, () => {
167
+ it('un-ignores the folder and leaves committing to the person', () => {
168
+ const root = repoWithDesign();
169
+ adoptToHub(root);
170
+ execFileSync('git', ['commit', '-qm', 'adopt'], { cwd: root, stdio: 'ignore' });
171
+
172
+ const res = detachToRepo(root);
173
+ assert.equal(res.action, 'removed');
174
+ assert.ok(!isHubOwned(readFileSync(join(root, '.gitignore'), 'utf8')));
175
+
176
+ // Not committed, and not even staged — theirs to decide. But git can SEE
177
+ // them again, which is the whole point of the transition.
178
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
179
+ cwd: root,
180
+ encoding: 'utf8',
181
+ });
182
+ assert.match(untracked, /\.design\/system\/ds\/brand\.css/);
183
+ });
184
+
185
+ it('round-trips: adopt then detach restores what git can see', () => {
186
+ const root = repoWithDesign();
187
+ const before = trackedDesignPaths(root).sort();
188
+ adoptToHub(root);
189
+ execFileSync('git', ['commit', '-qm', 'adopt'], { cwd: root, stdio: 'ignore' });
190
+ detachToRepo(root);
191
+ execFileSync('git', ['add', '--', '.design'], { cwd: root, stdio: 'ignore' });
192
+ execFileSync('git', ['commit', '-qm', 'detach'], { cwd: root, stdio: 'ignore' });
193
+ assert.deepEqual(trackedDesignPaths(root).sort(), before);
194
+ });
195
+ });
196
+
197
+ describe('the hybrid is a NAMED state, not an accident', { skip: gitOk ? false : 'no git' }, () => {
198
+ it('linked + still tracked reads as hybrid', () => {
199
+ const root = repoWithDesign();
200
+ const st = ownershipState(root, { linked: true });
201
+ assert.equal(st.mode, 'hybrid');
202
+ assert.equal(st.ignored, false);
203
+ assert.equal(st.trackedCount, 2);
204
+ });
205
+
206
+ it('linked + ignored + untracked reads as hub-owned', () => {
207
+ const root = repoWithDesign();
208
+ adoptToHub(root);
209
+ assert.equal(ownershipState(root, { linked: true }).mode, 'hub-owned');
210
+ });
211
+
212
+ it('unlinked is repo-owned whatever the gitignore says', () => {
213
+ const root = repoWithDesign();
214
+ assert.equal(ownershipState(root, { linked: false }).mode, 'repo-owned');
215
+ });
216
+ });
217
+
218
+ describe('Syncthing rides a lane gitignore cannot close', () => {
219
+ it('finds the managed folder root from a nested repo', () => {
220
+ const st = mkdtempSync(join(tmpdir(), 'stfolder-'));
221
+ made.push(st);
222
+ mkdirSync(join(st, '.stfolder'), { recursive: true });
223
+ mkdirSync(join(st, 'personal/maude'), { recursive: true });
224
+ assert.equal(syncthingFolderRoot(join(st, 'personal/maude')), st);
225
+ });
226
+
227
+ it('is null outside one', () => {
228
+ const plain = mkdtempSync(join(tmpdir(), 'plain-'));
229
+ made.push(plain);
230
+ assert.equal(syncthingFolderRoot(plain), null);
231
+ });
232
+
233
+ it('names the .stignore line relative to the managed root', () => {
234
+ assert.equal(
235
+ stignoreLineFor('/tree/personal/maude', '.design', '/tree'),
236
+ '/personal/maude/.design'
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('no git repo — the question does not arise', () => {
242
+ it('reads as hub-owned when linked, and every transition is a no-op', () => {
243
+ const plain = mkdtempSync(join(tmpdir(), 'nogit-'));
244
+ made.push(plain);
245
+ mkdirSync(join(plain, '.design'), { recursive: true });
246
+ writeFileSync(join(plain, '.design/config.json'), '{}');
247
+
248
+ assert.equal(isGitRepo(plain), false);
249
+ assert.equal(ownershipState(plain, { linked: true }).mode, 'hub-owned');
250
+ assert.equal(adoptToHub(plain).action, 'no-git');
251
+ assert.equal(detachToRepo(plain).action, 'no-git');
252
+ // And nothing was written — Maude does not require git, so it does not
253
+ // start leaving gitignores in projects that have no repo.
254
+ assert.equal(existsSync(join(plain, '.gitignore')), false);
255
+ });
256
+ });
257
+
258
+ describe('a .gitignore is a shared, untrusted file', () => {
259
+ // The repo is committed and DDR-054 says peers can write it. The first
260
+ // version matched the markers as a bare substring and replaced everything
261
+ // between the first BEGIN and the first END — so a peer could wrap a
262
+ // victim's real rules in our markers and have `adopt` delete them.
263
+ const hostile = [
264
+ 'node_modules/',
265
+ '# maude:hub-owned:begin',
266
+ '.env',
267
+ '*.pem',
268
+ 'secrets/',
269
+ '# maude:hub-owned:end',
270
+ ].join('\n');
271
+
272
+ it('refuses to rewrite a block it did not write', () => {
273
+ const res = applyOwnershipBlock(hostile, '.design');
274
+ assert.equal(res.action, 'refused-malformed');
275
+ assert.equal(res.contents, hostile, 'not one rule touched');
276
+ assert.match(res.contents, /\.env/);
277
+ assert.match(res.contents, /secrets\//);
278
+ });
279
+
280
+ it('and refuses to remove one either', () => {
281
+ assert.equal(removeOwnershipBlock(hostile).action, 'refused-malformed');
282
+ });
283
+
284
+ it('a lone marker is malformed, not "already hub-owned"', () => {
285
+ // The cheaper half of the same finding: `ignored` derived from one marker
286
+ // made a hybrid report itself as settled, so the state DDR-228 exists to
287
+ // end would persist undetected.
288
+ assert.equal(isHubOwned('# maude:hub-owned:begin\nnode_modules/\n'), false);
289
+ assert.equal(findOwnershipBlock('# maude:hub-owned:begin\n').reason, 'malformed');
290
+ });
291
+
292
+ it('a marker inside a comment is not a marker', () => {
293
+ assert.equal(isHubOwned('# see # maude:hub-owned:begin for details\n'), false);
294
+ });
295
+
296
+ it('our own block still round-trips', () => {
297
+ const { contents } = applyOwnershipBlock('node_modules/\n', '.design');
298
+ assert.equal(isHubOwned(contents), true);
299
+ const back = removeOwnershipBlock(contents);
300
+ assert.equal(back.action, 'removed');
301
+ assert.match(back.contents, /node_modules\//);
302
+ });
303
+
304
+ it('adopt refuses outright rather than untracking against a file it cannot read', {
305
+ skip: gitOk ? false : 'git not available',
306
+ }, () => {
307
+ const root = repoWithDesign();
308
+ writeFileSync(join(root, '.gitignore'), hostile);
309
+ const res = adoptToHub(root);
310
+ assert.equal(res.action, 'refused-malformed');
311
+ assert.equal(res.untracked, 0);
312
+ assert.equal(trackedDesignPaths(root).length, 2, 'the design root is still tracked');
313
+ assert.equal(readFileSync(join(root, '.gitignore'), 'utf8'), hostile);
314
+ });
315
+
316
+ it('refuses to write through a symlinked .gitignore', {
317
+ skip: gitOk ? false : 'git not available',
318
+ }, () => {
319
+ const root = repoWithDesign();
320
+ const outside = mkdtempSync(join(tmpdir(), 'gi-target-'));
321
+ made.push(outside);
322
+ const victim = join(outside, 'important.txt');
323
+ writeFileSync(victim, 'do not overwrite me');
324
+ symlinkSync(victim, join(root, '.gitignore'));
325
+
326
+ assert.throws(() => adoptToHub(root), /not a regular file/);
327
+ assert.equal(readFileSync(victim, 'utf8'), 'do not overwrite me');
328
+ });
329
+ });
@@ -97,16 +97,37 @@ export function addHub(url, token, extra = {}) {
97
97
  const norm = normalizeUrl(url);
98
98
  const cfg = loadHubsConfig();
99
99
  const priorRole = cfg.hubs[norm]?.role;
100
+ // LOCAL CONSENT SURVIVES EVERY RE-SAVE. `role` is a cache of what the hub
101
+ // said about you at sign-in; `codeModulesAllowed` is what YOU said about the
102
+ // hub, and a login response must not be able to change it.
103
+ const priorConsent = cfg.hubs[norm]?.codeModulesAllowed;
100
104
  cfg.hubs[norm] = {
101
105
  token,
102
106
  linkedAt: Date.now(),
103
107
  ...(priorRole ? { role: priorRole } : {}),
108
+ ...(typeof priorConsent === 'boolean' ? { codeModulesAllowed: priorConsent } : {}),
104
109
  ...extra,
105
110
  };
106
111
  saveHubsConfig(cfg);
107
112
  return cfg.hubs[norm];
108
113
  }
109
114
 
115
+ /**
116
+ * Record whether this machine accepts executable modules from `url`.
117
+ *
118
+ * The one writer for the receiver's `code-module` gate. Deliberately separate
119
+ * from `addHub` so it is greppable: a second setter appearing anywhere is the
120
+ * bug this function exists to make visible.
121
+ */
122
+ export function setHubCodeModules(url, allowed) {
123
+ const norm = normalizeUrl(url);
124
+ const cfg = loadHubsConfig();
125
+ if (!cfg.hubs[norm]) return false;
126
+ cfg.hubs[norm].codeModulesAllowed = allowed === true;
127
+ saveHubsConfig(cfg);
128
+ return true;
129
+ }
130
+
110
131
  /** Remove a hub entry. Returns true if anything was removed. */
111
132
  export function removeHub(url) {
112
133
  const norm = normalizeUrl(url);
@@ -12,6 +12,7 @@ import {
12
12
  normalizeUrl,
13
13
  removeHub,
14
14
  saveHubsConfig,
15
+ setHubCodeModules,
15
16
  } from './hubs-config.mjs';
16
17
 
17
18
  function withTmpConfig(fn) {
@@ -22,7 +23,11 @@ function withTmpConfig(fn) {
22
23
  try {
23
24
  return fn(path);
24
25
  } finally {
25
- if (prior === undefined) process.env.HUBS_CONFIG_PATH = undefined;
26
+ // `env.X = undefined` sets the STRING "undefined", so the override leaked
27
+ // out of the harness and every later `addHub` in the same process wrote a
28
+ // file literally named `undefined` into the repo root. `delete` is the
29
+ // only way to actually unset it.
30
+ if (prior === undefined) delete process.env.HUBS_CONFIG_PATH;
26
31
  else process.env.HUBS_CONFIG_PATH = prior;
27
32
  rmSync(dir, { recursive: true, force: true });
28
33
  }
@@ -117,3 +122,44 @@ test('a relink preserves the vouched role, and still clears per-machine attestat
117
122
  assert.equal(after.adoptedAt, undefined, 'a per-machine attestation does not');
118
123
  });
119
124
  });
125
+
126
+ // The code-module consent — the finding was that `codeModulesAllowed` was
127
+ // declared, read by the receiver, and set by NOTHING. So the gate could never
128
+ // open and `code-module` had no transport at all in hub-owned mode: an owner
129
+ // could push one through the door and no peer would ever accept it, which is
130
+ // the same "delivered nowhere" shape the file plane was built to fix.
131
+ //
132
+ // Through `withTmpConfig` like every other test here — the config is a real
133
+ // file on a real path, so a test that skips the harness reads whatever the
134
+ // previous one left behind.
135
+
136
+ test('the consent records, and survives a token re-save', () => {
137
+ withTmpConfig(() => {
138
+ const url = 'https://consent.example.test';
139
+ addHub(url, 'tok-1');
140
+ assert.equal(getHub(url).codeModulesAllowed, undefined, 'absent means no');
141
+
142
+ setHubCodeModules(url, true);
143
+ assert.equal(getHub(url).codeModulesAllowed, true);
144
+
145
+ // A silent token renewal must not revoke a decision the person made.
146
+ addHub(url, 'tok-2');
147
+ assert.equal(getHub(url).codeModulesAllowed, true);
148
+ assert.equal(getHub(url).token, 'tok-2');
149
+ });
150
+ });
151
+
152
+ test('the consent is false for anything that is not an explicit yes', () => {
153
+ withTmpConfig(() => {
154
+ const url = 'https://consent-strict.example.test';
155
+ addHub(url, 't');
156
+ setHubCodeModules(url, 'yes');
157
+ assert.equal(getHub(url).codeModulesAllowed, false);
158
+ });
159
+ });
160
+
161
+ test('setting consent on an unlinked hub does nothing', () => {
162
+ withTmpConfig(() => {
163
+ assert.equal(setHubCodeModules('https://nobody.example.test', true), false);
164
+ });
165
+ });