@1agh/maude 0.60.7 → 1.0.3
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 +201 -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 -71
- 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 +915 -308
- 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 +71 -108
- 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 +90 -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
|
@@ -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
|
+
});
|
package/cli/lib/hubs-config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|