diff --git a/.changeset/calm-frogs-store.md b/.changeset/calm-frogs-store.md new file mode 100644 index 0000000..bf7c71f --- /dev/null +++ b/.changeset/calm-frogs-store.md @@ -0,0 +1,19 @@ +--- +'frog': minor +--- + +Added pluggable friction stores, a Postgres.js-backed `Store.postgres({ connectionString })` factory, and the `Frog.create` logging API. + +```ts +import { Frog, Store } from 'frog' + +const store = Store.postgres({ connectionString: process.env.DATABASE_URL! }) +const frog = Frog.create({ store }) +await frog.log({ + body: 'The workaround used.', + severity: 'minor', + title: 'Tool required a workaround', +}) +const logs = await frog.logs() +await store.close() +``` diff --git a/README.md b/README.md index 2ac9c1c..31c3bc5 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,26 @@ Accept friction reported by other repositories with `.agents/friction-log/config `frog init` enables inbound reports by default. Run `frog init --no-inbound` to initialize without accepting inbound reports. +### Store Logs in a Database + +Frog separates friction logging from persistence, so agents and applications can record and inspect +logs through one API while choosing where they live. Repository files are the default, PostgreSQL is +supported today, and SQLite and Cloudflare D1 are planned. + +```sh +export FROG_DATABASE_URL=postgres://... +frog migrate +frog list +``` + +#### Database Support + +| Database | Status | Factory | +| ------------- | ----------- | -------------------------------------- | +| PostgreSQL | Available | `Store.postgres({ connectionString })` | +| SQLite | Coming soon | — | +| Cloudflare D1 | Coming soon | — | + ## CLI Reference ``` @@ -224,7 +244,9 @@ Commands: init Create the friction log, config, and issue form. list List entries with their state. log Write a friction entry. + migrate Create or upgrade the selected store. publish Report pending entries as GitHub issues. + resolve Remove one resolved friction entry. sync Reconcile entries against issue state. targets List dependencies that accept friction reports. diff --git a/package.json b/package.json index 6478cba..657fead 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@changesets/cli": "catalog:", + "@testcontainers/postgresql": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", "tsx": "catalog:", @@ -62,6 +63,7 @@ "@clack/prompts": "catalog:", "@octokit/rest": "catalog:", "incur": "catalog:", + "postgres": "catalog:", "yaml": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b982591..897785c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 + '@testcontainers/postgresql': + specifier: ^12.0.4 + version: 12.0.4 '@types/node': specifier: latest version: 26.1.1 @@ -30,6 +33,9 @@ catalogs: octokit: specifier: ^5.0.5 version: 5.0.5 + postgres: + specifier: ^3.4.9 + version: 3.4.9 tsx: specifier: ^4.23.1 version: 4.23.1 @@ -62,6 +68,9 @@ importers: incur: specifier: 'catalog:' version: 0.4.25 + postgres: + specifier: 'catalog:' + version: 3.4.9 yaml: specifier: 'catalog:' version: 2.9.0 @@ -69,6 +78,9 @@ importers: '@changesets/cli': specifier: 'catalog:' version: 2.31.1(@types/node@26.1.1) + '@testcontainers/postgresql': + specifier: 'catalog:' + version: 12.0.4(supports-color@10.2.2) '@types/node': specifier: 'catalog:' version: 26.1.1 @@ -140,6 +152,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -428,6 +443,20 @@ packages: cpu: [x64] os: [win32] + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -599,6 +628,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -612,6 +645,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1167,6 +1206,10 @@ packages: resolution: {integrity: sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1179,6 +1222,33 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1291,6 +1361,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testcontainers/postgresql@12.0.4': + resolution: {integrity: sha512-a/pLU6j5lpKKAlUTPwqweqMGhOSjgTSb6HBX69TOrXn32ifU37nnQDmNFTj8ddOAw+BQL9oTRkeOxVbZkqhgZA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1319,15 +1392,33 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1730,6 +1821,10 @@ packages: '@yuku-toolchain/types@0.5.43': resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1738,10 +1833,30 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1755,6 +1870,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1762,6 +1880,66 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.6: + resolution: {integrity: sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -1769,16 +1947,40 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1790,6 +1992,24 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -1801,10 +2021,35 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1821,9 +2066,33 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + docker-compose@1.4.2: + resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@5.0.1: + resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} + engines: {node: '>= 14.17'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1839,6 +2108,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1847,6 +2120,17 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1854,6 +2138,9 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1887,6 +2174,13 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1900,10 +2194,23 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -1926,6 +2233,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1935,10 +2245,17 @@ packages: engines: {node: '>=22'} hasBin: true + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1947,6 +2264,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1955,6 +2276,9 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1970,6 +2294,9 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1994,6 +2321,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -2072,9 +2403,21 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2102,6 +2445,26 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2110,11 +2473,21 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -2123,6 +2496,9 @@ packages: resolution: {integrity: sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==} engines: {node: '>= 20'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2186,6 +2562,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} @@ -2197,6 +2576,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -2230,6 +2613,10 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -2239,6 +2626,27 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -2252,10 +2660,32 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2268,6 +2698,12 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2291,6 +2727,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2313,19 +2752,50 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2338,10 +2808,32 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + testcontainers@12.0.4: + resolution: {integrity: sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2361,6 +2853,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2395,11 +2891,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2407,6 +2909,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -2420,6 +2926,9 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plus@0.2.6: resolution: {integrity: sha512-fFX8GLENhtzvnE4NmTPC8INRxjD2kZKcqUW0p7jOdawmHTNZqM5FiieyWOG6WH1a/axu9FCsbUNb918grfzDTw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -2542,6 +3051,17 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -2566,11 +3086,23 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -2595,6 +3127,10 @@ packages: typescript: optional: true + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -2621,6 +3157,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} '@blazediff/core@1.9.1': {} @@ -2905,6 +3443,25 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.2': @@ -3018,6 +3575,15 @@ snapshots: optionalDependencies: '@types/node': 26.1.1 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3032,6 +3598,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@kwsites/file-exists@1.1.1(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -3427,6 +4001,9 @@ snapshots: '@oxlint/plugins@1.73.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -3441,6 +4018,26 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -3500,6 +4097,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testcontainers/postgresql@12.0.4(supports-color@10.2.2)': + dependencies: + testcontainers: 12.0.4(supports-color@10.2.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -3533,14 +4139,42 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 26.1.1 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 26.1.1 + '@types/ssh2': 1.15.5 + '@types/estree@1.0.9': {} '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 26.1.1 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 26.1.1 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3795,12 +4429,48 @@ snapshots: '@yuku-toolchain/types@0.5.43': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -3813,6 +4483,10 @@ snapshots: array-union@2.1.0: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -3821,38 +4495,151 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-lock@1.4.1: {} + + async@3.2.6: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.6 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.6: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} bottleneck@2.19.5: {} + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-crc32@1.0.0: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + byline@5.0.0: {} + cac@6.7.14: {} chai@6.2.2: {} chardet@2.2.0: {} + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + content-type@2.0.0: {} convert-source-map@2.0.0: {} cookie@1.1.1: {} + core-util-is@1.0.3: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -3863,8 +4650,42 @@ snapshots: dependencies: path-type: 4.0.0 + docker-compose@1.4.2: + dependencies: + yaml: 2.9.0 + + docker-modem@5.0.7(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@5.0.1(supports-color@10.2.2): + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7(supports-color@10.2.2) + protobufjs: 7.6.5 + tar-fs: 2.1.5 + transitivePeerDependencies: + - supports-color + dom-accessibility-api@0.5.16: {} + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3903,16 +4724,30 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + expect-type@1.4.0: {} extendable-error@0.1.7: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3948,6 +4783,13 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs-constants@1.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3963,10 +4805,23 @@ snapshots: fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + + get-port@5.1.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -3988,6 +4843,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} incur@0.4.25: @@ -4000,20 +4857,28 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + inherits@2.0.4: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-number@7.0.0: {} + is-stream@2.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -4029,6 +4894,12 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -4050,6 +4921,10 @@ snapshots: kleur@4.1.5: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lightningcss-android-arm64@1.33.0: optional: true @@ -4103,8 +4978,16 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.camelcase@4.3.0: {} + lodash.startcase@4.4.0: {} + lodash@4.18.1: {} + + long@5.3.2: {} + + lru-cache@10.4.3: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -4140,12 +5023,33 @@ snapshots: - bufferutil - utf-8-validate + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@3.0.1: {} + mri@1.2.0: {} mrmime@2.0.1: {} + ms@2.1.3: {} + + nan@2.28.0: + optional: true + nanoid@3.3.16: {} + normalize-path@3.0.0: {} + obug@2.1.4: {} octokit@5.0.5: @@ -4162,6 +5066,10 @@ snapshots: '@octokit/types': 16.0.0 '@octokit/webhooks': 14.2.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + outdent@0.5.0: {} oxfmt@0.51.0: @@ -4262,6 +5170,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 @@ -4270,6 +5180,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -4292,6 +5207,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + prettier@2.8.8: {} pretty-format@27.5.1: @@ -4300,6 +5217,42 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@3.0.1(supports-color@10.2.2): + dependencies: + '@kwsites/file-exists': 1.1.1(supports-color@10.2.2) + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.1.1 + long: 5.3.2 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -4313,8 +5266,40 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + require-directory@2.1.1: {} + resolve-from@5.0.0: {} + retry@0.12.0: {} + reusify@1.1.0: {} rolldown@1.1.5: @@ -4342,6 +5327,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -4386,6 +5375,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sirv@3.0.2: @@ -4405,16 +5396,64 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split-ca@1.0.1: {} + sprintf-js@1.0.3: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + stackback@0.0.2: {} std-env@4.2.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} supports-color@10.2.2: {} @@ -4423,8 +5462,82 @@ snapshots: dependencies: has-flag: 4.0.0 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.4 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + term-size@2.2.1: {} + testcontainers@12.0.4(supports-color@10.2.2): + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.1 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3(supports-color@10.2.2) + docker-compose: 1.4.2 + dockerode: 5.0.1(supports-color@10.2.2) + get-port: 5.1.1 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1(supports-color@10.2.2) + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.3 + tmp: 0.2.7 + undici: 8.9.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -4438,6 +5551,8 @@ snapshots: tinyrainbow@3.1.0: {} + tmp@0.2.7: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4461,6 +5576,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -4484,10 +5601,14 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@5.26.5: {} + undici-types@8.3.0: {} undici@7.28.0: {} + undici@8.9.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -4498,6 +5619,8 @@ snapshots: universalify@0.1.2: {} + util-deprecate@1.0.2: {} + vite-plus@0.2.6(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(tsx@4.23.1)(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.141.0 @@ -4633,12 +5756,40 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + ws@8.21.0: {} ws@8.21.1: {} + y18n@5.0.8: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -4695,4 +5846,10 @@ snapshots: transitivePeerDependencies: - svelte + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ad214d..0781004 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,10 +7,12 @@ catalog: '@cloudflare/workers-types': ^4.20260701.0 '@clack/prompts': ^1.7.0 '@octokit/rest': ^22.0.1 + '@testcontainers/postgresql': ^12.0.4 '@types/node': latest '@vitest/coverage-v8': latest incur: 0.4.25 octokit: ^5.0.5 + postgres: ^3.4.9 tsx: ^4.23.1 typescript: latest vite-plus: latest @@ -19,7 +21,10 @@ catalog: zile: latest allowBuilds: + cpu-features: false esbuild: true + protobufjs: false + ssh2: false workerd: true minimumReleaseAgeExclude: diff --git a/src/Entry.test-d.ts b/src/Entry.test-d.ts new file mode 100644 index 0000000..cead77e --- /dev/null +++ b/src/Entry.test-d.ts @@ -0,0 +1,14 @@ +import * as Entry from './Entry.js' + +const context = { + attempts: 2, + flags: [true, false, null], + source: { kind: 'agent' }, +} as const satisfies Entry.Context + +expectTypeOf(context).toMatchTypeOf() + +// @ts-expect-error Dates do not preserve their value through entry serialization. +const invalidContext: Entry.Context = { collectedAt: new Date() } + +expectTypeOf(invalidContext).toEqualTypeOf() diff --git a/src/Entry.test.ts b/src/Entry.test.ts index b3768f0..48cd15f 100644 --- a/src/Entry.test.ts +++ b/src/Entry.test.ts @@ -87,6 +87,16 @@ The filter was swallowed. `[Entry.InvalidError: Entry \`lazy-squids-chew\` has invalid frontmatter. issue: Invalid string: must match pattern /^[\\w.-]+\\/[\\w.-]+#\\d+$/]`, ) }) + + test('error: rejects YAML aliases before validating recursive context', () => { + expect(() => + Entry.parse("---\ntitle: 'Slow'\ncontext: &context\n self: *context\n---\n\nBody.\n", { + id, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Entry.InvalidError: Entry \`lazy-squids-chew\` has invalid frontmatter. frontmatter: YAML aliases are not supported.]`, + ) + }) }) describe('serialize', () => { @@ -139,6 +149,20 @@ describe('serialize', () => { Entry.serialize({ body: 'Body.', labels: [], severity: 'minor', title: 'Slow' }), ).not.toContain('labels') }) + + test.each([new Date(), new Map(), undefined, 1n])( + 'error: rejects context values that cannot round trip: %o', + (value) => { + expect(() => + Entry.serialize({ + body: 'Body.', + context: { value } as unknown as Entry.Context, + severity: 'minor', + title: 'Slow', + }), + ).toThrow() + }, + ) }) describe('round trip', () => { @@ -150,6 +174,22 @@ describe('round trip', () => { { body: 'Body.', severity: 'minor', title: '@scope/pkg: 100% broken #1 @ 3:00' }, { body: 'Body.', severity: 'minor', title: 'no: yes, true, null, ~, 0x1' }, { body: 'Body.', severity: 'minor', title: 'emoji 🎉 and — dashes' }, + { + body: 'Body.', + context: {}, + severity: 'minor', + title: 'empty context', + }, + { + body: 'Body.', + context: { + attempts: 2, + flags: [true, false, null], + source: { kind: 'agent', version: '1' }, + }, + severity: 'minor', + title: 'structured context', + }, { body: '## Description\n\nMulti\n\nline\n\n```ts\nconst a = 1\n```', issue: 'wevm/viem#4821', diff --git a/src/Entry.ts b/src/Entry.ts index b891179..f8adcaf 100644 --- a/src/Entry.ts +++ b/src/Entry.ts @@ -14,8 +14,37 @@ export type Severity = (typeof severities)[number] /** Schema for {@link Severity}. */ export const Severity = z.enum(severities) +/** Recursive value that Frog can serialize and restore without changing its meaning. */ +export type ContextValue = + | boolean + | null + | number + | string + | readonly ContextValue[] + | { readonly [key: string]: ContextValue } + +/** Schema for {@link ContextValue}. */ +export const ContextValue: z.ZodType = z.lazy(() => + z.union([ + z.boolean(), + z.null(), + z.number().finite(), + z.string(), + z.array(ContextValue), + z.record(z.string(), ContextValue), + ]), +) + +/** Consumer-defined structured context stored without interpretation. */ +export type Context = Readonly> + +/** Schema for {@link Context}. */ +export const Context: z.ZodType = z.record(z.string(), ContextValue) + /** Frontmatter of an entry's write-up. */ export type Frontmatter = { + /** Consumer-defined structured context. Frog stores it but does not interpret it. */ + context?: Context | undefined /** Linked issue as `owner/name#number`. Written by publishing, absent while pending. */ issue?: string | undefined /** Extra issue labels, applied on top of the configured and severity labels. */ @@ -39,6 +68,7 @@ export type Frontmatter = { * annotation stops the hand-written type and the schema drifting. */ export const Frontmatter: z.ZodType = z.object({ + context: Context.optional(), issue: z .string() .regex(/^[\w.-]+\/[\w.-]+#\d+$/) @@ -65,6 +95,8 @@ export type Entry = Frontmatter & { */ const frontmatterRegex = /\s*---([^]*?)\n\s*---(\s*(?:\n|$)[^]*)/ +const NoYamlAliases = z.custom(() => false, 'YAML aliases are not supported.') + /** * Parses an entry's write-up. * @@ -85,14 +117,31 @@ export function parse(contents: string, options: parse.Options): Entry { if (!match) throw new MalformedError({ id }) const [, frontmatter = '', body = ''] = match - const data = (() => { + const document = (() => { try { - return YAML.parse(frontmatter) + const parsed = YAML.parseDocument(frontmatter) + const error = parsed.errors[0] + if (error) throw error + return parsed } catch (error) { throw new MalformedError({ cause: error as Error, id }) } })() + let hasAlias = false + YAML.visit(document, { + Alias() { + hasAlias = true + return YAML.visit.BREAK + }, + }) + if (hasAlias) { + const result = NoYamlAliases.safeParse(undefined) + if (!result.success) throw new InvalidError({ id, issues: result.error.issues }) + } + + const data = document.toJS({ maxAliasCount: 0 }) + const result = Frontmatter.safeParse(data) if (!result.success) throw new InvalidError({ id, issues: result.error.issues }) @@ -121,11 +170,13 @@ export declare namespace parse { * @returns File contents, ready to write. Absent optional fields are omitted, not written empty. */ export function serialize(entry: serialize.Options): string { - const { body, issue, labels, severity, target, title } = entry + const { body, context, issue, labels, severity, target, title } = entry + const normalizedContext = context === undefined ? undefined : Context.parse(context) const frontmatter = YAML.stringify( { title, severity, + ...(normalizedContext === undefined ? {} : { context: normalizedContext }), ...(target ? { target } : {}), ...(labels?.length ? { labels } : {}), ...(issue ? { issue } : {}), diff --git a/src/Frog.test-d.ts b/src/Frog.test-d.ts new file mode 100644 index 0000000..f5ffea8 --- /dev/null +++ b/src/Frog.test-d.ts @@ -0,0 +1,10 @@ +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +declare const store: Store.Store + +const frog = Frog.create({ store }) + +expectTypeOf(frog).toEqualTypeOf() +expectTypeOf(frog.log).returns.resolves.toEqualTypeOf() +expectTypeOf(frog.logs).returns.resolves.toEqualTypeOf() diff --git a/src/Frog.test.ts b/src/Frog.test.ts new file mode 100644 index 0000000..8f7f3fa --- /dev/null +++ b/src/Frog.test.ts @@ -0,0 +1,57 @@ +import { tmpdir } from '../test/helpers.js' +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +const entry = { + body: 'It took an unnecessary workaround.', + severity: 'minor', + title: 'Filters ignored', +} as const + +describe('create', () => { + test('behavior: returns a plain logger around the supplied store', async () => { + const store = Store.file({ root: await tmpdir() }) + const frog = Frog.create({ store }) + const result = await frog.log(entry) + + expect(Object.getPrototypeOf(frog)).toBe(Object.prototype) + expect(result).toMatchObject({ created: true, occurrences: 1 }) + expect(frog.store).toBe(store) + expect(await frog.logs()).toEqual([{ entry: result.entry, occurrences: 1 }]) + }) + + test('behavior: deduplicates normalized titles for stores without atomic logging', async () => { + const frog = Frog.create({ store: Store.file({ root: await tmpdir() }) }) + const first = await frog.log(entry) + const repeated = await frog.log({ ...entry, title: 'filters: ignored!' }) + + expect(repeated).toEqual({ + created: false, + entry: first.entry, + location: first.location, + occurrences: 1, + }) + expect(await frog.logs()).toHaveLength(1) + }) + + test('behavior: delegates atomic logging to a store that provides it', async () => { + const log = vi.fn(async () => ({ + created: false, + entry: { ...entry, id: 'existing' }, + location: 'custom:existing', + occurrences: 4, + })) + const store = Store.from({ + name: 'custom', + log, + read: vi.fn(), + get: vi.fn(), + write: vi.fn(), + remove: vi.fn(), + }) + const frog = Frog.create({ store }) + + await expect(frog.log(entry)).resolves.toMatchObject({ created: false, occurrences: 4 }) + expect(log).toHaveBeenCalledWith(entry, {}) + }) +}) diff --git a/src/Frog.ts b/src/Frog.ts new file mode 100644 index 0000000..456a011 --- /dev/null +++ b/src/Frog.ts @@ -0,0 +1,78 @@ +import * as Entry from './Entry.js' +import * as Store from './Store.js' + +/** A storage-independent friction logger. */ +export type Frog = { + /** Store used for every operation. */ + readonly store: Store.Store + /** Logs one friction occurrence. */ + readonly log: ( + entry: Entry.serialize.Options, + options?: Store.LogOptions, + ) => Promise + /** Lists canonical friction entries with their occurrence counts. */ + readonly logs: () => Promise +} + +/** Creates a friction logger that prepares its store before the first operation. */ +export function create(options: create.Options): Frog { + const store = options.store + let migrated: Promise | undefined + const migrate = () => { + if (migrated) return migrated + const current = Promise.resolve().then(() => store.migrate()) + migrated = current.catch((error) => { + migrated = undefined + throw error + }) + return migrated + } + return { + store, + async log(entry, logOptions = {}) { + await migrate() + return log(store, entry, logOptions) + }, + async logs() { + await migrate() + return store.records() + }, + } +} + +export declare namespace create { + /** Options for {@link create}. */ + type Options = { + /** Store used for every operation. */ + store: Store.Store + } +} + +async function log( + store: Store.Store, + entry: Entry.serialize.Options, + options: Store.LogOptions, +): Promise { + if (store.log) return store.log(entry, options) + + if (!options.force) { + const duplicate = (await store.read()).find( + (candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title), + ) + if (duplicate) + return { + created: false, + entry: duplicate, + location: store.location(duplicate.id), + occurrences: 1, + } + } + + const written = await store.write(entry) + return { + created: true, + entry: await store.get(written.id), + location: written.location, + occurrences: 1, + } +} diff --git a/src/Store.postgres.test.ts b/src/Store.postgres.test.ts new file mode 100644 index 0000000..1d35dee --- /dev/null +++ b/src/Store.postgres.test.ts @@ -0,0 +1,166 @@ +import { storeContract } from '../test/storeContract.js' +import * as Postgres from '../test/postgres.js' +import * as Entry from './Entry.js' +import * as Frog from './Frog.js' +import * as Store from './Store.js' + +const friction = { + body: 'The tool required an unnecessary workaround.', + context: { source: 'production-agent', trace: 'opaque-reference' }, + severity: 'major', + title: 'Tool result omitted its state', +} as const + +const postgres = Postgres.get() + +describe('postgres', () => { + test('behavior: Frog prepares storage before its first operation', async () => { + const logStore = postgres.create({ schema: 'frog_create_log' }) + const logsStore = postgres.create({ schema: 'frog_create_logs' }) + + await expect(Frog.create({ store: logStore }).log(friction)).resolves.toMatchObject({ + created: true, + }) + await expect(Frog.create({ store: logsStore }).logs()).resolves.toEqual([]) + }) + + test('behavior: migration creates the configured schema and is idempotent', async () => { + const client = postgres.client() + const store = postgres.create({ namespace: 'consumer-a', schema: 'frog' }) + const table = async () => + client<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'frog' AND table_name = 'frog_entries' + ` + + await expect(table()).resolves.toEqual([]) + await store.migrate() + await store.migrate() + + await expect(table()).resolves.toEqual([{ table_name: 'frog_entries' }]) + }) + + test('behavior: an omitted schema follows the client search path', async () => { + const client = postgres.client() + const store = postgres.create({ namespace: 'search-path' }) + const frog = Frog.create({ store }) + + await store.migrate() + await frog.log(friction) + + const result = await client<{ table_schema: string }[]>` + SELECT table_schema + FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = 'frog_entries' + ` + expect(result).toEqual([{ table_schema: 'public' }]) + }) + + test('behavior: logs, deduplicates, updates, lists, and removes', async () => { + const store = await postgres.store() + const frog = Frog.create({ store }) + + const first = await frog.log(friction) + const repeated = await frog.log({ ...friction, body: 'A later occurrence.' }) + expect(first).toMatchObject({ created: true, occurrences: 1 }) + expect(repeated).toMatchObject({ created: false, occurrences: 2, entry: first.entry }) + expect(first.entry.id).toMatch(/^\d{14}-tool-result-omitted-[0-9a-f]{8}$/) + expect(await frog.logs()).toEqual([{ entry: first.entry, occurrences: 2 }]) + + await store.write({ ...friction, issue: 'wevm/frog#123' }, { id: first.entry.id }) + expect((await store.get(first.entry.id)).issue).toBe('wevm/frog#123') + await expect(store.remove(first.entry.id)).resolves.toBe(true) + await expect(store.remove(first.entry.id)).resolves.toBe(false) + await expect(store.get(first.entry.id)).rejects.toMatchObject({ + code: 'ENTRY_NOT_FOUND', + name: 'Store.NotFoundError', + }) + }) + + test('behavior: updating a logged title moves its deduplication identity', async () => { + const store = await postgres.store() + const frog = Frog.create({ store }) + const first = await frog.log(friction) + + await store.write({ ...friction, title: 'Tool state was omitted' }, { id: first.entry.id }) + const repeated = await frog.log({ ...friction, title: 'tool state was omitted!' }) + + expect(repeated).toMatchObject({ created: false, occurrences: 2 }) + expect(repeated.entry.id).toBe(first.entry.id) + }) + + test('behavior: colliding title edits preserve independent entry identity', async () => { + const store = await postgres.store() + const frog = Frog.create({ store }) + const first = await frog.log(friction) + const second = await frog.log({ ...friction, title: 'Different friction' }) + + await store.write({ ...friction, title: 'Different friction' }, { id: first.entry.id }) + const repeated = await frog.log({ ...friction, title: 'different friction!' }) + + expect(await store.get(first.entry.id)).toMatchObject({ title: 'Different friction' }) + expect(repeated).toMatchObject({ created: false, occurrences: 2 }) + expect(repeated.entry.id).toBe(second.entry.id) + }) + + test('behavior: namespaces isolate consumers and force preserves intentional duplicates', async () => { + const first = Frog.create({ store: postgres.create({ namespace: 'one' }) }) + const second = Frog.create({ store: postgres.create({ namespace: 'two' }) }) + + await first.store.migrate() + await first.log(friction) + await first.log(friction, { force: true }) + await second.log(friction) + expect(await first.logs()).toHaveLength(2) + expect(await second.logs()).toHaveLength(1) + }) + + test('behavior: namespace defaults to default', async () => { + const store = Store.postgres({ connectionString: postgres.connectionString() }) + onTestFinished(() => store.close()) + await store.migrate() + const written = await Frog.create({ store }).log(friction) + + expect(written.location).toMatch(/^postgres:default\//) + }) + + test('behavior: closing the owned pool is idempotent', async () => { + const store = postgres.create() + await store.migrate() + + await expect(store.close()).resolves.toBeUndefined() + await expect(store.close()).resolves.toBeUndefined() + }) + + test('error: rejects an empty connection string', () => { + expect(() => Store.postgres({ connectionString: ' ' })).toThrow( + 'Postgres connectionString is required.', + ) + }) + + test('error: rejects unsafe schema names before issuing SQL', () => { + expect(() => + Store.postgres({ + connectionString: postgres.connectionString(), + schema: 'public; DROP TABLE users', + }), + ).toThrow('Postgres schema must be a SQL identifier.') + }) + + test('error: rejects schema names PostgreSQL would truncate', () => { + expect(() => + Store.postgres({ + connectionString: postgres.connectionString(), + schema: `s${'a'.repeat(63)}`, + }), + ).toThrow('Postgres schema must be at most 63 bytes.') + }) + + test('behavior: consumer context round trips without Frog interpreting it', () => { + const serialized = Entry.serialize(friction) + expect(Entry.parse(serialized, { id: 'one' }).context).toEqual(friction.context) + }) +}) + +storeContract('Postgres', () => postgres.store()) diff --git a/src/Store.test-d.ts b/src/Store.test-d.ts new file mode 100644 index 0000000..def5b7f --- /dev/null +++ b/src/Store.test-d.ts @@ -0,0 +1,18 @@ +import * as Store from './Store.js' + +declare const value: Store.from.Value + +expectTypeOf(Store.from(value)).toEqualTypeOf() +expectTypeOf(Store.file({ root: '/repo' })).toEqualTypeOf() +expectTypeOf( + Store.postgres({ connectionString: 'postgres://localhost/frog' }), +).toEqualTypeOf() +expectTypeOf( + Store.postgres({ + connectionString: 'postgres://localhost/frog', + namespace: 'agent', + schema: 'frog', + }), +).toEqualTypeOf() +// @ts-expect-error Postgres requires a connection string. +Store.postgres({ namespace: 'agent' }) diff --git a/src/Store.test.ts b/src/Store.test.ts index acb623c..bd80bdf 100644 --- a/src/Store.test.ts +++ b/src/Store.test.ts @@ -1,15 +1,50 @@ import fs from 'node:fs/promises' import path from 'node:path' import { tmpdir, writeFile } from '../test/helpers.js' +import { storeContract } from '../test/storeContract.js' +import type * as Entry from './Entry.js' import * as Store from './Store.js' const entry = "---\ntitle: 'Filters are ignored'\n---\n\nBody.\n" +storeContract('file', async () => Store.file({ root: await tmpdir() })) + /** Writes an entry's write-up, creating its directory. */ function write(id: string, root: string, contents = entry) { return writeFile(Store.toPath(id), contents, root) } +test('behavior: from derives optional operations for a custom store', async () => { + const entries = new Map() + const store = Store.from({ + name: 'memory', + read: async () => [...entries.values()], + get: async (id) => entries.get(id)!, + write: async (value, options = {}) => { + const id = options.id ?? 'memory-id' + entries.set(id, { ...value, id }) + return { id, location: `memory:${id}` } + }, + remove: async (id) => entries.delete(id), + }) + const value = { body: 'Body.', severity: 'minor', title: 'Scoped' } as const + + expect(store.tracksOccurrences).toBe(false) + expect(store.location('memory-id')).toBe('memory-id') + await expect(store.migrate()).resolves.toBeUndefined() + await expect(store.close()).resolves.toBeUndefined() + await expect(store.write(value)).resolves.toEqual({ + id: 'memory-id', + location: 'memory:memory-id', + }) + await expect(store.list()).resolves.toEqual(['memory-id']) + await expect(store.records()).resolves.toEqual([ + { entry: { ...value, id: 'memory-id' }, occurrences: 1 }, + ]) + await expect(store.files('memory-id')).resolves.toEqual([]) + await expect(store.remove('memory-id')).resolves.toBe(true) +}) + describe('write', () => { test('behavior: mints an id and returns the repo-relative path', async () => { const root = await tmpdir() @@ -93,7 +128,8 @@ describe('write', () => { describe('list', () => { test('behavior: returns sorted ids and skips non-entries', async () => { const root = await tmpdir() - for (const id of ['apple', 'zebra', 'middle', '.hidden']) await write(id, root) + for (const id of ['apple', 'zebra', 'middle']) await write(id, root) + await writeFile(`${Store.dir}/.hidden/${Store.filename}`, entry, root) for (const name of ['README.md', 'TEMPLATE.md', 'config.json', 'notes.txt']) await writeFile(`${Store.dir}/${name}`, entry, root) @@ -114,6 +150,15 @@ describe('list', () => { expect(await Store.list({ root })).toEqual(['real']) }) + test('behavior: unsafe directory names are ignored', async () => { + const root = await tmpdir() + await write('real', root) + for (const id of ['trailing.', 'trailing ', 'nested\\alias']) + await writeFile(`${Store.dir}/${id}/${Store.filename}`, entry, root) + + expect(await Store.list({ root })).toEqual(['real']) + }) + test('behavior: a missing directory is not an error', async () => { expect(await Store.list({ root: await tmpdir() })).toEqual([]) }) @@ -179,6 +224,31 @@ describe('toDir', () => { test('behavior: builds a repo-relative directory', () => { expect(Store.toDir('lazy-squids-chew')).toBe('.agents/friction-log/lazy-squids-chew') }) + + test('error: rejects path traversal', () => { + expect(() => Store.toDir('..')).toThrowErrorMatchingInlineSnapshot( + `[Store.InvalidIdError: Friction entry id \`..\` is not path-safe.]`, + ) + expect(() => Store.toDir('../outside')).toThrow() + expect(() => Store.toDir('nested\\outside')).toThrow() + }) +}) + +describe('isId', () => { + test.each([ + ['entry', true], + ['entry-2', true], + ['', false], + ['.hidden', false], + ['..', false], + ['entry.', false], + ['entry ', false], + ['../outside', false], + ['nested/outside', false], + ['nested\\outside', false], + ])('behavior: %s', (id, expected) => { + expect(Store.isId(id)).toBe(expected) + }) }) describe('toPath', () => { diff --git a/src/Store.ts b/src/Store.ts index 82810ea..fa566f3 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -1,5 +1,7 @@ +import { randomUUID } from 'node:crypto' import fs from 'node:fs/promises' import path from 'node:path' +import postgresjs from 'postgres' import * as Entry from './Entry.js' /** Directory holding entries, relative to the repository root. */ @@ -17,6 +19,173 @@ export type Options = { root: string } +/** Options for writing through a store. */ +export type WriteOptions = { + /** Existing entry id to replace. */ + id?: string | undefined +} + +/** Result of writing through a store. */ +export type WriteResult = { + /** Stable entry id. */ + id: string + /** Store-defined location suitable for diagnostics. */ + location: string +} + +/** One canonical entry and optional storage metadata. */ +export type StoredEntry = { + /** Canonical entry payload shared by every store. */ + entry: Entry.Entry + /** Number of observations when the store tracks recurrence. */ + occurrences: number +} + +/** Result of logging one friction occurrence. */ +export type LogResult = StoredEntry & { + /** Whether this call created a new entry. */ + created: boolean + /** Store-defined location suitable for diagnostics. */ + location: string +} + +/** Storage operations consumed by Frog. */ +export type Store = { + /** Stable store name for diagnostics and capability checks. */ + readonly name: string + /** Repository root when the store is bound to local files. */ + readonly root?: string | undefined + /** Whether the store preserves occurrence counts beyond the canonical entry. */ + readonly tracksOccurrences: boolean + /** Prepares store-owned storage. Safe to call repeatedly. */ + readonly migrate: () => Promise + /** Releases store-owned resources. Safe to call repeatedly. */ + readonly close: () => Promise + /** Lists every entry in stable id order. */ + readonly read: () => Promise + /** Lists entries with recurrence metadata. */ + readonly records: () => Promise + /** Lists entry ids in stable order. */ + readonly list: () => Promise + /** Reads one entry. */ + readonly get: (id: string) => Promise + /** Writes an entry, optionally replacing a known id. Every canonical entry field must round trip. */ + readonly write: (entry: Entry.serialize.Options, options?: WriteOptions) => Promise + /** Removes an entry and reports whether it existed. */ + readonly remove: (id: string) => Promise + /** Lists store-owned artifact locations. */ + readonly files: (id: string) => Promise + /** Returns the store-defined location for one entry. */ + readonly location: (id: string) => string + /** Atomically logs and deduplicates an occurrence when the store supports it. */ + readonly log?: + | ((entry: Entry.serialize.Options, options?: LogOptions) => Promise) + | undefined +} + +/** Options for logging one friction occurrence. */ +export type LogOptions = { + /** Preserve an intentional duplicate instead of deduplicating it. */ + force?: boolean | undefined +} + +/** Normalizes a custom store by supplying safe defaults for optional capabilities. */ +export function from(value: from.Value): Store { + return { + name: value.name, + ...(value.root !== undefined ? { root: value.root } : {}), + tracksOccurrences: value.tracksOccurrences ?? value.records !== undefined, + migrate: value.migrate ?? (async () => {}), + close: value.close ?? (async () => {}), + read: value.read, + records: + value.records ?? + (async () => (await value.read()).map((entry) => ({ entry, occurrences: 1 }))), + list: value.list ?? (async () => (await value.read()).map((entry) => entry.id).sort()), + get: value.get, + write: value.write, + remove: value.remove, + files: value.files ?? (async () => []), + location: value.location ?? ((id) => id), + ...(value.log ? { log: value.log } : {}), + } +} + +export declare namespace from { + /** Minimum operations required to adapt a store to Frog. */ + type Value = { + /** Stable store name for diagnostics and capability checks. */ + readonly name: string + /** Repository root when the store is bound to local files. */ + readonly root?: string | undefined + /** Whether the store preserves occurrence counts beyond the canonical entry. */ + readonly tracksOccurrences?: boolean | undefined + /** Lists every entry in stable id order. */ + readonly read: () => Promise + /** Reads one entry. */ + readonly get: (id: string) => Promise + /** Writes an entry, optionally replacing a known id. */ + readonly write: (entry: Entry.serialize.Options, options?: WriteOptions) => Promise + /** Removes an entry and reports whether it existed. */ + readonly remove: (id: string) => Promise + /** Prepares store-owned storage. Safe to call repeatedly. */ + readonly migrate?: (() => Promise) | undefined + /** Releases store-owned resources. Safe to call repeatedly. */ + readonly close?: (() => Promise) | undefined + /** Lists entries with recurrence metadata. Derived from `read` by default. */ + readonly records?: (() => Promise) | undefined + /** Lists entry ids in stable order. Derived from `read` by default. */ + readonly list?: (() => Promise) | undefined + /** Lists store-owned artifact locations. Empty by default. */ + readonly files?: ((id: string) => Promise) | undefined + /** Returns the store-defined location for one entry. Defaults to its id. */ + readonly location?: ((id: string) => string) | undefined + /** Atomically logs and deduplicates an occurrence when the store supports it. */ + readonly log?: + | ((entry: Entry.serialize.Options, options?: LogOptions) => Promise) + | undefined + } +} + +/** Binds the repository-file store to one root. */ +export function file(options: file.Options): Store { + const root = path.resolve(options.root) + return from({ + name: 'file', + root, + read: () => read({ root }), + list: () => list({ root }), + get: (id) => get(id, { root }), + write: async (entry, writeOptions = {}) => { + const written = await write(entry, { ...writeOptions, root }) + return { id: written.id, location: written.file } + }, + remove: (id) => remove(id, { root }), + files: (id) => files(id, { root }), + location: toPath, + }) +} + +export declare namespace file { + /** Options for {@link file}. */ + type Options = { + /** Repository root. Entries live in `/.agents/friction-log`. */ + root: string + } +} + +/** Whether a value is a safe, visible entry-directory name. */ +export function isId(id: string): boolean { + return ( + id.length > 0 && + !id.startsWith('.') && + !/[. ]$/.test(id) && + !id.includes('/') && + !id.includes('\\') && + !id.includes('\0') + ) +} + /** * Directory holding an entry and anything needed to reproduce it. * @@ -27,6 +196,7 @@ export type Options = { * @returns The repository-relative directory. */ export function toDir(id: string): string { + if (!isId(id)) throw invalidIdError(id) return `${dir}/${id}` } @@ -61,7 +231,7 @@ export function toId(file: string): string | undefined { if (!file.startsWith(`${dir}/`) || !file.endsWith(`/${filename}`)) return undefined const id = file.slice(dir.length + 1, -(filename.length + 1)) - if (!id || id.includes('/') || id.startsWith('.')) return undefined + if (!isId(id)) return undefined return id } @@ -75,6 +245,11 @@ export async function read(options: Options): Promise { return Promise.all(ids.map((id) => get(id, options))) } +/** Lists file-store entries with their single-observation metadata. */ +export async function records(options: Options): Promise { + return (await read(options)).map((entry) => ({ entry, occurrences: 1 })) +} + /** * Lists the ids of every entry, sorted. * @@ -91,7 +266,7 @@ export async function list(options: Options): Promise { }) const ids = found - .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .filter((entry) => entry.isDirectory() && isId(entry.name)) .map((entry) => entry.name) .sort() @@ -214,3 +389,184 @@ export async function remove(id: string, options: Options): Promise { await fs.rm(base, { force: true, recursive: true }) return true } + +type PostgresRow = { + contents: string + created?: boolean | undefined + id: string + occurrence_count: number | string +} + +/** Creates a Postgres-backed store from a connection string. */ +export function postgres(options: postgres.Options): Store { + const connectionString = required(options.connectionString, 'connectionString') + const namespace = required(options.namespace ?? 'default', 'namespace') + const schema = options.schema === undefined ? undefined : schemaName(options.schema) + const sql = postgresjs(connectionString) + const table = + schema === undefined ? sql('frog_entries') : sql`${sql(schema)}.${sql('frog_entries')}` + let closing: Promise | undefined + + const get = async (id: string): Promise => { + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} AND id = ${id} + ` + const row = result[0] + if (!row) throw notFoundError(id) + return Entry.parse(row.contents, { id: row.id }) + } + + return from({ + name: 'postgres', + tracksOccurrences: true, + location: (id) => postgresLocation(namespace, id), + close: () => (closing ??= sql.end()), + async migrate() { + if (schema !== undefined) await sql`CREATE SCHEMA IF NOT EXISTS ${sql(schema)}` + await sql` + CREATE TABLE IF NOT EXISTS ${table} ( + namespace text NOT NULL, + id text NOT NULL, + dedupe_key text NOT NULL, + contents text NOT NULL, + occurrence_count integer NOT NULL DEFAULT 1 CHECK (occurrence_count > 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (namespace, id), + UNIQUE (namespace, dedupe_key) + ) + ` + }, + async read() { + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} + ORDER BY id + ` + return result.map((row) => Entry.parse(row.contents, { id: row.id })) + }, + async records() { + const result = await sql` + SELECT id, contents, occurrence_count + FROM ${table} + WHERE namespace = ${namespace} + ORDER BY id + ` + return result.map((row) => ({ + entry: Entry.parse(row.contents, { id: row.id }), + occurrences: Number(row.occurrence_count), + })) + }, + async list() { + const result = await sql<{ id: string }[]>` + SELECT id FROM ${table} WHERE namespace = ${namespace} ORDER BY id + ` + return result.map((row) => row.id) + }, + get, + async write(entry, writeOptions = {}) { + const id = writeOptions.id ?? newPostgresId(entry.title) + const dedupeKey = `entry:${id}` + const titleKey = `title:${Entry.normalizeTitle(entry.title)}` + await sql` + INSERT INTO ${table} AS existing(namespace, id, dedupe_key, contents) + VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) + ON CONFLICT(namespace, id) DO UPDATE SET + dedupe_key = CASE + WHEN existing.dedupe_key LIKE 'title:%' AND NOT EXISTS ( + SELECT 1 + FROM ${table} AS duplicate + WHERE duplicate.namespace = ${namespace} + AND duplicate.dedupe_key = ${titleKey} + AND duplicate.id <> ${id} + ) THEN ${titleKey} + WHEN existing.dedupe_key LIKE 'title:%' THEN ${dedupeKey} + ELSE existing.dedupe_key + END, + contents = EXCLUDED.contents, + updated_at = now() + ` + return { id, location: postgresLocation(namespace, id) } + }, + async remove(id) { + const result = await sql<{ id: string }[]>` + DELETE FROM ${table} WHERE namespace = ${namespace} AND id = ${id} RETURNING id + ` + return result.length > 0 + }, + async log(entry, logOptions = {}) { + const id = newPostgresId(entry.title) + const dedupeKey = logOptions.force + ? `forced:${id}` + : `title:${Entry.normalizeTitle(entry.title)}` + const result = await sql` + INSERT INTO ${table}(namespace, id, dedupe_key, contents) + VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) + ON CONFLICT(namespace, dedupe_key) DO UPDATE SET + occurrence_count = ${table}.occurrence_count + 1, + updated_at = now() + RETURNING id, contents, occurrence_count, (occurrence_count = 1) AS created + ` + const row = result[0] + if (!row) throw new Error('Postgres did not return the logged friction entry.') + return { + created: row.created === true, + entry: Entry.parse(row.contents, { id: row.id }), + location: postgresLocation(namespace, row.id), + occurrences: Number(row.occurrence_count), + } + }, + }) +} + +export declare namespace postgres { + /** Postgres store configuration. */ + type Options = { + /** PostgreSQL connection URL. */ + connectionString: string + /** Isolates independent consumers sharing one table. Defaults to `default`. */ + namespace?: string | undefined + /** Optional PostgreSQL schema. Omit it to use the default search path. */ + schema?: string | undefined + } +} + +function newPostgresId(title: string): string { + return `${Entry.newId({ title })}-${randomUUID().slice(0, 8)}` +} + +function schemaName(schema: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) + throw new Error('Postgres schema must be a SQL identifier.') + if (schema.length > 63) throw new Error('Postgres schema must be at most 63 bytes.') + return schema +} + +function required(value: string, name: string): string { + const normalized = value.trim() + if (!normalized) throw new Error(`Postgres ${name} is required.`) + return normalized +} + +function postgresLocation(namespace: string, id: string): string { + return `postgres:${encodeURIComponent(namespace)}/${encodeURIComponent(id)}` +} + +function notFoundError(id: string): Error & { code: 'ENTRY_NOT_FOUND' } { + const error = Object.assign(new Error(`Friction entry \`${id}\` does not exist.`), { + code: 'ENTRY_NOT_FOUND' as const, + }) + error.name = 'Store.NotFoundError' + return error +} + +function invalidIdError(id: string): Error & { code: 'INVALID_ENTRY_ID' } { + const error = Object.assign(new Error(`Friction entry id \`${id}\` is not path-safe.`), { + code: 'INVALID_ENTRY_ID' as const, + }) + error.name = 'Store.InvalidIdError' + return error +} diff --git a/src/cli/Cli.test.ts b/src/cli/Cli.test.ts new file mode 100644 index 0000000..e3ba0bc --- /dev/null +++ b/src/cli/Cli.test.ts @@ -0,0 +1,18 @@ +import { serve } from './Cli.js' + +test.each([['--version'], ['list', '--help'], ['--schema']])( + 'behavior: %s does not initialize the configured store', + async (...argv) => { + let output = '' + await expect( + serve(argv, { + env: { FROG_DATABASE_URL: 'postgres://driver-must-not-load' }, + exit() {}, + stdout(value) { + output += value + }, + }), + ).resolves.toBeUndefined() + expect(output).toBeTruthy() + }, +) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index ee36d25..d841fab 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -1,12 +1,16 @@ -import { Binary, Cli } from 'incur' +import { Binary, Cli, middleware, z } from 'incur' import { init } from './commands/init.js' import { list } from './commands/list.js' import { log } from './commands/log.js' +import { migrate } from './commands/migrate.js' import { publish } from './commands/publish.js' +import { resolve } from './commands/resolve.js' import { sync } from './commands/sync.js' import { targets } from './commands/targets.js' import * as context from './internal/context.js' import * as packageManager from './internal/packageManager.js' +import * as environmentStore from './internal/store.js' +import type * as Store from '../Store.js' const globalOptionValues = new Set([ '--filter-output', @@ -15,35 +19,101 @@ const globalOptionValues = new Set([ '--token-offset', ]) -export const cli = Cli.create('frog', { - description: 'Automated friction logging for agents.', - sync: { - depth: 1, - suggestions: [ - 'log the friction I just hit', - 'show me which of my dependencies accept friction reports', - ], - }, - update: Binary.github({ repository: 'wevm/frog' }), +const storedCommands = new Set(['list', 'log', 'migrate', 'publish', 'resolve', 'sync']) +const introspectionOptions = new Set(['--help', '--llms', '--llms-full', '--schema', '--version']) +const envSchema = z.object({ + FROG_DATABASE_URL: z + .string() + .optional() + .describe('Postgres URL. Its presence selects the Postgres store.'), + FROG_NAMESPACE: z.string().optional().describe('Postgres namespace. Defaults to `default`.'), + FROG_SCHEMA: z.string().optional().describe('Optional Postgres schema.'), }) - .command(init) - .command(list) - .command(log) - .command(publish) - .command(sync) - .command(targets) + +/** Creates the CLI with one optional store for commands that persist friction. */ +export function create(options: create.Options = {}) { + return createCli(options) +} + +function createCli(options: createCli.Options = {}) { + return Cli.create('frog', { + description: 'Automated friction logging for agents.', + env: envSchema, + vars: environmentStore.vars, + sync: { + depth: 1, + suggestions: [ + 'log the friction I just hit', + 'show me which of my dependencies accept friction reports', + ], + }, + update: Binary.github({ repository: 'wevm/frog' }), + }) + .use( + middleware(async (context, next) => { + const store = options.store ?? (await options.resolveStore?.()) + if (store) context.set('store', store) + await next() + }), + ) + .command(init) + .command(list) + .command(log) + .command(migrate) + .command(publish) + .command(resolve) + .command(sync) + .command(targets) +} + +declare namespace createCli { + type Options = create.Options & { + resolveStore?: (() => Promise) | undefined + } +} + +export declare namespace create { + type Options = { + /** Store injected into commands that persist friction. The file store is derived per command by default. */ + store?: Store.Store | undefined + } +} + +export const cli = create() /** Serves init with the project runner when one is detected. */ -export async function serve( - argv: string[] = process.argv.slice(2), - options: Cli.serve.Options = {}, -) { - if (command(argv) !== 'init') return cli.serve(argv, options) +export async function serve(argv: string[] = process.argv.slice(2), options: serve.Options = {}) { + const { store, ...serveOptions } = options + let selected: environmentStore.Selection | undefined + let selecting: Promise | undefined + const runnerCli = createCli({ + ...(store ? { store } : {}), + ...(store === undefined && usesStore(argv) + ? { + resolveStore: async () => { + selected = await (selecting ??= environmentStore.resolve(options.env ?? process.env)) + return selected?.store + }, + } + : {}), + }) + const run = async () => { + if (command(argv) !== 'init') return runnerCli.serve(argv, serveOptions) + + const { root } = await context.resolve({ cwd: option(argv, '--cwd') }) + const runner = await packageManager.resolve({ env: options.env, root }) + if (!runner) return runnerCli.serve(argv, serveOptions) + return Cli.create(runner).command(init).serve(argv, serveOptions) + } + try { + return await run() + } finally { + await selected?.close() + } +} - const { root } = await context.resolve({ cwd: option(argv, '--cwd') }) - const runner = await packageManager.resolve({ env: options.env, root }) - if (!runner) return cli.serve(argv, options) - return Cli.create(runner).command(init).serve(argv, options) +export declare namespace serve { + type Options = Cli.serve.Options & create.Options } export default cli @@ -69,3 +139,8 @@ function option(argv: readonly string[], name: string): string | undefined { } return undefined } + +function usesStore(argv: readonly string[]): boolean { + if (argv.some((value) => introspectionOptions.has(value))) return false + return argv.includes('--mcp') || storedCommands.has(command(argv) ?? '') +} diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index 434fe99..ff5d88b 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -75,6 +75,70 @@ test('behavior: an empty directory lists nothing', async () => { }) }) +test('behavior: a durable store lists occurrence counts', async () => { + const entry = { body, id: 'one', severity: 'minor', title: 'Repeated friction' } as const + const store = Store.from({ + name: 'durable', + tracksOccurrences: true, + read: async () => [entry], + records: async () => [{ entry, occurrences: 2 }], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, + }) + + expect(await cli.data(['list', '--cwd', await helpers.repo()], {}, { store })).toMatchObject({ + entries: [{ occurrences: 2, title: 'Repeated friction' }], + }) +}) + +test('behavior: a custom store without recurrence metadata omits occurrence counts', async () => { + const entry = { body, id: 'one', severity: 'minor', title: 'One occurrence' } as const + const store = Store.from({ + name: 'memory', + read: async () => [entry], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, + }) + + const result = await cli.data<{ entries: { occurrences?: number | undefined }[] }>( + ['list', '--cwd', await helpers.repo()], + {}, + { store }, + ) + + expect(result.entries[0]).not.toHaveProperty('occurrences') +}) + +test('behavior: custom stores expose opaque artifact locations', async () => { + const entry = { body, id: 'ticket/123', severity: 'minor', title: 'Opaque id' } as const + const store = Store.from({ + name: 'remote', + read: async () => [entry], + get: async () => entry, + write: async () => ({ id: entry.id, location: entry.id }), + remove: async () => false, + files: async () => ['remote://ticket/123/repro.ts'], + }) + + const result = await cli.data<{ entries: { artifacts?: string[]; id: string }[] }>( + ['list', '--cwd', await helpers.repo()], + {}, + { store }, + ) + + expect(result.entries).toEqual([ + { + artifacts: ['remote://ticket/123/repro.ts'], + id: 'ticket/123', + severity: 'minor', + state: 'pending', + title: 'Opaque id', + }, + ]) +}) + test('behavior: filters by state', async () => { const cwd = await helpers.repo() await seed(cwd) diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 6f98464..b3cd2ec 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -3,11 +3,13 @@ import * as Git from '../../Git.js' import * as Store from '../../Store.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' +import * as environmentStore from '../internal/store.js' /** Local state of an entry. Remote issue state arrives with `sync`. */ const State = z.enum(['linked', 'pending']) export const list = Cli.create('list', { + vars: environmentStore.vars, description: 'List entries with their state.', options: z.object({ cwd: context.cwdOption, @@ -28,6 +30,12 @@ export const list = Cli.create('list', { .optional() .describe('Reproduction files, when the entry has any. Runnable as they are.'), id: z.string(), + occurrences: z + .number() + .int() + .positive() + .optional() + .describe('Times observed, when tracked.'), issue: z.string().optional().describe('Linked issue, absent while pending.'), severity: z.string(), state: State, @@ -40,13 +48,20 @@ export const list = Cli.create('list', { }), async run(c) { const { root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) + + if (c.options.since && store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: '`--since` is available only with the repository file store.', + }) // Both `c.error` calls stay at the top level of `run`. See `internal/attempt.ts` for why. - const entries = await attempt(Store.read({ root })) - if (!entries.ok) + const records = await attempt(store.records()) + if (!records.ok) return c.error({ - code: entries.code, - message: entries.message, + code: records.code, + message: records.message, cta: { commands: [{ command: 'list', description: 'Check that every entry parses' }], description: 'Fix the file, then:', @@ -66,7 +81,11 @@ export const list = Cli.create('list', { ? new Set(changed.value.map(Store.toId).filter((id): id is string => Boolean(id))) : undefined - const selected = entries.value + const occurrences = new Map( + records.value.map((record) => [record.entry.id, record.occurrences] as const), + ) + const selected = records.value + .map((record) => record.entry) .filter((entry) => !ids || ids.has(entry.id)) .filter( (entry) => !c.options.state || (entry.issue ? 'linked' : 'pending') === c.options.state, @@ -74,10 +93,14 @@ export const list = Cli.create('list', { const listed = await Promise.all( selected.map(async (entry) => { - const files = await Store.files(entry.id, { root }) - const artifacts = files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) + const files = await store.files(entry.id) + const artifacts = + store.name === 'file' + ? files.filter((file) => file.startsWith(`${Store.toArtifacts(entry.id)}/`)) + : [...files] return { id: entry.id, + ...(store.tracksOccurrences ? { occurrences: occurrences.get(entry.id) ?? 1 } : {}), severity: entry.severity, state: entry.issue ? ('linked' as const) : ('pending' as const), title: entry.title, diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 7b9b442..9365fce 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -5,11 +5,63 @@ import path from 'node:path' import * as cli from '../../../test/cli.js' import { github } from '../../../test/github.js' import * as helpers from '../../../test/helpers.js' +import * as Postgres from '../../../test/postgres.js' import * as Config from '../../Config.js' import * as Store from '../../Store.js' const title = '`pnpm test -- ` ignores file filters' const body = '## Description\n\nThe filter was swallowed.' +const postgres = Postgres.get() + +test('error: immediate publishing requires the file store', async () => { + const store = await postgres.store() + const cwd = await helpers.repo() + + expect( + (await cli.error(['log', title, '--body', body, '--publish', '--cwd', cwd], {}, { store })) + .code, + ).toBe('STORE_UNSUPPORTED_OPTION') +}) + +test('behavior: durable-store follow-up does not suggest repository publishing', async () => { + const store = await postgres.store() + const cwd = await helpers.repo() + + expect( + (await cli.run(['log', title, '--body', body, '--cwd', cwd], {}, { store })).envelope, + ).toMatchObject({ + meta: { cta: { commands: [{ command: 'frog list' }] } }, + ok: true, + }) +}) + +test('behavior: durable-store logging atomically records repeated titles', async () => { + const store = await postgres.store() + const cwd = await helpers.repo() + + const first = await cli.data(['log', title, '--body', body, '--cwd', cwd], {}, { store }) + const repeated = await cli.data( + ['log', title.toUpperCase(), '--body', 'Later details.', '--cwd', cwd], + {}, + { store }, + ) + + expect(repeated.id).toBe(first.id) + expect(repeated.title).toBe(title) + expect(await store.records()).toMatchObject([{ occurrences: 2 }]) +}) + +test('error: an injected file store must match the command root', async () => { + const cwd = await helpers.repo() + const storageRoot = await helpers.repo() + const store = Store.file({ root: storageRoot }) + + expect( + await cli.error(['log', title, '--body', body, '--cwd', cwd], {}, { store }), + ).toMatchObject({ code: 'STORE_ROOT_MISMATCH' }) + expect(await Store.list({ root: cwd })).toEqual([]) + expect(await Store.list({ root: storageRoot })).toEqual([]) +}) const ownForm = [ 'name: Friction', 'body:', diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 0d178fa..46b0050 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -1,6 +1,7 @@ import * as clack from '@clack/prompts' import { Cli, z } from 'incur' import * as Entry from '../../Entry.js' +import * as Frog from '../../Frog.js' import * as IssueForm from '../../IssueForm.js' import * as Store from '../../Store.js' import * as Target from '../../Target.js' @@ -9,6 +10,7 @@ import * as context from '../internal/context.js' import * as form from '../internal/form.js' import * as prompt from '../internal/prompt.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' import * as stdin from '../internal/stdin.js' import * as target from '../internal/target.js' @@ -40,6 +42,7 @@ async function promptSeverity(): Promise { } export const log = Cli.create('log', { + vars: environmentStore.vars, description: 'Write a friction entry.', args: z.object({ title: z.string().min(1).optional().describe('One line, specific enough to search for.'), @@ -99,8 +102,9 @@ export const log = Cli.create('log', { output: z.object({ artifacts: z .string() + .optional() .describe('Directory for reproduction files. Not created until something writes there.'), - file: z.string().describe('Path of the entry, relative to the repository root.'), + file: z.string().describe('Store location of the entry.'), id: z.string(), issue: z.string().optional().describe('Linked issue, when --publish filed one.'), title: z.string(), @@ -111,7 +115,25 @@ export const log = Cli.create('log', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) const interactive = prompt.interactive() + if (store.name === 'file' && store.root !== undefined && store.root !== root) + return c.error({ + code: 'STORE_ROOT_MISMATCH', + message: 'The injected file store must use the same root as `--cwd`.', + }) + if (c.options.publish && store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: '`--publish` is available only with the repository file store.', + }) + const opensEditor = c.options.open ?? (interactive && !c.options.body) + if (opensEditor && store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: + '`--open` is available only with the repository file store. Pass `--body` instead.', + }) // Piped input carries the whole entry, shaped like a commit message. It keeps `log` usable // without a terminal, where a prompt cannot run at all. @@ -145,9 +167,10 @@ export const log = Cli.create('log', { const ownTarget = !c.options.target || (repo !== undefined && targetRepo === repo) // Always load this repository's configured form from disk so a supplied body cannot bypass it. - const own = ownTarget - ? await attempt(form.own(root, { named: config.inbound.template })) - : undefined + const own = + ownTarget && store.name === 'file' + ? await attempt(form.own(root, { named: config.inbound.template })) + : undefined // Scaffold from the target's own issue form rather than from Frog's sections. An upstream project // judges a report against its own form. Fetched only when the answers would be used. Never fatal: @@ -217,13 +240,15 @@ export const log = Cli.create('log', { }, }) - const entries = await attempt(Store.read({ root })) - if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) + const entries = store.log ? undefined : await attempt(store.read()) + if (entries && !entries.ok) return c.error({ code: entries.code, message: entries.message }) - // Catch the repeat at authoring time, the only point where it is cheap. - const duplicate = entries.value.find( - (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), - ) + // Stores without atomic logging still catch the repeat at authoring time, where it is cheap. + const duplicate = entries?.ok + ? entries.value.find( + (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), + ) + : undefined if (duplicate && !c.options.force) return c.error({ code: 'DUPLICATE_FRICTION', @@ -241,16 +266,21 @@ export const log = Cli.create('log', { const severity = c.options.severity ?? (promptedSeverity?.ok ? promptedSeverity.value : 'minor') - const { file, id } = await Store.write( - { - body: body ?? scaffold ?? Entry.template, - severity, - title, - ...(c.options.label?.length ? { labels: c.options.label } : {}), - ...(c.options.target ? { target: c.options.target } : {}), - }, - { root }, + const logged = await attempt( + Frog.create({ store }).log( + { + body: body ?? scaffold ?? Entry.template, + severity, + title, + ...(c.options.label?.length ? { labels: c.options.label } : {}), + ...(c.options.target ? { target: c.options.target } : {}), + }, + { force: c.options.force }, + ), ) + if (!logged.ok) return c.error({ code: logged.code, message: logged.message }) + const { id, title: loggedTitle } = logged.value.entry + const file = logged.value.location // Reached interactively, or on request. The editor is the long-form input path. if (c.options.open ?? (interactive && !body)) { @@ -258,7 +288,7 @@ export const log = Cli.create('log', { prompt .edit(`${root}/${file}`, { command: c.env.VISUAL ?? c.env.EDITOR ?? 'vi' }) // Re-read so a body broken in the editor fails here rather than at publish time. - .then(() => Store.get(id, { root })), + .then(() => store.get(id)), ) if (!edited.ok) return c.error({ code: edited.code, message: edited.message }) const editedBodyError = validateBody(edited.value.body) @@ -267,19 +297,26 @@ export const log = Cli.create('log', { if (!c.options.publish) return c.ok( - { artifacts: Store.toArtifacts(id), file, id, title }, + { + ...(store.name === 'file' ? { artifacts: Store.toArtifacts(id) } : {}), + file, + id, + title: loggedTitle, + }, { cta: { commands: [ { command: 'list', description: 'See everything recorded' }, - { command: 'publish', description: 'File it as an issue now' }, + ...(store.name === 'file' + ? [{ command: 'publish', description: 'File it as an issue now' }] + : []), ], description: 'Next:', }, }, ) - const entry = await attempt(Store.get(id, { root })) + const entry = await attempt(store.get(id)) if (!entry.ok) return c.error({ code: entry.code, message: entry.message }) const publishBodyError = validateBody(entry.value.body) if (publishBodyError) return c.error(publishBodyError) @@ -335,7 +372,7 @@ export const log = Cli.create('log', { artifacts: Store.toArtifacts(id), file, id, - title, + title: entry.value.title, ...(filed.ok && 'issue' in filed.value ? { issue: filed.value.issue } : {}), ...(unfiled ? { unfiled } : {}), }, diff --git a/src/cli/commands/migrate.test.ts b/src/cli/commands/migrate.test.ts new file mode 100644 index 0000000..17674da --- /dev/null +++ b/src/cli/commands/migrate.test.ts @@ -0,0 +1,28 @@ +import * as cli from '../../../test/cli.js' +import * as Store from '../../Store.js' + +test('behavior: the file store reports that it needs no migration', async () => { + await expect(cli.data(['migrate'])).resolves.toEqual({ migrated: false, store: 'file' }) +}) + +test('behavior: delegates migration to the selected store', async () => { + let calls = 0 + const store = Store.from({ + name: 'test', + migrate: async () => { + calls++ + }, + read: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ id: 'unused', location: 'unused' }), + remove: async () => false, + }) + + await expect(cli.data(['migrate'], {}, { store })).resolves.toEqual({ + migrated: true, + store: 'test', + }) + expect(calls).toBe(1) +}) diff --git a/src/cli/commands/migrate.ts b/src/cli/commands/migrate.ts new file mode 100644 index 0000000..f1ee903 --- /dev/null +++ b/src/cli/commands/migrate.ts @@ -0,0 +1,14 @@ +import { Cli, z } from 'incur' +import * as Store from '../../Store.js' +import * as environmentStore from '../internal/store.js' + +export const migrate = Cli.create('migrate', { + vars: environmentStore.vars, + description: 'Prepare the selected store.', + output: z.object({ migrated: z.boolean(), store: z.string() }), + async run(c) { + const store = c.var.store ?? Store.file({ root: process.cwd() }) + await store.migrate() + return { migrated: store.name !== 'file', store: store.name } + }, +}) diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index ae0d593..4f65110 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -23,6 +23,23 @@ function env(url: string): Record { return { GITHUB_API_URL: url, GITHUB_TOKEN: 'test-token' } } +test('error: repository publishing requires the file store', async () => { + const store = Store.from({ + name: 'remote', + read: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ id: 'unused', location: 'unused' }), + remove: async () => false, + }) + const cwd = await helpers.repo({ remote }) + + expect((await cli.error(['publish', '--cwd', cwd], {}, { store })).code).toBe( + 'STORE_UNSUPPORTED_COMMAND', + ) +}) + test('behavior: files a pending entry and writes the link back', async () => { const cwd = await helpers.repo({ remote }) const instance = await github() diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index ed36f9b..dd6d968 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -7,6 +7,7 @@ import * as Target from '../../Target.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' import * as target from '../internal/target.js' /** Normalizes `--pr` into `owner/name#number`, accepting a bare number. */ @@ -15,6 +16,7 @@ function toPr(value: string, repo: string): string { } export const publish = Cli.create('publish', { + vars: environmentStore.vars, description: 'Publish friction entries as GitHub issues.', env: z.object({ GH_TOKEN: z.string().optional().describe('Fallback when GITHUB_TOKEN is unset.'), @@ -73,8 +75,16 @@ export const publish = Cli.create('publish', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) - const entries = await attempt(Store.read({ root })) + if (store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_COMMAND', + message: + '`publish` requires the repository file store because issue reconciliation is repository-owned.', + }) + + const entries = await attempt(store.read()) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) const deferred: { code: string; id: string; reason: string }[] = [] diff --git a/src/cli/commands/resolve.test.ts b/src/cli/commands/resolve.test.ts new file mode 100644 index 0000000..ea09a06 --- /dev/null +++ b/src/cli/commands/resolve.test.ts @@ -0,0 +1,53 @@ +import * as fs from 'node:fs/promises' +import * as cli from '../../../test/cli.js' +import * as helpers from '../../../test/helpers.js' +import * as Store from '../../Store.js' + +test('behavior: removes one resolved entry', async () => { + const cwd = await helpers.repo() + await Store.write( + { body: 'The workaround is no longer needed.', severity: 'minor', title: 'Resolved' }, + { id: 'resolved', root: cwd }, + ) + + await expect(cli.data(['resolve', 'resolved', '--cwd', cwd])).resolves.toEqual({ + id: 'resolved', + removed: true, + }) + await expect(Store.list({ root: cwd })).resolves.toEqual([]) + await expect(cli.data(['resolve', 'resolved', '--cwd', cwd])).resolves.toEqual({ + id: 'resolved', + removed: false, + }) +}) + +test('security: rejects path traversal without removing parent directories', async () => { + const cwd = await helpers.repo() + await helpers.writeFile('.agents/keep.txt', 'keep', cwd) + + await expect(cli.error(['resolve', '..', '--cwd', cwd])).resolves.toEqual({ + code: 'INVALID_ENTRY_ID', + message: 'Entry id must be one path-safe directory name.', + }) + await expect(fs.readFile(`${cwd}/.agents/keep.txt`, 'utf8')).resolves.toBe('keep') +}) + +test('behavior: passes opaque ids to configured stores', async () => { + const cwd = await helpers.repo() + const store = Store.from({ + name: 'custom', + read: async () => [], + get: async () => { + throw new Error('Unexpected get.') + }, + write: async () => { + throw new Error('Unexpected write.') + }, + remove: async (id) => id === 'ticket/123', + }) + + await expect(cli.data(['resolve', 'ticket/123', '--cwd', cwd], {}, { store })).resolves.toEqual({ + id: 'ticket/123', + removed: true, + }) +}) diff --git a/src/cli/commands/resolve.ts b/src/cli/commands/resolve.ts new file mode 100644 index 0000000..8345c9c --- /dev/null +++ b/src/cli/commands/resolve.ts @@ -0,0 +1,23 @@ +import { Cli, z } from 'incur' +import * as Store from '../../Store.js' +import * as context from '../internal/context.js' +import * as environmentStore from '../internal/store.js' + +export const resolve = Cli.create('resolve', { + vars: environmentStore.vars, + description: 'Remove one resolved friction entry.', + args: z.object({ id: z.string().min(1).describe('Exact entry id from `frog list`.') }), + options: z.object({ cwd: context.cwdOption }), + examples: [{ args: { id: '20260803000000-example' }, description: 'Mark one entry resolved' }], + output: z.object({ id: z.string(), removed: z.boolean() }), + async run(c) { + const { root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) + if (c.var.store === undefined && !Store.isId(c.args.id)) + return c.error({ + code: 'INVALID_ENTRY_ID', + message: 'Entry id must be one path-safe directory name.', + }) + return { id: c.args.id, removed: await store.remove(c.args.id) } + }, +}) diff --git a/src/cli/commands/sync.test.ts b/src/cli/commands/sync.test.ts index 1b4a06d..14dd611 100644 --- a/src/cli/commands/sync.test.ts +++ b/src/cli/commands/sync.test.ts @@ -26,6 +26,35 @@ function env(url: string): Record { return { GITHUB_API_URL: url, GITHUB_TOKEN: 'test-token' } } +test('error: repository reconciliation requires the file store', async () => { + const store = Store.from({ + name: 'remote', + read: async () => [], + get: async () => { + throw new Error('unused') + }, + write: async () => ({ id: 'unused', location: 'unused' }), + remove: async () => false, + }) + const cwd = await helpers.repo({ remote }) + + expect((await cli.error(['sync', '--cwd', cwd], {}, { store })).code).toBe( + 'STORE_UNSUPPORTED_COMMAND', + ) +}) + +test('error: an injected file store must match the command root', async () => { + const cwd = await helpers.repo({ remote }) + const storageRoot = await helpers.repo({ remote }) + const store = Store.file({ root: storageRoot }) + + expect(await cli.error(['sync', '--cwd', cwd], {}, { store })).toMatchObject({ + code: 'STORE_ROOT_MISMATCH', + }) + expect(await Store.list({ root: cwd })).toEqual([]) + expect(await Store.list({ root: storageRoot })).toEqual([]) +}) + function issueBody(id: string, body = 'Body.', severity?: Entry.Severity): string { return Github.renderBody({ body, diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 4e399ed..6268447 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -10,8 +10,10 @@ import * as Sync from '../../Sync.js' import { attempt } from '../internal/attempt.js' import * as context from '../internal/context.js' import * as publisher from '../internal/publish.js' +import * as environmentStore from '../internal/store.js' export const sync = Cli.create('sync', { + vars: environmentStore.vars, description: 'Reconcile entries against issue state.', env: z.object({ GH_TOKEN: z.string().optional().describe('Fallback when GITHUB_TOKEN is unset.'), @@ -69,8 +71,21 @@ export const sync = Cli.create('sync', { }), async run(c) { const { config, repo, root } = await context.resolve({ cwd: c.options.cwd }) + const store = c.var.store ?? Store.file({ root }) - const entries = await attempt(Store.read({ root })) + if (store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_COMMAND', + message: + '`sync` requires the repository file store because reconciliation mirrors are repository-owned.', + }) + if (store.root !== root) + return c.error({ + code: 'STORE_ROOT_MISMATCH', + message: 'The injected file store must use the same root as `--cwd`.', + }) + + const entries = await attempt(store.read()) if (!entries.ok) return c.error({ code: entries.code, message: entries.message }) const mirrors = await attempt(Mirrors.resolve({ root })) if (!mirrors.ok) return c.error({ code: mirrors.code, message: mirrors.message }) @@ -183,9 +198,9 @@ export const sync = Cli.create('sync', { }) await Git.rm(plan.remove.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) - for (const id of plan.remove) await Store.remove(id, { root }) + for (const id of plan.remove) await store.remove(id) for (const entry of [...plan.write, ...plan.clearLink]) - await Store.write(entry, { id: entry.id, root }) + await store.write(entry, { id: entry.id }) if (mirrorsChanged) await Mirrors.write(nextMirrors, { root }) const touched = [...plan.write, ...plan.clearLink].map((entry) => Store.toPath(entry.id)) @@ -367,10 +382,10 @@ export const sync = Cli.create('sync', { // goes, artifacts included. `ignoreUnmatch` covers entries that were never committed; those are // removed from disk below. await Git.rm(removedIds.map(Store.toDir), { cwd: root, ignoreUnmatch: true }) - for (const id of removedIds) await Store.remove(id, { root }) + for (const id of removedIds) await store.remove(id) for (const entry of [...plan.write, ...plan.clearLink]) - await Store.write(entry, { id: entry.id, root }) + await store.write(entry, { id: entry.id }) if (mirrorsChanged) await Mirrors.write(nextMirrors, { root }) const touched = [...plan.write, ...plan.clearLink].map((entry) => Store.toPath(entry.id)) diff --git a/src/cli/internal/store.test.ts b/src/cli/internal/store.test.ts new file mode 100644 index 0000000..b58f0b2 --- /dev/null +++ b/src/cli/internal/store.test.ts @@ -0,0 +1,28 @@ +import * as store from './store.js' + +test('behavior: the file store remains the zero-configuration default', async () => { + expect(store.configuration({})).toEqual({ kind: 'file' }) + expect(store.configuration({ DATABASE_URL: 'postgres://localhost/example' })).toEqual({ + kind: 'file', + }) +}) + +test('behavior: FROG_DATABASE_URL selects Postgres with an overridable namespace', () => { + expect(store.configuration({ FROG_DATABASE_URL: 'postgres://localhost/example' })).toEqual({ + connectionString: 'postgres://localhost/example', + kind: 'postgres', + namespace: 'default', + }) + expect( + store.configuration({ + FROG_DATABASE_URL: 'postgres://localhost/example', + FROG_NAMESPACE: 'agent', + FROG_SCHEMA: 'private', + }), + ).toEqual({ + connectionString: 'postgres://localhost/example', + kind: 'postgres', + namespace: 'agent', + schema: 'private', + }) +}) diff --git a/src/cli/internal/store.ts b/src/cli/internal/store.ts new file mode 100644 index 0000000..1748e82 --- /dev/null +++ b/src/cli/internal/store.ts @@ -0,0 +1,51 @@ +import { z } from 'incur' +import * as Store from '../../Store.js' + +export type Environment = Record + +/** Middleware variables shared by commands that persist friction. */ +export const vars = z.object({ store: z.custom().optional() }) + +export type Selection = { + store: Store.Store + close(): Promise +} + +export type Configuration = + | { kind: 'file' } + | { + connectionString: string + kind: 'postgres' + namespace: string + schema?: string | undefined + } + +/** Infers the store from conventional environment variables without opening a connection. */ +export function configuration(env: Environment): Configuration { + const connectionString = env['FROG_DATABASE_URL']?.trim() + if (!connectionString) return { kind: 'file' } + + const schema = env['FROG_SCHEMA']?.trim() + return { + connectionString, + kind: 'postgres', + namespace: env['FROG_NAMESPACE']?.trim() || 'default', + ...(schema ? { schema } : {}), + } +} + +/** Resolves the optional CLI store. */ +export async function resolve(env: Environment): Promise { + const selected = configuration(env) + if (selected.kind === 'file') return undefined + + const store = Store.postgres({ + connectionString: selected.connectionString, + namespace: selected.namespace, + ...(selected.schema ? { schema: selected.schema } : {}), + }) + return { + store, + close: store.close, + } +} diff --git a/src/index.ts b/src/index.ts index e521222..044f00c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ export * as Config from './Config.js' /** The entry format: frontmatter, body, ids, and title normalization. */ export * as Entry from './Entry.js' +/** Storage-independent friction logging for embedded consumers. */ +export * as Frog from './Frog.js' + /** Parses a project's GitHub issue form and renders the entry scaffold it implies. */ export * as IssueForm from './IssueForm.js' @@ -25,7 +28,7 @@ export * as Mirrors from './Mirrors.js' /** Reading and writing entries under `.agents/friction-log`. */ export * as Store from './Store.js' -/** Reconciling local entries against issue state, as a pure plan both adapters can apply. */ +/** Reconciling local entries against issue state, as a pure plan both stores can apply. */ export * as Sync from './Sync.js' /** Resolving where an entry's issue belongs, and every consent gate on the way. */ diff --git a/test/cli.ts b/test/cli.ts index 35d8935..73d1f11 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -1,4 +1,5 @@ import { serve } from '../src/cli/Cli.js' +import type * as Store from '../src/Store.js' export type Result = { /** Exit code, or `undefined` when the command never exited non-zero. */ @@ -9,6 +10,11 @@ export type Result = { stdout: string } +export type Options = { + /** Store injected into commands that persist friction. */ + store?: Store.Store | undefined +} + type Envelope = | { data: Record; meta?: Record; ok: true } | { error: { code: string; message: string }; meta?: Record; ok: false } @@ -22,11 +28,13 @@ type Envelope = export async function run( argv: readonly string[], env: Record = {}, + options: Options = {}, ): Promise { let stdout = '' let code: number | undefined await serve([...argv, '--json', '--full-output'], { env, + ...options, exit(value) { code ??= value }, @@ -41,8 +49,9 @@ export async function run( export async function data>( argv: readonly string[], env?: Record, + options?: Options, ): Promise { - const result = await run(argv, env) + const result = await run(argv, env, options) if (!result.envelope.ok) throw new Error(`Expected success, got ${result.envelope.error.code}: ${result.stdout}`) return result.envelope.data as value @@ -52,8 +61,9 @@ export async function data>( export async function error( argv: readonly string[], env?: Record, + options?: Options, ): Promise<{ code: string; message: string }> { - const result = await run(argv, env) + const result = await run(argv, env, options) if (result.envelope.ok) throw new Error(`Expected failure, got ${result.stdout}`) return result.envelope.error } diff --git a/test/helpers.ts b/test/helpers.ts index 83961e3..3e045c6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -30,6 +30,7 @@ export async function repo(options: repo.Options = {}): Promise { await git(['init', '--initial-branch=main'], dir) await git(['config', 'user.name', 'Test User'], dir) await git(['config', 'user.email', 'test@example.com'], dir) + await git(['config', 'commit.gpgsign', 'false'], dir) if (options.remote) await git(['remote', 'add', 'origin', options.remote], dir) return dir } diff --git a/test/postgres.ts b/test/postgres.ts new file mode 100644 index 0000000..7d3c732 --- /dev/null +++ b/test/postgres.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql' +import postgresjs from 'postgres' +import * as Store from '../src/Store.js' + +const image = 'postgres:18-alpine' + +/** Starts one isolated Postgres container for the importing test file. */ +export function get(): get.ReturnType { + let container: StartedPostgreSqlContainer | undefined + let client: postgresjs.Sql | undefined + let connectionString: string | undefined + + beforeAll(async () => { + container = await new PostgreSqlContainer(image).start() + connectionString = container.getConnectionUri() + client = postgresjs(connectionString) + }, 120_000) + + afterAll(async () => { + try { + await client?.end() + } finally { + await container?.stop() + } + }, 120_000) + + const getClient = () => { + if (!client) throw new Error('Postgres test container has not started.') + return client + } + + const getConnectionString = () => { + if (!connectionString) throw new Error('Postgres test container has not started.') + return connectionString + } + + const create = (options: get.Options = {}) => { + const store = Store.postgres({ + connectionString: getConnectionString(), + namespace: options.namespace ?? randomUUID(), + ...(options.schema ? { schema: options.schema } : {}), + }) + onTestFinished(() => store.close()) + return store + } + + return { + client: getClient, + connectionString: getConnectionString, + create, + async store(options = {}) { + const store = create(options) + await store.migrate() + return store + }, + } +} + +export declare namespace get { + /** Options for constructing an isolated store inside the test database. */ + type Options = { + /** Namespace for a test that needs to coordinate multiple stores. */ + namespace?: string | undefined + /** Optional schema for the store table. */ + schema?: string | undefined + } + + /** Container-backed Postgres test fixture. */ + type ReturnType = { + /** Returns the connected Postgres.js client after the test hook starts the container. */ + readonly client: () => postgresjs.Sql + /** Returns the container connection string after the test hook starts the container. */ + readonly connectionString: () => string + /** Creates an unmigrated store with an isolated namespace. */ + readonly create: (options?: Options) => Store.Store + /** Creates and migrates a store with an isolated namespace. */ + readonly store: (options?: Options) => Promise + } +} diff --git a/test/storeContract.ts b/test/storeContract.ts new file mode 100644 index 0000000..e1fcab3 --- /dev/null +++ b/test/storeContract.ts @@ -0,0 +1,47 @@ +import type * as Entry from '../src/Entry.js' +import type * as Store from '../src/Store.js' + +const canonicalEntry = { + body: 'The tool required an unnecessary workaround.', + context: { + execution: 'opaque-reference', + nested: { attempts: 2, recovered: false }, + source: 'production-agent', + }, + issue: 'wevm/frog#123', + labels: ['agent', 'tooling'], + severity: 'major', + target: 'wevm/frog', + title: 'Tool result omitted its state', +} as const satisfies Entry.serialize.Options + +/** Runs the canonical persistence contract against a store implementation. */ +export function storeContract(name: string, create: () => Promise) { + describe(`${name} store contract`, () => { + test('preserves the complete canonical entry schema', async () => { + const store = await create() + const written = await store.write(canonicalEntry) + + expect(written.id).toBeTruthy() + expect(written.location).toBeTruthy() + expect(await store.get(written.id)).toEqual({ ...canonicalEntry, id: written.id }) + expect(await store.read()).toEqual([{ ...canonicalEntry, id: written.id }]) + expect(await store.list()).toEqual([written.id]) + expect(await store.files(written.id)).toEqual(expect.any(Array)) + + const updated = { + ...canonicalEntry, + body: 'The workaround is no longer required.', + context: { source: 'operator', verified: true }, + labels: ['resolved'], + severity: 'minor', + } as const satisfies Entry.serialize.Options + await store.write(updated, { id: written.id }) + expect(await store.get(written.id)).toEqual({ ...updated, id: written.id }) + + await expect(store.remove(written.id)).resolves.toBe(true) + await expect(store.remove(written.id)).resolves.toBe(false) + await expect(store.list()).resolves.toEqual([]) + }) + }) +}