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/CONTRIBUTING.md b/CONTRIBUTING.md index f9a4774..da5a956 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,18 @@ 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`. +- 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. + ## pull requests Keep changes scoped and small. Include user-facing README updates when command behavior changes. diff --git a/README.md b/README.md index 9703503..45cebc7 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 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 -- 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 `env use` to switch afterward +- `--replace` overwrites an existing environment and makes it the default +- `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 +- `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..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", @@ -108,6 +113,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/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 04cf588..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,24 +11,43 @@ 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 -`--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`. +## 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 451163b..c2feead 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 env list --format json +t3cli env 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 | + +## env list + +```sh +t3cli env list [--format json] +``` + +Lists stored environments only. JSON fields: `name`, `url`, `local`, `default`, `active`. Tokens are never printed. + +## env use + +```sh +t3cli env use [--format json] +``` + +Sets the default environment without contacting the server. Fails if stored credentials for that environment cannot be decrypted. + +## env remove + +```sh +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. ## 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/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 019bb11..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", 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.test-utils.ts b/src/auth/layer.test-utils.ts new file mode 100644 index 0000000..983bfc7 --- /dev/null +++ b/src/auth/layer.test-utils.ts @@ -0,0 +1,34 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +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( + 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/auth/layer.ts b/src/auth/layer.ts index 63dfb79..2444a43 100644 --- a/src/auth/layer.ts +++ b/src/auth/layer.ts @@ -1,13 +1,23 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { T3Config } from "../config/service.ts"; +import { + defaultEnvironmentNameForLocal, + defaultEnvironmentNameFromUrl, +} from "../config/environment-name/name.ts"; +import type { EnvironmentSummary, ResolvedConfig } from "../config/types.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 } from "./type.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; @@ -16,58 +26,130 @@ 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 })), - }), - ); + 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* config - .writeStored({ - ...existing, + .upsertEnvironment({ + name: input.name, url: input.url, token: input.token, - ...(input.local !== undefined ? { local: input.local } : {}), + local: input.local, + ...(makeDefault ? { makeDefault: true } : {}), }) - .pipe( - Effect.catchTags({ - ConfigError: (error) => - Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })), + .pipe(Effect.mapError(mapConfigError)); + 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 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 env use ", }), ); + } + return defaultName; }); 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: (name: string) => + config.hasEnvironment(name).pipe(Effect.mapError(mapConfigError)), + defaultNameFromUrl: (url: string) => + defaultEnvironmentNameFromUrl(url).pipe(Effect.mapError(mapConfigError)), + defaultNameForLocal: () => Effect.succeed(defaultEnvironmentNameForLocal()), + listEnvironments, + useEnvironment: (name: string) => + config + .setDefaultEnvironment(name) + .pipe(Effect.mapError(mapConfigError), Effect.as({ name, default: true as const })), + resolveUnpairTarget, + unpairEnvironment: (input: { readonly name: string }) => + config + .removeEnvironment(input.name) + .pipe( + Effect.mapError(mapConfigError), + Effect.as({ name: input.name, removed: true as const }), + ), status, issueWebSocketTicket, }; }); export const T3AuthLive = Layer.effect(T3Auth, makeT3Auth()); + +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/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/service.test.ts b/src/auth/service.test.ts new file mode 100644 index 0000000..e10e2cb --- /dev/null +++ b/src/auth/service.test.ts @@ -0,0 +1,200 @@ +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { assert, describe, it } from "@effect/vitest"; + +import { T3Auth } from "./service.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", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-auth-"); + }).pipe( + Effect.flatMap((homeDir) => + 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(t3AuthDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("does not change default when replace is used for a new environment", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-auth-"); + }).pipe( + Effect.flatMap((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, + 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(t3AuthDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("promotes default when replacing an existing environment with replace", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-auth-"); + }).pipe( + Effect.flatMap((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, + }); + 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(t3AuthDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("lists environments without decrypting tokens", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-auth-"); + }).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* 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(t3AuthDepsLayer(homeDir))); + + const raw = yield* fs.readFileString(configPath); + 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 }); + + 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(t3AuthDepsLayer(homeDir))); + }), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); + + it.effect("resolves unpair target from encrypted default metadata", () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-auth-"); + }).pipe( + Effect.flatMap((homeDir) => + 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(t3AuthDepsLayer(homeDir))), + ), + Effect.provide(ConfigPlatformLayer), + Effect.scoped, + ), + ); +}); 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/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/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..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,26 +7,39 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Command } from "effect/unstable/cli"; import { createCliCommand } from "./cli/app.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"; 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"; 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(CliSelection.layer), + Layer.provide(CredentialCipherNode.layerNode), + Layer.provide(KeystoreKeyringNode.layerNode), +); const CliLayer = Layer.mergeAll( - AppLayer.pipe(Layer.provide(PlatformLayer)), + 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 a47a423..baf71dd 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -1,6 +1,8 @@ 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"; import { createProjectCommand } from "./project.ts"; @@ -15,8 +17,10 @@ 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(), + createEnvCommand(), listThreadsCommand, createModelCommand(), createProjectCommand(), diff --git a/src/cli/auth-format.ts b/src/cli/auth-format.ts deleted file mode 100644 index 699efb5..0000000 --- a/src/cli/auth-format.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { AuthSessionState } from "../auth/schema.ts"; -import type { ResolvedConfig } from "../config/service.ts"; -import type { LocalAuthResult, PairResult } from "../auth/type.ts"; - -export function formatAuthPaired(result: PairResult) { - return `paired: ${result.url}\nrole: ${result.role}\nexpires: ${result.expiresAt}`; -} - -export function formatAuthLocalHuman(result: LocalAuthResult) { - return [ - `paired: ${result.url}`, - `role: ${result.role}`, - `expires: ${result.expiresAt}`, - `baseDir: ${result.baseDir}`, - ].join("\n"); -} - -export function formatAuthLocalJson(result: LocalAuthResult) { - return result; -} - -export function formatAuthStatusHuman(input: { - readonly config: ResolvedConfig; - readonly result: AuthSessionState; -}) { - return [ - `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}`] : []), - ].join("\n"); -} - -export function formatAuthStatusJson(input: { - readonly config: ResolvedConfig; - readonly result: AuthSessionState; -}) { - return { - ...input.result, - url: input.config.url, - source: input.config.source, - local: input.config.local, - }; -} diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 6f85dfb..43f99dd 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -8,14 +8,48 @@ import { formatAuthPaired, formatAuthStatusHuman, formatAuthStatusJson, -} from "./auth-format.ts"; +} from "./format/auth.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 { resolveOutputFormat } from "./output-format.ts"; +import { CliRuntime } from "../cli/runtime/service.ts"; +import { loadT3CliEnv } from "../config/env/env.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"; +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 cliRuntime = yield* CliRuntime; + const t3CliEnv = yield* loadT3CliEnv; + 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, + cliRuntime, + t3CliEnv, + }); + } + 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"), @@ -28,24 +62,32 @@ const pairCommand = Command.make( { url: Flag.string("url"), local: Flag.boolean("local"), + name: envNameFlag, + replace: replaceFlag, format: formatFlag, }, - ({ url, local, format }) => + ({ 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); - 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,14 +100,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: envNameFlag, + 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; + 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, @@ -73,15 +118,20 @@ 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")); @@ -93,17 +143,16 @@ const statusCommand = Command.make( }, ({ format }) => Effect.gen(function* () { - const configService = yield* T3Config; 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 config = yield* configService.resolve(); + const resolvedFormat = resolveOutputFormat(format, cliRuntime, t3CliEnv, "json"); 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 deleted file mode 100644 index ee1f24f..0000000 --- a/src/cli/confirm.ts +++ /dev/null @@ -1,31 +0,0 @@ -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"; - -export const requireDestructiveConfirmation = Effect.fn("requireDestructiveConfirmation")( - function* (input: { - readonly message: string; - readonly yes: boolean; - readonly environment: EnvironmentShape; - }) { - if (input.yes) { - return; - } - if (!isInteractiveHumanTerminal(input.environment)) { - yield* Effect.fail( - new DestructiveConfirmationRequiredError({ - message: "destructive action requires --yes in non-interactive mode", - }), - ); - return; - } - const confirmed = yield* Prompt.run(Prompt.confirm({ message: input.message, initial: false })); - if (confirmed) { - return; - } - yield* Effect.fail(new DestructiveConfirmationRequiredError({ message: "aborted" })); - }, -); 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/env/flag.ts b/src/cli/env/flag.ts new file mode 100644 index 0000000..43dbfee --- /dev/null +++ b/src/cli/env/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/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/cli/env/selection-layer.test.ts b/src/cli/env/selection-layer.test.ts new file mode 100644 index 0000000..b1bcfe8 --- /dev/null +++ b/src/cli/env/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 "../../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", () => + 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/cli/env/selection-layer.ts b/src/cli/env/selection-layer.ts new file mode 100644 index 0000000..49ccbbc --- /dev/null +++ b/src/cli/env/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/service.ts"; +import { loadT3CliEnv } from "../../config/env/env.ts"; +import { cliEnvironmentSetting } from "./flag.ts"; + +export const layer = Layer.effect( + T3ConfigSelection, + Effect.gen(function* () { + const t3CliEnv = yield* loadT3CliEnv; + return { + getSelectedEnvironment: () => + Effect.gen(function* () { + const cliEnvironment = yield* Effect.serviceOption(cliEnvironmentSetting); + return resolveConfiguredEnvironment({ + cliFlag: Option.isSome(cliEnvironment) + ? Option.getOrUndefined(cliEnvironment.value) + : undefined, + t3cliEnv: Option.getOrUndefined(t3CliEnv.t3cliEnv), + }); + }), + }; + }), +); diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 9eff9a1..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( @@ -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 envNameFlag = Flag.string("name").pipe( + Flag.withDescription("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/format/auth.ts b/src/cli/format/auth.ts new file mode 100644 index 0000000..db8cfbf --- /dev/null +++ b/src/cli/format/auth.ts @@ -0,0 +1,74 @@ +import type { + AuthEnvironmentListItem, + AuthStatusResult, + LocalAuthResult, + PairResult, +} 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}`; +} + +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 & { readonly name: string }) { + return result; +} + +export function formatAuthStatusHuman(input: AuthStatusResult) { + return [ + ...(input.config.environment !== undefined ? [`environment: ${input.config.environment}`] : []), + `url: ${input.config.url}`, + `local: ${input.config.local ? "yes" : "no"}`, + `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: AuthStatusResult) { + return { + ...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/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/interaction/confirm.ts b/src/cli/interaction/confirm.ts new file mode 100644 index 0000000..a39b8ba --- /dev/null +++ b/src/cli/interaction/confirm.ts @@ -0,0 +1,64 @@ +import * as Effect from "effect/Effect"; +import * as Prompt from "effect/unstable/cli/Prompt"; + +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 cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; + }) { + if (input.yes) { + return; + } + if (!isInteractiveHumanTerminal(input.cliRuntime, input.t3CliEnv)) { + yield* Effect.fail( + new DestructiveConfirmationRequiredError({ + message: "destructive action requires --yes in non-interactive mode", + }), + ); + return; + } + const confirmed = yield* Prompt.run(Prompt.confirm({ message: input.message, initial: false })); + if (confirmed) { + return; + } + yield* Effect.fail(new DestructiveConfirmationRequiredError({ message: "aborted" })); + }, +); + +export const requireEnvironmentReplaceConfirmation = Effect.fn( + "requireEnvironmentReplaceConfirmation", +)(function* (input: { + readonly name: string; + readonly replace: boolean; + readonly cliRuntime: CliRuntime["Service"]; + readonly t3CliEnv: T3CliEnvShape; +}) { + if (input.replace) { + return; + } + if (!isInteractiveHumanTerminal(input.cliRuntime, input.t3CliEnv)) { + 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/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/config.ts b/src/config/config.ts new file mode 100644 index 0000000..8b11e12 --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,260 @@ +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 { 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, + resolveDefaultForUpsert, + selectEnvironmentName, + summarizeEnvironments, + validateCredentialEnvVars, +} 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 configSelection = yield* T3ConfigSelection; + const credentialCrypto = yield* T3CredentialCrypto; + const t3CliEnv = yield* loadT3CliEnv; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configProvider = yield* ConfigProvider.ConfigProvider; + const services = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, path), + Layer.succeed(T3CredentialCrypto, credentialCrypto), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), + ); + + 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("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("T3Config.resolveActiveEnvironmentName")( + 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 hasEnvCredentials = envUrl !== undefined && envToken !== undefined; + 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("T3Config.listEnvironments")(function* () { + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + return summarizeEnvironments(encrypted); + }); + + const upsertEnvironment = Effect.fn("T3Config.upsertEnvironment")(function* ( + input: UpsertEnvironmentInput, + ) { + yield* validateEnvironmentName(input.name); + const normalizedUrl = yield* normalizeHttpBaseUrl(input.url).pipe( + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), + ); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + 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* Effect.provide( + writeEncryptedConfigFile({ + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments: { + ...encrypted.environments, + [input.name]: { + url: normalizedUrl, + local: input.local, + token, + }, + }, + }), + services, + ); + }); + + const setDefaultEnvironment = Effect.fn("T3Config.setDefaultEnvironment")(function* ( + name: string, + ) { + yield* validateEnvironmentName(name); + const encrypted = yield* Effect.provide(readEncryptedConfigFile(), services); + 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, + default: name, + }), + services, + ); + return yield* Effect.void; + }); + + const removeEnvironment = Effect.fn("T3Config.removeEnvironment")(function* (name: string) { + yield* validateEnvironmentName(name); + 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* Effect.provide( + writeEncryptedConfigFile({ + version: 2, + ...(defaultName !== undefined ? { default: defaultName } : {}), + environments, + }), + services, + ); + return yield* Effect.void; + }); + + 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; + 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 --url ", + }), + ); + } + 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* 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, + encrypted, + }); + }); + + return { + resolve, + resolveActiveEnvironmentName, + listEnvironments, + upsertEnvironment, + setDefaultEnvironment, + removeEnvironment, + hasEnvironment, + getDefaultEnvironmentName, + } as const; +}); + +export const layer = Layer.effect(T3Config, make()); diff --git a/src/config/credential/cipher-node.ts b/src/config/credential/cipher-node.ts new file mode 100644 index 0000000..d3392c7 --- /dev/null +++ b/src/config/credential/cipher-node.ts @@ -0,0 +1,40 @@ +import { createCipheriv, createDecipheriv } from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { credentialCipherTagByteLength, T3CredentialCipher } from "./cipher.ts"; +import { CredentialCipherError } from "./error.ts"; + +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 layerNode = Layer.succeed(T3CredentialCipher, make()); diff --git a/src/config/credential/cipher-web.ts b/src/config/credential/cipher-web.ts new file mode 100644 index 0000000..6ef7f34 --- /dev/null +++ b/src/config/credential/cipher-web.ts @@ -0,0 +1,82 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { credentialCipherTagByteLength, T3CredentialCipher } from "./cipher.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, operation: "encrypt" | "decrypt") { + return Effect.tryPromise({ + try: () => + globalThis.crypto.subtle.importKey("raw", copyBytes(key), { name: aesGcmAlgorithm }, false, [ + "encrypt", + "decrypt", + ]), + catch: (cause) => new CredentialCipherError({ operation, cause }), + }); +} + +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 layerWeb = Layer.succeed(T3CredentialCipher, make()); diff --git a/src/config/credential/cipher.ts b/src/config/credential/cipher.ts new file mode 100644 index 0000000..7944762 --- /dev/null +++ b/src/config/credential/cipher.ts @@ -0,0 +1,39 @@ +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< + T3CredentialCipher, + { + readonly encrypt: ( + input: AesGcmEncryptInput, + ) => Effect.Effect; + readonly decrypt: ( + input: AesGcmDecryptInput, + ) => Effect.Effect; + } +>()("t3cli/T3CredentialCipher") {} 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/service.test.ts b/src/config/credential/service.test.ts new file mode 100644 index 0000000..a5f26c2 --- /dev/null +++ b/src/config/credential/service.test.ts @@ -0,0 +1,116 @@ +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 Path from "effect/Path"; +import { assert, describe, it } from "@effect/vitest"; + +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", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cryptoService = yield* Crypto.Crypto; + 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", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cryptoService = yield* Crypto.Crypto; + 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* 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://tampered.example", + local: false, + 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/credential/service.ts b/src/config/credential/service.ts new file mode 100644 index 0000000..ddbf140 --- /dev/null +++ b/src/config/credential/service.ts @@ -0,0 +1,205 @@ +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 { 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; + 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 textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const make = Effect.fn("makeT3CredentialCrypto")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + 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(Crypto.Crypto, cryptoService), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), + ); + + const fileKeystore = yield* Effect.provide(makeFileKeystore(), services); + 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 fileResult = yield* fileKeystore.read(); + if (fileResult.kind === "present") { + return fileResult.key; + } + return undefined; + }); + + const writeMasterKey = Effect.fn("writeMasterKey")(function* (key: Uint8Array) { + if (primaryKeystore !== undefined) { + yield* primaryKeystore.write(key).pipe( + Effect.catchTags({ + KeystoreUnavailableError: () => fileKeystore.write(key), + }), + ); + return; + } + yield* fileKeystore.write(key); + }); + + const getMasterKey = Effect.fn("getMasterKey")(function* () { + const existing = yield* readMasterKey(); + if (existing !== undefined) { + return existing; + } + 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: (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"); + const tag = yield* decodeBase64Field(input.token.tag, "token tag"); + const plaintext = yield* cipher + .decrypt({ + key: masterKey, + nonce, + ciphertext, + tag, + additionalData: buildCredentialAad(input), + }) + .pipe(Effect.mapError(mapCredentialCipherError)); + return decodeUtf8(plaintext); + }); + + return { encrypt, decrypt } satisfies T3CredentialCrypto["Service"]; +}); + +export const layer = Layer.effect(T3CredentialCrypto, make()); + +function buildCredentialAad(input: { + readonly environmentName: string; + readonly url: string; + readonly local: boolean; +}) { + return encodeUtf8( + `${configSchemaVersion}\0${input.environmentName}\0${input.url}\0${input.local}`, + ); +} + +function encodeUtf8(value: string) { + return textEncoder.encode(value); +} + +function decodeUtf8(value: Uint8Array) { + return textDecoder.decode(value); +} + +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 })), + }), + ); +} + +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/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/name.test.ts b/src/config/environment-name/name.test.ts new file mode 100644 index 0000000..4c4ceb7 --- /dev/null +++ b/src/config/environment-name/name.test.ts @@ -0,0 +1,66 @@ +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 { expectFailWithMessage } from "../../effect.test-utils.ts"; +import { ConfigError } from "../error.ts"; +import { + defaultEnvironmentNameForLocal, + defaultEnvironmentNameFromUrl, + migrateV1EnvironmentName, + slugifyEnvironmentName, + validateEnvironmentName, +} from "./name.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", () => + expectFailWithMessage(defaultEnvironmentNameFromUrl("not-a-url"), ConfigError, "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/environment-name/name.ts b/src/config/environment-name/name.ts new file mode 100644 index 0000000..13d4c57 --- /dev/null +++ b/src/config/environment-name/name.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; + +import { ConfigError } from "../error.ts"; +import { normalizeHttpBaseUrl } from "../url/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.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)); + }), + ); +} + +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/error.ts b/src/config/error.ts index 5a121aa..3c10b32 100644 --- a/src/config/error.ts +++ b/src/config/error.ts @@ -1,21 +1,19 @@ -import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; 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", { - 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 type ConfigServiceError = ConfigError | UrlError; +export type ConfigServiceError = ConfigError; + +export function isPlatformNotFoundError(error: PlatformError) { + return error.reason["_tag"] === "NotFound"; +} diff --git a/src/config/index.ts b/src/config/index.ts index 503e41a..56c0968 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,16 @@ -export { T3Config } from "./service.ts"; -export type { ResolvedConfig, StoredConfig } from "./service.ts"; -export { T3ConfigLive, makeT3Config } from "./layer.ts"; -export { ConfigError, UrlError } from "./error.ts"; -export type { ConfigServiceError } from "./error.ts"; +export * as Config from "./config.ts"; +export type { + EncryptedConfig, + EncryptedToken, + EnvironmentSummary, + ResolvedConfig, + UpsertEnvironmentInput, +} from "./types.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/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 new file mode 100644 index 0000000..49b784f --- /dev/null +++ b/src/config/keystore/keyring-node.ts @@ -0,0 +1,100 @@ +import * as Cause from "effect/Cause"; +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 { KeystoreUnavailableError } from "./error.ts"; +import { + masterKeyByteLength, + T3MasterKeyKeystoreFactory, + type MasterKeyKeystore, + type MasterKeyReadResult, +} from "./service.ts"; + +type KeyringModule = typeof import("@napi-rs/keyring"); + +const keyringService = "t3cli"; +const keyringAccount = "master-key"; + +const loadKeyringModuleOnce = Effect.tryPromise( + () => import("@napi-rs/keyring") as Promise, +); + +const loadKeyringModuleMemoized = Effect.cached(loadKeyringModuleOnce); + +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) { + 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.sync(() => { + try { + return parseKeyringPassword(entry.getPassword()); + } catch { + return { + kind: "unavailable" as const, + message: "failed to read credential key from OS keyring", + }; + } + }), + write: (key) => + Effect.try({ + try: () => { + entry.setPassword(Encoding.encodeBase64(key)); + }, + catch: (cause) => + new KeystoreUnavailableError({ + reason: "backend-unavailable", + cause: new Cause.UnknownError(cause), + }), + }), + }; +} + +const make = (): T3MasterKeyKeystoreFactory["Service"] => ({ + make: () => + Effect.gen(function* () { + const keyring = yield* loadKeyringModule; + return createKeyringKeystore(keyring); + }), +}); + +export const layerNode = Layer.succeed(T3MasterKeyKeystoreFactory, make()); diff --git a/src/config/keystore/service.test-utils.ts b/src/config/keystore/service.test-utils.ts new file mode 100644 index 0000000..e32767c --- /dev/null +++ b/src/config/keystore/service.test-utils.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 "./service.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/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/service.ts b/src/config/keystore/service.ts new file mode 100644 index 0000000..ce22a04 --- /dev/null +++ b/src/config/keystore/service.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ConfigError } from "../error.ts"; +import type { 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 } + | { readonly kind: "unavailable"; 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" || result.kind === "unavailable"; +} 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 new file mode 100644 index 0000000..5373564 --- /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 "./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( + "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/layer.ts b/src/config/layer.ts deleted file mode 100644 index 34be489..0000000 --- a/src/config/layer.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 { 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 }), - ), - ); - if (raw === undefined) { - return {}; - } - return yield* parseStoredConfig(raw); - }); - 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 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) { - return yield* Effect.fail( - new ConfigError({ message: "not authenticated. run: t3cli auth pair " }), - ); - } - const source: "env" | "config" = - (envUrlValue !== undefined && envUrlValue.length > 0) || - (envTokenValue !== undefined && envTokenValue.length > 0) - ? "env" - : "config"; - const normalizedUrl = yield* normalizeHttpBaseUrl(url); - return { - url: normalizedUrl, - token, - source, - local: stored.local ?? false, - }; - }); - - return { - readStored, - writeStored, - resolve, - }; -}); - -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"); -} 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/paths.ts b/src/config/paths/paths.ts new file mode 100644 index 0000000..8e2555b --- /dev/null +++ b/src/config/paths/paths.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { loadT3CliEnv } from "../env/env.ts"; +import { ConfigError } from "../error.ts"; + +export const resolveT3cliConfigDir = Effect.fn("resolveT3cliConfigDir")(function* () { + const path = yield* Path.Path; + 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"); +}); + +export const resolveConfigFilePath = Effect.fn("resolveConfigFilePath")(function* () { + const path = yield* Path.Path; + const configDir = yield* resolveT3cliConfigDir(); + return path.join(configDir, "config.json"); +}); + +export const resolveKeyFilePath = Effect.fn("resolveKeyFilePath")(function* () { + const path = yield* Path.Path; + const configDir = yield* resolveT3cliConfigDir(); + return path.join(configDir, "key"); +}); diff --git a/src/config/persist/file-mode.ts b/src/config/persist/file-mode.ts new file mode 100644 index 0000000..e754942 --- /dev/null +++ b/src/config/persist/file-mode.ts @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { ConfigError } from "../error.ts"; + +const privateFileMode = 0o600; + +export const hardenPrivateFileMode = Effect.fn("hardenPrivateFileMode")(function* ( + filePath: string, + 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 }), + ), + ); +}); diff --git a/src/config/persist/migration.test.ts b/src/config/persist/migration.test.ts new file mode 100644 index 0000000..84142ec --- /dev/null +++ b/src/config/persist/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/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"; + +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/migration.ts b/src/config/persist/migration.ts new file mode 100644 index 0000000..f991e81 --- /dev/null +++ b/src/config/persist/migration.ts @@ -0,0 +1,82 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +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/url.ts"; + +export const emptyEncryptedConfig = (): EncryptedConfig => ({ + version: 2, + environments: {}, +}); + +export const readEncryptedConfigFromValue = Effect.fn("readEncryptedConfigFromValue")(function* ( + value: unknown, +) { + return yield* 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(v1); + return { config: migrated, migratedFromV1: true as const }; + }).pipe( + Effect.catchTags({ + SchemaError: (error) => + Effect.fail(new ConfigError({ message: "failed to read config", cause: error })), + }), + ); +}); + +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 name = yield* migrateV1EnvironmentName(config).pipe( + Effect.flatMap((migratedName) => + validateEnvironmentName(migratedName).pipe(Effect.as(migratedName)), + ), + ); + const normalizedUrl = yield* normalizeHttpBaseUrl(config.url).pipe( + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), + ); + const crypto = yield* T3CredentialCrypto; + const token = yield* crypto.encrypt({ + 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/persist.test.ts b/src/config/persist/persist.test.ts new file mode 100644 index 0000000..c4b34e1 --- /dev/null +++ b/src/config/persist/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 "../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"; + +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/persist/persist.ts b/src/config/persist/persist.ts new file mode 100644 index 0000000..346f417 --- /dev/null +++ b/src/config/persist/persist.ts @@ -0,0 +1,65 @@ +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 { ConfigError, isPlatformNotFoundError } from "../error.ts"; +import { hardenPrivateFileMode } from "./file-mode.ts"; +import { emptyEncryptedConfig, readEncryptedConfigFromValue } from "./migration.ts"; +import { resolveConfigFilePath } from "../paths/paths.ts"; +import { UnknownConfigFileJson, StoredConfigV2FileJson } from "./schema.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({ + 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 const writeEncryptedConfigFile = Effect.fn("writeEncryptedConfigFile")(function* ( + config: EncryptedConfig, +) { + 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"); +}); diff --git a/src/config/persist/schema.ts b/src/config/persist/schema.ts new file mode 100644 index 0000000..1de64c9 --- /dev/null +++ b/src/config/persist/schema.ts @@ -0,0 +1,37 @@ +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; + +export const StoredConfigV1FileJson = Schema.fromJsonString(StoredConfigV1FileSchema); +export const StoredConfigV2FileJson = Schema.fromJsonString(StoredConfigV2FileSchema); +export const UnknownConfigFileJson = Schema.UnknownFromJsonString; diff --git a/src/config/platform.test-utils.ts b/src/config/platform.test-utils.ts new file mode 100644 index 0000000..f92dbfb --- /dev/null +++ b/src/config/platform.test-utils.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, +); diff --git a/src/config/resolve/resolve.test-utils.ts b/src/config/resolve/resolve.test-utils.ts new file mode 100644 index 0000000..c94c9c3 --- /dev/null +++ b/src/config/resolve/resolve.test-utils.ts @@ -0,0 +1,20 @@ +import type { EncryptedConfig } from "../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/config/resolve/resolve.test.ts b/src/config/resolve/resolve.test.ts new file mode 100644 index 0000000..8dc351e --- /dev/null +++ b/src/config/resolve/resolve.test.ts @@ -0,0 +1,137 @@ +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 { resolveConfiguredEnvironment } from "../selection/resolve.ts"; +import { sampleEncrypted, sampleEncryptedToken } from "./resolve.test-utils.ts"; +import { + buildResolvedConfigFromEnv, + resolveDefaultForUpsert, + selectEnvironmentName, + validateCredentialEnvVars, +} from "./resolve.ts"; + +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: sampleEncryptedToken(), + }, + }, + }), + "work", + ), + "home", + ); + }); + + it("promotes default when makeDefault is true", () => { + assert.equal( + resolveDefaultForUpsert( + sampleEncrypted({ + default: "home", + environments: { + home: { + url: "https://home.example", + local: false, + token: sampleEncryptedToken(), + }, + work: { + url: "https://work.example", + local: false, + token: { ...sampleEncryptedToken(), nonce: "n2", ciphertext: "c2", tag: "t2" }, + }, + }, + }), + "work", + true, + ), + "work", + ); + }); +}); diff --git a/src/config/resolve/resolve.ts b/src/config/resolve/resolve.ts new file mode 100644 index 0000000..abd3d50 --- /dev/null +++ b/src/config/resolve/resolve.ts @@ -0,0 +1,104 @@ +import * as Effect from "effect/Effect"; + +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; + 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.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), + 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).pipe( + Effect.mapError((error) => new ConfigError({ message: error.message, cause: error.cause })), + ); + return { + url: normalizedUrl, + token: input.token, + source: "config" as const, + local: selectedEnvironment.local, + environment: input.selectedName, + } satisfies ResolvedConfig; + }); +} 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 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/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 3fc0ded..0000000 --- a/src/config/service.ts +++ /dev/null @@ -1,26 +0,0 @@ -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; -}; - -export class T3Config extends Context.Service< - T3Config, - { - readonly readStored: () => Effect.Effect; - readonly writeStored: (config: StoredConfig) => Effect.Effect; - readonly resolve: () => Effect.Effect; - } ->()("t3cli/T3Config") {} diff --git a/src/config/temp-home.test-utils.ts b/src/config/temp-home.test-utils.ts new file mode 100644 index 0000000..7414960 --- /dev/null +++ b/src/config/temp-home.test-utils.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/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..8411d99 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,49 @@ +import type { + EncryptedToken, + StoredConfigV2File, + StoredEnvironmentFile, +} from "./persist/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/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/url.test.ts b/src/config/url/url.test.ts new file mode 100644 index 0000000..c86f8c4 --- /dev/null +++ b/src/config/url/url.test.ts @@ -0,0 +1,31 @@ +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"; + +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", () => + expectFailWithMessage(normalizeHttpBaseUrl("not-a-url"), UrlError, "invalid url"), + ); +}); + +describe("toWebSocketEndpointUrl", () => { + it.effect("rejects unsupported protocols with UrlError", () => + 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 637a794..0000000 --- a/src/environment/service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Context from "effect/Context"; - -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", -) {} 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 76eb899..50c5ad6 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,6 +1,8 @@ export { AppLayer, AuthAppLayer, + BaseAppLayer, + BaseAuthAppLayer, T3AuthLayer, T3AuthPairingLayer, T3AuthTransportLayer, @@ -9,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 new file mode 100644 index 0000000..0ceffd8 --- /dev/null +++ b/src/runtime/layer.test.ts @@ -0,0 +1,66 @@ +import "vite-plus/test/config"; + +import * as Effect from "effect/Effect"; +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 { cliEnvironmentSetting } from "../cli/env/flag.ts"; +import type { ResolvedConfig } from "../config/types.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( + "routes --environment through Command.run to T3Config.resolve ahead of default and T3CLI_ENV", + () => + Effect.gen(function* () { + return yield* makeTempHomeScoped("t3cli-runtime-"); + }).pipe( + 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* () { + 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" }); + + 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(testLayer)); + + yield* runResolveProbe(["--environment", "work"]).pipe(Effect.provide(testLayer)); + + 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, + ), + ); +}); diff --git a/src/runtime/layer.ts b/src/runtime/layer.ts index 33ace43..056f8f3 100644 --- a/src/runtime/layer.ts +++ b/src/runtime/layer.ts @@ -10,8 +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 { T3ConfigLive } from "../config/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"; @@ -20,6 +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 = Config.layer.pipe(Layer.provide(Credential.layer)); export const T3AuthTransportLayer = T3AuthTransportLive.pipe( Layer.provide(NodeHttpClient.layerUndici), ); @@ -33,13 +35,13 @@ 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( T3CodeConnectionProvider, Effect.gen(function* () { - const config = yield* T3Config; + const config = yield* Config.T3Config; return makeT3CodeConnectionProvider( config.resolve().pipe( Effect.map((resolved) => ({ @@ -56,7 +58,7 @@ const T3ConfigConnectionProviderLayer = Layer.effect( ), ); }), -).pipe(Layer.provide(T3ConfigLive)); +).pipe(Layer.provide(T3ConfigLayer)); const T3RpcLayer = T3RpcLive.pipe( Layer.provide( @@ -73,10 +75,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 +84,9 @@ export const AppLayer = Layer.mergeAll( T3ApplicationLayer, NodeCliPathLayer, ); + +export const BaseAuthAppLayer = Layer.mergeAll(T3ConfigLayer, T3AuthLayer); + +export const AuthAppLayer = BaseAuthAppLayer.pipe(Layer.provideMerge(Selection.layer)); + +export const AppLayer = BaseAppLayer.pipe(Layer.provideMerge(Selection.layer)); 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 11f877c..676b74a 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"; @@ -7,6 +8,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: { @@ -34,16 +42,14 @@ 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", }, deps: { - alwaysBundle: /^.+$/, + alwaysBundle: shouldBundlePackDependency, onlyBundle: false, }, dts: false,