@1agh/maude 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -41,8 +41,17 @@ const CONTENT_TYPE: Record<string, string> = {
41
41
  gif: 'image/gif',
42
42
  };
43
43
 
44
- /** Hard cap so a runaway/huge comp can't spawn an unbounded screenshot loop. */
45
- const MAX_FRAMES = 900; // 30 s @ 30 fps
44
+ /** Default frame ceiling — 2 min @ 30 fps. Raisable per-export via
45
+ * `options.maxFrames` (an editor invites longer cuts than the original 30 s
46
+ * cap; the flatmap RCA's real 90 s reel already violated it). A request that
47
+ * exceeds the resolved ceiling is REFUSED with remediation, never silently
48
+ * truncated. */
49
+ const DEFAULT_MAX_FRAMES = 3600;
50
+ /** Absolute backstop for `options.maxFrames` itself (10 min @ 30 fps). */
51
+ const MAX_FRAMES_CEILING = 18000;
52
+ /** Above this, the frame-step fallback path gets slow (~2.5 s/frame) and 1080p
53
+ * captures have died from memory pressure — warn loudly, recommend ≤720p. */
54
+ const HEAVY_FRAMES_WARNING = 900; // 30 s @ 30 fps
46
55
 
47
56
  async function runVideo(
48
57
  format: VideoFormat,
@@ -112,8 +121,19 @@ async function runVideo(
112
121
  const fps = clampInt(options.fps, 1, 60);
113
122
  if (fps) args.push('--fps', String(fps));
114
123
 
115
- const frames = resolveFrames(options, fps);
124
+ const maxFrames = resolveMaxFrames(options);
125
+ const frames = resolveFrames(options, fps, maxFrames);
116
126
  if (frames) args.push('--frames', String(frames));
127
+ // The shim clamps its comp-meta fallback to this same ceiling (and reports
128
+ // loudly when it has to), so raising the cap here raises it end-to-end.
129
+ args.push('--max-frames', String(maxFrames));
130
+ if ((frames ?? 0) > HEAVY_FRAMES_WARNING) {
131
+ console.error(
132
+ `⚠ long export: ${frames} frames. If the fast renderer falls back to ` +
133
+ `frame-step capture this runs ~2.5 s/frame; scale ≤2× (≈720–1080p) is ` +
134
+ 'recommended for memory headroom.'
135
+ );
136
+ }
117
137
 
118
138
  if (format === 'gif') {
119
139
  const colors = clampInt(options.gifColors, 2, 256);
@@ -175,16 +195,35 @@ function clampInt(raw: unknown, lo: number, hi: number): number | undefined {
175
195
  return Math.max(lo, Math.min(hi, n));
176
196
  }
177
197
 
178
- /** Resolve the frame count from options (frames | durationMs×fps), capped. */
179
- function resolveFrames(options: ExportOptions, fps: number | undefined): number | undefined {
180
- const explicit = clampInt(options.frames, 1, MAX_FRAMES);
181
- if (explicit) return explicit;
198
+ /** The frame ceiling for this export: `options.maxFrames` (clamped to the
199
+ * absolute backstop) or the raised default. */
200
+ function resolveMaxFrames(options: ExportOptions): number {
201
+ return clampInt(options.maxFrames, 1, MAX_FRAMES_CEILING) ?? DEFAULT_MAX_FRAMES;
202
+ }
203
+
204
+ /** Resolve the frame count from options (frames | durationMs×fps). A request
205
+ * over the ceiling is REFUSED with remediation — never silently truncated. */
206
+ function resolveFrames(
207
+ options: ExportOptions,
208
+ fps: number | undefined,
209
+ maxFrames: number
210
+ ): number | undefined {
211
+ const explicit = clampInt(options.frames, 1, MAX_FRAMES_CEILING);
182
212
  const durationMs = Number(options.durationMs);
183
- if (Number.isFinite(durationMs) && durationMs > 0 && fps) {
184
- return Math.min(MAX_FRAMES, Math.max(1, Math.round((durationMs / 1000) * fps)));
213
+ const fromDuration =
214
+ Number.isFinite(durationMs) && durationMs > 0 && fps
215
+ ? Math.max(1, Math.round((durationMs / 1000) * fps))
216
+ : undefined;
217
+ const requested = explicit ?? fromDuration;
218
+ if (requested != null && requested > maxFrames) {
219
+ throw new Error(
220
+ `export of ${requested} frames exceeds the ${maxFrames}-frame cap — ` +
221
+ 'pass options.maxFrames to raise it (long exports can take minutes and real memory)'
222
+ );
185
223
  }
224
+ if (requested != null) return requested;
186
225
  // No hint → the shim falls back to the in-page comp meta (durationInFrames),
187
- // still clamped there. Pass the ceiling so an unbounded comp can't run away.
226
+ // clamped to the --max-frames ceiling we pass it (loudly, never silently).
188
227
  return undefined;
189
228
  }
190
229
 
@@ -24,6 +24,7 @@ import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
24
24
  import { buildCanvasModule } from './canvas-build.ts';
25
25
  import { canvasLibPath } from './canvas-lib-resolver.ts';
26
26
  import { TranspileError } from './canvas-pipeline.ts';
27
+ import { createCloudEndpoints } from './cloud/endpoints.ts';
27
28
  import type { AiActivity } from './collab/ai-activity.ts';
28
29
  import type { Context } from './context.ts';
29
30
  import { reloadConfig } from './context.ts';
@@ -78,7 +79,6 @@ import {
78
79
  } from './generation/whisper-models.ts';
79
80
  import { createGitEndpoints } from './git/endpoints.ts';
80
81
  import { gitShowFile } from './git/service.ts';
81
- import { createCloudEndpoints } from './cloud/endpoints.ts';
82
82
  import { createGitHubEndpoints } from './github/endpoints.ts';
83
83
  import type { Inspect } from './inspect.ts';
84
84
  import { canvasSlug, writeLocator } from './locator.ts';
@@ -87,6 +87,7 @@ import { createPhotoStore, PHOTO_EDIT_MAX_BYTES } from './photo-store.ts';
87
87
  import { probeReadiness } from './readiness.ts';
88
88
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
89
89
  import { linkHub } from './sync/hub-link.ts';
90
+ import { isHubReadOnly } from './sync/hubs-config.ts';
90
91
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
91
92
  import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
92
93
  import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
@@ -1163,7 +1164,17 @@ export function createHttp(
1163
1164
  }
1164
1165
  },
1165
1166
 
1166
- '/_config': () => Response.json({ ...ctx.cfg, canvasOrigin: ctx.canvasOrigin }),
1167
+ // `readOnly` is COMPUTED, not stored in cfg: it comes from the role the
1168
+ // workspace vouched for at sign-in (Cloud Phase 25 C2), so the client
1169
+ // knows before it draws anything. It decides what the UI OFFERS and is
1170
+ // never what stops a write — the cell enforces that (Phase 25 C1),
1171
+ // whatever a patched client believes.
1172
+ '/_config': () =>
1173
+ Response.json({
1174
+ ...ctx.cfg,
1175
+ canvasOrigin: ctx.canvasOrigin,
1176
+ readOnly: ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false,
1177
+ }),
1167
1178
 
1168
1179
  // What's New feed (DDR-A) — read-only product-update list surfaced in the
1169
1180
  // Maude UI chrome. Main-origin only by omission from the canvas-origin
@@ -1241,7 +1252,10 @@ export function createHttp(
1241
1252
  const m = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(shot);
1242
1253
  if (!m || m[2].length > 7_000_000) continue; // ~5 MB decoded cap
1243
1254
  n += 1;
1244
- await Bun.write(join(dir, `screenshot-${n}.${m[1] === 'png' ? 'png' : 'jpg'}`), Buffer.from(m[2], 'base64'));
1255
+ await Bun.write(
1256
+ join(dir, `screenshot-${n}.${m[1] === 'png' ? 'png' : 'jpg'}`),
1257
+ Buffer.from(m[2], 'base64')
1258
+ );
1245
1259
  }
1246
1260
  return Response.json(
1247
1261
  { dir: relative(ctx.paths.repoRoot, dir), screenshots: n },
@@ -1810,6 +1824,12 @@ export function createHttp(
1810
1824
  // DELETE /_api/github/identity here; see DDR-114.)
1811
1825
  '/_api/cloud/status': async (req: Request) => {
1812
1826
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1827
+ // isLoopbackHost is a DNS-rebind guard, not a CSRF guard: a cross-site
1828
+ // page can issue a no-cors GET at 127.0.0.1:<port>. These two GETs read
1829
+ // the cloud credential's identity and (for projects) make a Bearer call
1830
+ // whose 401 DELETES the stored credential — a confused deputy worth
1831
+ // closing (validate 2026-07-30, defender F6).
1832
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1813
1833
  if (!isLoopbackHost(req.headers.get('host')))
1814
1834
  return new Response('local request required', { status: 403 });
1815
1835
  return gitJson(cloudApi.status());
@@ -1841,6 +1861,7 @@ export function createHttp(
1841
1861
  },
1842
1862
  '/_api/cloud/projects': async (req: Request) => {
1843
1863
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1864
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1844
1865
  if (!isLoopbackHost(req.headers.get('host')))
1845
1866
  return new Response('local request required', { status: 403 });
1846
1867
  return gitJson(await cloudApi.projects());
@@ -1863,8 +1884,13 @@ export function createHttp(
1863
1884
  return new Response('cross-origin write rejected', { status: 403 });
1864
1885
  if (!isLoopbackHost(req.headers.get('host')))
1865
1886
  return new Response('local request required', { status: 403 });
1866
- const body = (await req.json().catch(() => ({}))) as { code?: string };
1867
- return gitJson(await cloudApi.attachCode(String(body.code ?? '')));
1887
+ const body = (await req.json().catch(() => ({}))) as { code?: string; project?: string };
1888
+ return gitJson(
1889
+ await cloudApi.attachCode(
1890
+ String(body.code ?? ''),
1891
+ typeof body.project === 'string' ? body.project : undefined
1892
+ )
1893
+ );
1868
1894
  },
1869
1895
 
1870
1896
  '/_api/github/identity': async (req: Request) => {
@@ -2186,6 +2212,53 @@ export function createHttp(
2186
2212
  );
2187
2213
  },
2188
2214
 
2215
+ '/_api/clip-edit': async (req: Request) => {
2216
+ // feature-enhanced-video-editing (Phase 2) — parametric clip verbs, one
2217
+ // dispatch route: POST { canvas, artboardId?, stableId, contentHash?,
2218
+ // verb: speed|trim-in|audio|detach-audio|framing|grade|transition, …verb
2219
+ // params }. Same MAIN-ORIGIN-ONLY trust boundary as the other source
2220
+ // writes (absent from CANVAS_SAFE_API + startCanvasServer routes,
2221
+ // DDR-054) — this is also the agent door for /design:edit's timeline
2222
+ // vocabulary.
2223
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2224
+ if (!sameOriginWrite(req))
2225
+ return new Response('cross-origin write rejected', { status: 403 });
2226
+ if (!isLoopbackHost(req.headers.get('host')))
2227
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2228
+ const body = await readJson<{
2229
+ canvas?: unknown;
2230
+ artboardId?: unknown;
2231
+ stableId?: unknown;
2232
+ contentHash?: unknown;
2233
+ verb?: unknown;
2234
+ rate?: unknown;
2235
+ deltaFrames?: unknown;
2236
+ muted?: unknown;
2237
+ volume?: unknown;
2238
+ framing?: unknown;
2239
+ grade?: unknown;
2240
+ presentation?: unknown;
2241
+ durationInFrames?: unknown;
2242
+ atFrame?: unknown;
2243
+ src?: unknown;
2244
+ mediaKind?: unknown;
2245
+ text?: unknown;
2246
+ toIndex?: unknown;
2247
+ }>(req, 16 * 1024);
2248
+ if (!body) return new Response('body required', { status: 400 });
2249
+ const result = await api.clipEditOp(body);
2250
+ if (!result.ok) {
2251
+ return Response.json(
2252
+ { ok: false, error: result.error },
2253
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
2254
+ );
2255
+ }
2256
+ return Response.json(
2257
+ { ok: true, seq: result.seq, ...result.extra },
2258
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2259
+ );
2260
+ },
2261
+
2189
2262
  '/_api/remove-sequence': async (req: Request) => {
2190
2263
  // DDR-150 P3 — remove a clip. POST { canvas, stableId, artboardId?,
2191
2264
  // contentHash? } → removeClip (fingerprint + semantic gate; refuses the
@@ -2234,7 +2307,10 @@ export function createHttp(
2234
2307
  durationInFrames?: unknown;
2235
2308
  mediaTag?: unknown;
2236
2309
  src?: unknown;
2237
- }>(req, 8 * 1024);
2310
+ lane?: unknown;
2311
+ index?: unknown;
2312
+ placeholder?: unknown;
2313
+ }>(req, 16 * 1024);
2238
2314
  if (!body) return new Response('body required', { status: 400 });
2239
2315
  const result = await api.insertSequenceOp(body);
2240
2316
  if (!result.ok) {
@@ -2269,6 +2345,7 @@ export function createHttp(
2269
2345
  refStableId?: unknown;
2270
2346
  refContentHash?: unknown;
2271
2347
  position?: unknown;
2348
+ mode?: unknown;
2272
2349
  }>(req, 8 * 1024);
2273
2350
  if (!body) return new Response('body required', { status: 400 });
2274
2351
  const result = await api.reorderSequenceOp(body);
@@ -3750,6 +3827,36 @@ export function createHttp(
3750
3827
  );
3751
3828
  },
3752
3829
 
3830
+ '/_api/timeline-media': async (req: Request) => {
3831
+ // Task 7 (enhanced-video-editing) — filmstrip/waveform cache. Runtime
3832
+ // state under `_canvas-state/timeline-media/` (DDR-115). MAIN-ORIGIN only
3833
+ // (shell UI cache — not in CANVAS_SAFE_API / startCanvasServer routes).
3834
+ // DNS-rebinding guard on BOTH methods (security review 2026-07-30): under
3835
+ // rebinding sameOriginWrite passes (page origin == req origin == attacker
3836
+ // host), so a remote page could otherwise READ the cache (filmstrip JPEG
3837
+ // dataURLs of local footage → disclosure) or WRITE arbitrary JSON later
3838
+ // rendered as <img src> in the trusted shell. The loopback-host check is
3839
+ // the same backstop every sibling privileged route carries.
3840
+ if (!isLoopbackHost(req.headers.get('host')))
3841
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
3842
+ const url = new URL(req.url);
3843
+ const key = url.searchParams.get('key') ?? '';
3844
+ if (req.method === 'GET') {
3845
+ const data = await api.timelineMediaLoad(key);
3846
+ if (!data) return Response.json({ ok: false }, { status: 404 });
3847
+ return Response.json({ ok: true, data }, { headers: { 'Cache-Control': 'no-store' } });
3848
+ }
3849
+ if (req.method === 'PUT' || req.method === 'POST') {
3850
+ if (!sameOriginWrite(req))
3851
+ return new Response('cross-origin write rejected', { status: 403 });
3852
+ const body = await readJson<Record<string, unknown>>(req, 8 * 1024 * 1024);
3853
+ if (!body) return new Response('body required', { status: 400 });
3854
+ const ok = await api.timelineMediaSave(key, body);
3855
+ return Response.json({ ok }, { status: ok ? 200 : 400 });
3856
+ }
3857
+ return new Response('Method not allowed', { status: 405 });
3858
+ },
3859
+
3753
3860
  '/_canvas-state': async (req: Request) => {
3754
3861
  const url = new URL(req.url);
3755
3862
  if (req.method === 'GET') {
@@ -0,0 +1,335 @@
1
+ // ripple.ts — pure ripple engine (feature-enhanced-video-editing, Phase 0).
2
+ //
3
+ // When a clip's length changes (trim, speed, split, insert, remove), everything
4
+ // AFTER the edit point must shift so the cut stays gapless — the "magnetic"
5
+ // contract. Series clips (`TransitionSeries.Sequence` / `Series.Sequence`)
6
+ // need no `from` shift (Remotion computes their offsets), but the comp TOTAL
7
+ // still moves; standalone `<Sequence from={…}>` clips shift their `from`.
8
+ //
9
+ // The engine is PURE (source in → source out) and deliberately conservative:
10
+ // • literal ints rewrite in place (`from={90}` → `from={102}`);
11
+ // • additive const-arithmetic rewrites by merging the trailing integer term
12
+ // (`from={A + B - 20}` +12 → `from={A + B - 8}`) or appending one
13
+ // (`from={A}` +12 → `from={A + 12}`) — the symbolic form survives;
14
+ // • an expression it can't RESOLVE (function call, ternary, unknown ident)
15
+ // is refused loudly with a structured RippleError naming the clip — the UI
16
+ // renders that as guidance, never a silent mis-shift.
17
+ //
18
+ // Double-shift guard: the comp TOTAL is bumped FIRST (const-preferring, so a
19
+ // shared `const TOTAL = A + B` updates every user consistently). Each
20
+ // downstream `from` expression is then evaluated under the ORIGINAL and the
21
+ // POST-TOTAL const environments — an expression that already moved by the
22
+ // delta (e.g. `from={TOTAL - 60}` on an end card) is SKIPPED, one that didn't
23
+ // move gets the textual shift, and one that moved by anything else (partial
24
+ // coupling) is refused rather than guessed at.
25
+
26
+ import MagicString from 'magic-string';
27
+ import { parseSync } from 'oxc-parser';
28
+
29
+ import { assertCompSemantics, CanvasEditError, enumerateClips } from './canvas-edit.ts';
30
+
31
+ /** Structured refusal — a subclass so the api layer's `instanceof
32
+ * CanvasEditError → 422` mapping (and its error message plumbing) applies. */
33
+ export class RippleError extends CanvasEditError {
34
+ /** The unrewritable expression, when that's what was refused. */
35
+ readonly expr: string | null;
36
+ constructor(message: string, info: { canvas: string; id: string; expr?: string | null }) {
37
+ super(message, info);
38
+ this.name = 'RippleError';
39
+ this.expr = info.expr ?? null;
40
+ }
41
+ }
42
+
43
+ export interface RippleResult {
44
+ source: string;
45
+ /** stableIds of clips whose `from` was textually shifted. */
46
+ shifted: string[];
47
+ /** True when the comp duration (attr or its const) was rewritten. */
48
+ totalEdited: boolean;
49
+ }
50
+
51
+ export interface RippleOptions {
52
+ /** Shift downstream standalone `from` attrs (default true). */
53
+ shiftFroms?: boolean;
54
+ /** Bump the comp's durationInFrames / its const (default true). */
55
+ bumpTotal?: boolean;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Const resolution (mirrors timeline-parse.js — literal pass, then up to 3
60
+ // expression passes so `const TOTAL = A + B - XF` resolves).
61
+
62
+ const IDENT_RE = /^[A-Za-z_$][\w$]*$/;
63
+ const SAFE_EXPR_RE = /^[A-Za-z0-9_$+\-*/()\s.]+$/;
64
+
65
+ export function collectConsts(source: string): Record<string, number> {
66
+ const consts: Record<string, number> = {};
67
+ for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(-?\d+)\s*;/g)) {
68
+ consts[m[1] as string] = Number(m[2]);
69
+ }
70
+ for (let pass = 0; pass < 3; pass += 1) {
71
+ for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+);/g)) {
72
+ const name = m[1] as string;
73
+ if (Object.hasOwn(consts, name)) continue;
74
+ const v = resolveNum(m[2] as string, consts);
75
+ if (v != null) consts[name] = v;
76
+ }
77
+ }
78
+ return consts;
79
+ }
80
+
81
+ /** Resolve an expression to an integer: a literal, a known const, or simple
82
+ * arithmetic of them. `null` when not resolvable. */
83
+ export function resolveNum(raw: string, consts: Record<string, number>): number | null {
84
+ const t = String(raw ?? '').trim();
85
+ if (!t) return null;
86
+ if (/^-?\d+$/.test(t)) return Number(t);
87
+ if (IDENT_RE.test(t)) return Object.hasOwn(consts, t) ? (consts[t] as number) : null;
88
+ if (!SAFE_EXPR_RE.test(t)) return null;
89
+ const substituted = t.replace(/[A-Za-z_$][\w$]*/g, (id) =>
90
+ Object.hasOwn(consts, id) ? String(consts[id]) : id
91
+ );
92
+ if (!/^[-+*/()\d\s.]+$/.test(substituted) || !/\d/.test(substituted)) return null;
93
+ try {
94
+ // eslint-disable-next-line no-new-func
95
+ const v = Function(`"use strict";return (${substituted});`)() as unknown;
96
+ return typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Shift an additive-friendly expression by `delta`, preserving its symbolic
104
+ * form: a literal rewrites in place; a trailing integer term merges
105
+ * (`A + B - 20` +12 → `A + B - 8`); anything else valid gets ` + delta`
106
+ * appended. Returns null for an expression outside the safe charset — the
107
+ * caller refuses those (resolvability is checked separately).
108
+ */
109
+ export function shiftExpression(expr: string, delta: number): string | null {
110
+ const t = String(expr ?? '').trim();
111
+ if (!t || !SAFE_EXPR_RE.test(t)) return null;
112
+ if (delta === 0) return t;
113
+ if (/^-?\d+$/.test(t)) return String(Number(t) + delta);
114
+ const trailing = t.match(/^(.*?)([+-])\s*(\d+)\s*$/);
115
+ if (trailing) {
116
+ const base = (trailing[1] as string).trim();
117
+ // Only merge when the trailing int is a TERM of a top-level sum — a base
118
+ // ending in an operator or '(' means the int is an operand of * / ( … ),
119
+ // where merging would change the math. Fall through to append instead.
120
+ if (base && !/[+\-*/(]$/.test(base)) {
121
+ const cur = (trailing[2] === '-' ? -1 : 1) * Number(trailing[3]);
122
+ const next = cur + delta;
123
+ if (next === 0) return base;
124
+ return `${base} ${next < 0 ? '-' : '+'} ${Math.abs(next)}`;
125
+ }
126
+ }
127
+ return delta < 0 ? `${t} - ${-delta}` : `${t} + ${delta}`;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Attr helpers — regex over ONE opening tag's text (the retimeAttr house style).
132
+
133
+ export interface AttrSpan {
134
+ /** Absolute span of the inner expression (between the braces). */
135
+ start: number;
136
+ end: number;
137
+ inner: string;
138
+ }
139
+
140
+ /** Locate `name={…}` inside the opening tag that starts at `tagStart`. */
141
+ export function attrExprSpan(source: string, tagStart: number, name: string): AttrSpan | null {
142
+ const gt = source.indexOf('>', tagStart);
143
+ const tag = source.slice(tagStart, gt < 0 ? source.length : gt + 1);
144
+ const m = tag.match(new RegExp(`\\b${name}=\\{\\s*([^}]*?)\\s*\\}`));
145
+ if (!m || m.index == null) return null;
146
+ const braceRel = m[0].indexOf('{');
147
+ const innerRel = m[0].indexOf(m[1] as string, braceRel);
148
+ // Empty inner (`from={}` is invalid JSX anyway) — treat as absent.
149
+ if ((m[1] as string) === '') return null;
150
+ const start = tagStart + m.index + innerRel;
151
+ return { start, end: start + (m[1] as string).length, inner: m[1] as string };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // TOTAL rewrite (const-preferring, mirrors retimeAttr's preference order).
156
+
157
+ function findVideoCompTagStart(source: string, compName: string): number | null {
158
+ const re = /<VideoComp\b[^>]*>/g;
159
+ let m: RegExpExecArray | null = re.exec(source);
160
+ while (m) {
161
+ if (new RegExp(`component=\\{\\s*${compName}\\s*\\}`).test(m[0])) return m.index;
162
+ m = re.exec(source);
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** Plan the TOTAL bump on a scratch copy; returns the edited source or null
168
+ * when there is nothing to bump (no comp usage / no durationInFrames attr). */
169
+ function bumpTotal(
170
+ canvasAbsPath: string,
171
+ source: string,
172
+ compName: string,
173
+ delta: number
174
+ ): string | null {
175
+ const tagStart = findVideoCompTagStart(source, compName);
176
+ if (tagStart == null) return null;
177
+ const attr = attrExprSpan(source, tagStart, 'durationInFrames');
178
+ if (!attr) return null;
179
+ const s = new MagicString(source);
180
+ const inner = attr.inner.trim();
181
+ if (IDENT_RE.test(inner)) {
182
+ // durationInFrames={TOTAL} — rewrite the const so every other user of it
183
+ // (end-card `from={TOTAL - 60}`, interpolate windows) follows consistently.
184
+ const cm = source.match(new RegExp(`\\bconst\\s+${inner}\\s*=\\s*([^;\\n]+);`));
185
+ if (!cm || cm.index == null) {
186
+ throw new RippleError(
187
+ `comp duration is {${inner}} but no \`const ${inner} = …\` was found to rewrite`,
188
+ { canvas: canvasAbsPath, id: compName, expr: inner }
189
+ );
190
+ }
191
+ const init = (cm[1] as string).trim();
192
+ const initStart = cm.index + (cm[0] as string).indexOf(cm[1] as string);
193
+ const consts = collectConsts(source);
194
+ if (resolveNum(init, consts) == null) {
195
+ throw new RippleError(
196
+ `cannot ripple the comp duration: \`const ${inner} = ${init}\` is not simple arithmetic — adjust it by hand`,
197
+ { canvas: canvasAbsPath, id: compName, expr: init }
198
+ );
199
+ }
200
+ const next = shiftExpression(init, delta);
201
+ if (next == null) {
202
+ throw new RippleError(`cannot ripple the comp duration expression \`${init}\``, {
203
+ canvas: canvasAbsPath,
204
+ id: compName,
205
+ expr: init,
206
+ });
207
+ }
208
+ s.overwrite(initStart, initStart + (cm[1] as string).length, next);
209
+ return s.toString();
210
+ }
211
+ const consts = collectConsts(source);
212
+ if (resolveNum(inner, consts) == null) {
213
+ throw new RippleError(
214
+ `cannot ripple the comp duration: \`durationInFrames={${inner}}\` is not simple arithmetic`,
215
+ { canvas: canvasAbsPath, id: compName, expr: inner }
216
+ );
217
+ }
218
+ const next = shiftExpression(inner, delta);
219
+ if (next == null) {
220
+ throw new RippleError(`cannot ripple the comp duration expression \`${inner}\``, {
221
+ canvas: canvasAbsPath,
222
+ id: compName,
223
+ expr: inner,
224
+ });
225
+ }
226
+ s.overwrite(attr.start, attr.end, next);
227
+ return s.toString();
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // The engine.
232
+
233
+ /**
234
+ * Ripple everything after `afterStableId` by `deltaFrames` (positive = the
235
+ * clip grew / one was inserted; negative = it shrank / was removed). Pure —
236
+ * returns the new source; the caller owns disk writes and locking. Throws
237
+ * `RippleError` (a `CanvasEditError`) with a user-renderable message when an
238
+ * expression can't be rewritten safely.
239
+ */
240
+ export function applyRippleAfterClip(
241
+ canvasAbsPath: string,
242
+ source: string,
243
+ artboardId: string | undefined,
244
+ afterStableId: string,
245
+ deltaFrames: number,
246
+ options: RippleOptions = {}
247
+ ): RippleResult {
248
+ const shiftFroms = options.shiftFroms !== false;
249
+ const doTotal = options.bumpTotal !== false;
250
+ const delta = Math.round(deltaFrames);
251
+ if (delta === 0) return { source, shifted: [], totalEdited: false };
252
+
253
+ const before = enumerateClips(canvasAbsPath, source, artboardId);
254
+ const targetIdx = before.clips.findIndex((c) => c.stableId === afterStableId);
255
+ if (targetIdx < 0) {
256
+ throw new CanvasEditError(`clip "${afterStableId}" not found`, {
257
+ canvas: canvasAbsPath,
258
+ id: afterStableId,
259
+ });
260
+ }
261
+ const compName = before.compName;
262
+
263
+ const consts0 = collectConsts(source);
264
+
265
+ // Stage 1 — TOTAL (const-preferring, so shared users follow consistently).
266
+ let mid = source;
267
+ let totalEdited = false;
268
+ if (doTotal && compName) {
269
+ const bumped = bumpTotal(canvasAbsPath, mid, compName, delta);
270
+ if (bumped != null) {
271
+ mid = bumped;
272
+ totalEdited = true;
273
+ }
274
+ }
275
+ const consts1 = collectConsts(mid);
276
+
277
+ // Stage 2 — downstream standalone `from` shifts (fresh spans post-Stage-1).
278
+ const shifted: string[] = [];
279
+ if (shiftFroms) {
280
+ const after = enumerateClips(canvasAbsPath, mid, artboardId);
281
+ const idx = after.clips.findIndex((c) => c.stableId === afterStableId);
282
+ const downstream = after.clips.slice(idx + 1).filter((c) => c.kind === 'sequence');
283
+ const s = new MagicString(mid);
284
+ let touched = false;
285
+ for (const clip of downstream) {
286
+ if (clip.tag !== 'Sequence') continue; // series clips position themselves
287
+ const attr = attrExprSpan(mid, clip.start, 'from');
288
+ if (!attr) continue; // cursor-implicit — follows the flow by construction
289
+ const v0 = resolveNum(attr.inner, consts0);
290
+ const v1 = resolveNum(attr.inner, consts1);
291
+ if (v0 == null || v1 == null) {
292
+ throw new RippleError(
293
+ `cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` is not simple arithmetic — move it by hand, then retry`,
294
+ { canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
295
+ );
296
+ }
297
+ if (v1 - v0 === delta) continue; // already follows the bumped const
298
+ if (v1 !== v0) {
299
+ throw new RippleError(
300
+ `cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` is partially coupled to the comp duration — adjust it by hand`,
301
+ { canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
302
+ );
303
+ }
304
+ if (v0 + delta < 0) {
305
+ throw new RippleError(
306
+ `cannot ripple "${clip.stableId}": it would start at frame ${v0 + delta} (before 0)`,
307
+ { canvas: canvasAbsPath, id: clip.stableId }
308
+ );
309
+ }
310
+ const next = shiftExpression(attr.inner, delta);
311
+ if (next == null) {
312
+ throw new RippleError(
313
+ `cannot ripple "${clip.stableId}": \`from={${attr.inner.trim()}}\` can't be rewritten`,
314
+ { canvas: canvasAbsPath, id: clip.stableId, expr: attr.inner.trim() }
315
+ );
316
+ }
317
+ s.overwrite(attr.start, attr.end, next);
318
+ shifted.push(clip.stableId);
319
+ touched = true;
320
+ }
321
+ if (touched) mid = s.toString();
322
+ }
323
+
324
+ if (mid === source) return { source, shifted, totalEdited };
325
+
326
+ const parsed = parseSync(canvasAbsPath, mid, { sourceType: 'module' });
327
+ if (parsed.errors && parsed.errors.length > 0) {
328
+ throw new CanvasEditError(
329
+ `ripple produced invalid source: ${parsed.errors[0]?.message ?? 'parse error'}`,
330
+ { canvas: canvasAbsPath, id: afterStableId }
331
+ );
332
+ }
333
+ assertCompSemantics(canvasAbsPath, mid);
334
+ return { source: mid, shifted, totalEdited };
335
+ }
@@ -108,8 +108,8 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
108
108
  }
109
109
  }
110
110
 
111
- /** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
112
- export function saveHubCredential(normUrl: string, token: string): void {
111
+ /** Upsert the token (+ the vouched role) under `normUrl` + record per-machine trust; mode 0600. */
112
+ export function saveHubCredential(normUrl: string, token: string, role?: string): void {
113
113
  const path = hubsConfigPath();
114
114
  const dir = dirname(path);
115
115
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -123,7 +123,7 @@ export function saveHubCredential(normUrl: string, token: string): void {
123
123
  /* malformed → start fresh rather than throw */
124
124
  }
125
125
  }
126
- cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
126
+ cfg.hubs[normUrl] = { token, linkedAt: Date.now(), ...(role ? { role } : {}) };
127
127
  if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
128
128
  if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
129
129
  writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });