@1agh/maude 0.60.2 → 0.60.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/client/app.jsx +1 -0
- package/apps/studio/client/panels/SyncPanel.jsx +26 -11
- package/apps/studio/dist/client.bundle.js +393 -393
- package/apps/studio/http.ts +36 -6
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/asset-push.ts +30 -0
- package/apps/studio/sync/connection-state.ts +23 -0
- package/apps/studio/sync/discovery.ts +139 -0
- package/apps/studio/sync/index.ts +804 -163
- package/apps/studio/test/sync-asset-push.test.ts +93 -2
- package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
- package/apps/studio/test/sync-panel-surface.test.ts +10 -0
- package/apps/studio/test/sync-resync-routes.test.ts +43 -0
- package/apps/studio/test/sync-supervisor.test.ts +4 -0
- package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/commands/doctor.mjs +141 -6
- package/cli/lib/gitignore-drift.mjs +149 -0
- package/cli/lib/gitignore-drift.test.mjs +156 -0
- package/package.json +8 -8
|
@@ -71,6 +71,49 @@ describe('a foreign Host header is refused (the DNS-rebinding guard)', () => {
|
|
|
71
71
|
}
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
// A refusal the panel cannot explain is a refusal the person cannot act on.
|
|
75
|
+
// Every 403 here used to be plain text, and `SyncPanel.jsx` renders a fixed
|
|
76
|
+
// "Resync could not start." for any response carrying no `detail` — so the two
|
|
77
|
+
// gates above, a dead supervisor and a genuine bug all produced one causeless
|
|
78
|
+
// sentence. These assert the answer is machine-readable AND human-readable; the
|
|
79
|
+
// gates themselves are asserted, unchanged, above.
|
|
80
|
+
describe('every refusal explains itself', () => {
|
|
81
|
+
for (const route of ROUTES) {
|
|
82
|
+
test(`a cross-origin POST ${route} answers JSON with a reason and a detail`, async () => {
|
|
83
|
+
const res = await fetch(`${base}${route}`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { Origin: 'https://evil.example' },
|
|
86
|
+
});
|
|
87
|
+
expect(res.headers.get('content-type')).toContain('application/json');
|
|
88
|
+
const json = (await res.json()) as { ok: boolean; reason?: string; detail?: string };
|
|
89
|
+
expect(json.ok).toBe(false);
|
|
90
|
+
expect(json.reason).toBe('cross-origin');
|
|
91
|
+
expect(json.detail && json.detail.length > 0).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test(`a rebinding-Host POST ${route} answers JSON with a reason and a detail`, async () => {
|
|
95
|
+
const res = await fetch(`${base}${route}`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { Host: 'sync.127.0.0.1.nip.io' },
|
|
98
|
+
});
|
|
99
|
+
const json = (await res.json()) as { ok: boolean; reason?: string; detail?: string };
|
|
100
|
+
expect(json.ok).toBe(false);
|
|
101
|
+
expect(json.reason).toBe('untrusted-host');
|
|
102
|
+
expect(json.detail && json.detail.length > 0).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('a refusal names no request internals back to the caller', async () => {
|
|
107
|
+
// The presented Origin/Host is diagnostic and belongs in the server log —
|
|
108
|
+
// reflecting it would hand a canvas iframe a way to read back what it sent.
|
|
109
|
+
const res = await fetch(`${base}/_api/sync/resync`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { Origin: 'https://evil.example' },
|
|
112
|
+
});
|
|
113
|
+
expect(await res.text()).not.toContain('evil.example');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
74
117
|
describe('method gating', () => {
|
|
75
118
|
for (const route of ROUTES) {
|
|
76
119
|
test(`GET ${route} is 405 — resync is a write`, async () => {
|
|
@@ -74,6 +74,10 @@ function fakeRuntime(log: string[], id: string, size = 3): SyncRuntime {
|
|
|
74
74
|
stop: async () => {
|
|
75
75
|
log.push(`stop:${id}`);
|
|
76
76
|
},
|
|
77
|
+
adopt: async () => 0,
|
|
78
|
+
release: async () => 0,
|
|
79
|
+
rescanNow: async () => {},
|
|
80
|
+
pullRemoteNow: async () => {},
|
|
77
81
|
size: () => size,
|
|
78
82
|
agentFor: () => undefined,
|
|
79
83
|
status: () => null,
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Two peers, one hub — the reported bug, end to end, in both directions.
|
|
2
|
+
//
|
|
3
|
+
// The unit tests in `sync-attach-incremental.test.ts` pin each half against a
|
|
4
|
+
// stub. This one puts TWO REAL RUNTIMES on one shared hub and asks the question
|
|
5
|
+
// the user actually asked: if somebody makes a canvas over there, does it show
|
|
6
|
+
// up over here, live, without anyone restarting anything?
|
|
7
|
+
//
|
|
8
|
+
// Both directions are the same code — the cell runs the same runtime the
|
|
9
|
+
// desktop does — so a test that only checked one would prove half of nothing.
|
|
10
|
+
// The reported asymmetry (desktop→cloud "worked", cloud→desktop never did) was
|
|
11
|
+
// never about direction: it was about which end happens to restart.
|
|
12
|
+
//
|
|
13
|
+
// WHAT IS ASSERTED. Not `size()`, which a runtime can inflate by opening a
|
|
14
|
+
// provider it never connects. The peer's CONTENT has to arrive, its later edits
|
|
15
|
+
// have to keep flowing, and the awareness bridge — the thing behind cursors —
|
|
16
|
+
// has to be attached for the canvas that was discovered, not merely for the
|
|
17
|
+
// ones that existed at boot.
|
|
18
|
+
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
24
|
+
import * as Y from 'yjs';
|
|
25
|
+
import type { Context, DevServerConfig } from '../context.ts';
|
|
26
|
+
import { createBus } from '../context.ts';
|
|
27
|
+
import type { AwarenessRegistry } from '../sync/index.ts';
|
|
28
|
+
import { createSyncRuntime, type SyncProvider, type SyncRuntime } from '../sync/index.ts';
|
|
29
|
+
|
|
30
|
+
const HUB = 'https://hub.example.com';
|
|
31
|
+
|
|
32
|
+
let root: string;
|
|
33
|
+
let cfgPathEnv: string | undefined;
|
|
34
|
+
let realFetch: typeof fetch;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
root = mkdtempSync(join(tmpdir(), 'two-peer-'));
|
|
38
|
+
cfgPathEnv = process.env.HUBS_CONFIG_PATH;
|
|
39
|
+
process.env.HUBS_CONFIG_PATH = join(root, 'hubs.json');
|
|
40
|
+
writeFileSync(
|
|
41
|
+
process.env.HUBS_CONFIG_PATH,
|
|
42
|
+
JSON.stringify({ hubs: { [HUB]: { token: 'mau_test', linkedAt: 1 } } })
|
|
43
|
+
);
|
|
44
|
+
chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
|
|
45
|
+
realFetch = globalThis.fetch;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
globalThis.fetch = realFetch;
|
|
50
|
+
if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
|
|
51
|
+
else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
|
|
52
|
+
rmSync(root, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One hub both peers connect to.
|
|
57
|
+
*
|
|
58
|
+
* Holds a Y.Doc per documentName and relays between every connection attached
|
|
59
|
+
* to it. Each connection carries its OWN origin symbol, so an update that
|
|
60
|
+
* arrives from peer A reaches peer B and is not echoed back to A — the property
|
|
61
|
+
* a single shared symbol would silently break, leaving a test that passes
|
|
62
|
+
* because nothing ever moved.
|
|
63
|
+
*
|
|
64
|
+
* It also answers `GET /api/documents` from the same map, which is the whole
|
|
65
|
+
* point: the listing is what a peer can learn about a canvas it has never seen,
|
|
66
|
+
* and it can only ever name documents somebody actually opened.
|
|
67
|
+
*/
|
|
68
|
+
function makeHub() {
|
|
69
|
+
const docs = new Map<string, Y.Doc>();
|
|
70
|
+
|
|
71
|
+
function docFor(name: string): Y.Doc {
|
|
72
|
+
let d = docs.get(name);
|
|
73
|
+
if (!d) {
|
|
74
|
+
d = new Y.Doc();
|
|
75
|
+
docs.set(name, d);
|
|
76
|
+
}
|
|
77
|
+
return d;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function factory() {
|
|
81
|
+
return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
|
|
82
|
+
const hubDoc = docFor(args.documentName);
|
|
83
|
+
const local = args.document ?? new Y.Doc();
|
|
84
|
+
const conn = Symbol('conn');
|
|
85
|
+
// Initial state transfer, both ways — a real handshake converges the two
|
|
86
|
+
// sides before `onceSynced` resolves, and the cold-start reconcile the
|
|
87
|
+
// runtime runs afterwards depends on that having happened.
|
|
88
|
+
Y.applyUpdate(local, Y.encodeStateAsUpdate(hubDoc), conn);
|
|
89
|
+
Y.applyUpdate(hubDoc, Y.encodeStateAsUpdate(local), conn);
|
|
90
|
+
const onLocal = (u: Uint8Array, origin: unknown) => {
|
|
91
|
+
if (origin === conn) return;
|
|
92
|
+
Y.applyUpdate(hubDoc, u, conn);
|
|
93
|
+
};
|
|
94
|
+
const onHub = (u: Uint8Array, origin: unknown) => {
|
|
95
|
+
if (origin === conn) return;
|
|
96
|
+
Y.applyUpdate(local, u, conn);
|
|
97
|
+
};
|
|
98
|
+
local.on('update', onLocal);
|
|
99
|
+
hubDoc.on('update', onHub);
|
|
100
|
+
return {
|
|
101
|
+
document: local,
|
|
102
|
+
awareness: new Awareness(local),
|
|
103
|
+
async onceSynced() {},
|
|
104
|
+
destroy() {
|
|
105
|
+
local.off('update', onLocal);
|
|
106
|
+
hubDoc.off('update', onHub);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `GET /api/documents` — names only, exactly like the real route. */
|
|
113
|
+
function serveListing(): void {
|
|
114
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
115
|
+
if (String(input).endsWith('/api/documents')) {
|
|
116
|
+
return new Response(
|
|
117
|
+
JSON.stringify({ documents: [...docs.keys()].map((name) => ({ name, bytes: 1 })) }),
|
|
118
|
+
{ status: 200, headers: { 'content-type': 'application/json' } }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return new Response('nope', { status: 404 });
|
|
122
|
+
}) as typeof fetch;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
factory,
|
|
127
|
+
serveListing,
|
|
128
|
+
docs,
|
|
129
|
+
bodyOf: (n: string) => docFor(n).getText('html').toString(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A peer: its own disk, its own context, its own runtime. */
|
|
134
|
+
function makePeer(name: string) {
|
|
135
|
+
const repoRoot = join(root, name);
|
|
136
|
+
const designRoot = join(repoRoot, 'design');
|
|
137
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
138
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
139
|
+
const linkedHub: DevServerConfig['linkedHub'] = { url: HUB, linkedAt: 1 };
|
|
140
|
+
const ctx = {
|
|
141
|
+
// The sandbox split is active — the gate `.tsx` sync is coupled to
|
|
142
|
+
// (9.1-B). TSX is the real canvas format, and it is also where the pulled
|
|
143
|
+
// body lands: `fallbackCanvasPath` always resolves to `.tsx`, so a test
|
|
144
|
+
// written against `.html` would assert a file that correctly never exists.
|
|
145
|
+
canvasOrigin: 'http://canvas.localhost:9',
|
|
146
|
+
cfg: {
|
|
147
|
+
name,
|
|
148
|
+
projectLabel: null,
|
|
149
|
+
designRoot: 'design',
|
|
150
|
+
canvasGroups: [{ label: 'Canvases', path: 'ui' }],
|
|
151
|
+
rootClass: 'app',
|
|
152
|
+
themeDefault: 'dark',
|
|
153
|
+
tokensCssRel: 'system/colors.css',
|
|
154
|
+
teamAccentDefault: null,
|
|
155
|
+
handoffTargets: [],
|
|
156
|
+
newCanvasDir: 'ui',
|
|
157
|
+
newComponentDir: 'ui/components',
|
|
158
|
+
linkedHub,
|
|
159
|
+
_source: 'defaults',
|
|
160
|
+
},
|
|
161
|
+
projectLabel: name,
|
|
162
|
+
paths: {
|
|
163
|
+
repoRoot,
|
|
164
|
+
designRel: 'design',
|
|
165
|
+
designRoot,
|
|
166
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
167
|
+
activeFile: join(designRoot, '_active.json'),
|
|
168
|
+
commentsDir: join(designRoot, '_comments'),
|
|
169
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
170
|
+
historyDir: join(designRoot, '_history'),
|
|
171
|
+
tokensUrlRel: 'design/system/colors.css',
|
|
172
|
+
systemDirRel: 'system',
|
|
173
|
+
},
|
|
174
|
+
bus: createBus(),
|
|
175
|
+
} as Context;
|
|
176
|
+
|
|
177
|
+
/** Which slugs got a hub-awareness bridge — the cursor lane, per canvas. */
|
|
178
|
+
const bridged: string[] = [];
|
|
179
|
+
const registry: AwarenessRegistry = {
|
|
180
|
+
attachHubAwareness(slug) {
|
|
181
|
+
bridged.push(slug);
|
|
182
|
+
return () => {};
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
ctx,
|
|
188
|
+
bridged,
|
|
189
|
+
registry,
|
|
190
|
+
/** A canvas the way a real one exists: a `.tsx` body plus the sidecar that
|
|
191
|
+
* opts it into sync (Lock 1 — a hub must never be able to flip it). */
|
|
192
|
+
write(canvas: string, body: string) {
|
|
193
|
+
writeFileSync(join(designRoot, 'ui', `${canvas}.tsx`), body);
|
|
194
|
+
writeFileSync(
|
|
195
|
+
join(designRoot, 'ui', `${canvas}.meta.json`),
|
|
196
|
+
JSON.stringify({ syncable: true })
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
read(canvas: string) {
|
|
200
|
+
return readFileSync(join(designRoot, 'ui', `${canvas}.tsx`), 'utf8');
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Settle both the fs reader's quiet window and the doc round-trips. */
|
|
206
|
+
const settle = (ms = 450) => new Promise((res) => setTimeout(res, ms));
|
|
207
|
+
|
|
208
|
+
describe('two peers on one hub', () => {
|
|
209
|
+
let runtimes: (SyncRuntime | null)[] = [];
|
|
210
|
+
afterEach(async () => {
|
|
211
|
+
for (const r of runtimes) await r?.stop();
|
|
212
|
+
runtimes = [];
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a canvas created on peer A reaches peer B — no restart on either side', async () => {
|
|
216
|
+
const hub = makeHub();
|
|
217
|
+
const a = makePeer('a');
|
|
218
|
+
const b = makePeer('b');
|
|
219
|
+
// Both start with the same one canvas, so neither is in the special
|
|
220
|
+
// empty-project path and the only variable is the NEW one.
|
|
221
|
+
a.write('home', '<main>home</main>');
|
|
222
|
+
b.write('home', '<main>home</main>');
|
|
223
|
+
hub.serveListing();
|
|
224
|
+
|
|
225
|
+
const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
|
|
226
|
+
const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
|
|
227
|
+
runtimes = [ra, rb];
|
|
228
|
+
await ra?.start();
|
|
229
|
+
await rb?.start();
|
|
230
|
+
await settle(50);
|
|
231
|
+
|
|
232
|
+
expect(ra?.size()).toBe(1);
|
|
233
|
+
expect(rb?.size()).toBe(1);
|
|
234
|
+
|
|
235
|
+
// ── A makes a canvas. Nobody restarts anything. ──────────────────────
|
|
236
|
+
a.write('newidea', '<section>made on A</section>');
|
|
237
|
+
a.ctx.bus.emit('canvas-list-update', { action: 'added', rel: 'ui/newidea.tsx' });
|
|
238
|
+
await ra?.rescanNow();
|
|
239
|
+
await settle();
|
|
240
|
+
|
|
241
|
+
// It is on the hub — which on a cell is the step that used to be missing
|
|
242
|
+
// entirely, and is why the other side could not even learn the name.
|
|
243
|
+
expect(hub.bodyOf('ui-newidea')).toBe('<section>made on A</section>');
|
|
244
|
+
|
|
245
|
+
// ── B has never heard of it. It polls, and pulls it down. ────────────
|
|
246
|
+
await rb?.pullRemoteNow();
|
|
247
|
+
await settle();
|
|
248
|
+
|
|
249
|
+
expect(rb?.size()).toBe(2);
|
|
250
|
+
expect(b.read('newidea')).toBe('<section>made on A</section>');
|
|
251
|
+
// The cursor lane is attached for the canvas that ARRIVED, not just the
|
|
252
|
+
// ones that existed at boot — "no cursor in the new canvas" was half the
|
|
253
|
+
// report.
|
|
254
|
+
expect(b.bridged).toContain('ui-newidea');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('the discovered canvas keeps syncing — it is live, not a one-shot copy', async () => {
|
|
258
|
+
const hub = makeHub();
|
|
259
|
+
const a = makePeer('a');
|
|
260
|
+
const b = makePeer('b');
|
|
261
|
+
a.write('home', '<main>home</main>');
|
|
262
|
+
b.write('home', '<main>home</main>');
|
|
263
|
+
hub.serveListing();
|
|
264
|
+
|
|
265
|
+
const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
|
|
266
|
+
const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
|
|
267
|
+
runtimes = [ra, rb];
|
|
268
|
+
await ra?.start();
|
|
269
|
+
await rb?.start();
|
|
270
|
+
|
|
271
|
+
a.write('shared', '<p>v1</p>');
|
|
272
|
+
await ra?.rescanNow();
|
|
273
|
+
await settle();
|
|
274
|
+
await rb?.pullRemoteNow();
|
|
275
|
+
await settle();
|
|
276
|
+
expect(b.read('shared')).toBe('<p>v1</p>');
|
|
277
|
+
|
|
278
|
+
// A edits it again, well after the discovery. This is the difference
|
|
279
|
+
// between "it appeared" and "it syncs".
|
|
280
|
+
a.write('shared', '<p>v2 — edited after discovery</p>');
|
|
281
|
+
a.ctx.bus.emit('fs:any', 'ui/shared.tsx');
|
|
282
|
+
// Long enough for the whole chain: A's fs quiet window, the doc round-trip,
|
|
283
|
+
// and B's agent debouncing its doc→file write. Three debounces, not one.
|
|
284
|
+
await settle(1500);
|
|
285
|
+
|
|
286
|
+
expect(hub.bodyOf('ui-shared')).toBe('<p>v2 — edited after discovery</p>');
|
|
287
|
+
expect(b.read('shared')).toBe('<p>v2 — edited after discovery</p>');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('the other direction is the same code — B creates, A discovers', async () => {
|
|
291
|
+
const hub = makeHub();
|
|
292
|
+
const a = makePeer('a');
|
|
293
|
+
const b = makePeer('b');
|
|
294
|
+
a.write('home', '<main>home</main>');
|
|
295
|
+
b.write('home', '<main>home</main>');
|
|
296
|
+
hub.serveListing();
|
|
297
|
+
|
|
298
|
+
const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
|
|
299
|
+
const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
|
|
300
|
+
runtimes = [ra, rb];
|
|
301
|
+
await ra?.start();
|
|
302
|
+
await rb?.start();
|
|
303
|
+
|
|
304
|
+
b.write('fromb', '<article>made on B</article>');
|
|
305
|
+
await rb?.rescanNow();
|
|
306
|
+
await settle();
|
|
307
|
+
await ra?.pullRemoteNow();
|
|
308
|
+
await settle();
|
|
309
|
+
|
|
310
|
+
expect(a.read('fromb')).toBe('<article>made on B</article>');
|
|
311
|
+
expect(ra?.size()).toBe(2);
|
|
312
|
+
expect(a.bridged).toContain('ui-fromb');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a canvas each, made at the same time, and both sides end up with both', async () => {
|
|
316
|
+
// The case a one-directional fix passes and a real one has to survive:
|
|
317
|
+
// neither peer is "the server", and each has something the other lacks.
|
|
318
|
+
const hub = makeHub();
|
|
319
|
+
const a = makePeer('a');
|
|
320
|
+
const b = makePeer('b');
|
|
321
|
+
a.write('home', '<main>home</main>');
|
|
322
|
+
b.write('home', '<main>home</main>');
|
|
323
|
+
hub.serveListing();
|
|
324
|
+
|
|
325
|
+
const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
|
|
326
|
+
const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
|
|
327
|
+
runtimes = [ra, rb];
|
|
328
|
+
await ra?.start();
|
|
329
|
+
await rb?.start();
|
|
330
|
+
|
|
331
|
+
a.write('alpha', '<p>alpha</p>');
|
|
332
|
+
b.write('beta', '<p>beta</p>');
|
|
333
|
+
await Promise.all([ra?.rescanNow(), rb?.rescanNow()]);
|
|
334
|
+
await settle();
|
|
335
|
+
await Promise.all([ra?.pullRemoteNow(), rb?.pullRemoteNow()]);
|
|
336
|
+
await settle();
|
|
337
|
+
|
|
338
|
+
expect(a.read('beta')).toBe('<p>beta</p>');
|
|
339
|
+
expect(b.read('alpha')).toBe('<p>alpha</p>');
|
|
340
|
+
expect(ra?.size()).toBe(3);
|
|
341
|
+
expect(rb?.size()).toBe(3);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "continuous-sync-discovery",
|
|
6
|
+
"version": "0.60.3",
|
|
7
|
+
"date": "2026-08-13",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "A new canvas shows up everywhere, on its own",
|
|
10
|
+
"summary": "Make a canvas in the cloud and it arrives on your desktop; make one on your desktop and it is live in the cloud, with cursors, straight away. Until now each side only looked for new canvases when it started up, so whichever end you had not restarted lately simply never saw them.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "cloud-keeps-its-images",
|
|
15
|
+
"version": "0.60.3",
|
|
16
|
+
"date": "2026-08-13",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Your cloud project keeps its images",
|
|
19
|
+
"summary": "After the cloud moved your project between machines, photographs could come back as grey boxes and the only fix was re-uploading everything from your laptop. The cloud now restores those files itself, from the copy it already had.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "sync-resync-button",
|
|
6
24
|
"version": "0.60.0",
|
package/cli/commands/doctor.mjs
CHANGED
|
@@ -25,6 +25,11 @@ import { stdin, stdout } from 'node:process';
|
|
|
25
25
|
import { createInterface } from 'node:readline/promises';
|
|
26
26
|
import { parseArgs } from '../lib/argv.mjs';
|
|
27
27
|
import { lintConfig } from '../lib/config-lint.mjs';
|
|
28
|
+
import {
|
|
29
|
+
describeGitignoreDrift,
|
|
30
|
+
findGitignoreDrift,
|
|
31
|
+
removeGitignoreDrift,
|
|
32
|
+
} from '../lib/gitignore-drift.mjs';
|
|
28
33
|
import { getHub } from '../lib/hubs-config.mjs';
|
|
29
34
|
import { checkAll } from '../lib/preflight.mjs';
|
|
30
35
|
import { detectQualityGates, detectStack } from '../lib/stack-detect.mjs';
|
|
@@ -172,6 +177,25 @@ export async function run({ args, pkgRoot }) {
|
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
// ── Gitignore drift: is git dropping VERSIONED design content? ───────────
|
|
181
|
+
// The one failure mode nothing else can see. A rule OUTSIDE the
|
|
182
|
+
// `# maude:begin`/`# maude:end` markers is invisible to the block writer, so
|
|
183
|
+
// a stale `.design/*.annotations.svg` — hand-copied years ago from a planning
|
|
184
|
+
// doc that predates DDR-115 — silently keeps every draw layer out of git
|
|
185
|
+
// while both UIs render it happily. See `lib/gitignore-drift.mjs`.
|
|
186
|
+
const gitignoreFsPath = resolve(repoRoot, '.gitignore');
|
|
187
|
+
const designRel = (() => {
|
|
188
|
+
try {
|
|
189
|
+
const dcfg = JSON.parse(readFileSync(designFsPath, 'utf8'));
|
|
190
|
+
return typeof dcfg.designRoot === 'string' && dcfg.designRoot ? dcfg.designRoot : '.design';
|
|
191
|
+
} catch {
|
|
192
|
+
return '.design';
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
const gitignoreDrift = existsSync(gitignoreFsPath)
|
|
196
|
+
? findGitignoreDrift(readFileSync(gitignoreFsPath, 'utf8'), designRel)
|
|
197
|
+
: [];
|
|
198
|
+
|
|
175
199
|
// ── Summary ─────────────────────────────────────────────────────────────
|
|
176
200
|
const hardDepsMissing = Object.values(depsByPlugin)
|
|
177
201
|
.filter((r) => r.summary)
|
|
@@ -184,21 +208,44 @@ export async function run({ args, pkgRoot }) {
|
|
|
184
208
|
schemaErrors,
|
|
185
209
|
driftCount,
|
|
186
210
|
qualityAdditions: additionsCount,
|
|
187
|
-
|
|
211
|
+
gitignoreDrift: gitignoreDrift.length,
|
|
212
|
+
// A HARD failure, not a warning. Every other doctor finding is about
|
|
213
|
+
// configuration that is merely suboptimal; this one means work the person
|
|
214
|
+
// can see on screen is not in their repository and will not survive a fresh
|
|
215
|
+
// clone. Silence about that is how it went unnoticed for a whole project.
|
|
216
|
+
healthy: hardDepsMissing === 0 && schemaErrors === 0 && gitignoreDrift.length === 0,
|
|
188
217
|
};
|
|
189
218
|
|
|
190
219
|
if (jsonMode) {
|
|
191
220
|
process.stdout.write(
|
|
192
|
-
`${JSON.stringify(
|
|
221
|
+
`${JSON.stringify(
|
|
222
|
+
{
|
|
223
|
+
deps: depsByPlugin,
|
|
224
|
+
config: configReport,
|
|
225
|
+
design: designReport,
|
|
226
|
+
gitignore: { path: gitignoreFsPath, designRel, drift: gitignoreDrift },
|
|
227
|
+
summary,
|
|
228
|
+
},
|
|
229
|
+
null,
|
|
230
|
+
2
|
|
231
|
+
)}\n`
|
|
193
232
|
);
|
|
194
233
|
process.exit(summary.healthy ? 0 : 1);
|
|
195
234
|
}
|
|
196
235
|
|
|
197
|
-
printReport({ depsByPlugin, configReport, designReport, summary });
|
|
236
|
+
printReport({ depsByPlugin, configReport, designReport, gitignoreDrift, summary });
|
|
198
237
|
|
|
199
238
|
// ── Fix path ────────────────────────────────────────────────────────────
|
|
200
239
|
if (fix) {
|
|
201
|
-
await applyFixes({
|
|
240
|
+
await applyFixes({
|
|
241
|
+
repoRoot,
|
|
242
|
+
configFsPath,
|
|
243
|
+
depsByPlugin,
|
|
244
|
+
configReport,
|
|
245
|
+
gitignoreFsPath,
|
|
246
|
+
gitignoreDrift,
|
|
247
|
+
designRel,
|
|
248
|
+
});
|
|
202
249
|
} else if (!summary.healthy) {
|
|
203
250
|
process.stdout.write(
|
|
204
251
|
'\nRun with --fix to: install missing deps (prompt per item), drop unknown keys, apply detected drift, add missing quality gates. Existing user values are NEVER overwritten.\n'
|
|
@@ -249,7 +296,7 @@ function usage() {
|
|
|
249
296
|
`;
|
|
250
297
|
}
|
|
251
298
|
|
|
252
|
-
function printReport({ depsByPlugin, configReport, designReport, summary }) {
|
|
299
|
+
function printReport({ depsByPlugin, configReport, designReport, gitignoreDrift, summary }) {
|
|
253
300
|
process.stdout.write('maude doctor\n\n');
|
|
254
301
|
for (const [name, env] of Object.entries(depsByPlugin)) {
|
|
255
302
|
process.stdout.write(` Dependencies (plugins/${name}):\n`);
|
|
@@ -340,6 +387,20 @@ function printReport({ depsByPlugin, configReport, designReport, summary }) {
|
|
|
340
387
|
process.stdout.write('\n');
|
|
341
388
|
}
|
|
342
389
|
|
|
390
|
+
// Gitignore drift — git is dropping content DDR-115 calls versioned.
|
|
391
|
+
if (gitignoreDrift?.length) {
|
|
392
|
+
process.stdout.write(' Gitignore (.gitignore):\n');
|
|
393
|
+
for (const line of describeGitignoreDrift(gitignoreDrift)) {
|
|
394
|
+
process.stdout.write(` ✗ ${line}\n`);
|
|
395
|
+
}
|
|
396
|
+
process.stdout.write(
|
|
397
|
+
' These rules sit OUTSIDE the `# maude:begin`/`# maude:end` block, so\n' +
|
|
398
|
+
' re-running the writer cannot correct them. Work you can see in the UI\n' +
|
|
399
|
+
' is not in your repository and will not survive a fresh clone.\n' +
|
|
400
|
+
' `maude doctor --fix` removes exactly these lines, and nothing else.\n\n'
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
343
404
|
const parts = [];
|
|
344
405
|
if (summary.hardDepsMissing) parts.push(`${summary.hardDepsMissing} hard dep missing`);
|
|
345
406
|
if (summary.schemaErrors)
|
|
@@ -350,11 +411,23 @@ function printReport({ depsByPlugin, configReport, designReport, summary }) {
|
|
|
350
411
|
parts.push(
|
|
351
412
|
`${summary.qualityAdditions} quality addition${summary.qualityAdditions === 1 ? '' : 's'}`
|
|
352
413
|
);
|
|
414
|
+
if (summary.gitignoreDrift)
|
|
415
|
+
parts.push(
|
|
416
|
+
`${summary.gitignoreDrift} gitignore rule${summary.gitignoreDrift === 1 ? '' : 's'} dropping versioned content`
|
|
417
|
+
);
|
|
353
418
|
if (parts.length === 0) parts.push('all clear');
|
|
354
419
|
process.stdout.write(` Summary: ${parts.join(', ')}.\n`);
|
|
355
420
|
}
|
|
356
421
|
|
|
357
|
-
async function applyFixes({
|
|
422
|
+
async function applyFixes({
|
|
423
|
+
repoRoot,
|
|
424
|
+
configFsPath,
|
|
425
|
+
depsByPlugin,
|
|
426
|
+
configReport,
|
|
427
|
+
gitignoreFsPath,
|
|
428
|
+
gitignoreDrift,
|
|
429
|
+
designRel,
|
|
430
|
+
}) {
|
|
358
431
|
let configChanged = false;
|
|
359
432
|
|
|
360
433
|
// ── Config edits ───────────────────────────────────────────────────────
|
|
@@ -461,6 +534,68 @@ async function applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport }
|
|
|
461
534
|
}
|
|
462
535
|
}
|
|
463
536
|
|
|
537
|
+
// ── Gitignore drift ─────────────────────────────────────────────────────
|
|
538
|
+
//
|
|
539
|
+
// The ONE fix in this command that edits a file the user hand-wrote, so it is
|
|
540
|
+
// the one that asks. Everything else `--fix` does is additive or confined to
|
|
541
|
+
// generated regions; this deletes lines from somebody's `.gitignore`. The
|
|
542
|
+
// prompt names each line, and a non-TTY (CI, a scripted spawn) is a decline —
|
|
543
|
+
// never an assumed yes.
|
|
544
|
+
if (gitignoreDrift?.length && gitignoreFsPath && existsSync(gitignoreFsPath)) {
|
|
545
|
+
process.stdout.write('\n--- Gitignore rules dropping VERSIONED design content ---\n');
|
|
546
|
+
for (const line of describeGitignoreDrift(gitignoreDrift)) {
|
|
547
|
+
process.stdout.write(` ${line}\n`);
|
|
548
|
+
}
|
|
549
|
+
if (!stdin.isTTY) {
|
|
550
|
+
process.stdout.write(
|
|
551
|
+
' skipped — non-interactive stdin. Re-run in a terminal, or delete the lines above by hand.\n'
|
|
552
|
+
);
|
|
553
|
+
} else {
|
|
554
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
555
|
+
const ans = await rl.question(
|
|
556
|
+
` Remove ${gitignoreDrift.length === 1 ? 'this line' : `these ${gitignoreDrift.length} lines`} from .gitignore? [y/N] `
|
|
557
|
+
);
|
|
558
|
+
rl.close();
|
|
559
|
+
if (/^y(es)?$/i.test((ans || '').trim())) {
|
|
560
|
+
// DELETE ONLY WHAT WAS SHOWN, FROM THE TEXT IT WAS SHOWN FROM.
|
|
561
|
+
//
|
|
562
|
+
// `removeGitignoreDrift` deletes by LINE NUMBER, and its contract says
|
|
563
|
+
// those numbers must come from a scan of the same text — otherwise the
|
|
564
|
+
// numbers mean something else. The scan happens near the top of this
|
|
565
|
+
// command; the write happens here, after the report, after every
|
|
566
|
+
// per-dependency install (which shells out and can run for minutes) and
|
|
567
|
+
// after this prompt. In a Syncthing tree with concurrent sessions —
|
|
568
|
+
// which is exactly where this repo lives — that gap is long enough for
|
|
569
|
+
// the file to change, and the failure mode is silently deleting a line
|
|
570
|
+
// the person never saw and never approved. A `.gitignore` line can be
|
|
571
|
+
// the one keeping `.env` out of a commit.
|
|
572
|
+
//
|
|
573
|
+
// So: re-read, re-derive, and proceed only if the approved lines are
|
|
574
|
+
// still exactly where they were. Anything else is a re-run, not a guess.
|
|
575
|
+
const now = readFileSync(gitignoreFsPath, 'utf8');
|
|
576
|
+
const stillThere = findGitignoreDrift(now, designRel);
|
|
577
|
+
const approved = new Map(gitignoreDrift.map((d) => [d.line, d.text]));
|
|
578
|
+
const unchanged =
|
|
579
|
+
stillThere.length === approved.size &&
|
|
580
|
+
stillThere.every((d) => approved.get(d.line) === d.text);
|
|
581
|
+
if (!unchanged) {
|
|
582
|
+
process.stdout.write(
|
|
583
|
+
' ✗ .gitignore changed while this was waiting for an answer — nothing removed.\n' +
|
|
584
|
+
' Re-run `maude doctor --fix` to see the current state.\n'
|
|
585
|
+
);
|
|
586
|
+
} else {
|
|
587
|
+
writeFileSync(gitignoreFsPath, removeGitignoreDrift(now, stillThere), 'utf8');
|
|
588
|
+
process.stdout.write(
|
|
589
|
+
' ✓ removed. The files are still untracked until you `git add` them —\n' +
|
|
590
|
+
' `git status` will now show them for the first time.\n'
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
process.stdout.write(' skipped.\n');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
464
599
|
// Use the actually-changed state to decide on re-run hint.
|
|
465
600
|
void repoRoot;
|
|
466
601
|
}
|