@1agh/maude 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -175,6 +175,120 @@ export function resolveSelectionEl(
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Phase 12.2 — style maps for the CSS-knob properties. `authored` is what the
|
|
180
|
+
* element sets INLINE (React renders `style={{padding:8}}` → `style="padding:8px"`),
|
|
181
|
+
* so the knob pre-fills the EDITABLE source value and is blank when unset — not
|
|
182
|
+
* the noisy resolved default (`656.003px`, `rgb(0,0,0)`). `computed` is the
|
|
183
|
+
* resolved value, shown only as a faint placeholder hint. Empty for a detached
|
|
184
|
+
* node / SSR.
|
|
185
|
+
*/
|
|
186
|
+
// Phase 12.3 (W2.2 fix) — EVERY property the CSS panel has a control for, so each
|
|
187
|
+
// reads back into `authored` (pre-fills the right control) and is excluded from
|
|
188
|
+
// `customStyles`. The earlier short list omitted the box-model LONGHANDS
|
|
189
|
+
// (`padding-top`, `margin-left`, …) + the Layout/border longhands, so a value the
|
|
190
|
+
// panel wrote (e.g. an alt-scrub `padding-top`) fell through to customStyles and
|
|
191
|
+
// the box-model widget showed it as a "custom CSS property" instead of in the box.
|
|
192
|
+
const KNOB_PROPS = [
|
|
193
|
+
// Layout
|
|
194
|
+
'display',
|
|
195
|
+
'flex-direction',
|
|
196
|
+
'align-items',
|
|
197
|
+
'justify-content',
|
|
198
|
+
'gap',
|
|
199
|
+
// Typography
|
|
200
|
+
'font-family',
|
|
201
|
+
'color',
|
|
202
|
+
'font-size',
|
|
203
|
+
'font-weight',
|
|
204
|
+
'line-height',
|
|
205
|
+
'letter-spacing',
|
|
206
|
+
'text-align',
|
|
207
|
+
// Spacing — shorthand (for customStyles exclusion + whole-side authoring) AND
|
|
208
|
+
// the 8 longhands the box-model widget actually reads/writes.
|
|
209
|
+
'margin',
|
|
210
|
+
'margin-top',
|
|
211
|
+
'margin-right',
|
|
212
|
+
'margin-bottom',
|
|
213
|
+
'margin-left',
|
|
214
|
+
'padding',
|
|
215
|
+
'padding-top',
|
|
216
|
+
'padding-right',
|
|
217
|
+
'padding-bottom',
|
|
218
|
+
'padding-left',
|
|
219
|
+
// Size
|
|
220
|
+
'width',
|
|
221
|
+
'height',
|
|
222
|
+
'max-width',
|
|
223
|
+
// Appearance
|
|
224
|
+
'background-color',
|
|
225
|
+
'border-radius',
|
|
226
|
+
'border-top-left-radius',
|
|
227
|
+
'border-top-right-radius',
|
|
228
|
+
'border-bottom-left-radius',
|
|
229
|
+
'border-bottom-right-radius',
|
|
230
|
+
'border-width',
|
|
231
|
+
'border-style',
|
|
232
|
+
'border-color',
|
|
233
|
+
'box-shadow',
|
|
234
|
+
'opacity',
|
|
235
|
+
] as const;
|
|
236
|
+
|
|
237
|
+
// Phase 12.3 — HTML attributes the custom-attribute hatch may have written, so a
|
|
238
|
+
// just-added `data-*`/`aria-*`/`role`/`title` round-trips back into a panel row.
|
|
239
|
+
// Excludes the structural ones the panel manages elsewhere (style, class,
|
|
240
|
+
// data-cd-id pipeline anchor, the data-dc-* canvas chrome markers).
|
|
241
|
+
const ATTR_SKIP = /^(style|class|data-cd-id|data-dc-|data-dcid$)/;
|
|
242
|
+
|
|
243
|
+
function styleMapsFor(el: Element | null): {
|
|
244
|
+
authored: Record<string, string>;
|
|
245
|
+
computed: Record<string, string>;
|
|
246
|
+
customStyles: Record<string, string>;
|
|
247
|
+
attrs: Record<string, string>;
|
|
248
|
+
} {
|
|
249
|
+
if (!el || typeof window === 'undefined' || !window.getComputedStyle) {
|
|
250
|
+
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const inline = (el as HTMLElement).style;
|
|
254
|
+
const cs = window.getComputedStyle(el as HTMLElement);
|
|
255
|
+
const authored: Record<string, string> = {};
|
|
256
|
+
const computed: Record<string, string> = {};
|
|
257
|
+
const knob = new Set<string>(KNOB_PROPS as readonly string[]);
|
|
258
|
+
// Curated knob props: authored value (knob pre-fill) + computed (placeholder).
|
|
259
|
+
for (const p of KNOB_PROPS) {
|
|
260
|
+
const a = inline.getPropertyValue(p);
|
|
261
|
+
if (a) authored[p] = a.trim();
|
|
262
|
+
const c = cs.getPropertyValue(p);
|
|
263
|
+
if (c) computed[p] = c.trim();
|
|
264
|
+
}
|
|
265
|
+
// Every OTHER authored inline property → customStyles, so the panel can show
|
|
266
|
+
// a custom CSS property the user added that no curated row covers. EXCLUDE
|
|
267
|
+
// the panel-managed FAMILIES wholesale: setting `border`/`border-radius` (or
|
|
268
|
+
// their 3-way shorthands) makes the CSSOM expand them to the per-side
|
|
269
|
+
// longhands — `border-top-width`, `border-left-color`, … — which the panel
|
|
270
|
+
// controls but can't enumerate in the knob set. Without the family guard they
|
|
271
|
+
// leak into "custom CSS properties" (the same class of bug fixed for spacing).
|
|
272
|
+
const MANAGED_FAMILY = /^(margin|padding|border)(-|$)/;
|
|
273
|
+
const customStyles: Record<string, string> = {};
|
|
274
|
+
for (let i = 0; i < inline.length; i++) {
|
|
275
|
+
const p = inline.item(i);
|
|
276
|
+
if (!p || knob.has(p) || MANAGED_FAMILY.test(p)) continue;
|
|
277
|
+
const v = inline.getPropertyValue(p);
|
|
278
|
+
if (v) customStyles[p] = v.trim();
|
|
279
|
+
}
|
|
280
|
+
// Custom HTML attributes (the escape-hatch surface).
|
|
281
|
+
const attrs: Record<string, string> = {};
|
|
282
|
+
for (const a of Array.from((el as HTMLElement).attributes)) {
|
|
283
|
+
if (ATTR_SKIP.test(a.name)) continue;
|
|
284
|
+
attrs[a.name] = a.value;
|
|
285
|
+
}
|
|
286
|
+
return { authored, computed, customStyles, attrs };
|
|
287
|
+
} catch {
|
|
288
|
+
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
178
292
|
export function hoverTargetToSelection(target: HoverTarget, file?: string): Selection {
|
|
179
293
|
const el = target.el;
|
|
180
294
|
const rect =
|
|
@@ -223,5 +337,6 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
|
|
|
223
337
|
}
|
|
224
338
|
: null,
|
|
225
339
|
html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
|
|
340
|
+
...styleMapsFor(el),
|
|
226
341
|
};
|
|
227
342
|
}
|
|
@@ -321,6 +321,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
321
321
|
}, [openState, loadHistory]);
|
|
322
322
|
|
|
323
323
|
// ⌘E / Ctrl+E to open; ⌘⇧E / Ctrl+Shift+E to re-run last.
|
|
324
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-bind on `open` only — rerunLast is stable per render; re-running on it would tear down the global hotkey listener needlessly.
|
|
324
325
|
useEffect(() => {
|
|
325
326
|
function onKey(e: KeyboardEvent) {
|
|
326
327
|
const mod = e.metaKey || e.ctrlKey;
|
|
@@ -331,7 +332,6 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
331
332
|
}
|
|
332
333
|
window.addEventListener('keydown', onKey);
|
|
333
334
|
return () => window.removeEventListener('keydown', onKey);
|
|
334
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
335
335
|
}, [open]);
|
|
336
336
|
|
|
337
337
|
// Phase 6.5 T9 — context-menu entries dispatch `maude:open-export` so they
|
|
@@ -559,8 +559,8 @@ const DialogShell = (() => {
|
|
|
559
559
|
<div className="dc-ed-recent">
|
|
560
560
|
<h3>Recent</h3>
|
|
561
561
|
<ul>
|
|
562
|
-
{history.slice(0, 5).map((h
|
|
563
|
-
<li key={`${h.at}-${
|
|
562
|
+
{history.slice(0, 5).map((h) => (
|
|
563
|
+
<li key={`${h.at}-${h.filename}`}>
|
|
564
564
|
<button
|
|
565
565
|
type="button"
|
|
566
566
|
onClick={() => {
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Phase 27 (epic E2) — `/_api/git/*` orchestration.
|
|
2
|
+
//
|
|
3
|
+
// Pure-ish handlers behind the git routes: validate inputs, expand the DDR-112
|
|
4
|
+
// staging set (a selected canvas auto-stages its same-stem sidecars), call the
|
|
5
|
+
// service, and shape a `{ status, json }` result. NO HTTP/Request dependency —
|
|
6
|
+
// http.ts owns the HTTP gating (method · main-origin · CSRF · loopback) and
|
|
7
|
+
// passes already-parsed inputs in. That split keeps the security boundary in one
|
|
8
|
+
// place (http.ts, mirroring canvas-create.ts) and this module unit-testable +
|
|
9
|
+
// free of an http.ts import cycle.
|
|
10
|
+
//
|
|
11
|
+
// SECURITY: every route is main-origin-only by omission from CANVAS_SAFE_API +
|
|
12
|
+
// startCanvasServer's `routes` map (the dual-allowlist rule). The token on
|
|
13
|
+
// push/pull is used once for the service's `onAuth`/header and never logged,
|
|
14
|
+
// echoed, or persisted (it is stripped from the JSON we return on error).
|
|
15
|
+
|
|
16
|
+
import type { Context } from '../context.ts';
|
|
17
|
+
import { getGithubToken } from '../github/token.ts';
|
|
18
|
+
import {
|
|
19
|
+
type GitFileStatus,
|
|
20
|
+
gitCheckout,
|
|
21
|
+
gitCommit,
|
|
22
|
+
gitCreateBranch,
|
|
23
|
+
gitDiff,
|
|
24
|
+
gitDiscard,
|
|
25
|
+
gitFoldDraft,
|
|
26
|
+
gitListBranches,
|
|
27
|
+
gitLog,
|
|
28
|
+
gitPull,
|
|
29
|
+
gitPush,
|
|
30
|
+
gitResolve,
|
|
31
|
+
gitStatus,
|
|
32
|
+
isContainedRepoPath,
|
|
33
|
+
type ResolveChoice,
|
|
34
|
+
} from './service.ts';
|
|
35
|
+
|
|
36
|
+
export interface GitEndpointResult {
|
|
37
|
+
status: number;
|
|
38
|
+
json: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_MESSAGE = 1000;
|
|
42
|
+
const MAX_FILES = 5000;
|
|
43
|
+
|
|
44
|
+
// A git remote name / ref that is SAFE to pass as a bare argv positional to the
|
|
45
|
+
// system-git engine. The leading-`-` reject is load-bearing: `git push <remote>`
|
|
46
|
+
// / `git fetch <ref>` parse a dash-led positional as an OPTION, so an unvalidated
|
|
47
|
+
// `--upload-pack=…` / `--exec=…` is argument-injection → command execution
|
|
48
|
+
// (CWE-88, the CVE-2017-1000117 family; security review A1/A2). isomorphic-git is
|
|
49
|
+
// immune (values are data, not argv) but we validate at the boundary so BOTH
|
|
50
|
+
// engines are safe regardless of MAUDE_USE_SYSTEM_GIT.
|
|
51
|
+
const GIT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
52
|
+
// A remote NAME is `origin`/`upstream` — never a path, so it excludes `/`
|
|
53
|
+
// (a path-shaped name could otherwise reach git as a local-filesystem
|
|
54
|
+
// transport on the system engine; security re-review hardening note).
|
|
55
|
+
const GIT_REMOTE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
56
|
+
function safeGitArg(v: unknown): string | undefined {
|
|
57
|
+
return typeof v === 'string' && GIT_REF_RE.test(v) ? v : undefined;
|
|
58
|
+
}
|
|
59
|
+
function safeRemoteArg(v: unknown): string | undefined {
|
|
60
|
+
return typeof v === 'string' && GIT_REMOTE_RE.test(v) ? v : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GitEndpoints {
|
|
64
|
+
status(opts?: { checkRemote?: boolean; token?: string }): Promise<GitEndpointResult>;
|
|
65
|
+
commit(body: unknown): Promise<GitEndpointResult>;
|
|
66
|
+
discard(body: unknown): Promise<GitEndpointResult>;
|
|
67
|
+
push(body: unknown): Promise<GitEndpointResult>;
|
|
68
|
+
pull(body: unknown): Promise<GitEndpointResult>;
|
|
69
|
+
resolve(body: unknown): Promise<GitEndpointResult>;
|
|
70
|
+
log(limitRaw: string | null, pathRaw?: string | null): Promise<GitEndpointResult>;
|
|
71
|
+
diff(sha: string | null): Promise<GitEndpointResult>;
|
|
72
|
+
// Phase 29 (E4) — drafts (branches). The UI vocabulary: draft=branch, "Shared
|
|
73
|
+
// version"=main. The current branch is already on the status payload (`branch`).
|
|
74
|
+
branches(): Promise<GitEndpointResult>;
|
|
75
|
+
createBranch(body: unknown): Promise<GitEndpointResult>;
|
|
76
|
+
checkout(body: unknown): Promise<GitEndpointResult>;
|
|
77
|
+
// "Add this draft to the Shared version" — token-bearing (it publishes).
|
|
78
|
+
fold(body: unknown): Promise<GitEndpointResult>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createGitEndpoints(ctx: Context): GitEndpoints {
|
|
82
|
+
const dir = ctx.paths.repoRoot;
|
|
83
|
+
const designPrefix = ctx.paths.designRel;
|
|
84
|
+
|
|
85
|
+
function bad(error: string): GitEndpointResult {
|
|
86
|
+
return { status: 400, json: { ok: false, error } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function status(
|
|
90
|
+
opts: { checkRemote?: boolean; token?: string } = {}
|
|
91
|
+
): Promise<GitEndpointResult> {
|
|
92
|
+
// The "Get latest" remote ahead/behind probe needs a token for private repos.
|
|
93
|
+
// It is NOT client-supplied (A3 — no token in a GET query); we fetch it
|
|
94
|
+
// server-side from the keychain bridge (phase-28). No bridge / not signed in
|
|
95
|
+
// → undefined → local-only status (no remote nudge), never an error.
|
|
96
|
+
const token = opts.checkRemote
|
|
97
|
+
? (opts.token ?? (await getGithubToken()) ?? undefined)
|
|
98
|
+
: opts.token;
|
|
99
|
+
const result = await gitStatus(dir, {
|
|
100
|
+
designPrefix,
|
|
101
|
+
checkRemote: opts.checkRemote,
|
|
102
|
+
token,
|
|
103
|
+
});
|
|
104
|
+
return { status: 200, json: result };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function commit(body: unknown): Promise<GitEndpointResult> {
|
|
108
|
+
const b = (body ?? {}) as { message?: unknown; files?: unknown };
|
|
109
|
+
if (typeof b.message !== 'string') return bad('A version needs a short message.');
|
|
110
|
+
const message = b.message.trim();
|
|
111
|
+
if (!message) return bad('A version needs a short message.');
|
|
112
|
+
if (message.length > MAX_MESSAGE) return bad('That message is too long.');
|
|
113
|
+
|
|
114
|
+
let files: string[] | undefined;
|
|
115
|
+
if (b.files != null) {
|
|
116
|
+
if (!Array.isArray(b.files)) return bad('files must be a list.');
|
|
117
|
+
if (b.files.length > MAX_FILES) return bad('Too many files selected.');
|
|
118
|
+
const reqFiles: string[] = [];
|
|
119
|
+
for (const f of b.files) {
|
|
120
|
+
if (typeof f !== 'string' || !isContainedRepoPath(dir, f)) {
|
|
121
|
+
return bad('A selected file is outside this project.');
|
|
122
|
+
}
|
|
123
|
+
reqFiles.push(f.replace(/\\/g, '/'));
|
|
124
|
+
}
|
|
125
|
+
// DDR-112 — auto-stage each selected canvas's same-stem sidecars (the
|
|
126
|
+
// `.meta.json` layout/viewport state travels with its canvas). Only dirty
|
|
127
|
+
// sidecars are added, so a clean meta isn't needlessly committed.
|
|
128
|
+
const dirty = (await gitStatus(dir, { designPrefix })).files.map((f) => f.path);
|
|
129
|
+
files = expandSidecars(reqFiles, dirty);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await gitCommit(dir, message, files, { designPrefix });
|
|
133
|
+
if (!res.ok) return { status: 400, json: { ok: false, error: res.error } };
|
|
134
|
+
return { status: 200, json: { ok: true, sha: res.sha } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function discard(body: unknown): Promise<GitEndpointResult> {
|
|
138
|
+
const b = (body ?? {}) as { files?: unknown };
|
|
139
|
+
if (!Array.isArray(b.files) || b.files.length === 0) return bad('Select files to discard.');
|
|
140
|
+
if (b.files.length > MAX_FILES) return bad('Too many files selected.');
|
|
141
|
+
const files: string[] = [];
|
|
142
|
+
for (const f of b.files) {
|
|
143
|
+
if (typeof f !== 'string' || !isContainedRepoPath(dir, f)) {
|
|
144
|
+
return bad('A selected file is outside this project.');
|
|
145
|
+
}
|
|
146
|
+
files.push(f.replace(/\\/g, '/'));
|
|
147
|
+
}
|
|
148
|
+
const res = await gitDiscard(dir, files, { designPrefix });
|
|
149
|
+
if (!res.ok) return { status: 400, json: { ok: false, error: res.error } };
|
|
150
|
+
return { status: 200, json: { ok: true, discarded: res.discarded } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function push(body: unknown): Promise<GitEndpointResult> {
|
|
154
|
+
// Token resolution (phase-28): an explicit body token → the keychain bridge
|
|
155
|
+
// (signed in via GitHub) → undefined. The bridge token is fetched server-side
|
|
156
|
+
// (never client-supplied), so "Publish" works once you've signed in. With no
|
|
157
|
+
// token the system-git engine still uses the credential helper; iso-git without
|
|
158
|
+
// a token → authRequired → "Sign in to publish".
|
|
159
|
+
const token = readToken(body) ?? (await getGithubToken()) ?? undefined;
|
|
160
|
+
const b = (body ?? {}) as { remote?: unknown; ref?: unknown };
|
|
161
|
+
// Reject a dash-led / malformed remote|ref BEFORE it can reach git argv (A1).
|
|
162
|
+
if (b.remote != null && safeRemoteArg(b.remote) === undefined) return bad('Invalid remote.');
|
|
163
|
+
if (b.ref != null && safeGitArg(b.ref) === undefined) return bad('Invalid draft name.');
|
|
164
|
+
const res = await gitPush(dir, token, {
|
|
165
|
+
remote: safeRemoteArg(b.remote),
|
|
166
|
+
ref: safeGitArg(b.ref),
|
|
167
|
+
});
|
|
168
|
+
if (res.ok) return { status: 200, json: { ok: true } };
|
|
169
|
+
if (res.authRequired) {
|
|
170
|
+
return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
|
|
171
|
+
}
|
|
172
|
+
if (res.conflict) {
|
|
173
|
+
return {
|
|
174
|
+
status: 409,
|
|
175
|
+
json: { ok: false, conflict: true, error: 'Publish rejected — Get latest first.' },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { status: 502, json: { ok: false, error: res.error ?? 'Publish failed.' } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function pull(body: unknown): Promise<GitEndpointResult> {
|
|
182
|
+
const token = readToken(body) ?? (await getGithubToken()) ?? undefined;
|
|
183
|
+
const b = (body ?? {}) as { remote?: unknown; ref?: unknown };
|
|
184
|
+
if (b.remote != null && safeRemoteArg(b.remote) === undefined) return bad('Invalid remote.');
|
|
185
|
+
if (b.ref != null && safeGitArg(b.ref) === undefined) return bad('Invalid draft name.');
|
|
186
|
+
const res = await gitPull(dir, token, {
|
|
187
|
+
remote: safeRemoteArg(b.remote),
|
|
188
|
+
ref: safeGitArg(b.ref),
|
|
189
|
+
});
|
|
190
|
+
if (res.ok) return { status: 200, json: { ok: true } };
|
|
191
|
+
if (res.authRequired) {
|
|
192
|
+
return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
|
|
193
|
+
}
|
|
194
|
+
if (res.conflict) {
|
|
195
|
+
return { status: 409, json: { ok: false, conflict: true, files: res.files ?? [] } };
|
|
196
|
+
}
|
|
197
|
+
return { status: 502, json: { ok: false, error: res.error ?? 'Get latest failed.' } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function resolve(body: unknown): Promise<GitEndpointResult> {
|
|
201
|
+
const token = readToken(body) ?? (await getGithubToken()) ?? undefined;
|
|
202
|
+
const b = (body ?? {}) as { choice?: unknown; remote?: unknown; ref?: unknown };
|
|
203
|
+
const choice = b.choice;
|
|
204
|
+
if (choice !== 'mine' && choice !== 'theirs' && choice !== 'both')
|
|
205
|
+
return bad('Pick how to resolve: keep mine, theirs, or both.');
|
|
206
|
+
if (b.remote != null && safeRemoteArg(b.remote) === undefined) return bad('Invalid remote.');
|
|
207
|
+
if (b.ref != null && safeGitArg(b.ref) === undefined) return bad('Invalid draft name.');
|
|
208
|
+
const res = await gitResolve(dir, choice as ResolveChoice, token, {
|
|
209
|
+
remote: safeRemoteArg(b.remote),
|
|
210
|
+
ref: safeGitArg(b.ref),
|
|
211
|
+
});
|
|
212
|
+
if (res.ok) return { status: 200, json: { ok: true, copies: res.copies ?? [] } };
|
|
213
|
+
if (res.authRequired)
|
|
214
|
+
return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
|
|
215
|
+
if (res.unresolved?.length)
|
|
216
|
+
return { status: 409, json: { ok: false, unresolved: res.unresolved, error: res.error } };
|
|
217
|
+
return { status: 502, json: { ok: false, error: res.error ?? 'Could not finish the merge.' } };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function log(limitRaw: string | null, pathRaw?: string | null): Promise<GitEndpointResult> {
|
|
221
|
+
let limit = 30;
|
|
222
|
+
if (limitRaw != null) {
|
|
223
|
+
const n = Number(limitRaw);
|
|
224
|
+
if (Number.isFinite(n) && n > 0) limit = Math.min(Math.floor(n), 200);
|
|
225
|
+
}
|
|
226
|
+
// Optional per-file scope (phase-27.1 — History click-to-preview + DiffView
|
|
227
|
+
// version picker). A malformed / out-of-tree path is a hard 400, NOT a
|
|
228
|
+
// silent fall-back to the repo-wide log: a bug must never widen the history
|
|
229
|
+
// beyond the file the UI asked for.
|
|
230
|
+
let filepath: string | undefined;
|
|
231
|
+
if (pathRaw != null && pathRaw !== '') {
|
|
232
|
+
// Normalize separators BEFORE the guards so the validated string is byte-
|
|
233
|
+
// for-byte what reaches git (no post-guard mutation re-opening a bypass).
|
|
234
|
+
const p = pathRaw.replace(/\\/g, '/');
|
|
235
|
+
if (!isContainedRepoPath(dir, p)) return bad('That file is outside this project.');
|
|
236
|
+
// History is scoped to the DESIGN TREE (matching status/diff) — not the
|
|
237
|
+
// whole repo. This also rejects pathspec-magic prefixes (`:/`, `:(top)…`,
|
|
238
|
+
// `:(exclude)…`): none start with the designPrefix, so they can never reach
|
|
239
|
+
// system-git as live pathspec magic (defence-in-depth atop the `--`
|
|
240
|
+
// terminator + GIT_LITERAL_PATHSPECS in logSystem).
|
|
241
|
+
if (designPrefix && p !== designPrefix && !p.startsWith(`${designPrefix}/`)) {
|
|
242
|
+
return bad('That file is outside this project.');
|
|
243
|
+
}
|
|
244
|
+
filepath = p;
|
|
245
|
+
}
|
|
246
|
+
return { status: 200, json: { entries: await gitLog(dir, limit, filepath) } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function diff(sha: string | null): Promise<GitEndpointResult> {
|
|
250
|
+
// `sha` comes from a query param; allow only a git-ref-ish token whose FIRST
|
|
251
|
+
// char is alphanumeric — a leading `-` would be parsed by system-git's
|
|
252
|
+
// `git diff <sha> --` as an option, not a rev (argument injection, A2).
|
|
253
|
+
const ref = sha && /^[A-Za-z0-9][A-Za-z0-9_./~^@{}-]{0,199}$/.test(sha) ? sha : 'HEAD';
|
|
254
|
+
return { status: 200, json: { entries: await gitDiff(dir, ref, { designPrefix }) } };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── drafts (branches) — phase 29 / E4 ──────────────────────────────────────
|
|
258
|
+
async function branches(): Promise<GitEndpointResult> {
|
|
259
|
+
return { status: 200, json: { branches: await gitListBranches(dir) } };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function createBranch(body: unknown): Promise<GitEndpointResult> {
|
|
263
|
+
const b = (body ?? {}) as { name?: unknown };
|
|
264
|
+
// Reject a dash-led / malformed draft name BEFORE it can reach git argv (A1).
|
|
265
|
+
const name = safeGitArg(b.name);
|
|
266
|
+
if (name === undefined) return bad("That draft name has characters we can't use.");
|
|
267
|
+
const res = await gitCreateBranch(dir, name);
|
|
268
|
+
if (!res.ok) return { status: 400, json: { ok: false, error: res.error } };
|
|
269
|
+
return { status: 200, json: { ok: true, branch: res.branch } };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function checkout(body: unknown): Promise<GitEndpointResult> {
|
|
273
|
+
const b = (body ?? {}) as { name?: unknown };
|
|
274
|
+
const name = safeGitArg(b.name);
|
|
275
|
+
if (name === undefined) return bad('Invalid draft name.');
|
|
276
|
+
const res = await gitCheckout(dir, name);
|
|
277
|
+
// A dirty tree that blocks the switch is a precondition failure (409) so the
|
|
278
|
+
// UI can say "Save your changes first" distinctly from a 400 validation error.
|
|
279
|
+
if (!res.ok) return { status: 409, json: { ok: false, error: res.error } };
|
|
280
|
+
return { status: 200, json: { ok: true, branch: res.branch } };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function fold(body: unknown): Promise<GitEndpointResult> {
|
|
284
|
+
// Token resolution mirrors push (it publishes the Shared version): body token →
|
|
285
|
+
// keychain bridge → undefined → authRequired.
|
|
286
|
+
const token = readToken(body) ?? (await getGithubToken()) ?? undefined;
|
|
287
|
+
const b = (body ?? {}) as { name?: unknown; remote?: unknown };
|
|
288
|
+
const name = safeGitArg(b.name);
|
|
289
|
+
if (name === undefined) return bad('Invalid draft name.');
|
|
290
|
+
if (b.remote != null && safeRemoteArg(b.remote) === undefined) return bad('Invalid remote.');
|
|
291
|
+
const res = await gitFoldDraft(dir, name, token, { remote: safeRemoteArg(b.remote) });
|
|
292
|
+
if (res.ok) return { status: 200, json: { ok: true, shared: res.shared } };
|
|
293
|
+
if (res.authRequired)
|
|
294
|
+
return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
|
|
295
|
+
if (res.conflict) return { status: 409, json: { ok: false, conflict: true, error: res.error } };
|
|
296
|
+
return { status: 502, json: { ok: false, error: res.error ?? 'Could not add the draft.' } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
status,
|
|
301
|
+
commit,
|
|
302
|
+
discard,
|
|
303
|
+
push,
|
|
304
|
+
pull,
|
|
305
|
+
resolve,
|
|
306
|
+
log,
|
|
307
|
+
diff,
|
|
308
|
+
branches,
|
|
309
|
+
createBranch,
|
|
310
|
+
checkout,
|
|
311
|
+
fold,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Read a non-empty string token from a request body without retaining it. */
|
|
316
|
+
function readToken(body: unknown): string | null {
|
|
317
|
+
const t = (body as { token?: unknown })?.token;
|
|
318
|
+
return typeof t === 'string' && t.length > 0 ? t : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** DDR-112 — expand each selected path to include any DIRTY same-directory,
|
|
322
|
+
* same-stem sidecar (`<stem>.meta.json`, `<stem>.annotations.svg`, …). The `.`
|
|
323
|
+
* delimiter prevents `ui/Pricing` from grabbing `ui/Pricing v3.*`. */
|
|
324
|
+
export function expandSidecars(selected: string[], dirty: string[]): string[] {
|
|
325
|
+
const out = new Set(selected);
|
|
326
|
+
for (const sel of selected) {
|
|
327
|
+
const dot = sel.lastIndexOf('.');
|
|
328
|
+
if (dot === -1) continue;
|
|
329
|
+
const stem = `${sel.slice(0, dot)}.`;
|
|
330
|
+
for (const d of dirty) {
|
|
331
|
+
if (d !== sel && d.startsWith(stem)) out.add(d);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return [...out];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const __testing = { expandSidecars, safeGitArg, safeRemoteArg };
|
|
338
|
+
export type { GitFileStatus };
|