@1agh/maude 0.41.0 → 0.43.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 (260) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -63
  6. package/apps/studio/bin/_html-playwright.mjs +26 -4
  7. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  8. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  9. package/apps/studio/bin/_png-playwright.mjs +15 -2
  10. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  11. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/_video-playwright.mjs +141 -71
  15. package/apps/studio/bin/ingest-footage.sh +27 -0
  16. package/apps/studio/bin/photo-adjust.sh +163 -0
  17. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  18. package/apps/studio/bin/probe-footage.sh +28 -0
  19. package/apps/studio/bin/read-annotations.mjs +125 -2
  20. package/apps/studio/bin/screenshot.sh +53 -4
  21. package/apps/studio/canvas-edit.ts +459 -10
  22. package/apps/studio/canvas-lib.tsx +523 -5
  23. package/apps/studio/canvas-pipeline.ts +65 -7
  24. package/apps/studio/canvas-shell.tsx +156 -16
  25. package/apps/studio/client/app.jsx +476 -88
  26. package/apps/studio/client/export-center.jsx +460 -0
  27. package/apps/studio/client/github.js +4 -0
  28. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  29. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  30. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  31. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  32. package/apps/studio/client/photo-knobs.jsx +437 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +59 -0
  34. package/apps/studio/client/styles/4-components.css +150 -0
  35. package/apps/studio/commands/edit-source-command.ts +31 -4
  36. package/apps/studio/config.schema.json +2 -2
  37. package/apps/studio/context-menu.tsx +31 -6
  38. package/apps/studio/dist/client.bundle.js +5561 -19
  39. package/apps/studio/dist/comment-mount.js +2 -2
  40. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  41. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  42. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/dom-selection.ts +25 -0
  45. package/apps/studio/export-dialog.tsx +18 -25
  46. package/apps/studio/exporters/_runtime.ts +104 -0
  47. package/apps/studio/exporters/html.ts +12 -20
  48. package/apps/studio/exporters/index.ts +14 -2
  49. package/apps/studio/exporters/jobs.ts +376 -0
  50. package/apps/studio/exporters/pdf.ts +16 -20
  51. package/apps/studio/exporters/png.ts +12 -20
  52. package/apps/studio/exporters/pptx.ts +22 -23
  53. package/apps/studio/exporters/scope.ts +1 -0
  54. package/apps/studio/exporters/svg.ts +14 -22
  55. package/apps/studio/exporters/video.ts +37 -19
  56. package/apps/studio/footage/schema.test.ts +195 -0
  57. package/apps/studio/footage/schema.ts +484 -0
  58. package/apps/studio/footage-store.ts +235 -0
  59. package/apps/studio/fs-watch.ts +10 -0
  60. package/apps/studio/git/endpoints.ts +68 -7
  61. package/apps/studio/git/service.ts +198 -20
  62. package/apps/studio/github/service.ts +67 -0
  63. package/apps/studio/handoff.ts +3 -2
  64. package/apps/studio/http.ts +318 -64
  65. package/apps/studio/input-router.tsx +10 -0
  66. package/apps/studio/inspect.ts +53 -1
  67. package/apps/studio/paths.ts +10 -0
  68. package/apps/studio/photo/filters.ts +285 -0
  69. package/apps/studio/photo/pipeline.ts +607 -0
  70. package/apps/studio/photo/schema.ts +367 -0
  71. package/apps/studio/photo-store.ts +154 -0
  72. package/apps/studio/runtime-bundle.ts +29 -6
  73. package/apps/studio/server.ts +8 -2
  74. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  75. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  81. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  82. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  83. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  84. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  85. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  86. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  87. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  88. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  89. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  90. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  91. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  92. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  93. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  94. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  95. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  96. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  97. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  98. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  99. package/apps/studio/stickers/life-style/best.png +0 -0
  100. package/apps/studio/stickers/life-style/come-on.png +0 -0
  101. package/apps/studio/stickers/life-style/cut.png +0 -0
  102. package/apps/studio/stickers/life-style/detox.png +0 -0
  103. package/apps/studio/stickers/life-style/just.png +0 -0
  104. package/apps/studio/stickers/life-style/manifest.json +68 -0
  105. package/apps/studio/stickers/life-style/mirror.png +0 -0
  106. package/apps/studio/stickers/life-style/nice.png +0 -0
  107. package/apps/studio/stickers/life-style/objects.png +0 -0
  108. package/apps/studio/stickers/life-style/ok.png +0 -0
  109. package/apps/studio/stickers/life-style/queen.png +0 -0
  110. package/apps/studio/stickers/life-style/star.png +0 -0
  111. package/apps/studio/stickers/life-style/sun.png +0 -0
  112. package/apps/studio/stickers/life-style/water.png +0 -0
  113. package/apps/studio/stickers/life-style/yeah.png +0 -0
  114. package/apps/studio/stickers/life-style/you-can.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  118. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  119. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  120. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  121. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  122. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  123. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  124. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  125. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  126. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  127. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  128. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  129. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  130. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  131. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  132. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  133. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  134. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  135. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  136. package/apps/studio/stickers/project-status/group-100.png +0 -0
  137. package/apps/studio/stickers/project-status/group-101.png +0 -0
  138. package/apps/studio/stickers/project-status/group-102.png +0 -0
  139. package/apps/studio/stickers/project-status/group-103.png +0 -0
  140. package/apps/studio/stickers/project-status/group-104.png +0 -0
  141. package/apps/studio/stickers/project-status/group-105.png +0 -0
  142. package/apps/studio/stickers/project-status/group-106.png +0 -0
  143. package/apps/studio/stickers/project-status/group-107.png +0 -0
  144. package/apps/studio/stickers/project-status/group-108.png +0 -0
  145. package/apps/studio/stickers/project-status/group-109.png +0 -0
  146. package/apps/studio/stickers/project-status/group-110.png +0 -0
  147. package/apps/studio/stickers/project-status/group-111.png +0 -0
  148. package/apps/studio/stickers/project-status/group-112.png +0 -0
  149. package/apps/studio/stickers/project-status/group-113.png +0 -0
  150. package/apps/studio/stickers/project-status/group-114.png +0 -0
  151. package/apps/studio/stickers/project-status/group-115.png +0 -0
  152. package/apps/studio/stickers/project-status/group-117.png +0 -0
  153. package/apps/studio/stickers/project-status/group-118.png +0 -0
  154. package/apps/studio/stickers/project-status/group-119.png +0 -0
  155. package/apps/studio/stickers/project-status/group-120.png +0 -0
  156. package/apps/studio/stickers/project-status/group-121.png +0 -0
  157. package/apps/studio/stickers/project-status/group-122.png +0 -0
  158. package/apps/studio/stickers/project-status/group-41.png +0 -0
  159. package/apps/studio/stickers/project-status/group-42.png +0 -0
  160. package/apps/studio/stickers/project-status/group-43.png +0 -0
  161. package/apps/studio/stickers/project-status/group-44.png +0 -0
  162. package/apps/studio/stickers/project-status/group-45.png +0 -0
  163. package/apps/studio/stickers/project-status/group-46.png +0 -0
  164. package/apps/studio/stickers/project-status/group-47.png +0 -0
  165. package/apps/studio/stickers/project-status/group-48.png +0 -0
  166. package/apps/studio/stickers/project-status/group-49.png +0 -0
  167. package/apps/studio/stickers/project-status/group-50.png +0 -0
  168. package/apps/studio/stickers/project-status/group-51.png +0 -0
  169. package/apps/studio/stickers/project-status/group-52.png +0 -0
  170. package/apps/studio/stickers/project-status/group-53.png +0 -0
  171. package/apps/studio/stickers/project-status/group-54.png +0 -0
  172. package/apps/studio/stickers/project-status/group-55.png +0 -0
  173. package/apps/studio/stickers/project-status/group-56.png +0 -0
  174. package/apps/studio/stickers/project-status/group-57.png +0 -0
  175. package/apps/studio/stickers/project-status/group-58.png +0 -0
  176. package/apps/studio/stickers/project-status/group-59.png +0 -0
  177. package/apps/studio/stickers/project-status/group-60.png +0 -0
  178. package/apps/studio/stickers/project-status/group-61.png +0 -0
  179. package/apps/studio/stickers/project-status/group-62.png +0 -0
  180. package/apps/studio/stickers/project-status/group-63.png +0 -0
  181. package/apps/studio/stickers/project-status/group-64.png +0 -0
  182. package/apps/studio/stickers/project-status/group-65.png +0 -0
  183. package/apps/studio/stickers/project-status/group-66.png +0 -0
  184. package/apps/studio/stickers/project-status/group-67.png +0 -0
  185. package/apps/studio/stickers/project-status/group-68.png +0 -0
  186. package/apps/studio/stickers/project-status/group-69.png +0 -0
  187. package/apps/studio/stickers/project-status/group-70.png +0 -0
  188. package/apps/studio/stickers/project-status/group-71.png +0 -0
  189. package/apps/studio/stickers/project-status/group-72.png +0 -0
  190. package/apps/studio/stickers/project-status/group-73.png +0 -0
  191. package/apps/studio/stickers/project-status/group-74.png +0 -0
  192. package/apps/studio/stickers/project-status/group-75.png +0 -0
  193. package/apps/studio/stickers/project-status/group-76.png +0 -0
  194. package/apps/studio/stickers/project-status/group-77.png +0 -0
  195. package/apps/studio/stickers/project-status/group-78.png +0 -0
  196. package/apps/studio/stickers/project-status/group-79.png +0 -0
  197. package/apps/studio/stickers/project-status/group-80.png +0 -0
  198. package/apps/studio/stickers/project-status/group-81.png +0 -0
  199. package/apps/studio/stickers/project-status/group-82.png +0 -0
  200. package/apps/studio/stickers/project-status/group-83.png +0 -0
  201. package/apps/studio/stickers/project-status/group-84.png +0 -0
  202. package/apps/studio/stickers/project-status/group-85.png +0 -0
  203. package/apps/studio/stickers/project-status/group-86.png +0 -0
  204. package/apps/studio/stickers/project-status/group-87.png +0 -0
  205. package/apps/studio/stickers/project-status/group-88.png +0 -0
  206. package/apps/studio/stickers/project-status/group-89.png +0 -0
  207. package/apps/studio/stickers/project-status/group-90.png +0 -0
  208. package/apps/studio/stickers/project-status/group-91.png +0 -0
  209. package/apps/studio/stickers/project-status/group-92.png +0 -0
  210. package/apps/studio/stickers/project-status/group-93.png +0 -0
  211. package/apps/studio/stickers/project-status/group-94.png +0 -0
  212. package/apps/studio/stickers/project-status/group-96.png +0 -0
  213. package/apps/studio/stickers/project-status/group-97.png +0 -0
  214. package/apps/studio/stickers/project-status/group-98.png +0 -0
  215. package/apps/studio/stickers/project-status/group-99.png +0 -0
  216. package/apps/studio/stickers/project-status/manifest.json +328 -0
  217. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  218. package/apps/studio/test/acp-commands.test.ts +2 -1
  219. package/apps/studio/test/asset-api.test.ts +3 -3
  220. package/apps/studio/test/canvas-edit.test.ts +17 -6
  221. package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
  222. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  223. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  224. package/apps/studio/test/edit-source-command.test.ts +23 -0
  225. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  226. package/apps/studio/test/export-center.test.tsx +287 -0
  227. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  228. package/apps/studio/test/exporters/history.test.ts +32 -3
  229. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  230. package/apps/studio/test/footage-store.test.ts +100 -0
  231. package/apps/studio/test/git-branches.test.ts +97 -0
  232. package/apps/studio/test/github-api.test.ts +56 -1
  233. package/apps/studio/test/input-router.test.ts +33 -0
  234. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  235. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  236. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  237. package/apps/studio/test/photo-filters.test.ts +149 -0
  238. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  239. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  240. package/apps/studio/test/read-annotations.test.ts +164 -0
  241. package/apps/studio/test/shell-importmap.test.ts +49 -0
  242. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  243. package/apps/studio/test/timeline-parse.test.ts +57 -0
  244. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  245. package/apps/studio/test/undo-stack.test.ts +33 -0
  246. package/apps/studio/test/video-asset.test.ts +5 -5
  247. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  248. package/apps/studio/text-caret.ts +239 -0
  249. package/apps/studio/tool-palette.tsx +43 -20
  250. package/apps/studio/undo-stack.ts +94 -2
  251. package/apps/studio/use-annotation-resize.tsx +4 -4
  252. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  253. package/apps/studio/use-selection-set.tsx +21 -0
  254. package/apps/studio/video-comp.tsx +48 -7
  255. package/apps/studio/whats-new.json +70 -0
  256. package/apps/studio/ws.ts +6 -0
  257. package/cli/commands/design.mjs +34 -3
  258. package/cli/lib/gitignore-block.mjs +2 -0
  259. package/package.json +10 -10
  260. package/plugins/design/templates/_shell.html +1 -0
@@ -211,7 +211,9 @@ function isMaudeRuntimeState(p: string): boolean {
211
211
  return (
212
212
  /(^|\/)_(?:server|active|sync|preflight|locator|export-history)\.json$/.test(p) ||
213
213
  /(^|\/)_server\.(?:lock|log)$/.test(p) ||
214
- /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted)(?:\/|$)/.test(p)
214
+ /(^|\/)_(?:history|trash|draw|photo|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
215
+ p
216
+ )
215
217
  );
216
218
  }
217
219
 
@@ -539,6 +541,16 @@ function classifyRemoteUrl(url: string): RemoteTransport {
539
541
  return 'unsafe';
540
542
  }
541
543
 
544
+ /** True when a remote URL points at github.com (https or the scp-like ssh form).
545
+ * Decides the fold path (DDR-162): a GitHub remote gets the PR flow (push draft +
546
+ * open a pull request via the endpoint); anything else (no remote, a local/file
547
+ * remote) keeps the local-merge path. Only a routing decision — the authoritative,
548
+ * security-anchored owner/repo parse is `parseGitHubRemote` at the endpoint, which
549
+ * rejects an embedded `evil.com/github.com/…` even if this heuristic didn't. */
550
+ function isGitHubRemote(url: string): boolean {
551
+ return /(?:^|\/\/|@)github\.com[:/]/i.test((url || '').trim());
552
+ }
553
+
542
554
  /** Read a remote's configured URL (empty string when missing). */
543
555
  async function readRemoteUrl(dir: string, remote: string): Promise<string> {
544
556
  return (await git.getConfig({ fs, dir, path: `remote.${remote}.url` }).catch(() => null)) || '';
@@ -548,14 +560,42 @@ async function readRemoteUrl(dir: string, remote: string): Promise<string> {
548
560
  * never to an arbitrary HTTPS host an attacker put in `remote.origin.url` (PAT
549
561
  * exfil / SSRF). HTTP(S) urls only; ssh carries no token regardless. */
550
562
  function isTrustedTokenHost(url: string): boolean {
563
+ const u = (url || '').trim();
564
+ // SECURITY (F1 — parser-differential PAT exfil): the token-attach decision must NOT
565
+ // trust a `new URL()` parse that git+libcurl will REDO with a different grammar. A
566
+ // `https://github.com\@attacker/x` reads as host github.com under WHATWG but as host
567
+ // `attacker` under curl (backslash = path/userinfo char). Reject any byte that could
568
+ // re-open the authority — backslash, userinfo `@`, whitespace, control chars — then
569
+ // require the byte-exact canonical github.com https prefix, so the host resolves to
570
+ // github.com under BOTH parsers before the PAT is ever lent.
571
+ // Only printable ASCII (rejects whitespace, control, and non-ASCII homoglyphs),
572
+ // and no backslash / userinfo `@` — the two bytes that let curl re-resolve the host.
573
+ if (/[^\x21-\x7e]/.test(u) || u.includes('\\') || u.includes('@')) return false;
574
+ if (!/^https:\/\/github\.com\//i.test(u)) return false;
551
575
  try {
552
- const h = new URL(url).hostname.toLowerCase();
553
- return h === 'github.com' || h.endsWith('.github.com');
576
+ return new URL(u).hostname.toLowerCase() === 'github.com';
554
577
  } catch {
555
578
  return false;
556
579
  }
557
580
  }
558
581
 
582
+ /** The URL git will ACTUALLY dial for `remote`, after any `url.<base>.insteadOf`
583
+ * rewrite — the URL that must be host-validated, NOT the raw `remote.url`. A poisoned
584
+ * `.git/config` `url.<attacker>.insteadOf = https://github.com/` (rides a clone/folder,
585
+ * no file-review sees it) makes the SYSTEM engine dial the attacker even though
586
+ * `remote.url` is a clean github URL; this is the surviving instance of the F1
587
+ * "validate-here / connect-there" class (verify re-review). `git ls-remote --get-url`
588
+ * applies insteadOf and prints WITHOUT touching the network. iso-git ignores insteadOf,
589
+ * so when there's no system git the raw URL IS the effective one. */
590
+ async function effectiveRemoteUrl(dir: string, remote: string): Promise<string> {
591
+ const raw = await readRemoteUrl(dir, remote);
592
+ if (!raw) return ''; // no remote → 'none'
593
+ if (!(await systemGitAvailable())) return raw;
594
+ const r = await runGit(dir, [...HARDENED_REMOTE_FLAGS, 'ls-remote', '--get-url', remote]);
595
+ const eff = r.code === 0 ? r.stdout.trim() : '';
596
+ return eff || raw;
597
+ }
598
+
559
599
  /** Defense-in-depth for any `runGit` that resolves a config remote URL: disable the
560
600
  * command-EXECUTING transports at the git layer too (`classifyRemoteUrl` already
561
601
  * refuses them before we spawn — this is the backstop). Deliberately does NOT
@@ -765,8 +805,15 @@ export interface GitFoldResult {
765
805
  conflict?: boolean;
766
806
  authRequired?: boolean;
767
807
  error?: string;
768
- /** The Shared-version branch the draft was added to. */
808
+ /** The Shared-version branch the draft was added to (local merge) or targeted (PR). */
769
809
  shared?: string;
810
+ /** PR flow (DDR-162): a GitHub remote exists, the draft branch was pushed, and the
811
+ * endpoint should open a pull request `head → base`. When set, no local merge or
812
+ * push of the Shared version happened — the merge lands on GitHub, post-review. */
813
+ prReady?: boolean;
814
+ head?: string;
815
+ base?: string;
816
+ remoteUrl?: string;
770
817
  }
771
818
 
772
819
  /** "Add this draft to the Shared version" (phase-29 / E4, Task 7): merge the draft
@@ -791,6 +838,29 @@ export async function gitFoldDraft(
791
838
  if (!branches.some((b) => b.name === draftName))
792
839
  return { ok: false, error: "That draft doesn't exist." };
793
840
 
841
+ // A GitHub remote → PR flow (DDR-162): publish the DRAFT branch (branch protection
842
+ // guards the Shared version, not the draft) and signal the endpoint to open a pull
843
+ // request draft→shared. We deliberately do NOT merge or push the Shared version here
844
+ // — pushing a protected `main` is exactly what GitHub forbids, and the reason the PR
845
+ // exists. The merge lands on GitHub after review.
846
+ const remoteUrl = await readRemoteUrl(dir, remote);
847
+ if (isGitHubRemote(remoteUrl)) {
848
+ const push = await gitPush(dir, token, { remote, ref: draftName });
849
+ if (!push.ok) {
850
+ if (push.authRequired) return { ok: false, authRequired: true, error: push.error };
851
+ if (push.conflict)
852
+ return {
853
+ ok: false,
854
+ conflict: true,
855
+ error: 'Your draft moved on the server — Get latest first, then add it.',
856
+ };
857
+ return { ok: false, error: push.error ?? 'Could not publish your draft.' };
858
+ }
859
+ return { ok: true, shared, prReady: true, head: draftName, base: shared, remoteUrl };
860
+ }
861
+
862
+ // No remote (or a non-GitHub local remote) → merge the draft into the Shared version
863
+ // locally; there's no PR host. Unchanged pre-PR-flow behavior.
794
864
  // Merge the draft into the Shared version (FF when possible, else a merge commit).
795
865
  try {
796
866
  if (USE_SYSTEM_GIT) {
@@ -864,11 +934,59 @@ export async function gitFoldDraft(
864
934
 
865
935
  // ── push (Publish) ─────────────────────────────────────────────────────────
866
936
 
937
+ /** Publish / Get-latest transport routing — brings the network WRITE paths up to the
938
+ * same DDR-131/DDR-133 gate the network READ paths already enforce (gitFetchRemote
939
+ * `:1099`, remoteAheadBehind `:1628`). iso-git speaks HTTP(S) ONLY, so an ssh/git
940
+ * remote MUST run through the system binary (the "unrecognized transport protocol:
941
+ * ssh" bug was push/pull skipping this); a command-executing / non-github URL is
942
+ * REFUSED before any spawn; and the keychain PAT rides only a trusted-host HTTPS
943
+ * request. `none` (no remote configured) keeps the pre-gate routing so a local-only
944
+ * project's tokenless publish still short-circuits to "sign in" unchanged. */
945
+ type NetWriteRoute =
946
+ | { via: 'system'; tokenForSystem: string | undefined }
947
+ | { via: 'iso' }
948
+ | { via: 'legacy' }
949
+ | { via: 'authRequired' }
950
+ | { via: 'reject'; error: string };
951
+
952
+ async function resolveNetWriteRoute(
953
+ dir: string,
954
+ remote: string,
955
+ token: string | undefined
956
+ ): Promise<NetWriteRoute> {
957
+ const url = await effectiveRemoteUrl(dir, remote); // post-insteadOf — the URL git dials
958
+ const transport = classifyRemoteUrl(url);
959
+ if (transport === 'none') return { via: 'legacy' };
960
+ // Command-executing transports (ext::/fd::/transport:: — the `::` helpers) are RCE:
961
+ // refuse BEFORE any spawn so neither engine ever resolves them. A plain file/local
962
+ // remote is NOT rejected here — it's a legitimate explicit local-repo transfer
963
+ // (handled by the system branch below), matching HARDENED_REMOTE_FLAGS' stance that
964
+ // only shell-spawning helpers are blocked, not file object transfer.
965
+ if (url.includes('::'))
966
+ return { via: 'reject', error: 'Maude can only sync github.com (HTTPS or SSH) projects.' };
967
+ const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
968
+ if (transport === 'http' && !trustedHttp)
969
+ return { via: 'reject', error: 'Maude can only sync github.com projects.' };
970
+ // Tokenless github HTTPS with no system git to fall back on: iso can't authenticate
971
+ // → ask the user to sign in (mirrors gitFetchRemote `:1096`).
972
+ if (transport === 'http' && !token && !(await systemGitAvailable()))
973
+ return { via: 'authRequired' };
974
+ // System engine for: ssh (iso can't speak it), a file/local remote ('unsafe' minus
975
+ // the `::` helpers rejected above — e.g. a bare-repo path), or ANY remote once a git
976
+ // binary exists. The PAT rides only a trusted-host HTTPS request; ssh/local use the
977
+ // user's own key / on-disk path.
978
+ if ((await systemGitAvailable()) || transport === 'ssh' || transport === 'unsafe')
979
+ return { via: 'system', tokenForSystem: trustedHttp ? token : undefined };
980
+ return { via: 'iso' }; // github HTTPS + token, no system git present
981
+ }
982
+
867
983
  /** Publish. `token` is optional in phase-27: the system-git engine falls back to
868
984
  * the user's configured credential helper / SSH, so a developer-ish user who
869
985
  * cloned with system git can publish today (no in-UI token). The iso-git default
870
986
  * engine needs the token (no helper integration) → `authRequired` when absent,
871
- * which the UI renders as "Sign in to publish" (phase-28 keychain fills it). */
987
+ * which the UI renders as "Sign in to publish" (phase-28 keychain fills it).
988
+ * Engine choice goes through resolveNetWriteRoute so an ssh remote reaches the git
989
+ * binary instead of iso's HTTP-only transport (DDR-131/DDR-133 parity). */
872
990
  export async function gitPush(
873
991
  dir: string,
874
992
  token: string | undefined,
@@ -877,9 +995,21 @@ export async function gitPush(
877
995
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
878
996
  invalidateRemoteProbe(dir); // a publish changes ahead/behind — re-probe next status
879
997
  const remote = opts.remote || 'origin';
880
- return USE_SYSTEM_GIT
881
- ? pushSystem(dir, token, remote, opts.ref)
882
- : pushIso(dir, token, remote, opts.ref);
998
+ const route = await resolveNetWriteRoute(dir, remote, token);
999
+ switch (route.via) {
1000
+ case 'reject':
1001
+ return { ok: false, error: route.error };
1002
+ case 'authRequired':
1003
+ return { ok: false, authRequired: true, error: 'Sign in to publish.' };
1004
+ case 'system':
1005
+ return pushSystem(dir, route.tokenForSystem, remote, opts.ref);
1006
+ case 'iso':
1007
+ return pushIso(dir, token, remote, opts.ref);
1008
+ case 'legacy':
1009
+ return USE_SYSTEM_GIT
1010
+ ? pushSystem(dir, token, remote, opts.ref)
1011
+ : pushIso(dir, token, remote, opts.ref);
1012
+ }
883
1013
  }
884
1014
 
885
1015
  async function pushIso(
@@ -924,6 +1054,11 @@ async function pushIso(
924
1054
  } catch (e) {
925
1055
  const msg = errMsg(e);
926
1056
  if (isNonFastForward(msg)) return { ok: false, conflict: true };
1057
+ if (isTransportError(msg))
1058
+ return {
1059
+ ok: false,
1060
+ error: 'Publishing needs the git command-line tool for this project’s connection.',
1061
+ };
927
1062
  return { ok: false, error: msg };
928
1063
  }
929
1064
  }
@@ -942,9 +1077,15 @@ async function pushSystem(
942
1077
  // disk). Without one, fall through to the user's configured credential helper /
943
1078
  // SSH agent — the phase-27 "I cloned with system git" publish path.
944
1079
  const args = tokenHeaderArgs(token);
945
- args.push('push', remote, branch || 'HEAD');
1080
+ args.push(...HARDENED_REMOTE_FLAGS, 'push', remote, branch || 'HEAD');
946
1081
  const r = await runGit(dir, args);
947
1082
  if (r.code === 0) return { ok: true };
1083
+ // 127 = no git binary on PATH (the ssh-remote-but-no-CLI case; ssh always routes here).
1084
+ if (r.code === 127)
1085
+ return {
1086
+ ok: false,
1087
+ error: 'Publishing needs the git command-line tool for this project’s connection.',
1088
+ };
948
1089
  if (isNonFastForward(`${r.stderr} ${r.stdout}`.toLowerCase()))
949
1090
  return { ok: false, conflict: true };
950
1091
  return { ok: false, error: r.stderr.trim() || 'Publish failed.' };
@@ -960,13 +1101,17 @@ function isSafeGitPositional(v: string): boolean {
960
1101
  return SAFE_GIT_POSITIONAL.test(v);
961
1102
  }
962
1103
 
963
- /** Ephemeral `git -c http.extraheader=…` args carrying a token as HTTPS basic
964
- * auth, or `[]` when no token (fall back to the user's credential helper). The
965
- * header is per-invocation so the PAT never lands in the on-disk remote URL. */
1104
+ /** Ephemeral `git -c http.<github>.extraheader=…` args carrying a token as HTTPS
1105
+ * basic auth, or `[]` when no token (fall back to the user's credential helper). The
1106
+ * header is per-invocation so the PAT never lands in the on-disk remote URL — AND it
1107
+ * is SCOPED to `https://github.com/` (not the global `http.extraheader`): git attaches
1108
+ * it only to a request whose curl-RESOLVED host is github.com, so a poisoned remote
1109
+ * that WHATWG reads as github.com but curl dials elsewhere never receives the PAT
1110
+ * (F1 defense-in-depth, on top of the isTrustedTokenHost strict-validate). */
966
1111
  function tokenHeaderArgs(token: string | undefined): string[] {
967
1112
  if (!token) return [];
968
1113
  const auth = Buffer.from(`x-access-token:${token}`).toString('base64');
969
- return ['-c', `http.extraheader=Authorization: Basic ${auth}`];
1114
+ return ['-c', `http.https://github.com/.extraheader=Authorization: Basic ${auth}`];
970
1115
  }
971
1116
 
972
1117
  function isNonFastForward(blob: string): boolean {
@@ -979,6 +1124,15 @@ function isNonFastForward(blob: string): boolean {
979
1124
  );
980
1125
  }
981
1126
 
1127
+ /** iso-git speaks HTTP(S) only and throws "unrecognized transport protocol" on an
1128
+ * ssh/git remote. Routing now sends those to system git (resolveNetWriteRoute), so
1129
+ * this is a BELT: if a transport error ever still reaches an iso catch, map it to the
1130
+ * same "use the git CLI" copy gitFetchRemote shows (`:1153`) instead of leaking the
1131
+ * raw isomorphic-git string to the UI. */
1132
+ function isTransportError(blob: string): boolean {
1133
+ return /unrecognized transport|unsupported|protocol/i.test(blob);
1134
+ }
1135
+
982
1136
  // ── pull (Get latest) ─────────────────────────────────────────────────────
983
1137
 
984
1138
  /** Get latest. Same optional-token model as gitPush (see its doc comment). */
@@ -989,9 +1143,22 @@ export async function gitPull(
989
1143
  ): Promise<GitPullResult> {
990
1144
  if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
991
1145
  invalidateRemoteProbe(dir); // a pull changes ahead/behind — re-probe next status
992
- return USE_SYSTEM_GIT
993
- ? pullSystem(dir, token, opts.remote || 'origin', opts.ref)
994
- : pullIso(dir, token, opts.remote || 'origin', opts.ref);
1146
+ const remote = opts.remote || 'origin';
1147
+ const route = await resolveNetWriteRoute(dir, remote, token);
1148
+ switch (route.via) {
1149
+ case 'reject':
1150
+ return { ok: false, error: route.error };
1151
+ case 'authRequired':
1152
+ return { ok: false, authRequired: true, error: 'Sign in to get the latest.' };
1153
+ case 'system':
1154
+ return pullSystem(dir, route.tokenForSystem, remote, opts.ref);
1155
+ case 'iso':
1156
+ return pullIso(dir, token, remote, opts.ref);
1157
+ case 'legacy':
1158
+ return USE_SYSTEM_GIT
1159
+ ? pullSystem(dir, token, remote, opts.ref)
1160
+ : pullIso(dir, token, remote, opts.ref);
1161
+ }
995
1162
  }
996
1163
 
997
1164
  async function pullIso(
@@ -1020,7 +1187,13 @@ async function pullIso(
1020
1187
  // `data` is the list of conflicted filepaths. DiffView opens on these.
1021
1188
  const conflictFiles = mergeConflictFiles(e);
1022
1189
  if (conflictFiles) return { ok: false, conflict: true, files: conflictFiles };
1023
- return { ok: false, error: errMsg(e) };
1190
+ const msg = errMsg(e);
1191
+ if (isTransportError(msg))
1192
+ return {
1193
+ ok: false,
1194
+ error: 'Getting the latest needs the git command-line tool for this project’s connection.',
1195
+ };
1196
+ return { ok: false, error: msg };
1024
1197
  }
1025
1198
  }
1026
1199
 
@@ -1035,9 +1208,14 @@ async function pullSystem(
1035
1208
  return { ok: false, error: 'Invalid remote or draft name.' };
1036
1209
  }
1037
1210
  const args = tokenHeaderArgs(token);
1038
- args.push('pull', '--no-rebase', remote, branch || 'HEAD');
1211
+ args.push(...HARDENED_REMOTE_FLAGS, 'pull', '--no-rebase', remote, branch || 'HEAD');
1039
1212
  const r = await runGit(dir, args);
1040
1213
  if (r.code === 0) return { ok: true };
1214
+ if (r.code === 127)
1215
+ return {
1216
+ ok: false,
1217
+ error: 'Getting the latest needs the git command-line tool for this project’s connection.',
1218
+ };
1041
1219
  const blob = `${r.stderr}\n${r.stdout}`;
1042
1220
  if (/conflict/i.test(blob)) {
1043
1221
  // Parse `CONFLICT (content): Merge conflict in <path>` lines.
@@ -1076,7 +1254,7 @@ export async function gitFetchRemote(
1076
1254
  // binary — but a command-executing (`ext::`) / local (`file://`) URL must be
1077
1255
  // REFUSED, never handed to `git fetch` (it would run as the user). And the GitHub
1078
1256
  // token may only ride a github.com HTTPS request, never an attacker-chosen host.
1079
- const url = await readRemoteUrl(dir, remote);
1257
+ const url = await effectiveRemoteUrl(dir, remote); // post-insteadOf — the URL git dials
1080
1258
  const transport = classifyRemoteUrl(url);
1081
1259
  if (transport === 'none') return { ok: false, error: 'This project has no remote to refresh.' };
1082
1260
  if (transport === 'unsafe')
@@ -1611,7 +1789,7 @@ async function remoteAheadBehindUncached(
1611
1789
  // remote URL before touching the git binary. A command-executing (`ext::`) or
1612
1790
  // local (`file://`) URL is refused (return 0/0, no spawn); the token rides only a
1613
1791
  // github.com HTTPS request, never an attacker-chosen host.
1614
- const url = await readRemoteUrl(dir, remote);
1792
+ const url = await effectiveRemoteUrl(dir, remote); // post-insteadOf — the URL git dials
1615
1793
  const transport = classifyRemoteUrl(url);
1616
1794
  if (transport === 'none' || transport === 'unsafe') return { ahead: 0, behind: 0 };
1617
1795
  const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
@@ -164,6 +164,73 @@ export async function createRepo(token: string, input: CreateRepoInput): Promise
164
164
  };
165
165
  }
166
166
 
167
+ export interface PullRequest {
168
+ number: number;
169
+ html_url: string;
170
+ }
171
+
172
+ export interface CreatePullRequestInput {
173
+ /** The draft branch (same-repo head — a bare branch name). */
174
+ head: string;
175
+ /** The Shared-version branch the PR targets (main/master). */
176
+ base: string;
177
+ title: string;
178
+ body?: string;
179
+ }
180
+
181
+ /** Open a pull request `head → base` in the given repo. GitHub returns 422 both when
182
+ * a PR for this head/base already exists AND when there are no commits between them;
183
+ * we can't tell from the (friendly-mapped) message, so we probe for an already-open
184
+ * PR — if one exists, re-running "Add to Shared version" links it instead of erroring;
185
+ * otherwise it's a genuine no-op and we surface a friendly message. */
186
+ export async function createPullRequest(
187
+ token: string,
188
+ owner: string,
189
+ repo: string,
190
+ input: CreatePullRequestInput
191
+ ): Promise<PullRequest> {
192
+ const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
193
+ try {
194
+ const res = await api(token, path, {
195
+ method: 'POST',
196
+ body: { title: input.title, head: input.head, base: input.base, body: input.body ?? '' },
197
+ });
198
+ const r = (await res.json()) as { number: number; html_url: string };
199
+ return { number: r.number, html_url: r.html_url };
200
+ } catch (e) {
201
+ if (e instanceof GitHubApiError && e.status === 422) {
202
+ const existing = await findOpenPullRequest(token, owner, repo, input.head, input.base);
203
+ if (existing) return existing;
204
+ throw new GitHubApiError(
205
+ 422,
206
+ 'Nothing new to add — this draft already matches the Shared version.'
207
+ );
208
+ }
209
+ throw e;
210
+ }
211
+ }
212
+
213
+ /** The open PR for `head → base`, if any (recovers a link from the 422 above). */
214
+ async function findOpenPullRequest(
215
+ token: string,
216
+ owner: string,
217
+ repo: string,
218
+ head: string,
219
+ base: string
220
+ ): Promise<PullRequest | null> {
221
+ const q =
222
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls` +
223
+ `?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}`;
224
+ const res = await api(token, q).catch(() => null);
225
+ if (!res) return null;
226
+ const list = (await res.json().catch(() => null)) as Array<{
227
+ number: number;
228
+ html_url: string;
229
+ }> | null;
230
+ if (!Array.isArray(list) || list.length === 0) return null;
231
+ return { number: list[0].number, html_url: list[0].html_url };
232
+ }
233
+
167
234
  /**
168
235
  * Point the local project's `origin` at `remoteUrl` (iso-git, no network). Replaces
169
236
  * any existing remote of the same name so re-running "create project" is idempotent.
@@ -107,7 +107,8 @@ export interface EmitOptions {
107
107
  // Strip data-cd-id from source — the inverse of canvas-pipeline.ts pass 1.
108
108
 
109
109
  /**
110
- * Remove every ` data-cd-id="<hex>"` attribute from a TSX source string.
110
+ * Remove every pipeline-emitted attribute — ` data-cd-id="<hex>"` and the
111
+ * Phase-6 ` data-cd-editable="text"` marker — from a TSX source string.
111
112
  * Pure: caller persists. Uses the same oxc-parser + magic-string toolchain as
112
113
  * the pipeline that emitted them.
113
114
  */
@@ -136,7 +137,7 @@ export function stripDataCdId(canvasAbsPath: string, source: string): string {
136
137
  if (
137
138
  a?.type === 'JSXAttribute' &&
138
139
  a.name?.type === 'JSXIdentifier' &&
139
- a.name.name === 'data-cd-id' &&
140
+ (a.name.name === 'data-cd-id' || a.name.name === 'data-cd-editable') &&
140
141
  typeof a.start === 'number' &&
141
142
  typeof a.end === 'number'
142
143
  ) {