⚠️ Historical document. This file captures the original design from the CLI's pre-1.0 phase. Several decisions documented below have since been superseded — notably:
- Project linking has been removed. Sections referencing
photon link,~/.config/photon/links/<env>.json, or per-env link files no longer reflect the implementation. Project resolution is now--project <id>flag →$PHOTON_PROJECT_IDenv var → error.- Built-in env list is gone. Backend selection runs through
$PHOTON_API_HOST/--api-host <url>instead of a hardcoded production/staging/dev list.See the README for the current user-facing surface. This doc is preserved for context on rationale and trade-offs that informed earlier decisions.
Goal: replace the dashboard web UI for end-user interaction with a typed, ergonomic terminal experience. The binary is
photon(aliaspho); the npm package name is decided at publish time. This doc grounds the design in (a) the dashboard's actual API and web surface and (b) the established patterns ingh(GitHub CLI) andvercelCLI.
After photon-hq/dashboard@a440152 (post-bearer-plugin merge):
| Domain | Method + path | Notes |
|---|---|---|
Auth (mounted, auth.ts → better-auth + bearer() + deviceAuthorization()) |
POST /api/auth/sign-in/email |
email+password browser flow |
POST /api/auth/sign-up/email |
sign-up browser flow | |
POST /api/auth/sign-out |
session revoke | |
GET /api/auth/get-session |
session lookup (cookie or Authorization: Bearer) |
|
| OAuth Google routes | browser only | |
POST /api/auth/device/code |
RFC 8628 device-code request | |
POST /api/auth/device/token |
poll for access_token | |
POST /api/auth/device/approve |
(web) approve a code | |
POST /api/auth/device/deny |
(web) deny a code | |
GET /api/auth/device |
(web) verify a code | |
Profile (profile.ts) |
GET /api/profile |
returns {type:'developer'|'organization', profile} or null |
POST /api/profile/developer |
upsert developer profile | |
POST /api/profile/organization |
upsert org profile | |
OTP (otp.ts) |
POST /api/otp/email/send |
unauthed, unrate-limited (architecture-review S1) |
POST /api/otp/email/verify |
unauthed, unrate-limited | |
POST /api/otp/phone/send |
unauthed, unrate-limited | |
POST /api/otp/phone/verify |
authenticated | |
Projects (projects.ts) |
GET /api/projects |
list user's projects |
GET /api/projects/check-availability |
spectrum number probe | |
GET /api/projects/:id |
get one | |
POST /api/projects |
create (body: name, location, spectrum, template, observability) | |
PATCH /api/projects/:id |
update | |
DELETE /api/projects/:id |
delete | |
POST /api/projects/:id/regenerate-secret |
rotate projectSecret |
|
| Spectrum (sub-resource of project) | GET /api/projects/:id/spectrum/profile |
spectrum profile metadata |
PATCH /api/projects/:id/spectrum/profile |
update spectrum metadata | |
GET /api/projects/:id/spectrum/avatar-upload-url |
signed S3 PUT URL | |
GET /api/projects/:id/spectrum/users |
list users | |
POST /api/projects/:id/spectrum/users |
add user | |
DELETE /api/projects/:id/spectrum/users/:userId |
remove user | |
GET /api/projects/:id/platforms |
list integrated platforms | |
POST /api/projects/:id/platforms |
add platform | |
GET /api/projects/:id/lines |
list phone lines | |
POST /api/projects/:id/lines |
add line | |
DELETE /api/projects/:id/lines/:lineId |
remove line | |
Billing (billing.ts) |
GET /api/billing/plans |
available products |
POST /api/billing/checkout |
returns Stripe Checkout URL | |
GET /api/projects/:id/subscription |
current subscription | |
GET /api/projects/:id/subscription/poll |
long-poll Stripe sync (web only) | |
POST /api/projects/:id/subscription/manage |
returns Stripe Portal URL | |
| System | GET /api/health |
{status:"ok"} |
GET /api/info |
{environment} (excluded from public spec) |
💡 The
architecture-review.mdflags S1, S2, S3 as critical: OTP unrate-limited, web TS errors silenced, billing fallback resolves to"unknown"tier. None block CLI v1, but S3 affects howphoton billingrenders subscription state — flag it.
| Web page | User intent | CLI equivalent |
|---|---|---|
/(auth)/sign-up |
First-time signup with email+password+OTP+phone | web only — first-time users visit /(auth)/sign-up once; device-flow login takes over after |
/(auth)/sign-in |
Returning login | photon login (already shipped — device flow) |
/onboarding |
Choose developer-vs-organization profile | photon profile init (interactive prompts) |
/dashboard (root) |
List projects | photon projects ls ✅ |
/dashboard/new |
Create project (name, location, spectrum, template) | photon projects create |
/dashboard/[id] |
Project home / overview | photon projects show <id> ✅ |
/dashboard/[id]/settings |
Rename, danger zone, subscription overview | photon projects update, photon projects delete |
/dashboard/[id]/spectrum |
Manage Spectrum users / lines / platforms | photon spectrum users/lines/platforms <subcmd> |
/dashboard/[id]/billing |
View subscription + manage | photon billing show / checkout / manage (the latter two open browser) |
/dashboard/[id]/template |
Template gallery | photon template ls / use (low priority) |
/dashboard/[id]/observability |
Logs / traces | photon logs (out of scope v1; needs more API) |
/dashboard/[id]/debug |
Debug info | likely out of scope; admin-ish |
/device + /device/approve |
Device approve flow | already integrated server-side |
→ CLI v1 needs to cover everything except observability/debug/template-gallery and signup-from-zero (signup goes to web). That's still a big scope; we'll phase it.
Both gh and vercel converged on <noun> <verb> [args] [flags], with verbs being a small CRUD vocabulary plus a few specials. We adopt:
| Verb | Meaning | Aliases |
|---|---|---|
list |
enumerate things | ls |
show |
display one thing in detail | get, view |
create |
make a new thing | add, new |
update |
mutate a thing | edit, set |
delete |
remove a thing | rm, remove |
open |
open in browser (vercel-style) | — |
pull / push |
sync local↔remote (vercel pull/pull-env) |
— |
Default verb is list for collection nouns, like vercel: vercel project ≡ vercel project ls. Already used today in env list.
Both CLIs support two paths: interactive login + token for CI.
| gh | vercel | Photon CLI | |
|---|---|---|---|
| Interactive login | gh auth login (browser flow) |
vercel login (email magic link) |
photon login (RFC 8628 device flow) ✅ |
| CI / scriptable | GH_TOKEN env, --with-token flag |
VERCEL_TOKEN env, --token flag |
PHOTON_TOKEN env, --token flag (TODO) |
| Multi-account | per-host (--hostname) |
per-team (--scope) |
per-environment ✅ |
| Status | gh auth status |
vercel whoami |
photon whoami ✅ + photon auth status (TODO: cross-env) |
| Logout | gh auth logout |
vercel logout |
photon logout ✅ |
Add for v1.5: --token <t> flag and PHOTON_TOKEN env, both bypassing stored credentials. Critical for CI (architecture-review will probably want CLI-driven seeding tests).
gh defines a strong pattern that has been broadly adopted:
--jsonwith no fields lists available fields and exits.--json field1,field2returns structured JSON.--jq <expr>filters server-side (jq syntax, no system jq required — they bundle it).--template <go-template>for shell-friendly output.
vercel is simpler: most commands have --format json flag. Less expressive, less effort.
Recommendation for Photon CLI: start with vercel-style --json (boolean) per command + accept piping to jq. Adopt gh's --jq integration if community asks. Don't bother with go templates.
The CLI keeps an "active project" so most commands don't need --project <id> on every invocation. Two viable storage models:
| Model | Where | Example |
|---|---|---|
| Per-cwd (vercel-style) | <cwd>/.vercel/project.json written by vercel link |
Each repo can be linked to a different project. cd-ing switches context. |
| Per-env, in user config (Photon's choice) | ~/.config/photon/links/<env>.json written by photon link |
One active project per environment, regardless of cwd. Mirrors per-env credentials. |
Photon adopts the per-env user-config model because:
- It mirrors per-env credentials (
credentials/<env>.jsonalready exists;links/<env>.jsonlives next to it). Single mental model: "currently active project on currently active env." - Photon's expected user is closer to
gh's (occasional ops, scripting, debugging across multiple deploys) than vercel's (one repo per project). - No
.dashboard/(or any directory) clutter in user repos. Nothing to.gitignore. - Switching env via
photon env use stagingautomatically picks up staging's linked project — no risk of cross-env operations.
Trade-off: can't have repo A linked to project A while repo B is linked to project B at the same time. If users actually request this later, layer .photon/project.json on top with priority: cwd file → user-config link → ... — but don't preempt.
# Without linking — every command needs --project:
photon spectrum users ls --project abc123
photon projects show --project abc123
photon billing show --project abc123
# With linking, scoped to the active env:
photon env use staging
photon link abc123 # writes ~/.config/photon/links/staging.json
photon spectrum users ls # implicit: staging + project abc123
photon projects show # same
# Switch env, the link follows the env:
photon env use production
photon link xyz789 # writes ~/.config/photon/links/production.json
photon projects show # implicit: production + xyz789Resolution order:
--project <id>flag (highest)PHOTON_PROJECT_IDenv var~/.config/photon/links/<active-env>.json- (none → error: No project linked for env
"<env>". Runphoton link <id>or pass--project <id>.)
Both gh and vercel detect process.stdout.isTTY:
- TTY: spinners, prompts, colors, banner ASCII.
- Non-TTY (pipe, CI): silent, no prompts (require flags or fail), no colors (also
NO_COLOR=1).
vercel global flag --yes skips confirmation prompts ("are you sure?"). gh uses --force in some places. We should adopt --yes/-y since vercel's idiom is more common in the broader CLI ecosystem.
Both CLIs hand off to browser when the operation is fundamentally interactive:
vercel openopens project in dashboardvercel loginopens browser for OAuthgh pr view --webopens PR page
Our analogues:
photon loginopens device-approve page ✅photon billing checkoutopens Stripe checkout (already planned)photon billing manageopens Stripe portalphoton projects open <id>— opens project page in dashboard web (NEW idea, vercel-inspired)
vercel api <path> and gh api <path> let power users hit the API directly with auth handled. Not v1, but worth noting — when something isn't covered by a typed command, the escape hatch saves the day.
photon api /api/projects/abc/spectrum/users
photon api /api/projects -X POST -F name=testReading order: top to bottom roughly mirrors a new user's discovery path.
| Command | Status | Notes |
|---|---|---|
photon login [--env] [--no-browser] |
✅ | RFC 8628 device flow. Per-env credentials. |
photon logout [--env] |
✅ | server signOut + clear local. |
photon whoami [--env] |
✅ | validates session via /api/profile. |
photon auth status |
TODO | shows status across all envs (which are logged in, when). |
photon auth refresh |
future | token refresh — not supported by better-auth device-auth today; track upstream. |
| Command | Status | Notes |
|---|---|---|
photon env list |
✅ | table with current marker + logged-in flag. |
photon env use <name> |
✅ | persists currentEnv. |
photon env add <name> <url> |
✅ | custom env. |
photon env remove <name> (rm) |
✅ | custom env only. |
photon env current |
✅ | print active env. |
| Command | Status | Notes |
|---|---|---|
photon link <id> |
TODO | writes ~/.config/photon/links/<env>.json; per-env active project. |
photon unlink |
TODO | removes the file. |
photon link --status |
TODO | shows current link, if any. |
After this lands, --project <id> becomes optional on every command below.
| Command | Status | Notes |
|---|---|---|
photon profile show |
✅ | renders developer or organization profile. |
photon profile init |
TODO | interactive prompt → calls /api/profile/developer or /organization. Used during onboarding. |
photon profile update [--field=value...] |
TODO | non-interactive PATCH-style update. |
| Command | Status | Notes |
|---|---|---|
photon projects list (ls) |
✅ | table + --json. |
photon projects show <id> (get) |
✅ | detail + --json. |
photon projects create [--name] [--location] [--spectrum] [--template] |
TODO | interactive if flags missing; non-interactive with all flags. |
photon projects update [<id>] --name <new> (edit) |
TODO | uses linked project if <id> omitted. |
photon projects delete [<id>] [--yes] (rm) |
TODO | requires --yes in non-TTY; confirmation prompt in TTY. |
photon projects regenerate-secret [<id>] |
TODO | rotates projectSecret. Destructive — same --yes rule. |
photon projects open [<id>] |
TODO | opens project page in browser. |
photon projects link [<id>] |
TODO | alias of photon link for discoverability. |
photon projects check-phone <number> |
TODO | calls /check-availability. |
The CLI groups Spectrum by sub-noun (user/platform/line) since each has its own CRUD lifecycle.
| Command | Status | Notes |
|---|---|---|
photon spectrum profile show |
TODO | spectrum-side metadata. |
photon spectrum profile update [--field] |
TODO | mirrors web's spectrum settings panel. |
photon spectrum users list (ls) |
TODO | |
photon spectrum users add [--email] [--phone] [--first-name] [--last-name] [--invite] |
TODO | interactive if missing flags. |
photon spectrum users remove <user-id> (rm) |
TODO | --yes for non-TTY. |
photon spectrum platforms list (ls) |
TODO | |
photon spectrum platforms add [...] |
TODO | |
photon spectrum lines list (ls) |
TODO | |
photon spectrum lines add [...] |
TODO | |
photon spectrum lines remove <line-id> (rm) |
TODO | |
photon spectrum avatar upload <file> |
TODO | gets signed S3 URL, PUTs file. |
| Command | Status | Notes |
|---|---|---|
photon billing plans |
TODO | --json for scripting. |
photon billing show [<id>] |
TODO | current subscription summary. |
photon billing checkout [<id>] [--plan <id>] [--qty N] |
TODO | gets Stripe URL → open() it (with --no-browser opt-out). |
photon billing manage [<id>] |
TODO | gets Stripe portal URL → open() it. |
| Command | Status | Notes |
|---|---|---|
photon ping [--env] [--url] |
✅ | /api/health. |
photon api <path> [-X METHOD] [-d body] [-F field=val] |
TODO (v2) | raw authed call. |
photon --version |
✅ | |
photon help [<cmd>] |
✅ via commander | |
photon config show |
TODO | dumps current config + active env + linked project (no secrets). |
| Flag | Env var | Effect |
|---|---|---|
-e, --env <name> |
PHOTON_ENV |
override active env |
-p, --project <id> |
PHOTON_PROJECT_ID |
override linked project |
-t, --token <token> |
PHOTON_TOKEN |
bypass stored creds (CI) |
--json |
— | output JSON instead of formatted text (per-command, opt-in) |
--yes, -y |
— | skip confirmation prompts (required in non-TTY for destructive ops) |
--no-browser |
— | don't auto-open browser (login, billing, projects open) |
--no-color |
NO_COLOR=1 (preferred), PHOTON_NO_COLOR=1 |
disable colors. Honors the NO_COLOR standard; the namespaced var is a fallback. |
--debug |
PHOTON_DEBUG=1 |
verbose output incl. HTTP request/response |
-v, --version |
— | print version + exit |
-h, --help |
— | per-command help |
Default (TTY):
- Colors via
picocolors - Tables via
cli-table3 - Spinners + prompts via
@clack/prompts - Quiet success: just
✓ Logged in to staging as soleil@photon.codes— no banner - Errors:
✗ <message>in red, exit 1
Pipe / non-TTY:
- No colors, no spinners
- For collection commands (
ls): plain TSV-style fallback (or recommend--json) - For mutation commands: refuse without explicit flags (e.g.,
--yes)
--json (planned target — current commands print human text via die(); switching the error path to JSON when --json is set is part of Phase 8 polish):
- Output is exactly the API response body (or array of items). No wrapping.
- Errors:
{ "error": { "message": "...", "status": 401 } }instead of friendly text. Exit 1.
Inspired by Rust's compiler messages and gh:
✗ Project not found: abc123
Hint: list available projects with `photon projects ls`
Env: staging (https://staging-app.photon.codes)
Each error has:
✗ <one-line>(red)- Optional hint (dim) — what to try next
- Optional context (dim) — env, linked project, etc.
For 401:
✗ Session expired for staging.
Run: photon login --env staging
For network failures:
✗ Could not reach https://api.photon.codes — Unable to connect.
Hint: check your connection or pass --env to use a different one.
Adopt update-notifier. On every command after fetch (cached for 24h), if a new version is available print:
┌─────────────────────────────────────────────────┐
│ Update available 0.1.0 → 0.2.0 │
│ Run: bun install -g <package-name> │
└─────────────────────────────────────────────────┘
Disabled in non-TTY and via PHOTON_NO_UPDATE_NOTIFIER=1.
vercel has it (opt-in), gh doesn't. Not worth the privacy / engineering trade-off until we have actual usage.
src/
├── index.ts ← commander wiring
├── lib/
│ ├── env.ts ← built-in envs + path-traversal-safe name validation
│ ├── config.ts ← persistent {currentEnv, customEnvs}
│ ├── credentials.ts ← per-env access tokens (chmod 600)
│ ├── auth-client.ts ← better-auth/client + deviceAuthorizationClient
│ ├── api.ts ← Eden treaty + Bearer injection
│ ├── output.ts ← picocolors + cli-table3 + die()
│ ├── errors.ts ← typed errors
│ └── types.ts ← DTOs (workaround for Eden type degradation)
├── commands/
│ ├── env.ts
│ ├── login.ts
│ ├── logout.ts
│ ├── whoami.ts
│ ├── projects.ts ← will grow with create/update/delete/regenerate
│ ├── profile.ts
│ ├── ping.ts
│ └── hello.ts ← dev placeholder, drop before v1
└── types/
└── api.d.ts ← vendored from @photon-dashboard/api-public
src/
├── lib/
│ ├── link.ts ← ~/.config/photon/links/<env>.json r/w
│ ├── interactive.ts ← @clack/prompts wrappers (TTY-aware)
│ ├── browser.ts ← `open()` wrapper with --no-browser respect
│ └── api-context.ts ← resolveProject(): flag → env → linked → error
└── commands/
├── link.ts ← photon link / unlink
├── billing.ts ← plans / show / checkout / manage
├── spectrum/
│ ├── index.ts ← `photon spectrum` group
│ ├── users.ts
│ ├── platforms.ts
│ ├── lines.ts
│ ├── profile.ts
│ └── avatar.ts
└── api.ts ← raw passthrough (v2)
The @photon-ai/dashboard-api package gives us Eden treaty types. Some endpoints' response types may degrade to Record<string, any> when handlers infer from Drizzle; when that happens we work around it with hand-rolled DTOs in src/lib/types.ts. Two paths to upgrade:
- Server adds
response: t.Object(...)schemas (tracked at apps/api side, low priority). - CLI keeps growing
lib/types.tsas new endpoints are integrated.
Either is fine. Both improve over time without churn.
- Foundations: env system, per-env credentials, Eden client with Bearer, output utilities
- Auth:
login(device flow),logout,whoami, full RFC 8628 with 429 backoff - Env:
list / use / add / remove / current - Projects:
ls/show(with--json) - Profile:
show(with--json) ping(health check)- E2E verified against staging
photon link/unlinkphoton projects create / update / delete / regenerate-secretphoton projects open(browser)photon profile init / update- Add
--tokenflag +PHOTON_TOKENenv - Add
--yesfor destructive ops; require it in non-TTY - Add
--debugfor HTTP tracing
Estimated: ~6-8 hours.
photon spectrum users / platforms / lines / profile / avatar- Decide whether to expose
check-phoneasphoton projects check-phone <num>orphoton spectrum check <num>. Recommendation: latter (it's a Spectrum capability).
Estimated: ~4-6 hours, biggest set of endpoints.
photon billing plansphoton billing show— render subscription with the S3 caveatphoton billing checkout— open Stripe URLphoton billing manage— open portal
Estimated: ~2 hours.
- Update notifier
--no-color+NO_COLOR=1standard supportphoton auth status(cross-env summary)photon config show- Polish error UX with hints + context
Estimated: ~2 hours.
bun publishto npm- GitHub Actions release workflow
- README quickstart + screenshots
npx @photon/cli loginno-install onboarding path
photon api <path>raw passthroughdashboard alias setuser-defined shortcuts (gh-style)- Telemetry (only if we have a real reason)
photon logs(needs server-side log streaming API first)- Auto-update flow
The five questions originally listed here have been answered — see
cli-build-plan.md §0 for the
final decisions and rationale.
Two product questions remain open (read of db/schema.ts for the developer/
organization profile field set; read of each spectrum POST handler for body
shapes). They're resolved at the start of Phase 6 / Phase 7 respectively, not
blockers for Phase 5. See cli-build-plan.md §8.
- GitHub CLI manual — command grouping +
--json/--jq/--templatepatterns - GitHub CLI output formatting — how
--jsondiscovery works - Vercel CLI — full command surface (~50 commands)
- Vercel CLI global options —
--token,--scope,--project,--cwd,--no-color, etc. - NO_COLOR standard
- RFC 8628 — Device Authorization Grant
- Better Auth — Device Authorization plugin
- photon-hq/dashboard architecture review — S1/S2/S3 + 9 majors