@azx-pbc/helix-cli
v0.3.0
Published
Deploy CLI for Helix, the AZX App Platform
Readme
helix — Helix deploy CLI
helix is a per-app CLI, like git or vercel: you run it from inside an
app's directory. It reads that app's helix.json, zips the build output, uploads
it to the portal as a new version, and manages the live pointer.
The mental model
my-app/
helix.json ← helix reads this from the current working directory
dist/ ← the folder helix zips and uploads (configurable)Everything keys off the current working directory. cd into your app, then
run helix <command>. There is no "select an app" flag you normally need — the app
is wherever you're standing.
Configuration
Each setting is resolved flags → environment → helix.json → built-in default
(first match wins):
| Setting | Flag | Env | helix.json key | Default |
| ---------- | -------------- | ------------------ | ---------------- | -------------------------- |
| App slug | --slug | — | slug | (required) |
| Portal URL | --portal-url | HELIX_PORTAL_URL | portalUrl | http://localhost:3001 |
| Build dir | --dir | — | dir | dist |
| Auth token | --token | HELIX_TOKEN | — | (helix login if unset) |
A helix.json looks like:
{ "slug": "my-app", "dir": "dist", "portalUrl": "https://portal.example.com" }portalUrl is the one you cannot leave to the default. http://localhost:3001
is right only for a portal running on your own machine; against a deployed one,
set portalUrl (or HELIX_PORTAL_URL, or --portal-url) or every command —
starting with helix login — fails to connect to a portal that was never there.
Your portal prints the exact file to copy under How to develop → On your
machine. The file is the form worth checking in: login, create, deploy
and promote all resolve it the same way, so it is set once.
The repo's examples/*/helix.json deliberately omit it — a checked-in portal URL
would be wrong for every deployment but one — so to deploy an example, add
portalUrl to its file or export HELIX_PORTAL_URL first.
--dir is resolved relative to the current working directory, and so is
helix.json — another reason to run helix from the app directory.
Commands
helix login # browser sign-in (OIDC device flow)
helix logout # forget the cached tokens
helix whoami # who the portal thinks you are
helix create [--display-name <name>] [--visibility <v>] # register the app
helix deploy [--dir <dir>] [--bundle <zip>] [--promote] # upload a version
helix versions # list versions
helix promote <number> # make a version live
helix rollback [number] # revert the live pointer
helix skill [--path <file>] # write this deployment's agent skill to diskdeploy uploads the bundle as a preview; --promote flips it live in the
same step (architecture §5.1). visibility is internal | group:<id>[,<id>…] | password
| public.
helix skill fetches this deployment's rendered agent skill (GET /api/v1/skill,
ADR-0036) — the same document the portal's How to develop modal hands out, with
this deployment's hosts, servable models, and approval baselines already substituted
in. It needs no app slug or helix.json, only a portal URL and a token. Defaults to
./SKILL.md; --path .claude/skills/helix/SKILL.md drops it where a Claude Code
agent reads it off disk.
Breaking in 0.2.0: the
privatevisibility mode was renamed tointernal. It never checked which user signed in, only that someone had. Passing the old value now errors rather than mapping to the new one, deliberately: the name is reserved for a future owner-only mode, so a silent alias would come to mean the opposite of what it says. Update scripts to useinternal.
Authentication (M3)
Two paths, in precedence order:
- Static token —
HELIX_TOKEN/--token. Sends the value as a bearer token verbatim. This is the CI/scripts path, and also how the portal's dev-token stub keeps working (HELIX_TOKEN=$PORTAL_DEV_TOKEN). It is never accepted by a production portal. helix login— the OIDC device flow. The CLI asks the portal (GET /api/v1/auth/config) which issuer to use (the local dev IdP on:3002in dev; Entra later), prints a verification URL + code, and polls while you approve in a browser. Tokens land in~/.config/helix/tokens.json(mode 0600, keyed by issuer) and are silently renewed with the refresh token. On 401, nothing is auto-launched — agents run headless; the error says to runhelix login.
Running it
Installed from npm
npm i -g @azx-pbc/helix-cli
cd my-app
export HELIX_TOKEN="…"
helix deploy --promoteNeeds Node 22.12+ (or 24+). The engines range and the bundle's esbuild
target move together (see scripts/build.mjs), so the declared floor is real:
older runtimes may not merely warn, they may fail to parse it.
0.0.0 is a deprecated placeholder that exists only because npm requires a
package to exist before a trusted publisher can be attached to it. Every real
version is 0.1.0 or later and carries a provenance attestation. npm filters
registry versions by engines against the running runtime, so before 0.3.0
declared 22.12+ an install on Node 22 fell all the way back to that
placeholder — the reason a supported-looking Node could yield a
non-functional package.
From this monorepo today
Build the real binary once and link it; from then on helix behaves exactly as
it will when installed from npm:
pnpm --filter @azx-pbc/helix-cli build
npm link ./packages/cli # puts `helix` on your PATH
cd examples/hello-world
export HELIX_TOKEN="$PORTAL_DEV_TOKEN" # the portal's dev-token stub
helix create --display-name "Hello World"
helix deploy --promote
helix versionsRun it from your app directory so helix.json and a relative --dir
resolve against the app, not the repo.
Without linking, node packages/cli/dist/helix.js <cmd> works the same way. To
skip the build during CLI development, node --import tsx packages/cli/src/bin.ts <cmd>
runs straight from source.
About pnpm --filter @azx-pbc/helix-cli helix -- <cmd>
This form runs the CLI's dev script through pnpm. It works for flags now (the
CLI strips the -- that pnpm forwards — see src/args.ts), but pnpm runs the
script in packages/cli, not your app. So it will not find your app's
helix.json, and a relative --dir resolves against packages/cli. Use it only
for --help or with explicit --slug + an absolute --dir:
pnpm --filter @azx-pbc/helix-cli helix -- deploy --slug my-app \
--dir /abs/path/to/my-app/dist --promoteFor real deploys, prefer running from the app directory (npm link, above).
Packaging
This is the only package in the repo that emits JS. Everything else runs
from TypeScript source via tsx and is private: true; a published CLI can't.
pnpm build runs scripts/build.mjs, which esbuild-bundles src/bin.ts into a
single dist/helix.js with a #!/usr/bin/env node banner. Two things make that
the right shape rather than a tsc --outDir:
@azx-pbc/sharedgets inlined. It's a privateworkspace:*package whoseexportspoint straight at./src/index.ts, and the edge/portal/egress all consume it as raw TS deliberately. Publishing must not force a build+dist+d.ts ontosharedfor one consumer, and must not ship a manifest depending on@azx-pbc/[email protected]— a version no registry has. Bundling solves both, sosharedis a devDependency here, not a dependency.- No tsx at runtime. The
binused to point atsrc/bin.tsbehind a#!/usr/bin/env -S tsxshebang whiletsxwas only a devDependency, so a real global install would have been broken on arrival.
archiver, openid-client, and zod stay external and install from the
registry — bundling archiver's transitive tree buys nothing.
CI's package job builds, runs pnpm pack, asserts the tarball ships dist/
and no src/, then globally installs the tarball in a clean prefix with tsx
off PATH and runs helix --help. That last step is what actually proves
publishability; the unit tests never touch the bundle. It runs on every PR, so
a broken artifact fails before a release is ever cut.
Releasing
Releases are cut by tag and published by
.github/workflows/release-cli.yml:
cd packages/cli
npm version patch # or minor — edits package.json only
cd ../.. && git commit -am "release(cli): v0.1.1"
git tag cli-v0.1.1 && git push && git push --tagsThe workflow re-runs the whole build → pack → assert → global-install sequence
against the tag, refuses to publish if the tag and package.json disagree, and
then publishes with provenance.
Three things to know before touching it:
- The tag prefix is
cli-v, notv.v*is the platform's version and already drives the container-image builds inci.yml. The CLI versions independently. - There is no
NPM_TOKEN. Auth is npm trusted publishing (OIDC): npmjs.com has a registered trust relationship withAZX-PBC-OSS/helix+release-cli.yml. Renaming or moving that workflow file breaks publishing until the registration is updated — that narrowness is the point. - It packs with pnpm and publishes with npm. Only pnpm rewrites
catalog:andworkspace:*into real ranges; npm is the client whose OIDC support is documented and reliable. Each does the half it's good at.
See ADR-0032 for why public npm rather than GitHub Packages.
