mulmoterminal 6.7.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -2
- package/bin/check-versions.d.ts +7 -0
- package/bin/check-versions.js +80 -0
- package/bin/has-command.d.ts +4 -0
- package/bin/has-command.js +4 -4
- package/bin/mulmoterminal.js +7 -1
- package/bin/version-drift.d.ts +11 -0
- package/bin/version-drift.js +88 -0
- package/blueprints/ask/steps.json +2 -1
- package/blueprints/docs/checks/chaff.sh +1 -1
- package/blueprints/docs/skills/workspace/SKILL.md +1 -1
- package/blueprints/firebase/checks/security-report.sh +11 -0
- package/blueprints/firebase/checks/security.sh +41 -0
- package/blueprints/firebase/checks/start-here.sh +8 -0
- package/blueprints/firebase/manifest.json +2 -1
- package/blueprints/firebase/plan.json +8 -1
- package/blueprints/firebase/skills/deploy-production/SKILL.md +14 -1
- package/blueprints/firebase/skills/security/SKILL.md +80 -0
- package/blueprints/from-collection/checks/spec.sh +26 -0
- package/blueprints/from-collection/hearing.json +76 -0
- package/blueprints/from-collection/manifest.json +8 -0
- package/blueprints/from-collection/skills/acceptance/SKILL.md +26 -0
- package/blueprints/from-collection/spec/conversion.md +37 -0
- package/blueprints/from-collection/spec/requirements.md +45 -0
- package/blueprints/from-collection/steps.json +12 -0
- package/blueprints/local/checks/handover.sh +7 -1
- package/blueprints/local/checks/security-report.sh +11 -0
- package/blueprints/local/checks/security.sh +48 -0
- package/blueprints/local/checks/serve.sh +24 -0
- package/blueprints/local/checks/smoke.sh +1 -22
- package/blueprints/local/checks/spec.sh +4 -0
- package/blueprints/local/manifest.json +2 -1
- package/blueprints/local/plan.json +7 -0
- package/blueprints/local/security/checklist.md +2 -0
- package/blueprints/local/skills/handover/SKILL.md +17 -1
- package/blueprints/local/skills/security/SKILL.md +90 -0
- package/blueprints/polish/checks/targets.mjs +4 -0
- package/blueprints/polish/checks/targetsView.d.mts +2 -0
- package/blueprints/polish/checks/targetsView.mjs +7 -0
- package/blueprints/polish/hearing.json +4 -2
- package/blueprints/polish/presets.json +0 -1
- package/blueprints/polish/skills/polish/SKILL.md +1 -1
- package/blueprints/polish/skills/survey/SKILL.md +2 -1
- package/blueprints/polish/steps.json +2 -1
- package/blueprints/product/steps.json +1 -0
- package/blueprints/refactor/hearing.json +2 -1
- package/blueprints/review/checks/findings.mjs +4 -0
- package/blueprints/review/checks/findingsView.d.mts +12 -0
- package/blueprints/review/checks/findingsView.mjs +30 -0
- package/blueprints/review/steps.json +2 -1
- package/blueprints/style/checks/sources.mjs +4 -1
- package/blueprints/style/checks/sourcesView.d.mts +2 -0
- package/blueprints/style/checks/sourcesView.mjs +15 -0
- package/blueprints/style/skills/report/SKILL.md +1 -1
- package/blueprints/style/steps.json +4 -2
- package/blueprints/verify/checks/extract.mjs +3 -0
- package/blueprints/verify/checks/factsView.d.mts +4 -0
- package/blueprints/verify/checks/factsView.mjs +36 -0
- package/blueprints/verify/steps.json +2 -1
- package/blueprints/write/checks/outlineView.d.mts +4 -0
- package/blueprints/write/checks/outlineView.mjs +14 -0
- package/blueprints/write/checks/parts.mjs +5 -0
- package/blueprints/write/manifest.json +8 -1
- package/blueprints/write/steps.json +4 -2
- package/common/blueprint/collectionSource.ts +75 -0
- package/common/blueprint/hearing.ts +45 -5
- package/common/blueprint/manifest.ts +22 -5
- package/common/blueprint/plan.ts +5 -0
- package/common/blueprint/refusal.ts +3 -2
- package/common/blueprint/run.ts +4 -1
- package/common/collectionActions.ts +31 -0
- package/common/dirConfigSource.ts +3 -0
- package/common/filesPage.ts +39 -0
- package/common/keymap.ts +12 -1
- package/common/mdPreviewMessage.ts +16 -2
- package/dist/assets/{abnfDiagram-O67JEVCF-COztuCu2.js → abnfDiagram-O67JEVCF-Dy6jPGlc.js} +1 -1
- package/dist/assets/architecture-7GRP2DOG-DyaLKyU5.js +1 -0
- package/dist/assets/{architectureDiagram-NJMV4G6O-CocxKb8D.js → architectureDiagram-NJMV4G6O-Y1wqce2c.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CoIOnOr8.js → blockDiagram-BEXU5L5S-B6haVvbR.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-DCkPDN1m.js → c4Diagram-YGBWAQC7-CbqMeQ5M.js} +1 -1
- package/dist/assets/channel-T986AWhs.js +1 -0
- package/dist/assets/{chunk-3FUC2YCW-BpkN5ntL.js → chunk-3FUC2YCW-Dql-NfIM.js} +1 -1
- package/dist/assets/{chunk-5DYCD2WN-CkSUlK9I.js → chunk-5DYCD2WN-DPBLnLhJ.js} +1 -1
- package/dist/assets/{chunk-742MDFTN-IT8yQzYr.js → chunk-742MDFTN-Cfcav64I.js} +1 -1
- package/dist/assets/{chunk-7INBJB4K-JGgoF_Kc.js → chunk-7INBJB4K-gd2sm2Qq.js} +1 -1
- package/dist/assets/{chunk-7M6MHVWA-DeMh7ryC.js → chunk-7M6MHVWA-Cga-bP1d.js} +1 -1
- package/dist/assets/{chunk-7PRAP22T-Mu7uMyeb.js → chunk-7PRAP22T-B5CSlQEt.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-CZnXYcEC.js → chunk-GTNCS2PH-BzDxh0CI.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-hNVTjL0n.js → chunk-GWA4HPMP-CTVycvRw.js} +1 -1
- package/dist/assets/{chunk-MBY4JIJT-BNBCCYaO.js → chunk-MBY4JIJT-Dj2QOy3d.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-BFEY_LEK.js → chunk-NETBCI7D-CVlwwnxD.js} +1 -1
- package/dist/assets/{chunk-O7XYJQB3-igihGd57.js → chunk-O7XYJQB3-CBuERAl7.js} +1 -1
- package/dist/assets/{chunk-UA2S7LBM-AvoPHYX2.js → chunk-UA2S7LBM-Dq3DhUK-.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CnzHksV_.js → chunk-WEXAMYUT-D8fWG-1Z.js} +1 -1
- package/dist/assets/{chunk-Z7XXMR3K-BZUbrMkZ.js → chunk-Z7XXMR3K-zrP0_WoK.js} +1 -1
- package/dist/assets/{chunk-ZIGJFQKS-DFj3hrIY.js → chunk-ZIGJFQKS-Dzlt4QPL.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-C2GePjwg.js → classDiagram-v2-NBCMYWYE-CjA-LmiN.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-E4NRCqvt.js → cynefin-OW5HDTMX-CVRZEwmv.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BnHou4hN.js → cynefinDiagram-VND7K2PF-C9rD0DOQ.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-kSCwZxth.js → dagre-6A5THRUB-BuE8Gjyb.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-DT04U1z3.js → diagram-22UHCM2B-Cw3M7VEV.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-QaR0jD4H.js → diagram-3UASUU5V-Pu3L65WF.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-lmm7fQMw.js → diagram-ATOU4E4O-OI0bhF_A.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CdsN9JC6.js → diagram-CDSNMT55-CH39M5AD.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CaVs00rU.js → diagram-MLGK6HIB-B2nfDfEm.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-BudW9uT0.js → diagram-MPIPVDR6-CFiLA_-F.js} +1 -1
- package/dist/assets/{dist-4u5CgGvM.js → dist-BSqJ_nUe.js} +1 -1
- package/dist/assets/{dist-BkNFiPKG.js → dist-Sj3hCOj7.js} +1 -1
- package/dist/assets/{dist-DJ_QvH_t.js → dist-lS8XUoi1.js} +1 -1
- package/dist/assets/{dist-CBfB3Kbo.js → dist-pmD9XVGa.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DT9rFfx-.js → ebnfDiagram-ZINNZB2B-ChqhmMzu.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-DDeDnR5i.js → elk-276RUBZZ-De2Iml3H.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-B9Fv1DKw.js → erDiagram-OPXOYQCR-CUt6-AR7.js} +1 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DAxZbpPN.js +1 -0
- package/dist/assets/flowDiagram-KWPJA3E3-Bss7fLee.js +1 -0
- package/dist/assets/{ganttDiagram-FUAMR5RP-CzlCs_Rv.js → ganttDiagram-FUAMR5RP-BETJ1ApO.js} +1 -1
- package/dist/assets/{gitGraph-4MIJSDKK-DMlpm2X5.js → gitGraph-4MIJSDKK-DGOqms6x.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-BodBMwuj.js → gitGraphDiagram-X574FWY7-Btn58SWQ.js} +1 -1
- package/dist/assets/index-8B5Of03P.css +1 -0
- package/dist/assets/{index-DtoMXmGR.js → index-Dw_awLi8.js} +343 -342
- package/dist/assets/{info-A6RAGUB7-DcD8IyFk.js → info-A6RAGUB7-BIn6JHsL.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-BaZCI3bY.js → infoDiagram-VRGFBTTK-CLLPPwgt.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-C2YA2vvC.js → ishikawaDiagram-OU5B5YK6-C0dnEC2O.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-CqjWX8sS.js → journeyDiagram-ZHPQQLJL-DiOFy0_3.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-CoM8jlm5.js → kanban-definition-PNTS6WVX-DL0cY3t2.js} +1 -1
- package/dist/assets/{line-Bt2FCpGX.js → line-CInvdCWV.js} +1 -1
- package/dist/assets/{mermaid-parser.core-D9qR3oRl.js → mermaid-parser.core-BGDbqboQ.js} +3 -3
- package/dist/assets/{mermaid.core-BIXntNJ_.js → mermaid.core-CZVPcJ9v.js} +4 -4
- package/dist/assets/{mindmap-definition-NLK3R4M7-4FYsrukx.js → mindmap-definition-NLK3R4M7-U6yboX99.js} +1 -1
- package/dist/assets/{packet-AYTQ26CC-eQcU7oGW.js → packet-AYTQ26CC-DIDr2_-n.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-AwYWp-ta.js → pegDiagram-GJSIUBJH-BbpYcKtu.js} +1 -1
- package/dist/assets/{pie-WAS4IAKB-CJF4n-uR.js → pie-WAS4IAKB-BsD46nmb.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-CKnVXfdJ.js → pieDiagram-5QR66LMP-BgdP7pnf.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-BefBP8Qf.js → quadrantDiagram-O4NWA36T-mx6H7yGz.js} +1 -1
- package/dist/assets/{radar-RG4KPBEZ-BjQl0TqH.js → radar-RG4KPBEZ-BxzJE1SZ.js} +1 -1
- package/dist/assets/{railroad-74A4TZTK-Z4kUresc.js → railroad-74A4TZTK-d2ye0a_H.js} +1 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-BJ8b7QD7.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-YG_Upy_7.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-OgQyRk3s.js +1 -0
- package/dist/assets/{railroadDiagram-XR7U4H2S-BUskllkB.js → railroadDiagram-XR7U4H2S-CdHoygiZ.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-BCbsSEYH.js → requirementDiagram-PLB6GJNP-DgUPBmN3.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-DuFOhh5F.js → sankeyDiagram-IPEJSGJF-CCqhFBKm.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-CtacIk91.js → sequenceDiagram-PO4LG4MO-DsOw9dn3.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-Bh0PfQR6.js → stateDiagram-v2-GCMORJYK-KgYznRou.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-3VWib0yw.js → swimlanes-2SLR337P-cJ978qAp.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BOVOQpWl.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-DLUjmMJp.js → timeline-definition-EJHVYXUP-DVrpY7-_.js} +1 -1
- package/dist/assets/{treeView-Q6P3EWNA-urctZJZ8.js → treeView-Q6P3EWNA-C81INZMi.js} +1 -1
- package/dist/assets/{treemap-WGGIJYW6-7VaIVFkb.js → treemap-WGGIJYW6-C6m5_3M_.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-OlI3xHtd.js → usecaseDiagram-POWQR4AR-MOU_fUIi.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-ABZr8NaN.js → vennDiagram-UO4OBE2U-DQ03sLpX.js} +1 -1
- package/dist/assets/{wardley-WFR3VGLG-CX3iRocH.js → wardley-WFR3VGLG-D6oOZcfd.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-CmHM0PTe.js → wardleyDiagram-VNRHLVJA-DTu4wPK_.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-CYUP0J_R.js → xychartDiagram-PMCCYNJV-BnFJQnAh.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/backends/collectionActionIndex.spec.ts +40 -0
- package/server/backends/collectionActionIndex.ts +18 -0
- package/server/backends/collectionActionIndexRoute.ts +17 -0
- package/server/backends/collections.ts +1 -1
- package/server/backends/filesPage.ts +46 -0
- package/server/backends/html.ts +1 -1
- package/server/backends/remoteHost/handlers/listSkills.ts +55 -6
- package/server/backends/remoteHost/listSkillsSession.spec.ts +84 -0
- package/server/backends/remoteHost/pluginSkills.spec.ts +110 -0
- package/server/backends/remoteHost/pluginSkills.ts +90 -0
- package/server/backends/remoteHost/skillOverrides.ts +7 -2
- package/server/backends/sharedApp/context.ts +3 -0
- package/server/backends/sharedApp/declare.ts +18 -6
- package/server/backends/sharedApp/publish.ts +11 -3
- package/server/backends/sharedApp/rosterRemovals.ts +59 -0
- package/server/blueprint/collectionSnapshot.ts +87 -0
- package/server/blueprint/executor.ts +20 -5
- package/server/blueprint/newFolder.ts +13 -0
- package/server/blueprint/routes.ts +59 -9
- package/server/blueprint/samples.ts +4 -10
- package/server/blueprint/wiring.ts +5 -0
- package/server/blueprint/writeAllOrNone.ts +11 -0
- package/server/config/app-config.ts +6 -0
- package/server/config/config-body.ts +1 -0
- package/server/config/config-routes.ts +1 -1
- package/server/config/config-schema.ts +2 -0
- package/server/config/dir-config.ts +6 -1
- package/server/config/header-config.ts +31 -7
- package/server/config/header-context.ts +1 -1
- package/server/config/header-resolve.ts +7 -5
- package/server/config/worktree-dir-config.ts +1 -1
- package/server/files/mdPreviewEmbed.ts +10 -7
- package/server/infra/shared-app-tool.ts +24 -5
- package/server/routes/app-routes.ts +4 -0
- package/server/session/activity-transition.ts +12 -3
- package/server/session/lifecycle.ts +3 -2
- package/server/skills/mulmoterminal-header/SKILL.md +25 -1
- package/server/skills/mulmoterminal-keys/SKILL.md +8 -1
- package/server/skills/mulmoterminal-shared-app/SKILL.md +10 -2
- package/dist/assets/architecture-7GRP2DOG-DSPxsQOu.js +0 -1
- package/dist/assets/channel-j_IknEot.js +0 -1
- package/dist/assets/eventmodeling-NTZA5JFV-BnIWMpmq.js +0 -1
- package/dist/assets/flowDiagram-KWPJA3E3-CgKOYFOK.js +0 -1
- package/dist/assets/index-XRjuwB8c.css +0 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-DUcNrXOB.js +0 -1
- package/dist/assets/railroad-ebnf-LZEXJU2U-CiLdyg9w.js +0 -1
- package/dist/assets/railroad-peg-WCYAUIDC-k26xztEI.js +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-DZwaV5aw.js +0 -8
package/README.md
CHANGED
|
@@ -303,7 +303,8 @@ with `tmux` installed the agent sessions survive and come back under **Settings
|
|
|
303
303
|
survived a restart**; without it they end with the server.
|
|
304
304
|
|
|
305
305
|
**First-run setup (optional).** `npx mulmoterminal@latest init` checks your environment (Node ≥ 22.12
|
|
306
|
-
and every CLI in the table above),
|
|
306
|
+
and every CLI in the table above), says how to update when Node is behind the latest LTS of its line
|
|
307
|
+
or Claude Code is behind its `stable` release, seeds the launcher's **directory
|
|
307
308
|
presets** from the projects in your Claude Code history, and writes `~/.mulmoterminal/config.json`.
|
|
308
309
|
It's **idempotent** — re-run it any time to refresh the presets; it overwrites the managed parts
|
|
309
310
|
and keeps your other settings. When `claude` is installed it can hand off to the
|
|
@@ -766,7 +767,7 @@ The Settings modal (the gear button) persists per-user UI choices to `~/.mulmote
|
|
|
766
767
|
|
|
767
768
|

|
|
768
769
|
|
|
769
|
-
*Open it from the gear button in the toolbar. The **sidebar** groups the sections — Appearance, Projects, Header & launch, Input, Models & servers, Notifications, Integrations, Sessions, Help — and one is on screen at a time; on a phone the sidebar becomes a picker above the section.
|
|
770
|
+
*Open it from the gear button in the toolbar. The **sidebar** groups the sections — Appearance, Projects, Header & launch, Input, Models & servers, Notifications, Integrations, Sessions, Help — and one is on screen at a time; on a phone the sidebar becomes a picker above the section. The interface is available in **English, 日本語, 简体中文, 繁體中文 and 한국어**: it follows your browser's language unless you pick one under **Language** (per browser, like the theme). Translated so far: this modal, the grid's status words, and every button's hover tip and screen-reader label; the rest of the app's words are still English. Under the title, a **Version** row shows what is running: the version from the shipped `package.json`, plus a `commit <sha>` chip on a git checkout — there the version is only whatever was last released, so the commit is what identifies the build. When something newer exists, the row is followed by the header badge's update notice, command included. Pick a **theme**, set the **terminal font size**, **font** and **scroll speed** (and whether sending **returns to the latest output**), set a custom **attention sound**, list the repos the cross-repo **PRs & Issues** view should aggregate, add **launch commands** for grid cells, register your own **MCP servers**, and turn on the switches for what this app writes on your behalf — **issue work comments**, the **PR clone footer**, the **closing summary**, the **decision digest**, the **dev worklog** — no need to hand-edit the config file. Four settings stay with their skill because a form would be the wrong tool for them (`keymap`, `themes`, `providers`, `buttons`/`chips`); Settings shows what each is doing now and launches that skill. Note that **theme, font size, scroll speed and the return-to-latest switch are stored per browser** (they're display preferences, so a phone and a desktop keep their own); the rest live in `~/.mulmoterminal/config.json` and are shared by every client.*
|
|
770
771
|
|
|
771
772
|
| Field | Meaning |
|
|
772
773
|
| ------------ | ------- |
|
|
@@ -1287,6 +1288,29 @@ server says when it arrives — including any directory you expanded while waiti
|
|
|
1287
1288
|
pane has not read before says so (`Loading…`) rather than claiming to be empty, and a read that
|
|
1288
1289
|
fails shows the error rather than a tree it can no longer vouch for.
|
|
1289
1290
|
|
|
1291
|
+
**Several files open as tabs, when you ask for them.** A plain click in the tree replaces the file
|
|
1292
|
+
in front, as it always has — so someone who never wants a tab never sees one. **Cmd+click** (Ctrl+click
|
|
1293
|
+
off macOS) or the row menu's **Open in a new tab** adds a tab, and a strip of tabs appears under the
|
|
1294
|
+
header once there are two. Opening a file that already has a tab goes to that tab, from the tree, the
|
|
1295
|
+
finder, the search or a clicked terminal path alike. Switching tabs saves the one you leave, exactly as
|
|
1296
|
+
opening another file does, so only the front tab can hold unsaved edits; each tab keeps its own place
|
|
1297
|
+
(Preview or editor, caret, scroll) and the whole strip is remembered with the rest of the pane. Close a
|
|
1298
|
+
tab with its **×**, a middle click, or **Delete** while it has focus; ←/→, Home and End move between them.
|
|
1299
|
+
|
|
1300
|
+
**HTML pages and images show in the pane too.** An `.html` file has a **Preview** like a Markdown
|
|
1301
|
+
file: the page itself, sandboxed as presentHtml pages are — its scripts run on an opaque origin with
|
|
1302
|
+
no fetch/XHR; images and the curated CDN list still load, and so does an image it links beside it,
|
|
1303
|
+
while a relative stylesheet or script does not. An `.svg` previews as the picture it draws, and a PNG, JPEG, GIF or WebP opens as the image
|
|
1304
|
+
rather than as "not text". A path to any of these clicked in terminal output opens in the pane when
|
|
1305
|
+
it is up, so a chart an agent just wrote stays in the grid. They are served only under the workspace
|
|
1306
|
+
or a terminal's directory, like every other file the app hands to the browser.
|
|
1307
|
+
|
|
1308
|
+
**A link in the Preview opens in the pane.** A link to another file (`./b.md`, `../README.md`) is
|
|
1309
|
+
resolved against the document it is in and opens in a new tab — in Preview when it is Markdown — or
|
|
1310
|
+
goes to the tab that file already has. A link that climbs above the pane's folder is not opened and
|
|
1311
|
+
the pane says so; an external link opens in a new browser tab, and a `#heading` link scrolls the
|
|
1312
|
+
document.
|
|
1313
|
+
|
|
1290
1314
|
The toggle is not the only way in: while a cell is enlarged, **clicking a file path the agent
|
|
1291
1315
|
printed** opens it here too, rather than in a new tab or full-screen — see
|
|
1292
1316
|
[Clicking a file path](#clicking-a-file-path).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const NODE_RELEASES_URL: string;
|
|
2
|
+
export declare const CLAUDE_DIST_TAGS_URL: string;
|
|
3
|
+
export declare function fetchNodeReleases(): Promise<unknown>;
|
|
4
|
+
export declare function fetchClaudeStable(): Promise<string | null>;
|
|
5
|
+
export declare function resolveCommandPath(cmd: string, env?: Record<string, string | undefined>, platform?: string): string | null;
|
|
6
|
+
export declare function readClaudeVersion(binaryPath: string, timeout_ms?: number): Promise<string | null>;
|
|
7
|
+
export declare function checkVersions(options: { claudeBin: string | null }): Promise<{ node: string[]; claude: string[] }>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The network and process half of `init`'s version check (#2477); the decisions are in
|
|
2
|
+
// bin/version-drift.js. Every probe resolves null on failure — `init` must finish offline.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { extensionsFor, namesAPath, realProbe, searchDirectories } from "./has-command.js";
|
|
7
|
+
import { nodeUpgradeGuide } from "./node-install.js";
|
|
8
|
+
import { claudeDrift, claudeDriftLines, claudeUpdateCommands, nodeDrift, nodeDriftLines, parseClaudeVersion } from "./version-drift.js";
|
|
9
|
+
|
|
10
|
+
export const NODE_RELEASES_URL = "https://nodejs.org/dist/index.json";
|
|
11
|
+
const REGISTRY = (process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/$/, "");
|
|
12
|
+
export const CLAUDE_DIST_TAGS_URL = `${REGISTRY}/-/package/@anthropic-ai/claude-code/dist-tags`;
|
|
13
|
+
|
|
14
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
15
|
+
// `claude --version` starts the whole CLI, which is slower than a fetch on a cold disk.
|
|
16
|
+
const CLAUDE_VERSION_TIMEOUT_MS = 8000;
|
|
17
|
+
|
|
18
|
+
async function fetchJson(url) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
|
|
21
|
+
return response.ok ? await response.json() : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const fetchNodeReleases = () => fetchJson(NODE_RELEASES_URL);
|
|
28
|
+
|
|
29
|
+
export async function fetchClaudeStable() {
|
|
30
|
+
const distTags = await fetchJson(CLAUDE_DIST_TAGS_URL);
|
|
31
|
+
return typeof distTags?.stable === "string" ? distTags.stable : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where `cmd` lives, symlinks followed, or null — searched with the start-up gate's own PATH rules
|
|
36
|
+
* (bin/has-command.js). Only picks the update command, never gates.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveCommandPath(cmd, env = process.env, platform = process.platform) {
|
|
39
|
+
const join = platform === "win32" ? path.win32.join : path.posix.join;
|
|
40
|
+
const candidates = namesAPath(cmd) ? [cmd] : searchDirectories(platform, env).flatMap((dir) => extensionsFor(platform).map((ext) => join(dir, cmd + ext)));
|
|
41
|
+
const found = candidates.find((candidate) => realProbe.isFile(candidate) && (platform === "win32" || realProbe.isExecutable(candidate)));
|
|
42
|
+
if (found === undefined) return null;
|
|
43
|
+
try {
|
|
44
|
+
return realpathSync(found);
|
|
45
|
+
} catch {
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Own timer rather than execFile's `timeout`: that one only signals the child and then waits for it
|
|
51
|
+
// to exit, so a `claude` that ignores SIGTERM (or a grandchild holding stdout) would hold `init`.
|
|
52
|
+
export function readClaudeVersion(binaryPath, timeout_ms = CLAUDE_VERSION_TIMEOUT_MS) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const child = execFile(binaryPath, ["--version"], (error, stdout) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve(error ? null : parseClaudeVersion(stdout));
|
|
57
|
+
});
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
child.kill("SIGKILL");
|
|
60
|
+
resolve(null);
|
|
61
|
+
}, timeout_ms);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function claudeLines(claudeBin) {
|
|
66
|
+
const binaryPath = resolveCommandPath(claudeBin);
|
|
67
|
+
const [localVersion, stable] = await Promise.all([binaryPath ? readClaudeVersion(binaryPath) : null, fetchClaudeStable()]);
|
|
68
|
+
return claudeDriftLines(claudeDrift(localVersion, stable), localVersion, claudeUpdateCommands(binaryPath));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function nodeLines() {
|
|
72
|
+
const releases = await fetchNodeReleases();
|
|
73
|
+
return nodeDriftLines(nodeDrift(process.versions.node, releases), nodeUpgradeGuide(process.execPath, process.platform, process.env));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The hint lines `init` prints under its Node and Claude Code checks. */
|
|
77
|
+
export async function checkVersions({ claudeBin }) {
|
|
78
|
+
const [node, claude] = await Promise.all([nodeLines(), claudeBin ? claudeLines(claudeBin) : []]);
|
|
79
|
+
return { node, claude };
|
|
80
|
+
}
|
package/bin/has-command.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export interface CommandProbe {
|
|
|
3
3
|
isExecutable: (candidate: string) => boolean;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export declare const realProbe: CommandProbe;
|
|
7
|
+
export declare const namesAPath: (cmd: string) => boolean;
|
|
8
|
+
export declare const extensionsFor: (platform: string) => string[];
|
|
9
|
+
export declare const searchDirectories: (platform: string, env: Record<string, string | undefined>) => string[];
|
|
6
10
|
export declare function hasCommand(
|
|
7
11
|
cmd: string,
|
|
8
12
|
options?: { platform?: NodeJS.Platform; env?: Record<string, string | undefined>; probe?: CommandProbe },
|
package/bin/has-command.js
CHANGED
|
@@ -37,9 +37,9 @@ import path from "node:path";
|
|
|
37
37
|
const WINDOWS_LAUNCHABLE_EXTENSIONS = ["", ".exe", ".com", ".cmd", ".bat"];
|
|
38
38
|
|
|
39
39
|
/** A name CreateProcess/execvp resolves itself rather than by searching PATH. */
|
|
40
|
-
const namesAPath = (cmd) => cmd.includes("/") || cmd.includes("\\");
|
|
40
|
+
export const namesAPath = (cmd) => cmd.includes("/") || cmd.includes("\\");
|
|
41
41
|
|
|
42
|
-
const realProbe = {
|
|
42
|
+
export const realProbe = {
|
|
43
43
|
isFile: (candidate) => {
|
|
44
44
|
try {
|
|
45
45
|
return statSync(candidate).isFile();
|
|
@@ -60,7 +60,7 @@ const realProbe = {
|
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
// "" first so an explicit `foo.exe` is found as itself rather than as `foo.exe.EXE`.
|
|
63
|
-
const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_EXTENSIONS : [""]);
|
|
63
|
+
export const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_EXTENSIONS : [""]);
|
|
64
64
|
|
|
65
65
|
// A Windows PATH entry may be QUOTED — `"C:\Program Files\tools"` — which the shells strip and a
|
|
66
66
|
// plain join would not, leaving a path that matches nothing. Same rule as the server's
|
|
@@ -69,7 +69,7 @@ const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_E
|
|
|
69
69
|
// The current directory is on neither platform's list. cmd.exe searches it and POSIX shells do
|
|
70
70
|
// not, but what launches an agent is node-pty on both — and the directory THIS process sits in is
|
|
71
71
|
// the launch directory, never the one the PTY will run in.
|
|
72
|
-
const searchDirectories = (platform, env) => {
|
|
72
|
+
export const searchDirectories = (platform, env) => {
|
|
73
73
|
const raw = env.PATH || env.Path || "";
|
|
74
74
|
if (platform === "win32") {
|
|
75
75
|
return raw
|
package/bin/mulmoterminal.js
CHANGED
|
@@ -223,12 +223,18 @@ function passStartupGate(args) {
|
|
|
223
223
|
async function runInit(initArgs) {
|
|
224
224
|
log("Setting up MulmoTerminal…\n");
|
|
225
225
|
|
|
226
|
+
const hasClaude = agentInstalled("claude");
|
|
227
|
+
// Imported here so a normal launch never loads the version check or reaches its network.
|
|
228
|
+
const { checkVersions } = await import("./check-versions.js");
|
|
229
|
+
const versionHints = await checkVersions({ claudeBin: hasClaude ? agentBin("claude") : null });
|
|
230
|
+
|
|
226
231
|
const nodeOk = nodeMeetsMinimum(process.versions.node);
|
|
227
232
|
console.log(nodeOk ? ` ✓ Node ${process.versions.node}` : ` ✗ Node ${process.versions.node} — MulmoTerminal needs ≥ ${MIN_NODE_LABEL}`);
|
|
233
|
+
versionHints.node.forEach((line) => console.log(line));
|
|
228
234
|
|
|
229
|
-
const hasClaude = agentInstalled("claude");
|
|
230
235
|
if (hasClaude) {
|
|
231
236
|
console.log(" ✓ Claude Code CLI");
|
|
237
|
+
versionHints.claude.forEach((line) => console.log(line));
|
|
232
238
|
} else {
|
|
233
239
|
console.log(" ✗ Claude Code CLI — not found");
|
|
234
240
|
console.log(" → npm install -g @anthropic-ai/claude-code (then run `claude` and log in)");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Drift = { kind: "current" } | { kind: "unknown" } | { kind: "behind"; latest: string; sameMajor?: boolean };
|
|
2
|
+
export declare const CURRENT: Drift;
|
|
3
|
+
export declare const UNKNOWN: Drift;
|
|
4
|
+
export declare const CLAUDE_UPDATE_NATIVE: string;
|
|
5
|
+
export declare const CLAUDE_UPDATE_NPM: string;
|
|
6
|
+
export declare function nodeDrift(localVersion: string, releases: unknown): Drift;
|
|
7
|
+
export declare function claudeDrift(localVersion: string | null, stableVersion: string | null): Drift;
|
|
8
|
+
export declare function parseClaudeVersion(stdout: string | null | undefined): string | null;
|
|
9
|
+
export declare function claudeUpdateCommands(realPath: string | null): string[];
|
|
10
|
+
export declare function nodeDriftLines(drift: Drift, upgrade: import("./node-install.js").NodeUpgradeGuide): string[];
|
|
11
|
+
export declare function claudeDriftLines(drift: Drift, localVersion: string | null, updateCommands: string[]): string[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Whether this machine's Node and Claude Code are behind the published releases, and what `init`
|
|
2
|
+
// says about it (#2477). Pure: the releases, the local versions and the binary path are handed in
|
|
3
|
+
// by bin/check-versions.js, so every decision here is testable without the network.
|
|
4
|
+
import { NODE_DOWNLOAD_URL } from "./node-install.js";
|
|
5
|
+
import { isNewerVersion } from "./update-check.js";
|
|
6
|
+
|
|
7
|
+
export const CURRENT = { kind: "current" };
|
|
8
|
+
export const UNKNOWN = { kind: "unknown" };
|
|
9
|
+
|
|
10
|
+
const stripV = (version) => String(version).replace(/^v/, "");
|
|
11
|
+
const majorOf = (version) => Number.parseInt(stripV(version).split(".")[0], 10);
|
|
12
|
+
const newestOf = (versions) => versions.reduce((newest, version) => (isNewerVersion(version, newest) ? version : newest));
|
|
13
|
+
// Remote strings are printed as-is, so only a plain x.y.z is accepted — anything else would compare
|
|
14
|
+
// as zeros (and read as current) or put the registry's text on the user's terminal.
|
|
15
|
+
const isPlainVersion = (version) => typeof version === "string" && /^v?\d+\.\d+\.\d+$/.test(version);
|
|
16
|
+
const isRelease = (entry) => typeof entry === "object" && entry !== null && isPlainVersion(entry.version);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Node against the LTS releases in nodejs.org's index.json.
|
|
20
|
+
*
|
|
21
|
+
* The target is the newest LTS of the same major: moving someone off a working 22.x because 24.x
|
|
22
|
+
* exists is not this check's call. A major with no LTS release is compared with the newest LTS,
|
|
23
|
+
* unless it is newer than that one — a Current release is not behind anything.
|
|
24
|
+
*/
|
|
25
|
+
export function nodeDrift(localVersion, releases) {
|
|
26
|
+
if (!Array.isArray(releases) || Number.isNaN(majorOf(localVersion))) return UNKNOWN;
|
|
27
|
+
const ltsVersions = releases.filter((entry) => isRelease(entry) && entry.lts).map((entry) => stripV(entry.version));
|
|
28
|
+
if (ltsVersions.length === 0) return UNKNOWN;
|
|
29
|
+
const sameMajor = ltsVersions.filter((version) => majorOf(version) === majorOf(localVersion));
|
|
30
|
+
const newestLts = newestOf(ltsVersions);
|
|
31
|
+
if (sameMajor.length === 0 && majorOf(localVersion) > majorOf(newestLts)) return CURRENT;
|
|
32
|
+
const target = sameMajor.length > 0 ? newestOf(sameMajor) : newestLts;
|
|
33
|
+
return isNewerVersion(target, stripV(localVersion)) ? { kind: "behind", latest: target, sameMajor: sameMajor.length > 0 } : CURRENT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Claude Code against the npm `stable` dist-tag. Ahead of `stable` (on `latest`) is current. */
|
|
37
|
+
export function claudeDrift(localVersion, stableVersion) {
|
|
38
|
+
if (typeof localVersion !== "string" || !isPlainVersion(stableVersion)) return UNKNOWN;
|
|
39
|
+
return isNewerVersion(stableVersion, localVersion) ? { kind: "behind", latest: stableVersion } : CURRENT;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `claude --version` prints `2.1.284 (Claude Code)`; anything without a leading version is null. */
|
|
43
|
+
export function parseClaudeVersion(stdout) {
|
|
44
|
+
return /^\s*(\d+\.\d+\.\d+)/.exec(String(stdout ?? ""))?.[1] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const CLAUDE_UPDATE_NATIVE = "claude update";
|
|
48
|
+
export const CLAUDE_UPDATE_NPM = "npm install -g @anthropic-ai/claude-code@latest";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How to update the Claude Code at `realPath`. Only the two layouts checked on a real machine get a
|
|
52
|
+
* single command; anything else gets both, as the server's own "too old" message does.
|
|
53
|
+
*/
|
|
54
|
+
export function claudeUpdateCommands(realPath) {
|
|
55
|
+
const path = String(realPath ?? "").replaceAll("\\", "/");
|
|
56
|
+
if (path.includes("/node_modules/@anthropic-ai/claude-code/")) return [CLAUDE_UPDATE_NPM];
|
|
57
|
+
if (path.includes("/.local/share/claude/")) return [CLAUDE_UPDATE_NATIVE];
|
|
58
|
+
return [CLAUDE_UPDATE_NATIVE, `${CLAUDE_UPDATE_NPM} (if you installed it with npm)`];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const HINT = " → ";
|
|
62
|
+
const COMMAND = " ";
|
|
63
|
+
|
|
64
|
+
function updateLines(headline, commands, fallback, via = null) {
|
|
65
|
+
if (commands.length === 0) return [`${HINT}${headline} Update: ${fallback}`];
|
|
66
|
+
const withTool = via ? " with " + via : "";
|
|
67
|
+
return [`${HINT}${headline} To update${withTool}:`, ...commands.map((command) => `${COMMAND}${command}`)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The lines printed under `✓ Node …`; empty when there is nothing to say. The commands are the
|
|
72
|
+
* install tool's own upgrade (`nodeUpgradeGuide`), which may land on a newer major than the one
|
|
73
|
+
* named — so the tool is named too.
|
|
74
|
+
*/
|
|
75
|
+
export function nodeDriftLines(drift, upgrade) {
|
|
76
|
+
if (drift.kind === "unknown") return [`${HINT}○ could not check for a newer Node (offline?)`];
|
|
77
|
+
if (drift.kind !== "behind") return [];
|
|
78
|
+
const headline = drift.sameMajor ? `Node ${drift.latest} is out (latest ${majorOf(drift.latest)}.x LTS).` : `Node ${drift.latest} is the latest LTS.`;
|
|
79
|
+
return updateLines(headline, upgrade.commands, NODE_DOWNLOAD_URL, upgrade.via);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The lines printed under `✓ Claude Code CLI`; empty when there is nothing to say. */
|
|
83
|
+
export function claudeDriftLines(drift, localVersion, updateCommands) {
|
|
84
|
+
if (localVersion === null) return [`${HINT}○ could not read the Claude Code version`];
|
|
85
|
+
if (drift.kind === "unknown") return [`${HINT}○ could not check for a newer Claude Code (offline?)`];
|
|
86
|
+
if (drift.kind !== "behind") return [];
|
|
87
|
+
return updateLines(`Claude Code ${localVersion} is older than the stable release ${drift.latest}.`, updateCommands, CLAUDE_UPDATE_NATIVE);
|
|
88
|
+
}
|
|
@@ -12,7 +12,7 @@ repository, `.git/info/exclude`) until the person has approved the models.
|
|
|
12
12
|
1. `.blueprint/` already exists (the build created it). Everything this build records goes there.
|
|
13
13
|
2. Run chaff once through the base pack's wrapper, so its first download happens now and not in the
|
|
14
14
|
middle of a later step: `sh <base pack>/checks/chaff.sh rules --json > /dev/null`. The wrapper runs the
|
|
15
|
-
pinned chaff (`npx -y chaffjs@0.
|
|
15
|
+
pinned chaff (`npx -y chaffjs@0.11`) unless `CHAFF_BIN` names another. If it fails, show the person the
|
|
16
16
|
error and stop — do not work around it.
|
|
17
17
|
3. If the folder is a git repository, add `.blueprint/` to `.git/info/exclude`. That keeps the build's
|
|
18
18
|
working files out of commits without touching `.gitignore`, which belongs to the person.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The security review's report exists, walks every OWASP Top 10:2025 category, and leaves no HIGH or
|
|
3
|
+
# MEDIUM finding open or merely accepted. A finding is one line: `- <HIGH|MEDIUM|LOW> <fixed|open|accepted>: …`.
|
|
4
|
+
set -eu
|
|
5
|
+
report=.blueprint/security-review.md
|
|
6
|
+
[ -s "$report" ] || { echo "missing $report" >&2; exit 1; }
|
|
7
|
+
for category in A01 A02 A03 A04 A05 A06 A07 A08 A09 A10; do
|
|
8
|
+
grep -q "^#.*$category" "$report" || { echo "$report has no section for $category (OWASP Top 10:2025)" >&2; exit 1; }
|
|
9
|
+
done
|
|
10
|
+
open=$(grep -nE '^- (HIGH|MEDIUM) (open|accepted)' "$report" || true)
|
|
11
|
+
[ -z "$open" ] || { printf 'HIGH / MEDIUM findings must be fixed, but are open or accepted in %s:\n%s\n' "$report" "$open" >&2; exit 1; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The security review is proven by what ships, not by the review's word: the rules refuse what the
|
|
3
|
+
# review's emulator tests say they refuse, the rules end in deny-all, Hosting sends the headers a browser
|
|
4
|
+
# needs, no service account key is in the project, the dependencies have no known high-severity hole,
|
|
5
|
+
# the review's own report is in place, and dev runs what the review left — so production, which ships
|
|
6
|
+
# what dev ran, gets the fixes, and a header that breaks the page shows up here, on dev.
|
|
7
|
+
set -eu
|
|
8
|
+
here="$(dirname "$0")"
|
|
9
|
+
sh "$here/security-report.sh"
|
|
10
|
+
grep -qE 'allow +read, *write: *if +false' firestore.rules || { echo "firestore.rules does not end in a deny-all (allow read, write: if false)" >&2; exit 1; }
|
|
11
|
+
node -e 'const fs=require("node:fs");
|
|
12
|
+
const hosting=[JSON.parse(fs.readFileSync("firebase.json","utf8")).hosting].flat().filter(Boolean);
|
|
13
|
+
if (hosting.length===0) { console.error("firebase.json has no hosting section"); process.exit(1); }
|
|
14
|
+
for (const site of hosting) {
|
|
15
|
+
const all=(site.headers??[]).filter((h)=>h.source==="**").flatMap((h)=>h.headers??[]);
|
|
16
|
+
const value=(key)=>all.find((h)=>h.key.toLowerCase()===key)?.value??"";
|
|
17
|
+
const missing=[];
|
|
18
|
+
if (!/nosniff/i.test(value("x-content-type-options"))) missing.push("X-Content-Type-Options: nosniff");
|
|
19
|
+
if (!value("content-security-policy")) missing.push("Content-Security-Policy");
|
|
20
|
+
if (!/frame-ancestors/i.test(value("content-security-policy")) && !/deny|sameorigin/i.test(value("x-frame-options"))) missing.push("frame-ancestors or X-Frame-Options");
|
|
21
|
+
if (missing.length) { console.error("firebase.json hosting headers for ** lack: "+missing.join(", ")); process.exit(1); }
|
|
22
|
+
}'
|
|
23
|
+
keys=$(grep -rlE '"private_key"|-----BEGIN [A-Z ]*PRIVATE KEY-----' . --exclude-dir=node_modules --exclude-dir=.firebase --exclude-dir=dist 2>/dev/null || true)
|
|
24
|
+
[ -z "$keys" ] || { printf 'a private key is in the project; move it out and revoke it:\n%s\n' "$keys" >&2; exit 1; }
|
|
25
|
+
audit() {
|
|
26
|
+
# Read from the summary, not the exit code: an audit that could not reach the registry must not pass.
|
|
27
|
+
(cd "$1" && yarn audit --groups dependencies --json 2>/dev/null) | node -e 'let s="";process.stdin.on("data",(c)=>s+=c).on("end",()=>{
|
|
28
|
+
const summary = s.split("\n").filter(Boolean).map((line) => JSON.parse(line)).find((entry) => entry.type === "auditSummary");
|
|
29
|
+
if (!summary) { console.error(process.argv[1] + ": yarn audit did not report a summary (is the registry reachable?)"); process.exit(1); }
|
|
30
|
+
const { high = 0, critical = 0 } = summary.data.vulnerabilities;
|
|
31
|
+
if (high + critical > 0) { console.error(`${process.argv[1]}: yarn audit finds ${high} high and ${critical} critical vulnerabilities in dependencies`); process.exit(1); }
|
|
32
|
+
})' "$1"
|
|
33
|
+
}
|
|
34
|
+
audit .
|
|
35
|
+
if [ -f functions/package.json ]; then
|
|
36
|
+
[ -f functions/yarn.lock ] || { echo "functions/ has no yarn.lock; install its dependencies with yarn so they can be audited" >&2; exit 1; }
|
|
37
|
+
audit functions
|
|
38
|
+
fi
|
|
39
|
+
sh "$here/emulator-test.sh" security
|
|
40
|
+
[ .blueprint/build-id -nt .blueprint/security-review.md ] || { echo "dev was not deployed after the review; deploy it to dev again so production ships what dev runs" >&2; exit 1; }
|
|
41
|
+
sh "$here/deploy-check.sh" dev
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The first page the finished build shows says where the published app is and what to try to see it works.
|
|
3
|
+
set -eu
|
|
4
|
+
start=.blueprint/start-here.md
|
|
5
|
+
[ -s "$start" ] || { echo "missing $start" >&2; exit 1; }
|
|
6
|
+
prod=$(sh "$(dirname "$0")/project-id.sh" prod)
|
|
7
|
+
grep -q "https://$prod.web.app" "$start" || { echo "$start does not name the production URL (https://$prod.web.app)" >&2; exit 1; }
|
|
8
|
+
grep -q '^- \[ \] ' "$start" || { echo "$start has no checklist of what to try (- [ ] …)" >&2; exit 1; }
|
|
@@ -59,12 +59,19 @@
|
|
|
59
59
|
"check": "sh \"$BLUEPRINT_BASE/checks/protect.sh\"",
|
|
60
60
|
"gates": ["billing"]
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
"id": "security",
|
|
64
|
+
"title": "セキュリティ診断",
|
|
65
|
+
"description": "本番に公開する前に、OWASP Top 10:2025 に沿って作ったものを攻撃する側の目で読み、突ける穴を直してエミュレータの試験で確かめ、報告を書く。直したものは開発用に公開し直して、画面が壊れていないことまで確かめる。",
|
|
66
|
+
"skill": "skills/security",
|
|
67
|
+
"check": "sh \"$BLUEPRINT_BASE/checks/security.sh\""
|
|
68
|
+
},
|
|
62
69
|
{
|
|
63
70
|
"id": "deploy-production",
|
|
64
71
|
"title": "本番に公開",
|
|
65
72
|
"description": "開発用で確かめたものと同じビルドを本番に公開する。",
|
|
66
73
|
"skill": "skills/deploy-production",
|
|
67
|
-
"check": "sh \"$BLUEPRINT_BASE/checks/deploy-check.sh\" prod",
|
|
74
|
+
"check": "sh \"$BLUEPRINT_BASE/checks/deploy-check.sh\" prod && sh \"$BLUEPRINT_BASE/checks/start-here.sh\"",
|
|
68
75
|
"gates": ["deploy-production"]
|
|
69
76
|
}
|
|
70
77
|
]
|
|
@@ -12,8 +12,21 @@ This is the **deploy-production** gate: the user has approved it. Deploy the sam
|
|
|
12
12
|
then `firebase deploy --project prod`. The check compares the served id with `.blueprint/build-id`, so it
|
|
13
13
|
proves THIS build is live. Git is not needed for this.
|
|
14
14
|
2. Tell the user the URL and that the next change should go through dev first.
|
|
15
|
+
3. Write `.blueprint/start-here.md`, in the spec's language. It is what the person sees the moment the build
|
|
16
|
+
finishes, so it answers "now what?" in the order they will need it:
|
|
17
|
+
- **Open it** — the production URL (`https://<prod-project-id>.web.app`) and who can sign in, and how.
|
|
18
|
+
- **Check that it works** — one checkbox per must-have in the spec, each an action and what should happen
|
|
19
|
+
(`- [ ] 申請を 1 件出す → 承認者の一覧に出る`), using the app's real screen and button names; one for
|
|
20
|
+
someone who should be refused (another account, a signed-out visitor) being refused.
|
|
21
|
+
- **The next change** — edit, try it on dev (`https://<dev-project-id>.web.app`), then publish to production;
|
|
22
|
+
never straight to production.
|
|
23
|
+
- **Costs and safety** — the budget alert and where it goes; where the security review is
|
|
24
|
+
(`.blueprint/security-review.md`).
|
|
15
25
|
|
|
16
|
-
|
|
26
|
+
Keep it short: it is a first page, not the manual.
|
|
27
|
+
|
|
28
|
+
Done when the check passes: the production site serves the id this deploy wrote to `.blueprint/build-id`, and
|
|
29
|
+
`.blueprint/start-here.md` names the production URL and has the checklist.
|
|
17
30
|
|
|
18
31
|
## Always
|
|
19
32
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blueprint-firebase-security
|
|
3
|
+
description: "Before production, review the app against OWASP Top 10:2025, fix what is exploitable, prove it on the emulators, and redeploy dev."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Security review
|
|
7
|
+
|
|
8
|
+
Everything is built and runs on dev. Before production, review it the way an attacker would read it, fix what
|
|
9
|
+
is exploitable, and leave a report the person can read. On Firebase the attacker does not need the screen: the
|
|
10
|
+
web config is public, so anyone can call Firestore and the callable functions directly with their own client.
|
|
11
|
+
The rules and the functions are the whole defence.
|
|
12
|
+
|
|
13
|
+
## Method
|
|
14
|
+
|
|
15
|
+
1. **Context.** Read `.blueprint/spec.md` (above all "必ず詰める点" and the access table), then `firestore.rules`,
|
|
16
|
+
`functions/src/`, `firebase.json` and the web app's source. Note who may do what and where input enters.
|
|
17
|
+
2. **Audit, category by category** (the list below). For each rule and each callable, ask what a signed-in
|
|
18
|
+
stranger, a signed-out visitor and a user of another role can do with a hand-written request.
|
|
19
|
+
3. **Fix** every HIGH and MEDIUM finding, and prove it in `test/blueprint/security.spec.ts` against the
|
|
20
|
+
emulators: the attack is refused (`assertFails`, or the callable throwing) and the legitimate use still works.
|
|
21
|
+
4. **Report** in `.blueprint/security-review.md` (format below).
|
|
22
|
+
5. **Redeploy dev** the way the deploy-dev step does (new `.blueprint/build-id`, `firebase deploy --project dev`),
|
|
23
|
+
even when nothing changed, so production ships exactly what dev now runs. Do not deploy to `prod`.
|
|
24
|
+
|
|
25
|
+
Severity: **HIGH** is directly exploitable (read or change someone's data, bypass sign-in or a role, run code).
|
|
26
|
+
**MEDIUM** needs a specific condition but the impact is real. **LOW** is defence in depth. Report a finding only
|
|
27
|
+
when you can state how it is exploited and you are at least 70% sure; a guess is not a finding. Do not report
|
|
28
|
+
denial of service, rate limits, quota exhaustion, or missing validation on a field that cannot cause harm.
|
|
29
|
+
|
|
30
|
+
## What to look at in this app (OWASP Top 10:2025)
|
|
31
|
+
|
|
32
|
+
- **A01 Broken Access Control** — every `match` opens only what the access table says; ownership and role come
|
|
33
|
+
from `request.auth` (custom claims), never from a field the client wrote; `list` queries cannot read past what
|
|
34
|
+
`get` allows; every callable checks `context.auth` and the role itself.
|
|
35
|
+
- **A02 Security Misconfiguration** — the rules end in deny-all; Hosting sends `Content-Security-Policy` (with
|
|
36
|
+
`frame-ancestors 'none'`, and `connect-src` / `script-src` listing exactly the Firebase and Google hosts the
|
|
37
|
+
app uses), `X-Content-Type-Options: nosniff` and `Referrer-Policy`; App Check enforced; no emulator setting
|
|
38
|
+
reaches the production build.
|
|
39
|
+
- **A03 Software Supply Chain Failures** — `yarn audit` clean of high and critical at the root and in
|
|
40
|
+
`functions/` (install `functions/` with yarn); no dependency the app does not use.
|
|
41
|
+
- **A04 Cryptographic Failures** — no service account key, private key or admin credential anywhere in the
|
|
42
|
+
project or the built site; secrets for functions in Secret Manager, not in code or `.env` that is deployed.
|
|
43
|
+
- **A05 Injection** — the screen never renders stored text as HTML (`v-html` only on text the app wrote);
|
|
44
|
+
functions build no query, URL, shell command or file path from input without checking it.
|
|
45
|
+
- **A06 Insecure Design** — the spec's rules (limits, states, who may change what) are in the rules or the
|
|
46
|
+
functions, not only in the screen; field lists pinned with `keys().hasOnly` and `affectedKeys().hasOnly`.
|
|
47
|
+
- **A07 Authentication Failures** — only the sign-in providers the spec names are enabled; a signed-out or
|
|
48
|
+
unverified user gets nothing the spec does not allow them.
|
|
49
|
+
- **A08 Software or Data Integrity Failures** — roles and privileges are set only by functions (custom claims),
|
|
50
|
+
never by a document a user can write; uploaded files are checked by Storage rules for type and size.
|
|
51
|
+
- **A09 Security Logging and Alerting Failures** — privileged actions and refusals in functions are logged
|
|
52
|
+
without tokens or personal data beyond what the spec needs.
|
|
53
|
+
- **A10 Mishandling of Exceptional Conditions** — callables throw `HttpsError` with a short message, never a
|
|
54
|
+
stack or an internal error text; a failed check refuses rather than lets through.
|
|
55
|
+
|
|
56
|
+
## The report
|
|
57
|
+
|
|
58
|
+
`.blueprint/security-review.md`, in the spec's language. One section per category, its heading naming the id
|
|
59
|
+
(`## A01 …` through `## A10 …`), each with what you checked and every finding on a line of its own:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
- HIGH fixed: <what was wrong, how it could be exploited> — <what changed, which test proves it>
|
|
63
|
+
- LOW accepted: <what, and why it is acceptable for this app>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The state is `fixed`, `open` or `accepted`. A HIGH or MEDIUM may not be `open` or `accepted`: fix it, or ask
|
|
67
|
+
through the blueprint question tool if fixing it would change what the spec asks for. A category with nothing
|
|
68
|
+
to report says what was checked and "指摘なし". Write the report before step 5, so the deploy comes after it.
|
|
69
|
+
|
|
70
|
+
Done when the check passes: the report covers A01–A10 with nothing HIGH or MEDIUM left open, the rules end in
|
|
71
|
+
deny-all, Hosting sends the headers, no private key is in the project, both audits are clean,
|
|
72
|
+
`emulator-test.sh security` passes, and dev serves a build deployed after the report and still renders.
|
|
73
|
+
|
|
74
|
+
## Always
|
|
75
|
+
|
|
76
|
+
- Read `.blueprint/spec.md` first. It is the agreed specification; do not widen it.
|
|
77
|
+
- When you need a decision, ask it through the blueprint question tool and stop. Do not guess.
|
|
78
|
+
- Never enable billing, deploy to production, delete data or handle a credential unless this step's gates say the
|
|
79
|
+
user approved exactly that.
|
|
80
|
+
- Say you are done by stopping; the executor runs the check. Do not claim success yourself.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The spec carries the whole source over: every field, view, action and ingest of every copied collection is named in it
|
|
3
|
+
# by a token that says which collection it belongs to — `books.title`, `books.views.board`, `books.actions.tidy`,
|
|
4
|
+
# `books.ingest` — so nothing is dropped without a person seeing it go. A bare key is not enough: two collections
|
|
5
|
+
# share keys (`id`, `name`), and one of them would vouch for the other.
|
|
6
|
+
set -eu
|
|
7
|
+
[ -s .blueprint/source/source.json ] || { echo "missing .blueprint/source/source.json; the build was started without a collection" >&2; exit 1; }
|
|
8
|
+
node -e '
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const spec = fs.readFileSync(".blueprint/spec.md", "utf8");
|
|
11
|
+
const source = JSON.parse(fs.readFileSync(".blueprint/source/source.json", "utf8"));
|
|
12
|
+
const missing = source.collections.flatMap((slug) => {
|
|
13
|
+
const schema = JSON.parse(fs.readFileSync(`.blueprint/source/collections/${slug}/schema.json`, "utf8"));
|
|
14
|
+
const tokens = [
|
|
15
|
+
...Object.keys(schema.fields ?? {}).map((key) => `${slug}.${key}`),
|
|
16
|
+
...(schema.views ?? []).map((view) => `${slug}.views.${view.id}`),
|
|
17
|
+
...[...(schema.actions ?? []), ...(schema.collectionActions ?? [])].map((action) => `${slug}.actions.${action.id}`),
|
|
18
|
+
...(schema.ingest ? [`${slug}.ingest`] : []),
|
|
19
|
+
];
|
|
20
|
+
return tokens.filter((token) => !spec.includes("`" + token + "`"));
|
|
21
|
+
});
|
|
22
|
+
if (missing.length > 0) {
|
|
23
|
+
console.error("the spec does not carry these over from the source (name each in backquotes, as `collection.key`):\n" + missing.join("\n"));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
'
|