@1agh/maude 0.58.1 → 0.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/client/styles/6-acp-chat.css +12 -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 +1491 -1491
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- 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 +13 -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/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- 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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-path-pull.test.ts +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -147,6 +147,8 @@ export interface AcpBridgeOptions {
|
|
|
147
147
|
onUsage?: (usage: BridgeUsage) => void;
|
|
148
148
|
/** Override for `PERMISSION_TIMEOUT_MS` (tests only — production always gets the real default). */
|
|
149
149
|
permissionTimeoutMs?: number;
|
|
150
|
+
/** Override for `CANCEL_ESCALATION_MS` (tests only — production always gets the real default). */
|
|
151
|
+
cancelEscalationMs?: number;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
/**
|
|
@@ -561,6 +563,16 @@ export function newSessionParams(
|
|
|
561
563
|
// the security control, so failing open would defeat the point.
|
|
562
564
|
const PERMISSION_TIMEOUT_MS = 120_000;
|
|
563
565
|
|
|
566
|
+
// SECURITY/RELIABILITY (issue-82) — how long cancel() waits for the
|
|
567
|
+
// in-flight turn to actually END after asking the adapter cooperatively,
|
|
568
|
+
// before forcing it to end itself. A wedged subprocess — or one that never
|
|
569
|
+
// implemented `session/cancel` at all — must not be able to leave the Stop
|
|
570
|
+
// button permanently inert (see `cancel()`'s doc comment). Bounded like
|
|
571
|
+
// PERMISSION_TIMEOUT_MS/INITIALIZE_TIMEOUT_MS above: generous enough not to
|
|
572
|
+
// misfire on a merely-slow cancel ack, short enough that Stop still feels
|
|
573
|
+
// responsive.
|
|
574
|
+
const CANCEL_ESCALATION_MS = 5_000;
|
|
575
|
+
|
|
564
576
|
// SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
|
|
565
577
|
// can legitimately issue several tool calls back to back (a burst is normal
|
|
566
578
|
// agent behavior, not a bug — e.g. prompt-injected content directing several
|
|
@@ -624,6 +636,32 @@ export class AcpBridge {
|
|
|
624
636
|
// running establishSession() and stomping the single shared `replaying` flag.
|
|
625
637
|
private sessionPromises = new Map<string, Promise<string>>();
|
|
626
638
|
private currentSession: string | null = null; // the in-flight prompt's session
|
|
639
|
+
// issue-82 — every prompt() call currently racing a forced-cancellation
|
|
640
|
+
// (see `prompt()`), so cancel()/stop() can force ANY of them to settle
|
|
641
|
+
// when the adapter doesn't cooperate, instead of leaving one hanging
|
|
642
|
+
// forever. A Set, not a single field, on purpose (ethical-hacker finding):
|
|
643
|
+
// `handlePrompt` has no reentrancy guard, so a duplicate/replayed `prompt`
|
|
644
|
+
// frame for the same chat can start a SECOND overlapping turn on this
|
|
645
|
+
// bridge. A singleton field would be silently overwritten by the second
|
|
646
|
+
// turn's registration and then nulled by whichever turn settles first —
|
|
647
|
+
// even a normal, uncancelled completion — leaving an EARLIER, genuinely
|
|
648
|
+
// wedged turn permanently unescalatable and reopening this exact bug.
|
|
649
|
+
// Tracking every in-flight turn means cancel() always finds (and can
|
|
650
|
+
// force-end) whichever one is actually stuck, regardless of ordering.
|
|
651
|
+
private pendingTurns = new Set<{ reject: (err: Error) => void; settled: Promise<void> }>();
|
|
652
|
+
// issue-82 / ethical-hacker finding — set for good, synchronously, at the
|
|
653
|
+
// very top of `stop()`. `stop()` kills the subprocess but cannot make it
|
|
654
|
+
// die instantly (SIGTERM, not a synchronous severance of the ndjson
|
|
655
|
+
// stream), so a message the child had already started writing can still
|
|
656
|
+
// arrive and be dispatched afterward. Without this flag a
|
|
657
|
+
// `requestPermission`/`unstable_createElicitation` call landing in that
|
|
658
|
+
// window would register a fresh pending entry as if the session were
|
|
659
|
+
// still live — and a stale client `permission-response` could later
|
|
660
|
+
// resolve it as an "allow" against a session the user was told was
|
|
661
|
+
// closed. Checked at the top of both handlers below; never reset (a
|
|
662
|
+
// stopped bridge is torn out of the `bridges` map by `reap()` and never
|
|
663
|
+
// reused — see acp/index.ts).
|
|
664
|
+
private stopped = false;
|
|
627
665
|
/** Sessions whose bootstrap brief already hit the transcript (audit record). */
|
|
628
666
|
private briefLogged = new Set<string>();
|
|
629
667
|
private starting: Promise<void> | null = null;
|
|
@@ -859,8 +897,31 @@ export class AcpBridge {
|
|
|
859
897
|
}
|
|
860
898
|
}
|
|
861
899
|
|
|
862
|
-
/** Spawn + handshake exactly once; concurrent callers share the same promise.
|
|
900
|
+
/** Spawn + handshake exactly once; concurrent callers share the same promise.
|
|
901
|
+
*
|
|
902
|
+
* issue-82 follow-up (ethical-hacker finding on the fix itself) — once a
|
|
903
|
+
* bridge has been `stop()`'d, refuse to resurrect it rather than silently
|
|
904
|
+
* respawning. `stop()` is currently ONLY reached via `reap()` (which also
|
|
905
|
+
* deletes the bridge from `index.ts`'s map, so a fresh `AcpBridge` — with
|
|
906
|
+
* `stopped` correctly false — is created for the next turn) OR via THIS
|
|
907
|
+
* file's own cancel()-escalation path (`cancel()` calling `stop()` when a
|
|
908
|
+
* turn doesn't end in time), which does NOT go through `reap()` and leaves
|
|
909
|
+
* the SAME, now-permanently-`stopped` instance live in that map. Without
|
|
910
|
+
* this guard, a merely-slow (not malicious) turn that just misses
|
|
911
|
+
* `CANCEL_ESCALATION_MS` would respawn a working-looking subprocess whose
|
|
912
|
+
* `requestPermission`/`unstable_createElicitation` handlers silently
|
|
913
|
+
* auto-deny forever (the `stopped` check in both) — a hard-to-diagnose,
|
|
914
|
+
* silent regression of the permission gate with no visible error. Throwing
|
|
915
|
+
* here instead surfaces a clear, actionable error through the SAME
|
|
916
|
+
* try/catch every caller already has (`handlePrompt` etc.), so the failure
|
|
917
|
+
* is loud, not silent — the user is prompted to start a new chat, whose
|
|
918
|
+
* `getOrCreateEntry` makes a brand-new bridge with `stopped` unset. */
|
|
863
919
|
async ensureStarted(): Promise<void> {
|
|
920
|
+
if (this.stopped) {
|
|
921
|
+
throw new Error(
|
|
922
|
+
'This AI editing session was stopped and cannot be reused — start a new chat.'
|
|
923
|
+
);
|
|
924
|
+
}
|
|
864
925
|
if (this.conn) return;
|
|
865
926
|
if (!this.starting) {
|
|
866
927
|
this.starting = this.start().finally(() => {
|
|
@@ -1139,6 +1200,12 @@ export class AcpBridge {
|
|
|
1139
1200
|
void this.appendTranscript({ role: 'agent', update: u }, seq);
|
|
1140
1201
|
},
|
|
1141
1202
|
requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
|
1203
|
+
// issue-82 / ethical-hacker finding — a message the child had already
|
|
1204
|
+
// started writing before `stop()` killed it can still be dispatched
|
|
1205
|
+
// in the brief window before the process actually dies. Fail closed
|
|
1206
|
+
// immediately rather than register a pending entry a stale client
|
|
1207
|
+
// response could later resolve as "allow" against a torn-down session.
|
|
1208
|
+
if (this.stopped) return Promise.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1142
1209
|
// Milestone B (retires DDR-125 F2's blanket auto-approve) — the
|
|
1143
1210
|
// permission POLICY is now the selected session mode (sourced from
|
|
1144
1211
|
// Claude Code itself): `bypassPermissions`/`dontAsk` short-circuit
|
|
@@ -1195,6 +1262,11 @@ export class AcpBridge {
|
|
|
1195
1262
|
unstable_createElicitation: (
|
|
1196
1263
|
params: CreateElicitationRequest
|
|
1197
1264
|
): Promise<CreateElicitationResponse> => {
|
|
1265
|
+
// issue-82 / ethical-hacker finding — same fail-closed guard as
|
|
1266
|
+
// `requestPermission` above, and for the same reason: a message
|
|
1267
|
+
// already in flight from the child when `stop()` fires must not
|
|
1268
|
+
// register a pending entry a stale client response could resolve.
|
|
1269
|
+
if (this.stopped) return Promise.resolve({ action: 'decline' });
|
|
1198
1270
|
// feature-acp-ask-user-question — mirrors requestPermission's shape
|
|
1199
1271
|
// exactly. Fires for BOTH `AskUserQuestion` and any MCP-server
|
|
1200
1272
|
// elicitation (see the plan's Research section) — the toolCallId/
|
|
@@ -1316,10 +1388,40 @@ export class AcpBridge {
|
|
|
1316
1388
|
}
|
|
1317
1389
|
}
|
|
1318
1390
|
await this.appendTranscript({ role: 'user', text });
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1391
|
+
|
|
1392
|
+
// issue-82 — race the real adapter response against a forced rejection
|
|
1393
|
+
// `cancel()`/`stop()` can trigger. `turn.settled` resolves the moment the
|
|
1394
|
+
// RACE ITSELF is decided (security-auditor finding: resolving it only
|
|
1395
|
+
// after the transcript-append below would let a slow disk write
|
|
1396
|
+
// masquerade as a still-hanging turn and trigger a needless escalation
|
|
1397
|
+
// even though the real response already won) — the append is bookkeeping
|
|
1398
|
+
// on an already-settled outcome, not part of what `cancel()` waits on.
|
|
1399
|
+
let resolveSettled!: () => void;
|
|
1400
|
+
const settled = new Promise<void>((resolve) => {
|
|
1401
|
+
resolveSettled = resolve;
|
|
1322
1402
|
});
|
|
1403
|
+
const turn: { reject: (err: Error) => void; settled: Promise<void> } = {
|
|
1404
|
+
reject: () => {}, // replaced synchronously below before this can matter
|
|
1405
|
+
settled,
|
|
1406
|
+
};
|
|
1407
|
+
const forced = new Promise<never>((_, reject) => {
|
|
1408
|
+
turn.reject = reject;
|
|
1409
|
+
});
|
|
1410
|
+
let response: PromptResponse;
|
|
1411
|
+
// `add` happens INSIDE the try (not before it) so a synchronous throw
|
|
1412
|
+
// from `conn.prompt()` still reaches the `finally` and removes `turn` —
|
|
1413
|
+
// otherwise it would leak in the Set forever, and a later cancel()
|
|
1414
|
+
// calling `turn.reject()` on it would reject a `forced` promise nothing
|
|
1415
|
+
// is racing anymore (an unhandled rejection).
|
|
1416
|
+
try {
|
|
1417
|
+
this.pendingTurns.add(turn);
|
|
1418
|
+
const promptCall = conn.prompt({ sessionId, prompt: [{ type: 'text', text }] });
|
|
1419
|
+
promptCall.catch(() => {}); // a forced-reject race must not surface as an unhandled rejection
|
|
1420
|
+
response = await Promise.race([promptCall, forced]);
|
|
1421
|
+
} finally {
|
|
1422
|
+
this.pendingTurns.delete(turn);
|
|
1423
|
+
resolveSettled();
|
|
1424
|
+
}
|
|
1323
1425
|
await this.appendTranscript({ role: 'stop', stopReason: response.stopReason });
|
|
1324
1426
|
return { stopReason: response.stopReason };
|
|
1325
1427
|
}
|
|
@@ -1548,24 +1650,72 @@ export class AcpBridge {
|
|
|
1548
1650
|
}
|
|
1549
1651
|
}
|
|
1550
1652
|
|
|
1551
|
-
/**
|
|
1552
|
-
|
|
1653
|
+
/** Deny pending permissions/elicitations, then fire the cooperative ACP
|
|
1654
|
+
* `session/cancel` request — WITHOUT waiting for it to settle. A wedged
|
|
1655
|
+
* or non-cooperating subprocess can leave this RPC unanswered forever (or
|
|
1656
|
+
* reject it immediately if `session/cancel` isn't even implemented);
|
|
1657
|
+
* either way this must never make a caller wait on it, so both
|
|
1658
|
+
* `cancel()`'s escalation and `stop()`'s teardown stay bounded no matter
|
|
1659
|
+
* what the adapter does with the request. */
|
|
1660
|
+
private requestCancel(): void {
|
|
1553
1661
|
this.denyAllPendingPermissions();
|
|
1554
1662
|
this.declineAllPendingElicitations();
|
|
1555
1663
|
if (this.conn && this.currentSession) {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
}
|
|
1559
|
-
/* turn may already have finished */
|
|
1560
|
-
}
|
|
1664
|
+
this.conn.cancel({ sessionId: this.currentSession }).catch(() => {
|
|
1665
|
+
/* turn may already have finished, or the adapter doesn't implement cancel */
|
|
1666
|
+
});
|
|
1561
1667
|
}
|
|
1562
1668
|
}
|
|
1563
1669
|
|
|
1564
|
-
/**
|
|
1670
|
+
/**
|
|
1671
|
+
* Cancel the in-flight turn (no-op if nothing is running).
|
|
1672
|
+
*
|
|
1673
|
+
* Fires the cooperative ACP `session/cancel` request, but does not simply
|
|
1674
|
+
* trust it: a wedged subprocess — or one that never implemented
|
|
1675
|
+
* `session/cancel` at all — can leave the pending `prompt()` call
|
|
1676
|
+
* unsettled forever, which used to mean Stop silently did nothing
|
|
1677
|
+
* (issue-82): the client's turn loop never exits, `busy` stays true, and
|
|
1678
|
+
* the Stop button stays visible but inert. Any turn that hasn't actually
|
|
1679
|
+
* ended within `CANCEL_ESCALATION_MS` of asking is forced to end — reject
|
|
1680
|
+
* its pending `prompt()` — so it always settles as an error the client
|
|
1681
|
+
* surfaces, instead of hanging. If ANY turn needed forcing, the whole
|
|
1682
|
+
* bridge (one subprocess serving every turn) is presumed wedged and torn
|
|
1683
|
+
* down.
|
|
1684
|
+
*/
|
|
1685
|
+
async cancel(): Promise<void> {
|
|
1686
|
+
this.requestCancel();
|
|
1687
|
+
const turns = [...this.pendingTurns];
|
|
1688
|
+
if (turns.length === 0) return; // nothing in flight to escalate
|
|
1689
|
+
const escalated = await Promise.all(
|
|
1690
|
+
turns.map(async (turn) => {
|
|
1691
|
+
const result = await withTimeout(
|
|
1692
|
+
turn.settled,
|
|
1693
|
+
this.opts.cancelEscalationMs ?? CANCEL_ESCALATION_MS
|
|
1694
|
+
);
|
|
1695
|
+
if (result !== TIMED_OUT) return false;
|
|
1696
|
+
turn.reject(new Error('Cancelled — the agent did not respond in time and was stopped.'));
|
|
1697
|
+
return true;
|
|
1698
|
+
})
|
|
1699
|
+
);
|
|
1700
|
+
if (escalated.some(Boolean)) await this.stop();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Tear down: cancel, kill the subprocess, drop all handles + sessions.
|
|
1704
|
+
* Never blocks on the cooperative cancel ask (see `requestCancel`) — a
|
|
1705
|
+
* wedged subprocess must not be able to make `stop()` hang too. */
|
|
1565
1706
|
async stop(): Promise<void> {
|
|
1566
|
-
|
|
1567
|
-
this.
|
|
1568
|
-
this.
|
|
1707
|
+
// Fail-closed from this instant on — see the `stopped` field comment.
|
|
1708
|
+
this.stopped = true;
|
|
1709
|
+
this.requestCancel();
|
|
1710
|
+
// Any turn(s) still in flight — a direct stop() with no prior cancel(),
|
|
1711
|
+
// or cancel()'s own escalation calling back in here. Force them to
|
|
1712
|
+
// settle before the connection dies underneath them, so the client's
|
|
1713
|
+
// turn loop always exits instead of awaiting a promise that can now
|
|
1714
|
+
// never resolve.
|
|
1715
|
+
for (const turn of this.pendingTurns) {
|
|
1716
|
+
turn.reject(new Error('Stopped — the AI editing session was closed.'));
|
|
1717
|
+
}
|
|
1718
|
+
this.pendingTurns.clear();
|
|
1569
1719
|
try {
|
|
1570
1720
|
this.proc?.kill();
|
|
1571
1721
|
} catch {
|
|
@@ -17,18 +17,69 @@ import {
|
|
|
17
17
|
rotatePoint,
|
|
18
18
|
type Stroke,
|
|
19
19
|
strokeBBox,
|
|
20
|
+
strokeHitTest,
|
|
20
21
|
strokeRotation,
|
|
21
22
|
} from './annotations-model.ts';
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The largest bbox a TEXT stroke may present as a bind target, PER AXIS.
|
|
26
|
+
*
|
|
27
|
+
* A `TextStroke` has no stored `w`/`h` — `strokeBBox` SYNTHESIZES one from the
|
|
28
|
+
* content (`max(8, longest * fontSize * 0.55)` wide). So a long single line can
|
|
29
|
+
* project a bind strip tens of thousands of px wide, and `bindCandidate` scans
|
|
30
|
+
* topmost-first with no area preference: one such stroke would silently become a
|
|
31
|
+
* board-wide magnet that steals every endpoint the user drags. Text bigger than
|
|
32
|
+
* a plausible label is therefore not offered as a host at all (DDR-216 D9).
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_TEXT_BIND_W = 1200;
|
|
35
|
+
export const MAX_TEXT_BIND_H = 480;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hosts an arrow endpoint can attach to (shapes + cards, FigJam set).
|
|
39
|
+
*
|
|
40
|
+
* **Widened to `text` + `section` (DDR-216 D9).** FigJam binds connectors to
|
|
41
|
+
* anything, and on the only real board measured 2/2 connectors had at least one
|
|
42
|
+
* endpoint Maude could not bind — so an imported connector degraded to a frozen
|
|
43
|
+
* line. It is also a plain native gap: you could not attach an arrow to a text
|
|
44
|
+
* label or a section, independent of import.
|
|
45
|
+
*
|
|
46
|
+
* Three deliberate exclusions, each closing a regression the naive widening
|
|
47
|
+
* would have introduced:
|
|
48
|
+
*
|
|
49
|
+
* 1. **ANCHORED text is still not bindable.** `bindCandidate`/`anchorPoint`/
|
|
50
|
+
* `recomputeBoundArrows` call `strokeBBox(s)` WITHOUT the anchors map, and
|
|
51
|
+
* for a text stroke with `anchorId` that returns `null`. Admitting it would
|
|
52
|
+
* mint a bind that `anchorPoint` can never resolve — and `applyEnd` would
|
|
53
|
+
* keep it (it only strips when the host stops being bindable), freezing the
|
|
54
|
+
* arrow at stale coordinates forever. That is strictly worse than today's
|
|
55
|
+
* honest "strip the bind, freeze the endpoint". Anchored text lives inside a
|
|
56
|
+
* host shape which is itself bindable, so nothing is lost: bind to the shape.
|
|
57
|
+
* 2. **Oversized text is not offered** — see `MAX_TEXT_BIND_W`/`_H`.
|
|
58
|
+
* 3. **Groups are not bindable by construction** — Maude groups are a flat
|
|
59
|
+
* `groupIds[]` tag array, not addressable objects. A group-targeted import
|
|
60
|
+
* endpoint falls back to the group's geometric bbox and is reported.
|
|
61
|
+
*/
|
|
24
62
|
export function isBindable(s: Stroke): boolean {
|
|
25
|
-
|
|
63
|
+
if (
|
|
26
64
|
s.tool === 'rect' ||
|
|
27
65
|
s.tool === 'ellipse' ||
|
|
28
66
|
s.tool === 'polygon' ||
|
|
29
67
|
s.tool === 'sticky' ||
|
|
30
68
|
s.tool === 'image'
|
|
31
|
-
)
|
|
69
|
+
) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (s.tool === 'section') return true;
|
|
73
|
+
if (s.tool === 'text') {
|
|
74
|
+
if (s.anchorId) return false; // (1) — no resolvable bbox without the anchors map
|
|
75
|
+
const bb = strokeBBox(s);
|
|
76
|
+
// Per-AXIS, not an area product: an area cap is satisfiable by a very wide,
|
|
77
|
+
// very short strip, which is precisely the board-wide magnet shape. 4 000
|
|
78
|
+
// chars at the fontSize floor of 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960,
|
|
79
|
+
// comfortably under a 640×480 product (post-implementation review F8).
|
|
80
|
+
return !!bb && bb.w > 0 && bb.h > 0 && bb.w <= MAX_TEXT_BIND_W && bb.h <= MAX_TEXT_BIND_H; // (2)
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
/** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
|
|
@@ -78,6 +129,20 @@ export function bindCandidate(
|
|
|
78
129
|
) {
|
|
79
130
|
continue;
|
|
80
131
|
}
|
|
132
|
+
// A SECTION is a large container, and the natural gesture is to draw one
|
|
133
|
+
// AROUND existing notes — which puts it later in the array, i.e. topmost.
|
|
134
|
+
// Testing its raw bbox would make the whole interior a magnet, so every
|
|
135
|
+
// arrow drawn between two stickies inside a section would bind to the
|
|
136
|
+
// section instead. `strokeHitTest` already encodes the right rule (a section
|
|
137
|
+
// is grabbed by its BORDER or its label chip; the interior stays
|
|
138
|
+
// click-through), so defer to it rather than inventing a second geometry.
|
|
139
|
+
//
|
|
140
|
+
// Deliberately NOT "prefer the smallest containing bbox": containment here
|
|
141
|
+
// is over a bbox INFLATED by `threshold`, so smallest-area lets an 8×8 dot
|
|
142
|
+
// 14 px AWAY beat the large rect the pointer is actually inside — and it
|
|
143
|
+
// inverts the visual invariant, letting a shape hidden behind an opaque one
|
|
144
|
+
// win a bind the user cannot see happening (DDR-216 D9 fix 4).
|
|
145
|
+
if (s.tool === 'section' && !strokeHitTest(s, wx, wy, threshold)) continue;
|
|
81
146
|
return {
|
|
82
147
|
hostId: s.id,
|
|
83
148
|
nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
|
|
@@ -155,7 +220,21 @@ export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
|
|
|
155
220
|
}
|
|
156
221
|
}
|
|
157
222
|
const pt = anchorPoint(host, effective.nx, effective.ny);
|
|
158
|
-
if (!pt)
|
|
223
|
+
if (!pt) {
|
|
224
|
+
// The host is bindable but its bbox is unresolvable right now (e.g. a
|
|
225
|
+
// text stroke whose anchors map isn't reachable from here). Returning
|
|
226
|
+
// silently would KEEP a bind that can never be honoured — the arrow
|
|
227
|
+
// would freeze at stale coordinates permanently, across save/load,
|
|
228
|
+
// while still claiming to be attached. Strip it instead: the arrow
|
|
229
|
+
// survives unbound with its endpoint frozen, which is the same honest
|
|
230
|
+
// degradation a deleted host already gets. (DDR-216 D9 fix 2 — inert
|
|
231
|
+
// before the `isBindable` widening, load-bearing after it.)
|
|
232
|
+
const copy: ArrowStroke = { ...next };
|
|
233
|
+
delete copy[end === 'start' ? 'startBind' : 'endBind'];
|
|
234
|
+
next = copy;
|
|
235
|
+
mutated = true;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
159
238
|
if (end === 'start') {
|
|
160
239
|
if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
|
|
161
240
|
next = { ...next, x1: pt[0], y1: pt[1] };
|
package/apps/studio/api.ts
CHANGED
|
@@ -5260,7 +5260,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5260
5260
|
// `loadCanvasMeta` call the DDR-093 DS-map loop below already makes for
|
|
5261
5261
|
// non-path-owned (`ui/`) canvases — no extra I/O for the common case.
|
|
5262
5262
|
const canvasKinds: Record<string, string> = {};
|
|
5263
|
-
|
|
5263
|
+
// DDR-216 D7 adds `imported-figma`. The badge deliberately reads as
|
|
5264
|
+
// THIRD-PARTY CONTENT rather than as provenance-therefore-trustworthy: a
|
|
5265
|
+
// clean "imported from Figma" stamp otherwise makes the most
|
|
5266
|
+
// attacker-influenced artifact in the tree look like real design work, to a
|
|
5267
|
+
// human AND to `design-system-keeper` / the critic panel.
|
|
5268
|
+
const NOTABLE_KINDS = new Set(['reconstructed-experimental', 'imported-figma']);
|
|
5264
5269
|
const defaultDs = cfg.defaultDesignSystem || cfg.designSystems?.[0]?.name || 'project';
|
|
5265
5270
|
// A file under `system/<folder>/` belongs to the DS that owns that folder —
|
|
5266
5271
|
// path-authoritative, because specimens/ui_kits rarely carry a sidecar
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
import { execFileSync } from 'node:child_process';
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { lookup } from 'node:dns/promises';
|
|
42
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
43
43
|
import { isIP } from 'node:net';
|
|
44
|
-
import { join, resolve, sep } from 'node:path';
|
|
44
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
45
45
|
import { pathToFileURL } from 'node:url';
|
|
46
46
|
|
|
47
47
|
// A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
|
|
@@ -202,7 +202,7 @@ export function classifyAddress(addr) {
|
|
|
202
202
|
* FetchAssetError (code 3) on anything else — non-https scheme, embedded
|
|
203
203
|
* credentials, missing host.
|
|
204
204
|
*/
|
|
205
|
-
export function parseHttpsTarget(rawUrl) {
|
|
205
|
+
export function parseHttpsTarget(rawUrl, opts = {}) {
|
|
206
206
|
let u;
|
|
207
207
|
try {
|
|
208
208
|
u = new URL(rawUrl);
|
|
@@ -221,9 +221,71 @@ export function parseHttpsTarget(rawUrl) {
|
|
|
221
221
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
222
222
|
throw new FetchAssetError(3, `bad port ${u.port}`);
|
|
223
223
|
}
|
|
224
|
+
// DDR-216 D4 — an OPTIONAL host allowlist, at the gate rather than at the
|
|
225
|
+
// caller. Absent (the default) = today's unrestricted behaviour, so the
|
|
226
|
+
// existing moodboard/research callers are untouched. The Figma lane always
|
|
227
|
+
// passes it.
|
|
228
|
+
//
|
|
229
|
+
// Matching is exact-or-DOTTED-SUFFIX, never a bare `endsWith`: `endsWith(
|
|
230
|
+
// 'figma.com')` admits `evil-figma.com`, and `endsWith('.amazonaws.com')`
|
|
231
|
+
// admits every S3 bucket on earth.
|
|
232
|
+
//
|
|
233
|
+
// This is a REACH control (which endpoint), never a CONTENT control — the
|
|
234
|
+
// Figma render bucket is a SHARED object store any account can write into,
|
|
235
|
+
// so the byte-level sniff below is what actually protects the tree. And it
|
|
236
|
+
// NARROWS the IP gate, never replaces it: an allowlisted hostname that
|
|
237
|
+
// resolves to 127.0.0.1 is still refused by `resolveSafeIp`.
|
|
238
|
+
if (opts.allowHosts?.length) {
|
|
239
|
+
const h = host.toLowerCase();
|
|
240
|
+
const ok = opts.allowHosts.some((suffix) => {
|
|
241
|
+
const s = String(suffix).toLowerCase().replace(/^\./, '');
|
|
242
|
+
return h === s || h.endsWith(`.${s}`);
|
|
243
|
+
});
|
|
244
|
+
if (!ok) throw new FetchAssetError(3, `host not in this lane's allowlist: ${safeHostLabel(h)}`);
|
|
245
|
+
}
|
|
246
|
+
// DDR-216 D4 — the Figma lane pins 443. Named as NEW logic rather than
|
|
247
|
+
// assumed: this function accepts 1–65535 by default and always has.
|
|
248
|
+
if (opts.pinPort443 && port !== 443) {
|
|
249
|
+
throw new FetchAssetError(3, `this lane requires port 443 (got ${port})`);
|
|
250
|
+
}
|
|
224
251
|
return { host, port };
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A hostname reduced to a charset-validated token before it can be printed.
|
|
256
|
+
*
|
|
257
|
+
* DDR-216 D10: this verb's output is read BY an agent, so an upstream-controlled
|
|
258
|
+
* string must never appear verbatim in a message. A DNS label charset is narrow
|
|
259
|
+
* enough to be safe and specific enough to diagnose.
|
|
260
|
+
*/
|
|
261
|
+
function safeHostLabel(host) {
|
|
262
|
+
const cleaned = String(host)
|
|
263
|
+
.toLowerCase()
|
|
264
|
+
.replace(/[^a-z0-9.-]/g, '');
|
|
265
|
+
return cleaned.slice(0, 253) || '(unprintable)';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The `--raw-out` accept set: everything `sniffImageExt` accepts, PLUS SVG.
|
|
270
|
+
*
|
|
271
|
+
* `sniffImageExt` is deliberately NOT modified — it is the type gate the
|
|
272
|
+
* moodboard lane depends on, and a standing test asserts it still returns null
|
|
273
|
+
* for `<svg`/`<?xml`. The SVG probe below reuses the exact leading-token shape
|
|
274
|
+
* `_import-asset.mjs`'s own `svgPreParseReject` uses, so a payload accepted here
|
|
275
|
+
* is one that lane will actually attempt to parse (and then sanitize).
|
|
276
|
+
*/
|
|
277
|
+
export function sniffStagedKind(bytes) {
|
|
278
|
+
const raster = sniffImageExt(bytes);
|
|
279
|
+
if (raster) return raster;
|
|
280
|
+
// Text probe on the first bytes only — never decode the whole body to guess.
|
|
281
|
+
const head = Buffer.from(bytes.subarray(0, 256)).toString('utf8');
|
|
282
|
+
// Deliberately NARROWER than `svgPreParseReject`'s own leading-token set: a
|
|
283
|
+
// bare `<!--` prologue is legal SVG but it is also what lets an arbitrary text
|
|
284
|
+
// payload masquerade as one (review F2), and no Figma export starts with it.
|
|
285
|
+
// The full DDR-167 lane still validates properly downstream.
|
|
286
|
+
return /^\s*(<\?xml|<svg)/i.test(head) ? 'svg' : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
227
289
|
// ── image sniff + naming ─────────────────────────────────────────────────────
|
|
228
290
|
|
|
229
291
|
/**
|
|
@@ -408,10 +470,91 @@ export async function fetchAsset({
|
|
|
408
470
|
designRootRel = '.design',
|
|
409
471
|
maxBytes = DEFAULT_MAX_BYTES,
|
|
410
472
|
maxTime = DEFAULT_MAX_TIME,
|
|
473
|
+
allowHosts,
|
|
474
|
+
pinPort443 = false,
|
|
475
|
+
rawOut = null,
|
|
476
|
+
rawRoot = null,
|
|
411
477
|
}) {
|
|
412
|
-
const { host, port } = parseHttpsTarget(url);
|
|
478
|
+
const { host, port } = parseHttpsTarget(url, { allowHosts, pinPort443 });
|
|
413
479
|
const pinIp = await resolveSafeIp(host);
|
|
414
480
|
|
|
481
|
+
// ── `--raw-out` (DDR-216 D11) ──────────────────────────────────────────────
|
|
482
|
+
//
|
|
483
|
+
// Download under the FULL network gate (IP classification, DNS pin, redirect
|
|
484
|
+
// ban, size/time caps, host allowlist) but write to a CALLER-SUPPLIED path
|
|
485
|
+
// instead of `assets/`, and do NOT content-address or name the file.
|
|
486
|
+
//
|
|
487
|
+
// Why this exists: D11 composes two already-reviewed gates rather than
|
|
488
|
+
// widening one. A Figma vector export is an SVG, and this helper's raster-only
|
|
489
|
+
// sniff is load-bearing for its OTHER caller (the DDR-147 moodboard lane,
|
|
490
|
+
// where URLs are research-harvested and there is no allowlist at all). So the
|
|
491
|
+
// Figma lane stages the bytes here and hands them to `_import-asset.mjs`'s
|
|
492
|
+
// DDR-167 SVG lane (allowlist DOM-sanitize → SVGO validity gate) before
|
|
493
|
+
// anything is named or lands in the versioned tree.
|
|
494
|
+
//
|
|
495
|
+
// IT STILL SNIFFS. `--raw-out` must never mean "skip the type gate" — that
|
|
496
|
+
// would turn the one reviewable downloader into an unsniffed one (HTML, JS,
|
|
497
|
+
// polyglots, archives) for the next caller who copies it. It sniffs against
|
|
498
|
+
// an EXTENDED accept set; `sniffImageExt` itself is untouched, which is what
|
|
499
|
+
// keeps the standing "sniffImageExt still rejects SVG" test literally true.
|
|
500
|
+
if (rawOut) {
|
|
501
|
+
// CONTAINMENT (post-implementation review F2). Without this, `--raw-out`
|
|
502
|
+
// was an arbitrary-file-write primitive on the ONE helper whose whole job
|
|
503
|
+
// is to be the safe downloader: `resolve()` + `renameSync()` with no root
|
|
504
|
+
// relationship, no charset assertion, and a type gate a leading `<!--`
|
|
505
|
+
// satisfies. An agent that can run `maude design fetch-asset` (ACP
|
|
506
|
+
// default-allows `Bash(maude:*)`) could have written remote bytes over
|
|
507
|
+
// `CLAUDE.md` — durable prompt injection.
|
|
508
|
+
//
|
|
509
|
+
// So the mode now REQUIRES the caller to declare the directory it owns, and
|
|
510
|
+
// the target must resolve inside it. The Figma lane passes its per-run
|
|
511
|
+
// staging dir; there is no way to express "anywhere".
|
|
512
|
+
if (!rawRoot) {
|
|
513
|
+
throw new FetchAssetError(2, '--raw-out requires --raw-root (the directory the caller owns)');
|
|
514
|
+
}
|
|
515
|
+
// BOTH sides get realpath'd, or neither. On macOS `/var` and `/tmp` are
|
|
516
|
+
// themselves OS-level symlinks (`-> /private/var`, `-> /private/tmp`), so
|
|
517
|
+
// realpath'ing only the root compares `/private/var/…` against `/var/…` and
|
|
518
|
+
// rejects EVERY legitimate staging path — which is exactly how this shipped
|
|
519
|
+
// and why the first real import skipped all 29 of its assets. Same trap
|
|
520
|
+
// DDR-172 Decision 1 documents for its own symlink check.
|
|
521
|
+
//
|
|
522
|
+
// The target does not exist yet, so realpath its PARENT (which does — the
|
|
523
|
+
// caller made the staging dir) and rebuild the leaf onto it.
|
|
524
|
+
const rootAbs = realpathSync(resolve(rawRoot));
|
|
525
|
+
const requested = resolve(rawOut);
|
|
526
|
+
const parentAbs = realpathSync(dirname(requested));
|
|
527
|
+
const outAbs = join(parentAbs, basename(requested));
|
|
528
|
+
if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
|
|
529
|
+
throw new FetchAssetError(6, '--raw-out must resolve inside --raw-root');
|
|
530
|
+
}
|
|
531
|
+
// Never overwrite: a staged download is always a fresh file, so an existing
|
|
532
|
+
// target means either a collision or an attempt to clobber something.
|
|
533
|
+
if (existsSync(outAbs)) {
|
|
534
|
+
throw new FetchAssetError(6, '--raw-out target already exists');
|
|
535
|
+
}
|
|
536
|
+
const tmpRaw = `${outAbs}.part`;
|
|
537
|
+
try {
|
|
538
|
+
curlDownload({ url, host, port, pinIp, tmpAbs: tmpRaw, maxBytes, maxTime });
|
|
539
|
+
let data;
|
|
540
|
+
try {
|
|
541
|
+
data = readFileSync(tmpRaw);
|
|
542
|
+
} catch {
|
|
543
|
+
throw new FetchAssetError(4, 'download produced no file');
|
|
544
|
+
}
|
|
545
|
+
if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
|
|
546
|
+
if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
|
|
547
|
+
const kind = sniffStagedKind(data);
|
|
548
|
+
if (!kind) {
|
|
549
|
+
throw new FetchAssetError(5, 'not a png/jpg/gif/webp/svg payload (HTML/script rejected)');
|
|
550
|
+
}
|
|
551
|
+
renameSync(tmpRaw, outAbs);
|
|
552
|
+
return { ref: null, path: outAbs, name: null, bytes: data.length, ext: kind };
|
|
553
|
+
} finally {
|
|
554
|
+
rmSync(tmpRaw, { force: true });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
415
558
|
const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
|
|
416
559
|
mkdirSync(assetsDir, { recursive: true });
|
|
417
560
|
const tmpAbs = join(
|
|
@@ -476,6 +619,21 @@ function parseArgv(argv) {
|
|
|
476
619
|
case '--max-time':
|
|
477
620
|
out.maxTime = Number(argv[++i]);
|
|
478
621
|
break;
|
|
622
|
+
// DDR-216 D4/D11 — all three default to today's behaviour, so every
|
|
623
|
+
// existing caller of this helper is untouched.
|
|
624
|
+
case '--allow-host':
|
|
625
|
+
if (!out.allowHosts) out.allowHosts = [];
|
|
626
|
+
out.allowHosts.push(argv[++i]);
|
|
627
|
+
break;
|
|
628
|
+
case '--pin-port-443':
|
|
629
|
+
out.pinPort443 = true;
|
|
630
|
+
break;
|
|
631
|
+
case '--raw-out':
|
|
632
|
+
out.rawOut = argv[++i];
|
|
633
|
+
break;
|
|
634
|
+
case '--raw-root':
|
|
635
|
+
out.rawRoot = argv[++i];
|
|
636
|
+
break;
|
|
479
637
|
case '--json':
|
|
480
638
|
out.json = true;
|
|
481
639
|
break;
|
|
@@ -540,8 +698,14 @@ async function main() {
|
|
|
540
698
|
designRootRel: opts.designRoot,
|
|
541
699
|
maxBytes: opts.maxBytes,
|
|
542
700
|
maxTime: opts.maxTime,
|
|
701
|
+
...(opts.allowHosts ? { allowHosts: opts.allowHosts } : {}),
|
|
702
|
+
...(opts.pinPort443 ? { pinPort443: true } : {}),
|
|
703
|
+
...(opts.rawOut ? { rawOut: opts.rawOut } : {}),
|
|
704
|
+
...(opts.rawRoot ? { rawRoot: opts.rawRoot } : {}),
|
|
543
705
|
});
|
|
544
|
-
|
|
706
|
+
// `--raw-out` has no canvas ref (it deliberately writes outside `assets/`),
|
|
707
|
+
// so print the staged path instead of a null.
|
|
708
|
+
process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref ?? r.path}\n`);
|
|
545
709
|
} catch (err) {
|
|
546
710
|
process.stderr.write(`fetch-asset: ${err.message}\n`);
|
|
547
711
|
process.exit(err instanceof FetchAssetError ? err.code : 1);
|