@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.
Files changed (162) hide show
  1. package/README.md +6 -6
  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/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,97 @@
1
+ // Phase 27 (epic E2) Task 5 — live dirty-state.
2
+ //
3
+ // Event-driven, NOT polled: piggyback on the existing fs-watch bus (`fs:any`,
4
+ // already debounced 50 ms per-path and already filtered to drop `_server.json` /
5
+ // `_active.json` / `_sync.json` / `_history/`). On a versionable change we
6
+ // recompute `gitStatus` once (trailing-debounced ~300 ms, since git status is
7
+ // heavier than a CSS reload) and emit a `git-status` bus event. ws.ts forwards it
8
+ // to the inspector clients (the shell), which update the Changes-panel count
9
+ // badge + the per-row M/A/D tree badges reactively. No client polling, no extra
10
+ // fs.watch — one recompute per burst of saves.
11
+
12
+ import type { Context } from '../context.ts';
13
+ import { type GitStatusResult, gitStatus } from './service.ts';
14
+
15
+ export interface GitWatch {
16
+ /** Force a recompute + broadcast now (e.g. right after a commit lands). */
17
+ refresh(): void;
18
+ stop(): void;
19
+ }
20
+
21
+ const DEBOUNCE_MS = 300;
22
+
23
+ /** Recompute git-status only for changes to versionable design content — the
24
+ * plan's explicit allowlist (.tsx / .css / .meta.json / annotation SVG / comment
25
+ * + annotation dirs). Skips the noise fs-watch lets through (e.g. an `.html`
26
+ * fixture or a stray editor temp) so we don't fork git for a non-tracked save.
27
+ * `gitStatus` itself still drops Maude runtime state, so this is just an
28
+ * optimization, not a correctness gate. */
29
+ function isVersionable(rel: string): boolean {
30
+ const p = rel.replace(/\\/g, '/');
31
+ if (p.includes('/_comments/') || p.startsWith('_comments/')) return true;
32
+ if (p.includes('/_annotations/') || p.startsWith('_annotations/')) return true;
33
+ return /\.(tsx|css|meta\.json|svg)$/.test(p) || p.endsWith('.annotations.svg');
34
+ }
35
+
36
+ export function createGitWatch(ctx: Context): GitWatch {
37
+ let timer: ReturnType<typeof setTimeout> | null = null;
38
+ let running = false;
39
+ let pending = false;
40
+ let stopped = false;
41
+
42
+ async function recompute() {
43
+ if (stopped) return;
44
+ if (running) {
45
+ // A change landed mid-flight — run once more when the current pass finishes
46
+ // so the final state always reflects the last save.
47
+ pending = true;
48
+ return;
49
+ }
50
+ running = true;
51
+ try {
52
+ const status: GitStatusResult = await gitStatus(ctx.paths.repoRoot, {
53
+ designPrefix: ctx.paths.designRel,
54
+ });
55
+ if (!stopped) ctx.bus.emit('git-status', status);
56
+ } catch (err) {
57
+ console.warn('[git-watch] status failed:', err instanceof Error ? err.message : err);
58
+ } finally {
59
+ running = false;
60
+ if (pending && !stopped) {
61
+ pending = false;
62
+ schedule();
63
+ }
64
+ }
65
+ }
66
+
67
+ function schedule() {
68
+ if (stopped) return;
69
+ if (timer) clearTimeout(timer);
70
+ timer = setTimeout(() => {
71
+ timer = null;
72
+ void recompute();
73
+ }, DEBOUNCE_MS);
74
+ }
75
+
76
+ const unsub = ctx.bus.on('fs:any', (rel: string) => {
77
+ if (typeof rel === 'string' && isVersionable(rel)) schedule();
78
+ });
79
+
80
+ return {
81
+ refresh() {
82
+ // Immediate (still single-flight guarded) — used right after a commit so the
83
+ // panel clears without waiting for the next file save.
84
+ void recompute();
85
+ },
86
+ stop() {
87
+ stopped = true;
88
+ if (timer) {
89
+ clearTimeout(timer);
90
+ timer = null;
91
+ }
92
+ unsub();
93
+ },
94
+ };
95
+ }
96
+
97
+ export const __testing = { isVersionable, DEBOUNCE_MS };
@@ -0,0 +1,358 @@
1
+ // github/endpoints.ts — `/_api/github/*` orchestration (Phase 28 / epic E3).
2
+ //
3
+ // Pure-ish handlers behind the GitHub routes: validate inputs, resolve the token
4
+ // from the loopback bridge (token.ts), call the service, and shape a
5
+ // `{ status, json }` result. NO HTTP/Request dependency — http.ts owns the gating
6
+ // (method · main-origin CSRF · loopback Host) and this module owns orchestration,
7
+ // mirroring git/endpoints.ts.
8
+ //
9
+ // SECURITY: every route is main-origin-only by omission from CANVAS_SAFE_API +
10
+ // startCanvasServer's `routes` map (the dual-allowlist rule). The token is fetched
11
+ // per-request from the keychain bridge, used once for the GitHub call, and never
12
+ // logged, echoed, or returned to the client. Inputs (repo name, username) are
13
+ // validated at this boundary before they reach the GitHub API.
14
+
15
+ import fs, { existsSync, mkdirSync, statSync } from 'node:fs';
16
+ import { isAbsolute, join } from 'node:path';
17
+
18
+ import git from 'isomorphic-git';
19
+ import type { Context } from '../context.ts';
20
+ import { gitClone } from '../git/service.ts';
21
+ import { hasDesign, scaffoldDesign } from '../scaffold-design.ts';
22
+ import {
23
+ createRepo,
24
+ GitHubApiError,
25
+ getIdentity,
26
+ inviteCollaborator,
27
+ listUserRepos,
28
+ setRemote,
29
+ } from './service.ts';
30
+ import { getGithubToken } from './token.ts';
31
+
32
+ // A repo/folder NAME safe to join onto a user-chosen parent dir — alphanumeric
33
+ // start, then [A-Za-z0-9._-], no separators or leading dot ⇒ no traversal.
34
+ const SAFE_DIR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
35
+
36
+ export interface GitHubEndpointResult {
37
+ status: number;
38
+ json: unknown;
39
+ }
40
+
41
+ // A GitHub username: 1–39 chars, alphanumeric or single hyphens, no leading/
42
+ // trailing/consecutive hyphen. Anchored — never reaches the API path unvalidated.
43
+ const USERNAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
44
+
45
+ /** Turn a human project name into a valid GitHub repo slug, or null if nothing
46
+ * usable survives. GitHub repo names allow [A-Za-z0-9._-]; we lowercase, map
47
+ * spaces/invalid runs to single hyphens, trim hyphens, and cap at 100. */
48
+ export function slugifyRepoName(raw: unknown): string | null {
49
+ if (typeof raw !== 'string') return null;
50
+ const slug = raw
51
+ .normalize('NFKD')
52
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
53
+ .replace(/-+/g, '-')
54
+ .replace(/^[-.]+|[-.]+$/g, '')
55
+ .slice(0, 100)
56
+ .toLowerCase();
57
+ return slug.length > 0 ? slug : null;
58
+ }
59
+
60
+ /** Parse `owner/repo` out of a GitHub remote URL (https or ssh form).
61
+ *
62
+ * SECURITY (phase-28 audit D-1/F-2, HIGH): the host MUST be anchored to exactly
63
+ * `github.com`. The original `url.match(/github\.com[:/]+…/)` matched the substring
64
+ * ANYWHERE, so `https://evil.com/github.com/o/r.git` parsed as owner=`o` repo=`r`
65
+ * and the raw URL then flowed to `git.clone`'s host-agnostic `onAuth` → the keychain
66
+ * PAT was offered as Basic auth to the attacker host (token exfiltration). We parse
67
+ * the https form with the WHATWG URL parser and assert `hostname === 'github.com'`;
68
+ * callers MUST clone from the canonical URL rebuilt from the returned owner/repo,
69
+ * never the raw input. */
70
+ export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
71
+ const trimmed = String(url).trim();
72
+ let owner: string | undefined;
73
+ let repo: string | undefined;
74
+ // ssh form: git@github.com:owner/repo(.git) — host is literal here.
75
+ const ssh = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?\/?$/i);
76
+ if (ssh) {
77
+ owner = ssh[1];
78
+ repo = ssh[2];
79
+ } else {
80
+ let u: URL;
81
+ try {
82
+ u = new URL(trimmed);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (u.protocol !== 'https:') return null; // no http downgrade, no ssh:// smuggling
87
+ if (u.hostname.toLowerCase() !== 'github.com') return null; // ANCHORED host
88
+ const parts = u.pathname.replace(/^\/+/, '').split('/');
89
+ if (parts.length < 2) return null;
90
+ owner = parts[0];
91
+ repo = parts[1].replace(/\.git$/i, '');
92
+ }
93
+ if (!owner || !repo) return null;
94
+ if (!USERNAME_RE.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) return null;
95
+ return { owner, repo };
96
+ }
97
+
98
+ /** Canonical HTTPS clone URL for a validated owner/repo — the ONLY URL a caller
99
+ * should hand to git.clone (never raw user input; see parseGitHubRemote security). */
100
+ export function canonicalGitHubCloneUrl(owner: string, repo: string): string {
101
+ return `https://github.com/${owner}/${repo}.git`;
102
+ }
103
+
104
+ export interface GitHubEndpoints {
105
+ identity(): Promise<GitHubEndpointResult>;
106
+ repos(): Promise<GitHubEndpointResult>;
107
+ createRepo(body: unknown): Promise<GitHubEndpointResult>;
108
+ createProject(body: unknown): Promise<GitHubEndpointResult>;
109
+ invite(body: unknown): Promise<GitHubEndpointResult>;
110
+ clone(body: unknown): Promise<GitHubEndpointResult>;
111
+ initDesign(body: unknown): Promise<GitHubEndpointResult>;
112
+ }
113
+
114
+ /** Validate a user-chosen parent dir (from the native folder picker). */
115
+ function validParentDir(v: unknown): v is string {
116
+ return typeof v === 'string' && isAbsolute(v) && existsSync(v) && statSync(v).isDirectory();
117
+ }
118
+
119
+ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
120
+ const repoDir = ctx.paths.repoRoot;
121
+
122
+ function bad(error: string): GitHubEndpointResult {
123
+ return { status: 400, json: { ok: false, error } };
124
+ }
125
+
126
+ /** Resolve the token, run `fn`, and map any GitHubApiError to a JSON result. */
127
+ async function withToken(
128
+ fn: (token: string) => Promise<GitHubEndpointResult>
129
+ ): Promise<GitHubEndpointResult> {
130
+ const token = await getGithubToken();
131
+ if (!token) {
132
+ return {
133
+ status: 401,
134
+ json: { ok: false, signedIn: false, error: 'Sign in with GitHub in the Maude app first.' },
135
+ };
136
+ }
137
+ try {
138
+ return await fn(token);
139
+ } catch (e) {
140
+ if (e instanceof GitHubApiError) {
141
+ // status 0 = network → 502; otherwise pass GitHub's status through.
142
+ return { status: e.status === 0 ? 502 : e.status, json: { ok: false, error: e.message } };
143
+ }
144
+ return { status: 502, json: { ok: false, error: 'GitHub request failed. Try again.' } };
145
+ }
146
+ }
147
+
148
+ async function identity(): Promise<GitHubEndpointResult> {
149
+ return withToken(async (token) => ({
150
+ status: 200,
151
+ json: { ok: true, ...(await getIdentity(token)) },
152
+ }));
153
+ }
154
+
155
+ async function repos(): Promise<GitHubEndpointResult> {
156
+ return withToken(async (token) => ({
157
+ status: 200,
158
+ json: { ok: true, repos: await listUserRepos(token) },
159
+ }));
160
+ }
161
+
162
+ async function createRepoHandler(body: unknown): Promise<GitHubEndpointResult> {
163
+ const b = (body ?? {}) as { name?: unknown; private?: unknown; description?: unknown };
164
+ const name = slugifyRepoName(b.name);
165
+ if (!name) return bad('Enter a project name (letters, numbers, hyphens).');
166
+ if (b.private != null && typeof b.private !== 'boolean') return bad('Invalid visibility.');
167
+ const isPrivate = b.private !== false; // default PRIVATE for non-technical safety
168
+ let description: string | undefined;
169
+ if (b.description != null) {
170
+ if (typeof b.description !== 'string' || b.description.length > 350)
171
+ return bad('Description is too long.');
172
+ description = b.description;
173
+ }
174
+ return withToken(async (token) => {
175
+ const repo = await createRepo(token, { name, private: isPrivate, description });
176
+ // Point the local project's origin at the new repo so the next Publish pushes
177
+ // to it. iso-git, no network. Failure here is non-fatal to repo creation.
178
+ let remoteSet = true;
179
+ try {
180
+ await setRemote(repoDir, repo.clone_url);
181
+ } catch {
182
+ remoteSet = false;
183
+ }
184
+ return { status: 200, json: { ok: true, repo, remoteSet } };
185
+ });
186
+ }
187
+
188
+ async function invite(body: unknown): Promise<GitHubEndpointResult> {
189
+ const b = (body ?? {}) as { username?: unknown };
190
+ if (typeof b.username !== 'string') return bad('Enter a GitHub username.');
191
+ const username = b.username.trim().replace(/^@/, '');
192
+ if (!USERNAME_RE.test(username)) return bad('That doesn’t look like a GitHub username.');
193
+
194
+ // owner/repo come from the CURRENT project's origin remote — the client never
195
+ // supplies them, so you can only ever invite to the project you have open.
196
+ const remotes = await git.listRemotes({ fs, dir: repoDir }).catch(() => []);
197
+ const origin = remotes.find((r) => r.remote === 'origin');
198
+ if (!origin) {
199
+ return {
200
+ status: 409,
201
+ json: {
202
+ ok: false,
203
+ error: 'This project isn’t on GitHub yet — create or open it on GitHub first.',
204
+ },
205
+ };
206
+ }
207
+ const parsed = parseGitHubRemote(origin.url);
208
+ if (!parsed)
209
+ return { status: 409, json: { ok: false, error: 'This project isn’t a GitHub project.' } };
210
+
211
+ return withToken(async (token) => {
212
+ const res = await inviteCollaborator(token, parsed.owner, parsed.repo, username);
213
+ return { status: 200, json: { ok: true, invited: res.invited, username } };
214
+ });
215
+ }
216
+
217
+ // "Pull a local copy": clone a GitHub project into a user-chosen folder. The
218
+ // parentDir comes from the NATIVE folder picker (a Tauri command), and every
219
+ // input is validated before it reaches the filesystem / iso-git.
220
+ async function clone(body: unknown): Promise<GitHubEndpointResult> {
221
+ const b = (body ?? {}) as { cloneUrl?: unknown; parentDir?: unknown; name?: unknown };
222
+ const parsed = typeof b.cloneUrl === 'string' ? parseGitHubRemote(b.cloneUrl) : null;
223
+ if (!parsed) {
224
+ return bad('That isn’t a GitHub project link.');
225
+ }
226
+ // SECURITY (audit D-1/F-2): clone from the CANONICAL github.com URL rebuilt from
227
+ // the validated owner/repo — NEVER the raw user string. Even if a crafted link
228
+ // slipped past the parser, the token can only ever be offered to github.com.
229
+ const cloneUrl = canonicalGitHubCloneUrl(parsed.owner, parsed.repo);
230
+ if (typeof b.name !== 'string' || !SAFE_DIR_NAME.test(b.name))
231
+ return bad('Invalid project name.');
232
+ if (
233
+ typeof b.parentDir !== 'string' ||
234
+ !isAbsolute(b.parentDir) ||
235
+ !existsSync(b.parentDir) ||
236
+ !statSync(b.parentDir).isDirectory()
237
+ ) {
238
+ return bad('Pick a folder on your computer to save the project in.');
239
+ }
240
+ const target = join(b.parentDir, b.name);
241
+ if (existsSync(target)) {
242
+ return {
243
+ status: 409,
244
+ json: { ok: false, error: `A folder named “${b.name}” already exists there.` },
245
+ };
246
+ }
247
+ // Token OPTIONAL — public repos clone tokenless; private repos surface
248
+ // authRequired from the service if no token is available.
249
+ const token = (await getGithubToken()) ?? undefined;
250
+ const res = await gitClone(cloneUrl, target, token);
251
+ if (res.ok) {
252
+ // Tell the client whether the pulled repo is actually a Maude project. If not,
253
+ // the client must NOT switch to it (the dev-server can't serve a project with
254
+ // no .design/ — it fails loud) — it shows a "set it up" message instead.
255
+ return { status: 200, json: { ok: true, path: target, hasDesign: hasDesign(target) } };
256
+ }
257
+ if (res.authRequired) {
258
+ return { status: 401, json: { ok: false, authRequired: true, error: res.error } };
259
+ }
260
+ return {
261
+ status: 502,
262
+ json: { ok: false, error: res.error ?? 'Could not download the project.' },
263
+ };
264
+ }
265
+
266
+ // "New project" — create a GitHub repo AND a ready-to-design local project:
267
+ // create repo → make <parentDir>/<slug> → git init → scaffold a bootable .design/
268
+ // → point origin at the repo. No push here — the user Publishes (pushes) when
269
+ // ready, exactly like every other change. Returns the path to open.
270
+ async function createProject(body: unknown): Promise<GitHubEndpointResult> {
271
+ const b = (body ?? {}) as {
272
+ name?: unknown;
273
+ private?: unknown;
274
+ description?: unknown;
275
+ parentDir?: unknown;
276
+ };
277
+ const slug = slugifyRepoName(b.name);
278
+ if (!slug) return bad('Enter a project name (letters, numbers, hyphens).');
279
+ if (b.private != null && typeof b.private !== 'boolean') return bad('Invalid visibility.');
280
+ if (!validParentDir(b.parentDir))
281
+ return bad('Pick a folder on your computer to save the project in.');
282
+ let description: string | undefined;
283
+ if (b.description != null) {
284
+ if (typeof b.description !== 'string' || b.description.length > 350)
285
+ return bad('Description is too long.');
286
+ description = b.description;
287
+ }
288
+ const target = join(b.parentDir, slug);
289
+ if (existsSync(target)) {
290
+ return {
291
+ status: 409,
292
+ json: { ok: false, error: `A folder named “${slug}” already exists there.` },
293
+ };
294
+ }
295
+ const humanName = typeof b.name === 'string' ? b.name.trim() : slug;
296
+ return withToken(async (token) => {
297
+ const repo = await createRepo(token, {
298
+ name: slug,
299
+ private: b.private !== false,
300
+ description,
301
+ });
302
+ try {
303
+ mkdirSync(target, { recursive: true });
304
+ await git.init({ fs, dir: target, defaultBranch: 'main' });
305
+ const s = scaffoldDesign(target, humanName);
306
+ if (!s.ok)
307
+ return {
308
+ status: 500,
309
+ json: { ok: false, error: s.error ?? 'Could not set up the project.' },
310
+ };
311
+ await setRemote(target, repo.clone_url);
312
+ } catch (e) {
313
+ return {
314
+ status: 500,
315
+ json: {
316
+ ok: false,
317
+ error: e instanceof Error ? e.message : 'Could not set up the project.',
318
+ },
319
+ };
320
+ }
321
+ return { status: 200, json: { ok: true, path: target, repo } };
322
+ });
323
+ }
324
+
325
+ // Fallback for "open a folder/repo that isn't a Maude project yet" — scaffold a
326
+ // bootable .design/ into an existing folder so it can open (no GitHub, no token).
327
+ async function initDesign(body: unknown): Promise<GitHubEndpointResult> {
328
+ const b = (body ?? {}) as { dir?: unknown };
329
+ if (!validParentDir(b.dir)) return bad('That folder doesn’t exist.');
330
+ if (hasDesign(b.dir as string)) {
331
+ return { status: 200, json: { ok: true, alreadyDesign: true } };
332
+ }
333
+ const s = scaffoldDesign(b.dir as string);
334
+ if (!s.ok)
335
+ return {
336
+ status: 500,
337
+ json: { ok: false, error: s.error ?? 'Could not set up the project.' },
338
+ };
339
+ return { status: 200, json: { ok: true } };
340
+ }
341
+
342
+ return {
343
+ identity,
344
+ repos,
345
+ createRepo: createRepoHandler,
346
+ createProject,
347
+ invite,
348
+ clone,
349
+ initDesign,
350
+ };
351
+ }
352
+
353
+ export const __testing = {
354
+ slugifyRepoName,
355
+ parseGitHubRemote,
356
+ canonicalGitHubCloneUrl,
357
+ USERNAME_RE,
358
+ };
@@ -0,0 +1,231 @@
1
+ // github/service.ts — the GitHub REST + git-remote operations behind /_api/github/*
2
+ // (Phase 28 / epic E3 / DDR-108).
3
+ //
4
+ // DELIBERATE DEVIATION from the plan's "@octokit/rest": these are four simple REST
5
+ // calls (identity · create-repo · invite · list-repos) plus one local iso-git
6
+ // remote write. A hand-rolled `fetch` client keeps the bun --compile sidecar lean
7
+ // (no large mixed-module dep to bundle), gives full control over the non-technical
8
+ // error copy, and reuses the isomorphic-git engine already shipped in phase-27 for
9
+ // `setRemote`. Recorded in DDR-114. Bun provides global `fetch` + AbortSignal.
10
+ //
11
+ // SECURITY: the token is passed in (resolved by the endpoint layer from the
12
+ // loopback bridge — token.ts), used only as the `Authorization: Bearer` header /
13
+ // the iso-git `onAuth` username, and never logged or echoed. GitHub error bodies
14
+ // are mapped to friendly messages so raw API noise never reaches the UI.
15
+
16
+ import fs from 'node:fs';
17
+
18
+ import git from 'isomorphic-git';
19
+
20
+ const API = 'https://api.github.com';
21
+ const UA = 'maude-desktop';
22
+ const API_VERSION = '2022-11-28';
23
+
24
+ export interface GitHubIdentity {
25
+ login: string;
26
+ name: string | null;
27
+ avatar_url: string;
28
+ }
29
+
30
+ export interface CreatedRepo {
31
+ full_name: string;
32
+ clone_url: string;
33
+ html_url: string;
34
+ default_branch: string;
35
+ owner: string;
36
+ }
37
+
38
+ export interface RepoSummary {
39
+ name: string;
40
+ full_name: string;
41
+ owner: string;
42
+ private: boolean;
43
+ html_url: string;
44
+ clone_url: string;
45
+ updated_at: string;
46
+ }
47
+
48
+ export interface InviteResult {
49
+ /** true = a brand-new invitation was created; false = already had access. */
50
+ invited: boolean;
51
+ }
52
+
53
+ /** A GitHub API failure carrying a status + a message safe to show a non-technical user. */
54
+ export class GitHubApiError extends Error {
55
+ status: number;
56
+ constructor(status: number, message: string) {
57
+ super(message);
58
+ this.name = 'GitHubApiError';
59
+ this.status = status;
60
+ }
61
+ }
62
+
63
+ /** Map a failed GitHub response to a friendly, non-technical message. */
64
+ function friendly(status: number, gh: { message?: string } | null, fallback: string): string {
65
+ if (status === 401) return 'Your GitHub sign-in expired. Sign in again to continue.';
66
+ if (status === 403) {
67
+ const m = gh?.message ?? '';
68
+ if (/rate limit/i.test(m))
69
+ return 'GitHub is rate-limiting requests. Try again in a few minutes.';
70
+ return "GitHub didn't allow that — you may not have permission.";
71
+ }
72
+ if (status === 404) return "GitHub couldn't find that — it may be private or renamed.";
73
+ return fallback;
74
+ }
75
+
76
+ interface ApiOpts {
77
+ method?: string;
78
+ body?: unknown;
79
+ /** Status codes (besides 2xx) to treat as success and return the response for. */
80
+ okStatuses?: number[];
81
+ }
82
+
83
+ async function api(token: string, path: string, opts: ApiOpts = {}): Promise<Response> {
84
+ const { method = 'GET', body, okStatuses = [] } = opts;
85
+ let res: Response;
86
+ try {
87
+ res = await fetch(`${API}${path}`, {
88
+ method,
89
+ headers: {
90
+ Authorization: `Bearer ${token}`,
91
+ Accept: 'application/vnd.github+json',
92
+ 'X-GitHub-Api-Version': API_VERSION,
93
+ 'User-Agent': UA,
94
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
95
+ },
96
+ body: body !== undefined ? JSON.stringify(body) : undefined,
97
+ signal: AbortSignal.timeout(20_000),
98
+ });
99
+ } catch {
100
+ throw new GitHubApiError(
101
+ 0,
102
+ 'Could not reach GitHub. Check your internet connection and try again.'
103
+ );
104
+ }
105
+ if (res.ok || okStatuses.includes(res.status)) return res;
106
+
107
+ let gh: { message?: string } | null = null;
108
+ try {
109
+ gh = (await res.clone().json()) as { message?: string };
110
+ } catch {
111
+ gh = null;
112
+ }
113
+ throw new GitHubApiError(
114
+ res.status,
115
+ friendly(res.status, gh, 'GitHub request failed. Try again.')
116
+ );
117
+ }
118
+
119
+ /** The signed-in user's public profile (login + name + avatar). */
120
+ export async function getIdentity(token: string): Promise<GitHubIdentity> {
121
+ const res = await api(token, '/user');
122
+ const u = (await res.json()) as { login: string; name: string | null; avatar_url: string };
123
+ return { login: u.login, name: u.name ?? null, avatar_url: u.avatar_url };
124
+ }
125
+
126
+ export interface CreateRepoInput {
127
+ name: string;
128
+ private: boolean;
129
+ description?: string;
130
+ /** Seed an initial commit (README) so the new repo is immediately clonable. */
131
+ autoInit?: boolean;
132
+ }
133
+
134
+ /** Create a repo under the authenticated user. */
135
+ export async function createRepo(token: string, input: CreateRepoInput): Promise<CreatedRepo> {
136
+ const res = await api(token, '/user/repos', {
137
+ method: 'POST',
138
+ body: {
139
+ name: input.name,
140
+ private: input.private,
141
+ description: input.description ?? '',
142
+ auto_init: input.autoInit ?? false,
143
+ },
144
+ }).catch((e: unknown) => {
145
+ // 422 = name already taken / invalid — give the precise non-technical reason.
146
+ if (e instanceof GitHubApiError && e.status === 422) {
147
+ throw new GitHubApiError(422, 'You already have a project with that name — pick another.');
148
+ }
149
+ throw e;
150
+ });
151
+ const r = (await res.json()) as {
152
+ full_name: string;
153
+ clone_url: string;
154
+ html_url: string;
155
+ default_branch: string;
156
+ owner: { login: string };
157
+ };
158
+ return {
159
+ full_name: r.full_name,
160
+ clone_url: r.clone_url,
161
+ html_url: r.html_url,
162
+ default_branch: r.default_branch,
163
+ owner: r.owner.login,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Point the local project's `origin` at `remoteUrl` (iso-git, no network). Replaces
169
+ * any existing remote of the same name so re-running "create project" is idempotent.
170
+ */
171
+ export async function setRemote(
172
+ repoDir: string,
173
+ remoteUrl: string,
174
+ remote = 'origin'
175
+ ): Promise<void> {
176
+ await git.deleteRemote({ fs, dir: repoDir, remote }).catch(() => {
177
+ /* no existing remote — fine */
178
+ });
179
+ await git.addRemote({ fs, dir: repoDir, remote, url: remoteUrl });
180
+ }
181
+
182
+ /** Invite a collaborator by GitHub username (default: push/can-edit access). */
183
+ export async function inviteCollaborator(
184
+ token: string,
185
+ owner: string,
186
+ repo: string,
187
+ username: string,
188
+ permission: 'pull' | 'push' | 'admin' = 'push'
189
+ ): Promise<InviteResult> {
190
+ const res = await api(
191
+ token,
192
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
193
+ { method: 'PUT', body: { permission }, okStatuses: [204] }
194
+ ).catch((e: unknown) => {
195
+ if (e instanceof GitHubApiError && e.status === 404) {
196
+ throw new GitHubApiError(404, `No GitHub user named "${username}". Check the spelling.`);
197
+ }
198
+ if (e instanceof GitHubApiError && e.status === 422) {
199
+ throw new GitHubApiError(422, `Couldn't invite "${username}" — they may already be invited.`);
200
+ }
201
+ throw e;
202
+ });
203
+ // 201 = invitation created; 204 = already a collaborator (no new invite).
204
+ return { invited: res.status === 201 };
205
+ }
206
+
207
+ /** Repos the user owns or collaborates on, most-recently-updated first. */
208
+ export async function listUserRepos(token: string): Promise<RepoSummary[]> {
209
+ const res = await api(
210
+ token,
211
+ '/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator'
212
+ );
213
+ const repos = (await res.json()) as Array<{
214
+ name: string;
215
+ full_name: string;
216
+ owner: { login: string };
217
+ private: boolean;
218
+ html_url: string;
219
+ clone_url: string;
220
+ updated_at: string;
221
+ }>;
222
+ return repos.map((r) => ({
223
+ name: r.name,
224
+ full_name: r.full_name,
225
+ owner: r.owner.login,
226
+ private: r.private,
227
+ html_url: r.html_url,
228
+ clone_url: r.clone_url,
229
+ updated_at: r.updated_at,
230
+ }));
231
+ }