From 749a90e2317bf5381cc1e83e3b68a0bb160d1af8 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 28 Jul 2026 15:20:54 -0500 Subject: [PATCH 1/4] Scaffold the auditable CLI package Create a source-executed ESM command shell for the unscoped firstdraft package with deterministic help, version, exit status, and non-echoing usage errors. Keep the runtime dependency-free and free of installation hooks, telemetry, update checks, or implicit network access. Allowlist and inspect the exact tarball, then install and execute it offline as part of the checks. Pin development and release work to Node 24 while proving the Node 22.0 runtime floor on Linux and Windows. Scope test discovery to the repository test tree and use the working npm shell shim on the affected Windows floor runtime. --- .gitattributes | 2 + .github/workflows/ci.yml | 52 ++ .gitignore | 5 + .npmrc | 5 + .tool-versions | 1 + LICENSE | 21 + README.md | 32 + SECURITY.md | 8 + bin/firstdraft.js | 19 + eslint.config.js | 25 + jsconfig.json | 25 + package-lock.json | 1335 ++++++++++++++++++++++++++++++++++++++ package.json | 46 ++ scripts/check-pack.js | 31 + scripts/run-tests.js | 40 ++ scripts/smoke-package.js | 87 +++ src/cli.js | 84 +++ src/version.js | 11 + test/cli.test.js | 169 +++++ test/package.test.js | 46 ++ 20 files changed, 2044 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .tool-versions create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100755 bin/firstdraft.js create mode 100644 eslint.config.js create mode 100644 jsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check-pack.js create mode 100644 scripts/run-tests.js create mode 100644 scripts/smoke-package.js create mode 100644 src/cli.js create mode 100644 src/version.js create mode 100644 test/cli.test.js create mode 100644 test/package.test.js 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..9877012 --- /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] + 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..458683d --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# 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 currently contains only the auditable command shell; Plan and +network 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 +``` + +## Trust model + +- The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. +- The command shell has no runtime dependencies, install scripts, telemetry, update checks, or implicit network + activity. +- 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..75ca241 --- /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 = 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..3b06232 --- /dev/null +++ b/scripts/check-pack.js @@ -0,0 +1,31 @@ +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/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..7184fcb --- /dev/null +++ b/scripts/smoke-package.js @@ -0,0 +1,87 @@ +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, ""); +} 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..e7ce7fe --- /dev/null +++ b/src/cli.js @@ -0,0 +1,84 @@ +import { parseArgs } from "node:util"; + +import { VERSION } from "./version.js"; + +const HELP = `First Draft CLI + +Usage: + firstdraft [options] + +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"; + +/** + * @typedef {object} Writer + * @property {(text: string) => unknown} write + */ + +/** + * @typedef {object} RunOptions + * @property {readonly string[]} argv + * @property {Writer} stdout + * @property {Writer} stderr + */ + +/** @param {RunOptions} options */ +export function run({ argv, stdout, stderr }) { + let parsed; + + try { + parsed = parseArgs({ + args: [...argv], + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "V" }, + }, + allowPositionals: true, + strict: true, + }); + } catch (error) { + if (!isParseArgsError(error)) throw error; + + stderr.write(USAGE_ERROR); + return 2; + } + + if (argv.length === 0) { + stdout.write(HELP); + return 0; + } + + if (parsed.positionals.length > 0) { + stderr.write(UNKNOWN_COMMAND); + return 2; + } + + if (parsed.values.help) { + stdout.write(HELP); + return 0; + } + + if (parsed.values.version) { + stdout.write(`${VERSION}\n`); + return 0; + } + + stdout.write(HELP); + return 0; +} + +/** @param {unknown} error */ +function isParseArgsError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + error.code.startsWith("ERR_PARSE_ARGS_") + ); +} 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..40049df --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,169 @@ +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] + +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", () => { + const result = invoke([]); + + assert.deepEqual(result, { status: 0, stdout: HELP, stderr: "" }); +}); + +test("help uses long and short options", () => { + const long = invoke(["--help"]); + const short = invoke(["-h"]); + + assert.deepEqual(long, { status: 0, stdout: HELP, stderr: "" }); + assert.deepEqual(short, long); +}); + +test("version matches the package through long and short options", () => { + const long = invoke(["--version"]); + const short = 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", () => { + const canary = "\u001b[31mcanary-secret-command\n"; + + for (const argv of [ + [canary], + [canary, "--help"], + ["--help", canary], + [canary, "--version"], + ["--version", canary], + ]) { + const result = 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", () => { + for (const argv of [ + ["--canary-secret-option"], + ["--canary-secret-option", "--help"], + ["--help", "--canary-secret-option"], + ]) { + const result = invoke(argv); + + assert.deepEqual(result, { status: 2, stdout: "", stderr: USAGE_ERROR }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +test("recognized root options have deterministic precedence", () => { + for (const argv of [ + ["--help", "--version"], + ["-hV"], + ["--help", "--help"], + ["--"], + ]) { + assert.deepEqual(invoke(argv), { status: 0, stdout: HELP, stderr: "" }); + } + + assert.deepEqual(invoke(["--version", "--version"]), { + status: 0, + stdout: `${VERSION}\n`, + stderr: "", + }); +}); + +test("argument parsing does not mutate injected input", () => { + const argv = Object.freeze(["--version"]); + + assert.equal(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 */ +function invoke(argv) { + let stdout = ""; + let stderr = ""; + + const status = 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`, + ); + } +}); From a732fb2cd219c93def6b52464a8eaa7bf18adc54 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 28 Jul 2026 15:41:10 -0500 Subject: [PATCH 2/4] Initialize local Foundation Plan drafts Agents need a deterministic, private starting point before network behavior arrives. Add plan init to create the exact empty sketch/0.19 document and UUIDv7 Project state beneath a nested ignored .firstdraft directory. Validate keys and names at the schema and I-JSON boundary before invoking randomness or the filesystem. Refuse every existing .firstdraft path and use exclusive writes so initialization never overwrites existing data. Extend the package allowlist and tests for the new runtime files, and exercise the Node 22 compatibility job across Linux, Windows, and macOS. --- .github/workflows/ci.yml | 2 +- README.md | 19 +- scripts/check-pack.js | 2 + src/cli.js | 277 +++++++++++++++++- src/commands/plan-init.js | 87 ++++++ src/uuid-v7.js | 55 ++++ test/cli.test.js | 4 + test/plan-init.test.js | 590 ++++++++++++++++++++++++++++++++++++++ test/uuid-v7.test.js | 121 ++++++++ 9 files changed, 1137 insertions(+), 20 deletions(-) create mode 100644 src/commands/plan-init.js create mode 100644 src/uuid-v7.js create mode 100644 test/plan-init.test.js create mode 100644 test/uuid-v7.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9877012..3305484 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/README.md b/README.md index 458683d..01b0c08 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `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 currently contains only the auditable command shell; Plan and -network behavior will arrive in reviewed increments. +The package is not released yet. This repository contains the auditable command shell and local Foundation Plan +initialization; API and release behavior will arrive in reviewed increments. ## Requirements @@ -19,11 +19,22 @@ 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. + ## Trust model - The published CLI will run the reviewed JavaScript source directly, without generated or bundled code. -- The command shell has no runtime dependencies, install scripts, telemetry, update checks, or implicit network - activity. +- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or implicit network activity. - 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. diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 3b06232..9f2dbc9 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -26,6 +26,8 @@ if (result.status !== 0) { "bin/firstdraft.js", "package.json", "src/cli.js", + "src/commands/plan-init.js", + "src/uuid-v7.js", "src/version.js", ]); } diff --git a/src/cli.js b/src/cli.js index e7ce7fe..dd16ac6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,20 +1,59 @@ import { parseArgs } from "node:util"; +import { initializePlan, isFileSystemError } from "./commands/plan-init.js"; +import { generateUuidV7 } from "./uuid-v7.js"; import { VERSION } from "./version.js"; -const HELP = `First Draft CLI +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 USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft --help' for usage.\n"; -const UNKNOWN_COMMAND = +const PLAN_HELP = `First Draft CLI + +Usage: + firstdraft plan [options] + +Commands: + init Create a local empty Foundation Plan + +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"; /** * @typedef {object} Writer @@ -26,14 +65,48 @@ const UNKNOWN_COMMAND = * @property {readonly string[]} argv * @property {Writer} stdout * @property {Writer} stderr + * @property {string} [cwd] + * @property {() => string} [createProjectId] + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] + */ + +/** + * @typedef {object} CommandOptions + * @property {readonly string[]} argv + * @property {Writer} stdout + * @property {Writer} stderr + * @property {string} cwd + * @property {() => string} createProjectId + * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] */ /** @param {RunOptions} options */ -export function run({ argv, stdout, stderr }) { - let parsed; +export function run({ + argv, + stdout, + stderr, + cwd = process.cwd(), + createProjectId = generateUuidV7, + fileSystem, +}) { + if (argv[0] === "plan") { + return runPlan({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + createProjectId, + fileSystem, + }); + } - try { - parsed = parseArgs({ + 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" }, @@ -41,26 +114,26 @@ export function run({ argv, stdout, stderr }) { }, allowPositionals: true, strict: true, - }); - } catch (error) { - if (!isParseArgsError(error)) throw error; + }), + ); - stderr.write(USAGE_ERROR); + if (!parsed) { + stderr.write(ROOT_USAGE_ERROR); return 2; } if (argv.length === 0) { - stdout.write(HELP); + stdout.write(ROOT_HELP); return 0; } if (parsed.positionals.length > 0) { - stderr.write(UNKNOWN_COMMAND); + stderr.write(ROOT_UNKNOWN_COMMAND); return 2; } if (parsed.values.help) { - stdout.write(HELP); + stdout.write(ROOT_HELP); return 0; } @@ -69,10 +142,184 @@ export function run({ argv, stdout, stderr }) { return 0; } - stdout.write(HELP); + stdout.write(ROOT_HELP); + return 0; +} + +/** @param {CommandOptions} options */ +function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { + if (argv[0] === "init") { + return runPlanInit({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + createProjectId, + fileSystem, + }); + } + + 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 {CommandOptions} 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 ( diff --git a/src/commands/plan-init.js b/src/commands/plan-init.js new file mode 100644 index 0000000..8b27ed9 --- /dev/null +++ b/src/commands/plan-init.js @@ -0,0 +1,87 @@ +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 {unknown} error */ +export function isFileSystemError(error) { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + !error.code.startsWith("ERR_") + ); +} + +/** @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/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/test/cli.test.js b/test/cli.test.js index 40049df..69c92da 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -11,8 +11,12 @@ 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 diff --git a/test/plan-init.test.js b/test/plan-init.test.js new file mode 100644 index 0000000..4fa3f08 --- /dev/null +++ b/test/plan-init.test.js @@ -0,0 +1,590 @@ +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 + +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 command", () => { + assert.deepEqual(invoke(["plan"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(invoke(["plan", "--help"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); + assert.deepEqual(invoke(["plan", "-h"]), { + status: 0, + stdout: PLAN_HELP, + stderr: "", + }); +}); + +test("plan init help does not require creation options", () => { + for (const argv of [ + ["plan", "init", "--help"], + ["plan", "init", "-h"], + ["plan", "init", "--help", "--help"], + ["plan", "init", "-h", "-h"], + ]) { + assert.deepEqual(invoke(argv), { + status: 0, + stdout: PLAN_INIT_HELP, + stderr: "", + }); + } +}); + +test("plan commands return non-echoing usage errors", () => { + const canary = "canary-secret-command"; + + for (const argv of [ + ["plan", canary], + ["plan", canary, "--help"], + ["plan", "--help", canary], + ]) { + const result = 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 = invoke(argv); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: PLAN_USAGE_ERROR, + }); + refuteCanary(result); + } +}); + +test("plan init creates exact deterministic local files", (context) => { + const cwd = temporaryDirectory(context, "firstdraft init ünicode "); + const result = 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", (context) => { + const cwd = temporaryDirectory(context); + runGit(cwd, ["init", "--quiet"]); + + const result = 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", (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 = 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", () => { + 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 = 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", (context) => { + const cwd = temporaryDirectory(context); + const name = "Oscar Party \ud83c\udf89"; + + const result = 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", (context) => { + const directoryCwd = temporaryDirectory(context); + const directory = path.join(directoryCwd, ".firstdraft"); + mkdirSync(directory); + writeFileSync(path.join(directory, "canary.txt"), "canary-secret-directory"); + + const directoryResult = 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 = 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" }, + (context) => { + const cwd = temporaryDirectory(context); + const target = temporaryDirectory(context); + symlinkSync(target, path.join(cwd, ".firstdraft"), "dir"); + + const result = 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", (context) => { + const cwd = temporaryDirectory(context); + const first = 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 = 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", () => { + 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 = invokeValidInit("/unused", { fileSystem }); + + assertInitializationFailure(result); + assert.deepEqual(calls, operations.slice(0, failureIndex + 1)); + } +}); + +test("unexpected programming errors remain loud", () => { + /** @type {import("../src/commands/plan-init.js").FileSystem} */ + const fileSystem = { + mkdirSync() { + throw new TypeError("programming error"); + }, + writeFileSync() { + throw new Error("not reached"); + }, + }; + + assert.throws( + () => invokeValidInit("/unused", { fileSystem }), + /programming error/, + ); +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +} + +/** + * @param {string} cwd + * @param {Partial} [overrides] + */ +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/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", + }, + ); + } +}); From af33be324fd0bc1df62f8f888a8e0b30cbd9e8da Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Tue, 28 Jul 2026 21:35:21 -0500 Subject: [PATCH 3/4] Push local Foundation Plans conditionally Send the exact local Plan representation with a create-or-match precondition so agents cannot silently overwrite a newer remote head. Pin the accepted API origin and opaque ETag in private local state, validate response identity before updating it, and report ambiguous transport outcomes without inventing recovery data. --- README.md | 29 +- bin/firstdraft.js | 2 +- scripts/check-pack.js | 2 + src/cli.js | 189 +++++- src/commands/plan-init.js | 10 - src/commands/plan-push.js | 568 ++++++++++++++++++ src/file-system.js | 12 + test/cli.test.js | 42 +- test/plan-init.test.js | 69 +-- test/plan-push.test.js | 1139 +++++++++++++++++++++++++++++++++++++ 10 files changed, 1992 insertions(+), 70 deletions(-) create mode 100644 src/commands/plan-push.js create mode 100644 src/file-system.js create mode 100644 test/plan-push.test.js diff --git a/README.md b/README.md index 01b0c08..8a035c9 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `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 and local Foundation Plan -initialization; API and release behavior will arrive in reviewed increments. +The package is not released yet. This repository contains the auditable command shell, local Foundation Plan +initialization, and conditional whole-document push; release behavior will arrive in reviewed increments. ## Requirements @@ -31,10 +31,33 @@ This creates an empty `sketch/0.19` Plan and client-generated Project ID under ` 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. +## 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 implicit network activity. +- 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. diff --git a/bin/firstdraft.js b/bin/firstdraft.js index 75ca241..6aa514d 100755 --- a/bin/firstdraft.js +++ b/bin/firstdraft.js @@ -5,7 +5,7 @@ import { run } from "../src/cli.js"; process.stdout.on("error", handleStreamError); process.stderr.on("error", handleStreamError); -process.exitCode = run({ +process.exitCode = await run({ argv: process.argv.slice(2), stdout: process.stdout, stderr: process.stderr, diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 9f2dbc9..3e4da25 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -27,6 +27,8 @@ if (result.status !== 0) { "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/src/cli.js b/src/cli.js index dd16ac6..ce6685a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,6 +1,15 @@ import { parseArgs } from "node:util"; -import { initializePlan, isFileSystemError } from "./commands/plan-init.js"; +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"; @@ -25,11 +34,27 @@ Usage: Commands: init Create a local empty Foundation Plan + 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_INIT_HELP = `First Draft CLI Usage: @@ -54,6 +79,16 @@ const PLAN_INIT_USAGE_ERROR = 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"; /** * @typedef {object} Writer @@ -68,6 +103,11 @@ const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; * @property {string} [cwd] * @property {() => string} [createProjectId] * @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] */ /** @@ -78,16 +118,26 @@ const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n"; * @property {string} cwd * @property {() => string} createProjectId * @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] */ /** @param {RunOptions} options */ -export function run({ +export async function run({ argv, stdout, stderr, cwd = process.cwd(), createProjectId = generateUuidV7, fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl = process.env.FIRSTDRAFT_API_URL, }) { if (argv[0] === "plan") { return runPlan({ @@ -97,6 +147,11 @@ export function run({ cwd, createProjectId, fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, }); } @@ -147,7 +202,19 @@ function runRoot({ argv, stdout, stderr }) { } /** @param {CommandOptions} options */ -function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { +async function runPlan({ + argv, + stdout, + stderr, + cwd, + createProjectId, + fileSystem, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, +}) { if (argv[0] === "init") { return runPlanInit({ argv: argv.slice(1), @@ -159,6 +226,20 @@ function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { }); } + if (argv[0] === "push") { + return runPlanPush({ + argv: argv.slice(1), + stdout, + stderr, + cwd, + fetchFunction, + planPushFileSystem, + createTemporaryId, + createRequestSignal, + apiUrl, + }); + } + const parsed = parseArguments(() => parseArgs({ args: [...argv], @@ -187,6 +268,103 @@ function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) { 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 {CommandOptions} options */ function runPlanInit({ argv, @@ -329,3 +507,8 @@ function isParseArgsError(error) { 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 index 8b27ed9..c70ff4e 100644 --- a/src/commands/plan-init.js +++ b/src/commands/plan-init.js @@ -50,16 +50,6 @@ export function initializePlan({ ); } -/** @param {unknown} error */ -export function isFileSystemError(error) { - return ( - error instanceof Error && - "code" in error && - typeof error.code === "string" && - !error.code.startsWith("ERR_") - ); -} - /** @param {string} applicationKey @param {string} name */ function emptyPlan(applicationKey, name) { return { 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/test/cli.test.js b/test/cli.test.js index 69c92da..0f127af 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -28,30 +28,30 @@ const packageMetadata = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); -test("no arguments show help", () => { - const result = invoke([]); +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", () => { - const long = invoke(["--help"]); - const short = invoke(["-h"]); +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", () => { - const long = invoke(["--version"]); - const short = invoke(["-V"]); +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", () => { +test("an unknown command returns a non-echoing usage error", async () => { const canary = "\u001b[31mcanary-secret-command\n"; for (const argv of [ @@ -61,7 +61,7 @@ test("an unknown command returns a non-echoing usage error", () => { [canary, "--version"], ["--version", canary], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -73,40 +73,44 @@ test("an unknown command returns a non-echoing usage error", () => { } }); -test("an unknown option returns a non-echoing usage error", () => { +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 = invoke(argv); + 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", () => { +test("recognized root options have deterministic precedence", async () => { for (const argv of [ ["--help", "--version"], ["-hV"], ["--help", "--help"], ["--"], ]) { - assert.deepEqual(invoke(argv), { status: 0, stdout: HELP, stderr: "" }); + assert.deepEqual(await invoke(argv), { + status: 0, + stdout: HELP, + stderr: "", + }); } - assert.deepEqual(invoke(["--version", "--version"]), { + assert.deepEqual(await invoke(["--version", "--version"]), { status: 0, stdout: `${VERSION}\n`, stderr: "", }); }); -test("argument parsing does not mutate injected input", () => { +test("argument parsing does not mutate injected input", async () => { const argv = Object.freeze(["--version"]); - assert.equal(invoke(argv).status, 0); + assert.equal((await invoke(argv)).status, 0); assert.deepEqual(argv, ["--version"]); }); @@ -159,11 +163,11 @@ test("the executable preserves its status when an output pipe closes", async () }); /** @param {readonly string[]} argv */ -function invoke(argv) { +async function invoke(argv) { let stdout = ""; let stderr = ""; - const status = run({ + const status = await run({ argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 4fa3f08..025c716 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -27,6 +27,7 @@ Usage: Commands: init Create a local empty Foundation Plan + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -71,32 +72,32 @@ const EXPECTED_STATE = `{ } `; -test("plan help describes the available command", () => { - assert.deepEqual(invoke(["plan"]), { +test("plan help describes the available command", async () => { + assert.deepEqual(await invoke(["plan"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); - assert.deepEqual(invoke(["plan", "--help"]), { + assert.deepEqual(await invoke(["plan", "--help"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); - assert.deepEqual(invoke(["plan", "-h"]), { + assert.deepEqual(await invoke(["plan", "-h"]), { status: 0, stdout: PLAN_HELP, stderr: "", }); }); -test("plan init help does not require creation options", () => { +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(invoke(argv), { + assert.deepEqual(await invoke(argv), { status: 0, stdout: PLAN_INIT_HELP, stderr: "", @@ -104,7 +105,7 @@ test("plan init help does not require creation options", () => { } }); -test("plan commands return non-echoing usage errors", () => { +test("plan commands return non-echoing usage errors", async () => { const canary = "canary-secret-command"; for (const argv of [ @@ -112,7 +113,7 @@ test("plan commands return non-echoing usage errors", () => { ["plan", canary, "--help"], ["plan", "--help", canary], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -126,7 +127,7 @@ test("plan commands return non-echoing usage errors", () => { ["plan", "--canary-secret-option"], ["plan", "--canary-secret-option", "--help"], ]) { - const result = invoke(argv); + const result = await invoke(argv); assert.deepEqual(result, { status: 2, @@ -137,9 +138,9 @@ test("plan commands return non-echoing usage errors", () => { } }); -test("plan init creates exact deterministic local files", (context) => { +test("plan init creates exact deterministic local files", async (context) => { const cwd = temporaryDirectory(context, "firstdraft init ünicode "); - const result = invoke( + const result = await invoke( [ "plan", "init", @@ -212,11 +213,11 @@ test("the executable initializes with a production UUIDv7", (context) => { ); }); -test("the nested ignore file hides the complete local directory from Git", (context) => { +test("the nested ignore file hides the complete local directory from Git", async (context) => { const cwd = temporaryDirectory(context); runGit(cwd, ["init", "--quiet"]); - const result = invoke( + const result = await invoke( ["plan", "init", "--application-key=oscar_party", "--name=Oscar Party"], { cwd, createProjectId: () => PROJECT_ID }, ); @@ -240,13 +241,13 @@ test("the nested ignore file hides the complete local directory from Git", (cont assert.match(ignored.stdout, /\.firstdraft\/\.gitignore:1:\*/); }); -test("an existing root gitignore remains byte-for-byte unchanged", (context) => { +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 = invoke( + const result = await invoke( [ "plan", "init", @@ -262,7 +263,7 @@ test("an existing root gitignore remains byte-for-byte unchanged", (context) => assert.deepEqual(readFileSync(gitignore), original); }); -test("plan init validates every argument before randomness or filesystem access", () => { +test("plan init validates every argument before randomness or filesystem access", async () => { const invalidArguments = [ ["plan", "init"], ["plan", "init", "--application-key", "oscar_party"], @@ -331,7 +332,7 @@ test("plan init validates every argument before randomness or filesystem access" ]; for (const argv of invalidArguments) { - const result = invoke(argv, { + const result = await invoke(argv, { createProjectId: () => { throw new Error("randomness must not run"); }, @@ -347,11 +348,11 @@ test("plan init validates every argument before randomness or filesystem access" } }); -test("plan init accepts ordinary astral Unicode in the application name", (context) => { +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 = invoke( + const result = await invoke( ["plan", "init", "--application-key", "oscar_party", "--name", name], { cwd, createProjectId: () => PROJECT_ID }, ); @@ -363,13 +364,13 @@ test("plan init accepts ordinary astral Unicode in the application name", (conte assert.equal(plan.application.name, name); }); -test("plan init never overwrites an existing local path", (context) => { +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 = invokeValidInit(directoryCwd); + const directoryResult = await invokeValidInit(directoryCwd); assertInitializationFailure(directoryResult); assert.equal( readFileSync(path.join(directory, "canary.txt"), "utf8"), @@ -380,7 +381,7 @@ test("plan init never overwrites an existing local path", (context) => { const file = path.join(fileCwd, ".firstdraft"); writeFileSync(file, "canary-secret-file"); - const fileResult = invokeValidInit(fileCwd); + const fileResult = await invokeValidInit(fileCwd); assertInitializationFailure(fileResult); assert.equal(readFileSync(file, "utf8"), "canary-secret-file"); }); @@ -388,12 +389,12 @@ test("plan init never overwrites an existing local path", (context) => { test( "plan init refuses an existing symlink without following it", { skip: process.platform === "win32" }, - (context) => { + async (context) => { const cwd = temporaryDirectory(context); const target = temporaryDirectory(context); symlinkSync(target, path.join(cwd, ".firstdraft"), "dir"); - const result = invokeValidInit(cwd); + const result = await invokeValidInit(cwd); assertInitializationFailure(result); assert.equal( @@ -404,16 +405,16 @@ test( }, ); -test("a second initialization preserves the first Project", (context) => { +test("a second initialization preserves the first Project", async (context) => { const cwd = temporaryDirectory(context); - const first = invokeValidInit(cwd); + 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 = invoke( + const second = await invoke( [ "plan", "init", @@ -437,7 +438,7 @@ test("a second initialization preserves the first Project", (context) => { ); }); -test("partial filesystem failures stop immediately without cleanup", () => { +test("partial filesystem failures stop immediately without cleanup", async () => { const operations = [ "mkdir", "write:.gitignore", @@ -453,14 +454,14 @@ test("partial filesystem failures stop immediately without cleanup", () => { /** @type {string[]} */ const calls = []; const fileSystem = recordingFileSystem(calls, failureIndex); - const result = invokeValidInit("/unused", { fileSystem }); + const result = await invokeValidInit("/unused", { fileSystem }); assertInitializationFailure(result); assert.deepEqual(calls, operations.slice(0, failureIndex + 1)); } }); -test("unexpected programming errors remain loud", () => { +test("unexpected programming errors remain loud", async () => { /** @type {import("../src/commands/plan-init.js").FileSystem} */ const fileSystem = { mkdirSync() { @@ -471,7 +472,7 @@ test("unexpected programming errors remain loud", () => { }, }; - assert.throws( + await assert.rejects( () => invokeValidInit("/unused", { fileSystem }), /programming error/, ); @@ -481,11 +482,11 @@ test("unexpected programming errors remain loud", () => { * @param {readonly string[]} argv * @param {Partial} [overrides] */ -function invoke(argv, overrides = {}) { +async function invoke(argv, overrides = {}) { let stdout = ""; let stderr = ""; - const status = run({ + const status = await run({ argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, @@ -499,7 +500,7 @@ function invoke(argv, overrides = {}) { * @param {string} cwd * @param {Partial} [overrides] */ -function invokeValidInit(cwd, overrides = {}) { +async function invokeValidInit(cwd, overrides = {}) { return invoke( [ "plan", 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; +} From 0681afd48d7825a7a1a0112e248f3013d0123743 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 29 Jul 2026 01:07:28 -0500 Subject: [PATCH 4/4] Generate Foundation Plan subject IDs Foundation Plan authors need stable identities before adding independently mutable subjects, but the Skill should not invent or persist them itself. Expose the existing UUIDv7 generator as a pure leaf command with no project or network access. Document the continuity rules and exercise both direct and packed-package paths at the minimum runtime. --- README.md | 16 +++- scripts/smoke-package.js | 11 +++ src/cli.js | 80 ++++++++++++++++-- test/plan-init.test.js | 7 +- test/plan-subject-id.test.js | 153 +++++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 test/plan-subject-id.test.js diff --git a/README.md b/README.md index 8a035c9..e70790b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ 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, and conditional whole-document push; release behavior will arrive in reviewed increments. +initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in +reviewed increments. ## Requirements @@ -31,6 +32,19 @@ This creates an empty `sketch/0.19` Plan and client-generated Project ID under ` 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: diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 7184fcb..5d2d548 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -56,6 +56,17 @@ try { 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 }); } diff --git a/src/cli.js b/src/cli.js index ce6685a..dc74945 100644 --- a/src/cli.js +++ b/src/cli.js @@ -33,8 +33,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + 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 @@ -55,6 +56,18 @@ 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: @@ -89,6 +102,8 @@ 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 @@ -101,7 +116,9 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @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] @@ -117,6 +134,7 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @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] @@ -125,13 +143,19 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {string} [apiUrl] */ +/** + * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + */ + /** @param {RunOptions} options */ export async function run({ argv, stdout, stderr, - cwd = process.cwd(), + cwd, + getCwd = process.cwd, createProjectId = generateUuidV7, + createSubjectId = generateUuidV7, fileSystem, fetchFunction, planPushFileSystem, @@ -145,7 +169,9 @@ export async function run({ stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -201,13 +227,15 @@ function runRoot({ argv, stdout, stderr }) { return 0; } -/** @param {CommandOptions} options */ +/** @param {PlanCommandOptions} options */ async function runPlan({ argv, stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -220,7 +248,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), createProjectId, fileSystem, }); @@ -231,7 +259,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), fetchFunction, planPushFileSystem, createTemporaryId, @@ -240,6 +268,15 @@ async function runPlan({ }); } + if (argv[0] === "subject-id") { + return runPlanSubjectId({ + argv: argv.slice(1), + stdout, + stderr, + createSubjectId, + }); + } + const parsed = parseArguments(() => parseArgs({ args: [...argv], @@ -268,6 +305,33 @@ async function runPlan({ 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 */ @@ -365,7 +429,9 @@ async function runPlanPush({ return 0; } -/** @param {CommandOptions} options */ +/** + * @param {Pick} options + */ function runPlanInit({ argv, stdout, diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 025c716..5a01e4c 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -26,8 +26,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + 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 @@ -72,7 +73,7 @@ const EXPECTED_STATE = `{ } `; -test("plan help describes the available command", async () => { +test("plan help describes the available commands", async () => { assert.deepEqual(await invoke(["plan"]), { status: 0, stdout: PLAN_HELP, 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 }; +}