@1agh/maude 0.58.2 → 0.59.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// Desktop→cell asset push — DDR-217 + the 2026-08-11 addendum (fix 6 of the
|
|
2
|
+
// 2026-08-10 sync RCA, completed).
|
|
3
|
+
//
|
|
4
|
+
// The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
|
|
5
|
+
// desktop-linked project's binary assets never reached the cell — the grey
|
|
6
|
+
// boxes. The desktop is the one peer that HAS the bytes and already holds an
|
|
7
|
+
// authenticated channel to the hub, so it pushes them. There are TWO asset
|
|
8
|
+
// classes, served two different ways, so they push to two different routes:
|
|
9
|
+
//
|
|
10
|
+
// 1. TOP-LEVEL content-addressed uploads (`<designRoot>/assets/<sha8>.<ext>`)
|
|
11
|
+
// — referenced by the `/assets/<key>` shortcut, served on the cloud from
|
|
12
|
+
// the BUCKET proxy. Push → `PUT /assets/<key>` (bucket + checkout mirror).
|
|
13
|
+
// 2. DS / BRAND assets (`<designRoot>/system/<ds>/assets/logos/x.svg`, fonts,
|
|
14
|
+
// photos) — referenced by their FULL designRoot path
|
|
15
|
+
// (`/.design/system/<ds>/assets/…`) and served from the CHECKOUT by the
|
|
16
|
+
// studio child, never the bucket. The original fix only swept class 1, so
|
|
17
|
+
// these stayed grey (alligators has 93 of them). Push → `PUT
|
|
18
|
+
// /_asset-file/<designRoot-rel>` (checkout only, no bucket).
|
|
19
|
+
//
|
|
20
|
+
// Both are HEAD-first (skip what the cloud already holds) and streamed. The
|
|
21
|
+
// HUB's validation is the authoritative gate at each trust boundary; the
|
|
22
|
+
// filters here are the courtesy layer that keeps junk off the wire.
|
|
23
|
+
|
|
24
|
+
import { type Dirent, readdirSync, statSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** One path segment charset — matches the hub's component regexes. */
|
|
28
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
|
|
29
|
+
|
|
30
|
+
/** Max designRoot-relative depth (matches the hub's 8-segment cap). */
|
|
31
|
+
const MAX_SEGMENTS = 8;
|
|
32
|
+
|
|
33
|
+
/** Max relative-path length (matches the hub's 512 cap). */
|
|
34
|
+
const MAX_REL_LEN = 512;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The binary asset extensions that actually render — images, fonts, media.
|
|
38
|
+
* Deliberately NOT `.json`/`.meta.json`/`.photo.json`/`.tsx`/`.css`: a
|
|
39
|
+
* `.photo.json` sidecar is edit metadata, not a served asset, and the checkout
|
|
40
|
+
* route refuses non-asset extensions anyway (so pushing them would just waste
|
|
41
|
+
* the wire and 400). Case-insensitive — a DS ships `…P1020428.JPG`.
|
|
42
|
+
*/
|
|
43
|
+
const ASSET_EXTS = new Set([
|
|
44
|
+
'png',
|
|
45
|
+
'jpg',
|
|
46
|
+
'jpeg',
|
|
47
|
+
'gif',
|
|
48
|
+
'webp',
|
|
49
|
+
'avif',
|
|
50
|
+
'svg',
|
|
51
|
+
'mp4',
|
|
52
|
+
'webm',
|
|
53
|
+
'mov',
|
|
54
|
+
'mp3',
|
|
55
|
+
'wav',
|
|
56
|
+
'm4a',
|
|
57
|
+
'ogg',
|
|
58
|
+
'woff2',
|
|
59
|
+
'woff',
|
|
60
|
+
'ttf',
|
|
61
|
+
'otf',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** A 2 GB file in an assets dir is a mistake — don't move it silently. */
|
|
65
|
+
const MAX_PUSH_BYTES = 512 * 1024 * 1024;
|
|
66
|
+
|
|
67
|
+
export interface AssetPushResult {
|
|
68
|
+
pushed: string[];
|
|
69
|
+
skipped: number;
|
|
70
|
+
failed: { key: string; reason: string }[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* feature-sync-progress-modal — incremental asset-push progress, emitted onto
|
|
75
|
+
* the sync bus so the Sync panel can show assets moving instead of a silent
|
|
76
|
+
* gap between "canvases synced" and a log line at the end. Keys are LOCAL
|
|
77
|
+
* designRoot-relative paths (never hub-supplied); `failures` is capped at
|
|
78
|
+
* MAX_LISTED_FAILURES with `failedCount` carrying the true number.
|
|
79
|
+
*/
|
|
80
|
+
export interface AssetPushProgress {
|
|
81
|
+
/** Total pushable assets found this boot. */
|
|
82
|
+
total: number;
|
|
83
|
+
/** Files settled so far (pushed + skipped + failed). */
|
|
84
|
+
done: number;
|
|
85
|
+
pushed: number;
|
|
86
|
+
skipped: number;
|
|
87
|
+
failedCount: number;
|
|
88
|
+
/** First MAX_LISTED_FAILURES failures — enough to name the broken paths. */
|
|
89
|
+
failures: { key: string; reason: string }[];
|
|
90
|
+
/** The designRoot-relative path on the wire right now, null when finished. */
|
|
91
|
+
active: string | null;
|
|
92
|
+
/** True exactly once, on the final emit (also fires when total is 0). */
|
|
93
|
+
finished: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Cap on `failures` in a progress emit (same spirit as MAX_REJECTED_SLUGS —
|
|
97
|
+
* the payload reaches `_sync.json` + every open tab, so it stays bounded). */
|
|
98
|
+
export const MAX_LISTED_FAILURES = 20;
|
|
99
|
+
|
|
100
|
+
/** Longest single pause the hub can ask for. Matches the hub's rate-limit
|
|
101
|
+
* window (60 s) — a `Retry-After` larger than that is either a typo or a hub
|
|
102
|
+
* we should not be blocking a boot sweep on. */
|
|
103
|
+
const MAX_RETRY_DELAY_MS = 60_000;
|
|
104
|
+
|
|
105
|
+
/** Where an absent/unparsable `Retry-After` lands. Old hubs (pre-fix) send a
|
|
106
|
+
* bare 429 with no header, and their window is the same 60 s. */
|
|
107
|
+
const DEFAULT_RETRY_DELAY_MS = 60_000;
|
|
108
|
+
|
|
109
|
+
/** Total time ONE sweep may spend waiting out 429s. The paced retry exists to
|
|
110
|
+
* keep an un-upgraded hub livable, not to turn a boot into an hour-long
|
|
111
|
+
* background stall — past this, the remaining refusals fail fast and the
|
|
112
|
+
* next-boot backstop takes them. */
|
|
113
|
+
const MAX_SWEEP_BACKOFF_MS = 5 * 60_000;
|
|
114
|
+
|
|
115
|
+
/** How much of an error body reaches `failed[].reason`. Enough to tell "rate
|
|
116
|
+
* limit exceeded" from a Cloudflare error page — the distinction the 2026-08-11
|
|
117
|
+
* RCA had to reconstruct from edge logs because the client kept only a status
|
|
118
|
+
* code. Hub-supplied text ⇒ bounded and stripped before it reaches the UI. */
|
|
119
|
+
const ERROR_SNIPPET_CHARS = 80;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every upload closes its connection. NOT an optimization — a correctness
|
|
123
|
+
* requirement, learned the expensive way (2026-08-11, second pass).
|
|
124
|
+
*
|
|
125
|
+
* A peer that refuses a PUT **before reading the body** (the cloud studio door
|
|
126
|
+
* answering 401, the edge answering 503) leaves unread request bytes in an
|
|
127
|
+
* HTTP/1.1 keep-alive socket. The connection is then desynchronized: the next
|
|
128
|
+
* request Bun sends over it NEVER gets a response. With no retry that stayed
|
|
129
|
+
* invisible — the refusal was reported and the sweep moved on. The moment a
|
|
130
|
+
* retry re-sent on that same pooled socket, the sweep wedged forever and the
|
|
131
|
+
* dev-server sidecar died with it (Bun segfault, 4 crash-loops, alligators).
|
|
132
|
+
* Measured: `connection: close` on the retry ALONE does not help (the retry is
|
|
133
|
+
* handed the already-poisoned socket) — it has to be on the request that may be
|
|
134
|
+
* refused, i.e. every PUT. One TLS handshake per asset against multi-MB bodies
|
|
135
|
+
* is not a cost worth reasoning about.
|
|
136
|
+
*/
|
|
137
|
+
const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
|
|
138
|
+
|
|
139
|
+
/** HEAD is a small, bodyless probe — a hub that has not answered in 30 s is not
|
|
140
|
+
* about to. */
|
|
141
|
+
const HEAD_TIMEOUT_MS = 30_000;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* How long one upload may take before the sweep abandons it: a fixed floor plus
|
|
145
|
+
* an allowance for the bytes at a deliberately pessimistic 100 kB/s, capped.
|
|
146
|
+
* The backstop for anything that wedges a connection the way the keep-alive
|
|
147
|
+
* desync above did — a sweep that hangs forever takes the whole dev-server with
|
|
148
|
+
* it, and "this asset failed, next boot retries it" is always the better end.
|
|
149
|
+
*/
|
|
150
|
+
export function putTimeoutMs(bytes: number): number {
|
|
151
|
+
return Math.min(10 * 60_000, 60_000 + (Number.isFinite(bytes) ? bytes : 0) / 100);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Min ms between mid-flight progress emits. A 90-file DS at LAN speed would
|
|
155
|
+
* otherwise broadcast 90 payloads in a couple of seconds; failures and the
|
|
156
|
+
* final emit always go out regardless. */
|
|
157
|
+
const PROGRESS_INTERVAL_MS = 200;
|
|
158
|
+
|
|
159
|
+
function extOf(name: string): string {
|
|
160
|
+
const dot = name.lastIndexOf('.');
|
|
161
|
+
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every pushable binary asset under designRoot, as a designRoot-relative path.
|
|
166
|
+
* Walks into any directory named `assets` at any level (top-level `assets/`,
|
|
167
|
+
* `system/<ds>/assets/`, …) and collects the asset-extension files inside it.
|
|
168
|
+
* Skips runtime-state (`_*`), `.git`, `node_modules`. Missing root → [].
|
|
169
|
+
*/
|
|
170
|
+
export function listPushableAssets(designRoot: string): string[] {
|
|
171
|
+
const out: string[] = [];
|
|
172
|
+
// Walk the tree; once inside an `assets` dir, collect asset files below it.
|
|
173
|
+
const walk = (dir: string, rel: string, insideAssets: boolean): void => {
|
|
174
|
+
let entries: Dirent[];
|
|
175
|
+
try {
|
|
176
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const name = entry.name;
|
|
182
|
+
if (name.startsWith('_') || name === '.git' || name === 'node_modules') continue;
|
|
183
|
+
if (!SEGMENT.test(name)) continue; // dotfiles + odd charset
|
|
184
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
185
|
+
if (childRel.length > MAX_REL_LEN || childRel.split('/').length > MAX_SEGMENTS) continue;
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
walk(path.join(dir, name), childRel, insideAssets || name === 'assets');
|
|
188
|
+
} else if (entry.isFile()) {
|
|
189
|
+
if (!insideAssets) continue; // only files under some assets/ dir
|
|
190
|
+
if (!ASSET_EXTS.has(extOf(name))) continue;
|
|
191
|
+
try {
|
|
192
|
+
if (statSync(path.join(dir, name)).size > MAX_PUSH_BYTES) continue;
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
out.push(childRel);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
walk(designRoot, '', false);
|
|
201
|
+
return out.sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Where a given asset pushes: the bucket-backed route (top-level `assets/`) or
|
|
205
|
+
* the checkout route (a nested `…/assets/…` served from disk). */
|
|
206
|
+
function routeFor(rel: string): { url: string } {
|
|
207
|
+
const parts = rel.split('/');
|
|
208
|
+
if (parts[0] === 'assets') {
|
|
209
|
+
// Top-level content-addressed → the bucket `/assets/<key>` route.
|
|
210
|
+
return { url: `/assets/${parts.slice(1).join('/')}` };
|
|
211
|
+
}
|
|
212
|
+
// DS / brand asset served from the checkout → the checkout-file route, keyed
|
|
213
|
+
// by its FULL designRoot-relative path.
|
|
214
|
+
return { url: `/_asset-file/${rel.split('/').map(encodeURIComponent).join('/')}` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `Retry-After: <seconds>` → ms, clamped. Only the delta-seconds form is
|
|
218
|
+
* parsed; the HTTP-date form is not something our hub emits. */
|
|
219
|
+
function retryAfterMs(header: string | null): number {
|
|
220
|
+
const secs = Number(String(header ?? '').trim());
|
|
221
|
+
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_DELAY_MS;
|
|
222
|
+
return Math.min(secs * 1000, MAX_RETRY_DELAY_MS);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Why an upload was refused, in words — status PLUS a bounded snippet of the
|
|
227
|
+
* body. The hub says `{"error":"rate limit exceeded"}`; an edge that never
|
|
228
|
+
* reached the hub says HTML. Those are different bugs and the Sync panel should
|
|
229
|
+
* not make a person read logs to tell them apart.
|
|
230
|
+
*
|
|
231
|
+
* The body is hub-supplied ⇒ untrusted (DDR-054): control characters stripped,
|
|
232
|
+
* whitespace collapsed, hard length cap, and it only ever renders as text.
|
|
233
|
+
*/
|
|
234
|
+
async function failureReason(res: Response): Promise<string> {
|
|
235
|
+
let snippet = '';
|
|
236
|
+
try {
|
|
237
|
+
snippet = (await res.text())
|
|
238
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
|
|
239
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
240
|
+
.replace(/\s+/g, ' ')
|
|
241
|
+
.trim()
|
|
242
|
+
.slice(0, ERROR_SNIPPET_CHARS);
|
|
243
|
+
} catch {
|
|
244
|
+
/* a body we cannot read tells us nothing — the status still does */
|
|
245
|
+
}
|
|
246
|
+
return snippet ? `HTTP ${res.status} — ${snippet}` : `HTTP ${res.status}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* One upload, with the two retries that are worth having in-boot.
|
|
251
|
+
*
|
|
252
|
+
* 429 — the hub tells us when to come back (`Retry-After`), so come back then,
|
|
253
|
+
* ONCE. Before the 2026-08-11 fix the write lane sat in a 5/min per-IP bucket
|
|
254
|
+
* and the sweep ignored the header entirely, so a 182-asset project burned the
|
|
255
|
+
* window and moved ~5 files per boot. The hub half of the fix is the real
|
|
256
|
+
* one — this half is what keeps a not-yet-upgraded hub (the fleet rolls only on
|
|
257
|
+
* a release tag) finishing a sweep instead of grinding.
|
|
258
|
+
*
|
|
259
|
+
* 5xx — one immediate retry, because a transient edge/proxy hiccup on a 30 MB
|
|
260
|
+
* body should not need a whole new boot to get past.
|
|
261
|
+
*
|
|
262
|
+
* A second refusal is a real failure: report it and move on (the next-boot
|
|
263
|
+
* backstop is unchanged).
|
|
264
|
+
*/
|
|
265
|
+
async function putWithRetry(ctx: {
|
|
266
|
+
fetchImpl: typeof fetch;
|
|
267
|
+
url: string;
|
|
268
|
+
headers: Record<string, string>;
|
|
269
|
+
file: string;
|
|
270
|
+
sleep: (ms: number) => Promise<void>;
|
|
271
|
+
/** Mutable per-sweep pause budget, shared across every asset. */
|
|
272
|
+
backoff: { remainingMs: number };
|
|
273
|
+
timeoutFor: (bytes: number) => number;
|
|
274
|
+
}): Promise<Response> {
|
|
275
|
+
const send = (): Promise<Response> => {
|
|
276
|
+
const body = Bun.file(ctx.file);
|
|
277
|
+
return ctx.fetchImpl(ctx.url, {
|
|
278
|
+
method: 'PUT',
|
|
279
|
+
headers: {
|
|
280
|
+
...ctx.headers,
|
|
281
|
+
...UPLOAD_CONNECTION_HEADERS,
|
|
282
|
+
// Bun derives this from the file anyway (measured) — stated explicitly
|
|
283
|
+
// so a body length is never something a future body type has to guess.
|
|
284
|
+
'content-length': String(body.size),
|
|
285
|
+
},
|
|
286
|
+
body,
|
|
287
|
+
signal: AbortSignal.timeout(ctx.timeoutFor(body.size)),
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
const first = await send();
|
|
291
|
+
if (first.status === 429) {
|
|
292
|
+
const wait = retryAfterMs(first.headers?.get?.('retry-after') ?? null);
|
|
293
|
+
if (wait > ctx.backoff.remainingMs) return first;
|
|
294
|
+
ctx.backoff.remainingMs -= wait;
|
|
295
|
+
await ctx.sleep(wait);
|
|
296
|
+
return send();
|
|
297
|
+
}
|
|
298
|
+
if (first.status >= 500) return send();
|
|
299
|
+
return first;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
|
|
304
|
+
* asset per boot; upload only on a miss), sequential on purpose — assets run
|
|
305
|
+
* to videos, and saturating the link a fresh sync is also using would starve
|
|
306
|
+
* the handshakes this rides behind. Never throws; a failed upload is retried
|
|
307
|
+
* for free on the next boot.
|
|
308
|
+
*/
|
|
309
|
+
export async function pushAssets(opts: {
|
|
310
|
+
designRoot: string;
|
|
311
|
+
hubUrl: string;
|
|
312
|
+
/** Read at call time — silent renewal swaps the credential in place. */
|
|
313
|
+
token: () => string;
|
|
314
|
+
fetchImpl?: typeof fetch;
|
|
315
|
+
log?: Pick<Console, 'log' | 'warn'>;
|
|
316
|
+
/** feature-sync-progress-modal — incremental progress (throttled; failures
|
|
317
|
+
* and the final emit always fire). Never throws into the push loop. */
|
|
318
|
+
onProgress?: (progress: AssetPushProgress) => void;
|
|
319
|
+
/** Injectable clock for the throttle (tests). */
|
|
320
|
+
now?: () => number;
|
|
321
|
+
/** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
|
|
322
|
+
sleep?: (ms: number) => Promise<void>;
|
|
323
|
+
/** Injectable per-request time budget (tests — seconds, not minutes). */
|
|
324
|
+
timeoutFor?: (bytes: number) => number;
|
|
325
|
+
}): Promise<AssetPushResult> {
|
|
326
|
+
const { designRoot, hubUrl } = opts;
|
|
327
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
328
|
+
const log = opts.log ?? console;
|
|
329
|
+
const now = opts.now ?? Date.now;
|
|
330
|
+
const sleep =
|
|
331
|
+
opts.sleep ??
|
|
332
|
+
((ms: number) =>
|
|
333
|
+
new Promise<void>((res) => {
|
|
334
|
+
setTimeout(res, ms);
|
|
335
|
+
}));
|
|
336
|
+
const timeoutFor = opts.timeoutFor ?? putTimeoutMs;
|
|
337
|
+
// Shared across the whole sweep — see MAX_SWEEP_BACKOFF_MS.
|
|
338
|
+
const backoff = { remainingMs: MAX_SWEEP_BACKOFF_MS };
|
|
339
|
+
const base = hubUrl.replace(/\/+$/, '');
|
|
340
|
+
const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
|
|
341
|
+
|
|
342
|
+
const assets = listPushableAssets(designRoot);
|
|
343
|
+
// -Infinity seeds the throttle open, so the first emit always passes.
|
|
344
|
+
let lastEmit = -Infinity;
|
|
345
|
+
const emitProgress = (active: string | null, finished: boolean, force = false): void => {
|
|
346
|
+
if (!opts.onProgress) return;
|
|
347
|
+
const t = now();
|
|
348
|
+
if (!force && t - lastEmit < PROGRESS_INTERVAL_MS) return;
|
|
349
|
+
lastEmit = t;
|
|
350
|
+
try {
|
|
351
|
+
opts.onProgress({
|
|
352
|
+
total: assets.length,
|
|
353
|
+
done: out.pushed.length + out.skipped + out.failed.length,
|
|
354
|
+
pushed: out.pushed.length,
|
|
355
|
+
skipped: out.skipped,
|
|
356
|
+
failedCount: out.failed.length,
|
|
357
|
+
failures: out.failed.slice(0, MAX_LISTED_FAILURES),
|
|
358
|
+
active,
|
|
359
|
+
finished,
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
/* a broken listener must never break the push */
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
for (const rel of assets) {
|
|
367
|
+
emitProgress(rel, false);
|
|
368
|
+
const url = `${base}${routeFor(rel).url}`;
|
|
369
|
+
const headers = { authorization: `Bearer ${opts.token()}` };
|
|
370
|
+
try {
|
|
371
|
+
const head = await fetchImpl(url, {
|
|
372
|
+
method: 'HEAD',
|
|
373
|
+
headers,
|
|
374
|
+
signal: AbortSignal.timeout(HEAD_TIMEOUT_MS),
|
|
375
|
+
});
|
|
376
|
+
if (head.ok) {
|
|
377
|
+
out.skipped += 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// A hub that refuses the PROBE refuses the upload — pushing the body
|
|
381
|
+
// anyway just streams megabytes at a door that already said no. The
|
|
382
|
+
// cloud studio door answers exactly this for a route the deployed hub
|
|
383
|
+
// does not have yet, once per asset, for the whole DS asset set.
|
|
384
|
+
if (head.status === 401 || head.status === 403) {
|
|
385
|
+
out.failed.push({ key: rel, reason: await failureReason(head) });
|
|
386
|
+
emitProgress(rel, false, true);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const put = await putWithRetry({
|
|
390
|
+
fetchImpl,
|
|
391
|
+
url,
|
|
392
|
+
headers,
|
|
393
|
+
file: path.join(designRoot, rel),
|
|
394
|
+
sleep,
|
|
395
|
+
backoff,
|
|
396
|
+
timeoutFor,
|
|
397
|
+
});
|
|
398
|
+
if (put.ok) out.pushed.push(rel);
|
|
399
|
+
else {
|
|
400
|
+
out.failed.push({ key: rel, reason: await failureReason(put) });
|
|
401
|
+
emitProgress(rel, false, true);
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const e = err as Error;
|
|
405
|
+
out.failed.push({
|
|
406
|
+
key: rel,
|
|
407
|
+
// "TimeoutError: The operation timed out" tells a person nothing about
|
|
408
|
+
// which limit fired; name the budget instead.
|
|
409
|
+
reason: e.name === 'TimeoutError' ? 'timed out — the hub stopped answering' : e.message,
|
|
410
|
+
});
|
|
411
|
+
emitProgress(rel, false, true);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// No assets → no emits at all: a project without an assets/ dir should not
|
|
415
|
+
// grow an empty assets section in its Sync panel.
|
|
416
|
+
if (assets.length > 0) emitProgress(null, true, true);
|
|
417
|
+
|
|
418
|
+
if (out.pushed.length > 0) {
|
|
419
|
+
log.log?.(
|
|
420
|
+
`[sync/assets] pushed ${out.pushed.length} asset(s) to ${base} (${out.skipped} already there)`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (out.failed.length > 0) {
|
|
424
|
+
log.warn?.(
|
|
425
|
+
`[sync/assets] ${out.failed.length} asset(s) did not reach ${base} (retried next boot): ${out.failed
|
|
426
|
+
.slice(0, 3)
|
|
427
|
+
.map((f) => `${f.key} — ${f.reason}`)
|
|
428
|
+
.join('; ')}`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
@@ -28,6 +28,15 @@ export type SyncState = 'online' | 'connecting' | 'offline' | 'offline-long';
|
|
|
28
28
|
* refused auth for this documentName (scope / invalid token / rate limit). */
|
|
29
29
|
export type DocSyncState = 'pending' | 'connected' | 'auth-rejected';
|
|
30
30
|
|
|
31
|
+
/** feature-sync-progress-modal — one row of the per-document list the Sync
|
|
32
|
+
* panel renders. `reason` is OUR OWN classification vocabulary (the
|
|
33
|
+
* AuthFailureClass strings), never hub-supplied text. */
|
|
34
|
+
export interface SyncDocItem {
|
|
35
|
+
slug: string;
|
|
36
|
+
state: DocSyncState;
|
|
37
|
+
reason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
export interface SyncStatusSnapshot {
|
|
32
41
|
state: SyncState;
|
|
33
42
|
/** Local edits made since the hub went unreachable (replayed on reconnect). */
|
|
@@ -42,11 +51,31 @@ export interface SyncStatusSnapshot {
|
|
|
42
51
|
flash: 'synced' | null;
|
|
43
52
|
/** ms epoch this snapshot was produced. */
|
|
44
53
|
updatedAt: number;
|
|
54
|
+
/**
|
|
55
|
+
* ms epoch this link's monitor was created — i.e. when this runtime started
|
|
56
|
+
* trying. The one clock `connecting…` can honestly be measured against:
|
|
57
|
+
* `updatedAt` moves on every emit (an auth-rejection storm refreshes it
|
|
58
|
+
* forever), so "how long has nothing synced" needs a stamp that does NOT
|
|
59
|
+
* reset while nothing is actually working. Absent in pre-existing payloads.
|
|
60
|
+
*/
|
|
61
|
+
startedAt?: number;
|
|
45
62
|
/** DDR-102 — per-doc rollup (additive; absent in pre-DDR-102 payloads). */
|
|
46
63
|
docs?: { synced: number; pending: number; rejected: number };
|
|
47
64
|
/** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
|
|
48
65
|
* for the true count). Treat as text, never HTML. */
|
|
49
66
|
rejectedSlugs?: string[];
|
|
67
|
+
/**
|
|
68
|
+
* feature-sync-progress-modal — the per-document list behind `docs`, so the
|
|
69
|
+
* Sync panel can render rows without a second fetch. Bounded at
|
|
70
|
+
* MAX_SYNC_ITEMS with the INTERESTING states first (rejected, then pending,
|
|
71
|
+
* then connected): the truncated tail is then always the already-summarised
|
|
72
|
+
* happy case, and `itemsTruncated` says how many rows it holds. Slugs are
|
|
73
|
+
* local canvas identifiers; `reason` is our own classification vocabulary.
|
|
74
|
+
* Absent in pre-existing payloads.
|
|
75
|
+
*/
|
|
76
|
+
items?: SyncDocItem[];
|
|
77
|
+
/** Rows dropped by the MAX_SYNC_ITEMS cap (all `connected` by the sort). */
|
|
78
|
+
itemsTruncated?: number;
|
|
50
79
|
/**
|
|
51
80
|
* Canvases this run brought DOWN from the project — documents that existed
|
|
52
81
|
* only on the hub and are now real local files.
|
|
@@ -86,8 +115,10 @@ export interface ConnectionMonitor {
|
|
|
86
115
|
noteProviderStatus(providerId: string, status: ProviderStatus): void;
|
|
87
116
|
/** A local edit happened — counts toward queuedOps while not online. */
|
|
88
117
|
noteLocalEdit(): void;
|
|
89
|
-
/** DDR-102 — record a document's sync state (pending/connected/auth-rejected).
|
|
90
|
-
|
|
118
|
+
/** DDR-102 — record a document's sync state (pending/connected/auth-rejected).
|
|
119
|
+
* `reason` (feature-sync-progress-modal) is the classification for an
|
|
120
|
+
* auth-rejected doc — our own vocabulary, ignored for other states. */
|
|
121
|
+
noteDocState(slug: string, state: DocSyncState, reason?: string): void;
|
|
91
122
|
/** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
|
|
92
123
|
* applied): bumps `lastSyncAt` to now. */
|
|
93
124
|
noteSyncActivity(slug: string): void;
|
|
@@ -104,6 +135,21 @@ const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
|
|
|
104
135
|
const DEFAULT_FLASH_MS = 3_000;
|
|
105
136
|
/** Cap on rejectedSlugs in the snapshot (the rollup carries the true count). */
|
|
106
137
|
export const MAX_REJECTED_SLUGS = 20;
|
|
138
|
+
/**
|
|
139
|
+
* Cap on the per-document `items` list. Every emit synchronously writes
|
|
140
|
+
* `_sync.json` and fans out over WS, so the list must stay bounded no matter
|
|
141
|
+
* how many canvases a project grows — 200 rows ≈ a few KB, and the sort keeps
|
|
142
|
+
* everything a person must ACT on (rejected, pending) inside the cap.
|
|
143
|
+
*/
|
|
144
|
+
export const MAX_SYNC_ITEMS = 200;
|
|
145
|
+
|
|
146
|
+
/** Sort weight: the states a person must act on come first, so the cap only
|
|
147
|
+
* ever truncates the already-summarised happy tail. */
|
|
148
|
+
const ITEM_STATE_ORDER: Record<DocSyncState, number> = {
|
|
149
|
+
'auth-rejected': 0,
|
|
150
|
+
pending: 1,
|
|
151
|
+
connected: 2,
|
|
152
|
+
};
|
|
107
153
|
|
|
108
154
|
export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
|
|
109
155
|
const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
|
|
@@ -117,6 +163,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
117
163
|
const providerStatuses = new Map<string, ProviderStatus>();
|
|
118
164
|
// DDR-102 — per-doc states (pending/connected/auth-rejected).
|
|
119
165
|
const docStates = new Map<string, DocSyncState>();
|
|
166
|
+
// feature-sync-progress-modal — rejection classifications, keyed by slug.
|
|
167
|
+
// Held separately from docStates so the state machine above is untouched;
|
|
168
|
+
// dropped the moment a doc leaves auth-rejected (a re-probe that succeeds
|
|
169
|
+
// must not leave a stale reason on a connected row).
|
|
170
|
+
const docReasons = new Map<string, string>();
|
|
120
171
|
|
|
121
172
|
// NOT born connected. The monitor used to start `online`, so from the instant
|
|
122
173
|
// a link was created — before a socket, before a token was accepted, before a
|
|
@@ -130,6 +181,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
130
181
|
let lastSyncAt: number | null = null;
|
|
131
182
|
let offlineSince: number | null = null;
|
|
132
183
|
let flash: 'synced' | null = null;
|
|
184
|
+
/** When this monitor began trying — see `SyncStatusSnapshot.startedAt`. */
|
|
185
|
+
const startedAt = now();
|
|
133
186
|
|
|
134
187
|
let graceTimer: TimerHandle | null = null;
|
|
135
188
|
let escalateTimer: TimerHandle | null = null;
|
|
@@ -165,6 +218,19 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
165
218
|
if (rejectedSlugs.length < MAX_REJECTED_SLUGS) rejectedSlugs.push(slug);
|
|
166
219
|
} else docs.pending++;
|
|
167
220
|
}
|
|
221
|
+
// The per-row list, actionable states first so the cap only ever drops
|
|
222
|
+
// rows the aggregate counts already describe (see MAX_SYNC_ITEMS).
|
|
223
|
+
const allItems: SyncDocItem[] = [...docStates]
|
|
224
|
+
.map(([slug, st]) => {
|
|
225
|
+
const reason = docReasons.get(slug);
|
|
226
|
+
return reason ? { slug, state: st, reason } : { slug, state: st };
|
|
227
|
+
})
|
|
228
|
+
.sort(
|
|
229
|
+
(a, b) =>
|
|
230
|
+
ITEM_STATE_ORDER[a.state] - ITEM_STATE_ORDER[b.state] || a.slug.localeCompare(b.slug)
|
|
231
|
+
);
|
|
232
|
+
const items = allItems.slice(0, MAX_SYNC_ITEMS);
|
|
233
|
+
const itemsTruncated = allItems.length - items.length;
|
|
168
234
|
return {
|
|
169
235
|
state,
|
|
170
236
|
queuedOps,
|
|
@@ -172,8 +238,11 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
172
238
|
offlineSince,
|
|
173
239
|
flash,
|
|
174
240
|
updatedAt: now(),
|
|
241
|
+
startedAt,
|
|
175
242
|
docs,
|
|
176
243
|
rejectedSlugs,
|
|
244
|
+
items,
|
|
245
|
+
...(itemsTruncated > 0 ? { itemsTruncated } : {}),
|
|
177
246
|
...(pulled ? { pulled } : {}),
|
|
178
247
|
};
|
|
179
248
|
}
|
|
@@ -336,10 +405,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
336
405
|
emit();
|
|
337
406
|
},
|
|
338
407
|
|
|
339
|
-
noteDocState(slug, docState) {
|
|
408
|
+
noteDocState(slug, docState, reason) {
|
|
340
409
|
if (stopped) return;
|
|
410
|
+
// STATE-ONLY dedupe. This briefly compared `reason` too, which handed a
|
|
411
|
+
// hostile hub an amplifier: the rejection text is hub-controlled, so
|
|
412
|
+
// alternating it on an open socket forced a full emit — items rebuild +
|
|
413
|
+
// synchronous `_sync.json` write + WS fanout — per frame (security
|
|
414
|
+
// review 2026-08-11, sync-progress-modal defender). The reason is
|
|
415
|
+
// LATCHED for the life of a rejection episode instead: the first
|
|
416
|
+
// classification wins, leaving the rejected state clears the latch, and
|
|
417
|
+
// a NEW episode records a fresh reason. Repeat frames stay a no-op.
|
|
341
418
|
if (docStates.get(slug) === docState) return;
|
|
342
419
|
docStates.set(slug, docState);
|
|
420
|
+
if (docState !== 'auth-rejected') docReasons.delete(slug);
|
|
421
|
+
else if (reason !== undefined) docReasons.set(slug, reason);
|
|
343
422
|
emit();
|
|
344
423
|
},
|
|
345
424
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
|
|
23
23
|
// is the loopback caller gate + no reflection + no token on the probe.
|
|
24
24
|
|
|
25
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { dirname } from 'node:path';
|
|
27
27
|
|
|
28
28
|
import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
|
|
@@ -35,7 +35,7 @@ export interface HubLinkResult {
|
|
|
35
35
|
const HUB_PROBE_TIMEOUT_MS = 4000;
|
|
36
36
|
|
|
37
37
|
interface HubsFile {
|
|
38
|
-
hubs: Record<string, { token: string; linkedAt: number }>;
|
|
38
|
+
hubs: Record<string, { token: string; linkedAt: number; role?: string; expiresAt?: number }>;
|
|
39
39
|
trusted?: string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -108,8 +108,20 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Upsert the token (+ the vouched role + expiry) under `normUrl` + record
|
|
113
|
+
* per-machine trust; mode 0600.
|
|
114
|
+
*
|
|
115
|
+
* The upsert REPLACES the record, so a caller that knows the role/expiry must
|
|
116
|
+
* pass them again or they are dropped — the silent-renewal path reads the old
|
|
117
|
+
* record first and carries the role forward for exactly this reason.
|
|
118
|
+
*/
|
|
119
|
+
export function saveHubCredential(
|
|
120
|
+
normUrl: string,
|
|
121
|
+
token: string,
|
|
122
|
+
role?: string,
|
|
123
|
+
expiresAt?: number
|
|
124
|
+
): void {
|
|
113
125
|
const path = hubsConfigPath();
|
|
114
126
|
const dir = dirname(path);
|
|
115
127
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -123,15 +135,59 @@ export function saveHubCredential(normUrl: string, token: string, role?: string)
|
|
|
123
135
|
/* malformed → start fresh rather than throw */
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
|
-
cfg.hubs[normUrl] = {
|
|
138
|
+
cfg.hubs[normUrl] = {
|
|
139
|
+
token,
|
|
140
|
+
linkedAt: Date.now(),
|
|
141
|
+
...(role ? { role } : {}),
|
|
142
|
+
...(typeof expiresAt === 'number' && Number.isFinite(expiresAt) ? { expiresAt } : {}),
|
|
143
|
+
};
|
|
127
144
|
if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
|
|
128
145
|
if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
|
|
129
|
-
|
|
146
|
+
// F3 (2026-08-10 review) — atomic temp-then-rename. This write used to run
|
|
147
|
+
// only when a human pressed Connect; silent renewal now fires it on a timer,
|
|
148
|
+
// several times a minute under a rejection burst. A truncating in-place
|
|
149
|
+
// writeFileSync that is interrupted (crash / kill / full disk) leaves a
|
|
150
|
+
// half-written file — and since it holds EVERY hub credential on the machine,
|
|
151
|
+
// that drops them all. rename(2) is atomic on POSIX: a reader sees the whole
|
|
152
|
+
// old file or the whole new one, never a torn one.
|
|
153
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
154
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
155
|
+
try {
|
|
156
|
+
chmodSync(tmp, 0o600);
|
|
157
|
+
} catch {
|
|
158
|
+
/* windows / read-only fs — best effort */
|
|
159
|
+
}
|
|
160
|
+
renameSync(tmp, path);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Forget the credential + per-machine trust for `normUrl` — the unlink half of
|
|
165
|
+
* `saveHubCredential`. Idempotent (a missing entry is simply done), and atomic
|
|
166
|
+
* for the same reason the save is: the file holds EVERY hub credential on the
|
|
167
|
+
* machine, so a torn write drops them all.
|
|
168
|
+
*/
|
|
169
|
+
export function deleteHubCredential(normUrl: string): void {
|
|
170
|
+
const path = hubsConfigPath();
|
|
171
|
+
if (!existsSync(path)) return;
|
|
172
|
+
let cfg: HubsFile;
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
175
|
+
if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) return;
|
|
176
|
+
cfg = parsed as HubsFile;
|
|
177
|
+
} catch {
|
|
178
|
+
return; // malformed → nothing recoverable to forget
|
|
179
|
+
}
|
|
180
|
+
if (!(normUrl in cfg.hubs) && !cfg.trusted?.includes(normUrl)) return;
|
|
181
|
+
delete cfg.hubs[normUrl];
|
|
182
|
+
if (Array.isArray(cfg.trusted)) cfg.trusted = cfg.trusted.filter((u) => u !== normUrl);
|
|
183
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
184
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
130
185
|
try {
|
|
131
|
-
chmodSync(
|
|
186
|
+
chmodSync(tmp, 0o600);
|
|
132
187
|
} catch {
|
|
133
188
|
/* windows / read-only fs — best effort */
|
|
134
189
|
}
|
|
190
|
+
renameSync(tmp, path);
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
export const __testing = { probeHealth };
|