diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..30aae66 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +bin/*.js text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3305484 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + compatibility: + name: Node 22.0 / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + # Node 22.0's npm.ps1 is broken on Windows; Bash selects the bundled npm shell shim instead. + # https://github.com/nodejs/node/issues/52682 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.0.0 + package-manager-cache: false + - run: npm ci --omit=dev --ignore-scripts + - run: npm test + - run: npm run pack:check + - run: npm run pack:smoke + + quality: + name: Node 24 quality + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + - run: npm ci --ignore-scripts + - run: npm audit + - run: npm run check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..721204b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +coverage/ +node_modules/ +tmp/ +*.tgz diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7f6e831 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +audit=true +fund=false +ignore-scripts=true +package-lock=true +save-exact=true diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..fcf8a11 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.18.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c8d282 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 First Draft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e70790b --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# First Draft CLI + +`firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being +built for agents that author and review Foundation Plans with their users. + +The package is not released yet. This repository contains the auditable command shell, local Foundation Plan +initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in +reviewed increments. + +## Requirements + +- Running the CLI: Node.js 22.0.0 or newer +- Working on this repository: Node.js 24.18.0 (pinned in `.tool-versions`) + +## Development + +```sh +npm ci +npm run check +npm run pack:check +``` + +## Start a Foundation Plan + +From the project that the Plan describes: + +```sh +firstdraft plan init --application-key oscar_party --name "Oscar Party" +``` + +This creates an empty `sketch/0.19` Plan and client-generated Project ID under `.firstdraft/`. A nested ignore file +keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no +network request and refuses to replace an existing `.firstdraft` path. + +## Add Foundation Plan subjects + +Generate an identity before adding each new independently mutable authored subject: + +```sh +firstdraft plan subject-id +``` + +The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the +value, or make a network request. Preserve that UUID when renaming the subject or moving it to a different semantic +owner without changing its kind. Use a new UUID for a replacement concept. Readable keys and paths may change and +remain the document's links; the UUID preserves continuity between complete-document pushes. + +## Push a Foundation Plan + +From the initialized project: + +```sh +firstdraft plan push +``` + +The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The first push conditionally creates the +Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a +newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect. + +The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin +or a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a +later override must match it. + +If a failure happens after sending the request, the CLI reports that the outcome may be ambiguous and leaves local +state unchanged. It never constructs an ETag from the Plan digest or trusts an ETag from a response it could not +fully verify. Until First Draft has a read or reconciliation endpoint, an accepted request whose response cannot be +verified may require manual recovery. If a verified response cannot replace local state, preserve the printed +recovery state; an adjacent `.tmp` file may contain the same private recovery copy. + +## Trust model + +- The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. +- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or network activity except an + explicitly invoked API command. +- Package contents are allowlisted and checked before release. +- CI exercises the exact minimum Node.js version separately from current development tooling. +- Public releases will use npm provenance after the first useful version bootstraps trusted publishing. + +Security issues should follow the +[private reporting instructions](https://github.com/firstdraft/cli/security/advisories/new). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7c42fb2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security + +Please report suspected vulnerabilities through a +[private GitHub security advisory](https://github.com/firstdraft/cli/security/advisories/new). Do not include +sensitive details in a public Issue. + +The CLI has not released a supported version yet. This policy will name supported release lines before the first +public package is published. diff --git a/bin/firstdraft.js b/bin/firstdraft.js new file mode 100755 index 0000000..6aa514d --- /dev/null +++ b/bin/firstdraft.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { run } from "../src/cli.js"; + +process.stdout.on("error", handleStreamError); +process.stderr.on("error", handleStreamError); + +process.exitCode = await run({ + argv: process.argv.slice(2), + stdout: process.stdout, + stderr: process.stderr, +}); + +/** @param {Error} error */ +function handleStreamError(error) { + if ("code" in error && error.code === "EPIPE") return; + + throw error; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..462fa3a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,25 @@ +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + { + ignores: ["node_modules/", "tmp/"], + }, + js.configs.recommended, + { + files: ["**/*.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: globals.nodeBuiltin, + }, + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + rules: { + "no-constant-binary-expression": "error", + "no-duplicate-imports": "error", + "no-promise-executor-return": "error", + }, + }, +]; diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..adf5c78 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "lib": ["ES2023"], + "maxNodeModuleJsDepth": 0, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2023", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": [ + "bin/**/*.js", + "eslint.config.js", + "scripts/**/*.js", + "src/**/*.js", + "test/**/*.js" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..78b658a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1335 @@ +{ + "name": "firstdraft", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "firstdraft", + "version": "0.0.0", + "license": "MIT", + "bin": { + "firstdraft": "bin/firstdraft.js" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "22.20.1", + "eslint": "10.8.0", + "globals": "17.8.0", + "prettier": "3.9.6", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b1139d --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "firstdraft", + "version": "0.0.0", + "description": "Command-line interface for First Draft", + "license": "MIT", + "type": "module", + "bin": { + "firstdraft": "./bin/firstdraft.js" + }, + "files": [ + "bin", + "src" + ], + "engines": { + "node": ">=22.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/firstdraft/cli.git" + }, + "bugs": { + "url": "https://github.com/firstdraft/cli/issues" + }, + "homepage": "https://github.com/firstdraft/cli#readme", + "publishConfig": { + "access": "public" + }, + "scripts": { + "check": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run pack:check && npm run pack:smoke", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "pack:check": "node scripts/check-pack.js", + "pack:smoke": "node scripts/smoke-package.js", + "test": "node scripts/run-tests.js", + "typecheck": "tsc --project jsconfig.json" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "22.20.1", + "eslint": "10.8.0", + "globals": "17.8.0", + "prettier": "3.9.6", + "typescript": "7.0.2" + } +} diff --git a/scripts/check-pack.js b/scripts/check-pack.js new file mode 100644 index 0000000..3e4da25 --- /dev/null +++ b/scripts/check-pack.js @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +const npmCli = process.env.npm_execpath; +assert(npmCli, "npm_execpath is required; run this check through npm"); + +const result = spawnSync( + process.execPath, + [npmCli, "pack", "--dry-run", "--json", "--ignore-scripts"], + { encoding: "utf8" }, +); + +if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exitCode = result.status ?? 1; +} else { + /** @type {{files: {path: string}[]}[]} */ + const manifests = JSON.parse(result.stdout); + const [manifest] = manifests; + assert(manifest, "npm pack did not return a manifest"); + const paths = manifest.files.map(({ path }) => path).sort(); + + assert.deepEqual(paths, [ + "LICENSE", + "README.md", + "bin/firstdraft.js", + "package.json", + "src/cli.js", + "src/commands/plan-init.js", + "src/commands/plan-push.js", + "src/file-system.js", + "src/uuid-v7.js", + "src/version.js", + ]); +} diff --git a/scripts/run-tests.js b/scripts/run-tests.js new file mode 100644 index 0000000..6f237db --- /dev/null +++ b/scripts/run-tests.js @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +process.chdir(fileURLToPath(new URL("..", import.meta.url))); + +const testFiles = findTestFiles("test"); +assert.notEqual(testFiles.length, 0, "No test files found"); + +const result = spawnSync(process.execPath, ["--test", ...testFiles], { + stdio: "inherit", +}); + +if (result.error) { + throw result.error; +} + +process.exitCode = result.status ?? 1; + +/** + * @param {string} directory + * @returns {string[]} + */ +function findTestFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return findTestFiles(entryPath); + } + + return entry.isFile() && entry.name.endsWith(".test.js") + ? [entryPath] + : []; + }) + .sort(); +} diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js new file mode 100644 index 0000000..5d2d548 --- /dev/null +++ b/scripts/smoke-package.js @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const npmCli = requiredEnvironmentVariable("npm_execpath"); + +/** @type {{version: string}} */ +const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); +const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "firstdraft-cli-")); +const installationDirectory = path.join(temporaryDirectory, "installation"); + +try { + const packResult = runNpm([ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + temporaryDirectory, + ]); + /** @type {{filename: string}[]} */ + const packedManifests = JSON.parse(packResult.stdout); + const [packed] = packedManifests; + assert(packed, "npm pack did not return a manifest"); + const tarball = path.join(temporaryDirectory, packed.filename); + + mkdirSync(installationDirectory); + writeFileSync( + path.join(installationDirectory, "package.json"), + '{"name":"firstdraft-smoke","private":true}\n', + ); + + runNpm( + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--offline", + "--no-save", + tarball, + ], + installationDirectory, + ); + const execution = runNpm( + ["exec", "--offline", "--", "firstdraft", "--version"], + installationDirectory, + ); + + assert.equal(execution.stdout, `${packageMetadata.version}\n`); + assert.equal(execution.stderr, ""); + + const subjectId = runNpm( + ["exec", "--offline", "--", "firstdraft", "plan", "subject-id"], + installationDirectory, + ); + + assert.match( + subjectId.stdout, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/, + ); + assert.equal(subjectId.stderr, ""); +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} + +/** + * @param {string[]} arguments_ + * @param {string} [cwd] + */ +function runNpm(arguments_, cwd = process.cwd()) { + const result = spawnSync(process.execPath, [npmCli, ...arguments_], { + cwd, + encoding: "utf8", + }); + + assert.equal( + result.status, + 0, + `npm ${arguments_.join(" ")} failed\n${result.stdout}${result.stderr}`, + ); + + return result; +} + +/** @param {string} name */ +function requiredEnvironmentVariable(name) { + const value = process.env[name]; + assert(value, `${name} is required; run this check through npm`); + return value; +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..dc74945 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,580 @@ +import { parseArgs } from "node:util"; + +import { initializePlan } from "./commands/plan-init.js"; +import { + PlanPushConfigurationError, + PlanPushLocalError, + PlanPushNetworkError, + PlanPushProtocolError, + PlanPushStateWriteError, + pushPlan, +} from "./commands/plan-push.js"; +import { isFileSystemError } from "./file-system.js"; +import { generateUuidV7 } from "./uuid-v7.js"; +import { VERSION } from "./version.js"; + +const ROOT_HELP = `First Draft CLI + +Usage: + firstdraft [options] + firstdraft [options] + +Commands: + plan Work with Foundation Plans + +Options: + -h, --help Show help + -V, --version Show version +`; + +const PLAN_HELP = `First Draft CLI + +Usage: + firstdraft plan [options] + +Commands: + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft + +Options: + -h, --help Show help +`; + +const PLAN_PUSH_HELP = `First Draft CLI + +Usage: + firstdraft plan push + +Options: + -h, --help Show help + +Environment: + FIRSTDRAFT_API_URL Override the initial API origin + +The first successful push saves its API origin in .firstdraft/state.json. +Later pushes reject a different origin. +`; + +const PLAN_SUBJECT_ID_HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; + +const PLAN_INIT_HELP = `First Draft CLI + +Usage: + firstdraft plan init --application-key --name + +Options: + --application-key Lower-snake-case application key + --name Application display name + -h, --help Show help +`; + +const ROOT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; +const ROOT_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft --help' for usage.\n"; +const PLAN_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_INIT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan init --help' for usage.\n"; +const PLAN_INIT_ERROR = + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; +const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; +const PLAN_PUSH_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan push --help' for usage.\n"; +const PLAN_PUSH_CONFIGURATION_ERROR = + "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; +const PLAN_PUSH_LOCAL_ERROR = + "Could not read the local First Draft Plan or state. No network request was made.\n"; +const PLAN_PUSH_NETWORK_ERROR = + "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_PROTOCOL_ERROR = + "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_SUBJECT_ID_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; + +/** + * @typedef {object} Writer + * @property {(text: string) => unknown} write + */ + +/** + * @typedef {object} RunOptions + * @property {readonly string[]} argv + * @property {Writer} stdout + * @property {Writer} stderr + * @property {string} [cwd] + * @property {() => string} [getCwd] + * @property {() => string} [createProjectId] + * @property {() => string} [createSubjectId] + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] + * @property {() => string} [createTemporaryId] + * @property {() => AbortSignal} [createRequestSignal] + * @property {string} [apiUrl] + */ + +/** + * @typedef {object} CommandOptions + * @property {readonly string[]} argv + * @property {Writer} stdout + * @property {Writer} stderr + * @property {string} cwd + * @property {() => string} createProjectId + * @property {() => string} createSubjectId + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] + * @property {() => string} [createTemporaryId] + * @property {() => AbortSignal} [createRequestSignal] + * @property {string} [apiUrl] + */ + +/** + * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + */ + +/** @param {RunOptions} options */ +export async function run({ + argv, + stdout, + stderr, + cwd, + getCwd = process.cwd, + createProjectId = generateUuidV7, + createSubjectId = generateUuidV7, + fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl = process.env.FIRSTDRAFT_API_URL, +}) { + if (argv[0] === "plan") { + return runPlan({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + getCwd, + createProjectId, + createSubjectId, + fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, + }); + } + + return runRoot({ argv, stdout, stderr }); +} + +/** @param {Pick} options */ +function runRoot({ argv, stdout, stderr }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "V" }, + }, + allowPositionals: true, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(ROOT_USAGE_ERROR); + return 2; + } + + if (argv.length === 0) { + stdout.write(ROOT_HELP); + return 0; + } + + if (parsed.positionals.length > 0) { + stderr.write(ROOT_UNKNOWN_COMMAND); + return 2; + } + + if (parsed.values.help) { + stdout.write(ROOT_HELP); + return 0; + } + + if (parsed.values.version) { + stdout.write(`${VERSION}\n`); + return 0; + } + + stdout.write(ROOT_HELP); + return 0; +} + +/** @param {PlanCommandOptions} options */ +async function runPlan({ + argv, + stdout, + stderr, + cwd, + getCwd, + createProjectId, + createSubjectId, + fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, +}) { + if (argv[0] === "init") { + return runPlanInit({ + argv: argv.slice(1), + stdout, + stderr, + cwd: cwd ?? getCwd(), + createProjectId, + fileSystem, + }); + } + + if (argv[0] === "push") { + return runPlanPush({ + argv: argv.slice(1), + stdout, + stderr, + cwd: cwd ?? getCwd(), + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, + }); + } + + if (argv[0] === "subject-id") { + return runPlanSubjectId({ + argv: argv.slice(1), + stdout, + stderr, + createSubjectId, + }); + } + + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: true, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(PLAN_USAGE_ERROR); + return 2; + } + + if (parsed.positionals.length > 0) { + stderr.write(PLAN_UNKNOWN_COMMAND); + return 2; + } + + if (argv.length === 0 || parsed.values.help) { + stdout.write(PLAN_HELP); + return 0; + } + + stdout.write(PLAN_HELP); + return 0; +} + +/** + * @param {Pick} options + */ +function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: false, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(PLAN_SUBJECT_ID_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_SUBJECT_ID_HELP); + return 0; + } + + stdout.write(`${createSubjectId()}\n`); + return 0; +} + +/** + * @param {Pick} options + */ +async function runPlanPush({ + argv, + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + stderr.write(PLAN_PUSH_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_PUSH_HELP); + return 0; + } + + let result; + try { + result = await pushPlan({ + cwd, + apiUrl, + fetchFunction, + fileSystem: planPushFileSystem, + createTemporaryId, + createRequestSignal, + }); + } catch (error) { + if (error instanceof PlanPushConfigurationError) { + stderr.write(PLAN_PUSH_CONFIGURATION_ERROR); + return 2; + } + + if (error instanceof PlanPushLocalError) { + stderr.write(PLAN_PUSH_LOCAL_ERROR); + return 1; + } + + if (error instanceof PlanPushNetworkError) { + stderr.write(PLAN_PUSH_NETWORK_ERROR); + return 1; + } + + if (error instanceof PlanPushProtocolError) { + stderr.write(PLAN_PUSH_PROTOCOL_ERROR); + return 1; + } + + if (error instanceof PlanPushStateWriteError) { + writeJson(stderr, { + error: "local_state_not_saved", + detail: + "The Plan was accepted, but its ETag could not be saved. Do not push again until local state is repaired.", + recovery_state: error.recoveryState, + }); + return 1; + } + + throw error; + } + + if (!("etag" in result)) { + if (result.body === null) { + stderr.write(`First Draft rejected the Plan (HTTP ${result.status}).\n`); + } else { + writeJson(stderr, result.body); + } + return 1; + } + + writeJson(stdout, { + outcome: result.outcome, + etag: result.etag, + project: result.body.project, + foundation_plan: result.body.foundation_plan, + diagnostics: result.body.diagnostics, + }); + return 0; +} + +/** + * @param {Pick} options + */ +function runPlanInit({ + argv, + stdout, + stderr, + cwd, + createProjectId, + fileSystem, +}) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { + "application-key": { type: "string" }, + name: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + tokens: true, + }), + ); + + if (!parsed || repeatedValueOption(parsed.tokens)) { + stderr.write(PLAN_INIT_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_INIT_HELP); + return 0; + } + + const applicationKey = parsed.values["application-key"]; + const name = parsed.values.name; + if ( + typeof applicationKey !== "string" || + !/^[a-z][a-z0-9_]*$/.test(applicationKey) || + typeof name !== "string" || + !isValidApplicationName(name) + ) { + stderr.write(PLAN_INIT_USAGE_ERROR); + return 2; + } + + const projectId = createProjectId(); + + try { + initializePlan({ + applicationKey, + name, + projectId, + cwd, + fileSystem, + }); + } catch (error) { + if (!isFileSystemError(error)) throw error; + + stderr.write(PLAN_INIT_ERROR); + return 1; + } + + stdout.write(PLAN_INIT_SUCCESS); + return 0; +} + +/** @param {string} name */ +function isValidApplicationName(name) { + let hasNonWhitespace = false; + + for (const character of name) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint === 0 || + (codePoint >= 0xd800 && codePoint <= 0xdfff) || + (codePoint >= 0xfdd0 && codePoint <= 0xfdef) || + (codePoint & 0xfffe) === 0xfffe + ) { + return false; + } + + if (!isUnicodeWhitespace(codePoint)) hasNonWhitespace = true; + } + + return hasNonWhitespace; +} + +/** @param {number} codePoint */ +function isUnicodeWhitespace(codePoint) { + return ( + (codePoint >= 0x0009 && codePoint <= 0x000d) || + codePoint === 0x0020 || + codePoint === 0x0085 || + codePoint === 0x00a0 || + codePoint === 0x1680 || + (codePoint >= 0x2000 && codePoint <= 0x200a) || + codePoint === 0x2028 || + codePoint === 0x2029 || + codePoint === 0x202f || + codePoint === 0x205f || + codePoint === 0x3000 + ); +} + +/** + * @template T + * @param {() => T} callback + * @returns {T | null} + */ +function parseArguments(callback) { + try { + return callback(); + } catch (error) { + if (!isParseArgsError(error)) throw error; + + return null; + } +} + +/** @param {readonly {kind: string, name?: string}[]} tokens */ +function repeatedValueOption(tokens) { + const names = tokens + .filter( + (token) => + token.kind === "option" && + typeof token.name === "string" && + token.name !== "help", + ) + .map((token) => token.name); + + return new Set(names).size !== names.length; +} + +/** @param {unknown} error */ +function isParseArgsError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + error.code.startsWith("ERR_PARSE_ARGS_") + ); +} + +/** @param {Writer} writer @param {unknown} value */ +function writeJson(writer, value) { + writer.write(`${JSON.stringify(value, null, 2)}\n`); +} diff --git a/src/commands/plan-init.js b/src/commands/plan-init.js new file mode 100644 index 0000000..c70ff4e --- /dev/null +++ b/src/commands/plan-init.js @@ -0,0 +1,77 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +/** + * @typedef {object} FileSystem + * @property {typeof mkdirSync} mkdirSync + * @property {typeof writeFileSync} writeFileSync + */ + +/** @type {FileSystem} */ +const DEFAULT_FILE_SYSTEM = { mkdirSync, writeFileSync }; + +/** + * @typedef {object} InitializePlanOptions + * @property {string} applicationKey + * @property {string} name + * @property {string} projectId + * @property {string} cwd + * @property {FileSystem} [fileSystem] + */ + +/** @param {InitializePlanOptions} options */ +export function initializePlan({ + applicationKey, + name, + projectId, + cwd, + fileSystem = DEFAULT_FILE_SYSTEM, +}) { + const plan = `${JSON.stringify(emptyPlan(applicationKey, name), null, 2)}\n`; + const state = `${JSON.stringify(projectState(projectId), null, 2)}\n`; + const directory = path.join(cwd, ".firstdraft"); + const writeOptions = { flag: "wx", mode: 0o600, flush: true }; + + fileSystem.mkdirSync(directory, { recursive: false, mode: 0o700 }); + fileSystem.writeFileSync( + path.join(directory, ".gitignore"), + "*\n", + writeOptions, + ); + fileSystem.writeFileSync( + path.join(directory, "foundation-plan.json"), + plan, + writeOptions, + ); + fileSystem.writeFileSync( + path.join(directory, "state.json"), + state, + writeOptions, + ); +} + +/** @param {string} applicationKey @param {string} name */ +function emptyPlan(applicationKey, name) { + return { + format: "firstdraft.foundation-plan.sketch/0.19", + target: { + id: "rails", + profile: "rails-sketch/2026-07", + }, + application: { + key: applicationKey, + name, + native: {}, + delivery: {}, + entities: [], + }, + }; +} + +/** @param {string} projectId */ +function projectState(projectId) { + return { + format: "firstdraft.cli-state/1", + project_id: projectId, + }; +} diff --git a/src/commands/plan-push.js b/src/commands/plan-push.js new file mode 100644 index 0000000..c02ffee --- /dev/null +++ b/src/commands/plan-push.js @@ -0,0 +1,568 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { isFileSystemError } from "../file-system.js"; + +export const DEFAULT_API_URL = "https://firstdraft.com"; + +const FOUNDATION_PLAN_MEDIA_TYPE = + "application/vnd.firstdraft.foundation-plan+json"; +const STATE_FORMAT = "firstdraft.cli-state/1"; +const MAX_PLAN_BYTES = 1024 * 1024; +const MAX_STATE_BYTES = 4096; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_ETAG_BYTES = 1024; +const REQUEST_TIMEOUT_MS = 30_000; +const PROJECT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const STRONG_ETAG_PATTERN = /^"(?:[\x21\x23-\x7e\x80-\xff])*"$/; + +/** + * @typedef {object} PlanPushFileSystem + * @property {typeof lstatSync} lstatSync + * @property {typeof readFileSync} readFileSync + * @property {typeof renameSync} renameSync + * @property {typeof writeFileSync} writeFileSync + */ + +/** @type {PlanPushFileSystem} */ +const DEFAULT_FILE_SYSTEM = { + lstatSync, + readFileSync, + renameSync, + writeFileSync, +}; + +export class PlanPushConfigurationError extends Error {} +export class PlanPushLocalError extends Error {} +export class PlanPushNetworkError extends Error {} +export class PlanPushProtocolError extends Error {} + +export class PlanPushStateWriteError extends Error { + /** + * @param {{format: string, project_id: string, api_url: string, foundation_plan_etag: string}} recoveryState + * @param {{cause: Error}} options + */ + constructor(recoveryState, options) { + super("The Foundation Plan was accepted, but local state was not saved.", { + cause: options.cause, + }); + this.recoveryState = recoveryState; + } +} + +/** + * @typedef {object} PushPlanOptions + * @property {string} cwd + * @property {string} [apiUrl] + * @property {typeof globalThis.fetch} [fetchFunction] + * @property {PlanPushFileSystem} [fileSystem] + * @property {() => string} [createTemporaryId] + * @property {() => AbortSignal} [createRequestSignal] + */ + +/** + * @typedef {object} PushPlanResult + * @property {number} status + * @property {string} etag + * @property {"created" | "updated"} outcome + * @property {Record} body + */ + +/** + * @typedef {object} RejectedPushResult + * @property {number} status + * @property {unknown} body + */ + +/** + * @param {PushPlanOptions} options + * @returns {Promise} + */ +export async function pushPlan({ + cwd, + apiUrl, + fetchFunction = globalThis.fetch, + fileSystem = DEFAULT_FILE_SYSTEM, + createTemporaryId = randomUUID, + createRequestSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS), +}) { + const directory = path.join(cwd, ".firstdraft"); + assertLocalDirectory(directory, fileSystem); + const planPath = path.join(directory, "foundation-plan.json"); + const statePath = path.join(directory, "state.json"); + const planSource = readLocalFile(planPath, MAX_PLAN_BYTES, fileSystem); + const stateSource = readLocalFile(statePath, MAX_STATE_BYTES, fileSystem); + const state = loadState(stateSource); + const origin = resolveApiUrl(apiUrl, state.api_url); + const endpoint = new URL( + `/v1/projects/${state.project_id}/foundation-plan`, + origin, + ); + const headers = { + Accept: "application/json, application/problem+json", + "Content-Type": FOUNDATION_PLAN_MEDIA_TYPE, + ...(state.foundation_plan_etag + ? { "If-Match": state.foundation_plan_etag } + : { "If-None-Match": "*" }), + }; + + const response = await sendRequest(fetchFunction, endpoint, { + method: "PUT", + headers, + body: planSource, + redirect: "error", + signal: createRequestSignal(), + }); + const body = await readResponseBody(response); + const sourceSha256 = createHash("sha256").update(planSource).digest("hex"); + + if ( + response.status === 422 && + (responseMediaType(response) !== "application/json" || + !isDiagnosticBody(body, sourceSha256)) + ) { + throw new PlanPushProtocolError( + "First Draft returned invalid diagnostics.", + ); + } + + if (response.status !== 200 && response.status !== 201) { + if (response.ok) { + throw new PlanPushProtocolError( + "First Draft returned an unexpected success status.", + ); + } + return { + status: response.status, + body: + response.status === 422 || isProblemBody(response, body) ? body : null, + }; + } + + const etag = response.headers.get("etag"); + const expectedStatus = state.foundation_plan_etag ? 200 : 201; + if ( + response.status !== expectedStatus || + responseMediaType(response) !== "application/json" || + !isStrongEtag(etag) || + !isAcceptedBody(body, state.project_id, sourceSha256) + ) { + throw new PlanPushProtocolError( + "First Draft returned an invalid success response.", + ); + } + + saveState({ + statePath, + state, + apiUrl: origin, + etag, + fileSystem, + temporaryId: createTemporaryId(), + }); + + return { + status: response.status, + etag, + outcome: response.status === 201 ? "created" : "updated", + body, + }; +} + +/** @param {string | undefined} configured @param {string | undefined} stored */ +function resolveApiUrl(configured, stored) { + const configuredOrigin = + configured === undefined ? undefined : normalizeApiUrl(configured); + if ( + stored !== undefined && + configuredOrigin !== undefined && + stored !== configuredOrigin + ) { + throw new PlanPushConfigurationError( + "The configured API URL does not match local state.", + ); + } + + return stored ?? configuredOrigin ?? DEFAULT_API_URL; +} + +/** @param {string} value */ +export function normalizeApiUrl(value) { + let url; + + try { + url = new URL(value); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + throw new PlanPushConfigurationError("The API URL is invalid.", { + cause: error, + }); + } + + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || + url.hostname === "localhost" || + url.hostname === "[::1]"); + if ( + (url.protocol !== "https:" && !loopbackHttp) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new PlanPushConfigurationError("The API URL is invalid."); + } + + return url.origin; +} + +/** @param {string} directory @param {PlanPushFileSystem} fileSystem */ +function assertLocalDirectory(directory, fileSystem) { + try { + if (!fileSystem.lstatSync(directory).isDirectory()) { + throw new PlanPushLocalError( + "The local First Draft directory is invalid.", + ); + } + } catch (error) { + if (error instanceof PlanPushLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanPushLocalError( + "The local First Draft directory could not be read.", + { cause: error }, + ); + } +} + +/** + * @param {string} filePath + * @param {number} maximumBytes + * @param {PlanPushFileSystem} fileSystem + */ +function readLocalFile(filePath, maximumBytes, fileSystem) { + try { + const stat = fileSystem.lstatSync(filePath); + if (!stat.isFile() || stat.size > maximumBytes) { + throw new PlanPushLocalError("A local First Draft file is invalid."); + } + + const source = fileSystem.readFileSync(filePath); + if (!Buffer.isBuffer(source) || source.byteLength > maximumBytes) { + throw new PlanPushLocalError("A local First Draft file is invalid."); + } + + return source; + } catch (error) { + if (error instanceof PlanPushLocalError) throw error; + if (!isFileSystemError(error)) throw error; + + throw new PlanPushLocalError( + "A local First Draft file could not be read.", + { + cause: error, + }, + ); + } +} + +/** @param {Buffer} source */ +function loadState(source) { + let state; + + try { + state = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(source), + ); + } catch (error) { + if (!(error instanceof SyntaxError || error instanceof TypeError)) { + throw error; + } + + throw new PlanPushLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + + if (!isRecord(state)) { + throw new PlanPushLocalError("Local First Draft state is invalid."); + } + + const keys = Object.keys(state).sort(); + const hasRemoteState = + state.api_url !== undefined || state.foundation_plan_etag !== undefined; + const expectedKeys = hasRemoteState + ? ["api_url", "format", "foundation_plan_etag", "project_id"] + : ["format", "project_id"]; + let storedApiUrl; + + if (typeof state.api_url === "string") { + try { + storedApiUrl = normalizeApiUrl(state.api_url); + } catch (error) { + if (!(error instanceof PlanPushConfigurationError)) throw error; + + throw new PlanPushLocalError("Local First Draft state is invalid.", { + cause: error, + }); + } + } + + if ( + !arraysEqual(keys, expectedKeys) || + state.format !== STATE_FORMAT || + typeof state.project_id !== "string" || + !PROJECT_ID_PATTERN.test(state.project_id) || + (hasRemoteState && storedApiUrl !== state.api_url) || + (state.foundation_plan_etag !== undefined && + !isStrongEtag(state.foundation_plan_etag)) + ) { + throw new PlanPushLocalError("Local First Draft state is invalid."); + } + + return { + format: state.format, + project_id: state.project_id, + ...(storedApiUrl ? { api_url: storedApiUrl } : {}), + ...(typeof state.foundation_plan_etag === "string" + ? { foundation_plan_etag: state.foundation_plan_etag } + : {}), + }; +} + +/** + * @param {typeof globalThis.fetch} fetchFunction + * @param {URL} endpoint + * @param {RequestInit} request + */ +async function sendRequest(fetchFunction, endpoint, request) { + try { + return await fetchFunction(endpoint, request); + } catch (error) { + if (!(error instanceof Error)) throw error; + + throw new PlanPushNetworkError("The First Draft request failed.", { + cause: error, + }); + } +} + +/** @param {Response} response */ +async function readResponseBody(response) { + const bytes = await readResponseBytes(response); + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + + return null; + } +} + +/** @param {Response} response */ +async function readResponseBytes(response) { + const declaredLength = response.headers.get("content-length"); + if ( + declaredLength !== null && + /^\d+$/.test(declaredLength) && + Number(declaredLength) > MAX_RESPONSE_BYTES + ) { + if (response.body !== null) { + await response.body.cancel().catch(() => undefined); + } + throw new PlanPushProtocolError("The First Draft response is too large."); + } + + if (response.body === null) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks = []; + let byteLength = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new PlanPushProtocolError( + "The First Draft response is too large.", + ); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + if (error instanceof PlanPushProtocolError) throw error; + if (!(error instanceof Error)) throw error; + + throw new PlanPushNetworkError("The First Draft response failed.", { + cause: error, + }); + } + + return Buffer.concat(chunks, byteLength); +} + +/** + * @param {object} options + * @param {string} options.statePath + * @param {{format: string, project_id: string, api_url?: string, foundation_plan_etag?: string}} options.state + * @param {string} options.apiUrl + * @param {string} options.etag + * @param {PlanPushFileSystem} options.fileSystem + * @param {string} options.temporaryId + */ +function saveState({ + statePath, + state, + apiUrl, + etag, + fileSystem, + temporaryId, +}) { + const recoveryState = { + format: state.format, + project_id: state.project_id, + api_url: apiUrl, + foundation_plan_etag: etag, + }; + const source = `${JSON.stringify(recoveryState, null, 2)}\n`; + const temporaryPath = `${statePath}.${temporaryId}.tmp`; + + if (Buffer.byteLength(source) > MAX_STATE_BYTES) { + throw new PlanPushStateWriteError(recoveryState, { + cause: new RangeError("Local First Draft state exceeds its size limit."), + }); + } + + try { + fileSystem.writeFileSync(temporaryPath, source, { + flag: "wx", + mode: 0o600, + flush: true, + }); + fileSystem.renameSync(temporaryPath, statePath); + } catch (error) { + if (!isFileSystemError(error)) throw error; + + throw new PlanPushStateWriteError(recoveryState, { cause: error }); + } +} + +/** @param {Response} response */ +function responseMediaType(response) { + return ( + response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase() ?? "" + ); +} + +/** + * @param {unknown} body + * @param {string} projectId + * @param {string} sourceSha256 + */ +function isAcceptedBody(body, projectId, sourceSha256) { + if (!isRecord(body)) return false; + + const project = body.project; + const foundationPlan = body.foundation_plan; + return ( + isRecord(project) && + project.id === projectId && + Number.isSafeInteger(project.graph_version) && + Number(project.graph_version) >= 1 && + isRecord(foundationPlan) && + typeof foundationPlan.format === "string" && + foundationPlan.source_sha256 === sourceSha256 && + Array.isArray(body.diagnostics) && + body.diagnostics.every(isWarningDiagnostic) + ); +} + +/** @param {unknown} body @param {string} sourceSha256 */ +function isDiagnosticBody(body, sourceSha256) { + return ( + isRecord(body) && + body.source_sha256 === sourceSha256 && + Array.isArray(body.diagnostics) && + body.diagnostics.some( + (diagnostic) => + isDiagnostic(diagnostic) && diagnostic.severity === "error", + ) && + body.diagnostics.every(isDiagnostic) + ); +} + +/** @param {Response} response @param {unknown} body */ +function isProblemBody(response, body) { + return ( + responseMediaType(response) === "application/problem+json" && + isRecord(body) && + (body.type === undefined || body.type === "about:blank") && + typeof body.title === "string" && + body.status === response.status && + typeof body.code === "string" && + typeof body.detail === "string" + ); +} + +/** @param {unknown} value */ +function isWarningDiagnostic(value) { + return isDiagnostic(value) && value.severity === "warning"; +} + +/** + * @param {unknown} value + * @returns {value is Record & {severity: "error" | "warning"}} + */ +function isDiagnostic(value) { + return ( + isRecord(value) && + typeof value.code === "string" && + (value.severity === "error" || value.severity === "warning") && + typeof value.message === "string" + ); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** @param {unknown} value @returns {value is string} */ +function isStrongEtag(value) { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= MAX_ETAG_BYTES && + STRONG_ETAG_PATTERN.test(value) + ); +} + +/** @param {string[]} left @param {string[]} right */ +function arraysEqual(left, right) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/src/file-system.js b/src/file-system.js new file mode 100644 index 0000000..4082fde --- /dev/null +++ b/src/file-system.js @@ -0,0 +1,12 @@ +/** + * @param {unknown} error + * @returns {error is Error & {code: string}} + */ +export function isFileSystemError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + (error.code === "ERR_ACCESS_DENIED" || !error.code.startsWith("ERR_")) + ); +} diff --git a/src/uuid-v7.js b/src/uuid-v7.js new file mode 100644 index 0000000..e1511e9 --- /dev/null +++ b/src/uuid-v7.js @@ -0,0 +1,55 @@ +import { randomBytes as secureRandomBytes } from "node:crypto"; + +const MAX_TIMESTAMP = 2 ** 48 - 1; +const RANDOM_BYTE_LENGTH = 16; + +/** + * @typedef {object} UuidV7Options + * @property {() => number} [now] + * @property {(size: number) => Uint8Array} [randomBytes] + */ + +/** @param {UuidV7Options} [options] */ +export function generateUuidV7({ + now = Date.now, + randomBytes = secureRandomBytes, +} = {}) { + const timestamp = now(); + + if ( + !Number.isInteger(timestamp) || + timestamp < 0 || + timestamp > MAX_TIMESTAMP + ) { + throw new RangeError( + `UUIDv7 timestamp must be an integer between 0 and ${MAX_TIMESTAMP}`, + ); + } + + const suppliedRandomness = randomBytes(RANDOM_BYTE_LENGTH); + + if ( + !(suppliedRandomness instanceof Uint8Array) || + suppliedRandomness.byteLength !== RANDOM_BYTE_LENGTH + ) { + throw new TypeError("UUIDv7 randomBytes must return exactly 16 bytes"); + } + + const bytes = Uint8Array.from(suppliedRandomness); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let remainingTimestamp = timestamp; + + for (let index = 5; index >= 0; index -= 1) { + bytes[index] = remainingTimestamp % 256; + remainingTimestamp = Math.floor(remainingTimestamp / 256); + } + + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x70); + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80); + + const hex = Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..a3d7124 --- /dev/null +++ b/src/version.js @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; + +const packageMetadata = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); + +if (typeof packageMetadata.version !== "string") { + throw new TypeError("package.json must declare a string version"); +} + +export const VERSION = packageMetadata.version; diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..0f127af --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { run } from "../src/cli.js"; +import { VERSION } from "../src/version.js"; + +const HELP = `First Draft CLI + +Usage: + firstdraft [options] + firstdraft [options] + +Commands: + plan Work with Foundation Plans + +Options: + -h, --help Show help + -V, --version Show version +`; +const USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; +const UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft --help' for usage.\n"; +const packageMetadata = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); + +test("no arguments show help", async () => { + const result = await invoke([]); + + assert.deepEqual(result, { status: 0, stdout: HELP, stderr: "" }); +}); + +test("help uses long and short options", async () => { + const long = await invoke(["--help"]); + const short = await invoke(["-h"]); + + assert.deepEqual(long, { status: 0, stdout: HELP, stderr: "" }); + assert.deepEqual(short, long); +}); + +test("version matches the package through long and short options", async () => { + const long = await invoke(["--version"]); + const short = await invoke(["-V"]); + + assert.equal(VERSION, packageMetadata.version); + assert.deepEqual(long, { status: 0, stdout: `${VERSION}\n`, stderr: "" }); + assert.deepEqual(short, long); +}); + +test("an unknown command returns a non-echoing usage error", async () => { + const canary = "\u001b[31mcanary-secret-command\n"; + + for (const argv of [ + [canary], + [canary, "--help"], + ["--help", canary], + [canary, "--version"], + ["--version", canary], + ]) { + const result = await invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: UNKNOWN_COMMAND, + }); + assert.doesNotMatch(result.stderr, /canary-secret|\n\n/); + assert.equal(result.stderr.includes("\u001b"), false); + } +}); + +test("an unknown option returns a non-echoing usage error", async () => { + for (const argv of [ + ["--canary-secret-option"], + ["--canary-secret-option", "--help"], + ["--help", "--canary-secret-option"], + ]) { + const result = await invoke(argv); + + assert.deepEqual(result, { status: 2, stdout: "", stderr: USAGE_ERROR }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("recognized root options have deterministic precedence", async () => { + for (const argv of [ + ["--help", "--version"], + ["-hV"], + ["--help", "--help"], + ["--"], + ]) { + assert.deepEqual(await invoke(argv), { + status: 0, + stdout: HELP, + stderr: "", + }); + } + + assert.deepEqual(await invoke(["--version", "--version"]), { + status: 0, + stdout: `${VERSION}\n`, + stderr: "", + }); +}); + +test("argument parsing does not mutate injected input", async () => { + const argv = Object.freeze(["--version"]); + + assert.equal((await invoke(argv)).status, 0); + assert.deepEqual(argv, ["--version"]); +}); + +test("the executable delegates success and usage errors to the tested runner", () => { + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const success = spawnSync(process.execPath, [executable, "--version"], { + encoding: "utf8", + }); + const usageError = spawnSync(process.execPath, [executable, "unknown"], { + encoding: "utf8", + }); + + assert.deepEqual( + { status: success.status, stdout: success.stdout, stderr: success.stderr }, + { status: 0, stdout: `${VERSION}\n`, stderr: "" }, + ); + assert.deepEqual( + { + status: usageError.status, + stdout: usageError.stdout, + stderr: usageError.stderr, + }, + { status: 2, stdout: "", stderr: UNKNOWN_COMMAND }, + ); +}); + +test("the executable preserves its status when an output pipe closes", async () => { + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const help = spawn(process.execPath, [executable, "--help"], { + stdio: ["ignore", "pipe", "pipe"], + }); + help.stdout.destroy(); + const [helpStatus, helpSignal] = await once(help, "close"); + + assert.equal(helpStatus, 0); + assert.equal(helpSignal, null); + + const usageError = spawn(process.execPath, [executable, "unknown"], { + stdio: ["ignore", "pipe", "pipe"], + }); + usageError.stderr.destroy(); + const [usageStatus, usageSignal] = await once(usageError, "close"); + + assert.equal(usageStatus, 2); + assert.equal(usageSignal, null); +}); + +/** @param {readonly string[]} argv */ +async function invoke(argv) { + let stdout = ""; + let stderr = ""; + + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + }); + + return { status, stdout, stderr }; +} diff --git a/test/package.test.js b/test/package.test.js new file mode 100644 index 0000000..aae81dd --- /dev/null +++ b/test/package.test.js @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const metadata = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); + +test("package metadata preserves the audited runtime boundary", () => { + assert.equal(metadata.name, "firstdraft"); + assert.equal(metadata.type, "module"); + assert.equal(metadata.engines.node, ">=22.0.0"); + assert.deepEqual(metadata.bin, { firstdraft: "./bin/firstdraft.js" }); + assert.deepEqual(metadata.files, ["bin", "src"]); + assert.equal(metadata.scripts.test, "node scripts/run-tests.js"); + + for (const property of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "peerDependenciesMeta", + "bundledDependencies", + "bundleDependencies", + ]) { + assert.equal(property in metadata, false, `${property} must stay absent`); + } +}); + +test("package metadata defines no installation lifecycle", () => { + for (const script of [ + "preinstall", + "install", + "postinstall", + "prepack", + "postpack", + "prepare", + "prepublish", + "prepublishOnly", + ]) { + assert.equal( + script in metadata.scripts, + false, + `${script} must stay absent`, + ); + } +}); diff --git a/test/plan-init.test.js b/test/plan-init.test.js new file mode 100644 index 0000000..5a01e4c --- /dev/null +++ b/test/plan-init.test.js @@ -0,0 +1,592 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { run } from "../src/cli.js"; + +const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; +const PLAN_HELP = `First Draft CLI + +Usage: + firstdraft plan [options] + +Commands: + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft + +Options: + -h, --help Show help +`; +const PLAN_INIT_HELP = `First Draft CLI + +Usage: + firstdraft plan init --application-key --name + +Options: + --application-key Lower-snake-case application key + --name Application display name + -h, --help Show help +`; +const PLAN_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_UNKNOWN_COMMAND = + "Unknown command.\nRun 'firstdraft plan --help' for usage.\n"; +const PLAN_INIT_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan init --help' for usage.\n"; +const PLAN_INIT_ERROR = + "Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n"; + +const EXPECTED_PLAN = `{ + "format": "firstdraft.foundation-plan.sketch/0.19", + "target": { + "id": "rails", + "profile": "rails-sketch/2026-07" + }, + "application": { + "key": "oscar_party", + "name": "Oscar Party", + "native": {}, + "delivery": {}, + "entities": [] + } +} +`; +const EXPECTED_STATE = `{ + "format": "firstdraft.cli-state/1", + "project_id": "01900000-0000-7000-8000-000000000301" +} +`; + +test("plan help describes the available commands", async () => { + assert.deepEqual(await invoke(["plan"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(await invoke(["plan", "--help"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(await invoke(["plan", "-h"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); +}); + +test("plan init help does not require creation options", async () => { + for (const argv of [ + ["plan", "init", "--help"], + ["plan", "init", "-h"], + ["plan", "init", "--help", "--help"], + ["plan", "init", "-h", "-h"], + ]) { + assert.deepEqual(await invoke(argv), { + status: 0, + stdout: PLAN_INIT_HELP, + stderr: "", + }); + } +}); + +test("plan commands return non-echoing usage errors", async () => { + const canary = "canary-secret-command"; + + for (const argv of [ + ["plan", canary], + ["plan", canary, "--help"], + ["plan", "--help", canary], + ]) { + const result = await invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_UNKNOWN_COMMAND, + }); + refuteCanary(result); + } + + for (const argv of [ + ["plan", "--canary-secret-option"], + ["plan", "--canary-secret-option", "--help"], + ]) { + const result = await invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_USAGE_ERROR, + }); + refuteCanary(result); + } +}); + +test("plan init creates exact deterministic local files", async (context) => { + const cwd = temporaryDirectory(context, "firstdraft init ünicode "); + const result = await invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + const directory = path.join(cwd, ".firstdraft"); + + assert.deepEqual(result, { + status: 0, + stdout: "Initialized .firstdraft/foundation-plan.json.\n", + stderr: "", + }); + assert.equal(readFileSync(path.join(directory, ".gitignore"), "utf8"), "*\n"); + assert.equal( + readFileSync(path.join(directory, "foundation-plan.json"), "utf8"), + EXPECTED_PLAN, + ); + assert.equal( + readFileSync(path.join(directory, "state.json"), "utf8"), + EXPECTED_STATE, + ); + assert.equal(existsSync(path.join(cwd, ".gitignore")), false); + + if (process.platform !== "win32") { + assert.equal(statSync(directory).mode & 0o777, 0o700); + for (const file of [".gitignore", "foundation-plan.json", "state.json"]) { + assert.equal(statSync(path.join(directory, file)).mode & 0o777, 0o600); + } + } +}); + +test("the executable initializes with a production UUIDv7", (context) => { + const cwd = temporaryDirectory(context); + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const result = spawnSync( + process.execPath, + [ + executable, + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, encoding: "utf8" }, + ); + const state = JSON.parse( + readFileSync(path.join(cwd, ".firstdraft", "state.json"), "utf8"), + ); + + assert.deepEqual( + { status: result.status, stdout: result.stdout, stderr: result.stderr }, + { + status: 0, + stdout: "Initialized .firstdraft/foundation-plan.json.\n", + stderr: "", + }, + ); + assert.match( + state.project_id, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + +test("the nested ignore file hides the complete local directory from Git", async (context) => { + const cwd = temporaryDirectory(context); + runGit(cwd, ["init", "--quiet"]); + + const result = await invoke( + ["plan", "init", "--application-key=oscar_party", "--name=Oscar Party"], + { cwd, createProjectId: () => PROJECT_ID }, + ); + assert.equal(result.status, 0); + + const status = runGit(cwd, [ + "status", + "--porcelain", + "--untracked-files=all", + ]); + assert.equal(status.stdout, ""); + + const ignored = runGit(cwd, [ + "check-ignore", + "-v", + ".firstdraft/.gitignore", + ".firstdraft/foundation-plan.json", + ".firstdraft/state.json", + ]); + assert.equal(ignored.stdout.trim().split("\n").length, 3); + assert.match(ignored.stdout, /\.firstdraft\/\.gitignore:1:\*/); +}); + +test("an existing root gitignore remains byte-for-byte unchanged", async (context) => { + const cwd = temporaryDirectory(context); + const gitignore = path.join(cwd, ".gitignore"); + const original = Buffer.from([0x61, 0x0d, 0x0a, 0x62, 0x0a, 0xff]); + writeFileSync(gitignore, original); + + const result = await invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + + assert.equal(result.status, 0); + assert.deepEqual(readFileSync(gitignore), original); +}); + +test("plan init validates every argument before randomness or filesystem access", async () => { + const invalidArguments = [ + ["plan", "init"], + ["plan", "init", "--application-key", "oscar_party"], + ["plan", "init", "--name", "Oscar Party"], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--application-key", + "other", + "--name", + "Oscar Party", + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "--name", + "Other", + ], + [ + "plan", + "init", + "--application-key", + "Invalid-Key", + "--name", + "Oscar Party", + ], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u00a0\t"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u0085"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\u0000"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ud800"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\udc00"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ufdd0"], + ["plan", "init", "--application-key", "oscar_party", "--name", "\ufffe"], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + String.fromCodePoint(0x1fffe), + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "--canary-secret-option", + ], + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + "canary-secret-positional", + ], + ]; + + for (const argv of invalidArguments) { + const result = await invoke(argv, { + createProjectId: () => { + throw new Error("randomness must not run"); + }, + fileSystem: inaccessibleFileSystem(), + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_INIT_USAGE_ERROR, + }); + refuteCanary(result); + } +}); + +test("plan init accepts ordinary astral Unicode in the application name", async (context) => { + const cwd = temporaryDirectory(context); + const name = "Oscar Party \ud83c\udf89"; + + const result = await invoke( + ["plan", "init", "--application-key", "oscar_party", "--name", name], + { cwd, createProjectId: () => PROJECT_ID }, + ); + const plan = JSON.parse( + readFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json"), "utf8"), + ); + + assert.equal(result.status, 0); + assert.equal(plan.application.name, name); +}); + +test("plan init never overwrites an existing local path", async (context) => { + const directoryCwd = temporaryDirectory(context); + const directory = path.join(directoryCwd, ".firstdraft"); + mkdirSync(directory); + writeFileSync(path.join(directory, "canary.txt"), "canary-secret-directory"); + + const directoryResult = await invokeValidInit(directoryCwd); + assertInitializationFailure(directoryResult); + assert.equal( + readFileSync(path.join(directory, "canary.txt"), "utf8"), + "canary-secret-directory", + ); + + const fileCwd = temporaryDirectory(context); + const file = path.join(fileCwd, ".firstdraft"); + writeFileSync(file, "canary-secret-file"); + + const fileResult = await invokeValidInit(fileCwd); + assertInitializationFailure(fileResult); + assert.equal(readFileSync(file, "utf8"), "canary-secret-file"); +}); + +test( + "plan init refuses an existing symlink without following it", + { skip: process.platform === "win32" }, + async (context) => { + const cwd = temporaryDirectory(context); + const target = temporaryDirectory(context); + symlinkSync(target, path.join(cwd, ".firstdraft"), "dir"); + + const result = await invokeValidInit(cwd); + + assertInitializationFailure(result); + assert.equal( + lstatSync(path.join(cwd, ".firstdraft")).isSymbolicLink(), + true, + ); + assert.deepEqual(readFileNames(target), []); + }, +); + +test("a second initialization preserves the first Project", async (context) => { + const cwd = temporaryDirectory(context); + const first = await invokeValidInit(cwd); + const directory = path.join(cwd, ".firstdraft"); + const originalPlan = readFileSync( + path.join(directory, "foundation-plan.json"), + ); + const originalState = readFileSync(path.join(directory, "state.json")); + + const second = await invoke( + [ + "plan", + "init", + "--application-key", + "other_application", + "--name", + "Other Application", + ], + { cwd, createProjectId: () => "01900000-0000-7000-8000-000000000399" }, + ); + + assert.equal(first.status, 0); + assertInitializationFailure(second); + assert.deepEqual( + readFileSync(path.join(directory, "foundation-plan.json")), + originalPlan, + ); + assert.deepEqual( + readFileSync(path.join(directory, "state.json")), + originalState, + ); +}); + +test("partial filesystem failures stop immediately without cleanup", async () => { + const operations = [ + "mkdir", + "write:.gitignore", + "write:foundation-plan.json", + "write:state.json", + ]; + + for ( + let failureIndex = 0; + failureIndex < operations.length; + failureIndex += 1 + ) { + /** @type {string[]} */ + const calls = []; + const fileSystem = recordingFileSystem(calls, failureIndex); + const result = await invokeValidInit("/unused", { fileSystem }); + + assertInitializationFailure(result); + assert.deepEqual(calls, operations.slice(0, failureIndex + 1)); + } +}); + +test("unexpected programming errors remain loud", async () => { + /** @type {import("../src/commands/plan-init.js").FileSystem} */ + const fileSystem = { + mkdirSync() { + throw new TypeError("programming error"); + }, + writeFileSync() { + throw new Error("not reached"); + }, + }; + + await assert.rejects( + () => invokeValidInit("/unused", { fileSystem }), + /programming error/, + ); +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} + +/** + * @param {string} cwd + * @param {Partial} [overrides] + */ +async function invokeValidInit(cwd, overrides = {}) { + return invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID, ...overrides }, + ); +} + +/** @param {{stdout: string, stderr: string}} result */ +function refuteCanary(result) { + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /canary-secret/); +} + +/** @param {{status: number, stdout: string, stderr: string}} result */ +function assertInitializationFailure(result) { + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_INIT_ERROR, + }); + refuteCanary(result); +} + +/** @param {import("node:test").TestContext} context */ +function temporaryDirectory(context, prefix = "firstdraft-plan-init-") { + const directory = mkdtempSync(path.join(tmpdir(), prefix)); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +/** @param {string} cwd */ +function readFileNames(cwd) { + return readdirSync(cwd); +} + +/** @param {string} cwd @param {string[]} arguments_ */ +function runGit(cwd, arguments_) { + const result = spawnSync("git", arguments_, { cwd, encoding: "utf8" }); + assert.equal( + result.status, + 0, + `git ${arguments_.join(" ")} failed\n${result.stdout}${result.stderr}`, + ); + return result; +} + +function inaccessibleFileSystem() { + /** @type {import("../src/commands/plan-init.js").FileSystem} */ + return { + mkdirSync() { + throw new Error("filesystem must not run"); + }, + writeFileSync() { + throw new Error("filesystem must not run"); + }, + }; +} + +/** + * @param {string[]} calls + * @param {number} failureIndex + * @returns {import("../src/commands/plan-init.js").FileSystem} + */ +function recordingFileSystem(calls, failureIndex) { + /** @param {string} operation */ + function record(operation) { + calls.push(operation); + if (calls.length - 1 === failureIndex) { + const error = new Error("canary-secret-filesystem"); + Object.assign(error, { code: "EIO" }); + throw error; + } + } + + return { + mkdirSync() { + record("mkdir"); + }, + writeFileSync(file) { + record(`write:${path.basename(file.toString())}`); + }, + }; +} diff --git a/test/plan-push.test.js b/test/plan-push.test.js new file mode 100644 index 0000000..beac60c --- /dev/null +++ b/test/plan-push.test.js @@ -0,0 +1,1139 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { run } from "../src/cli.js"; +import { + PlanPushStateWriteError, + pushPlan, +} from "../src/commands/plan-push.js"; + +/** @typedef {{input: string | URL | Request, init: RequestInit | undefined}} FetchCall */ + +const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; +const API_URL = "https://api.example.test"; +const FIRST_ETAG = '"opaque:first-validator"'; +const SECOND_ETAG = '"opaque:second-validator"'; +const PLAN_PUSH_HELP = `First Draft CLI + +Usage: + firstdraft plan push + +Options: + -h, --help Show help + +Environment: + FIRSTDRAFT_API_URL Override the initial API origin + +The first successful push saves its API origin in .firstdraft/state.json. +Later pushes reject a different origin. +`; +const PLAN_PUSH_CONFIGURATION_ERROR = + "Invalid First Draft API configuration.\nRun 'firstdraft plan push --help' for usage.\n"; +const PLAN_PUSH_LOCAL_ERROR = + "Could not read the local First Draft Plan or state. No network request was made.\n"; +const PLAN_PUSH_NETWORK_ERROR = + "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_PUSH_PROTOCOL_ERROR = + "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; + +test("plan push help has no local or network prerequisites", async () => { + const inaccessible = () => { + throw new Error("help must not access dependencies"); + }; + + for (const argv of [ + ["plan", "push", "--help"], + ["plan", "push", "-h"], + ["plan", "push", "--help", "--help"], + ]) { + assert.deepEqual( + await invoke(argv, { + fetchFunction: inaccessible, + planPushFileSystem: inaccessiblePlanPushFileSystem(), + }), + { status: 0, stdout: PLAN_PUSH_HELP, stderr: "" }, + ); + } +}); + +test("the initial push sends exact bytes and saves its origin and ETag", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + const response = acceptedResponse(source, 201, FIRST_ETAG); + /** @type {FetchCall[]} */ + const calls = []; + const signal = new AbortController().signal; + const result = await invoke(["plan", "push"], { + cwd, + fetchFunction: recordingFetch(response, calls), + createRequestSignal: () => signal, + createTemporaryId: () => "initial-push", + apiUrl: `${API_URL}/`, + }); + + assert.equal(calls.length, 1); + const [call] = calls; + assert(call); + assert.equal( + String(call.input), + `${API_URL}/v1/projects/${PROJECT_ID}/foundation-plan`, + ); + assert.equal(call.init?.method, "PUT"); + assert.equal(call.init?.redirect, "error"); + assert.equal(call.init?.signal, signal); + const headers = new Headers(call.init?.headers); + assert.equal( + headers.get("content-type"), + "application/vnd.firstdraft.foundation-plan+json", + ); + assert.equal( + headers.get("accept"), + "application/json, application/problem+json", + ); + assert.equal(headers.get("if-none-match"), "*"); + assert.equal(headers.has("if-match"), false); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, source); + + assert.deepEqual(result, { + status: 0, + stdout: `${JSON.stringify( + { + outcome: "created", + etag: FIRST_ETAG, + ...acceptedBody(source), + }, + null, + 2, + )}\n`, + stderr: "", + }); + assert.deepEqual(readState(cwd), { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }); + assert.deepEqual(readdirSync(path.join(cwd, ".firstdraft")).sort(), [ + ".gitignore", + "foundation-plan.json", + "state.json", + ]); + if (process.platform !== "win32") { + assert.equal( + statSync(path.join(cwd, ".firstdraft", "state.json")).mode & 0o777, + 0o600, + ); + } +}); + +test("the initial push defaults to the First Draft production origin", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + /** @type {FetchCall[]} */ + const calls = []; + const result = await pushPlan({ + cwd, + fetchFunction: recordingFetch( + acceptedResponse(source, 201, FIRST_ETAG), + calls, + ), + createTemporaryId: () => "default-origin", + }); + + assert("etag" in result); + assert.equal( + String(calls[0]?.input), + `https://firstdraft.com/v1/projects/${PROJECT_ID}/foundation-plan`, + ); + assert.equal(readState(cwd).api_url, "https://firstdraft.com"); +}); + +test("later pushes replay the saved ETag and rotate it opaquely", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const pathToPlan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + const replacement = `${readFileSync(pathToPlan, "utf8")} `; + writeFileSync(pathToPlan, replacement); + /** @type {FetchCall[]} */ + const calls = []; + + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(Buffer.from(replacement), 200, SECOND_ETAG), + calls, + ), + createTemporaryId: () => "replacement", + }); + + const [call] = calls; + assert(call); + const headers = new Headers(call.init?.headers); + assert.equal(headers.get("if-match"), FIRST_ETAG); + assert.equal(headers.has("if-none-match"), false); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, Buffer.from(replacement)); + assert.equal(result.status, 0); + assert.equal(JSON.parse(result.stdout).outcome, "updated"); + assert.equal(JSON.parse(result.stdout).etag, SECOND_ETAG); + assert.equal(readState(cwd).foundation_plan_etag, SECOND_ETAG); + assert.equal(readState(cwd).api_url, API_URL); +}); + +test("a saved origin may be repeated but never changed", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + + const same = await invoke(["plan", "push"], { + cwd, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 200, SECOND_ETAG), + [], + ), + createTemporaryId: () => "same-origin", + apiUrl: `${API_URL}/`, + }); + assert.equal(same.status, 0); + + const different = await invoke(["plan", "push"], { + cwd, + apiUrl: "https://canary-secret.example", + fetchFunction: inaccessibleFetch(), + }); + assert.deepEqual(different, { + status: 2, + stdout: "", + stderr: PLAN_PUSH_CONFIGURATION_ERROR, + }); + assert.doesNotMatch(different.stderr, /canary-secret/); + assert.notDeepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).api_url, API_URL); + assert.equal(readState(cwd).foundation_plan_etag, SECOND_ETAG); +}); + +test("an API override must be one valid secure or loopback origin", async (context) => { + const invalidApiUrls = [ + "canary-secret-not-a-url", + "ftp://canary-secret.example", + "http://canary-secret.example", + "https://user:pass@canary-secret.example", + "https://canary-secret.example/path", + "https://canary-secret.example?query", + "https://canary-secret.example#hash", + ]; + + for (const apiUrl of invalidApiUrls) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_PUSH_CONFIGURATION_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("usage errors happen before local or network access", async () => { + for (const argv of [ + ["plan", "push", "--canary-secret-option"], + ["plan", "push", "canary-secret-positional"], + ]) { + const result = await invoke(argv, { + fetchFunction: inaccessibleFetch(), + planPushFileSystem: inaccessiblePlanPushFileSystem(), + }); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("HTTP diagnostics and problems leave local state byte-for-byte unchanged", async (context) => { + const cases = [ + { + status: 422, + body: { + source_sha256: "replace-with-request-digest", + diagnostics: [ + { + code: "foundation_plan.import.unsupported_bootstrap_content", + severity: "error", + message: "The Plan is not supported yet.", + }, + ], + }, + }, + { + status: 412, + body: { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The request precondition does not match.", + }, + }, + { + status: 428, + body: { + title: "Precondition Required", + status: 428, + code: "precondition_required", + detail: "A representation-specific precondition is required.", + }, + }, + ]; + + for (const { status, body } of cases) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const responseBody = + status === 422 + ? { ...body, source_sha256: sha256(planSource(cwd)) } + : body; + const response = + status === 422 + ? jsonResponse(responseBody, status) + : problemResponse(responseBody, status); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: `${JSON.stringify(responseBody, null, 2)}\n`, + }); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("a stale update preserves the prior ETag and exact local state", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + const problem = { + type: "about:blank", + title: "Precondition Failed", + status: 412, + code: "precondition_failed", + detail: "The Foundation Plan has changed.", + }; + /** @type {FetchCall[]} */ + const calls = []; + + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(problemResponse(problem, 412), calls), + }); + + const [call] = calls; + assert(call); + const headers = new Headers(call.init?.headers); + assert.equal(headers.get("if-match"), FIRST_ETAG); + assert.equal(headers.has("if-none-match"), false); + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: `${JSON.stringify(problem, null, 2)}\n`, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("an update rejects a create status before changing local state", async (context) => { + const cwd = await initializedDirectory(context); + await successfulInitialPush(cwd); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, SECOND_ETAG), + [], + ), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("422 diagnostics must identify the exact submitted bytes", async (context) => { + const invalidBodies = [ + { + source_sha256: "0".repeat(64), + diagnostics: [ + { code: "wrong", severity: "error", message: "Wrong source." }, + ], + }, + { + source_sha256: "replace-with-request-digest", + diagnostics: [ + { code: "warning", severity: "warning", message: "No error." }, + ], + }, + ]; + + for (const candidate of invalidBodies) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const body = + candidate.source_sha256 === "replace-with-request-digest" + ? { ...candidate, source_sha256: sha256(planSource(cwd)) } + : candidate; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(body, 422), []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + } + + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const diagnostic = { + source_sha256: sha256(planSource(cwd)), + diagnostics: [ + { code: "invalid", severity: "error", message: "Invalid Plan." }, + ], + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + new Response(JSON.stringify(diagnostic), { + status: 422, + headers: { "Content-Type": "text/plain" }, + }), + [], + ), + }); + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); +}); + +test("non-JSON HTTP failures are reported without echoing their body", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + new Response("canary-secret-upstream-body", { status: 503 }), + [], + ), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: "First Draft rejected the Plan (HTTP 503).\n", + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("transport failures disclose the ambiguous outcome without leaking errors", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: async () => { + throw new TypeError("canary-secret-network-detail"); + }, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_NETWORK_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); +}); + +test("success responses are bound to the request before state changes", async (context) => { + /** @type {((source: Buffer) => Response)[]} */ + const sourceMutators = [ + (source) => acceptedResponse(source, 200, FIRST_ETAG), + (source) => acceptedResponse(source, 201, 'W/"weak"'), + (source) => acceptedResponse(source, 201, `"${"x".repeat(1023)}"`), + () => new Response(null, { status: 204 }), + (source) => + new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + (source) => + new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { "Content-Type": "text/plain", ETag: FIRST_ETAG }, + }), + (source) => { + const body = acceptedBody(source); + body.project.id = "01900000-0000-7000-8000-000000000399"; + return jsonResponse(body, 201, FIRST_ETAG); + }, + (source) => { + const body = acceptedBody(source); + body.foundation_plan.source_sha256 = "0".repeat(64); + return jsonResponse(body, 201, FIRST_ETAG); + }, + (source) => { + const body = acceptedBody(source); + body.diagnostics = [ + { code: "canary", severity: "error", message: "canary-secret" }, + ]; + return jsonResponse(body, 201, FIRST_ETAG); + }, + ]; + + for (const makeResponse of sourceMutators) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(makeResponse(planSource(cwd)), []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + } +}); + +test("warning diagnostics survive an accepted response", async (context) => { + const cwd = await initializedDirectory(context); + const warning = { + code: "foundation_plan.example_warning", + severity: "warning", + message: "This Plan can be improved.", + }; + const response = acceptedResponse(planSource(cwd), 201, FIRST_ETAG, [ + warning, + ]); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + createTemporaryId: () => "warning", + }); + + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout).diagnostics, [warning]); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("success media types are compared case-insensitively", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + const response = new Response(JSON.stringify(acceptedBody(source)), { + status: 201, + headers: { + "Content-Type": "Application/JSON; Charset=UTF-8", + ETag: FIRST_ETAG, + }, + }); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + createTemporaryId: () => "mixed-case-media-type", + }); + + assert.equal(result.status, 0); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +test("a push from an uninitialized directory makes no request", async (context) => { + const cwd = temporaryDirectory(context); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); +}); + +test("local paths are bounded regular files beneath a real directory", async (context) => { + /** @type {((cwd: string) => Promise)[]} */ + const cases = [ + async (cwd) => rmSync(path.join(cwd, ".firstdraft", "state.json")), + async (cwd) => { + const plan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + rmSync(plan); + mkdirSync(plan); + }, + async (cwd) => + writeFileSync( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + Buffer.alloc(1024 * 1024 + 1), + ), + ]; + + if (process.platform !== "win32") { + cases.push(async (cwd) => { + const plan = path.join(cwd, ".firstdraft", "foundation-plan.json"); + const target = path.join(cwd, "outside-plan.json"); + writeFileSync(target, "canary-secret-outside-plan"); + rmSync(plan); + symlinkSync(target, plan); + }); + cases.push(async (cwd) => { + const state = statePath(cwd); + const target = path.join(cwd, "outside-state.json"); + writeFileSync(target, stateSource(cwd)); + rmSync(state); + symlinkSync(target, state); + }); + cases.push(async (cwd) => { + const directory = path.join(cwd, ".firstdraft"); + const target = path.join(cwd, "real-firstdraft"); + renameSync(directory, target); + symlinkSync(target, directory, "dir"); + }); + } + + for (const mutate of cases) { + const cwd = await initializedDirectory(context); + await mutate(cwd); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("invalid local state is rejected before exact Plan bytes leave the machine", async (context) => { + const invalidStates = [ + Buffer.from("{"), + Buffer.from([0xff]), + Buffer.from("{}\n"), + stateJson({ format: "other", project_id: PROJECT_ID }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: "01900000-0000-7000-8000-00000000030A", + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + extra: "canary-secret", + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: 'W/"weak"', + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: `"${"x".repeat(1023)}"`, + }), + stateJson({ + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: `${API_URL}/path`, + foundation_plan_etag: FIRST_ETAG, + }), + ]; + + for (const invalidState of invalidStates) { + const cwd = await initializedDirectory(context); + writeFileSync(statePath(cwd), invalidState); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: inaccessibleFetch(), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_LOCAL_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("the Plan is never parsed or reserialized locally", async (context) => { + const cwd = await initializedDirectory(context); + const invalidUtf8 = Buffer.from([0x7b, 0x22, 0x78, 0x22, 0x3a, 0xff, 0x7d]); + writeFileSync( + path.join(cwd, ".firstdraft", "foundation-plan.json"), + invalidUtf8, + ); + /** @type {FetchCall[]} */ + const calls = []; + const diagnostic = { + source_sha256: sha256(invalidUtf8), + diagnostics: [ + { + code: "foundation_plan.json.invalid", + severity: "error", + message: "The Foundation Plan is not valid UTF-8 JSON.", + }, + ], + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(jsonResponse(diagnostic, 422), calls), + }); + + const [call] = calls; + assert(call); + assert(Buffer.isBuffer(call.init?.body)); + assert.deepEqual(call.init.body, invalidUtf8); + assert.equal(result.status, 1); + assert.deepEqual(JSON.parse(result.stderr), diagnostic); +}); + +test("failed atomic state replacements report the accepted ETag", async (context) => { + for (const code of ["EACCES", "ERR_ACCESS_DENIED"]) { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const fileSystem = { + lstatSync, + readFileSync, + writeFileSync, + renameSync() { + const error = new Error("canary-secret-rename-detail"); + Object.assign(error, { code }); + throw error; + }, + }; + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + planPushFileSystem: fileSystem, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => code, + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.deepEqual(JSON.parse(result.stderr), { + error: "local_state_not_saved", + detail: + "The Plan was accepted, but its ETag could not be saved. Do not push again until local state is repaired.", + recovery_state: { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + assert.deepEqual(stateSource(cwd), before); + assert.equal( + readFileSync(`${statePath(cwd)}.${code}.tmp`, "utf8"), + `${JSON.stringify( + { + format: "firstdraft.cli-state/1", + project_id: PROJECT_ID, + api_url: API_URL, + foundation_plan_etag: FIRST_ETAG, + }, + null, + 2, + )}\n`, + ); + } +}); + +test("state serialization cannot create a file the CLI refuses to read", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + const oversizedApiUrl = `https://${"a".repeat(5000)}`; + + await assert.rejects( + pushPlan({ + cwd, + apiUrl: oversizedApiUrl, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => "oversized-state", + }), + (error) => { + assert(error instanceof PlanPushStateWriteError); + assert.equal(error.recoveryState.api_url, oversizedApiUrl); + assert.equal(error.recoveryState.foundation_plan_etag, FIRST_ETAG); + return true; + }, + ); + + assert.deepEqual(stateSource(cwd), before); + assert.deepEqual(readdirSync(path.join(cwd, ".firstdraft")).sort(), [ + ".gitignore", + "foundation-plan.json", + "state.json", + ]); +}); + +test("oversized success responses stop before local state changes", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x78])); + }, + cancel() { + cancelled = true; + }, + }), + { + status: 201, + headers: { + "Content-Type": "application/json", + "Content-Length": String(2 * 1024 * 1024 + 1), + ETag: FIRST_ETAG, + }, + }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(cancelled, true); +}); + +test("streamed oversized responses are cancelled at the byte cap", async (context) => { + const cwd = await initializedDirectory(context); + const before = stateSource(cwd); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array(1024 * 1024)); + controller.enqueue(new Uint8Array([0x78])); + }, + cancel() { + cancelled = true; + }, + }), + { + status: 201, + headers: { "Content-Type": "application/json", ETag: FIRST_ETAG }, + }, + ); + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(response, []), + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: PLAN_PUSH_PROTOCOL_ERROR, + }); + assert.deepEqual(stateSource(cwd), before); + assert.equal(cancelled, true); +}); + +test("the packaged executable completes a real local HTTP push", async (context) => { + const cwd = await initializedDirectory(context); + const source = planSource(cwd); + let requestBody = Buffer.alloc(0); + /** @type {import("node:http").IncomingHttpHeaders | undefined} */ + let requestHeaders; + const server = createServer((request, response) => { + /** @type {Buffer[]} */ + const chunks = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + requestBody = Buffer.concat(chunks); + requestHeaders = request.headers; + const body = JSON.stringify(acceptedBody(source)); + response.writeHead(201, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + ETag: FIRST_ETAG, + }); + response.end(body); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert(address && typeof address !== "string"); + const apiUrl = `http://127.0.0.1:${address.port}`; + const executable = fileURLToPath( + new URL("../bin/firstdraft.js", import.meta.url), + ); + const child = spawn(process.execPath, [executable, "plan", "push"], { + cwd, + env: { ...process.env, FIRSTDRAFT_API_URL: apiUrl }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + const [status] = await once(child, "close"); + + assert.equal(status, 0); + assert.equal(stderr, ""); + assert.equal(JSON.parse(stdout).outcome, "created"); + assert.deepEqual(requestBody, source); + assert.equal(requestHeaders?.["if-none-match"], "*"); + assert.equal( + requestHeaders?.["content-type"], + "application/vnd.firstdraft.foundation-plan+json", + ); + assert.equal(readState(cwd).api_url, apiUrl); + assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} + +/** @param {import("node:test").TestContext} context */ +async function initializedDirectory(context) { + const cwd = temporaryDirectory(context); + const result = await invoke( + [ + "plan", + "init", + "--application-key", + "oscar_party", + "--name", + "Oscar Party", + ], + { cwd, createProjectId: () => PROJECT_ID }, + ); + assert.equal(result.status, 0); + return cwd; +} + +/** @param {string} cwd */ +async function successfulInitialPush(cwd) { + const result = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch( + acceptedResponse(planSource(cwd), 201, FIRST_ETAG), + [], + ), + createTemporaryId: () => "initial-setup", + }); + assert.equal(result.status, 0); +} + +/** + * @param {Response} response + * @param {FetchCall[]} calls + * @returns {typeof globalThis.fetch} + */ +function recordingFetch(response, calls) { + return async (input, init) => { + calls.push({ input, init }); + return response; + }; +} + +/** @returns {typeof globalThis.fetch} */ +function inaccessibleFetch() { + return async () => { + throw new Error("network must not run"); + }; +} + +/** @returns {import("../src/commands/plan-push.js").PlanPushFileSystem} */ +function inaccessiblePlanPushFileSystem() { + return { + lstatSync() { + throw new Error("filesystem must not run"); + }, + readFileSync() { + throw new Error("filesystem must not run"); + }, + renameSync() { + throw new Error("filesystem must not run"); + }, + writeFileSync() { + throw new Error("filesystem must not run"); + }, + }; +} + +/** + * @param {Buffer} source + * @param {number} status + * @param {string} etag + * @param {unknown[]} [diagnostics] + */ +function acceptedResponse(source, status, etag, diagnostics = []) { + return jsonResponse(acceptedBody(source, diagnostics), status, etag); +} + +/** @param {Buffer} source @param {unknown[]} [diagnostics] */ +function acceptedBody(source, diagnostics = []) { + return { + project: { id: PROJECT_ID, graph_version: 1 }, + foundation_plan: { + format: "firstdraft.foundation-plan.sketch/0.19", + source_sha256: sha256(source), + }, + diagnostics, + }; +} + +/** @param {unknown} body @param {number} status @param {string} [etag] */ +function jsonResponse(body, status, etag) { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...(etag ? { ETag: etag } : {}), + }, + }); +} + +/** @param {unknown} body @param {number} status */ +function problemResponse(body, status) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/problem+json" }, + }); +} + +/** @param {Buffer} source */ +function sha256(source) { + return createHash("sha256").update(source).digest("hex"); +} + +/** @param {string} cwd */ +function planSource(cwd) { + return readFileSync(path.join(cwd, ".firstdraft", "foundation-plan.json")); +} + +/** @param {string} cwd */ +function stateSource(cwd) { + return readFileSync(statePath(cwd)); +} + +/** @param {string} cwd */ +function readState(cwd) { + return JSON.parse(readFileSync(statePath(cwd), "utf8")); +} + +/** @param {string} cwd */ +function statePath(cwd) { + return path.join(cwd, ".firstdraft", "state.json"); +} + +/** @param {Record} state */ +function stateJson(state) { + return Buffer.from(`${JSON.stringify(state, null, 2)}\n`); +} + +/** @param {import("node:test").TestContext} context */ +function temporaryDirectory(context) { + const directory = mkdtempSync(path.join(tmpdir(), "firstdraft-plan-push-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} diff --git a/test/plan-subject-id.test.js b/test/plan-subject-id.test.js new file mode 100644 index 0000000..45dca68 --- /dev/null +++ b/test/plan-subject-id.test.js @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const SUBJECT_ID = "01900000-0000-7000-8000-000000000302"; +const HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; +const USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; +const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/; +/** @satisfies {Partial} */ +const INACCESSIBLE_DEPENDENCIES = { + getCwd: () => { + throw new Error("Working directory must not be read"); + }, + fileSystem: { + mkdirSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + planPushFileSystem: { + lstatSync() { + throw new Error("Filesystem must not run"); + }, + readFileSync() { + throw new Error("Filesystem must not run"); + }, + renameSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + fetchFunction: async () => { + throw new Error("Network must not run"); + }, + createRequestSignal: () => { + throw new Error("Network setup must not run"); + }, +}; + +test("plan subject-id prints exactly one generated UUID", async () => { + let calls = 0; + const result = await invoke(["plan", "subject-id"], { + ...INACCESSIBLE_DEPENDENCIES, + createProjectId: () => { + throw new Error("Project ID generation must not run"); + }, + createSubjectId: () => { + calls += 1; + return SUBJECT_ID; + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${SUBJECT_ID}\n`, + stderr: "", + }); + assert.equal(calls, 1); +}); + +test("plan subject-id uses the production generator for fresh UUIDv7s", async () => { + const first = await invoke(["plan", "subject-id"], INACCESSIBLE_DEPENDENCIES); + const second = await invoke( + ["plan", "subject-id"], + INACCESSIBLE_DEPENDENCIES, + ); + + for (const result of [first, second]) { + assert.equal(result.status, 0); + assert.match(result.stdout, UUID_V7); + assert.equal(result.stderr, ""); + } + assert.notEqual(first.stdout, second.stdout); +}); + +test("plan subject-id help has no generation prerequisites", async () => { + for (const argv of [ + ["plan", "subject-id", "--help"], + ["plan", "subject-id", "-h"], + ["plan", "subject-id", "--help", "--help"], + ["plan", "subject-id", "-h", "-h"], + ]) { + assert.deepEqual( + await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }), + { status: 0, stdout: HELP, stderr: "" }, + ); + } +}); + +test("plan subject-id validates arguments before generating an ID", async () => { + const canary = "canary-secret-argument"; + + for (const argv of [ + ["plan", "subject-id", canary], + ["plan", "subject-id", `--${canary}`], + ["plan", "subject-id", "--help", canary], + ["plan", "subject-id", canary, "--help"], + ]) { + const result = await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: USAGE_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} diff --git a/test/uuid-v7.test.js b/test/uuid-v7.test.js new file mode 100644 index 0000000..09a83a4 --- /dev/null +++ b/test/uuid-v7.test.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { generateUuidV7 } from "../src/uuid-v7.js"; + +const MAX_TIMESTAMP = 2 ** 48 - 1; + +test("matches the RFC 9562 Appendix A.6 UUIDv7 vector", () => { + const randomness = Uint8Array.from([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xcc, 0xc3, 0x98, 0xc4, 0xdc, 0x0c, + 0x0c, 0x07, 0x39, 0x8f, + ]); + + assert.equal( + generateUuidV7({ + now: () => 1_645_557_742_000, + randomBytes: () => randomness, + }), + "017f22e2-79b0-7cc3-98c4-dc0c0c07398f", + ); +}); + +test("encodes the inclusive timestamp bounds in network byte order", () => { + assert.equal( + generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16), + }), + "00000000-0000-7000-8000-000000000000", + ); + assert.equal( + generateUuidV7({ + now: () => MAX_TIMESTAMP, + randomBytes: () => new Uint8Array(16).fill(0xff), + }), + "ffffffff-ffff-7fff-bfff-ffffffffffff", + ); +}); + +test("sets the version and variant while preserving the other random bits", () => { + const zeros = generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16), + }); + const ones = generateUuidV7({ + now: () => 0, + randomBytes: () => new Uint8Array(16).fill(0xff), + }); + + assert.equal(zeros, "00000000-0000-7000-8000-000000000000"); + assert.equal(ones, "00000000-0000-7fff-bfff-ffffffffffff"); + assert.match( + zeros, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/, + ); + assert.match( + ones, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-b[0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + +test("requests and copies exactly 16 random bytes", () => { + const randomness = new Uint8Array(16).fill(0xff); + const original = Uint8Array.from(randomness); + let requestedBytes; + + generateUuidV7({ + now: () => 0, + randomBytes: (size) => { + requestedBytes = size; + return randomness; + }, + }); + + assert.equal(requestedBytes, 16); + assert.deepEqual(randomness, original); +}); + +test("rejects timestamps outside the unsigned 48-bit integer range", () => { + for (const timestamp of [ + -1, + MAX_TIMESTAMP + 1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + assert.throws( + () => + generateUuidV7({ + now: () => timestamp, + randomBytes: () => new Uint8Array(16), + }), + { + name: "RangeError", + message: `UUIDv7 timestamp must be an integer between 0 and ${MAX_TIMESTAMP}`, + }, + ); + } +}); + +test("rejects random input that is not exactly 16 bytes", () => { + for (const randomness of [ + new Uint8Array(15), + new Uint8Array(17), + Array(16).fill(0), + "0000000000000000", + ]) { + assert.throws( + () => + generateUuidV7({ + now: () => 0, + // @ts-expect-error Exercise runtime validation of untrusted input. + randomBytes: () => randomness, + }), + { + name: "TypeError", + message: "UUIDv7 randomBytes must return exactly 16 bytes", + }, + ); + } +});