@coopcli/specsketch 0.1.3 → 5.3.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 +80 -2
- package/dist/cli/index.js +2159 -81
- package/dist/client/assets/abnfDiagram-VRR7QNED-CTNJVYti.js +1 -0
- package/dist/client/assets/{ar-SA-G6X2FPQ2-jwBzex-2.js → ar-SA-G6X2FPQ2-Dx5RkIpf.js} +1 -1
- package/dist/client/assets/architecture-TIHT7OUA-b9_tu6da.js +1 -0
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DdYSrIl0.js → architectureDiagram-ZJ3FMSHR-C7zIp5ev.js} +1 -1
- package/dist/client/assets/{az-AZ-76LH7QW2-Dis0vDnD.js → az-AZ-76LH7QW2-lKEr0lki.js} +1 -1
- package/dist/client/assets/{bg-BG-XCXSNQG7-BCcYYSpX.js → bg-BG-XCXSNQG7-BrueLcBf.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-IHRgXLG2.js → blockDiagram-677ZJIJ3-B3Y6utF7.js} +1 -1
- package/dist/client/assets/{bn-BD-2XOGV67Q-Cs38e4dA.js → bn-BD-2XOGV67Q-BVnHj_Zk.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-DuitrGTA.js → c4Diagram-LMCZKHZV-Dt_cruyV.js} +1 -1
- package/dist/client/assets/{ca-ES-6MX7JW3Y-J6JluMn1.js → ca-ES-6MX7JW3Y-BM6SppAL.js} +1 -1
- package/dist/client/assets/channel-Dz1KOO7f.js +1 -0
- package/dist/client/assets/{chunk-32BRIVSS-FVqbFiM9.js → chunk-32BRIVSS-Ca80Axke.js} +1 -1
- package/dist/client/assets/{chunk-52WLFC77-B5fSTF98.js → chunk-52WLFC77-CtSrQbPt.js} +1 -1
- package/dist/client/assets/{chunk-C7G6YPKG-CUlznrHX.js → chunk-C7G6YPKG-B2LFv6gm.js} +1 -1
- package/dist/client/assets/{chunk-EIO257PC-B_XNiJ0A.js → chunk-EIO257PC-BAdMCi3M.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-D7H3g3lm.js → chunk-EX3LRPZG-BJ4MtZ6_.js} +1 -1
- package/dist/client/assets/{chunk-FWX5IMBZ-DlSTzkRW.js → chunk-FWX5IMBZ-Oeex84SP.js} +2 -2
- package/dist/client/assets/{chunk-HOUHSVGY-BB87Tq5U.js → chunk-HOUHSVGY-CwXgFjr1.js} +1 -1
- package/dist/client/assets/{chunk-ICXQ74PX-DHmdnexU.js → chunk-ICXQ74PX-CNjPZw9f.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-Cd67Wk3-.js → chunk-MOJQB5TN-CH9qxz0s.js} +1 -1
- package/dist/client/assets/{chunk-OGEWGWER-G3kRomY-.js → chunk-OGEWGWER-CXz-hiqH.js} +1 -1
- package/dist/client/assets/{chunk-PUDLZKDR-DJw3XJhs.js → chunk-PUDLZKDR-DeKKZ45n.js} +1 -1
- package/dist/client/assets/{chunk-Q4XR5HBZ-BV5Bw0_D.js → chunk-Q4XR5HBZ-BfA-corw.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-C2vxCA10.js → chunk-V7JOEXUC-DBArf2HI.js} +1 -1
- package/dist/client/assets/{chunk-VAUOI2AC-B2lAp2X6.js → chunk-VAUOI2AC-Ce5MjSpg.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Dk58Ba0X.js → chunk-VR4S4FIN-DUkeWBHv.js} +1 -1
- package/dist/client/assets/{chunk-WYO6CB5R-rhSewh5r.js → chunk-WYO6CB5R-5IIfqwS1.js} +1 -1
- package/dist/client/assets/{chunk-ZGVPDNZ5-C8pQspwU.js → chunk-ZGVPDNZ5-BVFUMmyX.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-5dxJ_ejB.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-5dxJ_ejB.js +1 -0
- package/dist/client/assets/{cs-CZ-2BRQDIVT-CMTTkdlc.js → cs-CZ-2BRQDIVT-BeWSN4Lg.js} +1 -1
- package/dist/client/assets/{railroad-3IZDKUUU-Cyefv0S0.js → cynefin-VYW2F7L2-BPoEGt5P.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BlF8b9qP.js → cynefinDiagram-TSTJHNR4-D-HGa5eC.js} +1 -1
- package/dist/client/assets/{da-DK-5WZEPLOC-BMtFOXTa.js → da-DK-5WZEPLOC-C7Kj8cSr.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DytIg2RQ.js → dagre-VKFMJZFB-DYJas6XO.js} +1 -1
- package/dist/client/assets/{de-DE-XR44H4JA-C2G_UWRS.js → de-DE-XR44H4JA-CGONhJum.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-BOqWKyO9.js → diagram-FQU43EPY-CovScBBf.js} +2 -2
- package/dist/client/assets/{diagram-G47NLZAW-1dP1jpC7.js → diagram-G47NLZAW-6_UOQ1JD.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-VyXFGKGb.js → diagram-NH7WQ7WH-DfM8Ux2D.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-BP7GwNyd.js → diagram-OA4YK3LP-BFJDUTm_.js} +2 -2
- package/dist/client/assets/{diagram-WEI45ONY-DyHZtrRF.js → diagram-WEI45ONY-8OKtOSaq.js} +1 -1
- package/dist/client/assets/{dist-C_FPOqHa.js → dist-CBoHd3nF.js} +4 -4
- package/dist/client/assets/ebnfDiagram-CCIWWBDH-TBneTBs-.js +1 -0
- package/dist/client/assets/{el-GR-BZB4AONW-BTK70kle.js → el-GR-BZB4AONW-C9Q6-3JL.js} +1 -1
- package/dist/client/assets/{en-B4ZKOASM-DQDUvTjb.js → en-B4ZKOASM-DooiP2-J.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-CI8TMXqA.js → erDiagram-Q63AITRT-Dy5QBJCT.js} +1 -1
- package/dist/client/assets/{es-ES-U4NZUMDT-6TeZK11C.js → es-ES-U4NZUMDT-DKxT2wWX.js} +1 -1
- package/dist/client/assets/{eu-ES-A7QVB2H4-Cb0oNkmv.js → eu-ES-A7QVB2H4-DrKnZ3gY.js} +1 -1
- package/dist/client/assets/eventmodeling-45OFAUF4-DP9O5S3e.js +1 -0
- package/dist/client/assets/{fa-IR-HGAKTJCU-JYkLqShQ.js → fa-IR-HGAKTJCU-DjA2Px-T.js} +1 -1
- package/dist/client/assets/{fi-FI-Z5N7JZ37-u_gehdrx.js → fi-FI-Z5N7JZ37-NUghXbpa.js} +1 -1
- package/dist/client/assets/flowDiagram-23GEKE2U-CUBp7I9C.js +1 -0
- package/dist/client/assets/{fr-FR-RHASNOE6-CmE3ELbC.js → fr-FR-RHASNOE6-9Vmv2nhB.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-CH13wSU6.js → ganttDiagram-NO4QXBWP-DNoDTMaW.js} +1 -1
- package/dist/client/assets/gitGraph-TEB2WS4Q-BdzEwYG8.js +1 -0
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-IpKqkdqX.js → gitGraphDiagram-IHSO6WYX-Coi-sbuV.js} +1 -1
- package/dist/client/assets/{gl-ES-HMX3MZ6V-Fy4K9BOx.js → gl-ES-HMX3MZ6V-CrTqlGTr.js} +1 -1
- package/dist/client/assets/{he-IL-6SHJWFNN-BtuHPbrL.js → he-IL-6SHJWFNN-BDoFv8vR.js} +1 -1
- package/dist/client/assets/{hi-IN-IWLTKZ5I-TEGNFf4N.js → hi-IN-IWLTKZ5I-DwqsXYOg.js} +1 -1
- package/dist/client/assets/{hu-HU-A5ZG7DT2-BAaPCJl5.js → hu-HU-A5ZG7DT2-Bfb8Cu1b.js} +1 -1
- package/dist/client/assets/{id-ID-SAP4L64H-DyuFpf3d.js → id-ID-SAP4L64H-DOW7LO7-.js} +1 -1
- package/dist/client/assets/image-GAAHSSAO-BGgn-PVN.js +1 -0
- package/dist/client/assets/index-oPO4_QxD.js +270 -0
- package/dist/client/assets/{index--u8Mf6T6.css → index-pqEtQj4K.css} +1 -1
- package/dist/client/assets/info-DKCQHKI2-DrYXVDvR.js +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-DbwYrIVA.js → infoDiagram-FWYZ7A6U-lZtFhAXP.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DUKB4IOX.js → ishikawaDiagram-FXEZZL3T-BREj5_gD.js} +1 -1
- package/dist/client/assets/{it-IT-JPQ66NNP-DI0hWELN.js → it-IT-JPQ66NNP-Bgr9Cu8C.js} +1 -1
- package/dist/client/assets/{ja-JP-DBVTYXUO-BVDm3NlP.js → ja-JP-DBVTYXUO-BGNdWvdH.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-CBla9cDa.js → journeyDiagram-5HDEW3XC-CMBikQPO.js} +1 -1
- package/dist/client/assets/{kaa-6HZHGXH3-BPmENbr7.js → kaa-6HZHGXH3-CRDlnQ9T.js} +1 -1
- package/dist/client/assets/{kab-KAB-ZGHBKWFO-SJCSLUAj.js → kab-KAB-ZGHBKWFO-C4Lqev4p.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-DF40bon8.js → kanban-definition-HUTT4EX6-BWBx3j_A.js} +1 -1
- package/dist/client/assets/{kk-KZ-P5N5QNE5-iO6ccQA0.js → kk-KZ-P5N5QNE5-DgFr17Od.js} +1 -1
- package/dist/client/assets/{km-KH-HSX4SM5Z-DaeRmR92.js → km-KH-HSX4SM5Z-BDvXuwGA.js} +1 -1
- package/dist/client/assets/{ko-KR-MTYHY66A-BbIUlDG8.js → ko-KR-MTYHY66A-BNhHcL3I.js} +1 -1
- package/dist/client/assets/{ku-TR-6OUDTVRD-mV8MrK_d.js → ku-TR-6OUDTVRD-DNktxnYd.js} +1 -1
- package/dist/client/assets/{line-Cyy-k4TY.js → line-Dt0OF9ws.js} +1 -1
- package/dist/client/assets/{lt-LT-XHIRWOB4-NGxzPcvJ.js → lt-LT-XHIRWOB4-aRviZ0MH.js} +1 -1
- package/dist/client/assets/{lv-LV-5QDEKY6T-BSIwo-WO.js → lv-LV-5QDEKY6T-CoVYcDKN.js} +1 -1
- package/dist/client/assets/mermaid-parser.core-PdKOsWiX.js +7 -0
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BcAHqIrw.js → mindmap-definition-LN4V7U3C-CQ5v-EBF.js} +1 -1
- package/dist/client/assets/{mr-IN-CRQNXWMA-DMqRrESd.js → mr-IN-CRQNXWMA-BxQJIhtJ.js} +1 -1
- package/dist/client/assets/{my-MM-5M5IBNSE-BU9rr6eQ.js → my-MM-5M5IBNSE-FhM1FFVw.js} +1 -1
- package/dist/client/assets/{nb-NO-T6EIAALU-CfCFCnvr.js → nb-NO-T6EIAALU-CU26LQ4M.js} +1 -1
- package/dist/client/assets/{nl-NL-IS3SIHDZ-BDDDlrXW.js → nl-NL-IS3SIHDZ-DEbkQpIZ.js} +1 -1
- package/dist/client/assets/{nn-NO-6E72VCQL-DxXyaKak.js → nn-NO-6E72VCQL-DTXzh0og.js} +1 -1
- package/dist/client/assets/{oc-FR-POXYY2M6-DhEINSYs.js → oc-FR-POXYY2M6-BMWu-GuX.js} +1 -1
- package/dist/client/assets/{pa-IN-N4M65BXN-CK7mHZv5.js → pa-IN-N4M65BXN-BvvaU4wr.js} +1 -1
- package/dist/client/assets/{treemap-6X3UGDF4-BDCwJXtu.js → packet-7NZHBO7P-D3npuWOU.js} +1 -1
- package/dist/client/assets/pegDiagram-2B236MQR-BRo6DMPk.js +1 -0
- package/dist/client/assets/percentages-BXMCSKIN-EXL0wbvf.js +1 -0
- package/dist/client/assets/{wardley-OPB4EBWU-DyOUx8-a.js → pie-RZYD4A2V-CNkV5sKG.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-BLDvuItJ.js → pieDiagram-ENE6RG2P-DjyEljFW.js} +1 -1
- package/dist/client/assets/{pl-PL-T2D74RX3-CqscU5Ri.js → pl-PL-T2D74RX3-Q4-sfWh2.js} +1 -1
- package/dist/client/assets/{pt-BR-5N22H2LF-DmaMKyvD.js → pt-BR-5N22H2LF-D3I0MlSt.js} +1 -1
- package/dist/client/assets/{pt-PT-UZXXM6DQ-DF4Iue14.js → pt-PT-UZXXM6DQ-CjvJgWuJ.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-G7yFHj4A.js → quadrantDiagram-ABIIQ3AL-DwPjmUqM.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-D4wuR1xh.js → radar-I7S5WNFK-BUDPffFb.js} +1 -1
- package/dist/client/assets/{gitGraph-TEB2WS4Q-g2g2G5VS.js → railroad-3IZDKUUU-Cdo3kV8g.js} +1 -1
- package/dist/client/assets/railroad-abnf-AHOZXSZD-DhwVR0O6.js +1 -0
- package/dist/client/assets/railroad-ebnf-EBAXGLYW-BH-sR6KG.js +1 -0
- package/dist/client/assets/railroad-peg-LSFZ7HO6-DNVw0v5I.js +1 -0
- package/dist/client/assets/railroadDiagram-RFXS5EU6-DM4LPeTp.js +1 -0
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-C9A-HrEu.js → requirementDiagram-TGXJPOKE-BgufYhbt.js} +1 -1
- package/dist/client/assets/{ro-RO-JPDTUUEW-fRWAU3yw.js → ro-RO-JPDTUUEW-DTqaPOB6.js} +1 -1
- package/dist/client/assets/{ru-RU-B4JR7IUQ-CKPt5MqY.js → ru-RU-B4JR7IUQ-DGLVQl1a.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-exIXTcGE.js → sankeyDiagram-HTMAVEWB-B1Sjq8RS.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-aDjop-R8.js → sequenceDiagram-DBY2YBRQ-D5SV4V4W.js} +1 -1
- package/dist/client/assets/{si-LK-N5RQ5JYF-MDsRqHY9.js → si-LK-N5RQ5JYF-C3gSdqxC.js} +1 -1
- package/dist/client/assets/{sk-SK-C5VTKIMK-CRZETqXz.js → sk-SK-C5VTKIMK-Bg1sgW2y.js} +1 -1
- package/dist/client/assets/{sl-SI-NN7IZMDC-CXfLqNoN.js → sl-SI-NN7IZMDC-5tRgzcvI.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-y0Ml_ZQw.js → stateDiagram-2N3HPSRC-BDd2Qyke.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc9mjKnL.js +1 -0
- package/dist/client/assets/subset-shared.chunk-CBqF3lLT.js +1 -0
- package/dist/client/assets/{subset-worker.chunk-DbYO8wJu.js → subset-worker.chunk-kFoGWzk5.js} +1 -1
- package/dist/client/assets/{sv-SE-XGPEYMSR-BC6B5BtV.js → sv-SE-XGPEYMSR-BOzuSEwa.js} +1 -1
- package/dist/client/assets/{swimlanes-5IMT3BWC-CYWdivj3.js → swimlanes-5IMT3BWC-DjFw-CEo.js} +1 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DxdVR5MZ.js +8 -0
- package/dist/client/assets/{ta-IN-2NMHFXQM-DilUfcfF.js → ta-IN-2NMHFXQM-C7WRODt_.js} +1 -1
- package/dist/client/assets/{th-TH-HPSO5L25-BaInGJjE.js → th-TH-HPSO5L25-WFuWcN58.js} +1 -1
- package/dist/client/assets/{timeline-definition-FHXFAJF6-zAb82GBx.js → timeline-definition-FHXFAJF6-BItaRl8g.js} +1 -1
- package/dist/client/assets/{tr-TR-DEFEU3FU-oygIet1p.js → tr-TR-DEFEU3FU-DtSAkhUR.js} +1 -1
- package/dist/client/assets/{treeView-QDETBFTQ-sU-avi1O.js → treeView-QDETBFTQ-BsqpkMak.js} +1 -1
- package/dist/client/assets/treemap-6X3UGDF4-CF2ZChjy.js +1 -0
- package/dist/client/assets/{uk-UA-QMV73CPH-QqCQ4UX-.js → uk-UA-QMV73CPH-BLNYCDf-.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BXrsZk41.js → vennDiagram-L72KCM5P-DkVOQsDM.js} +1 -1
- package/dist/client/assets/{vi-VN-M7AON7JQ-yJDC_vY8.js → vi-VN-M7AON7JQ-CC-oykOI.js} +1 -1
- package/dist/client/assets/wardley-OPB4EBWU-BfqiNa2m.js +1 -0
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DlOqjfHt.js → wardleyDiagram-EHGQE667-C7jTfbZa.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BYm9TaM8.js → xychartDiagram-FW5EYKEG-KJVcpo2q.js} +1 -1
- package/dist/client/assets/{zh-CN-LNUGB5OW-BNjW-fP_.js → zh-CN-LNUGB5OW-BpxSN9jo.js} +1 -1
- package/dist/client/assets/{zh-HK-E62DVLB3-apDiW2t-.js → zh-HK-E62DVLB3-BVqdbOUX.js} +1 -1
- package/dist/client/assets/{zh-TW-RAJ6MFWO-qAMfxdUZ.js → zh-TW-RAJ6MFWO-c__UZRJ7.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +74 -69
- package/dist/client/assets/abnfDiagram-VRR7QNED-D5qnskik.js +0 -1
- package/dist/client/assets/architecture-TIHT7OUA-Bj5GS0Ct.js +0 -1
- package/dist/client/assets/channel-B0WxnXxi.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-Dnwqjkgm.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-Dnwqjkgm.js +0 -1
- package/dist/client/assets/ebnfDiagram-CCIWWBDH-Bitxc5e_.js +0 -1
- package/dist/client/assets/eventmodeling-45OFAUF4-CTwqsmD_.js +0 -1
- package/dist/client/assets/flowDiagram-23GEKE2U-BZYDBy9C.js +0 -1
- package/dist/client/assets/image-GAAHSSAO-tSmyz8fT.js +0 -1
- package/dist/client/assets/index-BkkP6_3-.js +0 -270
- package/dist/client/assets/info-DKCQHKI2-DjHPtgDy.js +0 -1
- package/dist/client/assets/mermaid-parser.core-BC7y6sOJ.js +0 -7
- package/dist/client/assets/packet-7NZHBO7P-BxQG27fi.js +0 -1
- package/dist/client/assets/pegDiagram-2B236MQR-6m6GI5bN.js +0 -1
- package/dist/client/assets/percentages-BXMCSKIN-BuLIaCEd.js +0 -1
- package/dist/client/assets/pie-RZYD4A2V-DrlUa6Jm.js +0 -1
- package/dist/client/assets/radar-I7S5WNFK-DB1qIDmZ.js +0 -1
- package/dist/client/assets/railroad-abnf-AHOZXSZD-BkviW5Tj.js +0 -1
- package/dist/client/assets/railroad-ebnf-EBAXGLYW-CXg6HJd6.js +0 -1
- package/dist/client/assets/railroad-peg-LSFZ7HO6-BgTAGb1l.js +0 -1
- package/dist/client/assets/railroadDiagram-RFXS5EU6-BvUGw21O.js +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D8dyI3My.js +0 -1
- package/dist/client/assets/subset-shared.chunk-BTjBJrZm.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-CdLCcze0.js +0 -8
package/dist/cli/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// server/cli.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { dirname as
|
|
4
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3 } from "node:fs";
|
|
5
|
+
import { homedir as homedir3 } from "node:os";
|
|
6
|
+
import { dirname as dirname3, extname, join as join5, normalize as normalize2, resolve as resolve3 } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { parseArgs } from "node:util";
|
|
8
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import Anthropic from "@anthropic-ai/sdk";
|
|
11
|
+
import { WebSocketServer } from "ws";
|
|
11
12
|
|
|
12
13
|
// server/app.ts
|
|
13
14
|
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
@@ -41,10 +42,29 @@ var GENERATION_MODELS = [
|
|
|
41
42
|
];
|
|
42
43
|
var DEFAULT_MODEL = "claude-opus-4-8";
|
|
43
44
|
var GenerationModel = z.enum(GENERATION_MODELS);
|
|
45
|
+
var ChatRole = z.enum(["user", "assistant", "summary"]);
|
|
46
|
+
var ChatMessage = z.object({
|
|
47
|
+
role: ChatRole,
|
|
48
|
+
text: z.string(),
|
|
49
|
+
/** ISO-8601 creation time. */
|
|
50
|
+
at: z.string()
|
|
51
|
+
});
|
|
44
52
|
var SpecRequest = z.object({
|
|
45
53
|
title: z.string().optional(),
|
|
46
54
|
model: GenerationModel.optional(),
|
|
47
|
-
graph: DiagramGraph
|
|
55
|
+
graph: DiagramGraph,
|
|
56
|
+
/**
|
|
57
|
+
* The spec-review conversation so far. Regenerate sends it; the server
|
|
58
|
+
* folds it into the generation prompt as steering context and resets the
|
|
59
|
+
* chat with a seed summary afterwards.
|
|
60
|
+
*/
|
|
61
|
+
conversation: z.array(ChatMessage).optional(),
|
|
62
|
+
/**
|
|
63
|
+
* User-edited generation system prompt (System Prompt side-pane option).
|
|
64
|
+
* Absent → the server's effective prompt (workspace override or built-in
|
|
65
|
+
* default). `@` references inside it resolve like any other input.
|
|
66
|
+
*/
|
|
67
|
+
systemPrompt: z.string().optional()
|
|
48
68
|
});
|
|
49
69
|
var CapabilitySpec = z.object({
|
|
50
70
|
capability: z.string(),
|
|
@@ -64,6 +84,38 @@ var SpecBundle = z.object({
|
|
|
64
84
|
/** One entry per capability the architecture introduces. */
|
|
65
85
|
specs: z.array(CapabilitySpec)
|
|
66
86
|
});
|
|
87
|
+
var ChatRequest = z.object({
|
|
88
|
+
/** The user's new message (may contain `@` references). */
|
|
89
|
+
message: z.string().min(1),
|
|
90
|
+
/** Conversation history preceding `message`, oldest first. */
|
|
91
|
+
conversation: z.array(ChatMessage).default([]),
|
|
92
|
+
model: GenerationModel.optional(),
|
|
93
|
+
/** URL mode has no workspace — the client supplies the bundle to ground in. */
|
|
94
|
+
bundle: SpecBundle.optional()
|
|
95
|
+
});
|
|
96
|
+
var UnresolvedRefInfo = z.object({ ref: z.string(), reason: z.string() });
|
|
97
|
+
var ChatReply = z.object({
|
|
98
|
+
reply: ChatMessage,
|
|
99
|
+
/** Workspace-relative paths whose contents joined the answer's context. */
|
|
100
|
+
resolvedRefs: z.array(z.string()),
|
|
101
|
+
/** References that soft-failed, with the reason shown to the user. */
|
|
102
|
+
unresolvedRefs: z.array(UnresolvedRefInfo)
|
|
103
|
+
});
|
|
104
|
+
var SpecResponse = z.object({
|
|
105
|
+
bundle: SpecBundle,
|
|
106
|
+
seed: z.array(ChatMessage)
|
|
107
|
+
});
|
|
108
|
+
var PromptSave = z.object({
|
|
109
|
+
system: z.string().min(1).nullable()
|
|
110
|
+
});
|
|
111
|
+
var PromptInfo = z.object({
|
|
112
|
+
/** The prompt the next generation will use (custom override or default). */
|
|
113
|
+
system: z.string(),
|
|
114
|
+
/** The built-in default, for the editor's Reset action. */
|
|
115
|
+
default: z.string(),
|
|
116
|
+
/** True when a custom override is active. */
|
|
117
|
+
custom: z.boolean()
|
|
118
|
+
});
|
|
67
119
|
var ExcalidrawScene = z.object({
|
|
68
120
|
elements: z.array(z.unknown()),
|
|
69
121
|
appState: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -79,9 +131,242 @@ var SessionSnapshot = z.object({
|
|
|
79
131
|
title: z.string().optional(),
|
|
80
132
|
model: GenerationModel.optional(),
|
|
81
133
|
scene: ExcalidrawScene.nullable(),
|
|
82
|
-
bundle: SpecBundle.nullable()
|
|
134
|
+
bundle: SpecBundle.nullable(),
|
|
135
|
+
/** Persisted spec-review chat (seed + exchanges), oldest first. */
|
|
136
|
+
chat: z.array(ChatMessage).optional()
|
|
83
137
|
});
|
|
84
138
|
|
|
139
|
+
// server/references.ts
|
|
140
|
+
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
141
|
+
import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
142
|
+
var REF_FILE_BYTE_LIMIT = 64 * 1024;
|
|
143
|
+
var REF_TOTAL_BYTE_LIMIT = 256 * 1024;
|
|
144
|
+
var REF_DIR_FILE_LIMIT = 50;
|
|
145
|
+
var SEARCH_IGNORE = /* @__PURE__ */ new Set([".git", "node_modules", "dist", ".wrangler", ".DS_Store"]);
|
|
146
|
+
function parseReferences(text) {
|
|
147
|
+
const tokens = [];
|
|
148
|
+
const pattern = /(^|[\s([{'"`])@([^\s@]+)/g;
|
|
149
|
+
for (const match of text.matchAll(pattern)) {
|
|
150
|
+
const token = (match[2] ?? "").replace(/[.,;:!?)\]}'"`>]+$/, "");
|
|
151
|
+
if (!token || token.startsWith("<")) continue;
|
|
152
|
+
if (!tokens.includes(token)) tokens.push(token);
|
|
153
|
+
}
|
|
154
|
+
return tokens;
|
|
155
|
+
}
|
|
156
|
+
function insideRoot(rootReal, target) {
|
|
157
|
+
return target.startsWith(rootReal + sep);
|
|
158
|
+
}
|
|
159
|
+
var WALK_ENTRY_LIMIT = 2e4;
|
|
160
|
+
function collectEntries(root) {
|
|
161
|
+
const files = [];
|
|
162
|
+
const dirs = [];
|
|
163
|
+
const walk = (dir) => {
|
|
164
|
+
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
165
|
+
let entries;
|
|
166
|
+
try {
|
|
167
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
168
|
+
} catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (files.length + dirs.length >= WALK_ENTRY_LIMIT) return;
|
|
173
|
+
if (SEARCH_IGNORE.has(entry.name) || entry.isSymbolicLink()) continue;
|
|
174
|
+
const full = join(dir, entry.name);
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
dirs.push(relative(root, full));
|
|
177
|
+
walk(full);
|
|
178
|
+
} else if (entry.isFile()) files.push(relative(root, full));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
walk(root);
|
|
182
|
+
return { files, dirs };
|
|
183
|
+
}
|
|
184
|
+
var collectFiles = (root) => collectEntries(root).files;
|
|
185
|
+
function findByName(root, name) {
|
|
186
|
+
return collectFiles(root).filter((p) => basename(p) === name).sort((a, b) => {
|
|
187
|
+
const depth = a.split(sep).length - b.split(sep).length;
|
|
188
|
+
return depth !== 0 ? depth : a.localeCompare(b);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
var FILE_SEARCH_LIMIT = 20;
|
|
192
|
+
function searchFiles(root, query, limit = FILE_SEARCH_LIMIT) {
|
|
193
|
+
if (root === void 0) return [];
|
|
194
|
+
let rootReal;
|
|
195
|
+
try {
|
|
196
|
+
rootReal = realpathSync(resolve(root));
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
const q = query.trim().toLowerCase();
|
|
201
|
+
const { files, dirs } = collectEntries(rootReal);
|
|
202
|
+
const all = [...dirs.map((d) => `${d}/`), ...files];
|
|
203
|
+
const matches = q ? all.filter((p) => p.toLowerCase().includes(q)) : all;
|
|
204
|
+
const rank = (p) => {
|
|
205
|
+
const lower = p.toLowerCase();
|
|
206
|
+
return [
|
|
207
|
+
q && basename(lower.replace(/\/$/, "")).includes(q) ? 0 : 1,
|
|
208
|
+
q ? lower.indexOf(q) : 0,
|
|
209
|
+
p.length
|
|
210
|
+
];
|
|
211
|
+
};
|
|
212
|
+
return matches.map((p) => ({ p, r: rank(p) })).sort(
|
|
213
|
+
(a, b) => a.r[0] - b.r[0] || a.r[1] - b.r[1] || a.r[2] - b.r[2] || a.p.localeCompare(b.p)
|
|
214
|
+
).slice(0, limit).map(({ p }) => p);
|
|
215
|
+
}
|
|
216
|
+
function readClamped(target) {
|
|
217
|
+
const raw = readFileSync(target);
|
|
218
|
+
if (raw.byteLength <= REF_FILE_BYTE_LIMIT) {
|
|
219
|
+
return { content: raw.toString("utf8"), truncated: false };
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
content: `${raw.subarray(0, REF_FILE_BYTE_LIMIT).toString("utf8")}
|
|
223
|
+
\u2026 [truncated at ${REF_FILE_BYTE_LIMIT} bytes]`,
|
|
224
|
+
truncated: true
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function expandReferences(text, root) {
|
|
228
|
+
const resolved = [];
|
|
229
|
+
const unresolved = [];
|
|
230
|
+
const refs = parseReferences(text);
|
|
231
|
+
if (refs.length === 0) return { resolved, unresolved };
|
|
232
|
+
let rootReal = null;
|
|
233
|
+
if (root !== void 0) {
|
|
234
|
+
try {
|
|
235
|
+
rootReal = realpathSync(resolve(root));
|
|
236
|
+
} catch {
|
|
237
|
+
rootReal = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let totalBytes = 0;
|
|
241
|
+
for (const ref of refs) {
|
|
242
|
+
if (rootReal === null) {
|
|
243
|
+
unresolved.push({ ref, reason: "file references are unavailable here" });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (totalBytes >= REF_TOTAL_BYTE_LIMIT) {
|
|
247
|
+
unresolved.push({ ref, reason: "reference budget exceeded \u2014 too much file content already included" });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const outcome = resolveOne(rootReal, ref);
|
|
251
|
+
if ("reason" in outcome) {
|
|
252
|
+
unresolved.push({ ref, ...outcome });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if ("dirFiles" in outcome) {
|
|
256
|
+
let included = 0;
|
|
257
|
+
for (const relPath of outcome.dirFiles) {
|
|
258
|
+
if (included >= REF_DIR_FILE_LIMIT || totalBytes >= REF_TOTAL_BYTE_LIMIT) break;
|
|
259
|
+
let body;
|
|
260
|
+
try {
|
|
261
|
+
body = readClamped(join(rootReal, relPath));
|
|
262
|
+
} catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
totalBytes += Buffer.byteLength(body.content, "utf8");
|
|
266
|
+
resolved.push({ ref, path: relPath, ...body });
|
|
267
|
+
included++;
|
|
268
|
+
}
|
|
269
|
+
const omitted = outcome.dirFiles.length - included;
|
|
270
|
+
if (included === 0) {
|
|
271
|
+
unresolved.push({
|
|
272
|
+
ref,
|
|
273
|
+
reason: "reference budget exceeded \u2014 too much file content already included"
|
|
274
|
+
});
|
|
275
|
+
} else if (omitted > 0) {
|
|
276
|
+
unresolved.push({
|
|
277
|
+
ref,
|
|
278
|
+
kind: "partial",
|
|
279
|
+
reason: `directory partially included \u2014 ${omitted} of ${outcome.dirFiles.length} files omitted by the size budget`
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
totalBytes += Buffer.byteLength(outcome.content, "utf8");
|
|
285
|
+
resolved.push({ ref, ...outcome });
|
|
286
|
+
}
|
|
287
|
+
return { resolved, unresolved };
|
|
288
|
+
}
|
|
289
|
+
function resolveOne(rootReal, ref) {
|
|
290
|
+
if (isAbsolute(ref)) return { reason: "absolute paths are not allowed" };
|
|
291
|
+
const rel = normalize(ref);
|
|
292
|
+
if (rel.split(sep).includes("..")) return { reason: "path escapes the working directory" };
|
|
293
|
+
const direct = resolve(rootReal, rel);
|
|
294
|
+
if (direct !== rootReal && !insideRoot(rootReal, direct)) {
|
|
295
|
+
return { reason: "path escapes the working directory" };
|
|
296
|
+
}
|
|
297
|
+
const stats = statOrNull(direct);
|
|
298
|
+
if (stats?.isDirectory()) {
|
|
299
|
+
let real;
|
|
300
|
+
try {
|
|
301
|
+
real = realpathSync(direct);
|
|
302
|
+
} catch {
|
|
303
|
+
return { reason: "not readable" };
|
|
304
|
+
}
|
|
305
|
+
if (real !== rootReal && !insideRoot(rootReal, real)) {
|
|
306
|
+
return { reason: "resolves outside the working directory" };
|
|
307
|
+
}
|
|
308
|
+
const dirFiles = collectFiles(direct).map((p) => relative(rootReal, join(direct, p))).sort((a, b) => {
|
|
309
|
+
const depth = a.split(sep).length - b.split(sep).length;
|
|
310
|
+
return depth !== 0 ? depth : a.localeCompare(b);
|
|
311
|
+
});
|
|
312
|
+
if (dirFiles.length === 0) return { reason: "directory contains no files" };
|
|
313
|
+
return { dirFiles };
|
|
314
|
+
}
|
|
315
|
+
if (stats?.isFile()) {
|
|
316
|
+
let real;
|
|
317
|
+
try {
|
|
318
|
+
real = realpathSync(direct);
|
|
319
|
+
} catch {
|
|
320
|
+
return { reason: "not readable" };
|
|
321
|
+
}
|
|
322
|
+
if (!insideRoot(rootReal, real)) return { reason: "resolves outside the working directory" };
|
|
323
|
+
try {
|
|
324
|
+
return { path: relative(rootReal, direct), ...readClamped(direct) };
|
|
325
|
+
} catch {
|
|
326
|
+
return { reason: "not readable" };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (rel.includes(sep)) return { reason: "not found" };
|
|
330
|
+
const matches = findByName(rootReal, rel);
|
|
331
|
+
if (matches.length === 0) return { reason: "not found" };
|
|
332
|
+
const best = matches[0];
|
|
333
|
+
try {
|
|
334
|
+
const body = readClamped(join(rootReal, best));
|
|
335
|
+
return {
|
|
336
|
+
path: best,
|
|
337
|
+
...body,
|
|
338
|
+
...matches.length > 1 ? {
|
|
339
|
+
note: `ambiguous \u2014 ${matches.length} files named "${rel}"; using ${best}, others: ${matches.slice(1, 5).join(", ")}${matches.length > 5 ? ", \u2026" : ""}`
|
|
340
|
+
} : {}
|
|
341
|
+
};
|
|
342
|
+
} catch {
|
|
343
|
+
return { reason: "not readable" };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function statOrNull(path) {
|
|
347
|
+
try {
|
|
348
|
+
return statSync(path);
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function renderReferenceContext(expansion) {
|
|
354
|
+
const parts = [];
|
|
355
|
+
for (const r of expansion.resolved) {
|
|
356
|
+
const note = r.note ? `(${r.note})
|
|
357
|
+
` : "";
|
|
358
|
+
parts.push(`=== @${r.path} ===
|
|
359
|
+
${note}${r.content}
|
|
360
|
+
=== end @${r.path} ===`);
|
|
361
|
+
}
|
|
362
|
+
for (const u of expansion.unresolved) {
|
|
363
|
+
parts.push(
|
|
364
|
+
u.kind === "partial" ? `@${u.ref}: ${u.reason}.` : `@${u.ref}: could not be resolved (${u.reason}) \u2014 do not invent its contents.`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return parts.join("\n\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
85
370
|
// server/app.ts
|
|
86
371
|
var SYSTEM = `You are a principal software architect and specification writer.
|
|
87
372
|
Given an architecture diagram as a graph (nodes = components/services/datastores/actors,
|
|
@@ -95,8 +380,26 @@ Follow OpenSpec conventions:
|
|
|
95
380
|
- Each capability the architecture introduces gets its own spec: a Purpose paragraph,
|
|
96
381
|
"The system SHALL ..." requirements, and a "WHEN / THEN" scenario per requirement.
|
|
97
382
|
- changeName and capability ids are kebab-case.
|
|
98
|
-
|
|
99
|
-
|
|
383
|
+
|
|
384
|
+
Referenced files: the input may contain "@<path | filename>" references that were resolved
|
|
385
|
+
to real file contents, delimited as "=== @<path> === \u2026 === end @<path> ===" blocks. That
|
|
386
|
+
content is authoritative ground truth about the existing system \u2014 prefer it over inference
|
|
387
|
+
from the diagram or your own assumptions. A note like "@foo.ts: could not be resolved" marks
|
|
388
|
+
a reference that failed to resolve; acknowledge the gap where relevant, never invent the
|
|
389
|
+
file's contents.
|
|
390
|
+
|
|
391
|
+
Conversation context: the input may include a "Conversation so far" section \u2014 a review chat
|
|
392
|
+
about the previous generation. Treat user statements there as requirements and corrections
|
|
393
|
+
that MUST steer this regeneration; treat assistant statements as clarifying context. Apply
|
|
394
|
+
them even where they override what the diagram alone would suggest, and say so in design.
|
|
395
|
+
|
|
396
|
+
When a baseline (existing artifacts and/or a prior conversation) is present, change only
|
|
397
|
+
what the diagram, referenced files, and conversation implicate \u2014 keep every unrelated part
|
|
398
|
+
of the previous spec stable, verbatim where possible.
|
|
399
|
+
|
|
400
|
+
Ground every requirement in what the diagram, referenced files, and conversation actually
|
|
401
|
+
show; record ambiguity as an assumption in design rather than inventing unrelated features.
|
|
402
|
+
Prefer 2-5 focused capabilities.`;
|
|
100
403
|
function renderBaseline(ws) {
|
|
101
404
|
const a = ws.readArtifacts();
|
|
102
405
|
const sections = [];
|
|
@@ -128,7 +431,121 @@ ${edges}
|
|
|
128
431
|
Notes:
|
|
129
432
|
${notes}`;
|
|
130
433
|
}
|
|
131
|
-
|
|
434
|
+
var CHAT_SYSTEM = `You are SpecSketch's spec review assistant. The user generated an OpenSpec
|
|
435
|
+
change proposal from an architecture diagram and is reviewing it. Answer questions about the
|
|
436
|
+
OpenSpec below: explain requirements and scenarios, surface gaps and ambiguities, suggest
|
|
437
|
+
concrete wording. Ground every answer in the OpenSpec artifacts and any referenced files
|
|
438
|
+
provided \u2014 quote the relevant artifact when it helps.
|
|
439
|
+
|
|
440
|
+
"=== @<path> ===" blocks are real file contents resolved from @-references; they are
|
|
441
|
+
authoritative over your assumptions. A note like "@x: could not be resolved" means the file
|
|
442
|
+
was unavailable \u2014 say so rather than guessing its contents.
|
|
443
|
+
|
|
444
|
+
Answers render as plain text in a narrow side pane: be concise, use short paragraphs or
|
|
445
|
+
simple "- " lists, no headings or heavy markdown.`;
|
|
446
|
+
function renderBundleContext(b) {
|
|
447
|
+
const sections = [
|
|
448
|
+
`--- proposal.md ---
|
|
449
|
+
${b.proposal}`,
|
|
450
|
+
`--- design.md ---
|
|
451
|
+
${b.design}`,
|
|
452
|
+
`--- tasks.md ---
|
|
453
|
+
${b.tasks}`,
|
|
454
|
+
...b.specs.map((s) => `--- specs/${s.capability}/spec.md ---
|
|
455
|
+
${s.spec}`)
|
|
456
|
+
];
|
|
457
|
+
return `Current OpenSpec (change "${b.changeName}"):
|
|
458
|
+
|
|
459
|
+
${sections.join("\n\n")}`;
|
|
460
|
+
}
|
|
461
|
+
function renderConversation(conversation) {
|
|
462
|
+
if (conversation.length === 0) return "";
|
|
463
|
+
const turns = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
|
|
464
|
+
${m.text}`).join("\n\n");
|
|
465
|
+
return `
|
|
466
|
+
|
|
467
|
+
Conversation so far (a review chat about the previous generation \u2014 user statements are requirements and corrections that MUST steer this regeneration):
|
|
468
|
+
|
|
469
|
+
${turns}`;
|
|
470
|
+
}
|
|
471
|
+
var SUMMARY_SYSTEM = `You write the opening message of a fresh spec-review chat inside SpecSketch.
|
|
472
|
+
A generation of the OpenSpec just completed and the previous conversation (if any) was
|
|
473
|
+
cleared. Produce a short plain-text handoff:
|
|
474
|
+
- If a prior conversation is provided, first recap in at most ~150 words what the user
|
|
475
|
+
asked for, corrected, and decided there.
|
|
476
|
+
- Then list what changed in this generation as short "- " bullets, grounded in the
|
|
477
|
+
changed-file list. When no files changed, say the artifacts were already up to date.
|
|
478
|
+
No headings, no markdown beyond "- " bullets, no preamble \u2014 the text is shown directly
|
|
479
|
+
as the first chat message.`;
|
|
480
|
+
async function makeSeed(anthropic, model, conversation, changedFiles, bundle) {
|
|
481
|
+
const changes = changedFiles.length ? changedFiles.map((f) => `- ${f}`).join("\n") : "- (none \u2014 every artifact was already up to date)";
|
|
482
|
+
const prompt = (conversation.length > 0 ? `Prior conversation:
|
|
483
|
+
|
|
484
|
+
${conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
|
|
485
|
+
${m.text}`).join("\n\n")}
|
|
486
|
+
|
|
487
|
+
` : "There is no prior conversation \u2014 summarize only the changes made in this generation.\n\n") + `Files changed by this generation:
|
|
488
|
+
${changes}
|
|
489
|
+
|
|
490
|
+
Spec summary: ${bundle.summary}`;
|
|
491
|
+
let text = "";
|
|
492
|
+
try {
|
|
493
|
+
const msg = await createWithModelGate(anthropic, {
|
|
494
|
+
model,
|
|
495
|
+
max_tokens: 700,
|
|
496
|
+
system: SUMMARY_SYSTEM,
|
|
497
|
+
messages: [{ role: "user", content: prompt }]
|
|
498
|
+
});
|
|
499
|
+
if (msg.stop_reason !== "refusal") text = textOf(msg);
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
if (!text) {
|
|
503
|
+
const files = changedFiles.length ? `Files updated: ${changedFiles.join(", ")}.` : "No files changed \u2014 everything was already up to date.";
|
|
504
|
+
text = conversation.length > 0 ? `Spec regenerated with the previous conversation as context. ${files}` : `Spec generated. ${files}`;
|
|
505
|
+
}
|
|
506
|
+
return [{ role: "summary", text, at: (/* @__PURE__ */ new Date()).toISOString() }];
|
|
507
|
+
}
|
|
508
|
+
function bundleFilePaths(b) {
|
|
509
|
+
return [
|
|
510
|
+
"proposal.md",
|
|
511
|
+
"design.md",
|
|
512
|
+
"tasks.md",
|
|
513
|
+
...b.specs.map((s) => `specs/${s.capability}/spec.md`)
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
var FABLE_BETAS = ["server-side-fallback-2026-06-01"];
|
|
517
|
+
var FABLE_FALLBACKS = [{ model: "claude-opus-4-8" }];
|
|
518
|
+
function parseWithModelGate(anthropic, request) {
|
|
519
|
+
return request.model === "claude-fable-5" ? anthropic.beta.messages.parse({ ...request, betas: FABLE_BETAS, fallbacks: FABLE_FALLBACKS }) : anthropic.messages.parse(request);
|
|
520
|
+
}
|
|
521
|
+
async function createWithModelGate(anthropic, request) {
|
|
522
|
+
const msg = request.model === "claude-fable-5" ? await anthropic.beta.messages.create({
|
|
523
|
+
...request,
|
|
524
|
+
betas: FABLE_BETAS,
|
|
525
|
+
fallbacks: FABLE_FALLBACKS
|
|
526
|
+
}) : await anthropic.messages.create(request);
|
|
527
|
+
return msg;
|
|
528
|
+
}
|
|
529
|
+
function textOf(msg) {
|
|
530
|
+
return msg.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
531
|
+
}
|
|
532
|
+
function anthropicErrorResponse(c, err, kind) {
|
|
533
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
534
|
+
const status = err.status;
|
|
535
|
+
if (status === 401 || /authentication|auth token|x-api-key/i.test(message)) {
|
|
536
|
+
return c.json(
|
|
537
|
+
{
|
|
538
|
+
error: "anthropic-auth",
|
|
539
|
+
hint: "No Anthropic credentials available to the server. Set ANTHROPIC_API_KEY in your environment, or install the Anthropic CLI and run `ant auth login` (macOS: brew install anthropics/tap/ant), then restart specsketch.",
|
|
540
|
+
detail: message
|
|
541
|
+
},
|
|
542
|
+
401
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return c.json({ error: kind, detail: message }, 502);
|
|
546
|
+
}
|
|
547
|
+
function createApp(anthropic, workspace, getShare, options) {
|
|
548
|
+
const refRoot = options === void 0 ? process.cwd() : options.refRoot;
|
|
132
549
|
const app = new Hono();
|
|
133
550
|
app.get("/api/health", (c) => c.json({ ok: true }));
|
|
134
551
|
app.get(
|
|
@@ -136,7 +553,8 @@ function createApp(anthropic, workspace) {
|
|
|
136
553
|
(c) => c.json({
|
|
137
554
|
workspace: Boolean(workspace),
|
|
138
555
|
models: GENERATION_MODELS,
|
|
139
|
-
defaultModel: DEFAULT_MODEL
|
|
556
|
+
defaultModel: DEFAULT_MODEL,
|
|
557
|
+
share: getShare ? getShare() : { active: false }
|
|
140
558
|
})
|
|
141
559
|
);
|
|
142
560
|
if (workspace) {
|
|
@@ -147,7 +565,8 @@ function createApp(anthropic, workspace) {
|
|
|
147
565
|
title: sidecar.title,
|
|
148
566
|
model: GENERATION_MODELS.find((m) => m === sidecar.model),
|
|
149
567
|
scene: workspace.loadScene(),
|
|
150
|
-
bundle: workspace.toBundle()
|
|
568
|
+
bundle: workspace.toBundle(),
|
|
569
|
+
chat: workspace.readChat()
|
|
151
570
|
});
|
|
152
571
|
});
|
|
153
572
|
app.put("/api/session", async (c) => {
|
|
@@ -159,81 +578,1609 @@ function createApp(anthropic, workspace) {
|
|
|
159
578
|
return c.json({ ok: true });
|
|
160
579
|
});
|
|
161
580
|
}
|
|
581
|
+
app.get(
|
|
582
|
+
"/api/files",
|
|
583
|
+
(c) => c.json({ files: searchFiles(refRoot, c.req.query("q") ?? "") })
|
|
584
|
+
);
|
|
585
|
+
app.get("/api/prompt", (c) => {
|
|
586
|
+
const custom = workspace?.readSystemPrompt();
|
|
587
|
+
return c.json({ system: custom ?? SYSTEM, default: SYSTEM, custom: custom !== void 0 });
|
|
588
|
+
});
|
|
589
|
+
app.put("/api/prompt", async (c) => {
|
|
590
|
+
const parsed = PromptSave.safeParse(await c.req.json().catch(() => null));
|
|
591
|
+
if (!parsed.success) {
|
|
592
|
+
return c.json({ error: "invalid prompt", detail: parsed.error.issues }, 400);
|
|
593
|
+
}
|
|
594
|
+
workspace?.writeSystemPrompt(parsed.data.system ?? void 0);
|
|
595
|
+
return c.json({ ok: true, persisted: Boolean(workspace) });
|
|
596
|
+
});
|
|
162
597
|
app.post("/api/spec", async (c) => {
|
|
163
598
|
const parsed = SpecRequest.safeParse(await c.req.json().catch(() => null));
|
|
164
599
|
if (!parsed.success) {
|
|
165
600
|
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
166
601
|
}
|
|
167
|
-
const { title, graph, model = DEFAULT_MODEL } = parsed.data;
|
|
602
|
+
const { title, graph, model = DEFAULT_MODEL, conversation = [] } = parsed.data;
|
|
168
603
|
if (graph.nodes.length === 0) return c.json({ error: "diagram has no nodes" }, 400);
|
|
604
|
+
const system = parsed.data.systemPrompt ?? workspace?.readSystemPrompt() ?? SYSTEM;
|
|
169
605
|
const changeNameHint = workspace ? `
|
|
170
606
|
|
|
171
607
|
Use exactly "${workspace.changeName}" as the changeName.` : "";
|
|
608
|
+
const refs = expandReferences(
|
|
609
|
+
[
|
|
610
|
+
...system === SYSTEM ? [] : [system],
|
|
611
|
+
title ?? "",
|
|
612
|
+
renderGraph(graph),
|
|
613
|
+
...conversation.map((m) => m.text)
|
|
614
|
+
].join("\n"),
|
|
615
|
+
refRoot
|
|
616
|
+
);
|
|
617
|
+
const refContext = renderReferenceContext(refs);
|
|
172
618
|
const prompt = `Architecture diagram: "${title ?? "Untitled architecture"}"
|
|
173
619
|
|
|
174
620
|
${renderGraph(graph)}
|
|
175
621
|
|
|
176
|
-
Produce the OpenSpec change proposal for this architecture as structured output.` + changeNameHint + (workspace ? renderBaseline(workspace) : "")
|
|
622
|
+
Produce the OpenSpec change proposal for this architecture as structured output.` + changeNameHint + (workspace ? renderBaseline(workspace) : "") + renderConversation(conversation) + (refContext ? `
|
|
623
|
+
|
|
624
|
+
Referenced files (resolved from @-references in the input):
|
|
625
|
+
|
|
626
|
+
${refContext}` : "");
|
|
177
627
|
let msg;
|
|
178
628
|
try {
|
|
179
|
-
|
|
629
|
+
msg = await parseWithModelGate(anthropic, {
|
|
180
630
|
model,
|
|
181
631
|
max_tokens: 16e3,
|
|
182
632
|
// non-streaming stays under the SDK HTTP timeout
|
|
183
|
-
system
|
|
633
|
+
system,
|
|
184
634
|
messages: [{ role: "user", content: prompt }],
|
|
185
635
|
output_config: { format: zodOutputFormat(SpecBundle) }
|
|
186
636
|
// No temperature/top_p or thinking config — both 400 on modern models.
|
|
187
|
-
};
|
|
188
|
-
msg = model === "claude-fable-5" ? (
|
|
189
|
-
// Fable: opt into server-side refusal fallbacks by default.
|
|
190
|
-
await anthropic.beta.messages.parse({
|
|
191
|
-
...request,
|
|
192
|
-
betas: ["server-side-fallback-2026-06-01"],
|
|
193
|
-
fallbacks: [{ model: "claude-opus-4-8" }]
|
|
194
|
-
})
|
|
195
|
-
) : await anthropic.messages.parse(request);
|
|
637
|
+
});
|
|
196
638
|
} catch (err) {
|
|
197
|
-
|
|
198
|
-
const status = err.status;
|
|
199
|
-
if (status === 401 || /authentication|auth token|x-api-key/i.test(message)) {
|
|
200
|
-
return c.json(
|
|
201
|
-
{
|
|
202
|
-
error: "anthropic-auth",
|
|
203
|
-
hint: "No Anthropic credentials available to the server. Set ANTHROPIC_API_KEY in your environment, or install the Anthropic CLI and run `ant auth login` (macOS: brew install anthropics/tap/ant), then restart specsketch.",
|
|
204
|
-
detail: message
|
|
205
|
-
},
|
|
206
|
-
401
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
return c.json({ error: "generation-failed", detail: message }, 502);
|
|
639
|
+
return anthropicErrorResponse(c, err, "generation-failed");
|
|
210
640
|
}
|
|
211
641
|
if (msg.stop_reason === "refusal") {
|
|
212
642
|
return c.json({ error: "refusal", detail: msg.stop_details }, 422);
|
|
213
643
|
}
|
|
214
644
|
if (!msg.parsed_output) return c.json({ error: "model returned no structured output" }, 502);
|
|
215
645
|
const output = SpecBundle.parse(msg.parsed_output);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
646
|
+
const bundle = workspace ? { ...output, changeName: workspace.changeName } : output;
|
|
647
|
+
const changedFiles = workspace ? workspace.writeBundle(bundle) : bundleFilePaths(bundle);
|
|
648
|
+
const seed = await makeSeed(anthropic, model, conversation, changedFiles, bundle);
|
|
649
|
+
if (workspace) workspace.writeChat(seed);
|
|
650
|
+
return c.json({ bundle, seed });
|
|
651
|
+
});
|
|
652
|
+
app.post("/api/chat", async (c) => {
|
|
653
|
+
const parsed = ChatRequest.safeParse(await c.req.json().catch(() => null));
|
|
654
|
+
if (!parsed.success) {
|
|
655
|
+
return c.json({ error: "invalid request", detail: parsed.error.issues }, 400);
|
|
656
|
+
}
|
|
657
|
+
const { message, conversation, model = DEFAULT_MODEL } = parsed.data;
|
|
658
|
+
const bundle = parsed.data.bundle ?? workspace?.toBundle() ?? null;
|
|
659
|
+
if (!bundle) return c.json({ error: "no spec to chat about \u2014 generate one first" }, 400);
|
|
660
|
+
const refs = expandReferences(message, refRoot);
|
|
661
|
+
const refContext = renderReferenceContext(refs);
|
|
662
|
+
const history = conversation.map((m) => ({
|
|
663
|
+
role: m.role === "user" ? "user" : "assistant",
|
|
664
|
+
content: m.text
|
|
665
|
+
}));
|
|
666
|
+
let msg;
|
|
667
|
+
try {
|
|
668
|
+
msg = await createWithModelGate(anthropic, {
|
|
669
|
+
model,
|
|
670
|
+
max_tokens: 2e3,
|
|
671
|
+
system: `${CHAT_SYSTEM}
|
|
672
|
+
|
|
673
|
+
${renderBundleContext(bundle)}`,
|
|
674
|
+
messages: [
|
|
675
|
+
...history,
|
|
676
|
+
{
|
|
677
|
+
role: "user",
|
|
678
|
+
content: message + (refContext ? `
|
|
679
|
+
|
|
680
|
+
Referenced files (resolved from @-references in the message):
|
|
681
|
+
|
|
682
|
+
${refContext}` : "")
|
|
683
|
+
}
|
|
684
|
+
]
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
return anthropicErrorResponse(c, err, "chat-failed");
|
|
688
|
+
}
|
|
689
|
+
if (msg.stop_reason === "refusal") {
|
|
690
|
+
return c.json({ error: "refusal", detail: msg.stop_details }, 422);
|
|
220
691
|
}
|
|
221
|
-
|
|
692
|
+
const text = textOf(msg);
|
|
693
|
+
if (!text) return c.json({ error: "model returned no text" }, 502);
|
|
694
|
+
const reply = { role: "assistant", text, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
695
|
+
workspace?.writeChat([
|
|
696
|
+
...conversation,
|
|
697
|
+
{ role: "user", text: message, at: reply.at },
|
|
698
|
+
reply
|
|
699
|
+
]);
|
|
700
|
+
return c.json({
|
|
701
|
+
reply,
|
|
702
|
+
resolvedRefs: refs.resolved.map((r) => r.path),
|
|
703
|
+
unresolvedRefs: refs.unresolved
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
app.delete("/api/chat", (c) => {
|
|
707
|
+
workspace?.writeChat([]);
|
|
708
|
+
return c.json({ ok: true });
|
|
222
709
|
});
|
|
223
710
|
return app;
|
|
224
711
|
}
|
|
225
712
|
|
|
713
|
+
// server/coop-auth.ts
|
|
714
|
+
import { homedir as homedir2 } from "node:os";
|
|
715
|
+
import { join as join3 } from "node:path";
|
|
716
|
+
|
|
717
|
+
// ../shared/src/cli-auth.ts
|
|
718
|
+
import { exec } from "node:child_process";
|
|
719
|
+
import { randomBytes } from "node:crypto";
|
|
720
|
+
import { existsSync } from "node:fs";
|
|
721
|
+
import { createServer } from "node:http";
|
|
722
|
+
import { hostname as hostname2 } from "node:os";
|
|
723
|
+
import { URL as URL2 } from "node:url";
|
|
724
|
+
|
|
725
|
+
// ../shared/src/cli-config.ts
|
|
726
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
727
|
+
import { homedir, hostname } from "node:os";
|
|
728
|
+
import { join as join2, dirname } from "node:path";
|
|
729
|
+
|
|
730
|
+
// ../shared/src/schemas.ts
|
|
731
|
+
import { z as z2 } from "zod";
|
|
732
|
+
var planTierSchema = z2.enum(["hobby", "squad", "org"]);
|
|
733
|
+
var jurisdictionSchema = z2.enum(["eu", "fedramp"]);
|
|
734
|
+
var locationHintSchema = z2.enum([
|
|
735
|
+
"wnam",
|
|
736
|
+
"enam",
|
|
737
|
+
"weur",
|
|
738
|
+
"eeur",
|
|
739
|
+
"apac",
|
|
740
|
+
"oc",
|
|
741
|
+
"afr",
|
|
742
|
+
"me",
|
|
743
|
+
"sam"
|
|
744
|
+
]);
|
|
745
|
+
var sessionNameSchema = z2.string().min(1).max(63).regex(
|
|
746
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
747
|
+
"Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
748
|
+
);
|
|
749
|
+
var teamNameSchema = z2.string().min(1).max(63).regex(
|
|
750
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
751
|
+
"Team name must be lowercase alphanumeric with hyphens"
|
|
752
|
+
);
|
|
753
|
+
var messageKindSchema = z2.enum(["text", "context", "request", "system"]);
|
|
754
|
+
var messageContentSchema = z2.object({
|
|
755
|
+
kind: messageKindSchema,
|
|
756
|
+
// No .max() here: the 10 000-char body limit is business logic owned by the
|
|
757
|
+
// SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
|
|
758
|
+
// both REST and WS. A schema cap would pre-empt it as a generic 400.
|
|
759
|
+
body: z2.string().min(1),
|
|
760
|
+
// Two-arg z.record: identical semantics in zod 3, required form in zod 4
|
|
761
|
+
// (specsketch bundles this file against zod 4 — keep every construct dual-safe).
|
|
762
|
+
metadata: z2.record(z2.string(), z2.unknown()).optional()
|
|
763
|
+
});
|
|
764
|
+
var createTeamInputSchema = z2.object({
|
|
765
|
+
name: teamNameSchema
|
|
766
|
+
});
|
|
767
|
+
var createSessionInputSchema = z2.object({
|
|
768
|
+
name: sessionNameSchema,
|
|
769
|
+
teamId: z2.string().optional(),
|
|
770
|
+
jurisdiction: jurisdictionSchema.optional(),
|
|
771
|
+
locationHint: locationHintSchema.optional()
|
|
772
|
+
});
|
|
773
|
+
var sendMessageInputSchema = z2.object({
|
|
774
|
+
content: messageContentSchema
|
|
775
|
+
});
|
|
776
|
+
var inviteInputSchema = z2.object({
|
|
777
|
+
email: z2.string().email(),
|
|
778
|
+
role: z2.enum(["admin", "member"]).default("member")
|
|
779
|
+
});
|
|
780
|
+
var joinTeamInputSchema = z2.object({
|
|
781
|
+
inviteCode: z2.string().min(1)
|
|
782
|
+
});
|
|
783
|
+
var markReadInputSchema = z2.object({
|
|
784
|
+
notificationIds: z2.array(z2.string().min(1))
|
|
785
|
+
});
|
|
786
|
+
var pollQuerySchema = z2.object({
|
|
787
|
+
since: z2.string().optional(),
|
|
788
|
+
limit: z2.coerce.number().int().min(1).max(100).default(50),
|
|
789
|
+
session: z2.string().optional()
|
|
790
|
+
});
|
|
791
|
+
var messagesQuerySchema = z2.object({
|
|
792
|
+
cursor: z2.string().optional(),
|
|
793
|
+
limit: z2.coerce.number().int().min(1).max(100).default(50)
|
|
794
|
+
});
|
|
795
|
+
var teamTypeSchema = z2.enum(["personal", "shared"]);
|
|
796
|
+
var teamRoleSchema = z2.enum(["owner", "admin", "member"]);
|
|
797
|
+
var accessPermissionSchema = z2.enum(["read", "write", "admin"]);
|
|
798
|
+
var accessGrantInputSchema = z2.object({
|
|
799
|
+
userId: z2.string().min(1),
|
|
800
|
+
sessionName: sessionNameSchema.nullable().default(null),
|
|
801
|
+
// null = general
|
|
802
|
+
permission: accessPermissionSchema
|
|
803
|
+
});
|
|
804
|
+
var machineIdSchema = z2.string().min(1).max(63).regex(
|
|
805
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
806
|
+
"Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
807
|
+
);
|
|
808
|
+
var clerkProfileInputSchema = z2.object({
|
|
809
|
+
email: z2.string().email().nullable(),
|
|
810
|
+
displayName: z2.string().min(1).max(100),
|
|
811
|
+
avatarUrl: z2.string().url().nullable(),
|
|
812
|
+
provider: z2.string().min(1).max(50),
|
|
813
|
+
providerUserId: z2.string().min(1).max(100)
|
|
814
|
+
});
|
|
815
|
+
var sessionEnrollmentSchema = z2.object({
|
|
816
|
+
sessionId: z2.string(),
|
|
817
|
+
team: z2.string(),
|
|
818
|
+
teamName: z2.string().optional(),
|
|
819
|
+
key: z2.string().startsWith("api_"),
|
|
820
|
+
enrolledAt: z2.string().datetime(),
|
|
821
|
+
apiUrl: z2.string().url().optional(),
|
|
822
|
+
machineId: z2.string().optional()
|
|
823
|
+
});
|
|
824
|
+
var authConfigSchema = z2.object({
|
|
825
|
+
key: z2.string().startsWith("api_"),
|
|
826
|
+
userId: z2.string().min(1),
|
|
827
|
+
displayName: z2.string().nullable().optional().default(null),
|
|
828
|
+
email: z2.string().email().nullable(),
|
|
829
|
+
machineId: z2.string().nullable(),
|
|
830
|
+
authenticatedAt: z2.string().datetime()
|
|
831
|
+
});
|
|
832
|
+
var configBaseFields = {
|
|
833
|
+
apiUrl: z2.string().url(),
|
|
834
|
+
machineId: z2.string().nullable(),
|
|
835
|
+
sessions: z2.record(z2.string(), sessionEnrollmentSchema),
|
|
836
|
+
defaults: z2.object({
|
|
837
|
+
team: z2.string().nullable(),
|
|
838
|
+
session: z2.string().nullable()
|
|
839
|
+
}),
|
|
840
|
+
poll: z2.object({
|
|
841
|
+
lastPollAt: z2.string().datetime().nullable()
|
|
842
|
+
})
|
|
843
|
+
};
|
|
844
|
+
var coopConfigV1Schema = z2.object({
|
|
845
|
+
version: z2.literal(1),
|
|
846
|
+
...configBaseFields
|
|
847
|
+
});
|
|
848
|
+
var coopConfigV2Schema = z2.object({
|
|
849
|
+
version: z2.literal(2),
|
|
850
|
+
...configBaseFields,
|
|
851
|
+
auth: authConfigSchema.optional()
|
|
852
|
+
});
|
|
853
|
+
var profileNameSchema = z2.string().regex(
|
|
854
|
+
/^[a-z0-9][a-z0-9_-]{0,63}$/,
|
|
855
|
+
"Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
|
|
856
|
+
);
|
|
857
|
+
var coopProfileSchema = z2.object({
|
|
858
|
+
apiUrl: z2.string().url(),
|
|
859
|
+
auth: authConfigSchema.optional(),
|
|
860
|
+
sessions: z2.record(z2.string(), sessionEnrollmentSchema),
|
|
861
|
+
defaults: z2.object({
|
|
862
|
+
team: z2.string().nullable(),
|
|
863
|
+
session: z2.string().nullable()
|
|
864
|
+
}),
|
|
865
|
+
poll: z2.object({
|
|
866
|
+
lastPollAt: z2.string().datetime().nullable()
|
|
867
|
+
})
|
|
868
|
+
});
|
|
869
|
+
var coopConfigV3ObjectSchema = z2.object({
|
|
870
|
+
version: z2.literal(3),
|
|
871
|
+
machineId: z2.string().nullable(),
|
|
872
|
+
defaultProfile: z2.string(),
|
|
873
|
+
profiles: z2.record(profileNameSchema, coopProfileSchema)
|
|
874
|
+
});
|
|
875
|
+
function requireDefaultProfilePointer(config, ctx) {
|
|
876
|
+
if (!(config.defaultProfile in config.profiles)) {
|
|
877
|
+
ctx.addIssue({
|
|
878
|
+
code: "custom",
|
|
879
|
+
path: ["defaultProfile"],
|
|
880
|
+
message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
|
|
885
|
+
requireDefaultProfilePointer
|
|
886
|
+
);
|
|
887
|
+
var coopConfigSchema = z2.discriminatedUnion("version", [
|
|
888
|
+
coopConfigV1Schema,
|
|
889
|
+
coopConfigV2Schema,
|
|
890
|
+
coopConfigV3ObjectSchema
|
|
891
|
+
]).superRefine((config, ctx) => {
|
|
892
|
+
if (config.version === 3) {
|
|
893
|
+
requireDefaultProfilePointer(config, ctx);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
var hookStdinSchema = z2.object({
|
|
897
|
+
session_id: z2.string().optional(),
|
|
898
|
+
cwd: z2.string().optional(),
|
|
899
|
+
hook_event_name: z2.string().optional()
|
|
900
|
+
}).passthrough();
|
|
901
|
+
var wsClientMessageSchema = z2.discriminatedUnion("type", [
|
|
902
|
+
z2.object({ type: z2.literal("message"), content: messageContentSchema }),
|
|
903
|
+
z2.object({ type: z2.literal("presence_request") }),
|
|
904
|
+
z2.object({ type: z2.literal("ping") })
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
// ../shared/src/cli-config.ts
|
|
908
|
+
var DEFAULT_CONFIG_DIR = join2(homedir(), ".coopcli");
|
|
909
|
+
var DEFAULT_CONFIG_PATH = join2(DEFAULT_CONFIG_DIR, "config.json");
|
|
910
|
+
var PROD_API_URL = "https://api.coopcli.com";
|
|
911
|
+
function emptyProfile(apiUrl) {
|
|
912
|
+
return {
|
|
913
|
+
apiUrl,
|
|
914
|
+
sessions: {},
|
|
915
|
+
defaults: { team: null, session: null },
|
|
916
|
+
poll: { lastPollAt: null }
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
var DEFAULT_CONFIG = {
|
|
920
|
+
version: 3,
|
|
921
|
+
machineId: hostname(),
|
|
922
|
+
defaultProfile: "default",
|
|
923
|
+
profiles: { default: emptyProfile(PROD_API_URL) }
|
|
924
|
+
};
|
|
925
|
+
function upgradeToV3(parsed) {
|
|
926
|
+
if (parsed.version === 3) return parsed;
|
|
927
|
+
const profile = {
|
|
928
|
+
apiUrl: parsed.apiUrl,
|
|
929
|
+
...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
|
|
930
|
+
sessions: parsed.sessions,
|
|
931
|
+
defaults: parsed.defaults,
|
|
932
|
+
poll: parsed.poll
|
|
933
|
+
};
|
|
934
|
+
return {
|
|
935
|
+
version: 3,
|
|
936
|
+
machineId: parsed.machineId,
|
|
937
|
+
defaultProfile: "default",
|
|
938
|
+
profiles: { default: profile }
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function profileFromEnv(env, envVars) {
|
|
942
|
+
for (const name of envVars) {
|
|
943
|
+
const value = env[name];
|
|
944
|
+
if (value) return value;
|
|
945
|
+
}
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
var ConfigStore = class {
|
|
949
|
+
config = null;
|
|
950
|
+
selectedProfile = null;
|
|
951
|
+
configPath;
|
|
952
|
+
configDir;
|
|
953
|
+
constructor(configPath) {
|
|
954
|
+
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
|
955
|
+
this.configDir = dirname(this.configPath);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Select the profile this store operates on for the rest of the process
|
|
959
|
+
* (--profile flag / COOP_PROFILE env). Without a selection, the
|
|
960
|
+
* defaultProfile pointer applies.
|
|
961
|
+
*/
|
|
962
|
+
selectProfile(name) {
|
|
963
|
+
this.selectedProfile = name;
|
|
964
|
+
}
|
|
965
|
+
activeProfileName(config) {
|
|
966
|
+
return this.selectedProfile ?? config.defaultProfile;
|
|
967
|
+
}
|
|
968
|
+
view(config) {
|
|
969
|
+
const profileName = this.activeProfileName(config);
|
|
970
|
+
const profile = config.profiles[profileName];
|
|
971
|
+
if (!profile) {
|
|
972
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
973
|
+
throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
|
|
974
|
+
}
|
|
975
|
+
return { profileName, machineId: config.machineId, ...profile };
|
|
976
|
+
}
|
|
977
|
+
/** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
|
|
978
|
+
cached() {
|
|
979
|
+
return this.view(this.cachedRaw());
|
|
980
|
+
}
|
|
981
|
+
/** Returns the full previously loaded config. Throws if load() hasn't been called. */
|
|
982
|
+
cachedRaw() {
|
|
983
|
+
if (this.config === null) {
|
|
984
|
+
throw new Error("Config not loaded yet. Call load() first.");
|
|
985
|
+
}
|
|
986
|
+
return this.config;
|
|
987
|
+
}
|
|
988
|
+
/** Load the config file and return the active profile's view. */
|
|
989
|
+
async load() {
|
|
990
|
+
return this.view(await this.loadRaw());
|
|
991
|
+
}
|
|
992
|
+
/** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
|
|
993
|
+
async loadRaw() {
|
|
994
|
+
if (this.config !== null) {
|
|
995
|
+
return this.config;
|
|
996
|
+
}
|
|
997
|
+
try {
|
|
998
|
+
const raw = await readFile(this.configPath, "utf8");
|
|
999
|
+
const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
|
|
1000
|
+
if (!parsed.machineId) {
|
|
1001
|
+
parsed.machineId = hostname();
|
|
1002
|
+
}
|
|
1003
|
+
this.config = parsed;
|
|
1004
|
+
return parsed;
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
1007
|
+
this.config = structuredClone(DEFAULT_CONFIG);
|
|
1008
|
+
return this.config;
|
|
1009
|
+
}
|
|
1010
|
+
if (err instanceof SyntaxError || err?.name === "ZodError") {
|
|
1011
|
+
throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
|
|
1012
|
+
}
|
|
1013
|
+
throw err;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async save(config) {
|
|
1017
|
+
await mkdir(this.configDir, { recursive: true, mode: 448 });
|
|
1018
|
+
const content = JSON.stringify(config, null, 2);
|
|
1019
|
+
const tmpPath = this.configPath + ".tmp";
|
|
1020
|
+
await writeFile(tmpPath, content, { mode: 384 });
|
|
1021
|
+
const { rename } = await import("node:fs/promises");
|
|
1022
|
+
await rename(tmpPath, this.configPath);
|
|
1023
|
+
try {
|
|
1024
|
+
await chmod(this.configPath, 384);
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
this.config = config;
|
|
1028
|
+
}
|
|
1029
|
+
/** Load, transform the active profile, and save — the single write path for profile-scoped state. */
|
|
1030
|
+
async saveProfile(mutate) {
|
|
1031
|
+
const config = await this.loadRaw();
|
|
1032
|
+
const name = this.activeProfileName(config);
|
|
1033
|
+
const profile = config.profiles[name];
|
|
1034
|
+
if (!profile) {
|
|
1035
|
+
throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
|
|
1036
|
+
}
|
|
1037
|
+
await this.save({
|
|
1038
|
+
...config,
|
|
1039
|
+
profiles: { ...config.profiles, [name]: mutate(profile) }
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
/** Validate config for common misconfigurations. Returns warning strings. */
|
|
1043
|
+
validate() {
|
|
1044
|
+
if (!this.config) return [];
|
|
1045
|
+
let view;
|
|
1046
|
+
try {
|
|
1047
|
+
view = this.view(this.config);
|
|
1048
|
+
} catch {
|
|
1049
|
+
return [];
|
|
1050
|
+
}
|
|
1051
|
+
const warnings = [];
|
|
1052
|
+
try {
|
|
1053
|
+
const url = new URL(view.apiUrl);
|
|
1054
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
1055
|
+
warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
|
|
1056
|
+
}
|
|
1057
|
+
} catch {
|
|
1058
|
+
warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
|
|
1059
|
+
}
|
|
1060
|
+
if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
|
|
1061
|
+
warnings.push(
|
|
1062
|
+
`profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
return warnings;
|
|
1066
|
+
}
|
|
1067
|
+
async enrollSession(name, enrollment) {
|
|
1068
|
+
await this.saveProfile((profile) => ({
|
|
1069
|
+
...profile,
|
|
1070
|
+
sessions: { ...profile.sessions, [name]: enrollment }
|
|
1071
|
+
}));
|
|
1072
|
+
}
|
|
1073
|
+
async unenrollSession(name) {
|
|
1074
|
+
await this.saveProfile((profile) => {
|
|
1075
|
+
const sessions = { ...profile.sessions };
|
|
1076
|
+
delete sessions[name];
|
|
1077
|
+
const defaults = { ...profile.defaults };
|
|
1078
|
+
if (defaults.session === name) {
|
|
1079
|
+
defaults.session = null;
|
|
1080
|
+
}
|
|
1081
|
+
return { ...profile, sessions, defaults };
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
async setDefault(key, value) {
|
|
1085
|
+
await this.saveProfile((profile) => ({
|
|
1086
|
+
...profile,
|
|
1087
|
+
defaults: { ...profile.defaults, [key]: value }
|
|
1088
|
+
}));
|
|
1089
|
+
}
|
|
1090
|
+
async getDefaultSession() {
|
|
1091
|
+
const view = await this.load();
|
|
1092
|
+
return view.defaults.session;
|
|
1093
|
+
}
|
|
1094
|
+
async getSessionKey(sessionName) {
|
|
1095
|
+
const view = await this.load();
|
|
1096
|
+
const enrollment = view.sessions[sessionName];
|
|
1097
|
+
if (!enrollment) {
|
|
1098
|
+
throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
|
|
1099
|
+
}
|
|
1100
|
+
return enrollment.key;
|
|
1101
|
+
}
|
|
1102
|
+
/** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
|
|
1103
|
+
async getApiUrl(sessionName) {
|
|
1104
|
+
const view = await this.load();
|
|
1105
|
+
if (sessionName) {
|
|
1106
|
+
const enrollment = view.sessions[sessionName];
|
|
1107
|
+
if (enrollment?.apiUrl) return enrollment.apiUrl;
|
|
1108
|
+
}
|
|
1109
|
+
return view.apiUrl;
|
|
1110
|
+
}
|
|
1111
|
+
/** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
|
|
1112
|
+
async getMachineId(sessionName) {
|
|
1113
|
+
const view = await this.load();
|
|
1114
|
+
if (sessionName) {
|
|
1115
|
+
const enrollment = view.sessions[sessionName];
|
|
1116
|
+
if (enrollment?.machineId) return enrollment.machineId;
|
|
1117
|
+
}
|
|
1118
|
+
if (view.machineId) return view.machineId;
|
|
1119
|
+
if (view.auth?.machineId) return view.auth.machineId;
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
async setMachineId(machineId) {
|
|
1123
|
+
const config = await this.loadRaw();
|
|
1124
|
+
await this.save({ ...config, machineId });
|
|
1125
|
+
}
|
|
1126
|
+
/** Set the active profile's API base URL. */
|
|
1127
|
+
async setApiUrl(apiUrl) {
|
|
1128
|
+
await this.saveProfile((profile) => ({ ...profile, apiUrl }));
|
|
1129
|
+
}
|
|
1130
|
+
/** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
|
|
1131
|
+
async ensureActiveProfile(apiUrl) {
|
|
1132
|
+
const config = await this.loadRaw();
|
|
1133
|
+
const name = this.activeProfileName(config);
|
|
1134
|
+
if (config.profiles[name]) return;
|
|
1135
|
+
await this.save({
|
|
1136
|
+
...config,
|
|
1137
|
+
profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
/** Re-point the defaultProfile fallback. The target must exist. */
|
|
1141
|
+
async setDefaultProfile(name) {
|
|
1142
|
+
const config = await this.loadRaw();
|
|
1143
|
+
if (!config.profiles[name]) {
|
|
1144
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
1145
|
+
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
1146
|
+
}
|
|
1147
|
+
await this.save({ ...config, defaultProfile: name });
|
|
1148
|
+
}
|
|
1149
|
+
/** True if any profile holds an auth block (used to detect the first login). */
|
|
1150
|
+
async hasAnyAuth() {
|
|
1151
|
+
const config = await this.loadRaw();
|
|
1152
|
+
return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Remove a profile including its credentials and enrollments. The
|
|
1156
|
+
* defaultProfile pointer target cannot be deleted — re-point first.
|
|
1157
|
+
*/
|
|
1158
|
+
async deleteProfile(name) {
|
|
1159
|
+
const config = await this.loadRaw();
|
|
1160
|
+
if (!config.profiles[name]) {
|
|
1161
|
+
const available = Object.keys(config.profiles).join(", ");
|
|
1162
|
+
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
1163
|
+
}
|
|
1164
|
+
if (config.defaultProfile === name) {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
`Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
const profiles = { ...config.profiles };
|
|
1170
|
+
delete profiles[name];
|
|
1171
|
+
await this.save({ ...config, profiles });
|
|
1172
|
+
}
|
|
1173
|
+
async updateLastPoll() {
|
|
1174
|
+
await this.saveProfile((profile) => ({
|
|
1175
|
+
...profile,
|
|
1176
|
+
poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1177
|
+
}));
|
|
1178
|
+
}
|
|
1179
|
+
/** Returns true if the active profile has an auth block. */
|
|
1180
|
+
isAuthenticated() {
|
|
1181
|
+
if (this.config === null) return false;
|
|
1182
|
+
return this.view(this.config).auth !== void 0;
|
|
1183
|
+
}
|
|
1184
|
+
/** Returns the user-scoped API key, or throws if not authenticated. */
|
|
1185
|
+
getAuthKey() {
|
|
1186
|
+
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
1187
|
+
if (!auth) {
|
|
1188
|
+
throw new Error("Not authenticated. Run `coop login` first.");
|
|
1189
|
+
}
|
|
1190
|
+
return auth.key;
|
|
1191
|
+
}
|
|
1192
|
+
/** Returns the user ID, or throws if not authenticated. */
|
|
1193
|
+
getUserId() {
|
|
1194
|
+
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
1195
|
+
if (!auth) {
|
|
1196
|
+
throw new Error("Not authenticated. Run `coop login` first.");
|
|
1197
|
+
}
|
|
1198
|
+
return auth.userId;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Saves the auth block into the active profile. Legacy v1/v2 files are
|
|
1202
|
+
* normalized to v3 by load(), so the write always persists v3.
|
|
1203
|
+
*/
|
|
1204
|
+
async setAuth(auth) {
|
|
1205
|
+
await this.saveProfile((profile) => ({ ...profile, auth }));
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Returns true if the user should be nudged to run `coop login`:
|
|
1209
|
+
* the active profile has no auth block.
|
|
1210
|
+
*/
|
|
1211
|
+
needsLoginNudge() {
|
|
1212
|
+
if (this.config === null) return true;
|
|
1213
|
+
return this.view(this.config).auth === void 0;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Removes the auth block from the active profile (for `coop logout`).
|
|
1217
|
+
* Sessions are preserved.
|
|
1218
|
+
*/
|
|
1219
|
+
async clearAuth() {
|
|
1220
|
+
await this.saveProfile((profile) => {
|
|
1221
|
+
const { auth: _removed, ...rest } = profile;
|
|
1222
|
+
return rest;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
function isNodeError(err) {
|
|
1227
|
+
return err instanceof Error && "code" in err;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ../shared/src/cli-auth.ts
|
|
1231
|
+
function resolveDisplayName(name, email, userId) {
|
|
1232
|
+
if (name && !name.startsWith("user_")) return name;
|
|
1233
|
+
if (email) return email.split("@")[0];
|
|
1234
|
+
if (userId) return userId.slice(0, 12);
|
|
1235
|
+
if (name) return name.slice(0, 12);
|
|
1236
|
+
return "unknown";
|
|
1237
|
+
}
|
|
1238
|
+
async function openBrowser(url) {
|
|
1239
|
+
const escaped = url.replace(/"/g, '\\"');
|
|
1240
|
+
const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
|
|
1241
|
+
return new Promise((resolve4) => {
|
|
1242
|
+
exec(cmd, () => resolve4());
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
|
|
1246
|
+
function htmlPage(title, body) {
|
|
1247
|
+
return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
|
|
1248
|
+
}
|
|
1249
|
+
function startLocalhostServer(options) {
|
|
1250
|
+
const timeout = options.timeout ?? 12e4;
|
|
1251
|
+
const loginCommand = options.loginCommand ?? "coop login";
|
|
1252
|
+
return new Promise((resolveServer, rejectServer) => {
|
|
1253
|
+
let resolveCallback;
|
|
1254
|
+
let rejectCallback;
|
|
1255
|
+
const callbackPromise = new Promise((res, rej) => {
|
|
1256
|
+
resolveCallback = res;
|
|
1257
|
+
rejectCallback = rej;
|
|
1258
|
+
});
|
|
1259
|
+
const timer = setTimeout(() => {
|
|
1260
|
+
rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
|
|
1261
|
+
server.close();
|
|
1262
|
+
}, timeout);
|
|
1263
|
+
const server = createServer((req, res) => {
|
|
1264
|
+
const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
|
|
1265
|
+
if (url.pathname === "/") {
|
|
1266
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1267
|
+
res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (url.pathname === "/callback") {
|
|
1271
|
+
const state = url.searchParams.get("state") ?? "";
|
|
1272
|
+
if (state !== options.expectedState) {
|
|
1273
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1274
|
+
res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const key = url.searchParams.get("key") ?? "";
|
|
1278
|
+
if (!key) {
|
|
1279
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1280
|
+
res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1284
|
+
res.end(htmlPage("Authentication Complete", "You can close this tab."));
|
|
1285
|
+
clearTimeout(timer);
|
|
1286
|
+
resolveCallback({
|
|
1287
|
+
key,
|
|
1288
|
+
userId: url.searchParams.get("userId") ?? "",
|
|
1289
|
+
email: url.searchParams.get("email") ?? "",
|
|
1290
|
+
displayName: url.searchParams.get("displayName") ?? "",
|
|
1291
|
+
state,
|
|
1292
|
+
apiUrl: url.searchParams.get("apiUrl") ?? ""
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1297
|
+
res.end("Not Found");
|
|
1298
|
+
});
|
|
1299
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1300
|
+
const addr = server.address();
|
|
1301
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1302
|
+
resolveServer({
|
|
1303
|
+
port,
|
|
1304
|
+
close: () => {
|
|
1305
|
+
clearTimeout(timer);
|
|
1306
|
+
server.close();
|
|
1307
|
+
},
|
|
1308
|
+
waitForCallback: () => callbackPromise
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
server.on("error", (err) => {
|
|
1312
|
+
clearTimeout(timer);
|
|
1313
|
+
rejectServer(err);
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
function deriveApiUrl(webUrl) {
|
|
1318
|
+
const url = new URL2(webUrl);
|
|
1319
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
1320
|
+
return "http://localhost:8788";
|
|
1321
|
+
}
|
|
1322
|
+
return `https://api.${url.hostname}`;
|
|
1323
|
+
}
|
|
1324
|
+
async function login(input, deps) {
|
|
1325
|
+
const state = randomBytes(16).toString("hex");
|
|
1326
|
+
const machineId = input.machine ?? hostname2();
|
|
1327
|
+
const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
|
|
1328
|
+
try {
|
|
1329
|
+
const webUrl = input.webUrl ?? "https://coopcli.com";
|
|
1330
|
+
const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
|
|
1331
|
+
process.stderr.write(`Opening browser for authentication...
|
|
1332
|
+
`);
|
|
1333
|
+
await deps.openBrowser(authUrl);
|
|
1334
|
+
process.stderr.write(`Waiting for callback on port ${server.port}...
|
|
1335
|
+
`);
|
|
1336
|
+
const params = await server.waitForCallback();
|
|
1337
|
+
const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
|
|
1338
|
+
const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
|
|
1339
|
+
const isFirstLogin = !await deps.config.hasAnyAuth();
|
|
1340
|
+
await deps.config.ensureActiveProfile(resolvedApiUrl);
|
|
1341
|
+
await deps.config.setAuth({
|
|
1342
|
+
key: params.key,
|
|
1343
|
+
userId: params.userId,
|
|
1344
|
+
displayName,
|
|
1345
|
+
email: params.email || null,
|
|
1346
|
+
machineId,
|
|
1347
|
+
authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1348
|
+
});
|
|
1349
|
+
const config = await deps.config.load();
|
|
1350
|
+
if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
|
|
1351
|
+
process.stderr.write(
|
|
1352
|
+
`
|
|
1353
|
+
Backend mismatch: you authenticated against ${params.apiUrl}
|
|
1354
|
+
but your config points to ${config.apiUrl}
|
|
1355
|
+
Updating config to ${resolvedApiUrl}
|
|
1356
|
+
|
|
1357
|
+
`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
if (resolvedApiUrl !== config.apiUrl) {
|
|
1361
|
+
await deps.config.setApiUrl(resolvedApiUrl);
|
|
1362
|
+
}
|
|
1363
|
+
if (isFirstLogin) {
|
|
1364
|
+
await deps.config.setDefaultProfile(config.profileName);
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
userId: params.userId,
|
|
1368
|
+
email: params.email || null,
|
|
1369
|
+
displayName
|
|
1370
|
+
};
|
|
1371
|
+
} finally {
|
|
1372
|
+
server.close();
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
var CliAuthError = class extends Error {
|
|
1376
|
+
constructor(message, kind) {
|
|
1377
|
+
super(message);
|
|
1378
|
+
this.kind = kind;
|
|
1379
|
+
this.name = "CliAuthError";
|
|
1380
|
+
}
|
|
1381
|
+
kind;
|
|
1382
|
+
};
|
|
1383
|
+
async function resolveCoopCredentials(opts) {
|
|
1384
|
+
const env = opts.env ?? process.env;
|
|
1385
|
+
const { configPath, loginHint } = opts;
|
|
1386
|
+
if (!existsSync(configPath)) {
|
|
1387
|
+
throw new CliAuthError(`No CoopCLI config found at ${configPath}.
|
|
1388
|
+
${loginHint}`, "missing-config");
|
|
1389
|
+
}
|
|
1390
|
+
const store = new ConfigStore(configPath);
|
|
1391
|
+
let raw;
|
|
1392
|
+
try {
|
|
1393
|
+
raw = await store.loadRaw();
|
|
1394
|
+
} catch {
|
|
1395
|
+
throw new CliAuthError(
|
|
1396
|
+
`Could not read CoopCLI credentials from ${configPath}.
|
|
1397
|
+
${loginHint}`,
|
|
1398
|
+
"unreadable-config"
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
const profileName = profileFromEnv(env, opts.profileEnvVars ?? ["COOP_PROFILE"]) ?? raw.defaultProfile;
|
|
1402
|
+
const profile = raw.profiles[profileName];
|
|
1403
|
+
if (!profile) {
|
|
1404
|
+
const available = Object.keys(raw.profiles).join(", ");
|
|
1405
|
+
throw new CliAuthError(
|
|
1406
|
+
`CoopCLI config at ${configPath} has no profile "${profileName}" (available: ${available}).
|
|
1407
|
+
${loginHint}`,
|
|
1408
|
+
"unknown-profile"
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
if (!profile.auth) {
|
|
1412
|
+
throw new CliAuthError(`CoopCLI config at ${configPath} has no login.
|
|
1413
|
+
${loginHint}`, "no-login");
|
|
1414
|
+
}
|
|
1415
|
+
const apiUrl = (opts.apiUrlEnvVar && env[opts.apiUrlEnvVar] || profile.apiUrl).replace(/\/+$/, "");
|
|
1416
|
+
return {
|
|
1417
|
+
apiUrl,
|
|
1418
|
+
key: profile.auth.key,
|
|
1419
|
+
userId: profile.auth.userId,
|
|
1420
|
+
displayName: profile.auth.displayName ?? null
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// server/coop-auth.ts
|
|
1425
|
+
var CoopAuthError = class extends Error {
|
|
1426
|
+
};
|
|
1427
|
+
var LOGIN_HINT = "--share needs a CoopCLI account. Run `specsketch login` (it opens your browser and saves credentials to ~/.coopcli/config.json), then rerun with --share.";
|
|
1428
|
+
async function readCoopAuth(configPath = join3(homedir2(), ".coopcli", "config.json"), env = process.env) {
|
|
1429
|
+
try {
|
|
1430
|
+
return await resolveCoopCredentials({
|
|
1431
|
+
configPath,
|
|
1432
|
+
env,
|
|
1433
|
+
profileEnvVars: ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"],
|
|
1434
|
+
apiUrlEnvVar: "SPECSKETCH_COOP_API_URL",
|
|
1435
|
+
loginHint: LOGIN_HINT
|
|
1436
|
+
});
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
if (err instanceof CliAuthError) throw new CoopAuthError(err.message);
|
|
1439
|
+
throw err;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// server/login.ts
|
|
1444
|
+
import { parseArgs } from "node:util";
|
|
1445
|
+
var LOGIN_USAGE = `Usage: specsketch login [--profile <name>] [--web-url <url>]
|
|
1446
|
+
|
|
1447
|
+
Opens your browser to sign in to CoopCLI and saves credentials to
|
|
1448
|
+
~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
|
|
1449
|
+
|
|
1450
|
+
--profile config profile to log into (default: SPECSKETCH_COOP_PROFILE,
|
|
1451
|
+
then COOP_PROFILE, then the config's default profile). A profile
|
|
1452
|
+
that doesn't exist yet is created.
|
|
1453
|
+
--web-url web app URL for browser auth (default: https://coopcli.com)`;
|
|
1454
|
+
var LoginUsageError = class extends Error {
|
|
1455
|
+
};
|
|
1456
|
+
function parseLoginArgs(argv) {
|
|
1457
|
+
let parsed;
|
|
1458
|
+
try {
|
|
1459
|
+
parsed = parseArgs({
|
|
1460
|
+
args: argv,
|
|
1461
|
+
options: { profile: { type: "string" }, "web-url": { type: "string" } },
|
|
1462
|
+
allowPositionals: true
|
|
1463
|
+
});
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
throw new LoginUsageError(err instanceof Error ? err.message : String(err));
|
|
1466
|
+
}
|
|
1467
|
+
if (parsed.positionals.length > 0) {
|
|
1468
|
+
throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
profile: parsed.values.profile ?? null,
|
|
1472
|
+
...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
async function runLogin(args, deps = {}) {
|
|
1476
|
+
const env = deps.env ?? process.env;
|
|
1477
|
+
const config = deps.config ?? new ConfigStore();
|
|
1478
|
+
const profile = args.profile ?? profileFromEnv(env, ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"]);
|
|
1479
|
+
if (profile) config.selectProfile(profile);
|
|
1480
|
+
const result = await login(
|
|
1481
|
+
{ webUrl: args.webUrl, loginCommand: "specsketch login" },
|
|
1482
|
+
{ config, openBrowser: deps.openBrowser ?? openBrowser }
|
|
1483
|
+
);
|
|
1484
|
+
(deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// server/remote-session.ts
|
|
1488
|
+
import { WebSocket } from "ws";
|
|
1489
|
+
|
|
1490
|
+
// ../shared/src/errors.ts
|
|
1491
|
+
function isApiErrorBody(value) {
|
|
1492
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1493
|
+
const v = value;
|
|
1494
|
+
return typeof v.category === "string" && typeof v.code === "string" && typeof v.message === "string";
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// ../shared/src/api-client.ts
|
|
1498
|
+
var CoopApiError = class extends Error {
|
|
1499
|
+
category;
|
|
1500
|
+
code;
|
|
1501
|
+
status;
|
|
1502
|
+
details;
|
|
1503
|
+
constructor(status, body) {
|
|
1504
|
+
super(body.message);
|
|
1505
|
+
this.name = "CoopApiError";
|
|
1506
|
+
this.category = body.category;
|
|
1507
|
+
this.code = body.code;
|
|
1508
|
+
this.status = status;
|
|
1509
|
+
this.details = body.details;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
function isCoopApiError(err) {
|
|
1513
|
+
return err instanceof CoopApiError;
|
|
1514
|
+
}
|
|
1515
|
+
async function streamToBuffer(stream) {
|
|
1516
|
+
const reader = stream.getReader();
|
|
1517
|
+
const chunks = [];
|
|
1518
|
+
for (; ; ) {
|
|
1519
|
+
const { done, value } = await reader.read();
|
|
1520
|
+
if (done) break;
|
|
1521
|
+
chunks.push(value);
|
|
1522
|
+
}
|
|
1523
|
+
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
1524
|
+
const result = new Uint8Array(total);
|
|
1525
|
+
let offset = 0;
|
|
1526
|
+
for (const chunk of chunks) {
|
|
1527
|
+
result.set(chunk, offset);
|
|
1528
|
+
offset += chunk.length;
|
|
1529
|
+
}
|
|
1530
|
+
return result;
|
|
1531
|
+
}
|
|
1532
|
+
var verbose = false;
|
|
1533
|
+
var ApiClient = class _ApiClient {
|
|
1534
|
+
constructor(apiUrl, token, userId) {
|
|
1535
|
+
this.apiUrl = apiUrl;
|
|
1536
|
+
this.token = token;
|
|
1537
|
+
this.userId = userId;
|
|
1538
|
+
}
|
|
1539
|
+
apiUrl;
|
|
1540
|
+
token;
|
|
1541
|
+
userId;
|
|
1542
|
+
/** Build a user-scoped path. Only callable when userId is set. */
|
|
1543
|
+
userPath(sub) {
|
|
1544
|
+
return `/api/u/${this.userId}${sub}`;
|
|
1545
|
+
}
|
|
1546
|
+
async request(method, path, body, params) {
|
|
1547
|
+
const url = new URL(path, this.apiUrl);
|
|
1548
|
+
if (params) {
|
|
1549
|
+
for (const [key, val] of Object.entries(params)) {
|
|
1550
|
+
if (val !== void 0) {
|
|
1551
|
+
url.searchParams.set(key, String(val));
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (verbose) {
|
|
1556
|
+
process.stderr.write(`[verbose] ${method} ${url.toString()}
|
|
1557
|
+
`);
|
|
1558
|
+
if (body) process.stderr.write(`[verbose] body: ${JSON.stringify(body)}
|
|
1559
|
+
`);
|
|
1560
|
+
}
|
|
1561
|
+
const res = await fetch(url.toString(), {
|
|
1562
|
+
method,
|
|
1563
|
+
headers: {
|
|
1564
|
+
Authorization: `Bearer ${this.token}`,
|
|
1565
|
+
"Content-Type": "application/json"
|
|
1566
|
+
},
|
|
1567
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1568
|
+
});
|
|
1569
|
+
if (verbose) {
|
|
1570
|
+
process.stderr.write(`[verbose] ${res.status} ${res.statusText}
|
|
1571
|
+
`);
|
|
1572
|
+
}
|
|
1573
|
+
if (!res.ok) {
|
|
1574
|
+
let parsed = null;
|
|
1575
|
+
try {
|
|
1576
|
+
const json = await res.json();
|
|
1577
|
+
if (isApiErrorBody(json)) {
|
|
1578
|
+
parsed = json;
|
|
1579
|
+
} else {
|
|
1580
|
+
const legacy = json;
|
|
1581
|
+
parsed = {
|
|
1582
|
+
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1583
|
+
code: "UNKNOWN",
|
|
1584
|
+
message: legacy.error ?? legacy.message ?? res.statusText
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
} catch {
|
|
1588
|
+
parsed = {
|
|
1589
|
+
category: res.status >= 500 ? "server_error" : "user_action",
|
|
1590
|
+
code: "UNKNOWN",
|
|
1591
|
+
message: res.statusText
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
if (verbose) {
|
|
1595
|
+
process.stderr.write(`[verbose] error: [${parsed.category}/${parsed.code}] ${parsed.message}
|
|
1596
|
+
`);
|
|
1597
|
+
}
|
|
1598
|
+
throw new CoopApiError(res.status, parsed);
|
|
1599
|
+
}
|
|
1600
|
+
return res.json();
|
|
1601
|
+
}
|
|
1602
|
+
async getMe() {
|
|
1603
|
+
const path = this.userId ? this.userPath("/me") : "/api/me";
|
|
1604
|
+
return this.request("GET", path);
|
|
1605
|
+
}
|
|
1606
|
+
async createTeam(input) {
|
|
1607
|
+
if (this.userId) {
|
|
1608
|
+
}
|
|
1609
|
+
return this.request("POST", "/api/teams", input);
|
|
1610
|
+
}
|
|
1611
|
+
async listTeams() {
|
|
1612
|
+
if (this.userId) {
|
|
1613
|
+
const me = await this.request("GET", this.userPath("/me"));
|
|
1614
|
+
return (me.teams ?? []).map((m) => ({
|
|
1615
|
+
id: m.teamId,
|
|
1616
|
+
name: m.teamName,
|
|
1617
|
+
type: m.teamId.startsWith("team:personal:") ? "personal" : "shared",
|
|
1618
|
+
plan: "hobby",
|
|
1619
|
+
createdBy: "",
|
|
1620
|
+
createdAt: m.joinedAt
|
|
1621
|
+
}));
|
|
1622
|
+
}
|
|
1623
|
+
return this.request("GET", "/api/teams");
|
|
1624
|
+
}
|
|
1625
|
+
async inviteToTeam(team, input) {
|
|
1626
|
+
return this.request("POST", `/api/teams/${encodeURIComponent(team)}/invite`, input);
|
|
1627
|
+
}
|
|
1628
|
+
async joinTeam(team, inviteCode) {
|
|
1629
|
+
await this.request("POST", `/api/teams/${encodeURIComponent(team)}/join`, { inviteCode });
|
|
1630
|
+
}
|
|
1631
|
+
async listTeamMembers(team) {
|
|
1632
|
+
return this.request("GET", `/api/teams/${encodeURIComponent(team)}/members`);
|
|
1633
|
+
}
|
|
1634
|
+
async createSession(input) {
|
|
1635
|
+
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
1636
|
+
return this.request("POST", path, input);
|
|
1637
|
+
}
|
|
1638
|
+
async listSessions() {
|
|
1639
|
+
const path = this.userId ? this.userPath("/sessions") : "/api/sessions";
|
|
1640
|
+
const result = await this.request("GET", path);
|
|
1641
|
+
return result.sessions;
|
|
1642
|
+
}
|
|
1643
|
+
async joinSession(name) {
|
|
1644
|
+
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/join`);
|
|
1645
|
+
}
|
|
1646
|
+
async leaveSession(name) {
|
|
1647
|
+
await this.request("POST", `/api/sessions/${encodeURIComponent(name)}/leave`);
|
|
1648
|
+
}
|
|
1649
|
+
async deleteSession(name) {
|
|
1650
|
+
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(name)}`) : `/api/sessions/${encodeURIComponent(name)}`;
|
|
1651
|
+
return this.request("DELETE", path);
|
|
1652
|
+
}
|
|
1653
|
+
async sendMessage(teamId, session, input) {
|
|
1654
|
+
if (this.userId) {
|
|
1655
|
+
return this.request(
|
|
1656
|
+
"POST",
|
|
1657
|
+
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1658
|
+
input,
|
|
1659
|
+
{ team: teamId }
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
return this.request("POST", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, input);
|
|
1663
|
+
}
|
|
1664
|
+
async getMessages(teamId, session, cursor, limit) {
|
|
1665
|
+
if (this.userId) {
|
|
1666
|
+
return this.request(
|
|
1667
|
+
"GET",
|
|
1668
|
+
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1669
|
+
void 0,
|
|
1670
|
+
{ team: teamId, cursor, limit }
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
1674
|
+
cursor,
|
|
1675
|
+
limit
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
async getLatestMessages(teamId, session, count = 5) {
|
|
1679
|
+
if (this.userId) {
|
|
1680
|
+
return this.request(
|
|
1681
|
+
"GET",
|
|
1682
|
+
this.userPath(`/sessions/${encodeURIComponent(session)}/messages`),
|
|
1683
|
+
void 0,
|
|
1684
|
+
{ team: teamId, latest: count }
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
return this.request("GET", `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/messages`, void 0, {
|
|
1688
|
+
latest: count
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
async queryAIContext(teamId, sessionName, input) {
|
|
1692
|
+
return this.request(
|
|
1693
|
+
"POST",
|
|
1694
|
+
this.userPath(`/sessions/${encodeURIComponent(sessionName)}/ai-context`),
|
|
1695
|
+
input,
|
|
1696
|
+
{ team: teamId }
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
async listSessionFiles(teamId, sessionName) {
|
|
1700
|
+
const result = await this.request(
|
|
1701
|
+
"GET",
|
|
1702
|
+
this.userPath(`/sessions/${encodeURIComponent(sessionName)}/files`),
|
|
1703
|
+
void 0,
|
|
1704
|
+
{ team: teamId }
|
|
1705
|
+
);
|
|
1706
|
+
return result.files;
|
|
1707
|
+
}
|
|
1708
|
+
async getNotifications(params) {
|
|
1709
|
+
const path = this.userId ? this.userPath("/notifications") : "/api/notifications";
|
|
1710
|
+
return this.request("GET", path, void 0, params);
|
|
1711
|
+
}
|
|
1712
|
+
async markRead(ids) {
|
|
1713
|
+
const path = this.userId ? this.userPath("/notifications/read") : "/api/notifications/read";
|
|
1714
|
+
await this.request("POST", path, { notificationIds: ids });
|
|
1715
|
+
}
|
|
1716
|
+
/** List the caller's user-scoped API keys. Requires user-level auth. */
|
|
1717
|
+
async listUserApiKeys() {
|
|
1718
|
+
const body = await this.request("GET", this.userPath("/keys"));
|
|
1719
|
+
return body.keys;
|
|
1720
|
+
}
|
|
1721
|
+
/** Revoke a user-scoped API key by id. Requires user-level auth. */
|
|
1722
|
+
async revokeUserApiKey(id) {
|
|
1723
|
+
await this.request("DELETE", this.userPath(`/keys/${encodeURIComponent(id)}`));
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Create a live canvas session (specsketch --share host). User-scoped
|
|
1727
|
+
* route; the caller validates the response shape against
|
|
1728
|
+
* canvasSessionCreatedSchema.
|
|
1729
|
+
*/
|
|
1730
|
+
async createCanvasSession(input) {
|
|
1731
|
+
return this.request("POST", this.userPath("/canvas/sessions"), input);
|
|
1732
|
+
}
|
|
1733
|
+
/** End a canvas session server-side (invalidates the share link). */
|
|
1734
|
+
async endCanvasSession(sessionId) {
|
|
1735
|
+
await this.request("DELETE", this.userPath(`/canvas/sessions/${encodeURIComponent(sessionId)}`));
|
|
1736
|
+
}
|
|
1737
|
+
async uploadFile(teamId, session, filename, body, size) {
|
|
1738
|
+
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/upload`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/upload`;
|
|
1739
|
+
const url = new URL(path, this.apiUrl);
|
|
1740
|
+
if (this.userId) url.searchParams.set("team", teamId);
|
|
1741
|
+
const form = new FormData();
|
|
1742
|
+
const bytes = new Uint8Array(
|
|
1743
|
+
body instanceof Buffer || body instanceof Uint8Array ? body : await streamToBuffer(body)
|
|
1744
|
+
);
|
|
1745
|
+
form.append("file", new Blob([bytes]), filename);
|
|
1746
|
+
form.append("filename", filename);
|
|
1747
|
+
const res = await fetch(url.toString(), {
|
|
1748
|
+
method: "POST",
|
|
1749
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
1750
|
+
body: form
|
|
1751
|
+
});
|
|
1752
|
+
if (!res.ok) {
|
|
1753
|
+
const text = await res.text();
|
|
1754
|
+
let msg = res.statusText;
|
|
1755
|
+
try {
|
|
1756
|
+
msg = JSON.parse(text).message ?? msg;
|
|
1757
|
+
} catch {
|
|
1758
|
+
}
|
|
1759
|
+
throw new CoopApiError(res.status, { category: "user_action", code: "UPLOAD_FAILED", message: msg });
|
|
1760
|
+
}
|
|
1761
|
+
return res.json();
|
|
1762
|
+
}
|
|
1763
|
+
async downloadFile(teamId, session, fileId) {
|
|
1764
|
+
const path = this.userId ? this.userPath(`/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`) : `/api/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(session)}/files/${encodeURIComponent(fileId)}`;
|
|
1765
|
+
const url = new URL(path, this.apiUrl);
|
|
1766
|
+
if (this.userId) url.searchParams.set("team", teamId);
|
|
1767
|
+
const res = await fetch(url.toString(), {
|
|
1768
|
+
method: "GET",
|
|
1769
|
+
headers: { Authorization: `Bearer ${this.token}` }
|
|
1770
|
+
});
|
|
1771
|
+
if (!res.ok) {
|
|
1772
|
+
const text = await res.text();
|
|
1773
|
+
let msg = res.statusText;
|
|
1774
|
+
try {
|
|
1775
|
+
msg = JSON.parse(text).message ?? msg;
|
|
1776
|
+
} catch {
|
|
1777
|
+
}
|
|
1778
|
+
throw new CoopApiError(res.status, { category: "user_action", code: "DOWNLOAD_FAILED", message: msg });
|
|
1779
|
+
}
|
|
1780
|
+
const disposition = res.headers.get("Content-Disposition") ?? "";
|
|
1781
|
+
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
|
1782
|
+
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : fileId;
|
|
1783
|
+
const size = parseInt(res.headers.get("Content-Length") ?? "0", 10);
|
|
1784
|
+
return { filename, body: res.body, size };
|
|
1785
|
+
}
|
|
1786
|
+
getWebSocketUrl(session) {
|
|
1787
|
+
const wsBase = this.apiUrl.replace(
|
|
1788
|
+
/^https?:\/\//,
|
|
1789
|
+
(match) => match === "https://" ? "wss://" : "ws://"
|
|
1790
|
+
);
|
|
1791
|
+
return `${wsBase}/api/sessions/${encodeURIComponent(session)}/ws?token=${encodeURIComponent(this.token)}`;
|
|
1792
|
+
}
|
|
1793
|
+
withToken(token) {
|
|
1794
|
+
return new _ApiClient(this.apiUrl, token, this.userId);
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1798
|
+
// ../shared/src/canvas-protocol.ts
|
|
1799
|
+
import { z as z3 } from "zod";
|
|
1800
|
+
var CANVAS_PROTOCOL_VERSION = 1;
|
|
1801
|
+
var canvasElementSchema = z3.object({
|
|
1802
|
+
id: z3.string().min(1),
|
|
1803
|
+
version: z3.number().int().nonnegative(),
|
|
1804
|
+
versionNonce: z3.number().int(),
|
|
1805
|
+
isDeleted: z3.boolean().optional()
|
|
1806
|
+
}).passthrough();
|
|
1807
|
+
var canvasFilesSchema = z3.record(z3.string(), z3.unknown());
|
|
1808
|
+
var canvasSceneSchema = z3.object({
|
|
1809
|
+
elements: z3.array(canvasElementSchema),
|
|
1810
|
+
files: canvasFilesSchema.optional()
|
|
1811
|
+
});
|
|
1812
|
+
var canvasParticipantSchema = z3.object({
|
|
1813
|
+
id: z3.string().min(1),
|
|
1814
|
+
role: z3.enum(["host", "guest"]),
|
|
1815
|
+
name: z3.string().optional(),
|
|
1816
|
+
color: z3.string().optional()
|
|
1817
|
+
});
|
|
1818
|
+
var canvasHelloSchema = z3.object({
|
|
1819
|
+
type: z3.literal("hello"),
|
|
1820
|
+
protocolVersion: z3.number().int().positive(),
|
|
1821
|
+
role: z3.enum(["host", "guest"]),
|
|
1822
|
+
/** Stable id across reconnects, if the client has one. */
|
|
1823
|
+
participantId: z3.string().min(1).max(64).optional(),
|
|
1824
|
+
name: z3.string().max(120).optional()
|
|
1825
|
+
});
|
|
1826
|
+
var canvasClientOpSchema = z3.object({
|
|
1827
|
+
type: z3.literal("op"),
|
|
1828
|
+
/** Changed or soft-deleted elements only — never the full scene. */
|
|
1829
|
+
elements: z3.array(canvasElementSchema).min(1),
|
|
1830
|
+
/** New binary files referenced by the changed elements, if any. */
|
|
1831
|
+
files: canvasFilesSchema.optional(),
|
|
1832
|
+
/** Client-local monotonic sequence, used by the sender for echo dedupe. */
|
|
1833
|
+
clientSeq: z3.number().int().nonnegative()
|
|
1834
|
+
});
|
|
1835
|
+
var canvasClientMessageSchema = z3.discriminatedUnion("type", [
|
|
1836
|
+
canvasHelloSchema,
|
|
1837
|
+
canvasClientOpSchema
|
|
1838
|
+
]);
|
|
1839
|
+
var canvasSnapshotSchema = z3.object({
|
|
1840
|
+
type: z3.literal("snapshot"),
|
|
1841
|
+
scene: canvasSceneSchema,
|
|
1842
|
+
revision: z3.number().int().nonnegative(),
|
|
1843
|
+
participants: z3.array(canvasParticipantSchema),
|
|
1844
|
+
/** Echoes the server's protocol version so clients can detect skew. */
|
|
1845
|
+
protocolVersion: z3.number().int().positive(),
|
|
1846
|
+
/** The participant id the server assigned to (or kept for) this client. */
|
|
1847
|
+
participantId: z3.string()
|
|
1848
|
+
});
|
|
1849
|
+
var canvasServerOpSchema = z3.object({
|
|
1850
|
+
type: z3.literal("op"),
|
|
1851
|
+
/** The elements that won reconciliation (never echoed to the sender). */
|
|
1852
|
+
elements: z3.array(canvasElementSchema),
|
|
1853
|
+
files: canvasFilesSchema.optional(),
|
|
1854
|
+
revision: z3.number().int().positive(),
|
|
1855
|
+
/** Participant id of the sender. */
|
|
1856
|
+
from: z3.string()
|
|
1857
|
+
});
|
|
1858
|
+
var canvasPresenceSchema = z3.object({
|
|
1859
|
+
type: z3.literal("presence"),
|
|
1860
|
+
participants: z3.array(canvasParticipantSchema)
|
|
1861
|
+
});
|
|
1862
|
+
var canvasEndSchema = z3.object({
|
|
1863
|
+
type: z3.literal("end"),
|
|
1864
|
+
reason: z3.string()
|
|
1865
|
+
});
|
|
1866
|
+
var canvasErrorSchema = z3.object({
|
|
1867
|
+
type: z3.literal("error"),
|
|
1868
|
+
code: z3.string(),
|
|
1869
|
+
message: z3.string()
|
|
1870
|
+
});
|
|
1871
|
+
var canvasServerMessageSchema = z3.discriminatedUnion("type", [
|
|
1872
|
+
canvasSnapshotSchema,
|
|
1873
|
+
canvasServerOpSchema,
|
|
1874
|
+
canvasPresenceSchema,
|
|
1875
|
+
canvasEndSchema,
|
|
1876
|
+
canvasErrorSchema
|
|
1877
|
+
]);
|
|
1878
|
+
var canvasSessionCreatedSchema = z3.object({
|
|
1879
|
+
sessionId: z3.string().min(1),
|
|
1880
|
+
/** Capability token — shown once; the server stores only its hash. */
|
|
1881
|
+
token: z3.string().min(1),
|
|
1882
|
+
shareUrl: z3.string().min(1),
|
|
1883
|
+
wsUrl: z3.string().min(1)
|
|
1884
|
+
});
|
|
1885
|
+
var canvasSessionSummarySchema = z3.object({
|
|
1886
|
+
sessionId: z3.string(),
|
|
1887
|
+
title: z3.string().nullable(),
|
|
1888
|
+
status: z3.enum(["active", "ended"]),
|
|
1889
|
+
createdAt: z3.number(),
|
|
1890
|
+
endedAt: z3.number().nullable()
|
|
1891
|
+
});
|
|
1892
|
+
function elementWins(local, incoming) {
|
|
1893
|
+
if (incoming.version !== local.version) return incoming.version > local.version;
|
|
1894
|
+
return incoming.versionNonce < local.versionNonce;
|
|
1895
|
+
}
|
|
1896
|
+
function reconcileCanvasElements(local, incoming) {
|
|
1897
|
+
const byId = new Map(local.map((el) => [el.id, el]));
|
|
1898
|
+
const accepted = [];
|
|
1899
|
+
for (const el of incoming) {
|
|
1900
|
+
const existing = byId.get(el.id);
|
|
1901
|
+
if (!existing || elementWins(existing, el)) {
|
|
1902
|
+
byId.set(el.id, el);
|
|
1903
|
+
accepted.push(el);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
return { elements: [...byId.values()], accepted };
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// server/remote-session.ts
|
|
1910
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1911
|
+
var HEARTBEAT_TIMEOUT_MS = 75e3;
|
|
1912
|
+
var BACKOFF_MIN_MS = 1e3;
|
|
1913
|
+
var BACKOFF_MAX_MS = 3e4;
|
|
1914
|
+
var PENDING_OP_LIMIT = 200;
|
|
1915
|
+
function asCanvasElements(elements) {
|
|
1916
|
+
if (!elements) return [];
|
|
1917
|
+
return elements.filter(
|
|
1918
|
+
(el) => typeof el === "object" && el !== null && typeof el.id === "string" && typeof el.version === "number" && typeof el.versionNonce === "number"
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
var RemoteSession = class _RemoteSession {
|
|
1922
|
+
sessionId;
|
|
1923
|
+
shareUrl;
|
|
1924
|
+
token;
|
|
1925
|
+
wsUrl;
|
|
1926
|
+
opts;
|
|
1927
|
+
status = "connecting";
|
|
1928
|
+
revision = 0;
|
|
1929
|
+
participants = [];
|
|
1930
|
+
ws = null;
|
|
1931
|
+
closed = false;
|
|
1932
|
+
clientSeq = 0;
|
|
1933
|
+
backoffMs;
|
|
1934
|
+
lastTrafficAt = 0;
|
|
1935
|
+
heartbeatTimer = null;
|
|
1936
|
+
reconnectTimer = null;
|
|
1937
|
+
pendingOps = [];
|
|
1938
|
+
api;
|
|
1939
|
+
constructor(opts, api, created) {
|
|
1940
|
+
this.opts = opts;
|
|
1941
|
+
this.api = api;
|
|
1942
|
+
this.sessionId = created.sessionId;
|
|
1943
|
+
this.token = created.token;
|
|
1944
|
+
this.shareUrl = created.shareUrl;
|
|
1945
|
+
this.wsUrl = created.wsUrl;
|
|
1946
|
+
this.backoffMs = opts.backoffMinMs ?? BACKOFF_MIN_MS;
|
|
1947
|
+
}
|
|
1948
|
+
/** Create the session server-side and open the host connection. */
|
|
1949
|
+
static async start(opts) {
|
|
1950
|
+
const api = opts.api ?? new ApiClient(opts.apiUrl, opts.key, opts.userId);
|
|
1951
|
+
let response;
|
|
1952
|
+
try {
|
|
1953
|
+
response = await api.createCanvasSession({ title: opts.title });
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
if (isCoopApiError(err)) {
|
|
1956
|
+
if (err.status === 401 || err.status === 403) {
|
|
1957
|
+
throw new Error(
|
|
1958
|
+
"CoopCLI rejected the saved credentials (revoked or expired key). Run `specsketch login` and retry."
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
throw new Error(`Could not create the shared session: HTTP ${err.status} ${err.message}`);
|
|
1962
|
+
}
|
|
1963
|
+
throw err;
|
|
1964
|
+
}
|
|
1965
|
+
const created = canvasSessionCreatedSchema.parse(response);
|
|
1966
|
+
const session = new _RemoteSession(opts, api, created);
|
|
1967
|
+
session.connect();
|
|
1968
|
+
return session;
|
|
1969
|
+
}
|
|
1970
|
+
// ── Upstream connection ──
|
|
1971
|
+
connect() {
|
|
1972
|
+
if (this.closed) return;
|
|
1973
|
+
const factory = this.opts.wsFactory ?? ((url, headers) => new WebSocket(url, { headers }));
|
|
1974
|
+
const ws = factory(this.wsUrl, { Authorization: `Bearer ${this.opts.key}` });
|
|
1975
|
+
this.ws = ws;
|
|
1976
|
+
this.lastTrafficAt = Date.now();
|
|
1977
|
+
ws.on("open", () => {
|
|
1978
|
+
this.backoffMs = this.opts.backoffMinMs ?? BACKOFF_MIN_MS;
|
|
1979
|
+
this.status = "connected";
|
|
1980
|
+
ws.send(
|
|
1981
|
+
JSON.stringify({
|
|
1982
|
+
type: "hello",
|
|
1983
|
+
protocolVersion: CANVAS_PROTOCOL_VERSION,
|
|
1984
|
+
role: "host",
|
|
1985
|
+
name: this.opts.hostName ?? void 0
|
|
1986
|
+
})
|
|
1987
|
+
);
|
|
1988
|
+
for (const frame of this.pendingOps.splice(0)) ws.send(frame);
|
|
1989
|
+
});
|
|
1990
|
+
ws.on("message", (data) => this.handleUpstreamFrame(String(data)));
|
|
1991
|
+
ws.on("close", (code) => {
|
|
1992
|
+
if (ws !== this.ws) return;
|
|
1993
|
+
this.stopHeartbeat();
|
|
1994
|
+
if (this.closed) return;
|
|
1995
|
+
this.status = "reconnecting";
|
|
1996
|
+
this.opts.log?.(`share: connection lost (code ${code}) \u2014 reconnecting`);
|
|
1997
|
+
this.scheduleReconnect();
|
|
1998
|
+
});
|
|
1999
|
+
ws.on("error", () => {
|
|
2000
|
+
});
|
|
2001
|
+
this.startHeartbeat();
|
|
2002
|
+
}
|
|
2003
|
+
scheduleReconnect() {
|
|
2004
|
+
if (this.closed || this.reconnectTimer) return;
|
|
2005
|
+
const jitter = Math.random() * 0.3 + 0.85;
|
|
2006
|
+
const delay = Math.round(this.backoffMs * jitter);
|
|
2007
|
+
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
2008
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2009
|
+
this.reconnectTimer = null;
|
|
2010
|
+
this.connect();
|
|
2011
|
+
}, delay);
|
|
2012
|
+
}
|
|
2013
|
+
startHeartbeat() {
|
|
2014
|
+
this.stopHeartbeat();
|
|
2015
|
+
this.heartbeatTimer = setInterval(() => {
|
|
2016
|
+
const ws = this.ws;
|
|
2017
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
2018
|
+
if (Date.now() - this.lastTrafficAt > HEARTBEAT_TIMEOUT_MS) {
|
|
2019
|
+
ws.terminate();
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
ws.send("ping");
|
|
2023
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
2024
|
+
this.heartbeatTimer.unref?.();
|
|
2025
|
+
}
|
|
2026
|
+
stopHeartbeat() {
|
|
2027
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
2028
|
+
this.heartbeatTimer = null;
|
|
2029
|
+
}
|
|
2030
|
+
// ── Frames from the DO ──
|
|
2031
|
+
handleUpstreamFrame(text) {
|
|
2032
|
+
this.lastTrafficAt = Date.now();
|
|
2033
|
+
if (text === "pong") return;
|
|
2034
|
+
const parsed = canvasServerMessageSchema.safeParse(
|
|
2035
|
+
(() => {
|
|
2036
|
+
try {
|
|
2037
|
+
return JSON.parse(text);
|
|
2038
|
+
} catch {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
})()
|
|
2042
|
+
);
|
|
2043
|
+
if (!parsed.success) return;
|
|
2044
|
+
const frame = parsed.data;
|
|
2045
|
+
switch (frame.type) {
|
|
2046
|
+
case "snapshot": {
|
|
2047
|
+
const local = this.opts.workspace.loadScene();
|
|
2048
|
+
const { elements, accepted } = reconcileCanvasElements(
|
|
2049
|
+
asCanvasElements(frame.scene.elements),
|
|
2050
|
+
asCanvasElements(local?.elements)
|
|
2051
|
+
);
|
|
2052
|
+
this.revision = frame.revision;
|
|
2053
|
+
this.participants = frame.participants;
|
|
2054
|
+
this.opts.workspace.saveScene({
|
|
2055
|
+
elements,
|
|
2056
|
+
appState: local?.appState,
|
|
2057
|
+
files: { ...frame.scene.files ?? {}, ...local?.files ?? {} }
|
|
2058
|
+
});
|
|
2059
|
+
if (accepted.length > 0) {
|
|
2060
|
+
this.sendUpstream(
|
|
2061
|
+
JSON.stringify({ type: "op", elements: accepted, clientSeq: ++this.clientSeq })
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
this.opts.onLocalBroadcast?.(text);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
case "op": {
|
|
2068
|
+
if (frame.revision > this.revision + 1) {
|
|
2069
|
+
this.ws?.terminate();
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
this.revision = frame.revision;
|
|
2073
|
+
this.applyRemoteElements(frame.elements, frame.files);
|
|
2074
|
+
this.opts.onLocalBroadcast?.(text);
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
case "presence": {
|
|
2078
|
+
this.participants = frame.participants;
|
|
2079
|
+
this.opts.onLocalBroadcast?.(text);
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
case "end": {
|
|
2083
|
+
this.opts.log?.(`share: session ended (${frame.reason})`);
|
|
2084
|
+
this.opts.onLocalBroadcast?.(text);
|
|
2085
|
+
this.closed = true;
|
|
2086
|
+
this.status = "ended";
|
|
2087
|
+
this.teardown();
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
case "error": {
|
|
2091
|
+
this.opts.log?.(`share: server error ${frame.code}: ${frame.message}`);
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
/** Persist remote edits into the change directory — the durable copy. */
|
|
2097
|
+
applyRemoteElements(incoming, files) {
|
|
2098
|
+
const local = this.opts.workspace.loadScene();
|
|
2099
|
+
const { elements } = reconcileCanvasElements(asCanvasElements(local?.elements), incoming);
|
|
2100
|
+
this.opts.workspace.saveScene({
|
|
2101
|
+
elements,
|
|
2102
|
+
appState: local?.appState,
|
|
2103
|
+
files: { ...local?.files ?? {}, ...files ?? {} }
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
// ── Frames from the host browser (via /ws/local) ──
|
|
2107
|
+
/**
|
|
2108
|
+
* Handle one message from a local browser socket. Ops are relayed upstream
|
|
2109
|
+
* verbatim (the browser already persists via its autosave PUT). A `hello`
|
|
2110
|
+
* is answered locally with a synthetic snapshot so the browser can seed
|
|
2111
|
+
* its revision/participant state without a round-trip to the DO.
|
|
2112
|
+
*/
|
|
2113
|
+
handleLocalFrame(text, reply) {
|
|
2114
|
+
const parsed = canvasClientMessageSchema.safeParse(
|
|
2115
|
+
(() => {
|
|
2116
|
+
try {
|
|
2117
|
+
return JSON.parse(text);
|
|
2118
|
+
} catch {
|
|
2119
|
+
return null;
|
|
2120
|
+
}
|
|
2121
|
+
})()
|
|
2122
|
+
);
|
|
2123
|
+
if (!parsed.success) return;
|
|
2124
|
+
if (parsed.data.type === "hello") {
|
|
2125
|
+
const scene = this.opts.workspace.loadScene();
|
|
2126
|
+
reply(
|
|
2127
|
+
JSON.stringify({
|
|
2128
|
+
type: "snapshot",
|
|
2129
|
+
scene: { elements: scene?.elements ?? [], files: scene?.files ?? void 0 },
|
|
2130
|
+
revision: this.revision,
|
|
2131
|
+
participants: this.participants,
|
|
2132
|
+
protocolVersion: CANVAS_PROTOCOL_VERSION,
|
|
2133
|
+
participantId: "host"
|
|
2134
|
+
})
|
|
2135
|
+
);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
this.sendUpstream(text);
|
|
2139
|
+
}
|
|
2140
|
+
sendUpstream(frameText) {
|
|
2141
|
+
const ws = this.ws;
|
|
2142
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2143
|
+
ws.send(frameText);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
this.pendingOps.push(frameText);
|
|
2147
|
+
if (this.pendingOps.length > PENDING_OP_LIMIT) this.pendingOps.shift();
|
|
2148
|
+
}
|
|
2149
|
+
// ── Teardown ──
|
|
2150
|
+
/** End the session server-side (invalidates the share link) and close. */
|
|
2151
|
+
async end() {
|
|
2152
|
+
if (this.status === "ended") return;
|
|
2153
|
+
this.closed = true;
|
|
2154
|
+
this.status = "ended";
|
|
2155
|
+
this.teardown();
|
|
2156
|
+
try {
|
|
2157
|
+
await this.api.endCanvasSession(this.sessionId);
|
|
2158
|
+
} catch {
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
teardown() {
|
|
2162
|
+
this.stopHeartbeat();
|
|
2163
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
2164
|
+
this.reconnectTimer = null;
|
|
2165
|
+
try {
|
|
2166
|
+
this.ws?.close(1e3, "host shutting down");
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
this.ws = null;
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
|
|
226
2173
|
// server/workspace.ts
|
|
227
2174
|
import { createHash } from "node:crypto";
|
|
228
2175
|
import {
|
|
229
|
-
existsSync,
|
|
2176
|
+
existsSync as existsSync2,
|
|
230
2177
|
mkdirSync,
|
|
231
|
-
readFileSync,
|
|
232
|
-
readdirSync,
|
|
2178
|
+
readFileSync as readFileSync2,
|
|
2179
|
+
readdirSync as readdirSync2,
|
|
233
2180
|
renameSync,
|
|
234
2181
|
writeFileSync
|
|
235
2182
|
} from "node:fs";
|
|
236
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
2183
|
+
import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
|
|
237
2184
|
var DIAGRAM_FILE = "diagram.excalidraw";
|
|
238
2185
|
var SIDECAR_FILE = "specsketch.json";
|
|
239
2186
|
var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
@@ -241,20 +2188,20 @@ var ChangeWorkspace = class {
|
|
|
241
2188
|
dir;
|
|
242
2189
|
changeName;
|
|
243
2190
|
constructor(dir) {
|
|
244
|
-
this.dir =
|
|
245
|
-
this.changeName =
|
|
2191
|
+
this.dir = resolve2(dir);
|
|
2192
|
+
this.changeName = basename2(this.dir);
|
|
246
2193
|
mkdirSync(this.dir, { recursive: true });
|
|
247
2194
|
}
|
|
248
2195
|
writeAtomic(relPath, content) {
|
|
249
|
-
const target =
|
|
250
|
-
mkdirSync(
|
|
2196
|
+
const target = join4(this.dir, relPath);
|
|
2197
|
+
mkdirSync(dirname2(target), { recursive: true });
|
|
251
2198
|
const tmp = `${target}.tmp`;
|
|
252
2199
|
writeFileSync(tmp, content, "utf8");
|
|
253
2200
|
renameSync(tmp, target);
|
|
254
2201
|
}
|
|
255
2202
|
readIfExists(relPath) {
|
|
256
|
-
const target =
|
|
257
|
-
return
|
|
2203
|
+
const target = join4(this.dir, relPath);
|
|
2204
|
+
return existsSync2(target) ? readFileSync2(target, "utf8") : void 0;
|
|
258
2205
|
}
|
|
259
2206
|
// ── Scene ────────────────────────────────────────────────────────────────
|
|
260
2207
|
loadScene() {
|
|
@@ -315,11 +2262,11 @@ var ChangeWorkspace = class {
|
|
|
315
2262
|
/** Current artifact files, however they were authored. */
|
|
316
2263
|
readArtifacts() {
|
|
317
2264
|
const specs = [];
|
|
318
|
-
const specsDir =
|
|
319
|
-
if (
|
|
320
|
-
for (const entry of
|
|
2265
|
+
const specsDir = join4(this.dir, "specs");
|
|
2266
|
+
if (existsSync2(specsDir)) {
|
|
2267
|
+
for (const entry of readdirSync2(specsDir, { withFileTypes: true })) {
|
|
321
2268
|
if (!entry.isDirectory()) continue;
|
|
322
|
-
const spec = this.readIfExists(
|
|
2269
|
+
const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
|
|
323
2270
|
if (spec !== void 0) specs.push({ capability: entry.name, spec });
|
|
324
2271
|
}
|
|
325
2272
|
}
|
|
@@ -353,7 +2300,7 @@ var ChangeWorkspace = class {
|
|
|
353
2300
|
"design.md": bundle.design,
|
|
354
2301
|
"tasks.md": bundle.tasks
|
|
355
2302
|
};
|
|
356
|
-
for (const s of bundle.specs) files[
|
|
2303
|
+
for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
|
|
357
2304
|
const written = [];
|
|
358
2305
|
const manifest = {};
|
|
359
2306
|
for (const [relPath, content] of Object.entries(files)) {
|
|
@@ -362,13 +2309,39 @@ var ChangeWorkspace = class {
|
|
|
362
2309
|
this.writeAtomic(relPath, content);
|
|
363
2310
|
written.push(relPath);
|
|
364
2311
|
}
|
|
365
|
-
this.updateSidecar((s) => ({
|
|
2312
|
+
this.updateSidecar((s) => ({
|
|
2313
|
+
...s,
|
|
2314
|
+
summary: bundle.summary,
|
|
2315
|
+
generated: manifest,
|
|
2316
|
+
// The diff-before-write result IS the changed-file signal for the
|
|
2317
|
+
// post-generation chat seed (generation-chat-reset-summary).
|
|
2318
|
+
lastGeneration: { at: (/* @__PURE__ */ new Date()).toISOString(), changedFiles: written }
|
|
2319
|
+
}));
|
|
366
2320
|
return written;
|
|
367
2321
|
}
|
|
2322
|
+
// ── Spec review chat ─────────────────────────────────────────────────────
|
|
2323
|
+
readChat() {
|
|
2324
|
+
return this.readSidecar().chat ?? [];
|
|
2325
|
+
}
|
|
2326
|
+
writeChat(chat) {
|
|
2327
|
+
this.updateSidecar((s) => ({ ...s, chat }));
|
|
2328
|
+
}
|
|
2329
|
+
// ── Generation system prompt override ────────────────────────────────────
|
|
2330
|
+
readSystemPrompt() {
|
|
2331
|
+
return this.readSidecar().systemPrompt;
|
|
2332
|
+
}
|
|
2333
|
+
/** `undefined` clears the override (reset to the built-in default). */
|
|
2334
|
+
writeSystemPrompt(systemPrompt) {
|
|
2335
|
+
this.updateSidecar(({ systemPrompt: _prev, ...s }) => ({
|
|
2336
|
+
...s,
|
|
2337
|
+
...systemPrompt !== void 0 ? { systemPrompt } : {}
|
|
2338
|
+
}));
|
|
2339
|
+
}
|
|
368
2340
|
};
|
|
369
2341
|
|
|
370
2342
|
// server/cli.ts
|
|
371
|
-
var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>]
|
|
2343
|
+
var USAGE = `Usage: specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
|
|
2344
|
+
specsketch login [--profile <name>] [--web-url <url>]
|
|
372
2345
|
|
|
373
2346
|
specsketch ./openspec/changes/new-feature-change
|
|
374
2347
|
|
|
@@ -377,15 +2350,23 @@ diagram.excalidraw inside it; Generate Spec writes proposal.md, design.md,
|
|
|
377
2350
|
tasks.md and specs/<capability>/spec.md alongside.
|
|
378
2351
|
|
|
379
2352
|
--port sets the preferred port (default 8787); if it's already in use the
|
|
380
|
-
server moves to the next available port and prints the actual URL
|
|
2353
|
+
server moves to the next available port and prints the actual URL.
|
|
2354
|
+
|
|
2355
|
+
--share hosts the canvas as a live multiplayer session on your CoopCLI
|
|
2356
|
+
account and prints a link coworkers can open in a browser \u2014 no account
|
|
2357
|
+
needed on their side. Requires \`specsketch login\` first. The local server
|
|
2358
|
+
still binds to 127.0.0.1 only; sharing runs over an outbound connection.
|
|
2359
|
+
|
|
2360
|
+
login authenticates this machine against your CoopCLI account (opens a
|
|
2361
|
+
browser, saves credentials to ~/.coopcli/config.json).`;
|
|
381
2362
|
var UsageError = class extends Error {
|
|
382
2363
|
};
|
|
383
2364
|
function parseCliArgs(argv) {
|
|
384
2365
|
let parsed;
|
|
385
2366
|
try {
|
|
386
|
-
parsed =
|
|
2367
|
+
parsed = parseArgs2({
|
|
387
2368
|
args: argv,
|
|
388
|
-
options: { port: { type: "string" } },
|
|
2369
|
+
options: { port: { type: "string" }, share: { type: "boolean" } },
|
|
389
2370
|
allowPositionals: true
|
|
390
2371
|
});
|
|
391
2372
|
} catch (err) {
|
|
@@ -397,7 +2378,7 @@ function parseCliArgs(argv) {
|
|
|
397
2378
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
398
2379
|
throw new UsageError(`invalid --port: ${String(parsed.values.port)}`);
|
|
399
2380
|
}
|
|
400
|
-
return { dir, port };
|
|
2381
|
+
return { dir, port, share: parsed.values.share === true };
|
|
401
2382
|
}
|
|
402
2383
|
var PORT_SCAN_LIMIT = 100;
|
|
403
2384
|
async function bindAvailablePort(start, tryBind) {
|
|
@@ -424,25 +2405,25 @@ var MIME = {
|
|
|
424
2405
|
".woff": "font/woff"
|
|
425
2406
|
};
|
|
426
2407
|
function resolveClientDir(moduleUrl) {
|
|
427
|
-
const here =
|
|
2408
|
+
const here = dirname3(fileURLToPath(moduleUrl));
|
|
428
2409
|
const candidates = [
|
|
429
|
-
|
|
2410
|
+
join5(here, "..", "client"),
|
|
430
2411
|
// packed: dist/cli -> dist/client
|
|
431
|
-
|
|
2412
|
+
join5(here, "..", "dist", "client")
|
|
432
2413
|
// repo: server/ -> dist/client
|
|
433
2414
|
];
|
|
434
2415
|
for (const dir of candidates) {
|
|
435
|
-
if (
|
|
2416
|
+
if (existsSync3(join5(dir, "index.html"))) return dir;
|
|
436
2417
|
}
|
|
437
2418
|
return null;
|
|
438
2419
|
}
|
|
439
2420
|
function staticResponse(clientDir, pathname) {
|
|
440
|
-
const rel =
|
|
441
|
-
const target =
|
|
442
|
-
if (!target.startsWith(
|
|
443
|
-
const file =
|
|
444
|
-
if (!
|
|
445
|
-
return new Response(
|
|
2421
|
+
const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
|
|
2422
|
+
const target = resolve3(clientDir, rel === "" ? "index.html" : rel);
|
|
2423
|
+
if (!target.startsWith(resolve3(clientDir))) return null;
|
|
2424
|
+
const file = existsSync3(target) && extname(target) ? target : join5(clientDir, "index.html");
|
|
2425
|
+
if (!existsSync3(file)) return null;
|
|
2426
|
+
return new Response(readFileSync3(file), {
|
|
446
2427
|
headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
|
|
447
2428
|
});
|
|
448
2429
|
}
|
|
@@ -454,12 +2435,12 @@ but "Generate Spec" needs one of:
|
|
|
454
2435
|
\u2022 or the Anthropic CLI's login profile (no key handling needed):
|
|
455
2436
|
brew install anthropics/tap/ant # macOS
|
|
456
2437
|
ant auth login`;
|
|
457
|
-
function detectCredentialSource(env = process.env, home =
|
|
2438
|
+
function detectCredentialSource(env = process.env, home = homedir3()) {
|
|
458
2439
|
if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
|
|
459
2440
|
if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
|
|
460
2441
|
try {
|
|
461
|
-
const dir =
|
|
462
|
-
if (
|
|
2442
|
+
const dir = join5(home, ".config", "anthropic", "credentials");
|
|
2443
|
+
if (readdirSync3(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
|
|
463
2444
|
} catch {
|
|
464
2445
|
}
|
|
465
2446
|
return null;
|
|
@@ -474,6 +2455,26 @@ function preflight(log = console.log) {
|
|
|
474
2455
|
}
|
|
475
2456
|
async function main(argv) {
|
|
476
2457
|
preflight();
|
|
2458
|
+
if (argv[0] === "login") {
|
|
2459
|
+
let loginArgs;
|
|
2460
|
+
try {
|
|
2461
|
+
loginArgs = parseLoginArgs(argv.slice(1));
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
console.error(
|
|
2464
|
+
err instanceof LoginUsageError ? `specsketch: ${err.message}
|
|
2465
|
+
|
|
2466
|
+
${LOGIN_USAGE}` : err
|
|
2467
|
+
);
|
|
2468
|
+
process.exit(1);
|
|
2469
|
+
}
|
|
2470
|
+
try {
|
|
2471
|
+
await runLogin(loginArgs);
|
|
2472
|
+
} catch (err) {
|
|
2473
|
+
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
2474
|
+
process.exit(1);
|
|
2475
|
+
}
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
477
2478
|
let args;
|
|
478
2479
|
try {
|
|
479
2480
|
args = parseCliArgs(argv);
|
|
@@ -488,17 +2489,36 @@ ${USAGE}` : err);
|
|
|
488
2489
|
console.error("specsketch: built UI assets not found (dist/client missing).");
|
|
489
2490
|
process.exit(1);
|
|
490
2491
|
}
|
|
2492
|
+
if (args.share) {
|
|
2493
|
+
try {
|
|
2494
|
+
await readCoopAuth();
|
|
2495
|
+
} catch (err) {
|
|
2496
|
+
console.error(`specsketch: ${err instanceof CoopAuthError ? err.message : String(err)}`);
|
|
2497
|
+
process.exit(1);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
491
2500
|
const workspace = new ChangeWorkspace(args.dir);
|
|
492
|
-
|
|
2501
|
+
let remote = null;
|
|
2502
|
+
const app = createApp(
|
|
2503
|
+
new Anthropic(),
|
|
2504
|
+
workspace,
|
|
2505
|
+
() => remote ? {
|
|
2506
|
+
active: true,
|
|
2507
|
+
shareUrl: remote.shareUrl,
|
|
2508
|
+
status: remote.status,
|
|
2509
|
+
participants: remote.participants.length
|
|
2510
|
+
} : { active: false }
|
|
2511
|
+
);
|
|
493
2512
|
app.get("*", (c) => {
|
|
494
2513
|
const res = staticResponse(clientDir, new URL(c.req.url).pathname);
|
|
495
2514
|
return res ?? c.notFound();
|
|
496
2515
|
});
|
|
2516
|
+
let httpServer = null;
|
|
497
2517
|
const tryBind = (port2) => new Promise((bound, failed) => {
|
|
498
|
-
const server = serve(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
);
|
|
2518
|
+
const server = serve({ fetch: app.fetch, port: port2, hostname: "127.0.0.1" }, (info) => {
|
|
2519
|
+
httpServer = server;
|
|
2520
|
+
bound(info.port);
|
|
2521
|
+
});
|
|
502
2522
|
server.once("error", (err) => {
|
|
503
2523
|
try {
|
|
504
2524
|
server.close();
|
|
@@ -515,6 +2535,14 @@ ${USAGE}` : err);
|
|
|
515
2535
|
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
516
2536
|
process.exit(1);
|
|
517
2537
|
}
|
|
2538
|
+
if (args.share) {
|
|
2539
|
+
try {
|
|
2540
|
+
remote = await startShare(workspace, httpServer);
|
|
2541
|
+
} catch (err) {
|
|
2542
|
+
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
2543
|
+
process.exit(1);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
518
2546
|
const source = detectCredentialSource();
|
|
519
2547
|
console.log(`coopcli specsketch`);
|
|
520
2548
|
console.log(` workspace: ${workspace.dir}`);
|
|
@@ -529,6 +2557,56 @@ ${USAGE}` : err);
|
|
|
529
2557
|
\u26A0 ${CREDENTIALS_HINT}
|
|
530
2558
|
`);
|
|
531
2559
|
}
|
|
2560
|
+
if (remote) {
|
|
2561
|
+
console.log(`
|
|
2562
|
+
\u2500\u2500 shared session \u2500\u2500`);
|
|
2563
|
+
console.log(` anyone with this link can view AND edit the canvas (no account needed):`);
|
|
2564
|
+
console.log(`
|
|
2565
|
+
${remote.shareUrl}
|
|
2566
|
+
`);
|
|
2567
|
+
console.log(` Ctrl-C ends the session and invalidates the link.`);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
async function startShare(workspace, server) {
|
|
2571
|
+
const auth = await readCoopAuth();
|
|
2572
|
+
const localClients = /* @__PURE__ */ new Set();
|
|
2573
|
+
const remote = await RemoteSession.start({
|
|
2574
|
+
...auth,
|
|
2575
|
+
workspace,
|
|
2576
|
+
title: workspace.changeName,
|
|
2577
|
+
hostName: auth.displayName,
|
|
2578
|
+
log: (msg) => console.log(` ${msg}`),
|
|
2579
|
+
onLocalBroadcast: (frame) => {
|
|
2580
|
+
for (const client of localClients) client.send(frame);
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2584
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2585
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
2586
|
+
if (pathname !== "/ws/local") {
|
|
2587
|
+
socket.destroy();
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2591
|
+
localClients.add(ws);
|
|
2592
|
+
ws.on("close", () => localClients.delete(ws));
|
|
2593
|
+
ws.on(
|
|
2594
|
+
"message",
|
|
2595
|
+
(data) => remote.handleLocalFrame(String(data), (reply) => ws.send(reply))
|
|
2596
|
+
);
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
let ending = false;
|
|
2600
|
+
const endAndExit = async () => {
|
|
2601
|
+
if (ending) return;
|
|
2602
|
+
ending = true;
|
|
2603
|
+
console.log("\nspecsketch: ending shared session\u2026");
|
|
2604
|
+
await remote.end();
|
|
2605
|
+
process.exit(0);
|
|
2606
|
+
};
|
|
2607
|
+
process.on("SIGINT", () => void endAndExit());
|
|
2608
|
+
process.on("SIGTERM", () => void endAndExit());
|
|
2609
|
+
return remote;
|
|
532
2610
|
}
|
|
533
2611
|
|
|
534
2612
|
// server/cli-entry.ts
|