@1agh/maude 0.60.7 → 1.0.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/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -28,6 +28,7 @@ import { mkdir, rm } from 'node:fs/promises';
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
|
|
30
30
|
import type { Bus } from '../context.ts';
|
|
31
|
+
import { resolveRenderLane } from '../workspace-mode.ts';
|
|
31
32
|
import {
|
|
32
33
|
type ExportContext,
|
|
33
34
|
type ExportDegradation,
|
|
@@ -37,7 +38,16 @@ import {
|
|
|
37
38
|
runExport,
|
|
38
39
|
type Scope,
|
|
39
40
|
} from './index.ts';
|
|
40
|
-
import
|
|
41
|
+
import {
|
|
42
|
+
formatNeedsBrowser,
|
|
43
|
+
NoRenderServiceError,
|
|
44
|
+
REMOTE_UNSUPPORTED_FORMATS,
|
|
45
|
+
REMOTE_UNSUPPORTED_MESSAGE,
|
|
46
|
+
type RemoteCanvasAccess,
|
|
47
|
+
renderRemotely,
|
|
48
|
+
resolveRenderService,
|
|
49
|
+
} from './remote.ts';
|
|
50
|
+
import { type ResolveScopeArgs, resolveScope } from './scope.ts';
|
|
41
51
|
|
|
42
52
|
/** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
|
|
43
53
|
export interface ExportHistoryEntry {
|
|
@@ -85,6 +95,14 @@ export interface EnqueueArgs {
|
|
|
85
95
|
options: ExportOptions;
|
|
86
96
|
resolve: Omit<ResolveScopeArgs, 'scope'>;
|
|
87
97
|
ctx: ExportContext;
|
|
98
|
+
/**
|
|
99
|
+
* feature-cloud-export-render-workers (DDR-230) — how the render service
|
|
100
|
+
* reaches this project's canvas when the job dispatches remotely: the
|
|
101
|
+
* PUBLIC canvas origin plus the requesting member's own render token,
|
|
102
|
+
* forwarded from the enqueue request. Absent on a desktop, where the lane
|
|
103
|
+
* is `local` and this is never read.
|
|
104
|
+
*/
|
|
105
|
+
remoteCanvas?: RemoteCanvasAccess;
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
export type DownloadResult =
|
|
@@ -310,7 +328,33 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
310
328
|
|
|
311
329
|
const controller = new AbortController();
|
|
312
330
|
|
|
331
|
+
// DDR-230 — where this job renders. `local` is the pre-render-workers
|
|
332
|
+
// spine, byte-identical. In a workspace the browser formats dispatch to
|
|
333
|
+
// the maude-render service (`remote`) or refuse with a remedy (`none`);
|
|
334
|
+
// browser-free formats (zip) run in-cell in every lane, because there is
|
|
335
|
+
// nothing to evaluate. Resolved per job, not per process, so tests can
|
|
336
|
+
// exercise the lanes without a reboot.
|
|
337
|
+
const lane = resolveRenderLane();
|
|
338
|
+
const dispatchRemote = lane !== 'local' && formatNeedsBrowser(args.format);
|
|
339
|
+
|
|
313
340
|
const result = (async (): Promise<ExportResult> => {
|
|
341
|
+
// Fail BEFORE taking a render slot: a job that can never render must not
|
|
342
|
+
// queue behind ones that can, and the refusal message is the remedy the
|
|
343
|
+
// export dialog shows (belt to the client's own lane gate).
|
|
344
|
+
const laneRefusal =
|
|
345
|
+
lane !== 'local' && REMOTE_UNSUPPORTED_FORMATS.has(args.format)
|
|
346
|
+
? REMOTE_UNSUPPORTED_MESSAGE
|
|
347
|
+
: dispatchRemote && lane === 'none'
|
|
348
|
+
? new NoRenderServiceError().message
|
|
349
|
+
: null;
|
|
350
|
+
if (laneRefusal) {
|
|
351
|
+
job.status = 'failed';
|
|
352
|
+
job.finishedAt = new Date().toISOString();
|
|
353
|
+
job.error = laneRefusal;
|
|
354
|
+
emit(job);
|
|
355
|
+
await persistAndEvict();
|
|
356
|
+
throw new Error(laneRefusal);
|
|
357
|
+
}
|
|
314
358
|
const release = await semaphore.acquire();
|
|
315
359
|
// Started here, not at enqueue — the timeout bounds RENDER time (the
|
|
316
360
|
// intent), not queue-wait time. A job can legitimately sit `queued`
|
|
@@ -323,20 +367,38 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
323
367
|
job.startedAt = new Date().toISOString();
|
|
324
368
|
emit(job);
|
|
325
369
|
|
|
326
|
-
const res =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
370
|
+
const res = dispatchRemote
|
|
371
|
+
? await renderRemotely({
|
|
372
|
+
format: args.format,
|
|
373
|
+
// Scope resolves HERE, against the cell's own checkout — Target
|
|
374
|
+
// is pure data, and the render service holds no tenant store to
|
|
375
|
+
// resolve against (DDR-230 §1).
|
|
376
|
+
targets: await resolveScope({
|
|
377
|
+
scope: args.scope,
|
|
378
|
+
...args.resolve,
|
|
379
|
+
options: args.options,
|
|
380
|
+
}),
|
|
381
|
+
options: args.options,
|
|
382
|
+
canvas: args.remoteCanvas ?? { origin: '' },
|
|
383
|
+
// resolveRenderService() is non-null on the `remote` lane by
|
|
384
|
+
// construction (the lane IS the presence of MAUDE_RENDER_URL).
|
|
385
|
+
service: resolveRenderService()!,
|
|
386
|
+
signal: controller.signal,
|
|
387
|
+
})
|
|
388
|
+
: await runExport({
|
|
389
|
+
format: args.format,
|
|
390
|
+
scope: args.scope,
|
|
391
|
+
options: args.options,
|
|
392
|
+
resolve: args.resolve,
|
|
393
|
+
ctx: args.ctx,
|
|
394
|
+
hooks: {
|
|
395
|
+
signal: controller.signal,
|
|
396
|
+
onProgress: (update) => {
|
|
397
|
+
job.progress = update;
|
|
398
|
+
emit(job);
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
340
402
|
|
|
341
403
|
job.status = 'done';
|
|
342
404
|
job.finishedAt = new Date().toISOString();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Remote render dispatch — feature-cloud-export-render-workers (DDR-230).
|
|
2
|
+
//
|
|
3
|
+
// In a workspace cell the browser-needing exporters cannot run: no browser
|
|
4
|
+
// enters the image (DDR-209 A′1, unchanged). What a cell CAN do is resolve the
|
|
5
|
+
// export's scope against its own checkout (Target is pure data) and hand the
|
|
6
|
+
// fully-resolved job to the `maude-render` service, which holds a Chromium and
|
|
7
|
+
// NOTHING ELSE — no HUB_SECRET, no tenant store, no provider keys (DDR-230).
|
|
8
|
+
// The service reaches the canvas exactly the way the member's browser does:
|
|
9
|
+
// through the public canvas origin, authenticated by the member's own
|
|
10
|
+
// short-lived read-only render token, forwarded from the enqueue request.
|
|
11
|
+
//
|
|
12
|
+
// This module is deliberately dumb: one POST, the artifact (or a typed error)
|
|
13
|
+
// back. Progress is coarse in v1 — queued → running → done; the per-frame
|
|
14
|
+
// stream stays a local-lane feature until the service grows a progress channel.
|
|
15
|
+
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import type { ExportDegradation } from './degraded.ts';
|
|
19
|
+
import type { ExportOptions, ExportResult, Format } from './index.ts';
|
|
20
|
+
import type { Target } from './scope.ts';
|
|
21
|
+
|
|
22
|
+
// The render service is the fleet's LEAST-trusted process (DDR-230 — it is the
|
|
23
|
+
// one place tenant TSX is evaluated in Chromium). Everything crossing back from
|
|
24
|
+
// it is untrusted, so its response is sanitized at THIS boundary before it
|
|
25
|
+
// reaches disk or the job record — security-review defender findings, 2026-08-21.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cap on the artifact a render worker may return — a hostile one must not OOM
|
|
29
|
+
* the cell. Read per-call (not a module const) so the deployment can tune it
|
|
30
|
+
* and tests can exercise the ceiling.
|
|
31
|
+
*/
|
|
32
|
+
function artifactCap(): number {
|
|
33
|
+
return Math.max(
|
|
34
|
+
1_000_000,
|
|
35
|
+
Number(process.env.MAUDE_RENDER_MAX_ARTIFACT_BYTES) || 512 * 1024 * 1024
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reduce whatever the render service put in `x-maude-filename` to a safe
|
|
41
|
+
* basename. This value has TWO untrusted sinks (security-review): it lands in
|
|
42
|
+
* `Bun.write(path.join(jobsDir, id, name), …)` (jobs.ts), so `../../…` is an
|
|
43
|
+
* arbitrary-path write; and it is reflected into a `Content-Disposition:
|
|
44
|
+
* attachment; filename="…"` header (http.ts), so a quote or CR/LF is header
|
|
45
|
+
* injection. A strict allowlist charset closes BOTH: keep only letters, digits,
|
|
46
|
+
* dot, dash, underscore — drop everything else (separators, quotes, control
|
|
47
|
+
* chars) — then reject a name that is only dots.
|
|
48
|
+
*/
|
|
49
|
+
function safeArtifactName(raw: string | null, format: Format): string {
|
|
50
|
+
const fallback = `export.${format === 'canva' ? 'zip' : format}`;
|
|
51
|
+
if (!raw) return fallback;
|
|
52
|
+
const base = path.basename(raw).replace(/[^A-Za-z0-9._-]/g, '');
|
|
53
|
+
if (!base || /^\.+$/.test(base)) return fallback;
|
|
54
|
+
return base.slice(0, 255);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read a response body with a hard byte ceiling — `arrayBuffer()` has none. */
|
|
58
|
+
async function readCapped(res: Response, max: number): Promise<Uint8Array> {
|
|
59
|
+
const declared = Number(res.headers.get('content-length'));
|
|
60
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
61
|
+
throw new Error(`render artifact too large (${declared} bytes > ${max})`);
|
|
62
|
+
}
|
|
63
|
+
const reader = res.body?.getReader();
|
|
64
|
+
if (!reader) return new Uint8Array(0);
|
|
65
|
+
const chunks: Uint8Array[] = [];
|
|
66
|
+
let total = 0;
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
total += value.byteLength;
|
|
71
|
+
if (total > max) {
|
|
72
|
+
await reader.cancel().catch(() => {});
|
|
73
|
+
throw new Error(`render artifact exceeded ${max} bytes mid-stream`);
|
|
74
|
+
}
|
|
75
|
+
chunks.push(value);
|
|
76
|
+
}
|
|
77
|
+
const out = new Uint8Array(total);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const c of chunks) {
|
|
80
|
+
out.set(c, offset);
|
|
81
|
+
offset += c.byteLength;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Formats that never touch a browser. `zip` is JSZip over project files — it
|
|
88
|
+
* runs in-cell in every lane. Everything else captures through the Playwright
|
|
89
|
+
* spine and must dispatch (or refuse) in a workspace.
|
|
90
|
+
*/
|
|
91
|
+
export const BROWSER_FREE_FORMATS: ReadonlySet<Format> = new Set<Format>(['zip']);
|
|
92
|
+
|
|
93
|
+
export function formatNeedsBrowser(format: Format): boolean {
|
|
94
|
+
return !BROWSER_FREE_FORMATS.has(format);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Formats the render service cannot produce: `canva` bundles PNG captures WITH
|
|
99
|
+
* project files (CSV, README, sources), and the service holds no tenant store
|
|
100
|
+
* to read them from (DDR-230 §1). In a workspace these refuse with a
|
|
101
|
+
* use-the-desktop remedy on BOTH the `remote` and `none` lanes.
|
|
102
|
+
*/
|
|
103
|
+
export const REMOTE_UNSUPPORTED_FORMATS: ReadonlySet<Format> = new Set<Format>(['canva']);
|
|
104
|
+
|
|
105
|
+
export const REMOTE_UNSUPPORTED_MESSAGE =
|
|
106
|
+
'Canva handoff bundles project files the render service can’t reach — export it from the desktop app.';
|
|
107
|
+
|
|
108
|
+
/** Where the render service lives, from the cell/hub environment (T6 wiring). */
|
|
109
|
+
export interface RenderService {
|
|
110
|
+
/** `MAUDE_RENDER_URL` — origin of the maude-render service. */
|
|
111
|
+
url: string;
|
|
112
|
+
/** `MAUDE_RENDER_SECRET` — shared ingress bearer. Never a hub secret. */
|
|
113
|
+
secret: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** How the render service reaches the tenant's canvas — the member's own grant. */
|
|
117
|
+
export interface RemoteCanvasAccess {
|
|
118
|
+
/** PUBLIC canvas origin (what the member's browser iframes load from). */
|
|
119
|
+
origin: string;
|
|
120
|
+
/** The member's render token (x-maude-canvas-token), forwarded verbatim. */
|
|
121
|
+
token?: string;
|
|
122
|
+
/** designRoot-relative tokens CSS path (`?tokens=` on the shell URL). */
|
|
123
|
+
tokensCssRel?: string;
|
|
124
|
+
/** designRoot-relative shared components CSS path, when the DS has one. */
|
|
125
|
+
componentsCssRel?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveRenderService(env: NodeJS.ProcessEnv = process.env): RenderService | null {
|
|
129
|
+
const url = env.MAUDE_RENDER_URL;
|
|
130
|
+
if (!url) return null;
|
|
131
|
+
return { url: url.replace(/\/+$/, ''), secret: env.MAUDE_RENDER_SECRET ?? '' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The message a lane-`none` refusal carries — surfaced verbatim in the job UI. */
|
|
135
|
+
export const NO_RENDER_SERVICE_MESSAGE =
|
|
136
|
+
'This format needs the render service, which this workspace doesn’t have configured. ' +
|
|
137
|
+
'ZIP still works here; for rendered formats use the desktop app, or add the maude-render ' +
|
|
138
|
+
'service (see the self-hosting docs).';
|
|
139
|
+
|
|
140
|
+
export class NoRenderServiceError extends Error {
|
|
141
|
+
constructor() {
|
|
142
|
+
super(NO_RENDER_SERVICE_MESSAGE);
|
|
143
|
+
this.name = 'NoRenderServiceError';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* POST one fully-resolved export job to the render service and return the
|
|
149
|
+
* artifact as a normal `ExportResult`. Degradation travels back as a JSON
|
|
150
|
+
* header rather than a wrapped body so the artifact bytes stream unmodified.
|
|
151
|
+
*/
|
|
152
|
+
export async function renderRemotely(args: {
|
|
153
|
+
format: Format;
|
|
154
|
+
targets: Target[];
|
|
155
|
+
options: ExportOptions;
|
|
156
|
+
canvas: RemoteCanvasAccess;
|
|
157
|
+
service: RenderService;
|
|
158
|
+
signal?: AbortSignal;
|
|
159
|
+
}): Promise<ExportResult> {
|
|
160
|
+
const { format, targets, options, canvas, service, signal } = args;
|
|
161
|
+
// The exporters' per-navigation timeouts (png.ts goto `--timeout`, etc.)
|
|
162
|
+
// default to ~8s — tuned for the DESKTOP path where the canvas origin is
|
|
163
|
+
// loopback and already warm. The render worker instead loads the canvas from
|
|
164
|
+
// the REMOTE canvas origin, and a cold cell builds the tenant's TSX in its
|
|
165
|
+
// sandbox on first access, so `_canvas-shell.html` can take far longer than
|
|
166
|
+
// 8s to fire `load`. Give the remote path a generous floor (unless the
|
|
167
|
+
// caller asked for more) so a cold-cell first render doesn't time out.
|
|
168
|
+
const remoteOptions: ExportOptions = {
|
|
169
|
+
...options,
|
|
170
|
+
timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
|
|
171
|
+
};
|
|
172
|
+
const res = await fetch(`${service.url}/render`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
signal,
|
|
175
|
+
headers: {
|
|
176
|
+
'content-type': 'application/json',
|
|
177
|
+
authorization: `Bearer ${service.secret}`,
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const detail = (await res.text().catch(() => '')) || `${res.status}`;
|
|
183
|
+
throw new Error(`render service refused the job: ${detail}`);
|
|
184
|
+
}
|
|
185
|
+
// Sanitize the filename BEFORE it can reach `Bun.write` in jobs.ts — the
|
|
186
|
+
// service is untrusted (DDR-230), and this header would otherwise choose the
|
|
187
|
+
// write path.
|
|
188
|
+
const filename = safeArtifactName(res.headers.get('x-maude-filename'), format);
|
|
189
|
+
const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
190
|
+
let degraded: ExportDegradation | undefined;
|
|
191
|
+
const degradedHeader = res.headers.get('x-maude-degraded');
|
|
192
|
+
if (degradedHeader) {
|
|
193
|
+
try {
|
|
194
|
+
degraded = JSON.parse(degradedHeader) as ExportDegradation;
|
|
195
|
+
} catch {
|
|
196
|
+
/* a malformed degradation note must not fail a real artifact */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const body = await readCapped(res, artifactCap());
|
|
200
|
+
return { filename, contentType, body, ...(degraded ? { degraded } : {}) };
|
|
201
|
+
}
|
|
@@ -76,11 +76,16 @@ async function decodeToBitmap(
|
|
|
76
76
|
b64: string,
|
|
77
77
|
mime: 'image/png' | 'image/jpeg' = 'image/png'
|
|
78
78
|
): Promise<ImageBitmap> {
|
|
79
|
-
|
|
79
|
+
// Uint8Array<ArrayBufferLike> vs BlobPart — see saveAsset in api.ts.
|
|
80
|
+
return createImageBitmap(new Blob([b64ToBytes(b64) as Uint8Array<ArrayBuffer>], { type: mime }));
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
let vstate: {
|
|
83
84
|
output: Output;
|
|
85
|
+
// Held alongside `output` because mediabunny's `Output` types `target` as the
|
|
86
|
+
// Target union — the concrete BufferTarget (the only one this encoder ever
|
|
87
|
+
// constructs) is what carries `.buffer`.
|
|
88
|
+
target: BufferTarget;
|
|
84
89
|
source: CanvasSource;
|
|
85
90
|
canvas: OffscreenCanvas;
|
|
86
91
|
ctx: OffscreenCanvasRenderingContext2D;
|
|
@@ -123,7 +128,8 @@ const api: MaudeEnc = {
|
|
|
123
128
|
}
|
|
124
129
|
if (!codec) throw new Error('no encodable video codec available in this browser');
|
|
125
130
|
|
|
126
|
-
const
|
|
131
|
+
const target = new BufferTarget();
|
|
132
|
+
const output = new Output({ format: outFormat, target });
|
|
127
133
|
// `latencyMode: 'quality'` is pinned, not left to the default. Per WebCodecs
|
|
128
134
|
// §7.11 `realtime` explicitly permits the encoder to DROP frames to hit a
|
|
129
135
|
// bitrate/framerate target, while `quality` MUST NOT — and mediabunny flips
|
|
@@ -139,7 +145,7 @@ const api: MaudeEnc = {
|
|
|
139
145
|
output.addVideoTrack(source, { frameRate: fps });
|
|
140
146
|
await output.start();
|
|
141
147
|
const container = outFormat instanceof Mp4OutputFormat ? 'mp4' : 'webm';
|
|
142
|
-
vstate = { output, source, canvas, ctx, fps, frame: 0, container, codec };
|
|
148
|
+
vstate = { output, target, source, canvas, ctx, fps, frame: 0, container, codec };
|
|
143
149
|
return { container, codec };
|
|
144
150
|
},
|
|
145
151
|
|
|
@@ -170,7 +176,7 @@ const api: MaudeEnc = {
|
|
|
170
176
|
async finishVideo() {
|
|
171
177
|
if (!vstate) throw new Error('startVideo not called');
|
|
172
178
|
await vstate.output.finalize();
|
|
173
|
-
const buf = vstate.
|
|
179
|
+
const buf = vstate.target.buffer;
|
|
174
180
|
if (!buf) throw new Error('encode produced no buffer');
|
|
175
181
|
const res: EncodeResult = {
|
|
176
182
|
b64: bytesToB64(buf),
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type Stroke,
|
|
41
41
|
type TextStroke,
|
|
42
42
|
} from '../annotations-model.ts';
|
|
43
|
+
import type { ArrowLineType } from '../canvas-arrowheads.ts';
|
|
43
44
|
import {
|
|
44
45
|
attrValue,
|
|
45
46
|
type Bounds,
|
|
@@ -107,14 +108,15 @@ const CAP_MAP: Readonly<Record<string, 'none' | 'triangle' | 'line'>> = Object.a
|
|
|
107
108
|
}
|
|
108
109
|
);
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
// Values are ArrowLineType — 'curved', not 'curve'. The old 'curve' matched no
|
|
112
|
+
// arm of the renderer's `lineType === 'curved'` checks, so every curved FigJam
|
|
113
|
+
// connector imported as a stroke nothing could draw as curved (it fell through
|
|
114
|
+
// to straight). Typed against the real union so a third spelling cannot recur.
|
|
115
|
+
const LINE_TYPE_MAP: Readonly<Record<string, ArrowLineType>> = Object.assign(Object.create(null), {
|
|
116
|
+
STRAIGHT: 'straight',
|
|
117
|
+
ELBOWED: 'elbow',
|
|
118
|
+
CURVED: 'curved',
|
|
119
|
+
});
|
|
118
120
|
|
|
119
121
|
export interface PendingImage {
|
|
120
122
|
/** The stroke whose `href` must be rewritten once the asset lands. */
|
|
@@ -184,7 +186,7 @@ function imageRef(node: FigmaNode): string | null {
|
|
|
184
186
|
export function nearestStickyColor(hex: string | null): string {
|
|
185
187
|
const target = hex ? hexToRgb01(hex) : null;
|
|
186
188
|
if (!target) return STICKY_PALETTE[0];
|
|
187
|
-
let best = STICKY_PALETTE[0];
|
|
189
|
+
let best: (typeof STICKY_PALETTE)[number] = STICKY_PALETTE[0];
|
|
188
190
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
189
191
|
for (const candidate of STICKY_PALETTE) {
|
|
190
192
|
const c = hexToRgb01(candidate);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Ambient types for `gifenc` — the package ships no declarations, so without
|
|
2
|
+
// this the GIF export lane was `any` end to end (and, before the A2 tsconfig
|
|
3
|
+
// completion, checked by nothing at all).
|
|
4
|
+
//
|
|
5
|
+
// Narrowed to the surface exporters/video-encode-lib.ts actually calls; widen
|
|
6
|
+
// it here rather than casting at a call site.
|
|
7
|
+
|
|
8
|
+
declare module 'gifenc' {
|
|
9
|
+
export type Palette = number[][];
|
|
10
|
+
|
|
11
|
+
export interface WriteFrameOptions {
|
|
12
|
+
palette?: Palette;
|
|
13
|
+
/** Frame delay in milliseconds. */
|
|
14
|
+
delay?: number;
|
|
15
|
+
transparent?: boolean;
|
|
16
|
+
transparentIndex?: number;
|
|
17
|
+
repeat?: number;
|
|
18
|
+
dispose?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GifEncoder {
|
|
22
|
+
writeFrame(
|
|
23
|
+
index: Uint8Array | number[],
|
|
24
|
+
width: number,
|
|
25
|
+
height: number,
|
|
26
|
+
opts?: WriteFrameOptions
|
|
27
|
+
): void;
|
|
28
|
+
finish(): void;
|
|
29
|
+
bytes(): Uint8Array;
|
|
30
|
+
bytesView(): Uint8Array;
|
|
31
|
+
reset(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function GIFEncoder(opts?: { auto?: boolean; initialCapacity?: number }): GifEncoder;
|
|
35
|
+
|
|
36
|
+
export function quantize(
|
|
37
|
+
rgba: Uint8Array | Uint8ClampedArray,
|
|
38
|
+
maxColors: number,
|
|
39
|
+
opts?: {
|
|
40
|
+
format?: 'rgb565' | 'rgb444' | 'rgba4444';
|
|
41
|
+
oneBitAlpha?: boolean;
|
|
42
|
+
clearAlpha?: boolean;
|
|
43
|
+
}
|
|
44
|
+
): Palette;
|
|
45
|
+
|
|
46
|
+
export function applyPalette(
|
|
47
|
+
rgba: Uint8Array | Uint8ClampedArray,
|
|
48
|
+
palette: Palette,
|
|
49
|
+
format?: 'rgb565' | 'rgb444' | 'rgba4444'
|
|
50
|
+
): Uint8Array;
|
|
51
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The shape of a History row — the one answer both committers produce.
|
|
2
|
+
//
|
|
3
|
+
// A commit row is drawn by the same `GitPanel` renderer whether it came from
|
|
4
|
+
// the desktop's own repo (`git/service.ts` → `/_api/git/log`) or from the cloud
|
|
5
|
+
// cell that owns a linked project's history (`apps/hub/src/history.mjs` →
|
|
6
|
+
// `/api/history`). Two spellings of "how a log line is asked for and parsed" is
|
|
7
|
+
// how a cloud row and a local row come to differ in a field the renderer reads
|
|
8
|
+
// — and the drift would surface as a blank author or an unparseable date on ONE
|
|
9
|
+
// of the two surfaces, which is precisely the class of bug nobody notices until
|
|
10
|
+
// the surface they don't use every day is the one that matters.
|
|
11
|
+
//
|
|
12
|
+
// ONE COPY, TWO RUNTIMES (the `apps/hub/Dockerfile` rule). The hub imports this
|
|
13
|
+
// file rather than re-typing it in `.mjs`, exactly as it does for
|
|
14
|
+
// `sync/autocommit.ts`, `sync/canvas-path.ts` and `cloud/mirror.mjs`: re-typing
|
|
15
|
+
// a guarantee is re-typing it without its tests. Hence React-free and
|
|
16
|
+
// dependency-free — no `node:*`, nothing a plain-Node hub or a bun-compiled
|
|
17
|
+
// sidecar has to resolve.
|
|
18
|
+
|
|
19
|
+
/** One commit, as every History surface renders it. */
|
|
20
|
+
export interface GitLogRecord {
|
|
21
|
+
sha: string;
|
|
22
|
+
message: string;
|
|
23
|
+
author: string;
|
|
24
|
+
email: string;
|
|
25
|
+
/** ISO-8601 commit date. */
|
|
26
|
+
date: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unit-separator field delimiter, record-separator line delimiter — survives
|
|
31
|
+
* any message punctuation (a commit subject may contain newlines, tabs, quotes
|
|
32
|
+
* and every separator a "sensible" format would have picked).
|
|
33
|
+
*/
|
|
34
|
+
export const GIT_LOG_FORMAT = '%H%x1f%s%x1f%an%x1f%ae%x1f%aI%x1e';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `git log` argv for `limit` commits, optionally scoped to one `filepath`.
|
|
38
|
+
*
|
|
39
|
+
* The `--` terminator makes `filepath` strictly positional — git can't read it
|
|
40
|
+
* as an option (no argument injection even if it began with a dash, which the
|
|
41
|
+
* caller's containment validation already rejects upstream).
|
|
42
|
+
*
|
|
43
|
+
* THE CALLER MUST ALSO SET `GIT_LITERAL_PATHSPECS=1` whenever `filepath` is
|
|
44
|
+
* present — see `gitLogEnv`. That hardening is a recorded phase-27.1 security
|
|
45
|
+
* re-review outcome, and it lives beside the argv precisely so a second call
|
|
46
|
+
* site cannot pick up one half of the pair.
|
|
47
|
+
*/
|
|
48
|
+
export function gitLogArgs(limit: number, filepath?: string): string[] {
|
|
49
|
+
const args = ['log', `-n${limit}`, `--pretty=format:${GIT_LOG_FORMAT}`];
|
|
50
|
+
if (filepath) args.push('--', filepath);
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The env `gitLogArgs`'s output must run under, or undefined when unscoped.
|
|
56
|
+
*
|
|
57
|
+
* GIT_LITERAL_PATHSPECS — match `filepath` VERBATIM, never as pathspec magic
|
|
58
|
+
* (`:(top)`, `:(exclude)`, globs). Callers already restrict it to the design
|
|
59
|
+
* tree; this makes system-git treat it as a plain path regardless, closing the
|
|
60
|
+
* pathspec-magic surface the `--` terminator alone doesn't.
|
|
61
|
+
*/
|
|
62
|
+
export function gitLogEnv(filepath?: string): { GIT_LITERAL_PATHSPECS: '1' } | undefined {
|
|
63
|
+
return filepath ? { GIT_LITERAL_PATHSPECS: '1' } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse `git log --pretty=format:GIT_LOG_FORMAT` stdout into records.
|
|
68
|
+
*
|
|
69
|
+
* Total and forgiving in one direction only: a record short of its five fields
|
|
70
|
+
* yields empty strings rather than `undefined`, so a renderer never has to
|
|
71
|
+
* defend against a half-parsed row. Garbage in is an empty list, never a throw.
|
|
72
|
+
*/
|
|
73
|
+
export function parseGitLog(stdout: string): GitLogRecord[] {
|
|
74
|
+
return String(stdout ?? '')
|
|
75
|
+
.split('\x1e')
|
|
76
|
+
.map((rec) => rec.replace(/^\n/, '').trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.map((rec) => {
|
|
79
|
+
const [sha, message, author, email, date] = rec.split('\x1f');
|
|
80
|
+
return {
|
|
81
|
+
sha: sha ?? '',
|
|
82
|
+
message: message ?? '',
|
|
83
|
+
author: author ?? '',
|
|
84
|
+
email: email ?? '',
|
|
85
|
+
date: date ?? '',
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One place that decides whether a relative path is safe to hand to git.
|
|
3
|
+
*
|
|
4
|
+
* WHY A HELPER AND NOT A REGEX PER CALL SITE (F-13 / B12). `designRel` and the
|
|
5
|
+
* per-file rels built on top of it reach git from four directions — the status
|
|
6
|
+
* prefix (`git/endpoints.ts`, `git/watch.ts`), the porcelain filter
|
|
7
|
+
* (`git/service.ts`), and the autocommit staging batch (`sync/autocommit.ts`).
|
|
8
|
+
* Three of those grew their own partial normalisation (`normPrefix` strips
|
|
9
|
+
* slashes and nothing else), and a rule spelled four ways is a rule with three
|
|
10
|
+
* chances to be spelled wrong.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS AND IS NOT THE RISK HERE, stated plainly so the next reader does not
|
|
13
|
+
* re-derive it:
|
|
14
|
+
*
|
|
15
|
+
* - **Argv injection is already defused** and this helper is not what defuses
|
|
16
|
+
* it. Every `runGit` / `run` call that carries a dynamic path puts it after
|
|
17
|
+
* a `--` separator, so a rel beginning with `-` is a filename to git, not a
|
|
18
|
+
* flag. The leading-`-` check below is belt-and-braces for a FUTURE call
|
|
19
|
+
* site that forgets the separator — a cheap guard, not the load-bearing one.
|
|
20
|
+
* - **Containment is the real risk**, and `--` does nothing about it. A rel
|
|
21
|
+
* carrying `..` is a perfectly well-formed pathspec that simply names a file
|
|
22
|
+
* outside the design root: `../../.github/workflows/release.yml` staged by
|
|
23
|
+
* an autocommit is a repo-wide write primitive reached from a path that is
|
|
24
|
+
* supposed to be scoped to `.design/`. That is what this refuses.
|
|
25
|
+
*
|
|
26
|
+
* The rules mirror `sync/file-membership.ts`'s `relShape` deliberately — the
|
|
27
|
+
* file plane already refuses exactly these shapes on the way in, and a second
|
|
28
|
+
* gate that disagreed with the first would just be a bug with two spellings.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Matches `file-membership.ts` MAX_REL_LEN — the same cap, on purpose. */
|
|
32
|
+
export const MAX_GIT_REL_LEN = 512;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Is `rel` safe to use as a git pathspec or as a containment prefix?
|
|
36
|
+
*
|
|
37
|
+
* Total: any input that is not a plainly safe repo-relative path is `false`.
|
|
38
|
+
* Never throws, never normalises — callers that want a normalised value use
|
|
39
|
+
* `safeGitPrefix`.
|
|
40
|
+
*/
|
|
41
|
+
export function isSafeGitRel(rel: unknown): rel is string {
|
|
42
|
+
if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_GIT_REL_LEN) return false;
|
|
43
|
+
// NUL and other control characters: a NUL truncates the path at the syscall
|
|
44
|
+
// boundary, so `assets/a\0../../x` is two different paths depending on who
|
|
45
|
+
// is looking at it.
|
|
46
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
|
|
47
|
+
if (/[\u0000-\u001f\u007f]/.test(rel)) return false;
|
|
48
|
+
// Absolute, Windows-absolute, or backslash-separated — none of these are a
|
|
49
|
+
// repo-relative path, and each reads differently to git than to `path.join`.
|
|
50
|
+
if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return false;
|
|
51
|
+
// A leading `-` at the START of the whole rel. See the docblock: this is the
|
|
52
|
+
// guard for a future call site that forgets `--`.
|
|
53
|
+
if (rel.startsWith('-')) return false;
|
|
54
|
+
for (const seg of rel.split('/')) {
|
|
55
|
+
if (!seg || seg === '.' || seg === '..') return false;
|
|
56
|
+
// A trailing space is invisible in every UI that would show this path and
|
|
57
|
+
// makes two different files look like one.
|
|
58
|
+
if (/ $/.test(seg)) return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalise a design-root prefix, or `null` when it is not safe to use.
|
|
65
|
+
*
|
|
66
|
+
* `''` is a legitimate ANSWER, not a refusal: an absent prefix means "no
|
|
67
|
+
* containment filter", which is what `gitStatus` has always done for a caller
|
|
68
|
+
* that passes none. `null` means the caller supplied something and it was
|
|
69
|
+
* refused — a caller must not silently treat that as "no filter", because that
|
|
70
|
+
* would widen the scope rather than narrow it.
|
|
71
|
+
*/
|
|
72
|
+
export function safeGitPrefix(raw: unknown): string | null {
|
|
73
|
+
if (raw === undefined || raw === null || raw === '') return '';
|
|
74
|
+
if (typeof raw !== 'string') return null;
|
|
75
|
+
const normalised = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
76
|
+
if (normalised === '') return '';
|
|
77
|
+
return isSafeGitRel(normalised) ? normalised : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Split a batch of rels into the ones git may be handed and the ones it may not.
|
|
82
|
+
*
|
|
83
|
+
* Batch-shaped rather than throw-shaped because the autocommit caller must keep
|
|
84
|
+
* making progress on the rest of a batch: one poisoned path is not a reason to
|
|
85
|
+
* stop committing a person's actual work, and a throw there would wedge the
|
|
86
|
+
* whole queue (the failure mode `partitionForStaging` was written to avoid).
|
|
87
|
+
*/
|
|
88
|
+
export function partitionSafeGitRels(rels: readonly string[]): {
|
|
89
|
+
safe: string[];
|
|
90
|
+
refused: string[];
|
|
91
|
+
} {
|
|
92
|
+
const safe: string[] = [];
|
|
93
|
+
const refused: string[] = [];
|
|
94
|
+
for (const rel of rels) (isSafeGitRel(rel) ? safe : refused).push(rel);
|
|
95
|
+
return { safe, refused };
|
|
96
|
+
}
|