From 4f218dfc87b000a1bdbee819a10fe3995ff439ba Mon Sep 17 00:00:00 2001 From: Keegan Donley Date: Thu, 22 Oct 2026 10:56:23 -0500 Subject: [PATCH 1/4] KZN-19166 - Support calling the admin CLI with current credentials --- README.md | 57 ++- src/__snapshots__/program.test.ts.snap | 2 + src/commands/admin.ts | 84 ++++ src/commands/auth.ts | 7 + src/lib/kizenAdmin.test.ts | 494 +++++++++++++++++++++ src/lib/kizenAdmin.ts | 371 ++++++++++++++++ src/lib/kizenVenv.test.ts | 566 +++++++++++++++++++++++++ src/lib/kizenVenv.ts | 488 +++++++++++++++++++++ src/lib/oauthCommands.test.ts | 120 ++++++ src/lib/oauthCommands.ts | 74 +++- src/lib/pythonProbe.test.ts | 133 ++++++ src/lib/pythonProbe.ts | 131 ++++++ src/program.test.ts | 200 ++++++++- src/program.ts | 3 + src/server/pythonExecutor.ts | 88 +--- 15 files changed, 2714 insertions(+), 104 deletions(-) create mode 100644 src/commands/admin.ts create mode 100644 src/lib/kizenAdmin.test.ts create mode 100644 src/lib/kizenAdmin.ts create mode 100644 src/lib/kizenVenv.test.ts create mode 100644 src/lib/kizenVenv.ts create mode 100644 src/lib/pythonProbe.test.ts create mode 100644 src/lib/pythonProbe.ts diff --git a/README.md b/README.md index cc9922a..3fc1e86 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ A local development environment for [Kizen](https://www.kizen.com) plugin apps. ## Requirements -| Requirement | Version | Why | -| ------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Node.js | `>=20` | Enforced by `engines` in `package.json`. | -| Google Chrome or Chromium | any recent release | The viewer is launched via [`chrome-launcher`](https://github.com/GoogleChrome/chrome-launcher) against a locally installed browser. No browser is bundled. | -| Python | 3.12 or 3.13 | Only needed to execute code steps locally. The dev server builds a virtualenv with the interpreter the step's runtime asks for (`python-3-12` / `python-3-13`), matching the runtime images the hosted Kizen code-runner ships. | +| Requirement | Version | Why | +| ------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Node.js | `>=20` | Enforced by `engines` in `package.json`. | +| Google Chrome or Chromium | any recent release | The viewer is launched via [`chrome-launcher`](https://github.com/GoogleChrome/chrome-launcher) against a locally installed browser. No browser is bundled. | +| Python | 3.12 or newer | Only needed to run code steps locally and for `appbuilder admin`. For code steps, the dev server builds a virtualenv with the interpreter the step's runtime asks for (`python-3-12` / `python-3-13`), matching the runtime images the hosted Kizen code-runner ships. `appbuilder admin` uses the newest Python 3.12 or newer it finds. | `chrome-launcher` auto-discovers an installed Chrome/Chromium; if yours lives somewhere unusual, set `CHROME_PATH` to the executable and it will be preferred. -Python is resolved lazily — the CLI only looks for an interpreter the first time a plugin actually runs a code step, so you can build UI-only plugins without it. +Python is resolved lazily — the CLI only looks for an interpreter the first time a plugin actually runs a code step, or the first time you run `appbuilder admin`, so you can build UI-only plugins without it. ## Installation @@ -384,7 +384,7 @@ Lists where a block can be pushed: the business's dashboards and homepages plus The CLI honors `HTTP_PROXY`/`HTTPS_PROXY` and `NO_PROXY` on Node versions that support it (24.14+, or with `NODE_USE_ENV_PROXY=1` set). -`customObjects` is sorted by name and ends with Contacts (`"fetchUrl": "client"`) when the CLI can look up the business's contacts object; Kizen doesn't list Contacts with the other custom objects, so if that lookup fails the row is silently left out. `--object ` lists Contacts' chart groups like any other object's. +`customObjects` is sorted by name and ends with Contacts (`"fetchUrl": "client"`) when the CLI can look up the business's contacts object; Kizen doesn't list Contacts with the other custom objects, so if that lookup fails, Contacts isn't included. `--object ` lists Contacts' chart groups like any other object's. | Flag | Default | Purpose | | -------------------------- | ------------------- | ------------------------------------------------------------- | @@ -457,7 +457,7 @@ appbuilder login [--env ] [--business [--user ]] appbuilder login [--env ] --list [--json] appbuilder whoami [--env ] appbuilder logout [--env ] [--all | --business [--user ]] -appbuilder auth-header [--env ] +appbuilder auth-header [--env ] [--business --user ] appbuilder block push [api_name] [--env ] [flags] appbuilder block targets [--env ] [flags] ``` @@ -483,16 +483,16 @@ When both stdin and stdout are terminals and you pass neither `--business` nor ` `logout` removes the current account's session and leaves the other saved accounts. `logout --business ` removes one saved account, matched the same way as for `login`; if it's the current account, you're signed out too. `logout --all` removes every saved account for the environment. When no environment has a current account, both `--business` and `--all` need `--env`. -| Command | `--env` default | What it does | -| ------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `login` | `go` | Signs in with the browser, or switches to a saved account, and stores the session | -| `whoami` | the only environment you're signed in to | Calls `/auth/bootstrap` with the session and prints the user and business. Tokens are never printed. | -| `logout` | the only environment you're signed in to | Deletes the current account's session, one saved account (`--business`), or all of them (`--all`). Local only: the refresh tokens stay valid on Kizen. | -| `auth-header` | from the MCP server URL ([see below](#using-the-session-from-claude-code-mcp)) | Prints `{"Authorization":"Bearer "}` for a Claude Code MCP `headersHelper` | +| Command | `--env` default | What it does | +| ------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `login` | `go` | Signs in with the browser, or switches to a saved account, and stores the session | +| `whoami` | the only environment you're signed in to | Calls `/auth/bootstrap` with the session and prints the user and business. Tokens are never printed. | +| `logout` | the only environment you're signed in to | Deletes the current account's session, one saved account (`--business`), or all of them (`--all`). Local only: the refresh tokens stay valid on Kizen. | +| `auth-header` | from the MCP server URL ([see below](#using-the-session-from-claude-code-mcp)) | Prints `{"Authorization":"Bearer "}` for a Claude Code MCP `headersHelper`. With `--business` and `--user` (used together), it serves only that saved account, and fails while another account is current; [`appbuilder admin`](#appbuilder-admin) passes them. | The production write gate (`--allow-production`) and every other flag of `block push` and `block targets` work the same with a session as with an API key. -Sessions are stored in `~/.kizenappbuilder/oauth/` (directory `0700`, files `0600`), separate from API-key profiles. Each saved account has its own file, `...json`, and `.json` names the current account. Access tokens last an hour and refresh automatically: when less than a minute is left, or once after a `401`. Refresh tokens rotate on each use. Each process makes one refresh at a time, and every file is written atomically. Concurrent `appbuilder` processes may each refresh, which is safe because Kizen returns the same token pair when a refresh token is reused within 5 minutes. Before saving, a refresh re-reads the session: it keeps the newer tokens if another process already refreshed or you signed in again to the same account, and it fails with a sign-in error if you signed out or switched to another account meanwhile. If the refresh token is rejected, the CLI keeps the tokens another process already refreshed to; otherwise it deletes that account's file, and the command asks you to run `appbuilder login` again (with `--env ` for an environment other than `go`). A refresh only ever writes or deletes its own account's file, and only `login` and `logout` change which account is current, so a refresh that finishes late can't undo a logout or a switch. One that finishes just after you sign out can leave that account saved, though not current; `logout --all` removes it. +Sessions are stored in `~/.kizenappbuilder/oauth/` (directory `0700`, files `0600`), separate from API-key profiles. Each saved account has its own file, `...json`, and `.json` names the current account. Access tokens last an hour and refresh automatically: when less than a minute is left, or once after a `401`. Refresh tokens rotate on each use. Each process makes one refresh at a time, and every file is written atomically. Concurrent `appbuilder` processes may each refresh, which is safe because Kizen returns the same token pair when a refresh token is reused within 5 minutes. Before saving, a refresh re-reads the session: it keeps the newer tokens if another process already refreshed or you signed in again to the same account, and it fails with a sign-in error if you signed out or switched to another account meanwhile. If the refresh token is rejected, the CLI keeps the tokens another process already refreshed to; otherwise it deletes that account's file, and the command asks you to run `appbuilder login` again (with `--env ` for an environment other than `go`). A refresh only ever writes or deletes its own account's file, and only `login` and `logout` change which account is current, so a refresh that finishes late can't undo a logout or a switch. A session file that is corrupt or can't be read isn't treated as "not signed in". Commands that use it report the file's path and ask you to run `appbuilder login` (with `--env ` for an environment other than `go`). `appbuilder logout --env ` signs you out either way. If the session file is the problem, logout removes it. If `.json` is the problem, logout removes that and leaves the saved accounts, and `logout --all` removes those too. `login --list` still lists a saved account whose file can't be read, by its ids. @@ -529,7 +529,24 @@ Claude Code gives the helper 10 seconds, so `auth-header` uses shorter timeouts `auth-header` prints a live access token by design: treat its output like a password. The token acts as you, in the one business you chose when signing in, until it expires (up to an hour). -Known limitation: the CLI only refreshes a token when less than a minute is left, so if Kizen revokes a token with more time left, `auth-header` keeps returning it until it expires or you run `appbuilder login` again. +### `appbuilder admin` + +Runs Kizen's admin CLI, `kizen`, signed in with your `appbuilder login` session. You don't install `kizen` yourself: you only need Python 3.12 or newer on `PATH`. + +```sh +appbuilder admin [--env ] [--] +appbuilder admin init [--force] +``` + +`--env` picks the session, and defaults to the only environment you're signed in to. The wrapper's own options (`--env`, `-h`/`--help`) come first and end at the first other argument or at `--`. Everything after that goes to `kizen` verbatim, so `appbuilder admin status --help` shows `kizen`'s help, and `appbuilder admin --help` shows this wrapper's. `init` is the wrapper's own subcommand only as the first argument; `appbuilder admin -- init` passes `init` to `kizen`. + +On first use, `appbuilder admin` installs `kizen` into a private virtual environment under `~/.kizenappbuilder/kizen/-/` and reuses it afterwards without touching the network. `appbuilder admin init` does that ahead of time, and needs no session. It uses the newest of `python3.14`, `python3.13`, `python3.12` and `python3` that is 3.12 or newer, downloads the `kizen` release this CLI version pins (currently 0.3.0) from its GitHub release, checks its sha256, and installs it and its dependencies from PyPI with `pip`. Progress goes to stderr; stdout is `kizen`'s. Each time it makes a new install, it removes installs for other `kizen` versions that have gone unused for 30 days. + +If the install is damaged (for example, the Python it was built with was removed), the command says so and changes nothing. `appbuilder admin init --force` deletes the install and installs it again. Don't run it while `appbuilder admin` is running, since a running `kizen` uses that install. + +Under app-builder, `kizen` gets your session through `KIZEN_AUTH_HELPER`: it names `appbuilder auth-header --env --business --user `, which `kizen` runs when it needs a token, and `KIZEN_BASE_URL`, `KIZEN_BUSINESS_ID` and `KIZEN_USER_ID` identify the session. With `--business` and `--user`, `auth-header` serves only that account, and only while it's the environment's current one. `kizen` keeps the token for the life of its process and runs the helper again only after a `401`. So if you switch accounts with `appbuilder login` while `kizen` runs, its next token fetch (at startup, or after a `401`) fails, and you run the command again. A token it already holds is still its own account's, and stays in use until it expires. The token never appears in `kizen`'s environment or arguments. `KIZEN_PROFILE` and `CLAUDE_CODE_MCP_SERVER_URL` are removed from its environment. Under app-builder, `kizen` refuses `init`, `envs list` and `upgrade` (`upgrade --check` reports that app-builder manages the install). The command exits with `kizen`'s exit code. + +To try a local `kizen` build, set `APPBUILDER_KIZEN_WHEEL` to the path of a `kizen_builder--….whl`. It's installed instead of the pinned release, with no download or hash check. ## Reference @@ -546,6 +563,8 @@ Prod follows the same order minus step 3: `PLUGIN_WIZARD_URL`, then **`PLUGIN_WI **`CLAUDE_CODE_MCP_SERVER_URL`** is set by Claude Code when it runs `appbuilder auth-header`, which uses it to pick the environment (see [Using the session from Claude Code](#using-the-session-from-claude-code-mcp)). +**`APPBUILDER_KIZEN_WHEEL`** is the path of a local `kizen_builder--….whl` for `appbuilder admin` to install instead of the `kizen` release this CLI version pins, with no download or hash check (see [`appbuilder admin`](#appbuilder-admin)). + ### The `.kizenapp/` directory `build` and `dev` create `.kizenapp/` next to your `kizen.json` and add it to `.gitignore` automatically. It holds machine-local state only — **keep it gitignored**: @@ -577,11 +596,11 @@ How the two navigation targets behave: - **`_blank`** (new-tab, relative, same origin) — the engine stores the context, opens the tab, then immediately deletes its own copy, relying on a real browser having already copied `sessionStorage` into the new tab. - **External / cross-origin** URLs — context is never attached and is dropped by design; these appear in the log as `ignored (external)`. -**Fidelity limit:** a real `_blank` open gives the new tab its own `sessionStorage` copy. The sandbox has no real second tab, so the "opener" and the simulated destination share one `sessionStorage`; to keep the engine's reader helpers working, the harness snapshots the payload and re-inserts it under the same key immediately after the engine deletes it. Behavior matches a real browser for reading/consuming, but the two "tabs" are not truly isolated. +**`sessionStorage` in the sandbox:** the "opener" and the simulated destination share one `sessionStorage`. To keep the engine's reader helpers working, the harness snapshots the payload and re-inserts it under the same key immediately after the engine deletes it. Reading and consuming the payload behave as they do in a browser. -**Scope boundary:** navigating to a path that matches one of your plugin's own routable pages activates that page's tab without carrying the URL through, so the page cannot observe its own `session_data_key` via the simulated location. Use the simulated destination page (any non-routable in-app path) to inspect what a destination receives. +**Your plugin's own pages:** navigating to a path that matches one of your plugin's routable pages activates that page's tab without carrying the URL through, so the page doesn't receive its `session_data_key`. To inspect what a destination receives, use the simulated destination page (any non-routable in-app path). -**Error surfacing:** if `sessionStorage` writes fail (e.g. quota exceeded, storage disabled), the engine navigates without context and reports a message through the same `onError` path every script artifact already uses — it shows in that artifact's result UI and the DevTools console, not as a separate log entry, because on failure the URL carries no key for the harness to detect. Note also that context is serialized with `JSON.stringify` inside the worker script: circular references and `BigInt` values throw there before any navigation happens, while functions, `undefined` values, and symbols are silently dropped. +**Error surfacing:** if `sessionStorage` writes fail (e.g. quota exceeded, storage disabled), the engine navigates without context and reports a message through the same `onError` path every script artifact already uses — it shows in that artifact's result UI and the DevTools console, not as a separate log entry, because on failure the URL carries no key for the harness to detect. Context is serialized with `JSON.stringify` inside the worker script: circular references and `BigInt` values throw there before any navigation happens, while functions, `undefined` values, and symbols are omitted. ## Contributing diff --git a/src/__snapshots__/program.test.ts.snap b/src/__snapshots__/program.test.ts.snap index a24aae2..192f0bf 100644 --- a/src/__snapshots__/program.test.ts.snap +++ b/src/__snapshots__/program.test.ts.snap @@ -29,6 +29,8 @@ Commands: whoami [options] Show the user and business of the stored session auth-header [options] Print an Authorization header for the stored session as JSON (for a Claude Code MCP headersHelper) + admin [options] Run Kizen's admin CLI (kizen) with the signed-in + session help [command] display help for command " `; diff --git a/src/commands/admin.ts b/src/commands/admin.ts new file mode 100644 index 0000000..7a3d4c6 --- /dev/null +++ b/src/commands/admin.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import { Command, type ParseOptionsResult } from 'commander'; +import { ADMIN_PREFIX, runAdmin, type AdminDeps } from '../lib/kizenAdmin.js'; +import { createDefaultKizenVenvDeps, ensureKizen } from '../lib/kizenVenv.js'; +import { createOAuthStore } from '../lib/oauthStore.js'; + +const ADMIN = 'admin'; + +const INIT_HELP = `Usage: appbuilder admin init [--force] + +Install kizen for appbuilder admin, or confirm it's installed. Needs Python 3.12 or newer. + +Options: + --force remove kizen's install and install it again, to repair a damaged one; don't run it + while appbuilder admin is running +`; + +/** + * `admin` takes its arguments raw: commander parses none of them, so kizen's own `--help`, + * `--version` and `--` reach it untouched. `runAdmin` reads the wrapper's options itself. + */ +class RawArgsCommand extends Command { + override parseOptions(argv: string[]): ParseOptionsResult { + return { operands: [...argv], unknown: [] }; + } +} + +export function createDefaultAdminDeps(command: Command): AdminDeps { + const writeStderr = (text: string): void => { + process.stderr.write(text); + }; + + const venvDeps = createDefaultKizenVenvDeps((line) => { + writeStderr(`${ADMIN_PREFIX}: ${line}\n`); + }, writeStderr); + + return { + store: createOAuthStore(), + ensureKizen: (options) => ensureKizen(venvDeps, options), + spawn, + signals: process, + processEnv: process.env, + execPath: process.execPath, + scriptPath: process.argv[1], + outputHelp: () => { + command.outputHelp(); + }, + outputInitHelp: () => { + process.stdout.write(INIT_HELP); + }, + writeStderr, + setExitCode: (code) => { + process.exitCode = code; + }, + }; +} + +export function adminCommand(program: Command): void { + const admin = new RawArgsCommand(ADMIN) + .copyInheritedSettings(program) + .description("Run Kizen's admin CLI (kizen) with the signed-in session") + .usage('[--env ] [--] \n appbuilder admin init [--force]') + .option('--env ', 'environment to use (default: the only signed-in one)') + .allowExcessArguments() + .addHelpText( + 'after', + ` +Wrapper options end at the first other argument or at --; everything after goes to kizen. +"appbuilder admin init" installs kizen ahead of time; it's otherwise installed on first use.`, + ) + .action(async () => { + await runAdmin(admin.args, createDefaultAdminDeps(admin)); + }); + + program.addCommand(admin); + + // Without positional options, the root parses its own options (`-V`/`--version`) and drops `--` + // anywhere in argv, subcommand arguments included. Hand everything after a leading `admin` to it + // untouched instead; every other command parses as before. + const parseOptions = program.parseOptions.bind(program); + + program.parseOptions = (argv: string[]): ParseOptionsResult => + argv[0] === ADMIN ? { operands: [ADMIN], unknown: argv.slice(1) } : parseOptions(argv); +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 8189b0d..4b6b55a 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -62,6 +62,13 @@ const AUTH_COMMANDS: AuthCommandSpec[] = [ description: 'Print an Authorization header for the stored session as JSON (for a Claude Code MCP headersHelper)', env: 'environment to use; must match CLAUDE_CODE_MCP_SERVER_URL when that is set (default: the environment matching it, else the only signed-in one)', + options: [ + [ + '--business ', + "with --user, use only this saved account, and fail if it isn't the environment's current one", + ], + ['--user ', 'with --business, the user id of that account'], + ], run: runAuthHeader, }, ]; diff --git a/src/lib/kizenAdmin.test.ts b/src/lib/kizenAdmin.test.ts new file mode 100644 index 0000000..3e41739 --- /dev/null +++ b/src/lib/kizenAdmin.test.ts @@ -0,0 +1,494 @@ +import { execFileSync } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi, type Mock } from 'vitest'; +import type { Environment } from './credentials.js'; +import { + kizenAuthHelper, + kizenChildEnv, + parseAdminArgs, + runAdmin, + type AdminDeps, + type SpawnKizen, +} from './kizenAdmin.js'; +import type { KizenInstall } from './kizenVenv.js'; +import type { OAuthSession } from './oauthStore.js'; + +const BUSINESS_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const ACCESS_TOKEN = 'access-token-secret'; +const REFRESH_TOKEN = 'refresh-token-secret'; + +const session = (environment: Environment): OAuthSession => ({ + environment, + businessId: BUSINESS_ID, + userId: USER_ID, + businessName: 'Example', + fullName: undefined, + email: undefined, + issuer: 'https://staging.kizen.com', + clientId: 'client', + tokenEndpoint: 'https://staging.kizen.com/oauth/token', + accessToken: ACCESS_TOKEN, + refreshToken: REFRESH_TOKEN, + expiresAt: 0, +}); + +const INSTALL: KizenInstall = { + version: '1.2.3', + dir: '/venvs/1.2.3-abc', + python: '/venvs/1.2.3-abc/bin/python', + kizen: '/venvs/1.2.3-abc/bin/kizen', + built: false, +}; + +type FakeChild = EventEmitter & { kill: Mock<(signal: NodeJS.Signals) => boolean> }; + +interface FakeAdminDeps extends AdminDeps { + store: { + listSessions: Mock<() => Promise>; + readSession: Mock<(environment: Environment) => Promise>; + }; + ensureKizen: Mock; + spawn: Mock; + outputHelp: Mock<() => void>; + outputInitHelp: Mock<() => void>; + setExitCode: Mock<(code: number) => void>; +} + +interface Fake { + deps: FakeAdminDeps; + children: FakeChild[]; + stderr: string[]; + signals: EventEmitter; +} + +/** Deps whose child closes with `exit` (or never, when `exit` is 'hold'). */ +function fakeDeps( + options: { + sessions?: Environment[]; + signedIn?: boolean; + exit?: [code: number | null, signal: NodeJS.Signals | null] | 'hold' | 'spawn-error'; + processEnv?: Record; + } = {}, +): Fake { + const sessions = options.sessions ?? ['staging']; + const children: FakeChild[] = []; + const stderr: string[] = []; + const signals = new EventEmitter(); + const spawn = vi.fn(() => { + const child: FakeChild = Object.assign(new EventEmitter(), { + kill: vi.fn<(signal: NodeJS.Signals) => boolean>(() => true), + }); + + children.push(child); + + setImmediate(() => { + if (options.exit === 'spawn-error') { + child.emit('error', new Error('spawn /venvs/1.2.3-abc/bin/python ENOENT')); + child.emit('close', -2, null); + } else if (options.exit !== 'hold') { + const [code, signal] = options.exit ?? [0, null]; + + child.emit('close', code, signal); + } + }); + + return child as unknown as ChildProcess; + }); + const deps: FakeAdminDeps = { + store: { + listSessions: vi.fn(() => Promise.resolve(sessions)), + readSession: vi.fn((environment: Environment) => + Promise.resolve(options.signedIn === false ? null : session(environment)), + ), + }, + ensureKizen: vi.fn(() => Promise.resolve(INSTALL)), + spawn, + signals: { + on: (signal: NodeJS.Signals, listener: () => void) => signals.on(signal, listener), + off: (signal: NodeJS.Signals, listener: () => void) => signals.off(signal, listener), + }, + processEnv: options.processEnv ?? { PATH: '/usr/bin' }, + execPath: '/usr/local/bin/node', + scriptPath: '/opt/appbuilder/dist/index.js', + outputHelp: vi.fn(), + outputInitHelp: vi.fn(), + writeStderr: (text: string) => { + stderr.push(text); + }, + setExitCode: vi.fn(), + }; + + return { deps, children, stderr, signals }; +} + +const spawnCall = ( + deps: FakeAdminDeps, +): { command: string; args: string[]; options: { env: Record; stdio: string } } => { + const [command, args, options] = deps.spawn.mock.calls[0] as unknown as [ + string, + string[], + { env: Record; stdio: string }, + ]; + + return { command, args, options }; +}; + +describe('parseAdminArgs', () => { + it.each<[string[], ReturnType]>([ + [[], { kind: 'run', env: undefined, args: [] }], + [['--help'], { kind: 'help' }], + [['-h'], { kind: 'help' }], + [['--env', 'staging', '--help'], { kind: 'help' }], + [['--env', 'staging', 'status'], { kind: 'run', env: 'staging', args: ['status'] }], + [['--env=go', 'status'], { kind: 'run', env: 'go', args: ['status'] }], + // Wrapper options end at the first other token: everything after is kizen's. + [ + ['status', '--help', '--env', 'go', '--version', '--', '-h'], + { + kind: 'run', + env: undefined, + args: ['status', '--help', '--env', 'go', '--version', '--', '-h'], + }, + ], + [['--version'], { kind: 'run', env: undefined, args: ['--version'] }], + [['--dry-run', 'apply'], { kind: 'run', env: undefined, args: ['--dry-run', 'apply'] }], + // ... or at `--`, which is dropped; anything after it is kizen's, even `--` again. + [['--', '--help'], { kind: 'run', env: undefined, args: ['--help'] }], + [['--env', 'go', '--', '--', 'x'], { kind: 'run', env: 'go', args: ['--', 'x'] }], + [['init'], { kind: 'init', force: false }], + [['init', '--force'], { kind: 'init', force: true }], + [['--env', 'go', 'init'], { kind: 'init', force: false }], + // --force is init's, only after it. + [['--force', 'init'], { kind: 'run', env: undefined, args: ['--force', 'init'] }], + [['--', 'init', '--force'], { kind: 'run', env: undefined, args: ['init', '--force'] }], + [ + ['init', '--force', 'x'], + { kind: 'error', message: 'admin init takes only --force, but got: --force x' }, + ], + [['init', '--help'], { kind: 'init-help' }], + [['init', '-h'], { kind: 'init-help' }], + // (o) `-- init` is kizen's init, which kizen refuses under this mode. + [['--', 'init'], { kind: 'run', env: undefined, args: ['init'] }], + [['status', 'init'], { kind: 'run', env: undefined, args: ['status', 'init'] }], + [ + ['init', 'extra'], + { kind: 'error', message: 'admin init takes only --force, but got: extra' }, + ], + [ + ['init', '--help', 'x'], + { kind: 'error', message: 'admin init takes only --force, but got: --help x' }, + ], + [['--env'], { kind: 'error', message: "option '--env ' argument missing" }], + [['--env='], { kind: 'error', message: "option '--env ' argument missing" }], + ])('%j', (args, expected) => { + expect(parseAdminArgs(args)).toStrictEqual(expected); + }); +}); + +describe('(f) KIZEN_AUTH_HELPER', () => { + it("round-trips through Python's shlex.split, with spaces and quotes", () => { + const execPath = "/Users/o'brien/my node/bin/node"; + const scriptPath = '/opt/app "builder"/$HOME/dist/index.js'; + const helper = kizenAuthHelper(execPath, scriptPath, 'staging', { + businessId: BUSINESS_ID, + userId: USER_ID, + }); + const split = JSON.parse( + execFileSync( + 'python3', + ['-I', '-c', 'import shlex,sys,json; print(json.dumps(shlex.split(sys.argv[1])))', helper], + { encoding: 'utf-8' }, + ), + ) as unknown; + + expect(split).toStrictEqual([ + execPath, + scriptPath, + 'auth-header', + '--env', + 'staging', + '--business', + BUSINESS_ID, + '--user', + USER_ID, + ]); + }); + + it('leaves plain paths bare', () => { + expect( + kizenAuthHelper('/usr/bin/node', '/opt/cli/index.js', 'go', { + businessId: BUSINESS_ID, + userId: USER_ID, + }), + ).toBe( + `/usr/bin/node /opt/cli/index.js auth-header --env go --business ${BUSINESS_ID} --user ${USER_ID}`, + ); + }); +}); + +describe('kizenChildEnv', () => { + it('(j) strips KIZEN_PROFILE and CLAUDE_CODE_MCP_SERVER_URL and keeps the rest', () => { + expect( + kizenChildEnv( + { + PATH: '/usr/bin', + KIZEN_PROFILE: 'prod', + CLAUDE_CODE_MCP_SERVER_URL: 'https://app.go.kizen.com/api/mcp', + KIZEN_BASE_URL: 'https://elsewhere.example', + UNSET: undefined, + }, + { + KIZEN_AUTH_HELPER: 'helper', + KIZEN_BASE_URL: 'https://staging.kizen.com', + KIZEN_BUSINESS_ID: BUSINESS_ID, + KIZEN_USER_ID: USER_ID, + }, + ), + ).toStrictEqual({ + PATH: '/usr/bin', + KIZEN_AUTH_HELPER: 'helper', + KIZEN_BASE_URL: 'https://staging.kizen.com', + KIZEN_BUSINESS_ID: BUSINESS_ID, + KIZEN_USER_ID: USER_ID, + }); + }); +}); + +describe('runAdmin', () => { + it('runs /bin/python /bin/kizen with the session variables and inherited stdio', async () => { + const { deps } = fakeDeps({ + processEnv: { PATH: '/usr/bin', KIZEN_PROFILE: 'x', CLAUDE_CODE_MCP_SERVER_URL: 'y' }, + }); + + await runAdmin(['--env', 'staging', 'envs', 'show'], deps); + + const { command, args, options } = spawnCall(deps); + + expect(command).toBe(INSTALL.python); + expect(args).toStrictEqual([INSTALL.kizen, 'envs', 'show']); + expect(options.stdio).toBe('inherit'); + expect(options.env).toStrictEqual({ + PATH: '/usr/bin', + KIZEN_AUTH_HELPER: `/usr/local/bin/node /opt/appbuilder/dist/index.js auth-header --env staging --business ${BUSINESS_ID} --user ${USER_ID}`, + KIZEN_BASE_URL: 'https://staging.kizen.com', + KIZEN_BUSINESS_ID: BUSINESS_ID, + KIZEN_USER_ID: USER_ID, + }); + expect(deps.setExitCode).toHaveBeenCalledWith(0); + }); + + it('defaults to the only signed-in environment', async () => { + const { deps } = fakeDeps({ sessions: ['integration'] }); + + await runAdmin(['status'], deps); + + expect(spawnCall(deps).options.env.KIZEN_BASE_URL).toBe('https://integration.kizen.dev'); + expect(deps.store.readSession).toHaveBeenCalledWith('integration'); + }); + + it('(a) puts no token, Bearer or refresh token in the child env or argv', async () => { + const { deps } = fakeDeps({ processEnv: { PATH: '/usr/bin' } }); + + await runAdmin(['status'], deps); + + const serialized = JSON.stringify(deps.spawn.mock.calls); + + expect(serialized).not.toContain(ACCESS_TOKEN); + expect(serialized).not.toContain(REFRESH_TOKEN); + expect(serialized).not.toMatch(/bearer/i); + }); + + it.each<[string, Parameters[0], string[], string]>([ + [ + 'not signed in anywhere', + { sessions: [] }, + ['status'], + "appbuilder admin: You're not signed in. Run `appbuilder login` (add `--env ` to sign in to an environment other than go).\n", + ], + [ + 'signed in to several environments', + { sessions: ['go', 'staging'] }, + ['status'], + "appbuilder admin: You're signed in to several environments; pass --env : go, staging\n", + ], + [ + 'an unknown --env', + {}, + ['--env', 'nope', 'status'], + 'appbuilder admin: Unknown --env "nope". Use one of: go, fmo, staging, integration, test1.\n', + ], + [ + 'no session for the chosen --env', + { signedIn: false }, + ['--env', 'staging', 'status'], + "appbuilder admin: You're not signed in to staging. Run `appbuilder login --env staging` to sign in.\n", + ], + ])('(g) %s: no provisioning, no spawn, the hint, exit 1', async (_label, options, args, line) => { + const { deps, stderr } = fakeDeps(options); + + await runAdmin(args, deps); + + expect(deps.ensureKizen).not.toHaveBeenCalled(); + expect(deps.spawn).not.toHaveBeenCalled(); + expect(stderr).toStrictEqual([line]); + expect(deps.setExitCode).toHaveBeenCalledWith(1); + }); + + it('reports an unreadable session file without provisioning', async () => { + const { deps, stderr } = fakeDeps(); + + deps.store.readSession.mockRejectedValueOnce(new Error('The saved session can’t be read.')); + + await runAdmin(['status'], deps); + + expect(deps.ensureKizen).not.toHaveBeenCalled(); + expect(stderr).toStrictEqual(['appbuilder admin: The saved session can’t be read.\n']); + }); + + it('reports a provisioning failure without spawning', async () => { + const { deps, stderr } = fakeDeps(); + + deps.ensureKizen.mockRejectedValueOnce( + new Error('No Python 3.12 or newer found to run kizen.'), + ); + + await runAdmin(['status'], deps); + + expect(deps.spawn).not.toHaveBeenCalled(); + expect(stderr).toStrictEqual([ + 'appbuilder admin: No Python 3.12 or newer found to run kizen.\n', + ]); + expect(deps.setExitCode).toHaveBeenCalledWith(1); + }); + + it("(i) exits with kizen's exit code", async () => { + const { deps } = fakeDeps({ exit: [3, null] }); + + await runAdmin(['status'], deps); + + expect(deps.setExitCode).toHaveBeenCalledWith(3); + }); + + it('(i) exits with 128 + the signal number when a signal ended kizen', async () => { + const { deps } = fakeDeps({ exit: [null, 'SIGTERM'] }); + + await runAdmin(['status'], deps); + + expect(deps.setExitCode).toHaveBeenCalledWith(143); + }); + + it('(i) swallows SIGINT without forwarding it, and still exits with the child code', async () => { + const { deps, children, signals } = fakeDeps({ exit: 'hold' }); + const running = runAdmin(['status'], deps); + + await vi.waitFor(() => { + expect(children).toHaveLength(1); + }); + + // A listener is what keeps node alive on Ctrl-C; the terminal already sent kizen its own. + expect(signals.listenerCount('SIGINT')).toBe(1); + + signals.emit('SIGINT'); + + const [child] = children; + + expect(child?.kill).not.toHaveBeenCalled(); + + child?.emit('close', 130, null); + await running; + + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(deps.setExitCode).toHaveBeenCalledWith(130); + }); + + it('(i) forwards SIGTERM and SIGHUP to kizen, then removes its listeners', async () => { + const { deps, children, signals } = fakeDeps({ exit: 'hold' }); + const running = runAdmin(['status'], deps); + + await vi.waitFor(() => { + expect(children).toHaveLength(1); + }); + + signals.emit('SIGTERM'); + signals.emit('SIGHUP'); + + const [child] = children; + + expect(child?.kill.mock.calls).toStrictEqual([['SIGTERM'], ['SIGHUP']]); + + child?.emit('close', null, 'SIGTERM'); + await running; + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + expect(signals.listenerCount(signal)).toBe(0); + } + + expect(deps.setExitCode).toHaveBeenCalledWith(143); + }); + + it('(i) a spawn error prints one line, exits 1 and removes its listeners', async () => { + const { deps, stderr, signals } = fakeDeps({ exit: 'spawn-error' }); + + await runAdmin(['status'], deps); + + expect(stderr).toStrictEqual([ + "appbuilder admin: couldn't start kizen: spawn /venvs/1.2.3-abc/bin/python ENOENT\n", + ]); + expect(deps.setExitCode.mock.calls).toStrictEqual([[1]]); + expect(signals.listenerCount('SIGINT')).toBe(0); + }); + + it('passes --force to the provisioner only from admin init --force', async () => { + const { deps } = fakeDeps(); + + await runAdmin(['init', '--force'], deps); + await runAdmin(['init'], deps); + await runAdmin(['status'], deps); + + expect(deps.ensureKizen.mock.calls).toStrictEqual([[{ force: true }], [undefined], []]); + }); + + it('(m) init and the auto path share the provisioner; init needs no session', async () => { + const { deps, stderr } = fakeDeps({ sessions: [] }); + + await runAdmin(['init'], deps); + + expect(deps.ensureKizen).toHaveBeenCalledTimes(1); + expect(deps.store.listSessions).not.toHaveBeenCalled(); + expect(stderr).toStrictEqual(['appbuilder admin: kizen 1.2.3 is ready in /venvs/1.2.3-abc.\n']); + + const auto = fakeDeps(); + + auto.deps.ensureKizen = deps.ensureKizen; + + await runAdmin(['status'], auto.deps); + + expect(deps.ensureKizen).toHaveBeenCalledTimes(2); + expect(auto.deps.spawn).toHaveBeenCalledTimes(1); + }); + + it('shows help for --help and init --help without provisioning', async () => { + const { deps } = fakeDeps(); + + await runAdmin(['--help'], deps); + await runAdmin(['init', '--help'], deps); + + expect(deps.outputHelp).toHaveBeenCalledTimes(1); + expect(deps.outputInitHelp).toHaveBeenCalledTimes(1); + expect(deps.ensureKizen).not.toHaveBeenCalled(); + }); + + it('rejects init with extra arguments', async () => { + const { deps, stderr } = fakeDeps(); + + await runAdmin(['init', 'now'], deps); + + expect(stderr).toStrictEqual([ + 'appbuilder admin: admin init takes only --force, but got: now\n', + ]); + expect(deps.setExitCode).toHaveBeenCalledWith(1); + expect(deps.ensureKizen).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/kizenAdmin.ts b/src/lib/kizenAdmin.ts new file mode 100644 index 0000000..3d4ff50 --- /dev/null +++ b/src/lib/kizenAdmin.ts @@ -0,0 +1,371 @@ +/** + * `appbuilder admin`: runs the Python `kizen` CLI (from the venv `kizenVenv.ts` provisions) with + * app-builder's OAuth session. kizen's side of the contract is kizen-admin-cli's + * `docs/operating.md` §1: `KIZEN_AUTH_HELPER` is a command kizen runs (split with `shlex.split`, + * no shell) whenever it needs `{"Authorization":"Bearer …"}`, so the token itself never reaches + * kizen's environment or argv. + */ +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { constants } from 'node:os'; +import { apiOriginFor } from '../../shared/lib/kizenUrls.js'; +import type { Environment } from './credentials.js'; +import { errorMessage } from './guards.js'; +import type { KizenInstall } from './kizenVenv.js'; +import { loginRequired } from './oauth.js'; +import { resolveSessionEnvironment } from './oauthSession.js'; +import type { OAuthAccount, OAuthStore } from './oauthStore.js'; +import { quoteArg } from './shellArg.js'; + +export const ADMIN_PREFIX = 'appbuilder admin'; + +/** What `admin`'s raw arguments ask for. */ +export type AdminInvocation = + | { kind: 'help' } + | { kind: 'init'; force: boolean } + | { kind: 'init-help' } + | { kind: 'run'; env: string | undefined; args: string[] } + | { kind: 'error'; message: string }; + +const ENV_FLAG = '--env'; +const HELP_FLAGS: readonly string[] = ['-h', '--help']; +const FORCE_FLAG = '--force'; + +/** + * Wrapper options (`--env `, `--env=`, `-h`/`--help`) come first and end at the first + * other token or at `--`; everything after goes to kizen verbatim. `init` is the wrapper's own + * subcommand only as the first token after the wrapper options, never after `--`. + */ +export function parseAdminArgs(args: readonly string[]): AdminInvocation { + let env: string | undefined; + let index = 0; + + while (index < args.length) { + const arg = args[index] ?? ''; + + if (arg === '--') { + return { kind: 'run', env, args: args.slice(index + 1) }; + } + + if (HELP_FLAGS.includes(arg)) { + return { kind: 'help' }; + } + + if (arg === ENV_FLAG || arg.startsWith(`${ENV_FLAG}=`)) { + const value = arg === ENV_FLAG ? args[index + 1] : arg.slice(ENV_FLAG.length + 1); + + if (value === undefined || value === '') { + return { kind: 'error', message: "option '--env ' argument missing" }; + } + + env = value; + index += arg === ENV_FLAG ? 2 : 1; + + continue; + } + + break; + } + + const rest = args.slice(index); + + if (rest[0] === 'init') { + const extra = rest.slice(1); + + if (extra.length === 0) { + return { kind: 'init', force: false }; + } + + if (extra.length === 1 && extra[0] === FORCE_FLAG) { + return { kind: 'init', force: true }; + } + + if (extra.length === 1 && HELP_FLAGS.includes(extra[0] ?? '')) { + return { kind: 'init-help' }; + } + + return { + kind: 'error', + message: `admin init takes only --force, but got: ${extra.map(quoteArg).join(' ')}`, + }; + } + + return { kind: 'run', env, args: rest }; +} + +/** + * The `KIZEN_AUTH_HELPER` command: this CLI's `auth-header --env --business --user `, + * bound to the account kizen was given its ids for, so after a switch to another account it fails + * rather than hand kizen that account's token. kizen caches the header for the life of its process + * and runs the helper again only after a 401 (`kizen_builder/auth_helper.py`), so a switch makes + * kizen's next token fetch fail (at startup, or after a 401), while a token it already holds, + * which is still its own account's, stays in use until it expires. Run by the same node and + * script, with each word POSIX-quoted so kizen's `shlex.split` gets them back exactly. + */ +export const kizenAuthHelper = ( + execPath: string, + scriptPath: string, + environment: Environment, + account: OAuthAccount, +): string => + [ + execPath, + scriptPath, + 'auth-header', + '--env', + environment, + '--business', + account.businessId, + '--user', + account.userId, + ] + .map(quoteArg) + .join(' '); + +/** Set by the caller's shell, but they'd steer kizen or `auth-header` away from this session. */ +const STRIPPED_ENV = ['KIZEN_PROFILE', 'CLAUDE_CODE_MCP_SERVER_URL'] as const; + +export interface KizenSessionEnv { + KIZEN_AUTH_HELPER: string; + KIZEN_BASE_URL: string; + KIZEN_BUSINESS_ID: string; + KIZEN_USER_ID: string; +} + +/** `base` minus `KIZEN_PROFILE` and `CLAUDE_CODE_MCP_SERVER_URL`, plus kizen's session variables. */ +export function kizenChildEnv( + base: Readonly>, + session: KizenSessionEnv, +): Record { + const env: Record = {}; + + for (const [name, value] of Object.entries(base)) { + if (value !== undefined && !(STRIPPED_ENV as readonly string[]).includes(name)) { + env[name] = value; + } + } + + return { ...env, ...session }; +} + +export type SpawnKizen = ( + command: string, + args: string[], + options: SpawnOptions & { stdio: 'inherit'; env: Record }, +) => ChildProcess; + +export interface SignalTarget { + on: (signal: NodeJS.Signals, listener: () => void) => unknown; + off: (signal: NodeJS.Signals, listener: () => void) => unknown; +} + +/** Sent to this process alone (`kill`, a closed terminal), so kizen only hears them if passed on. */ +export const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGHUP']; + +/** + * Runs `/bin/python /bin/kizen ` with inherited stdio, forwarding SIGTERM and + * SIGHUP to it. SIGINT is swallowed, not forwarded: a terminal's Ctrl-C already reaches kizen + * through the foreground process group, and a second one would interrupt Python's + * KeyboardInterrupt cleanup. Swallowing it keeps this process alive to wait for kizen's exit. + * Resolves with its exit code, or 128 + the signal number when a signal ended it; rejects when it + * can't start. + * + * The interpreter runs the console script rather than the script running itself: its shebang was + * written while the venv was still at its temporary build path, which the publish rename leaves + * dangling. + */ +export function runKizen( + install: Pick, + args: readonly string[], + env: Record, + spawnKizen: SpawnKizen, + signals: SignalTarget, +): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + + try { + child = spawnKizen(install.python, [install.kizen, ...args], { stdio: 'inherit', env }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + + return; + } + + const listeners = [ + ...FORWARDED_SIGNALS.map((signal) => { + const forward = (): void => { + child.kill(signal); + }; + + return [signal, forward] as const; + }), + ['SIGINT', (): void => undefined] as const, + ]; + + for (const [signal, listener] of listeners) { + signals.on(signal, listener); + } + + let settled = false; + const settle = (finish: () => void): void => { + if (settled) { + return; + } + + settled = true; + + for (const [signal, listener] of listeners) { + signals.off(signal, listener); + } + + finish(); + }; + + child.on('error', (error) => { + settle(() => { + reject(error); + }); + }); + child.on('close', (code, signal) => { + settle(() => { + resolve(code ?? 128 + (signal === null ? 0 : constants.signals[signal])); + }); + }); + }); +} + +export interface AdminDeps { + store: Pick; + /** + * The provisioner `admin init` and every `admin` run share (`ensureKizen`). `force` (only from + * `admin init --force`) removes the install first. + */ + ensureKizen: (options?: { force?: boolean }) => Promise; + spawn: SpawnKizen; + signals: SignalTarget; + processEnv: Readonly>; + /** `process.execPath` and `process.argv[1]`: how kizen runs `appbuilder auth-header`. */ + execPath: string; + scriptPath: string | undefined; + outputHelp: () => void; + outputInitHelp: () => void; + writeStderr: (text: string) => void; + setExitCode: (code: number) => void; +} + +const fail = (deps: AdminDeps, message: string): void => { + deps.writeStderr(`${ADMIN_PREFIX}: ${message}\n`); + deps.setExitCode(1); +}; + +/** `admin init`: provisions (or confirms) the kizen venv. Needs no session. */ +export async function runAdminInit(deps: AdminDeps, force = false): Promise { + let install: KizenInstall; + + try { + install = await deps.ensureKizen(force ? { force } : undefined); + } catch (error) { + fail(deps, errorMessage(error)); + + return; + } + + deps.writeStderr(`${ADMIN_PREFIX}: kizen ${install.version} is ready in ${install.dir}.\n`); +} + +/** The signed-in session's kizen variables, or why there's none. Never reads a token. */ +async function sessionEnv( + env: string | undefined, + deps: AdminDeps, +): Promise<{ ok: true; session: KizenSessionEnv } | { ok: false; message: string }> { + const resolved = await resolveSessionEnvironment(env, () => deps.store.listSessions()); + + if (!resolved.ok) { + return { ok: false, message: resolved.message }; + } + + const { environment } = resolved; + const session = await deps.store.readSession(environment); + + if (session === null) { + return { + ok: false, + message: loginRequired(environment, `You're not signed in to ${environment}.`).message, + }; + } + + if (deps.scriptPath === undefined) { + return { ok: false, message: "can't tell which script is running, so kizen can't call back." }; + } + + return { + ok: true, + session: { + KIZEN_AUTH_HELPER: kizenAuthHelper(deps.execPath, deps.scriptPath, environment, session), + KIZEN_BASE_URL: apiOriginFor(environment), + KIZEN_BUSINESS_ID: session.businessId, + KIZEN_USER_ID: session.userId, + }, + }; +} + +/** `appbuilder admin [--env ] [--] ` and `appbuilder admin init`. */ +export async function runAdmin(args: readonly string[], deps: AdminDeps): Promise { + const invocation = parseAdminArgs(args); + + switch (invocation.kind) { + case 'help': + deps.outputHelp(); + + return; + case 'init-help': + deps.outputInitHelp(); + + return; + case 'init': + await runAdminInit(deps, invocation.force); + + return; + case 'error': + fail(deps, invocation.message); + + return; + case 'run': + break; + } + + // The session comes first, so a signed-out user never triggers a download. + let resolved: Awaited>; + + try { + resolved = await sessionEnv(invocation.env, deps); + } catch (error) { + fail(deps, errorMessage(error)); + + return; + } + + if (!resolved.ok) { + fail(deps, resolved.message); + + return; + } + + let install: KizenInstall; + + try { + install = await deps.ensureKizen(); + } catch (error) { + fail(deps, errorMessage(error)); + + return; + } + + const childEnv = kizenChildEnv(deps.processEnv, resolved.session); + + try { + deps.setExitCode(await runKizen(install, invocation.args, childEnv, deps.spawn, deps.signals)); + } catch (error) { + fail(deps, `couldn't start kizen: ${errorMessage(error)}`); + } +} diff --git a/src/lib/kizenVenv.test.ts b/src/lib/kizenVenv.test.ts new file mode 100644 index 0000000..ca883fd --- /dev/null +++ b/src/lib/kizenVenv.test.ts @@ -0,0 +1,566 @@ +import { createHash } from 'node:crypto'; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + utimes, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type * as FsPromisesModule from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { runAdminInit, type AdminDeps } from './kizenAdmin.js'; +import { + ensureKizen, + isReadyVenv, + KIZEN_PIN, + kizenWheelUrl, + READY_MARKER, + SCRATCH_MAX_AGE_MS, + VENV_MAX_IDLE_MS, + type KizenPin, + type KizenVenvDeps, +} from './kizenVenv.js'; + +/** Runs once, just before `stat` of the path it names: lets a test act between two fs checks. */ +const hooks = vi.hoisted(() => ({ + beforeStat: undefined as { path: string; run: () => Promise } | undefined, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal(); + const stat = async (...args: Parameters): Promise => { + const hook = hooks.beforeStat; + + if (args[0] === hook?.path) { + hooks.beforeStat = undefined; + await hook.run(); + } + + return fs.stat(...args); + }; + + return { ...fs, stat, default: { ...fs, stat } }; +}); + +afterEach(() => { + hooks.beforeStat = undefined; +}); + +const WHEEL_BYTES = Buffer.from('not really a wheel'); +const WHEEL_SHA = createHash('sha256').update(WHEEL_BYTES).digest('hex'); +const PIN: KizenPin = { version: '1.2.3', wheelUrl: kizenWheelUrl('1.2.3'), sha256: WHEEL_SHA }; +const VENV_NAME = `1.2.3-${WHEEL_SHA.slice(0, 12)}`; +const DAY_MS = 24 * 60 * 60_000; + +let baseDir: string; + +beforeEach(async () => { + baseDir = join(await mkdtemp(join(tmpdir(), 'appbuilder-kizen-venv-')), 'kizen'); +}); + +afterEach(async () => { + await rm(dirname(baseDir), { recursive: true, force: true }); +}); + +/** + * A fake `run`: `-m venv ` creates `/bin/python`; `/bin/python -m pip install` + * creates `/bin/kizen`. `fail` makes one of the two steps reject. + */ +type FakeRun = Mock<(bin: string, args: string[]) => Promise>; + +function fakeRun( + options: { fail?: 'venv' | 'pip'; beforePip?: () => Promise } = {}, +): FakeRun { + return vi.fn(async (bin: string, args: string[]): Promise => { + if (args[1] === 'venv') { + if (options.fail === 'venv') { + throw new Error('python3.14 exited with code 1'); + } + + const dir = args[2] ?? ''; + + await mkdir(join(dir, 'bin'), { recursive: true }); + await writeFile(join(dir, 'bin', 'python'), ''); + + return; + } + + await options.beforePip?.(); + + if (options.fail === 'pip') { + throw new Error('python exited with code 1'); + } + + await writeFile(join(dirname(bin), 'kizen'), '#!/tmp/.tmp-build/venv/bin/python\n'); + }); +} + +interface FakeVenvDeps extends KizenVenvDeps { + fetch: Mock; + findPython: Mock; + run: FakeRun; +} + +function deps(overrides: Partial = {}): FakeVenvDeps { + return { + baseDir, + pin: PIN, + localWheel: undefined, + fetch: vi.fn(() => Promise.resolve(new Response(WHEEL_BYTES))), + findPython: vi.fn(() => + Promise.resolve({ bin: 'python3.14', version: [3, 14] }), + ), + run: fakeRun(), + log: vi.fn(), + now: Date.now, + ...overrides, + } as FakeVenvDeps; +} + +const entries = async (): Promise => (await readdir(baseDir)).sort(); + +describe('ensureKizen', () => { + it('builds, marks and publishes the venv at -, running kizen through its python', async () => { + const d = deps(); + const install = await ensureKizen(d); + const target = join(baseDir, VENV_NAME); + + expect(install).toStrictEqual({ + version: '1.2.3', + dir: target, + python: join(target, 'bin', 'python'), + kizen: join(target, 'bin', 'kizen'), + built: true, + }); + expect(await isReadyVenv(target, WHEEL_SHA)).toBe(true); + expect(JSON.parse(await readFile(join(target, READY_MARKER), 'utf-8'))).toStrictEqual({ + version: '1.2.3', + sha256: WHEEL_SHA, + }); + expect(await entries()).toStrictEqual([VENV_NAME]); + expect(d.fetch).toHaveBeenCalledWith(PIN.wheelUrl, expect.anything()); + + const [venvCall, pipCall] = d.run.mock.calls as [string, string[]][]; + + expect(venvCall?.[0]).toBe('python3.14'); + expect(venvCall?.[1].slice(0, 2)).toStrictEqual(['-m', 'venv']); + expect(pipCall?.[1].slice(0, 4)).toStrictEqual([ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + ]); + expect(pipCall?.[1][4]).toMatch(/kizen_builder-1\.2\.3-py3-none-any\.whl$/); + }); + + it('(l) is idempotent: a ready venv is reused with no network, probing or subprocess', async () => { + const d = deps(); + + await ensureKizen(d); + + const second = await ensureKizen(d); + + expect(second.built).toBe(false); + expect(d.fetch).toHaveBeenCalledTimes(1); + expect(d.findPython).toHaveBeenCalledTimes(1); + expect(d.run).toHaveBeenCalledTimes(2); + }); + + it('(b) a sha256 mismatch aborts, deletes the download and leaves no ready venv', async () => { + const d = deps({ pin: { ...PIN, sha256: 'f'.repeat(64) } }); + + await expect(ensureKizen(d)).rejects.toThrow( + `The kizen 1.2.3 download doesn't match its pinned sha256 (expected ${'f'.repeat(64)}, got ${WHEEL_SHA}), so nothing was installed.`, + ); + expect(d.run).not.toHaveBeenCalled(); + // The scratch directory, wheel included, is gone, and nothing was published. + expect(await entries()).toStrictEqual([]); + }); + + /** A venv at the target that isn't ready: no code path here leaves one, so it's damaged. */ + async function damagedTarget(marker?: object): Promise { + const target = join(baseDir, VENV_NAME); + + await mkdir(join(target, 'bin'), { recursive: true }); + await writeFile(join(target, 'bin', 'python'), ''); + await writeFile(join(target, 'bin', 'kizen'), ''); + await writeFile(join(target, 'sentinel'), ''); + + if (marker !== undefined) { + await writeFile(join(target, READY_MARKER), JSON.stringify(marker)); + } + + return target; + } + + const DAMAGED = `kizen's install at ${join('', VENV_NAME)} is damaged. Run \`appbuilder admin init --force\` to reinstall it.`; + const damagedMessage = (): string => DAMAGED.replace('', baseDir); + + it.each([ + ['no marker', undefined], + ['a marker naming another sha', { version: '1.2.3', sha256: 'a'.repeat(64) }], + ])( + '(c) a damaged install (%s) is reported, naming --force, and left exactly as it is', + async (_label, marker) => { + const target = await damagedTarget(marker); + const d = deps(); + + await expect(ensureKizen(d)).rejects.toThrow(damagedMessage()); + expect(d.fetch).not.toHaveBeenCalled(); + expect(d.run).not.toHaveBeenCalled(); + expect(await readdir(target)).toContain('sentinel'); + expect(await entries()).toStrictEqual([VENV_NAME]); + }, + ); + + it('(c) --force rebuilds a damaged install', async () => { + const target = await damagedTarget(); + const d = deps(); + const install = await ensureKizen(d, { force: true }); + + expect(install.built).toBe(true); + expect(await isReadyVenv(target, WHEEL_SHA)).toBe(true); + expect(await readdir(target)).not.toContain('sentinel'); + expect(await entries()).toStrictEqual([VENV_NAME]); + }); + + it('--force rebuilds a ready install', async () => { + const d = deps(); + + await ensureKizen(d); + + const install = await ensureKizen(d, { force: true }); + + expect(install.built).toBe(true); + expect(d.fetch).toHaveBeenCalledTimes(2); + expect(d.run).toHaveBeenCalledTimes(4); + expect(await isReadyVenv(install.dir, WHEEL_SHA)).toBe(true); + expect(await entries()).toStrictEqual([VENV_NAME]); + }); + + it('(d) two concurrent first runs leave one ready venv and both succeed', async () => { + let arrived = 0; + let release: () => void = () => undefined; + const bothAtPip = new Promise((resolve) => { + release = resolve; + }); + // Both builds reach pip before either publishes, so their renames race. + const run = fakeRun({ + beforePip: async () => { + arrived += 1; + + if (arrived === 2) { + release(); + } + + await bothAtPip; + }, + }); + + const [first, second] = await Promise.all([ + ensureKizen(deps({ run })), + ensureKizen(deps({ run })), + ]); + + expect(first.dir).toBe(second.dir); + expect([first.built, second.built].sort()).toStrictEqual([false, true]); + expect(run).toHaveBeenCalledTimes(4); + expect(await isReadyVenv(first.dir, WHEEL_SHA)).toBe(true); + expect(await entries()).toStrictEqual([VENV_NAME]); + }); + + it('pins kizen 0.3.0 at its GitHub release asset', () => { + expect(KIZEN_PIN).toStrictEqual({ + version: '0.3.0', + wheelUrl: + 'https://github.com/kizen/kizen-admin-cli/releases/download/v0.3.0/kizen_builder-0.3.0-py3-none-any.whl', + sha256: '9f2017dd43bdc4554b655c87e25d1b5c754b04e0b8a32f1ed7516e30b9edf2d7', + }); + }); + + it('installs APPBUILDER_KIZEN_WHEEL without downloading, keyed by its own sha256 and filename version', async () => { + const wheel = join(dirname(baseDir), 'kizen_builder-0.3.0.dev1-py3-none-any.whl'); + const bytes = Buffer.from('a local build'); + + await writeFile(wheel, bytes); + + const sha = createHash('sha256').update(bytes).digest('hex'); + const d = deps({ pin: KIZEN_PIN, localWheel: wheel }); + const install = await ensureKizen(d); + + expect(install.version).toBe('0.3.0.dev1'); + expect(install.dir).toBe(join(baseDir, `0.3.0.dev1-${sha.slice(0, 12)}`)); + expect(d.fetch).not.toHaveBeenCalled(); + expect((d.run.mock.calls[1] as [string, string[]])[1][4]).toBe(wheel); + expect(await isReadyVenv(install.dir, sha)).toBe(true); + }); + + it('refuses an APPBUILDER_KIZEN_WHEEL that is not named like a kizen_builder wheel', async () => { + const wheel = join(dirname(baseDir), 'kizen.whl'); + + await writeFile(wheel, 'x'); + + await expect(ensureKizen(deps({ localWheel: wheel }))).rejects.toThrow( + `APPBUILDER_KIZEN_WHEEL must name a kizen_builder wheel (kizen_builder--….whl), not ${wheel}.`, + ); + }); + + it('touches the marker each time a ready venv is used', async () => { + await ensureKizen(deps()); + + const marker = join(baseDir, VENV_NAME, READY_MARKER); + const old = new Date(Date.now() - 10 * DAY_MS); + + await utimes(marker, old, old); + await ensureKizen(deps()); + + expect(Date.now() - (await stat(marker)).mtimeMs).toBeLessThan(DAY_MS); + }); +}); + +describe('publish', () => { + const target = (): string => join(baseDir, VENV_NAME); + + /** Another run's ready venv, renamed into place as its publish does, with a file to tell it apart. */ + async function publishAnother(): Promise { + const dir = join(baseDir, '.tmp-another', 'venv'); + + await mkdir(join(dir, 'bin'), { recursive: true }); + await writeFile(join(dir, 'bin', 'python'), ''); + await writeFile(join(dir, 'bin', 'kizen'), ''); + await writeFile(join(dir, 'theirs'), ''); + await writeFile( + join(dir, READY_MARKER), + JSON.stringify({ version: '1.2.3', sha256: WHEEL_SHA }), + ); + await rename(dir, target()); + await rm(join(baseDir, '.tmp-another'), { recursive: true }); + } + + it('a ready target published during the build wins: built:false, target untouched', async () => { + const d = deps({ run: fakeRun({ beforePip: publishAnother }) }); + const install = await ensureKizen(d); + + expect(install.built).toBe(false); + expect(await readdir(target())).toContain('theirs'); + expect(await isReadyVenv(target(), WHEEL_SHA)).toBe(true); + expect(await entries()).toStrictEqual([VENV_NAME]); + }); + + it('a ready venv published between the ready check and the exists check is used, not called damaged', async () => { + // The ready check finds nothing; another run publishes just before the exists check. + hooks.beforeStat = { path: target(), run: publishAnother }; + + const d = deps(); + const install = await ensureKizen(d); + + expect(hooks.beforeStat).toBeUndefined(); + expect(install.built).toBe(false); + expect(await readdir(target())).toContain('theirs'); + expect(d.fetch).not.toHaveBeenCalled(); + expect(d.run).not.toHaveBeenCalled(); + }); + + it('a damaged target appearing during the build is reported and left alone', async () => { + const d = deps({ + run: fakeRun({ + beforePip: async () => { + await mkdir(join(target(), 'bin'), { recursive: true }); + await writeFile(join(target(), 'sentinel'), ''); + }, + }), + }); + + await expect(ensureKizen(d)).rejects.toThrow( + `kizen's install at ${target()} is damaged. Run \`appbuilder admin init --force\` to reinstall it.`, + ); + expect(await readdir(target())).toStrictEqual(['bin', 'sentinel']); + expect(await entries()).toStrictEqual([VENV_NAME]); + }); + + it('creates the kizen directory private to the user', async () => { + await ensureKizen(deps()); + + expect((await stat(baseDir)).mode & 0o777).toBe(0o700); + }); +}); + +describe('(q) pruning after a publish', () => { + async function venvSibling(name: string, ageMs: number, withMarker = true): Promise { + const dir = join(baseDir, name); + const time = new Date(Date.now() - ageMs); + + await mkdir(join(dir, 'bin'), { recursive: true }); + + if (withMarker) { + await writeFile(join(dir, READY_MARKER), '{}'); + await utimes(join(dir, READY_MARKER), time, time); + } + + await utimes(dir, time, time); + + return dir; + } + + async function scratchSibling(name: string, ageMs: number): Promise { + const time = new Date(Date.now() - ageMs); + + await mkdir(join(baseDir, name), { recursive: true }); + await utimes(join(baseDir, name), time, time); + } + + it('removes an old sibling and keeps one touched within 30 days', async () => { + await mkdir(baseDir, { recursive: true }); + await venvSibling('0.1.0-aaaaaaaaaaaa', VENV_MAX_IDLE_MS + DAY_MS); + await venvSibling('0.1.1-bbbbbbbbbbbb', VENV_MAX_IDLE_MS - DAY_MS); + // The directory is old but its marker was touched recently: it's in use. + await venvSibling('0.1.2-cccccccccccc', 1000); + await utimes( + join(baseDir, '0.1.2-cccccccccccc'), + new Date(0), + new Date(Date.now() - 2 * VENV_MAX_IDLE_MS), + ); + await venvSibling('0.0.9-dddddddddddd', VENV_MAX_IDLE_MS + DAY_MS, false); + await scratchSibling('.tmp-0.1.0-aaaaaaaaaaaa-1-old', SCRATCH_MAX_AGE_MS + 60_000); + await scratchSibling('.tmp-0.1.0-aaaaaaaaaaaa-2-new', 60_000); + // Not created by this code: never touched, however old. + await scratchSibling('notes', VENV_MAX_IDLE_MS * 3); + + await ensureKizen(deps()); + + expect(await entries()).toStrictEqual( + [ + '.tmp-0.1.0-aaaaaaaaaaaa-2-new', + '0.1.1-bbbbbbbbbbbb', + '0.1.2-cccccccccccc', + VENV_NAME, + 'notes', + ].sort(), + ); + }); + + it('does not prune when a ready venv is reused', async () => { + await ensureKizen(deps()); + await venvSibling('0.1.0-aaaaaaaaaaaa', VENV_MAX_IDLE_MS + DAY_MS); + + await ensureKizen(deps()); + + expect(await entries()).toContain('0.1.0-aaaaaaaaaaaa'); + }); +}); + +describe('(n) admin init failures exit 1 with their line', () => { + function initDeps(venvDeps: KizenVenvDeps): { + adminDeps: AdminDeps; + stderr: string[]; + setExitCode: Mock<(code: number) => void>; + } { + const stderr: string[] = []; + const setExitCode = vi.fn<(code: number) => void>(); + const adminDeps = { + ensureKizen: (options?: { force?: boolean }) => ensureKizen(venvDeps, options), + writeStderr: (text: string) => { + stderr.push(text); + }, + setExitCode, + } as unknown as AdminDeps; + + return { adminDeps, stderr, setExitCode }; + } + + it.each<[string, Partial, string]>([ + [ + 'no Python', + { + findPython: () => Promise.reject(new Error('No Python 3.12 or newer found to run kizen.')), + }, + 'No Python 3.12 or newer found to run kizen.', + ], + [ + 'an HTTP error', + { fetch: () => Promise.resolve(new Response('missing', { status: 404 })) }, + `Couldn't download kizen from ${PIN.wheelUrl}: HTTP 404`, + ], + [ + 'a network error', + { + fetch: () => + Promise.reject( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }), + ), + }, + `Couldn't download kizen from ${PIN.wheelUrl}: ENOTFOUND`, + ], + [ + 'a hash mismatch', + { pin: { ...PIN, sha256: '0'.repeat(64) } }, + "doesn't match its pinned sha256", + ], + [ + 'a venv failure', + { run: fakeRun({ fail: 'venv' }) }, + "Couldn't create a virtual environment: python3.14 exited with code 1", + ], + [ + 'a pip failure', + { run: fakeRun({ fail: 'pip' }) }, + "Couldn't install kizen: python exited with code 1", + ], + ])('%s', async (_label, overrides, expected) => { + const { adminDeps, stderr, setExitCode } = initDeps(deps(overrides)); + + await runAdminInit(adminDeps); + + expect(setExitCode).toHaveBeenCalledWith(1); + expect(stderr).toHaveLength(1); + expect(stderr[0]).toMatch(/^appbuilder admin: .*\n$/); + expect(stderr[0]).toContain(expected); + // Whatever failed, nothing half-built is left published. + expect(await isReadyVenv(join(baseDir, VENV_NAME), WHEEL_SHA)).toBe(false); + }); + + it('a damaged install, then init --force repairs it', async () => { + await mkdir(join(baseDir, VENV_NAME, 'bin'), { recursive: true }); + + const venvDeps = deps(); + const plain = initDeps(venvDeps); + + await runAdminInit(plain.adminDeps); + + expect(plain.setExitCode).toHaveBeenCalledWith(1); + expect(plain.stderr).toStrictEqual([ + `appbuilder admin: kizen's install at ${join(baseDir, VENV_NAME)} is damaged. Run \`appbuilder admin init --force\` to reinstall it.\n`, + ]); + + const forced = initDeps(venvDeps); + + await runAdminInit(forced.adminDeps, true); + + expect(forced.setExitCode).not.toHaveBeenCalled(); + expect(await isReadyVenv(join(baseDir, VENV_NAME), WHEEL_SHA)).toBe(true); + }); + + it('reports a ready venv on success, then again without rebuilding', async () => { + const venvDeps = deps(); + const first = initDeps(venvDeps); + + await runAdminInit(first.adminDeps); + + const second = initDeps(venvDeps); + + await runAdminInit(second.adminDeps); + + const line = `appbuilder admin: kizen 1.2.3 is ready in ${join(baseDir, VENV_NAME)}.\n`; + + expect(first.stderr).toStrictEqual([line]); + expect(second.stderr).toStrictEqual([line]); + expect(first.setExitCode).not.toHaveBeenCalled(); + expect(venvDeps.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/kizenVenv.ts b/src/lib/kizenVenv.ts new file mode 100644 index 0000000..83eb60e --- /dev/null +++ b/src/lib/kizenVenv.ts @@ -0,0 +1,488 @@ +/** + * The private venv `appbuilder admin` runs the Python `kizen` CLI from, so nobody has to install + * kizen themselves: one venv per pinned kizen-builder wheel, under + * `~/.kizenappbuilder/kizen/-/`. + * + * kizen/ + * -/ a published venv; ready once `.appbuilder-ready.json` names its sha + * .tmp---…/ a build in progress: `venv/` plus the downloaded wheel + * + * A venv is built in a `.tmp-` sibling, gets its marker last, and is published with one atomic + * rename, so a half-built venv is never at the published path with a marker. Nothing is locked: + * two first runs both build, the first rename wins and the other discards its copy. Nothing ever + * moves or deletes a published venv except `appbuilder admin init --force`: a rename can't + * replace a non-empty directory, so publishing is race-free without a lock. A venv at the path + * that isn't ready (no code path here leaves one) is reported as damaged, for `--force` to fix. + */ +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { + access, + mkdir, + readdir, + readFile, + rename, + rm, + stat, + utimes, + writeFile, +} from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import { GLOBAL_CREDENTIALS_DIR } from './credentials.js'; +import { errorCause, errorMessage, isErrnoCode, isRecord } from './guards.js'; +import { findPython, formatVersion, type FoundPython, type PythonVersion } from './pythonProbe.js'; + +export interface KizenPin { + version: string; + wheelUrl: string; + sha256: string; +} + +/** The URL `.github/workflows/release.yml` in kizen-admin-cli attaches a release's wheel at. */ +export const kizenWheelUrl = (version: string): string => + `https://github.com/kizen/kizen-admin-cli/releases/download/v${version}/kizen_builder-${version}-py3-none-any.whl`; + +// To bump: new tag, then the wheel's sha256 from the GitHub release digest, confirmed by shasum. +export const KIZEN_PIN: KizenPin = { + version: '0.3.0', + wheelUrl: kizenWheelUrl('0.3.0'), + sha256: '9f2017dd43bdc4554b655c87e25d1b5c754b04e0b8a32f1ed7516e30b9edf2d7', +}; + +/** Dev override: a local wheel to install instead of the pinned release. */ +export const LOCAL_WHEEL_ENV = 'APPBUILDER_KIZEN_WHEEL'; + +export const KIZEN_VENVS_DIR = join(GLOBAL_CREDENTIALS_DIR, 'kizen'); +export const READY_MARKER = '.appbuilder-ready.json'; + +/** kizen-builder's `requires-python`. No upper cap: any newer Python is fine. */ +export const KIZEN_MIN_PYTHON: PythonVersion = [3, 12]; +/** Probed in this order; the newest one that meets the minimum wins. */ +export const KIZEN_PYTHON_CANDIDATES: readonly string[] = [ + 'python3.14', + 'python3.13', + 'python3.12', + 'python3', +]; + +/** Another version's venv is removed once its marker hasn't been touched for this long. */ +export const VENV_MAX_IDLE_MS = 30 * 24 * 60 * 60_000; +/** A `.tmp-` directory is removed once it's older than this. */ +export const SCRATCH_MAX_AGE_MS = 60 * 60_000; +export const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; + +/** `-`: only directories with this shape are ever pruned as venvs. */ +const VENV_DIR_NAME = /^[0-9][\w.+!]*-[0-9a-f]{12}$/; +const WHEEL_VERSION = /^kizen_builder-([^-]+)-.*\.whl$/; + +export interface KizenInstall { + version: string; + dir: string; + python: string; + kizen: string; + /** Whether this call built it; false when a ready venv was reused. */ + built: boolean; +} + +export interface KizenVenvDeps { + baseDir: string; + pin: KizenPin; + /** `APPBUILDER_KIZEN_WHEEL`, if set. */ + localWheel: string | undefined; + fetch: typeof fetch; + /** A Python new enough for kizen; throws when there's none. */ + findPython: () => Promise; + /** Runs a command to completion; rejects with a one-line error when it fails. */ + run: (bin: string, args: string[]) => Promise; + /** One progress line, for stderr. */ + log: (line: string) => void; + now: () => number; +} + +/** The newest Python ≥ 3.12 among `KIZEN_PYTHON_CANDIDATES`. */ +export async function findKizenPython( + probe?: (bin: string) => Promise, +): Promise { + const { found, tried } = await findPython( + KIZEN_PYTHON_CANDIDATES, + KIZEN_MIN_PYTHON, + 'newest', + probe, + ); + + if (found === undefined) { + throw new Error( + `No Python ${formatVersion(KIZEN_MIN_PYTHON)} or newer found to run kizen. ` + + `Tried ${tried.join(', ')}. Install Python ${formatVersion(KIZEN_MIN_PYTHON)} or newer and retry.`, + ); + } + + return found; +} + +/** Keeps the last lines a failed command printed, so its error can be shown. */ +const OUTPUT_TAIL_LINES = 20; + +/** + * Runs `bin args` with its output captured. On failure the captured output's tail goes to + * `writeStderr` and the promise rejects with one line. + */ +export const runQuietly = + (writeStderr: (text: string) => void) => + (bin: string, args: string[]): Promise => + new Promise((resolve, reject) => { + const proc = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + const collect = (chunk: Buffer): void => { + output += chunk.toString('utf-8'); + }; + + proc.stdout.on('data', collect); + proc.stderr.on('data', collect); + proc.on('error', (error) => { + reject(new Error(`couldn't run ${bin}: ${error.message}`)); + }); + proc.on('close', (code, signal) => { + if (code === 0) { + resolve(); + + return; + } + + const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n'); + + if (tail !== '') { + writeStderr(`${tail}\n`); + } + + reject( + new Error( + `${basename(bin)} exited with ${code === null ? String(signal) : `code ${String(code)}`}`, + ), + ); + }); + }); + +export function createDefaultKizenVenvDeps( + log: (line: string) => void, + writeStderr: (text: string) => void, + processEnv: Readonly> = process.env, +): KizenVenvDeps { + const localWheel = processEnv[LOCAL_WHEEL_ENV]; + + return { + baseDir: KIZEN_VENVS_DIR, + pin: KIZEN_PIN, + localWheel: localWheel === '' ? undefined : localWheel, + fetch: (...args) => fetch(...args), + findPython: () => findKizenPython(), + run: runQuietly(writeStderr), + log, + now: Date.now, + }; +} + +const sha256OfFile = async (path: string): Promise => { + const hash = createHash('sha256'); + + await pipeline(createReadStream(path), hash); + + return hash.digest('hex'); +}; + +const markerPath = (dir: string): string => join(dir, READY_MARKER); +const venvBin = (dir: string, name: string): string => join(dir, 'bin', name); + +/** Ready: the marker names `sha256`, and both `bin/python` and `bin/kizen` exist. */ +export async function isReadyVenv(dir: string, sha256: string): Promise { + try { + const marker = JSON.parse(await readFile(markerPath(dir), 'utf-8')) as unknown; + + if (!isRecord(marker) || marker.sha256 !== sha256) { + return false; + } + + await access(venvBin(dir, 'python')); + await access(venvBin(dir, 'kizen')); + + return true; + } catch { + return false; + } +} + +/** Records a use of the venv in its marker's mtime, which pruning reads. Best-effort. */ +async function touchMarker(dir: string, now: number): Promise { + const time = new Date(now); + + await utimes(markerPath(dir), time, time).catch(() => undefined); +} + +interface WheelSource { + version: string; + sha256: string; + /** A local file, or undefined to download `pin.wheelUrl`. */ + localPath: string | undefined; +} + +async function wheelSource(deps: KizenVenvDeps): Promise { + if (deps.localWheel !== undefined) { + const path = deps.localWheel; + const version = WHEEL_VERSION.exec(basename(path))?.[1]; + + if (version === undefined) { + throw new Error( + `${LOCAL_WHEEL_ENV} must name a kizen_builder wheel (kizen_builder--….whl), not ${path}.`, + ); + } + + let sha256: string; + + try { + sha256 = await sha256OfFile(path); + } catch (error) { + throw new Error(`Couldn't read ${LOCAL_WHEEL_ENV} ${path}: ${errorCause(error)}`); + } + + return { version, sha256, localPath: path }; + } + + return { version: deps.pin.version, sha256: deps.pin.sha256, localPath: undefined }; +} + +/** Streams `url` to `dest`, hashing as it goes; returns the sha256. */ +async function download(deps: KizenVenvDeps, url: string, dest: string): Promise { + const hash = createHash('sha256'); + + try { + const res = await deps.fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + + if (!res.ok || res.body === null) { + throw new Error(`HTTP ${String(res.status)}`); + } + + await pipeline( + Readable.fromWeb(res.body as NodeReadableStream), + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }), + createWriteStream(dest), + ); + } catch (error) { + throw new Error(`Couldn't download kizen from ${url}: ${errorCause(error)}`); + } + + return hash.digest('hex'); +} + +const scratchName = (venvName: string): string => + `.tmp-${venvName}-${String(process.pid)}-${randomBytes(4).toString('hex')}`; + +/** + * Removes what this code created and nobody is using: other versions' venvs whose marker (or, with + * none, the directory) hasn't been touched for 30 days, and `.tmp-` directories older than an hour. + * Best-effort: every failure is ignored. + */ +export async function pruneKizenVenvs(baseDir: string, keep: string, now: number): Promise { + let entries: string[]; + + try { + entries = await readdir(baseDir); + } catch { + return; + } + + await Promise.all( + entries.map(async (name) => { + if (name === keep) { + return; + } + + const path = join(baseDir, name); + + try { + if (VENV_DIR_NAME.test(name)) { + const { mtimeMs } = await stat(markerPath(path)).catch(() => stat(path)); + + if (now - mtimeMs > VENV_MAX_IDLE_MS) { + await rm(path, { recursive: true, force: true }); + } + } else if (name.startsWith('.tmp-')) { + const { mtimeMs } = await stat(path); + + if (now - mtimeMs > SCRATCH_MAX_AGE_MS) { + await rm(path, { recursive: true, force: true }); + } + } + } catch { + // Best-effort. + } + }), + ); +} + +const exists = (path: string): Promise => + stat(path).then( + () => true, + () => false, + ); + +const damaged = (target: string): Error => + new Error( + `kizen's install at ${target} is damaged. Run \`appbuilder admin init --force\` to reinstall it.`, + ); + +/** + * Moves the venv at `built` to `target`; returns whether `built` is the one published. A taken + * `target` is never touched: a ready one means a concurrent build won and `built` is discarded, + * anything else is reported as damaged. + */ +async function publish(built: string, target: string, sha256: string): Promise { + try { + await rename(built, target); + + return true; + } catch (error) { + if (!isErrnoCode(error, 'ENOTEMPTY') && !isErrnoCode(error, 'EEXIST')) { + throw error; + } + } + + if (await isReadyVenv(target, sha256)) { + return false; + } + + throw damaged(target); +} + +/** + * The kizen venv for the pinned wheel (or `APPBUILDER_KIZEN_WHEEL`), built on first use. A ready + * venv is reused without network, probing or subprocesses. Used by `admin init` and by every + * `admin` run. + */ +export async function ensureKizen( + deps: KizenVenvDeps, + options: { force?: boolean } = {}, +): Promise { + const source = await wheelSource(deps); + const venvName = `${source.version}-${source.sha256.slice(0, 12)}`; + const target = join(deps.baseDir, venvName); + const install = (built: boolean): KizenInstall => ({ + version: source.version, + dir: target, + python: venvBin(target, 'python'), + kizen: venvBin(target, 'kizen'), + built, + }); + + if (options.force === true) { + // User-initiated (`admin init --force`), and documented as not for while kizen runs. + deps.log(`Removing ${target}.`); + + await rm(target, { recursive: true, force: true }); + } else if (await isReadyVenv(target, source.sha256)) { + await touchMarker(target, deps.now()); + + return install(false); + } else if (await exists(target)) { + // A concurrent run may have published between the two checks; a ready venv only becomes + // unready through --force, so one re-check settles it. + if (await isReadyVenv(target, source.sha256)) { + await touchMarker(target, deps.now()); + + return install(false); + } + + // Not something this code leaves behind, so not something it should guess how to replace. + throw damaged(target); + } + + deps.log(`Installing kizen ${source.version} into ${target} (first run only).`); + + const python = await deps.findPython(); + + deps.log(`Using ${python.bin} (Python ${formatVersion(python.version)}).`); + + await mkdir(deps.baseDir, { recursive: true, mode: 0o700 }); + + const scratch = join(deps.baseDir, scratchName(venvName)); + const venv = join(scratch, 'venv'); + let published: boolean; + + try { + await mkdir(scratch); + + let wheel: string; + + if (source.localPath === undefined) { + wheel = join(scratch, basename(new URL(deps.pin.wheelUrl).pathname)); + + deps.log(`Downloading ${deps.pin.wheelUrl}`); + + const actual = await download(deps, deps.pin.wheelUrl, wheel); + + if (actual !== source.sha256) { + await rm(wheel, { force: true }); + + throw new Error( + `The kizen ${source.version} download doesn't match its pinned sha256 ` + + `(expected ${source.sha256}, got ${actual}), so nothing was installed.`, + ); + } + } else { + wheel = source.localPath; + + deps.log(`Using the local wheel ${wheel} (${LOCAL_WHEEL_ENV}).`); + } + + deps.log('Creating a virtual environment.'); + + try { + await deps.run(python.bin, ['-m', 'venv', venv]); + } catch (error) { + throw new Error(`Couldn't create a virtual environment: ${errorMessage(error)}`); + } + + deps.log(`Installing kizen ${source.version} from the wheel and its dependencies from PyPI.`); + + try { + await deps.run(venvBin(venv, 'python'), [ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + wheel, + ]); + } catch (error) { + throw new Error(`Couldn't install kizen: ${errorMessage(error)}`); + } + + await writeFile( + markerPath(venv), + `${JSON.stringify({ version: source.version, sha256: source.sha256 }, null, 2)}\n`, + ); + + published = await publish(venv, target, source.sha256); + } finally { + await rm(scratch, { recursive: true, force: true }).catch(() => undefined); + } + + const now = deps.now(); + + await touchMarker(target, now); + + if (published) { + deps.log(`kizen ${source.version} is installed.`); + + await pruneKizenVenvs(deps.baseDir, venvName, now); + } + + return install(published); +} diff --git a/src/lib/oauthCommands.test.ts b/src/lib/oauthCommands.test.ts index a3505bd..8378014 100644 --- a/src/lib/oauthCommands.test.ts +++ b/src/lib/oauthCommands.test.ts @@ -1656,4 +1656,124 @@ describe('auth-header', () => { expectHeaderFailure(h); } }); + + describe('bound to an account (--business and --user, as kizen runs it)', () => { + const bound = { env: 'staging', business: BIZ_A, user: USER_A }; + const B_ACCESS = 'secret-b-access'; + + it("serves that account's token while it is the current one", async () => { + await signIn(store, session('staging')); + + const h = harness(noKizen); + + await runAuthHeader(bound, h.deps); + + expect(h.stdout).toStrictEqual([header(ACCESS)]); + expect(h.stderr).toStrictEqual([]); + expect(h.exitCodes).toStrictEqual([]); + // The token comes from that account's own file. + expect(h.providerOptions).toStrictEqual([ + { requestTimeoutMs: AUTH_HEADER_REQUEST_TIMEOUT_MS, account: ACCOUNT_A }, + ]); + }); + + it('fails closed, naming the mismatch, after a switch to another account', async () => { + await signIn(store, session('staging')); + // Another terminal switches staging to account B while kizen still holds A's ids. + await signIn(store, session('staging', { ...ACCOUNT_B, accessToken: B_ACCESS })); + + const h = harness(noKizen); + + await runAuthHeader(bound, h.deps); + + expectHeaderFailure(h); + expect(h.stderr).toStrictEqual([ + `appbuilder auth-header: --business ${BIZ_A} --user ${USER_A} isn't the current staging account (the current account is business ${BIZ_B}, user ${USER_B}), so auth-header won't send its token. Run the command again.\n`, + ]); + expect(h.all()).not.toContain(B_ACCESS); + // It fails before reading (or refreshing) any token. + expect(h.providerOptions).toStrictEqual([]); + expect(h.fetchMock).not.toHaveBeenCalled(); + }); + + it('fails closed after a switch that lands while the token is being read', async () => { + await signIn(store, session('staging')); + + const h = harness(noKizen); + const provider = h.deps.tokenProvider; + + h.deps.tokenProvider = (environment, providerOpts) => { + const inner = provider(environment, providerOpts); + + return { + ...inner, + getAccessToken: async () => { + const token = await inner.getAccessToken(); + + await signIn(store, session('staging', { ...ACCOUNT_B, accessToken: B_ACCESS })); + + return token; + }, + }; + }; + + await runAuthHeader(bound, h.deps); + + expectHeaderFailure(h, "isn't the current staging account (the current account is business"); + expect(h.providerOptions).toHaveLength(1); + }); + + it('fails closed when nobody is signed in to the environment any more', async () => { + await signIn(store, session('staging')); + await store.signOut('staging'); + + const h = harness(noKizen); + + await runAuthHeader(bound, h.deps); + + expectHeaderFailure(h, 'appbuilder auth-header: '); + }); + + it('fails closed when the account is saved but not current', async () => { + await signIn(store, session('staging')); + await signIn(store, session('staging', { ...ACCOUNT_B, accessToken: B_ACCESS })); + + // A's file is still saved, so its token could be read: the pointer check is what refuses. + expect(await store.readAccount('staging', ACCOUNT_A)).not.toBeNull(); + + const h = harness(noKizen); + + await runAuthHeader(bound, h.deps); + + expectHeaderFailure(h, "isn't the current staging account"); + }); + + it.each([[{ business: BIZ_A }], [{ user: USER_A }]])('refuses %j alone', async (partial) => { + await signIn(store, session('staging')); + + const h = harness(noKizen); + + await runAuthHeader({ env: 'staging', ...partial }, h.deps); + + expect(h.stderr).toStrictEqual([ + 'appbuilder auth-header: auth-header takes --business and --user together.\n', + ]); + expectHeaderFailure(h); + expect(h.providerOptions).toStrictEqual([]); + }); + + it('without the flags, serves the current account as before, after a switch too', async () => { + await signIn(store, session('staging')); + await signIn(store, session('staging', { ...ACCOUNT_B, accessToken: B_ACCESS })); + + const h = harness(noKizen); + + await runAuthHeader({ env: 'staging' }, h.deps); + + expect(h.stdout).toStrictEqual([header(B_ACCESS)]); + expect(h.providerOptions).toStrictEqual([ + { requestTimeoutMs: AUTH_HEADER_REQUEST_TIMEOUT_MS }, + ]); + }); + }); }); diff --git a/src/lib/oauthCommands.ts b/src/lib/oauthCommands.ts index dfcd4d9..9340188 100644 --- a/src/lib/oauthCommands.ts +++ b/src/lib/oauthCommands.ts @@ -26,6 +26,7 @@ import { accountKey, createOAuthStore, sameAccount, + type OAuthAccount, type OAuthSession, type OAuthStore, type SavedAccount, @@ -726,14 +727,81 @@ async function headerEnvironment( return explicit ?? sessionEnvironment(undefined, deps.store); } -/** Claude Code `headersHelper`. Failures print one stderr line and nothing to stdout. */ -export const runAuthHeader: AuthCommand = async (options, deps) => { +export interface AuthHeaderCommandOptions extends AuthCommandOptions { + business?: string; + user?: string; +} + +/** The account `--business` and `--user` bind `auth-header` to, if given (both or neither). */ +function boundAccount(options: AuthHeaderCommandOptions): OAuthAccount | undefined { + const { business, user } = options; + + if (business === undefined && user === undefined) { + return undefined; + } + + if (business === undefined || user === undefined) { + throw new Error('auth-header takes --business and --user together.'); + } + + return { businessId: business, userId: user }; +} + +/** + * The bound account's token. It comes from that account's own file, so it's never another + * account's; and the environment's current account must still be that one, so a switch to another + * account fails the helper (as it aborts any running command) instead of serving on. + */ +async function boundAccountToken( + environment: Environment, + account: OAuthAccount, + deps: AuthCommandDeps, +): Promise { + /** Throws unless `account` is the environment's current account. */ + const requireCurrent = async (): Promise => { + const current = await deps.store.readSession(environment); + + if (current === null || !sameAccount(current, account)) { + const now = + current === null + ? 'nobody is signed in' + : `the current account is business ${current.businessId}, user ${current.userId}`; + + throw new Error( + `--business ${account.businessId} --user ${account.userId} isn't the current ${environment} account (${now}), so auth-header won't send its token. Run the command again.`, + ); + } + }; + + // Before the token read, so an account that's no longer current fails without a refresh; and + // after it, so a switch during that read fails too. + await requireCurrent(); + + const { requestTimeoutMs } = deps.authHeaderTimeouts; + const token = await deps + .tokenProvider(environment, { requestTimeoutMs, account }) + .getAccessToken(); + + await requireCurrent(); + + return token; +} + +/** + * Claude Code `headersHelper`, and kizen's `KIZEN_AUTH_HELPER` (bound with `--business` and + * `--user`). Failures print one stderr line and nothing to stdout. + */ +export const runAuthHeader: AuthCommand = async (options, deps) => { let line: string; try { + const account = boundAccount(options); const environment = await headerEnvironment(options.env, deps); const { requestTimeoutMs } = deps.authHeaderTimeouts; - const token = await deps.tokenProvider(environment, { requestTimeoutMs }).getAccessToken(); + const token = + account === undefined + ? await deps.tokenProvider(environment, { requestTimeoutMs }).getAccessToken() + : await boundAccountToken(environment, account, deps); line = `${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`; } catch (error) { diff --git a/src/lib/pythonProbe.test.ts b/src/lib/pythonProbe.test.ts new file mode 100644 index 0000000..5436478 --- /dev/null +++ b/src/lib/pythonProbe.test.ts @@ -0,0 +1,133 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { findKizenPython } from './kizenVenv.js'; +import { + CODE_STEP_MIN_PYTHON, + findCodeStepPython, + findPython, + probePython, +} from './pythonProbe.js'; + +/** What each fake binary prints for its version; a missing key is a binary that isn't installed. */ +const hooks = vi.hoisted(() => ({ + versions: {} as Record, + spawned: [] as string[][], +})); + +vi.mock('node:child_process', () => ({ + spawn: (bin: string, args: string[]) => { + hooks.spawned.push([bin, ...args]); + + const proc = Object.assign(new EventEmitter(), { stdout: new EventEmitter() }); + const version = hooks.versions[bin]; + + setImmediate(() => { + if (version === undefined) { + proc.emit('error', Object.assign(new Error(`spawn ${bin} ENOENT`), { code: 'ENOENT' })); + + return; + } + + proc.stdout.emit('data', Buffer.from(`${version}\n`)); + proc.emit('close', 0); + }); + + return proc; + }, +})); + +afterEach(() => { + hooks.versions = {}; + hooks.spawned = []; +}); + +const PROBE_ARGS = ['-c', "import sys; print('%d.%d' % sys.version_info[:2])"]; + +describe('probePython', () => { + it('reads major.minor from the interpreter, and null when it is missing', async () => { + hooks.versions = { python3: '3.13' }; + + await expect(probePython('python3')).resolves.toStrictEqual([3, 13]); + await expect(probePython('python9')).resolves.toBeNull(); + expect(hooks.spawned).toStrictEqual([ + ['python3', ...PROBE_ARGS], + ['python9', ...PROBE_ARGS], + ]); + }); +}); + +describe('findCodeStepPython (pythonExecutor, unchanged)', () => { + it('keeps the 3.10 minimum', () => { + expect(CODE_STEP_MIN_PYTHON).toStrictEqual([3, 10]); + }); + + it('prefers the runtime binary and stops there without probing python3', async () => { + hooks.versions = { 'python3.13': '3.13', python3: '3.12' }; + + await expect(findCodeStepPython('python-3-13')).resolves.toBe('python3.13'); + expect(hooks.spawned.map(([bin]) => bin)).toStrictEqual(['python3.13']); + }); + + it('falls back to python3, and probes only python3 for a runtime without a version', async () => { + hooks.versions = { python3: '3.10' }; + + await expect(findCodeStepPython('python-3-12')).resolves.toBe('python3'); + await expect(findCodeStepPython('python')).resolves.toBe('python3'); + expect(hooks.spawned.map(([bin]) => bin)).toStrictEqual(['python3.12', 'python3', 'python3']); + }); + + it('throws the same error text as before the extraction', async () => { + hooks.versions = { python3: '3.9' }; + + await expect(findCodeStepPython('python-3-13')).rejects.toThrow( + 'No Python >= 3.10 found for runtime "python-3-13". The local runner needs Python 3.10+ ' + + 'to install the bundled packages (e.g. psycopg 3.3), matching the remote code-runner ' + + '(3.12 / 3.13). Tried python3.13: not found, python3: 3.9 (too old). ' + + 'Install Python 3.12 or 3.13 and retry.', + ); + }); +}); + +describe('findPython', () => { + it('keeps the earlier candidate on a tie when preferring the newest', async () => { + hooks.versions = { a: '3.13', b: '3.13' }; + + await expect(findPython(['a', 'b'], [3, 12], 'newest')).resolves.toStrictEqual({ + found: { bin: 'a', version: [3, 13] }, + tried: [], + }); + }); +}); + +describe('findKizenPython', () => { + it('accepts Python 3.14 and prefers it over 3.12', async () => { + hooks.versions = { 'python3.14': '3.14', 'python3.12': '3.12', python3: '3.12' }; + + await expect(findKizenPython()).resolves.toStrictEqual({ + bin: 'python3.14', + version: [3, 14], + }); + }); + + it('prefers the newest even when it is the last candidate, with no upper cap', async () => { + hooks.versions = { 'python3.12': '3.12', python3: '3.15' }; + + await expect(findKizenPython()).resolves.toStrictEqual({ bin: 'python3', version: [3, 15] }); + expect(hooks.spawned.map(([bin]) => bin)).toStrictEqual([ + 'python3.14', + 'python3.13', + 'python3.12', + 'python3', + ]); + }); + + it('rejects Python 3.11 as too old and names every candidate it tried', async () => { + hooks.versions = { python3: '3.11' }; + + await expect(findKizenPython()).rejects.toThrow( + 'No Python 3.12 or newer found to run kizen. Tried python3.14: not found, ' + + 'python3.13: not found, python3.12: not found, python3: 3.11 (too old). ' + + 'Install Python 3.12 or newer and retry.', + ); + }); +}); diff --git a/src/lib/pythonProbe.ts b/src/lib/pythonProbe.ts new file mode 100644 index 0000000..85a0956 --- /dev/null +++ b/src/lib/pythonProbe.ts @@ -0,0 +1,131 @@ +/** + * Finding a system Python: probe each candidate binary for its version and pick one that's new + * enough. Shared by the code-step venv (`src/server/pythonExecutor.ts`) and the kizen venv + * (`kizenVenv.ts`). + */ +import { spawn } from 'node:child_process'; +import { resolvePythonBinary } from './pythonRuntime.js'; + +export type PythonVersion = readonly [major: number, minor: number]; + +export interface FoundPython { + bin: string; + version: PythonVersion; +} + +export function meetsMinimum([major, minor]: PythonVersion, minimum: PythonVersion): boolean { + return major > minimum[0] || (major === minimum[0] && minor >= minimum[1]); +} + +export function formatVersion(version: PythonVersion): string { + return `${String(version[0])}.${String(version[1])}`; +} + +const newer = (a: PythonVersion, b: PythonVersion): boolean => + a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]); + +// Probe an interpreter for its (major, minor) version. Resolves null if the +// binary is missing or can't run, so callers can try the next candidate. +export function probePython(bin: string): Promise<[number, number] | null> { + return new Promise((resolve) => { + const proc = spawn(bin, ['-c', "import sys; print('%d.%d' % sys.version_info[:2])"], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + + let out = ''; + + proc.stdout.on('data', (chunk: Buffer) => { + out += chunk.toString('utf-8'); + }); + proc.on('error', () => { + resolve(null); + }); + proc.on('close', (code) => { + const match = /(\d+)\.(\d+)/.exec(out); + + if (code !== 0 || !match) { + resolve(null); + + return; + } + + resolve([Number(match[1]), Number(match[2])]); + }); + }); +} + +export interface FindPythonResult { + /** Undefined when no candidate meets the minimum. */ + found: FoundPython | undefined; + /** One `: not found` or `: (too old)` per candidate rejected. */ + tried: string[]; +} + +/** + * Probes `candidates` in order against `minimum`. `first` stops at the first one that meets it; + * `newest` probes them all and picks the newest that does (the earlier candidate on a tie). + */ +export async function findPython( + candidates: readonly string[], + minimum: PythonVersion, + prefer: 'first' | 'newest', + probe: (bin: string) => Promise = probePython, +): Promise { + const tried: string[] = []; + let found: FoundPython | undefined; + + for (const bin of candidates) { + const version = await probe(bin); + + if (!version) { + tried.push(`${bin}: not found`); + + continue; + } + + if (!meetsMinimum(version, minimum)) { + tried.push(`${bin}: ${formatVersion(version)} (too old)`); + + continue; + } + + if (found === undefined || newer(version, found.version)) { + found = { bin, version }; + } + + if (prefer === 'first') { + break; + } + } + + return { found, tried }; +} + +// The bundled requirements (psycopg 3.3 in particular) require Python >= 3.10, +// matching the remote code-runner which only ships 3.12 / 3.13 images. A venv +// built on an older interpreter fails `pip install` with a cryptic +// "No matching distribution found for psycopg[binary]<4.0,>=3.3". +export const CODE_STEP_MIN_PYTHON: PythonVersion = [3, 10]; + +// Pick a system interpreter new enough to install the code-step venv's bundled +// requirements. Prefer the exact version the runtime asked for, then fall back +// to python3. Throws a clear, actionable error when nothing usable is found. +export async function findCodeStepPython( + runtime: string, + probe: (bin: string) => Promise = probePython, +): Promise { + const preferred = resolvePythonBinary(runtime); + const candidates = preferred === 'python3' ? ['python3'] : [preferred, 'python3']; + const { found, tried } = await findPython(candidates, CODE_STEP_MIN_PYTHON, 'first', probe); + + if (found !== undefined) { + return found.bin; + } + + throw new Error( + `No Python >= ${formatVersion(CODE_STEP_MIN_PYTHON)} found for runtime "${runtime}". ` + + `The local runner needs Python ${formatVersion(CODE_STEP_MIN_PYTHON)}+ to install the bundled ` + + `packages (e.g. psycopg 3.3), matching the remote code-runner (3.12 / 3.13). ` + + `Tried ${tried.join(', ')}. Install Python 3.12 or 3.13 and retry.`, + ); +} diff --git a/src/program.test.ts b/src/program.test.ts index 074223f..5cda2d8 100644 --- a/src/program.test.ts +++ b/src/program.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Command, Option } from 'commander'; import { ALL_VALID_ICONS_LIST, CUSTOM_ICON_NAMES } from '../shared/lib/validIcons.js'; +import type * as KizenAdminModule from './lib/kizenAdmin.js'; import type * as SetupClaudeModule from './lib/setupClaude.js'; import { mcpServerPlan, runSetupClaude } from './lib/setupClaude.js'; import { createProgram } from './program.js'; @@ -14,9 +15,66 @@ vi.mock('./lib/setupClaude.js', async (importOriginal) => ({ runSetupClaude: vi.fn(() => Promise.resolve()), })); +/** + * `admin` runs the real `runAdmin` (so the wrapper's own parsing is covered), with a fake session, + * provisioner and spawn in place of the boundaries. + */ +const admin = vi.hoisted(() => ({ + spawned: [] as string[][], + provisioned: 0, + helpShown: 0, +})); + +vi.mock('./lib/kizenAdmin.js', async (importOriginal) => { + const actual = await importOriginal(); + const { EventEmitter } = await import('node:events'); + + return { + ...actual, + runAdmin: vi.fn((args: readonly string[], deps: KizenAdminModule.AdminDeps) => + actual.runAdmin(args, { + ...deps, + store: { + listSessions: () => Promise.resolve(['staging']), + readSession: () => + Promise.resolve({ + businessId: '11111111-1111-4111-8111-111111111111', + userId: '22222222-2222-4222-8222-222222222222', + } as never), + }, + ensureKizen: () => { + admin.provisioned += 1; + + return Promise.resolve({ + version: '1.2.3', + dir: '/venv', + python: '/venv/bin/python', + kizen: '/venv/bin/kizen', + built: false, + }); + }, + spawn: (command, spawnArgs) => { + admin.spawned.push([command, ...spawnArgs]); + + const child = Object.assign(new EventEmitter(), { kill: () => true }); + + setImmediate(() => child.emit('close', 0, null)); + + return child as never; + }, + outputHelp: () => { + admin.helpShown += 1; + }, + writeStderr: () => undefined, + setExitCode: () => undefined, + }), + ), + }; +}); + const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -/** The thirteen commands, in the order `createProgram` registers them. */ +/** The fourteen commands, in the order `createProgram` registers them. */ const COMMAND_NAMES = [ 'create', 'setup-claude', @@ -31,6 +89,7 @@ const COMMAND_NAMES = [ 'logout', 'whoami', 'auth-header', + 'admin', ]; /** Every command's `--help` blurb, verbatim. */ @@ -50,6 +109,7 @@ const COMMAND_DESCRIPTIONS: Record = { whoami: 'Show the user and business of the stored session', 'auth-header': 'Print an Authorization header for the stored session as JSON (for a Claude Code MCP headersHelper)', + admin: "Run Kizen's admin CLI (kizen) with the signed-in session", }; /** The flag strings each command declares, in declaration order. */ @@ -89,7 +149,8 @@ const COMMAND_OPTION_FLAGS: Record = { login: ['--env ', '--business ', '--user ', '--list', '--json'], logout: ['--env ', '--all', '--business ', '--user '], whoami: ['--env '], - 'auth-header': ['--env '], + 'auth-header': ['--env ', '--business ', '--user '], + admin: ['--env '], }; const BLOCK_PUSH_FLAGS = [ @@ -176,7 +237,7 @@ describe('createProgram', () => { }); describe('command wiring', () => { - it('registers exactly the thirteen documented commands, in order', () => { + it('registers exactly the fourteen documented commands, in order', () => { expect(createProgram().commands.map((command) => command.name())).toStrictEqual(COMMAND_NAMES); }); @@ -434,3 +495,136 @@ describe('setup-claude --mcp helper', () => { expect(config.headersHelper).toBe(`npx -y ${pkg.name}@${pkg.version} auth-header`); }); }); + +describe('admin command', () => { + afterEach(() => { + admin.spawned = []; + admin.provisioned = 0; + admin.helpShown = 0; + }); + + const kizenArgs = async (args: string[]): Promise => { + await createProgram().parseAsync(['admin', ...args], { from: 'user' }); + + const [spawned] = admin.spawned; + + expect(spawned?.slice(0, 2)).toStrictEqual(['/venv/bin/python', '/venv/bin/kizen']); + + return spawned?.slice(2); + }; + + it.each([ + [ + ['status', '--help'], + ['status', '--help'], + ], + [['--version'], ['--version']], + [['-V'], ['-V']], + [ + ['--dry-run', 'apply', '-h'], + ['--dry-run', 'apply', '-h'], + ], + [ + ['--env', 'staging', 'status', '--env', 'go'], + ['status', '--env', 'go'], + ], + [ + ['--env=staging', 'x', '--', 'y'], + ['x', '--', 'y'], + ], + [['--', '--help'], ['--help']], + [['--', '--version'], ['--version']], + [ + ['--', '--', 'a'], + ['--', 'a'], + ], + // (o) kizen's own init, which it refuses under this mode. + [['--', 'init'], ['init']], + ])('(h) passes %j to kizen as %j', async (args, expected) => { + expect(await kizenArgs(args)).toStrictEqual(expected); + }); + + it('shows the wrapper help for --help before any kizen argument', async () => { + await createProgram().parseAsync(['admin', '--help'], { from: 'user' }); + await createProgram().parseAsync(['admin', '--env', 'staging', '-h'], { from: 'user' }); + + expect(admin.helpShown).toBe(2); + expect(admin.spawned).toStrictEqual([]); + expect(admin.provisioned).toBe(0); + }); + + it('`help admin` prints the admin help and neither provisions nor runs kizen', () => { + const program = createProgram(); + const command = subcommand(program, 'admin'); + let out = ''; + + command + .exitOverride() + .configureHelp({ helpWidth: 100 }) + .configureOutput({ + writeOut: (text) => { + out += text; + }, + }); + + expect(() => program.parse(['help', 'admin'], { from: 'user' })).toThrow( + expect.objectContaining({ code: 'commander.help' }), + ); + expect(out).toContain('Usage: appbuilder admin [--env ] [--] '); + expect(out).toContain('--env '); + expect(admin.provisioned).toBe(0); + expect(admin.spawned).toStrictEqual([]); + }); + + it('runs admin init through the shared provisioner without spawning kizen', async () => { + await createProgram().parseAsync(['admin', 'init'], { from: 'user' }); + + expect(admin.provisioned).toBe(1); + expect(admin.spawned).toStrictEqual([]); + }); + + it('documents its usage in its help', () => { + const command = subcommand(createProgram(), 'admin'); + + command.configureHelp({ helpWidth: 100 }); + + expect(command.helpInformation()).toContain( + 'Usage: appbuilder admin [--env ] [--] \n appbuilder admin init', + ); + }); +}); + +describe('(r) option parsing of other commands is unchanged', () => { + function capture(args: string[]): { out: string; code: string | undefined } { + const program = createProgram(); + let out = ''; + + program.exitOverride().configureOutput({ + writeOut: (text) => { + out += text; + }, + writeErr: (text) => { + out += text; + }, + }); + + try { + program.parse(args, { from: 'user' }); + } catch (error) { + return { out, code: (error as { code?: string }).code }; + } + + return { out, code: undefined }; + } + + it.each([[['dev', '-V']], [['block', 'push', '--version']], [['-V', 'admin']]])( + '%j still prints the version', + (args) => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8')) as { + version: string; + }; + + expect(capture(args)).toStrictEqual({ out: `${pkg.version}\n`, code: 'commander.version' }); + }, + ); +}); diff --git a/src/program.ts b/src/program.ts index b5481d2..f9f656d 100644 --- a/src/program.ts +++ b/src/program.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; +import { adminCommand } from './commands/admin.js'; import { authCommands } from './commands/auth.js'; import { blockCommand } from './commands/block.js'; import { buildCommand } from './commands/build.js'; @@ -46,5 +47,7 @@ export function createProgram(): Command { authCommands(program); + adminCommand(program); + return program; } diff --git a/src/server/pythonExecutor.ts b/src/server/pythonExecutor.ts index 497b692..5011aa0 100644 --- a/src/server/pythonExecutor.ts +++ b/src/server/pythonExecutor.ts @@ -8,7 +8,13 @@ import { isSupportedScriptRuntime, scriptRuntimeToApiName, } from '@kizenapps/packager'; -import { parseRequirementsFile, resolvePythonBinary } from '../lib/pythonRuntime.js'; +import { parseRequirementsFile } from '../lib/pythonRuntime.js'; +import { + CODE_STEP_MIN_PYTHON, + findCodeStepPython, + meetsMinimum, + probePython, +} from '../lib/pythonProbe.js'; import { type ExecutionResult, type HttpRequests, @@ -216,82 +222,6 @@ if (VENV_PACKAGES.length === 0) { let venvReady: Promise | null = null; -// The bundled requirements (psycopg 3.3 in particular) require Python >= 3.10, -// matching the remote code-runner which only ships 3.12 / 3.13 images. A venv -// built on an older interpreter fails `pip install` with a cryptic -// "No matching distribution found for psycopg[binary]<4.0,>=3.3". -const MIN_PYTHON: readonly [number, number] = [3, 10]; - -function meetsMinimum([major, minor]: [number, number]): boolean { - return major > MIN_PYTHON[0] || (major === MIN_PYTHON[0] && minor >= MIN_PYTHON[1]); -} - -function formatVersion(version: readonly [number, number]): string { - return `${String(version[0])}.${String(version[1])}`; -} - -// Probe an interpreter for its (major, minor) version. Resolves null if the -// binary is missing or can't run, so callers can try the next candidate. -function probePython(bin: string): Promise<[number, number] | null> { - return new Promise((resolve) => { - const proc = spawn(bin, ['-c', "import sys; print('%d.%d' % sys.version_info[:2])"], { - stdio: ['ignore', 'pipe', 'ignore'], - }); - - let out = ''; - - proc.stdout.on('data', (chunk: Buffer) => { - out += chunk.toString('utf-8'); - }); - proc.on('error', () => { - resolve(null); - }); - proc.on('close', (code) => { - const match = /(\d+)\.(\d+)/.exec(out); - - if (code !== 0 || !match) { - resolve(null); - - return; - } - - resolve([Number(match[1]), Number(match[2])]); - }); - }); -} - -// Pick a system interpreter new enough to install the bundled requirements. -// Prefer the exact version the runtime asked for, then fall back to python3. -// Throws a clear, actionable error when nothing usable is found. -async function findSystemPython(runtime: string): Promise { - const preferred = resolvePythonBinary(runtime); - const candidates = preferred === 'python3' ? ['python3'] : [preferred, 'python3']; - const tried: string[] = []; - - for (const bin of candidates) { - const version = await probePython(bin); - - if (!version) { - tried.push(`${bin}: not found`); - - continue; - } - - if (meetsMinimum(version)) { - return bin; - } - - tried.push(`${bin}: ${formatVersion(version)} (too old)`); - } - - throw new Error( - `No Python >= ${formatVersion(MIN_PYTHON)} found for runtime "${runtime}". ` + - `The local runner needs Python ${formatVersion(MIN_PYTHON)}+ to install the bundled ` + - `packages (e.g. psycopg 3.3), matching the remote code-runner (3.12 / 3.13). ` + - `Tried ${tried.join(', ')}. Install Python 3.12 or 3.13 and retry.`, - ); -} - function runCommand( bin: string, args: string[], @@ -394,7 +324,7 @@ function ensureVenv(runtime: string, listener?: VenvInstallListener): Promise Date: Thu, 22 Oct 2026 11:06:46 -0500 Subject: [PATCH 2/4] KZN-19166 - version bump --- CONTRIBUTING.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a57547..458a261 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ Adding a dependency has a licensing consequence worth knowing about before you r ## Releases -Publishing is automated. Every push to `main` publishes a prerelease to the `next` dist-tag, stamped as `-`; pushing a `v*` tag verifies that `package.json` matches the tag and publishes to `latest`. Don't bump versions by hand in a pull request. +Publishing is automated. Every push to `main` publishes a prerelease to the `next` dist-tag, stamped as `-`; pushing a `v*` tag verifies that `package.json` matches the tag and publishes to `latest`. A pull request bumps `version` in `package.json` itself, following semver: patch for fixes, minor for new commands or options, major for breaking changes. ## License diff --git a/package.json b/package.json index 6476d1d..ece7dd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kizenapps/cli", - "version": "1.16.0", + "version": "1.17.0", "description": "CLI tool for running Kizen plugin apps locally", "license": "GPL-3.0-only", "repository": { From ed5fc9c52ecd945813a1555c9a741859c1eeb47f Mon Sep 17 00:00:00 2001 From: Keegan Donley Date: Thu, 22 Oct 2026 11:59:09 -0500 Subject: [PATCH 3/4] KZN-19166 - handle SIGINT --- src/commands/admin.ts | 4 ++ src/lib/kizenAdmin.test.ts | 89 ++++++++++++++++++++++++++++++-------- src/lib/kizenAdmin.ts | 36 +++++++++++---- 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/commands/admin.ts b/src/commands/admin.ts index 7a3d4c6..8522c1a 100644 --- a/src/commands/admin.ts +++ b/src/commands/admin.ts @@ -34,11 +34,15 @@ export function createDefaultAdminDeps(command: Command): AdminDeps { writeStderr(`${ADMIN_PREFIX}: ${line}\n`); }, writeStderr); + // @types/node types `isTTY` as boolean, but it's undefined on a stream that isn't a TTY. + const stdin: { isTTY?: boolean } = process.stdin; + return { store: createOAuthStore(), ensureKizen: (options) => ensureKizen(venvDeps, options), spawn, signals: process, + interactive: stdin.isTTY === true, processEnv: process.env, execPath: process.execPath, scriptPath: process.argv[1], diff --git a/src/lib/kizenAdmin.test.ts b/src/lib/kizenAdmin.test.ts index 3e41739..8fa304a 100644 --- a/src/lib/kizenAdmin.test.ts +++ b/src/lib/kizenAdmin.test.ts @@ -70,6 +70,7 @@ function fakeDeps( signedIn?: boolean; exit?: [code: number | null, signal: NodeJS.Signals | null] | 'hold' | 'spawn-error'; processEnv?: Record; + interactive?: boolean; } = {}, ): Fake { const sessions = options.sessions ?? ['staging']; @@ -109,6 +110,7 @@ function fakeDeps( on: (signal: NodeJS.Signals, listener: () => void) => signals.on(signal, listener), off: (signal: NodeJS.Signals, listener: () => void) => signals.off(signal, listener), }, + interactive: options.interactive ?? true, processEnv: options.processEnv ?? { PATH: '/usr/bin' }, execPath: '/usr/local/bin/node', scriptPath: '/opt/appbuilder/dist/index.js', @@ -379,8 +381,8 @@ describe('runAdmin', () => { expect(deps.setExitCode).toHaveBeenCalledWith(143); }); - it('(i) swallows SIGINT without forwarding it, and still exits with the child code', async () => { - const { deps, children, signals } = fakeDeps({ exit: 'hold' }); + it('(i) interactive: swallows SIGINT without forwarding it, and still exits with the child code', async () => { + const { deps, children, signals } = fakeDeps({ interactive: true, exit: 'hold' }); const running = runAdmin(['status'], deps); await vi.waitFor(() => { @@ -390,6 +392,7 @@ describe('runAdmin', () => { // A listener is what keeps node alive on Ctrl-C; the terminal already sent kizen its own. expect(signals.listenerCount('SIGINT')).toBe(1); + signals.emit('SIGINT'); signals.emit('SIGINT'); const [child] = children; @@ -399,46 +402,94 @@ describe('runAdmin', () => { child?.emit('close', 130, null); await running; - expect(signals.listenerCount('SIGINT')).toBe(0); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + expect(signals.listenerCount(signal)).toBe(0); + } + expect(deps.setExitCode).toHaveBeenCalledWith(130); }); - it('(i) forwards SIGTERM and SIGHUP to kizen, then removes its listeners', async () => { - const { deps, children, signals } = fakeDeps({ exit: 'hold' }); + it('(i) non-interactive: forwards each SIGINT to kizen once, then removes its listeners', async () => { + const { deps, children, signals } = fakeDeps({ interactive: false, exit: 'hold' }); const running = runAdmin(['status'], deps); await vi.waitFor(() => { expect(children).toHaveLength(1); }); - signals.emit('SIGTERM'); - signals.emit('SIGHUP'); + expect(signals.listenerCount('SIGINT')).toBe(1); + + signals.emit('SIGINT'); const [child] = children; - expect(child?.kill.mock.calls).toStrictEqual([['SIGTERM'], ['SIGHUP']]); + expect(child?.kill.mock.calls).toStrictEqual([['SIGINT']]); - child?.emit('close', null, 'SIGTERM'); + signals.emit('SIGINT'); + + expect(child?.kill.mock.calls).toStrictEqual([['SIGINT'], ['SIGINT']]); + + child?.emit('close', null, 'SIGINT'); await running; for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { expect(signals.listenerCount(signal)).toBe(0); } - expect(deps.setExitCode).toHaveBeenCalledWith(143); + expect(deps.setExitCode).toHaveBeenCalledWith(130); }); - it('(i) a spawn error prints one line, exits 1 and removes its listeners', async () => { - const { deps, stderr, signals } = fakeDeps({ exit: 'spawn-error' }); + it.each([ + ['interactive', true], + ['non-interactive', false], + ])( + '(i) %s: forwards SIGTERM and SIGHUP to kizen, then removes its listeners', + async (_, interactive) => { + const { deps, children, signals } = fakeDeps({ interactive, exit: 'hold' }); + const running = runAdmin(['status'], deps); - await runAdmin(['status'], deps); + await vi.waitFor(() => { + expect(children).toHaveLength(1); + }); - expect(stderr).toStrictEqual([ - "appbuilder admin: couldn't start kizen: spawn /venvs/1.2.3-abc/bin/python ENOENT\n", - ]); - expect(deps.setExitCode.mock.calls).toStrictEqual([[1]]); - expect(signals.listenerCount('SIGINT')).toBe(0); - }); + signals.emit('SIGTERM'); + signals.emit('SIGHUP'); + + const [child] = children; + + expect(child?.kill.mock.calls).toStrictEqual([['SIGTERM'], ['SIGHUP']]); + + child?.emit('close', null, 'SIGTERM'); + await running; + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + expect(signals.listenerCount(signal)).toBe(0); + } + + expect(deps.setExitCode).toHaveBeenCalledWith(143); + }, + ); + + it.each([ + ['interactive', true], + ['non-interactive', false], + ])( + '(i) %s: a spawn error prints one line, exits 1 and removes its listeners', + async (_, interactive) => { + const { deps, stderr, signals } = fakeDeps({ interactive, exit: 'spawn-error' }); + + await runAdmin(['status'], deps); + + expect(stderr).toStrictEqual([ + "appbuilder admin: couldn't start kizen: spawn /venvs/1.2.3-abc/bin/python ENOENT\n", + ]); + expect(deps.setExitCode.mock.calls).toStrictEqual([[1]]); + + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + expect(signals.listenerCount(signal)).toBe(0); + } + }, + ); it('passes --force to the provisioner only from admin init --force', async () => { const { deps } = fakeDeps(); diff --git a/src/lib/kizenAdmin.ts b/src/lib/kizenAdmin.ts index 3d4ff50..89d3ac1 100644 --- a/src/lib/kizenAdmin.ts +++ b/src/lib/kizenAdmin.ts @@ -163,11 +163,16 @@ export const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGHUP' /** * Runs `/bin/python /bin/kizen ` with inherited stdio, forwarding SIGTERM and - * SIGHUP to it. SIGINT is swallowed, not forwarded: a terminal's Ctrl-C already reaches kizen - * through the foreground process group, and a second one would interrupt Python's - * KeyboardInterrupt cleanup. Swallowing it keeps this process alive to wait for kizen's exit. - * Resolves with its exit code, or 128 + the signal number when a signal ended it; rejects when it - * can't start. + * SIGHUP to it. SIGINT depends on `interactive` (stdin is a TTY), decided once at start: + * - interactive: swallowed, not forwarded. A terminal's Ctrl-C already reaches kizen through the + * foreground process group, and a second one would interrupt Python's KeyboardInterrupt + * cleanup. The listener keeps this process alive to wait for kizen's exit. + * - non-interactive: forwarded like SIGTERM, since a caller's `kill -INT` or + * `child.kill('SIGINT')` reaches only this process. The accepted tradeoff: with stdin + * redirected in a foreground terminal, a Ctrl-C reaches kizen twice. + * + * Resolves with kizen's exit code, or 128 + the signal number when a signal ended it; rejects + * when it can't start. Every listener is removed once it settles. * * The interpreter runs the console script rather than the script running itself: its shebang was * written while the venv was still at its temporary build path, which the publish rename leaves @@ -179,6 +184,7 @@ export function runKizen( env: Record, spawnKizen: SpawnKizen, signals: SignalTarget, + interactive: boolean, ): Promise { return new Promise((resolve, reject) => { let child: ChildProcess; @@ -191,15 +197,18 @@ export function runKizen( return; } + const forwarded: readonly NodeJS.Signals[] = interactive + ? FORWARDED_SIGNALS + : [...FORWARDED_SIGNALS, 'SIGINT']; const listeners = [ - ...FORWARDED_SIGNALS.map((signal) => { + ...forwarded.map((signal) => { const forward = (): void => { child.kill(signal); }; return [signal, forward] as const; }), - ['SIGINT', (): void => undefined] as const, + ...(interactive ? [['SIGINT', (): void => undefined] as const] : []), ]; for (const [signal, listener] of listeners) { @@ -243,6 +252,8 @@ export interface AdminDeps { ensureKizen: (options?: { force?: boolean }) => Promise; spawn: SpawnKizen; signals: SignalTarget; + /** Whether stdin is a TTY, so a Ctrl-C already reaches kizen and SIGINT isn't forwarded. */ + interactive: boolean; processEnv: Readonly>; /** `process.execPath` and `process.argv[1]`: how kizen runs `appbuilder auth-header`. */ execPath: string; @@ -364,7 +375,16 @@ export async function runAdmin(args: readonly string[], deps: AdminDeps): Promis const childEnv = kizenChildEnv(deps.processEnv, resolved.session); try { - deps.setExitCode(await runKizen(install, invocation.args, childEnv, deps.spawn, deps.signals)); + deps.setExitCode( + await runKizen( + install, + invocation.args, + childEnv, + deps.spawn, + deps.signals, + deps.interactive, + ), + ); } catch (error) { fail(deps, `couldn't start kizen: ${errorMessage(error)}`); } From 37e6652bbf2074eb7aacca29bce0d1998c6f4776 Mon Sep 17 00:00:00 2001 From: Keegan Donley Date: Thu, 22 Oct 2026 14:30:56 -0500 Subject: [PATCH 4/4] KZN-19138 - update icon definitions --- shared/lib/validIcons.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared/lib/validIcons.ts b/shared/lib/validIcons.ts index 3668bec..994f1df 100644 --- a/shared/lib/validIcons.ts +++ b/shared/lib/validIcons.ts @@ -293,6 +293,10 @@ export const VALID_ICONS = new Set([ 'chevron-up', 'window-restore', 'forward', + 'circle-check', + 'arrow-progress', + 'puzzle-piece', + 'code', ]); export const VALID_ICONS_LIST = [...VALID_ICONS].sort();