From f27b228362d08c8bb4a695d7068043dbf75bf6ff Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 08:41:20 +0300 Subject: [PATCH 01/21] Add multi-environment auth with encrypted v2 config. Support named environments, global --environment selection, AES-256-GCM credential storage with OS keyring and key-file fallback, and auth list/use/unpair commands. Co-authored-by: Cursor --- README.md | 36 +- package.json | 1 + pnpm-lock.yaml | 141 +++++++- skills/t3code-cli/reference/commands.md | 5 +- skills/t3code-cli/reference/setup.md | 76 +++- src/auth/auth.test.ts | 263 ++++++++++++++ src/auth/index.ts | 7 + src/auth/layer.ts | 158 +++++++-- src/auth/service.ts | 30 +- src/auth/type.ts | 48 ++- src/bin.ts | 7 +- src/cli/app.ts | 2 + src/cli/auth-format.ts | 67 +++- src/cli/auth.ts | 164 ++++++++- src/cli/confirm.ts | 30 ++ src/cli/environment-flag.ts | 8 + src/cli/flags.ts | 10 + src/cli/selection-layer.ts | 27 ++ src/config/codec.ts | 62 ++++ src/config/config.test.ts | 438 ++++++++++++++++++++++++ src/config/credential-service.ts | 28 ++ src/config/credential.test.ts | 149 ++++++++ src/config/credential.ts | 220 ++++++++++++ src/config/environment-name.ts | 48 +++ src/config/file-mode.ts | 22 ++ src/config/index.ts | 14 +- src/config/keyring.ts | 54 +++ src/config/layer.ts | 259 ++++++++++---- src/config/migration.ts | 83 +++++ src/config/paths.ts | 20 ++ src/config/persist.ts | 65 ++++ src/config/resolve.test.ts | 167 +++++++++ src/config/resolve.ts | 101 ++++++ src/config/schema.ts | 33 ++ src/config/selection-layer.ts | 21 ++ src/config/selection-resolve.ts | 14 + src/config/selection.ts | 9 + src/config/service.ts | 37 +- src/config/types.ts | 45 +++ src/runtime/index.ts | 2 + src/runtime/layer.test.ts | 130 +++++++ src/runtime/layer.ts | 20 +- vite.config.ts | 9 +- 43 files changed, 2928 insertions(+), 202 deletions(-) create mode 100644 src/auth/auth.test.ts create mode 100644 src/cli/environment-flag.ts create mode 100644 src/cli/selection-layer.ts create mode 100644 src/config/codec.ts create mode 100644 src/config/config.test.ts create mode 100644 src/config/credential-service.ts create mode 100644 src/config/credential.test.ts create mode 100644 src/config/credential.ts create mode 100644 src/config/environment-name.ts create mode 100644 src/config/file-mode.ts create mode 100644 src/config/keyring.ts create mode 100644 src/config/migration.ts create mode 100644 src/config/paths.ts create mode 100644 src/config/persist.ts create mode 100644 src/config/resolve.test.ts create mode 100644 src/config/resolve.ts create mode 100644 src/config/schema.ts create mode 100644 src/config/selection-layer.ts create mode 100644 src/config/selection-resolve.ts create mode 100644 src/config/selection.ts create mode 100644 src/config/types.ts create mode 100644 src/runtime/layer.test.ts diff --git a/README.md b/README.md index 9703503..b444842 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,27 @@ npx skills add tarik02/t3cli ## Authentication +`t3cli` stores multiple named auth environments in `~/.config/t3cli/config.json` (or `$XDG_CONFIG_HOME/t3cli/config.json`). Tokens are encrypted at rest with AES-256-GCM; the master key is stored in the OS keyring when available, otherwise in `~/.config/t3cli/key`. + ```sh -t3cli auth pair --url [--local] # Pair with a remote server -t3cli auth local # Local t3code installation -t3cli auth status # Check current auth +t3cli auth pair --url [--name ] [--replace] [--local] # Pair with a remote server +t3cli auth local [--name ] [--replace] # Local t3code installation +t3cli auth list [--format json] # List stored environments +t3cli auth use [--format json] # Set default environment +t3cli auth unpair [--name ] [--yes] # Remove local credentials +t3cli auth status [--format json] # Check current auth +t3cli --environment ... # Use a specific environment once ``` - Use `auth pair` with a pairing URL from a running t3code server -- Use `auth local` to authenticate against a local t3code installation +- Default environment names: hostname slug from the paired URL, or `local` for `auth local` +- `auth pair` / `auth local` set the default environment only when creating the first stored environment; use `auth use` to switch afterward +- `--replace` overwrites an existing environment and makes it the default +- `auth unpair` removes local CLI credentials only; any remote token can remain valid until natural expiry +- Use `auth local` or `auth pair --local` to authenticate against a local t3code installation - Local auth enables automatic project resolution from the current directory +- Set `T3CLI_ENV=` to select an environment when `--environment` is omitted +- `T3CODE_URL` and `T3CODE_TOKEN` override the selected environment only when both are set ## Project Management @@ -143,12 +155,15 @@ t3cli terminal destroy [--thread ] [--yes] [--format auto|huma When flags are omitted, the CLI reads these environment variables (first match wins): -| Variable | Used by | -| ---------------------- | ----------------------------------------- | -| `T3CODE_PROJECT_ROOT` | `--project` | -| `T3CODE_PROJECT_ID` | `--project` (after `T3CODE_PROJECT_ROOT`) | -| `T3CODE_WORKTREE_PATH` | `--worktree` | -| `T3CODE_THREAD_ID` | `--thread` | +| Variable | Used by | +| ---------------------- | --------------------------------------------- | +| `T3CODE_PROJECT_ROOT` | `--project` | +| `T3CODE_PROJECT_ID` | `--project` (after `T3CODE_PROJECT_ROOT`) | +| `T3CODE_WORKTREE_PATH` | `--worktree` | +| `T3CODE_THREAD_ID` | `--thread` | +| `T3CLI_ENV` | `--environment` | +| `T3CODE_URL` | server URL override (requires `T3CODE_TOKEN`) | +| `T3CODE_TOKEN` | auth token override (requires `T3CODE_URL`) | ### Project Resolution @@ -177,6 +192,7 @@ t3cli wait --format ndjson ```sh --help # Show help --version # Show version +--environment # Auth environment for this command --completions # Generate shell completions (bash|zsh|fish|sh) --log-level # Set log level ``` diff --git a/package.json b/package.json index 8910be4..452fea2 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.78", + "@napi-rs/keyring": "^1.3.0", "effect": "4.0.0-beta.78" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aee3aad..c32fc40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,12 +9,6 @@ catalogs: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78 - effect: - specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78 - vite-plus: - specifier: 0.1.24 - version: 0.1.24 patchedDependencies: '@effect/vitest@4.0.0-beta.78': @@ -28,6 +22,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78)(ioredis@5.10.1) + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78 @@ -207,6 +204,87 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1764,6 +1842,57 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 diff --git a/skills/t3code-cli/reference/commands.md b/skills/t3code-cli/reference/commands.md index 04cf588..eca1bdb 100644 --- a/skills/t3code-cli/reference/commands.md +++ b/skills/t3code-cli/reference/commands.md @@ -13,16 +13,19 @@ Auth commands: [setup.md](setup.md) ## Global flags -`--help` · `--version` · `--completions bash|zsh|fish|sh` · `--log-level all|trace|debug|info|warn|warning|error|fatal|none` +`--help` · `--version` · `--environment ` · `--completions bash|zsh|fish|sh` · `--log-level all|trace|debug|info|warn|warning|error|fatal|none` ## Environment variables | Variable | Maps to | Priority | | ---------------------- | ------------------------ | -------- | +| `T3CLI_ENV` | `--environment` | 1 | | `T3CODE_PROJECT_ROOT` | `--project` | 1 | | `T3CODE_PROJECT_ID` | `--project` | 2 | | `T3CODE_WORKTREE_PATH` | `--worktree` | 1 | | `T3CODE_THREAD_ID` | `--thread` | 1 | +| `T3CODE_URL` | server URL override | — | +| `T3CODE_TOKEN` | auth token override | — | | `T3CLI_AGENT` | Non-human default format | — | Also treated as agent env (no live TTY): `CI`, `CODEX_CI`, `CODEX_THREAD_ID`. diff --git a/skills/t3code-cli/reference/setup.md b/skills/t3code-cli/reference/setup.md index 451163b..8a866cc 100644 --- a/skills/t3code-cli/reference/setup.md +++ b/skills/t3code-cli/reference/setup.md @@ -9,35 +9,83 @@ - [ ] t3cli model list --format json ``` +## Multiple environments + +One `t3cli` install can store credentials for multiple servers. Environment names must be non-empty slugs: `[A-Za-z0-9._-]`. + +```sh +t3cli auth pair --url --name work +t3cli auth local --name local +t3cli auth list --format json +t3cli auth use work +t3cli --environment local project list +``` + +Selection precedence for runtime commands: + +1. `--environment ` +2. `T3CLI_ENV=` +3. config `default` + +`T3CODE_URL` and `T3CODE_TOKEN` override the selected environment only when both are set. + ## auth pair Pair with a remote t3code server using a pairing URL from the server UI. ```sh -t3cli auth pair --url [--local] [--format json] +t3cli auth pair --url [--name ] [--replace] [--local] [--format json] ``` -| Flag | Required | Description | -| --------- | -------- | ---------------------------------------------------- | -| `--url` | yes | Pairing URL | -| `--local` | no | Mark config as local; enables cwd project resolution | +| Flag | Required | Description | +| ----------- | -------- | ---------------------------------------------------------------------- | +| `--url` | yes | Pairing URL | +| `--name` | no | Environment name (default: hostname slug from URL) | +| `--replace` | no | Replace an existing environment with the same name and make it default | +| `--local` | no | Mark config as local; enables cwd project resolution | ## auth local Authenticate against a local t3code installation. Always writes `local: true` to config. ```sh -t3cli auth local [--format json] +t3cli auth local [--name ] [--replace] [--format json] t3cli auth local --base-dir --origin --role owner ``` -| Flag | Default | Description | -| ------------ | ------------- | --------------------- | -| `--base-dir` | auto | t3code data directory | -| `--origin` | auto | Server origin URL | -| `--role` | `owner` | `owner` or `client` | -| `--label` | `t3cli` | Client label | -| `--subject` | `t3cli-local` | Token subject | +| Flag | Default | Description | +| ------------ | ------------- | ----------------------------------------- | +| `--name` | `local` | Environment name | +| `--replace` | no | Replace existing name and make it default | +| `--base-dir` | auto | t3code data directory | +| `--origin` | auto | Server origin URL | +| `--role` | `owner` | `owner` or `client` | +| `--label` | `t3cli` | Client label | +| `--subject` | `t3cli-local` | Token subject | + +## auth list + +```sh +t3cli auth list [--format json] +``` + +Lists stored environments only. JSON fields: `name`, `url`, `local`, `default`, `active`. Tokens are never printed. + +## auth use + +```sh +t3cli auth use [--format json] +``` + +Sets the default environment without contacting the server. + +## auth unpair + +```sh +t3cli auth unpair [--name ] [--yes] [--format json] +``` + +Removes local CLI credentials for the default environment or `--name`. Requires confirmation; non-interactive mode requires `--yes`. Remote tokens may remain valid until natural expiry. ## auth status @@ -45,7 +93,7 @@ t3cli auth local --base-dir --origin --role owner t3cli auth status [--format json] ``` -Returns current URL, role, and whether config is local. +Returns active environment name when config-backed, plus current URL, `local`, `source`, role, and expiry. ## Local vs remote auth diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts new file mode 100644 index 0000000..89d6dc1 --- /dev/null +++ b/src/auth/auth.test.ts @@ -0,0 +1,263 @@ +import "vite-plus/test/config"; + +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; + +import { T3LocalAuth } from "./local.ts"; +import { T3AuthPairing } from "./pairing.ts"; +import { T3AuthLive } from "./layer.ts"; +import { T3Auth } from "./service.ts"; +import { T3AuthTransport } from "./transport.ts"; +import { T3ConfigLive } from "../config/layer.ts"; +import type { CredentialCrypto } from "../config/credential-service.ts"; +import { T3CredentialCrypto } from "../config/credential-service.ts"; +import { Environment } from "../environment/service.ts"; +import { T3ConfigSelection } from "../config/selection.ts"; + +const testMasterKey = Buffer.alloc(32, 7); + +const testCredentialCrypto: CredentialCrypto = { + encrypt: (input) => + Effect.sync(() => { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); + cipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); + return { + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + }; + }), + decrypt: (input) => + Effect.sync(() => { + const nonce = Buffer.from(input.token.nonce, "base64"); + const ciphertext = Buffer.from(input.token.ciphertext, "base64"); + const tag = Buffer.from(input.token.tag, "base64"); + const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { + authTagLength: 16, + }); + decipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + }), +}; + +function makeAuthLayer(homeDir: string) { + const environmentLayer = Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }); + const configLayer = T3ConfigLive.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(T3CredentialCrypto, testCredentialCrypto), + environmentLayer, + Layer.succeed(T3ConfigSelection)({ + getSelectedEnvironment: () => Effect.succeed(undefined), + }), + ), + ), + ); + return T3AuthLive.pipe( + Layer.provide( + Layer.mergeAll( + configLayer, + Layer.succeed(T3AuthTransport)({ + bootstrapBearer: () => Effect.die("unused in test"), + getSession: () => Effect.succeed({ authenticated: false }), + issueWebSocketTicket: () => Effect.die("unused in test"), + }), + Layer.succeed(T3LocalAuth)({ + local: () => Effect.die("unused in test"), + }), + Layer.succeed(T3AuthPairing)({ + pair: () => Effect.die("unused in test"), + }), + ), + ), + ); +} + +describe("T3Auth persistence", () => { + it("fails to persist a duplicate environment without allowReplace", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + allowReplace: true, + }); + const result = yield* auth + .persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + allowReplace: false, + }) + .pipe(Effect.exit); + assert.equal(Exit.isFailure(result), true); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("does not change default when replace is used for a new environment", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + allowReplace: true, + }); + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + replace: true, + allowReplace: true, + }); + const listed = yield* auth.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, true); + assert.equal(listed.find((environment) => environment.name === "work")?.default, false); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("promotes default when replacing an existing environment with replace", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + allowReplace: true, + }); + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + allowReplace: true, + }); + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + replace: true, + allowReplace: true, + }); + const listed = yield* auth.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "work")?.default, true); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("lists environments without decrypting tokens", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); + const configPath = join(homeDir, ".config", "t3cli", "config.json"); + try { + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + allowReplace: true, + }); + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + allowReplace: true, + }); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + const raw = await readFile(configPath, "utf8"); + const parsed: { + environments: Record; + } = JSON.parse(raw); + parsed.environments.home!.token.tag = "AAAAAAAAAAAAAAAAAAAAAA=="; + await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 }); + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + const listed = yield* auth.listEnvironments(); + assert.equal(listed.length, 2); + assert.equal(listed.find((environment) => environment.name === "home")?.active, true); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("resolves unpair target from encrypted default metadata", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + allowReplace: true, + }); + const target = yield* auth.resolveUnpairTarget({}); + assert.equal(target, "home"); + }).pipe(Effect.provide(makeAuthLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/auth/index.ts b/src/auth/index.ts index 1f7c653..a97c150 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -17,7 +17,13 @@ export type { AuthError } from "./error.ts"; export type { AuthSessionState, AuthWebSocketTicketResult } from "./schema.ts"; export type { AuthConfigInput, + AuthEnvironmentListItem, + AuthEnvironmentSummary, + AuthResolvedConfig, AuthSessionRole, + AuthStatusResult, + AuthUnpairResult, + AuthUseResult, LocalAuthInput, LocalAuthOriginInput, LocalAuthResult, @@ -25,4 +31,5 @@ export type { LocalAuthTokenResult, PairingUrl, PairResult, + PersistEnvironmentInput, } from "./type.ts"; diff --git a/src/auth/layer.ts b/src/auth/layer.ts index 63dfb79..62d3d8f 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -1,13 +1,19 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { ConfigError, UrlError } from "../config/error.ts"; +import { + defaultEnvironmentNameForLocal, + defaultEnvironmentNameFromUrl, +} from "../config/environment-name.ts"; +import type { EnvironmentSummary, ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; import { AuthConfigError } from "./error.ts"; import { T3LocalAuth } from "./local.ts"; import { T3AuthPairing } from "./pairing.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; -import type { AuthConfigInput } from "./type.ts"; +import type { AuthConfigInput, AuthEnvironmentListItem, AuthResolvedConfig } from "./type.ts"; export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const config = yield* T3Config; @@ -16,58 +22,148 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const pairing = yield* T3AuthPairing; const status = Effect.fn("T3AuthLive.status")(function* () { - const resolved = yield* config.resolve().pipe( - Effect.catchTags({ - ConfigError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), - UrlError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), - }), - ); - return yield* transport.getSession(resolved); + const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); + const session = yield* transport.getSession(resolved); + return { config: toAuthResolvedConfig(resolved), session } as const; }); const issueWebSocketTicket = Effect.fn("T3AuthLive.issueWebSocketTicket")(function* () { - const resolved = yield* config.resolve().pipe( - Effect.catchTags({ - ConfigError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), - UrlError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), - }), - ); + const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); return yield* transport.issueWebSocketTicket(resolved); }); const writeConfig = Effect.fn("T3AuthLive.writeConfig")(function* (input: AuthConfigInput) { - const existing = yield* config.readStored().pipe( - Effect.catchTags({ - ConfigError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), - }), - ); yield* config - .writeStored({ - ...existing, + .upsertEnvironment({ + name: input.name, url: input.url, token: input.token, - ...(input.local !== undefined ? { local: input.local } : {}), + local: input.local, + ...(input.makeDefault === true ? { makeDefault: true } : {}), }) - .pipe( - Effect.catchTags({ - ConfigError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), + .pipe(Effect.mapError(mapConfigError)); + }); + + const environmentExists = Effect.fn("T3AuthLive.environmentExists")(function* (name: string) { + return yield* config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)); + }); + + const defaultNameFromUrl = Effect.fn("T3AuthLive.defaultNameFromUrl")(function* (url: string) { + return yield* defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)); + }); + + const defaultNameForLocal = Effect.fn("T3AuthLive.defaultNameForLocal")(() => + Effect.succeed(defaultEnvironmentNameForLocal()), + ); + + const persistEnvironment = Effect.fn("T3AuthLive.persistEnvironment")(function* (input: { + readonly name: string; + readonly url: string; + readonly token: string; + readonly local: boolean; + readonly replace?: boolean; + readonly allowReplace: boolean; + }) { + const exists = yield* config.hasEnvironment(input.name).pipe(Effect.mapError(mapConfigError)); + if (exists && !input.allowReplace) { + return yield* Effect.fail( + new AuthConfigError({ + message: `environment '${input.name}' already exists: pass --replace`, + }), + ); + } + const makeDefault = exists && input.replace === true; + yield* writeConfig({ + name: input.name, + url: input.url, + token: input.token, + local: input.local, + ...(makeDefault ? { makeDefault: true } : {}), + }); + return input.name; + }); + + const listEnvironments = Effect.fn("T3AuthLive.listEnvironments")(function* () { + const [environments, activeName] = yield* Effect.all( + [config.listEnvironments(), config.resolveActiveEnvironmentName()], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(mapConfigError)); + return environments.map((environment) => toAuthEnvironmentListItem(environment, activeName)); + }); + + const useEnvironment = Effect.fn("T3AuthLive.useEnvironment")(function* (name: string) { + yield* config.setDefaultEnvironment(name).pipe(Effect.mapError(mapConfigError)); + return { name, default: true as const }; + }); + + const resolveUnpairTarget = Effect.fn("T3AuthLive.resolveUnpairTarget")(function* (input: { + readonly name?: string; + }) { + if (input.name !== undefined && input.name.length > 0) { + return input.name; + } + const defaultName = yield* config + .getDefaultEnvironmentName() + .pipe(Effect.mapError(mapConfigError)); + if (defaultName === undefined) { + return yield* Effect.fail( + new AuthConfigError({ + message: "no environment selected: pass --name or run: t3cli auth use ", }), ); + } + return defaultName; + }); + + const unpairEnvironment = Effect.fn("T3AuthLive.unpairEnvironment")(function* (input: { + readonly name: string; + }) { + yield* config.removeEnvironment(input.name).pipe(Effect.mapError(mapConfigError)); + return { name: input.name, removed: true as const }; }); return { pair: pairing.pair, local: localAuth.local, writeConfig, + persistEnvironment, + environmentExists, + defaultNameFromUrl, + defaultNameForLocal, + listEnvironments, + useEnvironment, + resolveUnpairTarget, + unpairEnvironment, status, issueWebSocketTicket, }; }); export const T3AuthLive = Layer.effect(T3Auth, makeT3Auth()); + +function mapConfigError(error: ConfigError | UrlError) { + return new AuthConfigError({ message: "auth config failed", cause: error }); +} + +function toAuthResolvedConfig(config: ResolvedConfig): AuthResolvedConfig { + return { + url: config.url, + token: config.token, + source: config.source, + local: config.local, + ...(config.environment !== undefined ? { environment: config.environment } : {}), + }; +} + +function toAuthEnvironmentListItem( + environment: EnvironmentSummary, + activeName: string | undefined, +): AuthEnvironmentListItem { + return { + name: environment.name, + url: environment.url, + local: environment.local, + default: environment.default, + active: environment.name === activeName, + }; +} diff --git a/src/auth/service.ts b/src/auth/service.ts index eb7decd..04477c0 100644 --- a/src/auth/service.ts +++ b/src/auth/service.ts @@ -1,9 +1,19 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { AuthSessionState, AuthWebSocketTicketResult } from "./schema.ts"; +import type { AuthWebSocketTicketResult } from "./schema.ts"; import type { AuthError } from "./error.ts"; -import type { AuthConfigInput, LocalAuthInput, LocalAuthResult, PairResult } from "./type.ts"; +import type { + AuthConfigInput, + AuthEnvironmentListItem, + AuthStatusResult, + AuthUnpairResult, + AuthUseResult, + LocalAuthInput, + LocalAuthResult, + PairResult, + PersistEnvironmentInput, +} from "./type.ts"; export class T3Auth extends Context.Service< T3Auth, @@ -11,7 +21,21 @@ export class T3Auth extends Context.Service< readonly pair: (value: string) => Effect.Effect; readonly local: (input: LocalAuthInput) => Effect.Effect; readonly writeConfig: (input: AuthConfigInput) => Effect.Effect; - readonly status: () => Effect.Effect; + readonly persistEnvironment: ( + input: PersistEnvironmentInput, + ) => Effect.Effect; + readonly environmentExists: (name: string) => Effect.Effect; + readonly defaultNameFromUrl: (url: string) => Effect.Effect; + readonly defaultNameForLocal: () => Effect.Effect; + readonly listEnvironments: () => Effect.Effect; + readonly useEnvironment: (name: string) => Effect.Effect; + readonly unpairEnvironment: (input: { + readonly name: string; + }) => Effect.Effect; + readonly resolveUnpairTarget: (input: { + readonly name?: string; + }) => Effect.Effect; + readonly status: () => Effect.Effect; readonly issueWebSocketTicket: () => Effect.Effect; } >()("t3cli/T3Auth") {} diff --git a/src/auth/type.ts b/src/auth/type.ts index f1d3c71..63052f6 100644 --- a/src/auth/type.ts +++ b/src/auth/type.ts @@ -1,4 +1,5 @@ import type { AuthBearerBootstrapResult } from "./schema.ts"; +import type { AuthSessionState } from "./schema.ts"; export type AuthSessionRole = AuthBearerBootstrapResult["role"]; @@ -8,9 +9,44 @@ export type PairingUrl = { }; export type AuthConfigInput = { + readonly name: string; readonly url: string; readonly token: string; - readonly local?: boolean; + readonly local: boolean; + readonly makeDefault?: boolean; +}; + +export type PersistEnvironmentInput = { + readonly name: string; + readonly url: string; + readonly token: string; + readonly local: boolean; + readonly replace?: boolean; + readonly allowReplace: boolean; +}; + +export type AuthResolvedConfig = { + readonly url: string; + readonly token: string; + readonly source: "env" | "config"; + readonly local: boolean; + readonly environment?: string; +}; + +export type AuthEnvironmentSummary = { + readonly name: string; + readonly url: string; + readonly local: boolean; + readonly default: boolean; +}; + +export type AuthStatusResult = { + readonly config: AuthResolvedConfig; + readonly session: AuthSessionState; +}; + +export type AuthEnvironmentListItem = AuthEnvironmentSummary & { + readonly active: boolean; }; export type PairResult = { @@ -51,3 +87,13 @@ export type LocalAuthResult = { readonly source: "local"; readonly baseDir: string; }; + +export type AuthUseResult = { + readonly name: string; + readonly default: true; +}; + +export type AuthUnpairResult = { + readonly name: string; + readonly removed: true; +}; diff --git a/src/bin.ts b/src/bin.ts index 07ec4ed..08109a9 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -6,12 +6,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; +import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; import { NodeEnvironmentLive } from "./environment/layer.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; import { NodeTerminalIoLive } from "./cli/terminal/io-node-layer.ts"; import { T3Output } from "./cli/output/service.ts"; -import { AppLayer } from "./runtime/layer.ts"; +import { BaseAppLayer } from "./runtime/layer.ts"; import { T3VersionBundledLive, T3VersionPackageJsonLive } from "./version/layer.ts"; import { T3Version } from "./version/service.ts"; @@ -22,8 +23,10 @@ const VersionLive = const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); +const CliAppLayer = BaseAppLayer.pipe(Layer.provideMerge(T3CliConfigSelectionLive)); + const CliLayer = Layer.mergeAll( - AppLayer.pipe(Layer.provide(PlatformLayer)), + CliAppLayer.pipe(Layer.provide(PlatformLayer)), NodeServices.layer, NodeEnvironmentLive, T3InputLive.pipe(Layer.provide(NodeServices.layer)), diff --git a/src/cli/app.ts b/src/cli/app.ts index a47a423..a2f37cf 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -1,6 +1,7 @@ import { Command } from "effect/unstable/cli"; import { createAuthCommand } from "./auth.ts"; +import { cliEnvironmentSetting } from "./environment-flag.ts"; import { createTerminalCommandGroup } from "./terminal.ts"; import { createModelCommand } from "./model.ts"; import { createProjectCommand } from "./project.ts"; @@ -15,6 +16,7 @@ import { waitForThreadCommand } from "./threads/wait.ts"; export function createCliCommand() { return Command.make("t3cli").pipe( Command.withDescription("non-interactive cli for running t3code server"), + Command.withGlobalFlags([cliEnvironmentSetting]), Command.withSubcommands([ createAuthCommand(), listThreadsCommand, diff --git a/src/cli/auth-format.ts b/src/cli/auth-format.ts index 699efb5..5d07626 100644 --- a/src/cli/auth-format.ts +++ b/src/cli/auth-format.ts @@ -1,45 +1,74 @@ -import type { AuthSessionState } from "../auth/schema.ts"; -import type { ResolvedConfig } from "../config/service.ts"; -import type { LocalAuthResult, PairResult } from "../auth/type.ts"; +import type { + AuthEnvironmentListItem, + AuthStatusResult, + LocalAuthResult, + PairResult, +} from "../auth/type.ts"; -export function formatAuthPaired(result: PairResult) { - return `paired: ${result.url}\nrole: ${result.role}\nexpires: ${result.expiresAt}`; +export function formatAuthPaired(result: PairResult & { readonly name: string }) { + return `paired: ${result.url}\nname: ${result.name}\nrole: ${result.role}\nexpires: ${result.expiresAt}`; } -export function formatAuthLocalHuman(result: LocalAuthResult) { +export function formatAuthLocalHuman(result: LocalAuthResult & { readonly name: string }) { return [ `paired: ${result.url}`, + `name: ${result.name}`, `role: ${result.role}`, `expires: ${result.expiresAt}`, `baseDir: ${result.baseDir}`, ].join("\n"); } -export function formatAuthLocalJson(result: LocalAuthResult) { +export function formatAuthLocalJson(result: LocalAuthResult & { readonly name: string }) { return result; } -export function formatAuthStatusHuman(input: { - readonly config: ResolvedConfig; - readonly result: AuthSessionState; -}) { +export function formatAuthStatusHuman(input: AuthStatusResult) { return [ + ...(input.config.environment !== undefined ? [`environment: ${input.config.environment}`] : []), `url: ${input.config.url}`, `local: ${input.config.local ? "yes" : "no"}`, - `authenticated: ${input.result.authenticated ? "yes" : "no"}`, - ...(input.result.role !== undefined ? [`role: ${input.result.role}`] : []), - ...(input.result.expiresAt !== undefined ? [`expires: ${input.result.expiresAt}`] : []), + `source: ${input.config.source}`, + `authenticated: ${input.session.authenticated ? "yes" : "no"}`, + ...(input.session.role !== undefined ? [`role: ${input.session.role}`] : []), + ...(input.session.expiresAt !== undefined ? [`expires: ${input.session.expiresAt}`] : []), ].join("\n"); } -export function formatAuthStatusJson(input: { - readonly config: ResolvedConfig; - readonly result: AuthSessionState; -}) { +export function formatAuthStatusJson(input: AuthStatusResult) { return { - ...input.result, + ...input.session, + ...(input.config.environment !== undefined ? { environment: input.config.environment } : {}), url: input.config.url, source: input.config.source, local: input.config.local, }; } + +export function formatAuthListHuman(environments: readonly AuthEnvironmentListItem[]) { + if (environments.length === 0) { + return "no environments"; + } + return environments + .map((environment) => { + const markers = [ + environment.default ? "default" : undefined, + environment.active ? "active" : undefined, + ].filter((marker) => marker !== undefined); + const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : ""; + return `${environment.name}: ${environment.url} [local=${environment.local ? "yes" : "no"}]${suffix}`; + }) + .join("\n"); +} + +export function formatAuthListJson(environments: readonly AuthEnvironmentListItem[]) { + return environments; +} + +export function formatAuthUseHuman(result: { readonly name: string }) { + return `default environment: ${result.name}`; +} + +export function formatAuthUnpairHuman(result: { readonly name: string }) { + return `removed environment: ${result.name}`; +} diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 6f85dfb..9601a71 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -1,25 +1,70 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { Command, Flag } from "effect/unstable/cli"; +import { Argument, Command, Flag } from "effect/unstable/cli"; import { + formatAuthListHuman, + formatAuthListJson, formatAuthLocalHuman, formatAuthLocalJson, formatAuthPaired, formatAuthStatusHuman, formatAuthStatusJson, + formatAuthUnpairHuman, + formatAuthUseHuman, } from "./auth-format.ts"; import { T3Auth } from "../auth/service.ts"; -import { T3Config } from "../config/service.ts"; import { Environment } from "../environment/service.ts"; -import { formatFlag } from "./flags.ts"; +import { + requireDestructiveConfirmation, + requireEnvironmentReplaceConfirmation, +} from "./confirm.ts"; +import { authNameFlag, formatFlag, replaceFlag, yesFlag } from "./flags.ts"; import { resolveOutputFormat } from "./output-format.ts"; import { T3Output } from "./output/service.ts"; +const persistAuthEnvironment = Effect.fn("persistAuthEnvironment")(function* (input: { + readonly explicitName: Option.Option; + readonly fallbackName: string; + readonly url: string; + readonly token: string; + readonly local: boolean; + readonly replace: boolean; +}) { + const auth = yield* T3Auth; + const environment = yield* Environment; + const environmentName = Option.isSome(input.explicitName) + ? input.explicitName.value + : input.fallbackName; + const exists = yield* auth.environmentExists(environmentName); + if (exists) { + yield* requireEnvironmentReplaceConfirmation({ + name: environmentName, + replace: input.replace, + environment, + }); + } + return yield* auth.persistEnvironment({ + name: environmentName, + url: input.url, + token: input.token, + local: input.local, + replace: input.replace, + allowReplace: true, + }); +}); + export function createAuthCommand() { return Command.make("auth").pipe( Command.withDescription("auth commands"), - Command.withSubcommands([pairCommand, localCommand, statusCommand]), + Command.withSubcommands([ + pairCommand, + localCommand, + listCommand, + useCommand, + unpairCommand, + statusCommand, + ]), ); } @@ -28,24 +73,31 @@ const pairCommand = Command.make( { url: Flag.string("url"), local: Flag.boolean("local"), + name: authNameFlag, + replace: replaceFlag, format: formatFlag, }, - ({ url, local, format }) => + ({ url, local, name, replace, format }) => Effect.gen(function* () { const auth = yield* T3Auth; const environment = yield* Environment; const output = yield* T3Output; const resolvedFormat = resolveOutputFormat(format, environment, "json"); const result = yield* auth.pair(url); - yield* auth.writeConfig({ + const fallbackName = yield* auth.defaultNameFromUrl(result.url); + const environmentName = yield* persistAuthEnvironment({ + explicitName: name, + fallbackName, url: result.url, token: result.token, local, + replace, }); + const payload = { ...result, name: environmentName }; if (resolvedFormat === "json") { - yield* output.printJson(result); + yield* output.printJson(payload); } else { - yield* output.printInfo(formatAuthPaired(result)); + yield* output.printInfo(formatAuthPaired(payload)); } }), ).pipe(Command.withDescription("pair with t3code server")); @@ -58,9 +110,11 @@ const localCommand = Command.make( role: Flag.choice("role", ["owner", "client"] as const).pipe(Flag.withDefault("owner")), label: Flag.string("label").pipe(Flag.withDefault("t3cli")), subject: Flag.string("subject").pipe(Flag.withDefault("t3cli-local")), + name: authNameFlag, + replace: replaceFlag, format: formatFlag, }, - ({ baseDir, origin, role, label, subject, format }) => + ({ baseDir, origin, role, label, subject, name, replace, format }) => Effect.gen(function* () { const auth = yield* T3Auth; const environment = yield* Environment; @@ -73,19 +127,99 @@ const localCommand = Command.make( ...(Option.isSome(baseDir) ? { baseDir: baseDir.value } : {}), ...(Option.isSome(origin) ? { origin: origin.value } : {}), }); - yield* auth.writeConfig({ + const fallbackName = yield* auth.defaultNameForLocal(); + const environmentName = yield* persistAuthEnvironment({ + explicitName: name, + fallbackName, url: result.url, token: result.token, local: true, + replace, }); + const payload = { ...result, name: environmentName }; if (resolvedFormat === "json") { - yield* output.printJson(formatAuthLocalJson(result)); + yield* output.printJson(formatAuthLocalJson(payload)); } else { - yield* output.printInfo(formatAuthLocalHuman(result)); + yield* output.printInfo(formatAuthLocalHuman(payload)); } }), ).pipe(Command.withDescription("authenticate with local t3code installation")); +const listCommand = Command.make( + "list", + { + format: formatFlag, + }, + ({ format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const environment = yield* Environment; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const environments = yield* auth.listEnvironments(); + if (resolvedFormat === "json") { + yield* output.printJson(formatAuthListJson(environments)); + } else { + yield* output.printInfo(formatAuthListHuman(environments)); + } + }), +).pipe(Command.withDescription("list stored auth environments")); + +const useCommand = Command.make( + "use", + { + name: Argument.string("name"), + format: formatFlag, + }, + ({ name, format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const environment = yield* Environment; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const result = yield* auth.useEnvironment(name); + if (resolvedFormat === "json") { + yield* output.printJson(result); + } else { + yield* output.printInfo(formatAuthUseHuman(result)); + } + }), +).pipe(Command.withDescription("set the default auth environment")); + +const unpairCommand = Command.make( + "unpair", + { + name: authNameFlag, + yes: yesFlag, + format: formatFlag, + }, + ({ name, yes, format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const environment = yield* Environment; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const targetName = yield* auth.resolveUnpairTarget( + Option.isSome(name) ? { name: name.value } : {}, + ); + yield* requireDestructiveConfirmation({ + message: `Remove local credentials for environment '${targetName}'?`, + yes, + environment, + }); + const result = yield* auth.unpairEnvironment({ name: targetName }); + if (resolvedFormat === "json") { + yield* output.printJson(result); + } else { + yield* output.printInfo(formatAuthUnpairHuman(result)); + } + }), +).pipe( + Command.withDescription( + "remove local credentials for an environment (remote tokens may remain valid until expiry)", + ), +); + const statusCommand = Command.make( "status", { @@ -93,17 +227,15 @@ const statusCommand = Command.make( }, ({ format }) => Effect.gen(function* () { - const configService = yield* T3Config; const auth = yield* T3Auth; const environment = yield* Environment; const output = yield* T3Output; const resolvedFormat = resolveOutputFormat(format, environment, "json"); - const config = yield* configService.resolve(); const result = yield* auth.status(); if (resolvedFormat === "json") { - yield* output.printJson(formatAuthStatusJson({ config, result })); + yield* output.printJson(formatAuthStatusJson(result)); } else { - yield* output.printInfo(formatAuthStatusHuman({ config, result })); + yield* output.printInfo(formatAuthStatusHuman(result)); } }), ).pipe(Command.withDescription("show auth status")); diff --git a/src/cli/confirm.ts b/src/cli/confirm.ts index ee1f24f..0773258 100644 --- a/src/cli/confirm.ts +++ b/src/cli/confirm.ts @@ -29,3 +29,33 @@ export const requireDestructiveConfirmation = Effect.fn("requireDestructiveConfi yield* Effect.fail(new DestructiveConfirmationRequiredError({ message: "aborted" })); }, ); + +export const requireEnvironmentReplaceConfirmation = Effect.fn( + "requireEnvironmentReplaceConfirmation", +)(function* (input: { + readonly name: string; + readonly replace: boolean; + readonly environment: EnvironmentShape; +}) { + if (input.replace) { + return; + } + if (!isInteractiveHumanTerminal(input.environment)) { + yield* Effect.fail( + new DestructiveConfirmationRequiredError({ + message: `environment '${input.name}' already exists: pass --replace`, + }), + ); + return; + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ + message: `Environment '${input.name}' already exists. Replace?`, + initial: false, + }), + ); + if (confirmed) { + return; + } + yield* Effect.fail(new DestructiveConfirmationRequiredError({ message: "aborted" })); +}); diff --git a/src/cli/environment-flag.ts b/src/cli/environment-flag.ts new file mode 100644 index 0000000..43dbfee --- /dev/null +++ b/src/cli/environment-flag.ts @@ -0,0 +1,8 @@ +import { Flag, GlobalFlag } from "effect/unstable/cli"; + +export const cliEnvironmentSetting = GlobalFlag.setting("environment")({ + flag: Flag.string("environment").pipe( + Flag.withDescription("Auth environment name for this command"), + Flag.optional, + ), +}); diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 9eff9a1..b57b948 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -35,6 +35,16 @@ export const yesFlag = Flag.boolean("yes").pipe( Flag.withDefault(false), ); +export const replaceFlag = Flag.boolean("replace").pipe( + Flag.withDescription("Replace an existing environment with the same name"), + Flag.withDefault(false), +); + +export const authNameFlag = Flag.string("name").pipe( + Flag.withDescription("Auth environment name"), + Flag.optional, +); + export const forceFlag = Flag.boolean("force").pipe( Flag.withDescription("Delete non-empty project (cascade thread deletes)"), Flag.withDefault(false), diff --git a/src/cli/selection-layer.ts b/src/cli/selection-layer.ts new file mode 100644 index 0000000..9577ea5 --- /dev/null +++ b/src/cli/selection-layer.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { resolveConfiguredEnvironment } from "../config/selection-resolve.ts"; +import { T3ConfigSelection } from "../config/selection.ts"; +import { Environment } from "../environment/service.ts"; +import { cliEnvironmentSetting } from "./environment-flag.ts"; + +export const T3CliConfigSelectionLive = Layer.effect( + T3ConfigSelection, + Effect.gen(function* () { + const environment = yield* Environment; + return { + getSelectedEnvironment: () => + Effect.gen(function* () { + const cliEnvironment = yield* Effect.serviceOption(cliEnvironmentSetting); + return resolveConfiguredEnvironment({ + cliFlag: Option.isSome(cliEnvironment) + ? Option.getOrUndefined(cliEnvironment.value) + : undefined, + t3cliEnv: environment.env["T3CLI_ENV"], + }); + }), + }; + }), +); diff --git a/src/config/codec.ts b/src/config/codec.ts new file mode 100644 index 0000000..b2ecc24 --- /dev/null +++ b/src/config/codec.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; + +import type { + CredentialCrypto, + CredentialDecryptInput, + CredentialEncryptInput, +} from "./credential-service.ts"; +import type { EncryptedConfig, DecryptedConfig, DecryptedEnvironment } from "./types.ts"; + +export function encryptEnvironment(crypto: CredentialCrypto, input: CredentialEncryptInput) { + return crypto.encrypt(input); +} + +export function decryptEnvironment(crypto: CredentialCrypto, input: CredentialDecryptInput) { + return crypto.decrypt(input); +} + +export function decryptConfig(crypto: CredentialCrypto, config: EncryptedConfig) { + return Effect.gen(function* () { + const environments: Record = {}; + for (const [name, environmentConfig] of Object.entries(config.environments)) { + environments[name] = { + url: environmentConfig.url, + local: environmentConfig.local, + token: yield* decryptEnvironment(crypto, { + environmentName: name, + url: environmentConfig.url, + local: environmentConfig.local, + token: environmentConfig.token, + }), + }; + } + return { + version: 2 as const, + ...(config.default !== undefined ? { default: config.default } : {}), + environments, + } satisfies DecryptedConfig; + }); +} + +export function encryptConfig(crypto: CredentialCrypto, config: DecryptedConfig) { + return Effect.gen(function* () { + const environments: Record = {}; + for (const [name, environmentConfig] of Object.entries(config.environments)) { + environments[name] = { + url: environmentConfig.url, + local: environmentConfig.local, + token: yield* encryptEnvironment(crypto, { + environmentName: name, + url: environmentConfig.url, + local: environmentConfig.local, + token: environmentConfig.token, + }), + }; + } + return { + version: 2 as const, + ...(config.default !== undefined ? { default: config.default } : {}), + environments, + } satisfies EncryptedConfig; + }); +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts new file mode 100644 index 0000000..96d375c --- /dev/null +++ b/src/config/config.test.ts @@ -0,0 +1,438 @@ +import "vite-plus/test/config"; + +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; + +import { Environment } from "../environment/service.ts"; +import { decryptEnvironment } from "./codec.ts"; +import type { CredentialCrypto } from "./credential-service.ts"; +import { T3CredentialCrypto } from "./credential-service.ts"; +import { migrateV1FileToEncrypted } from "./migration.ts"; +import { T3ConfigLive } from "./layer.ts"; +import { T3ConfigSelection } from "./selection.ts"; +import { T3ConfigSelectionLive } from "./selection-layer.ts"; +import { T3Config } from "./service.ts"; + +const testMasterKey = Buffer.alloc(32, 7); + +const testCredentialCrypto: CredentialCrypto = { + encrypt: (input) => + Effect.sync(() => { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); + cipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); + return { + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + }; + }), + decrypt: (input) => + Effect.sync(() => { + const nonce = Buffer.from(input.token.nonce, "base64"); + const ciphertext = Buffer.from(input.token.ciphertext, "base64"); + const tag = Buffer.from(input.token.tag, "base64"); + const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { + authTagLength: 16, + }); + decipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + }), +}; + +function makeEnvironmentLayer(homeDir: string, env: Record = {}) { + return Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env, + stdoutIsTTY: false, + stderrIsTTY: false, + }); +} + +function makeConfigLayer( + homeDir: string, + input: { + readonly selection?: string; + readonly env?: Record; + readonly useSelectionLive?: boolean; + } = {}, +) { + const environmentLayer = makeEnvironmentLayer(homeDir, input.env ?? {}); + const selectionLayer = + input.useSelectionLive === true + ? T3ConfigSelectionLive.pipe(Layer.provide(environmentLayer)) + : Layer.succeed(T3ConfigSelection)({ + getSelectedEnvironment: () => Effect.succeed(input.selection), + }); + return T3ConfigLive.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(T3CredentialCrypto, testCredentialCrypto), + environmentLayer, + selectionLayer, + ), + ), + ); +} + +describe("config persistence", () => { + it.effect("migrates v1 flat config and roundtrips encrypted tokens", () => + Effect.gen(function* () { + const migrated = yield* migrateV1FileToEncrypted(testCredentialCrypto, { + url: "https://app.example.com", + token: "secret-token", + local: false, + }); + assert.equal(migrated.default, "app.example.com"); + const token = yield* decryptEnvironment(testCredentialCrypto, { + environmentName: "app.example.com", + url: "https://app.example.com", + local: false, + token: migrated.environments["app.example.com"]!.token, + }); + assert.equal(token, "secret-token"); + }), + ); + + it("persists v1 config as encrypted v2 on first read", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + const secretToken = "legacy-plaintext-token"; + const configPath = join(homeDir, ".config", "t3cli", "config.json"); + try { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ + url: "https://home.example", + token: secretToken, + local: false, + })}\n`, + { mode: 0o600 }, + ); + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.listEnvironments(); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + const raw = await readFile(configPath, "utf8"); + assert.equal(raw.includes(secretToken), false); + assert.equal(raw.includes('"version": 2'), true); + assert.equal(raw.includes('"kind": "encrypted"'), true); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("keeps default unchanged when upserting an existing environment without makeDefault", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + }); + const listed = yield* config.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, true); + assert.equal(listed.find((environment) => environment.name === "work")?.default, false); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("promotes default when upserting with makeDefault", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + makeDefault: true, + }); + const listed = yield* config.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, false); + assert.equal(listed.find((environment) => environment.name === "work")?.default, true); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("never writes plaintext tokens to config.json", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + const secretToken = "super-secret-token-value"; + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: secretToken, + local: false, + }); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + const configPath = join(homeDir, ".config", "t3cli", "config.json"); + const raw = await readFile(configPath, "utf8"); + assert.equal(raw.includes(secretToken), false); + assert.equal(raw.includes('"kind": "encrypted"'), true); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("clears default when removing the default environment", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.removeEnvironment("home"); + const defaultName = yield* config.getDefaultEnvironmentName(); + assert.equal(defaultName, undefined); + const listed = yield* config.listEnvironments(); + assert.equal( + listed.every((environment) => !environment.default), + true, + ); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("resolves selected environment from config selection service", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + assert.equal(resolved.token, "work-token"); + } + }).pipe(Effect.provide(makeConfigLayer(homeDir, { selection: "work" }))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("resolves T3CLI_ENV through the selection layer", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + } + }).pipe( + Effect.provide( + makeConfigLayer(homeDir, { + useSelectionLive: true, + env: { T3CLI_ENV: "work" }, + }), + ), + ), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("resolves env override with local=false even when selected stored environment is local", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "work", + url: "http://localhost:8787", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "env"); + assert.equal(resolved.local, false); + assert.equal(resolved.url, "https://remote.example"); + assert.equal(resolved.token, "env-token"); + }).pipe( + Effect.provide( + makeConfigLayer(homeDir, { + selection: "work", + env: { + T3CODE_URL: "https://remote.example", + T3CODE_TOKEN: "env-token", + }, + }), + ), + ), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("reads default environment name without decrypting tokens", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + const defaultName = yield* config.getDefaultEnvironmentName(); + assert.equal(defaultName, "home"); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("hardens existing config file permissions to 0600 on write", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); + const configPath = join(homeDir, ".config", "t3cli", "config.json"); + try { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify({ version: 2, environments: {} })}\n`, { + mode: 0o644, + }); + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ); + const configStat = await stat(configPath); + assert.equal(configStat.mode & 0o777, 0o600); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it.effect("fails decrypt when ciphertext AAD does not match", () => + Effect.gen(function* () { + const token = yield* testCredentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret", + }); + const result = yield* decryptEnvironment(testCredentialCrypto, { + environmentName: "home", + url: "https://tampered.example", + local: false, + token, + }).pipe(Effect.exit); + assert.equal(Exit.isFailure(result), true); + }), + ); +}); diff --git a/src/config/credential-service.ts b/src/config/credential-service.ts new file mode 100644 index 0000000..1b7acd9 --- /dev/null +++ b/src/config/credential-service.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ConfigError } from "./error.ts"; +import type { EncryptedToken } from "./schema.ts"; + +export type CredentialEncryptInput = { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; + readonly token: string; +}; + +export type CredentialDecryptInput = { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; + readonly token: EncryptedToken; +}; + +export class T3CredentialCrypto extends Context.Service()( + "t3cli/T3CredentialCrypto", +) {} + +export type CredentialCrypto = { + readonly encrypt: (input: CredentialEncryptInput) => Effect.Effect; + readonly decrypt: (input: CredentialDecryptInput) => Effect.Effect; +}; diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts new file mode 100644 index 0000000..f5ffa2e --- /dev/null +++ b/src/config/credential.test.ts @@ -0,0 +1,149 @@ +import "vite-plus/test/config"; + +import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { expect, vi } from "vite-plus/test"; + +import { Environment } from "../environment/service.ts"; +import { + makeT3CredentialCrypto, + parseKeyringPassword, + shouldFallbackToKeyFile, +} from "./credential.ts"; + +vi.mock("./keyring.ts", () => ({ + getKeyringStore: () => null, +})); + +describe("keyring fallback", () => { + it("treats invalid stored keyring values as corrupt", () => { + const result = parseKeyringPassword("not-a-valid-key"); + assert.equal(result.kind, "corrupt"); + expect(shouldFallbackToKeyFile(result)).toBe(false); + }); + + it("falls back to key file when keyring backend is unavailable", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); + const keyPath = join(homeDir, ".config", "t3cli", "key"); + const masterKey = randomBytes(32); + try { + await mkdir(dirname(keyPath), { recursive: true }); + await writeFile(keyPath, `${masterKey.toString("base64")}\n`, { mode: 0o600 }); + const crypto = await Effect.runPromise( + makeT3CredentialCrypto().pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }), + ), + ), + ), + ); + const encrypted = await Effect.runPromise( + crypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }), + ); + const token = await Effect.runPromise( + crypto.decrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: encrypted, + }), + ); + assert.equal(token, "secret-token"); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("hardens existing key file permissions to 0600 on use", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); + const keyPath = join(homeDir, ".config", "t3cli", "key"); + const masterKey = randomBytes(32); + try { + await mkdir(dirname(keyPath), { recursive: true }); + await writeFile(keyPath, `${masterKey.toString("base64")}\n`, { mode: 0o644 }); + const crypto = await Effect.runPromise( + makeT3CredentialCrypto().pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }), + ), + ), + ), + ); + await Effect.runPromise( + crypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }), + ); + const keyStat = await stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it("creates key file with 0600 permissions when keyring is unavailable", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); + const keyPath = join(homeDir, ".config", "t3cli", "key"); + try { + const crypto = await Effect.runPromise( + makeT3CredentialCrypto().pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }), + ), + ), + ), + ); + await Effect.runPromise( + crypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "rotate-write", + }), + ); + const keyStat = await stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/config/credential.ts b/src/config/credential.ts new file mode 100644 index 0000000..a8f9bc8 --- /dev/null +++ b/src/config/credential.ts @@ -0,0 +1,220 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Filter from "effect/Filter"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { Environment } from "../environment/service.ts"; +import { + T3CredentialCrypto, + type CredentialDecryptInput, + type CredentialEncryptInput, +} from "./credential-service.ts"; +import { ConfigError } from "./error.ts"; +import { hardenPrivateFileMode } from "./file-mode.ts"; +import { getKeyringStore } from "./keyring.ts"; +import { resolveKeyFilePath } from "./paths.ts"; + +const configSchemaVersion = 2; +const masterKeyByteLength = 32; +const gcmNonceByteLength = 12; +const keyringService = "t3cli"; +const keyringAccount = "master-key"; + +export type KeyringReadResult = + | { readonly kind: "missing" } + | { readonly kind: "present"; readonly key: Buffer } + | { readonly kind: "corrupt"; readonly message: string } + | { readonly kind: "unavailable"; readonly message: string }; + +export function shouldFallbackToKeyFile(result: KeyringReadResult) { + return result.kind === "missing" || result.kind === "unavailable"; +} + +export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* Environment; + const keyFilePath = resolveKeyFilePath(path, environment); + + const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { + const raw = yield* fs.readFileString(filePath).pipe( + Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => + Effect.succeed(undefined), + ), + Effect.mapError( + (error) => new ConfigError({ message: "failed to read credential key file", cause: error }), + ), + ); + if (raw === undefined) { + return undefined; + } + const key = Buffer.from(raw.trim(), "base64"); + if (key.byteLength !== masterKeyByteLength) { + return yield* Effect.fail( + new ConfigError({ message: "invalid credential key file: unexpected key length" }), + ); + } + return key; + }); + + const writeKeyFileMasterKey = Effect.fn("writeKeyFileMasterKey")(function* ( + filePath: string, + key: Buffer, + ) { + yield* fs + .makeDirectory(path.dirname(filePath), { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError( + (error) => + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + ); + yield* fs + .writeFileString(filePath, `${key.toString("base64")}\n`, { mode: 0o600 }) + .pipe( + Effect.mapError( + (error) => + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + ); + yield* hardenPrivateFileMode(fs, filePath, "credential key"); + }); + + const getMasterKey = Effect.fn("T3CredentialCryptoLive.getMasterKey")(function* () { + const keyringResult = yield* readKeyringMasterKey(); + if (keyringResult.kind === "present") { + return keyringResult.key; + } + if (keyringResult.kind === "corrupt") { + return yield* Effect.fail( + new ConfigError({ + message: `corrupt credential key in OS keyring: ${keyringResult.message}`, + }), + ); + } + if (shouldFallbackToKeyFile(keyringResult)) { + const fileKey = yield* readKeyFileMasterKey(keyFilePath); + if (fileKey !== undefined) { + yield* hardenPrivateFileMode(fs, keyFilePath, "credential key"); + return fileKey; + } + } + const generated = randomBytes(masterKeyByteLength); + const storedInKeyring = yield* writeKeyringMasterKey(generated); + if (storedInKeyring) { + return generated; + } + yield* writeKeyFileMasterKey(keyFilePath, generated); + return generated; + }); + + const encrypt = Effect.fn("T3CredentialCryptoLive.encrypt")(function* ( + input: CredentialEncryptInput, + ) { + const masterKey = yield* getMasterKey(); + const nonce = randomBytes(gcmNonceByteLength); + const cipher = createCipheriv("aes-256-gcm", masterKey, nonce, { + authTagLength: 16, + }); + cipher.setAAD(buildAad(input)); + const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64"), + }; + }); + + const decrypt = Effect.fn("T3CredentialCryptoLive.decrypt")(function* ( + input: CredentialDecryptInput, + ) { + const masterKey = yield* getMasterKey(); + return yield* Effect.try({ + try: () => decryptToken(masterKey, input), + catch: () => new ConfigError({ message: "failed to decrypt credential token" }), + }); + }); + + return { encrypt, decrypt }; +}); + +export const T3CredentialCryptoLive = Layer.effect(T3CredentialCrypto, makeT3CredentialCrypto()); + +function buildAad(input: { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; +}) { + return Buffer.from( + `${configSchemaVersion}\0${input.environmentName}\0${input.url}\0${input.local}`, + "utf8", + ); +} + +function decryptToken(masterKey: Buffer, input: CredentialDecryptInput) { + const nonce = Buffer.from(input.token.nonce, "base64"); + const ciphertext = Buffer.from(input.token.ciphertext, "base64"); + const tag = Buffer.from(input.token.tag, "base64"); + const decipher = createDecipheriv("aes-256-gcm", masterKey, nonce, { + authTagLength: 16, + }); + decipher.setAAD(buildAad(input)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); +} + +function readKeyringMasterKey() { + return Effect.sync((): KeyringReadResult => { + const keyring = getKeyringStore(); + if (keyring === null) { + return { + kind: "unavailable", + message: "OS keyring backend is not available", + }; + } + try { + return parseKeyringPassword(keyring.readPassword(keyringService, keyringAccount)); + } catch (error) { + return { + kind: "unavailable", + message: error instanceof Error ? error.message : "failed to read keyring entry", + }; + } + }); +} + +export function parseKeyringPassword(password: string | null): KeyringReadResult { + if (password === null || password.length === 0) { + return { kind: "missing" }; + } + const key = Buffer.from(password, "base64"); + if (key.byteLength !== masterKeyByteLength) { + return { + kind: "corrupt", + message: "unexpected key length", + }; + } + return { kind: "present", key }; +} + +function writeKeyringMasterKey(key: Buffer) { + return Effect.sync(() => { + const keyring = getKeyringStore(); + if (keyring === null) { + return false; + } + try { + keyring.writePassword(keyringService, keyringAccount, key.toString("base64")); + return true; + } catch { + return false; + } + }); +} diff --git a/src/config/environment-name.ts b/src/config/environment-name.ts new file mode 100644 index 0000000..079c8c4 --- /dev/null +++ b/src/config/environment-name.ts @@ -0,0 +1,48 @@ +import * as Effect from "effect/Effect"; + +import { ConfigError } from "./error.ts"; +import { normalizeHttpBaseUrl } from "./url.ts"; + +const environmentNamePattern = /^[A-Za-z0-9._-]+$/; + +export function slugifyEnvironmentName(value: string) { + const slug = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "default"; +} + +export function validateEnvironmentName(name: string) { + if (name.length === 0 || !environmentNamePattern.test(name)) { + return Effect.fail( + new ConfigError({ + message: `invalid environment name '${name}': use non-empty [A-Za-z0-9._-]`, + }), + ); + } + return Effect.void; +} + +export function defaultEnvironmentNameFromUrl(url: string) { + return normalizeHttpBaseUrl(url).pipe( + Effect.flatMap((normalized) => { + const hostname = slugifyEnvironmentName(new URL(normalized).hostname.trim()); + return validateEnvironmentName(hostname).pipe(Effect.as(hostname)); + }), + ); +} + +export function defaultEnvironmentNameForLocal() { + return "local" as const; +} + +export function migrateV1EnvironmentName(input: { + readonly url?: string; + readonly local?: boolean; +}) { + if (input.local === true) { + return Effect.succeed(defaultEnvironmentNameForLocal()); + } + if (input.url !== undefined && input.url.length > 0) { + return defaultEnvironmentNameFromUrl(input.url); + } + return Effect.succeed("default"); +} diff --git a/src/config/file-mode.ts b/src/config/file-mode.ts new file mode 100644 index 0000000..9f7c66f --- /dev/null +++ b/src/config/file-mode.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; + +import { ConfigError } from "./error.ts"; + +const privateFileMode = 0o600; + +export function hardenPrivateFileMode( + fs: FileSystem.FileSystem, + filePath: string, + label: "config" | "credential key", +) { + return fs.chmod(filePath, privateFileMode).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: `failed to set ${label} file permissions`, + cause: error, + }), + ), + ); +} diff --git a/src/config/index.ts b/src/config/index.ts index 503e41a..e58302d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,17 @@ export { T3Config } from "./service.ts"; -export type { ResolvedConfig, StoredConfig } from "./service.ts"; +export type { + EncryptedConfig, + EncryptedToken, + EnvironmentSummary, + ResolvedConfig, + UpsertEnvironmentInput, +} from "./service.ts"; export { T3ConfigLive, makeT3Config } from "./layer.ts"; +export { T3CredentialCrypto } from "./credential-service.ts"; +export type { CredentialCrypto } from "./credential-service.ts"; +export { T3CredentialCryptoLive } from "./credential.ts"; +export { T3ConfigSelection } from "./selection.ts"; +export { T3ConfigSelectionLive } from "./selection-layer.ts"; +export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; export { ConfigError, UrlError } from "./error.ts"; export type { ConfigServiceError } from "./error.ts"; diff --git a/src/config/keyring.ts b/src/config/keyring.ts new file mode 100644 index 0000000..5b91055 --- /dev/null +++ b/src/config/keyring.ts @@ -0,0 +1,54 @@ +import { createRequire } from "node:module"; + +type KeyringEntry = { + getPassword(): string | null; + setPassword(password: string): void; +}; + +type KeyringModule = { + Entry: new (service: string, account: string) => KeyringEntry; +}; + +const require = createRequire(import.meta.url); + +let cachedKeyringModule: KeyringModule | null | undefined; + +function isKeyringModule(value: unknown): value is KeyringModule { + if (typeof value !== "object" || value === null || !("Entry" in value)) { + return false; + } + return typeof value.Entry === "function"; +} + +function loadKeyringModule(): KeyringModule | null { + if (cachedKeyringModule !== undefined) { + return cachedKeyringModule; + } + try { + const loaded: unknown = require("@napi-rs/keyring"); + cachedKeyringModule = isKeyringModule(loaded) ? loaded : null; + } catch { + cachedKeyringModule = null; + } + return cachedKeyringModule; +} + +export type KeyringStore = { + readonly readPassword: (service: string, account: string) => string | null; + readonly writePassword: (service: string, account: string, password: string) => void; +}; + +export function getKeyringStore(): KeyringStore | null { + const keyring = loadKeyringModule(); + if (keyring === null) { + return null; + } + return { + readPassword(service, account) { + return new keyring.Entry(service, account).getPassword(); + }, + writePassword(service, account, password) { + new keyring.Entry(service, account).setPassword(password); + }, + }; +} diff --git a/src/config/layer.ts b/src/config/layer.ts index 34be489..8e251b0 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -1,105 +1,212 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Filter from "effect/Filter"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { Environment, type EnvironmentShape } from "../environment/service.ts"; -import { ConfigError } from "./error.ts"; -import { T3Config, type StoredConfig } from "./service.ts"; +import { Environment } from "../environment/service.ts"; +import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; +import { T3CredentialCrypto } from "./credential-service.ts"; +import { validateEnvironmentName } from "./environment-name.ts"; +import { ConfigError, UrlError } from "./error.ts"; +import { resolveConfigFilePath } from "./paths.ts"; +import { readEncryptedConfigFile, writeEncryptedConfigFile } from "./persist.ts"; +import { + buildResolvedConfigFromEnv, + buildResolvedConfigFromStored, + resolveDefaultForUpsert, + selectEnvironmentName, + summarizeEnvironments, + validateCredentialEnvVars, +} from "./resolve.ts"; +import { T3ConfigSelection } from "./selection.ts"; +import { T3Config, type UpsertEnvironmentInput } from "./service.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; export const makeT3Config = Effect.fn("makeT3Config")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* Environment; - const configFilePath = resolveConfigPath(path, environment); - const readStored = Effect.fn("T3ConfigLive.readStored")(function* () { - const raw = yield* fs.readFileString(configFilePath).pipe( - Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => - Effect.succeed(undefined), - ), - Effect.mapError( - (error) => new ConfigError({ message: "failed to read config", cause: error }), - ), + const credentialCrypto = yield* T3CredentialCrypto; + const configSelection = yield* T3ConfigSelection; + const configFilePath = resolveConfigFilePath(path, environment); + + const readEncrypted = Effect.fn("T3ConfigLive.readEncrypted")(function* () { + return yield* readEncryptedConfigFile(fs, path, configFilePath, credentialCrypto); + }); + + const hasEnvironment = Effect.fn("T3ConfigLive.hasEnvironment")(function* (name: string) { + const encrypted = yield* readEncrypted(); + return encrypted.environments[name] !== undefined; + }); + + const getDefaultEnvironmentName = Effect.fn("T3ConfigLive.getDefaultEnvironmentName")( + function* () { + const encrypted = yield* readEncrypted(); + const defaultName = encrypted.default; + return defaultName !== undefined && defaultName.length > 0 ? defaultName : undefined; + }, + ); + + const resolveActiveEnvironmentName = Effect.fn("T3ConfigLive.resolveActiveEnvironmentName")( + function* () { + const encrypted = yield* readEncrypted(); + const envUrl = environment.env["T3CODE_URL"]?.trim(); + const envToken = environment.env["T3CODE_TOKEN"]?.trim(); + yield* validateCredentialEnvVars({ envUrl, envToken }); + const hasEnvCredentials = + envUrl !== undefined && envUrl.length > 0 && envToken !== undefined && envToken.length > 0; + if (hasEnvCredentials) { + return undefined; + } + const configuredEnvironment = yield* configSelection.getSelectedEnvironment(); + const selectedName = selectEnvironmentName({ + selectedEnvironment: configuredEnvironment, + defaultEnvironment: encrypted.default, + }); + if (selectedName === undefined || selectedName.length === 0) { + return undefined; + } + if (encrypted.environments[selectedName] === undefined) { + return undefined; + } + return selectedName; + }, + ); + + const listEnvironments = Effect.fn("T3ConfigLive.listEnvironments")(function* () { + const encrypted = yield* readEncrypted(); + return summarizeEnvironments(encrypted); + }); + + const upsertEnvironment = Effect.fn("T3ConfigLive.upsertEnvironment")(function* ( + input: UpsertEnvironmentInput, + ) { + yield* validateEnvironmentName(input.name); + const normalizedUrl = yield* normalizeHttpBaseUrl(input.url).pipe( + Effect.mapError(mapUrlToConfigError), ); - if (raw === undefined) { - return {}; + const encrypted = yield* readEncrypted(); + const token = yield* encryptEnvironment(credentialCrypto, { + environmentName: input.name, + url: normalizedUrl, + local: input.local, + token: input.token, + }); + const defaultName = resolveDefaultForUpsert(encrypted, input.name, input.makeDefault); + yield* writeEncryptedConfigFile(fs, path, configFilePath, { + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments: { + ...encrypted.environments, + [input.name]: { + url: normalizedUrl, + local: input.local, + token, + }, + }, + }); + }); + + const setDefaultEnvironment = Effect.fn("T3ConfigLive.setDefaultEnvironment")(function* ( + name: string, + ) { + yield* validateEnvironmentName(name); + const encrypted = yield* readEncrypted(); + if (encrypted.environments[name] === undefined) { + return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } - return yield* parseStoredConfig(raw); + yield* writeEncryptedConfigFile(fs, path, configFilePath, { + ...encrypted, + default: name, + }); + return yield* Effect.void; }); - const writeStored = Effect.fn("T3ConfigLive.writeStored")(function* (config: StoredConfig) { - yield* fs - .makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }) - .pipe( - Effect.mapError( - (error) => new ConfigError({ message: "failed to write config", cause: error }), - ), - ); - yield* fs - .writeFileString(configFilePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) - .pipe( - Effect.mapError( - (error) => new ConfigError({ message: "failed to write config", cause: error }), - ), - ); + + const removeEnvironment = Effect.fn("T3ConfigLive.removeEnvironment")(function* (name: string) { + yield* validateEnvironmentName(name); + const encrypted = yield* readEncrypted(); + if (encrypted.environments[name] === undefined) { + return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); + } + const { [name]: _removed, ...environments } = encrypted.environments; + const defaultName = encrypted.default === name ? undefined : encrypted.default; + yield* writeEncryptedConfigFile(fs, path, configFilePath, { + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments, + }); + return yield* Effect.void; }); + const resolve = Effect.fn("T3ConfigLive.resolve")(function* () { - const stored = yield* readStored(); - const envUrl = environment.env["T3CODE_URL"]; - const envToken = environment.env["T3CODE_TOKEN"]; - const envUrlValue = envUrl?.trim(); - const envTokenValue = envToken?.trim(); - const url = envUrlValue !== undefined && envUrlValue.length > 0 ? envUrlValue : stored.url; - const token = - envTokenValue !== undefined && envTokenValue.length > 0 ? envTokenValue : stored.token; - if (url === undefined || url.length === 0 || token === undefined || token.length === 0) { + const encrypted = yield* readEncrypted(); + const envUrl = environment.env["T3CODE_URL"]?.trim(); + const envToken = environment.env["T3CODE_TOKEN"]?.trim(); + yield* validateCredentialEnvVars({ envUrl, envToken }); + const hasEnvUrl = envUrl !== undefined && envUrl.length > 0; + const configuredEnvironment = yield* configSelection.getSelectedEnvironment(); + const selectedName = selectEnvironmentName({ + selectedEnvironment: configuredEnvironment, + defaultEnvironment: encrypted.default, + }); + + if (hasEnvUrl && envToken !== undefined) { + if (selectedName !== undefined && selectedName.length > 0) { + yield* validateEnvironmentName(selectedName); + if (encrypted.environments[selectedName] === undefined) { + return yield* Effect.fail( + new ConfigError({ message: `environment not found: ${selectedName}` }), + ); + } + } + return yield* buildResolvedConfigFromEnv({ + envUrl, + envToken, + }); + } + + if (selectedName === undefined || selectedName.length === 0) { return yield* Effect.fail( - new ConfigError({ message: "not authenticated. run: t3cli auth pair " }), + new ConfigError({ + message: "not authenticated. run: t3cli auth pair --url ", + }), ); } - const source: "env" | "config" = - (envUrlValue !== undefined && envUrlValue.length > 0) || - (envTokenValue !== undefined && envTokenValue.length > 0) - ? "env" - : "config"; - const normalizedUrl = yield* normalizeHttpBaseUrl(url); - return { - url: normalizedUrl, + yield* validateEnvironmentName(selectedName); + if (encrypted.environments[selectedName] === undefined) { + return yield* Effect.fail( + new ConfigError({ message: `environment not found: ${selectedName}` }), + ); + } + + const selectedEnvironment = encrypted.environments[selectedName]; + const token = yield* decryptEnvironment(credentialCrypto, { + environmentName: selectedName, + url: selectedEnvironment.url, + local: selectedEnvironment.local, + token: selectedEnvironment.token, + }); + return yield* buildResolvedConfigFromStored({ + selectedName, token, - source, - local: stored.local ?? false, - }; + encrypted, + }); }); return { - readStored, - writeStored, resolve, - }; + resolveActiveEnvironmentName, + listEnvironments, + upsertEnvironment, + setDefaultEnvironment, + removeEnvironment, + hasEnvironment, + getDefaultEnvironmentName, + } as const; }); export const T3ConfigLive = Layer.effect(T3Config, makeT3Config()); -const StoredConfigSchema = Schema.Struct({ - url: Schema.optionalKey(Schema.String), - token: Schema.optionalKey(Schema.String), - local: Schema.optionalKey(Schema.Boolean), -}); - -function parseStoredConfig(raw: string) { - return Schema.decodeUnknownEffect(Schema.fromJsonString(StoredConfigSchema))(raw).pipe( - Effect.mapError((error) => new ConfigError({ message: "failed to read config", cause: error })), - ); -} - -function resolveConfigPath(path: Path.Path, environment: EnvironmentShape) { - const xdgConfigHome = environment.env["XDG_CONFIG_HOME"]; - const xdgConfigHomeValue = xdgConfigHome?.trim(); - const root = - xdgConfigHomeValue !== undefined && xdgConfigHomeValue.length > 0 - ? xdgConfigHomeValue - : path.join(environment.homeDir, ".config"); - return path.join(root, "t3cli", "config.json"); +function mapUrlToConfigError(error: UrlError) { + return new ConfigError({ message: error.message }); } diff --git a/src/config/migration.ts b/src/config/migration.ts new file mode 100644 index 0000000..928a918 --- /dev/null +++ b/src/config/migration.ts @@ -0,0 +1,83 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { encryptEnvironment } from "./codec.ts"; +import type { CredentialCrypto } from "./credential-service.ts"; +import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; +import { ConfigError, UrlError } from "./error.ts"; +import { + StoredConfigV1FileSchema, + StoredConfigV2FileSchema, + type StoredConfigV1File, +} from "./schema.ts"; +import type { EncryptedConfig } from "./types.ts"; +import { normalizeHttpBaseUrl } from "./url.ts"; + +export const emptyEncryptedConfig = (): EncryptedConfig => ({ + version: 2, + environments: {}, +}); + +export function readEncryptedConfigFromValue(crypto: CredentialCrypto, value: unknown) { + return Effect.gen(function* () { + const v2 = yield* Schema.decodeUnknownEffect(StoredConfigV2FileSchema)(value).pipe( + Effect.option, + ); + if (Option.isSome(v2)) { + return { config: v2.value, migratedFromV1: false as const }; + } + const v1 = yield* Schema.decodeUnknownEffect(StoredConfigV1FileSchema)(value); + const migrated = yield* migrateV1FileToEncrypted(crypto, v1); + return { config: migrated, migratedFromV1: true as const }; + }).pipe(Effect.mapError((error) => mapMigrationError(error))); +} + +export function migrateV1FileToEncrypted(crypto: CredentialCrypto, config: StoredConfigV1File) { + return Effect.gen(function* () { + if ( + (config.url === undefined || config.url.length === 0) && + (config.token === undefined || config.token.length === 0) + ) { + return emptyEncryptedConfig(); + } + if (config.url === undefined || config.token === undefined) { + return yield* Effect.fail( + new ConfigError({ message: "failed to read config: incomplete v1 credentials" }), + ); + } + const name = yield* migrateV1EnvironmentName(config).pipe( + Effect.flatMap((migratedName) => + validateEnvironmentName(migratedName).pipe(Effect.as(migratedName)), + ), + ); + const normalizedUrl = yield* normalizeHttpBaseUrl(config.url); + const token = yield* encryptEnvironment(crypto, { + environmentName: name, + url: normalizedUrl, + local: config.local ?? false, + token: config.token, + }); + return { + version: 2 as const, + default: name, + environments: { + [name]: { + url: normalizedUrl, + local: config.local ?? false, + token, + }, + }, + } satisfies EncryptedConfig; + }); +} + +function mapMigrationError(error: ConfigError | Schema.SchemaError | UrlError) { + if (error instanceof ConfigError) { + return error; + } + if (error instanceof UrlError) { + return new ConfigError({ message: `failed to read config: ${error.message}` }); + } + return new ConfigError({ message: "failed to read config", cause: error }); +} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 0000000..f9c0487 --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,20 @@ +import type * as Path from "effect/Path"; + +import type { EnvironmentShape } from "../environment/service.ts"; + +export function resolveT3cliConfigDir(path: Path.Path, environment: EnvironmentShape) { + const xdgConfigHome = environment.env["XDG_CONFIG_HOME"]?.trim(); + const root = + xdgConfigHome !== undefined && xdgConfigHome.length > 0 + ? xdgConfigHome + : path.join(environment.homeDir, ".config"); + return path.join(root, "t3cli"); +} + +export function resolveConfigFilePath(path: Path.Path, environment: EnvironmentShape) { + return path.join(resolveT3cliConfigDir(path, environment), "config.json"); +} + +export function resolveKeyFilePath(path: Path.Path, environment: EnvironmentShape) { + return path.join(resolveT3cliConfigDir(path, environment), "key"); +} diff --git a/src/config/persist.ts b/src/config/persist.ts new file mode 100644 index 0000000..3bfa79d --- /dev/null +++ b/src/config/persist.ts @@ -0,0 +1,65 @@ +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Filter from "effect/Filter"; +import type * as Path from "effect/Path"; + +import type { CredentialCrypto } from "./credential-service.ts"; +import { ConfigError } from "./error.ts"; +import { hardenPrivateFileMode } from "./file-mode.ts"; +import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; +import type { EncryptedConfig } from "./types.ts"; + +export function readEncryptedConfigFile( + fs: FileSystem.FileSystem, + path: Path.Path, + configFilePath: string, + crypto: CredentialCrypto, +) { + return Effect.gen(function* () { + const raw = yield* fs.readFileString(configFilePath).pipe( + Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => + Effect.succeed(undefined), + ), + Effect.mapError( + (error) => new ConfigError({ message: "failed to read config", cause: error }), + ), + ); + if (raw === undefined) { + return emptyEncryptedConfig(); + } + const value = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: () => new ConfigError({ message: "failed to read config" }), + }); + const read = yield* readEncryptedConfigFromValue(crypto, value); + if (read.migratedFromV1) { + yield* writeEncryptedConfigFile(fs, path, configFilePath, read.config); + } + return read.config; + }); +} + +export function writeEncryptedConfigFile( + fs: FileSystem.FileSystem, + path: Path.Path, + configFilePath: string, + config: EncryptedConfig, +) { + return Effect.gen(function* () { + yield* fs + .makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }) + .pipe( + Effect.mapError( + (error) => new ConfigError({ message: "failed to write config", cause: error }), + ), + ); + yield* fs + .writeFileString(configFilePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + .pipe( + Effect.mapError( + (error) => new ConfigError({ message: "failed to write config", cause: error }), + ), + ); + yield* hardenPrivateFileMode(fs, configFilePath, "config"); + }); +} diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts new file mode 100644 index 0000000..8aadb72 --- /dev/null +++ b/src/config/resolve.test.ts @@ -0,0 +1,167 @@ +import "vite-plus/test/config"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { + buildResolvedConfigFromEnv, + resolveDefaultForUpsert, + selectEnvironmentName, + validateCredentialEnvVars, +} from "./resolve.ts"; +import { resolveConfiguredEnvironment } from "./selection-resolve.ts"; +import type { EncryptedConfig } from "./types.ts"; + +const sampleEncrypted = (input: { + readonly default?: string; + readonly environments?: EncryptedConfig["environments"]; +}): EncryptedConfig => ({ + version: 2, + ...(input.default !== undefined ? { default: input.default } : {}), + environments: input.environments ?? {}, +}); + +describe("resolveConfiguredEnvironment", () => { + it("prefers cli flag over T3CLI_ENV", () => { + assert.equal( + resolveConfiguredEnvironment({ + cliFlag: "cli", + t3cliEnv: "env", + }), + "cli", + ); + }); + + it("falls back to T3CLI_ENV when cli flag is absent", () => { + assert.equal( + resolveConfiguredEnvironment({ + t3cliEnv: "env", + }), + "env", + ); + }); +}); + +describe("selectEnvironmentName", () => { + it("prefers selected environment over config default", () => { + const selected = selectEnvironmentName({ + selectedEnvironment: "cli", + defaultEnvironment: "default", + }); + assert.equal(selected, "cli"); + }); + + it("falls back to config default", () => { + assert.equal( + selectEnvironmentName({ + defaultEnvironment: "default", + }), + "default", + ); + }); +}); + +describe("validateCredentialEnvVars", () => { + it.effect("requires both T3CODE_URL and T3CODE_TOKEN together", () => + Effect.gen(function* () { + const onlyUrl = yield* validateCredentialEnvVars({ envUrl: "https://example.com" }).pipe( + Effect.exit, + ); + assert.equal(Exit.isFailure(onlyUrl), true); + + const onlyToken = yield* validateCredentialEnvVars({ envToken: "token" }).pipe(Effect.exit); + assert.equal(Exit.isFailure(onlyToken), true); + + yield* validateCredentialEnvVars({ + envUrl: "https://example.com", + envToken: "token", + }); + }), + ); +}); + +describe("buildResolvedConfigFromEnv", () => { + it.effect( + "does not inherit local=true from selected stored environment when env vars override", + () => + Effect.gen(function* () { + const resolved = yield* buildResolvedConfigFromEnv({ + envUrl: "https://remote.example", + envToken: "env-token", + }); + assert.equal(resolved.source, "env"); + assert.equal(resolved.local, false); + assert.equal(resolved.url, "https://remote.example"); + assert.equal(resolved.token, "env-token"); + }), + ); +}); + +describe("resolveDefaultForUpsert", () => { + it("sets default only for the first environment", () => { + assert.equal(resolveDefaultForUpsert(sampleEncrypted({}), "first"), "first"); + assert.equal( + resolveDefaultForUpsert( + sampleEncrypted({ + default: "home", + environments: { + home: { + url: "https://home.example", + local: false, + token: { + kind: "encrypted", + alg: "aes-256-gcm", + key: "default", + nonce: "n", + ciphertext: "c", + tag: "t", + }, + }, + }, + }), + "work", + ), + "home", + ); + }); + + it("promotes default when makeDefault is true", () => { + assert.equal( + resolveDefaultForUpsert( + sampleEncrypted({ + default: "home", + environments: { + home: { + url: "https://home.example", + local: false, + token: { + kind: "encrypted", + alg: "aes-256-gcm", + key: "default", + nonce: "n", + ciphertext: "c", + tag: "t", + }, + }, + work: { + url: "https://work.example", + local: false, + token: { + kind: "encrypted", + alg: "aes-256-gcm", + key: "default", + nonce: "n2", + ciphertext: "c2", + tag: "t2", + }, + }, + }, + }), + "work", + true, + ), + "work", + ); + }); +}); diff --git a/src/config/resolve.ts b/src/config/resolve.ts new file mode 100644 index 0000000..bf9152e --- /dev/null +++ b/src/config/resolve.ts @@ -0,0 +1,101 @@ +import * as Effect from "effect/Effect"; + +import { validateEnvironmentName } from "./environment-name.ts"; +import { ConfigError } from "./error.ts"; +import type { EncryptedConfig, ResolvedConfig } from "./types.ts"; +import { normalizeHttpBaseUrl } from "./url.ts"; + +export type ResolveSelectionInput = { + readonly selectedEnvironment?: string | undefined; + readonly defaultEnvironment?: string | undefined; +}; + +export function selectEnvironmentName(input: ResolveSelectionInput) { + if (input.selectedEnvironment !== undefined && input.selectedEnvironment.length > 0) { + return input.selectedEnvironment; + } + return input.defaultEnvironment; +} + +export type ResolveCredentialInput = { + readonly envUrl?: string | undefined; + readonly envToken?: string | undefined; +}; + +export function validateCredentialEnvVars(input: ResolveCredentialInput) { + const hasEnvUrl = input.envUrl !== undefined && input.envUrl.length > 0; + const hasEnvToken = input.envToken !== undefined && input.envToken.length > 0; + if (hasEnvUrl !== hasEnvToken) { + return Effect.fail( + new ConfigError({ + message: "T3CODE_URL and T3CODE_TOKEN must both be set together", + }), + ); + } + return Effect.void; +} + +export function resolveDefaultForUpsert( + encrypted: EncryptedConfig, + environmentName: string, + makeDefault?: boolean, +): string | undefined { + if (makeDefault === true) { + return environmentName; + } + if (Object.keys(encrypted.environments).length === 0) { + return environmentName; + } + return encrypted.default; +} + +export function summarizeEnvironments(encrypted: EncryptedConfig) { + return Object.entries(encrypted.environments) + .map(([name, environmentConfig]) => ({ + name, + url: environmentConfig.url, + local: environmentConfig.local, + default: encrypted.default === name, + })) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function buildResolvedConfigFromEnv(input: { + readonly envUrl: string; + readonly envToken: string; +}) { + return normalizeHttpBaseUrl(input.envUrl).pipe( + Effect.map((normalizedUrl) => { + return { + url: normalizedUrl, + token: input.envToken, + source: "env" as const, + local: false, + } satisfies ResolvedConfig; + }), + ); +} + +export function buildResolvedConfigFromStored(input: { + readonly selectedName: string; + readonly token: string; + readonly encrypted: EncryptedConfig; +}) { + return Effect.gen(function* () { + yield* validateEnvironmentName(input.selectedName); + const selectedEnvironment = input.encrypted.environments[input.selectedName]; + if (selectedEnvironment === undefined) { + return yield* Effect.fail( + new ConfigError({ message: `environment not found: ${input.selectedName}` }), + ); + } + const normalizedUrl = yield* normalizeHttpBaseUrl(selectedEnvironment.url); + return { + url: normalizedUrl, + token: input.token, + source: "config" as const, + local: selectedEnvironment.local, + environment: input.selectedName, + } satisfies ResolvedConfig; + }); +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..e736a5e --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; + +export const EncryptedTokenSchema = Schema.Struct({ + kind: Schema.Literal("encrypted"), + alg: Schema.Literal("aes-256-gcm"), + key: Schema.Literal("default"), + nonce: Schema.String, + ciphertext: Schema.String, + tag: Schema.String, +}); + +export const StoredEnvironmentFileSchema = Schema.Struct({ + url: Schema.String, + local: Schema.Boolean, + token: EncryptedTokenSchema, +}); + +export const StoredConfigV2FileSchema = Schema.Struct({ + version: Schema.Literal(2), + default: Schema.optionalKey(Schema.String), + environments: Schema.Record(Schema.String, StoredEnvironmentFileSchema), +}); + +export const StoredConfigV1FileSchema = Schema.Struct({ + url: Schema.optionalKey(Schema.String), + token: Schema.optionalKey(Schema.String), + local: Schema.optionalKey(Schema.Boolean), +}); + +export type EncryptedToken = Schema.Schema.Type; +export type StoredEnvironmentFile = Schema.Schema.Type; +export type StoredConfigV2File = Schema.Schema.Type; +export type StoredConfigV1File = Schema.Schema.Type; diff --git a/src/config/selection-layer.ts b/src/config/selection-layer.ts new file mode 100644 index 0000000..fe079c9 --- /dev/null +++ b/src/config/selection-layer.ts @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { Environment } from "../environment/service.ts"; +import { resolveConfiguredEnvironment } from "./selection-resolve.ts"; +import { T3ConfigSelection } from "./selection.ts"; + +export const T3ConfigSelectionLive = Layer.effect( + T3ConfigSelection, + Effect.gen(function* () { + const environment = yield* Environment; + return { + getSelectedEnvironment: () => + Effect.sync(() => + resolveConfiguredEnvironment({ + t3cliEnv: environment.env["T3CLI_ENV"], + }), + ), + }; + }), +); diff --git a/src/config/selection-resolve.ts b/src/config/selection-resolve.ts new file mode 100644 index 0000000..4f9f9f1 --- /dev/null +++ b/src/config/selection-resolve.ts @@ -0,0 +1,14 @@ +export function resolveConfiguredEnvironment(input: { + readonly cliFlag?: string | undefined; + readonly t3cliEnv?: string | undefined; +}): string | undefined { + const fromFlag = input.cliFlag?.trim(); + if (fromFlag !== undefined && fromFlag.length > 0) { + return fromFlag; + } + const fromEnv = input.t3cliEnv?.trim(); + if (fromEnv !== undefined && fromEnv.length > 0) { + return fromEnv; + } + return undefined; +} diff --git a/src/config/selection.ts b/src/config/selection.ts new file mode 100644 index 0000000..febba7c --- /dev/null +++ b/src/config/selection.ts @@ -0,0 +1,9 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export class T3ConfigSelection extends Context.Service< + T3ConfigSelection, + { + readonly getSelectedEnvironment: () => Effect.Effect; + } +>()("t3cli/T3ConfigSelection") {} diff --git a/src/config/service.ts b/src/config/service.ts index 3fc0ded..062e465 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -2,25 +2,32 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type { ConfigError, ConfigServiceError } from "./error.ts"; - -export type StoredConfig = { - readonly url?: string; - readonly token?: string; - readonly local?: boolean; -}; - -export type ResolvedConfig = { - readonly url: string; - readonly token: string; - readonly source: "env" | "config"; - readonly local: boolean; -}; +import type { + EncryptedConfig, + EncryptedToken, + EnvironmentSummary, + ResolvedConfig, + UpsertEnvironmentInput, +} from "./types.ts"; export class T3Config extends Context.Service< T3Config, { - readonly readStored: () => Effect.Effect; - readonly writeStored: (config: StoredConfig) => Effect.Effect; readonly resolve: () => Effect.Effect; + readonly resolveActiveEnvironmentName: () => Effect.Effect; + readonly listEnvironments: () => Effect.Effect; + readonly upsertEnvironment: (input: UpsertEnvironmentInput) => Effect.Effect; + readonly setDefaultEnvironment: (name: string) => Effect.Effect; + readonly removeEnvironment: (name: string) => Effect.Effect; + readonly hasEnvironment: (name: string) => Effect.Effect; + readonly getDefaultEnvironmentName: () => Effect.Effect; } >()("t3cli/T3Config") {} + +export type { + EncryptedConfig, + EncryptedToken, + EnvironmentSummary, + ResolvedConfig, + UpsertEnvironmentInput, +}; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..8b12a30 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,45 @@ +import type { EncryptedToken, StoredConfigV2File, StoredEnvironmentFile } from "./schema.ts"; + +export type EncryptedEnvironment = StoredEnvironmentFile; + +export type EncryptedConfig = StoredConfigV2File; + +export type DecryptedEnvironment = { + readonly url: string; + readonly local: boolean; + readonly token: string; +}; + +export type DecryptedConfig = { + readonly version: 2; + readonly default?: string; + readonly environments: Readonly>; +}; + +export type EnvironmentSummary = { + readonly name: string; + readonly url: string; + readonly local: boolean; + readonly default: boolean; +}; + +export type ResolvedConfig = { + readonly url: string; + readonly token: string; + readonly source: "env" | "config"; + readonly local: boolean; + readonly environment?: string; +}; + +export type UpsertEnvironmentInput = { + readonly name: string; + readonly url: string; + readonly token: string; + readonly local: boolean; + readonly makeDefault?: boolean; +}; + +export type StoredConfig = DecryptedConfig; +export type StoredEnvironment = DecryptedEnvironment; + +export type { EncryptedToken }; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 76eb899..e341698 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,6 +1,8 @@ export { AppLayer, AuthAppLayer, + BaseAppLayer, + BaseAuthAppLayer, T3AuthLayer, T3AuthPairingLayer, T3AuthTransportLayer, diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts new file mode 100644 index 0000000..798faf1 --- /dev/null +++ b/src/runtime/layer.test.ts @@ -0,0 +1,130 @@ +import "vite-plus/test/config"; + +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Command } from "effect/unstable/cli"; + +import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; +import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; +import { Environment } from "../environment/service.ts"; +import type { CredentialCrypto } from "../config/credential-service.ts"; +import { T3CredentialCrypto } from "../config/credential-service.ts"; +import type { ResolvedConfig } from "../config/types.ts"; +import { T3Config } from "../config/service.ts"; +import { BaseAppLayer } from "./layer.ts"; + +const testMasterKey = Buffer.alloc(32, 7); + +const testCredentialCrypto: CredentialCrypto = { + encrypt: (input) => + Effect.sync(() => { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); + cipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); + return { + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: nonce.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + }; + }), + decrypt: (input) => + Effect.sync(() => { + const nonce = Buffer.from(input.token.nonce, "base64"); + const ciphertext = Buffer.from(input.token.ciphertext, "base64"); + const tag = Buffer.from(input.token.tag, "base64"); + const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { + authTagLength: 16, + }); + decipher.setAAD( + Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), + ); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + }), +}; + +function makeCliAppLayer(homeDir: string) { + const environmentLayer = Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: { T3CLI_ENV: "home" }, + stdoutIsTTY: false, + stderrIsTTY: false, + }); + return BaseAppLayer.pipe( + Layer.provideMerge(T3CliConfigSelectionLive), + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + environmentLayer, + Layer.succeed(T3CredentialCrypto, testCredentialCrypto), + ), + ), + ); +} + +describe("CLI app layer composition", () => { + it("routes --environment through Command.run to T3Config.resolve ahead of default and T3CLI_ENV", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "t3cli-runtime-test-")); + const cliAppLayer = makeCliAppLayer(homeDir); + const resolvedRef = await Effect.runPromise(Ref.make(undefined)); + const resolveProbeCommand = Command.make("resolve-probe", {}, () => + Effect.gen(function* () { + const config = yield* T3Config; + const resolved = yield* config.resolve(); + yield* Ref.set(resolvedRef, resolved); + }), + ).pipe(Command.withGlobalFlags([cliEnvironmentSetting])); + const runResolveProbe = Command.runWith(resolveProbeCommand, { version: "0.0.0-test" }); + try { + await Effect.runPromise( + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + }).pipe(Effect.provide(cliAppLayer)), + ); + + await Effect.runPromise( + runResolveProbe(["--environment", "work"]).pipe( + Effect.provide(cliAppLayer), + Effect.provide(NodeServices.layer), + ), + ); + + const resolved = await Effect.runPromise(Ref.get(resolvedRef)); + assert.isDefined(resolved); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + assert.equal(resolved.token, "work-token"); + } + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index 33ace43..c8a5f56 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -11,6 +11,8 @@ import { T3LocalAuthTokenLive } from "../auth/local-token.ts"; import { T3AuthPairingLive } from "../auth/pairing.ts"; import { T3AuthTransportLive } from "../auth/transport.ts"; import { T3ConfigLive } from "../config/layer.ts"; +import { T3CredentialCryptoLive } from "../config/credential.ts"; +import { T3ConfigSelectionLive } from "../config/selection-layer.ts"; import { T3Config } from "../config/service.ts"; import { T3CodeConnectionError } from "../connection/error.ts"; import { T3CodeConnectionProvider, makeT3CodeConnectionProvider } from "../connection/service.ts"; @@ -20,6 +22,8 @@ import { T3RpcOperationsLive } from "../rpc/operation.ts"; import { NodeSqlClientFactoryLive } from "../sql/node-sqlite-client.ts"; import { NodeCliPathLayer } from "../cli-path/layer.ts"; +export const T3CredentialCryptoLayer = T3CredentialCryptoLive; +export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLayer)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); @@ -33,7 +37,7 @@ export const T3LocalAuthLayer = T3LocalAuthLive.pipe( export const T3AuthPairingLayer = T3AuthPairingLive.pipe(Layer.provide(T3AuthTransportLayer)); export const T3AuthLayer = T3AuthLive.pipe( Layer.provide( - Layer.mergeAll(T3ConfigLive, T3AuthTransportLayer, T3LocalAuthLayer, T3AuthPairingLayer), + Layer.mergeAll(T3ConfigLayer, T3AuthTransportLayer, T3LocalAuthLayer, T3AuthPairingLayer), ), ); const T3ConfigConnectionProviderLayer = Layer.effect( @@ -56,7 +60,7 @@ const T3ConfigConnectionProviderLayer = Layer.effect( ), ); }), -).pipe(Layer.provide(T3ConfigLive)); +).pipe(Layer.provide(T3ConfigLayer)); const T3RpcLayer = T3RpcLive.pipe( Layer.provide( @@ -73,10 +77,8 @@ const T3ApplicationLayer = T3ApplicationLive.pipe( Layer.provide(Layer.mergeAll(T3RpcOperationsLayer, T3OrchestrationLayer)), ); -export const AuthAppLayer = Layer.mergeAll(T3ConfigLive, T3AuthLayer); - -export const AppLayer = Layer.mergeAll( - T3ConfigLive, +export const BaseAppLayer = Layer.mergeAll( + T3ConfigLayer, T3AuthLayer, T3RpcLayer, T3RpcOperationsLayer, @@ -84,3 +86,9 @@ export const AppLayer = Layer.mergeAll( T3ApplicationLayer, NodeCliPathLayer, ); + +export const BaseAuthAppLayer = Layer.mergeAll(T3ConfigLayer, T3AuthLayer); + +export const AuthAppLayer = BaseAuthAppLayer.pipe(Layer.provideMerge(T3ConfigSelectionLive)); + +export const AppLayer = BaseAppLayer.pipe(Layer.provideMerge(T3ConfigSelectionLive)); diff --git a/vite.config.ts b/vite.config.ts index 11f877c..fdd61cf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,13 @@ import packageJson from "./package.json" with { type: "json" }; const rootDir = path.dirname(fileURLToPath(import.meta.url)); +function shouldBundlePackDependency(id: string): boolean { + if (id === "@napi-rs/keyring" || id.startsWith("@napi-rs/keyring-")) { + return false; + } + return true; +} + export default defineConfig({ resolve: { alias: { @@ -43,7 +50,7 @@ export default defineConfig({ t3tools: "src/t3tools/index.ts", }, deps: { - alwaysBundle: /^.+$/, + alwaysBundle: shouldBundlePackDependency, onlyBundle: false, }, dts: false, From 46feb4af19bd163a1d4f001cdb2d97e24a2a4078 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 19:49:54 +0300 Subject: [PATCH 02/21] Mark node fs/path boundaries in tests and vite config. Add nodeBuiltinImport suppressions for integration tests and build config that legitimately use node:fs and node:path. Co-authored-by: Cursor --- src/auth/auth.test.ts | 1 + src/config/config.test.ts | 1 + src/config/credential.test.ts | 1 + src/runtime/layer.test.ts | 1 + vite.config.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 89d6dc1..0cec38d 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 96d375c..f3530e3 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index f5ffa2e..dbe38cc 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; import { randomBytes } from "node:crypto"; diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 798faf1..c142666 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; diff --git a/vite.config.ts b/vite.config.ts index fdd61cf..000d3f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Vite config resolves local paths with node:path. import path from "node:path"; import { fileURLToPath } from "node:url"; From c9f0713eb2d43f8f0a274dc15017c0720ec15814 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:02:07 +0300 Subject: [PATCH 03/21] Refactor integration tests to use Effect platform services. Tests now use scoped temp directories, T3CredentialCryptoLive, and effect/Crypto instead of node builtins and ad-hoc mocks. Co-authored-by: Cursor --- src/auth/auth.test.ts | 223 +++++++------- src/config/config.test.ts | 543 ++++++++++++++++++---------------- src/config/credential.test.ts | 229 +++++++------- src/runtime/layer.test.ts | 150 ++++------ 4 files changed, 549 insertions(+), 596 deletions(-) diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 0cec38d..68550db 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -1,63 +1,27 @@ -// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import { T3LocalAuth } from "./local.ts"; import { T3AuthPairing } from "./pairing.ts"; import { T3AuthLive } from "./layer.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; +import { T3CredentialCryptoLive } from "../config/credential.ts"; import { T3ConfigLive } from "../config/layer.ts"; -import type { CredentialCrypto } from "../config/credential-service.ts"; -import { T3CredentialCrypto } from "../config/credential-service.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; -const testMasterKey = Buffer.alloc(32, 7); - -const testCredentialCrypto: CredentialCrypto = { - encrypt: (input) => - Effect.sync(() => { - const nonce = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); - cipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); - return { - kind: "encrypted" as const, - alg: "aes-256-gcm" as const, - key: "default" as const, - nonce: nonce.toString("base64"), - ciphertext: ciphertext.toString("base64"), - tag: cipher.getAuthTag().toString("base64"), - }; - }), - decrypt: (input) => - Effect.sync(() => { - const nonce = Buffer.from(input.token.nonce, "base64"); - const ciphertext = Buffer.from(input.token.ciphertext, "base64"); - const tag = Buffer.from(input.token.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { - authTagLength: 16, - }); - decipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); - }), -}; +vi.mock("../config/keyring.ts", () => ({ + getKeyringStore: () => null, +})); function makeAuthLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ @@ -67,12 +31,12 @@ function makeAuthLayer(homeDir: string) { stdoutIsTTY: false, stderrIsTTY: false, }); + const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); const configLayer = T3ConfigLive.pipe( + Layer.provide(T3CredentialCryptoLive), Layer.provide( Layer.mergeAll( - NodeServices.layer, - Layer.succeed(T3CredentialCrypto, testCredentialCrypto), - environmentLayer, + platformLayer, Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(undefined), }), @@ -100,10 +64,12 @@ function makeAuthLayer(homeDir: string) { } describe("T3Auth persistence", () => { - it("fails to persist a duplicate environment without allowReplace", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); - try { - await Effect.runPromise( + it.effect("fails to persist a duplicate environment without allowReplace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const auth = yield* T3Auth; yield* auth.persistEnvironment({ @@ -124,16 +90,18 @@ describe("T3Auth persistence", () => { .pipe(Effect.exit); assert.equal(Exit.isFailure(result), true); }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("does not change default when replace is used for a new environment", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); - try { - await Effect.runPromise( + it.effect("does not change default when replace is used for a new environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const auth = yield* T3Auth; yield* auth.persistEnvironment({ @@ -155,16 +123,18 @@ describe("T3Auth persistence", () => { assert.equal(listed.find((environment) => environment.name === "home")?.default, true); assert.equal(listed.find((environment) => environment.name === "work")?.default, false); }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("promotes default when replacing an existing environment with replace", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); - try { - await Effect.runPromise( + it.effect("promotes default when replacing an existing environment with replace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const auth = yield* T3Auth; yield* auth.persistEnvironment({ @@ -192,58 +162,69 @@ describe("T3Auth persistence", () => { const listed = yield* auth.listEnvironments(); assert.equal(listed.find((environment) => environment.name === "work")?.default, true); }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("lists environments without decrypting tokens", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); - const configPath = join(homeDir, ".config", "t3cli", "config.json"); - try { - await Effect.runPromise( + it.effect("lists environments without decrypting tokens", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + return { fs, path, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, homeDir }) => Effect.gen(function* () { - const auth = yield* T3Auth; - yield* auth.persistEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - allowReplace: true, - }); - yield* auth.persistEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: false, - allowReplace: true, + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + + yield* Effect.gen(function* () { + const auth = yield* T3Auth; + yield* auth.persistEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + allowReplace: true, + }); + yield* auth.persistEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + allowReplace: true, + }); + }).pipe(Effect.provide(makeAuthLayer(homeDir))); + + const raw = yield* fs.readFileString(configPath); + const parsed: { + environments: Record; + } = JSON.parse(raw); + parsed.environments.home!.token.tag = "AAAAAAAAAAAAAAAAAAAAAA=="; + yield* fs.writeFileString(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { + mode: 0o600, }); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - const raw = await readFile(configPath, "utf8"); - const parsed: { - environments: Record; - } = JSON.parse(raw); - parsed.environments.home!.token.tag = "AAAAAAAAAAAAAAAAAAAAAA=="; - await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 }); - await Effect.runPromise( - Effect.gen(function* () { - const auth = yield* T3Auth; - const listed = yield* auth.listEnvironments(); - assert.equal(listed.length, 2); - assert.equal(listed.find((environment) => environment.name === "home")?.active, true); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); - it("resolves unpair target from encrypted default metadata", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-auth-test-")); - try { - await Effect.runPromise( + yield* Effect.gen(function* () { + const auth = yield* T3Auth; + const listed = yield* auth.listEnvironments(); + assert.equal(listed.length, 2); + assert.equal(listed.find((environment) => environment.name === "home")?.active, true); + }).pipe(Effect.provide(makeAuthLayer(homeDir))); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); + + it.effect("resolves unpair target from encrypted default metadata", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const auth = yield* T3Auth; yield* auth.persistEnvironment({ @@ -256,9 +237,9 @@ describe("T3Auth persistence", () => { const target = yield* auth.resolveUnpairTarget({}); assert.equal(target, "home"); }).pipe(Effect.provide(makeAuthLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); }); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f3530e3..0aaa611 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1,62 +1,26 @@ -// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment } from "./codec.ts"; -import type { CredentialCrypto } from "./credential-service.ts"; -import { T3CredentialCrypto } from "./credential-service.ts"; +import { makeT3CredentialCrypto, T3CredentialCryptoLive } from "./credential.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { T3ConfigLive } from "./layer.ts"; import { T3ConfigSelection } from "./selection.ts"; import { T3ConfigSelectionLive } from "./selection-layer.ts"; import { T3Config } from "./service.ts"; -const testMasterKey = Buffer.alloc(32, 7); - -const testCredentialCrypto: CredentialCrypto = { - encrypt: (input) => - Effect.sync(() => { - const nonce = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); - cipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); - return { - kind: "encrypted" as const, - alg: "aes-256-gcm" as const, - key: "default" as const, - nonce: nonce.toString("base64"), - ciphertext: ciphertext.toString("base64"), - tag: cipher.getAuthTag().toString("base64"), - }; - }), - decrypt: (input) => - Effect.sync(() => { - const nonce = Buffer.from(input.token.nonce, "base64"); - const ciphertext = Buffer.from(input.token.ciphertext, "base64"); - const tag = Buffer.from(input.token.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { - authTagLength: 16, - }); - decipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); - }), -}; +vi.mock("./keyring.ts", () => ({ + getKeyringStore: () => null, +})); function makeEnvironmentLayer(homeDir: string, env: Record = {}) { return Layer.succeed(Environment)({ @@ -77,6 +41,7 @@ function makeConfigLayer( } = {}, ) { const environmentLayer = makeEnvironmentLayer(homeDir, input.env ?? {}); + const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); const selectionLayer = input.useSelectionLive === true ? T3ConfigSelectionLive.pipe(Layer.provide(environmentLayer)) @@ -84,104 +49,122 @@ function makeConfigLayer( getSelectedEnvironment: () => Effect.succeed(input.selection), }); return T3ConfigLive.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(T3CredentialCrypto, testCredentialCrypto), - environmentLayer, - selectionLayer, - ), - ), + Layer.provide(T3CredentialCryptoLive), + Layer.provide(Layer.mergeAll(platformLayer, selectionLayer)), ); } describe("config persistence", () => { it.effect("migrates v1 flat config and roundtrips encrypted tokens", () => Effect.gen(function* () { - const migrated = yield* migrateV1FileToEncrypted(testCredentialCrypto, { - url: "https://app.example.com", - token: "secret-token", - local: false, - }); - assert.equal(migrated.default, "app.example.com"); - const token = yield* decryptEnvironment(testCredentialCrypto, { - environmentName: "app.example.com", - url: "https://app.example.com", - local: false, - token: migrated.environments["app.example.com"]!.token, - }); - assert.equal(token, "secret-token"); - }), - ); - - it("persists v1 config as encrypted v2 on first read", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - const secretToken = "legacy-plaintext-token"; - const configPath = join(homeDir, ".config", "t3cli", "config.json"); - try { - await mkdir(dirname(configPath), { recursive: true }); - await writeFile( - configPath, - `${JSON.stringify({ - url: "https://home.example", - token: secretToken, - local: false, - })}\n`, - { mode: 0o600 }, - ); - await Effect.runPromise( + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { - const config = yield* T3Config; - yield* config.listEnvironments(); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - const raw = await readFile(configPath, "utf8"); - assert.equal(raw.includes(secretToken), false); - assert.equal(raw.includes('"version": 2'), true); - assert.equal(raw.includes('"kind": "encrypted"'), true); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); - - it("keeps default unchanged when upserting an existing environment without makeDefault", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", + const credentialCrypto = yield* makeT3CredentialCrypto().pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, makeEnvironmentLayer(homeDir))), + ); + const migrated = yield* migrateV1FileToEncrypted(credentialCrypto, { + url: "https://app.example.com", + token: "secret-token", local: false, }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", + assert.equal(migrated.default, "app.example.com"); + const token = yield* decryptEnvironment(credentialCrypto, { + environmentName: "app.example.com", + url: "https://app.example.com", local: false, + token: migrated.environments["app.example.com"]!.token, }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token-2", - local: false, - }); - const listed = yield* config.listEnvironments(); - assert.equal(listed.find((environment) => environment.name === "home")?.default, true); - assert.equal(listed.find((environment) => environment.name === "work")?.default, false); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + assert.equal(token, "secret-token"); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("promotes default when upserting with makeDefault", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( + it.effect("persists v1 config as encrypted v2 on first read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + return { fs, path, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, homeDir }) => + Effect.gen(function* () { + const secretToken = "legacy-plaintext-token"; + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString( + configPath, + `${JSON.stringify({ + url: "https://home.example", + token: secretToken, + local: false, + })}\n`, + { mode: 0o600 }, + ); + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.listEnvironments(); + }).pipe(Effect.provide(makeConfigLayer(homeDir))); + const raw = yield* fs.readFileString(configPath); + assert.equal(raw.includes(secretToken), false); + assert.equal(raw.includes('"version": 2'), true); + assert.equal(raw.includes('"kind": "encrypted"'), true); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); + + it.effect( + "keeps default unchanged when upserting an existing environment without makeDefault", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + }); + const listed = yield* config.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, true); + assert.equal(listed.find((environment) => environment.name === "work")?.default, false); + }).pipe(Effect.provide(makeConfigLayer(homeDir))), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); + + it.effect("promotes default when upserting with makeDefault", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ @@ -207,40 +190,48 @@ describe("config persistence", () => { assert.equal(listed.find((environment) => environment.name === "home")?.default, false); assert.equal(listed.find((environment) => environment.name === "work")?.default, true); }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("never writes plaintext tokens to config.json", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - const secretToken = "super-secret-token-value"; - try { - await Effect.runPromise( + it.effect("never writes plaintext tokens to config.json", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + return { fs, path, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, homeDir }) => Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: secretToken, - local: false, - }); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - const configPath = join(homeDir, ".config", "t3cli", "config.json"); - const raw = await readFile(configPath, "utf8"); - assert.equal(raw.includes(secretToken), false); - assert.equal(raw.includes('"kind": "encrypted"'), true); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + const secretToken = "super-secret-token-value"; + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: secretToken, + local: false, + }); + }).pipe(Effect.provide(makeConfigLayer(homeDir))); + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + const raw = yield* fs.readFileString(configPath); + assert.equal(raw.includes(secretToken), false); + assert.equal(raw.includes('"kind": "encrypted"'), true); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("clears default when removing the default environment", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( + it.effect("clears default when removing the default environment", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ @@ -264,16 +255,18 @@ describe("config persistence", () => { true, ); }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("resolves selected environment from config selection service", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( + it.effect("resolves selected environment from config selection service", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ @@ -295,16 +288,18 @@ describe("config persistence", () => { assert.equal(resolved.token, "work-token"); } }).pipe(Effect.provide(makeConfigLayer(homeDir, { selection: "work" }))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("resolves T3CLI_ENV through the selection layer", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( + it.effect("resolves T3CLI_ENV through the selection layer", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ @@ -332,50 +327,56 @@ describe("config persistence", () => { }), ), ), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("resolves env override with local=false even when selected stored environment is local", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "work", - url: "http://localhost:8787", - token: "work-token", - local: true, - }); - const resolved = yield* config.resolve(); - assert.equal(resolved.source, "env"); - assert.equal(resolved.local, false); - assert.equal(resolved.url, "https://remote.example"); - assert.equal(resolved.token, "env-token"); - }).pipe( - Effect.provide( - makeConfigLayer(homeDir, { - selection: "work", - env: { - T3CODE_URL: "https://remote.example", - T3CODE_TOKEN: "env-token", - }, - }), + it.effect( + "resolves env override with local=false even when selected stored environment is local", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "work", + url: "http://localhost:8787", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "env"); + assert.equal(resolved.local, false); + assert.equal(resolved.url, "https://remote.example"); + assert.equal(resolved.token, "env-token"); + }).pipe( + Effect.provide( + makeConfigLayer(homeDir, { + selection: "work", + env: { + T3CODE_URL: "https://remote.example", + T3CODE_TOKEN: "env-token", + }, + }), + ), ), ), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("reads default environment name without decrypting tokens", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - try { - await Effect.runPromise( + it.effect("reads default environment name without decrypting tokens", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ @@ -387,53 +388,73 @@ describe("config persistence", () => { const defaultName = yield* config.getDefaultEnvironmentName(); assert.equal(defaultName, "home"); }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("hardens existing config file permissions to 0600 on write", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-config-test-")); - const configPath = join(homeDir, ".config", "t3cli", "config.json"); - try { - await mkdir(dirname(configPath), { recursive: true }); - await writeFile(configPath, `${JSON.stringify({ version: 2, environments: {} })}\n`, { - mode: 0o644, - }); - await Effect.runPromise( + it.effect("hardens existing config file permissions to 0600 on write", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + return { fs, path, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, homeDir }) => Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ); - const configStat = await stat(configPath); - assert.equal(configStat.mode & 0o777, 0o600); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString( + configPath, + `${JSON.stringify({ version: 2, environments: {} })}\n`, + { mode: 0o644 }, + ); + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + }).pipe(Effect.provide(makeConfigLayer(homeDir))); + const configStat = yield* fs.stat(configPath); + assert.equal(configStat.mode & 0o777, 0o600); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); it.effect("fails decrypt when ciphertext AAD does not match", () => Effect.gen(function* () { - const token = yield* testCredentialCrypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret", - }); - const result = yield* decryptEnvironment(testCredentialCrypto, { - environmentName: "home", - url: "https://tampered.example", - local: false, - token, - }).pipe(Effect.exit); - assert.equal(Exit.isFailure(result), true); - }), + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const credentialCrypto = yield* makeT3CredentialCrypto().pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, makeEnvironmentLayer(homeDir))), + ); + const token = yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret", + }); + const result = yield* decryptEnvironment(credentialCrypto, { + environmentName: "home", + url: "https://tampered.example", + local: false, + token, + }).pipe(Effect.exit); + assert.equal(Exit.isFailure(result), true); + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), ); }); diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index dbe38cc..508a673 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -1,13 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; -import { randomBytes } from "node:crypto"; -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { expect, vi } from "vite-plus/test"; @@ -23,6 +20,19 @@ vi.mock("./keyring.ts", () => ({ getKeyringStore: () => null, })); +function makeCredentialLayer(homeDir: string) { + return Layer.mergeAll( + NodeServices.layer, + Layer.succeed(Environment)({ + cwd: homeDir, + homeDir, + env: {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }), + ); +} + describe("keyring fallback", () => { it("treats invalid stored keyring values as corrupt", () => { const result = parseKeyringPassword("not-a-valid-key"); @@ -30,121 +40,104 @@ describe("keyring fallback", () => { expect(shouldFallbackToKeyFile(result)).toBe(false); }); - it("falls back to key file when keyring backend is unavailable", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); - const keyPath = join(homeDir, ".config", "t3cli", "key"); - const masterKey = randomBytes(32); - try { - await mkdir(dirname(keyPath), { recursive: true }); - await writeFile(keyPath, `${masterKey.toString("base64")}\n`, { mode: 0o600 }); - const crypto = await Effect.runPromise( - makeT3CredentialCrypto().pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }), - ), - ), - ), - ); - const encrypted = await Effect.runPromise( - crypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret-token", - }), - ); - const token = await Effect.runPromise( - crypto.decrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: encrypted, + it.effect("falls back to key file when keyring backend is unavailable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cryptoService = yield* Crypto.Crypto; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); + return { fs, path, cryptoService, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, cryptoService, homeDir }) => + Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const masterKey = yield* cryptoService.randomBytes(32); + yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { + mode: 0o600, + }); + const credentialCrypto = yield* makeT3CredentialCrypto().pipe( + Effect.provide(makeCredentialLayer(homeDir)), + ); + const encrypted = yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }); + const token = yield* credentialCrypto.decrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: encrypted, + }); + assert.equal(token, "secret-token"); }), - ); - assert.equal(token, "secret-token"); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("hardens existing key file permissions to 0600 on use", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); - const keyPath = join(homeDir, ".config", "t3cli", "key"); - const masterKey = randomBytes(32); - try { - await mkdir(dirname(keyPath), { recursive: true }); - await writeFile(keyPath, `${masterKey.toString("base64")}\n`, { mode: 0o644 }); - const crypto = await Effect.runPromise( - makeT3CredentialCrypto().pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }), - ), - ), - ), - ); - await Effect.runPromise( - crypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret-token", + it.effect("hardens existing key file permissions to 0600 on use", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cryptoService = yield* Crypto.Crypto; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); + return { fs, path, cryptoService, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, cryptoService, homeDir }) => + Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const masterKey = yield* cryptoService.randomBytes(32); + yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { + mode: 0o644, + }); + const credentialCrypto = yield* makeT3CredentialCrypto().pipe( + Effect.provide(makeCredentialLayer(homeDir)), + ); + yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }); + const keyStat = yield* fs.stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); }), - ); - const keyStat = await stat(keyPath); - assert.equal(keyStat.mode & 0o777, 0o600); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); - it("creates key file with 0600 permissions when keyring is unavailable", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-credential-test-")); - const keyPath = join(homeDir, ".config", "t3cli", "key"); - try { - const crypto = await Effect.runPromise( - makeT3CredentialCrypto().pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }), - ), - ), - ), - ); - await Effect.runPromise( - crypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "rotate-write", + it.effect("creates key file with 0600 permissions when keyring is unavailable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); + return { fs, path, homeDir }; + }).pipe( + Effect.flatMap(({ fs, path, homeDir }) => + Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const credentialCrypto = yield* makeT3CredentialCrypto().pipe( + Effect.provide(makeCredentialLayer(homeDir)), + ); + yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "rotate-write", + }); + const keyStat = yield* fs.stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); }), - ); - const keyStat = await stat(keyPath); - assert.equal(keyStat.mode & 0o777, 0o600); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); }); diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index c142666..d997bba 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -1,62 +1,24 @@ -// @effect-diagnostics nodeBuiltinImport:off - Integration tests use real temp directories. import "vite-plus/test/config"; -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { Command } from "effect/unstable/cli"; +import { vi } from "vite-plus/test"; import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; import { Environment } from "../environment/service.ts"; -import type { CredentialCrypto } from "../config/credential-service.ts"; -import { T3CredentialCrypto } from "../config/credential-service.ts"; import type { ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; import { BaseAppLayer } from "./layer.ts"; -const testMasterKey = Buffer.alloc(32, 7); - -const testCredentialCrypto: CredentialCrypto = { - encrypt: (input) => - Effect.sync(() => { - const nonce = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", testMasterKey, nonce, { authTagLength: 16 }); - cipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); - return { - kind: "encrypted" as const, - alg: "aes-256-gcm" as const, - key: "default" as const, - nonce: nonce.toString("base64"), - ciphertext: ciphertext.toString("base64"), - tag: cipher.getAuthTag().toString("base64"), - }; - }), - decrypt: (input) => - Effect.sync(() => { - const nonce = Buffer.from(input.token.nonce, "base64"); - const ciphertext = Buffer.from(input.token.ciphertext, "base64"); - const tag = Buffer.from(input.token.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", testMasterKey, nonce, { - authTagLength: 16, - }); - decipher.setAAD( - Buffer.from(`2\0${input.environmentName}\0${input.url}\0${input.local}`, "utf8"), - ); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); - }), -}; +vi.mock("../config/keyring.ts", () => ({ + getKeyringStore: () => null, +})); function makeCliAppLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ @@ -68,64 +30,60 @@ function makeCliAppLayer(homeDir: string) { }); return BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - environmentLayer, - Layer.succeed(T3CredentialCrypto, testCredentialCrypto), - ), - ), + Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), ); } describe("CLI app layer composition", () => { - it("routes --environment through Command.run to T3Config.resolve ahead of default and T3CLI_ENV", async () => { - const homeDir = await mkdtemp(join(tmpdir(), "t3cli-runtime-test-")); - const cliAppLayer = makeCliAppLayer(homeDir); - const resolvedRef = await Effect.runPromise(Ref.make(undefined)); - const resolveProbeCommand = Command.make("resolve-probe", {}, () => + it.effect( + "routes --environment through Command.run to T3Config.resolve ahead of default and T3CLI_ENV", + () => Effect.gen(function* () { - const config = yield* T3Config; - const resolved = yield* config.resolve(); - yield* Ref.set(resolvedRef, resolved); - }), - ).pipe(Command.withGlobalFlags([cliEnvironmentSetting])); - const runResolveProbe = Command.runWith(resolveProbeCommand, { version: "0.0.0-test" }); - try { - await Effect.runPromise( - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: false, - }); - }).pipe(Effect.provide(cliAppLayer)), - ); + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-runtime-test-" }); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const cliAppLayer = makeCliAppLayer(homeDir); + const resolvedRef = yield* Ref.make(undefined); + const resolveProbeCommand = Command.make("resolve-probe", {}, () => + Effect.gen(function* () { + const config = yield* T3Config; + const resolved = yield* config.resolve(); + yield* Ref.set(resolvedRef, resolved); + }), + ).pipe(Command.withGlobalFlags([cliEnvironmentSetting])); + const runResolveProbe = Command.runWith(resolveProbeCommand, { version: "0.0.0-test" }); - await Effect.runPromise( - runResolveProbe(["--environment", "work"]).pipe( - Effect.provide(cliAppLayer), - Effect.provide(NodeServices.layer), - ), - ); + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + }).pipe(Effect.provide(cliAppLayer)); - const resolved = await Effect.runPromise(Ref.get(resolvedRef)); - assert.isDefined(resolved); - assert.equal(resolved.source, "config"); - if (resolved.source === "config") { - assert.equal(resolved.environment, "work"); - assert.equal(resolved.token, "work-token"); - } - } finally { - await rm(homeDir, { recursive: true, force: true }); - } - }); + yield* runResolveProbe(["--environment", "work"]).pipe(Effect.provide(cliAppLayer)); + + const resolved = yield* Ref.get(resolvedRef); + assert.isDefined(resolved); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + assert.equal(resolved.token, "work-token"); + } + }), + ), + Effect.provide(NodeServices.layer), + Effect.scoped, + ), + ); }); From 26850f3a361c13f760d181c47f98c7e9bbd5a010 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:14:52 +0300 Subject: [PATCH 04/21] Harden config I/O with Schema JSON and typed error mapping. Use schema JSON codecs for config persistence, Effect catchTags for keyring and credential failures, and dynamic import for the optional keyring backend. Co-authored-by: Cursor --- src/auth/auth.test.ts | 33 +++++-- src/config/config.test.ts | 46 ++++++---- src/config/credential.test.ts | 9 +- src/config/credential.ts | 166 +++++++++++++++++++++++----------- src/config/error.ts | 12 +++ src/config/keyring.ts | 111 ++++++++++++++++++----- src/config/migration.ts | 21 ++--- src/config/persist.ts | 59 ++++++------ src/config/schema.ts | 4 + src/runtime/layer.test.ts | 9 +- 10 files changed, 320 insertions(+), 150 deletions(-) diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 68550db..5712c18 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; @@ -16,12 +17,16 @@ import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; import { T3CredentialCryptoLive } from "../config/credential.ts"; import { T3ConfigLive } from "../config/layer.ts"; +import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; -vi.mock("../config/keyring.ts", () => ({ - getKeyringStore: () => null, -})); +vi.mock("../config/keyring.ts", async () => { + const EffectModule = await import("effect/Effect"); + return { + getKeyringStore: () => EffectModule.succeed(null), + }; +}); function makeAuthLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ @@ -198,11 +203,23 @@ describe("T3Auth persistence", () => { }).pipe(Effect.provide(makeAuthLayer(homeDir))); const raw = yield* fs.readFileString(configPath); - const parsed: { - environments: Record; - } = JSON.parse(raw); - parsed.environments.home!.token.tag = "AAAAAAAAAAAAAAAAAAAAAA=="; - yield* fs.writeFileString(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { + const parsed = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); + const homeEnvironment = parsed.environments.home; + assert.isDefined(homeEnvironment); + const corrupted = yield* Schema.encodeEffect(StoredConfigV2FileJson)({ + ...parsed, + environments: { + ...parsed.environments, + home: { + ...homeEnvironment, + token: { + ...homeEnvironment.token, + tag: "AAAAAAAAAAAAAAAAAAAAAA==", + }, + }, + }, + }); + yield* fs.writeFileString(configPath, `${corrupted}\n`, { mode: 0o600, }); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0aaa611..fabbb76 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; @@ -14,13 +15,17 @@ import { decryptEnvironment } from "./codec.ts"; import { makeT3CredentialCrypto, T3CredentialCryptoLive } from "./credential.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { T3ConfigLive } from "./layer.ts"; +import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; import { T3ConfigSelection } from "./selection.ts"; import { T3ConfigSelectionLive } from "./selection-layer.ts"; import { T3Config } from "./service.ts"; -vi.mock("./keyring.ts", () => ({ - getKeyringStore: () => null, -})); +vi.mock("./keyring.ts", async () => { + const EffectModule = await import("effect/Effect"); + return { + getKeyringStore: () => EffectModule.succeed(null), + }; +}); function makeEnvironmentLayer(homeDir: string, env: Record = {}) { return Layer.succeed(Environment)({ @@ -97,23 +102,23 @@ describe("config persistence", () => { const secretToken = "legacy-plaintext-token"; const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); - yield* fs.writeFileString( - configPath, - `${JSON.stringify({ - url: "https://home.example", - token: secretToken, - local: false, - })}\n`, - { mode: 0o600 }, - ); + const legacyConfig = yield* Schema.encodeEffect(StoredConfigV1FileJson)({ + url: "https://home.example", + token: secretToken, + local: false, + }); + yield* fs.writeFileString(configPath, `${legacyConfig}\n`, { mode: 0o600 }); yield* Effect.gen(function* () { const config = yield* T3Config; yield* config.listEnvironments(); }).pipe(Effect.provide(makeConfigLayer(homeDir))); const raw = yield* fs.readFileString(configPath); assert.equal(raw.includes(secretToken), false); - assert.equal(raw.includes('"version": 2'), true); - assert.equal(raw.includes('"kind": "encrypted"'), true); + const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); + assert.equal(persisted.version, 2); + const environment = Object.values(persisted.environments)[0]; + assert.isDefined(environment); + assert.equal(environment.token.kind, "encrypted"); }), ), Effect.provide(NodeServices.layer), @@ -218,7 +223,8 @@ describe("config persistence", () => { const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); const raw = yield* fs.readFileString(configPath); assert.equal(raw.includes(secretToken), false); - assert.equal(raw.includes('"kind": "encrypted"'), true); + const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); + assert.equal(persisted.environments.home?.token.kind, "encrypted"); }), ), Effect.provide(NodeServices.layer), @@ -405,11 +411,11 @@ describe("config persistence", () => { Effect.gen(function* () { const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); - yield* fs.writeFileString( - configPath, - `${JSON.stringify({ version: 2, environments: {} })}\n`, - { mode: 0o644 }, - ); + const emptyConfig = yield* Schema.encodeEffect(StoredConfigV2FileJson)({ + version: 2, + environments: {}, + }); + yield* fs.writeFileString(configPath, `${emptyConfig}\n`, { mode: 0o644 }); yield* Effect.gen(function* () { const config = yield* T3Config; yield* config.upsertEnvironment({ diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 508a673..1aae745 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -16,9 +16,12 @@ import { shouldFallbackToKeyFile, } from "./credential.ts"; -vi.mock("./keyring.ts", () => ({ - getKeyringStore: () => null, -})); +vi.mock("./keyring.ts", async () => { + const EffectModule = await import("effect/Effect"); + return { + getKeyringStore: () => EffectModule.succeed(null), + }; +}); function makeCredentialLayer(homeDir: string) { return Layer.mergeAll( diff --git a/src/config/credential.ts b/src/config/credential.ts index a8f9bc8..638e106 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -2,7 +2,6 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Filter from "effect/Filter"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -12,9 +11,9 @@ import { type CredentialDecryptInput, type CredentialEncryptInput, } from "./credential-service.ts"; -import { ConfigError } from "./error.ts"; +import { ConfigError, CredentialDecryptError, isPlatformNotFoundError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; -import { getKeyringStore } from "./keyring.ts"; +import { getKeyringStore, KeyringOperationError, keyringErrorMessage } from "./keyring.ts"; import { resolveKeyFilePath } from "./paths.ts"; const configSchemaVersion = 2; @@ -29,6 +28,10 @@ export type KeyringReadResult = | { readonly kind: "corrupt"; readonly message: string } | { readonly kind: "unavailable"; readonly message: string }; +type KeyringWriteResult = + | { readonly kind: "stored" } + | { readonly kind: "unavailable"; readonly message: string }; + export function shouldFallbackToKeyFile(result: KeyringReadResult) { return result.kind === "missing" || result.kind === "unavailable"; } @@ -41,12 +44,17 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { const raw = yield* fs.readFileString(filePath).pipe( - Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => - Effect.succeed(undefined), - ), - Effect.mapError( - (error) => new ConfigError({ message: "failed to read credential key file", cause: error }), - ), + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail( + new ConfigError({ + message: "failed to read credential key file", + cause: error, + }), + ), + }), ); if (raw === undefined) { return undefined; @@ -64,22 +72,22 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi filePath: string, key: Buffer, ) { - yield* fs - .makeDirectory(path.dirname(filePath), { recursive: true, mode: 0o700 }) - .pipe( - Effect.mapError( - (error) => + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - ); - yield* fs - .writeFileString(filePath, `${key.toString("base64")}\n`, { mode: 0o600 }) - .pipe( - Effect.mapError( - (error) => + ), + }), + ); + yield* fs.writeFileString(filePath, `${key.toString("base64")}\n`, { mode: 0o600 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - ); + ), + }), + ); yield* hardenPrivateFileMode(fs, filePath, "credential key"); }); @@ -103,8 +111,8 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi } } const generated = randomBytes(masterKeyByteLength); - const storedInKeyring = yield* writeKeyringMasterKey(generated); - if (storedInKeyring) { + const writeResult = yield* writeKeyringMasterKey(generated); + if (writeResult.kind === "stored") { return generated; } yield* writeKeyFileMasterKey(keyFilePath, generated); @@ -138,8 +146,18 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi const masterKey = yield* getMasterKey(); return yield* Effect.try({ try: () => decryptToken(masterKey, input), - catch: () => new ConfigError({ message: "failed to decrypt credential token" }), - }); + catch: (cause) => new CredentialDecryptError({ cause }), + }).pipe( + Effect.catchTags({ + CredentialDecryptError: (error) => + Effect.fail( + new ConfigError({ + message: "failed to decrypt credential token", + cause: error.cause, + }), + ), + }), + ); }); return { encrypt, decrypt }; @@ -170,24 +188,41 @@ function decryptToken(masterKey: Buffer, input: CredentialDecryptInput) { return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); } -function readKeyringMasterKey() { - return Effect.sync((): KeyringReadResult => { - const keyring = getKeyringStore(); - if (keyring === null) { +function readKeyringMasterKey(): Effect.Effect { + return Effect.gen(function* () { + const store = yield* getKeyringStore(); + if (store === null) { return { kind: "unavailable", message: "OS keyring backend is not available", - }; - } - try { - return parseKeyringPassword(keyring.readPassword(keyringService, keyringAccount)); - } catch (error) { - return { - kind: "unavailable", - message: error instanceof Error ? error.message : "failed to read keyring entry", - }; + } satisfies KeyringReadResult; } - }); + return yield* Effect.try({ + try: () => parseKeyringPassword(store.readPassword(keyringService, keyringAccount)), + catch: (cause) => new KeyringOperationError({ cause }), + }).pipe( + Effect.catchTags({ + KeyringOperationError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringReadResult), + }), + ); + }).pipe( + Effect.catchTags({ + KeyringModuleLoadError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringReadResult), + KeyringModuleNotFoundError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringReadResult), + }), + ); } export function parseKeyringPassword(password: string | null): KeyringReadResult { @@ -204,17 +239,42 @@ export function parseKeyringPassword(password: string | null): KeyringReadResult return { kind: "present", key }; } -function writeKeyringMasterKey(key: Buffer) { - return Effect.sync(() => { - const keyring = getKeyringStore(); - if (keyring === null) { - return false; - } - try { - keyring.writePassword(keyringService, keyringAccount, key.toString("base64")); - return true; - } catch { - return false; +function writeKeyringMasterKey(key: Buffer): Effect.Effect { + return Effect.gen(function* () { + const store = yield* getKeyringStore(); + if (store === null) { + return { + kind: "unavailable", + message: "OS keyring backend is not available", + } satisfies KeyringWriteResult; } - }); + return yield* Effect.try({ + try: () => { + store.writePassword(keyringService, keyringAccount, key.toString("base64")); + return { kind: "stored" } as const; + }, + catch: (cause) => new KeyringOperationError({ cause }), + }).pipe( + Effect.catchTags({ + KeyringOperationError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringWriteResult), + }), + ); + }).pipe( + Effect.catchTags({ + KeyringModuleLoadError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringWriteResult), + KeyringModuleNotFoundError: (error) => + Effect.succeed({ + kind: "unavailable", + message: keyringErrorMessage(error.cause), + } satisfies KeyringWriteResult), + }), + ); } diff --git a/src/config/error.ts b/src/config/error.ts index 5a121aa..35eec6d 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -5,6 +5,7 @@ import { PlatformError } from "effect/PlatformError"; const ConfigErrorCauseSchema = Schema.Union([ Schema.instanceOf(PlatformError), Schema.instanceOf(Schema.SchemaError), + Schema.Defect(), ]); export class UrlError extends Schema.TaggedErrorClass()("UrlError", { @@ -18,4 +19,15 @@ export class ConfigError extends Schema.TaggedErrorClass()("ConfigE cause: Schema.optionalKey(ConfigErrorCauseSchema), }) {} +export class CredentialDecryptError extends Schema.TaggedErrorClass()( + "CredentialDecryptError", + { + cause: Schema.Defect(), + }, +) {} + export type ConfigServiceError = ConfigError | UrlError; + +export function isPlatformNotFoundError(error: PlatformError) { + return error.reason["_tag"] === "NotFound"; +} diff --git a/src/config/keyring.ts b/src/config/keyring.ts index 5b91055..4cff722 100644 --- a/src/config/keyring.ts +++ b/src/config/keyring.ts @@ -1,4 +1,6 @@ -import { createRequire } from "node:module"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Predicate from "effect/Predicate"; type KeyringEntry = { getPassword(): string | null; @@ -9,40 +11,63 @@ type KeyringModule = { Entry: new (service: string, account: string) => KeyringEntry; }; -const require = createRequire(import.meta.url); +export class KeyringModuleLoadError extends Schema.TaggedErrorClass()( + "KeyringModuleLoadError", + { + cause: Schema.Defect(), + }, +) {} + +export class KeyringModuleNotFoundError extends Schema.TaggedErrorClass()( + "KeyringModuleNotFoundError", + { + cause: Schema.Defect(), + }, +) {} + +export class KeyringOperationError extends Schema.TaggedErrorClass()( + "KeyringOperationError", + { + cause: Schema.Defect(), + }, +) {} + +export type KeyringStore = { + readonly readPassword: (service: string, account: string) => string | null; + readonly writePassword: (service: string, account: string, password: string) => void; +}; let cachedKeyringModule: KeyringModule | null | undefined; function isKeyringModule(value: unknown): value is KeyringModule { - if (typeof value !== "object" || value === null || !("Entry" in value)) { - return false; - } - return typeof value.Entry === "function"; + return Predicate.hasProperty(value, "Entry") && Predicate.isFunction(value.Entry); } -function loadKeyringModule(): KeyringModule | null { - if (cachedKeyringModule !== undefined) { - return cachedKeyringModule; - } - try { - const loaded: unknown = require("@napi-rs/keyring"); - cachedKeyringModule = isKeyringModule(loaded) ? loaded : null; - } catch { - cachedKeyringModule = null; - } - return cachedKeyringModule; +function isMissingKeyringModule(cause: unknown): boolean { + return ( + Predicate.hasProperty(cause, "code") && + Predicate.isString(cause.code) && + (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") + ); } -export type KeyringStore = { - readonly readPassword: (service: string, account: string) => string | null; - readonly writePassword: (service: string, account: string, password: string) => void; -}; +function classifyKeyringModuleLoadFailure(cause: unknown) { + return isMissingKeyringModule(cause) + ? new KeyringModuleNotFoundError({ cause }) + : new KeyringModuleLoadError({ cause }); +} -export function getKeyringStore(): KeyringStore | null { - const keyring = loadKeyringModule(); - if (keyring === null) { - return null; +export function keyringErrorMessage(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; } + if (Predicate.isString(cause)) { + return cause; + } + return "keyring operation failed"; +} + +function createKeyringStore(keyring: KeyringModule): KeyringStore { return { readPassword(service, account) { return new keyring.Entry(service, account).getPassword(); @@ -52,3 +77,39 @@ export function getKeyringStore(): KeyringStore | null { }, }; } + +export function loadKeyringModule(): Effect.Effect< + KeyringModule | null, + KeyringModuleLoadError | KeyringModuleNotFoundError +> { + if (cachedKeyringModule !== undefined) { + return Effect.succeed(cachedKeyringModule); + } + return Effect.tryPromise({ + try: () => import("@napi-rs/keyring"), + catch: classifyKeyringModuleLoadFailure, + }).pipe( + Effect.map((module) => (isKeyringModule(module) ? module : null)), + Effect.tap((module) => + Effect.sync(() => { + cachedKeyringModule = module; + }), + ), + Effect.catchTags({ + KeyringModuleNotFoundError: () => + Effect.sync(() => { + cachedKeyringModule = null; + return null; + }), + }), + ); +} + +export function getKeyringStore(): Effect.Effect< + KeyringStore | null, + KeyringModuleLoadError | KeyringModuleNotFoundError +> { + return loadKeyringModule().pipe( + Effect.map((module) => (module === null ? null : createKeyringStore(module))), + ); +} diff --git a/src/config/migration.ts b/src/config/migration.ts index 928a918..2534442 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; import { encryptEnvironment } from "./codec.ts"; import type { CredentialCrypto } from "./credential-service.ts"; import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError, UrlError } from "./error.ts"; +import { ConfigError } from "./error.ts"; import { StoredConfigV1FileSchema, StoredConfigV2FileSchema, @@ -30,7 +30,14 @@ export function readEncryptedConfigFromValue(crypto: CredentialCrypto, value: un const v1 = yield* Schema.decodeUnknownEffect(StoredConfigV1FileSchema)(value); const migrated = yield* migrateV1FileToEncrypted(crypto, v1); return { config: migrated, migratedFromV1: true as const }; - }).pipe(Effect.mapError((error) => mapMigrationError(error))); + }).pipe( + Effect.catchTags({ + UrlError: (error) => + Effect.fail(new ConfigError({ message: `failed to read config: ${error.message}` })), + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); } export function migrateV1FileToEncrypted(crypto: CredentialCrypto, config: StoredConfigV1File) { @@ -71,13 +78,3 @@ export function migrateV1FileToEncrypted(crypto: CredentialCrypto, config: Store } satisfies EncryptedConfig; }); } - -function mapMigrationError(error: ConfigError | Schema.SchemaError | UrlError) { - if (error instanceof ConfigError) { - return error; - } - if (error instanceof UrlError) { - return new ConfigError({ message: `failed to read config: ${error.message}` }); - } - return new ConfigError({ message: "failed to read config", cause: error }); -} diff --git a/src/config/persist.ts b/src/config/persist.ts index 3bfa79d..018a0bb 100644 --- a/src/config/persist.ts +++ b/src/config/persist.ts @@ -1,12 +1,13 @@ import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; -import * as Filter from "effect/Filter"; import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import type { CredentialCrypto } from "./credential-service.ts"; -import { ConfigError } from "./error.ts"; +import { ConfigError, isPlatformNotFoundError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; +import { UnknownConfigFileJson, StoredConfigV2FileJson } from "./schema.ts"; import type { EncryptedConfig } from "./types.ts"; export function readEncryptedConfigFile( @@ -17,20 +18,22 @@ export function readEncryptedConfigFile( ) { return Effect.gen(function* () { const raw = yield* fs.readFileString(configFilePath).pipe( - Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () => - Effect.succeed(undefined), - ), - Effect.mapError( - (error) => new ConfigError({ message: "failed to read config", cause: error }), - ), + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), ); if (raw === undefined) { return emptyEncryptedConfig(); } - const value = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: () => new ConfigError({ message: "failed to read config" }), - }); + const value = yield* Schema.decodeUnknownEffect(UnknownConfigFileJson)(raw).pipe( + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); const read = yield* readEncryptedConfigFromValue(crypto, value); if (read.migratedFromV1) { yield* writeEncryptedConfigFile(fs, path, configFilePath, read.config); @@ -46,20 +49,24 @@ export function writeEncryptedConfigFile( config: EncryptedConfig, ) { return Effect.gen(function* () { - yield* fs - .makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }) - .pipe( - Effect.mapError( - (error) => new ConfigError({ message: "failed to write config", cause: error }), - ), - ); - yield* fs - .writeFileString(configFilePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) - .pipe( - Effect.mapError( - (error) => new ConfigError({ message: "failed to write config", cause: error }), - ), - ); + yield* fs.makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + const encoded = yield* Schema.encodeEffect(StoredConfigV2FileJson)(config).pipe( + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + yield* fs.writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); yield* hardenPrivateFileMode(fs, configFilePath, "config"); }); } diff --git a/src/config/schema.ts b/src/config/schema.ts index e736a5e..1de64c9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -31,3 +31,7 @@ export type EncryptedToken = Schema.Schema.Type; export type StoredEnvironmentFile = Schema.Schema.Type; export type StoredConfigV2File = Schema.Schema.Type; export type StoredConfigV1File = Schema.Schema.Type; + +export const StoredConfigV1FileJson = Schema.fromJsonString(StoredConfigV1FileSchema); +export const StoredConfigV2FileJson = Schema.fromJsonString(StoredConfigV2FileSchema); +export const UnknownConfigFileJson = Schema.UnknownFromJsonString; diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index d997bba..262f36e 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -16,9 +16,12 @@ import type { ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; import { BaseAppLayer } from "./layer.ts"; -vi.mock("../config/keyring.ts", () => ({ - getKeyringStore: () => null, -})); +vi.mock("../config/keyring.ts", async () => { + const EffectModule = await import("effect/Effect"); + return { + getKeyringStore: () => EffectModule.succeed(null), + }; +}); function makeCliAppLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ From 39e2a7d5be294932690ad044ddd8827d24438635 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:23:43 +0300 Subject: [PATCH 05/21] Extract credential cipher into swappable layers wired at entrypoint. Move AES-GCM out of credential crypto into Node and Web Crypto implementations so the CLI selects the platform cipher in bin.ts without hardcoding crypto in config code. Co-authored-by: Cursor --- src/auth/auth.test.ts | 2 + src/bin.ts | 6 +- src/config/config.test.ts | 19 ++- src/config/credential-cipher-node.ts | 49 +++++++ src/config/credential-cipher-service.ts | 38 +++++ src/config/credential-cipher-web.ts | 90 ++++++++++++ src/config/credential.test.ts | 2 + src/config/credential.ts | 183 ++++++++++++++++-------- src/config/error.ts | 4 +- src/config/index.ts | 4 + src/runtime/layer.test.ts | 2 + src/runtime/layer.ts | 3 +- 12 files changed, 338 insertions(+), 64 deletions(-) create mode 100644 src/config/credential-cipher-node.ts create mode 100644 src/config/credential-cipher-service.ts create mode 100644 src/config/credential-cipher-web.ts diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 5712c18..92f2462 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -16,6 +16,7 @@ import { T3AuthLive } from "./layer.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; import { T3CredentialCryptoLive } from "../config/credential.ts"; +import { T3CredentialCipherWebLive } from "../config/credential-cipher-web.ts"; import { T3ConfigLive } from "../config/layer.ts"; import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; @@ -42,6 +43,7 @@ function makeAuthLayer(homeDir: string) { Layer.provide( Layer.mergeAll( platformLayer, + T3CredentialCipherWebLive, Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(undefined), }), diff --git a/src/bin.ts b/src/bin.ts index 08109a9..97e9d5d 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -7,6 +7,7 @@ import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; +import { T3CredentialCipherNodeLive } from "./config/credential-cipher-node.ts"; import { NodeEnvironmentLive } from "./environment/layer.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; @@ -23,7 +24,10 @@ const VersionLive = const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); -const CliAppLayer = BaseAppLayer.pipe(Layer.provideMerge(T3CliConfigSelectionLive)); +const CliAppLayer = BaseAppLayer.pipe( + Layer.provideMerge(T3CliConfigSelectionLive), + Layer.provide(T3CredentialCipherNodeLive), +); const CliLayer = Layer.mergeAll( CliAppLayer.pipe(Layer.provide(PlatformLayer)), diff --git a/src/config/config.test.ts b/src/config/config.test.ts index fabbb76..ce124ca 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -13,6 +13,7 @@ import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment } from "./codec.ts"; import { makeT3CredentialCrypto, T3CredentialCryptoLive } from "./credential.ts"; +import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { T3ConfigLive } from "./layer.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; @@ -55,7 +56,7 @@ function makeConfigLayer( }); return T3ConfigLive.pipe( Layer.provide(T3CredentialCryptoLive), - Layer.provide(Layer.mergeAll(platformLayer, selectionLayer)), + Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, T3CredentialCipherWebLive)), ); } @@ -68,7 +69,13 @@ describe("config persistence", () => { Effect.flatMap((homeDir) => Effect.gen(function* () { const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide(Layer.mergeAll(NodeServices.layer, makeEnvironmentLayer(homeDir))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + T3CredentialCipherWebLive, + makeEnvironmentLayer(homeDir), + ), + ), ); const migrated = yield* migrateV1FileToEncrypted(credentialCrypto, { url: "https://app.example.com", @@ -442,7 +449,13 @@ describe("config persistence", () => { Effect.flatMap((homeDir) => Effect.gen(function* () { const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide(Layer.mergeAll(NodeServices.layer, makeEnvironmentLayer(homeDir))), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + T3CredentialCipherWebLive, + makeEnvironmentLayer(homeDir), + ), + ), ); const token = yield* credentialCrypto.encrypt({ environmentName: "home", diff --git a/src/config/credential-cipher-node.ts b/src/config/credential-cipher-node.ts new file mode 100644 index 0000000..f64dd94 --- /dev/null +++ b/src/config/credential-cipher-node.ts @@ -0,0 +1,49 @@ +import { createCipheriv, createDecipheriv } from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + credentialCipherTagByteLength, + T3CredentialCipher, + type CredentialCipher, +} from "./credential-cipher-service.ts"; +import { CredentialCipherError } from "./error.ts"; + +function makeNodeCredentialCipher(): CredentialCipher { + return { + encrypt: (input) => + Effect.try({ + try: () => { + const cipher = createCipheriv("aes-256-gcm", input.key, input.nonce, { + authTagLength: credentialCipherTagByteLength, + }); + cipher.setAAD(input.additionalData); + const ciphertext = Buffer.concat([cipher.update(input.plaintext), cipher.final()]); + return { + ciphertext: new Uint8Array(ciphertext), + tag: new Uint8Array(cipher.getAuthTag()), + }; + }, + catch: (cause) => new CredentialCipherError({ cause }), + }), + decrypt: (input) => + Effect.try({ + try: () => { + const decipher = createDecipheriv("aes-256-gcm", input.key, input.nonce, { + authTagLength: credentialCipherTagByteLength, + }); + decipher.setAAD(input.additionalData); + decipher.setAuthTag(input.tag); + const plaintext = Buffer.concat([decipher.update(input.ciphertext), decipher.final()]); + return new Uint8Array(plaintext); + }, + catch: (cause) => new CredentialCipherError({ cause }), + }), + }; +} + +export const T3CredentialCipherNodeLive = Layer.succeed( + T3CredentialCipher, + makeNodeCredentialCipher(), +); diff --git a/src/config/credential-cipher-service.ts b/src/config/credential-cipher-service.ts new file mode 100644 index 0000000..8d080f1 --- /dev/null +++ b/src/config/credential-cipher-service.ts @@ -0,0 +1,38 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { CredentialCipherError } from "./error.ts"; + +export const credentialCipherNonceByteLength = 12; +export const credentialCipherTagByteLength = 16; + +export type AesGcmEncryptInput = { + readonly key: Uint8Array; + readonly nonce: Uint8Array; + readonly plaintext: Uint8Array; + readonly additionalData: Uint8Array; +}; + +export type AesGcmDecryptInput = { + readonly key: Uint8Array; + readonly nonce: Uint8Array; + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; + readonly additionalData: Uint8Array; +}; + +export type AesGcmEncryptResult = { + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; +}; + +export class T3CredentialCipher extends Context.Service()( + "t3cli/T3CredentialCipher", +) {} + +export type CredentialCipher = { + readonly encrypt: ( + input: AesGcmEncryptInput, + ) => Effect.Effect; + readonly decrypt: (input: AesGcmDecryptInput) => Effect.Effect; +}; diff --git a/src/config/credential-cipher-web.ts b/src/config/credential-cipher-web.ts new file mode 100644 index 0000000..a2ee471 --- /dev/null +++ b/src/config/credential-cipher-web.ts @@ -0,0 +1,90 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + credentialCipherTagByteLength, + T3CredentialCipher, + type CredentialCipher, +} from "./credential-cipher-service.ts"; +import { CredentialCipherError } from "./error.ts"; + +const aesGcmAlgorithm = "AES-GCM"; + +function copyBytes(bytes: Uint8Array) { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy; +} + +function importAesGcmKey(key: Uint8Array) { + return Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.importKey("raw", copyBytes(key), { name: aesGcmAlgorithm }, false, [ + "encrypt", + "decrypt", + ]), + catch: (cause) => new CredentialCipherError({ cause }), + }); +} + +function makeWebCredentialCipher(): CredentialCipher { + return { + encrypt: (input) => + Effect.gen(function* () { + const cryptoKey = yield* importAesGcmKey(input.key); + const encrypted = yield* Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.encrypt( + { + name: aesGcmAlgorithm, + iv: copyBytes(input.nonce), + additionalData: copyBytes(input.additionalData), + tagLength: credentialCipherTagByteLength * 8, + }, + cryptoKey, + copyBytes(input.plaintext), + ), + catch: (cause) => new CredentialCipherError({ cause }), + }); + const bytes = new Uint8Array(encrypted); + if (bytes.byteLength < credentialCipherTagByteLength) { + return yield* Effect.fail( + new CredentialCipherError({ + cause: new Error("encrypted output shorter than auth tag"), + }), + ); + } + return { + ciphertext: bytes.slice(0, -credentialCipherTagByteLength), + tag: bytes.slice(-credentialCipherTagByteLength), + }; + }), + decrypt: (input) => + Effect.gen(function* () { + const cryptoKey = yield* importAesGcmKey(input.key); + const combined = new Uint8Array(input.ciphertext.byteLength + input.tag.byteLength); + combined.set(input.ciphertext); + combined.set(input.tag, input.ciphertext.byteLength); + const plaintext = yield* Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.decrypt( + { + name: aesGcmAlgorithm, + iv: copyBytes(input.nonce), + additionalData: copyBytes(input.additionalData), + tagLength: credentialCipherTagByteLength * 8, + }, + cryptoKey, + combined, + ), + catch: (cause) => new CredentialCipherError({ cause }), + }); + return new Uint8Array(plaintext); + }), + }; +} + +export const T3CredentialCipherWebLive = Layer.succeed( + T3CredentialCipher, + makeWebCredentialCipher(), +); diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 1aae745..8107a62 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -15,6 +15,7 @@ import { parseKeyringPassword, shouldFallbackToKeyFile, } from "./credential.ts"; +import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; vi.mock("./keyring.ts", async () => { const EffectModule = await import("effect/Effect"); @@ -26,6 +27,7 @@ vi.mock("./keyring.ts", async () => { function makeCredentialLayer(homeDir: string) { return Layer.mergeAll( NodeServices.layer, + T3CredentialCipherWebLive, Layer.succeed(Environment)({ cwd: homeDir, homeDir, diff --git a/src/config/credential.ts b/src/config/credential.ts index 638e106..c868c38 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -1,30 +1,36 @@ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; - +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import { Environment } from "../environment/service.ts"; +import { + T3CredentialCipher, + credentialCipherNonceByteLength, +} from "./credential-cipher-service.ts"; import { T3CredentialCrypto, type CredentialDecryptInput, type CredentialEncryptInput, } from "./credential-service.ts"; -import { ConfigError, CredentialDecryptError, isPlatformNotFoundError } from "./error.ts"; +import { ConfigError, isPlatformNotFoundError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { getKeyringStore, KeyringOperationError, keyringErrorMessage } from "./keyring.ts"; import { resolveKeyFilePath } from "./paths.ts"; const configSchemaVersion = 2; const masterKeyByteLength = 32; -const gcmNonceByteLength = 12; const keyringService = "t3cli"; const keyringAccount = "master-key"; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); export type KeyringReadResult = | { readonly kind: "missing" } - | { readonly kind: "present"; readonly key: Buffer } + | { readonly kind: "present"; readonly key: Uint8Array } | { readonly kind: "corrupt"; readonly message: string } | { readonly kind: "unavailable"; readonly message: string }; @@ -40,6 +46,8 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* Environment; + const cryptoService = yield* Crypto.Crypto; + const cipher = yield* T3CredentialCipher; const keyFilePath = resolveKeyFilePath(path, environment); const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { @@ -59,7 +67,17 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi if (raw === undefined) { return undefined; } - const key = Buffer.from(raw.trim(), "base64"); + const key = yield* decodeBase64Bytes(raw.trim()).pipe( + Effect.catchTags({ + EncodingError: (error) => + Effect.fail( + new ConfigError({ + message: "invalid credential key file: invalid base64", + cause: error, + }), + ), + }), + ); if (key.byteLength !== masterKeyByteLength) { return yield* Effect.fail( new ConfigError({ message: "invalid credential key file: unexpected key length" }), @@ -70,7 +88,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi const writeKeyFileMasterKey = Effect.fn("writeKeyFileMasterKey")(function* ( filePath: string, - key: Buffer, + key: Uint8Array, ) { yield* fs.makeDirectory(path.dirname(filePath), { recursive: true, mode: 0o700 }).pipe( Effect.catchTags({ @@ -80,7 +98,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi ), }), ); - yield* fs.writeFileString(filePath, `${key.toString("base64")}\n`, { mode: 0o600 }).pipe( + yield* fs.writeFileString(filePath, `${Encoding.encodeBase64(key)}\n`, { mode: 0o600 }).pipe( Effect.catchTags({ PlatformError: (error) => Effect.fail( @@ -110,7 +128,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi return fileKey; } } - const generated = randomBytes(masterKeyByteLength); + const generated = yield* secureRandomBytes(cryptoService, masterKeyByteLength); const writeResult = yield* writeKeyringMasterKey(generated); if (writeResult.kind === "stored") { return generated; @@ -123,20 +141,32 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi input: CredentialEncryptInput, ) { const masterKey = yield* getMasterKey(); - const nonce = randomBytes(gcmNonceByteLength); - const cipher = createCipheriv("aes-256-gcm", masterKey, nonce, { - authTagLength: 16, - }); - cipher.setAAD(buildAad(input)); - const ciphertext = Buffer.concat([cipher.update(input.token, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); + const nonce = yield* secureRandomBytes(cryptoService, credentialCipherNonceByteLength); + const encrypted = yield* cipher + .encrypt({ + key: masterKey, + nonce, + plaintext: encodeUtf8(input.token), + additionalData: buildCredentialAad(input), + }) + .pipe( + Effect.catchTags({ + CredentialCipherError: (error) => + Effect.fail( + new ConfigError({ + message: "failed to encrypt credential token", + cause: error.cause, + }), + ), + }), + ); return { kind: "encrypted" as const, alg: "aes-256-gcm" as const, key: "default" as const, - nonce: nonce.toString("base64"), - ciphertext: ciphertext.toString("base64"), - tag: tag.toString("base64"), + nonce: Encoding.encodeBase64(nonce), + ciphertext: Encoding.encodeBase64(encrypted.ciphertext), + tag: Encoding.encodeBase64(encrypted.tag), }; }); @@ -144,20 +174,29 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi input: CredentialDecryptInput, ) { const masterKey = yield* getMasterKey(); - return yield* Effect.try({ - try: () => decryptToken(masterKey, input), - catch: (cause) => new CredentialDecryptError({ cause }), - }).pipe( - Effect.catchTags({ - CredentialDecryptError: (error) => - Effect.fail( - new ConfigError({ - message: "failed to decrypt credential token", - cause: error.cause, - }), - ), - }), - ); + const nonce = yield* decodeBase64Field(input.token.nonce, "token nonce"); + const ciphertext = yield* decodeBase64Field(input.token.ciphertext, "token ciphertext"); + const tag = yield* decodeBase64Field(input.token.tag, "token tag"); + const plaintext = yield* cipher + .decrypt({ + key: masterKey, + nonce, + ciphertext, + tag, + additionalData: buildCredentialAad(input), + }) + .pipe( + Effect.catchTags({ + CredentialCipherError: (error) => + Effect.fail( + new ConfigError({ + message: "failed to decrypt credential token", + cause: error.cause, + }), + ), + }), + ); + return decodeUtf8(plaintext); }); return { encrypt, decrypt }; @@ -165,27 +204,52 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi export const T3CredentialCryptoLive = Layer.effect(T3CredentialCrypto, makeT3CredentialCrypto()); -function buildAad(input: { +function buildCredentialAad(input: { readonly environmentName: string; readonly url: string; readonly local: boolean; }) { - return Buffer.from( + return encodeUtf8( `${configSchemaVersion}\0${input.environmentName}\0${input.url}\0${input.local}`, - "utf8", ); } -function decryptToken(masterKey: Buffer, input: CredentialDecryptInput) { - const nonce = Buffer.from(input.token.nonce, "base64"); - const ciphertext = Buffer.from(input.token.ciphertext, "base64"); - const tag = Buffer.from(input.token.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", masterKey, nonce, { - authTagLength: 16, - }); - decipher.setAAD(buildAad(input)); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); +function encodeUtf8(value: string) { + return textEncoder.encode(value); +} + +function decodeUtf8(value: Uint8Array) { + return textDecoder.decode(value); +} + +function secureRandomBytes(cryptoService: Crypto.Crypto, size: number) { + return cryptoService.randomBytes(size).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to generate secure random bytes", + cause: error, + }), + ), + ); +} + +function decodeBase64Bytes(value: string) { + return Effect.fromResult(Encoding.decodeBase64(value)); +} + +function decodeBase64Field(value: string, field: string) { + return decodeBase64Bytes(value).pipe( + Effect.catchTags({ + EncodingError: (error) => + Effect.fail( + new ConfigError({ + message: `invalid encrypted token ${field}`, + cause: error, + }), + ), + }), + ); } function readKeyringMasterKey(): Effect.Effect { @@ -229,17 +293,24 @@ export function parseKeyringPassword(password: string | null): KeyringReadResult if (password === null || password.length === 0) { return { kind: "missing" }; } - const key = Buffer.from(password, "base64"); - if (key.byteLength !== masterKeyByteLength) { - return { - kind: "corrupt", - message: "unexpected key length", - }; - } - return { kind: "present", key }; + return Result.match(Encoding.decodeBase64(password.trim()), { + onFailure: () => ({ + kind: "corrupt" as const, + message: "invalid base64 key", + }), + onSuccess: (key) => { + if (key.byteLength !== masterKeyByteLength) { + return { + kind: "corrupt" as const, + message: "unexpected key length", + }; + } + return { kind: "present" as const, key }; + }, + }); } -function writeKeyringMasterKey(key: Buffer): Effect.Effect { +function writeKeyringMasterKey(key: Uint8Array): Effect.Effect { return Effect.gen(function* () { const store = yield* getKeyringStore(); if (store === null) { @@ -250,7 +321,7 @@ function writeKeyringMasterKey(key: Buffer): Effect.Effect { } return yield* Effect.try({ try: () => { - store.writePassword(keyringService, keyringAccount, key.toString("base64")); + store.writePassword(keyringService, keyringAccount, Encoding.encodeBase64(key)); return { kind: "stored" } as const; }, catch: (cause) => new KeyringOperationError({ cause }), diff --git a/src/config/error.ts b/src/config/error.ts index 35eec6d..966d4e3 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -19,8 +19,8 @@ export class ConfigError extends Schema.TaggedErrorClass()("ConfigE cause: Schema.optionalKey(ConfigErrorCauseSchema), }) {} -export class CredentialDecryptError extends Schema.TaggedErrorClass()( - "CredentialDecryptError", +export class CredentialCipherError extends Schema.TaggedErrorClass()( + "CredentialCipherError", { cause: Schema.Defect(), }, diff --git a/src/config/index.ts b/src/config/index.ts index e58302d..fdb1428 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,10 @@ export type { export { T3ConfigLive, makeT3Config } from "./layer.ts"; export { T3CredentialCrypto } from "./credential-service.ts"; export type { CredentialCrypto } from "./credential-service.ts"; +export { T3CredentialCipher } from "./credential-cipher-service.ts"; +export type { CredentialCipher } from "./credential-cipher-service.ts"; +export { T3CredentialCipherNodeLive } from "./credential-cipher-node.ts"; +export { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; export { T3CredentialCryptoLive } from "./credential.ts"; export { T3ConfigSelection } from "./selection.ts"; export { T3ConfigSelectionLive } from "./selection-layer.ts"; diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 262f36e..7af240c 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -10,6 +10,7 @@ import { Command } from "effect/unstable/cli"; import { vi } from "vite-plus/test"; import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; +import { T3CredentialCipherNodeLive } from "../config/credential-cipher-node.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; import { Environment } from "../environment/service.ts"; import type { ResolvedConfig } from "../config/types.ts"; @@ -33,6 +34,7 @@ function makeCliAppLayer(homeDir: string) { }); return BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), + Layer.provide(T3CredentialCipherNodeLive), Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), ); } diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index c8a5f56..3365710 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -22,8 +22,7 @@ import { T3RpcOperationsLive } from "../rpc/operation.ts"; import { NodeSqlClientFactoryLive } from "../sql/node-sqlite-client.ts"; import { NodeCliPathLayer } from "../cli-path/layer.ts"; -export const T3CredentialCryptoLayer = T3CredentialCryptoLive; -export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLayer)); +export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLive)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); From b0c58ae494bdb37a303f3bfbbdcb54066c301e11 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:26:15 +0300 Subject: [PATCH 06/21] Resolve FileSystem via yield* in hardenPrivateFileMode. Stop passing FileSystem as a function argument and satisfy the helper's service requirement at call sites so credential crypto APIs keep R = never. Co-authored-by: Cursor --- src/config/credential.ts | 8 ++++++-- src/config/file-mode.ts | 10 +++++----- src/config/persist.ts | 6 ++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/config/credential.ts b/src/config/credential.ts index c868c38..de077ba 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -106,7 +106,9 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi ), }), ); - yield* hardenPrivateFileMode(fs, filePath, "credential key"); + yield* hardenPrivateFileMode(filePath, "credential key").pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); }); const getMasterKey = Effect.fn("T3CredentialCryptoLive.getMasterKey")(function* () { @@ -124,7 +126,9 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi if (shouldFallbackToKeyFile(keyringResult)) { const fileKey = yield* readKeyFileMasterKey(keyFilePath); if (fileKey !== undefined) { - yield* hardenPrivateFileMode(fs, keyFilePath, "credential key"); + yield* hardenPrivateFileMode(keyFilePath, "credential key").pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); return fileKey; } } diff --git a/src/config/file-mode.ts b/src/config/file-mode.ts index 9f7c66f..dd738c5 100644 --- a/src/config/file-mode.ts +++ b/src/config/file-mode.ts @@ -1,16 +1,16 @@ import * as Effect from "effect/Effect"; -import type * as FileSystem from "effect/FileSystem"; +import * as FileSystem from "effect/FileSystem"; import { ConfigError } from "./error.ts"; const privateFileMode = 0o600; -export function hardenPrivateFileMode( - fs: FileSystem.FileSystem, +export const hardenPrivateFileMode = Effect.fn("hardenPrivateFileMode")(function* ( filePath: string, label: "config" | "credential key", ) { - return fs.chmod(filePath, privateFileMode).pipe( + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(filePath, privateFileMode).pipe( Effect.mapError( (error) => new ConfigError({ @@ -19,4 +19,4 @@ export function hardenPrivateFileMode( }), ), ); -} +}); diff --git a/src/config/persist.ts b/src/config/persist.ts index 018a0bb..59a2c62 100644 --- a/src/config/persist.ts +++ b/src/config/persist.ts @@ -1,5 +1,5 @@ import * as Effect from "effect/Effect"; -import type * as FileSystem from "effect/FileSystem"; +import * as FileSystem from "effect/FileSystem"; import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -67,6 +67,8 @@ export function writeEncryptedConfigFile( Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), }), ); - yield* hardenPrivateFileMode(fs, configFilePath, "config"); + yield* hardenPrivateFileMode(configFilePath, "config").pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); }); } From a4b6ffd306e139373382f2af5020ba1966ea41c9 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:33:00 +0300 Subject: [PATCH 07/21] Propagate keyring load failures as tagged errors for consumer fallback. Memoize module loading with Effect.cached and handle KeyringModuleLoadError and KeyringModuleNotFoundError in credential crypto instead of swallowing failures as null. Co-authored-by: Cursor --- src/auth/auth.test.ts | 11 ++++- src/config/config.test.ts | 11 ++++- src/config/credential.test.ts | 11 ++++- src/config/credential.ts | 12 ------ src/config/keyring.ts | 79 ++++++++++------------------------- src/runtime/layer.test.ts | 11 ++++- 6 files changed, 57 insertions(+), 78 deletions(-) diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 92f2462..2e4d6fa 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -22,10 +22,17 @@ import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; -vi.mock("../config/keyring.ts", async () => { +vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const actual = await importOriginal(); return { - getKeyringStore: () => EffectModule.succeed(null), + ...actual, + getKeyringStore: () => + EffectModule.fail( + new actual.KeyringModuleNotFoundError({ + cause: new Error("keyring unavailable in test"), + }), + ), }; }); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ce124ca..67f784f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -21,10 +21,17 @@ import { T3ConfigSelection } from "./selection.ts"; import { T3ConfigSelectionLive } from "./selection-layer.ts"; import { T3Config } from "./service.ts"; -vi.mock("./keyring.ts", async () => { +vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const actual = await importOriginal(); return { - getKeyringStore: () => EffectModule.succeed(null), + ...actual, + getKeyringStore: () => + EffectModule.fail( + new actual.KeyringModuleNotFoundError({ + cause: new Error("keyring unavailable in test"), + }), + ), }; }); diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 8107a62..6db3251 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -17,10 +17,17 @@ import { } from "./credential.ts"; import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; -vi.mock("./keyring.ts", async () => { +vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const actual = await importOriginal(); return { - getKeyringStore: () => EffectModule.succeed(null), + ...actual, + getKeyringStore: () => + EffectModule.fail( + new actual.KeyringModuleNotFoundError({ + cause: new Error("keyring unavailable in test"), + }), + ), }; }); diff --git a/src/config/credential.ts b/src/config/credential.ts index de077ba..735b2b0 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -259,12 +259,6 @@ function decodeBase64Field(value: string, field: string) { function readKeyringMasterKey(): Effect.Effect { return Effect.gen(function* () { const store = yield* getKeyringStore(); - if (store === null) { - return { - kind: "unavailable", - message: "OS keyring backend is not available", - } satisfies KeyringReadResult; - } return yield* Effect.try({ try: () => parseKeyringPassword(store.readPassword(keyringService, keyringAccount)), catch: (cause) => new KeyringOperationError({ cause }), @@ -317,12 +311,6 @@ export function parseKeyringPassword(password: string | null): KeyringReadResult function writeKeyringMasterKey(key: Uint8Array): Effect.Effect { return Effect.gen(function* () { const store = yield* getKeyringStore(); - if (store === null) { - return { - kind: "unavailable", - message: "OS keyring backend is not available", - } satisfies KeyringWriteResult; - } return yield* Effect.try({ try: () => { store.writePassword(keyringService, keyringAccount, Encoding.encodeBase64(key)); diff --git a/src/config/keyring.ts b/src/config/keyring.ts index 4cff722..3027b06 100644 --- a/src/config/keyring.ts +++ b/src/config/keyring.ts @@ -2,14 +2,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; -type KeyringEntry = { - getPassword(): string | null; - setPassword(password: string): void; -}; - -type KeyringModule = { - Entry: new (service: string, account: string) => KeyringEntry; -}; +type KeyringModule = typeof import("@napi-rs/keyring"); export class KeyringModuleLoadError extends Schema.TaggedErrorClass()( "KeyringModuleLoadError", @@ -37,26 +30,6 @@ export type KeyringStore = { readonly writePassword: (service: string, account: string, password: string) => void; }; -let cachedKeyringModule: KeyringModule | null | undefined; - -function isKeyringModule(value: unknown): value is KeyringModule { - return Predicate.hasProperty(value, "Entry") && Predicate.isFunction(value.Entry); -} - -function isMissingKeyringModule(cause: unknown): boolean { - return ( - Predicate.hasProperty(cause, "code") && - Predicate.isString(cause.code) && - (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") - ); -} - -function classifyKeyringModuleLoadFailure(cause: unknown) { - return isMissingKeyringModule(cause) - ? new KeyringModuleNotFoundError({ cause }) - : new KeyringModuleLoadError({ cause }); -} - export function keyringErrorMessage(cause: unknown): string { if (cause instanceof Error) { return cause.message; @@ -67,6 +40,14 @@ export function keyringErrorMessage(cause: unknown): string { return "keyring operation failed"; } +function classifyKeyringModuleLoadFailure(cause: unknown) { + return Predicate.hasProperty(cause, "code") && + Predicate.isString(cause.code) && + (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") + ? new KeyringModuleNotFoundError({ cause }) + : new KeyringModuleLoadError({ cause }); +} + function createKeyringStore(keyring: KeyringModule): KeyringStore { return { readPassword(service, account) { @@ -78,38 +59,20 @@ function createKeyringStore(keyring: KeyringModule): KeyringStore { }; } -export function loadKeyringModule(): Effect.Effect< - KeyringModule | null, - KeyringModuleLoadError | KeyringModuleNotFoundError -> { - if (cachedKeyringModule !== undefined) { - return Effect.succeed(cachedKeyringModule); - } - return Effect.tryPromise({ - try: () => import("@napi-rs/keyring"), - catch: classifyKeyringModuleLoadFailure, - }).pipe( - Effect.map((module) => (isKeyringModule(module) ? module : null)), - Effect.tap((module) => - Effect.sync(() => { - cachedKeyringModule = module; - }), - ), - Effect.catchTags({ - KeyringModuleNotFoundError: () => - Effect.sync(() => { - cachedKeyringModule = null; - return null; - }), - }), - ); -} +const loadKeyringModuleOnce = Effect.tryPromise({ + try: () => import("@napi-rs/keyring") as Promise, + catch: classifyKeyringModuleLoadFailure, +}); + +const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); export function getKeyringStore(): Effect.Effect< - KeyringStore | null, + KeyringStore, KeyringModuleLoadError | KeyringModuleNotFoundError > { - return loadKeyringModule().pipe( - Effect.map((module) => (module === null ? null : createKeyringStore(module))), - ); + return Effect.gen(function* () { + const load = yield* loadKeyringModuleMemoized; + const module = yield* load; + return createKeyringStore(module); + }); } diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 7af240c..08a5470 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -17,10 +17,17 @@ import type { ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; import { BaseAppLayer } from "./layer.ts"; -vi.mock("../config/keyring.ts", async () => { +vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const actual = await importOriginal(); return { - getKeyringStore: () => EffectModule.succeed(null), + ...actual, + getKeyringStore: () => + EffectModule.fail( + new actual.KeyringModuleNotFoundError({ + cause: new Error("keyring unavailable in test"), + }), + ), }; }); From c2e53ce1bc830b1d94cc36ec7c25ea05767bb917 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:54:58 +0300 Subject: [PATCH 08/21] Resolve config paths via Effect services instead of passing deps as args. Yield Path and Environment inside path helpers so callers no longer thread platform services through resolveT3cliConfigDir and related functions. Co-authored-by: Cursor --- src/config/credential.ts | 4 +--- src/config/layer.ts | 2 +- src/config/paths.ts | 27 +++++++++++++++++---------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/config/credential.ts b/src/config/credential.ts index 735b2b0..7f516aa 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; -import { Environment } from "../environment/service.ts"; import { T3CredentialCipher, credentialCipherNonceByteLength, @@ -45,10 +44,9 @@ export function shouldFallbackToKeyFile(result: KeyringReadResult) { export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* Environment; const cryptoService = yield* Crypto.Crypto; const cipher = yield* T3CredentialCipher; - const keyFilePath = resolveKeyFilePath(path, environment); + const keyFilePath = yield* resolveKeyFilePath(); const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { const raw = yield* fs.readFileString(filePath).pipe( diff --git a/src/config/layer.ts b/src/config/layer.ts index 8e251b0..d081604 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -28,7 +28,7 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { const environment = yield* Environment; const credentialCrypto = yield* T3CredentialCrypto; const configSelection = yield* T3ConfigSelection; - const configFilePath = resolveConfigFilePath(path, environment); + const configFilePath = yield* resolveConfigFilePath(); const readEncrypted = Effect.fn("T3ConfigLive.readEncrypted")(function* () { return yield* readEncryptedConfigFile(fs, path, configFilePath, credentialCrypto); diff --git a/src/config/paths.ts b/src/config/paths.ts index f9c0487..1ea8b69 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,20 +1,27 @@ -import type * as Path from "effect/Path"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; -import type { EnvironmentShape } from "../environment/service.ts"; +import { Environment } from "../environment/service.ts"; -export function resolveT3cliConfigDir(path: Path.Path, environment: EnvironmentShape) { +export const resolveT3cliConfigDir = Effect.fn("resolveT3cliConfigDir")(function* () { + const path = yield* Path.Path; + const environment = yield* Environment; const xdgConfigHome = environment.env["XDG_CONFIG_HOME"]?.trim(); const root = xdgConfigHome !== undefined && xdgConfigHome.length > 0 ? xdgConfigHome : path.join(environment.homeDir, ".config"); return path.join(root, "t3cli"); -} +}); -export function resolveConfigFilePath(path: Path.Path, environment: EnvironmentShape) { - return path.join(resolveT3cliConfigDir(path, environment), "config.json"); -} +export const resolveConfigFilePath = Effect.fn("resolveConfigFilePath")(function* () { + const path = yield* Path.Path; + const configDir = yield* resolveT3cliConfigDir(); + return path.join(configDir, "config.json"); +}); -export function resolveKeyFilePath(path: Path.Path, environment: EnvironmentShape) { - return path.join(resolveT3cliConfigDir(path, environment), "key"); -} +export const resolveKeyFilePath = Effect.fn("resolveKeyFilePath")(function* () { + const path = yield* Path.Path; + const configDir = yield* resolveT3cliConfigDir(); + return path.join(configDir, "key"); +}); From f4bc2cd3c7e5fc3eb9a24c191c71b3dd0da72fbc Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 20:58:51 +0300 Subject: [PATCH 09/21] Yield config services in codec, persist, and migration helpers. Stop threading FileSystem, Path, Environment, and CredentialCrypto through function arguments; resolve them via Effect services at call sites in the config layer. Co-authored-by: Cursor --- src/config/codec.ts | 102 +++++++++++++++++----------------- src/config/config.test.ts | 42 ++++++-------- src/config/credential.ts | 22 ++++++-- src/config/layer.ts | 90 +++++++++++++++++------------- src/config/migration.ts | 83 ++++++++++++++-------------- src/config/persist.ts | 113 ++++++++++++++++++-------------------- 6 files changed, 231 insertions(+), 221 deletions(-) diff --git a/src/config/codec.ts b/src/config/codec.ts index b2ecc24..cc43177 100644 --- a/src/config/codec.ts +++ b/src/config/codec.ts @@ -1,62 +1,64 @@ import * as Effect from "effect/Effect"; -import type { - CredentialCrypto, - CredentialDecryptInput, - CredentialEncryptInput, +import { + T3CredentialCrypto, + type CredentialDecryptInput, + type CredentialEncryptInput, } from "./credential-service.ts"; import type { EncryptedConfig, DecryptedConfig, DecryptedEnvironment } from "./types.ts"; -export function encryptEnvironment(crypto: CredentialCrypto, input: CredentialEncryptInput) { - return crypto.encrypt(input); -} +export const encryptEnvironment = Effect.fn("encryptEnvironment")(function* ( + input: CredentialEncryptInput, +) { + const crypto = yield* T3CredentialCrypto; + return yield* crypto.encrypt(input); +}); -export function decryptEnvironment(crypto: CredentialCrypto, input: CredentialDecryptInput) { - return crypto.decrypt(input); -} +export const decryptEnvironment = Effect.fn("decryptEnvironment")(function* ( + input: CredentialDecryptInput, +) { + const crypto = yield* T3CredentialCrypto; + return yield* crypto.decrypt(input); +}); -export function decryptConfig(crypto: CredentialCrypto, config: EncryptedConfig) { - return Effect.gen(function* () { - const environments: Record = {}; - for (const [name, environmentConfig] of Object.entries(config.environments)) { - environments[name] = { +export const decryptConfig = Effect.fn("decryptConfig")(function* (config: EncryptedConfig) { + const environments: Record = {}; + for (const [name, environmentConfig] of Object.entries(config.environments)) { + environments[name] = { + url: environmentConfig.url, + local: environmentConfig.local, + token: yield* decryptEnvironment({ + environmentName: name, url: environmentConfig.url, local: environmentConfig.local, - token: yield* decryptEnvironment(crypto, { - environmentName: name, - url: environmentConfig.url, - local: environmentConfig.local, - token: environmentConfig.token, - }), - }; - } - return { - version: 2 as const, - ...(config.default !== undefined ? { default: config.default } : {}), - environments, - } satisfies DecryptedConfig; - }); -} + token: environmentConfig.token, + }), + }; + } + return { + version: 2 as const, + ...(config.default !== undefined ? { default: config.default } : {}), + environments, + } satisfies DecryptedConfig; +}); -export function encryptConfig(crypto: CredentialCrypto, config: DecryptedConfig) { - return Effect.gen(function* () { - const environments: Record = {}; - for (const [name, environmentConfig] of Object.entries(config.environments)) { - environments[name] = { +export const encryptConfig = Effect.fn("encryptConfig")(function* (config: DecryptedConfig) { + const environments: Record = {}; + for (const [name, environmentConfig] of Object.entries(config.environments)) { + environments[name] = { + url: environmentConfig.url, + local: environmentConfig.local, + token: yield* encryptEnvironment({ + environmentName: name, url: environmentConfig.url, local: environmentConfig.local, - token: yield* encryptEnvironment(crypto, { - environmentName: name, - url: environmentConfig.url, - local: environmentConfig.local, - token: environmentConfig.token, - }), - }; - } - return { - version: 2 as const, - ...(config.default !== undefined ? { default: config.default } : {}), - environments, - } satisfies EncryptedConfig; - }); -} + token: environmentConfig.token, + }), + }; + } + return { + version: 2 as const, + ...(config.default !== undefined ? { default: config.default } : {}), + environments, + } satisfies EncryptedConfig; +}); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 67f784f..f6f10dd 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -11,8 +11,8 @@ import { assert, describe, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; -import { decryptEnvironment } from "./codec.ts"; -import { makeT3CredentialCrypto, T3CredentialCryptoLive } from "./credential.ts"; +import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; +import { T3CredentialCryptoLive } from "./credential.ts"; import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { T3ConfigLive } from "./layer.ts"; @@ -45,6 +45,14 @@ function makeEnvironmentLayer(homeDir: string, env: Record = {}) }); } +function makeCredentialCryptoLayer(homeDir: string) { + return T3CredentialCryptoLive.pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, T3CredentialCipherWebLive, makeEnvironmentLayer(homeDir)), + ), + ); +} + function makeConfigLayer( homeDir: string, input: { @@ -75,29 +83,20 @@ describe("config persistence", () => { }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { - const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - T3CredentialCipherWebLive, - makeEnvironmentLayer(homeDir), - ), - ), - ); - const migrated = yield* migrateV1FileToEncrypted(credentialCrypto, { + const migrated = yield* migrateV1FileToEncrypted({ url: "https://app.example.com", token: "secret-token", local: false, }); assert.equal(migrated.default, "app.example.com"); - const token = yield* decryptEnvironment(credentialCrypto, { + const token = yield* decryptEnvironment({ environmentName: "app.example.com", url: "https://app.example.com", local: false, token: migrated.environments["app.example.com"]!.token, }); assert.equal(token, "secret-token"); - }), + }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir))), ), Effect.provide(NodeServices.layer), Effect.scoped, @@ -455,29 +454,20 @@ describe("config persistence", () => { }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { - const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - T3CredentialCipherWebLive, - makeEnvironmentLayer(homeDir), - ), - ), - ); - const token = yield* credentialCrypto.encrypt({ + const token = yield* encryptEnvironment({ environmentName: "home", url: "https://home.example", local: false, token: "secret", }); - const result = yield* decryptEnvironment(credentialCrypto, { + const result = yield* decryptEnvironment({ environmentName: "home", url: "https://tampered.example", local: false, token, }).pipe(Effect.exit); assert.equal(Exit.isFailure(result), true); - }), + }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir))), ), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/src/config/credential.ts b/src/config/credential.ts index 7f516aa..a57ce2b 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -10,6 +10,7 @@ import { T3CredentialCipher, credentialCipherNonceByteLength, } from "./credential-cipher-service.ts"; +import { Environment } from "../environment/service.ts"; import { T3CredentialCrypto, type CredentialDecryptInput, @@ -44,9 +45,13 @@ export function shouldFallbackToKeyFile(result: KeyringReadResult) { export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const environment = yield* Environment; const cryptoService = yield* Crypto.Crypto; const cipher = yield* T3CredentialCipher; - const keyFilePath = yield* resolveKeyFilePath(); + const keyFilePath = yield* resolveKeyFilePath().pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(Environment, environment), + ); const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { const raw = yield* fs.readFileString(filePath).pipe( @@ -130,7 +135,9 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi return fileKey; } } - const generated = yield* secureRandomBytes(cryptoService, masterKeyByteLength); + const generated = yield* secureRandomBytes(masterKeyByteLength).pipe( + Effect.provideService(Crypto.Crypto, cryptoService), + ); const writeResult = yield* writeKeyringMasterKey(generated); if (writeResult.kind === "stored") { return generated; @@ -143,7 +150,9 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi input: CredentialEncryptInput, ) { const masterKey = yield* getMasterKey(); - const nonce = yield* secureRandomBytes(cryptoService, credentialCipherNonceByteLength); + const nonce = yield* secureRandomBytes(credentialCipherNonceByteLength).pipe( + Effect.provideService(Crypto.Crypto, cryptoService), + ); const encrypted = yield* cipher .encrypt({ key: masterKey, @@ -224,8 +233,9 @@ function decodeUtf8(value: Uint8Array) { return textDecoder.decode(value); } -function secureRandomBytes(cryptoService: Crypto.Crypto, size: number) { - return cryptoService.randomBytes(size).pipe( +const secureRandomBytes = Effect.fn("secureRandomBytes")(function* (size: number) { + const cryptoService = yield* Crypto.Crypto; + return yield* cryptoService.randomBytes(size).pipe( Effect.mapError( (error) => new ConfigError({ @@ -234,7 +244,7 @@ function secureRandomBytes(cryptoService: Crypto.Crypto, size: number) { }), ), ); -} +}); function decodeBase64Bytes(value: string) { return Effect.fromResult(Encoding.decodeBase64(value)); diff --git a/src/config/layer.ts b/src/config/layer.ts index d081604..4beaf89 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -8,7 +8,6 @@ import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; import { T3CredentialCrypto } from "./credential-service.ts"; import { validateEnvironmentName } from "./environment-name.ts"; import { ConfigError, UrlError } from "./error.ts"; -import { resolveConfigFilePath } from "./paths.ts"; import { readEncryptedConfigFile, writeEncryptedConfigFile } from "./persist.ts"; import { buildResolvedConfigFromEnv, @@ -23,15 +22,22 @@ import { T3Config, type UpsertEnvironmentInput } from "./service.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; export const makeT3Config = Effect.fn("makeT3Config")(function* () { + const environment = yield* Environment; + const configSelection = yield* T3ConfigSelection; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* Environment; const credentialCrypto = yield* T3CredentialCrypto; - const configSelection = yield* T3ConfigSelection; - const configFilePath = yield* resolveConfigFilePath(); + + const withConfigServices = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(Environment, environment), + Effect.provideService(T3CredentialCrypto, credentialCrypto), + ); const readEncrypted = Effect.fn("T3ConfigLive.readEncrypted")(function* () { - return yield* readEncryptedConfigFile(fs, path, configFilePath, credentialCrypto); + return yield* withConfigServices(readEncryptedConfigFile()); }); const hasEnvironment = Effect.fn("T3ConfigLive.hasEnvironment")(function* (name: string) { @@ -86,25 +92,29 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { Effect.mapError(mapUrlToConfigError), ); const encrypted = yield* readEncrypted(); - const token = yield* encryptEnvironment(credentialCrypto, { - environmentName: input.name, - url: normalizedUrl, - local: input.local, - token: input.token, - }); + const token = yield* withConfigServices( + encryptEnvironment({ + environmentName: input.name, + url: normalizedUrl, + local: input.local, + token: input.token, + }), + ); const defaultName = resolveDefaultForUpsert(encrypted, input.name, input.makeDefault); - yield* writeEncryptedConfigFile(fs, path, configFilePath, { - version: 2, - ...(defaultName !== undefined ? { default: defaultName } : {}), - environments: { - ...encrypted.environments, - [input.name]: { - url: normalizedUrl, - local: input.local, - token, + yield* withConfigServices( + writeEncryptedConfigFile({ + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments: { + ...encrypted.environments, + [input.name]: { + url: normalizedUrl, + local: input.local, + token, + }, }, - }, - }); + }), + ); }); const setDefaultEnvironment = Effect.fn("T3ConfigLive.setDefaultEnvironment")(function* ( @@ -115,10 +125,12 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { if (encrypted.environments[name] === undefined) { return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } - yield* writeEncryptedConfigFile(fs, path, configFilePath, { - ...encrypted, - default: name, - }); + yield* withConfigServices( + writeEncryptedConfigFile({ + ...encrypted, + default: name, + }), + ); return yield* Effect.void; }); @@ -130,11 +142,13 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { } const { [name]: _removed, ...environments } = encrypted.environments; const defaultName = encrypted.default === name ? undefined : encrypted.default; - yield* writeEncryptedConfigFile(fs, path, configFilePath, { - version: 2, - ...(defaultName !== undefined ? { default: defaultName } : {}), - environments, - }); + yield* withConfigServices( + writeEncryptedConfigFile({ + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments, + }), + ); return yield* Effect.void; }); @@ -180,12 +194,14 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { } const selectedEnvironment = encrypted.environments[selectedName]; - const token = yield* decryptEnvironment(credentialCrypto, { - environmentName: selectedName, - url: selectedEnvironment.url, - local: selectedEnvironment.local, - token: selectedEnvironment.token, - }); + const token = yield* withConfigServices( + decryptEnvironment({ + environmentName: selectedName, + url: selectedEnvironment.url, + local: selectedEnvironment.local, + token: selectedEnvironment.token, + }), + ); return yield* buildResolvedConfigFromStored({ selectedName, token, diff --git a/src/config/migration.ts b/src/config/migration.ts index 2534442..a17266f 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -3,7 +3,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { encryptEnvironment } from "./codec.ts"; -import type { CredentialCrypto } from "./credential-service.ts"; import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; import { ConfigError } from "./error.ts"; import { @@ -19,8 +18,10 @@ export const emptyEncryptedConfig = (): EncryptedConfig => ({ environments: {}, }); -export function readEncryptedConfigFromValue(crypto: CredentialCrypto, value: unknown) { - return Effect.gen(function* () { +export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromValue")(function* ( + value: unknown, +) { + return yield* Effect.gen(function* () { const v2 = yield* Schema.decodeUnknownEffect(StoredConfigV2FileSchema)(value).pipe( Effect.option, ); @@ -28,7 +29,7 @@ export function readEncryptedConfigFromValue(crypto: CredentialCrypto, value: un return { config: v2.value, migratedFromV1: false as const }; } const v1 = yield* Schema.decodeUnknownEffect(StoredConfigV1FileSchema)(value); - const migrated = yield* migrateV1FileToEncrypted(crypto, v1); + const migrated = yield* migrateV1FileToEncrypted(v1); return { config: migrated, migratedFromV1: true as const }; }).pipe( Effect.catchTags({ @@ -38,43 +39,43 @@ export function readEncryptedConfigFromValue(crypto: CredentialCrypto, value: un Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), }), ); -} +}); -export function migrateV1FileToEncrypted(crypto: CredentialCrypto, config: StoredConfigV1File) { - return Effect.gen(function* () { - if ( - (config.url === undefined || config.url.length === 0) && - (config.token === undefined || config.token.length === 0) - ) { - return emptyEncryptedConfig(); - } - if (config.url === undefined || config.token === undefined) { - return yield* Effect.fail( - new ConfigError({ message: "failed to read config: incomplete v1 credentials" }), - ); - } - const name = yield* migrateV1EnvironmentName(config).pipe( - Effect.flatMap((migratedName) => - validateEnvironmentName(migratedName).pipe(Effect.as(migratedName)), - ), +export const migrateV1FileToEncrypted = Effect.fn("migrateV1FileToEncrypted")(function* ( + config: StoredConfigV1File, +) { + if ( + (config.url === undefined || config.url.length === 0) && + (config.token === undefined || config.token.length === 0) + ) { + return emptyEncryptedConfig(); + } + if (config.url === undefined || config.token === undefined) { + return yield* Effect.fail( + new ConfigError({ message: "failed to read config: incomplete v1 credentials" }), ); - const normalizedUrl = yield* normalizeHttpBaseUrl(config.url); - const token = yield* encryptEnvironment(crypto, { - environmentName: name, - url: normalizedUrl, - local: config.local ?? false, - token: config.token, - }); - return { - version: 2 as const, - default: name, - environments: { - [name]: { - url: normalizedUrl, - local: config.local ?? false, - token, - }, - }, - } satisfies EncryptedConfig; + } + const name = yield* migrateV1EnvironmentName(config).pipe( + Effect.flatMap((migratedName) => + validateEnvironmentName(migratedName).pipe(Effect.as(migratedName)), + ), + ); + const normalizedUrl = yield* normalizeHttpBaseUrl(config.url); + const token = yield* encryptEnvironment({ + environmentName: name, + url: normalizedUrl, + local: config.local ?? false, + token: config.token, }); -} + return { + version: 2 as const, + default: name, + environments: { + [name]: { + url: normalizedUrl, + local: config.local ?? false, + token, + }, + }, + } satisfies EncryptedConfig; +}); diff --git a/src/config/persist.ts b/src/config/persist.ts index 59a2c62..be954ec 100644 --- a/src/config/persist.ts +++ b/src/config/persist.ts @@ -1,74 +1,65 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import type * as Path from "effect/Path"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import type { CredentialCrypto } from "./credential-service.ts"; import { ConfigError, isPlatformNotFoundError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; +import { resolveConfigFilePath } from "./paths.ts"; import { UnknownConfigFileJson, StoredConfigV2FileJson } from "./schema.ts"; import type { EncryptedConfig } from "./types.ts"; -export function readEncryptedConfigFile( - fs: FileSystem.FileSystem, - path: Path.Path, - configFilePath: string, - crypto: CredentialCrypto, -) { - return Effect.gen(function* () { - const raw = yield* fs.readFileString(configFilePath).pipe( - Effect.catchTags({ - PlatformError: (error) => - isPlatformNotFoundError(error) - ? Effect.succeed(undefined) - : Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), - }), - ); - if (raw === undefined) { - return emptyEncryptedConfig(); - } - const value = yield* Schema.decodeUnknownEffect(UnknownConfigFileJson)(raw).pipe( - Effect.catchTags({ - SchemaError: (error) => - Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), - }), - ); - const read = yield* readEncryptedConfigFromValue(crypto, value); - if (read.migratedFromV1) { - yield* writeEncryptedConfigFile(fs, path, configFilePath, read.config); - } - return read.config; - }); -} +export const readEncryptedConfigFile = Effect.fn("readEncryptedConfigFile")(function* () { + const fs = yield* FileSystem.FileSystem; + const configFilePath = yield* resolveConfigFilePath(); + const raw = yield* fs.readFileString(configFilePath).pipe( + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); + if (raw === undefined) { + return emptyEncryptedConfig(); + } + const value = yield* Schema.decodeUnknownEffect(UnknownConfigFileJson)(raw).pipe( + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); + const read = yield* readEncryptedConfigFromValue(value); + if (read.migratedFromV1) { + yield* writeEncryptedConfigFile(read.config); + } + return read.config; +}); -export function writeEncryptedConfigFile( - fs: FileSystem.FileSystem, - path: Path.Path, - configFilePath: string, +export const writeEncryptedConfigFile = Effect.fn("writeEncryptedConfigFile")(function* ( config: EncryptedConfig, ) { - return Effect.gen(function* () { - yield* fs.makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), - ); - const encoded = yield* Schema.encodeEffect(StoredConfigV2FileJson)(config).pipe( - Effect.catchTags({ - SchemaError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), - ); - yield* fs.writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), - ); - yield* hardenPrivateFileMode(configFilePath, "config").pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); - }); -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configFilePath = yield* resolveConfigFilePath(); + yield* fs.makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + const encoded = yield* Schema.encodeEffect(StoredConfigV2FileJson)(config).pipe( + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + yield* fs.writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + yield* hardenPrivateFileMode(configFilePath, "config"); +}); From 93490bc9a52713cde4a317e903d0633f25f8723b Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 21:29:37 +0300 Subject: [PATCH 10/21] Align config modules with Effect service conventions. Inline service interfaces, consolidate errors, namespace exports, and wire cipher layers at entrypoints so tests and runtime share the same composition pattern. Co-authored-by: Cursor --- src/auth/auth.test.ts | 16 ++- src/bin.ts | 4 +- src/config/codec.ts | 2 +- src/config/config.test.ts | 22 +-- src/config/credential-cipher-node.ts | 73 +++++----- src/config/credential-cipher-web.ts | 126 ++++++++---------- ...cipher-service.ts => credential-cipher.ts} | 21 +-- src/config/credential-service.ts | 28 ---- src/config/credential.test.ts | 26 ++-- src/config/credential.ts | 103 +++++++------- src/config/error.ts | 45 +++++++ src/config/index.ts | 21 +-- src/config/keyring.ts | 52 ++------ src/config/layer.ts | 41 +++--- src/config/migration.ts | 5 +- src/runtime/layer.test.ts | 8 +- src/runtime/layer.ts | 6 +- 17 files changed, 288 insertions(+), 311 deletions(-) rename src/config/{credential-cipher-service.ts => credential-cipher.ts} (64%) delete mode 100644 src/config/credential-service.ts diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 2e4d6fa..48ca7b1 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -15,21 +15,23 @@ import { T3AuthPairing } from "./pairing.ts"; import { T3AuthLive } from "./layer.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; -import { T3CredentialCryptoLive } from "../config/credential.ts"; -import { T3CredentialCipherWebLive } from "../config/credential-cipher-web.ts"; -import { T3ConfigLive } from "../config/layer.ts"; +import * as Config from "../config/layer.ts"; +import * as CredentialCrypto from "../config/credential.ts"; +import { layerWeb } from "../config/credential-cipher-web.ts"; import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const { KeyringModuleLoadError } = await import("../config/error.ts"); const actual = await importOriginal(); return { ...actual, getKeyringStore: () => EffectModule.fail( - new actual.KeyringModuleNotFoundError({ + new KeyringModuleLoadError({ + reason: "module-not-found", cause: new Error("keyring unavailable in test"), }), ), @@ -45,12 +47,12 @@ function makeAuthLayer(homeDir: string) { stderrIsTTY: false, }); const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); - const configLayer = T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), + const configLayer = Config.layer.pipe( + Layer.provide(CredentialCrypto.layer), Layer.provide( Layer.mergeAll( platformLayer, - T3CredentialCipherWebLive, + layerWeb, Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(undefined), }), diff --git a/src/bin.ts b/src/bin.ts index 97e9d5d..587b44f 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -7,7 +7,7 @@ import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; -import { T3CredentialCipherNodeLive } from "./config/credential-cipher-node.ts"; +import { layerNode } from "./config/credential-cipher-node.ts"; import { NodeEnvironmentLive } from "./environment/layer.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; @@ -26,7 +26,7 @@ const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); const CliAppLayer = BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(T3CredentialCipherNodeLive), + Layer.provide(layerNode), ); const CliLayer = Layer.mergeAll( diff --git a/src/config/codec.ts b/src/config/codec.ts index cc43177..7202029 100644 --- a/src/config/codec.ts +++ b/src/config/codec.ts @@ -4,7 +4,7 @@ import { T3CredentialCrypto, type CredentialDecryptInput, type CredentialEncryptInput, -} from "./credential-service.ts"; +} from "./credential.ts"; import type { EncryptedConfig, DecryptedConfig, DecryptedEnvironment } from "./types.ts"; export const encryptEnvironment = Effect.fn("encryptEnvironment")(function* ( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f6f10dd..baf4f67 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -12,10 +12,10 @@ import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; -import { T3CredentialCryptoLive } from "./credential.ts"; -import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; +import * as Config from "./layer.ts"; +import * as CredentialCrypto from "./credential.ts"; +import { layerWeb } from "./credential-cipher-web.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; -import { T3ConfigLive } from "./layer.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; import { T3ConfigSelection } from "./selection.ts"; import { T3ConfigSelectionLive } from "./selection-layer.ts"; @@ -23,12 +23,14 @@ import { T3Config } from "./service.ts"; vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const { KeyringModuleLoadError } = await import("./error.ts"); const actual = await importOriginal(); return { ...actual, getKeyringStore: () => EffectModule.fail( - new actual.KeyringModuleNotFoundError({ + new KeyringModuleLoadError({ + reason: "module-not-found", cause: new Error("keyring unavailable in test"), }), ), @@ -46,10 +48,8 @@ function makeEnvironmentLayer(homeDir: string, env: Record = {}) } function makeCredentialCryptoLayer(homeDir: string) { - return T3CredentialCryptoLive.pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, T3CredentialCipherWebLive, makeEnvironmentLayer(homeDir)), - ), + return CredentialCrypto.layer.pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, layerWeb, makeEnvironmentLayer(homeDir))), ); } @@ -69,9 +69,9 @@ function makeConfigLayer( : Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(input.selection), }); - return T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), - Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, T3CredentialCipherWebLive)), + return Config.layer.pipe( + Layer.provide(CredentialCrypto.layer), + Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, layerWeb)), ); } diff --git a/src/config/credential-cipher-node.ts b/src/config/credential-cipher-node.ts index f64dd94..74677f9 100644 --- a/src/config/credential-cipher-node.ts +++ b/src/config/credential-cipher-node.ts @@ -3,47 +3,38 @@ import { createCipheriv, createDecipheriv } from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { - credentialCipherTagByteLength, - T3CredentialCipher, - type CredentialCipher, -} from "./credential-cipher-service.ts"; +import { credentialCipherTagByteLength, T3CredentialCipher } from "./credential-cipher.ts"; import { CredentialCipherError } from "./error.ts"; -function makeNodeCredentialCipher(): CredentialCipher { - return { - encrypt: (input) => - Effect.try({ - try: () => { - const cipher = createCipheriv("aes-256-gcm", input.key, input.nonce, { - authTagLength: credentialCipherTagByteLength, - }); - cipher.setAAD(input.additionalData); - const ciphertext = Buffer.concat([cipher.update(input.plaintext), cipher.final()]); - return { - ciphertext: new Uint8Array(ciphertext), - tag: new Uint8Array(cipher.getAuthTag()), - }; - }, - catch: (cause) => new CredentialCipherError({ cause }), - }), - decrypt: (input) => - Effect.try({ - try: () => { - const decipher = createDecipheriv("aes-256-gcm", input.key, input.nonce, { - authTagLength: credentialCipherTagByteLength, - }); - decipher.setAAD(input.additionalData); - decipher.setAuthTag(input.tag); - const plaintext = Buffer.concat([decipher.update(input.ciphertext), decipher.final()]); - return new Uint8Array(plaintext); - }, - catch: (cause) => new CredentialCipherError({ cause }), - }), - }; -} +const make = (): T3CredentialCipher["Service"] => ({ + encrypt: (input) => + Effect.try({ + try: () => { + const cipher = createCipheriv("aes-256-gcm", input.key, input.nonce, { + authTagLength: credentialCipherTagByteLength, + }); + cipher.setAAD(input.additionalData); + const ciphertext = Buffer.concat([cipher.update(input.plaintext), cipher.final()]); + return { + ciphertext: new Uint8Array(ciphertext), + tag: new Uint8Array(cipher.getAuthTag()), + }; + }, + catch: (cause) => new CredentialCipherError({ operation: "encrypt", cause }), + }), + decrypt: (input) => + Effect.try({ + try: () => { + const decipher = createDecipheriv("aes-256-gcm", input.key, input.nonce, { + authTagLength: credentialCipherTagByteLength, + }); + decipher.setAAD(input.additionalData); + decipher.setAuthTag(input.tag); + const plaintext = Buffer.concat([decipher.update(input.ciphertext), decipher.final()]); + return new Uint8Array(plaintext); + }, + catch: (cause) => new CredentialCipherError({ operation: "decrypt", cause }), + }), +}); -export const T3CredentialCipherNodeLive = Layer.succeed( - T3CredentialCipher, - makeNodeCredentialCipher(), -); +export const layerNode = Layer.succeed(T3CredentialCipher, make()); diff --git a/src/config/credential-cipher-web.ts b/src/config/credential-cipher-web.ts index a2ee471..cf7cf79 100644 --- a/src/config/credential-cipher-web.ts +++ b/src/config/credential-cipher-web.ts @@ -1,11 +1,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { - credentialCipherTagByteLength, - T3CredentialCipher, - type CredentialCipher, -} from "./credential-cipher-service.ts"; +import { credentialCipherTagByteLength, T3CredentialCipher } from "./credential-cipher.ts"; import { CredentialCipherError } from "./error.ts"; const aesGcmAlgorithm = "AES-GCM"; @@ -16,75 +12,71 @@ function copyBytes(bytes: Uint8Array) { return copy; } -function importAesGcmKey(key: Uint8Array) { +function importAesGcmKey(key: Uint8Array, operation: "encrypt" | "decrypt") { return Effect.tryPromise({ try: () => globalThis.crypto.subtle.importKey("raw", copyBytes(key), { name: aesGcmAlgorithm }, false, [ "encrypt", "decrypt", ]), - catch: (cause) => new CredentialCipherError({ cause }), + catch: (cause) => new CredentialCipherError({ operation, cause }), }); } -function makeWebCredentialCipher(): CredentialCipher { - return { - encrypt: (input) => - Effect.gen(function* () { - const cryptoKey = yield* importAesGcmKey(input.key); - const encrypted = yield* Effect.tryPromise({ - try: () => - globalThis.crypto.subtle.encrypt( - { - name: aesGcmAlgorithm, - iv: copyBytes(input.nonce), - additionalData: copyBytes(input.additionalData), - tagLength: credentialCipherTagByteLength * 8, - }, - cryptoKey, - copyBytes(input.plaintext), - ), - catch: (cause) => new CredentialCipherError({ cause }), - }); - const bytes = new Uint8Array(encrypted); - if (bytes.byteLength < credentialCipherTagByteLength) { - return yield* Effect.fail( - new CredentialCipherError({ - cause: new Error("encrypted output shorter than auth tag"), - }), - ); - } - return { - ciphertext: bytes.slice(0, -credentialCipherTagByteLength), - tag: bytes.slice(-credentialCipherTagByteLength), - }; - }), - decrypt: (input) => - Effect.gen(function* () { - const cryptoKey = yield* importAesGcmKey(input.key); - const combined = new Uint8Array(input.ciphertext.byteLength + input.tag.byteLength); - combined.set(input.ciphertext); - combined.set(input.tag, input.ciphertext.byteLength); - const plaintext = yield* Effect.tryPromise({ - try: () => - globalThis.crypto.subtle.decrypt( - { - name: aesGcmAlgorithm, - iv: copyBytes(input.nonce), - additionalData: copyBytes(input.additionalData), - tagLength: credentialCipherTagByteLength * 8, - }, - cryptoKey, - combined, - ), - catch: (cause) => new CredentialCipherError({ cause }), - }); - return new Uint8Array(plaintext); - }), - }; -} +const make = (): T3CredentialCipher["Service"] => ({ + encrypt: (input) => + Effect.gen(function* () { + const cryptoKey = yield* importAesGcmKey(input.key, "encrypt"); + const encrypted = yield* Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.encrypt( + { + name: aesGcmAlgorithm, + iv: copyBytes(input.nonce), + additionalData: copyBytes(input.additionalData), + tagLength: credentialCipherTagByteLength * 8, + }, + cryptoKey, + copyBytes(input.plaintext), + ), + catch: (cause) => new CredentialCipherError({ operation: "encrypt", cause }), + }); + const bytes = new Uint8Array(encrypted); + if (bytes.byteLength < credentialCipherTagByteLength) { + return yield* Effect.fail( + new CredentialCipherError({ + operation: "encrypt", + cause: new Error("encrypted output shorter than auth tag"), + }), + ); + } + return { + ciphertext: bytes.slice(0, -credentialCipherTagByteLength), + tag: bytes.slice(-credentialCipherTagByteLength), + }; + }), + decrypt: (input) => + Effect.gen(function* () { + const cryptoKey = yield* importAesGcmKey(input.key, "decrypt"); + const combined = new Uint8Array(input.ciphertext.byteLength + input.tag.byteLength); + combined.set(input.ciphertext); + combined.set(input.tag, input.ciphertext.byteLength); + const plaintext = yield* Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.decrypt( + { + name: aesGcmAlgorithm, + iv: copyBytes(input.nonce), + additionalData: copyBytes(input.additionalData), + tagLength: credentialCipherTagByteLength * 8, + }, + cryptoKey, + combined, + ), + catch: (cause) => new CredentialCipherError({ operation: "decrypt", cause }), + }); + return new Uint8Array(plaintext); + }), +}); -export const T3CredentialCipherWebLive = Layer.succeed( - T3CredentialCipher, - makeWebCredentialCipher(), -); +export const layerWeb = Layer.succeed(T3CredentialCipher, make()); diff --git a/src/config/credential-cipher-service.ts b/src/config/credential-cipher.ts similarity index 64% rename from src/config/credential-cipher-service.ts rename to src/config/credential-cipher.ts index 8d080f1..7944762 100644 --- a/src/config/credential-cipher-service.ts +++ b/src/config/credential-cipher.ts @@ -26,13 +26,14 @@ export type AesGcmEncryptResult = { readonly tag: Uint8Array; }; -export class T3CredentialCipher extends Context.Service()( - "t3cli/T3CredentialCipher", -) {} - -export type CredentialCipher = { - readonly encrypt: ( - input: AesGcmEncryptInput, - ) => Effect.Effect; - readonly decrypt: (input: AesGcmDecryptInput) => Effect.Effect; -}; +export class T3CredentialCipher extends Context.Service< + T3CredentialCipher, + { + readonly encrypt: ( + input: AesGcmEncryptInput, + ) => Effect.Effect; + readonly decrypt: ( + input: AesGcmDecryptInput, + ) => Effect.Effect; + } +>()("t3cli/T3CredentialCipher") {} diff --git a/src/config/credential-service.ts b/src/config/credential-service.ts deleted file mode 100644 index 1b7acd9..0000000 --- a/src/config/credential-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ConfigError } from "./error.ts"; -import type { EncryptedToken } from "./schema.ts"; - -export type CredentialEncryptInput = { - readonly environmentName: string; - readonly url: string; - readonly local: boolean; - readonly token: string; -}; - -export type CredentialDecryptInput = { - readonly environmentName: string; - readonly url: string; - readonly local: boolean; - readonly token: EncryptedToken; -}; - -export class T3CredentialCrypto extends Context.Service()( - "t3cli/T3CredentialCrypto", -) {} - -export type CredentialCrypto = { - readonly encrypt: (input: CredentialEncryptInput) => Effect.Effect; - readonly decrypt: (input: CredentialDecryptInput) => Effect.Effect; -}; diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 6db3251..13d2602 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -10,21 +10,19 @@ import { assert, describe, it } from "@effect/vitest"; import { expect, vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; -import { - makeT3CredentialCrypto, - parseKeyringPassword, - shouldFallbackToKeyFile, -} from "./credential.ts"; -import { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; +import { make, parseKeyringPassword, shouldFallbackToKeyFile } from "./credential.ts"; +import { layerWeb } from "./credential-cipher-web.ts"; vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const { KeyringModuleLoadError } = await import("./error.ts"); const actual = await importOriginal(); return { ...actual, getKeyringStore: () => EffectModule.fail( - new actual.KeyringModuleNotFoundError({ + new KeyringModuleLoadError({ + reason: "module-not-found", cause: new Error("keyring unavailable in test"), }), ), @@ -34,7 +32,7 @@ vi.mock("./keyring.ts", async (importOriginal) => { function makeCredentialLayer(homeDir: string) { return Layer.mergeAll( NodeServices.layer, - T3CredentialCipherWebLive, + layerWeb, Layer.succeed(Environment)({ cwd: homeDir, homeDir, @@ -68,9 +66,7 @@ describe("keyring fallback", () => { yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { mode: 0o600, }); - const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide(makeCredentialLayer(homeDir)), - ); + const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); const encrypted = yield* credentialCrypto.encrypt({ environmentName: "home", url: "https://home.example", @@ -107,9 +103,7 @@ describe("keyring fallback", () => { yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { mode: 0o644, }); - const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide(makeCredentialLayer(homeDir)), - ); + const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); yield* credentialCrypto.encrypt({ environmentName: "home", url: "https://home.example", @@ -135,9 +129,7 @@ describe("keyring fallback", () => { Effect.flatMap(({ fs, path, homeDir }) => Effect.gen(function* () { const keyPath = path.join(homeDir, ".config", "t3cli", "key"); - const credentialCrypto = yield* makeT3CredentialCrypto().pipe( - Effect.provide(makeCredentialLayer(homeDir)), - ); + const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); yield* credentialCrypto.encrypt({ environmentName: "home", url: "https://home.example", diff --git a/src/config/credential.ts b/src/config/credential.ts index a57ce2b..51d2703 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -1,3 +1,4 @@ +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -6,21 +7,43 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; -import { - T3CredentialCipher, - credentialCipherNonceByteLength, -} from "./credential-cipher-service.ts"; import { Environment } from "../environment/service.ts"; +import { T3CredentialCipher, credentialCipherNonceByteLength } from "./credential-cipher.ts"; +import type { EncryptedToken } from "./schema.ts"; import { - T3CredentialCrypto, - type CredentialDecryptInput, - type CredentialEncryptInput, -} from "./credential-service.ts"; -import { ConfigError, isPlatformNotFoundError } from "./error.ts"; + ConfigError, + KeyringOperationError, + describeCredentialCipherError, + describeKeyringModuleLoadError, + describeKeyringOperationError, + isPlatformNotFoundError, +} from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; -import { getKeyringStore, KeyringOperationError, keyringErrorMessage } from "./keyring.ts"; +import { getKeyringStore } from "./keyring.ts"; import { resolveKeyFilePath } from "./paths.ts"; +export type CredentialEncryptInput = { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; + readonly token: string; +}; + +export type CredentialDecryptInput = { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; + readonly token: EncryptedToken; +}; + +export class T3CredentialCrypto extends Context.Service< + T3CredentialCrypto, + { + readonly encrypt: (input: CredentialEncryptInput) => Effect.Effect; + readonly decrypt: (input: CredentialDecryptInput) => Effect.Effect; + } +>()("t3cli/T3CredentialCrypto") {} + const configSchemaVersion = 2; const masterKeyByteLength = 32; const keyringService = "t3cli"; @@ -42,16 +65,20 @@ export function shouldFallbackToKeyFile(result: KeyringReadResult) { return result.kind === "missing" || result.kind === "unavailable"; } -export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(function* () { +export const make = Effect.fn("makeT3CredentialCrypto")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* Environment; const cryptoService = yield* Crypto.Crypto; const cipher = yield* T3CredentialCipher; - const keyFilePath = yield* resolveKeyFilePath().pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(Environment, environment), + const services = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, path), + Layer.succeed(Environment, environment), + Layer.succeed(Crypto.Crypto, cryptoService), ); + const run = (effect: Effect.Effect) => Effect.provide(effect, services); + const keyFilePath = yield* run(resolveKeyFilePath()); const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { const raw = yield* fs.readFileString(filePath).pipe( @@ -109,9 +136,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi ), }), ); - yield* hardenPrivateFileMode(filePath, "credential key").pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); + yield* run(hardenPrivateFileMode(filePath, "credential key")); }); const getMasterKey = Effect.fn("T3CredentialCryptoLive.getMasterKey")(function* () { @@ -129,15 +154,11 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi if (shouldFallbackToKeyFile(keyringResult)) { const fileKey = yield* readKeyFileMasterKey(keyFilePath); if (fileKey !== undefined) { - yield* hardenPrivateFileMode(keyFilePath, "credential key").pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); + yield* run(hardenPrivateFileMode(keyFilePath, "credential key")); return fileKey; } } - const generated = yield* secureRandomBytes(masterKeyByteLength).pipe( - Effect.provideService(Crypto.Crypto, cryptoService), - ); + const generated = yield* run(secureRandomBytes(masterKeyByteLength)); const writeResult = yield* writeKeyringMasterKey(generated); if (writeResult.kind === "stored") { return generated; @@ -150,9 +171,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi input: CredentialEncryptInput, ) { const masterKey = yield* getMasterKey(); - const nonce = yield* secureRandomBytes(credentialCipherNonceByteLength).pipe( - Effect.provideService(Crypto.Crypto, cryptoService), - ); + const nonce = yield* run(secureRandomBytes(credentialCipherNonceByteLength)); const encrypted = yield* cipher .encrypt({ key: masterKey, @@ -165,8 +184,8 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi CredentialCipherError: (error) => Effect.fail( new ConfigError({ - message: "failed to encrypt credential token", - cause: error.cause, + message: describeCredentialCipherError(error), + cause: error, }), ), }), @@ -201,8 +220,8 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi CredentialCipherError: (error) => Effect.fail( new ConfigError({ - message: "failed to decrypt credential token", - cause: error.cause, + message: describeCredentialCipherError(error), + cause: error, }), ), }), @@ -213,7 +232,7 @@ export const makeT3CredentialCrypto = Effect.fn("makeT3CredentialCrypto")(functi return { encrypt, decrypt }; }); -export const T3CredentialCryptoLive = Layer.effect(T3CredentialCrypto, makeT3CredentialCrypto()); +export const layer = Layer.effect(T3CredentialCrypto, make()); function buildCredentialAad(input: { readonly environmentName: string; @@ -269,13 +288,13 @@ function readKeyringMasterKey(): Effect.Effect { const store = yield* getKeyringStore(); return yield* Effect.try({ try: () => parseKeyringPassword(store.readPassword(keyringService, keyringAccount)), - catch: (cause) => new KeyringOperationError({ cause }), + catch: (cause) => new KeyringOperationError({ operation: "read-password", cause }), }).pipe( Effect.catchTags({ KeyringOperationError: (error) => Effect.succeed({ kind: "unavailable", - message: keyringErrorMessage(error.cause), + message: describeKeyringOperationError(error), } satisfies KeyringReadResult), }), ); @@ -284,12 +303,7 @@ function readKeyringMasterKey(): Effect.Effect { KeyringModuleLoadError: (error) => Effect.succeed({ kind: "unavailable", - message: keyringErrorMessage(error.cause), - } satisfies KeyringReadResult), - KeyringModuleNotFoundError: (error) => - Effect.succeed({ - kind: "unavailable", - message: keyringErrorMessage(error.cause), + message: describeKeyringModuleLoadError(error), } satisfies KeyringReadResult), }), ); @@ -324,13 +338,13 @@ function writeKeyringMasterKey(key: Uint8Array): Effect.Effect new KeyringOperationError({ cause }), + catch: (cause) => new KeyringOperationError({ operation: "write-password", cause }), }).pipe( Effect.catchTags({ KeyringOperationError: (error) => Effect.succeed({ kind: "unavailable", - message: keyringErrorMessage(error.cause), + message: describeKeyringOperationError(error), } satisfies KeyringWriteResult), }), ); @@ -339,12 +353,7 @@ function writeKeyringMasterKey(key: Uint8Array): Effect.Effect Effect.succeed({ kind: "unavailable", - message: keyringErrorMessage(error.cause), - } satisfies KeyringWriteResult), - KeyringModuleNotFoundError: (error) => - Effect.succeed({ - kind: "unavailable", - message: keyringErrorMessage(error.cause), + message: describeKeyringModuleLoadError(error), } satisfies KeyringWriteResult), }), ); diff --git a/src/config/error.ts b/src/config/error.ts index 966d4e3..08b2241 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -22,12 +22,57 @@ export class ConfigError extends Schema.TaggedErrorClass()("ConfigE export class CredentialCipherError extends Schema.TaggedErrorClass()( "CredentialCipherError", { + operation: Schema.Literals(["encrypt", "decrypt"]), + cause: Schema.Defect(), + }, +) {} + +export class KeyringModuleLoadError extends Schema.TaggedErrorClass()( + "KeyringModuleLoadError", + { + reason: Schema.Literals(["module-not-found", "load-failed"]), + cause: Schema.Defect(), + }, +) {} + +export class KeyringOperationError extends Schema.TaggedErrorClass()( + "KeyringOperationError", + { + operation: Schema.Literals(["read-password", "write-password"]), cause: Schema.Defect(), }, ) {} export type ConfigServiceError = ConfigError | UrlError; +export function describeCredentialCipherError(error: CredentialCipherError) { + return error.operation === "encrypt" + ? "failed to encrypt credential cipher payload" + : "failed to decrypt credential cipher payload"; +} + +export function describeKeyringModuleLoadError(error: KeyringModuleLoadError) { + return error.reason === "module-not-found" + ? "OS keyring backend is not available" + : "failed to load OS keyring backend"; +} + +export function describeKeyringOperationError(error: KeyringOperationError) { + return error.operation === "read-password" + ? "failed to read credential key from OS keyring" + : "failed to write credential key to OS keyring"; +} + +export function configErrorFromUrl(error: UrlError) { + return new ConfigError({ + message: + error.protocol !== undefined + ? `unsupported server url protocol: ${error.protocol}` + : "invalid url", + cause: error.cause, + }); +} + export function isPlatformNotFoundError(error: PlatformError) { return error.reason["_tag"] === "NotFound"; } diff --git a/src/config/index.ts b/src/config/index.ts index fdb1428..ab5a323 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,16 +6,19 @@ export type { ResolvedConfig, UpsertEnvironmentInput, } from "./service.ts"; -export { T3ConfigLive, makeT3Config } from "./layer.ts"; -export { T3CredentialCrypto } from "./credential-service.ts"; -export type { CredentialCrypto } from "./credential-service.ts"; -export { T3CredentialCipher } from "./credential-cipher-service.ts"; -export type { CredentialCipher } from "./credential-cipher-service.ts"; -export { T3CredentialCipherNodeLive } from "./credential-cipher-node.ts"; -export { T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; -export { T3CredentialCryptoLive } from "./credential.ts"; +export * as Config from "./layer.ts"; +export * as CredentialCipher from "./credential-cipher.ts"; +export * as CredentialCipherNode from "./credential-cipher-node.ts"; +export * as CredentialCipherWeb from "./credential-cipher-web.ts"; +export * as CredentialCrypto from "./credential.ts"; export { T3ConfigSelection } from "./selection.ts"; export { T3ConfigSelectionLive } from "./selection-layer.ts"; export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; -export { ConfigError, UrlError } from "./error.ts"; +export { + ConfigError, + UrlError, + CredentialCipherError, + KeyringModuleLoadError, + KeyringOperationError, +} from "./error.ts"; export type { ConfigServiceError } from "./error.ts"; diff --git a/src/config/keyring.ts b/src/config/keyring.ts index 3027b06..4e49761 100644 --- a/src/config/keyring.ts +++ b/src/config/keyring.ts @@ -1,51 +1,28 @@ import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; -type KeyringModule = typeof import("@napi-rs/keyring"); - -export class KeyringModuleLoadError extends Schema.TaggedErrorClass()( - "KeyringModuleLoadError", - { - cause: Schema.Defect(), - }, -) {} - -export class KeyringModuleNotFoundError extends Schema.TaggedErrorClass()( - "KeyringModuleNotFoundError", - { - cause: Schema.Defect(), - }, -) {} +import { KeyringModuleLoadError } from "./error.ts"; -export class KeyringOperationError extends Schema.TaggedErrorClass()( - "KeyringOperationError", - { - cause: Schema.Defect(), - }, -) {} +type KeyringModule = typeof import("@napi-rs/keyring"); export type KeyringStore = { readonly readPassword: (service: string, account: string) => string | null; readonly writePassword: (service: string, account: string, password: string) => void; }; -export function keyringErrorMessage(cause: unknown): string { - if (cause instanceof Error) { - return cause.message; - } - if (Predicate.isString(cause)) { - return cause; - } - return "keyring operation failed"; +function isModuleNotFound(cause: unknown) { + return ( + Predicate.hasProperty(cause, "code") && + Predicate.isString(cause.code) && + (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") + ); } function classifyKeyringModuleLoadFailure(cause: unknown) { - return Predicate.hasProperty(cause, "code") && - Predicate.isString(cause.code) && - (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") - ? new KeyringModuleNotFoundError({ cause }) - : new KeyringModuleLoadError({ cause }); + return new KeyringModuleLoadError({ + reason: isModuleNotFound(cause) ? "module-not-found" : "load-failed", + cause, + }); } function createKeyringStore(keyring: KeyringModule): KeyringStore { @@ -66,10 +43,7 @@ const loadKeyringModuleOnce = Effect.tryPromise({ const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); -export function getKeyringStore(): Effect.Effect< - KeyringStore, - KeyringModuleLoadError | KeyringModuleNotFoundError -> { +export function getKeyringStore(): Effect.Effect { return Effect.gen(function* () { const load = yield* loadKeyringModuleMemoized; const module = yield* load; diff --git a/src/config/layer.ts b/src/config/layer.ts index 4beaf89..a0f2af2 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -5,9 +5,9 @@ import * as Path from "effect/Path"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; -import { T3CredentialCrypto } from "./credential-service.ts"; +import { T3CredentialCrypto } from "./credential.ts"; +import { ConfigError, configErrorFromUrl } from "./error.ts"; import { validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError, UrlError } from "./error.ts"; import { readEncryptedConfigFile, writeEncryptedConfigFile } from "./persist.ts"; import { buildResolvedConfigFromEnv, @@ -21,23 +21,22 @@ import { T3ConfigSelection } from "./selection.ts"; import { T3Config, type UpsertEnvironmentInput } from "./service.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; -export const makeT3Config = Effect.fn("makeT3Config")(function* () { +export const make = Effect.fn("makeT3Config")(function* () { const environment = yield* Environment; const configSelection = yield* T3ConfigSelection; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const credentialCrypto = yield* T3CredentialCrypto; - - const withConfigServices = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Environment, environment), - Effect.provideService(T3CredentialCrypto, credentialCrypto), - ); + const services = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, path), + Layer.succeed(Environment, environment), + Layer.succeed(T3CredentialCrypto, credentialCrypto), + ); + const run = (effect: Effect.Effect) => Effect.provide(effect, services); const readEncrypted = Effect.fn("T3ConfigLive.readEncrypted")(function* () { - return yield* withConfigServices(readEncryptedConfigFile()); + return yield* run(readEncryptedConfigFile()); }); const hasEnvironment = Effect.fn("T3ConfigLive.hasEnvironment")(function* (name: string) { @@ -89,10 +88,10 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { ) { yield* validateEnvironmentName(input.name); const normalizedUrl = yield* normalizeHttpBaseUrl(input.url).pipe( - Effect.mapError(mapUrlToConfigError), + Effect.mapError(configErrorFromUrl), ); const encrypted = yield* readEncrypted(); - const token = yield* withConfigServices( + const token = yield* run( encryptEnvironment({ environmentName: input.name, url: normalizedUrl, @@ -101,7 +100,7 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { }), ); const defaultName = resolveDefaultForUpsert(encrypted, input.name, input.makeDefault); - yield* withConfigServices( + yield* run( writeEncryptedConfigFile({ version: 2, ...(defaultName !== undefined ? { default: defaultName } : {}), @@ -125,7 +124,7 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { if (encrypted.environments[name] === undefined) { return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } - yield* withConfigServices( + yield* run( writeEncryptedConfigFile({ ...encrypted, default: name, @@ -142,7 +141,7 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { } const { [name]: _removed, ...environments } = encrypted.environments; const defaultName = encrypted.default === name ? undefined : encrypted.default; - yield* withConfigServices( + yield* run( writeEncryptedConfigFile({ version: 2, ...(defaultName !== undefined ? { default: defaultName } : {}), @@ -194,7 +193,7 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { } const selectedEnvironment = encrypted.environments[selectedName]; - const token = yield* withConfigServices( + const token = yield* run( decryptEnvironment({ environmentName: selectedName, url: selectedEnvironment.url, @@ -221,8 +220,4 @@ export const makeT3Config = Effect.fn("makeT3Config")(function* () { } as const; }); -export const T3ConfigLive = Layer.effect(T3Config, makeT3Config()); - -function mapUrlToConfigError(error: UrlError) { - return new ConfigError({ message: error.message }); -} +export const layer = Layer.effect(T3Config, make()); diff --git a/src/config/migration.ts b/src/config/migration.ts index a17266f..d6512f9 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -4,7 +4,7 @@ import * as Schema from "effect/Schema"; import { encryptEnvironment } from "./codec.ts"; import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError } from "./error.ts"; +import { ConfigError, configErrorFromUrl } from "./error.ts"; import { StoredConfigV1FileSchema, StoredConfigV2FileSchema, @@ -33,8 +33,7 @@ export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromVa return { config: migrated, migratedFromV1: true as const }; }).pipe( Effect.catchTags({ - UrlError: (error) => - Effect.fail(new ConfigError({ message: `failed to read config: ${error.message}` })), + UrlError: (error) => Effect.fail(configErrorFromUrl(error)), SchemaError: (error) => Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), }), diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 08a5470..94b3dc0 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -10,7 +10,7 @@ import { Command } from "effect/unstable/cli"; import { vi } from "vite-plus/test"; import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; -import { T3CredentialCipherNodeLive } from "../config/credential-cipher-node.ts"; +import { layerNode } from "../config/credential-cipher-node.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; import { Environment } from "../environment/service.ts"; import type { ResolvedConfig } from "../config/types.ts"; @@ -19,12 +19,14 @@ import { BaseAppLayer } from "./layer.ts"; vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); + const { KeyringModuleLoadError } = await import("../config/error.ts"); const actual = await importOriginal(); return { ...actual, getKeyringStore: () => EffectModule.fail( - new actual.KeyringModuleNotFoundError({ + new KeyringModuleLoadError({ + reason: "module-not-found", cause: new Error("keyring unavailable in test"), }), ), @@ -41,7 +43,7 @@ function makeCliAppLayer(homeDir: string) { }); return BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(T3CredentialCipherNodeLive), + Layer.provide(layerNode), Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), ); } diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index 3365710..874b014 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -10,8 +10,8 @@ import { T3LocalAuthOriginLive } from "../auth/local-origin.ts"; import { T3LocalAuthTokenLive } from "../auth/local-token.ts"; import { T3AuthPairingLive } from "../auth/pairing.ts"; import { T3AuthTransportLive } from "../auth/transport.ts"; -import { T3ConfigLive } from "../config/layer.ts"; -import { T3CredentialCryptoLive } from "../config/credential.ts"; +import * as Config from "../config/layer.ts"; +import * as CredentialCrypto from "../config/credential.ts"; import { T3ConfigSelectionLive } from "../config/selection-layer.ts"; import { T3Config } from "../config/service.ts"; import { T3CodeConnectionError } from "../connection/error.ts"; @@ -22,7 +22,7 @@ import { T3RpcOperationsLive } from "../rpc/operation.ts"; import { NodeSqlClientFactoryLive } from "../sql/node-sqlite-client.ts"; import { NodeCliPathLayer } from "../cli-path/layer.ts"; -export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLive)); +export const T3ConfigLayer = Config.layer.pipe(Layer.provide(CredentialCrypto.layer)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); From e06d2caa7669754e3c039602f0b9a1d0a21d320a Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:24:16 +0300 Subject: [PATCH 11/21] Simplify config exports and keyring error handling. Use direct package exports instead of namespace re-exports, return undefined when the keyring is unavailable, and handle keyring IO with plain try/catch instead of throw-then-catch tagged errors. Co-authored-by: Cursor --- src/auth/auth.test.ts | 21 +++---- src/bin.ts | 4 +- src/config/config.test.ts | 27 ++++----- src/config/credential.test.ts | 13 +--- src/config/credential.ts | 111 +++++++++++----------------------- src/config/error.ts | 34 ----------- src/config/index.ts | 22 +++---- src/config/keyring.ts | 30 +++------ src/runtime/layer.test.ts | 13 +--- src/runtime/layer.ts | 6 +- 10 files changed, 83 insertions(+), 198 deletions(-) diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 48ca7b1..506bd92 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -15,26 +15,19 @@ import { T3AuthPairing } from "./pairing.ts"; import { T3AuthLive } from "./layer.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; -import * as Config from "../config/layer.ts"; -import * as CredentialCrypto from "../config/credential.ts"; -import { layerWeb } from "../config/credential-cipher-web.ts"; +import { layer as T3ConfigLive } from "../config/layer.ts"; +import { layer as T3CredentialCryptoLive } from "../config/credential.ts"; +import { layerWeb as T3CredentialCipherWebLive } from "../config/credential-cipher-web.ts"; import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); - const { KeyringModuleLoadError } = await import("../config/error.ts"); const actual = await importOriginal(); return { ...actual, - getKeyringStore: () => - EffectModule.fail( - new KeyringModuleLoadError({ - reason: "module-not-found", - cause: new Error("keyring unavailable in test"), - }), - ), + getKeyringStore: () => EffectModule.succeed(undefined), }; }); @@ -47,12 +40,12 @@ function makeAuthLayer(homeDir: string) { stderrIsTTY: false, }); const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); - const configLayer = Config.layer.pipe( - Layer.provide(CredentialCrypto.layer), + const configLayer = T3ConfigLive.pipe( + Layer.provide(T3CredentialCryptoLive), Layer.provide( Layer.mergeAll( platformLayer, - layerWeb, + T3CredentialCipherWebLive, Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(undefined), }), diff --git a/src/bin.ts b/src/bin.ts index 587b44f..059291b 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -7,7 +7,7 @@ import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; -import { layerNode } from "./config/credential-cipher-node.ts"; +import { layerNode as T3CredentialCipherNodeLive } from "./config/credential-cipher-node.ts"; import { NodeEnvironmentLive } from "./environment/layer.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; @@ -26,7 +26,7 @@ const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); const CliAppLayer = BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(layerNode), + Layer.provide(T3CredentialCipherNodeLive), ); const CliLayer = Layer.mergeAll( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index baf4f67..098775d 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -12,9 +12,9 @@ import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; -import * as Config from "./layer.ts"; -import * as CredentialCrypto from "./credential.ts"; -import { layerWeb } from "./credential-cipher-web.ts"; +import { layer as T3ConfigLive } from "./layer.ts"; +import { layer as T3CredentialCryptoLive } from "./credential.ts"; +import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; import { T3ConfigSelection } from "./selection.ts"; @@ -23,17 +23,10 @@ import { T3Config } from "./service.ts"; vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); - const { KeyringModuleLoadError } = await import("./error.ts"); const actual = await importOriginal(); return { ...actual, - getKeyringStore: () => - EffectModule.fail( - new KeyringModuleLoadError({ - reason: "module-not-found", - cause: new Error("keyring unavailable in test"), - }), - ), + getKeyringStore: () => EffectModule.succeed(undefined), }; }); @@ -48,8 +41,10 @@ function makeEnvironmentLayer(homeDir: string, env: Record = {}) } function makeCredentialCryptoLayer(homeDir: string) { - return CredentialCrypto.layer.pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, layerWeb, makeEnvironmentLayer(homeDir))), + return T3CredentialCryptoLive.pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, T3CredentialCipherWebLive, makeEnvironmentLayer(homeDir)), + ), ); } @@ -69,9 +64,9 @@ function makeConfigLayer( : Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(input.selection), }); - return Config.layer.pipe( - Layer.provide(CredentialCrypto.layer), - Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, layerWeb)), + return T3ConfigLive.pipe( + Layer.provide(T3CredentialCryptoLive), + Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, T3CredentialCipherWebLive)), ); } diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 13d2602..a149ace 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -11,28 +11,21 @@ import { expect, vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { make, parseKeyringPassword, shouldFallbackToKeyFile } from "./credential.ts"; -import { layerWeb } from "./credential-cipher-web.ts"; +import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; vi.mock("./keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); - const { KeyringModuleLoadError } = await import("./error.ts"); const actual = await importOriginal(); return { ...actual, - getKeyringStore: () => - EffectModule.fail( - new KeyringModuleLoadError({ - reason: "module-not-found", - cause: new Error("keyring unavailable in test"), - }), - ), + getKeyringStore: () => EffectModule.succeed(undefined), }; }); function makeCredentialLayer(homeDir: string) { return Layer.mergeAll( NodeServices.layer, - layerWeb, + T3CredentialCipherWebLive, Layer.succeed(Environment)({ cwd: homeDir, homeDir, diff --git a/src/config/credential.ts b/src/config/credential.ts index 51d2703..482bb93 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -10,14 +10,7 @@ import * as Result from "effect/Result"; import { Environment } from "../environment/service.ts"; import { T3CredentialCipher, credentialCipherNonceByteLength } from "./credential-cipher.ts"; import type { EncryptedToken } from "./schema.ts"; -import { - ConfigError, - KeyringOperationError, - describeCredentialCipherError, - describeKeyringModuleLoadError, - describeKeyringOperationError, - isPlatformNotFoundError, -} from "./error.ts"; +import { ConfigError, CredentialCipherError, isPlatformNotFoundError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { getKeyringStore } from "./keyring.ts"; import { resolveKeyFilePath } from "./paths.ts"; @@ -179,17 +172,7 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { plaintext: encodeUtf8(input.token), additionalData: buildCredentialAad(input), }) - .pipe( - Effect.catchTags({ - CredentialCipherError: (error) => - Effect.fail( - new ConfigError({ - message: describeCredentialCipherError(error), - cause: error, - }), - ), - }), - ); + .pipe(Effect.mapError(mapCredentialCipherError)); return { kind: "encrypted" as const, alg: "aes-256-gcm" as const, @@ -215,17 +198,7 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { tag, additionalData: buildCredentialAad(input), }) - .pipe( - Effect.catchTags({ - CredentialCipherError: (error) => - Effect.fail( - new ConfigError({ - message: describeCredentialCipherError(error), - cause: error, - }), - ), - }), - ); + .pipe(Effect.mapError(mapCredentialCipherError)); return decodeUtf8(plaintext); }); @@ -286,27 +259,15 @@ function decodeBase64Field(value: string, field: string) { function readKeyringMasterKey(): Effect.Effect { return Effect.gen(function* () { const store = yield* getKeyringStore(); - return yield* Effect.try({ - try: () => parseKeyringPassword(store.readPassword(keyringService, keyringAccount)), - catch: (cause) => new KeyringOperationError({ operation: "read-password", cause }), - }).pipe( - Effect.catchTags({ - KeyringOperationError: (error) => - Effect.succeed({ - kind: "unavailable", - message: describeKeyringOperationError(error), - } satisfies KeyringReadResult), - }), - ); - }).pipe( - Effect.catchTags({ - KeyringModuleLoadError: (error) => - Effect.succeed({ - kind: "unavailable", - message: describeKeyringModuleLoadError(error), - } satisfies KeyringReadResult), - }), - ); + if (store === undefined) { + return keyringUnavailable("OS keyring backend is not available"); + } + try { + return parseKeyringPassword(store.readPassword(keyringService, keyringAccount)); + } catch { + return keyringUnavailable("failed to read credential key from OS keyring"); + } + }); } export function parseKeyringPassword(password: string | null): KeyringReadResult { @@ -333,28 +294,28 @@ export function parseKeyringPassword(password: string | null): KeyringReadResult function writeKeyringMasterKey(key: Uint8Array): Effect.Effect { return Effect.gen(function* () { const store = yield* getKeyringStore(); - return yield* Effect.try({ - try: () => { - store.writePassword(keyringService, keyringAccount, Encoding.encodeBase64(key)); - return { kind: "stored" } as const; - }, - catch: (cause) => new KeyringOperationError({ operation: "write-password", cause }), - }).pipe( - Effect.catchTags({ - KeyringOperationError: (error) => - Effect.succeed({ - kind: "unavailable", - message: describeKeyringOperationError(error), - } satisfies KeyringWriteResult), - }), - ); - }).pipe( - Effect.catchTags({ - KeyringModuleLoadError: (error) => - Effect.succeed({ - kind: "unavailable", - message: describeKeyringModuleLoadError(error), - } satisfies KeyringWriteResult), - }), - ); + if (store === undefined) { + return keyringUnavailable("OS keyring backend is not available"); + } + try { + store.writePassword(keyringService, keyringAccount, Encoding.encodeBase64(key)); + return { kind: "stored" as const }; + } catch { + return keyringUnavailable("failed to write credential key to OS keyring"); + } + }); +} + +function keyringUnavailable(message: string) { + return { kind: "unavailable" as const, message }; +} + +function mapCredentialCipherError(error: CredentialCipherError) { + return new ConfigError({ + message: + error.operation === "encrypt" + ? "failed to encrypt credential cipher payload" + : "failed to decrypt credential cipher payload", + cause: error, + }); } diff --git a/src/config/error.ts b/src/config/error.ts index 08b2241..6edfbdb 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -27,42 +27,8 @@ export class CredentialCipherError extends Schema.TaggedErrorClass()( - "KeyringModuleLoadError", - { - reason: Schema.Literals(["module-not-found", "load-failed"]), - cause: Schema.Defect(), - }, -) {} - -export class KeyringOperationError extends Schema.TaggedErrorClass()( - "KeyringOperationError", - { - operation: Schema.Literals(["read-password", "write-password"]), - cause: Schema.Defect(), - }, -) {} - export type ConfigServiceError = ConfigError | UrlError; -export function describeCredentialCipherError(error: CredentialCipherError) { - return error.operation === "encrypt" - ? "failed to encrypt credential cipher payload" - : "failed to decrypt credential cipher payload"; -} - -export function describeKeyringModuleLoadError(error: KeyringModuleLoadError) { - return error.reason === "module-not-found" - ? "OS keyring backend is not available" - : "failed to load OS keyring backend"; -} - -export function describeKeyringOperationError(error: KeyringOperationError) { - return error.operation === "read-password" - ? "failed to read credential key from OS keyring" - : "failed to write credential key to OS keyring"; -} - export function configErrorFromUrl(error: UrlError) { return new ConfigError({ message: diff --git a/src/config/index.ts b/src/config/index.ts index ab5a323..454b761 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,19 +6,17 @@ export type { ResolvedConfig, UpsertEnvironmentInput, } from "./service.ts"; -export * as Config from "./layer.ts"; -export * as CredentialCipher from "./credential-cipher.ts"; -export * as CredentialCipherNode from "./credential-cipher-node.ts"; -export * as CredentialCipherWeb from "./credential-cipher-web.ts"; -export * as CredentialCrypto from "./credential.ts"; +export { layer as T3ConfigLive, make as makeT3Config } from "./layer.ts"; +export { + T3CredentialCrypto, + layer as T3CredentialCryptoLive, + make as makeT3CredentialCrypto, +} from "./credential.ts"; +export { T3CredentialCipher } from "./credential-cipher.ts"; +export { layerNode as T3CredentialCipherNodeLive } from "./credential-cipher-node.ts"; +export { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; export { T3ConfigSelection } from "./selection.ts"; export { T3ConfigSelectionLive } from "./selection-layer.ts"; export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; -export { - ConfigError, - UrlError, - CredentialCipherError, - KeyringModuleLoadError, - KeyringOperationError, -} from "./error.ts"; +export { ConfigError, UrlError } from "./error.ts"; export type { ConfigServiceError } from "./error.ts"; diff --git a/src/config/keyring.ts b/src/config/keyring.ts index 4e49761..b64b6d4 100644 --- a/src/config/keyring.ts +++ b/src/config/keyring.ts @@ -1,7 +1,5 @@ import * as Effect from "effect/Effect"; -import * as Predicate from "effect/Predicate"; - -import { KeyringModuleLoadError } from "./error.ts"; +import * as Option from "effect/Option"; type KeyringModule = typeof import("@napi-rs/keyring"); @@ -10,21 +8,6 @@ export type KeyringStore = { readonly writePassword: (service: string, account: string, password: string) => void; }; -function isModuleNotFound(cause: unknown) { - return ( - Predicate.hasProperty(cause, "code") && - Predicate.isString(cause.code) && - (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") - ); -} - -function classifyKeyringModuleLoadFailure(cause: unknown) { - return new KeyringModuleLoadError({ - reason: isModuleNotFound(cause) ? "module-not-found" : "load-failed", - cause, - }); -} - function createKeyringStore(keyring: KeyringModule): KeyringStore { return { readPassword(service, account) { @@ -38,15 +21,18 @@ function createKeyringStore(keyring: KeyringModule): KeyringStore { const loadKeyringModuleOnce = Effect.tryPromise({ try: () => import("@napi-rs/keyring") as Promise, - catch: classifyKeyringModuleLoadFailure, -}); + catch: (cause) => cause, +}).pipe(Effect.option); const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); -export function getKeyringStore(): Effect.Effect { +export function getKeyringStore(): Effect.Effect { return Effect.gen(function* () { const load = yield* loadKeyringModuleMemoized; const module = yield* load; - return createKeyringStore(module); + if (Option.isNone(module)) { + return undefined; + } + return createKeyringStore(module.value); }); } diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 94b3dc0..217752d 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -10,7 +10,7 @@ import { Command } from "effect/unstable/cli"; import { vi } from "vite-plus/test"; import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; -import { layerNode } from "../config/credential-cipher-node.ts"; +import { layerNode as T3CredentialCipherNodeLive } from "../config/credential-cipher-node.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; import { Environment } from "../environment/service.ts"; import type { ResolvedConfig } from "../config/types.ts"; @@ -19,17 +19,10 @@ import { BaseAppLayer } from "./layer.ts"; vi.mock("../config/keyring.ts", async (importOriginal) => { const EffectModule = await import("effect/Effect"); - const { KeyringModuleLoadError } = await import("../config/error.ts"); const actual = await importOriginal(); return { ...actual, - getKeyringStore: () => - EffectModule.fail( - new KeyringModuleLoadError({ - reason: "module-not-found", - cause: new Error("keyring unavailable in test"), - }), - ), + getKeyringStore: () => EffectModule.succeed(undefined), }; }); @@ -43,7 +36,7 @@ function makeCliAppLayer(homeDir: string) { }); return BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(layerNode), + Layer.provide(T3CredentialCipherNodeLive), Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), ); } diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index 874b014..f8787e9 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -10,8 +10,8 @@ import { T3LocalAuthOriginLive } from "../auth/local-origin.ts"; import { T3LocalAuthTokenLive } from "../auth/local-token.ts"; import { T3AuthPairingLive } from "../auth/pairing.ts"; import { T3AuthTransportLive } from "../auth/transport.ts"; -import * as Config from "../config/layer.ts"; -import * as CredentialCrypto from "../config/credential.ts"; +import { layer as T3ConfigLive } from "../config/layer.ts"; +import { layer as T3CredentialCryptoLive } from "../config/credential.ts"; import { T3ConfigSelectionLive } from "../config/selection-layer.ts"; import { T3Config } from "../config/service.ts"; import { T3CodeConnectionError } from "../connection/error.ts"; @@ -22,7 +22,7 @@ import { T3RpcOperationsLive } from "../rpc/operation.ts"; import { NodeSqlClientFactoryLive } from "../sql/node-sqlite-client.ts"; import { NodeCliPathLayer } from "../cli-path/layer.ts"; -export const T3ConfigLayer = Config.layer.pipe(Layer.provide(CredentialCrypto.layer)); +export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLive)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); From 2243bbdaf645c2bc1d92b7b2795b5b942f9e17a7 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:25:50 +0300 Subject: [PATCH 12/21] Export config types from types.ts directly. Drop the pointless re-export chain through service.ts. Co-authored-by: Cursor --- src/config/index.ts | 2 +- src/config/layer.ts | 3 ++- src/config/service.ts | 8 -------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 454b761..3154b99 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,7 +5,7 @@ export type { EnvironmentSummary, ResolvedConfig, UpsertEnvironmentInput, -} from "./service.ts"; +} from "./types.ts"; export { layer as T3ConfigLive, make as makeT3Config } from "./layer.ts"; export { T3CredentialCrypto, diff --git a/src/config/layer.ts b/src/config/layer.ts index a0f2af2..bc7976a 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -18,7 +18,8 @@ import { validateCredentialEnvVars, } from "./resolve.ts"; import { T3ConfigSelection } from "./selection.ts"; -import { T3Config, type UpsertEnvironmentInput } from "./service.ts"; +import { T3Config } from "./service.ts"; +import type { UpsertEnvironmentInput } from "./types.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; export const make = Effect.fn("makeT3Config")(function* () { diff --git a/src/config/service.ts b/src/config/service.ts index 062e465..b5c0c39 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -23,11 +23,3 @@ export class T3Config extends Context.Service< readonly getDefaultEnvironmentName: () => Effect.Effect; } >()("t3cli/T3Config") {} - -export type { - EncryptedConfig, - EncryptedToken, - EnvironmentSummary, - ResolvedConfig, - UpsertEnvironmentInput, -}; From 95e59ddd43e48296c8eed80b8c3ed2ad9230fe77 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:30:22 +0300 Subject: [PATCH 13/21] Add keystore factory service with keyring and file backends. Wire the Node keyring factory at the CLI entrypoint, fail on unexpected keyring errors, and fall back to the file keystore only when the factory reports KeystoreUnavailableError. Co-authored-by: Cursor --- src/auth/auth.test.ts | 12 +- src/bin.ts | 2 + src/config/config.test.ts | 27 ++-- src/config/credential.test.ts | 19 +-- src/config/credential.ts | 207 ++++++---------------------- src/config/error.ts | 8 ++ src/config/index.ts | 3 + src/config/keyring.ts | 38 ----- src/config/keystore-file.ts | 99 +++++++++++++ src/config/keystore-keyring-node.ts | 101 ++++++++++++++ src/config/keystore-test.ts | 15 ++ src/config/keystore.ts | 27 ++++ src/config/service.ts | 8 +- src/runtime/layer.test.ts | 12 +- 14 files changed, 325 insertions(+), 253 deletions(-) delete mode 100644 src/config/keyring.ts create mode 100644 src/config/keystore-file.ts create mode 100644 src/config/keystore-keyring-node.ts create mode 100644 src/config/keystore-test.ts create mode 100644 src/config/keystore.ts diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 506bd92..57a503b 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -8,7 +8,6 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; import { T3LocalAuth } from "./local.ts"; import { T3AuthPairing } from "./pairing.ts"; @@ -18,19 +17,11 @@ import { T3AuthTransport } from "./transport.ts"; import { layer as T3ConfigLive } from "../config/layer.ts"; import { layer as T3CredentialCryptoLive } from "../config/credential.ts"; import { layerWeb as T3CredentialCipherWebLive } from "../config/credential-cipher-web.ts"; +import { unavailableKeystoreFactoryLayer } from "../config/keystore-test.ts"; import { StoredConfigV2FileJson } from "../config/schema.ts"; import { Environment } from "../environment/service.ts"; import { T3ConfigSelection } from "../config/selection.ts"; -vi.mock("../config/keyring.ts", async (importOriginal) => { - const EffectModule = await import("effect/Effect"); - const actual = await importOriginal(); - return { - ...actual, - getKeyringStore: () => EffectModule.succeed(undefined), - }; -}); - function makeAuthLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ cwd: homeDir, @@ -46,6 +37,7 @@ function makeAuthLayer(homeDir: string) { Layer.mergeAll( platformLayer, T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, Layer.succeed(T3ConfigSelection)({ getSelectedEnvironment: () => Effect.succeed(undefined), }), diff --git a/src/bin.ts b/src/bin.ts index 059291b..434e07b 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,6 +8,7 @@ import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; import { layerNode as T3CredentialCipherNodeLive } from "./config/credential-cipher-node.ts"; +import { layerNode as T3MasterKeyKeystoreFactoryNodeLive } from "./config/keystore-keyring-node.ts"; import { NodeEnvironmentLive } from "./environment/layer.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; @@ -27,6 +28,7 @@ const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); const CliAppLayer = BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), Layer.provide(T3CredentialCipherNodeLive), + Layer.provide(T3MasterKeyKeystoreFactoryNodeLive), ); const CliLayer = Layer.mergeAll( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 098775d..17ba4a6 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -8,28 +8,19 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; import { layer as T3ConfigLive } from "./layer.ts"; import { layer as T3CredentialCryptoLive } from "./credential.ts"; import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; +import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; import { T3ConfigSelection } from "./selection.ts"; import { T3ConfigSelectionLive } from "./selection-layer.ts"; import { T3Config } from "./service.ts"; -vi.mock("./keyring.ts", async (importOriginal) => { - const EffectModule = await import("effect/Effect"); - const actual = await importOriginal(); - return { - ...actual, - getKeyringStore: () => EffectModule.succeed(undefined), - }; -}); - function makeEnvironmentLayer(homeDir: string, env: Record = {}) { return Layer.succeed(Environment)({ cwd: homeDir, @@ -43,7 +34,12 @@ function makeEnvironmentLayer(homeDir: string, env: Record = {}) function makeCredentialCryptoLayer(homeDir: string) { return T3CredentialCryptoLive.pipe( Layer.provide( - Layer.mergeAll(NodeServices.layer, T3CredentialCipherWebLive, makeEnvironmentLayer(homeDir)), + Layer.mergeAll( + NodeServices.layer, + T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, + makeEnvironmentLayer(homeDir), + ), ), ); } @@ -66,7 +62,14 @@ function makeConfigLayer( }); return T3ConfigLive.pipe( Layer.provide(T3CredentialCryptoLive), - Layer.provide(Layer.mergeAll(platformLayer, selectionLayer, T3CredentialCipherWebLive)), + Layer.provide( + Layer.mergeAll( + platformLayer, + selectionLayer, + T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, + ), + ), ); } diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index a149ace..0310bfc 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -7,25 +7,20 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { expect, vi } from "vite-plus/test"; +import { expect } from "vite-plus/test"; import { Environment } from "../environment/service.ts"; -import { make, parseKeyringPassword, shouldFallbackToKeyFile } from "./credential.ts"; +import { make } from "./credential.ts"; import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; - -vi.mock("./keyring.ts", async (importOriginal) => { - const EffectModule = await import("effect/Effect"); - const actual = await importOriginal(); - return { - ...actual, - getKeyringStore: () => EffectModule.succeed(undefined), - }; -}); +import { parseKeyringPassword } from "./keystore-keyring-node.ts"; +import { shouldUseFileKeystoreForRead } from "./keystore.ts"; +import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; function makeCredentialLayer(homeDir: string) { return Layer.mergeAll( NodeServices.layer, T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, Layer.succeed(Environment)({ cwd: homeDir, homeDir, @@ -40,7 +35,7 @@ describe("keyring fallback", () => { it("treats invalid stored keyring values as corrupt", () => { const result = parseKeyringPassword("not-a-valid-key"); assert.equal(result.kind, "corrupt"); - expect(shouldFallbackToKeyFile(result)).toBe(false); + expect(shouldUseFileKeystoreForRead(result)).toBe(false); }); it.effect("falls back to key file when keyring backend is unavailable", () => diff --git a/src/config/credential.ts b/src/config/credential.ts index 482bb93..b0611dc 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -5,15 +5,13 @@ import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Result from "effect/Result"; import { Environment } from "../environment/service.ts"; import { T3CredentialCipher, credentialCipherNonceByteLength } from "./credential-cipher.ts"; import type { EncryptedToken } from "./schema.ts"; -import { ConfigError, CredentialCipherError, isPlatformNotFoundError } from "./error.ts"; -import { hardenPrivateFileMode } from "./file-mode.ts"; -import { getKeyringStore } from "./keyring.ts"; -import { resolveKeyFilePath } from "./paths.ts"; +import { ConfigError, CredentialCipherError } from "./error.ts"; +import { makeFileKeystore } from "./keystore-file.ts"; +import { T3MasterKeyKeystoreFactory, masterKeyByteLength } from "./keystore.ts"; export type CredentialEncryptInput = { readonly environmentName: string; @@ -38,32 +36,16 @@ export class T3CredentialCrypto extends Context.Service< >()("t3cli/T3CredentialCrypto") {} const configSchemaVersion = 2; -const masterKeyByteLength = 32; -const keyringService = "t3cli"; -const keyringAccount = "master-key"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -export type KeyringReadResult = - | { readonly kind: "missing" } - | { readonly kind: "present"; readonly key: Uint8Array } - | { readonly kind: "corrupt"; readonly message: string } - | { readonly kind: "unavailable"; readonly message: string }; - -type KeyringWriteResult = - | { readonly kind: "stored" } - | { readonly kind: "unavailable"; readonly message: string }; - -export function shouldFallbackToKeyFile(result: KeyringReadResult) { - return result.kind === "missing" || result.kind === "unavailable"; -} - export const make = Effect.fn("makeT3CredentialCrypto")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* Environment; const cryptoService = yield* Crypto.Crypto; const cipher = yield* T3CredentialCipher; + const keystoreFactory = yield* T3MasterKeyKeystoreFactory; const services = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path), @@ -71,98 +53,55 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { Layer.succeed(Crypto.Crypto, cryptoService), ); const run = (effect: Effect.Effect) => Effect.provide(effect, services); - const keyFilePath = yield* run(resolveKeyFilePath()); - const readKeyFileMasterKey = Effect.fn("readKeyFileMasterKey")(function* (filePath: string) { - const raw = yield* fs.readFileString(filePath).pipe( - Effect.catchTags({ - PlatformError: (error) => - isPlatformNotFoundError(error) - ? Effect.succeed(undefined) - : Effect.fail( - new ConfigError({ - message: "failed to read credential key file", - cause: error, - }), - ), - }), - ); - if (raw === undefined) { - return undefined; + const fileKeystore = yield* run(makeFileKeystore()); + const primaryKeystore = yield* keystoreFactory.make().pipe( + Effect.catchTags({ + KeystoreUnavailableError: () => Effect.succeed(undefined), + }), + ); + + const readMasterKey = Effect.fn("readMasterKey")(function* () { + if (primaryKeystore !== undefined) { + const primaryResult = yield* primaryKeystore.read(); + if (primaryResult.kind === "present") { + return primaryResult.key; + } + if (primaryResult.kind === "corrupt") { + return yield* Effect.fail( + new ConfigError({ + message: `corrupt credential key in OS keyring: ${primaryResult.message}`, + }), + ); + } } - const key = yield* decodeBase64Bytes(raw.trim()).pipe( - Effect.catchTags({ - EncodingError: (error) => - Effect.fail( - new ConfigError({ - message: "invalid credential key file: invalid base64", - cause: error, - }), - ), - }), - ); - if (key.byteLength !== masterKeyByteLength) { - return yield* Effect.fail( - new ConfigError({ message: "invalid credential key file: unexpected key length" }), - ); + + const fileResult = yield* fileKeystore.read(); + if (fileResult.kind === "present") { + return fileResult.key; } - return key; + return undefined; }); - const writeKeyFileMasterKey = Effect.fn("writeKeyFileMasterKey")(function* ( - filePath: string, - key: Uint8Array, - ) { - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true, mode: 0o700 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail( - new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - }), - ); - yield* fs.writeFileString(filePath, `${Encoding.encodeBase64(key)}\n`, { mode: 0o600 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail( - new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - }), - ); - yield* run(hardenPrivateFileMode(filePath, "credential key")); + const writeMasterKey = Effect.fn("writeMasterKey")(function* (key: Uint8Array) { + if (primaryKeystore !== undefined) { + yield* primaryKeystore.write(key); + return; + } + yield* fileKeystore.write(key); }); - const getMasterKey = Effect.fn("T3CredentialCryptoLive.getMasterKey")(function* () { - const keyringResult = yield* readKeyringMasterKey(); - if (keyringResult.kind === "present") { - return keyringResult.key; - } - if (keyringResult.kind === "corrupt") { - return yield* Effect.fail( - new ConfigError({ - message: `corrupt credential key in OS keyring: ${keyringResult.message}`, - }), - ); - } - if (shouldFallbackToKeyFile(keyringResult)) { - const fileKey = yield* readKeyFileMasterKey(keyFilePath); - if (fileKey !== undefined) { - yield* run(hardenPrivateFileMode(keyFilePath, "credential key")); - return fileKey; - } + const getMasterKey = Effect.fn("getMasterKey")(function* () { + const existing = yield* readMasterKey(); + if (existing !== undefined) { + return existing; } const generated = yield* run(secureRandomBytes(masterKeyByteLength)); - const writeResult = yield* writeKeyringMasterKey(generated); - if (writeResult.kind === "stored") { - return generated; - } - yield* writeKeyFileMasterKey(keyFilePath, generated); + yield* writeMasterKey(generated); return generated; }); - const encrypt = Effect.fn("T3CredentialCryptoLive.encrypt")(function* ( - input: CredentialEncryptInput, - ) { + const encrypt = Effect.fn("encrypt")(function* (input: CredentialEncryptInput) { const masterKey = yield* getMasterKey(); const nonce = yield* run(secureRandomBytes(credentialCipherNonceByteLength)); const encrypted = yield* cipher @@ -183,9 +122,7 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { }; }); - const decrypt = Effect.fn("T3CredentialCryptoLive.decrypt")(function* ( - input: CredentialDecryptInput, - ) { + const decrypt = Effect.fn("decrypt")(function* (input: CredentialDecryptInput) { const masterKey = yield* getMasterKey(); const nonce = yield* decodeBase64Field(input.token.nonce, "token nonce"); const ciphertext = yield* decodeBase64Field(input.token.ciphertext, "token ciphertext"); @@ -238,12 +175,8 @@ const secureRandomBytes = Effect.fn("secureRandomBytes")(function* (size: number ); }); -function decodeBase64Bytes(value: string) { - return Effect.fromResult(Encoding.decodeBase64(value)); -} - function decodeBase64Field(value: string, field: string) { - return decodeBase64Bytes(value).pipe( + return Effect.fromResult(Encoding.decodeBase64(value)).pipe( Effect.catchTags({ EncodingError: (error) => Effect.fail( @@ -256,60 +189,6 @@ function decodeBase64Field(value: string, field: string) { ); } -function readKeyringMasterKey(): Effect.Effect { - return Effect.gen(function* () { - const store = yield* getKeyringStore(); - if (store === undefined) { - return keyringUnavailable("OS keyring backend is not available"); - } - try { - return parseKeyringPassword(store.readPassword(keyringService, keyringAccount)); - } catch { - return keyringUnavailable("failed to read credential key from OS keyring"); - } - }); -} - -export function parseKeyringPassword(password: string | null): KeyringReadResult { - if (password === null || password.length === 0) { - return { kind: "missing" }; - } - return Result.match(Encoding.decodeBase64(password.trim()), { - onFailure: () => ({ - kind: "corrupt" as const, - message: "invalid base64 key", - }), - onSuccess: (key) => { - if (key.byteLength !== masterKeyByteLength) { - return { - kind: "corrupt" as const, - message: "unexpected key length", - }; - } - return { kind: "present" as const, key }; - }, - }); -} - -function writeKeyringMasterKey(key: Uint8Array): Effect.Effect { - return Effect.gen(function* () { - const store = yield* getKeyringStore(); - if (store === undefined) { - return keyringUnavailable("OS keyring backend is not available"); - } - try { - store.writePassword(keyringService, keyringAccount, Encoding.encodeBase64(key)); - return { kind: "stored" as const }; - } catch { - return keyringUnavailable("failed to write credential key to OS keyring"); - } - }); -} - -function keyringUnavailable(message: string) { - return { kind: "unavailable" as const, message }; -} - function mapCredentialCipherError(error: CredentialCipherError) { return new ConfigError({ message: diff --git a/src/config/error.ts b/src/config/error.ts index 6edfbdb..a081e79 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -27,6 +27,14 @@ export class CredentialCipherError extends Schema.TaggedErrorClass()( + "KeystoreUnavailableError", + { + reason: Schema.Literals(["module-not-found"]), + cause: Schema.Defect(), + }, +) {} + export type ConfigServiceError = ConfigError | UrlError; export function configErrorFromUrl(error: UrlError) { diff --git a/src/config/index.ts b/src/config/index.ts index 3154b99..ff14aff 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -15,6 +15,9 @@ export { export { T3CredentialCipher } from "./credential-cipher.ts"; export { layerNode as T3CredentialCipherNodeLive } from "./credential-cipher-node.ts"; export { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; +export { T3MasterKeyKeystoreFactory } from "./keystore.ts"; +export type { MasterKeyKeystore, MasterKeyReadResult } from "./keystore.ts"; +export { layerNode as T3MasterKeyKeystoreFactoryNodeLive } from "./keystore-keyring-node.ts"; export { T3ConfigSelection } from "./selection.ts"; export { T3ConfigSelectionLive } from "./selection-layer.ts"; export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; diff --git a/src/config/keyring.ts b/src/config/keyring.ts deleted file mode 100644 index b64b6d4..0000000 --- a/src/config/keyring.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -type KeyringModule = typeof import("@napi-rs/keyring"); - -export type KeyringStore = { - readonly readPassword: (service: string, account: string) => string | null; - readonly writePassword: (service: string, account: string, password: string) => void; -}; - -function createKeyringStore(keyring: KeyringModule): KeyringStore { - return { - readPassword(service, account) { - return new keyring.Entry(service, account).getPassword(); - }, - writePassword(service, account, password) { - new keyring.Entry(service, account).setPassword(password); - }, - }; -} - -const loadKeyringModuleOnce = Effect.tryPromise({ - try: () => import("@napi-rs/keyring") as Promise, - catch: (cause) => cause, -}).pipe(Effect.option); - -const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); - -export function getKeyringStore(): Effect.Effect { - return Effect.gen(function* () { - const load = yield* loadKeyringModuleMemoized; - const module = yield* load; - if (Option.isNone(module)) { - return undefined; - } - return createKeyringStore(module.value); - }); -} diff --git a/src/config/keystore-file.ts b/src/config/keystore-file.ts new file mode 100644 index 0000000..b3d90ef --- /dev/null +++ b/src/config/keystore-file.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ConfigError, isPlatformNotFoundError } from "./error.ts"; +import { + masterKeyByteLength, + type MasterKeyKeystore, + type MasterKeyReadResult, +} from "./keystore.ts"; +import { resolveKeyFilePath } from "./paths.ts"; + +const privateFileMode = 0o600; + +export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const keyFilePath = yield* resolveKeyFilePath(); + + const read = (): Effect.Effect => + Effect.gen(function* () { + const raw = yield* fs.readFileString(keyFilePath).pipe( + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail( + new ConfigError({ + message: "failed to read credential key file", + cause: error, + }), + ), + }), + ); + if (raw === undefined) { + return { kind: "missing" }; + } + const key = yield* Effect.fromResult(Encoding.decodeBase64(raw.trim())).pipe( + Effect.catchTags({ + EncodingError: (error) => + Effect.fail( + new ConfigError({ + message: "invalid credential key file: invalid base64", + cause: error, + }), + ), + }), + ); + if (key.byteLength !== masterKeyByteLength) { + return yield* Effect.fail( + new ConfigError({ message: "invalid credential key file: unexpected key length" }), + ); + } + yield* fs.chmod(keyFilePath, privateFileMode).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to set credential key file permissions", + cause: error, + }), + ), + ); + return { kind: "present", key }; + }); + + const write = (key: Uint8Array): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(keyFilePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + }), + ); + yield* fs + .writeFileString(keyFilePath, `${Encoding.encodeBase64(key)}\n`, { mode: privateFileMode }) + .pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + }), + ); + yield* fs.chmod(keyFilePath, privateFileMode).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to set credential key file permissions", + cause: error, + }), + ), + ); + }); + + return { read, write } satisfies MasterKeyKeystore; +}); diff --git a/src/config/keystore-keyring-node.ts b/src/config/keystore-keyring-node.ts new file mode 100644 index 0000000..bd32c76 --- /dev/null +++ b/src/config/keystore-keyring-node.ts @@ -0,0 +1,101 @@ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; + +import { ConfigError, KeystoreUnavailableError } from "./error.ts"; +import { + masterKeyByteLength, + T3MasterKeyKeystoreFactory, + type MasterKeyKeystore, + type MasterKeyReadResult, +} from "./keystore.ts"; + +type KeyringModule = typeof import("@napi-rs/keyring"); + +const keyringService = "t3cli"; +const keyringAccount = "master-key"; + +function isModuleNotFound(cause: unknown) { + return ( + Predicate.hasProperty(cause, "code") && + Predicate.isString(cause.code) && + (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") + ); +} + +const loadKeyringModuleOnce = Effect.tryPromise({ + try: () => import("@napi-rs/keyring") as Promise, + catch: (cause) => cause, +}); + +const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); + +const loadKeyringModule = Effect.fn("loadKeyringModule")(function* () { + const load = yield* loadKeyringModuleMemoized; + return yield* load.pipe( + Effect.mapError((cause) => { + if (isModuleNotFound(cause)) { + return new KeystoreUnavailableError({ reason: "module-not-found", cause }); + } + return new ConfigError({ message: "failed to load OS keyring backend", cause }); + }), + ); +}); + +export function parseKeyringPassword(password: string | null): MasterKeyReadResult { + if (password === null || password.length === 0) { + return { kind: "missing" }; + } + return Result.match(Encoding.decodeBase64(password.trim()), { + onFailure: () => ({ + kind: "corrupt" as const, + message: "invalid base64 key", + }), + onSuccess: (key) => { + if (key.byteLength !== masterKeyByteLength) { + return { + kind: "corrupt" as const, + message: "unexpected key length", + }; + } + return { kind: "present" as const, key }; + }, + }); +} + +function createKeyringKeystore(keyring: KeyringModule): MasterKeyKeystore { + const entry = new keyring.Entry(keyringService, keyringAccount); + return { + read: () => + Effect.try({ + try: () => parseKeyringPassword(entry.getPassword()), + catch: (cause) => + new ConfigError({ + message: "failed to read credential key from OS keyring", + cause, + }), + }), + write: (key) => + Effect.try({ + try: () => { + entry.setPassword(Encoding.encodeBase64(key)); + }, + catch: (cause) => + new ConfigError({ + message: "failed to write credential key to OS keyring", + cause, + }), + }), + }; +} + +const make = (): T3MasterKeyKeystoreFactory["Service"] => ({ + make: Effect.fn("makeKeyringKeystore")(function* () { + const keyring = yield* loadKeyringModule(); + return createKeyringKeystore(keyring); + }), +}); + +export const layerNode = Layer.succeed(T3MasterKeyKeystoreFactory, make()); diff --git a/src/config/keystore-test.ts b/src/config/keystore-test.ts new file mode 100644 index 0000000..b71e83f --- /dev/null +++ b/src/config/keystore-test.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { KeystoreUnavailableError } from "./error.ts"; +import { T3MasterKeyKeystoreFactory } from "./keystore.ts"; + +export const unavailableKeystoreFactoryLayer = Layer.succeed(T3MasterKeyKeystoreFactory)({ + make: () => + Effect.fail( + new KeystoreUnavailableError({ + reason: "module-not-found", + cause: new Error("keyring unavailable in test"), + }), + ), +}); diff --git a/src/config/keystore.ts b/src/config/keystore.ts new file mode 100644 index 0000000..f136590 --- /dev/null +++ b/src/config/keystore.ts @@ -0,0 +1,27 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ConfigError, KeystoreUnavailableError } from "./error.ts"; + +export const masterKeyByteLength = 32; + +export type MasterKeyReadResult = + | { readonly kind: "missing" } + | { readonly kind: "present"; readonly key: Uint8Array } + | { readonly kind: "corrupt"; readonly message: string }; + +export type MasterKeyKeystore = { + readonly read: () => Effect.Effect; + readonly write: (key: Uint8Array) => Effect.Effect; +}; + +export class T3MasterKeyKeystoreFactory extends Context.Service< + T3MasterKeyKeystoreFactory, + { + readonly make: () => Effect.Effect; + } +>()("t3cli/T3MasterKeyKeystoreFactory") {} + +export function shouldUseFileKeystoreForRead(result: MasterKeyReadResult) { + return result.kind === "missing"; +} diff --git a/src/config/service.ts b/src/config/service.ts index b5c0c39..db67159 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -2,13 +2,7 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type { ConfigError, ConfigServiceError } from "./error.ts"; -import type { - EncryptedConfig, - EncryptedToken, - EnvironmentSummary, - ResolvedConfig, - UpsertEnvironmentInput, -} from "./types.ts"; +import type { EnvironmentSummary, ResolvedConfig, UpsertEnvironmentInput } from "./types.ts"; export class T3Config extends Context.Service< T3Config, diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 217752d..de63055 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -7,25 +7,16 @@ import * as Ref from "effect/Ref"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { Command } from "effect/unstable/cli"; -import { vi } from "vite-plus/test"; import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; import { layerNode as T3CredentialCipherNodeLive } from "../config/credential-cipher-node.ts"; +import { unavailableKeystoreFactoryLayer } from "../config/keystore-test.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; import { Environment } from "../environment/service.ts"; import type { ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; import { BaseAppLayer } from "./layer.ts"; -vi.mock("../config/keyring.ts", async (importOriginal) => { - const EffectModule = await import("effect/Effect"); - const actual = await importOriginal(); - return { - ...actual, - getKeyringStore: () => EffectModule.succeed(undefined), - }; -}); - function makeCliAppLayer(homeDir: string) { const environmentLayer = Layer.succeed(Environment)({ cwd: homeDir, @@ -37,6 +28,7 @@ function makeCliAppLayer(homeDir: string) { return BaseAppLayer.pipe( Layer.provideMerge(T3CliConfigSelectionLive), Layer.provide(T3CredentialCipherNodeLive), + Layer.provide(unavailableKeystoreFactoryLayer), Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), ); } From b6e32922a46615f17176a1b6986ae097db3405af Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:33:25 +0300 Subject: [PATCH 14/21] Remove thin credential crypto wrapper functions. Call T3CredentialCrypto encrypt and decrypt directly from layer, migration, and tests. Co-authored-by: Cursor --- src/config/codec.ts | 26 +++++--------------------- src/config/config.test.ts | 23 +++++++++++++---------- src/config/layer.ts | 29 ++++++++++++----------------- src/config/migration.ts | 5 +++-- 4 files changed, 33 insertions(+), 50 deletions(-) diff --git a/src/config/codec.ts b/src/config/codec.ts index 7202029..2020d77 100644 --- a/src/config/codec.ts +++ b/src/config/codec.ts @@ -1,33 +1,16 @@ import * as Effect from "effect/Effect"; -import { - T3CredentialCrypto, - type CredentialDecryptInput, - type CredentialEncryptInput, -} from "./credential.ts"; +import { T3CredentialCrypto } from "./credential.ts"; import type { EncryptedConfig, DecryptedConfig, DecryptedEnvironment } from "./types.ts"; -export const encryptEnvironment = Effect.fn("encryptEnvironment")(function* ( - input: CredentialEncryptInput, -) { - const crypto = yield* T3CredentialCrypto; - return yield* crypto.encrypt(input); -}); - -export const decryptEnvironment = Effect.fn("decryptEnvironment")(function* ( - input: CredentialDecryptInput, -) { - const crypto = yield* T3CredentialCrypto; - return yield* crypto.decrypt(input); -}); - export const decryptConfig = Effect.fn("decryptConfig")(function* (config: EncryptedConfig) { + const crypto = yield* T3CredentialCrypto; const environments: Record = {}; for (const [name, environmentConfig] of Object.entries(config.environments)) { environments[name] = { url: environmentConfig.url, local: environmentConfig.local, - token: yield* decryptEnvironment({ + token: yield* crypto.decrypt({ environmentName: name, url: environmentConfig.url, local: environmentConfig.local, @@ -43,12 +26,13 @@ export const decryptConfig = Effect.fn("decryptConfig")(function* (config: Encry }); export const encryptConfig = Effect.fn("encryptConfig")(function* (config: DecryptedConfig) { + const crypto = yield* T3CredentialCrypto; const environments: Record = {}; for (const [name, environmentConfig] of Object.entries(config.environments)) { environments[name] = { url: environmentConfig.url, local: environmentConfig.local, - token: yield* encryptEnvironment({ + token: yield* crypto.encrypt({ environmentName: name, url: environmentConfig.url, local: environmentConfig.local, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 17ba4a6..56fd74f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -10,9 +10,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { Environment } from "../environment/service.ts"; -import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; +import { T3CredentialCrypto, layer as T3CredentialCryptoLive } from "./credential.ts"; import { layer as T3ConfigLive } from "./layer.ts"; -import { layer as T3CredentialCryptoLive } from "./credential.ts"; import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; @@ -87,7 +86,8 @@ describe("config persistence", () => { local: false, }); assert.equal(migrated.default, "app.example.com"); - const token = yield* decryptEnvironment({ + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.decrypt({ environmentName: "app.example.com", url: "https://app.example.com", local: false, @@ -452,18 +452,21 @@ describe("config persistence", () => { }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { - const token = yield* encryptEnvironment({ + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.encrypt({ environmentName: "home", url: "https://home.example", local: false, token: "secret", }); - const result = yield* decryptEnvironment({ - environmentName: "home", - url: "https://tampered.example", - local: false, - token, - }).pipe(Effect.exit); + const result = yield* crypto + .decrypt({ + environmentName: "home", + url: "https://tampered.example", + local: false, + token, + }) + .pipe(Effect.exit); assert.equal(Exit.isFailure(result), true); }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir))), ), diff --git a/src/config/layer.ts b/src/config/layer.ts index bc7976a..39b9ad3 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -4,7 +4,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { Environment } from "../environment/service.ts"; -import { decryptEnvironment, encryptEnvironment } from "./codec.ts"; import { T3CredentialCrypto } from "./credential.ts"; import { ConfigError, configErrorFromUrl } from "./error.ts"; import { validateEnvironmentName } from "./environment-name.ts"; @@ -92,14 +91,12 @@ export const make = Effect.fn("makeT3Config")(function* () { Effect.mapError(configErrorFromUrl), ); const encrypted = yield* readEncrypted(); - const token = yield* run( - encryptEnvironment({ - environmentName: input.name, - url: normalizedUrl, - local: input.local, - token: input.token, - }), - ); + const token = yield* credentialCrypto.encrypt({ + environmentName: input.name, + url: normalizedUrl, + local: input.local, + token: input.token, + }); const defaultName = resolveDefaultForUpsert(encrypted, input.name, input.makeDefault); yield* run( writeEncryptedConfigFile({ @@ -194,14 +191,12 @@ export const make = Effect.fn("makeT3Config")(function* () { } const selectedEnvironment = encrypted.environments[selectedName]; - const token = yield* run( - decryptEnvironment({ - environmentName: selectedName, - url: selectedEnvironment.url, - local: selectedEnvironment.local, - token: selectedEnvironment.token, - }), - ); + const token = yield* credentialCrypto.decrypt({ + environmentName: selectedName, + url: selectedEnvironment.url, + local: selectedEnvironment.local, + token: selectedEnvironment.token, + }); return yield* buildResolvedConfigFromStored({ selectedName, token, diff --git a/src/config/migration.ts b/src/config/migration.ts index d6512f9..6e39bca 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { encryptEnvironment } from "./codec.ts"; +import { T3CredentialCrypto } from "./credential.ts"; import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; import { ConfigError, configErrorFromUrl } from "./error.ts"; import { @@ -60,7 +60,8 @@ export const migrateV1FileToEncrypted = Effect.fn("migrateV1FileToEncrypted")(fu ), ); const normalizedUrl = yield* normalizeHttpBaseUrl(config.url); - const token = yield* encryptEnvironment({ + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.encrypt({ environmentName: name, url: normalizedUrl, local: config.local ?? false, From 01adf62365117d76634a68c9d40db4e0f09424e7 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:35:03 +0300 Subject: [PATCH 15/21] Remove more unnecessary passthrough wrappers. Delete unused codec module, simplify config read helper, and inline thin auth config delegates. Co-authored-by: Cursor --- src/auth/layer.ts | 84 ++++++++++++----------------- src/config/codec.ts | 48 ----------------- src/config/keystore-keyring-node.ts | 11 ++-- src/config/layer.ts | 5 +- 4 files changed, 42 insertions(+), 106 deletions(-) delete mode 100644 src/config/codec.ts diff --git a/src/auth/layer.ts b/src/auth/layer.ts index 62d3d8f..eee11db 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -32,30 +32,6 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { return yield* transport.issueWebSocketTicket(resolved); }); - const writeConfig = Effect.fn("T3AuthLive.writeConfig")(function* (input: AuthConfigInput) { - yield* config - .upsertEnvironment({ - name: input.name, - url: input.url, - token: input.token, - local: input.local, - ...(input.makeDefault === true ? { makeDefault: true } : {}), - }) - .pipe(Effect.mapError(mapConfigError)); - }); - - const environmentExists = Effect.fn("T3AuthLive.environmentExists")(function* (name: string) { - return yield* config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)); - }); - - const defaultNameFromUrl = Effect.fn("T3AuthLive.defaultNameFromUrl")(function* (url: string) { - return yield* defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)); - }); - - const defaultNameForLocal = Effect.fn("T3AuthLive.defaultNameForLocal")(() => - Effect.succeed(defaultEnvironmentNameForLocal()), - ); - const persistEnvironment = Effect.fn("T3AuthLive.persistEnvironment")(function* (input: { readonly name: string; readonly url: string; @@ -73,13 +49,15 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { ); } const makeDefault = exists && input.replace === true; - yield* writeConfig({ - name: input.name, - url: input.url, - token: input.token, - local: input.local, - ...(makeDefault ? { makeDefault: true } : {}), - }); + yield* config + .upsertEnvironment({ + name: input.name, + url: input.url, + token: input.token, + local: input.local, + ...(makeDefault ? { makeDefault: true } : {}), + }) + .pipe(Effect.mapError(mapConfigError)); return input.name; }); @@ -91,11 +69,6 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { return environments.map((environment) => toAuthEnvironmentListItem(environment, activeName)); }); - const useEnvironment = Effect.fn("T3AuthLive.useEnvironment")(function* (name: string) { - yield* config.setDefaultEnvironment(name).pipe(Effect.mapError(mapConfigError)); - return { name, default: true as const }; - }); - const resolveUnpairTarget = Effect.fn("T3AuthLive.resolveUnpairTarget")(function* (input: { readonly name?: string; }) { @@ -115,25 +88,38 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { return defaultName; }); - const unpairEnvironment = Effect.fn("T3AuthLive.unpairEnvironment")(function* (input: { - readonly name: string; - }) { - yield* config.removeEnvironment(input.name).pipe(Effect.mapError(mapConfigError)); - return { name: input.name, removed: true as const }; - }); - return { pair: pairing.pair, local: localAuth.local, - writeConfig, + writeConfig: (input: AuthConfigInput) => + config + .upsertEnvironment({ + name: input.name, + url: input.url, + token: input.token, + local: input.local, + ...(input.makeDefault === true ? { makeDefault: true } : {}), + }) + .pipe(Effect.mapError(mapConfigError)), persistEnvironment, - environmentExists, - defaultNameFromUrl, - defaultNameForLocal, + environmentExists: (name: string) => + config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)), + defaultNameFromUrl: (url: string) => + defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)), + defaultNameForLocal: () => Effect.succeed(defaultEnvironmentNameForLocal()), listEnvironments, - useEnvironment, + useEnvironment: (name: string) => + config + .setDefaultEnvironment(name) + .pipe(Effect.mapError(mapConfigError), Effect.as({ name, default: true as const })), resolveUnpairTarget, - unpairEnvironment, + unpairEnvironment: (input: { readonly name: string }) => + config + .removeEnvironment(input.name) + .pipe( + Effect.mapError(mapConfigError), + Effect.as({ name: input.name, removed: true as const }), + ), status, issueWebSocketTicket, }; diff --git a/src/config/codec.ts b/src/config/codec.ts deleted file mode 100644 index 2020d77..0000000 --- a/src/config/codec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as Effect from "effect/Effect"; - -import { T3CredentialCrypto } from "./credential.ts"; -import type { EncryptedConfig, DecryptedConfig, DecryptedEnvironment } from "./types.ts"; - -export const decryptConfig = Effect.fn("decryptConfig")(function* (config: EncryptedConfig) { - const crypto = yield* T3CredentialCrypto; - const environments: Record = {}; - for (const [name, environmentConfig] of Object.entries(config.environments)) { - environments[name] = { - url: environmentConfig.url, - local: environmentConfig.local, - token: yield* crypto.decrypt({ - environmentName: name, - url: environmentConfig.url, - local: environmentConfig.local, - token: environmentConfig.token, - }), - }; - } - return { - version: 2 as const, - ...(config.default !== undefined ? { default: config.default } : {}), - environments, - } satisfies DecryptedConfig; -}); - -export const encryptConfig = Effect.fn("encryptConfig")(function* (config: DecryptedConfig) { - const crypto = yield* T3CredentialCrypto; - const environments: Record = {}; - for (const [name, environmentConfig] of Object.entries(config.environments)) { - environments[name] = { - url: environmentConfig.url, - local: environmentConfig.local, - token: yield* crypto.encrypt({ - environmentName: name, - url: environmentConfig.url, - local: environmentConfig.local, - token: environmentConfig.token, - }), - }; - } - return { - version: 2 as const, - ...(config.default !== undefined ? { default: config.default } : {}), - environments, - } satisfies EncryptedConfig; -}); diff --git a/src/config/keystore-keyring-node.ts b/src/config/keystore-keyring-node.ts index bd32c76..e5b69de 100644 --- a/src/config/keystore-keyring-node.ts +++ b/src/config/keystore-keyring-node.ts @@ -32,7 +32,7 @@ const loadKeyringModuleOnce = Effect.tryPromise({ const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); -const loadKeyringModule = Effect.fn("loadKeyringModule")(function* () { +const loadKeyringModule = Effect.gen(function* () { const load = yield* loadKeyringModuleMemoized; return yield* load.pipe( Effect.mapError((cause) => { @@ -92,10 +92,11 @@ function createKeyringKeystore(keyring: KeyringModule): MasterKeyKeystore { } const make = (): T3MasterKeyKeystoreFactory["Service"] => ({ - make: Effect.fn("makeKeyringKeystore")(function* () { - const keyring = yield* loadKeyringModule(); - return createKeyringKeystore(keyring); - }), + make: () => + Effect.gen(function* () { + const keyring = yield* loadKeyringModule; + return createKeyringKeystore(keyring); + }), }); export const layerNode = Layer.succeed(T3MasterKeyKeystoreFactory, make()); diff --git a/src/config/layer.ts b/src/config/layer.ts index 39b9ad3..5d68b1d 100644 --- a/src/config/layer.ts +++ b/src/config/layer.ts @@ -34,10 +34,7 @@ export const make = Effect.fn("makeT3Config")(function* () { Layer.succeed(T3CredentialCrypto, credentialCrypto), ); const run = (effect: Effect.Effect) => Effect.provide(effect, services); - - const readEncrypted = Effect.fn("T3ConfigLive.readEncrypted")(function* () { - return yield* run(readEncryptedConfigFile()); - }); + const readEncrypted = () => run(readEncryptedConfigFile()); const hasEnvironment = Effect.fn("T3ConfigLive.hasEnvironment")(function* (name: string) { const encrypted = yield* readEncrypted(); From 68f1f75e3ad1633393bdb119603ab5afd8fec08a Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:40:18 +0300 Subject: [PATCH 16/21] Harden config error handling across auth, migration, and keystore. Preserve auth error messages from config failures, map migration URL errors at source, consolidate platform error helpers, and treat broken keyring backends as unavailable with file fallback. Co-authored-by: Cursor --- src/auth/error.ts | 4 ++ src/auth/layer.ts | 34 +++++++------- src/config/credential.test.ts | 5 ++ src/config/credential.ts | 6 ++- src/config/error.ts | 38 ++++++++++++++- src/config/file-mode.ts | 16 +++---- src/config/keystore-file.ts | 73 +++++++++++------------------ src/config/keystore-keyring-node.ts | 37 ++++++++------- src/config/keystore.ts | 7 +-- src/config/migration.ts | 8 ++-- src/config/persist.ts | 41 +++++----------- 11 files changed, 141 insertions(+), 128 deletions(-) diff --git a/src/auth/error.ts b/src/auth/error.ts index 019bb11..70f29fd 100644 --- a/src/auth/error.ts +++ b/src/auth/error.ts @@ -18,6 +18,10 @@ export class AuthConfigError extends Schema.TaggedErrorClass()( cause: Schema.optionalKey(Schema.Union([ConfigError, UrlError])), }) {} +export function authConfigErrorFromConfig(error: ConfigError | UrlError) { + return new AuthConfigError({ message: error.message, cause: error }); +} + export class AuthTransportError extends Schema.TaggedErrorClass()( "AuthTransportError", { diff --git a/src/auth/layer.ts b/src/auth/layer.ts index eee11db..c837749 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -1,14 +1,13 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ConfigError, UrlError } from "../config/error.ts"; import { defaultEnvironmentNameForLocal, defaultEnvironmentNameFromUrl, } from "../config/environment-name.ts"; import type { EnvironmentSummary, ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; -import { AuthConfigError } from "./error.ts"; +import { AuthConfigError, authConfigErrorFromConfig } from "./error.ts"; import { T3LocalAuth } from "./local.ts"; import { T3AuthPairing } from "./pairing.ts"; import { T3Auth } from "./service.ts"; @@ -22,13 +21,13 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const pairing = yield* T3AuthPairing; const status = Effect.fn("T3AuthLive.status")(function* () { - const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); + const resolved = yield* config.resolve().pipe(Effect.mapError(authConfigErrorFromConfig)); const session = yield* transport.getSession(resolved); return { config: toAuthResolvedConfig(resolved), session } as const; }); const issueWebSocketTicket = Effect.fn("T3AuthLive.issueWebSocketTicket")(function* () { - const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); + const resolved = yield* config.resolve().pipe(Effect.mapError(authConfigErrorFromConfig)); return yield* transport.issueWebSocketTicket(resolved); }); @@ -40,7 +39,9 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { readonly replace?: boolean; readonly allowReplace: boolean; }) { - const exists = yield* config.hasEnvironment(input.name).pipe(Effect.mapError(mapConfigError)); + const exists = yield* config + .hasEnvironment(input.name) + .pipe(Effect.mapError(authConfigErrorFromConfig)); if (exists && !input.allowReplace) { return yield* Effect.fail( new AuthConfigError({ @@ -57,7 +58,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { local: input.local, ...(makeDefault ? { makeDefault: true } : {}), }) - .pipe(Effect.mapError(mapConfigError)); + .pipe(Effect.mapError(authConfigErrorFromConfig)); return input.name; }); @@ -65,7 +66,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const [environments, activeName] = yield* Effect.all( [config.listEnvironments(), config.resolveActiveEnvironmentName()], { concurrency: "unbounded" }, - ).pipe(Effect.mapError(mapConfigError)); + ).pipe(Effect.mapError(authConfigErrorFromConfig)); return environments.map((environment) => toAuthEnvironmentListItem(environment, activeName)); }); @@ -77,7 +78,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { } const defaultName = yield* config .getDefaultEnvironmentName() - .pipe(Effect.mapError(mapConfigError)); + .pipe(Effect.mapError(authConfigErrorFromConfig)); if (defaultName === undefined) { return yield* Effect.fail( new AuthConfigError({ @@ -100,24 +101,27 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { local: input.local, ...(input.makeDefault === true ? { makeDefault: true } : {}), }) - .pipe(Effect.mapError(mapConfigError)), + .pipe(Effect.mapError(authConfigErrorFromConfig)), persistEnvironment, environmentExists: (name: string) => - config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)), + config.hasEnvironment(name).pipe(Effect.mapError(authConfigErrorFromConfig)), defaultNameFromUrl: (url: string) => - defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)), + defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(authConfigErrorFromConfig)), defaultNameForLocal: () => Effect.succeed(defaultEnvironmentNameForLocal()), listEnvironments, useEnvironment: (name: string) => config .setDefaultEnvironment(name) - .pipe(Effect.mapError(mapConfigError), Effect.as({ name, default: true as const })), + .pipe( + Effect.mapError(authConfigErrorFromConfig), + Effect.as({ name, default: true as const }), + ), resolveUnpairTarget, unpairEnvironment: (input: { readonly name: string }) => config .removeEnvironment(input.name) .pipe( - Effect.mapError(mapConfigError), + Effect.mapError(authConfigErrorFromConfig), Effect.as({ name: input.name, removed: true as const }), ), status, @@ -127,10 +131,6 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { export const T3AuthLive = Layer.effect(T3Auth, makeT3Auth()); -function mapConfigError(error: ConfigError | UrlError) { - return new AuthConfigError({ message: "auth config failed", cause: error }); -} - function toAuthResolvedConfig(config: ResolvedConfig): AuthResolvedConfig { return { url: config.url, diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 0310bfc..8bfacff 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -38,6 +38,11 @@ describe("keyring fallback", () => { expect(shouldUseFileKeystoreForRead(result)).toBe(false); }); + it("treats unavailable keyring reads as file-keystore fallback", () => { + const result = { kind: "unavailable" as const, message: "failed" }; + expect(shouldUseFileKeystoreForRead(result)).toBe(true); + }); + it.effect("falls back to key file when keyring backend is unavailable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/src/config/credential.ts b/src/config/credential.ts index b0611dc..0d58498 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -85,7 +85,11 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { const writeMasterKey = Effect.fn("writeMasterKey")(function* (key: Uint8Array) { if (primaryKeystore !== undefined) { - yield* primaryKeystore.write(key); + yield* primaryKeystore.write(key).pipe( + Effect.catchTags({ + KeystoreUnavailableError: () => fileKeystore.write(key), + }), + ); return; } yield* fileKeystore.write(key); diff --git a/src/config/error.ts b/src/config/error.ts index a081e79..d67071b 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { PlatformError } from "effect/PlatformError"; @@ -30,13 +31,48 @@ export class CredentialCipherError extends Schema.TaggedErrorClass()( "KeystoreUnavailableError", { - reason: Schema.Literals(["module-not-found"]), + reason: Schema.Literals(["module-not-found", "backend-unavailable"]), cause: Schema.Defect(), }, ) {} export type ConfigServiceError = ConfigError | UrlError; +export function configErrorFromPlatformError(message: string, error: PlatformError) { + return new ConfigError({ message, cause: error }); +} + +export function mapPlatformErrorToConfigError(message: string) { + return (error: PlatformError) => configErrorFromPlatformError(message, error); +} + +export function catchPlatformError(message: string) { + return { + PlatformError: (error: PlatformError) => + Effect.fail(configErrorFromPlatformError(message, error)), + } as const; +} + +export function catchPlatformErrorUnlessNotFound(message: string) { + return { + PlatformError: (error: PlatformError) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail(configErrorFromPlatformError(message, error)), + } as const; +} + +export function configErrorFromSchemaError(message: string, error: Schema.SchemaError) { + return new ConfigError({ message, cause: error }); +} + +export function catchSchemaError(message: string) { + return { + SchemaError: (error: Schema.SchemaError) => + Effect.fail(configErrorFromSchemaError(message, error)), + } as const; +} + export function configErrorFromUrl(error: UrlError) { return new ConfigError({ message: diff --git a/src/config/file-mode.ts b/src/config/file-mode.ts index dd738c5..a91eb68 100644 --- a/src/config/file-mode.ts +++ b/src/config/file-mode.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import { ConfigError } from "./error.ts"; +import { mapPlatformErrorToConfigError } from "./error.ts"; const privateFileMode = 0o600; @@ -10,13 +10,9 @@ export const hardenPrivateFileMode = Effect.fn("hardenPrivateFileMode")(function label: "config" | "credential key", ) { const fs = yield* FileSystem.FileSystem; - yield* fs.chmod(filePath, privateFileMode).pipe( - Effect.mapError( - (error) => - new ConfigError({ - message: `failed to set ${label} file permissions`, - cause: error, - }), - ), - ); + yield* fs + .chmod(filePath, privateFileMode) + .pipe( + Effect.mapError(mapPlatformErrorToConfigError(`failed to set ${label} file permissions`)), + ); }); diff --git a/src/config/keystore-file.ts b/src/config/keystore-file.ts index b3d90ef..3f2ffcb 100644 --- a/src/config/keystore-file.ts +++ b/src/config/keystore-file.ts @@ -3,7 +3,12 @@ import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ConfigError, isPlatformNotFoundError } from "./error.ts"; +import { + catchPlatformError, + catchPlatformErrorUnlessNotFound, + ConfigError, + mapPlatformErrorToConfigError, +} from "./error.ts"; import { masterKeyByteLength, type MasterKeyKeystore, @@ -20,19 +25,11 @@ export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { const read = (): Effect.Effect => Effect.gen(function* () { - const raw = yield* fs.readFileString(keyFilePath).pipe( - Effect.catchTags({ - PlatformError: (error) => - isPlatformNotFoundError(error) - ? Effect.succeed(undefined) - : Effect.fail( - new ConfigError({ - message: "failed to read credential key file", - cause: error, - }), - ), - }), - ); + const raw = yield* fs + .readFileString(keyFilePath) + .pipe( + Effect.catchTags(catchPlatformErrorUnlessNotFound("failed to read credential key file")), + ); if (raw === undefined) { return { kind: "missing" }; } @@ -52,47 +49,31 @@ export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { new ConfigError({ message: "invalid credential key file: unexpected key length" }), ); } - yield* fs.chmod(keyFilePath, privateFileMode).pipe( - Effect.mapError( - (error) => - new ConfigError({ - message: "failed to set credential key file permissions", - cause: error, - }), - ), - ); + yield* fs + .chmod(keyFilePath, privateFileMode) + .pipe( + Effect.mapError( + mapPlatformErrorToConfigError("failed to set credential key file permissions"), + ), + ); return { kind: "present", key }; }); const write = (key: Uint8Array): Effect.Effect => Effect.gen(function* () { - yield* fs.makeDirectory(path.dirname(keyFilePath), { recursive: true, mode: 0o700 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail( - new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - }), - ); + yield* fs + .makeDirectory(path.dirname(keyFilePath), { recursive: true, mode: 0o700 }) + .pipe(Effect.catchTags(catchPlatformError("failed to write credential key file"))); yield* fs .writeFileString(keyFilePath, `${Encoding.encodeBase64(key)}\n`, { mode: privateFileMode }) + .pipe(Effect.catchTags(catchPlatformError("failed to write credential key file"))); + yield* fs + .chmod(keyFilePath, privateFileMode) .pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail( - new ConfigError({ message: "failed to write credential key file", cause: error }), - ), - }), + Effect.mapError( + mapPlatformErrorToConfigError("failed to set credential key file permissions"), + ), ); - yield* fs.chmod(keyFilePath, privateFileMode).pipe( - Effect.mapError( - (error) => - new ConfigError({ - message: "failed to set credential key file permissions", - cause: error, - }), - ), - ); }); return { read, write } satisfies MasterKeyKeystore; diff --git a/src/config/keystore-keyring-node.ts b/src/config/keystore-keyring-node.ts index e5b69de..ea62bbf 100644 --- a/src/config/keystore-keyring-node.ts +++ b/src/config/keystore-keyring-node.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; -import { ConfigError, KeystoreUnavailableError } from "./error.ts"; +import { KeystoreUnavailableError } from "./error.ts"; import { masterKeyByteLength, T3MasterKeyKeystoreFactory, @@ -25,6 +25,10 @@ function isModuleNotFound(cause: unknown) { ); } +function keyringBackendUnavailable(cause: unknown) { + return new KeystoreUnavailableError({ reason: "backend-unavailable", cause }); +} + const loadKeyringModuleOnce = Effect.tryPromise({ try: () => import("@napi-rs/keyring") as Promise, catch: (cause) => cause, @@ -35,12 +39,11 @@ const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); const loadKeyringModule = Effect.gen(function* () { const load = yield* loadKeyringModuleMemoized; return yield* load.pipe( - Effect.mapError((cause) => { - if (isModuleNotFound(cause)) { - return new KeystoreUnavailableError({ reason: "module-not-found", cause }); - } - return new ConfigError({ message: "failed to load OS keyring backend", cause }); - }), + Effect.mapError((cause) => + isModuleNotFound(cause) + ? new KeystoreUnavailableError({ reason: "module-not-found", cause }) + : keyringBackendUnavailable(cause), + ), ); }); @@ -69,24 +72,22 @@ function createKeyringKeystore(keyring: KeyringModule): MasterKeyKeystore { const entry = new keyring.Entry(keyringService, keyringAccount); return { read: () => - Effect.try({ - try: () => parseKeyringPassword(entry.getPassword()), - catch: (cause) => - new ConfigError({ + Effect.sync(() => { + try { + return parseKeyringPassword(entry.getPassword()); + } catch { + return { + kind: "unavailable" as const, message: "failed to read credential key from OS keyring", - cause, - }), + }; + } }), write: (key) => Effect.try({ try: () => { entry.setPassword(Encoding.encodeBase64(key)); }, - catch: (cause) => - new ConfigError({ - message: "failed to write credential key to OS keyring", - cause, - }), + catch: keyringBackendUnavailable, }), }; } diff --git a/src/config/keystore.ts b/src/config/keystore.ts index f136590..3aa31e7 100644 --- a/src/config/keystore.ts +++ b/src/config/keystore.ts @@ -8,11 +8,12 @@ export const masterKeyByteLength = 32; export type MasterKeyReadResult = | { readonly kind: "missing" } | { readonly kind: "present"; readonly key: Uint8Array } - | { readonly kind: "corrupt"; readonly message: string }; + | { readonly kind: "corrupt"; readonly message: string } + | { readonly kind: "unavailable"; readonly message: string }; export type MasterKeyKeystore = { readonly read: () => Effect.Effect; - readonly write: (key: Uint8Array) => Effect.Effect; + readonly write: (key: Uint8Array) => Effect.Effect; }; export class T3MasterKeyKeystoreFactory extends Context.Service< @@ -23,5 +24,5 @@ export class T3MasterKeyKeystoreFactory extends Context.Service< >()("t3cli/T3MasterKeyKeystoreFactory") {} export function shouldUseFileKeystoreForRead(result: MasterKeyReadResult) { - return result.kind === "missing"; + return result.kind === "missing" || result.kind === "unavailable"; } diff --git a/src/config/migration.ts b/src/config/migration.ts index 6e39bca..bdb3533 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -4,7 +4,7 @@ import * as Schema from "effect/Schema"; import { T3CredentialCrypto } from "./credential.ts"; import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError, configErrorFromUrl } from "./error.ts"; +import { ConfigError, configErrorFromUrl, configErrorFromSchemaError } from "./error.ts"; import { StoredConfigV1FileSchema, StoredConfigV2FileSchema, @@ -35,7 +35,7 @@ export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromVa Effect.catchTags({ UrlError: (error) => Effect.fail(configErrorFromUrl(error)), SchemaError: (error) => - Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + Effect.fail(configErrorFromSchemaError("failed to read config", error)), }), ); }); @@ -59,7 +59,9 @@ export const migrateV1FileToEncrypted = Effect.fn("migrateV1FileToEncrypted")(fu validateEnvironmentName(migratedName).pipe(Effect.as(migratedName)), ), ); - const normalizedUrl = yield* normalizeHttpBaseUrl(config.url); + const normalizedUrl = yield* normalizeHttpBaseUrl(config.url).pipe( + Effect.mapError(configErrorFromUrl), + ); const crypto = yield* T3CredentialCrypto; const token = yield* crypto.encrypt({ environmentName: name, diff --git a/src/config/persist.ts b/src/config/persist.ts index be954ec..bf53f52 100644 --- a/src/config/persist.ts +++ b/src/config/persist.ts @@ -3,7 +3,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { ConfigError, isPlatformNotFoundError } from "./error.ts"; +import { catchPlatformError, catchPlatformErrorUnlessNotFound, catchSchemaError } from "./error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; import { resolveConfigFilePath } from "./paths.ts"; @@ -13,22 +13,14 @@ import type { EncryptedConfig } from "./types.ts"; export const readEncryptedConfigFile = Effect.fn("readEncryptedConfigFile")(function* () { const fs = yield* FileSystem.FileSystem; const configFilePath = yield* resolveConfigFilePath(); - const raw = yield* fs.readFileString(configFilePath).pipe( - Effect.catchTags({ - PlatformError: (error) => - isPlatformNotFoundError(error) - ? Effect.succeed(undefined) - : Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), - }), - ); + const raw = yield* fs + .readFileString(configFilePath) + .pipe(Effect.catchTags(catchPlatformErrorUnlessNotFound("failed to read config"))); if (raw === undefined) { return emptyEncryptedConfig(); } const value = yield* Schema.decodeUnknownEffect(UnknownConfigFileJson)(raw).pipe( - Effect.catchTags({ - SchemaError: (error) => - Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), - }), + Effect.catchTags(catchSchemaError("failed to read config")), ); const read = yield* readEncryptedConfigFromValue(value); if (read.migratedFromV1) { @@ -43,23 +35,14 @@ export const writeEncryptedConfigFile = Effect.fn("writeEncryptedConfigFile")(fu const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configFilePath = yield* resolveConfigFilePath(); - yield* fs.makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), - ); + yield* fs + .makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }) + .pipe(Effect.catchTags(catchPlatformError("failed to write config"))); const encoded = yield* Schema.encodeEffect(StoredConfigV2FileJson)(config).pipe( - Effect.catchTags({ - SchemaError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), - ); - yield* fs.writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }).pipe( - Effect.catchTags({ - PlatformError: (error) => - Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), - }), + Effect.catchTags(catchSchemaError("failed to write config")), ); + yield* fs + .writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }) + .pipe(Effect.catchTags(catchPlatformError("failed to write config"))); yield* hardenPrivateFileMode(configFilePath, "config"); }); From 13603451090ced8e97bfdd300cb75d4a49535e6e Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:47:01 +0300 Subject: [PATCH 17/21] Map UrlError to ConfigError at all config boundaries. Close remaining leaks in resolve and environment-name derivation, tighten service error types, and dedupe encoding error handling. Co-authored-by: Cursor --- src/config/config.test.ts | 25 +++++++++++++++++++++++++ src/config/credential.ts | 12 ++---------- src/config/environment-name.ts | 3 ++- src/config/error.ts | 10 +++++++++- src/config/index.ts | 2 +- src/config/keystore-file.ts | 11 ++--------- src/config/keystore.ts | 2 +- src/config/migration.ts | 1 - src/config/resolve.ts | 7 +++++-- src/config/service.ts | 4 ++-- 10 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 56fd74f..6923e2d 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1,7 +1,9 @@ import "vite-plus/test/config"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -14,6 +16,7 @@ import { T3CredentialCrypto, layer as T3CredentialCryptoLive } from "./credentia import { layer as T3ConfigLive } from "./layer.ts"; import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; +import { ConfigError } from "./error.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; import { T3ConfigSelection } from "./selection.ts"; @@ -101,6 +104,28 @@ describe("config persistence", () => { ), ); + it.effect("maps invalid v1 urls to ConfigError when migrating directly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = yield* fs.makeTempDirectory({ prefix: "t3cli-migrate-url-error" }); + const exit = yield* migrateV1FileToEncrypted({ + url: "not-a-url", + token: "secret-token", + local: false, + }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir)), Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + if (!Exit.isFailure(exit)) { + return; + } + const error = Cause.findErrorOption(exit.cause); + assert.isTrue(Option.isSome(error)); + if (Option.isSome(error)) { + assert.instanceOf(error.value, ConfigError); + assert.equal(error.value.message, "invalid url"); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("persists v1 config as encrypted v2 on first read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/src/config/credential.ts b/src/config/credential.ts index 0d58498..8911748 100644 --- a/src/config/credential.ts +++ b/src/config/credential.ts @@ -9,7 +9,7 @@ import * as Path from "effect/Path"; import { Environment } from "../environment/service.ts"; import { T3CredentialCipher, credentialCipherNonceByteLength } from "./credential-cipher.ts"; import type { EncryptedToken } from "./schema.ts"; -import { ConfigError, CredentialCipherError } from "./error.ts"; +import { catchEncodingError, ConfigError, CredentialCipherError } from "./error.ts"; import { makeFileKeystore } from "./keystore-file.ts"; import { T3MasterKeyKeystoreFactory, masterKeyByteLength } from "./keystore.ts"; @@ -181,15 +181,7 @@ const secureRandomBytes = Effect.fn("secureRandomBytes")(function* (size: number function decodeBase64Field(value: string, field: string) { return Effect.fromResult(Encoding.decodeBase64(value)).pipe( - Effect.catchTags({ - EncodingError: (error) => - Effect.fail( - new ConfigError({ - message: `invalid encrypted token ${field}`, - cause: error, - }), - ), - }), + Effect.catchTags(catchEncodingError(`invalid encrypted token ${field}`)), ); } diff --git a/src/config/environment-name.ts b/src/config/environment-name.ts index 079c8c4..f7f3d58 100644 --- a/src/config/environment-name.ts +++ b/src/config/environment-name.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; -import { ConfigError } from "./error.ts"; +import { ConfigError, configErrorFromUrl } from "./error.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; const environmentNamePattern = /^[A-Za-z0-9._-]+$/; @@ -23,6 +23,7 @@ export function validateEnvironmentName(name: string) { export function defaultEnvironmentNameFromUrl(url: string) { return normalizeHttpBaseUrl(url).pipe( + Effect.mapError(configErrorFromUrl), Effect.flatMap((normalized) => { const hostname = slugifyEnvironmentName(new URL(normalized).hostname.trim()); return validateEnvironmentName(hostname).pipe(Effect.as(hostname)); diff --git a/src/config/error.ts b/src/config/error.ts index d67071b..0e17ce4 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as Schema from "effect/Schema"; import { PlatformError } from "effect/PlatformError"; @@ -36,7 +37,7 @@ export class KeystoreUnavailableError extends Schema.TaggedErrorClass + Effect.fail(new ConfigError({ message, cause: error })), + } as const; +} + export function configErrorFromUrl(error: UrlError) { return new ConfigError({ message: diff --git a/src/config/index.ts b/src/config/index.ts index ff14aff..80a6cb5 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -21,5 +21,5 @@ export { layerNode as T3MasterKeyKeystoreFactoryNodeLive } from "./keystore-keyr export { T3ConfigSelection } from "./selection.ts"; export { T3ConfigSelectionLive } from "./selection-layer.ts"; export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; -export { ConfigError, UrlError } from "./error.ts"; +export { ConfigError, KeystoreUnavailableError, UrlError } from "./error.ts"; export type { ConfigServiceError } from "./error.ts"; diff --git a/src/config/keystore-file.ts b/src/config/keystore-file.ts index 3f2ffcb..e37a03a 100644 --- a/src/config/keystore-file.ts +++ b/src/config/keystore-file.ts @@ -4,6 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { + catchEncodingError, catchPlatformError, catchPlatformErrorUnlessNotFound, ConfigError, @@ -34,15 +35,7 @@ export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { return { kind: "missing" }; } const key = yield* Effect.fromResult(Encoding.decodeBase64(raw.trim())).pipe( - Effect.catchTags({ - EncodingError: (error) => - Effect.fail( - new ConfigError({ - message: "invalid credential key file: invalid base64", - cause: error, - }), - ), - }), + Effect.catchTags(catchEncodingError("invalid credential key file: invalid base64")), ); if (key.byteLength !== masterKeyByteLength) { return yield* Effect.fail( diff --git a/src/config/keystore.ts b/src/config/keystore.ts index 3aa31e7..5b07469 100644 --- a/src/config/keystore.ts +++ b/src/config/keystore.ts @@ -19,7 +19,7 @@ export type MasterKeyKeystore = { export class T3MasterKeyKeystoreFactory extends Context.Service< T3MasterKeyKeystoreFactory, { - readonly make: () => Effect.Effect; + readonly make: () => Effect.Effect; } >()("t3cli/T3MasterKeyKeystoreFactory") {} diff --git a/src/config/migration.ts b/src/config/migration.ts index bdb3533..37f0c9e 100644 --- a/src/config/migration.ts +++ b/src/config/migration.ts @@ -33,7 +33,6 @@ export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromVa return { config: migrated, migratedFromV1: true as const }; }).pipe( Effect.catchTags({ - UrlError: (error) => Effect.fail(configErrorFromUrl(error)), SchemaError: (error) => Effect.fail(configErrorFromSchemaError("failed to read config", error)), }), diff --git a/src/config/resolve.ts b/src/config/resolve.ts index bf9152e..f8eb82a 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import { validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError } from "./error.ts"; +import { ConfigError, configErrorFromUrl } from "./error.ts"; import type { EncryptedConfig, ResolvedConfig } from "./types.ts"; import { normalizeHttpBaseUrl } from "./url.ts"; @@ -65,6 +65,7 @@ export function buildResolvedConfigFromEnv(input: { readonly envToken: string; }) { return normalizeHttpBaseUrl(input.envUrl).pipe( + Effect.mapError(configErrorFromUrl), Effect.map((normalizedUrl) => { return { url: normalizedUrl, @@ -89,7 +90,9 @@ export function buildResolvedConfigFromStored(input: { new ConfigError({ message: `environment not found: ${input.selectedName}` }), ); } - const normalizedUrl = yield* normalizeHttpBaseUrl(selectedEnvironment.url); + const normalizedUrl = yield* normalizeHttpBaseUrl(selectedEnvironment.url).pipe( + Effect.mapError(configErrorFromUrl), + ); return { url: normalizedUrl, token: input.token, diff --git a/src/config/service.ts b/src/config/service.ts index db67159..9c9fe50 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -1,13 +1,13 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { ConfigError, ConfigServiceError } from "./error.ts"; +import type { ConfigError } from "./error.ts"; import type { EnvironmentSummary, ResolvedConfig, UpsertEnvironmentInput } from "./types.ts"; export class T3Config extends Context.Service< T3Config, { - readonly resolve: () => Effect.Effect; + readonly resolve: () => Effect.Effect; readonly resolveActiveEnvironmentName: () => Effect.Effect; readonly listEnvironments: () => Effect.Effect; readonly upsertEnvironment: (input: UpsertEnvironmentInput) => Effect.Effect; From ce3f01cd2a3e7a5bb39191d7de7f73f138b0d882 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 22 Jun 2026 22:59:09 +0300 Subject: [PATCH 18/21] Refactor tests to use shared shims and smaller focused suites. Split the monolithic config tests, add src/test layer helpers and ConfigPlatformLayer, and document testing conventions in CONTRIBUTING. Co-authored-by: Cursor --- CONTRIBUTING.md | 8 + src/auth/auth.test.ts | 104 +--- src/config/config.test.ts | 502 ------------------ src/config/credential.test.ts | 182 +++---- src/config/environment-name.test.ts | 65 +++ src/config/layer.test.ts | 209 ++++++++ src/config/migration.test.ts | 71 +++ src/config/persist.test.ts | 113 ++++ src/config/resolve.test.ts | 38 +- src/config/selection-layer.test.ts | 49 ++ src/config/url.test.ts | 29 + src/environment/service.ts | 16 +- src/runtime/layer.test.ts | 44 +- src/test/fixtures/encrypted-config.ts | 20 + src/test/helpers/assert-errors.ts | 47 ++ src/test/helpers/temp-home.ts | 7 + src/test/layers/auth.ts | 34 ++ src/test/layers/cli-config-routing.ts | 32 ++ src/test/layers/config.ts | 46 ++ src/test/layers/credential-crypto.ts | 23 + .../layers/keystore-unavailable.ts} | 4 +- src/test/platform.ts | 20 + 22 files changed, 915 insertions(+), 748 deletions(-) delete mode 100644 src/config/config.test.ts create mode 100644 src/config/environment-name.test.ts create mode 100644 src/config/layer.test.ts create mode 100644 src/config/migration.test.ts create mode 100644 src/config/persist.test.ts create mode 100644 src/config/selection-layer.test.ts create mode 100644 src/config/url.test.ts create mode 100644 src/test/fixtures/encrypted-config.ts create mode 100644 src/test/helpers/assert-errors.ts create mode 100644 src/test/helpers/temp-home.ts create mode 100644 src/test/layers/auth.ts create mode 100644 src/test/layers/cli-config-routing.ts create mode 100644 src/test/layers/config.ts create mode 100644 src/test/layers/credential-crypto.ts rename src/{config/keystore-test.ts => test/layers/keystore-unavailable.ts} (72%) create mode 100644 src/test/platform.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9a4774..e1260a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,14 @@ pnpm release:check Use `pnpm format` and `pnpm lint:fix` for mechanical fixes. +## testing + +- Prefer pure `it()` tests for logic with no service dependencies. +- Use `it.layer(ConfigPlatformLayer)` for config/auth tests that need filesystem, path, and crypto only — not full `NodeServices.layer`. +- Put shared test layers and shims in `src/test/layers/`; use `Environment.layerTest()` for home-dir fixtures. +- Split tests by module/concern; keep integration files under ~120 lines when possible. +- Reserve full app-layer smoke tests for CLI routing and end-to-end wiring only. + ## pull requests Keep changes scoped and small. Include user-facing README updates when command behavior changes. diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 57a503b..55a9adf 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -3,72 +3,20 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { T3LocalAuth } from "./local.ts"; -import { T3AuthPairing } from "./pairing.ts"; -import { T3AuthLive } from "./layer.ts"; import { T3Auth } from "./service.ts"; -import { T3AuthTransport } from "./transport.ts"; -import { layer as T3ConfigLive } from "../config/layer.ts"; -import { layer as T3CredentialCryptoLive } from "../config/credential.ts"; -import { layerWeb as T3CredentialCipherWebLive } from "../config/credential-cipher-web.ts"; -import { unavailableKeystoreFactoryLayer } from "../config/keystore-test.ts"; import { StoredConfigV2FileJson } from "../config/schema.ts"; -import { Environment } from "../environment/service.ts"; -import { T3ConfigSelection } from "../config/selection.ts"; - -function makeAuthLayer(homeDir: string) { - const environmentLayer = Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }); - const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); - const configLayer = T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), - Layer.provide( - Layer.mergeAll( - platformLayer, - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - Layer.succeed(T3ConfigSelection)({ - getSelectedEnvironment: () => Effect.succeed(undefined), - }), - ), - ), - ); - return T3AuthLive.pipe( - Layer.provide( - Layer.mergeAll( - configLayer, - Layer.succeed(T3AuthTransport)({ - bootstrapBearer: () => Effect.die("unused in test"), - getSession: () => Effect.succeed({ authenticated: false }), - issueWebSocketTicket: () => Effect.die("unused in test"), - }), - Layer.succeed(T3LocalAuth)({ - local: () => Effect.die("unused in test"), - }), - Layer.succeed(T3AuthPairing)({ - pair: () => Effect.die("unused in test"), - }), - ), - ), - ); -} +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3AuthDepsLayer } from "../test/layers/auth.ts"; describe("T3Auth persistence", () => { it.effect("fails to persist a duplicate environment without allowReplace", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + return yield* makeTempHomeScoped("t3cli-auth-"); }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { @@ -90,17 +38,16 @@ describe("T3Auth persistence", () => { }) .pipe(Effect.exit); assert.equal(Exit.isFailure(result), true); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))), ), - Effect.provide(NodeServices.layer), + Effect.provide(ConfigPlatformLayer), Effect.scoped, ), ); it.effect("does not change default when replace is used for a new environment", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + return yield* makeTempHomeScoped("t3cli-auth-"); }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { @@ -123,17 +70,16 @@ describe("T3Auth persistence", () => { const listed = yield* auth.listEnvironments(); assert.equal(listed.find((environment) => environment.name === "home")?.default, true); assert.equal(listed.find((environment) => environment.name === "work")?.default, false); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))), ), - Effect.provide(NodeServices.layer), + Effect.provide(ConfigPlatformLayer), Effect.scoped, ), ); it.effect("promotes default when replacing an existing environment with replace", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + return yield* makeTempHomeScoped("t3cli-auth-"); }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { @@ -162,22 +108,21 @@ describe("T3Auth persistence", () => { }); const listed = yield* auth.listEnvironments(); assert.equal(listed.find((environment) => environment.name === "work")?.default, true); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))), ), - Effect.provide(NodeServices.layer), + Effect.provide(ConfigPlatformLayer), Effect.scoped, ), ); it.effect("lists environments without decrypting tokens", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); - return { fs, path, homeDir }; + return yield* makeTempHomeScoped("t3cli-auth-"); }).pipe( - Effect.flatMap(({ fs, path, homeDir }) => + Effect.flatMap((homeDir) => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); yield* Effect.gen(function* () { @@ -196,7 +141,7 @@ describe("T3Auth persistence", () => { local: false, allowReplace: true, }); - }).pipe(Effect.provide(makeAuthLayer(homeDir))); + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))); const raw = yield* fs.readFileString(configPath); const parsed = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); @@ -215,27 +160,24 @@ describe("T3Auth persistence", () => { }, }, }); - yield* fs.writeFileString(configPath, `${corrupted}\n`, { - mode: 0o600, - }); + yield* fs.writeFileString(configPath, `${corrupted}\n`, { mode: 0o600 }); yield* Effect.gen(function* () { const auth = yield* T3Auth; const listed = yield* auth.listEnvironments(); assert.equal(listed.length, 2); assert.equal(listed.find((environment) => environment.name === "home")?.active, true); - }).pipe(Effect.provide(makeAuthLayer(homeDir))); + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))); }), ), - Effect.provide(NodeServices.layer), + Effect.provide(ConfigPlatformLayer), Effect.scoped, ), ); it.effect("resolves unpair target from encrypted default metadata", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-auth-test-" }); + return yield* makeTempHomeScoped("t3cli-auth-"); }).pipe( Effect.flatMap((homeDir) => Effect.gen(function* () { @@ -249,9 +191,9 @@ describe("T3Auth persistence", () => { }); const target = yield* auth.resolveUnpairTarget({}); assert.equal(target, "home"); - }).pipe(Effect.provide(makeAuthLayer(homeDir))), + }).pipe(Effect.provide(t3AuthDepsLayer(homeDir))), ), - Effect.provide(NodeServices.layer), + Effect.provide(ConfigPlatformLayer), Effect.scoped, ), ); diff --git a/src/config/config.test.ts b/src/config/config.test.ts deleted file mode 100644 index 6923e2d..0000000 --- a/src/config/config.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import "vite-plus/test/config"; - -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; - -import { Environment } from "../environment/service.ts"; -import { T3CredentialCrypto, layer as T3CredentialCryptoLive } from "./credential.ts"; -import { layer as T3ConfigLive } from "./layer.ts"; -import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; -import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; -import { ConfigError } from "./error.ts"; -import { migrateV1FileToEncrypted } from "./migration.ts"; -import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; -import { T3ConfigSelection } from "./selection.ts"; -import { T3ConfigSelectionLive } from "./selection-layer.ts"; -import { T3Config } from "./service.ts"; - -function makeEnvironmentLayer(homeDir: string, env: Record = {}) { - return Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env, - stdoutIsTTY: false, - stderrIsTTY: false, - }); -} - -function makeCredentialCryptoLayer(homeDir: string) { - return T3CredentialCryptoLive.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - makeEnvironmentLayer(homeDir), - ), - ), - ); -} - -function makeConfigLayer( - homeDir: string, - input: { - readonly selection?: string; - readonly env?: Record; - readonly useSelectionLive?: boolean; - } = {}, -) { - const environmentLayer = makeEnvironmentLayer(homeDir, input.env ?? {}); - const platformLayer = Layer.mergeAll(NodeServices.layer, environmentLayer); - const selectionLayer = - input.useSelectionLive === true - ? T3ConfigSelectionLive.pipe(Layer.provide(environmentLayer)) - : Layer.succeed(T3ConfigSelection)({ - getSelectedEnvironment: () => Effect.succeed(input.selection), - }); - return T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), - Layer.provide( - Layer.mergeAll( - platformLayer, - selectionLayer, - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - ), - ), - ); -} - -describe("config persistence", () => { - it.effect("migrates v1 flat config and roundtrips encrypted tokens", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const migrated = yield* migrateV1FileToEncrypted({ - url: "https://app.example.com", - token: "secret-token", - local: false, - }); - assert.equal(migrated.default, "app.example.com"); - const crypto = yield* T3CredentialCrypto; - const token = yield* crypto.decrypt({ - environmentName: "app.example.com", - url: "https://app.example.com", - local: false, - token: migrated.environments["app.example.com"]!.token, - }); - assert.equal(token, "secret-token"); - }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("maps invalid v1 urls to ConfigError when migrating directly", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const homeDir = yield* fs.makeTempDirectory({ prefix: "t3cli-migrate-url-error" }); - const exit = yield* migrateV1FileToEncrypted({ - url: "not-a-url", - token: "secret-token", - local: false, - }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir)), Effect.exit); - assert.isTrue(Exit.isFailure(exit)); - if (!Exit.isFailure(exit)) { - return; - } - const error = Cause.findErrorOption(exit.cause); - assert.isTrue(Option.isSome(error)); - if (Option.isSome(error)) { - assert.instanceOf(error.value, ConfigError); - assert.equal(error.value.message, "invalid url"); - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("persists v1 config as encrypted v2 on first read", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - return { fs, path, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, homeDir }) => - Effect.gen(function* () { - const secretToken = "legacy-plaintext-token"; - const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); - yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); - const legacyConfig = yield* Schema.encodeEffect(StoredConfigV1FileJson)({ - url: "https://home.example", - token: secretToken, - local: false, - }); - yield* fs.writeFileString(configPath, `${legacyConfig}\n`, { mode: 0o600 }); - yield* Effect.gen(function* () { - const config = yield* T3Config; - yield* config.listEnvironments(); - }).pipe(Effect.provide(makeConfigLayer(homeDir))); - const raw = yield* fs.readFileString(configPath); - assert.equal(raw.includes(secretToken), false); - const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); - assert.equal(persisted.version, 2); - const environment = Object.values(persisted.environments)[0]; - assert.isDefined(environment); - assert.equal(environment.token.kind, "encrypted"); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect( - "keeps default unchanged when upserting an existing environment without makeDefault", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token-2", - local: false, - }); - const listed = yield* config.listEnvironments(); - assert.equal(listed.find((environment) => environment.name === "home")?.default, true); - assert.equal(listed.find((environment) => environment.name === "work")?.default, false); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("promotes default when upserting with makeDefault", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token-2", - local: false, - makeDefault: true, - }); - const listed = yield* config.listEnvironments(); - assert.equal(listed.find((environment) => environment.name === "home")?.default, false); - assert.equal(listed.find((environment) => environment.name === "work")?.default, true); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("never writes plaintext tokens to config.json", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - return { fs, path, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, homeDir }) => - Effect.gen(function* () { - const secretToken = "super-secret-token-value"; - yield* Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: secretToken, - local: false, - }); - }).pipe(Effect.provide(makeConfigLayer(homeDir))); - const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); - const raw = yield* fs.readFileString(configPath); - assert.equal(raw.includes(secretToken), false); - const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); - assert.equal(persisted.environments.home?.token.kind, "encrypted"); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("clears default when removing the default environment", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: false, - }); - yield* config.removeEnvironment("home"); - const defaultName = yield* config.getDefaultEnvironmentName(); - assert.equal(defaultName, undefined); - const listed = yield* config.listEnvironments(); - assert.equal( - listed.every((environment) => !environment.default), - true, - ); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("resolves selected environment from config selection service", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: true, - }); - const resolved = yield* config.resolve(); - assert.equal(resolved.source, "config"); - if (resolved.source === "config") { - assert.equal(resolved.environment, "work"); - assert.equal(resolved.token, "work-token"); - } - }).pipe(Effect.provide(makeConfigLayer(homeDir, { selection: "work" }))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("resolves T3CLI_ENV through the selection layer", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - yield* config.upsertEnvironment({ - name: "work", - url: "https://work.example", - token: "work-token", - local: true, - }); - const resolved = yield* config.resolve(); - assert.equal(resolved.source, "config"); - if (resolved.source === "config") { - assert.equal(resolved.environment, "work"); - } - }).pipe( - Effect.provide( - makeConfigLayer(homeDir, { - useSelectionLive: true, - env: { T3CLI_ENV: "work" }, - }), - ), - ), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect( - "resolves env override with local=false even when selected stored environment is local", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "work", - url: "http://localhost:8787", - token: "work-token", - local: true, - }); - const resolved = yield* config.resolve(); - assert.equal(resolved.source, "env"); - assert.equal(resolved.local, false); - assert.equal(resolved.url, "https://remote.example"); - assert.equal(resolved.token, "env-token"); - }).pipe( - Effect.provide( - makeConfigLayer(homeDir, { - selection: "work", - env: { - T3CODE_URL: "https://remote.example", - T3CODE_TOKEN: "env-token", - }, - }), - ), - ), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("reads default environment name without decrypting tokens", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - const defaultName = yield* config.getDefaultEnvironmentName(); - assert.equal(defaultName, "home"); - }).pipe(Effect.provide(makeConfigLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("hardens existing config file permissions to 0600 on write", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - return { fs, path, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, homeDir }) => - Effect.gen(function* () { - const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); - yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); - const emptyConfig = yield* Schema.encodeEffect(StoredConfigV2FileJson)({ - version: 2, - environments: {}, - }); - yield* fs.writeFileString(configPath, `${emptyConfig}\n`, { mode: 0o644 }); - yield* Effect.gen(function* () { - const config = yield* T3Config; - yield* config.upsertEnvironment({ - name: "home", - url: "https://home.example", - token: "home-token", - local: false, - }); - }).pipe(Effect.provide(makeConfigLayer(homeDir))); - const configStat = yield* fs.stat(configPath); - assert.equal(configStat.mode & 0o777, 0o600); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("fails decrypt when ciphertext AAD does not match", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-config-test-" }); - }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const crypto = yield* T3CredentialCrypto; - const token = yield* crypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret", - }); - const result = yield* crypto - .decrypt({ - environmentName: "home", - url: "https://tampered.example", - local: false, - token, - }) - .pipe(Effect.exit); - assert.equal(Exit.isFailure(result), true); - }).pipe(Effect.provide(makeCredentialCryptoLayer(homeDir))), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); -}); diff --git a/src/config/credential.test.ts b/src/config/credential.test.ts index 8bfacff..9764c43 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential.test.ts @@ -2,36 +2,20 @@ import "vite-plus/test/config"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { expect } from "vite-plus/test"; -import { Environment } from "../environment/service.ts"; -import { make } from "./credential.ts"; -import { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; +import { T3CredentialCrypto } from "./credential.ts"; import { parseKeyringPassword } from "./keystore-keyring-node.ts"; import { shouldUseFileKeystoreForRead } from "./keystore.ts"; -import { unavailableKeystoreFactoryLayer } from "./keystore-test.ts"; +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3CredentialCryptoDepsLayer } from "../test/layers/credential-crypto.ts"; -function makeCredentialLayer(homeDir: string) { - return Layer.mergeAll( - NodeServices.layer, - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }), - ); -} - -describe("keyring fallback", () => { +describe("parseKeyringPassword", () => { it("treats invalid stored keyring values as corrupt", () => { const result = parseKeyringPassword("not-a-valid-key"); assert.equal(result.kind, "corrupt"); @@ -42,42 +26,38 @@ describe("keyring fallback", () => { const result = { kind: "unavailable" as const, message: "failed" }; expect(shouldUseFileKeystoreForRead(result)).toBe(true); }); +}); +describe("T3CredentialCrypto", () => { it.effect("falls back to key file when keyring backend is unavailable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cryptoService = yield* Crypto.Crypto; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); - return { fs, path, cryptoService, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, cryptoService, homeDir }) => - Effect.gen(function* () { - const keyPath = path.join(homeDir, ".config", "t3cli", "key"); - const masterKey = yield* cryptoService.randomBytes(32); - yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); - yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { - mode: 0o600, - }); - const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); - const encrypted = yield* credentialCrypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret-token", - }); - const token = yield* credentialCrypto.decrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: encrypted, - }); - assert.equal(token, "secret-token"); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + const homeDir = yield* makeTempHomeScoped("t3cli-credential-"); + yield* Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const masterKey = yield* cryptoService.randomBytes(32); + yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { + mode: 0o600, + }); + const credentialCrypto = yield* T3CredentialCrypto; + const encrypted = yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }); + const token = yield* credentialCrypto.decrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: encrypted, + }); + assert.equal(token, "secret-token"); + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir))); + }).pipe(Effect.provide(ConfigPlatformLayer), Effect.scoped), ); it.effect("hardens existing key file permissions to 0600 on use", () => @@ -85,56 +65,68 @@ describe("keyring fallback", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cryptoService = yield* Crypto.Crypto; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); - return { fs, path, cryptoService, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, cryptoService, homeDir }) => - Effect.gen(function* () { - const keyPath = path.join(homeDir, ".config", "t3cli", "key"); - const masterKey = yield* cryptoService.randomBytes(32); - yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); - yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { - mode: 0o644, - }); - const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); - yield* credentialCrypto.encrypt({ - environmentName: "home", - url: "https://home.example", - local: false, - token: "secret-token", - }); - const keyStat = yield* fs.stat(keyPath); - assert.equal(keyStat.mode & 0o777, 0o600); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + const homeDir = yield* makeTempHomeScoped("t3cli-credential-"); + yield* Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const masterKey = yield* cryptoService.randomBytes(32); + yield* fs.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fs.writeFileString(keyPath, `${Buffer.from(masterKey).toString("base64")}\n`, { + mode: 0o644, + }); + const credentialCrypto = yield* T3CredentialCrypto; + yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret-token", + }); + const keyStat = yield* fs.stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir))); + }).pipe(Effect.provide(ConfigPlatformLayer), Effect.scoped), ); it.effect("creates key file with 0600 permissions when keyring is unavailable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-credential-test-" }); - return { fs, path, homeDir }; - }).pipe( - Effect.flatMap(({ fs, path, homeDir }) => - Effect.gen(function* () { - const keyPath = path.join(homeDir, ".config", "t3cli", "key"); - const credentialCrypto = yield* make().pipe(Effect.provide(makeCredentialLayer(homeDir))); - yield* credentialCrypto.encrypt({ + const homeDir = yield* makeTempHomeScoped("t3cli-credential-"); + yield* Effect.gen(function* () { + const keyPath = path.join(homeDir, ".config", "t3cli", "key"); + const credentialCrypto = yield* T3CredentialCrypto; + yield* credentialCrypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "rotate-write", + }); + const keyStat = yield* fs.stat(keyPath); + assert.equal(keyStat.mode & 0o777, 0o600); + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir))); + }).pipe(Effect.provide(ConfigPlatformLayer), Effect.scoped), + ); + + it.effect("fails decrypt when ciphertext AAD does not match", () => + Effect.gen(function* () { + const homeDir = yield* makeTempHomeScoped("t3cli-credential-"); + yield* Effect.gen(function* () { + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.encrypt({ + environmentName: "home", + url: "https://home.example", + local: false, + token: "secret", + }); + const result = yield* crypto + .decrypt({ environmentName: "home", - url: "https://home.example", + url: "https://tampered.example", local: false, - token: "rotate-write", - }); - const keyStat = yield* fs.stat(keyPath); - assert.equal(keyStat.mode & 0o777, 0o600); - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + token, + }) + .pipe(Effect.exit); + assert.equal(Exit.isFailure(result), true); + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir))); + }).pipe(Effect.provide(ConfigPlatformLayer), Effect.scoped), ); }); diff --git a/src/config/environment-name.test.ts b/src/config/environment-name.test.ts new file mode 100644 index 0000000..93f400d --- /dev/null +++ b/src/config/environment-name.test.ts @@ -0,0 +1,65 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { assert, describe, it } from "@effect/vitest"; + +import { + defaultEnvironmentNameForLocal, + defaultEnvironmentNameFromUrl, + migrateV1EnvironmentName, + slugifyEnvironmentName, + validateEnvironmentName, +} from "./environment-name.ts"; +import { expectConfigError } from "../test/helpers/assert-errors.ts"; + +describe("slugifyEnvironmentName", () => { + it("slugifies invalid characters", () => { + assert.equal(slugifyEnvironmentName("app.example.com"), "app.example.com"); + assert.equal(slugifyEnvironmentName("---"), "default"); + }); +}); + +describe("validateEnvironmentName", () => { + it.effect("rejects empty and invalid names", () => + Effect.gen(function* () { + const empty = yield* validateEnvironmentName("").pipe(Effect.exit); + assert.equal(Exit.isFailure(empty), true); + const invalid = yield* validateEnvironmentName("bad name").pipe(Effect.exit); + assert.equal(Exit.isFailure(invalid), true); + yield* validateEnvironmentName("valid.name-1"); + }), + ); +}); + +describe("defaultEnvironmentNameFromUrl", () => { + it.effect("derives hostname from normalized url", () => + Effect.gen(function* () { + const name = yield* defaultEnvironmentNameFromUrl("https://app.example.com/path"); + assert.equal(name, "app.example.com"); + }), + ); + + it.effect("maps invalid urls to ConfigError", () => + expectConfigError(defaultEnvironmentNameFromUrl("not-a-url"), "invalid url"), + ); +}); + +describe("migrateV1EnvironmentName", () => { + it.effect("uses local name for local configs", () => + Effect.gen(function* () { + const name = yield* migrateV1EnvironmentName({ local: true }); + assert.equal(name, defaultEnvironmentNameForLocal()); + }), + ); + + it.effect("derives name from url when present", () => + Effect.gen(function* () { + const name = yield* migrateV1EnvironmentName({ + url: "https://work.example", + local: false, + }); + assert.equal(name, "work.example"); + }), + ); +}); diff --git a/src/config/layer.test.ts b/src/config/layer.test.ts new file mode 100644 index 0000000..af5af1a --- /dev/null +++ b/src/config/layer.test.ts @@ -0,0 +1,209 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +import { assert, describe, it } from "@effect/vitest"; + +import { T3Config } from "./service.ts"; +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3ConfigDepsLayer } from "../test/layers/config.ts"; + +describe("T3Config", () => { + it.effect( + "keeps default unchanged when upserting an existing environment without makeDefault", + () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + }); + const listed = yield* config.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, true); + assert.equal(listed.find((environment) => environment.name === "work")?.default, false); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("promotes default when upserting with makeDefault", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token-2", + local: false, + makeDefault: true, + }); + const listed = yield* config.listEnvironments(); + assert.equal(listed.find((environment) => environment.name === "home")?.default, false); + assert.equal(listed.find((environment) => environment.name === "work")?.default, true); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("clears default when removing the default environment", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: false, + }); + yield* config.removeEnvironment("home"); + const defaultName = yield* config.getDefaultEnvironmentName(); + assert.equal(defaultName, undefined); + const listed = yield* config.listEnvironments(); + assert.equal( + listed.every((environment) => !environment.default), + true, + ); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("resolves selected environment from config selection service", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + assert.equal(resolved.token, "work-token"); + } + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir, { selection: "work" }))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect( + "resolves env override with local=false even when selected stored environment is local", + () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "work", + url: "http://localhost:8787", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "env"); + assert.equal(resolved.local, false); + assert.equal(resolved.url, "https://remote.example"); + assert.equal(resolved.token, "env-token"); + }).pipe( + Effect.provide( + t3ConfigDepsLayer(homeDir, { + selection: "work", + env: { + T3CODE_URL: "https://remote.example", + T3CODE_TOKEN: "env-token", + }, + }), + ), + ), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("reads default environment name without decrypting tokens", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-config-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + const defaultName = yield* config.getDefaultEnvironmentName(); + assert.equal(defaultName, "home"); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); +}); diff --git a/src/config/migration.test.ts b/src/config/migration.test.ts new file mode 100644 index 0000000..f48a6d6 --- /dev/null +++ b/src/config/migration.test.ts @@ -0,0 +1,71 @@ +import "vite-plus/test/config"; + +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { assert, describe, it } from "@effect/vitest"; + +import { ConfigError } from "./error.ts"; +import { T3CredentialCrypto } from "./credential.ts"; +import { migrateV1FileToEncrypted } from "./migration.ts"; +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3CredentialCryptoDepsLayer } from "../test/layers/credential-crypto.ts"; + +describe("migrateV1FileToEncrypted", () => { + it.effect("migrates v1 flat config and roundtrips encrypted tokens", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-migration-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const migrated = yield* migrateV1FileToEncrypted({ + url: "https://app.example.com", + token: "secret-token", + local: false, + }); + assert.equal(migrated.default, "app.example.com"); + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.decrypt({ + environmentName: "app.example.com", + url: "https://app.example.com", + local: false, + token: migrated.environments["app.example.com"]!.token, + }); + assert.equal(token, "secret-token"); + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("maps invalid v1 urls to ConfigError when migrating directly", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-migrate-url-error-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const exit = yield* migrateV1FileToEncrypted({ + url: "not-a-url", + token: "secret-token", + local: false, + }).pipe(Effect.provide(t3CredentialCryptoDepsLayer(homeDir)), Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + if (!Exit.isFailure(exit)) { + return; + } + const error = Cause.findErrorOption(exit.cause); + assert.isTrue(Option.isSome(error)); + if (Option.isSome(error)) { + assert.instanceOf(error.value, ConfigError); + assert.equal(error.value.message, "invalid url"); + } + }), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); +}); diff --git a/src/config/persist.test.ts b/src/config/persist.test.ts new file mode 100644 index 0000000..1d357d0 --- /dev/null +++ b/src/config/persist.test.ts @@ -0,0 +1,113 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { assert, describe, it } from "@effect/vitest"; + +import { T3Config } from "./service.ts"; +import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3ConfigDepsLayer } from "../test/layers/config.ts"; + +describe("config file persistence", () => { + it.effect("persists v1 config as encrypted v2 on first read", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-persist-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretToken = "legacy-plaintext-token"; + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + const legacyConfig = yield* Schema.encodeEffect(StoredConfigV1FileJson)({ + url: "https://home.example", + token: secretToken, + local: false, + }); + yield* fs.writeFileString(configPath, `${legacyConfig}\n`, { mode: 0o600 }); + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.listEnvironments(); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))); + const raw = yield* fs.readFileString(configPath); + assert.equal(raw.includes(secretToken), false); + const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); + assert.equal(persisted.version, 2); + const environment = Object.values(persisted.environments)[0]; + assert.isDefined(environment); + assert.equal(environment.token.kind, "encrypted"); + }), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("never writes plaintext tokens to config.json", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-persist-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretToken = "super-secret-token-value"; + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: secretToken, + local: false, + }); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))); + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + const raw = yield* fs.readFileString(configPath); + assert.equal(raw.includes(secretToken), false); + const persisted = yield* Schema.decodeUnknownEffect(StoredConfigV2FileJson)(raw); + assert.equal(persisted.environments.home?.token.kind, "encrypted"); + }), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("hardens existing config file permissions to 0600 on write", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-persist-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(homeDir, ".config", "t3cli", "config.json"); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + const emptyConfig = yield* Schema.encodeEffect(StoredConfigV2FileJson)({ + version: 2, + environments: {}, + }); + yield* fs.writeFileString(configPath, `${emptyConfig}\n`, { mode: 0o644 }); + yield* Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + }).pipe(Effect.provide(t3ConfigDepsLayer(homeDir))); + const configStat = yield* fs.stat(configPath); + assert.equal(configStat.mode & 0o777, 0o600); + }), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); +}); diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index 8aadb72..3c97f2c 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -11,16 +11,7 @@ import { validateCredentialEnvVars, } from "./resolve.ts"; import { resolveConfiguredEnvironment } from "./selection-resolve.ts"; -import type { EncryptedConfig } from "./types.ts"; - -const sampleEncrypted = (input: { - readonly default?: string; - readonly environments?: EncryptedConfig["environments"]; -}): EncryptedConfig => ({ - version: 2, - ...(input.default !== undefined ? { default: input.default } : {}), - environments: input.environments ?? {}, -}); +import { sampleEncrypted, sampleEncryptedToken } from "../test/fixtures/encrypted-config.ts"; describe("resolveConfiguredEnvironment", () => { it("prefers cli flag over T3CLI_ENV", () => { @@ -109,14 +100,7 @@ describe("resolveDefaultForUpsert", () => { home: { url: "https://home.example", local: false, - token: { - kind: "encrypted", - alg: "aes-256-gcm", - key: "default", - nonce: "n", - ciphertext: "c", - tag: "t", - }, + token: sampleEncryptedToken(), }, }, }), @@ -135,26 +119,12 @@ describe("resolveDefaultForUpsert", () => { home: { url: "https://home.example", local: false, - token: { - kind: "encrypted", - alg: "aes-256-gcm", - key: "default", - nonce: "n", - ciphertext: "c", - tag: "t", - }, + token: sampleEncryptedToken(), }, work: { url: "https://work.example", local: false, - token: { - kind: "encrypted", - alg: "aes-256-gcm", - key: "default", - nonce: "n2", - ciphertext: "c2", - tag: "t2", - }, + token: { ...sampleEncryptedToken(), nonce: "n2", ciphertext: "c2", tag: "t2" }, }, }, }), diff --git a/src/config/selection-layer.test.ts b/src/config/selection-layer.test.ts new file mode 100644 index 0000000..586b47d --- /dev/null +++ b/src/config/selection-layer.test.ts @@ -0,0 +1,49 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +import { assert, describe, it } from "@effect/vitest"; + +import { T3Config } from "./service.ts"; +import { ConfigPlatformLayer } from "../test/platform.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { t3ConfigDepsLayer } from "../test/layers/config.ts"; + +describe("T3ConfigSelectionLive", () => { + it.effect("resolves T3CLI_ENV through the selection layer", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-selection-"); + }).pipe( + Effect.flatMap((homeDir) => + Effect.gen(function* () { + const config = yield* T3Config; + yield* config.upsertEnvironment({ + name: "home", + url: "https://home.example", + token: "home-token", + local: false, + }); + yield* config.upsertEnvironment({ + name: "work", + url: "https://work.example", + token: "work-token", + local: true, + }); + const resolved = yield* config.resolve(); + assert.equal(resolved.source, "config"); + if (resolved.source === "config") { + assert.equal(resolved.environment, "work"); + } + }).pipe( + Effect.provide( + t3ConfigDepsLayer(homeDir, { + useSelectionLive: true, + env: { T3CLI_ENV: "work" }, + }), + ), + ), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); +}); diff --git a/src/config/url.test.ts b/src/config/url.test.ts new file mode 100644 index 0000000..ad0053d --- /dev/null +++ b/src/config/url.test.ts @@ -0,0 +1,29 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +import { assert, describe, it } from "@effect/vitest"; + +import { normalizeHttpBaseUrl, toWebSocketEndpointUrl } from "./url.ts"; +import { expectUrlError } from "../test/helpers/assert-errors.ts"; + +describe("normalizeHttpBaseUrl", () => { + it.effect("strips query and trailing slash from urls with paths", () => + Effect.gen(function* () { + const normalized = yield* normalizeHttpBaseUrl("https://app.example.com/api/?q=1"); + assert.equal(normalized, "https://app.example.com/api"); + }), + ); + + it.effect("maps invalid urls to UrlError", () => + expectUrlError(normalizeHttpBaseUrl("not-a-url"), "invalid url"), + ); +}); + +describe("toWebSocketEndpointUrl", () => { + it.effect("rejects unsupported protocols with UrlError", () => + expectUrlError( + toWebSocketEndpointUrl("ftp://example.com", "/ws"), + "unsupported server url protocol: ftp:", + ), + ); +}); diff --git a/src/environment/service.ts b/src/environment/service.ts index 637a794..268865f 100644 --- a/src/environment/service.ts +++ b/src/environment/service.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; export type EnvironmentShape = { readonly cwd: string; @@ -10,4 +11,17 @@ export type EnvironmentShape = { export class Environment extends Context.Service()( "t3cli/Environment", -) {} +) { + static layerTest = (input: { + readonly homeDir: string; + readonly cwd?: string; + readonly env?: Readonly>; + }) => + Layer.succeed(Environment, { + cwd: input.cwd ?? input.homeDir, + homeDir: input.homeDir, + env: input.env ?? {}, + stdoutIsTTY: false, + stderrIsTTY: false, + }); +} diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index de63055..65d11d8 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -1,49 +1,27 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { Command } from "effect/unstable/cli"; -import { T3CliConfigSelectionLive } from "../cli/selection-layer.ts"; -import { layerNode as T3CredentialCipherNodeLive } from "../config/credential-cipher-node.ts"; -import { unavailableKeystoreFactoryLayer } from "../config/keystore-test.ts"; import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; -import { Environment } from "../environment/service.ts"; import type { ResolvedConfig } from "../config/types.ts"; import { T3Config } from "../config/service.ts"; -import { BaseAppLayer } from "./layer.ts"; +import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; +import { cliConfigRoutingLayerTest } from "../test/layers/cli-config-routing.ts"; -function makeCliAppLayer(homeDir: string) { - const environmentLayer = Layer.succeed(Environment)({ - cwd: homeDir, - homeDir, - env: { T3CLI_ENV: "home" }, - stdoutIsTTY: false, - stderrIsTTY: false, - }); - return BaseAppLayer.pipe( - Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(T3CredentialCipherNodeLive), - Layer.provide(unavailableKeystoreFactoryLayer), - Layer.provide(Layer.mergeAll(NodeServices.layer, environmentLayer)), - ); -} - -describe("CLI app layer composition", () => { +describe("CLI config routing", () => { it.effect( "routes --environment through Command.run to T3Config.resolve ahead of default and T3CLI_ENV", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3cli-runtime-test-" }); + return yield* makeTempHomeScoped("t3cli-runtime-"); }).pipe( - Effect.flatMap((homeDir) => - Effect.gen(function* () { - const cliAppLayer = makeCliAppLayer(homeDir); + Effect.flatMap((homeDir) => { + const testLayer = cliConfigRoutingLayerTest(homeDir); + return Effect.gen(function* () { const resolvedRef = yield* Ref.make(undefined); const resolveProbeCommand = Command.make("resolve-probe", {}, () => Effect.gen(function* () { @@ -68,9 +46,9 @@ describe("CLI app layer composition", () => { token: "work-token", local: false, }); - }).pipe(Effect.provide(cliAppLayer)); + }).pipe(Effect.provide(testLayer)); - yield* runResolveProbe(["--environment", "work"]).pipe(Effect.provide(cliAppLayer)); + yield* runResolveProbe(["--environment", "work"]).pipe(Effect.provide(testLayer)); const resolved = yield* Ref.get(resolvedRef); assert.isDefined(resolved); @@ -79,8 +57,8 @@ describe("CLI app layer composition", () => { assert.equal(resolved.environment, "work"); assert.equal(resolved.token, "work-token"); } - }), - ), + }); + }), Effect.provide(NodeServices.layer), Effect.scoped, ), diff --git a/src/test/fixtures/encrypted-config.ts b/src/test/fixtures/encrypted-config.ts new file mode 100644 index 0000000..2bbcb81 --- /dev/null +++ b/src/test/fixtures/encrypted-config.ts @@ -0,0 +1,20 @@ +import type { EncryptedConfig } from "../../config/types.ts"; + +export const sampleEncrypted = (input: { + readonly default?: string; + readonly environments?: EncryptedConfig["environments"]; +}): EncryptedConfig => ({ + version: 2, + ...(input.default !== undefined ? { default: input.default } : {}), + environments: input.environments ?? {}, +}); + +export const sampleEncryptedToken = () => + ({ + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: "n", + ciphertext: "c", + tag: "t", + }) as const; diff --git a/src/test/helpers/assert-errors.ts b/src/test/helpers/assert-errors.ts new file mode 100644 index 0000000..c088d13 --- /dev/null +++ b/src/test/helpers/assert-errors.ts @@ -0,0 +1,47 @@ +import { assert } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; + +import { ConfigError, UrlError } from "../../config/error.ts"; + +function assertExitFailure( + exit: Exit.Exit, +): asserts exit is Exit.Failure { + if (!Exit.isFailure(exit)) { + throw new Error("expected failure"); + } +} + +export const expectConfigError = ( + effect: Effect.Effect, + message: string, +): Effect.Effect => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit); + assertExitFailure(exit); + const error = Cause.findErrorOption(exit.cause); + if (Option.isNone(error)) { + return yield* Effect.die("expected failure"); + } + assert.instanceOf(error.value, ConfigError); + assert.equal(error.value.message, message); + return yield* Effect.void; + }); + +export const expectUrlError = ( + effect: Effect.Effect, + message: string, +): Effect.Effect => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit); + assertExitFailure(exit); + const error = Cause.findErrorOption(exit.cause); + if (Option.isNone(error)) { + return yield* Effect.die("expected failure"); + } + assert.instanceOf(error.value, UrlError); + assert.equal(error.value.message, message); + return yield* Effect.void; + }); diff --git a/src/test/helpers/temp-home.ts b/src/test/helpers/temp-home.ts new file mode 100644 index 0000000..7414960 --- /dev/null +++ b/src/test/helpers/temp-home.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +export const makeTempHomeScoped = Effect.fn("makeTempHomeScoped")(function* (prefix: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); +}); diff --git a/src/test/layers/auth.ts b/src/test/layers/auth.ts new file mode 100644 index 0000000..c27af9e --- /dev/null +++ b/src/test/layers/auth.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { T3AuthLive } from "../../auth/layer.ts"; +import { T3LocalAuth } from "../../auth/local.ts"; +import { T3AuthPairing } from "../../auth/pairing.ts"; +import { T3AuthTransport } from "../../auth/transport.ts"; +import { t3ConfigDepsLayer } from "./config.ts"; +import { ConfigPlatformLayer } from "../platform.ts"; + +export function t3AuthDepsLayer(homeDir: string) { + return T3AuthLive.pipe( + Layer.provide( + Layer.mergeAll( + t3ConfigDepsLayer(homeDir), + Layer.succeed(T3AuthTransport)({ + bootstrapBearer: () => Effect.die("unused in test"), + getSession: () => Effect.succeed({ authenticated: false }), + issueWebSocketTicket: () => Effect.die("unused in test"), + }), + Layer.succeed(T3LocalAuth)({ + local: () => Effect.die("unused in test"), + }), + Layer.succeed(T3AuthPairing)({ + pair: () => Effect.die("unused in test"), + }), + ), + ), + ); +} + +export function t3AuthLayerTest(homeDir: string) { + return Layer.mergeAll(ConfigPlatformLayer, t3AuthDepsLayer(homeDir)); +} diff --git a/src/test/layers/cli-config-routing.ts b/src/test/layers/cli-config-routing.ts new file mode 100644 index 0000000..6768b79 --- /dev/null +++ b/src/test/layers/cli-config-routing.ts @@ -0,0 +1,32 @@ +import * as Layer from "effect/Layer"; + +import { T3CliConfigSelectionLive } from "../../cli/selection-layer.ts"; +import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; +import { layer as T3ConfigLive } from "../../config/layer.ts"; +import { layerNode as T3CredentialCipherNodeLive } from "../../config/credential-cipher-node.ts"; +import { Environment } from "../../environment/service.ts"; +import { ConfigPlatformLayer } from "../platform.ts"; +import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; + +export function cliConfigRoutingLayerTest(homeDir: string) { + const environmentLayer = Environment.layerTest({ + homeDir, + env: { T3CLI_ENV: "home" }, + }); + + return Layer.mergeAll( + ConfigPlatformLayer, + environmentLayer, + T3ConfigLive.pipe( + Layer.provide(T3CredentialCryptoLive), + Layer.provide( + Layer.mergeAll( + environmentLayer, + T3CliConfigSelectionLive.pipe(Layer.provide(environmentLayer)), + T3CredentialCipherNodeLive, + unavailableKeystoreFactoryLayer, + ), + ), + ), + ); +} diff --git a/src/test/layers/config.ts b/src/test/layers/config.ts new file mode 100644 index 0000000..4c522cd --- /dev/null +++ b/src/test/layers/config.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { layer as T3ConfigLive } from "../../config/layer.ts"; +import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; +import { layerWeb as T3CredentialCipherWebLive } from "../../config/credential-cipher-web.ts"; +import { T3ConfigSelection } from "../../config/selection.ts"; +import { T3ConfigSelectionLive } from "../../config/selection-layer.ts"; +import { Environment } from "../../environment/service.ts"; +import { ConfigPlatformLayer } from "../platform.ts"; +import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; + +export type T3ConfigLayerTestInput = { + readonly selection?: string; + readonly env?: Readonly>; + readonly useSelectionLive?: boolean; +}; + +export function t3ConfigDepsLayer(homeDir: string, input: T3ConfigLayerTestInput = {}) { + const environmentLayer = Environment.layerTest({ + homeDir, + ...(input.env !== undefined ? { env: input.env } : {}), + }); + const selectionLayer = + input.useSelectionLive === true + ? T3ConfigSelectionLive.pipe(Layer.provide(environmentLayer)) + : Layer.succeed(T3ConfigSelection)({ + getSelectedEnvironment: () => Effect.succeed(input.selection), + }); + + return T3ConfigLive.pipe( + Layer.provide(T3CredentialCryptoLive), + Layer.provide( + Layer.mergeAll( + environmentLayer, + selectionLayer, + T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, + ), + ), + ); +} + +export function t3ConfigLayerTest(homeDir: string, input: T3ConfigLayerTestInput = {}) { + return Layer.mergeAll(ConfigPlatformLayer, t3ConfigDepsLayer(homeDir, input)); +} diff --git a/src/test/layers/credential-crypto.ts b/src/test/layers/credential-crypto.ts new file mode 100644 index 0000000..3cad80b --- /dev/null +++ b/src/test/layers/credential-crypto.ts @@ -0,0 +1,23 @@ +import * as Layer from "effect/Layer"; + +import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; +import { layerWeb as T3CredentialCipherWebLive } from "../../config/credential-cipher-web.ts"; +import { Environment } from "../../environment/service.ts"; +import { ConfigPlatformLayer } from "../platform.ts"; +import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; + +export function t3CredentialCryptoDepsLayer(homeDir: string) { + return T3CredentialCryptoLive.pipe( + Layer.provide( + Layer.mergeAll( + Environment.layerTest({ homeDir }), + T3CredentialCipherWebLive, + unavailableKeystoreFactoryLayer, + ), + ), + ); +} + +export function t3CredentialCryptoLayerTest(homeDir: string) { + return Layer.mergeAll(ConfigPlatformLayer, t3CredentialCryptoDepsLayer(homeDir)); +} diff --git a/src/config/keystore-test.ts b/src/test/layers/keystore-unavailable.ts similarity index 72% rename from src/config/keystore-test.ts rename to src/test/layers/keystore-unavailable.ts index b71e83f..39925a4 100644 --- a/src/config/keystore-test.ts +++ b/src/test/layers/keystore-unavailable.ts @@ -1,8 +1,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { KeystoreUnavailableError } from "./error.ts"; -import { T3MasterKeyKeystoreFactory } from "./keystore.ts"; +import { KeystoreUnavailableError } from "../../config/error.ts"; +import { T3MasterKeyKeystoreFactory } from "../../config/keystore.ts"; export const unavailableKeystoreFactoryLayer = Layer.succeed(T3MasterKeyKeystoreFactory)({ make: () => diff --git a/src/test/platform.ts b/src/test/platform.ts new file mode 100644 index 0000000..f92dbfb --- /dev/null +++ b/src/test/platform.ts @@ -0,0 +1,20 @@ +import * as Layer from "effect/Layer"; +import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeStdio from "@effect/platform-node/NodeStdio"; +import * as NodeTerminal from "@effect/platform-node/NodeTerminal"; + +export const ConfigPlatformLayer = Layer.mergeAll( + NodeFileSystem.layer, + NodePath.layer, + NodeCrypto.layer, +); + +export const CliCommandPlatformLayer = Layer.mergeAll( + ConfigPlatformLayer, + NodeStdio.layer, + NodeTerminal.layer, + NodeChildProcessSpawner.layer, +); From a088f8b351d6015fa182db92dcb5dab8f53f49dc Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 23 Jun 2026 00:10:11 +0300 Subject: [PATCH 19/21] Restructure config/cli modules and tighten the library export surface. Colocate feature folders and tests, apply Effect service conventions, drop cross-module re-export shims, and document public package.json subpaths for library consumers. Co-authored-by: Cursor --- CONTRIBUTING.md | 6 +- package.json | 29 +++-- src/application/index.ts | 5 - src/application/project-commands.ts | 6 +- src/application/projects.ts | 13 +-- src/application/threads.test.ts | 10 +- src/application/threads.ts | 6 +- src/auth/error.ts | 7 +- .../auth.ts => auth/layer.test-utils.ts} | 12 +- src/auth/layer.ts | 38 +++--- src/auth/local-base-dir.ts | 19 ++- src/auth/local-origin.ts | 17 +-- src/auth/local-token.ts | 49 ++++---- src/auth/{auth.test.ts => service.test.ts} | 8 +- src/auth/transport.ts | 2 +- src/bin.ts | 23 ++-- src/cli/app.ts | 2 +- src/cli/auth.ts | 56 +++++---- src/cli/env.ts | 103 ++++++++++++++++ src/cli/{environment-flag.ts => env/flag.ts} | 0 src/cli/env/index.ts | 2 + .../env}/selection-layer.test.ts | 8 +- src/cli/{ => env}/selection-layer.ts | 14 +-- src/cli/flags.ts | 6 +- src/cli/{auth-format.ts => format/auth.ts} | 2 +- src/cli/format/index.ts | 12 ++ src/cli/{model-format.ts => format/model.ts} | 0 src/cli/format/output.ts | 51 ++++++++ .../{project-format.ts => format/project.ts} | 0 .../terminal.ts} | 0 .../thread.test.ts} | 2 +- .../{thread-format.ts => format/thread.ts} | 6 +- src/cli/index.ts | 20 ++++ src/cli/{ => interaction}/confirm.ts | 17 +-- src/cli/{ => interaction}/self-action.ts | 14 ++- src/cli/model.ts | 12 +- src/cli/output-format.ts | 45 ------- src/cli/project.ts | 17 +-- src/cli/projects/delete.ts | 23 ++-- src/cli/require.ts | 16 +-- src/cli/runtime/index.ts | 1 + src/cli/runtime/service.ts | 28 +++++ src/{ => cli}/scope/index.ts | 0 src/{ => cli}/scope/resolve.ts | 20 ++-- src/cli/terminal/attach.ts | 7 +- src/cli/terminal/commands.test.ts | 101 ---------------- src/cli/terminal/create.ts | 17 ++- src/cli/terminal/destroy.ts | 22 ++-- src/cli/terminal/list.ts | 17 ++- src/cli/terminal/read.test-utils.ts | 48 ++++++++ src/cli/terminal/read.test.ts | 35 ++++++ src/cli/terminal/read.ts | 7 +- src/cli/terminal/scope.ts | 15 ++- src/cli/terminal/stream.ts | 7 +- src/cli/terminal/wait.test.ts | 8 +- src/cli/terminal/wait.ts | 15 ++- src/cli/terminal/write.test.ts | 34 ++++++ src/cli/terminal/write.ts | 17 ++- src/cli/threads/approve.ts | 14 ++- src/cli/threads/archive.ts | 19 +-- src/cli/threads/callback.ts | 8 +- src/cli/threads/delete.ts | 26 +++-- src/cli/threads/interrupt.ts | 19 +-- src/cli/threads/list.test.ts | 8 +- src/cli/threads/list.ts | 18 ++- src/cli/threads/messages.ts | 16 +-- src/cli/threads/respond.ts | 14 ++- src/cli/threads/send.ts | 26 +++-- src/cli/threads/show.ts | 16 +-- src/cli/threads/start.ts | 29 ++--- src/cli/threads/unarchive.ts | 14 ++- src/cli/threads/update.ts | 19 +-- src/cli/threads/wait.ts | 16 +-- src/cli/wait-events.ts | 2 +- src/config/{layer.ts => config.ts} | 110 ++++++++++-------- .../cipher-node.ts} | 2 +- .../cipher-web.ts} | 2 +- .../cipher.ts} | 0 src/config/credential/env.ts | 13 +++ src/config/credential/error.ts | 9 ++ src/config/credential/index.ts | 6 + src/config/credential/service.test-utils.ts | 25 ++++ .../service.test.ts} | 24 +--- .../{credential.ts => credential/service.ts} | 105 +++++++++-------- src/config/env/env.test-utils.ts | 14 +++ src/config/env/env.ts | 81 +++++++++++++ src/config/env/index.ts | 2 + .../base-dir.ts => config/env/layout.ts} | 9 -- src/config/environment-name/index.ts | 7 ++ .../name.test.ts} | 7 +- .../name.ts} | 6 +- src/config/error.ts | 77 ------------ src/config/index.ts | 27 ++--- src/config/keystore-file.ts | 73 ------------ src/config/keystore/error.ts | 9 ++ src/config/keystore/file.ts | 96 +++++++++++++++ src/config/keystore/index.ts | 4 + src/config/keystore/keyring-node.test.ts | 12 ++ .../keyring-node.ts} | 51 ++++---- .../keystore/service.test-utils.ts} | 4 +- src/config/keystore/service.test.ts | 18 +++ .../{keystore.ts => keystore/service.ts} | 3 +- src/config/layer.test-utils.ts | 45 +++++++ src/config/layer.test.ts | 8 +- src/config/paths/index.ts | 1 + src/config/{ => paths}/paths.ts | 23 ++-- src/config/{ => persist}/file-mode.ts | 7 +- src/config/{ => persist}/migration.test.ts | 10 +- src/config/{ => persist}/migration.ts | 14 +-- src/config/{ => persist}/persist.test.ts | 8 +- src/config/{ => persist}/persist.ts | 45 ++++--- src/config/{ => persist}/schema.ts | 0 .../platform.test-utils.ts} | 0 .../resolve/resolve.test-utils.ts} | 2 +- src/config/{ => resolve}/resolve.test.ts | 4 +- src/config/{ => resolve}/resolve.ts | 12 +- src/config/selection-layer.ts | 21 ---- src/config/selection.ts | 9 -- src/config/selection/index.ts | 1 + .../resolve.ts} | 0 src/config/selection/service.ts | 29 +++++ src/config/service.ts | 19 --- .../temp-home.test-utils.ts} | 0 src/config/types.ts | 6 +- src/config/url/error.ts | 8 ++ src/config/url/index.ts | 7 ++ src/config/{ => url}/url.test.ts | 8 +- src/config/{ => url}/url.ts | 0 src/connection/index.ts | 1 - src/effect.test-utils.ts | 25 ++++ src/environment/layer.ts | 12 -- src/environment/service.ts | 27 ----- src/index.ts | 2 +- src/layout/index.ts | 1 - .../node.ts => node/connection.ts} | 2 +- src/node/index.ts | 3 +- src/orchestration/index.ts | 1 - src/rpc/error.ts | 2 +- src/rpc/index.ts | 5 +- src/rpc/layer.ts | 2 +- src/runtime/index.ts | 1 - src/runtime/layer.test-utils.ts | 32 +++++ src/runtime/layer.test.ts | 8 +- src/runtime/layer.ts | 15 ++- src/test/helpers/assert-errors.ts | 47 -------- src/test/layers/cli-config-routing.ts | 32 ----- src/test/layers/config.ts | 46 -------- src/test/layers/credential-crypto.ts | 23 ---- tsconfig.dts.json | 2 - vite.config.ts | 2 - 150 files changed, 1523 insertions(+), 1192 deletions(-) rename src/{test/layers/auth.ts => auth/layer.test-utils.ts} (72%) rename src/auth/{auth.test.ts => service.test.ts} (96%) create mode 100644 src/cli/env.ts rename src/cli/{environment-flag.ts => env/flag.ts} (100%) create mode 100644 src/cli/env/index.ts rename src/{config => cli/env}/selection-layer.test.ts (83%) rename src/cli/{ => env}/selection-layer.ts (58%) rename src/cli/{auth-format.ts => format/auth.ts} (98%) create mode 100644 src/cli/format/index.ts rename src/cli/{model-format.ts => format/model.ts} (100%) create mode 100644 src/cli/format/output.ts rename src/cli/{project-format.ts => format/project.ts} (100%) rename src/cli/{terminal-format.ts => format/terminal.ts} (100%) rename src/cli/{thread-format.test.ts => format/thread.test.ts} (94%) rename src/cli/{thread-format.ts => format/thread.ts} (95%) rename src/cli/{ => interaction}/confirm.ts (72%) rename src/cli/{ => interaction}/self-action.ts (62%) delete mode 100644 src/cli/output-format.ts create mode 100644 src/cli/runtime/index.ts create mode 100644 src/cli/runtime/service.ts rename src/{ => cli}/scope/index.ts (100%) rename src/{ => cli}/scope/resolve.ts (71%) delete mode 100644 src/cli/terminal/commands.test.ts create mode 100644 src/cli/terminal/read.test-utils.ts create mode 100644 src/cli/terminal/read.test.ts create mode 100644 src/cli/terminal/write.test.ts rename src/config/{layer.ts => config.ts} (57%) rename src/config/{credential-cipher-node.ts => credential/cipher-node.ts} (98%) rename src/config/{credential-cipher-web.ts => credential/cipher-web.ts} (99%) rename src/config/{credential-cipher.ts => credential/cipher.ts} (100%) create mode 100644 src/config/credential/env.ts create mode 100644 src/config/credential/error.ts create mode 100644 src/config/credential/index.ts create mode 100644 src/config/credential/service.test-utils.ts rename src/config/{credential.test.ts => credential/service.test.ts} (83%) rename src/config/{credential.ts => credential/service.ts} (66%) create mode 100644 src/config/env/env.test-utils.ts create mode 100644 src/config/env/env.ts create mode 100644 src/config/env/index.ts rename src/{layout/base-dir.ts => config/env/layout.ts} (79%) create mode 100644 src/config/environment-name/index.ts rename src/config/{environment-name.test.ts => environment-name/name.test.ts} (89%) rename src/config/{environment-name.ts => environment-name/name.ts} (87%) delete mode 100644 src/config/keystore-file.ts create mode 100644 src/config/keystore/error.ts create mode 100644 src/config/keystore/file.ts create mode 100644 src/config/keystore/index.ts create mode 100644 src/config/keystore/keyring-node.test.ts rename src/config/{keystore-keyring-node.ts => keystore/keyring-node.ts} (69%) rename src/{test/layers/keystore-unavailable.ts => config/keystore/service.test-utils.ts} (72%) create mode 100644 src/config/keystore/service.test.ts rename src/config/{keystore.ts => keystore/service.ts} (89%) create mode 100644 src/config/layer.test-utils.ts create mode 100644 src/config/paths/index.ts rename src/config/{ => paths}/paths.ts (53%) rename src/config/{ => persist}/file-mode.ts (66%) rename src/config/{ => persist}/migration.test.ts (87%) rename src/config/{ => persist}/migration.ts (83%) rename src/config/{ => persist}/persist.test.ts (95%) rename src/config/{ => persist}/persist.ts (50%) rename src/config/{ => persist}/schema.ts (100%) rename src/{test/platform.ts => config/platform.test-utils.ts} (100%) rename src/{test/fixtures/encrypted-config.ts => config/resolve/resolve.test-utils.ts} (88%) rename src/config/{ => resolve}/resolve.test.ts (95%) rename src/config/{ => resolve}/resolve.ts (86%) delete mode 100644 src/config/selection-layer.ts delete mode 100644 src/config/selection.ts create mode 100644 src/config/selection/index.ts rename src/config/{selection-resolve.ts => selection/resolve.ts} (100%) create mode 100644 src/config/selection/service.ts delete mode 100644 src/config/service.ts rename src/{test/helpers/temp-home.ts => config/temp-home.test-utils.ts} (100%) create mode 100644 src/config/url/error.ts create mode 100644 src/config/url/index.ts rename src/config/{ => url}/url.test.ts (77%) rename src/config/{ => url}/url.ts (100%) create mode 100644 src/effect.test-utils.ts delete mode 100644 src/environment/layer.ts delete mode 100644 src/environment/service.ts delete mode 100644 src/layout/index.ts rename src/{connection/node.ts => node/connection.ts} (87%) create mode 100644 src/runtime/layer.test-utils.ts delete mode 100644 src/test/helpers/assert-errors.ts delete mode 100644 src/test/layers/cli-config-routing.ts delete mode 100644 src/test/layers/config.ts delete mode 100644 src/test/layers/credential-crypto.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1260a4..da5a956 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,11 @@ Use `pnpm format` and `pnpm lint:fix` for mechanical fixes. - Prefer pure `it()` tests for logic with no service dependencies. - Use `it.layer(ConfigPlatformLayer)` for config/auth tests that need filesystem, path, and crypto only — not full `NodeServices.layer`. -- Put shared test layers and shims in `src/test/layers/`; use `Environment.layerTest()` for home-dir fixtures. +- Colocate tests as `{module}.test.ts` next to `{module}.ts`; shared fixtures use `{module}.test-utils.ts` in the same directory. +- Keep feature code grouped under its module (e.g. `config/credential/cipher.ts`, `config/url/error.ts`) with sibling-only `index.ts` barrels — no standalone `error/` directories, but each feature owns an `error.ts` when needed. +- Top-level module files use the module name (e.g. `config/config.ts` for `T3Config`, `config/error.ts` for shared config errors). +- Use `it.layer(ConfigPlatformLayer)` for config/auth tests that need filesystem, path, and crypto only — not full `NodeServices.layer`. +- Use `CliRuntime.layerTest()` for cwd fixtures and `config/env/env.test-utils.ts` for `HOME`. - Split tests by module/concern; keep integration files under ~120 lines when possible. - Reserve full app-layer smoke tests for CLI routing and end-to-end wiring only. diff --git a/package.json b/package.json index 452fea2..3b9d8e8 100644 --- a/package.json +++ b/package.json @@ -36,60 +36,65 @@ "exports": { ".": { "types": "./dist/src/index.d.ts", + "import": "./dist/index.js", "default": "./dist/index.js" }, "./application": { "types": "./dist/src/application/index.d.ts", + "import": "./dist/application.js", "default": "./dist/application.js" }, "./auth": { "types": "./dist/src/auth/index.d.ts", + "import": "./dist/auth.js", "default": "./dist/auth.js" }, "./cli": { "types": "./dist/src/cli/index.d.ts", + "import": "./dist/cli.js", "default": "./dist/cli.js" }, "./config": { "types": "./dist/src/config/index.d.ts", + "import": "./dist/config.js", "default": "./dist/config.js" }, "./connection": { "types": "./dist/src/connection/index.d.ts", + "import": "./dist/connection.js", "default": "./dist/connection.js" }, "./contracts": { "types": "./dist/src/contracts/index.d.ts", + "import": "./dist/contracts.js", "default": "./dist/contracts.js" }, - "./t3tools": { - "types": "./dist/upstream-t3code/packages/contracts/src/index.d.ts", - "default": "./dist/t3tools.js" - }, - "./layout": { - "types": "./dist/src/layout/index.d.ts", - "default": "./dist/layout.js" - }, "./node": { "types": "./dist/src/node/index.d.ts", + "import": "./dist/node.js", "default": "./dist/node.js" }, "./orchestration": { "types": "./dist/src/orchestration/index.d.ts", + "import": "./dist/orchestration.js", "default": "./dist/orchestration.js" }, "./rpc": { "types": "./dist/src/rpc/index.d.ts", + "import": "./dist/rpc.js", "default": "./dist/rpc.js" }, "./runtime": { "types": "./dist/src/runtime/index.d.ts", + "import": "./dist/runtime.js", "default": "./dist/runtime.js" }, - "./scope": { - "types": "./dist/src/scope/index.d.ts", - "default": "./dist/scope.js" - } + "./t3tools": { + "types": "./dist/upstream-t3code/packages/contracts/src/index.d.ts", + "import": "./dist/t3tools.js", + "default": "./dist/t3tools.js" + }, + "./package.json": "./package.json" }, "scripts": { "build": "vp pack && tsc -p tsconfig.dts.json", diff --git a/src/application/index.ts b/src/application/index.ts index ae5cc26..4078b7e 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -18,11 +18,6 @@ export type { SendThreadInput, StartThreadInput, StartThreadPolicy, - T3ApplicationService, - T3ModelApplicationService, - T3ProjectApplicationService, - T3TerminalApplicationService, - T3ThreadApplicationService, UpdateThreadInput, WaitEvent, } from "./service.ts"; diff --git a/src/application/project-commands.ts b/src/application/project-commands.ts index bbb34bd..9a5e2b8 100644 --- a/src/application/project-commands.ts +++ b/src/application/project-commands.ts @@ -4,16 +4,14 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { CommandId, ProjectId, type ClientOrchestrationCommand } from "#t3tools/contracts"; -import { Environment } from "../environment/service.ts"; - export const makeProjectCreateCommand = Effect.fn("makeProjectCreateCommand")(function* (input: { readonly path: string; readonly title?: string; + readonly cwd: string; }) { const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const environment = yield* Environment; - const workspaceRoot = path.resolve(environment.cwd, input.path); + const workspaceRoot = path.resolve(input.cwd, input.path); const projectId = ProjectId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie)); const title = input.title?.trim(); const createdAt = DateTime.formatIso(yield* DateTime.now); diff --git a/src/application/projects.ts b/src/application/projects.ts index 6cf2c18..caf5f9b 100644 --- a/src/application/projects.ts +++ b/src/application/projects.ts @@ -2,7 +2,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; -import { Environment } from "../environment/service.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; import { T3Orchestration } from "../orchestration/service.ts"; import { ProjectCreateVisibilityError, ProjectLookupError } from "../domain/error.ts"; import { findProjectById, resolveProjectScope } from "../domain/helpers.ts"; @@ -14,7 +14,7 @@ export const makeProjectApplication = Effect.fn("makeProjectApplication")(functi const orchestration = yield* T3Orchestration; const crypto = yield* Crypto.Crypto; const path = yield* Path.Path; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; const loadShell: T3ProjectApplicationService["loadShell"] = Effect.fn( "T3ApplicationLive.loadShell", )(function* () { @@ -40,11 +40,10 @@ export const makeProjectApplication = Effect.fn("makeProjectApplication")(functi const addProject: T3ProjectApplicationService["addProject"] = Effect.fn( "T3ApplicationLive.addProject", )(function* (projectInput: { readonly path: string; readonly title?: string }) { - const command = yield* makeProjectCreateCommand(projectInput).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Environment, environment), - ); + const command = yield* makeProjectCreateCommand({ + ...projectInput, + cwd: cliRuntime.cwd, + }).pipe(Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto)); const dispatch = yield* orchestration.dispatch(command); const snapshot = yield* waitForShellSequence({ sequence: dispatch.sequence }).pipe( Effect.provideService(T3Orchestration, orchestration), diff --git a/src/application/threads.test.ts b/src/application/threads.test.ts index f5407b3..68ff1be 100644 --- a/src/application/threads.test.ts +++ b/src/application/threads.test.ts @@ -12,7 +12,8 @@ import type { OrchestrationThreadShell, } from "#t3tools/contracts"; -import { NodeEnvironmentLive } from "../environment/layer.ts"; +import * as CliRuntime from "../cli/runtime/service.ts"; +import { t3CliEnvConfigLayer } from "../config/env/env.test-utils.ts"; import { T3Orchestration, type Orchestration } from "../orchestration/service.ts"; import { makeThreadApplication } from "./threads.ts"; @@ -58,7 +59,12 @@ function makeOrchestrationLayer(input: { } const testLayer = (orchestration: Layer.Layer) => - Layer.mergeAll(orchestration, NodeServices.layer, NodeEnvironmentLive); + Layer.mergeAll( + orchestration, + NodeServices.layer, + CliRuntime.layer, + t3CliEnvConfigLayer("/tmp/t3cli-test"), + ); describe("interruptThread", () => { it.layer(NodeServices.layer)("interruptThread", (t) => { diff --git a/src/application/threads.ts b/src/application/threads.ts index 0ea3bcb..9977ca0 100644 --- a/src/application/threads.ts +++ b/src/application/threads.ts @@ -2,7 +2,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; -import { Environment } from "../environment/service.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; import { T3Orchestration } from "../orchestration/service.ts"; import { ProjectLookupError, ThreadLookupError, ThreadSessionError } from "../domain/error.ts"; import { resolveProjectScope } from "../domain/helpers.ts"; @@ -41,7 +41,7 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function const orchestration = yield* T3Orchestration; const crypto = yield* Crypto.Crypto; const path = yield* Path.Path; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; const awaitShellSequence = (sequence: number) => waitForShellSequence({ sequence }).pipe(Effect.provideService(T3Orchestration, orchestration)); const awaitThreadCompletion = (threadId: string) => @@ -149,7 +149,7 @@ export const makeThreadApplication = Effect.fn("makeThreadApplication")(function return yield* Effect.fail( new ProjectLookupError({ message: "project is required", - ref: environment.cwd, + ref: cliRuntime.cwd, }), ); } diff --git a/src/auth/error.ts b/src/auth/error.ts index 70f29fd..78fe87e 100644 --- a/src/auth/error.ts +++ b/src/auth/error.ts @@ -3,7 +3,8 @@ import * as Schema from "effect/Schema"; import { PlatformError } from "effect/PlatformError"; import { HttpClientError } from "effect/unstable/http"; -import { ConfigError, UrlError } from "../config/error.ts"; +import { ConfigError } from "../config/error.ts"; +import { UrlError } from "../config/url/error.ts"; export class AuthPairingUrlError extends Schema.TaggedErrorClass()( "AuthPairingUrlError", @@ -18,10 +19,6 @@ export class AuthConfigError extends Schema.TaggedErrorClass()( cause: Schema.optionalKey(Schema.Union([ConfigError, UrlError])), }) {} -export function authConfigErrorFromConfig(error: ConfigError | UrlError) { - return new AuthConfigError({ message: error.message, cause: error }); -} - export class AuthTransportError extends Schema.TaggedErrorClass()( "AuthTransportError", { diff --git a/src/test/layers/auth.ts b/src/auth/layer.test-utils.ts similarity index 72% rename from src/test/layers/auth.ts rename to src/auth/layer.test-utils.ts index c27af9e..983bfc7 100644 --- a/src/test/layers/auth.ts +++ b/src/auth/layer.test-utils.ts @@ -1,12 +1,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { T3AuthLive } from "../../auth/layer.ts"; -import { T3LocalAuth } from "../../auth/local.ts"; -import { T3AuthPairing } from "../../auth/pairing.ts"; -import { T3AuthTransport } from "../../auth/transport.ts"; -import { t3ConfigDepsLayer } from "./config.ts"; -import { ConfigPlatformLayer } from "../platform.ts"; +import { T3AuthLive } from "./layer.ts"; +import { T3LocalAuth } from "./local.ts"; +import { T3AuthPairing } from "./pairing.ts"; +import { T3AuthTransport } from "./transport.ts"; +import { t3ConfigDepsLayer } from "../config/layer.test-utils.ts"; +import { ConfigPlatformLayer } from "../config/platform.test-utils.ts"; export function t3AuthDepsLayer(homeDir: string) { return T3AuthLive.pipe( diff --git a/src/auth/layer.ts b/src/auth/layer.ts index c837749..21751b6 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -4,16 +4,21 @@ import * as Layer from "effect/Layer"; import { defaultEnvironmentNameForLocal, defaultEnvironmentNameFromUrl, -} from "../config/environment-name.ts"; +} from "../config/environment-name/name.ts"; import type { EnvironmentSummary, ResolvedConfig } from "../config/types.ts"; -import { T3Config } from "../config/service.ts"; -import { AuthConfigError, authConfigErrorFromConfig } from "./error.ts"; +import type { ConfigError } from "../config/error.ts"; +import type { UrlError } from "../config/url/error.ts"; +import { T3Config } from "../config/config.ts"; +import { AuthConfigError } from "./error.ts"; import { T3LocalAuth } from "./local.ts"; import { T3AuthPairing } from "./pairing.ts"; import { T3Auth } from "./service.ts"; import { T3AuthTransport } from "./transport.ts"; import type { AuthConfigInput, AuthEnvironmentListItem, AuthResolvedConfig } from "./type.ts"; +const mapConfigError = (error: ConfigError | UrlError) => + new AuthConfigError({ message: error.message, cause: error }); + export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const config = yield* T3Config; const transport = yield* T3AuthTransport; @@ -21,13 +26,13 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const pairing = yield* T3AuthPairing; const status = Effect.fn("T3AuthLive.status")(function* () { - const resolved = yield* config.resolve().pipe(Effect.mapError(authConfigErrorFromConfig)); + const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); const session = yield* transport.getSession(resolved); return { config: toAuthResolvedConfig(resolved), session } as const; }); const issueWebSocketTicket = Effect.fn("T3AuthLive.issueWebSocketTicket")(function* () { - const resolved = yield* config.resolve().pipe(Effect.mapError(authConfigErrorFromConfig)); + const resolved = yield* config.resolve().pipe(Effect.mapError(mapConfigError)); return yield* transport.issueWebSocketTicket(resolved); }); @@ -39,9 +44,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { readonly replace?: boolean; readonly allowReplace: boolean; }) { - const exists = yield* config - .hasEnvironment(input.name) - .pipe(Effect.mapError(authConfigErrorFromConfig)); + const exists = yield* config.hasEnvironment(input.name).pipe(Effect.mapError(mapConfigError)); if (exists && !input.allowReplace) { return yield* Effect.fail( new AuthConfigError({ @@ -58,7 +61,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { local: input.local, ...(makeDefault ? { makeDefault: true } : {}), }) - .pipe(Effect.mapError(authConfigErrorFromConfig)); + .pipe(Effect.mapError(mapConfigError)); return input.name; }); @@ -66,7 +69,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { const [environments, activeName] = yield* Effect.all( [config.listEnvironments(), config.resolveActiveEnvironmentName()], { concurrency: "unbounded" }, - ).pipe(Effect.mapError(authConfigErrorFromConfig)); + ).pipe(Effect.mapError(mapConfigError)); return environments.map((environment) => toAuthEnvironmentListItem(environment, activeName)); }); @@ -78,7 +81,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { } const defaultName = yield* config .getDefaultEnvironmentName() - .pipe(Effect.mapError(authConfigErrorFromConfig)); + .pipe(Effect.mapError(mapConfigError)); if (defaultName === undefined) { return yield* Effect.fail( new AuthConfigError({ @@ -101,27 +104,24 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { local: input.local, ...(input.makeDefault === true ? { makeDefault: true } : {}), }) - .pipe(Effect.mapError(authConfigErrorFromConfig)), + .pipe(Effect.mapError(mapConfigError)), persistEnvironment, environmentExists: (name: string) => - config.hasEnvironment(name).pipe(Effect.mapError(authConfigErrorFromConfig)), + config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)), defaultNameFromUrl: (url: string) => - defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(authConfigErrorFromConfig)), + defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)), defaultNameForLocal: () => Effect.succeed(defaultEnvironmentNameForLocal()), listEnvironments, useEnvironment: (name: string) => config .setDefaultEnvironment(name) - .pipe( - Effect.mapError(authConfigErrorFromConfig), - Effect.as({ name, default: true as const }), - ), + .pipe(Effect.mapError(mapConfigError), Effect.as({ name, default: true as const })), resolveUnpairTarget, unpairEnvironment: (input: { readonly name: string }) => config .removeEnvironment(input.name) .pipe( - Effect.mapError(authConfigErrorFromConfig), + Effect.mapError(mapConfigError), Effect.as({ name: input.name, removed: true as const }), ), status, diff --git a/src/auth/local-base-dir.ts b/src/auth/local-base-dir.ts index 7c3369a..6ea5a73 100644 --- a/src/auth/local-base-dir.ts +++ b/src/auth/local-base-dir.ts @@ -1,17 +1,24 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; -import { Environment } from "../environment/service.ts"; -import { resolveT3BaseDir } from "../layout/base-dir.ts"; +import type { CliRuntime } from "../cli/runtime/service.ts"; +import type { T3CliEnvShape } from "../config/env/env.ts"; +import { resolveT3BaseDir } from "../config/env/layout.ts"; export const resolveLocalBaseDir = Effect.fn("resolveLocalBaseDir")(function* (input: { readonly baseDir?: string | undefined; + readonly cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; }) { - const environment = yield* Environment; + const homeDir = yield* Option.match(input.t3CliEnv.home, { + onNone: () => Effect.die("HOME is not set"), + onSome: Effect.succeed, + }); return yield* resolveT3BaseDir({ layout: { - cwd: environment.cwd, - homeDir: environment.homeDir, - t3codeHome: environment.env["T3CODE_HOME"], + cwd: input.cliRuntime.cwd, + homeDir, + t3codeHome: Option.getOrUndefined(input.t3CliEnv.t3codeHome), }, baseDir: input.baseDir, }); diff --git a/src/auth/local-origin.ts b/src/auth/local-origin.ts index d7eb1e7..870d465 100644 --- a/src/auth/local-origin.ts +++ b/src/auth/local-origin.ts @@ -4,8 +4,9 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { normalizeHttpBaseUrl } from "../config/url.ts"; -import { Environment } from "../environment/service.ts"; +import { normalizeHttpBaseUrl } from "../config/url/url.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; import { AuthLocalError } from "./error.ts"; import { resolveLocalBaseDir } from "./local-base-dir.ts"; import { decodeAuthLocalRuntimeStateFromJson } from "./schema.ts"; @@ -21,15 +22,17 @@ export class T3LocalAuthOrigin extends Context.Service< export const makeT3LocalAuthOrigin = Effect.fn("makeT3LocalAuthOrigin")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const resolve = Effect.fn("T3LocalAuthOriginLive.resolve")(function* ( input?: LocalAuthOriginInput, ) { - const baseDir = yield* resolveLocalBaseDir({ baseDir: input?.baseDir }).pipe( - Effect.provideService(Environment, environment), - Effect.provideService(Path.Path, path), - ); + const baseDir = yield* resolveLocalBaseDir({ + baseDir: input?.baseDir, + cliRuntime, + t3CliEnv, + }).pipe(Effect.provideService(Path.Path, path)); if (input?.origin !== undefined) { return yield* normalizeLocalOrigin(input.origin); } diff --git a/src/auth/local-token.ts b/src/auth/local-token.ts index 90859a1..c1b4095 100644 --- a/src/auth/local-token.ts +++ b/src/auth/local-token.ts @@ -15,7 +15,8 @@ import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { Environment } from "../environment/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; import { SqlClientFactory } from "../sql/service.ts"; import { AuthLocalDatabaseError, @@ -38,7 +39,8 @@ export class T3LocalAuthToken extends Context.Service< export const makeT3LocalAuthToken = Effect.fn("makeT3LocalAuthToken")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const crypto = yield* Crypto.Crypto; const sqlClientFactory = yield* SqlClientFactory; @@ -100,14 +102,15 @@ export const makeT3LocalAuthToken = Effect.fn("makeT3LocalAuthToken")(function* function openAuthDatabase(dbPath: string) { return sqlClientFactory.sqliteClient({ filename: dbPath }).pipe( - Effect.catchTag("SqlError", (error) => - Effect.fail( - new AuthLocalDatabaseError({ - operation: "connect", - message: error.message, - }), - ), - ), + Effect.catchTags({ + SqlError: (error) => + Effect.fail( + new AuthLocalDatabaseError({ + operation: "connect", + message: error.message, + }), + ), + }), ); } @@ -157,14 +160,15 @@ export const makeT3LocalAuthToken = Effect.fn("makeT3LocalAuthToken")(function* expiresAt: DateTime.formatIso(expiresAt), }).pipe( provideAuthDatabase(input.dbPath), - Effect.catchTag("SqlError", (error) => - Effect.fail( - new AuthLocalDatabaseError({ - operation: Predicate.isTagged(error.reason, "ConnectionError") ? "connect" : "query", - message: error.message, - }), - ), - ), + Effect.catchTags({ + SqlError: (error) => + Effect.fail( + new AuthLocalDatabaseError({ + operation: Predicate.isTagged(error.reason, "ConnectionError") ? "connect" : "query", + message: error.message, + }), + ), + }), ); return { token, @@ -185,10 +189,11 @@ export const makeT3LocalAuthToken = Effect.fn("makeT3LocalAuthToken")(function* ); } - const baseDir = yield* resolveLocalBaseDir({ baseDir: input.baseDir }).pipe( - Effect.provideService(Environment, environment), - Effect.provideService(Path.Path, path), - ); + const baseDir = yield* resolveLocalBaseDir({ + baseDir: input.baseDir, + cliRuntime, + t3CliEnv, + }).pipe(Effect.provideService(Path.Path, path)); const session = yield* issueLocalDatabaseSession({ dbPath: path.join(baseDir, "userdata", "state.sqlite"), secretsDir: path.join(baseDir, "userdata", "secrets"), diff --git a/src/auth/auth.test.ts b/src/auth/service.test.ts similarity index 96% rename from src/auth/auth.test.ts rename to src/auth/service.test.ts index 55a9adf..e10e2cb 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/service.test.ts @@ -8,10 +8,10 @@ import * as Schema from "effect/Schema"; import { assert, describe, it } from "@effect/vitest"; import { T3Auth } from "./service.ts"; -import { StoredConfigV2FileJson } from "../config/schema.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3AuthDepsLayer } from "../test/layers/auth.ts"; +import { StoredConfigV2FileJson } from "../config/persist/schema.ts"; +import { ConfigPlatformLayer } from "../config/platform.test-utils.ts"; +import { makeTempHomeScoped } from "../config/temp-home.test-utils.ts"; +import { t3AuthDepsLayer } from "./layer.test-utils.ts"; describe("T3Auth persistence", () => { it.effect("fails to persist a duplicate environment without allowReplace", () => diff --git a/src/auth/transport.ts b/src/auth/transport.ts index 0eb87e8..be3dbdd 100644 --- a/src/auth/transport.ts +++ b/src/auth/transport.ts @@ -9,7 +9,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; -import { toHttpEndpointUrl } from "../config/url.ts"; +import { toHttpEndpointUrl } from "../config/url/url.ts"; import { AuthTransportError } from "./error.ts"; import { type AuthBearerBootstrapResult, diff --git a/src/bin.ts b/src/bin.ts index 434e07b..4e91ce8 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -6,10 +7,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.ts"; -import { T3CliConfigSelectionLive } from "./cli/selection-layer.ts"; -import { layerNode as T3CredentialCipherNodeLive } from "./config/credential-cipher-node.ts"; -import { layerNode as T3MasterKeyKeystoreFactoryNodeLive } from "./config/keystore-keyring-node.ts"; -import { NodeEnvironmentLive } from "./environment/layer.ts"; +import * as CliSelection from "./cli/env/selection-layer.ts"; +import * as CliRuntime from "./cli/runtime/service.ts"; +import * as CredentialCipherNode from "./config/credential/cipher-node.ts"; +import * as KeystoreKeyringNode from "./config/keystore/keyring-node.ts"; import { T3InputLive } from "./cli/input/layer.ts"; import { T3OutputLive } from "./cli/output/layer.ts"; import { NodeTerminalIoLive } from "./cli/terminal/io-node-layer.ts"; @@ -20,21 +21,25 @@ import { T3Version } from "./version/service.ts"; declare const T3CLI_VERSION: string | undefined; +if (process.env.HOME === undefined || process.env.HOME.trim().length === 0) { + process.env.HOME = homedir(); +} + const VersionLive = typeof T3CLI_VERSION === "string" ? T3VersionBundledLive : T3VersionPackageJsonLive; -const PlatformLayer = Layer.mergeAll(NodeServices.layer, NodeEnvironmentLive); +const PlatformLayer = Layer.mergeAll(NodeServices.layer, CliRuntime.layer); const CliAppLayer = BaseAppLayer.pipe( - Layer.provideMerge(T3CliConfigSelectionLive), - Layer.provide(T3CredentialCipherNodeLive), - Layer.provide(T3MasterKeyKeystoreFactoryNodeLive), + Layer.provideMerge(CliSelection.layer), + Layer.provide(CredentialCipherNode.layerNode), + Layer.provide(KeystoreKeyringNode.layerNode), ); const CliLayer = Layer.mergeAll( CliAppLayer.pipe(Layer.provide(PlatformLayer)), NodeServices.layer, - NodeEnvironmentLive, + CliRuntime.layer, T3InputLive.pipe(Layer.provide(NodeServices.layer)), T3OutputLive.pipe(Layer.provide(NodeServices.layer)), NodeTerminalIoLive, diff --git a/src/cli/app.ts b/src/cli/app.ts index a2f37cf..498bb70 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -1,7 +1,7 @@ import { Command } from "effect/unstable/cli"; import { createAuthCommand } from "./auth.ts"; -import { cliEnvironmentSetting } from "./environment-flag.ts"; +import { cliEnvironmentSetting } from "./env/flag.ts"; import { createTerminalCommandGroup } from "./terminal.ts"; import { createModelCommand } from "./model.ts"; import { createProjectCommand } from "./project.ts"; diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 9601a71..11914b2 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -12,15 +12,16 @@ import { formatAuthStatusJson, formatAuthUnpairHuman, formatAuthUseHuman, -} from "./auth-format.ts"; +} from "./format/auth.ts"; import { T3Auth } from "../auth/service.ts"; -import { Environment } from "../environment/service.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; import { requireDestructiveConfirmation, requireEnvironmentReplaceConfirmation, -} from "./confirm.ts"; -import { authNameFlag, formatFlag, replaceFlag, yesFlag } from "./flags.ts"; -import { resolveOutputFormat } from "./output-format.ts"; +} from "./interaction/confirm.ts"; +import { envNameFlag, formatFlag, replaceFlag, yesFlag } from "./flags.ts"; +import { resolveOutputFormat } from "./format/output.ts"; import { T3Output } from "./output/service.ts"; const persistAuthEnvironment = Effect.fn("persistAuthEnvironment")(function* (input: { @@ -32,7 +33,8 @@ const persistAuthEnvironment = Effect.fn("persistAuthEnvironment")(function* (in readonly replace: boolean; }) { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const environmentName = Option.isSome(input.explicitName) ? input.explicitName.value : input.fallbackName; @@ -41,7 +43,8 @@ const persistAuthEnvironment = Effect.fn("persistAuthEnvironment")(function* (in yield* requireEnvironmentReplaceConfirmation({ name: environmentName, replace: input.replace, - environment, + cliRuntime, + t3CliEnv, }); } return yield* auth.persistEnvironment({ @@ -73,16 +76,17 @@ const pairCommand = Command.make( { url: Flag.string("url"), local: Flag.boolean("local"), - name: authNameFlag, + name: envNameFlag, replace: replaceFlag, format: formatFlag, }, ({ url, local, name, replace, format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* auth.pair(url); const fallbackName = yield* auth.defaultNameFromUrl(result.url); const environmentName = yield* persistAuthEnvironment({ @@ -110,16 +114,17 @@ const localCommand = Command.make( role: Flag.choice("role", ["owner", "client"] as const).pipe(Flag.withDefault("owner")), label: Flag.string("label").pipe(Flag.withDefault("t3cli")), subject: Flag.string("subject").pipe(Flag.withDefault("t3cli-local")), - name: authNameFlag, + name: envNameFlag, replace: replaceFlag, format: formatFlag, }, ({ baseDir, origin, role, label, subject, name, replace, format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* auth.local({ role, label, @@ -153,9 +158,10 @@ const listCommand = Command.make( ({ format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const environments = yield* auth.listEnvironments(); if (resolvedFormat === "json") { yield* output.printJson(formatAuthListJson(environments)); @@ -174,9 +180,10 @@ const useCommand = Command.make( ({ name, format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* auth.useEnvironment(name); if (resolvedFormat === "json") { yield* output.printJson(result); @@ -189,23 +196,25 @@ const useCommand = Command.make( const unpairCommand = Command.make( "unpair", { - name: authNameFlag, + name: envNameFlag, yes: yesFlag, format: formatFlag, }, ({ name, yes, format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const targetName = yield* auth.resolveUnpairTarget( Option.isSome(name) ? { name: name.value } : {}, ); yield* requireDestructiveConfirmation({ message: `Remove local credentials for environment '${targetName}'?`, yes, - environment, + cliRuntime, + t3CliEnv, }); const result = yield* auth.unpairEnvironment({ name: targetName }); if (resolvedFormat === "json") { @@ -228,9 +237,10 @@ const statusCommand = Command.make( ({ format }) => Effect.gen(function* () { const auth = yield* T3Auth; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* auth.status(); if (resolvedFormat === "json") { yield* output.printJson(formatAuthStatusJson(result)); diff --git a/src/cli/env.ts b/src/cli/env.ts new file mode 100644 index 0000000..48ff4bc --- /dev/null +++ b/src/cli/env.ts @@ -0,0 +1,103 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Argument, Command } from "effect/unstable/cli"; + +import { + formatAuthListHuman, + formatAuthListJson, + formatAuthUnpairHuman, + formatAuthUseHuman, +} from "./format/auth.ts"; +import { T3Auth } from "../auth/service.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { requireDestructiveConfirmation } from "./interaction/confirm.ts"; +import { envNameFlag, formatFlag, yesFlag } from "./flags.ts"; +import { resolveOutputFormat } from "./format/output.ts"; +import { T3Output } from "./output/service.ts"; + +export function createEnvCommand() { + return Command.make("env").pipe( + Command.withDescription("manage stored environments"), + Command.withSubcommands([listCommand, useCommand, removeCommand]), + ); +} + +const listCommand = Command.make( + "list", + { + format: formatFlag, + }, + ({ format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); + const environments = yield* auth.listEnvironments(); + if (resolvedFormat === "json") { + yield* output.printJson(formatAuthListJson(environments)); + } else { + yield* output.printInfo(formatAuthListHuman(environments)); + } + }), +).pipe(Command.withDescription("list stored environments")); + +const useCommand = Command.make( + "use", + { + name: Argument.string("name"), + format: formatFlag, + }, + ({ name, format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); + const result = yield* auth.useEnvironment(name); + if (resolvedFormat === "json") { + yield* output.printJson(result); + } else { + yield* output.printInfo(formatAuthUseHuman(result)); + } + }), +).pipe(Command.withDescription("set the default environment")); + +const removeCommand = Command.make( + "remove", + { + name: envNameFlag, + yes: yesFlag, + format: formatFlag, + }, + ({ name, yes, format }) => + Effect.gen(function* () { + const auth = yield* T3Auth; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + const output = yield* T3Output; + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); + const targetName = yield* auth.resolveUnpairTarget( + Option.isSome(name) ? { name: name.value } : {}, + ); + yield* requireDestructiveConfirmation({ + message: `Remove local credentials for environment '${targetName}'?`, + yes, + cliRuntime, + t3CliEnv, + }); + const result = yield* auth.unpairEnvironment({ name: targetName }); + if (resolvedFormat === "json") { + yield* output.printJson(result); + } else { + yield* output.printInfo(formatAuthUnpairHuman(result)); + } + }), +).pipe( + Command.withDescription( + "remove local credentials for an environment (remote tokens may remain valid until expiry)", + ), +); diff --git a/src/cli/environment-flag.ts b/src/cli/env/flag.ts similarity index 100% rename from src/cli/environment-flag.ts rename to src/cli/env/flag.ts diff --git a/src/cli/env/index.ts b/src/cli/env/index.ts new file mode 100644 index 0000000..650e78e --- /dev/null +++ b/src/cli/env/index.ts @@ -0,0 +1,2 @@ +export { cliEnvironmentSetting } from "./flag.ts"; +export * as Selection from "./selection-layer.ts"; diff --git a/src/config/selection-layer.test.ts b/src/cli/env/selection-layer.test.ts similarity index 83% rename from src/config/selection-layer.test.ts rename to src/cli/env/selection-layer.test.ts index 586b47d..b1bcfe8 100644 --- a/src/config/selection-layer.test.ts +++ b/src/cli/env/selection-layer.test.ts @@ -3,10 +3,10 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; import { assert, describe, it } from "@effect/vitest"; -import { T3Config } from "./service.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3ConfigDepsLayer } from "../test/layers/config.ts"; +import { T3Config } from "../../config/config.ts"; +import { ConfigPlatformLayer } from "../../config/platform.test-utils.ts"; +import { makeTempHomeScoped } from "../../config/temp-home.test-utils.ts"; +import { t3ConfigDepsLayer } from "../../config/layer.test-utils.ts"; describe("T3ConfigSelectionLive", () => { it.effect("resolves T3CLI_ENV through the selection layer", () => diff --git a/src/cli/selection-layer.ts b/src/cli/env/selection-layer.ts similarity index 58% rename from src/cli/selection-layer.ts rename to src/cli/env/selection-layer.ts index 9577ea5..49ccbbc 100644 --- a/src/cli/selection-layer.ts +++ b/src/cli/env/selection-layer.ts @@ -2,15 +2,15 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { resolveConfiguredEnvironment } from "../config/selection-resolve.ts"; -import { T3ConfigSelection } from "../config/selection.ts"; -import { Environment } from "../environment/service.ts"; -import { cliEnvironmentSetting } from "./environment-flag.ts"; +import { resolveConfiguredEnvironment } from "../../config/selection/resolve.ts"; +import { T3ConfigSelection } from "../../config/selection/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { cliEnvironmentSetting } from "./flag.ts"; -export const T3CliConfigSelectionLive = Layer.effect( +export const layer = Layer.effect( T3ConfigSelection, Effect.gen(function* () { - const environment = yield* Environment; + const t3CliEnv = yield* loadT3CliEnv; return { getSelectedEnvironment: () => Effect.gen(function* () { @@ -19,7 +19,7 @@ export const T3CliConfigSelectionLive = Layer.effect( cliFlag: Option.isSome(cliEnvironment) ? Option.getOrUndefined(cliEnvironment.value) : undefined, - t3cliEnv: environment.env["T3CLI_ENV"], + t3cliEnv: Option.getOrUndefined(t3CliEnv.t3cliEnv), }); }), }; diff --git a/src/cli/flags.ts b/src/cli/flags.ts index b57b948..d83ade3 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -4,7 +4,7 @@ import { humanJsonFormatChoices, humanJsonNdjsonFormatChoices, humanNdjsonFormatChoices, -} from "./output-format.ts"; +} from "./format/output.ts"; export const projectFlag = Flag.string("project").pipe( Flag.withDescription( @@ -40,8 +40,8 @@ export const replaceFlag = Flag.boolean("replace").pipe( Flag.withDefault(false), ); -export const authNameFlag = Flag.string("name").pipe( - Flag.withDescription("Auth environment name"), +export const envNameFlag = Flag.string("name").pipe( + Flag.withDescription("Environment name"), Flag.optional, ); diff --git a/src/cli/auth-format.ts b/src/cli/format/auth.ts similarity index 98% rename from src/cli/auth-format.ts rename to src/cli/format/auth.ts index 5d07626..db8cfbf 100644 --- a/src/cli/auth-format.ts +++ b/src/cli/format/auth.ts @@ -3,7 +3,7 @@ import type { AuthStatusResult, LocalAuthResult, PairResult, -} from "../auth/type.ts"; +} from "../../auth/type.ts"; export function formatAuthPaired(result: PairResult & { readonly name: string }) { return `paired: ${result.url}\nname: ${result.name}\nrole: ${result.role}\nexpires: ${result.expiresAt}`; diff --git a/src/cli/format/index.ts b/src/cli/format/index.ts new file mode 100644 index 0000000..c8c40f7 --- /dev/null +++ b/src/cli/format/index.ts @@ -0,0 +1,12 @@ +export { + resolveOutputFormat, + canRenderLiveTerminal, + isInteractiveHumanTerminal, + isAgentEnvironment, + humanJsonFormatChoices, + humanNdjsonFormatChoices, + humanJsonNdjsonFormatChoices, + type HumanJsonFormat, + type HumanNdjsonFormat, + type HumanJsonNdjsonFormat, +} from "./output.ts"; diff --git a/src/cli/model-format.ts b/src/cli/format/model.ts similarity index 100% rename from src/cli/model-format.ts rename to src/cli/format/model.ts diff --git a/src/cli/format/output.ts b/src/cli/format/output.ts new file mode 100644 index 0000000..43556e9 --- /dev/null +++ b/src/cli/format/output.ts @@ -0,0 +1,51 @@ +import * as Option from "effect/Option"; + +import type { T3CliEnvShape } from "../../config/env/env.ts"; +import type { CliRuntime } from "../runtime/service.ts"; + +export const humanJsonFormatChoices = ["auto", "human", "json"] as const; +export const humanNdjsonFormatChoices = ["auto", "human", "ndjson"] as const; +export const humanJsonNdjsonFormatChoices = ["auto", "human", "json", "ndjson"] as const; + +export type HumanJsonFormat = (typeof humanJsonFormatChoices)[number]; +export type HumanNdjsonFormat = (typeof humanNdjsonFormatChoices)[number]; +export type HumanJsonNdjsonFormat = (typeof humanJsonNdjsonFormatChoices)[number]; + +export function resolveOutputFormat( + format: "auto" | "human" | T, + cliRuntime: CliRuntime["Service"], + t3CliEnv: T3CliEnvShape, + nonHumanFormat: T, +): "human" | T { + if (format !== "auto") { + return format; + } + return isHumanTerminal(cliRuntime, t3CliEnv) ? "human" : nonHumanFormat; +} + +export function canRenderLiveTerminal(cliRuntime: CliRuntime["Service"], t3CliEnv: T3CliEnvShape) { + return ( + cliRuntime.stderrIsTTY && + Option.getOrElse(t3CliEnv.term, () => "") !== "dumb" && + !isAgentEnvironment(t3CliEnv) + ); +} + +export function isInteractiveHumanTerminal( + cliRuntime: CliRuntime["Service"], + t3CliEnv: T3CliEnvShape, +) { + return ( + cliRuntime.stdoutIsTTY && + !isAgentEnvironment(t3CliEnv) && + Option.getOrElse(t3CliEnv.term, () => "") !== "dumb" + ); +} + +function isHumanTerminal(cliRuntime: CliRuntime["Service"], t3CliEnv: T3CliEnvShape) { + return isInteractiveHumanTerminal(cliRuntime, t3CliEnv); +} + +export function isAgentEnvironment(t3CliEnv: T3CliEnvShape) { + return t3CliEnv.ci || t3CliEnv.codexCi || t3CliEnv.codexThreadId || t3CliEnv.t3cliAgent; +} diff --git a/src/cli/project-format.ts b/src/cli/format/project.ts similarity index 100% rename from src/cli/project-format.ts rename to src/cli/format/project.ts diff --git a/src/cli/terminal-format.ts b/src/cli/format/terminal.ts similarity index 100% rename from src/cli/terminal-format.ts rename to src/cli/format/terminal.ts diff --git a/src/cli/thread-format.test.ts b/src/cli/format/thread.test.ts similarity index 94% rename from src/cli/thread-format.test.ts rename to src/cli/format/thread.test.ts index 7d9ba37..9e7173c 100644 --- a/src/cli/thread-format.test.ts +++ b/src/cli/format/thread.test.ts @@ -4,7 +4,7 @@ import { assert, describe, it } from "@effect/vitest"; import { fromPartial } from "@total-typescript/shoehorn"; import type { OrchestrationThreadShell } from "#t3tools/contracts"; -import { formatThreadsHuman } from "./thread-format.ts"; +import { formatThreadsHuman } from "./thread.ts"; describe("formatThreadsHuman", () => { it("appends (archived) for archived threads", () => { diff --git a/src/cli/thread-format.ts b/src/cli/format/thread.ts similarity index 95% rename from src/cli/thread-format.ts rename to src/cli/format/thread.ts index c1b2adc..58c8b80 100644 --- a/src/cli/thread-format.ts +++ b/src/cli/format/thread.ts @@ -1,7 +1,7 @@ -import type { ThreadShow } from "../application/threads.ts"; -import type { WaitEvent } from "../application/service.ts"; +import type { ThreadShow } from "../../application/threads.ts"; +import type { WaitEvent } from "../../application/service.ts"; import type { OrchestrationThread, OrchestrationThreadShell } from "#t3tools/contracts"; -import { latestAssistantMessage, threadStatus } from "../domain/thread-lifecycle.ts"; +import { latestAssistantMessage, threadStatus } from "../../domain/thread-lifecycle.ts"; export function formatThreadShowJson(thread: ThreadShow) { return { diff --git a/src/cli/index.ts b/src/cli/index.ts index a4f3c6f..f582f06 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,3 +8,23 @@ export { waitFormatFlag, worktreeFlag, } from "./flags.ts"; +export * as Env from "./env/index.ts"; +export * as Runtime from "./runtime/index.ts"; +export { + resolveCommandProjectRef, + resolveProjectRef, + resolveThreadId, + resolveWorktreePath, +} from "./scope/index.ts"; +export { + resolveOutputFormat, + canRenderLiveTerminal, + isInteractiveHumanTerminal, + isAgentEnvironment, + humanJsonFormatChoices, + humanNdjsonFormatChoices, + humanJsonNdjsonFormatChoices, + type HumanJsonFormat, + type HumanNdjsonFormat, + type HumanJsonNdjsonFormat, +} from "./format/index.ts"; diff --git a/src/cli/confirm.ts b/src/cli/interaction/confirm.ts similarity index 72% rename from src/cli/confirm.ts rename to src/cli/interaction/confirm.ts index 0773258..a39b8ba 100644 --- a/src/cli/confirm.ts +++ b/src/cli/interaction/confirm.ts @@ -1,20 +1,22 @@ import * as Effect from "effect/Effect"; import * as Prompt from "effect/unstable/cli/Prompt"; -import { DestructiveConfirmationRequiredError } from "./error.ts"; -import { isInteractiveHumanTerminal } from "./output-format.ts"; -import type { EnvironmentShape } from "../environment/service.ts"; +import { DestructiveConfirmationRequiredError } from "../error.ts"; +import { isInteractiveHumanTerminal } from "../format/output.ts"; +import type { T3CliEnvShape } from "../../config/env/env.ts"; +import type { CliRuntime } from "../runtime/service.ts"; export const requireDestructiveConfirmation = Effect.fn("requireDestructiveConfirmation")( function* (input: { readonly message: string; readonly yes: boolean; - readonly environment: EnvironmentShape; + readonly cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; }) { if (input.yes) { return; } - if (!isInteractiveHumanTerminal(input.environment)) { + if (!isInteractiveHumanTerminal(input.cliRuntime, input.t3CliEnv)) { yield* Effect.fail( new DestructiveConfirmationRequiredError({ message: "destructive action requires --yes in non-interactive mode", @@ -35,12 +37,13 @@ export const requireEnvironmentReplaceConfirmation = Effect.fn( )(function* (input: { readonly name: string; readonly replace: boolean; - readonly environment: EnvironmentShape; + readonly cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; }) { if (input.replace) { return; } - if (!isInteractiveHumanTerminal(input.environment)) { + if (!isInteractiveHumanTerminal(input.cliRuntime, input.t3CliEnv)) { yield* Effect.fail( new DestructiveConfirmationRequiredError({ message: `environment '${input.name}' already exists: pass --replace`, diff --git a/src/cli/self-action.ts b/src/cli/interaction/self-action.ts similarity index 62% rename from src/cli/self-action.ts rename to src/cli/interaction/self-action.ts index 5496924..e92c6c4 100644 --- a/src/cli/self-action.ts +++ b/src/cli/interaction/self-action.ts @@ -1,23 +1,25 @@ import * as Effect from "effect/Effect"; -import { SelfActionError } from "./error.ts"; -import { isAgentEnvironment } from "./output-format.ts"; -import type { EnvironmentShape } from "../environment/service.ts"; +import { SelfActionError } from "../error.ts"; +import { isAgentEnvironment } from "../format/output.ts"; +import type { T3CliEnvShape } from "../../config/env/env.ts"; +import type { CliRuntime } from "../runtime/service.ts"; export const requireSelfActionConfirmation = Effect.fn("requireSelfActionConfirmation")( function* (input: { readonly threadId: string; readonly force: boolean; - readonly environment: EnvironmentShape; + readonly cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; readonly action: string; }) { if (input.force) { return; } - if (!isAgentEnvironment(input.environment)) { + if (!isAgentEnvironment(input.t3CliEnv)) { return; } - const callerThreadId = input.environment.env.T3CODE_THREAD_ID; + const callerThreadId = input.t3CliEnv.scope.t3codeThreadId; if (callerThreadId === undefined || callerThreadId.length === 0) { return; } diff --git a/src/cli/model.ts b/src/cli/model.ts index a6e615c..ccabacf 100644 --- a/src/cli/model.ts +++ b/src/cli/model.ts @@ -4,9 +4,10 @@ import { Command, Flag } from "effect/unstable/cli"; import { formatFlag } from "./flags.ts"; import { T3Application } from "../application/service.ts"; -import { Environment } from "../environment/service.ts"; -import { formatModelsHuman } from "./model-format.ts"; -import { resolveOutputFormat } from "./output-format.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { formatModelsHuman } from "./format/model.ts"; +import { resolveOutputFormat } from "./format/output.ts"; import { T3Output } from "./output/service.ts"; export function createModelCommand() { @@ -26,10 +27,11 @@ const listCommand = Command.make( ({ all, provider, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const providerValue = Option.getOrUndefined(provider); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const providers = yield* application.listModels({ all, ...(providerValue !== undefined && providerValue.length > 0 diff --git a/src/cli/output-format.ts b/src/cli/output-format.ts deleted file mode 100644 index 8a391bf..0000000 --- a/src/cli/output-format.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { EnvironmentShape } from "../environment/service.ts"; - -export const humanJsonFormatChoices = ["auto", "human", "json"] as const; -export const humanNdjsonFormatChoices = ["auto", "human", "ndjson"] as const; -export const humanJsonNdjsonFormatChoices = ["auto", "human", "json", "ndjson"] as const; - -export type HumanJsonFormat = (typeof humanJsonFormatChoices)[number]; -export type HumanNdjsonFormat = (typeof humanNdjsonFormatChoices)[number]; -export type HumanJsonNdjsonFormat = (typeof humanJsonNdjsonFormatChoices)[number]; - -export function resolveOutputFormat( - format: "auto" | "human" | T, - environment: EnvironmentShape, - nonHumanFormat: T, -): "human" | T { - if (format !== "auto") { - return format; - } - return isHumanTerminal(environment) ? "human" : nonHumanFormat; -} - -export function canRenderLiveTerminal(environment: EnvironmentShape) { - return ( - environment.stderrIsTTY && environment.env.TERM !== "dumb" && !isAgentEnvironment(environment) - ); -} - -export function isInteractiveHumanTerminal(environment: EnvironmentShape) { - return ( - environment.stdoutIsTTY && !isAgentEnvironment(environment) && environment.env.TERM !== "dumb" - ); -} - -function isHumanTerminal(environment: EnvironmentShape) { - return isInteractiveHumanTerminal(environment); -} - -export function isAgentEnvironment(environment: EnvironmentShape) { - return ( - environment.env.CI !== undefined || - environment.env.CODEX_CI !== undefined || - environment.env.CODEX_THREAD_ID !== undefined || - environment.env.T3CLI_AGENT !== undefined - ); -} diff --git a/src/cli/project.ts b/src/cli/project.ts index d26b2e2..9ba73d7 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -3,11 +3,12 @@ import * as Option from "effect/Option"; import { Command, Flag } from "effect/unstable/cli"; import { formatFlag, projectPathFlag } from "./flags.ts"; -import { formatProjectAddedHuman, formatProjectsHuman } from "./project-format.ts"; +import { formatProjectAddedHuman, formatProjectsHuman } from "./format/project.ts"; import { deleteProjectCommand } from "./projects/delete.ts"; import { T3Application } from "../application/service.ts"; -import { Environment } from "../environment/service.ts"; -import { resolveOutputFormat } from "./output-format.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { resolveOutputFormat } from "./format/output.ts"; import { T3Output } from "./output/service.ts"; export function createProjectCommand() { @@ -25,9 +26,10 @@ const listCommand = Command.make( ({ format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const snapshot = yield* application.loadShell(); if (resolvedFormat === "json") { yield* output.printJson(snapshot.projects); @@ -47,9 +49,10 @@ const addCommand = Command.make( ({ path, title, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const titleValue = Option.getOrUndefined(title); const result = yield* application.addProject({ path, diff --git a/src/cli/projects/delete.ts b/src/cli/projects/delete.ts index 01cc640..5e005c5 100644 --- a/src/cli/projects/delete.ts +++ b/src/cli/projects/delete.ts @@ -1,13 +1,14 @@ import * as Effect from "effect/Effect"; import { Command } from "effect/unstable/cli"; -import { requireDestructiveConfirmation } from "../confirm.ts"; +import { requireDestructiveConfirmation } from "../interaction/confirm.ts"; import { forceFlag, formatFlag, projectFlag, yesFlag } from "../flags.ts"; -import { formatProjectDeletedHuman } from "../project-format.ts"; +import { formatProjectDeletedHuman } from "../format/project.ts"; import { requireCommandProjectRef } from "../require.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const deleteProjectCommand = Command.make( @@ -21,20 +22,18 @@ export const deleteProjectCommand = Command.make( ({ project, force, yes, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const projectRef = yield* requireCommandProjectRef({ - project, - env: environment.env, - cwd: environment.cwd, - }); + const projectRef = yield* requireCommandProjectRef({ project }); const resolvedProject = yield* application.resolveProject(projectRef); yield* requireDestructiveConfirmation({ message: `Delete project ${resolvedProject.title} (${resolvedProject.id})?`, yes, - environment, + cliRuntime, + t3CliEnv, }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* application.deleteProject({ projectId: resolvedProject.id, ...(force ? { force: true } : {}), diff --git a/src/cli/require.ts b/src/cli/require.ts index e5fda32..a0634ca 100644 --- a/src/cli/require.ts +++ b/src/cli/require.ts @@ -1,21 +1,23 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { T3Config } from "../config/service.ts"; +import { T3Config } from "../config/config.ts"; +import { loadT3CliEnv } from "../config/env/env.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; import { ProjectLookupError } from "../domain/error.ts"; -import { resolveCommandProjectRef } from "../scope/index.ts"; +import { resolveCommandProjectRef } from "./scope/index.ts"; export const requireCommandProjectRef = Effect.fn("requireCommandProjectRef")(function* (input: { readonly project: Option.Option; - readonly env: Readonly>; - readonly cwd: string; }) { + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const config = yield* T3Config; const resolved = yield* config.resolve(); const ref = resolveCommandProjectRef({ value: Option.getOrUndefined(input.project), - env: input.env, - cwd: input.cwd, + scope: t3CliEnv.scope, + cwd: cliRuntime.cwd, isLocal: resolved.local, }); if (ref === undefined) { @@ -23,7 +25,7 @@ export const requireCommandProjectRef = Effect.fn("requireCommandProjectRef")(fu new ProjectLookupError({ message: "project is required: pass --project, set T3CODE_PROJECT_ROOT / T3CODE_PROJECT_ID, or use local auth", - ref: input.cwd, + ref: cliRuntime.cwd, }), ); } diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts new file mode 100644 index 0000000..65163c2 --- /dev/null +++ b/src/cli/runtime/index.ts @@ -0,0 +1 @@ +export { CliRuntime, layer } from "./service.ts"; diff --git a/src/cli/runtime/service.ts b/src/cli/runtime/service.ts new file mode 100644 index 0000000..c41036f --- /dev/null +++ b/src/cli/runtime/service.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; + +export class CliRuntime extends Context.Service< + CliRuntime, + { + readonly cwd: string; + readonly stdoutIsTTY: boolean; + readonly stderrIsTTY: boolean; + } +>()("t3cli/CliRuntime") { + static layerTest = (input?: { + readonly cwd?: string; + readonly stdoutIsTTY?: boolean; + readonly stderrIsTTY?: boolean; + }) => + Layer.succeed(CliRuntime, { + cwd: input?.cwd ?? process.cwd(), + stdoutIsTTY: input?.stdoutIsTTY ?? false, + stderrIsTTY: input?.stderrIsTTY ?? false, + }); +} + +export const layer = Layer.succeed(CliRuntime, { + cwd: process.cwd(), + stdoutIsTTY: process.stdout.isTTY ?? false, + stderrIsTTY: process.stderr.isTTY ?? false, +}); diff --git a/src/scope/index.ts b/src/cli/scope/index.ts similarity index 100% rename from src/scope/index.ts rename to src/cli/scope/index.ts diff --git a/src/scope/resolve.ts b/src/cli/scope/resolve.ts similarity index 71% rename from src/scope/resolve.ts rename to src/cli/scope/resolve.ts index bd9a6e2..be3e288 100644 --- a/src/scope/resolve.ts +++ b/src/cli/scope/resolve.ts @@ -1,15 +1,17 @@ +import type { T3CliEnvScope } from "../../config/env/env.ts"; + export function resolveProjectRef(input: { readonly value: string | undefined; - readonly env: Readonly>; + readonly scope: T3CliEnvScope; }): string | undefined { if (input.value !== undefined && input.value.length > 0) { return input.value; } - const fromRoot = input.env.T3CODE_PROJECT_ROOT; + const fromRoot = input.scope.t3codeProjectRoot; if (fromRoot !== undefined && fromRoot.length > 0) { return fromRoot; } - const fromId = input.env.T3CODE_PROJECT_ID; + const fromId = input.scope.t3codeProjectId; if (fromId !== undefined && fromId.length > 0) { return fromId; } @@ -18,11 +20,11 @@ export function resolveProjectRef(input: { export function resolveCommandProjectRef(input: { readonly value: string | undefined; - readonly env: Readonly>; + readonly scope: T3CliEnvScope; readonly cwd: string; readonly isLocal?: boolean; }): string | undefined { - const explicit = resolveProjectRef({ value: input.value, env: input.env }); + const explicit = resolveProjectRef({ value: input.value, scope: input.scope }); if (explicit !== undefined) { return explicit; } @@ -34,13 +36,13 @@ export function resolveCommandProjectRef(input: { export function resolveWorktreePath(input: { readonly value: string | undefined; - readonly env: Readonly>; + readonly scope: T3CliEnvScope; readonly inferred?: string; }): string | undefined { if (input.value !== undefined && input.value.length > 0) { return input.value; } - const fromEnv = input.env.T3CODE_WORKTREE_PATH; + const fromEnv = input.scope.t3codeWorktreePath; if (fromEnv !== undefined && fromEnv.length > 0) { return fromEnv; } @@ -49,12 +51,12 @@ export function resolveWorktreePath(input: { export function resolveThreadId(input: { readonly value: string | undefined; - readonly env: Readonly>; + readonly scope: T3CliEnvScope; }): string | undefined { if (input.value !== undefined && input.value.length > 0) { return input.value; } - const fromEnv = input.env.T3CODE_THREAD_ID; + const fromEnv = input.scope.t3codeThreadId; if (fromEnv !== undefined && fromEnv.length > 0) { return fromEnv; } diff --git a/src/cli/terminal/attach.ts b/src/cli/terminal/attach.ts index af85cbf..0ca4c08 100644 --- a/src/cli/terminal/attach.ts +++ b/src/cli/terminal/attach.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import { Argument, Command } from "effect/unstable/cli"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; import { threadFlag } from "../flags.ts"; import { requireCommandThreadId } from "./scope.ts"; import { runAttachedTerminalSession, toTerminalAttachTarget } from "./shared.ts"; @@ -16,11 +15,7 @@ export const attachTerminalCommand = Command.make( ({ thread, terminalId }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const threadId = yield* requireCommandThreadId({ thread }); const terminal = yield* application.getTerminal({ threadId, terminalId, diff --git a/src/cli/terminal/commands.test.ts b/src/cli/terminal/commands.test.ts deleted file mode 100644 index 4d98c5c..0000000 --- a/src/cli/terminal/commands.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import "vite-plus/test/config"; - -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Option from "effect/Option"; -import { assert, describe, it } from "@effect/vitest"; -import { Command } from "effect/unstable/cli"; -import { fromPartial } from "@total-typescript/shoehorn"; - -import { T3Application } from "../../application/service.ts"; -import { T3Config } from "../../config/service.ts"; -import { NodeEnvironmentLive } from "../../environment/layer.ts"; -import { InvalidFlagCombinationError } from "../error.ts"; -import { T3Input } from "../input/service.ts"; -import { T3Output } from "../output/service.ts"; -import { readTerminalCommand } from "./read.ts"; -import { writeTerminalCommand } from "./write.ts"; - -const testLayer = Layer.mergeAll( - Layer.succeed( - T3Application, - fromPartial({ - getTerminal: () => Effect.die("getTerminal should not be called"), - attachTerminal: () => Effect.die("attachTerminal should not be called"), - writeTerminal: () => Effect.die("writeTerminal should not be called"), - }), - ), - Layer.succeed( - T3Config, - fromPartial({ - resolve: () => - Effect.succeed({ - url: "ws://localhost", - token: "token", - source: "config", - local: true, - }), - }), - ), - Layer.succeed(T3Output, { - writeStdout: () => Effect.void, - writeStderr: () => Effect.void, - printJson: () => Effect.void, - printNdjson: () => Effect.void, - printInfo: () => Effect.void, - }), - Layer.succeed(T3Input, { - readStdin: () => Effect.die("readStdin should not be called"), - readStdinBinary: () => Effect.die("readStdinBinary should not be called"), - }), - NodeServices.layer, - NodeEnvironmentLive, -); - -describe("readTerminalCommand", () => { - it.layer(testLayer)("readTerminalCommand", (t) => { - t.effect("rejects --from-sequence without --follow", () => - Effect.gen(function* () { - const run = Command.runWith(readTerminalCommand, { version: "0.0.0-test" }); - const exit = yield* run(["--thread", "thread-1", "term-1", "--from-sequence", "1"]).pipe( - Effect.exit, - ); - assert.isTrue(Exit.isFailure(exit)); - if (!Exit.isFailure(exit)) { - return; - } - const error = Cause.findErrorOption(exit.cause); - assert.isTrue(Option.isSome(error)); - if (Option.isSome(error)) { - assert.instanceOf(error.value, InvalidFlagCombinationError); - assert.equal(error.value.message, "--from-sequence requires --follow"); - } - }), - ); - }); -}); - -describe("writeTerminalCommand", () => { - it.layer(testLayer)("writeTerminalCommand", (t) => { - t.effect("rejects multiple payload sources", () => - Effect.gen(function* () { - const run = Command.runWith(writeTerminalCommand, { version: "0.0.0-test" }); - const exit = yield* run(["--thread", "thread-1", "term-1", "hello", "--stdin"]).pipe( - Effect.exit, - ); - assert.isTrue(Exit.isFailure(exit)); - if (!Exit.isFailure(exit)) { - return; - } - const error = Cause.findErrorOption(exit.cause); - assert.isTrue(Option.isSome(error)); - if (Option.isSome(error)) { - assert.instanceOf(error.value, InvalidFlagCombinationError); - } - }), - ); - }); -}); diff --git a/src/cli/terminal/create.ts b/src/cli/terminal/create.ts index e8f40df..6404b36 100644 --- a/src/cli/terminal/create.ts +++ b/src/cli/terminal/create.ts @@ -2,11 +2,12 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import { formatTerminalCreatedHuman } from "../terminal-format.ts"; +import { formatTerminalCreatedHuman } from "../format/terminal.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { formatFlag, threadFlag } from "../flags.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { requireCommandThreadId } from "./scope.ts"; import { runAttachedTerminalSession, snapshotToTerminalAttachTarget } from "./shared.ts"; @@ -23,12 +24,10 @@ export const createTerminalCommand = Command.make( ({ thread, command, id, attach, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const threadId = yield* requireCommandThreadId({ thread }); const terminalId = Option.getOrUndefined(id); const commandValue = Option.getOrUndefined(command); const snapshot = yield* application.createTerminal({ @@ -44,7 +43,7 @@ export const createTerminalCommand = Command.make( return; } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); if (resolvedFormat === "json") { yield* output.printJson(snapshot); } else { diff --git a/src/cli/terminal/destroy.ts b/src/cli/terminal/destroy.ts index 820bdfa..eeef765 100644 --- a/src/cli/terminal/destroy.ts +++ b/src/cli/terminal/destroy.ts @@ -1,12 +1,13 @@ import * as Effect from "effect/Effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import { requireDestructiveConfirmation } from "../confirm.ts"; -import { formatTerminalDestroyedHuman } from "../terminal-format.ts"; +import { requireDestructiveConfirmation } from "../interaction/confirm.ts"; +import { formatTerminalDestroyedHuman } from "../format/terminal.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { formatFlag, threadFlag, yesFlag } from "../flags.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { requireCommandThreadId } from "./scope.ts"; @@ -22,16 +23,15 @@ export const destroyTerminalCommand = Command.make( ({ thread, terminalId, quiet, yes, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const threadId = yield* requireCommandThreadId({ thread }); yield* requireDestructiveConfirmation({ message: `Destroy terminal ${terminalId} in thread ${threadId} and delete its history?`, yes, - environment, + cliRuntime, + t3CliEnv, }); const terminal = { threadId, @@ -41,7 +41,7 @@ export const destroyTerminalCommand = Command.make( if (quiet) { return; } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); if (resolvedFormat === "json") { yield* output.printJson({ threadId: terminal.threadId, diff --git a/src/cli/terminal/list.ts b/src/cli/terminal/list.ts index 3d2a013..e82f31e 100644 --- a/src/cli/terminal/list.ts +++ b/src/cli/terminal/list.ts @@ -1,11 +1,12 @@ import * as Effect from "effect/Effect"; import { Command } from "effect/unstable/cli"; -import { formatTerminalListHuman } from "../terminal-format.ts"; +import { formatTerminalListHuman } from "../format/terminal.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { formatFlag, threadFlag } from "../flags.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { requireCommandThreadId } from "./scope.ts"; @@ -18,13 +19,11 @@ export const listTerminalCommand = Command.make( ({ thread, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const threadId = yield* requireCommandThreadId({ thread }); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const terminals = yield* application.listTerminals(threadId); if (resolvedFormat === "json") { yield* output.printJson(terminals); diff --git a/src/cli/terminal/read.test-utils.ts b/src/cli/terminal/read.test-utils.ts new file mode 100644 index 0000000..54e93e4 --- /dev/null +++ b/src/cli/terminal/read.test-utils.ts @@ -0,0 +1,48 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { fromPartial } from "@total-typescript/shoehorn"; + +import { T3Application } from "../../application/service.ts"; +import { T3Config } from "../../config/config.ts"; +import * as CliRuntime from "../../cli/runtime/service.ts"; +import { t3CliEnvConfigLayer } from "../../config/env/env.test-utils.ts"; +import { T3Input } from "../input/service.ts"; +import { T3Output } from "../output/service.ts"; + +export const terminalCommandTestLayer = Layer.mergeAll( + Layer.succeed( + T3Application, + fromPartial({ + getTerminal: () => Effect.die("getTerminal should not be called"), + attachTerminal: () => Effect.die("attachTerminal should not be called"), + writeTerminal: () => Effect.die("writeTerminal should not be called"), + }), + ), + Layer.succeed( + T3Config, + fromPartial({ + resolve: () => + Effect.succeed({ + url: "ws://localhost", + token: "token", + source: "config", + local: true, + }), + }), + ), + Layer.succeed(T3Output, { + writeStdout: () => Effect.void, + writeStderr: () => Effect.void, + printJson: () => Effect.void, + printNdjson: () => Effect.void, + printInfo: () => Effect.void, + }), + Layer.succeed(T3Input, { + readStdin: () => Effect.die("readStdin should not be called"), + readStdinBinary: () => Effect.die("readStdinBinary should not be called"), + }), + NodeServices.layer, + CliRuntime.layer, + t3CliEnvConfigLayer("/tmp/t3cli-test"), +); diff --git a/src/cli/terminal/read.test.ts b/src/cli/terminal/read.test.ts new file mode 100644 index 0000000..f70e552 --- /dev/null +++ b/src/cli/terminal/read.test.ts @@ -0,0 +1,35 @@ +import "vite-plus/test/config"; + +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { assert, describe, it } from "@effect/vitest"; +import { Command } from "effect/unstable/cli"; + +import { InvalidFlagCombinationError } from "../error.ts"; +import { readTerminalCommand } from "./read.ts"; +import { terminalCommandTestLayer } from "./read.test-utils.ts"; + +describe("readTerminalCommand", () => { + it.layer(terminalCommandTestLayer)("readTerminalCommand", (t) => { + t.effect("rejects --from-sequence without --follow", () => + Effect.gen(function* () { + const run = Command.runWith(readTerminalCommand, { version: "0.0.0-test" }); + const exit = yield* run(["--thread", "thread-1", "term-1", "--from-sequence", "1"]).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + if (!Exit.isFailure(exit)) { + return; + } + const error = Cause.findErrorOption(exit.cause); + assert.isTrue(Option.isSome(error)); + if (Option.isSome(error)) { + assert.instanceOf(error.value, InvalidFlagCombinationError); + assert.equal(error.value.message, "--from-sequence requires --follow"); + } + }), + ); + }); +}); diff --git a/src/cli/terminal/read.ts b/src/cli/terminal/read.ts index 4e463ad..a73295b 100644 --- a/src/cli/terminal/read.ts +++ b/src/cli/terminal/read.ts @@ -4,7 +4,6 @@ import * as Stream from "effect/Stream"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; import { InvalidFlagCombinationError, InvalidLimitError } from "../error.ts"; import { threadFlag } from "../flags.ts"; import { T3Output } from "../output/service.ts"; @@ -28,11 +27,7 @@ export const readTerminalCommand = Command.make( Effect.gen(function* () { const output = yield* T3Output; const application = yield* T3Application; - const environment = yield* Environment; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const threadId = yield* requireCommandThreadId({ thread }); const fromSequenceValue = Option.getOrUndefined(fromSequence); if (fromSequenceValue !== undefined && fromSequenceValue < 0) { diff --git a/src/cli/terminal/scope.ts b/src/cli/terminal/scope.ts index d87cb7b..296e6af 100644 --- a/src/cli/terminal/scope.ts +++ b/src/cli/terminal/scope.ts @@ -1,24 +1,29 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { resolveThreadId } from "../../scope/index.ts"; +import type { T3CliEnvScope } from "../../config/env/env.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { MissingThreadError } from "../error.ts"; export function resolveCommandThreadId(input: { readonly thread: Option.Option; - readonly env: Readonly>; + readonly scope: T3CliEnvScope; }): string | undefined { return resolveThreadId({ value: Option.getOrUndefined(input.thread), - env: input.env, + scope: input.scope, }); } export const requireCommandThreadId = Effect.fn("requireCommandThreadId")(function* (input: { readonly thread: Option.Option; - readonly env: Readonly>; }) { - const threadId = resolveCommandThreadId(input); + const t3CliEnv = yield* loadT3CliEnv; + const threadId = resolveCommandThreadId({ + thread: input.thread, + scope: t3CliEnv.scope, + }); if (threadId === undefined) { return yield* Effect.fail( new MissingThreadError({ diff --git a/src/cli/terminal/stream.ts b/src/cli/terminal/stream.ts index 34b7faa..2e98993 100644 --- a/src/cli/terminal/stream.ts +++ b/src/cli/terminal/stream.ts @@ -4,7 +4,6 @@ import * as Stream from "effect/Stream"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; import { InvalidLimitError } from "../error.ts"; import { threadFlag } from "../flags.ts"; import { T3Output } from "../output/service.ts"; @@ -25,11 +24,7 @@ export const streamTerminalCommand = Command.make( Effect.gen(function* () { const output = yield* T3Output; const application = yield* T3Application; - const environment = yield* Environment; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const threadId = yield* requireCommandThreadId({ thread }); const fromSequenceValue = Option.getOrUndefined(fromSequence); if (fromSequenceValue !== undefined && fromSequenceValue < 0) { diff --git a/src/cli/terminal/wait.test.ts b/src/cli/terminal/wait.test.ts index e2041f7..ca49fb9 100644 --- a/src/cli/terminal/wait.test.ts +++ b/src/cli/terminal/wait.test.ts @@ -12,8 +12,9 @@ import { Command } from "effect/unstable/cli"; import { fromPartial } from "@total-typescript/shoehorn"; import { T3Application } from "../../application/service.ts"; -import { T3Config } from "../../config/service.ts"; -import { NodeEnvironmentLive } from "../../environment/layer.ts"; +import { T3Config } from "../../config/config.ts"; +import * as CliRuntime from "../../cli/runtime/service.ts"; +import { t3CliEnvConfigLayer } from "../../config/env/env.test-utils.ts"; import { T3Output } from "../output/service.ts"; import { TerminalCliError } from "./error.ts"; import { resolveMetadataWait, waitTerminalCommand } from "./wait.ts"; @@ -95,7 +96,8 @@ describe("waitTerminalCommand", () => { printInfo: () => Effect.void, }), NodeServices.layer, - NodeEnvironmentLive, + CliRuntime.layer, + t3CliEnvConfigLayer("/tmp/t3cli-test"), ); it.layer(testLayer)("waitTerminalCommand", (t) => { diff --git a/src/cli/terminal/wait.ts b/src/cli/terminal/wait.ts index cb07f1f..76ec47c 100644 --- a/src/cli/terminal/wait.ts +++ b/src/cli/terminal/wait.ts @@ -5,9 +5,10 @@ import type { TerminalMetadataStreamEvent, TerminalSummary } from "#t3tools/cont import { Argument, Command, Flag } from "effect/unstable/cli"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { formatFlag, threadFlag } from "../flags.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { TerminalCliError } from "./error.ts"; import { requireCommandThreadId } from "./scope.ts"; @@ -54,13 +55,11 @@ export const waitTerminalCommand = Command.make( ({ thread, terminalId, target, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const threadId = yield* requireCommandThreadId({ thread }); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const resolution = yield* application.watchTerminalMetadata().pipe( Stream.map((event) => resolveMetadataWait(event, target, threadId, terminalId)), diff --git a/src/cli/terminal/write.test.ts b/src/cli/terminal/write.test.ts new file mode 100644 index 0000000..c425a7b --- /dev/null +++ b/src/cli/terminal/write.test.ts @@ -0,0 +1,34 @@ +import "vite-plus/test/config"; + +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { assert, describe, it } from "@effect/vitest"; +import { Command } from "effect/unstable/cli"; + +import { InvalidFlagCombinationError } from "../error.ts"; +import { terminalCommandTestLayer } from "./read.test-utils.ts"; +import { writeTerminalCommand } from "./write.ts"; + +describe("writeTerminalCommand", () => { + it.layer(terminalCommandTestLayer)("writeTerminalCommand", (t) => { + t.effect("rejects multiple payload sources", () => + Effect.gen(function* () { + const run = Command.runWith(writeTerminalCommand, { version: "0.0.0-test" }); + const exit = yield* run(["--thread", "thread-1", "term-1", "hello", "--stdin"]).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + if (!Exit.isFailure(exit)) { + return; + } + const error = Cause.findErrorOption(exit.cause); + assert.isTrue(Option.isSome(error)); + if (Option.isSome(error)) { + assert.instanceOf(error.value, InvalidFlagCombinationError); + } + }), + ); + }); +}); diff --git a/src/cli/terminal/write.ts b/src/cli/terminal/write.ts index 24a8a31..9eff2ec 100644 --- a/src/cli/terminal/write.ts +++ b/src/cli/terminal/write.ts @@ -2,13 +2,14 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import { formatTerminalWrittenHuman } from "../terminal-format.ts"; +import { formatTerminalWrittenHuman } from "../format/terminal.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { InvalidFlagCombinationError } from "../error.ts"; import { T3Input } from "../input/service.ts"; import { formatFlag, threadFlag } from "../flags.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { decodeBase64Payload, decodeHexPayload } from "./encoding.ts"; import { TerminalCliError } from "./error.ts"; @@ -31,11 +32,9 @@ export const writeTerminalCommand = Command.make( const output = yield* T3Output; const inputService = yield* T3Input; const application = yield* T3Application; - const environment = yield* Environment; - const threadId = yield* requireCommandThreadId({ - thread, - env: environment.env, - }); + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + const threadId = yield* requireCommandThreadId({ thread }); const argumentData = Option.getOrUndefined(data); const hexData = Option.getOrUndefined(hex); const base64Data = Option.getOrUndefined(base64); @@ -83,7 +82,7 @@ export const writeTerminalCommand = Command.make( return; } const bytes = Buffer.byteLength(payload, "latin1"); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); if (resolvedFormat === "json") { yield* output.printJson({ threadId, diff --git a/src/cli/threads/approve.ts b/src/cli/threads/approve.ts index 372185e..23f74d0 100644 --- a/src/cli/threads/approve.ts +++ b/src/cli/threads/approve.ts @@ -4,10 +4,11 @@ import { Command, Flag } from "effect/unstable/cli"; import { formatFlag, threadFlag } from "../flags.ts"; import { MissingRequestError, MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; const approvalDecisionFlag = Flag.choice("decision", ["accept", "decline", "cancel"] as const).pipe( @@ -30,11 +31,12 @@ export const approveThreadCommand = Command.make( ({ thread, request, decision, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -49,7 +51,7 @@ export const approveThreadCommand = Command.make( new MissingRequestError({ message: "request id is required: pass --request" }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* application.approveThread({ threadId, requestId, decision }); if (resolvedFormat === "json") { return yield* output.printJson(result); diff --git a/src/cli/threads/archive.ts b/src/cli/threads/archive.ts index 7423d8b..f3b46a3 100644 --- a/src/cli/threads/archive.ts +++ b/src/cli/threads/archive.ts @@ -4,11 +4,12 @@ import { Command } from "effect/unstable/cli"; import { formatFlag, selfActionForceFlag, threadFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { requireSelfActionConfirmation } from "../self-action.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { requireSelfActionConfirmation } from "../interaction/self-action.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const archiveThreadCommand = Command.make( @@ -21,11 +22,12 @@ export const archiveThreadCommand = Command.make( ({ thread, force, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -37,10 +39,11 @@ export const archiveThreadCommand = Command.make( yield* requireSelfActionConfirmation({ threadId, force, - environment, + cliRuntime, + t3CliEnv, action: "archive", }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const dispatch = yield* application.archiveThread(threadId); if (resolvedFormat === "json") { return yield* output.printJson(dispatch); diff --git a/src/cli/threads/callback.ts b/src/cli/threads/callback.ts index 7954dcc..cf29126 100644 --- a/src/cli/threads/callback.ts +++ b/src/cli/threads/callback.ts @@ -6,8 +6,8 @@ import { Command, Flag } from "effect/unstable/cli"; import { threadFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; -import { Environment } from "../../environment/service.ts"; +import { resolveThreadId } from "../scope/index.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { T3Application } from "../../application/service.ts"; import { T3Output } from "../output/service.ts"; import { CliPath } from "../../cli-path/service.ts"; @@ -26,7 +26,7 @@ export const callbackThreadCommand = Command.make( ({ from, thread, prompt, background }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const cliPath = yield* CliPath; const spawner = yield* ChildProcessSpawner; @@ -35,7 +35,7 @@ export const callbackThreadCommand = Command.make( const targetThreadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (targetThreadId === undefined) { return yield* Effect.fail( diff --git a/src/cli/threads/delete.ts b/src/cli/threads/delete.ts index 98923b0..c065eb0 100644 --- a/src/cli/threads/delete.ts +++ b/src/cli/threads/delete.ts @@ -2,15 +2,16 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { Command } from "effect/unstable/cli"; -import { requireDestructiveConfirmation } from "../confirm.ts"; +import { requireDestructiveConfirmation } from "../interaction/confirm.ts"; import { formatFlag, selfActionForceFlag, threadFlag, yesFlag } from "../flags.ts"; -import { formatThreadDeletedHuman } from "../thread-format.ts"; +import { formatThreadDeletedHuman } from "../format/thread.ts"; import { MissingThreadError } from "../error.ts"; -import { requireSelfActionConfirmation } from "../self-action.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { requireSelfActionConfirmation } from "../interaction/self-action.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const deleteThreadCommand = Command.make( @@ -24,11 +25,12 @@ export const deleteThreadCommand = Command.make( ({ thread, force, yes, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -40,15 +42,17 @@ export const deleteThreadCommand = Command.make( yield* requireSelfActionConfirmation({ threadId, force, - environment, + cliRuntime, + t3CliEnv, action: "delete", }); yield* requireDestructiveConfirmation({ message: `Delete thread ${threadId}?`, yes, - environment, + cliRuntime, + t3CliEnv, }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* application.deleteThread(threadId); if (resolvedFormat === "json") { return yield* output.printJson({ diff --git a/src/cli/threads/interrupt.ts b/src/cli/threads/interrupt.ts index 32cf30a..65cadce 100644 --- a/src/cli/threads/interrupt.ts +++ b/src/cli/threads/interrupt.ts @@ -4,11 +4,12 @@ import { Command } from "effect/unstable/cli"; import { formatFlag, selfActionForceFlag, threadFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { requireSelfActionConfirmation } from "../self-action.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { requireSelfActionConfirmation } from "../interaction/self-action.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const interruptThreadCommand = Command.make( @@ -21,11 +22,12 @@ export const interruptThreadCommand = Command.make( ({ thread, force, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -37,10 +39,11 @@ export const interruptThreadCommand = Command.make( yield* requireSelfActionConfirmation({ threadId, force, - environment, + cliRuntime, + t3CliEnv, action: "interrupt", }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const dispatch = yield* application.interruptThread(threadId); if (resolvedFormat === "json") { return yield* output.printJson({ threadId, dispatch }); diff --git a/src/cli/threads/list.test.ts b/src/cli/threads/list.test.ts index 9a9621b..3a0f902 100644 --- a/src/cli/threads/list.test.ts +++ b/src/cli/threads/list.test.ts @@ -11,8 +11,9 @@ import { Command } from "effect/unstable/cli"; import { fromPartial } from "@total-typescript/shoehorn"; import { T3Application } from "../../application/service.ts"; -import { T3Config } from "../../config/service.ts"; -import { NodeEnvironmentLive } from "../../environment/layer.ts"; +import { T3Config } from "../../config/config.ts"; +import * as CliRuntime from "../../cli/runtime/service.ts"; +import { t3CliEnvConfigLayer } from "../../config/env/env.test-utils.ts"; import { InvalidFlagCombinationError } from "../error.ts"; import { T3Output } from "../output/service.ts"; import { listThreadsCommand } from "./list.ts"; @@ -44,7 +45,8 @@ const testLayer = Layer.mergeAll( printInfo: () => Effect.void, }), NodeServices.layer, - NodeEnvironmentLive, + CliRuntime.layer, + t3CliEnvConfigLayer("/tmp/t3cli-test"), ); describe("listThreadsCommand", () => { diff --git a/src/cli/threads/list.ts b/src/cli/threads/list.ts index be3ad33..7d03b4c 100644 --- a/src/cli/threads/list.ts +++ b/src/cli/threads/list.ts @@ -5,10 +5,11 @@ import type { ListThreadsInclude } from "../../application/service.ts"; import { formatFlag, projectFlag } from "../flags.ts"; import { InvalidFlagCombinationError } from "../error.ts"; import { requireCommandProjectRef } from "../require.ts"; -import { formatThreadsHuman } from "../thread-format.ts"; +import { formatThreadsHuman } from "../format/thread.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const listThreadsCommand = Command.make( @@ -22,7 +23,8 @@ export const listThreadsCommand = Command.make( ({ project, archived, all, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; if (archived && all) { yield* Effect.fail( @@ -32,12 +34,8 @@ export const listThreadsCommand = Command.make( ); } const include: ListThreadsInclude = archived ? "archived" : all ? "all" : "active"; - const resolvedFormat = resolveOutputFormat(format, environment, "json"); - const projectRef = yield* requireCommandProjectRef({ - project, - env: environment.env, - cwd: environment.cwd, - }); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); + const projectRef = yield* requireCommandProjectRef({ project }); const result = yield* application.listThreads(projectRef, { include }); if (resolvedFormat === "json") { yield* output.printJson(result.threads); diff --git a/src/cli/threads/messages.ts b/src/cli/threads/messages.ts index 6bc900a..6209c04 100644 --- a/src/cli/threads/messages.ts +++ b/src/cli/threads/messages.ts @@ -5,11 +5,12 @@ import { Command, Flag } from "effect/unstable/cli"; import { formatFlag, threadFlag } from "../flags.ts"; import { InvalidLimitError } from "../error.ts"; import { MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; -import { formatThreadMessagesHuman, formatThreadMessagesJson } from "../thread-format.ts"; +import { resolveThreadId } from "../scope/index.ts"; +import { formatThreadMessagesHuman, formatThreadMessagesJson } from "../format/thread.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const getThreadTranscriptCommand = Command.make( @@ -28,11 +29,12 @@ export const getThreadTranscriptCommand = Command.make( ); } const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -41,7 +43,7 @@ export const getThreadTranscriptCommand = Command.make( }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const detail = yield* application.getThreadMessages(threadId); if (resolvedFormat === "json") { return yield* output.printJson(formatThreadMessagesJson(detail, full)); diff --git a/src/cli/threads/respond.ts b/src/cli/threads/respond.ts index 3018cbe..b8e32df 100644 --- a/src/cli/threads/respond.ts +++ b/src/cli/threads/respond.ts @@ -5,11 +5,12 @@ import { Command, Flag } from "effect/unstable/cli"; import { formatFlag, threadFlag } from "../flags.ts"; import { MissingRequestError, MissingThreadError } from "../error.ts"; import { readJsonAnswers } from "../message-input.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { T3Input } from "../input/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; const requestFlag = Flag.string("request").pipe( @@ -35,11 +36,12 @@ export const respondThreadCommand = Command.make( readStdin: inputService.readStdin, }); const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -54,7 +56,7 @@ export const respondThreadCommand = Command.make( new MissingRequestError({ message: "request id is required: pass --request" }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const result = yield* application.respondToThread({ threadId, requestId, diff --git a/src/cli/threads/send.ts b/src/cli/threads/send.ts index f2fa43f..e84d933 100644 --- a/src/cli/threads/send.ts +++ b/src/cli/threads/send.ts @@ -6,12 +6,13 @@ import { modelFlags, selfActionForceFlag, threadFlag, threadFormatFlag } from ". import { readInitialMessage } from "../message-input.ts"; import { buildModelOptions } from "../model-options.ts"; import { MissingThreadError } from "../error.ts"; -import { requireSelfActionConfirmation } from "../self-action.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { requireSelfActionConfirmation } from "../interaction/self-action.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { T3Input } from "../input/service.ts"; -import { canRenderLiveTerminal, resolveOutputFormat } from "../output-format.ts"; +import { canRenderLiveTerminal, resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { printWaitEventsHuman, printWaitEventsNdjson } from "../wait-events.ts"; @@ -54,11 +55,12 @@ export const sendThreadCommand = Command.make( thinking, }); const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -70,7 +72,8 @@ export const sendThreadCommand = Command.make( yield* requireSelfActionConfirmation({ threadId, force, - environment, + cliRuntime, + t3CliEnv, action: "send a message to", }); const input = { @@ -78,7 +81,12 @@ export const sendThreadCommand = Command.make( threadId, ...(options.length > 0 ? { options } : {}), }; - const resolvedFormat = resolveOutputFormat(format, environment, wait ? "ndjson" : "json"); + const resolvedFormat = resolveOutputFormat( + format, + cliRuntime, + t3CliEnv, + wait ? "ndjson" : "json", + ); if (resolvedFormat === "ndjson") { const sent = yield* application.sendThread(input, { until: wait ? "dispatch" : "visible" }); @@ -101,7 +109,7 @@ export const sendThreadCommand = Command.make( } yield* printWaitEventsHuman(output, application.watchThread(sent.threadId), { threadId: sent.threadId, - live: canRenderLiveTerminal(environment), + live: canRenderLiveTerminal(cliRuntime, t3CliEnv), }); return yield* Effect.void; } diff --git a/src/cli/threads/show.ts b/src/cli/threads/show.ts index 7f97cd1..0b9c832 100644 --- a/src/cli/threads/show.ts +++ b/src/cli/threads/show.ts @@ -4,11 +4,12 @@ import { Command } from "effect/unstable/cli"; import { formatFlag, threadFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; -import { formatThreadShowHuman, formatThreadShowJson } from "../thread-format.ts"; +import { resolveThreadId } from "../scope/index.ts"; +import { formatThreadShowHuman, formatThreadShowJson } from "../format/thread.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const showThreadCommand = Command.make( @@ -20,11 +21,12 @@ export const showThreadCommand = Command.make( ({ thread, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -33,7 +35,7 @@ export const showThreadCommand = Command.make( }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const detail = yield* application.showThread(threadId); if (resolvedFormat === "json") { return yield* output.printJson(formatThreadShowJson(detail)); diff --git a/src/cli/threads/start.ts b/src/cli/threads/start.ts index 5b71455..6eaa656 100644 --- a/src/cli/threads/start.ts +++ b/src/cli/threads/start.ts @@ -7,12 +7,13 @@ import { modelFlags, projectFlag, threadFormatFlag, worktreeFlag } from "../flag import { readInitialMessage } from "../message-input.ts"; import { buildModelOptions } from "../model-options.ts"; import { requireCommandProjectRef } from "../require.ts"; -import { resolveWorktreePath } from "../../scope/index.ts"; -import { formatThreadStartedHuman } from "../thread-format.ts"; +import { resolveWorktreePath } from "../scope/index.ts"; +import { formatThreadStartedHuman } from "../format/thread.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { T3Input } from "../input/service.ts"; -import { canRenderLiveTerminal, resolveOutputFormat } from "../output-format.ts"; +import { canRenderLiveTerminal, resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { printWaitEventsHuman, printWaitEventsNdjson } from "../wait-events.ts"; @@ -64,16 +65,13 @@ export const startThreadCommand = Command.make( thinking, }); const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; - const projectRef = yield* requireCommandProjectRef({ - project, - env: environment.env, - cwd: environment.cwd, - }); + const projectRef = yield* requireCommandProjectRef({ project }); const worktreePath = resolveWorktreePath({ value: Option.getOrUndefined(worktree), - env: environment.env, + scope: t3CliEnv.scope, }); const input = { message: text, @@ -86,7 +84,12 @@ export const startThreadCommand = Command.make( ...(modelValue !== undefined && modelValue.length > 0 ? { model: modelValue } : {}), ...(options.length > 0 ? { options } : {}), }; - const resolvedFormat = resolveOutputFormat(format, environment, wait ? "ndjson" : "json"); + const resolvedFormat = resolveOutputFormat( + format, + cliRuntime, + t3CliEnv, + wait ? "ndjson" : "json", + ); if (resolvedFormat === "ndjson") { const started = yield* application.startThread(input, { @@ -117,7 +120,7 @@ export const startThreadCommand = Command.make( } yield* printWaitEventsHuman(output, application.watchThread(started.threadId), { threadId: started.threadId, - live: canRenderLiveTerminal(environment), + live: canRenderLiveTerminal(cliRuntime, t3CliEnv), }); return; } diff --git a/src/cli/threads/unarchive.ts b/src/cli/threads/unarchive.ts index 6718e7a..daeef3a 100644 --- a/src/cli/threads/unarchive.ts +++ b/src/cli/threads/unarchive.ts @@ -4,10 +4,11 @@ import { Command } from "effect/unstable/cli"; import { formatFlag, threadFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const unarchiveThreadCommand = Command.make( @@ -19,11 +20,12 @@ export const unarchiveThreadCommand = Command.make( ({ thread, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -32,7 +34,7 @@ export const unarchiveThreadCommand = Command.make( }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); const dispatch = yield* application.unarchiveThread(threadId); if (resolvedFormat === "json") { return yield* output.printJson(dispatch); diff --git a/src/cli/threads/update.ts b/src/cli/threads/update.ts index 7de7b1f..7b6137b 100644 --- a/src/cli/threads/update.ts +++ b/src/cli/threads/update.ts @@ -8,12 +8,13 @@ import { MissingThreadError, MissingUpdateFieldsError, } from "../error.ts"; -import { requireSelfActionConfirmation } from "../self-action.ts"; +import { requireSelfActionConfirmation } from "../interaction/self-action.ts"; import { buildModelOptions } from "../model-options.ts"; -import { resolveThreadId } from "../../scope/index.ts"; +import { resolveThreadId } from "../scope/index.ts"; import { T3Application } from "../../application/service.ts"; -import { Environment } from "../../environment/service.ts"; -import { resolveOutputFormat } from "../output-format.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; export const updateThreadCommand = Command.make( @@ -50,11 +51,12 @@ export const updateThreadCommand = Command.make( }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -67,7 +69,8 @@ export const updateThreadCommand = Command.make( yield* requireSelfActionConfirmation({ threadId, force, - environment, + cliRuntime, + t3CliEnv, action: "update", }); @@ -141,7 +144,7 @@ export const updateThreadCommand = Command.make( ? { worktreePath: worktreeValue } : {}), }); - const resolvedFormat = resolveOutputFormat(format, environment, "json"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); if (resolvedFormat === "json") { return yield* output.printJson(dispatch); } diff --git a/src/cli/threads/wait.ts b/src/cli/threads/wait.ts index 8c1ea6c..2183a73 100644 --- a/src/cli/threads/wait.ts +++ b/src/cli/threads/wait.ts @@ -4,10 +4,11 @@ import { Command } from "effect/unstable/cli"; import { threadFlag, waitFormatFlag } from "../flags.ts"; import { MissingThreadError } from "../error.ts"; -import { resolveThreadId } from "../../scope/index.ts"; -import { Environment } from "../../environment/service.ts"; +import { resolveThreadId } from "../scope/index.ts"; +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; import { T3Application } from "../../application/service.ts"; -import { canRenderLiveTerminal, resolveOutputFormat } from "../output-format.ts"; +import { canRenderLiveTerminal, resolveOutputFormat } from "../format/output.ts"; import { T3Output } from "../output/service.ts"; import { printWaitEventsHuman, printWaitEventsNdjson } from "../wait-events.ts"; @@ -20,11 +21,12 @@ export const waitForThreadCommand = Command.make( ({ thread, format }) => Effect.gen(function* () { const application = yield* T3Application; - const environment = yield* Environment; + const cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; const output = yield* T3Output; const threadId = resolveThreadId({ value: Option.getOrUndefined(thread), - env: environment.env, + scope: t3CliEnv.scope, }); if (threadId === undefined) { return yield* Effect.fail( @@ -33,13 +35,13 @@ export const waitForThreadCommand = Command.make( }), ); } - const resolvedFormat = resolveOutputFormat(format, environment, "ndjson"); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "ndjson"); if (resolvedFormat === "ndjson") { return yield* printWaitEventsNdjson(output, application.watchThread(threadId)); } return yield* printWaitEventsHuman(output, application.watchThread(threadId), { threadId, - live: canRenderLiveTerminal(environment), + live: canRenderLiveTerminal(cliRuntime, t3CliEnv), }); }), ).pipe(Command.withDescription("wait for thread to pause")); diff --git a/src/cli/wait-events.ts b/src/cli/wait-events.ts index 1daa0a7..ec46e9d 100644 --- a/src/cli/wait-events.ts +++ b/src/cli/wait-events.ts @@ -8,7 +8,7 @@ import type { WaitEvent } from "../application/service.ts"; import { ThreadSessionError } from "../domain/error.ts"; import { latestAssistantMessage } from "../domain/thread-lifecycle.ts"; import type { T3Output } from "./output/service.ts"; -import { formatWaitDoneHuman, formatWaitEventNdjson } from "./thread-format.ts"; +import { formatWaitDoneHuman, formatWaitEventNdjson } from "./format/thread.ts"; export function printWaitEventsNdjson( output: T3Output["Service"], diff --git a/src/config/layer.ts b/src/config/config.ts similarity index 57% rename from src/config/layer.ts rename to src/config/config.ts index 5d68b1d..6e4bca8 100644 --- a/src/config/layer.ts +++ b/src/config/config.ts @@ -1,13 +1,17 @@ +import * as Context from "effect/Context"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { Environment } from "../environment/service.ts"; -import { T3CredentialCrypto } from "./credential.ts"; -import { ConfigError, configErrorFromUrl } from "./error.ts"; -import { validateEnvironmentName } from "./environment-name.ts"; -import { readEncryptedConfigFile, writeEncryptedConfigFile } from "./persist.ts"; +import { credentialTokenFromEnv } from "./credential/env.ts"; +import { T3CredentialCrypto } from "./credential/service.ts"; +import { loadT3CliEnv } from "./env/env.ts"; +import { validateEnvironmentName } from "./environment-name/name.ts"; +import { ConfigError } from "./error.ts"; +import { readEncryptedConfigFile, writeEncryptedConfigFile } from "./persist/persist.ts"; import { buildResolvedConfigFromEnv, buildResolvedConfigFromStored, @@ -15,48 +19,57 @@ import { selectEnvironmentName, summarizeEnvironments, validateCredentialEnvVars, -} from "./resolve.ts"; -import { T3ConfigSelection } from "./selection.ts"; -import { T3Config } from "./service.ts"; -import type { UpsertEnvironmentInput } from "./types.ts"; -import { normalizeHttpBaseUrl } from "./url.ts"; +} from "./resolve/resolve.ts"; +import { T3ConfigSelection } from "./selection/service.ts"; +import type { EnvironmentSummary, ResolvedConfig, UpsertEnvironmentInput } from "./types.ts"; +import { normalizeHttpBaseUrl } from "./url/url.ts"; + +export class T3Config extends Context.Service< + T3Config, + { + readonly resolve: () => Effect.Effect; + readonly resolveActiveEnvironmentName: () => Effect.Effect; + readonly listEnvironments: () => Effect.Effect; + readonly upsertEnvironment: (input: UpsertEnvironmentInput) => Effect.Effect; + readonly setDefaultEnvironment: (name: string) => Effect.Effect; + readonly removeEnvironment: (name: string) => Effect.Effect; + readonly hasEnvironment: (name: string) => Effect.Effect; + readonly getDefaultEnvironmentName: () => Effect.Effect; + } +>()("t3cli/T3Config") {} export const make = Effect.fn("makeT3Config")(function* () { - const environment = yield* Environment; const configSelection = yield* T3ConfigSelection; + const credentialCrypto = yield* T3CredentialCrypto; + const t3CliEnv = yield* loadT3CliEnv; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const credentialCrypto = yield* T3CredentialCrypto; + const configProvider = yield* ConfigProvider.ConfigProvider; const services = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path), - Layer.succeed(Environment, environment), Layer.succeed(T3CredentialCrypto, credentialCrypto), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), ); - const run = (effect: Effect.Effect) => Effect.provide(effect, services); - const readEncrypted = () => run(readEncryptedConfigFile()); - const hasEnvironment = Effect.fn("T3ConfigLive.hasEnvironment")(function* (name: string) { - const encrypted = yield* readEncrypted(); + const hasEnvironment = Effect.fn("T3Config.hasEnvironment")(function* (name: string) { + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); return encrypted.environments[name] !== undefined; }); - const getDefaultEnvironmentName = Effect.fn("T3ConfigLive.getDefaultEnvironmentName")( - function* () { - const encrypted = yield* readEncrypted(); - const defaultName = encrypted.default; - return defaultName !== undefined && defaultName.length > 0 ? defaultName : undefined; - }, - ); + const getDefaultEnvironmentName = Effect.fn("T3Config.getDefaultEnvironmentName")(function* () { + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + const defaultName = encrypted.default; + return defaultName !== undefined && defaultName.length > 0 ? defaultName : undefined; + }); - const resolveActiveEnvironmentName = Effect.fn("T3ConfigLive.resolveActiveEnvironmentName")( + const resolveActiveEnvironmentName = Effect.fn("T3Config.resolveActiveEnvironmentName")( function* () { - const encrypted = yield* readEncrypted(); - const envUrl = environment.env["T3CODE_URL"]?.trim(); - const envToken = environment.env["T3CODE_TOKEN"]?.trim(); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + const envUrl = Option.getOrUndefined(t3CliEnv.t3codeUrl); + const envToken = Option.getOrUndefined(credentialTokenFromEnv(t3CliEnv)); yield* validateCredentialEnvVars({ envUrl, envToken }); - const hasEnvCredentials = - envUrl !== undefined && envUrl.length > 0 && envToken !== undefined && envToken.length > 0; + const hasEnvCredentials = envUrl !== undefined && envToken !== undefined; if (hasEnvCredentials) { return undefined; } @@ -75,19 +88,19 @@ export const make = Effect.fn("makeT3Config")(function* () { }, ); - const listEnvironments = Effect.fn("T3ConfigLive.listEnvironments")(function* () { - const encrypted = yield* readEncrypted(); + const listEnvironments = Effect.fn("T3Config.listEnvironments")(function* () { + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); return summarizeEnvironments(encrypted); }); - const upsertEnvironment = Effect.fn("T3ConfigLive.upsertEnvironment")(function* ( + const upsertEnvironment = Effect.fn("T3Config.upsertEnvironment")(function* ( input: UpsertEnvironmentInput, ) { yield* validateEnvironmentName(input.name); const normalizedUrl = yield* normalizeHttpBaseUrl(input.url).pipe( - Effect.mapError(configErrorFromUrl), + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), ); - const encrypted = yield* readEncrypted(); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); const token = yield* credentialCrypto.encrypt({ environmentName: input.name, url: normalizedUrl, @@ -95,7 +108,7 @@ export const make = Effect.fn("makeT3Config")(function* () { token: input.token, }); const defaultName = resolveDefaultForUpsert(encrypted, input.name, input.makeDefault); - yield* run( + yield* Effect.provide( writeEncryptedConfigFile({ version: 2, ...(defaultName !== undefined ? { default: defaultName } : {}), @@ -108,50 +121,53 @@ export const make = Effect.fn("makeT3Config")(function* () { }, }, }), + services, ); }); - const setDefaultEnvironment = Effect.fn("T3ConfigLive.setDefaultEnvironment")(function* ( + const setDefaultEnvironment = Effect.fn("T3Config.setDefaultEnvironment")(function* ( name: string, ) { yield* validateEnvironmentName(name); - const encrypted = yield* readEncrypted(); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); if (encrypted.environments[name] === undefined) { return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } - yield* run( + yield* Effect.provide( writeEncryptedConfigFile({ ...encrypted, default: name, }), + services, ); return yield* Effect.void; }); - const removeEnvironment = Effect.fn("T3ConfigLive.removeEnvironment")(function* (name: string) { + const removeEnvironment = Effect.fn("T3Config.removeEnvironment")(function* (name: string) { yield* validateEnvironmentName(name); - const encrypted = yield* readEncrypted(); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); if (encrypted.environments[name] === undefined) { return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } const { [name]: _removed, ...environments } = encrypted.environments; const defaultName = encrypted.default === name ? undefined : encrypted.default; - yield* run( + yield* Effect.provide( writeEncryptedConfigFile({ version: 2, ...(defaultName !== undefined ? { default: defaultName } : {}), environments, }), + services, ); return yield* Effect.void; }); - const resolve = Effect.fn("T3ConfigLive.resolve")(function* () { - const encrypted = yield* readEncrypted(); - const envUrl = environment.env["T3CODE_URL"]?.trim(); - const envToken = environment.env["T3CODE_TOKEN"]?.trim(); + const resolve = Effect.fn("T3Config.resolve")(function* () { + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + const envUrl = Option.getOrUndefined(t3CliEnv.t3codeUrl); + const envToken = Option.getOrUndefined(credentialTokenFromEnv(t3CliEnv)); yield* validateCredentialEnvVars({ envUrl, envToken }); - const hasEnvUrl = envUrl !== undefined && envUrl.length > 0; + const hasEnvUrl = envUrl !== undefined; const configuredEnvironment = yield* configSelection.getSelectedEnvironment(); const selectedName = selectEnvironmentName({ selectedEnvironment: configuredEnvironment, diff --git a/src/config/credential-cipher-node.ts b/src/config/credential/cipher-node.ts similarity index 98% rename from src/config/credential-cipher-node.ts rename to src/config/credential/cipher-node.ts index 74677f9..d3392c7 100644 --- a/src/config/credential-cipher-node.ts +++ b/src/config/credential/cipher-node.ts @@ -3,7 +3,7 @@ import { createCipheriv, createDecipheriv } from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { credentialCipherTagByteLength, T3CredentialCipher } from "./credential-cipher.ts"; +import { credentialCipherTagByteLength, T3CredentialCipher } from "./cipher.ts"; import { CredentialCipherError } from "./error.ts"; const make = (): T3CredentialCipher["Service"] => ({ diff --git a/src/config/credential-cipher-web.ts b/src/config/credential/cipher-web.ts similarity index 99% rename from src/config/credential-cipher-web.ts rename to src/config/credential/cipher-web.ts index cf7cf79..6ef7f34 100644 --- a/src/config/credential-cipher-web.ts +++ b/src/config/credential/cipher-web.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { credentialCipherTagByteLength, T3CredentialCipher } from "./credential-cipher.ts"; +import { credentialCipherTagByteLength, T3CredentialCipher } from "./cipher.ts"; import { CredentialCipherError } from "./error.ts"; const aesGcmAlgorithm = "AES-GCM"; diff --git a/src/config/credential-cipher.ts b/src/config/credential/cipher.ts similarity index 100% rename from src/config/credential-cipher.ts rename to src/config/credential/cipher.ts diff --git a/src/config/credential/env.ts b/src/config/credential/env.ts new file mode 100644 index 0000000..35373c3 --- /dev/null +++ b/src/config/credential/env.ts @@ -0,0 +1,13 @@ +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; + +import type { T3CliEnvShape } from "../env/env.ts"; + +export function credentialTokenFromEnv( + t3CliEnv: Pick, +): Option.Option { + return Option.flatMap(t3CliEnv.t3codeToken, (token) => { + const value = Redacted.value(token).trim(); + return value.length > 0 ? Option.some(value) : Option.none(); + }); +} diff --git a/src/config/credential/error.ts b/src/config/credential/error.ts new file mode 100644 index 0000000..10c93cb --- /dev/null +++ b/src/config/credential/error.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +export class CredentialCipherError extends Schema.TaggedErrorClass()( + "CredentialCipherError", + { + operation: Schema.Literals(["encrypt", "decrypt"]), + cause: Schema.Defect(), + }, +) {} diff --git a/src/config/credential/index.ts b/src/config/credential/index.ts new file mode 100644 index 0000000..005c91f --- /dev/null +++ b/src/config/credential/index.ts @@ -0,0 +1,6 @@ +export * as Service from "./service.ts"; +export * as Cipher from "./cipher.ts"; +export * as CipherNode from "./cipher-node.ts"; +export * as CipherWeb from "./cipher-web.ts"; +export { credentialTokenFromEnv } from "./env.ts"; +export { CredentialCipherError } from "./error.ts"; diff --git a/src/config/credential/service.test-utils.ts b/src/config/credential/service.test-utils.ts new file mode 100644 index 0000000..cec68da --- /dev/null +++ b/src/config/credential/service.test-utils.ts @@ -0,0 +1,25 @@ +import * as Layer from "effect/Layer"; + +import { CliRuntime } from "../../cli/runtime/service.ts"; +import { ConfigPlatformLayer } from "../platform.test-utils.ts"; +import { t3CliEnvConfigLayer } from "../env/env.test-utils.ts"; +import * as CredentialCipherWeb from "./cipher-web.ts"; +import * as Keystore from "../keystore/service.test-utils.ts"; +import * as Credential from "./service.ts"; + +export function t3CredentialCryptoDepsLayer(homeDir: string) { + return Credential.layer.pipe( + Layer.provide( + Layer.mergeAll( + CliRuntime.layerTest({ cwd: homeDir }), + t3CliEnvConfigLayer(homeDir), + CredentialCipherWeb.layerWeb, + Keystore.unavailableKeystoreFactoryLayer, + ), + ), + ); +} + +export function t3CredentialCryptoLayerTest(homeDir: string) { + return Layer.mergeAll(ConfigPlatformLayer, t3CredentialCryptoDepsLayer(homeDir)); +} diff --git a/src/config/credential.test.ts b/src/config/credential/service.test.ts similarity index 83% rename from src/config/credential.test.ts rename to src/config/credential/service.test.ts index 9764c43..a5f26c2 100644 --- a/src/config/credential.test.ts +++ b/src/config/credential/service.test.ts @@ -6,27 +6,11 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { assert, describe, it } from "@effect/vitest"; -import { expect } from "vite-plus/test"; -import { T3CredentialCrypto } from "./credential.ts"; -import { parseKeyringPassword } from "./keystore-keyring-node.ts"; -import { shouldUseFileKeystoreForRead } from "./keystore.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3CredentialCryptoDepsLayer } from "../test/layers/credential-crypto.ts"; - -describe("parseKeyringPassword", () => { - it("treats invalid stored keyring values as corrupt", () => { - const result = parseKeyringPassword("not-a-valid-key"); - assert.equal(result.kind, "corrupt"); - expect(shouldUseFileKeystoreForRead(result)).toBe(false); - }); - - it("treats unavailable keyring reads as file-keystore fallback", () => { - const result = { kind: "unavailable" as const, message: "failed" }; - expect(shouldUseFileKeystoreForRead(result)).toBe(true); - }); -}); +import { ConfigPlatformLayer } from "../platform.test-utils.ts"; +import { makeTempHomeScoped } from "../temp-home.test-utils.ts"; +import { T3CredentialCrypto } from "./service.ts"; +import { t3CredentialCryptoDepsLayer } from "./service.test-utils.ts"; describe("T3CredentialCrypto", () => { it.effect("falls back to key file when keyring backend is unavailable", () => diff --git a/src/config/credential.ts b/src/config/credential/service.ts similarity index 66% rename from src/config/credential.ts rename to src/config/credential/service.ts index 8911748..ddbf140 100644 --- a/src/config/credential.ts +++ b/src/config/credential/service.ts @@ -1,17 +1,18 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import { Environment } from "../environment/service.ts"; -import { T3CredentialCipher, credentialCipherNonceByteLength } from "./credential-cipher.ts"; -import type { EncryptedToken } from "./schema.ts"; -import { catchEncodingError, ConfigError, CredentialCipherError } from "./error.ts"; -import { makeFileKeystore } from "./keystore-file.ts"; -import { T3MasterKeyKeystoreFactory, masterKeyByteLength } from "./keystore.ts"; +import { T3CredentialCipher, credentialCipherNonceByteLength } from "./cipher.ts"; +import type { EncryptedToken } from "../persist/schema.ts"; +import { ConfigError } from "../error.ts"; +import { CredentialCipherError } from "./error.ts"; +import { makeFileKeystore } from "../keystore/file.ts"; +import { T3MasterKeyKeystoreFactory, masterKeyByteLength } from "../keystore/service.ts"; export type CredentialEncryptInput = { readonly environmentName: string; @@ -42,19 +43,18 @@ const textDecoder = new TextDecoder(); export const make = Effect.fn("makeT3CredentialCrypto")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const environment = yield* Environment; + const configProvider = yield* ConfigProvider.ConfigProvider; const cryptoService = yield* Crypto.Crypto; const cipher = yield* T3CredentialCipher; const keystoreFactory = yield* T3MasterKeyKeystoreFactory; const services = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path), - Layer.succeed(Environment, environment), Layer.succeed(Crypto.Crypto, cryptoService), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), ); - const run = (effect: Effect.Effect) => Effect.provide(effect, services); - const fileKeystore = yield* run(makeFileKeystore()); + const fileKeystore = yield* Effect.provide(makeFileKeystore(), services); const primaryKeystore = yield* keystoreFactory.make().pipe( Effect.catchTags({ KeystoreUnavailableError: () => Effect.succeed(undefined), @@ -100,33 +100,52 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { if (existing !== undefined) { return existing; } - const generated = yield* run(secureRandomBytes(masterKeyByteLength)); + const generated = yield* cryptoService.randomBytes(masterKeyByteLength).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to generate secure random bytes", + cause: error, + }), + ), + ); yield* writeMasterKey(generated); return generated; }); - const encrypt = Effect.fn("encrypt")(function* (input: CredentialEncryptInput) { - const masterKey = yield* getMasterKey(); - const nonce = yield* run(secureRandomBytes(credentialCipherNonceByteLength)); - const encrypted = yield* cipher - .encrypt({ - key: masterKey, - nonce, - plaintext: encodeUtf8(input.token), - additionalData: buildCredentialAad(input), - }) - .pipe(Effect.mapError(mapCredentialCipherError)); - return { - kind: "encrypted" as const, - alg: "aes-256-gcm" as const, - key: "default" as const, - nonce: Encoding.encodeBase64(nonce), - ciphertext: Encoding.encodeBase64(encrypted.ciphertext), - tag: Encoding.encodeBase64(encrypted.tag), - }; - }); - - const decrypt = Effect.fn("decrypt")(function* (input: CredentialDecryptInput) { + const encrypt: (input: CredentialEncryptInput) => Effect.Effect = + Effect.fn("encrypt")(function* (input) { + const masterKey = yield* getMasterKey(); + const nonce = yield* cryptoService.randomBytes(credentialCipherNonceByteLength).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to generate secure random bytes", + cause: error, + }), + ), + ); + const encrypted = yield* cipher + .encrypt({ + key: masterKey, + nonce, + plaintext: encodeUtf8(input.token), + additionalData: buildCredentialAad(input), + }) + .pipe(Effect.mapError(mapCredentialCipherError)); + return { + kind: "encrypted" as const, + alg: "aes-256-gcm" as const, + key: "default" as const, + nonce: Encoding.encodeBase64(nonce), + ciphertext: Encoding.encodeBase64(encrypted.ciphertext), + tag: Encoding.encodeBase64(encrypted.tag), + }; + }); + + const decrypt: (input: CredentialDecryptInput) => Effect.Effect = Effect.fn( + "decrypt", + )(function* (input) { const masterKey = yield* getMasterKey(); const nonce = yield* decodeBase64Field(input.token.nonce, "token nonce"); const ciphertext = yield* decodeBase64Field(input.token.ciphertext, "token ciphertext"); @@ -143,7 +162,7 @@ export const make = Effect.fn("makeT3CredentialCrypto")(function* () { return decodeUtf8(plaintext); }); - return { encrypt, decrypt }; + return { encrypt, decrypt } satisfies T3CredentialCrypto["Service"]; }); export const layer = Layer.effect(T3CredentialCrypto, make()); @@ -166,22 +185,12 @@ function decodeUtf8(value: Uint8Array) { return textDecoder.decode(value); } -const secureRandomBytes = Effect.fn("secureRandomBytes")(function* (size: number) { - const cryptoService = yield* Crypto.Crypto; - return yield* cryptoService.randomBytes(size).pipe( - Effect.mapError( - (error) => - new ConfigError({ - message: "failed to generate secure random bytes", - cause: error, - }), - ), - ); -}); - function decodeBase64Field(value: string, field: string) { return Effect.fromResult(Encoding.decodeBase64(value)).pipe( - Effect.catchTags(catchEncodingError(`invalid encrypted token ${field}`)), + Effect.catchTags({ + EncodingError: (error) => + Effect.fail(new ConfigError({ message: `invalid encrypted token ${field}`, cause: error })), + }), ); } diff --git a/src/config/env/env.test-utils.ts b/src/config/env/env.test-utils.ts new file mode 100644 index 0000000..36c45f0 --- /dev/null +++ b/src/config/env/env.test-utils.ts @@ -0,0 +1,14 @@ +import * as ConfigProvider from "effect/ConfigProvider"; + +export function t3CliEnvConfigLayer( + homeDir: string, + env: Readonly> = {}, +) { + const record: Record = { HOME: homeDir }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + record[key] = value; + } + } + return ConfigProvider.layer(ConfigProvider.fromEnv({ env: record })); +} diff --git a/src/config/env/env.ts b/src/config/env/env.ts new file mode 100644 index 0000000..f54349a --- /dev/null +++ b/src/config/env/env.ts @@ -0,0 +1,81 @@ +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Redacted from "effect/Redacted"; + +export type T3CliEnvScope = { + readonly t3codeProjectRoot?: string | undefined; + readonly t3codeProjectId?: string | undefined; + readonly t3codeWorktreePath?: string | undefined; + readonly t3codeThreadId?: string | undefined; +}; + +export type T3CliEnvShape = { + readonly home: Option.Option; + readonly xdgConfigHome: Option.Option; + readonly t3cliEnv: Option.Option; + readonly t3codeUrl: Option.Option; + readonly t3codeToken: Option.Option; + readonly t3codeHome: Option.Option; + readonly term: Option.Option; + readonly ci: boolean; + readonly codexCi: boolean; + readonly codexThreadId: boolean; + readonly t3cliAgent: boolean; + readonly scope: T3CliEnvScope; +}; + +export const T3CliEnvConfig = Config.all({ + home: Config.string("HOME").pipe(Config.option), + xdgConfigHome: Config.string("XDG_CONFIG_HOME").pipe(Config.option), + t3cliEnv: Config.string("T3CLI_ENV").pipe(Config.option), + t3codeUrl: Config.string("T3CODE_URL").pipe(Config.option), + t3codeToken: Config.redacted("T3CODE_TOKEN").pipe(Config.option), + t3codeHome: Config.string("T3CODE_HOME").pipe(Config.option), + t3codeProjectRoot: Config.string("T3CODE_PROJECT_ROOT").pipe(Config.option), + t3codeProjectId: Config.string("T3CODE_PROJECT_ID").pipe(Config.option), + t3codeWorktreePath: Config.string("T3CODE_WORKTREE_PATH").pipe(Config.option), + t3codeThreadId: Config.string("T3CODE_THREAD_ID").pipe(Config.option), + term: Config.string("TERM").pipe(Config.option), + ci: Config.string("CI").pipe(Config.option, Config.map(Option.isSome)), + codexCi: Config.string("CODEX_CI").pipe(Config.option, Config.map(Option.isSome)), + codexThreadId: Config.string("CODEX_THREAD_ID").pipe(Config.option, Config.map(Option.isSome)), + t3cliAgent: Config.string("T3CLI_AGENT").pipe(Config.option, Config.map(Option.isSome)), +}); + +function toScope(loaded: { + readonly t3codeProjectRoot: Option.Option; + readonly t3codeProjectId: Option.Option; + readonly t3codeWorktreePath: Option.Option; + readonly t3codeThreadId: Option.Option; +}): T3CliEnvScope { + return { + ...(Option.isSome(loaded.t3codeProjectRoot) + ? { t3codeProjectRoot: loaded.t3codeProjectRoot.value } + : {}), + ...(Option.isSome(loaded.t3codeProjectId) + ? { t3codeProjectId: loaded.t3codeProjectId.value } + : {}), + ...(Option.isSome(loaded.t3codeWorktreePath) + ? { t3codeWorktreePath: loaded.t3codeWorktreePath.value } + : {}), + ...(Option.isSome(loaded.t3codeThreadId) + ? { t3codeThreadId: loaded.t3codeThreadId.value } + : {}), + }; +} + +export const loadT3CliEnv = Effect.map(T3CliEnvConfig, (loaded) => ({ + home: loaded.home, + xdgConfigHome: loaded.xdgConfigHome, + t3cliEnv: loaded.t3cliEnv, + t3codeUrl: loaded.t3codeUrl, + t3codeToken: loaded.t3codeToken, + t3codeHome: loaded.t3codeHome, + term: loaded.term, + ci: loaded.ci, + codexCi: loaded.codexCi, + codexThreadId: loaded.codexThreadId, + t3cliAgent: loaded.t3cliAgent, + scope: toScope(loaded), +})); diff --git a/src/config/env/index.ts b/src/config/env/index.ts new file mode 100644 index 0000000..4e280a3 --- /dev/null +++ b/src/config/env/index.ts @@ -0,0 +1,2 @@ +export { loadT3CliEnv, T3CliEnvConfig, type T3CliEnvScope, type T3CliEnvShape } from "./env.ts"; +export { resolveT3BaseDir, type T3Layout } from "./layout.ts"; diff --git a/src/layout/base-dir.ts b/src/config/env/layout.ts similarity index 79% rename from src/layout/base-dir.ts rename to src/config/env/layout.ts index 2755f1a..a715195 100644 --- a/src/layout/base-dir.ts +++ b/src/config/env/layout.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -8,14 +7,6 @@ export type T3Layout = { readonly t3codeHome?: string | undefined; }; -export function readT3LayoutFromNodeProcess(): T3Layout { - return { - cwd: process.cwd(), - homeDir: homedir(), - t3codeHome: process.env["T3CODE_HOME"], - }; -} - export const resolveT3BaseDir = Effect.fn("resolveT3BaseDir")(function* (input: { readonly layout: T3Layout; readonly baseDir?: string | undefined; diff --git a/src/config/environment-name/index.ts b/src/config/environment-name/index.ts new file mode 100644 index 0000000..b830fa0 --- /dev/null +++ b/src/config/environment-name/index.ts @@ -0,0 +1,7 @@ +export { + defaultEnvironmentNameForLocal, + defaultEnvironmentNameFromUrl, + migrateV1EnvironmentName, + slugifyEnvironmentName, + validateEnvironmentName, +} from "./name.ts"; diff --git a/src/config/environment-name.test.ts b/src/config/environment-name/name.test.ts similarity index 89% rename from src/config/environment-name.test.ts rename to src/config/environment-name/name.test.ts index 93f400d..4c4ceb7 100644 --- a/src/config/environment-name.test.ts +++ b/src/config/environment-name/name.test.ts @@ -4,14 +4,15 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { assert, describe, it } from "@effect/vitest"; +import { expectFailWithMessage } from "../../effect.test-utils.ts"; +import { ConfigError } from "../error.ts"; import { defaultEnvironmentNameForLocal, defaultEnvironmentNameFromUrl, migrateV1EnvironmentName, slugifyEnvironmentName, validateEnvironmentName, -} from "./environment-name.ts"; -import { expectConfigError } from "../test/helpers/assert-errors.ts"; +} from "./name.ts"; describe("slugifyEnvironmentName", () => { it("slugifies invalid characters", () => { @@ -41,7 +42,7 @@ describe("defaultEnvironmentNameFromUrl", () => { ); it.effect("maps invalid urls to ConfigError", () => - expectConfigError(defaultEnvironmentNameFromUrl("not-a-url"), "invalid url"), + expectFailWithMessage(defaultEnvironmentNameFromUrl("not-a-url"), ConfigError, "invalid url"), ); }); diff --git a/src/config/environment-name.ts b/src/config/environment-name/name.ts similarity index 87% rename from src/config/environment-name.ts rename to src/config/environment-name/name.ts index f7f3d58..13d4c57 100644 --- a/src/config/environment-name.ts +++ b/src/config/environment-name/name.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; -import { ConfigError, configErrorFromUrl } from "./error.ts"; -import { normalizeHttpBaseUrl } from "./url.ts"; +import { ConfigError } from "../error.ts"; +import { normalizeHttpBaseUrl } from "../url/url.ts"; const environmentNamePattern = /^[A-Za-z0-9._-]+$/; @@ -23,7 +23,7 @@ export function validateEnvironmentName(name: string) { export function defaultEnvironmentNameFromUrl(url: string) { return normalizeHttpBaseUrl(url).pipe( - Effect.mapError(configErrorFromUrl), + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), Effect.flatMap((normalized) => { const hostname = slugifyEnvironmentName(new URL(normalized).hostname.trim()); return validateEnvironmentName(hostname).pipe(Effect.as(hostname)); diff --git a/src/config/error.ts b/src/config/error.ts index 0e17ce4..3c10b32 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -1,6 +1,3 @@ -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as Schema from "effect/Schema"; import { PlatformError } from "effect/PlatformError"; @@ -10,87 +7,13 @@ const ConfigErrorCauseSchema = Schema.Union([ Schema.Defect(), ]); -export class UrlError extends Schema.TaggedErrorClass()("UrlError", { - message: Schema.String, - protocol: Schema.optionalKey(Schema.String), - cause: Schema.optionalKey(Schema.instanceOf(Cause.IllegalArgumentError)), -}) {} - export class ConfigError extends Schema.TaggedErrorClass()("ConfigError", { message: Schema.String, cause: Schema.optionalKey(ConfigErrorCauseSchema), }) {} -export class CredentialCipherError extends Schema.TaggedErrorClass()( - "CredentialCipherError", - { - operation: Schema.Literals(["encrypt", "decrypt"]), - cause: Schema.Defect(), - }, -) {} - -export class KeystoreUnavailableError extends Schema.TaggedErrorClass()( - "KeystoreUnavailableError", - { - reason: Schema.Literals(["module-not-found", "backend-unavailable"]), - cause: Schema.Defect(), - }, -) {} - export type ConfigServiceError = ConfigError; -export function configErrorFromPlatformError(message: string, error: PlatformError) { - return new ConfigError({ message, cause: error }); -} - -export function mapPlatformErrorToConfigError(message: string) { - return (error: PlatformError) => configErrorFromPlatformError(message, error); -} - -export function catchPlatformError(message: string) { - return { - PlatformError: (error: PlatformError) => - Effect.fail(configErrorFromPlatformError(message, error)), - } as const; -} - -export function catchPlatformErrorUnlessNotFound(message: string) { - return { - PlatformError: (error: PlatformError) => - isPlatformNotFoundError(error) - ? Effect.succeed(undefined) - : Effect.fail(configErrorFromPlatformError(message, error)), - } as const; -} - -export function configErrorFromSchemaError(message: string, error: Schema.SchemaError) { - return new ConfigError({ message, cause: error }); -} - -export function catchSchemaError(message: string) { - return { - SchemaError: (error: Schema.SchemaError) => - Effect.fail(configErrorFromSchemaError(message, error)), - } as const; -} - -export function catchEncodingError(message: string) { - return { - EncodingError: (error: Encoding.EncodingError) => - Effect.fail(new ConfigError({ message, cause: error })), - } as const; -} - -export function configErrorFromUrl(error: UrlError) { - return new ConfigError({ - message: - error.protocol !== undefined - ? `unsupported server url protocol: ${error.protocol}` - : "invalid url", - cause: error.cause, - }); -} - export function isPlatformNotFoundError(error: PlatformError) { return error.reason["_tag"] === "NotFound"; } diff --git a/src/config/index.ts b/src/config/index.ts index 80a6cb5..56c0968 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,4 @@ -export { T3Config } from "./service.ts"; +export * as Config from "./config.ts"; export type { EncryptedConfig, EncryptedToken, @@ -6,20 +6,11 @@ export type { ResolvedConfig, UpsertEnvironmentInput, } from "./types.ts"; -export { layer as T3ConfigLive, make as makeT3Config } from "./layer.ts"; -export { - T3CredentialCrypto, - layer as T3CredentialCryptoLive, - make as makeT3CredentialCrypto, -} from "./credential.ts"; -export { T3CredentialCipher } from "./credential-cipher.ts"; -export { layerNode as T3CredentialCipherNodeLive } from "./credential-cipher-node.ts"; -export { layerWeb as T3CredentialCipherWebLive } from "./credential-cipher-web.ts"; -export { T3MasterKeyKeystoreFactory } from "./keystore.ts"; -export type { MasterKeyKeystore, MasterKeyReadResult } from "./keystore.ts"; -export { layerNode as T3MasterKeyKeystoreFactoryNodeLive } from "./keystore-keyring-node.ts"; -export { T3ConfigSelection } from "./selection.ts"; -export { T3ConfigSelectionLive } from "./selection-layer.ts"; -export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; -export { ConfigError, KeystoreUnavailableError, UrlError } from "./error.ts"; -export type { ConfigServiceError } from "./error.ts"; +export { ConfigError, type ConfigServiceError } from "./error.ts"; +export * as Credential from "./credential/index.ts"; +export * as Keystore from "./keystore/index.ts"; +export * as Selection from "./selection/index.ts"; +export * as Env from "./env/index.ts"; +export * as Paths from "./paths/index.ts"; +export * as Url from "./url/index.ts"; +export * as EnvironmentName from "./environment-name/index.ts"; diff --git a/src/config/keystore-file.ts b/src/config/keystore-file.ts deleted file mode 100644 index e37a03a..0000000 --- a/src/config/keystore-file.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; - -import { - catchEncodingError, - catchPlatformError, - catchPlatformErrorUnlessNotFound, - ConfigError, - mapPlatformErrorToConfigError, -} from "./error.ts"; -import { - masterKeyByteLength, - type MasterKeyKeystore, - type MasterKeyReadResult, -} from "./keystore.ts"; -import { resolveKeyFilePath } from "./paths.ts"; - -const privateFileMode = 0o600; - -export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const keyFilePath = yield* resolveKeyFilePath(); - - const read = (): Effect.Effect => - Effect.gen(function* () { - const raw = yield* fs - .readFileString(keyFilePath) - .pipe( - Effect.catchTags(catchPlatformErrorUnlessNotFound("failed to read credential key file")), - ); - if (raw === undefined) { - return { kind: "missing" }; - } - const key = yield* Effect.fromResult(Encoding.decodeBase64(raw.trim())).pipe( - Effect.catchTags(catchEncodingError("invalid credential key file: invalid base64")), - ); - if (key.byteLength !== masterKeyByteLength) { - return yield* Effect.fail( - new ConfigError({ message: "invalid credential key file: unexpected key length" }), - ); - } - yield* fs - .chmod(keyFilePath, privateFileMode) - .pipe( - Effect.mapError( - mapPlatformErrorToConfigError("failed to set credential key file permissions"), - ), - ); - return { kind: "present", key }; - }); - - const write = (key: Uint8Array): Effect.Effect => - Effect.gen(function* () { - yield* fs - .makeDirectory(path.dirname(keyFilePath), { recursive: true, mode: 0o700 }) - .pipe(Effect.catchTags(catchPlatformError("failed to write credential key file"))); - yield* fs - .writeFileString(keyFilePath, `${Encoding.encodeBase64(key)}\n`, { mode: privateFileMode }) - .pipe(Effect.catchTags(catchPlatformError("failed to write credential key file"))); - yield* fs - .chmod(keyFilePath, privateFileMode) - .pipe( - Effect.mapError( - mapPlatformErrorToConfigError("failed to set credential key file permissions"), - ), - ); - }); - - return { read, write } satisfies MasterKeyKeystore; -}); diff --git a/src/config/keystore/error.ts b/src/config/keystore/error.ts new file mode 100644 index 0000000..71a6866 --- /dev/null +++ b/src/config/keystore/error.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +export class KeystoreUnavailableError extends Schema.TaggedErrorClass()( + "KeystoreUnavailableError", + { + reason: Schema.Literals(["module-not-found", "backend-unavailable"]), + cause: Schema.Defect(), + }, +) {} diff --git a/src/config/keystore/file.ts b/src/config/keystore/file.ts new file mode 100644 index 0000000..7c1b8a6 --- /dev/null +++ b/src/config/keystore/file.ts @@ -0,0 +1,96 @@ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ConfigError, isPlatformNotFoundError } from "../error.ts"; +import { + masterKeyByteLength, + type MasterKeyKeystore, + type MasterKeyReadResult, +} from "./service.ts"; +import { resolveKeyFilePath } from "../paths/paths.ts"; + +const privateFileMode = 0o600; + +export const makeFileKeystore = Effect.fn("makeFileKeystore")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const keyFilePath = yield* resolveKeyFilePath(); + + const read = (): Effect.Effect => + Effect.gen(function* () { + const raw = yield* fs.readFileString(keyFilePath).pipe( + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail( + new ConfigError({ message: "failed to read credential key file", cause: error }), + ), + }), + ); + if (raw === undefined) { + return { kind: "missing" }; + } + const key = yield* Effect.fromResult(Encoding.decodeBase64(raw.trim())).pipe( + Effect.catchTags({ + EncodingError: (error) => + Effect.fail( + new ConfigError({ + message: "invalid credential key file: invalid base64", + cause: error, + }), + ), + }), + ); + if (key.byteLength !== masterKeyByteLength) { + return yield* Effect.fail( + new ConfigError({ message: "invalid credential key file: unexpected key length" }), + ); + } + yield* fs.chmod(keyFilePath, privateFileMode).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to set credential key file permissions", + cause: error, + }), + ), + ); + return { kind: "present", key }; + }); + + const write = (key: Uint8Array): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(keyFilePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + }), + ); + yield* fs + .writeFileString(keyFilePath, `${Encoding.encodeBase64(key)}\n`, { mode: privateFileMode }) + .pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( + new ConfigError({ message: "failed to write credential key file", cause: error }), + ), + }), + ); + yield* fs.chmod(keyFilePath, privateFileMode).pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: "failed to set credential key file permissions", + cause: error, + }), + ), + ); + }); + + return { read, write } satisfies MasterKeyKeystore; +}); diff --git a/src/config/keystore/index.ts b/src/config/keystore/index.ts new file mode 100644 index 0000000..f29bc94 --- /dev/null +++ b/src/config/keystore/index.ts @@ -0,0 +1,4 @@ +export * as Service from "./service.ts"; +export { KeystoreUnavailableError } from "./error.ts"; +export * as KeyringNode from "./keyring-node.ts"; +export * as File from "./file.ts"; diff --git a/src/config/keystore/keyring-node.test.ts b/src/config/keystore/keyring-node.test.ts new file mode 100644 index 0000000..743b4f7 --- /dev/null +++ b/src/config/keystore/keyring-node.test.ts @@ -0,0 +1,12 @@ +import "vite-plus/test/config"; + +import { assert, describe, it } from "@effect/vitest"; + +import { parseKeyringPassword } from "./keyring-node.ts"; + +describe("parseKeyringPassword", () => { + it("treats invalid stored keyring values as corrupt", () => { + const result = parseKeyringPassword("not-a-valid-key"); + assert.equal(result.kind, "corrupt"); + }); +}); diff --git a/src/config/keystore-keyring-node.ts b/src/config/keystore/keyring-node.ts similarity index 69% rename from src/config/keystore-keyring-node.ts rename to src/config/keystore/keyring-node.ts index ea62bbf..49b784f 100644 --- a/src/config/keystore-keyring-node.ts +++ b/src/config/keystore/keyring-node.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; @@ -10,42 +11,34 @@ import { T3MasterKeyKeystoreFactory, type MasterKeyKeystore, type MasterKeyReadResult, -} from "./keystore.ts"; +} from "./service.ts"; type KeyringModule = typeof import("@napi-rs/keyring"); const keyringService = "t3cli"; const keyringAccount = "master-key"; -function isModuleNotFound(cause: unknown) { - return ( - Predicate.hasProperty(cause, "code") && - Predicate.isString(cause.code) && - (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND") - ); -} - -function keyringBackendUnavailable(cause: unknown) { - return new KeystoreUnavailableError({ reason: "backend-unavailable", cause }); -} - -const loadKeyringModuleOnce = Effect.tryPromise({ - try: () => import("@napi-rs/keyring") as Promise, - catch: (cause) => cause, -}); +const loadKeyringModuleOnce = Effect.tryPromise( + () => import("@napi-rs/keyring") as Promise, +); const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); -const loadKeyringModule = Effect.gen(function* () { - const load = yield* loadKeyringModuleMemoized; - return yield* load.pipe( - Effect.mapError((cause) => - isModuleNotFound(cause) - ? new KeystoreUnavailableError({ reason: "module-not-found", cause }) - : keyringBackendUnavailable(cause), +const loadKeyringModule = Effect.flatMap(loadKeyringModuleMemoized, (load) => + load.pipe( + Effect.mapError( + (error) => + new KeystoreUnavailableError({ + reason: + Predicate.hasProperty(error.cause, "code") && + error.cause.code === "ERR_MODULE_NOT_FOUND" + ? "module-not-found" + : "backend-unavailable", + cause: error, + }), ), - ); -}); + ), +); export function parseKeyringPassword(password: string | null): MasterKeyReadResult { if (password === null || password.length === 0) { @@ -87,7 +80,11 @@ function createKeyringKeystore(keyring: KeyringModule): MasterKeyKeystore { try: () => { entry.setPassword(Encoding.encodeBase64(key)); }, - catch: keyringBackendUnavailable, + catch: (cause) => + new KeystoreUnavailableError({ + reason: "backend-unavailable", + cause: new Cause.UnknownError(cause), + }), }), }; } diff --git a/src/test/layers/keystore-unavailable.ts b/src/config/keystore/service.test-utils.ts similarity index 72% rename from src/test/layers/keystore-unavailable.ts rename to src/config/keystore/service.test-utils.ts index 39925a4..e32767c 100644 --- a/src/test/layers/keystore-unavailable.ts +++ b/src/config/keystore/service.test-utils.ts @@ -1,8 +1,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { KeystoreUnavailableError } from "../../config/error.ts"; -import { T3MasterKeyKeystoreFactory } from "../../config/keystore.ts"; +import { KeystoreUnavailableError } from "./error.ts"; +import { T3MasterKeyKeystoreFactory } from "./service.ts"; export const unavailableKeystoreFactoryLayer = Layer.succeed(T3MasterKeyKeystoreFactory)({ make: () => diff --git a/src/config/keystore/service.test.ts b/src/config/keystore/service.test.ts new file mode 100644 index 0000000..b8448a8 --- /dev/null +++ b/src/config/keystore/service.test.ts @@ -0,0 +1,18 @@ +import "vite-plus/test/config"; + +import { describe, it } from "@effect/vitest"; +import { expect } from "vite-plus/test"; + +import { shouldUseFileKeystoreForRead } from "./service.ts"; + +describe("shouldUseFileKeystoreForRead", () => { + it("treats unavailable keyring reads as file-keystore fallback", () => { + const result = { kind: "unavailable" as const, message: "failed" }; + expect(shouldUseFileKeystoreForRead(result)).toBe(true); + }); + + it("does not fall back for corrupt keyring reads", () => { + const result = { kind: "corrupt" as const, message: "invalid key" }; + expect(shouldUseFileKeystoreForRead(result)).toBe(false); + }); +}); diff --git a/src/config/keystore.ts b/src/config/keystore/service.ts similarity index 89% rename from src/config/keystore.ts rename to src/config/keystore/service.ts index 5b07469..ce22a04 100644 --- a/src/config/keystore.ts +++ b/src/config/keystore/service.ts @@ -1,7 +1,8 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { ConfigError, KeystoreUnavailableError } from "./error.ts"; +import type { ConfigError } from "../error.ts"; +import type { KeystoreUnavailableError } from "./error.ts"; export const masterKeyByteLength = 32; diff --git a/src/config/layer.test-utils.ts b/src/config/layer.test-utils.ts new file mode 100644 index 0000000..9e08c93 --- /dev/null +++ b/src/config/layer.test-utils.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Credential from "./credential/service.ts"; +import * as Config from "./config.ts"; +import * as Selection from "./selection/service.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { ConfigPlatformLayer } from "./platform.test-utils.ts"; +import { t3CliEnvConfigLayer } from "./env/env.test-utils.ts"; +import * as CredentialCipherWeb from "./credential/cipher-web.ts"; +import * as Keystore from "./keystore/service.test-utils.ts"; + +export type T3ConfigLayerTestInput = { + readonly selection?: string; + readonly env?: Readonly>; + readonly useSelectionLive?: boolean; +}; + +export function t3ConfigDepsLayer(homeDir: string, input: T3ConfigLayerTestInput = {}) { + const cliRuntimeLayer = CliRuntime.layerTest({ cwd: homeDir }); + const configLayer = t3CliEnvConfigLayer(homeDir, input.env ?? {}); + const platformEnvLayer = Layer.mergeAll(cliRuntimeLayer, configLayer); + const selectionLayer = + input.useSelectionLive === true + ? Selection.layer.pipe(Layer.provide(platformEnvLayer)) + : Layer.succeed(Selection.T3ConfigSelection)({ + getSelectedEnvironment: () => Effect.succeed(input.selection), + }); + + return Config.layer.pipe( + Layer.provide(Credential.layer), + Layer.provide( + Layer.mergeAll( + platformEnvLayer, + selectionLayer, + CredentialCipherWeb.layerWeb, + Keystore.unavailableKeystoreFactoryLayer, + ), + ), + ); +} + +export function t3ConfigLayerTest(homeDir: string, input: T3ConfigLayerTestInput = {}) { + return Layer.mergeAll(ConfigPlatformLayer, t3ConfigDepsLayer(homeDir, input)); +} diff --git a/src/config/layer.test.ts b/src/config/layer.test.ts index af5af1a..5373564 100644 --- a/src/config/layer.test.ts +++ b/src/config/layer.test.ts @@ -3,10 +3,10 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; import { assert, describe, it } from "@effect/vitest"; -import { T3Config } from "./service.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3ConfigDepsLayer } from "../test/layers/config.ts"; +import { T3Config } from "./config.ts"; +import { ConfigPlatformLayer } from "./platform.test-utils.ts"; +import { makeTempHomeScoped } from "./temp-home.test-utils.ts"; +import { t3ConfigDepsLayer } from "./layer.test-utils.ts"; describe("T3Config", () => { it.effect( diff --git a/src/config/paths/index.ts b/src/config/paths/index.ts new file mode 100644 index 0000000..0cdea3e --- /dev/null +++ b/src/config/paths/index.ts @@ -0,0 +1 @@ +export { resolveConfigFilePath, resolveKeyFilePath, resolveT3cliConfigDir } from "./paths.ts"; diff --git a/src/config/paths.ts b/src/config/paths/paths.ts similarity index 53% rename from src/config/paths.ts rename to src/config/paths/paths.ts index 1ea8b69..8e2555b 100644 --- a/src/config/paths.ts +++ b/src/config/paths/paths.ts @@ -1,16 +1,25 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { Environment } from "../environment/service.ts"; +import { loadT3CliEnv } from "../env/env.ts"; +import { ConfigError } from "../error.ts"; export const resolveT3cliConfigDir = Effect.fn("resolveT3cliConfigDir")(function* () { const path = yield* Path.Path; - const environment = yield* Environment; - const xdgConfigHome = environment.env["XDG_CONFIG_HOME"]?.trim(); - const root = - xdgConfigHome !== undefined && xdgConfigHome.length > 0 - ? xdgConfigHome - : path.join(environment.homeDir, ".config"); + const t3CliEnv = yield* loadT3CliEnv.pipe( + Effect.mapError( + (error) => new ConfigError({ message: "failed to load CLI environment", cause: error }), + ), + ); + const home = yield* Option.match(t3CliEnv.home, { + onNone: () => Effect.die("HOME is not set"), + onSome: Effect.succeed, + }); + const root = Option.match(t3CliEnv.xdgConfigHome, { + onNone: () => path.join(home, ".config"), + onSome: (xdgConfigHome) => xdgConfigHome, + }); return path.join(root, "t3cli"); }); diff --git a/src/config/file-mode.ts b/src/config/persist/file-mode.ts similarity index 66% rename from src/config/file-mode.ts rename to src/config/persist/file-mode.ts index a91eb68..e754942 100644 --- a/src/config/file-mode.ts +++ b/src/config/persist/file-mode.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import { mapPlatformErrorToConfigError } from "./error.ts"; +import { ConfigError } from "../error.ts"; const privateFileMode = 0o600; @@ -13,6 +13,9 @@ export const hardenPrivateFileMode = Effect.fn("hardenPrivateFileMode")(function yield* fs .chmod(filePath, privateFileMode) .pipe( - Effect.mapError(mapPlatformErrorToConfigError(`failed to set ${label} file permissions`)), + Effect.mapError( + (error) => + new ConfigError({ message: `failed to set ${label} file permissions`, cause: error }), + ), ); }); diff --git a/src/config/migration.test.ts b/src/config/persist/migration.test.ts similarity index 87% rename from src/config/migration.test.ts rename to src/config/persist/migration.test.ts index f48a6d6..84142ec 100644 --- a/src/config/migration.test.ts +++ b/src/config/persist/migration.test.ts @@ -6,12 +6,12 @@ import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import { assert, describe, it } from "@effect/vitest"; -import { ConfigError } from "./error.ts"; -import { T3CredentialCrypto } from "./credential.ts"; +import { ConfigError } from "../error.ts"; +import { T3CredentialCrypto } from "../credential/service.ts"; +import { ConfigPlatformLayer } from "../platform.test-utils.ts"; +import { makeTempHomeScoped } from "../temp-home.test-utils.ts"; +import { t3CredentialCryptoDepsLayer } from "../credential/service.test-utils.ts"; import { migrateV1FileToEncrypted } from "./migration.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3CredentialCryptoDepsLayer } from "../test/layers/credential-crypto.ts"; describe("migrateV1FileToEncrypted", () => { it.effect("migrates v1 flat config and roundtrips encrypted tokens", () => diff --git a/src/config/migration.ts b/src/config/persist/migration.ts similarity index 83% rename from src/config/migration.ts rename to src/config/persist/migration.ts index 37f0c9e..f991e81 100644 --- a/src/config/migration.ts +++ b/src/config/persist/migration.ts @@ -2,16 +2,16 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { T3CredentialCrypto } from "./credential.ts"; -import { migrateV1EnvironmentName, validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError, configErrorFromUrl, configErrorFromSchemaError } from "./error.ts"; +import { T3CredentialCrypto } from "../credential/service.ts"; +import { migrateV1EnvironmentName, validateEnvironmentName } from "../environment-name/name.ts"; +import { ConfigError } from "../error.ts"; import { StoredConfigV1FileSchema, StoredConfigV2FileSchema, type StoredConfigV1File, } from "./schema.ts"; -import type { EncryptedConfig } from "./types.ts"; -import { normalizeHttpBaseUrl } from "./url.ts"; +import type { EncryptedConfig } from "../types.ts"; +import { normalizeHttpBaseUrl } from "../url/url.ts"; export const emptyEncryptedConfig = (): EncryptedConfig => ({ version: 2, @@ -34,7 +34,7 @@ export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromVa }).pipe( Effect.catchTags({ SchemaError: (error) => - Effect.fail(configErrorFromSchemaError("failed to read config", error)), + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), }), ); }); @@ -59,7 +59,7 @@ export const migrateV1FileToEncrypted = Effect.fn("migrateV1FileToEncrypted")(fu ), ); const normalizedUrl = yield* normalizeHttpBaseUrl(config.url).pipe( - Effect.mapError(configErrorFromUrl), + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), ); const crypto = yield* T3CredentialCrypto; const token = yield* crypto.encrypt({ diff --git a/src/config/persist.test.ts b/src/config/persist/persist.test.ts similarity index 95% rename from src/config/persist.test.ts rename to src/config/persist/persist.test.ts index 1d357d0..c4b34e1 100644 --- a/src/config/persist.test.ts +++ b/src/config/persist/persist.test.ts @@ -6,11 +6,11 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { assert, describe, it } from "@effect/vitest"; -import { T3Config } from "./service.ts"; +import { T3Config } from "../config.ts"; +import { ConfigPlatformLayer } from "../platform.test-utils.ts"; +import { makeTempHomeScoped } from "../temp-home.test-utils.ts"; +import { t3ConfigDepsLayer } from "../layer.test-utils.ts"; import { StoredConfigV1FileJson, StoredConfigV2FileJson } from "./schema.ts"; -import { ConfigPlatformLayer } from "../test/platform.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { t3ConfigDepsLayer } from "../test/layers/config.ts"; describe("config file persistence", () => { it.effect("persists v1 config as encrypted v2 on first read", () => diff --git a/src/config/persist.ts b/src/config/persist/persist.ts similarity index 50% rename from src/config/persist.ts rename to src/config/persist/persist.ts index bf53f52..346f417 100644 --- a/src/config/persist.ts +++ b/src/config/persist/persist.ts @@ -3,24 +3,32 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { catchPlatformError, catchPlatformErrorUnlessNotFound, catchSchemaError } from "./error.ts"; +import { ConfigError, isPlatformNotFoundError } from "../error.ts"; import { hardenPrivateFileMode } from "./file-mode.ts"; import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; -import { resolveConfigFilePath } from "./paths.ts"; +import { resolveConfigFilePath } from "../paths/paths.ts"; import { UnknownConfigFileJson, StoredConfigV2FileJson } from "./schema.ts"; -import type { EncryptedConfig } from "./types.ts"; +import type { EncryptedConfig } from "../types.ts"; export const readEncryptedConfigFile = Effect.fn("readEncryptedConfigFile")(function* () { const fs = yield* FileSystem.FileSystem; const configFilePath = yield* resolveConfigFilePath(); - const raw = yield* fs - .readFileString(configFilePath) - .pipe(Effect.catchTags(catchPlatformErrorUnlessNotFound("failed to read config"))); + const raw = yield* fs.readFileString(configFilePath).pipe( + Effect.catchTags({ + PlatformError: (error) => + isPlatformNotFoundError(error) + ? Effect.succeed(undefined) + : Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); if (raw === undefined) { return emptyEncryptedConfig(); } const value = yield* Schema.decodeUnknownEffect(UnknownConfigFileJson)(raw).pipe( - Effect.catchTags(catchSchemaError("failed to read config")), + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), ); const read = yield* readEncryptedConfigFromValue(value); if (read.migratedFromV1) { @@ -35,14 +43,23 @@ export const writeEncryptedConfigFile = Effect.fn("writeEncryptedConfigFile")(fu const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configFilePath = yield* resolveConfigFilePath(); - yield* fs - .makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }) - .pipe(Effect.catchTags(catchPlatformError("failed to write config"))); + yield* fs.makeDirectory(path.dirname(configFilePath), { recursive: true, mode: 0o700 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); const encoded = yield* Schema.encodeEffect(StoredConfigV2FileJson)(config).pipe( - Effect.catchTags(catchSchemaError("failed to write config")), + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), + ); + yield* fs.writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(new ConfigError({ message: "failed to write config", cause: error })), + }), ); - yield* fs - .writeFileString(configFilePath, `${encoded}\n`, { mode: 0o600 }) - .pipe(Effect.catchTags(catchPlatformError("failed to write config"))); yield* hardenPrivateFileMode(configFilePath, "config"); }); diff --git a/src/config/schema.ts b/src/config/persist/schema.ts similarity index 100% rename from src/config/schema.ts rename to src/config/persist/schema.ts diff --git a/src/test/platform.ts b/src/config/platform.test-utils.ts similarity index 100% rename from src/test/platform.ts rename to src/config/platform.test-utils.ts diff --git a/src/test/fixtures/encrypted-config.ts b/src/config/resolve/resolve.test-utils.ts similarity index 88% rename from src/test/fixtures/encrypted-config.ts rename to src/config/resolve/resolve.test-utils.ts index 2bbcb81..c94c9c3 100644 --- a/src/test/fixtures/encrypted-config.ts +++ b/src/config/resolve/resolve.test-utils.ts @@ -1,4 +1,4 @@ -import type { EncryptedConfig } from "../../config/types.ts"; +import type { EncryptedConfig } from "../types.ts"; export const sampleEncrypted = (input: { readonly default?: string; diff --git a/src/config/resolve.test.ts b/src/config/resolve/resolve.test.ts similarity index 95% rename from src/config/resolve.test.ts rename to src/config/resolve/resolve.test.ts index 3c97f2c..8dc351e 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve/resolve.test.ts @@ -4,14 +4,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import { resolveConfiguredEnvironment } from "../selection/resolve.ts"; +import { sampleEncrypted, sampleEncryptedToken } from "./resolve.test-utils.ts"; import { buildResolvedConfigFromEnv, resolveDefaultForUpsert, selectEnvironmentName, validateCredentialEnvVars, } from "./resolve.ts"; -import { resolveConfiguredEnvironment } from "./selection-resolve.ts"; -import { sampleEncrypted, sampleEncryptedToken } from "../test/fixtures/encrypted-config.ts"; describe("resolveConfiguredEnvironment", () => { it("prefers cli flag over T3CLI_ENV", () => { diff --git a/src/config/resolve.ts b/src/config/resolve/resolve.ts similarity index 86% rename from src/config/resolve.ts rename to src/config/resolve/resolve.ts index f8eb82a..abd3d50 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve/resolve.ts @@ -1,9 +1,9 @@ import * as Effect from "effect/Effect"; -import { validateEnvironmentName } from "./environment-name.ts"; -import { ConfigError, configErrorFromUrl } from "./error.ts"; -import type { EncryptedConfig, ResolvedConfig } from "./types.ts"; -import { normalizeHttpBaseUrl } from "./url.ts"; +import { validateEnvironmentName } from "../environment-name/name.ts"; +import { ConfigError } from "../error.ts"; +import type { EncryptedConfig, ResolvedConfig } from "../types.ts"; +import { normalizeHttpBaseUrl } from "../url/url.ts"; export type ResolveSelectionInput = { readonly selectedEnvironment?: string | undefined; @@ -65,7 +65,7 @@ export function buildResolvedConfigFromEnv(input: { readonly envToken: string; }) { return normalizeHttpBaseUrl(input.envUrl).pipe( - Effect.mapError(configErrorFromUrl), + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), Effect.map((normalizedUrl) => { return { url: normalizedUrl, @@ -91,7 +91,7 @@ export function buildResolvedConfigFromStored(input: { ); } const normalizedUrl = yield* normalizeHttpBaseUrl(selectedEnvironment.url).pipe( - Effect.mapError(configErrorFromUrl), + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), ); return { url: normalizedUrl, diff --git a/src/config/selection-layer.ts b/src/config/selection-layer.ts deleted file mode 100644 index fe079c9..0000000 --- a/src/config/selection-layer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { Environment } from "../environment/service.ts"; -import { resolveConfiguredEnvironment } from "./selection-resolve.ts"; -import { T3ConfigSelection } from "./selection.ts"; - -export const T3ConfigSelectionLive = Layer.effect( - T3ConfigSelection, - Effect.gen(function* () { - const environment = yield* Environment; - return { - getSelectedEnvironment: () => - Effect.sync(() => - resolveConfiguredEnvironment({ - t3cliEnv: environment.env["T3CLI_ENV"], - }), - ), - }; - }), -); diff --git a/src/config/selection.ts b/src/config/selection.ts deleted file mode 100644 index febba7c..0000000 --- a/src/config/selection.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -export class T3ConfigSelection extends Context.Service< - T3ConfigSelection, - { - readonly getSelectedEnvironment: () => Effect.Effect; - } ->()("t3cli/T3ConfigSelection") {} diff --git a/src/config/selection/index.ts b/src/config/selection/index.ts new file mode 100644 index 0000000..492f0a5 --- /dev/null +++ b/src/config/selection/index.ts @@ -0,0 +1 @@ +export * as Service from "./service.ts"; diff --git a/src/config/selection-resolve.ts b/src/config/selection/resolve.ts similarity index 100% rename from src/config/selection-resolve.ts rename to src/config/selection/resolve.ts diff --git a/src/config/selection/service.ts b/src/config/selection/service.ts new file mode 100644 index 0000000..dd9d599 --- /dev/null +++ b/src/config/selection/service.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { loadT3CliEnv } from "../env/env.ts"; +import { resolveConfiguredEnvironment } from "./resolve.ts"; + +export class T3ConfigSelection extends Context.Service< + T3ConfigSelection, + { + readonly getSelectedEnvironment: () => Effect.Effect; + } +>()("t3cli/T3ConfigSelection") {} + +export const layer = Layer.effect( + T3ConfigSelection, + Effect.gen(function* () { + const t3CliEnv = yield* loadT3CliEnv; + return { + getSelectedEnvironment: () => + Effect.sync(() => + resolveConfiguredEnvironment({ + t3cliEnv: Option.getOrUndefined(t3CliEnv.t3cliEnv), + }), + ), + }; + }), +); diff --git a/src/config/service.ts b/src/config/service.ts deleted file mode 100644 index 9c9fe50..0000000 --- a/src/config/service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { ConfigError } from "./error.ts"; -import type { EnvironmentSummary, ResolvedConfig, UpsertEnvironmentInput } from "./types.ts"; - -export class T3Config extends Context.Service< - T3Config, - { - readonly resolve: () => Effect.Effect; - readonly resolveActiveEnvironmentName: () => Effect.Effect; - readonly listEnvironments: () => Effect.Effect; - readonly upsertEnvironment: (input: UpsertEnvironmentInput) => Effect.Effect; - readonly setDefaultEnvironment: (name: string) => Effect.Effect; - readonly removeEnvironment: (name: string) => Effect.Effect; - readonly hasEnvironment: (name: string) => Effect.Effect; - readonly getDefaultEnvironmentName: () => Effect.Effect; - } ->()("t3cli/T3Config") {} diff --git a/src/test/helpers/temp-home.ts b/src/config/temp-home.test-utils.ts similarity index 100% rename from src/test/helpers/temp-home.ts rename to src/config/temp-home.test-utils.ts diff --git a/src/config/types.ts b/src/config/types.ts index 8b12a30..8411d99 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,8 @@ -import type { EncryptedToken, StoredConfigV2File, StoredEnvironmentFile } from "./schema.ts"; +import type { + EncryptedToken, + StoredConfigV2File, + StoredEnvironmentFile, +} from "./persist/schema.ts"; export type EncryptedEnvironment = StoredEnvironmentFile; diff --git a/src/config/url/error.ts b/src/config/url/error.ts new file mode 100644 index 0000000..cec7871 --- /dev/null +++ b/src/config/url/error.ts @@ -0,0 +1,8 @@ +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; + +export class UrlError extends Schema.TaggedErrorClass()("UrlError", { + message: Schema.String, + protocol: Schema.optionalKey(Schema.String), + cause: Schema.optionalKey(Schema.instanceOf(Cause.IllegalArgumentError)), +}) {} diff --git a/src/config/url/index.ts b/src/config/url/index.ts new file mode 100644 index 0000000..4cfc5aa --- /dev/null +++ b/src/config/url/index.ts @@ -0,0 +1,7 @@ +export { + normalizeHttpBaseUrl, + toWebSocketBaseUrl, + toHttpEndpointUrl, + toWebSocketEndpointUrl, +} from "./url.ts"; +export { UrlError } from "./error.ts"; diff --git a/src/config/url.test.ts b/src/config/url/url.test.ts similarity index 77% rename from src/config/url.test.ts rename to src/config/url/url.test.ts index ad0053d..c86f8c4 100644 --- a/src/config/url.test.ts +++ b/src/config/url/url.test.ts @@ -3,8 +3,9 @@ import "vite-plus/test/config"; import * as Effect from "effect/Effect"; import { assert, describe, it } from "@effect/vitest"; +import { expectFailWithMessage } from "../../effect.test-utils.ts"; +import { UrlError } from "./error.ts"; import { normalizeHttpBaseUrl, toWebSocketEndpointUrl } from "./url.ts"; -import { expectUrlError } from "../test/helpers/assert-errors.ts"; describe("normalizeHttpBaseUrl", () => { it.effect("strips query and trailing slash from urls with paths", () => @@ -15,14 +16,15 @@ describe("normalizeHttpBaseUrl", () => { ); it.effect("maps invalid urls to UrlError", () => - expectUrlError(normalizeHttpBaseUrl("not-a-url"), "invalid url"), + expectFailWithMessage(normalizeHttpBaseUrl("not-a-url"), UrlError, "invalid url"), ); }); describe("toWebSocketEndpointUrl", () => { it.effect("rejects unsupported protocols with UrlError", () => - expectUrlError( + expectFailWithMessage( toWebSocketEndpointUrl("ftp://example.com", "/ws"), + UrlError, "unsupported server url protocol: ftp:", ), ); diff --git a/src/config/url.ts b/src/config/url/url.ts similarity index 100% rename from src/config/url.ts rename to src/config/url/url.ts diff --git a/src/connection/index.ts b/src/connection/index.ts index dd6220e..de992eb 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -1,6 +1,5 @@ export { T3CodeConnectionError } from "./error.ts"; export { T3CodeRpcLayer, makeT3CodeRpcLayer } from "./layer.ts"; -export { T3CodeNodeRpcLayer } from "./node.ts"; export { T3CodeConnectionProvider, T3CodeConnectionProviderLive, diff --git a/src/effect.test-utils.ts b/src/effect.test-utils.ts new file mode 100644 index 0000000..1d0e8de --- /dev/null +++ b/src/effect.test-utils.ts @@ -0,0 +1,25 @@ +import { assert } from "@effect/vitest"; +import { assertInstanceOf } from "@effect/vitest/utils"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; + +export const expectFailWithMessage = ( + effect: Effect.Effect, + ErrorClass: new (...args: never) => E, + message: string, +): Effect.Effect => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit); + if (!Exit.isFailure(exit)) { + return yield* Effect.die("expected failure"); + } + const error = Cause.findErrorOption(exit.cause); + if (Option.isNone(error)) { + return yield* Effect.die("expected failure cause"); + } + assertInstanceOf(error.value, ErrorClass); + assert.equal(error.value.message, message); + return yield* Effect.void; + }); diff --git a/src/environment/layer.ts b/src/environment/layer.ts deleted file mode 100644 index 906a4dd..0000000 --- a/src/environment/layer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { homedir } from "node:os"; -import * as Layer from "effect/Layer"; - -import { Environment } from "./service.ts"; - -export const NodeEnvironmentLive = Layer.succeed(Environment)({ - cwd: process.cwd(), - homeDir: homedir(), - env: process.env, - stdoutIsTTY: process.stdout.isTTY ?? false, - stderrIsTTY: process.stderr.isTTY ?? false, -}); diff --git a/src/environment/service.ts b/src/environment/service.ts deleted file mode 100644 index 268865f..0000000 --- a/src/environment/service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Context from "effect/Context"; -import * as Layer from "effect/Layer"; - -export type EnvironmentShape = { - readonly cwd: string; - readonly homeDir: string; - readonly env: Readonly>; - readonly stdoutIsTTY: boolean; - readonly stderrIsTTY: boolean; -}; - -export class Environment extends Context.Service()( - "t3cli/Environment", -) { - static layerTest = (input: { - readonly homeDir: string; - readonly cwd?: string; - readonly env?: Readonly>; - }) => - Layer.succeed(Environment, { - cwd: input.cwd ?? input.homeDir, - homeDir: input.homeDir, - env: input.env ?? {}, - stdoutIsTTY: false, - stderrIsTTY: false, - }); -} diff --git a/src/index.ts b/src/index.ts index 250ff12..ffd809e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,6 @@ export type { StartThreadInput, StartThreadPolicy, WaitEvent, + ApplicationError, } from "./application/index.ts"; -export type { ApplicationError } from "./application/index.ts"; export { AppLayer, AuthAppLayer } from "./runtime/index.ts"; diff --git a/src/layout/index.ts b/src/layout/index.ts deleted file mode 100644 index 7214702..0000000 --- a/src/layout/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { readT3LayoutFromNodeProcess, resolveT3BaseDir, type T3Layout } from "./base-dir.ts"; diff --git a/src/connection/node.ts b/src/node/connection.ts similarity index 87% rename from src/connection/node.ts rename to src/node/connection.ts index d6c8bfe..77125f7 100644 --- a/src/connection/node.ts +++ b/src/node/connection.ts @@ -2,7 +2,7 @@ import * as Layer from "effect/Layer"; import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; -import { T3CodeRpcLayer } from "./layer.ts"; +import { T3CodeRpcLayer } from "../connection/index.ts"; const NodeConnectionPlatformLayer = Layer.mergeAll( NodeHttpClient.layerUndici, diff --git a/src/node/index.ts b/src/node/index.ts index 8ad1163..772aee6 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -1,2 +1 @@ -export { T3CodeNodeRpcLayer } from "../connection/node.ts"; -export { NodeEnvironmentLive } from "../environment/layer.ts"; +export { T3CodeNodeRpcLayer } from "./connection.ts"; diff --git a/src/orchestration/index.ts b/src/orchestration/index.ts index d43eef2..7363bca 100644 --- a/src/orchestration/index.ts +++ b/src/orchestration/index.ts @@ -5,4 +5,3 @@ export { type OrchestrationError, } from "./service.ts"; export { makeT3Orchestration, T3OrchestrationLive } from "./layer.ts"; -export { T3OrchestrationLayer } from "../runtime/layer.ts"; diff --git a/src/rpc/error.ts b/src/rpc/error.ts index c123e9f..bd148c4 100644 --- a/src/rpc/error.ts +++ b/src/rpc/error.ts @@ -15,7 +15,7 @@ import { RpcClientError } from "effect/unstable/rpc"; import { AuthTransportError } from "../auth/error.ts"; import { T3CodeConnectionError } from "../connection/error.ts"; -import { UrlError } from "../config/error.ts"; +import { UrlError } from "../config/url/error.ts"; const RpcErrorCauseSchema = Schema.Union([ RpcClientError.RpcClientError, diff --git a/src/rpc/index.ts b/src/rpc/index.ts index 1acd42e..c126630 100644 --- a/src/rpc/index.ts +++ b/src/rpc/index.ts @@ -1,6 +1,5 @@ -export { RpcError, type OrchestrationError } from "./error.ts"; +export { RpcError } from "./error.ts"; export { makeT3RpcLayer, T3RpcLive } from "./layer.ts"; export { T3RpcOperations, T3RpcOperationsLive, makeT3RpcOperations } from "./operation.ts"; -export type { T3RpcOperationsService } from "./operation.ts"; export { T3Rpc } from "./service.ts"; -export type { T3RpcService, WsClient } from "./service.ts"; +export type { WsClient } from "./service.ts"; diff --git a/src/rpc/layer.ts b/src/rpc/layer.ts index 84573ae..0307ec3 100644 --- a/src/rpc/layer.ts +++ b/src/rpc/layer.ts @@ -12,7 +12,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { T3AuthTransport } from "../auth/transport.ts"; import { T3CodeConnectionProvider } from "../connection/service.ts"; -import { normalizeHttpBaseUrl, toWebSocketEndpointUrl } from "../config/url.ts"; +import { normalizeHttpBaseUrl, toWebSocketEndpointUrl } from "../config/url/url.ts"; import { CliWsRpcGroup } from "./ws-group.ts"; import { RpcError } from "./error.ts"; import { T3Rpc, type WsClient } from "./service.ts"; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index e341698..50c5ad6 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -11,4 +11,3 @@ export { T3LocalAuthTokenLayer, T3OrchestrationLayer, } from "./layer.ts"; -export { NodeEnvironmentLive } from "../environment/layer.ts"; diff --git a/src/runtime/layer.test-utils.ts b/src/runtime/layer.test-utils.ts new file mode 100644 index 0000000..0c940fe --- /dev/null +++ b/src/runtime/layer.test-utils.ts @@ -0,0 +1,32 @@ +import * as Layer from "effect/Layer"; + +import * as CliSelection from "../cli/env/selection-layer.ts"; +import * as CliRuntime from "../cli/runtime/service.ts"; +import * as Config from "../config/config.ts"; +import * as Credential from "../config/credential/service.ts"; +import * as CredentialCipherNode from "../config/credential/cipher-node.ts"; +import { ConfigPlatformLayer } from "../config/platform.test-utils.ts"; +import { t3CliEnvConfigLayer } from "../config/env/env.test-utils.ts"; +import * as Keystore from "../config/keystore/service.test-utils.ts"; + +export function cliConfigRoutingLayerTest(homeDir: string) { + const cliRuntimeLayer = CliRuntime.CliRuntime.layerTest({ cwd: homeDir }); + const configLayer = t3CliEnvConfigLayer(homeDir, { T3CLI_ENV: "home" }); + const platformEnvLayer = Layer.mergeAll(cliRuntimeLayer, configLayer); + + return Layer.mergeAll( + ConfigPlatformLayer, + platformEnvLayer, + Config.layer.pipe( + Layer.provide(Credential.layer), + Layer.provide( + Layer.mergeAll( + platformEnvLayer, + CliSelection.layer.pipe(Layer.provide(platformEnvLayer)), + CredentialCipherNode.layerNode, + Keystore.unavailableKeystoreFactoryLayer, + ), + ), + ), + ); +} diff --git a/src/runtime/layer.test.ts b/src/runtime/layer.test.ts index 65d11d8..0ceffd8 100644 --- a/src/runtime/layer.test.ts +++ b/src/runtime/layer.test.ts @@ -6,11 +6,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { Command } from "effect/unstable/cli"; -import { cliEnvironmentSetting } from "../cli/environment-flag.ts"; +import { cliEnvironmentSetting } from "../cli/env/flag.ts"; import type { ResolvedConfig } from "../config/types.ts"; -import { T3Config } from "../config/service.ts"; -import { makeTempHomeScoped } from "../test/helpers/temp-home.ts"; -import { cliConfigRoutingLayerTest } from "../test/layers/cli-config-routing.ts"; +import { T3Config } from "../config/config.ts"; +import { makeTempHomeScoped } from "../config/temp-home.test-utils.ts"; +import { cliConfigRoutingLayerTest } from "./layer.test-utils.ts"; describe("CLI config routing", () => { it.effect( diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index f8787e9..056f8f3 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -10,10 +10,9 @@ import { T3LocalAuthOriginLive } from "../auth/local-origin.ts"; import { T3LocalAuthTokenLive } from "../auth/local-token.ts"; import { T3AuthPairingLive } from "../auth/pairing.ts"; import { T3AuthTransportLive } from "../auth/transport.ts"; -import { layer as T3ConfigLive } from "../config/layer.ts"; -import { layer as T3CredentialCryptoLive } from "../config/credential.ts"; -import { T3ConfigSelectionLive } from "../config/selection-layer.ts"; -import { T3Config } from "../config/service.ts"; +import * as Config from "../config/config.ts"; +import * as Credential from "../config/credential/service.ts"; +import * as Selection from "../config/selection/service.ts"; import { T3CodeConnectionError } from "../connection/error.ts"; import { T3CodeConnectionProvider, makeT3CodeConnectionProvider } from "../connection/service.ts"; import { T3OrchestrationLive } from "../orchestration/layer.ts"; @@ -22,7 +21,7 @@ import { T3RpcOperationsLive } from "../rpc/operation.ts"; import { NodeSqlClientFactoryLive } from "../sql/node-sqlite-client.ts"; import { NodeCliPathLayer } from "../cli-path/layer.ts"; -export const T3ConfigLayer = T3ConfigLive.pipe(Layer.provide(T3CredentialCryptoLive)); +export const T3ConfigLayer = Config.layer.pipe(Layer.provide(Credential.layer)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); @@ -42,7 +41,7 @@ export const T3AuthLayer = T3AuthLive.pipe( const T3ConfigConnectionProviderLayer = Layer.effect( T3CodeConnectionProvider, Effect.gen(function* () { - const config = yield* T3Config; + const config = yield* Config.T3Config; return makeT3CodeConnectionProvider( config.resolve().pipe( Effect.map((resolved) => ({ @@ -88,6 +87,6 @@ export const BaseAppLayer = Layer.mergeAll( export const BaseAuthAppLayer = Layer.mergeAll(T3ConfigLayer, T3AuthLayer); -export const AuthAppLayer = BaseAuthAppLayer.pipe(Layer.provideMerge(T3ConfigSelectionLive)); +export const AuthAppLayer = BaseAuthAppLayer.pipe(Layer.provideMerge(Selection.layer)); -export const AppLayer = BaseAppLayer.pipe(Layer.provideMerge(T3ConfigSelectionLive)); +export const AppLayer = BaseAppLayer.pipe(Layer.provideMerge(Selection.layer)); diff --git a/src/test/helpers/assert-errors.ts b/src/test/helpers/assert-errors.ts deleted file mode 100644 index c088d13..0000000 --- a/src/test/helpers/assert-errors.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { assert } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; - -import { ConfigError, UrlError } from "../../config/error.ts"; - -function assertExitFailure( - exit: Exit.Exit, -): asserts exit is Exit.Failure { - if (!Exit.isFailure(exit)) { - throw new Error("expected failure"); - } -} - -export const expectConfigError = ( - effect: Effect.Effect, - message: string, -): Effect.Effect => - Effect.gen(function* () { - const exit = yield* effect.pipe(Effect.exit); - assertExitFailure(exit); - const error = Cause.findErrorOption(exit.cause); - if (Option.isNone(error)) { - return yield* Effect.die("expected failure"); - } - assert.instanceOf(error.value, ConfigError); - assert.equal(error.value.message, message); - return yield* Effect.void; - }); - -export const expectUrlError = ( - effect: Effect.Effect, - message: string, -): Effect.Effect => - Effect.gen(function* () { - const exit = yield* effect.pipe(Effect.exit); - assertExitFailure(exit); - const error = Cause.findErrorOption(exit.cause); - if (Option.isNone(error)) { - return yield* Effect.die("expected failure"); - } - assert.instanceOf(error.value, UrlError); - assert.equal(error.value.message, message); - return yield* Effect.void; - }); diff --git a/src/test/layers/cli-config-routing.ts b/src/test/layers/cli-config-routing.ts deleted file mode 100644 index 6768b79..0000000 --- a/src/test/layers/cli-config-routing.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Layer from "effect/Layer"; - -import { T3CliConfigSelectionLive } from "../../cli/selection-layer.ts"; -import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; -import { layer as T3ConfigLive } from "../../config/layer.ts"; -import { layerNode as T3CredentialCipherNodeLive } from "../../config/credential-cipher-node.ts"; -import { Environment } from "../../environment/service.ts"; -import { ConfigPlatformLayer } from "../platform.ts"; -import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; - -export function cliConfigRoutingLayerTest(homeDir: string) { - const environmentLayer = Environment.layerTest({ - homeDir, - env: { T3CLI_ENV: "home" }, - }); - - return Layer.mergeAll( - ConfigPlatformLayer, - environmentLayer, - T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), - Layer.provide( - Layer.mergeAll( - environmentLayer, - T3CliConfigSelectionLive.pipe(Layer.provide(environmentLayer)), - T3CredentialCipherNodeLive, - unavailableKeystoreFactoryLayer, - ), - ), - ), - ); -} diff --git a/src/test/layers/config.ts b/src/test/layers/config.ts deleted file mode 100644 index 4c522cd..0000000 --- a/src/test/layers/config.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { layer as T3ConfigLive } from "../../config/layer.ts"; -import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; -import { layerWeb as T3CredentialCipherWebLive } from "../../config/credential-cipher-web.ts"; -import { T3ConfigSelection } from "../../config/selection.ts"; -import { T3ConfigSelectionLive } from "../../config/selection-layer.ts"; -import { Environment } from "../../environment/service.ts"; -import { ConfigPlatformLayer } from "../platform.ts"; -import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; - -export type T3ConfigLayerTestInput = { - readonly selection?: string; - readonly env?: Readonly>; - readonly useSelectionLive?: boolean; -}; - -export function t3ConfigDepsLayer(homeDir: string, input: T3ConfigLayerTestInput = {}) { - const environmentLayer = Environment.layerTest({ - homeDir, - ...(input.env !== undefined ? { env: input.env } : {}), - }); - const selectionLayer = - input.useSelectionLive === true - ? T3ConfigSelectionLive.pipe(Layer.provide(environmentLayer)) - : Layer.succeed(T3ConfigSelection)({ - getSelectedEnvironment: () => Effect.succeed(input.selection), - }); - - return T3ConfigLive.pipe( - Layer.provide(T3CredentialCryptoLive), - Layer.provide( - Layer.mergeAll( - environmentLayer, - selectionLayer, - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - ), - ), - ); -} - -export function t3ConfigLayerTest(homeDir: string, input: T3ConfigLayerTestInput = {}) { - return Layer.mergeAll(ConfigPlatformLayer, t3ConfigDepsLayer(homeDir, input)); -} diff --git a/src/test/layers/credential-crypto.ts b/src/test/layers/credential-crypto.ts deleted file mode 100644 index 3cad80b..0000000 --- a/src/test/layers/credential-crypto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as Layer from "effect/Layer"; - -import { layer as T3CredentialCryptoLive } from "../../config/credential.ts"; -import { layerWeb as T3CredentialCipherWebLive } from "../../config/credential-cipher-web.ts"; -import { Environment } from "../../environment/service.ts"; -import { ConfigPlatformLayer } from "../platform.ts"; -import { unavailableKeystoreFactoryLayer } from "./keystore-unavailable.ts"; - -export function t3CredentialCryptoDepsLayer(homeDir: string) { - return T3CredentialCryptoLive.pipe( - Layer.provide( - Layer.mergeAll( - Environment.layerTest({ homeDir }), - T3CredentialCipherWebLive, - unavailableKeystoreFactoryLayer, - ), - ), - ); -} - -export function t3CredentialCryptoLayerTest(homeDir: string) { - return Layer.mergeAll(ConfigPlatformLayer, t3CredentialCryptoDepsLayer(homeDir)); -} diff --git a/tsconfig.dts.json b/tsconfig.dts.json index 9e26ba9..779d888 100644 --- a/tsconfig.dts.json +++ b/tsconfig.dts.json @@ -15,12 +15,10 @@ "src/config/index.ts", "src/connection/index.ts", "src/contracts/index.ts", - "src/layout/index.ts", "src/node/index.ts", "src/orchestration/index.ts", "src/rpc/index.ts", "src/runtime/index.ts", - "src/scope/index.ts", "src/t3tools/index.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index 000d3f4..676b74a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,11 +42,9 @@ export default defineConfig({ cli: "src/cli/index.ts", connection: "src/connection/index.ts", contracts: "src/contracts/index.ts", - layout: "src/layout/index.ts", node: "src/node/index.ts", orchestration: "src/orchestration/index.ts", rpc: "src/rpc/index.ts", - scope: "src/scope/index.ts", runtime: "src/runtime/index.ts", t3tools: "src/t3tools/index.ts", }, From 13ec31f50c9424636e95a99368fb318152a5a8dc Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 23 Jun 2026 00:17:07 +0300 Subject: [PATCH 20/21] Register env command and guard default environment switching. Wire env list/use/remove into the CLI, drop duplicate auth subcommands, and verify credential decryption before selecting a default environment with clearer error messages. Co-authored-by: Cursor --- src/auth/layer.ts | 2 +- src/cli/app.ts | 2 + src/cli/auth.ts | 101 ++----------------------------------------- src/config/config.ts | 40 ++++++++++++++--- 4 files changed, 41 insertions(+), 104 deletions(-) diff --git a/src/auth/layer.ts b/src/auth/layer.ts index 21751b6..2444a43 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -85,7 +85,7 @@ export const makeT3Auth = Effect.fn("makeT3Auth")(function* () { if (defaultName === undefined) { return yield* Effect.fail( new AuthConfigError({ - message: "no environment selected: pass --name or run: t3cli auth use ", + message: "no environment selected: pass --name or run: t3cli env use ", }), ); } diff --git a/src/cli/app.ts b/src/cli/app.ts index 498bb70..baf71dd 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -1,6 +1,7 @@ import { Command } from "effect/unstable/cli"; import { createAuthCommand } from "./auth.ts"; +import { createEnvCommand } from "./env.ts"; import { cliEnvironmentSetting } from "./env/flag.ts"; import { createTerminalCommandGroup } from "./terminal.ts"; import { createModelCommand } from "./model.ts"; @@ -19,6 +20,7 @@ export function createCliCommand() { Command.withGlobalFlags([cliEnvironmentSetting]), Command.withSubcommands([ createAuthCommand(), + createEnvCommand(), listThreadsCommand, createModelCommand(), createProjectCommand(), diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 11914b2..43f99dd 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -1,26 +1,19 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { Argument, Command, Flag } from "effect/unstable/cli"; +import { Command, Flag } from "effect/unstable/cli"; import { - formatAuthListHuman, - formatAuthListJson, formatAuthLocalHuman, formatAuthLocalJson, formatAuthPaired, formatAuthStatusHuman, formatAuthStatusJson, - formatAuthUnpairHuman, - formatAuthUseHuman, } from "./format/auth.ts"; import { T3Auth } from "../auth/service.ts"; import { CliRuntime } from "../cli/runtime/service.ts"; import { loadT3CliEnv } from "../config/env/env.ts"; -import { - requireDestructiveConfirmation, - requireEnvironmentReplaceConfirmation, -} from "./interaction/confirm.ts"; -import { envNameFlag, formatFlag, replaceFlag, yesFlag } from "./flags.ts"; +import { requireEnvironmentReplaceConfirmation } from "./interaction/confirm.ts"; +import { envNameFlag, formatFlag, replaceFlag } from "./flags.ts"; import { resolveOutputFormat } from "./format/output.ts"; import { T3Output } from "./output/service.ts"; @@ -60,14 +53,7 @@ const persistAuthEnvironment = Effect.fn("persistAuthEnvironment")(function* (in export function createAuthCommand() { return Command.make("auth").pipe( Command.withDescription("auth commands"), - Command.withSubcommands([ - pairCommand, - localCommand, - listCommand, - useCommand, - unpairCommand, - statusCommand, - ]), + Command.withSubcommands([pairCommand, localCommand, statusCommand]), ); } @@ -150,85 +136,6 @@ const localCommand = Command.make( }), ).pipe(Command.withDescription("authenticate with local t3code installation")); -const listCommand = Command.make( - "list", - { - format: formatFlag, - }, - ({ format }) => - Effect.gen(function* () { - const auth = yield* T3Auth; - const cliRuntime = yield* CliRuntime; - const t3CliEnv = yield* loadT3CliEnv; - const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); - const environments = yield* auth.listEnvironments(); - if (resolvedFormat === "json") { - yield* output.printJson(formatAuthListJson(environments)); - } else { - yield* output.printInfo(formatAuthListHuman(environments)); - } - }), -).pipe(Command.withDescription("list stored auth environments")); - -const useCommand = Command.make( - "use", - { - name: Argument.string("name"), - format: formatFlag, - }, - ({ name, format }) => - Effect.gen(function* () { - const auth = yield* T3Auth; - const cliRuntime = yield* CliRuntime; - const t3CliEnv = yield* loadT3CliEnv; - const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); - const result = yield* auth.useEnvironment(name); - if (resolvedFormat === "json") { - yield* output.printJson(result); - } else { - yield* output.printInfo(formatAuthUseHuman(result)); - } - }), -).pipe(Command.withDescription("set the default auth environment")); - -const unpairCommand = Command.make( - "unpair", - { - name: envNameFlag, - yes: yesFlag, - format: formatFlag, - }, - ({ name, yes, format }) => - Effect.gen(function* () { - const auth = yield* T3Auth; - const cliRuntime = yield* CliRuntime; - const t3CliEnv = yield* loadT3CliEnv; - const output = yield* T3Output; - const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); - const targetName = yield* auth.resolveUnpairTarget( - Option.isSome(name) ? { name: name.value } : {}, - ); - yield* requireDestructiveConfirmation({ - message: `Remove local credentials for environment '${targetName}'?`, - yes, - cliRuntime, - t3CliEnv, - }); - const result = yield* auth.unpairEnvironment({ name: targetName }); - if (resolvedFormat === "json") { - yield* output.printJson(result); - } else { - yield* output.printInfo(formatAuthUnpairHuman(result)); - } - }), -).pipe( - Command.withDescription( - "remove local credentials for an environment (remote tokens may remain valid until expiry)", - ), -); - const statusCommand = Command.make( "status", { diff --git a/src/config/config.ts b/src/config/config.ts index 6e4bca8..8b11e12 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -133,6 +133,24 @@ export const make = Effect.fn("makeT3Config")(function* () { if (encrypted.environments[name] === undefined) { return yield* Effect.fail(new ConfigError({ message: `environment not found: ${name}` })); } + const selectedEnvironment = encrypted.environments[name]; + yield* credentialCrypto + .decrypt({ + environmentName: name, + url: selectedEnvironment.url, + local: selectedEnvironment.local, + token: selectedEnvironment.token, + }) + .pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: `failed to decrypt credentials for environment '${name}'`, + cause: error.cause, + }), + ), + Effect.asVoid, + ); yield* Effect.provide( writeEncryptedConfigFile({ ...encrypted, @@ -204,12 +222,22 @@ export const make = Effect.fn("makeT3Config")(function* () { } const selectedEnvironment = encrypted.environments[selectedName]; - const token = yield* credentialCrypto.decrypt({ - environmentName: selectedName, - url: selectedEnvironment.url, - local: selectedEnvironment.local, - token: selectedEnvironment.token, - }); + const token = yield* credentialCrypto + .decrypt({ + environmentName: selectedName, + url: selectedEnvironment.url, + local: selectedEnvironment.local, + token: selectedEnvironment.token, + }) + .pipe( + Effect.mapError( + (error) => + new ConfigError({ + message: `failed to decrypt credentials for environment '${selectedName}'`, + cause: error.cause, + }), + ), + ); return yield* buildResolvedConfigFromStored({ selectedName, token, From 053cb5d582008e341c186e66198360507fd466f5 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 23 Jun 2026 00:26:01 +0300 Subject: [PATCH 21/21] Add changeset and update docs for multi-environment auth. Document breaking CLI and library changes in the release notes, and align README and agent skill references with env list/use/remove. Co-authored-by: Cursor --- .changeset/multi-environment-auth.md | 44 +++++++++++++++++++++++++ README.md | 10 +++--- skills/t3code-cli/SKILL.md | 11 ++++--- skills/t3code-cli/reference/commands.md | 20 ++++++++++- skills/t3code-cli/reference/setup.md | 18 +++++----- 5 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 .changeset/multi-environment-auth.md diff --git a/.changeset/multi-environment-auth.md b/.changeset/multi-environment-auth.md new file mode 100644 index 0000000..af051c0 --- /dev/null +++ b/.changeset/multi-environment-auth.md @@ -0,0 +1,44 @@ +--- +"t3code-cli": minor +--- + +Add multi-environment auth with encrypted credential storage. + +### Features + +- Store multiple named auth environments in `~/.config/t3cli/config.json` (or `$XDG_CONFIG_HOME/t3cli/config.json`). +- Encrypt tokens at rest with AES-256-GCM; bind ciphertext to environment name, URL, and `local` flag via additional authenticated data. +- Store the master key in the OS keyring when available (`@napi-rs/keyring`), otherwise in `~/.config/t3cli/key` with `0600` permissions. +- Migrate legacy v1 flat config (plaintext `url`/`token`) to encrypted v2 on first read; rewrite `config.json` automatically. +- Add global `--environment ` flag and `T3CLI_ENV` for per-command environment selection. +- Add `T3CODE_URL` + `T3CODE_TOKEN` override when both are set. +- Add `env list`, `env use`, and `env remove` commands for managing stored environments. +- Add `--name` and `--replace` flags to `auth pair` and `auth local`. +- Verify credential decryption before switching the default environment (`env use`). + +### Breaking changes + +**CLI** + +- `auth list`, `auth use`, and `auth unpair` are removed. Use `env list`, `env use`, and `env remove` instead. +- `auth unpair` is renamed to `env remove` (same behavior: removes local credentials only). + +**Library (`t3code-cli/config`)** + +- `T3ConfigLive`, `makeT3Config`, `UrlError`, and `StoredConfig` are removed from the public export surface. +- Import config services via namespace exports instead: `Config`, `Credential`, `Keystore`, `Selection`, `Env`, `Paths`, `Url`, `EnvironmentName`. +- `ResolvedConfig` remains exported but now includes optional `environment` and a `source` discriminator (`config` | `env`). + +**Library (`t3code-cli/connection`)** + +- `T3CodeNodeRpcLayer` is no longer exported from `t3code-cli/connection`. Import it from `t3code-cli/node`. + +**Library (`t3code-cli/runtime` and `t3code-cli/node`)** + +- `NodeEnvironmentLive` is removed. Environment/process snapshot access moved to `CliRuntime` under `t3code-cli/cli`. + +### Upgrade notes + +- Existing single-environment v1 configs upgrade automatically on the first command that reads config. +- After upgrade, tokens are encrypted; keep the OS keyring entry or `~/.config/t3cli/key` file backed up if you rely on stored credentials. +- Scripts using `auth list`, `auth use`, or `auth unpair` must switch to the `env` subcommands. diff --git a/README.md b/README.md index b444842..45cebc7 100644 --- a/README.md +++ b/README.md @@ -46,18 +46,18 @@ npx skills add tarik02/t3cli ```sh t3cli auth pair --url [--name ] [--replace] [--local] # Pair with a remote server t3cli auth local [--name ] [--replace] # Local t3code installation -t3cli auth list [--format json] # List stored environments -t3cli auth use [--format json] # Set default environment -t3cli auth unpair [--name ] [--yes] # Remove local credentials t3cli auth status [--format json] # Check current auth +t3cli env list [--format json] # List stored environments +t3cli env use [--format json] # Set default environment +t3cli env remove [--name ] [--yes] # Remove local credentials t3cli --environment ... # Use a specific environment once ``` - Use `auth pair` with a pairing URL from a running t3code server - Default environment names: hostname slug from the paired URL, or `local` for `auth local` -- `auth pair` / `auth local` set the default environment only when creating the first stored environment; use `auth use` to switch afterward +- `auth pair` / `auth local` set the default environment only when creating the first stored environment; use `env use` to switch afterward - `--replace` overwrites an existing environment and makes it the default -- `auth unpair` removes local CLI credentials only; any remote token can remain valid until natural expiry +- `env remove` removes local CLI credentials only; any remote token can remain valid until natural expiry - Use `auth local` or `auth pair --local` to authenticate against a local t3code installation - Local auth enables automatic project resolution from the current directory - Set `T3CLI_ENV=` to select an environment when `--environment` is omitted diff --git a/skills/t3code-cli/SKILL.md b/skills/t3code-cli/SKILL.md index a49ff74..d7df7b5 100644 --- a/skills/t3code-cli/SKILL.md +++ b/skills/t3code-cli/SKILL.md @@ -49,11 +49,12 @@ t3cli start "task description" --provider "$PROVIDER" --model "$MODEL" --wait ## Scope resolution -| Target | Flag | Env (first match wins) | -| -------- | ------------ | ------------------------------------------------------------------- | -| Project | `--project` | `T3CODE_PROJECT_ROOT` → `T3CODE_PROJECT_ID` → cwd (local auth only) | -| Worktree | `--worktree` | `T3CODE_WORKTREE_PATH` → inferred from cwd | -| Thread | `--thread` | `T3CODE_THREAD_ID` | +| Target | Flag | Env (first match wins) | +| ----------- | --------------- | ------------------------------------------------------------------- | +| Environment | `--environment` | `T3CLI_ENV` → config default | +| Project | `--project` | `T3CODE_PROJECT_ROOT` → `T3CODE_PROJECT_ID` → cwd (local auth only) | +| Worktree | `--worktree` | `T3CODE_WORKTREE_PATH` → inferred from cwd | +| Thread | `--thread` | `T3CODE_THREAD_ID` | Project matching: id → `workspaceRoot` → ancestor under workspace → known thread `worktreePath`. Remote pairing without `--local` requires explicit `--project` or `T3CODE_PROJECT_*`. diff --git a/skills/t3code-cli/reference/commands.md b/skills/t3code-cli/reference/commands.md index eca1bdb..b5e0c37 100644 --- a/skills/t3code-cli/reference/commands.md +++ b/skills/t3code-cli/reference/commands.md @@ -2,6 +2,8 @@ ``` t3cli +├── auth pair|local|status +├── env list|use|remove ├── project list|add|delete ├── model list ├── list|start|send|show|transcript|wait @@ -9,7 +11,7 @@ t3cli └── thread approve|respond|archive|interrupt|unarchive|update|delete|callback ``` -Auth commands: [setup.md](setup.md) +Auth and environment commands: [setup.md](setup.md) ## Global flags @@ -30,6 +32,22 @@ Auth commands: [setup.md](setup.md) Also treated as agent env (no live TTY): `CI`, `CODEX_CI`, `CODEX_THREAD_ID`. +## auth + +```sh +t3cli auth pair --url [--name ] [--replace] [--local] [--format json] +t3cli auth local [--name ] [--replace] [--format json] +t3cli auth status [--format json] +``` + +## env + +```sh +t3cli env list [--format json] +t3cli env use [--format json] +t3cli env remove [--name ] [--yes] [--format json] +``` + ## project ```sh diff --git a/skills/t3code-cli/reference/setup.md b/skills/t3code-cli/reference/setup.md index 8a866cc..c2feead 100644 --- a/skills/t3code-cli/reference/setup.md +++ b/skills/t3code-cli/reference/setup.md @@ -16,8 +16,8 @@ One `t3cli` install can store credentials for multiple servers. Environment name ```sh t3cli auth pair --url --name work t3cli auth local --name local -t3cli auth list --format json -t3cli auth use work +t3cli env list --format json +t3cli env use work t3cli --environment local project list ``` @@ -63,26 +63,26 @@ t3cli auth local --base-dir --origin --role owner | `--label` | `t3cli` | Client label | | `--subject` | `t3cli-local` | Token subject | -## auth list +## env list ```sh -t3cli auth list [--format json] +t3cli env list [--format json] ``` Lists stored environments only. JSON fields: `name`, `url`, `local`, `default`, `active`. Tokens are never printed. -## auth use +## env use ```sh -t3cli auth use [--format json] +t3cli env use [--format json] ``` -Sets the default environment without contacting the server. +Sets the default environment without contacting the server. Fails if stored credentials for that environment cannot be decrypted. -## auth unpair +## env remove ```sh -t3cli auth unpair [--name ] [--yes] [--format json] +t3cli env remove [--name ] [--yes] [--format json] ``` Removes local CLI credentials for the default environment or `--name`. Requires confirmation; non-interactive mode requires `--yes`. Remote tokens may remain valid until natural expiry.