diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0f27ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI +on: + pull_request: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install --no-audit --no-fund + - run: npm run typecheck + - run: npm run build + - run: node dist/index.js --help diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a6a147f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +# Publishes to npm when a version tag is pushed: git tag v0.1.0 && git push --tags +name: Release +on: + push: + tags: ['v*'] +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - run: npm install --no-audit --no-fund + - run: npm run typecheck && npm run build + - run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96b15a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.idem/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..3be9be0 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# idem — deploy to iDeploy from your terminal + +```bash +npm install -g @idem-ai/cli +cd my-project +idem deploy +``` + +That is the whole thing. The first time, `idem deploy`: + +1. **signs you in** — your browser opens on iDeploy with a code; check it matches the terminal and click *Authorise*; +2. **creates the application** — asks for its name (the folder's by default) and its workspace; +3. **sends your code** — as Git sees it: `.gitignore` and `.idemignore` respected, never `node_modules`, `.git` or your `.env`; +4. **offers your `.env` variables** — iDeploy replaces local addresses (`localhost`, `127.0.0.1`) with the databases, Redis and RabbitMQ of the workspace; +5. **follows the deployment live** and ends with the address. + +The next times, it just sends the new code and deploys. The folder remembers its application in `.idem/project.json` (kept out of Git). + +``` +┌ idem deploy +◇ Project read: 412 files, 3.1 MB +◇ Code sent to g-requete +◆ Deploying g-requete (abf35520) +│ ──► Build strategy: recipe (Laravel) +│ ✓ Release command done. +└ Live at https://g-requete.idem.africa (2 min 31) +``` + +Not sure what to type? Run **`idem`** alone: a menu lists what you can do with the application of this folder. + +## Commands + +| Command | | +|---|---| +| `idem deploy` | Deploy this folder (creates the application the first time). `-y` asks nothing, `-d` does not wait, `-a ` targets an application, `--env` sends `.env` with a new one | +| `idem login` / `logout` / `whoami` | Sign in through the browser (`--token` for CI) · sign out and revoke this machine's token | +| `idem apps` | Your applications, with their status and address | +| `idem link` / `unlink` | Link this folder to an existing application, or forget it | +| `idem logs [-f]` | What the running application prints; `-f` follows it | +| `idem status` | The application, its containers and its last deployment | +| `idem open [--dashboard]` | Open the application — or its page on iDeploy | +| `idem restart` | Restart it (asks first; `-y` does not) | +| `idem rollback [--to ]` | Back to an earlier version | +| `idem env [ls\|set\|rm\|pull]` | Variables: list (hidden; `--reveal`), `set KEY=value…`, `rm KEY…`, `pull` into a local `.env` | +| `idem dashboard` | Open iDeploy | + +Every command takes `--json` for scripts. + +## In CI + +Create a token on iDeploy (*Security › API tokens*) and give it as `IDEM_TOKEN`: + +```yaml +- run: npx @idem-ai/cli deploy --yes --app my-app + env: + IDEM_TOKEN: ${{ secrets.IDEM_TOKEN }} +``` + +The command fails (exit code 1) when the deployment fails; the previous version keeps serving. + +## Limits + +The CLI sends up to 3000 files and 9 MB (before compression). A larger project: add what its build does not need to `.idemignore` (same syntax as `.gitignore`) — the CLI names the heaviest folders — or deploy it from its Git repository on iDeploy. + +## Configuration + +| | | +|---|---| +| `~/.config/idem/config.json` | your token (readable by you only) | +| `IDEM_TOKEN` | a token, instead of signing in | +| `IDEM_API_URL` | another iDeploy instance (`idem login --api ` keeps it) | + +## Develop + +```bash +npm install +npm run build && node dist/index.js --help +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f7fe461 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1580 @@ +{ + "name": "@idem-ai/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@idem-ai/cli", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.1.0", + "ignore": "^5.3.1", + "picocolors": "^1.0.1" + }, + "bin": { + "idem": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsup": "^8.1.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "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/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", + "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f41312b --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@idem-ai/cli", + "version": "0.1.0", + "description": "Deploy to iDeploy from your terminal — one command: idem deploy", + "license": "Apache-2.0", + "type": "module", + "bin": { + "idem": "dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18.17" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run typecheck && npm run build" + }, + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.1.0", + "ignore": "^5.3.1", + "picocolors": "^1.0.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsup": "^8.1.0", + "typescript": "^5.5.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Idem-AI/cli.git" + }, + "keywords": [ + "idem", + "ideploy", + "deploy", + "cli", + "paas" + ] +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..1f0b177 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,111 @@ +/** + * The iDeploy API, as the CLI uses it. Every answer is `{ success, data }` or + * `{ success: false, error: { code, message } }`; errors carry the API's own + * message, meant to be shown as is. + */ +import { apiUrl, token } from './config.js'; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null + ) { + super(message); + } +} + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export interface RequestOptions { + /** false: a public route (sign-in). */ + auth?: boolean; + /** This token instead of the saved one (checking a token before saving it). */ + token?: string; +} + +export async function request(method: Method, path: string, body?: unknown, opts: RequestOptions = {}): Promise { + const headers: Record = { Accept: 'application/json', 'User-Agent': 'idem-cli' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (opts.auth !== false) { + const t = opts.token ?? token(); + if (!t) throw new ApiError('You are not signed in. Run `idem login`.', 401, 'NOT_SIGNED_IN'); + headers.Authorization = `Bearer ${t}`; + } + let response: Response; + try { + response = await fetch(`${apiUrl()}/api/v1${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + throw new ApiError(`iDeploy cannot be reached (${apiUrl()}): ${(err as Error).message}`, 0, 'NETWORK'); + } + const text = await response.text(); + let json: { success?: boolean; data?: T; error?: { code?: string; message?: string } } = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch { + /* not JSON: a proxy page */ + } + if (!response.ok || json.success === false) { + if (response.status === 401) { + throw new ApiError('Your session has expired or was revoked. Run `idem login`.', 401, 'UNAUTHORIZED'); + } + const message = json.error?.message || `iDeploy answered ${response.status}${text && !json.error ? `: ${text.slice(0, 200)}` : ''}`; + throw new ApiError(message, response.status, json.error?.code ?? null); + } + return json.data as T; +} + +export const api = { + get: (path: string, opts?: RequestOptions) => request('GET', path, undefined, opts), + post: (path: string, body?: unknown, opts?: RequestOptions) => request('POST', path, body ?? {}, opts), + put: (path: string, body?: unknown) => request('PUT', path, body ?? {}), + delete: (path: string) => request('DELETE', path), +}; + +// ── What the CLI reads ──────────────────────────────────────────────────────── + +export interface Me { + id: number; + name: string; + email: string; + team: { id: number; name: string } | null; +} + +export interface Workspace { + uuid: string; + name: string; +} + +export interface Application { + uuid: string; + name: string; + status?: string | null; + fqdn?: string | null; + /** Where it answers, computed by the API. */ + link?: string | null; + workspace_name?: string | null; + workspace_uuid?: string | null; + git_repository?: string | null; + build_pack?: string | null; + updated_at?: string | null; +} + +export interface Deployment { + deployment_uuid: string; + status: string; + logs: string | null; + application_uuid: string; + application_name: string; + application_url: string | null; + created_at?: string; + commit?: string | null; +} + +export interface EnvVar { + key: string; + value: string; +} diff --git a/src/commands/apps.ts b/src/commands/apps.ts new file mode 100644 index 0000000..d9797cb --- /dev/null +++ b/src/commands/apps.ts @@ -0,0 +1,113 @@ +/** The applications of the team, and linking this folder to one. */ +import { api, Application } from '../api.js'; +import { readLink, removeLink } from '../project.js'; +import { linkFolder, listApps, openBrowser, requireSignedIn, resolveApp } from '../shared.js'; +import { webUrl } from '../config.js'; +import { appUrl, color, fail, printJson, prompts } from '../ui.js'; + +function dot(status?: string | null): string { + const s = (status ?? '').toLowerCase(); + if (s.startsWith('running')) return color.green('●'); + if (s.startsWith('exited') || s.includes('unhealthy') || s.includes('degraded')) return color.red('●'); + return color.dim('○'); +} + +export async function apps(): Promise { + requireSignedIn(); + const list = await listApps(); + if (printJson(list.map((a) => ({ uuid: a.uuid, name: a.name, workspace: a.workspace_name, status: a.status, url: appUrl(a) })))) return; + if (list.length === 0) { + prompts.log.info(`No application yet. Run ${color.cyan('idem deploy')} in a project folder.`); + return; + } + const linked = readLink()?.applicationUuid; + const width = Math.min(Math.max(...list.map((a) => a.name.length)), 32); + const lines = list.map((a) => { + const url = appUrl(a); + return `${dot(a.status)} ${a.name.padEnd(width)} ${color.dim((a.workspace_name ?? '').padEnd(18))} ${url ? color.cyan(url) : color.dim('—')}${a.uuid === linked ? color.yellow(' ← this folder') : ''}`; + }); + process.stdout.write(lines.join('\n') + '\n'); +} + +export async function link(opts: { app?: string }): Promise { + requireSignedIn(); + let app: Application; + if (opts.app) { + app = await resolveApp(opts.app); + } else { + // Not resolveApp: it would answer with the application already linked. + if (!process.stdin.isTTY) fail('Which application?', 'Pass `--app `.'); + const list = await listApps(); + if (list.length === 0) fail('You have no application yet.', 'Run `idem deploy` here instead: it creates one.'); + const uuid = await prompts.select({ + message: 'Link this folder to', + options: list.map((a) => ({ value: a.uuid, label: a.name, hint: a.workspace_name ?? undefined })), + }); + if (prompts.isCancel(uuid)) return void prompts.cancel('Not linked.'); + app = list.find((a) => a.uuid === uuid)!; + } + linkFolder(app); + if (!printJson({ linked: app.uuid, name: app.name })) prompts.log.success(`This folder now deploys to ${color.bold(app.name)}.`); +} + +export async function unlink(): Promise { + const was = readLink(); + if (!removeLink()) { + prompts.log.info('This folder was not linked.'); + return; + } + prompts.log.success(`Unlinked from ${color.bold(was?.applicationName ?? 'the application')}. The application itself is untouched.`); +} + +interface DeploymentRow { + deployment_uuid: string; + status: string; + commit?: string | null; + created_at?: string; +} + +export async function status(opts: { app?: string }): Promise { + const app = await resolveApp(opts.app); + const [fresh, deployments, containers] = await Promise.all([ + api.get(`/applications/${app.uuid}`), + api.get(`/applications/${app.uuid}/deployments`).catch(() => [] as DeploymentRow[]), + api.get<{ status: string }>(`/applications/${app.uuid}/status`).then((r) => r.status ?? '').catch(() => ''), + ]); + const last = deployments[0]; + if (printJson({ uuid: fresh.uuid, name: fresh.name, status: fresh.status, url: appUrl(fresh), lastDeployment: last ?? null })) return; + const rows = [ + `${color.dim('Application')} ${color.bold(fresh.name)} ${dot(fresh.status)} ${fresh.status ?? 'unknown'}`, + `${color.dim('Address ')} ${appUrl(fresh) ? color.cyan(appUrl(fresh)!) : '—'}`, + `${color.dim('Workspace ')} ${fresh.workspace_name ?? '—'}`, + last + ? `${color.dim('Deployment ')} ${last.status} ${color.dim(`${last.deployment_uuid.slice(0, 8)}${last.created_at ? ` · ${new Date(last.created_at).toLocaleString()}` : ''}`)}` + : `${color.dim('Deployment ')} none yet`, + ]; + if (containers.trim()) rows.push(`${color.dim('Containers ')} ${containers.trim().split('\n').join('\n ')}`); + prompts.note(rows.join('\n'), 'Status'); +} + +export async function open(opts: { app?: string; dashboard?: boolean }): Promise { + const app = await resolveApp(opts.app); + const url = opts.dashboard ? `${webUrl()}/applications/${app.uuid}` : appUrl(app); + if (!url) fail(`${app.name} has no address yet.`, `Deploy it with ${color.cyan('idem deploy')}, or open its page: idem open --dashboard`); + if (!openBrowser(url)) process.stdout.write(url + '\n'); + else prompts.log.info(`Opened ${color.cyan(url)}`); +} + +export async function restart(opts: { app?: string; yes?: boolean }): Promise { + const app = await resolveApp(opts.app); + if (!opts.yes && process.stdin.isTTY) { + const sure = await prompts.confirm({ message: `Restart ${app.name}? It is briefly unavailable.`, initialValue: true }); + if (prompts.isCancel(sure) || !sure) return void prompts.cancel('Not restarted.'); + } + const spin = prompts.spinner(); + spin.start(`Restarting ${app.name}`); + await api.post(`/applications/${app.uuid}/restart`); + spin.stop(`${app.name} restarted.`); +} + +export async function dashboard(): Promise { + const url = webUrl(); + if (!openBrowser(url)) process.stdout.write(url + '\n'); +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..9fe37b0 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,115 @@ +/** + * Signing in: through the browser by default (a code shown here, approved on + * iDeploy), or with a token for CI (`--token`, or IDEM_TOKEN without login). + */ +import { hostname, userInfo } from 'node:os'; +import { api, ApiError, Me } from '../api.js'; +import { apiUrl, clearConfig, readConfig, token, writeConfig } from '../config.js'; +import { openBrowser, sleep } from '../shared.js'; +import { color, fail, interactive, printJson, prompts } from '../ui.js'; + +interface Started { + deviceCode: string; + userCode: string; + verificationUrl: string; + expiresIn: number; + interval: number; +} + +async function saveSignedIn(value: string, api_url?: string): Promise { + const me = await api.get('/me', { token: value }); + const config = readConfig(); + writeConfig({ + ...config, + ...(api_url ? { apiUrl: api_url } : {}), + token: value, + user: { name: me.name, email: me.email, team: me.team?.name ?? null }, + }); + return me; +} + +function machine(): string { + let user = ''; + try { + user = userInfo().username; + } catch { + /* no user name */ + } + return `${user ? `${user}@` : ''}${hostname()} (${process.platform})`; +} + +/** The browser sign-in; resolves once approved. */ +export async function browserLogin(): Promise { + const started = await api.post('/cli/auth/start', { host: machine() }, { auth: false }); + prompts.note( + `${color.bold(started.userCode)}\n\n${color.dim('Check that the page shows the same code, then authorise.')}\n${color.cyan(started.verificationUrl)}`, + 'Sign in to iDeploy' + ); + const opened = openBrowser(started.verificationUrl); + if (!opened) prompts.log.info('Open the link above in a browser (on any device).'); + + const spinner = prompts.spinner(); + spinner.start('Waiting for your approval in the browser'); + const deadline = Date.now() + started.expiresIn * 1000; + while (Date.now() < deadline) { + await sleep(started.interval * 1000); + let answer: { status: string; token?: string }; + try { + answer = await api.post<{ status: string; token?: string }>('/cli/auth/poll', { device_code: started.deviceCode }, { auth: false }); + } catch { + continue; // a network hiccup: ask again + } + if (answer.status === 'approved' && answer.token) { + const me = await saveSignedIn(answer.token); + spinner.stop(`Signed in as ${color.bold(me.name)}${me.team ? ` · team ${me.team.name}` : ''}`); + return me; + } + if (answer.status === 'denied') { + spinner.stop('Refused in the browser.', 1); + fail('Sign-in refused.'); + } + if (answer.status === 'expired') break; + } + spinner.stop('The code expired.', 1); + fail('Sign-in timed out.', 'Run `idem login` again.'); +} + +export async function login(opts: { token?: string; api?: string }): Promise { + if (opts.api) process.env.IDEM_API_URL = opts.api; + if (opts.token) { + try { + const me = await saveSignedIn(opts.token, opts.api); + if (!printJson({ signedIn: true, name: me.name, team: me.team?.name ?? null })) { + prompts.log.success(`Signed in as ${color.bold(me.name)}`); + } + } catch (err) { + fail(err instanceof ApiError && err.status === 401 ? 'This token is not valid.' : (err as Error).message); + } + return; + } + if (!interactive()) fail('Signing in through the browser needs a terminal.', 'In CI, set IDEM_TOKEN (create one under Security › API tokens), or pass --token.'); + prompts.intro(color.bgCyan(color.black(' idem '))); + if (opts.api) writeConfig({ ...readConfig(), apiUrl: opts.api }); + await browserLogin(); + prompts.outro(`Next: ${color.cyan('idem deploy')} in your project folder.`); +} + +export async function logout(): Promise { + const current = readConfig().token; + if (!current) { + prompts.log.info('You were not signed in.'); + return; + } + // `|`: the token is revoked on iDeploy too, not only forgotten here. + const id = Number(current.split('|')[0]); + if (id > 0) await api.delete(`/security/api-tokens/${id}`).catch(() => undefined); + clearConfig(); + prompts.log.success('Signed out. This machine\'s token is revoked.'); +} + +export async function whoami(): Promise { + if (!token()) fail('You are not signed in.', 'Run `idem login`.'); + const me = await api.get('/me'); + if (printJson({ name: me.name, email: me.email, team: me.team?.name ?? null, api: apiUrl() })) return; + prompts.log.info(`${color.bold(me.name)} <${me.email}>${me.team ? ` · team ${color.bold(me.team.name)}` : ''}\n${color.dim(apiUrl())}`); +} diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts new file mode 100644 index 0000000..6dcfb59 --- /dev/null +++ b/src/commands/deploy.ts @@ -0,0 +1,223 @@ +/** + * `idem deploy` — the one command. Signs in if needed, links the folder to an + * application (creating it the first time), sends the code, follows the + * deployment and ends with the address — or with why it failed. + */ +import { basename, join } from 'node:path'; +import { api, ApiError, Application, Workspace } from '../api.js'; +import { token } from '../config.js'; +import { formatBytes, MAX_BYTES, MAX_FILES, packFolder } from '../pack.js'; +import { readLink, removeLink, writeLink } from '../project.js'; +import { appNameFrom, elapsed, followDeployment, listApps, readEnvFile, resolveApp } from '../shared.js'; +import { appUrl, color, fail, interactive, isJson, printJson, prompts, unlessCancelled } from '../ui.js'; +import { browserLogin } from './auth.js'; + +export interface DeployOptions { + app?: string; + workspace?: string; + name?: string; + yes?: boolean; + detach?: boolean; + env?: boolean; +} + +interface QuickDeployResult { + deploymentUuid?: string; + applicationUuid?: string; + url?: string | null; + workspace: { uuid: string; name: string }; + linkedVariables?: { key: string; resource: string }[]; +} + +const NEW_WORKSPACE = '__new__'; + +export async function deploy(opts: DeployOptions): Promise { + const started = Date.now(); + const cwd = process.cwd(); + const ask = interactive() && !opts.yes && !isJson(); + if (!isJson()) prompts.intro(color.bgCyan(color.black(' idem deploy '))); + + // 1. Signed in — through the browser, right here, when it is the first time. + if (!token()) { + if (!interactive()) fail('You are not signed in.', 'In CI, set IDEM_TOKEN (Security › API tokens on iDeploy).'); + await browserLogin(); + } + + // 2. The code, as Git would see it. + const spin = isJson() ? null : prompts.spinner(); + spin?.start('Reading the project'); + const pack = packFolder(cwd); + if (pack.count === 0) { + spin?.stop('Nothing to send.', 1); + fail('This folder has no file to deploy.', 'Run `idem deploy` in your project folder.'); + } + if (pack.tooLarge) { + spin?.stop('Too large to send.', 1); + const heavy = pack.heaviest.map((h) => ` ${h.dir} ${formatBytes(h.bytes)}`).join('\n'); + fail( + `The project is ${formatBytes(pack.bytes)} in ${pack.count} files: iDeploy takes up to ${formatBytes(MAX_BYTES)} and ${MAX_FILES} files from the CLI.`, + `The heaviest folders:\n${heavy}\nAdd what the build does not need to .idemignore (same syntax as .gitignore), or deploy from Git on iDeploy.` + ); + } + spin?.stop(`Project read: ${pack.count} files, ${formatBytes(pack.bytes)}`); + + // 3. Which application: --app, the folder's link, else a new one. + let target: Application | null = null; + if (opts.app) { + target = await resolveApp(opts.app); + writeLink({ applicationUuid: target.uuid, applicationName: target.name, workspaceUuid: target.workspace_uuid, workspaceName: target.workspace_name }); + } else { + const link = readLink(); + if (link) { + target = await api.get(`/applications/${link.applicationUuid}`).catch((err: unknown) => { + if (err instanceof ApiError && err.status === 404) return null; + throw err; + }); + if (!target) { + removeLink(); + prompts.log.warn(`"${link.applicationName}" no longer exists on iDeploy: a new application is created.`); + } + } else if (ask) { + // A folder deployed before from another machine: offer the application of the same name. + const name = appNameFrom(basename(cwd)); + const same = (await listApps()).filter((a) => a.name.toLowerCase() === name); + if (same.length > 0) { + const choice = unlessCancelled( + await prompts.select({ + message: `An application "${name}" exists already. Deploy this folder to it?`, + options: [ + ...same.map((a) => ({ value: a.uuid, label: `Yes — ${a.name}`, hint: a.workspace_name ?? undefined })), + { value: '', label: 'No — create a new application' }, + ], + }) + ); + if (choice) { + target = same.find((a) => a.uuid === choice)!; + writeLink({ applicationUuid: target.uuid, applicationName: target.name, workspaceUuid: target.workspace_uuid, workspaceName: target.workspace_name }); + } + } + } + } + + let deploymentUuid: string; + let applicationUuid: string; + let applicationName: string; + let url: string | null = null; + + if (target) { + // 4a. An existing application: its code is replaced, a deployment queued. + spin?.start(`Sending the code to ${color.bold(target.name)}`); + const sent = await api.put<{ deploymentUuid: string | null }>(`/applications/${target.uuid}/source`, { files: pack.files, deploy: true }); + if (!sent.deploymentUuid) { + spin?.stop('Code sent, but no deployment was queued.', 1); + fail('iDeploy did not queue a deployment.'); + } + spin?.stop(`Code sent to ${color.bold(target.name)}`); + deploymentUuid = sent.deploymentUuid; + applicationUuid = target.uuid; + applicationName = target.name; + url = appUrl(target); + } else { + // 4b. A new application: its name, its workspace, its variables. + let name = appNameFrom(opts.name ?? basename(cwd)); + if (ask && !opts.name) { + name = appNameFrom( + unlessCancelled( + await prompts.text({ + message: 'Name of the application', + initialValue: name, + validate: (v) => (appNameFrom(v) ? undefined : 'Letters, digits and dashes.'), + }) + ) + ); + } + + let workspace: { workspace_uuid?: string; workspace_name?: string } = { workspace_name: opts.workspace ?? name }; + if (opts.workspace) { + const found = (await api.get('/workspaces')).find( + (w) => w.uuid === opts.workspace || w.name.toLowerCase() === opts.workspace!.toLowerCase() + ); + workspace = found ? { workspace_uuid: found.uuid } : { workspace_name: opts.workspace }; + } else if (ask) { + const workspaces = await api.get('/workspaces'); + if (workspaces.length > 0) { + const choice = unlessCancelled( + await prompts.select({ + message: 'Workspace (its databases are linked automatically)', + options: [ + { value: NEW_WORKSPACE, label: `New workspace "${name}"` }, + ...workspaces.map((w) => ({ value: w.uuid, label: w.name })), + ], + }) + ); + workspace = choice === NEW_WORKSPACE ? { workspace_name: name } : { workspace_uuid: String(choice) }; + } + } + + // The .env holds this machine's values: sent only when the developer agrees. + let environment_variables: { key: string; value: string }[] = []; + const local = readEnvFile(join(cwd, '.env')); + if (local && local.length > 0) { + let send = Boolean(opts.env); + if (ask && !opts.env) { + send = unlessCancelled( + await prompts.confirm({ + message: `Send the ${local.length} variables of .env? (${local + .slice(0, 4) + .map((v) => v.key) + .join(', ')}${local.length > 4 ? '…' : ''}) — iDeploy replaces local addresses by its own databases`, + initialValue: true, + }) + ); + } + if (send) environment_variables = local; + } + + spin?.start(`Creating ${color.bold(name)} and sending the code`); + const created = await api.post('/quick-deploy', { + name, + ...workspace, + files: pack.files, + environment_variables, + }); + if (!created.applicationUuid || !created.deploymentUuid) { + spin?.stop('Not created.', 1); + fail('iDeploy did not create the application.'); + } + spin?.stop(`Created ${color.bold(name)} in workspace ${color.bold(created.workspace.name)}`); + writeLink({ applicationUuid: created.applicationUuid, applicationName: name, workspaceUuid: created.workspace.uuid, workspaceName: created.workspace.name }); + if (created.linkedVariables?.length && !isJson()) { + prompts.log.info( + `Linked to the workspace: ${created.linkedVariables.map((v) => `${color.bold(v.key)} → ${v.resource}`).join(', ')}` + ); + } + deploymentUuid = created.deploymentUuid; + applicationUuid = created.applicationUuid; + applicationName = name; + url = created.url ?? null; + } + + if (opts.detach) { + if (!printJson({ deploymentUuid, applicationUuid, url })) { + prompts.outro(`Deployment queued. Follow it with ${color.cyan('idem status')}${url ? ` · ${color.cyan(url)}` : ''}`); + } + return; + } + + // 5. Followed to its end, live. + if (!isJson()) prompts.log.step(`Deploying ${color.bold(applicationName)} ${color.dim(`(${deploymentUuid.slice(0, 8)})`)}`); + const final = await followDeployment(deploymentUuid, { quiet: isJson() }); + url = final.application_url ?? url; + + if (final.status === 'finished') { + if (!printJson({ status: 'finished', deploymentUuid, applicationUuid, url })) { + prompts.outro(`${color.green('Live')}${url ? ` at ${color.cyan(color.bold(url))}` : ''} ${color.dim(`(${elapsed(started)})`)}`); + } + return; + } + if (printJson({ status: final.status, deploymentUuid, applicationUuid, url })) process.exit(1); + fail( + final.status === 'cancelled-by-user' ? 'The deployment was cancelled.' : 'The deployment failed — the cause is in the log above.', + `The previous version, if any, is still the one serving. Logs: ${color.cyan('idem logs')} · page: ${color.cyan('idem open --dashboard')}` + ); +} diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 0000000..cb53301 --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,52 @@ +/** An application's variables: list (masked), set, remove, write to a local .env. */ +import { existsSync, writeFileSync } from 'node:fs'; +import { api, EnvVar } from '../api.js'; +import { parseEnv, resolveApp } from '../shared.js'; +import { color, fail, interactive, printJson, prompts } from '../ui.js'; + +function mask(value: string): string { + if (!value) return color.dim('(empty)'); + return value.length <= 4 ? '••••' : `${value.slice(0, 2)}${'•'.repeat(Math.min(value.length - 2, 10))}`; +} + +export async function envList(opts: { app?: string; reveal?: boolean }): Promise { + const app = await resolveApp(opts.app); + const vars = await api.get(`/applications/${app.uuid}/envs`); + if (printJson(opts.reveal ? vars.map((v) => ({ key: v.key, value: v.value })) : vars.map((v) => ({ key: v.key })))) return; + if (vars.length === 0) return void prompts.log.info(`${app.name} has no variable.`); + const width = Math.min(Math.max(...vars.map((v) => v.key.length)), 40); + process.stdout.write(vars.map((v) => `${v.key.padEnd(width)} ${opts.reveal ? v.value : mask(v.value)}`).join('\n') + '\n'); + if (!opts.reveal) prompts.log.info(color.dim('Values hidden — --reveal shows them.')); +} + +export async function envSet(pairs: string[], opts: { app?: string }): Promise { + const parsed = parseEnv(pairs.join('\n')); + if (parsed.length === 0 || parsed.length !== pairs.length) fail('Give variables as KEY=value.', 'Example: idem env set APP_ENV=production MAIL_HOST=smtp.example.com'); + const app = await resolveApp(opts.app); + for (const v of parsed) await api.post(`/applications/${app.uuid}/envs`, { key: v.key, value: v.value, is_runtime: true }); + if (printJson({ set: parsed.map((v) => v.key) })) return; + prompts.log.success(`Set ${parsed.map((v) => color.bold(v.key)).join(', ')} on ${app.name}.`); + prompts.log.info(`They apply at the next deployment: ${color.cyan('idem deploy')} (or redeploy from iDeploy).`); +} + +export async function envRemove(keys: string[], opts: { app?: string }): Promise { + if (keys.length === 0) fail('Which variable?', 'Example: idem env rm OLD_KEY'); + const app = await resolveApp(opts.app); + for (const key of keys) await api.delete(`/applications/${app.uuid}/envs/${encodeURIComponent(key)}`); + if (!printJson({ removed: keys })) prompts.log.success(`Removed ${keys.map((k) => color.bold(k)).join(', ')} from ${app.name}.`); +} + +/** The deployed values in a local file — never over an existing one without asking. */ +export async function envPull(file: string | undefined, opts: { app?: string; yes?: boolean }): Promise { + const target = file ?? '.env'; + const app = await resolveApp(opts.app); + if (existsSync(target) && !opts.yes) { + if (!interactive()) fail(`${target} exists already.`, 'Pass --yes to replace it, or give another file name.'); + const replace = await prompts.confirm({ message: `${target} exists. Replace it?`, initialValue: false }); + if (prompts.isCancel(replace) || !replace) return void prompts.cancel('Kept as it was.'); + } + const vars = await api.get(`/applications/${app.uuid}/envs`); + const quote = (v: string) => (/[\s#"'$\\]/.test(v) ? `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` : v); + writeFileSync(target, `# ${app.name} — written by idem env pull\n` + vars.map((v) => `${v.key}=${quote(v.value)}`).join('\n') + '\n', { mode: 0o600 }); + if (!printJson({ file: target, count: vars.length })) prompts.log.success(`${vars.length} variables written to ${target}. Keep it out of Git.`); +} diff --git a/src/commands/logs.ts b/src/commands/logs.ts new file mode 100644 index 0000000..1af5a00 --- /dev/null +++ b/src/commands/logs.ts @@ -0,0 +1,56 @@ +/** What the running application prints — the last lines, or followed live (-f). */ +import { api } from '../api.js'; +import { resolveApp, sleep } from '../shared.js'; +import { color, fail, isJson } from '../ui.js'; + +interface LogsAnswer { + container: string | null; + logs: string; +} + +/** `2026-10-18T23:27:44.123456789Z text` → its time and text. */ +function split(line: string): { at: string | null; text: string } { + const m = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s?(.*)$/.exec(line); + return m ? { at: m[1], text: m[2] } : { at: null, text: line }; +} + +function print(lines: string[]): void { + for (const line of lines) { + const { at, text } = split(line); + if (isJson()) { + process.stdout.write(JSON.stringify({ at, text }) + '\n'); + continue; + } + const time = at ? color.dim(new Date(at).toLocaleTimeString()) + ' ' : ''; + const painted = /\b(error|exception|fatal|SQLSTATE)\b/i.test(text) ? color.red(text) : /\bwarn(ing)?\b/i.test(text) ? color.yellow(text) : text; + process.stdout.write(time + painted + '\n'); + } +} + +export async function logs(opts: { app?: string; follow?: boolean; lines?: string }): Promise { + const app = await resolveApp(opts.app); + const tail = Math.min(Math.max(Number(opts.lines) || 200, 1), 2000); + const first = await api.get(`/applications/${app.uuid}/logs?tail=${tail}`); + if (!first.container) fail(`${app.name} is not running: no container to read.`, 'Deploy it with `idem deploy`, or see `idem status`.'); + let lines = first.logs.split('\n').filter(Boolean); + print(lines); + if (!opts.follow) return; + + // Following: asks for what came since the last line, and drops the ones already shown at that same instant. + let last = lines.length ? split(lines[lines.length - 1]).at : null; + let seenAtLast = new Set(lines.filter((l) => split(l).at === last)); + for (;;) { + await sleep(2000); + const since = last ? `&since=${encodeURIComponent(last)}` : ''; + const next = await api.get(`/applications/${app.uuid}/logs?tail=2000${since}`).catch(() => null); + if (!next) continue; + if (!next.container) fail(`${app.name} stopped.`); + lines = next.logs.split('\n').filter(Boolean).filter((l) => !seenAtLast.has(l)); + if (lines.length === 0) continue; + print(lines); + const newest = split(lines[lines.length - 1]).at; + if (newest !== last) seenAtLast = new Set(); + last = newest ?? last; + for (const l of lines) if (split(l).at === last) seenAtLast.add(l); + } +} diff --git a/src/commands/menu.ts b/src/commands/menu.ts new file mode 100644 index 0000000..64d4434 --- /dev/null +++ b/src/commands/menu.ts @@ -0,0 +1,71 @@ +/** + * `idem` alone: a menu, like Render's — the application of this folder first, + * then everything else, without remembering a single command. + */ +import { basename } from 'node:path'; +import { token } from '../config.js'; +import { readLink } from '../project.js'; +import { color, prompts, unlessCancelled } from '../ui.js'; +import { browserLogin, logout } from './auth.js'; +import { deploy } from './deploy.js'; +import { apps, dashboard, link, open, restart, status } from './apps.js'; +import { logs } from './logs.js'; +import { rollback } from './rollback.js'; +import { envList } from './env.js'; + +type Action = 'deploy' | 'logs' | 'status' | 'open' | 'restart' | 'rollback' | 'env' | 'apps' | 'link' | 'dashboard' | 'logout' | 'quit'; + +export async function menu(): Promise { + prompts.intro(color.bgCyan(color.black(' idem '))); + if (!token()) await browserLogin(); + const linked = readLink(); + const here = linked ? `${color.bold(linked.applicationName)}` : color.bold(basename(process.cwd())); + + const options: { value: Action; label: string; hint?: string }[] = [ + { value: 'deploy', label: linked ? `Deploy ${linked.applicationName}` : `Deploy this folder`, hint: 'idem deploy' }, + ...(linked + ? ([ + { value: 'logs', label: 'Logs, live', hint: 'idem logs -f' }, + { value: 'status', label: 'Status', hint: 'idem status' }, + { value: 'open', label: 'Open in the browser', hint: 'idem open' }, + { value: 'env', label: 'Variables', hint: 'idem env' }, + { value: 'restart', label: 'Restart', hint: 'idem restart' }, + { value: 'rollback', label: 'Back to an earlier version', hint: 'idem rollback' }, + ] as { value: Action; label: string; hint?: string }[]) + : [{ value: 'link' as Action, label: 'Link this folder to an existing application', hint: 'idem link' }]), + { value: 'apps', label: 'All my applications', hint: 'idem apps' }, + { value: 'dashboard', label: 'Open iDeploy', hint: 'idem dashboard' }, + { value: 'logout', label: 'Sign out', hint: 'idem logout' }, + { value: 'quit', label: 'Quit' }, + ]; + const action = unlessCancelled( + await prompts.select({ message: linked ? `This folder deploys to ${here}` : 'What do you want to do?', options }) + ) as Action; + + switch (action) { + case 'deploy': + return deploy({}); + case 'logs': + return logs({ follow: true }); + case 'status': + return status({}); + case 'open': + return open({}); + case 'env': + return envList({}); + case 'restart': + return restart({}); + case 'rollback': + return rollback({}); + case 'apps': + return apps(); + case 'link': + return link({}); + case 'dashboard': + return dashboard(); + case 'logout': + return logout(); + case 'quit': + prompts.outro('See you.'); + } +} diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts new file mode 100644 index 0000000..e3dab2c --- /dev/null +++ b/src/commands/rollback.ts @@ -0,0 +1,48 @@ +/** Back to a version that worked: a past deployment, built again. */ +import { api } from '../api.js'; +import { followDeployment, resolveApp } from '../shared.js'; +import { color, fail, interactive, printJson, prompts, unlessCancelled } from '../ui.js'; + +interface Target { + deploymentUuid: string; + commit: string; + status: string; + finishedAt: string | null; +} + +export async function rollback(opts: { app?: string; to?: string; yes?: boolean; detach?: boolean }): Promise { + const app = await resolveApp(opts.app); + const targets = await api.get(`/applications/${app.uuid}/rollback-targets`); + if (targets.length < 2 && !opts.to) fail(`${app.name} has no earlier version to go back to.`); + + let chosen = opts.to ? targets.find((t) => t.deploymentUuid.startsWith(opts.to!)) : undefined; + if (opts.to && !chosen) fail(`No successful deployment "${opts.to}".`); + if (!chosen) { + if (!interactive() || opts.yes) { + chosen = targets[1]; + } else { + const uuid = unlessCancelled( + await prompts.select({ + message: 'Back to which version?', + options: targets.map((t, i) => ({ + value: t.deploymentUuid, + label: `${t.deploymentUuid.slice(0, 8)} ${t.commit === 'HEAD' ? '' : t.commit.slice(0, 7)}`, + hint: `${t.finishedAt ? new Date(t.finishedAt).toLocaleString() : ''}${i === 0 ? ' · current' : ''}`, + })), + initialValue: targets[1]?.deploymentUuid, + }) + ); + chosen = targets.find((t) => t.deploymentUuid === uuid)!; + } + } + + const started = await api.post<{ deploymentUuid: string }>(`/applications/${app.uuid}/rollback`, { deployment_uuid: chosen.deploymentUuid }); + if (opts.detach) { + if (!printJson(started)) prompts.log.success(`Rollback queued (${started.deploymentUuid.slice(0, 8)}).`); + return; + } + prompts.log.step(`Rolling ${color.bold(app.name)} back to ${chosen.deploymentUuid.slice(0, 8)}`); + const final = await followDeployment(started.deploymentUuid); + if (final.status !== 'finished') fail('The rollback failed — the cause is in the log above.'); + prompts.outro(`${color.green('Back')} on ${chosen.deploymentUuid.slice(0, 8)}${final.application_url ? ` · ${color.cyan(final.application_url)}` : ''}`); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..14c3aea --- /dev/null +++ b/src/config.ts @@ -0,0 +1,54 @@ +/** + * Where the CLI keeps who you are: ~/.config/idem/config.json, readable by you + * only. IDEM_TOKEN and IDEM_API_URL override it (CI, another instance). + */ +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const DEFAULT_API_URL = 'https://ideploy-api.idem.africa'; +export const DEFAULT_WEB_URL = 'https://ideploy.idem.africa'; + +export interface Config { + apiUrl?: string; + token?: string; + user?: { name: string; email: string; team?: string | null }; +} + +function configPath(): string { + const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(base, 'idem', 'config.json'); +} + +export function readConfig(): Config { + try { + return JSON.parse(readFileSync(configPath(), 'utf8')) as Config; + } catch { + return {}; + } +} + +export function writeConfig(config: Config): void { + const path = configPath(); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + // An existing file keeps its mode on write: set it again. + chmodSync(path, 0o600); +} + +export function clearConfig(): void { + const path = configPath(); + if (existsSync(path)) rmSync(path); +} + +export function apiUrl(): string { + return (process.env.IDEM_API_URL || readConfig().apiUrl || DEFAULT_API_URL).replace(/\/+$/, ''); +} + +export function webUrl(): string { + return (process.env.IDEM_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, ''); +} + +export function token(): string | null { + return process.env.IDEM_TOKEN || readConfig().token || null; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ae5c498 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,78 @@ +/** + * idem — deploy to iDeploy from your terminal. + * + * idem deploy the one command: sign in, link, send, follow, address + * idem a menu, when you do not remember the command + */ +import { Command } from 'commander'; +import { createRequire } from 'node:module'; +import { run, setJson, interactive } from './ui.js'; +import { login, logout, whoami } from './commands/auth.js'; +import { deploy } from './commands/deploy.js'; +import { apps, dashboard, link, open, restart, status, unlink } from './commands/apps.js'; +import { logs } from './commands/logs.js'; +import { rollback } from './commands/rollback.js'; +import { envList, envPull, envRemove, envSet } from './commands/env.js'; +import { menu } from './commands/menu.js'; + +const { version } = createRequire(import.meta.url)('../package.json') as { version: string }; + +const program = new Command(); +program + .name('idem') + .description('Deploy to iDeploy from your terminal. Start with: idem deploy') + .version(version, '-v, --version') + .option('--json', 'machine-readable output (scripts, CI)') + .hook('preAction', (_program, action) => setJson(Boolean(action.optsWithGlobals().json))) + .action( + run(async () => { + if (!interactive()) return void program.help(); + await menu(); + }) + ); + +program + .command('deploy') + .description('Deploy this folder — creates the application the first time') + .option('-a, --app ', 'deploy to this application (name or id)') + .option('-w, --workspace ', 'workspace of a new application') + .option('-n, --name ', 'name of a new application (default: the folder name)') + .option('-y, --yes', 'ask nothing: defaults everywhere (CI)') + .option('--env', 'send the variables of .env with a new application') + .option('-d, --detach', 'queue the deployment and return without following it') + .action(run(deploy)); + +program.command('login').description('Sign in through the browser').option('--token ', 'sign in with an API token instead (CI)').option('--api ', 'another iDeploy instance').action(run(login)); +program.command('logout').description('Sign out and revoke this machine\'s token').action(run(logout)); +program.command('whoami').description('Who you are signed in as').action(run(whoami)); + +program.command('apps').alias('ls').description('Your applications').action(run(apps)); +program.command('link').description('Link this folder to an existing application').option('-a, --app ', 'the application').action(run(link)); +program.command('unlink').description('Forget which application this folder deploys to').action(run(unlink)); +program.command('status').description('Status of the application and its last deployment').option('-a, --app ').action(run(status)); +program + .command('logs') + .description('What the running application prints') + .option('-a, --app ') + .option('-f, --follow', 'keep printing new lines') + .option('-n, --lines ', 'how many of the last lines', '200') + .action(run(logs)); +program.command('open').description('Open the application in the browser').option('-a, --app ').option('--dashboard', 'open its page on iDeploy instead').action(run(open)); +program.command('dashboard').description('Open iDeploy in the browser').action(run(dashboard)); +program.command('restart').description('Restart the application').option('-a, --app ').option('-y, --yes', 'do not ask').action(run(restart)); +program + .command('rollback') + .description('Go back to an earlier version') + .option('-a, --app ') + .option('--to ', 'the deployment to go back to (id, or its first characters)') + .option('-y, --yes', 'the previous version, without asking') + .option('-d, --detach', 'do not follow it') + .action(run(rollback)); + +const env = program.command('env').description('Variables of the application'); +env.command('ls', { isDefault: true }).description('List them (values hidden)').option('-a, --app ').option('--reveal', 'show the values').action(run(envList)); +env.command('set').description('Set KEY=value…').argument('').option('-a, --app ').action(run(envSet)); +env.command('rm').description('Remove variables').argument('').option('-a, --app ').action(run(envRemove)); +env.command('pull').description('Write them to a local .env').argument('[file]').option('-a, --app ').option('-y, --yes', 'replace an existing file').action(run(envPull)); + +program.parseAsync(process.argv); diff --git a/src/pack.ts b/src/pack.ts new file mode 100644 index 0000000..973d425 --- /dev/null +++ b/src/pack.ts @@ -0,0 +1,116 @@ +/** + * The code sent to iDeploy: the folder as Git would see it — `.gitignore` and + * `.idemignore` respected, nested ones too — never `.git`, `node_modules` nor + * an `.env` with secrets. Text as is, binaries as base64, within the limits + * the API accepts. + */ +import { lstatSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import ignore, { Ignore } from 'ignore'; + +/** The API's limits (application-source.service.ts). */ +export const MAX_FILES = 3000; +export const MAX_BYTES = 9 * 1024 * 1024; + +/** Never sent, whatever the ignore files say. */ +const ALWAYS = ['.git/', 'node_modules/', '.idem/', '.DS_Store', 'Thumbs.db', '.env', '.env.*', '!.env.example', '!.env.sample', '!.env.dist', '!.env.template']; + +export type PackedFile = string | { base64: string }; + +export interface Pack { + files: Record; + count: number; + bytes: number; + /** The largest folders, when the pack is over a limit: what to add to .idemignore. */ + heaviest: { dir: string; bytes: number }[]; + tooLarge: boolean; +} + +interface Rule { + /** The folder the ignore file is in, relative to the root ('' for the root). */ + base: string; + ig: Ignore; +} + +function rulesIn(root: string, dir: string): Rule[] { + const ig = ignore(); + let found = false; + for (const name of ['.gitignore', '.idemignore']) { + try { + ig.add(readFileSync(join(root, dir, name), 'utf8')); + found = true; + } catch { + /* none here */ + } + } + return found ? [{ base: dir, ig }] : []; +} + +function ignored(path: string, isDir: boolean, rules: Rule[], always: Ignore): boolean { + const target = isDir ? `${path}/` : path; + if (always.ignores(target)) return true; + for (const { base, ig } of rules) { + const rel = base ? (target.startsWith(`${base}/`) ? target.slice(base.length + 1) : null) : target; + if (rel && ig.ignores(rel)) return true; + } + return false; +} + +/** A binary file has a NUL byte early on; it travels as base64. */ +function isBinary(buffer: Buffer): boolean { + return buffer.subarray(0, 8000).includes(0); +} + +export function packFolder(root: string): Pack { + const always = ignore().add(ALWAYS); + const files: Record = {}; + const perDir = new Map(); + let count = 0; + let bytes = 0; + + const walk = (dir: string, rules: Rule[]): void => { + const here = [...rules, ...rulesIn(root, dir)]; + let entries: string[]; + try { + entries = readdirSync(join(root, dir)).sort(); + } catch { + return; + } + for (const name of entries) { + const path = dir ? `${dir}/${name}` : name; + let stat; + try { + stat = lstatSync(join(root, path)); + } catch { + continue; + } + // Links are not followed: one could point anywhere on this machine. + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + if (!ignored(path, true, here, always)) walk(path, here); + continue; + } + if (!stat.isFile() || ignored(path, false, here, always)) continue; + count++; + bytes += stat.size; + const top = path.includes('/') ? path.split('/')[0] : '.'; + perDir.set(top, (perDir.get(top) ?? 0) + stat.size); + if (count > MAX_FILES || bytes > MAX_BYTES) continue; + const content = readFileSync(join(root, path)); + files[path] = isBinary(content) ? { base64: content.toString('base64') } : content.toString('utf8'); + } + }; + walk('', []); + + const heaviest = [...perDir.entries()] + .map(([dir, size]) => ({ dir, bytes: size })) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, 5); + return { files, count, bytes, heaviest, tooLarge: count > MAX_FILES || bytes > MAX_BYTES }; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/src/project.ts b/src/project.ts new file mode 100644 index 0000000..adc7805 --- /dev/null +++ b/src/project.ts @@ -0,0 +1,47 @@ +/** + * Which application this folder deploys to: `.idem/project.json`, written by + * the first `idem deploy` (or `idem link`) and kept out of Git. + */ +import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface ProjectLink { + applicationUuid: string; + applicationName: string; + workspaceUuid?: string | null; + workspaceName?: string | null; +} + +const DIR = '.idem'; +const FILE = 'project.json'; + +export function readLink(cwd = process.cwd()): ProjectLink | null { + try { + const link = JSON.parse(readFileSync(join(cwd, DIR, FILE), 'utf8')) as ProjectLink; + return link.applicationUuid ? link : null; + } catch { + return null; + } +} + +export function writeLink(link: ProjectLink, cwd = process.cwd()): void { + mkdirSync(join(cwd, DIR), { recursive: true }); + writeFileSync(join(cwd, DIR, FILE), JSON.stringify(link, null, 2) + '\n'); + ignoreInGit(cwd); +} + +export function removeLink(cwd = process.cwd()): boolean { + const path = join(cwd, DIR, FILE); + if (!existsSync(path)) return false; + rmSync(path); + return true; +} + +/** `.idem/` names this machine's link, not the project: kept out of the repository. */ +function ignoreInGit(cwd: string): void { + const gitignore = join(cwd, '.gitignore'); + const content = existsSync(gitignore) ? readFileSync(gitignore, 'utf8') : ''; + if (/^\/?\.idem\/?\s*$/m.test(content)) return; + if (!existsSync(join(cwd, '.git')) && !content) return; + appendFileSync(gitignore, `${content && !content.endsWith('\n') ? '\n' : ''}.idem/\n`); +} diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..0878705 --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,188 @@ +/** + * What several commands need: the browser, the application a command is about, + * a deployment followed to its end, a .env read. + */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { api, Application, Deployment } from './api.js'; +import { readLink, writeLink } from './project.js'; +import { token } from './config.js'; +import { color, fail, interactive, prompts, unlessCancelled } from './ui.js'; + +/** Opens a page in the default browser; false when there is none (SSH, CI). */ +export function openBrowser(url: string): boolean { + if (!interactive()) return false; + const [cmd, args] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]] + : ['xdg-open', [url]]; + try { + const child = spawn(cmd, args as string[], { stdio: 'ignore', detached: true }); + child.on('error', () => undefined); + child.unref(); + return true; + } catch { + return false; + } +} + +export function requireSignedIn(): void { + if (!token()) fail('You are not signed in.', 'Run `idem login` — or set IDEM_TOKEN in CI.'); +} + +export async function listApps(): Promise { + return api.get('/applications'); +} + +/** + * The application a command acts on: `--app `, else the one this + * folder is linked to, else one chosen from the list. + */ +export async function resolveApp(option?: string): Promise { + requireSignedIn(); + if (option) { + const apps = await listApps(); + const wanted = option.toLowerCase(); + const found = apps.filter((a) => a.uuid === option || a.name.toLowerCase() === wanted); + if (found.length === 1) return found[0]; + if (found.length > 1) fail(`Several applications are named "${option}".`, 'Use its id: `idem apps` lists them.'); + fail(`No application "${option}".`, '`idem apps` lists them.'); + } + const link = readLink(); + if (link) { + try { + return await api.get(`/applications/${link.applicationUuid}`); + } catch { + fail(`This folder is linked to "${link.applicationName}", which no longer exists.`, 'Run `idem unlink`, then `idem deploy`.'); + } + } + if (!interactive()) fail('No application given.', 'Pass `--app `, or run it in a folder linked with `idem link`.'); + const apps = await listApps(); + if (apps.length === 0) fail('You have no application yet.', 'Run `idem deploy` in your project folder.'); + const uuid = unlessCancelled( + await prompts.select({ + message: 'Which application?', + options: apps.map((a) => ({ value: a.uuid, label: a.name, hint: a.workspace_name ?? undefined })), + }) + ); + return apps.find((a) => a.uuid === uuid)!; +} + +export function linkFolder(app: Application): void { + writeLink({ + applicationUuid: app.uuid, + applicationName: app.name, + workspaceUuid: app.workspace_uuid ?? null, + workspaceName: app.workspace_name ?? null, + }); +} + +const FINAL = new Set(['finished', 'failed', 'cancelled-by-user']); + +/** A log line, coloured by what it says. */ +function paint(line: string): string { + if (/^\s*(❌|✗|ERROR|Error:)/.test(line)) return color.red(line); + if (/^\s*(⚠|WARN)/.test(line)) return color.yellow(line); + if (/^\s*(✅|✓)/.test(line)) return color.green(line); + if (/^\s*──►/.test(line)) return color.cyan(color.bold(line)); + if (/^\s*\$ /.test(line)) return color.dim(line); + return line; +} + +/** What is new in a log the API returns whole (its start may have been trimmed). */ +export function newPart(previous: string, current: string): string { + if (!previous) return current; + if (current.startsWith(previous)) return current.slice(previous.length); + const tail = previous.slice(-400); + const at = current.lastIndexOf(tail); + return at >= 0 ? current.slice(at + tail.length) : current; +} + +/** + * Follows a deployment to its end, printing its log as it is written. + * Resolves with the final deployment. + */ +export async function followDeployment(deploymentUuid: string, opts: { quiet?: boolean } = {}): Promise { + let printed = ''; + let partial = ''; + let failures = 0; + const started = Date.now(); + for (;;) { + let d: Deployment; + try { + d = await api.get(`/deploy/${deploymentUuid}`); + failures = 0; + } catch (err) { + // A network hiccup during a long build is not the deployment failing. + if (++failures >= 10) throw err; + await sleep(3000); + continue; + } + const logs = d.logs ?? ''; + const fresh = newPart(printed, logs); + printed = logs; + if (!opts.quiet && fresh) { + const text = partial + fresh; + const lines = text.split('\n'); + partial = FINAL.has(d.status) ? '' : (lines.pop() ?? ''); + for (const line of FINAL.has(d.status) ? lines.filter(Boolean) : lines) process.stdout.write(`${color.dim('│')} ${paint(line)}\n`); + } + if (FINAL.has(d.status)) return d; + // Hours, not minutes: a build can be long; Ctrl+C stops following, not the deployment. + if (Date.now() - started > 3 * 3600 * 1000) return d; + await sleep(2000); + } +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** KEY=VALUE lines of a .env file: comments, `export`, quotes handled. */ +export function parseEnv(text: string): { key: string; value: string }[] { + const out: { key: string; value: string }[] = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(line); + if (!m) continue; + let value = m[2]; + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.lastIndexOf(quote) > 0) { + value = value.slice(1, value.lastIndexOf(quote)); + if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"'); + } else { + value = value.replace(/\s+#.*$/, '').trim(); + } + out.push({ key: m[1], value }); + } + return out; +} + +export function readEnvFile(path: string): { key: string; value: string }[] | null { + try { + return parseEnv(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +/** A name iDeploy accepts, from a folder name. */ +export function appNameFrom(folder: string): string { + return ( + folder + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50) || 'my-app' + ); +} + +export function elapsed(since: number): string { + const s = Math.round((Date.now() - since) / 1000); + return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')}`; +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..13ca95a --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,69 @@ +/** + * How the CLI talks: colours and prompts on a terminal, plain lines (or JSON) + * when it is piped or runs in CI — a prompt there would hang the job. + */ +import pc from 'picocolors'; +import * as p from '@clack/prompts'; +import { ApiError } from './api.js'; + +export const color = pc; +export const prompts = p; + +/** A person at a terminal, who can answer a question. */ +export function interactive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI; +} + +let jsonMode = false; +export function setJson(on: boolean): void { + jsonMode = on; +} +export function isJson(): boolean { + return jsonMode; +} + +/** Prints the result for a script, when --json was asked. Returns whether it did. */ +export function printJson(value: unknown): boolean { + if (!jsonMode) return false; + process.stdout.write(JSON.stringify(value, null, 2) + '\n'); + return true; +} + +/** Stops with a message the developer can act on, and a non-zero exit code. */ +export function fail(message: string, hint?: string): never { + if (jsonMode) { + process.stderr.write(JSON.stringify({ error: message }) + '\n'); + } else { + p.log.error(message); + if (hint) p.log.info(color.dim(hint)); + } + process.exit(1); +} + +/** A cancelled prompt (Ctrl+C) ends quietly. */ +export function unlessCancelled(value: T): Exclude { + if (p.isCancel(value)) { + p.cancel('Cancelled.'); + process.exit(130); + } + return value as Exclude; +} + +/** Runs a command, turning API and unexpected errors into a clear message. */ +export function run(fn: (...args: A) => Promise): (...args: A) => Promise { + return async (...args: A) => { + try { + await fn(...args); + } catch (err) { + if (err instanceof ApiError) fail(err.message); + fail((err as Error)?.message ?? String(err)); + } + }; +} + +/** The address an application answers at, first one, with its scheme. */ +export function appUrl(app: { link?: string | null; fqdn?: string | null }): string | null { + if (app.link) return app.link; + const first = app.fqdn?.split(',')[0]?.trim(); + return first ? (first.startsWith('http') ? first : `https://${first}`) : null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..71bdccc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..7200bc6 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node18', + platform: 'node', + clean: true, + banner: { js: '#!/usr/bin/env node' }, +});