@1agh/maude 0.30.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.
Files changed (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -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 };