@1agh/maude 0.22.2 → 0.24.0
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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
// Sync runtime entry point — Phase 9 Task 4 wiring.
|
|
2
|
+
//
|
|
3
|
+
// Public surface for the dev-server boot:
|
|
4
|
+
//
|
|
5
|
+
// const runtime = createSyncRuntime(ctx);
|
|
6
|
+
// if (runtime) await runtime.start();
|
|
7
|
+
// // ... later ...
|
|
8
|
+
// await runtime.stop();
|
|
9
|
+
//
|
|
10
|
+
// Returns null when the project is unlinked (`.design/config.json` has no
|
|
11
|
+
// `linkedHub` field) — preserves solo mode behavior bit-for-bit. When linked:
|
|
12
|
+
// resolves the per-machine token, opens one HocuspocusProvider + sync agent
|
|
13
|
+
// per canvas, and wires the existing ctx.bus 'fs:any' events through the
|
|
14
|
+
// agent's echo guard.
|
|
15
|
+
//
|
|
16
|
+
// HocuspocusProvider import is dynamic so a misconfigured project (linked but
|
|
17
|
+
// the provider lib didn't install for some reason) prints a useful error
|
|
18
|
+
// instead of crashing the dev-server boot.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { readdir } from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import type { Awareness } from 'y-protocols/awareness';
|
|
25
|
+
import * as Y from 'yjs';
|
|
26
|
+
|
|
27
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
28
|
+
import type { Context } from '../context.ts';
|
|
29
|
+
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
30
|
+
import {
|
|
31
|
+
type ConnectionMonitor,
|
|
32
|
+
type ProviderStatus,
|
|
33
|
+
createConnectionMonitor,
|
|
34
|
+
} from './connection-state.ts';
|
|
35
|
+
import { type EchoGuard, createEchoGuard } from './echo-guard.ts';
|
|
36
|
+
import { type FsReader, createFsReader } from './fs-mirror.ts';
|
|
37
|
+
import { getHubToken } from './hubs-config.ts';
|
|
38
|
+
import { migrateSeed } from './migrate-seed.ts';
|
|
39
|
+
import { type DocProjection, createDocProjection } from './projection.ts';
|
|
40
|
+
import { type SyncStatusStore, createSyncStatusStore } from './status.ts';
|
|
41
|
+
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
42
|
+
|
|
43
|
+
/** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
|
|
44
|
+
export interface SyncProvider {
|
|
45
|
+
readonly document: Y.Doc;
|
|
46
|
+
/**
|
|
47
|
+
* The provider's hub-synced Awareness, when it exposes one. Phase 9 Task 5
|
|
48
|
+
* bridges this to the collab Room's Awareness so cursors relay through the
|
|
49
|
+
* hub. Optional — a provider without awareness (or a test stub) just skips
|
|
50
|
+
* the bridge.
|
|
51
|
+
*/
|
|
52
|
+
readonly awareness?: Awareness;
|
|
53
|
+
/** Resolves when the first hub sync handshake completes. */
|
|
54
|
+
onceSynced(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
|
|
57
|
+
* mode). Returns an unsubscribe fn. Optional — a test stub or a provider
|
|
58
|
+
* without status events just isn't monitored (treated as always-online).
|
|
59
|
+
*/
|
|
60
|
+
onStatus?(cb: (status: ProviderStatus) => void): () => void;
|
|
61
|
+
destroy(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Structural surface of the collab registry the runtime needs for Task 5.
|
|
66
|
+
* Defined here (rather than imported from collab/) to avoid a dev-server
|
|
67
|
+
* module cycle — the real `Registry` satisfies it.
|
|
68
|
+
*/
|
|
69
|
+
export interface AwarenessRegistry {
|
|
70
|
+
attachHubAwareness(slug: string, awareness: Awareness): () => void;
|
|
71
|
+
/** Phase 9.1 — relay a hub-pushed comment/annotation snapshot into a live
|
|
72
|
+
* room (wholesale, in-process) so the peer's canvas reflects it immediately
|
|
73
|
+
* and the room's own debounced persist can't clobber the synced state back.
|
|
74
|
+
* Optional so file-sync-only tests can pass a minimal registry. */
|
|
75
|
+
syncRoomFromComments?(slug: string, comments: readonly unknown[]): void;
|
|
76
|
+
syncRoomFromAnnotations?(slug: string, svg: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug. When present
|
|
79
|
+
* AND `ctx.sharedDoc` is set, the runtime attaches the HocuspocusProvider to
|
|
80
|
+
* THIS doc instead of a fresh one (the convergence: one doc, both providers).
|
|
81
|
+
* Optional so file-sync-only tests can pass a minimal registry.
|
|
82
|
+
*/
|
|
83
|
+
getDoc?(slug: string): Y.Doc;
|
|
84
|
+
/** Keep a shared-doc room alive while its provider is attached (drop guard). */
|
|
85
|
+
pin?(slug: string): void;
|
|
86
|
+
/** Release the shared-doc pin on runtime stop. */
|
|
87
|
+
unpin?(slug: string): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Factory the runtime calls per discovered canvas. Default uses Hocuspocus. */
|
|
91
|
+
export type ProviderFactory = (args: {
|
|
92
|
+
url: string;
|
|
93
|
+
token: string;
|
|
94
|
+
documentName: string;
|
|
95
|
+
/**
|
|
96
|
+
* Phase 9.2 (DDR-064) — when set, the provider MUST attach to this existing
|
|
97
|
+
* `Y.Doc` (the shared room doc) instead of creating its own. The runtime
|
|
98
|
+
* passes it only when `ctx.sharedDoc` is on and the registry exposes
|
|
99
|
+
* `getDoc`. Default factory: `args.document ?? new Y.Doc()`.
|
|
100
|
+
*/
|
|
101
|
+
document?: Y.Doc;
|
|
102
|
+
}) => SyncProvider | Promise<SyncProvider>;
|
|
103
|
+
|
|
104
|
+
export interface SyncRuntime {
|
|
105
|
+
start(): Promise<void>;
|
|
106
|
+
stop(): Promise<void>;
|
|
107
|
+
/** Number of active per-canvas agents. */
|
|
108
|
+
size(): number;
|
|
109
|
+
/** Test inspection — get the agent for a slug if one was created. */
|
|
110
|
+
agentFor(slug: string): CanvasSyncAgent | undefined;
|
|
111
|
+
/** Current offline/sync status payload (Task 8), or null when unlinked. */
|
|
112
|
+
status(): import('./status.ts').SyncStatusPayload | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CreateSyncRuntimeOptions {
|
|
116
|
+
/** Override the HocuspocusProvider factory (test injection). */
|
|
117
|
+
providerFactory?: ProviderFactory;
|
|
118
|
+
/** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
|
|
119
|
+
adopt?: boolean;
|
|
120
|
+
/** Discovery override — pass an explicit canvas list instead of scanning. */
|
|
121
|
+
canvases?: CanvasDescriptor[];
|
|
122
|
+
/**
|
|
123
|
+
* Collab registry — when provided, each provider's Awareness is bridged to
|
|
124
|
+
* the matching Room so cursors relay through the hub (Task 5). Omitted in
|
|
125
|
+
* unit tests that only exercise the file-sync path.
|
|
126
|
+
*/
|
|
127
|
+
registry?: AwarenessRegistry;
|
|
128
|
+
/**
|
|
129
|
+
* Override the offline-mode connection monitor (Task 8 test injection —
|
|
130
|
+
* lets tests pass injectable timers/clock). Defaults to a real monitor that
|
|
131
|
+
* writes `_sync.json` + broadcasts 'sync:status' on the bus.
|
|
132
|
+
*/
|
|
133
|
+
connectionMonitor?: ConnectionMonitor;
|
|
134
|
+
/** Override the status store (Task 8 test injection). */
|
|
135
|
+
statusStore?: SyncStatusStore;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One canvas the sync runtime tracks. `slug` is the stable doc-name shared
|
|
140
|
+
* with the hub; the three `paths` are absolute on-disk locations the agent
|
|
141
|
+
* mirrors.
|
|
142
|
+
*/
|
|
143
|
+
export interface CanvasDescriptor {
|
|
144
|
+
slug: string;
|
|
145
|
+
html: string;
|
|
146
|
+
comments: string;
|
|
147
|
+
annotations: string;
|
|
148
|
+
/** The canvas `.meta.json` sidecar (sibling of the body). Phase 9.1 Gap 2 —
|
|
149
|
+
* shared keys (layout/artboards) sync; per-user viewport stays local. */
|
|
150
|
+
meta: string;
|
|
151
|
+
/** The canvas's sibling `.css` (Phase 9.1 Gap 3), synced as opaque text. */
|
|
152
|
+
css: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the sync runtime, or return null when the project isn't linked to a
|
|
157
|
+
* hub. Idempotent — callers can safely invoke this on every boot.
|
|
158
|
+
*/
|
|
159
|
+
export function createSyncRuntime(
|
|
160
|
+
ctx: Context,
|
|
161
|
+
opts: CreateSyncRuntimeOptions = {}
|
|
162
|
+
): SyncRuntime | null {
|
|
163
|
+
const linked = ctx.cfg.linkedHub;
|
|
164
|
+
if (!linked) return null;
|
|
165
|
+
const linkedHub = linked;
|
|
166
|
+
|
|
167
|
+
// DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
|
|
168
|
+
// where a future CI workflow runs `maude design serve` and a PR-controlled
|
|
169
|
+
// linkedHub.url silently grants a remote actor write access in an
|
|
170
|
+
// environment carrying GITHUB_TOKEN. Override via MAUDE_SYNC_IN_CI=1.
|
|
171
|
+
if (
|
|
172
|
+
!process.env.MAUDE_SYNC_IN_CI &&
|
|
173
|
+
(process.env.CI === 'true' || process.env.CI === '1' || !!process.env.GITHUB_ACTIONS)
|
|
174
|
+
) {
|
|
175
|
+
console.warn(
|
|
176
|
+
'[sync] disabled in CI environment (CI / GITHUB_ACTIONS detected). DDR-054 §2a. Set MAUDE_SYNC_IN_CI=1 to override.'
|
|
177
|
+
);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// DDR-054 §2e — scheme allowlist. Refuse plaintext to non-loopback hosts
|
|
182
|
+
// (closes attacker F9 cleartext token exfil and the F2 last-mile chain).
|
|
183
|
+
const schemeError = checkUrlScheme(linkedHub.url);
|
|
184
|
+
if (schemeError) {
|
|
185
|
+
console.error(`[sync] refusing to start: ${schemeError}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const resolvedToken = getHubToken(linkedHub.url);
|
|
190
|
+
if (!resolvedToken) {
|
|
191
|
+
console.warn(
|
|
192
|
+
`[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
|
|
193
|
+
);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const token: string = resolvedToken;
|
|
197
|
+
|
|
198
|
+
const providerFactory = opts.providerFactory ?? defaultProviderFactory;
|
|
199
|
+
const echoGuard = createEchoGuard();
|
|
200
|
+
const agents = new Map<string, CanvasSyncAgent>();
|
|
201
|
+
// Phase 9.2 (DDR-064) — under sharedDoc the disk handler is a loop-free
|
|
202
|
+
// projection (sole owner of html/css/meta doc→file + all-types file→doc),
|
|
203
|
+
// created INSTEAD of an agent. Exactly one of agents/projections is populated
|
|
204
|
+
// per run (chosen by useSharedDoc).
|
|
205
|
+
const projections = new Map<string, DocProjection>();
|
|
206
|
+
const providers = new Map<string, SyncProvider>();
|
|
207
|
+
const awarenessDetaches: Array<() => void> = [];
|
|
208
|
+
const statusDetaches: Array<() => void> = [];
|
|
209
|
+
// Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
|
|
210
|
+
// attached to their shared doc; released on stop(). Empty unless sharedDoc.
|
|
211
|
+
const pinnedSlugs = new Set<string>();
|
|
212
|
+
// The shared-doc convergence path is active only when the flag is ON AND the
|
|
213
|
+
// registry can hand us the canvas's single doc. Flag OFF / no registry / a
|
|
214
|
+
// minimal test registry without getDoc → the proven two-doc path, unchanged.
|
|
215
|
+
const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
|
|
216
|
+
let fsReader: FsReader | null = null;
|
|
217
|
+
let busUnsub: (() => void) | null = null;
|
|
218
|
+
let started = false;
|
|
219
|
+
let stopped = false;
|
|
220
|
+
|
|
221
|
+
// Task 8 — offline-mode status surface, initialized in start() once the
|
|
222
|
+
// canvas count is known. The store writes `_sync.json` + broadcasts
|
|
223
|
+
// 'sync:status' on the bus; the monitor aggregates provider WS status into
|
|
224
|
+
// online/offline/escalated and feeds every change to the store.
|
|
225
|
+
let statusStore: SyncStatusStore | null = null;
|
|
226
|
+
let monitor: ConnectionMonitor | null = null;
|
|
227
|
+
|
|
228
|
+
async function start(): Promise<void> {
|
|
229
|
+
if (started || stopped) return;
|
|
230
|
+
started = true;
|
|
231
|
+
|
|
232
|
+
const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
|
|
233
|
+
const canvases = scan.canvases;
|
|
234
|
+
// T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
|
|
235
|
+
// content, so the whole set is untrusted Claude-context. Mark it (writes
|
|
236
|
+
// `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
|
|
237
|
+
// when the set is empty). Best-effort — never throws into boot.
|
|
238
|
+
writeUntrustedMarkers(ctx, canvases, linkedHub.url);
|
|
239
|
+
if (canvases.length === 0) {
|
|
240
|
+
// DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
|
|
241
|
+
// while syncing nothing (TSX-only projects: discovery admits .html only,
|
|
242
|
+
// .tsx needs the opt-in + sandbox gate that 9.1-A/B ship). Surface the gap
|
|
243
|
+
// loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
|
|
244
|
+
// broadcast so open tabs render it immediately.
|
|
245
|
+
surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
statusStore =
|
|
250
|
+
opts.statusStore ??
|
|
251
|
+
createSyncStatusStore({
|
|
252
|
+
url: linkedHub.url,
|
|
253
|
+
canvases: canvases.length,
|
|
254
|
+
sharedDoc: useSharedDoc,
|
|
255
|
+
write: (payload) => {
|
|
256
|
+
const file = path.join(ctx.paths.designRoot, '_sync.json');
|
|
257
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
258
|
+
},
|
|
259
|
+
broadcast: (payload) => ctx.bus.emit('sync:status', payload),
|
|
260
|
+
});
|
|
261
|
+
const store = statusStore;
|
|
262
|
+
monitor =
|
|
263
|
+
opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
|
|
264
|
+
const mon = monitor;
|
|
265
|
+
|
|
266
|
+
const reader = createFsReader({
|
|
267
|
+
rootDir: ctx.paths.designRoot,
|
|
268
|
+
accept: (rel) => {
|
|
269
|
+
const ext = path.extname(rel).toLowerCase();
|
|
270
|
+
// `.tsx` added in T3 (9.1-B) so local edits to an opted-in syncable
|
|
271
|
+
// `.tsx` body propagate. Non-syncable `.tsx` changes still notify but
|
|
272
|
+
// match no agent in onRead (descriptor-scoped) → harmless no-op.
|
|
273
|
+
// `.css` added in Gap 3 so the canvas's sibling stylesheet syncs.
|
|
274
|
+
return (
|
|
275
|
+
ext === '.html' || ext === '.tsx' || ext === '.json' || ext === '.svg' || ext === '.css'
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
onRead: (evt) => {
|
|
279
|
+
const abs = path.join(ctx.paths.designRoot, evt.path);
|
|
280
|
+
// Dispatch to whichever disk handler owns this path. Only one of
|
|
281
|
+
// agents/projections is populated (useSharedDoc decides); the projector
|
|
282
|
+
// exposes the same applyFromFs(evt) shape as the agent.
|
|
283
|
+
for (const agent of agents.values()) {
|
|
284
|
+
if (agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
|
|
285
|
+
}
|
|
286
|
+
for (const proj of projections.values()) {
|
|
287
|
+
if (proj.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
fsReader = reader;
|
|
292
|
+
|
|
293
|
+
busUnsub = ctx.bus.on('fs:any', (rel: string) => {
|
|
294
|
+
reader.notify(rel);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const adoptOnce = opts.adopt ?? !!linkedHub.adopt;
|
|
298
|
+
let adoptReconciled = 0;
|
|
299
|
+
const adoptTarget = canvases.length;
|
|
300
|
+
|
|
301
|
+
for (const canvas of canvases) {
|
|
302
|
+
try {
|
|
303
|
+
// Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
|
|
304
|
+
// the collab room's single Y.Doc (registry.getDoc) instead of a fresh
|
|
305
|
+
// one, so browser edits flow straight into the doc that syncs to the
|
|
306
|
+
// hub — no disk hop, no relay, no clobber. Pin the room so the
|
|
307
|
+
// last-browser-leaves drop can't destroy the doc out from under the
|
|
308
|
+
// provider. NB: cold-start seeding of a divergent local+hub doc is the
|
|
309
|
+
// duplication trap (Risk 1) — made safe by Phase E (migrate-seed); the
|
|
310
|
+
// flag stays OFF until then.
|
|
311
|
+
const sharedYDoc = useSharedDoc ? opts.registry?.getDoc?.(canvas.slug) : undefined;
|
|
312
|
+
if (useSharedDoc && sharedYDoc) {
|
|
313
|
+
opts.registry?.pin?.(canvas.slug);
|
|
314
|
+
pinnedSlugs.add(canvas.slug);
|
|
315
|
+
}
|
|
316
|
+
const provider = await providerFactory({
|
|
317
|
+
url: linkedHub.url,
|
|
318
|
+
token,
|
|
319
|
+
documentName: canvas.slug,
|
|
320
|
+
document: sharedYDoc,
|
|
321
|
+
});
|
|
322
|
+
providers.set(canvas.slug, provider);
|
|
323
|
+
const canvasPaths = {
|
|
324
|
+
html: canvas.html,
|
|
325
|
+
comments: canvas.comments,
|
|
326
|
+
annotations: canvas.annotations,
|
|
327
|
+
meta: canvas.meta,
|
|
328
|
+
css: canvas.css,
|
|
329
|
+
};
|
|
330
|
+
// Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
|
|
331
|
+
// loop-free projection (html/css/meta doc→file + all-types file→doc;
|
|
332
|
+
// the collab room keeps comments/annotations doc→file, so no
|
|
333
|
+
// double-write). Flag-OFF keeps the proven two-doc agent.
|
|
334
|
+
let agent: CanvasSyncAgent | undefined;
|
|
335
|
+
let projection: DocProjection | undefined;
|
|
336
|
+
if (useSharedDoc && sharedYDoc) {
|
|
337
|
+
projection = createDocProjection({
|
|
338
|
+
slug: canvas.slug,
|
|
339
|
+
doc: provider.document,
|
|
340
|
+
paths: canvasPaths,
|
|
341
|
+
echoGuard,
|
|
342
|
+
});
|
|
343
|
+
projection.start();
|
|
344
|
+
projections.set(canvas.slug, projection);
|
|
345
|
+
} else {
|
|
346
|
+
agent = createCanvasSyncAgent({
|
|
347
|
+
slug: canvas.slug,
|
|
348
|
+
doc: provider.document,
|
|
349
|
+
paths: canvasPaths,
|
|
350
|
+
echoGuard,
|
|
351
|
+
adopt: adoptOnce,
|
|
352
|
+
onConflict: (info) => store.addConflict(info),
|
|
353
|
+
});
|
|
354
|
+
agent.start();
|
|
355
|
+
agents.set(canvas.slug, agent);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Task 8 — feed this provider's WS status into the offline monitor.
|
|
359
|
+
if (provider.onStatus) {
|
|
360
|
+
statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
|
|
361
|
+
} else {
|
|
362
|
+
// No status events (test stub) — treat as connected so the monitor
|
|
363
|
+
// doesn't sit in the boot 'connecting' state forever.
|
|
364
|
+
mon.noteProviderStatus(canvas.slug, 'connected');
|
|
365
|
+
}
|
|
366
|
+
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
367
|
+
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
368
|
+
// sharedDoc there is no agent origin to key off (browser edits carry a
|
|
369
|
+
// RoomConn origin); queued-edit counting in that mode is a known gap
|
|
370
|
+
// (offline-banner accuracy only, not data) deferred past Phase C.
|
|
371
|
+
if (agent) {
|
|
372
|
+
const agentOrigin = agent.origin;
|
|
373
|
+
const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
|
|
374
|
+
if (origin === agentOrigin) mon.noteLocalEdit();
|
|
375
|
+
};
|
|
376
|
+
provider.document.on('update', onLocalUpdate);
|
|
377
|
+
statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Task 5 — bridge the provider's hub-synced Awareness to the Room so
|
|
381
|
+
// browser cursors relay cross-machine. No-op when the provider exposes
|
|
382
|
+
// no awareness or no registry was passed (file-sync-only tests).
|
|
383
|
+
if (opts.registry && provider.awareness) {
|
|
384
|
+
awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Relay hub-pushed comment/annotation changes straight into the live
|
|
388
|
+
// room — IN-PROCESS + synchronous, so the room's in-memory doc is
|
|
389
|
+
// updated BEFORE its 800ms persist timer can flush stale pre-sync state
|
|
390
|
+
// back over the file (the disk-mediated re-seed in createCollab loses
|
|
391
|
+
// that race under an actively-edited peer; this is the tight path that
|
|
392
|
+
// actually closes the "comment reverts" clobber). Wholesale-replace via
|
|
393
|
+
// syncRoomFrom* → no duplication. Skip agent-origin updates (our own
|
|
394
|
+
// disk→doc apply — the file is authoritative there; a local design:edit
|
|
395
|
+
// reaches the room via createCollab's fs hook instead).
|
|
396
|
+
//
|
|
397
|
+
// CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
|
|
398
|
+
// whole-doc update. A whole-doc relay re-applies BOTH types on every
|
|
399
|
+
// change, so a comment sync would re-push the (stale) annotation and
|
|
400
|
+
// clobber an annotation the peer just drew but hasn't synced yet — and
|
|
401
|
+
// vice versa. Per-type observers keep the two lanes independent.
|
|
402
|
+
//
|
|
403
|
+
// Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
|
|
404
|
+
// room's doc, so there is no second doc to relay into — the room already
|
|
405
|
+
// has every change. Skipping the relay is what RETIRES the
|
|
406
|
+
// wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
|
|
407
|
+
// CRDT merge handles concurrency, no last-writer-wins blob copy.
|
|
408
|
+
const reg = opts.registry;
|
|
409
|
+
if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
|
|
410
|
+
const agentOrigin = agent.origin;
|
|
411
|
+
const slug = canvas.slug;
|
|
412
|
+
const provComments = provider.document.getArray(Y_TYPES.comments);
|
|
413
|
+
const provAnn = provider.document.getMap(Y_TYPES.annotations);
|
|
414
|
+
const onComments = (_e: unknown, tx: { origin: unknown }) => {
|
|
415
|
+
if (tx.origin === agentOrigin) return;
|
|
416
|
+
reg.syncRoomFromComments?.(slug, provComments.toArray());
|
|
417
|
+
};
|
|
418
|
+
const onAnn = (_e: unknown, tx: { origin: unknown }) => {
|
|
419
|
+
if (tx.origin === agentOrigin) return;
|
|
420
|
+
const svg = provAnn.get('svg');
|
|
421
|
+
if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
|
|
422
|
+
};
|
|
423
|
+
provComments.observe(onComments);
|
|
424
|
+
provAnn.observe(onAnn);
|
|
425
|
+
statusDetaches.push(() => {
|
|
426
|
+
provComments.unobserve(onComments);
|
|
427
|
+
provAnn.unobserve(onAnn);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Cold-start reconcile fires once the provider has hub state.
|
|
432
|
+
void provider.onceSynced().then(async () => {
|
|
433
|
+
if (projection) {
|
|
434
|
+
// Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
|
|
435
|
+
// materializing: escapes the duplication trap by picking ONE source
|
|
436
|
+
// (hub-wins if the synced doc holds state; adopt local files only
|
|
437
|
+
// when the hub was empty), inside a MIGRATION transaction. The room
|
|
438
|
+
// file-seed is disabled for this pinned slug (createCollab
|
|
439
|
+
// shouldSeed), so no duplicate items can be introduced afterward.
|
|
440
|
+
const result = migrateSeed({
|
|
441
|
+
slug: canvas.slug,
|
|
442
|
+
doc: provider.document,
|
|
443
|
+
paths: canvasPaths,
|
|
444
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
445
|
+
});
|
|
446
|
+
if (result === 'local-adopt') {
|
|
447
|
+
console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
|
|
448
|
+
}
|
|
449
|
+
// Then materialize the converged doc to disk (safe — never clobbers
|
|
450
|
+
// non-empty local with an empty doc value).
|
|
451
|
+
projection.reconcile();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (!agent) return;
|
|
455
|
+
await agent.reconcile();
|
|
456
|
+
if (adoptOnce) {
|
|
457
|
+
adoptReconciled++;
|
|
458
|
+
if (adoptReconciled === adoptTarget) {
|
|
459
|
+
// All canvases adopted — clear the flag from .design/config.json
|
|
460
|
+
// so re-running serve doesn't re-trigger. DDR-054 §2i.
|
|
461
|
+
clearAdoptFlag(ctx);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.error(`[sync/${canvas.slug}] failed to start:`, err);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Persist an initial status snapshot so `_sync.json` exists — and `maude
|
|
471
|
+
// design status` + the browser banner report "agent running" — from the
|
|
472
|
+
// moment serve boots. The ConnectionMonitor only emits on *transitions* and
|
|
473
|
+
// starts in 'online', so on a clean fast localhost connect (provider
|
|
474
|
+
// reaches 'connected' at/before subscribe → no transition fires) nothing
|
|
475
|
+
// would otherwise be written, and status would read "idle / sync agent not
|
|
476
|
+
// running" while sync is in fact healthy. This was the observed bug.
|
|
477
|
+
store.update(mon.snapshot());
|
|
478
|
+
|
|
479
|
+
console.log(
|
|
480
|
+
`[sync] linked to ${linkedHub.url} — ${agents.size + projections.size}/${canvases.length} canvas(es) syncing${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function stop(): Promise<void> {
|
|
485
|
+
if (stopped) return;
|
|
486
|
+
stopped = true;
|
|
487
|
+
for (const detach of awarenessDetaches) {
|
|
488
|
+
try {
|
|
489
|
+
detach();
|
|
490
|
+
} catch {
|
|
491
|
+
/* best-effort — registry teardown also clears bridges on destroyAll */
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
awarenessDetaches.length = 0;
|
|
495
|
+
// Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
|
|
496
|
+
// / destroyed normally on shutdown. Empty unless sharedDoc was active.
|
|
497
|
+
for (const slug of pinnedSlugs) {
|
|
498
|
+
try {
|
|
499
|
+
opts.registry?.unpin?.(slug);
|
|
500
|
+
} catch {
|
|
501
|
+
/* best-effort */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
pinnedSlugs.clear();
|
|
505
|
+
for (const detach of statusDetaches) {
|
|
506
|
+
try {
|
|
507
|
+
detach();
|
|
508
|
+
} catch {
|
|
509
|
+
/* best-effort */
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
statusDetaches.length = 0;
|
|
513
|
+
monitor?.stop();
|
|
514
|
+
busUnsub?.();
|
|
515
|
+
busUnsub = null;
|
|
516
|
+
fsReader?.stop();
|
|
517
|
+
fsReader = null;
|
|
518
|
+
for (const agent of agents.values()) {
|
|
519
|
+
try {
|
|
520
|
+
await agent.flush();
|
|
521
|
+
agent.stop();
|
|
522
|
+
} catch {
|
|
523
|
+
/* best-effort */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
agents.clear();
|
|
527
|
+
// Phase 9.2 (DDR-064) — final doc→file flush + stop the projectors BEFORE
|
|
528
|
+
// tearing down providers (so the converged doc lands on disk). The shared
|
|
529
|
+
// doc itself is owned by the collab room and destroyed by registry teardown,
|
|
530
|
+
// not here.
|
|
531
|
+
for (const proj of projections.values()) {
|
|
532
|
+
try {
|
|
533
|
+
await proj.flush();
|
|
534
|
+
proj.stop();
|
|
535
|
+
} catch {
|
|
536
|
+
/* best-effort */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
projections.clear();
|
|
540
|
+
for (const provider of providers.values()) {
|
|
541
|
+
try {
|
|
542
|
+
provider.destroy();
|
|
543
|
+
} catch {
|
|
544
|
+
/* best-effort */
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
providers.clear();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
start,
|
|
552
|
+
stop,
|
|
553
|
+
// Under sharedDoc the per-canvas handler is a projection, not an agent;
|
|
554
|
+
// count both so size() reflects the synced-canvas count in either mode.
|
|
555
|
+
size: () => agents.size + projections.size,
|
|
556
|
+
agentFor: (slug) => agents.get(slug),
|
|
557
|
+
status: () => statusStore?.get() ?? null,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* ---------------------------------------------------------------- discovery */
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Scan `<designRoot>/{ui,system}/` for `.html` canvas files and return one
|
|
565
|
+
* CanvasDescriptor per. Mirrors the existing api.ts file-tree scan but
|
|
566
|
+
* specialised for the sync runtime (we only need the three paths per canvas,
|
|
567
|
+
* not the full metadata).
|
|
568
|
+
*
|
|
569
|
+
* DDR-054 §2b / DDR-060 — `.tsx` canvases are deliberately EXCLUDED from sync.
|
|
570
|
+
* The dev-server transpiles `.tsx` to JavaScript and serves it as
|
|
571
|
+
* `application/javascript` in iframe same-origin; a hostile hub pushing
|
|
572
|
+
* arbitrary TypeScript source would result in RCE (the audit's CRITICAL F1).
|
|
573
|
+
* Since Phase 3.6 made `.tsx` the ONLY canvas format, this means real projects
|
|
574
|
+
* discover zero syncable canvases — surfaced loudly by `surfaceNoSyncable`
|
|
575
|
+
* (9.1-D) rather than silently. The per-canvas opt-in (`.meta.json.syncable:
|
|
576
|
+
* true`) + CSP/sandbox gate that make `.tsx` syncable land in 9.1-A/B.
|
|
577
|
+
*/
|
|
578
|
+
export async function discoverCanvases(ctx: Context): Promise<CanvasDescriptor[]> {
|
|
579
|
+
return (await scanCanvases(ctx)).canvases;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Result of a canvas-group scan: syncable descriptors + a tally of the .tsx
|
|
583
|
+
* canvases that exist but are NOT yet syncable (DDR-060 — they need the
|
|
584
|
+
* per-canvas opt-in + sandbox gate). The tsx count feeds 9.1-D's loud
|
|
585
|
+
* zero-syncable surface so the message can say *why* nothing syncs. */
|
|
586
|
+
export interface CanvasScan {
|
|
587
|
+
canvases: CanvasDescriptor[];
|
|
588
|
+
tsxCount: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
|
|
592
|
+
const out: CanvasDescriptor[] = [];
|
|
593
|
+
const counter = { tsx: 0 };
|
|
594
|
+
// T3 (9.1-B) — LOAD-BEARING coupling (the plan's "two locks flip together"
|
|
595
|
+
// invariant): a `.tsx` body is admitted to sync ONLY when the cross-origin
|
|
596
|
+
// CSP/sandbox containment is active (`ctx.canvasOrigin` is set — Lock 2). The
|
|
597
|
+
// sandbox is now ON BY DEFAULT, but a user can opt out with
|
|
598
|
+
// MAUDE_CANVAS_ORIGIN_SPLIT=0; if they do, `canvasOrigin` is undefined and NO
|
|
599
|
+
// `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
|
|
600
|
+
// decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
|
|
601
|
+
const splitActive = !!ctx.canvasOrigin;
|
|
602
|
+
for (const group of ctx.cfg.canvasGroups) {
|
|
603
|
+
const groupAbs = path.join(ctx.paths.designRoot, group.path);
|
|
604
|
+
if (!existsSync(groupAbs)) continue;
|
|
605
|
+
await walk(
|
|
606
|
+
groupAbs,
|
|
607
|
+
ctx.paths.designRoot,
|
|
608
|
+
ctx.paths.commentsDir,
|
|
609
|
+
ctx.paths.designRel,
|
|
610
|
+
out,
|
|
611
|
+
counter,
|
|
612
|
+
splitActive
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
return { canvases: out, tsxCount: counter.tsx };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* T3 (9.1-B) — per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
|
|
620
|
+
* sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
|
|
621
|
+
* human editing the sidecar; it is deliberately NOT in the untrusted
|
|
622
|
+
* `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
|
|
623
|
+
* flip its own body into the sync set. Missing sidecar / parse error → false.
|
|
624
|
+
*/
|
|
625
|
+
function readSyncableFlag(bodyAbs: string): boolean {
|
|
626
|
+
const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
|
|
627
|
+
try {
|
|
628
|
+
const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
|
|
629
|
+
return obj && typeof obj === 'object' && obj.syncable === true;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function walk(
|
|
636
|
+
dirAbs: string,
|
|
637
|
+
designRoot: string,
|
|
638
|
+
commentsDir: string,
|
|
639
|
+
designRel: string,
|
|
640
|
+
acc: CanvasDescriptor[],
|
|
641
|
+
counter: { tsx: number },
|
|
642
|
+
splitActive: boolean
|
|
643
|
+
): Promise<void> {
|
|
644
|
+
let entries: import('node:fs').Dirent[];
|
|
645
|
+
try {
|
|
646
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
647
|
+
} catch {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
for (const entry of entries) {
|
|
651
|
+
const abs = path.join(dirAbs, entry.name);
|
|
652
|
+
if (entry.isDirectory()) {
|
|
653
|
+
// Skip plugin runtime dirs.
|
|
654
|
+
if (entry.name.startsWith('_')) continue;
|
|
655
|
+
await walk(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
659
|
+
// T3 (9.1-B) — a `.tsx` syncs ONLY when the sandbox is active AND the
|
|
660
|
+
// sidecar opts in (see readSyncableFlag + the splitActive coupling above).
|
|
661
|
+
// Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
|
|
662
|
+
// can explain *why* it isn't syncing.
|
|
663
|
+
if (ext === '.tsx') {
|
|
664
|
+
if (!(splitActive && readSyncableFlag(abs))) {
|
|
665
|
+
counter.tsx += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
// falls through to descriptor push (body = the .tsx file)
|
|
669
|
+
} else if (ext !== '.html') {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const slug = slugFor(abs, designRoot, designRel);
|
|
673
|
+
acc.push({
|
|
674
|
+
// `html` is the canvas BODY path — `.html` or an opted-in `.tsx`. The
|
|
675
|
+
// sync codec treats the body as opaque Y.Text, so the field name is
|
|
676
|
+
// historical; both formats round-trip identically (DDR-060).
|
|
677
|
+
slug,
|
|
678
|
+
html: abs,
|
|
679
|
+
comments: path.join(commentsDir, `${slug}.json`),
|
|
680
|
+
annotations: path.join(designRoot, `${slug}.annotations.svg`),
|
|
681
|
+
// The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
|
|
682
|
+
meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
|
|
683
|
+
// The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
|
|
684
|
+
css: abs.replace(/\.(tsx|html)$/i, '.css'),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The shape written to `<designRoot>/_sync.json` (and broadcast on the
|
|
691
|
+
* 'sync:status' bus) when the project is linked but has zero syncable
|
|
692
|
+
* canvases. DDR-060 / 9.1-D. Distinct from the live SyncStatusPayload — the
|
|
693
|
+
* `notSyncable` discriminator lets the CLI status line and the browser banner
|
|
694
|
+
* render the "linked but nothing syncs" state instead of a healthy one.
|
|
695
|
+
*/
|
|
696
|
+
export interface NoSyncablePayload {
|
|
697
|
+
linked: true;
|
|
698
|
+
notSyncable: true;
|
|
699
|
+
url: string;
|
|
700
|
+
reason: string;
|
|
701
|
+
/** Count of .tsx canvases present (syncable once 9.1-A/B land). */
|
|
702
|
+
tsxCount: number;
|
|
703
|
+
canvases: 0;
|
|
704
|
+
updatedAt: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Build the zero-syncable payload (exported for the CLI + tests). */
|
|
708
|
+
export function buildNoSyncablePayload(
|
|
709
|
+
url: string,
|
|
710
|
+
tsxCount: number,
|
|
711
|
+
designRoot: string
|
|
712
|
+
): NoSyncablePayload {
|
|
713
|
+
const reason =
|
|
714
|
+
tsxCount > 0
|
|
715
|
+
? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
|
|
716
|
+
: `no canvases found under ${designRoot}.`;
|
|
717
|
+
return {
|
|
718
|
+
linked: true,
|
|
719
|
+
notSyncable: true,
|
|
720
|
+
url,
|
|
721
|
+
reason,
|
|
722
|
+
tsxCount,
|
|
723
|
+
canvases: 0,
|
|
724
|
+
updatedAt: Date.now(),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* 9.1-D — replace the silent zero-canvas early-return with a loud surface:
|
|
730
|
+
* a warn line, a `_sync.json` the CLI + browser read, and a bus broadcast.
|
|
731
|
+
* Best-effort on the write/broadcast — a failure there must never throw into
|
|
732
|
+
* the boot path (solo mode for unlinked projects is unaffected either way).
|
|
733
|
+
*/
|
|
734
|
+
function surfaceNoSyncable(ctx: Context, url: string, tsxCount: number): void {
|
|
735
|
+
const payload = buildNoSyncablePayload(url, tsxCount, ctx.paths.designRoot);
|
|
736
|
+
console.warn(`[sync] linked to ${url} but 0 syncable canvases — ${payload.reason}`);
|
|
737
|
+
try {
|
|
738
|
+
const file = path.join(ctx.paths.designRoot, '_sync.json');
|
|
739
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
740
|
+
} catch {
|
|
741
|
+
/* best-effort — never throw into boot */
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
ctx.bus.emit('sync:status', payload);
|
|
745
|
+
} catch {
|
|
746
|
+
/* best-effort */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function slugFor(absPath: string, designRoot: string, designRel: string): string {
|
|
751
|
+
let rel = path.relative(designRoot, absPath);
|
|
752
|
+
rel = rel.replace(/\\/g, '/');
|
|
753
|
+
// Mirror api.fileSlug(): collapse path separators to '-', strip extension.
|
|
754
|
+
const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
755
|
+
if (rel.startsWith(prefix)) rel = rel.slice(prefix.length);
|
|
756
|
+
return rel
|
|
757
|
+
.replace(/\//g, '-')
|
|
758
|
+
.replace(/\s+/g, '_')
|
|
759
|
+
.replace(/\.(tsx|html)$/i, '')
|
|
760
|
+
.replace(/^\.+/, '')
|
|
761
|
+
.toLowerCase();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/* ---------------------------------------------------------------- default provider */
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* The production provider factory — instantiates a real HocuspocusProvider.
|
|
768
|
+
* Imported dynamically so tests / unlinked projects don't pay the load cost.
|
|
769
|
+
*/
|
|
770
|
+
async function defaultProviderFactory(args: {
|
|
771
|
+
url: string;
|
|
772
|
+
token: string;
|
|
773
|
+
documentName: string;
|
|
774
|
+
document?: Y.Doc;
|
|
775
|
+
}): Promise<SyncProvider> {
|
|
776
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
|
|
777
|
+
let mod: any;
|
|
778
|
+
try {
|
|
779
|
+
mod = await import('@hocuspocus/provider');
|
|
780
|
+
} catch (err) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`@hocuspocus/provider unavailable — install it under plugins/design/dev-server/. (${err instanceof Error ? err.message : String(err)})`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
// Hocuspocus accepts ws:// or wss://; the linked URL is http(s)://, so swap
|
|
786
|
+
// the scheme. The provider also accepts http(s):// and upgrades internally
|
|
787
|
+
// in newer versions, but ws:// is explicit + portable.
|
|
788
|
+
const wsUrl = toWsUrl(args.url);
|
|
789
|
+
// Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
|
|
790
|
+
// injected one; otherwise own a fresh doc (the legacy two-doc path).
|
|
791
|
+
const document = args.document ?? new Y.Doc();
|
|
792
|
+
// biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
|
|
793
|
+
const provider: any = new mod.HocuspocusProvider({
|
|
794
|
+
url: wsUrl,
|
|
795
|
+
name: args.documentName,
|
|
796
|
+
token: args.token,
|
|
797
|
+
document,
|
|
798
|
+
connect: true,
|
|
799
|
+
// Make a rejected handshake LOUD instead of an invisible reconnect loop.
|
|
800
|
+
// The most common cause is a scope-bound token (DDR-053 default scope =
|
|
801
|
+
// label) that can't authorize this canvas's flat-slug documentName — that
|
|
802
|
+
// failure mode previously looked identical to "hub unreachable / offline".
|
|
803
|
+
onAuthenticationFailed: (data: { reason?: string }) => {
|
|
804
|
+
const reason = (data?.reason ?? 'unknown reason').replace(/[\r\n]/g, ' ').slice(0, 200);
|
|
805
|
+
console.warn(
|
|
806
|
+
`[sync] hub REJECTED auth for documentName="${args.documentName}": ${reason}.
|
|
807
|
+
If this is "token not authorized for this documentName", the token's scope
|
|
808
|
+
does not cover this canvas — mint a hub-wide token (\`maude hub token generate
|
|
809
|
+
--scope '*'\` or an admin-UI invite) and re-link. The peer keeps retrying until then.`
|
|
810
|
+
);
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
return {
|
|
814
|
+
document,
|
|
815
|
+
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
816
|
+
// so the runtime can bridge it to the collab Room (Task 5).
|
|
817
|
+
awareness: provider.awareness as Awareness | undefined,
|
|
818
|
+
onStatus(cb: (status: ProviderStatus) => void): () => void {
|
|
819
|
+
// HocuspocusProvider emits 'status' with { status: 'connected' |
|
|
820
|
+
// 'connecting' | 'disconnected' } on every WS transition (Task 8).
|
|
821
|
+
const handler = (evt: { status?: string }) => {
|
|
822
|
+
const s = evt?.status;
|
|
823
|
+
if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
|
|
824
|
+
};
|
|
825
|
+
provider.on('status', handler);
|
|
826
|
+
// Seed the subscriber with the provider's CURRENT status immediately. On
|
|
827
|
+
// localhost the WS can reach 'connected'/synced inside the constructor —
|
|
828
|
+
// before this listener attaches — so waiting for the *next* transition
|
|
829
|
+
// would leave the connection monitor un-seeded and `_sync.json` never
|
|
830
|
+
// written (status reads "idle / sync agent not running" while sync is in
|
|
831
|
+
// fact healthy). Reading provider.status closes that race; it always
|
|
832
|
+
// reflects the true current state, so a missed event can't strand us.
|
|
833
|
+
const cur = provider.status;
|
|
834
|
+
if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
|
|
835
|
+
return () => provider.off('status', handler);
|
|
836
|
+
},
|
|
837
|
+
onceSynced(): Promise<void> {
|
|
838
|
+
return new Promise<void>((resolve) => {
|
|
839
|
+
if (provider.synced) {
|
|
840
|
+
resolve();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const handler = () => {
|
|
844
|
+
provider.off('synced', handler);
|
|
845
|
+
resolve();
|
|
846
|
+
};
|
|
847
|
+
provider.on('synced', handler);
|
|
848
|
+
});
|
|
849
|
+
},
|
|
850
|
+
destroy() {
|
|
851
|
+
provider.destroy();
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Convert an http(s):// URL to ws(s):// for the HocuspocusProvider. */
|
|
857
|
+
export function toWsUrl(httpUrl: string): string {
|
|
858
|
+
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
|
859
|
+
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
|
860
|
+
return httpUrl;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Rewrite .design/config.json to drop `linkedHub.adopt`. Called once after
|
|
865
|
+
* all canvases finish their first adopt-reconcile. DDR-054 §2i (defender I5).
|
|
866
|
+
* Best-effort — failure logs and leaves the disk flag in place; the only
|
|
867
|
+
* downstream cost is the user being prompted to re-run adopt.
|
|
868
|
+
*/
|
|
869
|
+
function clearAdoptFlag(ctx: Context): void {
|
|
870
|
+
const cfgPath = path.join(ctx.paths.repoRoot, '.design', 'config.json');
|
|
871
|
+
if (!existsSync(cfgPath)) return;
|
|
872
|
+
try {
|
|
873
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8')) as {
|
|
874
|
+
linkedHub?: { adopt?: boolean; lastAdoptedAt?: number };
|
|
875
|
+
};
|
|
876
|
+
if (!raw?.linkedHub?.adopt) return;
|
|
877
|
+
raw.linkedHub.adopt = undefined;
|
|
878
|
+
raw.linkedHub.lastAdoptedAt = Date.now();
|
|
879
|
+
// Strip undefined values via stringify/parse so the JSON output is clean.
|
|
880
|
+
const cleaned = JSON.parse(JSON.stringify(raw));
|
|
881
|
+
writeFileSync(cfgPath, `${JSON.stringify(cleaned, null, 2)}\n`, 'utf8');
|
|
882
|
+
console.log('[sync] adopt complete — cleared linkedHub.adopt from .design/config.json');
|
|
883
|
+
} catch (err) {
|
|
884
|
+
console.warn(
|
|
885
|
+
'[sync] failed to clear linkedHub.adopt:',
|
|
886
|
+
err instanceof Error ? err.message : err
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Refuse non-loopback ws:// / http:// (cleartext token exposure to MITM).
|
|
893
|
+
* DDR-054 §2e (attacker F9). Returns null on accept, error string on refuse.
|
|
894
|
+
*
|
|
895
|
+
* Loopback hosts (localhost, 127.0.0.1, [::1], ::1) keep ws:// allowed for
|
|
896
|
+
* local hub development. Non-http(s)/ws(s) schemes are refused outright.
|
|
897
|
+
*/
|
|
898
|
+
export function checkUrlScheme(url: string): string | null {
|
|
899
|
+
let u: URL;
|
|
900
|
+
try {
|
|
901
|
+
u = new URL(url);
|
|
902
|
+
} catch {
|
|
903
|
+
return `invalid hub URL: ${url}`;
|
|
904
|
+
}
|
|
905
|
+
const proto = u.protocol.toLowerCase();
|
|
906
|
+
if (proto !== 'http:' && proto !== 'https:' && proto !== 'ws:' && proto !== 'wss:') {
|
|
907
|
+
return `unsupported hub URL scheme: ${proto} (expected https:// or wss://)`;
|
|
908
|
+
}
|
|
909
|
+
const isPlaintext = proto === 'http:' || proto === 'ws:';
|
|
910
|
+
if (!isPlaintext) return null;
|
|
911
|
+
const host = u.hostname.toLowerCase();
|
|
912
|
+
const isLoopback =
|
|
913
|
+
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
914
|
+
if (!isLoopback) {
|
|
915
|
+
return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${host} or change the host to localhost.`;
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|