@workser/cli 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3655,6 +3655,141 @@ var import_picocolors2 = __toESM(require_picocolors(), 1);
3655
3655
 
3656
3656
  // src/help-content.ts
3657
3657
  var HELP_TOPICS = [
3658
+ {
3659
+ topic: "analysis",
3660
+ title: "Analysis \u2014 running Python on this project's data",
3661
+ summary: "Run pandas locally under a sandbox and a clock, so the code, the output and the timing land in the task where the owner can see them.",
3662
+ commands: ["analysis"],
3663
+ source: "skills/workser/reference/analysis.md",
3664
+ body: `# Analysis \u2014 running Python on this project's data
3665
+
3666
+ \`\`\`
3667
+ workser analysis runtime [--app <webAppId>]
3668
+ workser analysis run --app <webAppId> --file report.py [--timeout <ms>]
3669
+ workser analysis run --app <webAppId> --code 'print(1)'
3670
+ \`\`\`
3671
+
3672
+ ## Why not just run \`python\` yourself
3673
+
3674
+ Two reasons, and the second matters more.
3675
+
3676
+ It runs inside the same OS sandbox a structured agent run gets, scoped to the
3677
+ app's own folder \u2014 so a script that goes wrong goes wrong in one directory.
3678
+
3679
+ And it is **recorded**. The code, the output and how long it took land in the
3680
+ task, where the owner can see them. An analysis nobody can see is an assertion,
3681
+ which is the same problem \`workser api call\` solves for a service with no
3682
+ screen. If a number is going to end up in front of the customer, run it here.
3683
+
3684
+ ## Check the runtime before you write the script
3685
+
3686
+ \`\`\`
3687
+ workser analysis runtime --app <id> --json
3688
+ \`\`\`
3689
+
3690
+ It reports the interpreter that would be used \u2014 the app's own \`.venv\` first, if
3691
+ it has one \u2014 and whether \`pandas\` and \`matplotlib\` are importable. Exit code is
3692
+ non-zero when Python is missing, so you find out in a second rather than after
3693
+ writing a hundred lines.
3694
+
3695
+ ## Limits, said plainly
3696
+
3697
+ Five minutes by default, fifteen at most. Output is capped per stream and
3698
+ truncation is reported. Nothing is silently dropped.
3699
+
3700
+ These are local limits and they are the right ones: this work does **not** go in
3701
+ a deployed function. \`maxDuration\` is for a slow request; an analysis reads a lot
3702
+ of rows and takes as long as it takes, so putting it behind a serverless timeout
3703
+ means the useful analyses are exactly the ones that fail.
3704
+
3705
+ ## What counts as evidence
3706
+
3707
+ A number on its own is not a finding. When you report a result, say what the
3708
+ query was, how many rows it covered, and what window of time \u2014 a figure with no
3709
+ denominator is the easiest thing in this product to get wrong and the hardest
3710
+ for the owner to check.
3711
+ `
3712
+ },
3713
+ {
3714
+ topic: "api",
3715
+ title: "Services \u2014 calling one, and describing it",
3716
+ summary: "Call this project's API through the same console the owner sees, and make sure every route it serves is written down before you call the task done.",
3717
+ commands: ["api"],
3718
+ source: "skills/workser/reference/api.md",
3719
+ body: `# Services \u2014 calling one, and describing it
3720
+
3721
+ A service has no screen. Everything else you build can be looked at; an API can
3722
+ only be *called* \u2014 so unless the calls go somewhere the owner can see, "the API
3723
+ works" is an assertion with nothing behind it.
3724
+
3725
+ \`\`\`
3726
+ workser api list [--app <webAppId>]
3727
+ workser api call <path> [--app <id>] [--method <verb>] [--body <text>]
3728
+ [--header 'Name: value'] [--env local|preview|production]
3729
+ workser api spec [--check]
3730
+ \`\`\`
3731
+
3732
+ ## Call it through the console, not through curl
3733
+
3734
+ \`workser api call\` goes through the same request console the owner has open. The
3735
+ status, the timing and the body you see are the ones they see, and the
3736
+ credentials come from the app's own environment rather than from your command
3737
+ line \u2014 so a token never lands in a transcript.
3738
+
3739
+ \`\`\`
3740
+ workser api call /orders --app <id> --json
3741
+ workser api call /orders --app <id> --method POST --body '{"item":"latte"}' --json
3742
+ \`\`\`
3743
+
3744
+ \`--env\` picks which copy to call. \`local\` is the dev server on this machine and
3745
+ is the default; \`preview\` and \`production\` are the deployments. The host always
3746
+ comes from that choice \u2014 a path is a path, never a URL, and passing one is
3747
+ refused.
3748
+
3749
+ Exit code: non-zero only when nothing answered. A 404 or a 422 is a *successful*
3750
+ call with an informative answer, so checking that a route correctly rejects bad
3751
+ input works exactly as you would expect.
3752
+
3753
+ ## Save the calls that matter
3754
+
3755
+ Requests saved at \`api/requests.json\` in the service's repo show up in the
3756
+ owner's console. Write the handful that describe what the service does \u2014 not a
3757
+ test suite:
3758
+
3759
+ \`\`\`json
3760
+ [
3761
+ { "id": "list-orders", "name": "List today's orders", "method": "GET",
3762
+ "path": "/api/orders", "note": "What the shop screen loads." },
3763
+ { "id": "place-order", "name": "Place an order", "method": "POST",
3764
+ "path": "/api/orders", "body": "{\\"item\\":\\"latte\\"}" }
3765
+ ]
3766
+ \`\`\`
3767
+
3768
+ They are in the repo on purpose: a call worth saving outlives the session that
3769
+ saved it, and it shows up in the diff.
3770
+
3771
+ ## Describe every route before you call it done
3772
+
3773
+ \`\`\`
3774
+ workser api spec --check
3775
+ \`\`\`
3776
+
3777
+ It compares the routes the repo actually serves \u2014 read from the file layout, so
3778
+ it cannot be fooled by a comment \u2014 with the paths your OpenAPI document
3779
+ declares, and fails when one is missing. Write the document at
3780
+ \`api/openapi.json\` (YAML also works).
3781
+
3782
+ It is not an OpenAPI validator and does not check schemas or responses. It asks
3783
+ one question, so that it is cheap enough to run every time: is every route
3784
+ written down. A health probe is exempt. A path in the spec that the repo does
3785
+ not serve is reported but does not fail \u2014 you may be documenting something
3786
+ built next.
3787
+
3788
+ Run it alongside \`workser verify\` before declaring an API task finished. An API
3789
+ somebody can call and an API somebody can integrate with are different products,
3790
+ and the spec is the difference.
3791
+ `
3792
+ },
3658
3793
  {
3659
3794
  topic: "automation",
3660
3795
  title: "Workflows & connected apps",
@@ -3770,6 +3905,39 @@ other surface the brand feeds.
3770
3905
 
3771
3906
  For generating artwork *in* the brand's palette, see \`workser help images\` \u2014
3772
3907
  put the colours from \`design show\` into the prompt.
3908
+
3909
+ ## The three places design lives \u2014 and which one you are in
3910
+
3911
+ They are separate on purpose, and confusing them is the commonest mistake here.
3912
+
3913
+ | What | Scope | Where |
3914
+ | --- | --- | --- |
3915
+ | **Brand** \u2014 colours, fonts, logo | The **project** | \`business_settings\`, read with \`design show\` |
3916
+ | **Design files** \u2014 the \`.fig\` work | The **project**, many files | the project's design workspace folder |
3917
+ | **Layout options** \u2014 choices to show the owner | **One app** | \`design/options.json\` in that app's folder |
3918
+
3919
+ **Design is not an app.** It has no port, no URL, nothing to deploy. Never create
3920
+ an app for it.
3921
+
3922
+ When you write \`design/options.json\`, put the design file each option came from
3923
+ in a \`source\` field:
3924
+
3925
+ \`\`\`json
3926
+ { "options": [
3927
+ { "id": "warm", "name": "Warm and simple", "route": "/",
3928
+ "note": "Bigger type, more space.", "source": "hero-v2.fig" }
3929
+ ] }
3930
+ \`\`\`
3931
+
3932
+ \`source\` is a path **inside the project's design workspace** \u2014 relative, no
3933
+ \`..\`, no absolute paths, no URLs. Anything else is dropped. Leave it out when the
3934
+ option was written straight into code with no design file behind it; that is an
3935
+ ordinary case and inventing a source is worse than omitting one.
3936
+
3937
+ Why it matters: the owner picks an option in one app and later opens the design
3938
+ workspace. Without \`source\`, nothing connects the decision they just made to the
3939
+ file it came from, and "why does the site look like this?" has three unrelated
3940
+ answers.
3773
3941
  `
3774
3942
  },
3775
3943
  {
@@ -3822,6 +3990,77 @@ copy is the current one.
3822
3990
  \`workser business\` is how **you** inspect and fix data while building. The app reads
3823
3991
  the same records at runtime through \`workser.business\` in \`@workser/app\` \u2014 see the
3824
3992
  \`workser-sdk\` skill. An app shelling out to this CLI per request is wrong.
3993
+ `
3994
+ },
3995
+ {
3996
+ topic: "checks",
3997
+ title: "Checks \u2014 is it safe, and is it still up",
3998
+ summary: "Scan the code for leaked secrets, known-bad dependencies and over-broad permissions, and check that what you published is still answering.",
3999
+ commands: ["scan", "health"],
4000
+ source: "skills/workser/reference/checks.md",
4001
+ body: `# Checks \u2014 is it safe, and is it still up
4002
+
4003
+ Two questions nothing else in this CLI asks. \`verify\` tells you the code
4004
+ compiles. These tell you it is not dangerous, and that it is still working an
4005
+ hour after you shipped it.
4006
+
4007
+ Run both before you say a task is done.
4008
+
4009
+ \`\`\`
4010
+ workser scan # deps \xB7 secrets \xB7 permissions, over this folder
4011
+ workser scan --check # same, but exits non-zero on anything serious
4012
+ workser scan --only secrets # one check: deps, secrets, permissions
4013
+ workser scan --staged # look at staged changes only
4014
+
4015
+ workser health # is every published app still answering?
4016
+ workser health --app <webAppId> # just one
4017
+ \`\`\`
4018
+
4019
+ ## scan
4020
+
4021
+ Three checks, all local \u2014 no login, no project, no network except for \`deps\`.
4022
+
4023
+ **secrets** looks at what your changes ADD (\`git diff HEAD\`), not at the whole
4024
+ tree, so it fires on the key you just wrote rather than on every example file
4025
+ forever. It knows the shapes that are actually credentials \u2014 AWS, GitHub,
4026
+ Stripe, OpenAI, Anthropic, Google, Slack, private keys, database URLs with a
4027
+ password in them, and \`API_KEY = "\u2026"\` with something real on the right. It
4028
+ ignores placeholders (\`your-api-key\`, \`process.env.X\`, \`<REPLACE_ME>\`) and
4029
+ example, fixture and lockfile paths.
4030
+
4031
+ **deps** runs \`npm audit\` and reports high and critical only. Moderate and low
4032
+ advisories on transitive dev dependencies are real and are not worth a report
4033
+ nobody finishes reading.
4034
+
4035
+ **permissions** catches three specific mistakes: a \`NEXT_PUBLIC_\u2026SECRET\`
4036
+ compiled into the browser bundle, an API that accepts credentialed requests
4037
+ from any website, and a real \`.env\` committed to the repository.
4038
+
4039
+ **A check that could not run says so.** Offline, \`deps\` reports "not checked"
4040
+ with the reason \u2014 never "nothing found". If you are quoting a scan result, quote
4041
+ what it checked as well as what it found.
4042
+
4043
+ If it finds a secret: move the value to \`workser env set\`, and treat the old one
4044
+ as leaked. Rotating it is the owner's decision, not yours \u2014 say so and let them.
4045
+
4046
+ ## health
4047
+
4048
+ Probes the stable preview and production addresses of every app in the project
4049
+ and reports up or down, with the round trip. It exits non-zero if anything is
4050
+ down, so a step can gate on it.
4051
+
4052
+ Two things worth knowing:
4053
+
4054
+ * It is the same check the desktop runs on a timer. Both fold into one streak,
4055
+ so a run of yours counts toward the same total.
4056
+ * After three failed checks in a row on a **production** address that has
4057
+ worked before, an incident task is opened on the owner's board automatically.
4058
+ A preview address is checked and reported but never escalated \u2014 it is not
4059
+ customer-facing, and waking the owner for it teaches them to ignore the ones
4060
+ that are.
4061
+
4062
+ An app that has never been published has no address, so there is nothing to
4063
+ check. That is reported as a note, not as a pass.
3825
4064
  `
3826
4065
  },
3827
4066
  {
@@ -3916,6 +4155,7 @@ them.
3916
4155
  \`\`\`
3917
4156
  workser artifact add <path> [--kind <k>] [-d <text>] # record a finished deliverable
3918
4157
  workser artifact add --url <url> --kind app # record a deployed app
4158
+ workser artifact add <path> --kind <shape> --data <json> [--promote]
3919
4159
  workser artifact run # which task you're attached to
3920
4160
 
3921
4161
  workser ask "<question>" [--type <t>] [--option <o>] # ask the user, WAIT for the answer
@@ -3942,6 +4182,43 @@ are detected automatically); pass it explicitly for \`app\` / \`url\`.
3942
4182
  To publish an app: \`workser deploy\` (preview) or \`workser deploy --prod\` (live), then
3943
4183
  register the URL it returns as an \`app\` artifact so the user can open it from the task.
3944
4184
 
4185
+ ## The shapes the task draws as cards
4186
+
4187
+ Most kinds say what kind of FILE something is. A few say what the user **asked
4188
+ for**, and those get a card of their own on the task:
4189
+
4190
+ | \`--kind\` | What the card shows | \`--data\` it reads |
4191
+ |---|---|---|
4192
+ | \`report\` | the chart behind a number | \u2014 |
4193
+ | \`walkthrough\` | the flow, as frames | \`frames\` |
4194
+ | \`before_after\` | a wipe between two pictures | \`shots: [{url,label}, \u2026]\` |
4195
+ | \`checks\` | what was tested | \`passed\`, \`total\` |
4196
+ | \`web_app\` | a published app | \`deployedAt\`, \`pagesChanged\` |
4197
+ | \`service\` | a job and what it reaches | \`nextRun\` |
4198
+ | \`design\` | a layout | \u2014 |
4199
+
4200
+ \`\`\`
4201
+ workser artifact add ./checks.json --kind checks --data '{"passed":12,"total":12}'
4202
+ workser artifact add --url https://acme.workser.app --kind web_app \\
4203
+ --data '{"deployedAt":"2026-08-20T14:02:00Z","pagesChanged":3}'
4204
+ \`\`\`
4205
+
4206
+ **Every \`--data\` field is optional, and a missing one is left off the card \u2014 it
4207
+ is never drawn as zero.** \`0 pages changed\` is a claim you cannot support and
4208
+ reads as "it did nothing"; saying nothing reads as "not measured", which is
4209
+ true. Only pass a figure you actually counted.
4210
+
4211
+ ## Handing something up to the task
4212
+
4213
+ \`--promote\` marks an artifact as one of the things the user asked for, so it
4214
+ appears on the task itself instead of inside your step.
4215
+
4216
+ Use it when you know: the report they wanted, the app you published, the
4217
+ document explaining what changed. Do **not** promote working material \u2014
4218
+ screenshots you took to check your own work, intermediate exports, a scratch
4219
+ file. A task that hands up everything buries the six things they wanted under
4220
+ sixty they did not.
4221
+
3945
4222
  ## Ask the user something (and get an answer back)
3946
4223
 
3947
4224
  When you're blocked \u2014 a missing value, an ambiguous requirement, permission for
@@ -3970,42 +4247,62 @@ without you ever seeing it).
3970
4247
  },
3971
4248
  {
3972
4249
  topic: "deploy",
3973
- title: "Deploy, environment variables & logs",
3974
- summary: "Ship the app, configure it, and find out why it is down.",
3975
- commands: ["deploy", "env", "logs", "versions", "domain", "open", "verify"],
4250
+ title: "Deploy, addresses & logs",
4251
+ summary: "Ship the app, find its address, and find out why it is down.",
4252
+ commands: ["deploy", "logs", "versions", "urls", "deployments", "domain", "open", "verify"],
3976
4253
  source: "skills/workser/reference/deploy.md",
3977
4254
  body: `# Deploy, environment variables & logs
3978
4255
 
3979
4256
  Getting the app online and configured, and finding out why it isn't.
3980
4257
 
3981
4258
  \`\`\`
3982
- workser deploy [--prod] [--watch] # deploy (git \u2192 Vercel); --watch waits for live URL
4259
+ workser deploy [--env production] [--watch] # deploy (git \u2192 Vercel); default is preview
3983
4260
  workser deploy status [id] # status of a deploy (default: latest)
3984
- workser logs [-n 100] [-f] # recent logs
3985
- workser versions # deploy history
3986
- workser domain list # custom domains (read)
4261
+ workser urls # every app's stable preview + live address
4262
+ workser logs [-n 100] [-f] [--env production] [--app <id>]
4263
+ workser versions [--env production] # history; the badge says which env is live
4264
+ workser deployments list [--env production] [--app <id>]
4265
+ workser deployments inspect <id> [--logs]
4266
+ workser deployments promote # ship the latest build (the owner confirms)
4267
+ workser deployments rollback <version> # put an earlier one back (the owner confirms)
4268
+ workser domain list # custom domains
4269
+ workser domain add shop.co.th # attach one (the owner confirms)
4270
+ workser domain add app.shop.co.th --app <webAppId>
4271
+ workser domain rm shop.co.th # detach one (the owner confirms)
3987
4272
  workser open # open the live app
3988
4273
  workser verify # run typecheck/lint/build
3989
4274
 
3990
- workser env set KEY=VALUE [K2=V2\u2026] # set env vars
3991
- workser env list # list keys (values masked)
3992
- workser env get KEY # one value (sensitive)
3993
4275
  \`\`\`
3994
4276
 
4277
+ Settings \u2014 \`workser env\` \u2014 are their own topic: \`workser help env\`.
4278
+
3995
4279
  ## Notes that matter
3996
4280
 
3997
4281
  - **\`verify\` gates "done".** Run \`workser verify --json\` before you say a task is
3998
4282
  finished. \`"ok": false\` means fix the listed errors and re-run \u2014 a green build is
3999
4283
  the bar, not your reading of the diff.
4000
- - **\`deploy\` without \`--prod\` is a preview.** Preview first when the change is
4001
- risky; \`--prod\` puts it in front of real users.
4284
+ - **\`deploy\` without \`--env\` is a preview.** Preview first when the change is
4285
+ risky; \`--env production\` (or the older \`--prod\`, which means the same) puts it
4286
+ in front of real users. Passing both, disagreeing, is refused rather than
4287
+ resolved.
4288
+ - **\`urls\` is where the address comes from \u2014 not the deploy response.** The host
4289
+ in a deploy response is per-build and the next deploy retires it. \`urls\`
4290
+ returns the stable ones, and says why an app has none rather than printing a
4291
+ blank.
4292
+ - **\`promote\` and \`rollback\` are the same upstream call and two commands on
4293
+ purpose.** Promote ships the newest build; rollback puts version N back. Both
4294
+ ask the owner and return exit 7 (\`awaiting_approval\`) until they answer \u2014 and
4295
+ that gate holds even on a "just do it" run.
4296
+ - **There is no \`deployments cancel\`.** Nothing upstream can stop a build that is
4297
+ already running. Wait for it and then promote or roll back.
4002
4298
  - **\`--watch\` blocks until there's a live URL.** Without it you get a deploy id and
4003
4299
  have to poll \`deploy status\`.
4004
- - **\`env set\` writes a value you never see.** That's the point \u2014 when the user has
4005
- a secret, have them run it (or set it in Orbit) rather than pasting it to you.
4006
- - **\`env get\` returns a secret.** Don't echo it into the conversation.
4007
- - **\`env rm\` and \`domain set\` are owner-only** (exit 6). Tell the user to do it in
4008
- Orbit; don't look for a workaround.
4300
+ - **\`domain add\` and \`domain rm\` ask the owner to confirm** and return exit 7
4301
+ (\`awaiting_approval\`) until they do \u2014 tell them to approve, then retry the same
4302
+ command. Domains Workser owns (\`workser.ai\` and its subdomains) and hostnames
4303
+ the hosting provider assigns (\`*.vercel.app\`) are refused outright: those are
4304
+ not attachable, and the app's own preview and live URLs already exist without
4305
+ attaching anything.
4009
4306
 
4010
4307
  ## After a successful deploy
4011
4308
 
@@ -4016,13 +4313,178 @@ workser artifact add --url https://acme.workser.app --kind app -t "Storefront"
4016
4313
  \`\`\`
4017
4314
 
4018
4315
  See \`reference/deliverables.md\`.
4316
+ `
4317
+ },
4318
+ {
4319
+ topic: "docs",
4320
+ title: "Project documents",
4321
+ summary: "Write and revise the project's pages, keep the markdown mirror readable, and put the diagram in the document rather than in your reply.",
4322
+ commands: ["doc"],
4323
+ source: "skills/workser/reference/docs.md",
4324
+ body: `# Project documents
4325
+
4326
+ A document is a page in the project's Docs panel and a git-tracked markdown
4327
+ mirror at \`.workser/docs/<id>.md\`. Both are the same document: the panel renders
4328
+ the rich text, the mirror is what you, git and the next agent can read as text.
4329
+
4330
+ \`\`\`
4331
+ workser doc list [--work-item <id>]
4332
+ workser doc show <id> [--markdown]
4333
+ workser doc create <title> [--work-item <id>] [--markdown <text>]
4334
+ [--content-json <json>]
4335
+ workser doc update <id> [--title <text>] [--markdown <text>]
4336
+ workser doc diagram <id> [--check]
4337
+ \`\`\`
4338
+
4339
+ ## Revise the page that exists
4340
+
4341
+ The project outlives your session, and a second copy of a page is worse than no
4342
+ page \u2014 nobody can tell which one is current.
4343
+
4344
+ \`\`\`
4345
+ workser doc list --json # is there already a page for this?
4346
+ workser doc update <id> --markdown "$(cat updated.md)"
4347
+ \`\`\`
4348
+
4349
+ \`workser doc show <id> --markdown\` reports the mirror's path so you can open the
4350
+ file with your normal tools instead of reconstructing prose from blocks.
4351
+
4352
+ \`--work-item <id>\` links a document to a Board card (a card has at most one). A
4353
+ linked document renders on its card and is *hidden* from the Docs panel, so a
4354
+ plan spanning several phases should stay unlinked \u2014 it belongs to the project,
4355
+ not to phase 1.
4356
+
4357
+ ## Put the diagram in the document
4358
+
4359
+ A page explaining how something fits together should contain the picture, not a
4360
+ paragraph describing one. Write it as a \`\`\`mermaid fence in the markdown: the
4361
+ Docs panel renders it, \`git diff\` shows it as changed lines, and the next agent
4362
+ reads it without a screenshot.
4363
+
4364
+ \`\`\`
4365
+ workser doc diagram <id> --check # exits non-zero when the page has none
4366
+ \`\`\`
4367
+
4368
+ Use \`--check\` on any page whose job is to explain a structure \u2014 an architecture
4369
+ page, a data model, a flow. It reads the mirror on disk rather than the block
4370
+ content, which is deliberate: a diagram that exists in the editor but not in the
4371
+ mirror is invisible to git, to you, and to whoever opens the file next.
4372
+ `
4373
+ },
4374
+ {
4375
+ topic: "env",
4376
+ title: "Settings \u2014 cloud, per environment, and on this computer",
4377
+ summary: "Set and read an app's settings, hold a different value in production, and pull them onto this computer without clobbering local ones.",
4378
+ commands: ["env"],
4379
+ source: "skills/workser/reference/env.md",
4380
+ body: `# Settings \u2014 cloud, per environment, and on this computer
4381
+
4382
+ \`\`\`
4383
+ workser env set KEY=VALUE [K2=V2\u2026] [--env production]
4384
+ workser env list [--env production] # keys, masked; marks where they differ
4385
+ workser env get KEY [--env production] # one value (sensitive)
4386
+ workser env pull [--env production] [--overwrite]
4387
+ \`\`\`
4388
+
4389
+ ## \`--env\` \u2014 say which environment you mean
4390
+
4391
+ \`deploy\`, \`logs\` and \`versions\` take \`--env preview\` or \`--env production\`.
4392
+ \`env set\` takes those and \`--env development\` as well.
4393
+
4394
+ Without it: \`deploy\` builds a preview, \`logs\` and \`versions\` talk about
4395
+ whichever deployment is newest, and \`env set\` writes to all three environments.
4396
+ Those are the old defaults and they have not changed.
4397
+
4398
+ **There is no development deployment.** Nothing is ever built into it \u2014 it is
4399
+ the environment the app uses when it runs on this computer \u2014 so \`deploy --env
4400
+ development\` and \`logs --env development\` are refused rather than quietly shown
4401
+ preview.
4402
+
4403
+ **A key can now hold a different value per environment.** \`env set --env
4404
+ production DATABASE_URL=\u2026\` writes an override; every other environment keeps
4405
+ the shared value. \`env list --env production\` and \`env get KEY --env production\`
4406
+ read it back.
4019
4407
 
4020
- ## Local vs cloud environment
4408
+ Without \`--env\` you get the **shared** value \u2014 what the key is everywhere unless
4409
+ overridden \u2014 and \`env list\` marks which keys differ:
4021
4410
 
4022
- \`env set\` configures the **cloud** environment (production and preview). The \`.env\`
4023
- files in the app folder configure **this computer** \u2014 the user edits those in Orbit
4024
- under Settings \u2192 "On this computer", and saving there restarts the dev server. Don't
4025
- hand-edit \`.env.local\` to change cloud behaviour; they are different environments.
4411
+ \`\`\`
4412
+ API_KEY = sk-\u2022\u2022\u2022 (different in production)
4413
+ \`\`\`
4414
+
4415
+ That marker is the one to read before changing anything: editing the shared
4416
+ value will not touch production if production has its own.
4417
+
4418
+ \`env rm KEY --env production\` removes just that override and the key keeps its
4419
+ shared value. \`env rm KEY\` removes the key entirely.
4420
+
4421
+ ## Local settings are this computer's, and are not overwritten
4422
+
4423
+ \`env pull\` writes into the app folder's own env file \u2014 \`.env\` for most runtimes,
4424
+ \`.env.local\` for Next.js, because that is the file each one actually reads.
4425
+
4426
+ **It fills in what is missing and leaves what is already there alone.** A local
4427
+ \`DATABASE_URL\` usually points at the developer's own database on purpose;
4428
+ replacing it because somebody asked to pull one missing key destroys work
4429
+ Workser cannot give back. Keys it left alone are **named** in the output.
4430
+
4431
+ \`\`\`
4432
+ workser env pull # fill the gaps, touch nothing else
4433
+ workser env pull --env production # fill them from production's values
4434
+ workser env pull --overwrite # replace local values too
4435
+ \`\`\`
4436
+
4437
+ Starting an app for the first time does the same thing automatically, with the
4438
+ same rule.
4439
+
4440
+ ## A key can hold a different value per environment
4441
+
4442
+ \`workser env set --env production DATABASE_URL=\u2026\` writes an override; every
4443
+ other environment keeps the shared value.
4444
+
4445
+ Without \`--env\` you get the **shared** value \u2014 what the key is everywhere unless
4446
+ overridden \u2014 and \`env list\` marks which keys differ:
4447
+
4448
+ \`\`\`
4449
+ API_KEY = sk-\u2022\u2022\u2022 (different in production)
4450
+ \`\`\`
4451
+
4452
+ Read that marker before changing anything: editing the shared value will not
4453
+ touch production if production has its own.
4454
+
4455
+ \`env rm KEY --env production\` removes just that override and the key keeps its
4456
+ shared value. \`env rm KEY\` removes the key entirely.
4457
+
4458
+ ## Local settings are this computer's, and are not overwritten
4459
+
4460
+ \`env pull\` writes into the app folder's own env file \u2014 \`.env\` for most runtimes,
4461
+ \`.env.local\` for Next.js, because that is the file each one actually reads.
4462
+
4463
+ **It fills in what is missing and leaves what is already there alone.** A local
4464
+ \`DATABASE_URL\` usually points at the developer's own database on purpose;
4465
+ replacing it because somebody asked to pull one missing key destroys work
4466
+ Workser cannot give back. Keys it left alone are **named** in the output.
4467
+
4468
+ \`\`\`
4469
+ workser env pull # fill the gaps, touch nothing else
4470
+ workser env pull --env production # fill them from production's values
4471
+ workser env pull --overwrite # replace local values too
4472
+ \`\`\`
4473
+
4474
+ Starting an app for the first time does the same thing automatically, with the
4475
+ same rule.
4476
+
4477
+ ## Notes that matter
4478
+
4479
+ - **\`env set\` writes a value you never see.** That's the point \u2014 when the user
4480
+ has a secret, have them run it (or set it in Orbit) rather than pasting it to
4481
+ you.
4482
+ - **\`env get\` returns a secret.** Don't echo it into the conversation.
4483
+ - **\`env rm\` is owner-only** (exit 6). Tell the user to do it in Orbit; don't
4484
+ look for a workaround.
4485
+ - **Cloud and local are different environments.** \`env set\` configures the
4486
+ cloud; the files in the app folder configure this computer. Don't hand-edit
4487
+ one to change the other.
4026
4488
  `
4027
4489
  },
4028
4490
  {
@@ -4098,18 +4560,31 @@ can \`memory search\` and find it.
4098
4560
  },
4099
4561
  {
4100
4562
  topic: "neon",
4101
- title: "The project's own Neon backend",
4102
- summary: "Neon-branch object storage and functions. Dedicated tenancy only.",
4563
+ title: "The project's own database",
4564
+ summary: "Branches, databases, compute, plus Neon-branch object storage and functions. Dedicated tenancy only.",
4103
4565
  commands: ["neon"],
4104
4566
  source: "skills/workser/reference/neon-backend.md",
4105
- body: `# The project's own Neon backend
4567
+ body: `# The project's own database
4106
4568
 
4107
- S3-compatible object storage and Node.js HTTP functions on the project's own Neon
4108
- branch \u2014 they branch with the database. **Additive** infrastructure, not a
4109
- replacement for \`workser storage\`.
4569
+ The project's database, run the way an operator runs one: branches (copies of
4570
+ the data), the databases on them, and the compute that serves them. Plus
4571
+ S3-compatible object storage and Node.js HTTP functions on the same branch.
4110
4572
 
4111
4573
  \`\`\`
4112
4574
  workser neon status # tenancy + toggles + region verdict
4575
+
4576
+ workser neon branch list # copies of the data; the live one is marked
4577
+ workser neon branch create qa-run # a copy to work on, made in a second
4578
+ workser neon branch create qa --from <id> --no-compute
4579
+ workser neon branch reset <branchId> # throw its changes away (asks the owner)
4580
+ workser neon branch rm <branchId> # delete it and its data (asks the owner)
4581
+
4582
+ workser neon database list [--branch <id>]
4583
+ workser neon database create <name> [--branch <id>] [--owner <role>]
4584
+ workser neon database rm <name> [--branch <id>] # asks the owner
4585
+
4586
+ workser neon endpoints # what compute is running, and idle
4587
+
4113
4588
  workser neon storage list | create <name> | rm <bucket>
4114
4589
  workser neon storage ls <bucket> [prefix]
4115
4590
  workser neon storage put <bucket> <local> [key]
@@ -4127,6 +4602,24 @@ Region is fixed when the project is created. \`regionSupportsNeonBackend: false\
4127
4602
  **final, not retryable** \u2014 no amount of waiting or retrying changes it. When you see
4128
4603
  it, say so plainly and fall back to \`workser storage\` (the default bucket).
4129
4604
 
4605
+ ## Branches are the useful one
4606
+
4607
+ A branch is a **full copy of the data**, made in about a second, costing almost
4608
+ nothing until something writes to it. That is what lets a check run against real
4609
+ data without being able to damage it \u2014 give a QA step its own branch instead of
4610
+ pointing it at the live database.
4611
+
4612
+ Two things cannot happen at all, whatever anyone approves: **the branch the app
4613
+ runs on cannot be deleted or reset**, and neither can **the database it connects
4614
+ to**. Those refusals come from the server, not from the approval prompt. If you
4615
+ meant to reset a copy and got that message, you named the live one.
4616
+
4617
+ \`reset\` deletes nothing by name and destroys just as much: it replaces a
4618
+ branch's contents with its source's. It asks the owner for exactly that reason.
4619
+
4620
+ \`--no-compute\` makes a branch with no compute. It is cheaper and **nothing can
4621
+ connect to it** \u2014 useful as a snapshot, useless as somewhere to run tests.
4622
+
4130
4623
  ## Notes that matter
4131
4624
 
4132
4625
  - **\`neon storage rm <bucket>\` deletes the bucket and everything in it.** Not
@@ -4135,8 +4628,11 @@ it, say so plainly and fall back to \`workser storage\` (the default bucket).
4135
4628
  through Workser.
4136
4629
  - **Functions deploy from a zip.** Build the bundle first, then
4137
4630
  \`workser neon functions deploy <slug> <zip>\`.
4138
- - **Most apps don't need this.** If the user just wants to store uploads, the default
4139
- bucket in \`reference/storage.md\` is the answer.
4631
+ - **\`neon endpoints\` is the cost question.** \`active\` means it is billing;
4632
+ \`idle\` means it is not. It is the only place in the product that answers "what
4633
+ is this database costing me while nothing is happening".
4634
+ - **Most apps never need the storage or functions half.** If the user just wants
4635
+ to store uploads, the default bucket in \`reference/storage.md\` is the answer.
4140
4636
  `
4141
4637
  },
4142
4638
  {
@@ -4189,16 +4685,16 @@ real scope, not just a repeat of the task.
4189
4685
  },
4190
4686
  {
4191
4687
  topic: "sdlc-entities",
4192
- title: "Board cards, decisions, requirements, and docs",
4688
+ title: "Board cards, decisions and requirements",
4193
4689
  summary: "Read what this project already tracks and decided, keep the Board honest as you work, and record what a future maintainer will need.",
4194
- commands: ["board", "decision", "requirement", "doc"],
4690
+ commands: ["board", "decision", "requirement"],
4195
4691
  source: "skills/workser/reference/sdlc-entities.md",
4196
- body: `# Board cards, decisions, requirements, and docs
4692
+ body: `# Board cards, decisions and requirements
4197
4693
 
4198
4694
  These are the project's memory across sessions. They write to the **same tables**
4199
- the Orbit desktop's Board, Project Memory, and Docs panels use, so anything here
4200
- appears there too \u2014 and (when this CLI runs inside an Orbit-spawned agent run)
4201
- as an inline card in the conversation you're working in.
4695
+ the Orbit desktop's Board and Project Memory panels use, so anything here appears
4696
+ there too \u2014 and, inside an Orbit-spawned run, as an inline card in the
4697
+ conversation. Documents have their own guide: \`workser help docs\`.
4202
4698
 
4203
4699
  \`\`\`
4204
4700
  workser board list [--status <value>] [--label <value>] [--limit <n>]
@@ -4221,11 +4717,6 @@ workser requirement show <id>
4221
4717
  workser requirement create <title> --body <text> [--status <text>]
4222
4718
  workser requirement update <id> [--title <text>] [--body <text>] [--status <text>]
4223
4719
 
4224
- workser doc list [--work-item <id>]
4225
- workser doc show <id> [--markdown]
4226
- workser doc create <title> [--work-item <id>] [--markdown <text>]
4227
- [--content-json <json>]
4228
- workser doc update <id> [--title <text>] [--markdown <text>]
4229
4720
  \`\`\`
4230
4721
 
4231
4722
  ## Read first \u2014 this is the part that matters
@@ -4254,7 +4745,6 @@ workser board create "Phase 1 \u2014 schema + migration" \\
4254
4745
  --description "Add orders/line_items tables and the migration." \\
4255
4746
  --status in-progress --json
4256
4747
  workser board create "Phase 2 \u2014 checkout API" --description "\u2026" --json
4257
- workser board create "Phase 3 \u2014 cart UI" --description "\u2026" --json
4258
4748
 
4259
4749
  # the plan itself, ONE doc, deliberately NOT linked to a card
4260
4750
  workser doc create "Checkout \u2014 implementation plan" --markdown "$(cat plan.md)" --json
@@ -4264,8 +4754,8 @@ workser decision create "Carts live server-side" --context "\u2026" --decision "
4264
4754
  \`\`\`
4265
4755
 
4266
4756
  **Don't pass \`--work-item\` for a multi-phase plan.** A linked document renders on
4267
- its card and is *hidden* from the Docs panel; a plan spanning three phases belongs
4268
- to the project, not to phase 1.
4757
+ its card and is *hidden* from the Docs panel; a plan spanning three phases
4758
+ belongs to the project, not to phase 1.
4269
4759
 
4270
4760
  The bar: if the user closed this conversation now, the Board should still show
4271
4761
  what's left and the doc should still explain the plan to whoever continues it.
@@ -4291,22 +4781,19 @@ workser board create "Fix the login bug" --status in-progress --priority high \\
4291
4781
  \`\`\`
4292
4782
 
4293
4783
  \`board update\` replaces the labels you pass rather than merging them, and
4294
- touches only the fields you name. There is no \`board delete\` \u2014 \`done\` is the
4295
- terminal state for finished work, and removing a card the user filed is theirs
4296
- to do in Orbit.
4784
+ touches only the fields you name. There is no \`board delete\`: \`done\` is the
4785
+ terminal state, and removing a card the user filed is theirs to do in Orbit.
4297
4786
 
4298
4787
  ## Decisions are append-only
4299
4788
 
4300
4789
  \`decision create\` is for something with real tradeoffs worth a paper trail:
4301
- \`--context\` is why it came up, \`--decision\` is what was decided,
4302
- \`--consequences\` is the follow-on effects. There is deliberately **no
4303
- \`decision update\`** \u2014 a decision record states what was decided at a point in
4304
- time. When it stops being right, record a new decision that supersedes it and
4305
- say so in its \`--context\`. Editing the history is how a decision log stops
4306
- being worth reading.
4790
+ \`--context\` is why it came up, \`--decision\` what was decided, \`--consequences\`
4791
+ the follow-on effects. There is deliberately **no \`decision update\`** \u2014 a record
4792
+ states what was decided at a point in time. When it stops being right, record a
4793
+ new decision that supersedes it and say so in its \`--context\`. Editing the
4794
+ history is how a decision log stops being worth reading.
4307
4795
 
4308
- Requirements are different: they legitimately move along, so they do have
4309
- \`update\`.
4796
+ Requirements legitimately move along, so they do have \`update\`.
4310
4797
 
4311
4798
  \`\`\`
4312
4799
  workser requirement create "Support SSO" --body "Enterprise customers need SAML." \\
@@ -4317,25 +4804,11 @@ workser requirement update <id> --status done
4317
4804
  ## Docs
4318
4805
 
4319
4806
  \`--markdown\` is the normal way to write one. The body is stored both as the
4320
- rich-text content the Docs panel renders and as a git-tracked markdown mirror
4321
- at \`.workser/docs/<id>.md\` \u2014 \`workser doc show <id> --markdown\` reports that
4322
- path so you can read the file with your normal tools.
4323
-
4324
- Revise the page that exists rather than creating a second copy of it:
4325
-
4326
- \`\`\`
4327
- workser doc list --json # is there already a page for this?
4328
- workser doc update <id> --markdown "$(cat updated.md)"
4329
- \`\`\`
4330
-
4331
- \`--work-item <id>\` links a document to a Board card (a card has at most one).
4332
-
4333
- ## When to record, and when not to
4807
+ rich text the Docs panel renders and as a git-tracked mirror at
4808
+ \`.workser/docs/<id>.md\`; \`workser doc show <id> --markdown\` reports that path so
4809
+ you can read the file with your normal tools.
4334
4810
 
4335
- Record what a future maintainer would need: follow-up work you found but didn't
4336
- do, a choice between real alternatives, a behaviour worth writing down. Don't
4337
- narrate every small step \u2014 and never treat filing a card as a substitute for the
4338
- work. A card saying "fix the bug" is not fixing the bug.
4811
+ Revise trd saying "fix the bug" is not fixing the bug.
4339
4812
  `
4340
4813
  },
4341
4814
  {
@@ -4429,6 +4902,7 @@ workser task subtask add <title> [--role <value>] [--kind <value>]
4429
4902
  workser task subtask list [taskId]
4430
4903
  workser task subtask update <id> [--title|--note|--role|--kind|--scope]
4431
4904
  workser task subtask remove <id>
4905
+ workser task subtask send-back <id> --note <text> # redo it, and say why
4432
4906
 
4433
4907
  workser task can-start [id] # may work begin? refuses until approved
4434
4908
  workser task approval request # tell the owner the plan is ready
@@ -4487,6 +4961,75 @@ workser task done --summary "The report now shows cost per KOL, with six months
4487
4961
  \`\`\`
4488
4962
 
4489
4963
  Write the summary for someone who runs a business and does not read code.
4964
+
4965
+ ## Sending a step back
4966
+
4967
+ A step that finished but is not good enough is **sent back**, not replaced:
4968
+
4969
+ \`\`\`
4970
+ workser task subtask send-back 3f2a\u2026 --note "The totals ignore refunds."
4971
+ \`\`\`
4972
+
4973
+ That puts it in the queue again as a **second attempt** on the same step. Two
4974
+ reasons it matters that this is not a new step:
4975
+
4976
+ - The owner's screen can then say *"1 send-back, fixed \u2014 2nd run passed"*. A
4977
+ replacement step says only that two steps exist, which tells them nothing
4978
+ about whether their team caught its own mistake.
4979
+ - \`--note\` is the reason, and it is recorded against the attempt being
4980
+ rejected. Without it the history can say a step ran twice but not why.
4981
+
4982
+ It refuses a step that is still working. Let it finish first \u2014 the run is
4983
+ still writing to it.
4984
+ `
4985
+ },
4986
+ {
4987
+ topic: "usage",
4988
+ title: "Usage \u2014 what is being used, against the plan",
4989
+ summary: "How much database, file storage, projects and apps are in use, and how close that is to what the plan allows.",
4990
+ commands: ["usage"],
4991
+ source: "skills/workser/reference/usage.md",
4992
+ body: `# Usage \u2014 what is being used, against the plan
4993
+
4994
+ \`\`\`
4995
+ workser usage # storage, projects, apps \u2014 and how close each is to the limit
4996
+ \`\`\`
4997
+
4998
+ Run it before you propose anything that adds to a count. "Create another
4999
+ project" is a plan you can only sensibly make if you know the plan allows two
5000
+ and two already exist.
5001
+
5002
+ ## Two scopes in one answer, on purpose
5003
+
5004
+ * **Database and files are ORGANISATION-wide.** One pool across every project.
5005
+ There is no per-project storage limit, and reporting one would invent it.
5006
+ * **Projects, and apps in this project, are counted where they apply.** These
5007
+ are the limits people actually hit.
5008
+
5009
+ ## Two kinds of limit, which do not mean the same thing
5010
+
5011
+ * **Hard cap** \u2014 projects, apps. Going over is **refused**. \`workser usage\`
5012
+ exits non-zero when one is reached, so a step can gate on it.
5013
+ * **Soft allowance** \u2014 database, files. Going over is **billed as extra**,
5014
+ never blocked. It does not fail the command, because a customer growing past
5015
+ their allowance should not have their automation start breaking that day.
5016
+
5017
+ ## "not measured" is not zero
5018
+
5019
+ A figure that could not be read prints as \`not measured\`, with the reason, and
5020
+ draws no bar. Do not report it as \`0\`, and do not tell the user they have room
5021
+ based on it \u2014 nobody looked.
5022
+
5023
+ If a scan comes back with a figure missing, say which one and why. "Your
5024
+ database is using 2.5 GB of 10; the file total could not be read" is a useful
5025
+ sentence. "You are using 2.5 GB of 20" is not, and it is wrong.
5026
+
5027
+ ## What to do with it
5028
+
5029
+ - Near a **soft** limit: tell the owner what the extra will cost them, and what
5030
+ is taking the space. Do not delete anything to make a number look better.
5031
+ - At a **hard** cap: say which plan raises it. Do not attempt the create \u2014 it
5032
+ will be refused, and a failed attempt reads to the owner as a broken product.
4490
5033
  `
4491
5034
  },
4492
5035
  {
@@ -4746,6 +5289,18 @@ Commands that only read your account (projects, env, db, logs, status) work as n
4746
5289
  { code: "needs_local_app" }
4747
5290
  );
4748
5291
  }
5292
+ function requireDaemon(ctx, what, why) {
5293
+ if (ctx.mode === "daemon") return;
5294
+ throw new WorkserError(
5295
+ `\`workser ${what}\` ${why}, so it needs the Workser app running on this computer.
5296
+
5297
+ This shell is talking to ${ctx.endpoint} instead of a local app.
5298
+
5299
+ \u2022 On your own computer: open Workser and try again.
5300
+ \u2022 On a computer without Workser: install it from https://workser.ai/download and sign in.`,
5301
+ { code: "needs_local_app" }
5302
+ );
5303
+ }
4749
5304
  function requireProject(ctx) {
4750
5305
  if (!ctx.projectId) {
4751
5306
  throw new WorkserError(
@@ -5335,7 +5890,7 @@ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
5335
5890
  import { basename as basename2 } from "path";
5336
5891
  function registerNeon(program3) {
5337
5892
  const neon = program3.command("neon").description(
5338
- "The project's own Neon backend: object storage buckets and functions"
5893
+ "The project's own database: branches, databases, compute, storage and functions"
5339
5894
  );
5340
5895
  neon.command("status").description(
5341
5896
  "Whether this project can use Neon storage/functions (tenancy, toggles, region)"
@@ -5557,70 +6112,321 @@ function registerNeon(program3) {
5557
6112
  ok(res, () => success(`Deleted function ${args[0]}.`));
5558
6113
  })
5559
6114
  );
5560
- }
5561
- async function presign(ctx, projectId, bucket, key, operation, expiresInSeconds) {
5562
- const res = await api(
5563
- ctx,
5564
- `/v1/projects/${projectId}/neon-storage/buckets/${encodeURIComponent(bucket)}/presign`,
5565
- { body: { key, operation, expiresInSeconds } }
5566
- );
5567
- const url = res?.url ?? res?.signedUrl ?? res?.presignedUrl;
5568
- if (!url) {
5569
- throw new WorkserError(
5570
- `The daemon did not return a presigned URL for ${key}.`,
5571
- { code: "unexpected_response", details: res }
5572
- );
5573
- }
5574
- return { url, headers: res?.headers };
5575
- }
5576
-
5577
- // src/commands/env.ts
5578
- var import_picocolors9 = __toESM(require_picocolors(), 1);
5579
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
5580
- function appQuery(opts) {
5581
- const app = typeof opts?.app === "string" ? opts.app : "";
5582
- return app ? `?webAppId=${encodeURIComponent(app)}` : "";
5583
- }
5584
- var APP_FLAG_HELP = "Which web app to target (defaults to the project's primary app)";
5585
- function registerEnv(program3) {
5586
- const env = program3.command("env").description("Manage web app environment variables");
5587
- env.command("set <pairs...>").description("Set one or more KEY=VALUE variables").option("--app <webAppId>", APP_FLAG_HELP).action(
5588
- action(async ({ ctx, args, opts }) => {
6115
+ const branch = neon.command("branch").description("Copies of the project's data, to work on without touching it");
6116
+ branch.command("list").description("Every branch, with the one the app runs on marked").action(
6117
+ action(async ({ ctx }) => {
5589
6118
  const projectId = requireProject(ctx);
5590
- const pairs = args[0].map((p) => {
5591
- const i = p.indexOf("=");
5592
- if (i < 0) throw new WorkserError(`Invalid pair "${p}". Use KEY=VALUE.`, { code: "bad_input" });
5593
- return { key: p.slice(0, i), value: p.slice(i + 1) };
5594
- });
5595
- const res = await api(ctx, `/v1/projects/${projectId}/env${appQuery(opts)}`, {
5596
- body: { vars: pairs }
5597
- });
5598
- const count = typeof res?.count === "number" ? res.count : pairs.length;
5599
- ok(res, () => {
5600
- success(`Set ${count} variable(s): ${pairs.map((p) => p.key).join(", ")}`);
5601
- if (res?.usedDefault && res?.webAppName) {
5602
- line(import_picocolors9.default.dim(`on ${res.webAppName} (primary app) \u2014 use --app to target another`));
6119
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-branches`);
6120
+ ok(items, () => {
6121
+ if (!items?.length) return line(import_picocolors8.default.dim("No branches."));
6122
+ for (const b of items) {
6123
+ const mark = b.isProjectBranch ? import_picocolors8.default.green(" \u2190 the app runs on this") : "";
6124
+ line(
6125
+ `${import_picocolors8.default.bold(b.name ?? "?")} ${import_picocolors8.default.dim(b.id ?? "")}${b.parent_id ? import_picocolors8.default.dim(" from " + b.parent_id) : ""}${mark}`
6126
+ );
5603
6127
  }
5604
6128
  });
5605
6129
  })
5606
6130
  );
5607
- env.command("get <key>").description("Print one variable's value (sensitive)").option("--app <webAppId>", APP_FLAG_HELP).action(
6131
+ branch.command("create <name>").description("Make a copy of the data to work on").option("--from <branchId>", "copy this branch instead of the one in use").option(
6132
+ "--no-compute",
6133
+ "create it without compute \u2014 cheaper, and nothing can connect to it"
6134
+ ).action(
5608
6135
  action(async ({ ctx, args, opts }) => {
5609
6136
  const projectId = requireProject(ctx);
5610
- const res = await api(
5611
- ctx,
5612
- `/v1/projects/${projectId}/env/${encodeURIComponent(args[0])}${appQuery(opts)}`
6137
+ const noCompute = opts.compute === false;
6138
+ const res = await api(ctx, `/v1/projects/${projectId}/neon-branches`, {
6139
+ body: {
6140
+ name: args[0],
6141
+ ...opts.from ? { fromBranchId: String(opts.from) } : {},
6142
+ // Commander turns `--no-compute` into `compute: false`. The typed
6143
+ // opts bag is loose here, so the comparison is written against the
6144
+ // value rather than the declared type.
6145
+ ...noCompute ? { withEndpoint: false } : {}
6146
+ }
6147
+ });
6148
+ ok(res, () => {
6149
+ success(`Created branch ${args[0]}.`);
6150
+ const id = res?.branch?.id ?? res?.id;
6151
+ if (id) line(import_picocolors8.default.dim(id));
6152
+ if (noCompute) {
6153
+ line(
6154
+ import_picocolors8.default.dim(
6155
+ "It has no compute, so nothing can connect to it until one is added."
6156
+ )
6157
+ );
6158
+ }
6159
+ });
6160
+ })
6161
+ );
6162
+ branch.command("reset <branchId>").description("Throw away a branch's changes and take its source data again (asks for approval)").action(
6163
+ action(async ({ ctx, args }) => {
6164
+ const projectId = requireProject(ctx);
6165
+ const res = await api(
6166
+ ctx,
6167
+ `/v1/projects/${projectId}/neon-branches/${encodeURIComponent(args[0])}/reset`,
6168
+ { body: {} }
6169
+ );
6170
+ ok(res, () => success(`Reset ${args[0]} to its source.`));
6171
+ })
6172
+ );
6173
+ branch.command("rm <branchId>").description("Delete a branch and its data (asks for approval)").action(
6174
+ action(async ({ ctx, args }) => {
6175
+ const projectId = requireProject(ctx);
6176
+ const res = await api(
6177
+ ctx,
6178
+ `/v1/projects/${projectId}/neon-branches/${encodeURIComponent(args[0])}`,
6179
+ { method: "DELETE" }
6180
+ );
6181
+ ok(res, () => success(`Deleted branch ${args[0]}.`));
6182
+ })
6183
+ );
6184
+ const database = neon.command("database").description("Databases on a branch");
6185
+ database.command("list").description("Databases on a branch (default: the one the app uses)").option("--branch <branchId>", "look at another branch").action(
6186
+ action(async ({ ctx, opts }) => {
6187
+ const projectId = requireProject(ctx);
6188
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-databases`, {
6189
+ query: opts.branch ? { branchId: String(opts.branch) } : void 0
6190
+ });
6191
+ ok(items, () => {
6192
+ if (!items?.length) return line(import_picocolors8.default.dim("No databases."));
6193
+ for (const d of items) {
6194
+ const mark = d.isProjectDatabase ? import_picocolors8.default.green(" \u2190 the app connects to this") : "";
6195
+ line(`${import_picocolors8.default.bold(d.name ?? "?")}${import_picocolors8.default.dim(" owner " + (d.owner_name ?? "?"))}${mark}`);
6196
+ }
6197
+ });
6198
+ })
6199
+ );
6200
+ database.command("create <name>").description("Create a database on a branch").option("--branch <branchId>", "which branch (default: the one the app uses)").option("--owner <role>", "which role owns it (default: the branch's own owner)").action(
6201
+ action(async ({ ctx, args, opts }) => {
6202
+ const projectId = requireProject(ctx);
6203
+ const res = await api(ctx, `/v1/projects/${projectId}/neon-databases`, {
6204
+ body: {
6205
+ name: args[0],
6206
+ ...opts.branch ? { branchId: String(opts.branch) } : {},
6207
+ ...opts.owner ? { ownerName: String(opts.owner) } : {}
6208
+ }
6209
+ });
6210
+ ok(res, () => success(`Created database ${args[0]}.`));
6211
+ })
6212
+ );
6213
+ database.command("rm <name>").description("Delete a database and every table in it (asks for approval)").option("--branch <branchId>", "which branch (default: the one the app uses)").action(
6214
+ action(async ({ ctx, args, opts }) => {
6215
+ const projectId = requireProject(ctx);
6216
+ const res = await api(
6217
+ ctx,
6218
+ `/v1/projects/${projectId}/neon-databases/${encodeURIComponent(args[0])}`,
6219
+ {
6220
+ method: "DELETE",
6221
+ query: opts.branch ? { branchId: String(opts.branch) } : void 0
6222
+ }
6223
+ );
6224
+ ok(res, () => success(`Deleted database ${args[0]}.`));
6225
+ })
6226
+ );
6227
+ neon.command("endpoints").description("The compute that runs the database \u2014 what is on, and what it costs idle").action(
6228
+ action(async ({ ctx }) => {
6229
+ const projectId = requireProject(ctx);
6230
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-endpoints`);
6231
+ ok(items, () => {
6232
+ if (!items?.length) {
6233
+ return line(
6234
+ import_picocolors8.default.dim("No compute endpoints \u2014 nothing can connect to this database.")
6235
+ );
6236
+ }
6237
+ for (const e of items) {
6238
+ const state = e.current_state === "active" ? import_picocolors8.default.green("active") : import_picocolors8.default.dim(e.current_state ?? "?");
6239
+ line(
6240
+ `${import_picocolors8.default.bold(e.type ?? "endpoint")} ${state} ${import_picocolors8.default.dim(e.branch_id ?? "")}` + (e.host ? " " + import_picocolors8.default.cyan(e.host) : "")
6241
+ );
6242
+ }
6243
+ });
6244
+ })
6245
+ );
6246
+ }
6247
+ async function presign(ctx, projectId, bucket, key, operation, expiresInSeconds) {
6248
+ const res = await api(
6249
+ ctx,
6250
+ `/v1/projects/${projectId}/neon-storage/buckets/${encodeURIComponent(bucket)}/presign`,
6251
+ { body: { key, operation, expiresInSeconds } }
6252
+ );
6253
+ const url = res?.url ?? res?.signedUrl ?? res?.presignedUrl;
6254
+ if (!url) {
6255
+ throw new WorkserError(
6256
+ `The daemon did not return a presigned URL for ${key}.`,
6257
+ { code: "unexpected_response", details: res }
6258
+ );
6259
+ }
6260
+ return { url, headers: res?.headers };
6261
+ }
6262
+
6263
+ // src/commands/env.ts
6264
+ var import_picocolors9 = __toESM(require_picocolors(), 1);
6265
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
6266
+
6267
+ // src/environments.ts
6268
+ var ENVIRONMENTS = ["development", "preview", "production"];
6269
+ var DEPLOY_ENVIRONMENTS = ["preview", "production"];
6270
+ var ALIASES2 = {
6271
+ dev: "development",
6272
+ development: "development",
6273
+ local: "development",
6274
+ preview: "preview",
6275
+ staging: "preview",
6276
+ stage: "preview",
6277
+ test: "preview",
6278
+ prod: "production",
6279
+ production: "production",
6280
+ live: "production",
6281
+ main: "production"
6282
+ };
6283
+ function parseEnvironment(raw) {
6284
+ if (raw === void 0 || raw === null || raw === "") return { ok: true };
6285
+ const text = String(raw).trim().toLowerCase();
6286
+ const value = ALIASES2[text];
6287
+ if (!value) {
6288
+ return {
6289
+ ok: false,
6290
+ error: `--env takes ${ENVIRONMENTS.join(", ")} (or dev/prod). "${raw}" is not one of them.`
6291
+ };
6292
+ }
6293
+ return { ok: true, value };
6294
+ }
6295
+ function parseDeployEnvironment(raw, verb) {
6296
+ const parsed = parseEnvironment(raw);
6297
+ if (!parsed.ok || !parsed.value) return parsed;
6298
+ if (parsed.value === "development") {
6299
+ return {
6300
+ ok: false,
6301
+ error: `Nothing is ever deployed to development \u2014 it is the environment your app uses when it runs on this computer. \`${verb}\` can address preview or production.`
6302
+ };
6303
+ }
6304
+ return parsed;
6305
+ }
6306
+ function envTargets(environment) {
6307
+ if (!environment) return [...ENVIRONMENTS];
6308
+ if (environment === "preview") return ["preview", "development"];
6309
+ return [environment];
6310
+ }
6311
+ function targetSummary(environment) {
6312
+ if (!environment) return "in every environment";
6313
+ if (environment === "preview") return "in preview and development";
6314
+ return `in ${environment}`;
6315
+ }
6316
+ function urlRows(apps) {
6317
+ const rows = [];
6318
+ for (const app of apps) {
6319
+ const appId = typeof app?.id === "string" ? app.id : "";
6320
+ if (!appId) continue;
6321
+ const appName = (app.name ?? "").trim() || "Untitled app";
6322
+ for (const environment of DEPLOY_ENVIRONMENTS) {
6323
+ const url = environment === "production" ? app.productionUrl : app.previewUrl;
6324
+ rows.push({
6325
+ appId,
6326
+ appName,
6327
+ environment,
6328
+ url: url && url.trim() ? url.trim() : null,
6329
+ note: url && url.trim() ? null : noteFor(environment)
6330
+ });
6331
+ }
6332
+ }
6333
+ return rows;
6334
+ }
6335
+ function noteFor(environment) {
6336
+ return environment === "production" ? "not live yet \u2014 `workser deploy --env production` publishes it" : "no preview yet \u2014 `workser deploy` builds one";
6337
+ }
6338
+ function urlsSummary(rows) {
6339
+ if (!rows.length) return "This project has no apps yet.";
6340
+ const live = rows.filter((r) => r.environment === "production" && r.url).length;
6341
+ const apps = new Set(rows.map((r) => r.appId)).size;
6342
+ if (!live) {
6343
+ return `${apps} ${apps === 1 ? "app" : "apps"}, none live yet.`;
6344
+ }
6345
+ return `${apps} ${apps === 1 ? "app" : "apps"} \u2014 ${live} live.`;
6346
+ }
6347
+
6348
+ // src/commands/env.ts
6349
+ function appQuery(opts, environment) {
6350
+ const params = new URLSearchParams();
6351
+ if (typeof opts?.app === "string" && opts.app) params.set("webAppId", opts.app);
6352
+ if (environment) params.set("environment", environment);
6353
+ const query = params.toString();
6354
+ return query ? `?${query}` : "";
6355
+ }
6356
+ var APP_FLAG_HELP = "Which web app to target (defaults to the project's primary app)";
6357
+ var ENV_FLAG_HELP = "Which environment: production, preview or development (default: all three)";
6358
+ function readEnv(opts) {
6359
+ const parsed = parseEnvironment(opts?.env);
6360
+ if (!parsed.ok) throw new WorkserError(parsed.error, { code: "bad_input" });
6361
+ return parsed.value;
6362
+ }
6363
+ function registerEnv(program3) {
6364
+ const env = program3.command("env").description("Manage web app environment variables");
6365
+ env.command("set <pairs...>").description("Set one or more KEY=VALUE variables").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).action(
6366
+ action(async ({ ctx, args, opts }) => {
6367
+ const projectId = requireProject(ctx);
6368
+ const environment = readEnv(opts);
6369
+ const pairs = args[0].map((p) => {
6370
+ const i = p.indexOf("=");
6371
+ if (i < 0) throw new WorkserError(`Invalid pair "${p}". Use KEY=VALUE.`, { code: "bad_input" });
6372
+ return {
6373
+ key: p.slice(0, i),
6374
+ value: p.slice(i + 1),
6375
+ // Sent only when asked for. Omitted means all three, which is what
6376
+ // this command has always done — and quietly narrowing that default
6377
+ // would have un-set production for every script the day it shipped.
6378
+ ...environment ? { target: envTargets(environment) } : {}
6379
+ };
6380
+ });
6381
+ const res = await api(ctx, `/v1/projects/${projectId}/env${appQuery(opts)}`, {
6382
+ body: { vars: pairs }
6383
+ });
6384
+ const count = typeof res?.count === "number" ? res.count : pairs.length;
6385
+ ok(res, () => {
6386
+ success(
6387
+ `Set ${count} variable(s) ${targetSummary(environment)}: ` + pairs.map((p) => p.key).join(", ")
6388
+ );
6389
+ if (res?.usedDefault && res?.webAppName) {
6390
+ line(import_picocolors9.default.dim(`on ${res.webAppName} (primary app) \u2014 use --app to target another`));
6391
+ }
6392
+ });
6393
+ })
6394
+ );
6395
+ env.command("get <key>").description("Print one variable's value (sensitive)").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).action(
6396
+ action(async ({ ctx, args, opts }) => {
6397
+ const projectId = requireProject(ctx);
6398
+ const environment = readEnv(opts);
6399
+ const res = await api(
6400
+ ctx,
6401
+ `/v1/projects/${projectId}/env/${encodeURIComponent(args[0])}${appQuery(opts, environment)}`
5613
6402
  );
5614
6403
  ok(res, () => line(res.value ?? ""));
5615
6404
  })
5616
6405
  );
5617
- env.command("list").description("List variable keys (values masked)").option("--app <webAppId>", APP_FLAG_HELP).action(
6406
+ env.command("list").description("List variable keys (values masked)").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).action(
5618
6407
  action(async ({ ctx, opts }) => {
5619
6408
  const projectId = requireProject(ctx);
5620
- const items = await api(ctx, `/v1/projects/${projectId}/env${appQuery(opts)}`);
6409
+ const environment = readEnv(opts);
6410
+ const items = await api(
6411
+ ctx,
6412
+ `/v1/projects/${projectId}/env${appQuery(opts, environment)}`
6413
+ );
5621
6414
  ok(items, () => {
5622
- if (!items?.length) return line(import_picocolors9.default.dim("No variables set."));
5623
- for (const v of items) line(`${v.key}${import_picocolors9.default.dim(" = " + (v.masked ?? "\u2022\u2022\u2022\u2022"))}`);
6415
+ if (!items?.length) {
6416
+ return line(
6417
+ import_picocolors9.default.dim(
6418
+ environment ? `No variables apply in ${environment}.` : "No variables set."
6419
+ )
6420
+ );
6421
+ }
6422
+ for (const v of items) {
6423
+ const differs = !environment && v.overriddenIn?.length ? import_picocolors9.default.yellow(` (different in ${v.overriddenIn.join(", ")})`) : "";
6424
+ line(`${v.key}${import_picocolors9.default.dim(" = " + (v.masked ?? "\u2022\u2022\u2022\u2022"))}${differs}`);
6425
+ }
6426
+ if (environment) {
6427
+ line(import_picocolors9.default.dim(`
6428
+ Showing the values that apply in ${environment}.`));
6429
+ }
5624
6430
  });
5625
6431
  })
5626
6432
  );
@@ -5633,12 +6439,17 @@ function registerEnv(program3) {
5633
6439
  })
5634
6440
  )
5635
6441
  );
5636
- env.command("pull").description("Write this app's cloud env vars into a local file (default .env.local)").option("--app <webAppId>", APP_FLAG_HELP).option("--out <file>", "Local file to write", ".env.local").action(
6442
+ env.command("pull").description("Write this app's cloud env vars into a local file (default .env.local)").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).option("--out <file>", "Local file to write", ".env.local").option(
6443
+ "--overwrite",
6444
+ "replace values this computer already has (default: fill in only what is missing)",
6445
+ false
6446
+ ).action(
5637
6447
  action(async ({ ctx, opts }) => {
5638
6448
  const projectId = requireProject(ctx);
6449
+ const environment = readEnv(opts);
5639
6450
  const items = await api(
5640
6451
  ctx,
5641
- `/v1/projects/${projectId}/env${appQuery(opts)}`
6452
+ `/v1/projects/${projectId}/env${appQuery(opts, environment)}`
5642
6453
  );
5643
6454
  const outPath = typeof opts?.out === "string" ? opts.out : ".env.local";
5644
6455
  if (!items?.length) {
@@ -5651,38 +6462,66 @@ function registerEnv(program3) {
5651
6462
  for (const item of items) {
5652
6463
  const res = await api(
5653
6464
  ctx,
5654
- `/v1/projects/${projectId}/env/${encodeURIComponent(item.key)}${appQuery(opts)}`
6465
+ `/v1/projects/${projectId}/env/${encodeURIComponent(item.key)}${appQuery(opts, environment)}`
5655
6466
  );
5656
6467
  pulled.push({ key: item.key, value: res?.value ?? "" });
5657
6468
  }
5658
- await mergeEnvFile(outPath, pulled);
6469
+ const result = await mergeEnvFile(outPath, pulled, {
6470
+ overwrite: !!opts.overwrite
6471
+ });
5659
6472
  ok(
5660
- { file: outPath, pulled: pulled.map((p) => p.key) },
5661
- () => success(
5662
- `Pulled ${pulled.length} variable(s) into ${import_picocolors9.default.bold(outPath)}.`
5663
- )
6473
+ {
6474
+ file: outPath,
6475
+ pulled: result.written,
6476
+ skipped: result.skipped,
6477
+ overwrite: !!opts.overwrite
6478
+ },
6479
+ () => {
6480
+ success(
6481
+ `Pulled ${result.written.length} variable(s) into ${import_picocolors9.default.bold(outPath)}.`
6482
+ );
6483
+ if (result.skipped.length) {
6484
+ line(
6485
+ import_picocolors9.default.dim(
6486
+ `Left alone (this computer already has ${result.skipped.length === 1 ? "it" : "them"}): ${result.skipped.join(", ")}`
6487
+ )
6488
+ );
6489
+ line(import_picocolors9.default.dim("Use --overwrite to replace them."));
6490
+ }
6491
+ }
5664
6492
  );
5665
6493
  })
5666
6494
  );
5667
6495
  }
5668
6496
  var ENV_KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=/;
5669
- async function mergeEnvFile(path, vars) {
6497
+ async function mergeEnvFile(path, vars, opts = {}) {
5670
6498
  const existing = await readFile3(path, "utf8").catch(() => "");
5671
6499
  const lines = existing.length ? existing.split(/\r?\n/) : [];
5672
6500
  const remaining = new Map(vars.map((v) => [v.key, v.value]));
6501
+ const skipped = [];
6502
+ const replaced = [];
5673
6503
  const merged = lines.map((rawLine) => {
5674
6504
  const match = ENV_KEY_LINE.exec(rawLine);
5675
6505
  if (!match || !remaining.has(match[1])) return rawLine;
5676
6506
  const key = match[1];
6507
+ if (!opts.overwrite) {
6508
+ remaining.delete(key);
6509
+ skipped.push(key);
6510
+ return rawLine;
6511
+ }
5677
6512
  const value = remaining.get(key);
5678
6513
  remaining.delete(key);
6514
+ replaced.push(key);
5679
6515
  return `${key}=${formatEnvValue(value)}`;
5680
6516
  });
5681
6517
  while (merged.length && merged[merged.length - 1] === "") merged.pop();
6518
+ const written = [...replaced];
5682
6519
  for (const [key, value] of remaining) {
5683
6520
  merged.push(`${key}=${formatEnvValue(value)}`);
6521
+ written.push(key);
5684
6522
  }
5685
6523
  await writeFile3(path, merged.join("\n") + "\n", "utf8");
6524
+ return { written, skipped };
5686
6525
  }
5687
6526
  function formatEnvValue(value) {
5688
6527
  return /[\s"'#]/.test(value) ? JSON.stringify(value) : value;
@@ -5692,16 +6531,30 @@ function formatEnvValue(value) {
5692
6531
  var import_picocolors10 = __toESM(require_picocolors(), 1);
5693
6532
  var TERMINAL = /* @__PURE__ */ new Set(["ready", "live", "success", "error", "failed", "canceled"]);
5694
6533
  function registerDeploy(program3) {
5695
- const deploy = program3.command("deploy").description("Deploy the current project to Workser (git \u2192 Vercel) and return a live URL").option("--prod", "deploy to production", false).option("--watch", "wait for the deploy to finish, streaming status", false).option(
6534
+ const deploy = program3.command("deploy").description("Deploy the current project to Workser (git \u2192 Vercel) and return a live URL").option("--prod", "deploy to production (same as --env production)", false).option(
6535
+ "--env <environment>",
6536
+ "which environment to deploy: preview (default) or production"
6537
+ ).option("--watch", "wait for the deploy to finish, streaming status", false).option(
5696
6538
  "--app <webAppId>",
5697
6539
  "which app to publish (default: the app this folder is linked to)"
5698
6540
  ).action(
5699
6541
  action(async ({ ctx, opts }) => {
5700
6542
  requireLocalApp(ctx, "deploy");
5701
6543
  const projectId = requireProject(ctx);
6544
+ const parsedEnv = parseDeployEnvironment(opts.env, "deploy");
6545
+ if (!parsedEnv.ok) {
6546
+ throw new WorkserError(parsedEnv.error, { code: "bad_input" });
6547
+ }
6548
+ if (opts.prod && parsedEnv.value === "preview") {
6549
+ throw new WorkserError(
6550
+ "`--prod` and `--env preview` ask for different things. Pass one.",
6551
+ { code: "bad_input" }
6552
+ );
6553
+ }
6554
+ const prod = Boolean(opts.prod) || parsedEnv.value === "production";
5702
6555
  const dep = await api(ctx, `/v1/projects/${projectId}/deploy`, {
5703
6556
  body: {
5704
- prod: Boolean(opts.prod),
6557
+ prod,
5705
6558
  cwd: ctx.cwd,
5706
6559
  ...opts.app ? { webAppId: opts.app } : {}
5707
6560
  }
@@ -5743,10 +6596,22 @@ function printDeploy(dep) {
5743
6596
  // src/commands/versions.ts
5744
6597
  var import_picocolors11 = __toESM(require_picocolors(), 1);
5745
6598
  function registerVersions(program3) {
5746
- program3.command("versions").description("List the Workser-managed versions of the project (deploy history)").action(
5747
- action(async ({ ctx }) => {
6599
+ program3.command("versions").description("List the Workser-managed versions of the project (deploy history)").option("--app <webAppId>", "which app (defaults to the primary app)").option(
6600
+ "--env <environment>",
6601
+ "which environment `deployed` should mean: preview or production"
6602
+ ).action(
6603
+ action(async ({ ctx, opts }) => {
5748
6604
  const projectId = requireProject(ctx);
5749
- const items = await api(ctx, `/v1/projects/${projectId}/versions`);
6605
+ const parsed = parseDeployEnvironment(opts.env, "versions");
6606
+ if (!parsed.ok) {
6607
+ throw new WorkserError(parsed.error, { code: "bad_input" });
6608
+ }
6609
+ const items = await api(ctx, `/v1/projects/${projectId}/versions`, {
6610
+ query: {
6611
+ ...opts.app ? { webAppId: String(opts.app) } : {},
6612
+ ...parsed.value ? { environment: parsed.value } : {}
6613
+ }
6614
+ });
5750
6615
  ok(items, () => {
5751
6616
  if (!items?.length) return line(import_picocolors11.default.dim("No versions yet. `workser deploy` to create one."));
5752
6617
  for (const v of items) {
@@ -5760,7 +6625,7 @@ function formatVersion(v) {
5760
6625
  const ref = import_picocolors11.default.yellow(shortRef(v.ref));
5761
6626
  const when = import_picocolors11.default.dim(formatTime(v.createdAt));
5762
6627
  const msg = (v.message ?? "").trim() || import_picocolors11.default.dim("(no message)");
5763
- const badge = v.deployed ? " " + import_picocolors11.default.green("deployed") : "";
6628
+ const badge = v.deployed ? " " + import_picocolors11.default.green(v.deployedEnvironment ? `live in ${v.deployedEnvironment}` : "deployed") : "";
5764
6629
  const url = v.url ? " " + import_picocolors11.default.cyan(v.url) : "";
5765
6630
  return `${ref} ${when} ${msg}${badge}${url}`;
5766
6631
  }
@@ -5775,19 +6640,30 @@ function formatTime(t) {
5775
6640
  }
5776
6641
 
5777
6642
  // src/commands/logs.ts
6643
+ var import_picocolors12 = __toESM(require_picocolors(), 1);
5778
6644
  function registerLogs(program3) {
5779
- program3.command("logs").description("Show recent logs for the project's app").option("-n, --lines <n>", "number of lines", "100").option("-f, --follow", "keep streaming new logs", false).action(
6645
+ program3.command("logs").description("Show the build logs of the latest deployment").option("-n, --lines <n>", "number of lines", "100").option("-f, --follow", "keep streaming new logs", false).option("--app <webAppId>", "which app (defaults to the primary app)").option("--env <environment>", "preview or production (default: whichever deployed last)").action(
5780
6646
  action(async ({ ctx, opts }) => {
5781
6647
  const projectId = requireProject(ctx);
6648
+ const parsed = parseDeployEnvironment(opts.env, "logs");
6649
+ if (!parsed.ok) {
6650
+ throw new WorkserError(parsed.error, { code: "bad_input" });
6651
+ }
6652
+ const scope = {
6653
+ ...opts.app ? { webAppId: String(opts.app) } : {},
6654
+ ...parsed.value ? { environment: parsed.value } : {}
6655
+ };
5782
6656
  const first = await api(ctx, `/v1/projects/${projectId}/logs`, {
5783
- query: { lines: opts.lines }
6657
+ query: { lines: opts.lines, ...scope }
5784
6658
  });
5785
- ok(first, () => (first.entries ?? []).forEach((e) => line(formatLog(e))));
6659
+ ok(first, () => print(first));
5786
6660
  if (opts.follow && !isJson()) {
5787
6661
  let cursor = first.cursor;
5788
6662
  for (; ; ) {
5789
6663
  await sleep(2e3);
5790
- const next = await api(ctx, `/v1/projects/${projectId}/logs`, { query: { after: cursor } });
6664
+ const next = await api(ctx, `/v1/projects/${projectId}/logs`, {
6665
+ query: { after: cursor, ...scope }
6666
+ });
5791
6667
  (next.entries ?? []).forEach((e) => line(formatLog(e)));
5792
6668
  if (next.cursor) cursor = next.cursor;
5793
6669
  }
@@ -5795,31 +6671,112 @@ function registerLogs(program3) {
5795
6671
  })
5796
6672
  );
5797
6673
  }
6674
+ function print(res) {
6675
+ const entries = res?.entries ?? [];
6676
+ for (const e of entries) line(formatLog(e));
6677
+ if (!entries.length && res?.note) line(import_picocolors12.default.dim(res.note));
6678
+ if (entries.length && res?.deploymentId) {
6679
+ const where = res.environment ? ` (${res.environment})` : "";
6680
+ line(import_picocolors12.default.dim(`\u2014 build ${res.deploymentId}${where}`));
6681
+ }
6682
+ }
5798
6683
  function formatLog(e) {
5799
6684
  if (typeof e === "string") return e;
5800
6685
  return `${e.ts ?? ""} ${e.level ? `[${e.level}] ` : ""}${e.message ?? ""}`.trim();
5801
6686
  }
5802
6687
 
5803
6688
  // src/commands/domain.ts
5804
- var import_picocolors12 = __toESM(require_picocolors(), 1);
6689
+ var import_picocolors13 = __toESM(require_picocolors(), 1);
6690
+
6691
+ // src/reserved-domains.ts
6692
+ var RESERVED_APEX = [
6693
+ "workser.ai",
6694
+ "workser.app",
6695
+ "workser.dev"
6696
+ ];
6697
+ var PROVIDER_HOSTS = ["vercel.app", "neon.tech", "expo.dev"];
6698
+ function normaliseDomain(input) {
6699
+ return input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "").replace(/\.$/, "");
6700
+ }
6701
+ function isUnder(host, suffix) {
6702
+ return host === suffix || host.endsWith(`.${suffix}`);
6703
+ }
6704
+ function checkDomain(input) {
6705
+ const host = normaliseDomain(input);
6706
+ if (!host) {
6707
+ return { allowed: false, reason: "No domain given." };
6708
+ }
6709
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(host)) {
6710
+ return {
6711
+ allowed: false,
6712
+ reason: `"${input}" is not a valid domain name.`
6713
+ };
6714
+ }
6715
+ for (const apex of RESERVED_APEX) {
6716
+ if (isUnder(host, apex)) {
6717
+ return {
6718
+ allowed: false,
6719
+ reason: `${host} belongs to Workser and cannot be attached to a project. Use a domain the customer owns. Workser's own preview and live addresses are assigned automatically \u2014 there is nothing to attach.`
6720
+ };
6721
+ }
6722
+ }
6723
+ for (const provider of PROVIDER_HOSTS) {
6724
+ if (isUnder(host, provider)) {
6725
+ return {
6726
+ allowed: false,
6727
+ reason: `${host} is assigned by the hosting provider, not attached as a custom domain. If you were trying to find where this app is already served, read its URLs instead of adding a domain.`
6728
+ };
6729
+ }
6730
+ }
6731
+ return { allowed: true };
6732
+ }
6733
+ function assertDomainAllowed(input) {
6734
+ const verdict = checkDomain(input);
6735
+ if (!verdict.allowed) throw new Error(verdict.reason);
6736
+ return normaliseDomain(input);
6737
+ }
6738
+
6739
+ // src/commands/domain.ts
5805
6740
  function registerDomain(program3) {
5806
- const domain = program3.command("domain").description("Inspect the project's custom domains");
5807
- domain.command("set <domain>").description("(owner-only) Attach a custom domain \u2014 do this in Workser Orbit").action(
5808
- action(
5809
- () => ownerOnly({
5810
- action: "domain set",
5811
- reason: "attaching a custom domain",
5812
- owner: "add the domain (and verify DNS)"
5813
- })
5814
- )
6741
+ const domain = program3.command("domain").description("Manage the project's custom domains");
6742
+ domain.command("add <domain>").description("Attach a custom domain or subdomain (asks the owner to confirm)").option("--app <webAppId>", "Which app to attach it to").action(
6743
+ action(async ({ ctx, args, opts }) => {
6744
+ const projectId = requireProject(ctx);
6745
+ const host = assertDomainAllowed(String(args[0]));
6746
+ const result = await api(
6747
+ ctx,
6748
+ opts.app ? `/v1/projects/${projectId}/web-apps/${opts.app}/domains` : `/v1/projects/${projectId}/domains`,
6749
+ { method: "POST", body: { domain: host } }
6750
+ );
6751
+ ok(result, () => {
6752
+ line(`${import_picocolors13.default.green("Attached")} ${host}`);
6753
+ line(
6754
+ import_picocolors13.default.dim(
6755
+ "It goes live once DNS points at us. Run `workser domain list` to see its status."
6756
+ )
6757
+ );
6758
+ });
6759
+ })
6760
+ );
6761
+ domain.command("rm <domain>").description("Detach a custom domain (asks the owner to confirm)").option("--app <webAppId>", "Which app it is attached to").action(
6762
+ action(async ({ ctx, args, opts }) => {
6763
+ const projectId = requireProject(ctx);
6764
+ const host = String(args[0]).trim().toLowerCase();
6765
+ const result = await api(
6766
+ ctx,
6767
+ opts.app ? `/v1/projects/${projectId}/web-apps/${opts.app}/domains/${encodeURIComponent(host)}` : `/v1/projects/${projectId}/domains/${encodeURIComponent(host)}`,
6768
+ { method: "DELETE" }
6769
+ );
6770
+ ok(result, () => line(`${import_picocolors13.default.yellow("Detached")} ${host}`));
6771
+ })
5815
6772
  );
5816
6773
  domain.command("list").description("List domains attached to the project").action(
5817
6774
  action(async ({ ctx }) => {
5818
6775
  const projectId = requireProject(ctx);
5819
6776
  const items = await api(ctx, `/v1/projects/${projectId}/domains`);
5820
6777
  ok(items, () => {
5821
- if (!items?.length) return line(import_picocolors12.default.dim("No custom domains."));
5822
- for (const d of items) line(`${d.domain}${import_picocolors12.default.dim(" " + (d.status ?? ""))}`);
6778
+ if (!items?.length) return line(import_picocolors13.default.dim("No custom domains."));
6779
+ for (const d of items) line(`${d.domain}${import_picocolors13.default.dim(" " + (d.status ?? ""))}`);
5823
6780
  });
5824
6781
  })
5825
6782
  );
@@ -5851,7 +6808,7 @@ function openUrl(url) {
5851
6808
  }
5852
6809
 
5853
6810
  // src/commands/doctor.ts
5854
- var import_picocolors13 = __toESM(require_picocolors(), 1);
6811
+ var import_picocolors14 = __toESM(require_picocolors(), 1);
5855
6812
  import { spawnSync } from "child_process";
5856
6813
  function registerDoctor(program3) {
5857
6814
  program3.command("doctor").description("Print the resolved endpoint, mode, token presence (masked), and current project").action(
@@ -5863,14 +6820,14 @@ function registerDoctor(program3) {
5863
6820
  const tokenSource = opts.token ? "--token" : process.env.WORKSER_TOKEN ? "$WORKSER_TOKEN" : session.token ? "session" : void 0;
5864
6821
  const endpointSource = opts.endpoint ? "--endpoint" : process.env.WORKSER_DAEMON_URL ? "$WORKSER_DAEMON_URL" : session.endpoint ? "session" : process.env.WORKSER_API_URL ? "$WORKSER_API_URL" : `cloud-default: ${env}`;
5865
6822
  const projectSource = opts.project ? "--project" : link?.projectId ? ".workser link" : session.defaultProjectId ? "session" : void 0;
5866
- const git = gitVersion();
6823
+ const git2 = gitVersion();
5867
6824
  const report = {
5868
6825
  endpoint: ctx.endpoint,
5869
6826
  endpointSource,
5870
6827
  env,
5871
6828
  envIgnored,
5872
6829
  mode: ctx.mode,
5873
- git: { present: git !== null, version: git },
6830
+ git: { present: git2 !== null, version: git2 },
5874
6831
  token: {
5875
6832
  present: Boolean(ctx.token),
5876
6833
  masked: ctx.token ? maskToken(ctx.token) : null,
@@ -5885,29 +6842,29 @@ function registerDoctor(program3) {
5885
6842
  workspace: session.workspaceName ?? null
5886
6843
  };
5887
6844
  ok(report, () => {
5888
- line(import_picocolors13.default.bold("workser doctor"));
5889
- line(` endpoint: ${ctx.endpoint} ${import_picocolors13.default.dim(`(${endpointSource})`)}`);
6845
+ line(import_picocolors14.default.bold("workser doctor"));
6846
+ line(` endpoint: ${ctx.endpoint} ${import_picocolors14.default.dim(`(${endpointSource})`)}`);
5890
6847
  line(
5891
- ` env: ${env === "prod" ? import_picocolors13.default.yellow(env) : env}` + import_picocolors13.default.dim(process.env.WORKSER_ENV ? " ($WORKSER_ENV)" : " (default)")
6848
+ ` env: ${env === "prod" ? import_picocolors14.default.yellow(env) : env}` + import_picocolors14.default.dim(process.env.WORKSER_ENV ? " ($WORKSER_ENV)" : " (default)")
5892
6849
  );
5893
6850
  line(` mode: ${ctx.mode}`);
5894
6851
  line(
5895
- ` token: ${ctx.token ? `${maskToken(ctx.token)} ${import_picocolors13.default.dim(`(${tokenSource})`)}` : import_picocolors13.default.yellow("none \u2014 run `workser login`")}`
6852
+ ` token: ${ctx.token ? `${maskToken(ctx.token)} ${import_picocolors14.default.dim(`(${tokenSource})`)}` : import_picocolors14.default.yellow("none \u2014 run `workser login`")}`
5896
6853
  );
5897
6854
  line(
5898
- ` project: ${ctx.projectId ?? import_picocolors13.default.dim("none")}` + (link?.name ? ` ${import_picocolors13.default.dim(`(${link.name})`)}` : "") + (projectSource ? import_picocolors13.default.dim(` [${projectSource}]`) : "")
6855
+ ` project: ${ctx.projectId ?? import_picocolors14.default.dim("none")}` + (link?.name ? ` ${import_picocolors14.default.dim(`(${link.name})`)}` : "") + (projectSource ? import_picocolors14.default.dim(` [${projectSource}]`) : "")
5899
6856
  );
5900
6857
  line(` cwd: ${ctx.cwd}`);
5901
- line(` git: ${git ?? import_picocolors13.default.dim("not on this shell's PATH")}`);
5902
- if (!git) {
6858
+ line(` git: ${git2 ?? import_picocolors14.default.dim("not on this shell's PATH")}`);
6859
+ if (!git2) {
5903
6860
  line("");
5904
6861
  line(
5905
- import_picocolors13.default.dim(
6862
+ import_picocolors14.default.dim(
5906
6863
  " Workser brings its own git, so syncing and publishing still work."
5907
6864
  )
5908
6865
  );
5909
6866
  line(
5910
- import_picocolors13.default.dim(
6867
+ import_picocolors14.default.dim(
5911
6868
  ` Only needed if you want to run git yourself here. ${GIT_INSTALL_HINT}`
5912
6869
  )
5913
6870
  );
@@ -5915,16 +6872,16 @@ function registerDoctor(program3) {
5915
6872
  if (envIgnored) {
5916
6873
  line("");
5917
6874
  line(
5918
- import_picocolors13.default.yellow(
6875
+ import_picocolors14.default.yellow(
5919
6876
  ` WORKSER_ENV=${env} is not in effect \u2014 ${endpointSource} wins.`
5920
6877
  )
5921
6878
  );
5922
6879
  line(
5923
- import_picocolors13.default.dim(
6880
+ import_picocolors14.default.dim(
5924
6881
  ` Re-run \`workser login\` to switch (the saved token is tied to ${ctx.endpoint}),`
5925
6882
  )
5926
6883
  );
5927
- line(import_picocolors13.default.dim(` or pass --endpoint ${ENV_BASE_URLS[env]}.`));
6884
+ line(import_picocolors14.default.dim(` or pass --endpoint ${ENV_BASE_URLS[env]}.`));
5928
6885
  }
5929
6886
  });
5930
6887
  })
@@ -5954,7 +6911,7 @@ function maskToken(token) {
5954
6911
  }
5955
6912
 
5956
6913
  // src/commands/agent.ts
5957
- var import_picocolors14 = __toESM(require_picocolors(), 1);
6914
+ var import_picocolors15 = __toESM(require_picocolors(), 1);
5958
6915
  var SPAWNABLE_AGENTS = ["claude_code", "codex", "kimi", "opencode", "grok"];
5959
6916
  function registerAgent(program3) {
5960
6917
  const agent = program3.command("agent").description("Delegate focused subtasks to your configured agent roles (each runs isolated)");
@@ -5962,20 +6919,20 @@ function registerAgent(program3) {
5962
6919
  action(async ({ ctx }) => {
5963
6920
  const cfg = await api(ctx, "/v1/agents");
5964
6921
  ok(cfg, () => {
5965
- line(import_picocolors14.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors14.default.dim("none")));
5966
- line(import_picocolors14.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors14.default.dim("none")));
6922
+ line(import_picocolors15.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors15.default.dim("none")));
6923
+ line(import_picocolors15.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors15.default.dim("none")));
5967
6924
  if (cfg?.effectiveMainAgent && cfg.effectiveMainAgent !== cfg.mainAgent) {
5968
6925
  line(
5969
- import_picocolors14.default.yellow(
6926
+ import_picocolors15.default.yellow(
5970
6927
  ` \u2937 failover active: runs use ${cfg.effectiveMainAgent} (main not available)`
5971
6928
  )
5972
6929
  );
5973
6930
  }
5974
6931
  const roles = cfg?.roles ?? [];
5975
6932
  if (!roles.length) {
5976
- line(import_picocolors14.default.dim("No subagents configured. Add them in the Workser Orbit Agents screen."));
6933
+ line(import_picocolors15.default.dim("No subagents configured. Add them in the Workser Orbit Agents screen."));
5977
6934
  } else {
5978
- line(import_picocolors14.default.bold("subagents:"));
6935
+ line(import_picocolors15.default.bold("subagents:"));
5979
6936
  for (const r of roles) line(" " + formatRole(r));
5980
6937
  }
5981
6938
  const detected = cfg?.detected ?? [];
@@ -5983,7 +6940,7 @@ function registerAgent(program3) {
5983
6940
  (d) => d?.installed && d?.authed !== false
5984
6941
  );
5985
6942
  line(
5986
- import_picocolors14.default.bold("spawnable (workser agent spawn <agent>):") + " " + (spawnable.length ? spawnable.map((d) => toContractId(d.id)).join(", ") : import_picocolors14.default.dim("none connected"))
6943
+ import_picocolors15.default.bold("spawnable (workser agent spawn <agent>):") + " " + (spawnable.length ? spawnable.map((d) => toContractId(d.id)).join(", ") : import_picocolors15.default.dim("none connected"))
5987
6944
  );
5988
6945
  });
5989
6946
  })
@@ -5997,8 +6954,8 @@ function registerAgent(program3) {
5997
6954
  backupAgent: cfg?.backupAgent ?? null
5998
6955
  },
5999
6956
  () => {
6000
- line(import_picocolors14.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors14.default.dim("none")));
6001
- line(import_picocolors14.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors14.default.dim("none")));
6957
+ line(import_picocolors15.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors15.default.dim("none")));
6958
+ line(import_picocolors15.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors15.default.dim("none")));
6002
6959
  }
6003
6960
  );
6004
6961
  })
@@ -6052,21 +7009,21 @@ function toContractId(id) {
6052
7009
  return id === "claude" ? "claude_code" : id;
6053
7010
  }
6054
7011
  function formatRole(r) {
6055
- const label = import_picocolors14.default.yellow(r.role);
6056
- const agent = import_picocolors14.default.dim("\xB7 " + (r.agent ?? "?"));
6057
- const enabled = r.enabled === false ? import_picocolors14.default.red("disabled") : import_picocolors14.default.green("enabled");
7012
+ const label = import_picocolors15.default.yellow(r.role);
7013
+ const agent = import_picocolors15.default.dim("\xB7 " + (r.agent ?? "?"));
7014
+ const enabled = r.enabled === false ? import_picocolors15.default.red("disabled") : import_picocolors15.default.green("enabled");
6058
7015
  const runnable = r.installed && r.authed !== false;
6059
- const ready = runnable ? import_picocolors14.default.green("runnable") : import_picocolors14.default.dim("not runnable");
7016
+ const ready = runnable ? import_picocolors15.default.green("runnable") : import_picocolors15.default.dim("not runnable");
6060
7017
  const extras = [];
6061
7018
  if (r.model) extras.push(`model ${r.model}`);
6062
7019
  if (Array.isArray(r.apps) && r.apps.length) extras.push(`apps: ${r.apps.join(",")}`);
6063
7020
  if (Array.isArray(r.mcp) && r.mcp.length) extras.push(`mcp: ${r.mcp.length}`);
6064
- const tail = extras.length ? " " + import_picocolors14.default.dim(extras.join(" \xB7 ")) : "";
7021
+ const tail = extras.length ? " " + import_picocolors15.default.dim(extras.join(" \xB7 ")) : "";
6065
7022
  return `${label} ${agent} ${enabled} ${ready}${tail}`;
6066
7023
  }
6067
7024
 
6068
7025
  // src/commands/verify.ts
6069
- var import_picocolors15 = __toESM(require_picocolors(), 1);
7026
+ var import_picocolors16 = __toESM(require_picocolors(), 1);
6070
7027
  function registerVerify(program3) {
6071
7028
  program3.command("verify").description(
6072
7029
  "Run the project's checks (typecheck/lint/build) \u2014 use before declaring a task done"
@@ -6088,23 +7045,23 @@ function registerVerify(program3) {
6088
7045
  function printVerify(res) {
6089
7046
  if (!res) return;
6090
7047
  if (!res.checks?.length) {
6091
- line(import_picocolors15.default.dim(res.note ?? "No checks detected."));
7048
+ line(import_picocolors16.default.dim(res.note ?? "No checks detected."));
6092
7049
  return;
6093
7050
  }
6094
7051
  for (const c of res.checks) {
6095
7052
  line(
6096
- ` ${c.ok ? import_picocolors15.default.green("\u2713") : import_picocolors15.default.red("\u2717")} ${c.name}${c.ok ? "" : import_picocolors15.default.dim(` (exit ${c.exitCode})`)}`
7053
+ ` ${c.ok ? import_picocolors16.default.green("\u2713") : import_picocolors16.default.red("\u2717")} ${c.name}${c.ok ? "" : import_picocolors16.default.dim(` (exit ${c.exitCode})`)}`
6097
7054
  );
6098
7055
  }
6099
7056
  if (res.ok) success("All checks passed");
6100
7057
  else
6101
7058
  line(
6102
- import_picocolors15.default.red("Some checks failed \u2014 fix the errors above and re-run ") + import_picocolors15.default.bold("workser verify") + import_picocolors15.default.red(".")
7059
+ import_picocolors16.default.red("Some checks failed \u2014 fix the errors above and re-run ") + import_picocolors16.default.bold("workser verify") + import_picocolors16.default.red(".")
6103
7060
  );
6104
7061
  }
6105
7062
 
6106
7063
  // src/commands/checkpoint.ts
6107
- var import_picocolors16 = __toESM(require_picocolors(), 1);
7064
+ var import_picocolors17 = __toESM(require_picocolors(), 1);
6108
7065
  function registerCheckpoint(program3) {
6109
7066
  program3.command("checkpoint [label]").description(
6110
7067
  "Save the current state of this folder so you can come back to it"
@@ -6121,8 +7078,8 @@ function registerCheckpoint(program3) {
6121
7078
  ok(res, () => {
6122
7079
  const p = res?.point;
6123
7080
  success(`Saved a checkpoint${p?.label ? `: ${p.label}` : ""}`);
6124
- if (p?.ref) line(import_picocolors16.default.dim(` ${p.ref.slice(0, 7)}`));
6125
- line(import_picocolors16.default.dim(" Come back to it with `workser restore`."));
7081
+ if (p?.ref) line(import_picocolors17.default.dim(` ${p.ref.slice(0, 7)}`));
7082
+ line(import_picocolors17.default.dim(" Come back to it with `workser restore`."));
6126
7083
  });
6127
7084
  })
6128
7085
  );
@@ -6148,12 +7105,12 @@ function registerCheckpoint(program3) {
6148
7105
  );
6149
7106
  if (res?.filesChanged) {
6150
7107
  line(
6151
- import_picocolors16.default.dim(
7108
+ import_picocolors17.default.dim(
6152
7109
  ` ${res.filesChanged} file${res.filesChanged === 1 ? "" : "s"} changed`
6153
7110
  )
6154
7111
  );
6155
7112
  }
6156
- line(import_picocolors16.default.dim(" This is reversible: `workser restore` again."));
7113
+ line(import_picocolors17.default.dim(" This is reversible: `workser restore` again."));
6157
7114
  });
6158
7115
  })
6159
7116
  );
@@ -6170,25 +7127,25 @@ function registerCheckpoint(program3) {
6170
7127
  function printPoints(points) {
6171
7128
  if (!points.length) {
6172
7129
  info("No checkpoints yet for this folder.");
6173
- line(import_picocolors16.default.dim(" Take one with `workser checkpoint`."));
7130
+ line(import_picocolors17.default.dim(" Take one with `workser checkpoint`."));
6174
7131
  return;
6175
7132
  }
6176
- line(import_picocolors16.default.bold("Checkpoints"));
7133
+ line(import_picocolors17.default.bold("Checkpoints"));
6177
7134
  for (const p of points) {
6178
7135
  const when = p.at ? new Date(p.at).toLocaleString() : "";
6179
7136
  line(
6180
- ` ${import_picocolors16.default.dim(p.ref.slice(0, 7))} ${p.label}${when ? import_picocolors16.default.dim(` ${when}`) : ""}`
7137
+ ` ${import_picocolors17.default.dim(p.ref.slice(0, 7))} ${p.label}${when ? import_picocolors17.default.dim(` ${when}`) : ""}`
6181
7138
  );
6182
7139
  }
6183
7140
  line(
6184
- import_picocolors16.default.dim(
7141
+ import_picocolors17.default.dim(
6185
7142
  "\nGo back with `workser restore <ref>`, or just `workser restore` for the newest."
6186
7143
  )
6187
7144
  );
6188
7145
  }
6189
7146
 
6190
7147
  // src/commands/sync.ts
6191
- var import_picocolors17 = __toESM(require_picocolors(), 1);
7148
+ var import_picocolors18 = __toESM(require_picocolors(), 1);
6192
7149
  function registerSync(program3) {
6193
7150
  program3.command("sync").description(
6194
7151
  "Reconcile this folder with the copy Workser holds (pull, then push)"
@@ -6215,7 +7172,7 @@ function registerSync(program3) {
6215
7172
  warn(res?.message ?? "Couldn't sync this folder.");
6216
7173
  if (res?.state === "diverged") {
6217
7174
  line(
6218
- import_picocolors17.default.dim(
7175
+ import_picocolors18.default.dim(
6219
7176
  " This folder and Workser's copy have both changed. Open Workser to resolve it."
6220
7177
  )
6221
7178
  );
@@ -6227,7 +7184,7 @@ function registerSync(program3) {
6227
7184
  return;
6228
7185
  }
6229
7186
  success("Synced");
6230
- if (res?.ref) line(import_picocolors17.default.dim(` ${String(res.ref).slice(0, 7)}`));
7187
+ if (res?.ref) line(import_picocolors18.default.dim(` ${String(res.ref).slice(0, 7)}`));
6231
7188
  });
6232
7189
  if (refused) process.exitCode = 1;
6233
7190
  })
@@ -6235,7 +7192,7 @@ function registerSync(program3) {
6235
7192
  }
6236
7193
 
6237
7194
  // src/commands/workflow.ts
6238
- var import_picocolors18 = __toESM(require_picocolors(), 1);
7195
+ var import_picocolors19 = __toESM(require_picocolors(), 1);
6239
7196
  function registerWorkflow(program3) {
6240
7197
  const wf = program3.command("workflow").description("Create, run, and inspect workflow automations for the project");
6241
7198
  wf.command("list").description("List the project's workflows").action(
@@ -6243,10 +7200,10 @@ function registerWorkflow(program3) {
6243
7200
  const projectId = requireProject(ctx);
6244
7201
  const items = await api(ctx, `/v1/projects/${projectId}/workflows`);
6245
7202
  ok(items, () => {
6246
- if (!items?.length) return line(import_picocolors18.default.dim("No workflows yet. `workser workflow create`."));
7203
+ if (!items?.length) return line(import_picocolors19.default.dim("No workflows yet. `workser workflow create`."));
6247
7204
  for (const w of items) {
6248
- const status = w.is_active ? import_picocolors18.default.green("active") : import_picocolors18.default.dim("inactive");
6249
- line(`${w.id} ${import_picocolors18.default.bold(w.name ?? "Untitled")} ${status}`);
7205
+ const status = w.is_active ? import_picocolors19.default.green("active") : import_picocolors19.default.dim("inactive");
7206
+ line(`${w.id} ${import_picocolors19.default.bold(w.name ?? "Untitled")} ${status}`);
6250
7207
  }
6251
7208
  });
6252
7209
  })
@@ -6258,7 +7215,7 @@ function registerWorkflow(program3) {
6258
7215
  const res = await api(ctx, `/v1/projects/${projectId}/workflows`, {
6259
7216
  body: { name: args[0], ...extra }
6260
7217
  });
6261
- ok(res, () => line(`Created workflow ${import_picocolors18.default.bold(res.id)}.`));
7218
+ ok(res, () => line(`Created workflow ${import_picocolors19.default.bold(res.id)}.`));
6262
7219
  })
6263
7220
  );
6264
7221
  wf.command("get <id>").description("Show a workflow's full definition").action(
@@ -6293,8 +7250,8 @@ function registerWorkflow(program3) {
6293
7250
  action(async ({ ctx, args }) => {
6294
7251
  const items = await api(ctx, `/v1/workflows/${args[0]}/executions`);
6295
7252
  ok(items, () => {
6296
- if (!items?.length) return line(import_picocolors18.default.dim("No runs yet."));
6297
- for (const e of items) line(`${e.id} ${e.status ?? ""} ${import_picocolors18.default.dim(e.started_at ?? "")}`);
7253
+ if (!items?.length) return line(import_picocolors19.default.dim("No runs yet."));
7254
+ for (const e of items) line(`${e.id} ${e.status ?? ""} ${import_picocolors19.default.dim(e.started_at ?? "")}`);
6298
7255
  });
6299
7256
  })
6300
7257
  );
@@ -6302,15 +7259,15 @@ function registerWorkflow(program3) {
6302
7259
  action(async ({ ctx, args }) => {
6303
7260
  const items = await api(ctx, `/v1/node-types`, { query: { q: args[0] } });
6304
7261
  ok(items, () => {
6305
- if (!items?.length) return line(import_picocolors18.default.dim("No matching node types."));
6306
- for (const n of items) line(`${n.name ?? n.type} ${import_picocolors18.default.dim(n.category ?? "")}`);
7262
+ if (!items?.length) return line(import_picocolors19.default.dim("No matching node types."));
7263
+ for (const n of items) line(`${n.name ?? n.type} ${import_picocolors19.default.dim(n.category ?? "")}`);
6307
7264
  });
6308
7265
  })
6309
7266
  );
6310
7267
  }
6311
7268
 
6312
7269
  // src/commands/app.ts
6313
- var import_picocolors19 = __toESM(require_picocolors(), 1);
7270
+ var import_picocolors20 = __toESM(require_picocolors(), 1);
6314
7271
  function registerApp(program3) {
6315
7272
  const appCmd = program3.command("app").description("Connect and use third-party app integrations (Gmail, Slack, Stripe, ...)");
6316
7273
  appCmd.command("list").description("List connectable toolkits and this project's existing connections").option("--toolkit <slug>", "filter connections to one toolkit").action(
@@ -6323,8 +7280,8 @@ function registerApp(program3) {
6323
7280
  ok({ catalog, connections }, () => {
6324
7281
  const connected = new Set((connections ?? []).map((c) => c.toolkit ?? c.composio_app));
6325
7282
  for (const t of catalog ?? []) {
6326
- const status = connected.has(t.slug) ? import_picocolors19.default.green("connected") : import_picocolors19.default.dim("not connected");
6327
- line(`${t.slug} ${import_picocolors19.default.bold(t.name ?? t.slug)} ${status}`);
7283
+ const status = connected.has(t.slug) ? import_picocolors20.default.green("connected") : import_picocolors20.default.dim("not connected");
7284
+ line(`${t.slug} ${import_picocolors20.default.bold(t.name ?? t.slug)} ${status}`);
6328
7285
  }
6329
7286
  });
6330
7287
  })
@@ -6341,7 +7298,7 @@ function registerApp(program3) {
6341
7298
  });
6342
7299
  ok(
6343
7300
  res,
6344
- () => res.oauth_url ? line(`Open this URL to finish connecting: ${import_picocolors19.default.underline(res.oauth_url)}`) : line(`Connection ${res.connection_id} is ${res.status}.`)
7301
+ () => res.oauth_url ? line(`Open this URL to finish connecting: ${import_picocolors20.default.underline(res.oauth_url)}`) : line(`Connection ${res.connection_id} is ${res.status}.`)
6345
7302
  );
6346
7303
  })
6347
7304
  );
@@ -6359,8 +7316,8 @@ function registerApp(program3) {
6359
7316
  const projectId = requireProject(ctx);
6360
7317
  const items = await api(ctx, `/v1/projects/${projectId}/integrations/${args[0]}/tools`);
6361
7318
  ok(items, () => {
6362
- if (!items?.length) return line(import_picocolors19.default.dim("No tools found."));
6363
- for (const t of items) line(`${t.slug} ${import_picocolors19.default.dim(t.description ?? "")}`);
7319
+ if (!items?.length) return line(import_picocolors20.default.dim("No tools found."));
7320
+ for (const t of items) line(`${t.slug} ${import_picocolors20.default.dim(t.description ?? "")}`);
6364
7321
  });
6365
7322
  })
6366
7323
  );
@@ -6376,7 +7333,7 @@ function registerApp(program3) {
6376
7333
  }
6377
7334
 
6378
7335
  // src/commands/tool.ts
6379
- var import_picocolors20 = __toESM(require_picocolors(), 1);
7336
+ var import_picocolors21 = __toESM(require_picocolors(), 1);
6380
7337
  function registerTool(program3) {
6381
7338
  const tool = program3.command("tool").description(
6382
7339
  "Computer-use tools: filesystem, shell, screenshot, input control, clipboard, notifications, basic browser"
@@ -6385,7 +7342,7 @@ function registerTool(program3) {
6385
7342
  action(async ({ ctx }) => {
6386
7343
  const tools = await api(ctx, "/v1/tool/list");
6387
7344
  ok(tools, () => {
6388
- if (!tools?.length) return line(import_picocolors20.default.dim("No tools available."));
7345
+ if (!tools?.length) return line(import_picocolors21.default.dim("No tools available."));
6389
7346
  const byCategory = /* @__PURE__ */ new Map();
6390
7347
  for (const t of tools) {
6391
7348
  const list = byCategory.get(t.category) ?? [];
@@ -6393,9 +7350,9 @@ function registerTool(program3) {
6393
7350
  byCategory.set(t.category, list);
6394
7351
  }
6395
7352
  for (const [category, items] of byCategory) {
6396
- line(import_picocolors20.default.bold(category) + ":");
7353
+ line(import_picocolors21.default.bold(category) + ":");
6397
7354
  for (const t of items) {
6398
- line(` ${t.name} ${import_picocolors20.default.dim(t.description ?? "")}`);
7355
+ line(` ${t.name} ${import_picocolors21.default.dim(t.description ?? "")}`);
6399
7356
  }
6400
7357
  }
6401
7358
  });
@@ -6413,7 +7370,7 @@ function registerTool(program3) {
6413
7370
  }
6414
7371
 
6415
7372
  // src/commands/memory.ts
6416
- var import_picocolors21 = __toESM(require_picocolors(), 1);
7373
+ var import_picocolors22 = __toESM(require_picocolors(), 1);
6417
7374
  function registerMemory(program3) {
6418
7375
  const memory = program3.command("memory").description("Durable, cross-conversation project memory (shared with cloud agents on the same project)");
6419
7376
  memory.command("add <content>").description("Store something worth remembering across future conversations").option("--metadata <json>", "extra metadata for filtering, as a JSON string").option("--id <customId>", "custom id for dedup/updates").action(
@@ -6437,9 +7394,9 @@ function registerMemory(program3) {
6437
7394
  });
6438
7395
  ok(res, () => {
6439
7396
  const results = res?.results ?? res ?? [];
6440
- if (!results?.length) return line(import_picocolors21.default.dim("No matching memories."));
7397
+ if (!results?.length) return line(import_picocolors22.default.dim("No matching memories."));
6441
7398
  for (const r of results) {
6442
- line(`${import_picocolors21.default.dim(r.id ?? "?")} ${r.memory ?? r.content ?? ""}`);
7399
+ line(`${import_picocolors22.default.dim(r.id ?? "?")} ${r.memory ?? r.content ?? ""}`);
6443
7400
  }
6444
7401
  });
6445
7402
  })
@@ -6456,7 +7413,7 @@ function registerMemory(program3) {
6456
7413
  }
6457
7414
 
6458
7415
  // src/commands/business.ts
6459
- var import_picocolors22 = __toESM(require_picocolors(), 1);
7416
+ var import_picocolors23 = __toESM(require_picocolors(), 1);
6460
7417
  var RESOURCE_PATHS = {
6461
7418
  "business-config": "business-config",
6462
7419
  "business-settings": "business-settings",
@@ -6516,7 +7473,7 @@ function registerBusiness(program3) {
6516
7473
  const projectId = requireProject(ctx);
6517
7474
  const [resource] = args;
6518
7475
  const res = await api(ctx, businessPath(projectId, resource), { body: JSON.parse(opts.body) });
6519
- ok(res, () => line(`Created ${resource} ${import_picocolors22.default.bold(res?.id ?? "")}.`));
7476
+ ok(res, () => line(`Created ${resource} ${import_picocolors23.default.bold(res?.id ?? "")}.`));
6520
7477
  })
6521
7478
  );
6522
7479
  biz.command("update <resource> <id>").description("Update a record by id (PATCH/PUT \u2014 matches the underlying route)").option("--body <json>", "changed fields as a JSON object string", "{}").action(
@@ -6558,7 +7515,7 @@ function businessPath(projectId, resource, subpath) {
6558
7515
  }
6559
7516
 
6560
7517
  // src/commands/artifact.ts
6561
- var import_picocolors23 = __toESM(require_picocolors(), 1);
7518
+ var import_picocolors24 = __toESM(require_picocolors(), 1);
6562
7519
  import { existsSync as existsSync2, statSync } from "fs";
6563
7520
  import { resolve as resolve2, basename as basename3 } from "path";
6564
7521
  var KINDS = [
@@ -6575,14 +7532,28 @@ var KINDS = [
6575
7532
  "audio",
6576
7533
  "document",
6577
7534
  "archive",
6578
- "other"
7535
+ "other",
7536
+ // Deliverable shapes — see above.
7537
+ "report",
7538
+ "walkthrough",
7539
+ "before_after",
7540
+ "checks",
7541
+ "web_app",
7542
+ "service",
7543
+ "design"
6579
7544
  ];
6580
7545
  function registerArtifact(program3) {
6581
7546
  const artifact = program3.command("artifact").description("Record the files, folders and apps this task produced");
6582
7547
  artifact.command("add [path]").description("Register a deliverable so it shows on the task").option(
6583
7548
  "-k, --kind <kind>",
6584
7549
  `what it is: ${KINDS.join(" | ")} (default: inferred from the path)`
6585
- ).option("-t, --title <title>", "display name (default: the file name)").option("-d, --description <text>", "one line on what it is / what it's for").option("-u, --url <url>", "a hosted deliverable (deployed app, public file)").action(
7550
+ ).option("-t, --title <title>", "display name (default: the file name)").option("-d, --description <text>", "one line on what it is / what it's for").option("-u, --url <url>", "a hosted deliverable (deployed app, public file)").option(
7551
+ "--data <json>",
7552
+ `figures the card shows, as JSON \u2014 e.g. '{"passed":12,"total":12}'`
7553
+ ).option(
7554
+ "--promote",
7555
+ "hand it straight to the task as one of the things the owner asked for"
7556
+ ).action(
6586
7557
  action(async ({ ctx, opts, args }) => {
6587
7558
  const rawPath = args[0];
6588
7559
  const url = opts.url;
@@ -6612,19 +7583,36 @@ function registerArtifact(program3) {
6612
7583
  { code: "bad_request" }
6613
7584
  );
6614
7585
  }
7586
+ let data;
7587
+ if (opts.data) {
7588
+ try {
7589
+ const parsed = JSON.parse(String(opts.data));
7590
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
7591
+ throw new Error("not an object");
7592
+ }
7593
+ data = parsed;
7594
+ } catch (err) {
7595
+ throw new WorkserError(
7596
+ `--data must be a JSON object. ${err instanceof Error ? err.message : String(err)}`,
7597
+ { code: "bad_request" }
7598
+ );
7599
+ }
7600
+ }
6615
7601
  const res = await api(ctx, `/v1/runs/${runTarget(ctx)}/artifacts`, {
6616
7602
  body: {
6617
7603
  path: absPath,
6618
7604
  url,
6619
7605
  kind,
6620
7606
  title: opts.title || (absPath ? basename3(absPath) : url),
6621
- description: opts.description
7607
+ description: opts.description,
7608
+ data,
7609
+ promote: opts.promote ? true : void 0
6622
7610
  }
6623
7611
  });
6624
7612
  ok(
6625
7613
  res,
6626
7614
  () => success(
6627
- `Recorded ${import_picocolors23.default.bold(res?.title ?? "artifact")}${res?.kind ? import_picocolors23.default.dim(` (${res.kind})`) : ""}`
7615
+ `Recorded ${import_picocolors24.default.bold(res?.title ?? "artifact")}${res?.kind ? import_picocolors24.default.dim(` (${res.kind})`) : ""}`
6628
7616
  )
6629
7617
  );
6630
7618
  })
@@ -6638,11 +7626,11 @@ function registerArtifact(program3) {
6638
7626
  }
6639
7627
  function printRun(run) {
6640
7628
  if (!run) return;
6641
- line(` run ${import_picocolors23.default.bold(run.runId)}`);
7629
+ line(` run ${import_picocolors24.default.bold(run.runId)}`);
6642
7630
  if (run.taskId) line(` task ${run.taskId}`);
6643
7631
  if (run.conversationId) line(` chat ${run.conversationId}`);
6644
7632
  if (run.projectId) line(` project ${run.projectId}`);
6645
- if (run.cwd) line(` folder ${import_picocolors23.default.dim(run.cwd)}`);
7633
+ if (run.cwd) line(` folder ${import_picocolors24.default.dim(run.cwd)}`);
6646
7634
  }
6647
7635
 
6648
7636
  // src/commands/image.ts
@@ -6716,7 +7704,7 @@ async function download(url, output) {
6716
7704
  }
6717
7705
 
6718
7706
  // src/commands/ask.ts
6719
- var import_picocolors24 = __toESM(require_picocolors(), 1);
7707
+ var import_picocolors25 = __toESM(require_picocolors(), 1);
6720
7708
  var TYPES = [
6721
7709
  "input",
6722
7710
  "choice",
@@ -6767,7 +7755,7 @@ function registerAsk(program3) {
6767
7755
  code: "bad_request"
6768
7756
  });
6769
7757
  }
6770
- info(import_picocolors24.default.dim("Waiting for the user to answer\u2026"));
7758
+ info(import_picocolors25.default.dim("Waiting for the user to answer\u2026"));
6771
7759
  const res = await api(ctx, `/v1/runs/${runTarget(ctx)}/ask`, {
6772
7760
  body: {
6773
7761
  type,
@@ -6793,12 +7781,12 @@ function deriveTitle(message) {
6793
7781
  function printAnswer(res) {
6794
7782
  if (!res) return;
6795
7783
  if (res.status === "answered") {
6796
- line(` ${import_picocolors24.default.green("answered")}`);
7784
+ line(` ${import_picocolors25.default.green("answered")}`);
6797
7785
  const value = extract(res.response);
6798
7786
  if (value) line(` ${value}`);
6799
7787
  return;
6800
7788
  }
6801
- line(` ${import_picocolors24.default.yellow(res.status)} ${import_picocolors24.default.dim(res.reason ?? "")}`);
7789
+ line(` ${import_picocolors25.default.yellow(res.status)} ${import_picocolors25.default.dim(res.reason ?? "")}`);
6802
7790
  }
6803
7791
  function extract(response) {
6804
7792
  if (response == null) return "";
@@ -6817,7 +7805,7 @@ function extract(response) {
6817
7805
  }
6818
7806
 
6819
7807
  // src/commands/search.ts
6820
- var import_picocolors25 = __toESM(require_picocolors(), 1);
7808
+ var import_picocolors26 = __toESM(require_picocolors(), 1);
6821
7809
  function registerSearch(program3) {
6822
7810
  program3.command("search <query>").description("Search the web (Google-grounded, server-side)").option("-n, --max-results <n>", "max results", "5").action(
6823
7811
  action(async ({ ctx, args, opts }) => {
@@ -6830,9 +7818,9 @@ function registerSearch(program3) {
6830
7818
  line("");
6831
7819
  }
6832
7820
  const results = res?.results ?? [];
6833
- if (!results.length) return line(import_picocolors25.default.dim("No results."));
7821
+ if (!results.length) return line(import_picocolors26.default.dim("No results."));
6834
7822
  for (const r of results) {
6835
- line(`${r.title || import_picocolors25.default.dim("(untitled)")} ${import_picocolors25.default.dim(r.url)}`);
7823
+ line(`${r.title || import_picocolors26.default.dim("(untitled)")} ${import_picocolors26.default.dim(r.url)}`);
6836
7824
  }
6837
7825
  });
6838
7826
  })
@@ -6840,7 +7828,7 @@ function registerSearch(program3) {
6840
7828
  }
6841
7829
 
6842
7830
  // src/commands/board.ts
6843
- var import_picocolors26 = __toESM(require_picocolors(), 1);
7831
+ var import_picocolors27 = __toESM(require_picocolors(), 1);
6844
7832
 
6845
7833
  // src/commands/record-step.ts
6846
7834
  async function recordEntityStep(ctx, opts) {
@@ -6879,7 +7867,7 @@ function registerBoard(program3) {
6879
7867
  }
6880
7868
  ok(rows, () => {
6881
7869
  if (!rows.length) {
6882
- line(import_picocolors26.default.dim("No cards on the Board yet."));
7870
+ line(import_picocolors27.default.dim("No cards on the Board yet."));
6883
7871
  return;
6884
7872
  }
6885
7873
  for (const r of rows) line(formatRow(r));
@@ -6894,7 +7882,7 @@ function registerBoard(program3) {
6894
7882
  `/v1/projects/${projectId}/work-items/${args[0]}`
6895
7883
  );
6896
7884
  ok(row, () => {
6897
- line(`${import_picocolors26.default.bold(row.title)} ${import_picocolors26.default.dim(row.id)}`);
7885
+ line(`${import_picocolors27.default.bold(row.title)} ${import_picocolors27.default.dim(row.id)}`);
6898
7886
  line(`${statusTag(row.status)} priority ${row.priority}`);
6899
7887
  if (row.ownerHuman) line(`owner: ${row.ownerHuman}`);
6900
7888
  if (row.labels?.length) line(`labels: ${row.labels.join(", ")}`);
@@ -6935,7 +7923,7 @@ ${row.description}`);
6935
7923
  refId: row?.id,
6936
7924
  output: { workItem: row }
6937
7925
  });
6938
- ok(row, () => line(`Created work item ${import_picocolors26.default.bold(row?.id ?? "")} \u2014 ${title}`));
7926
+ ok(row, () => line(`Created work item ${import_picocolors27.default.bold(row?.id ?? "")} \u2014 ${title}`));
6939
7927
  })
6940
7928
  );
6941
7929
  board.command("update <id>").description("Change fields on a card \u2014 pass only what changes").option("--title <text>", "new title").option("--description <text>", "new description").option("--status <value>", STATUSES.join(" | ")).option("--priority <value>", PRIORITIES.join(" | ")).option(
@@ -6969,7 +7957,7 @@ ${row.description}`);
6969
7957
  );
6970
7958
  }
6971
7959
  const row = await patchItem(ctx, projectId, String(args[0]), body);
6972
- ok(row, () => line(`Updated ${import_picocolors26.default.bold(row.id)} \u2014 ${row.title} ${statusTag(row.status)}`));
7960
+ ok(row, () => line(`Updated ${import_picocolors27.default.bold(row.id)} \u2014 ${row.title} ${statusTag(row.status)}`));
6973
7961
  })
6974
7962
  );
6975
7963
  board.command("move <id> <status>").description(
@@ -6980,14 +7968,14 @@ ${row.description}`);
6980
7968
  const status = String(args[1]);
6981
7969
  assertStatus(status);
6982
7970
  const row = await patchItem(ctx, projectId, String(args[0]), { status });
6983
- ok(row, () => line(`Moved ${import_picocolors26.default.bold(row.title)} \u2192 ${statusTag(row.status)}`));
7971
+ ok(row, () => line(`Moved ${import_picocolors27.default.bold(row.title)} \u2192 ${statusTag(row.status)}`));
6984
7972
  })
6985
7973
  );
6986
7974
  board.command("close <id>").description("Shorthand for `board move <id> done`").action(
6987
7975
  action(async ({ ctx, args }) => {
6988
7976
  const projectId = requireProject(ctx);
6989
7977
  const row = await patchItem(ctx, projectId, String(args[0]), { status: "done" });
6990
- ok(row, () => line(`Closed ${import_picocolors26.default.bold(row.title)} ${statusTag(row.status)}`));
7978
+ ok(row, () => line(`Closed ${import_picocolors27.default.bold(row.title)} ${statusTag(row.status)}`));
6991
7979
  })
6992
7980
  );
6993
7981
  }
@@ -7020,23 +8008,23 @@ function assertPriority(value) {
7020
8008
  }
7021
8009
  }
7022
8010
  function formatRow(r) {
7023
- const labels = r.labels?.length ? import_picocolors26.default.dim(` [${r.labels.join(", ")}]`) : "";
7024
- const owner = r.ownerHuman ? import_picocolors26.default.dim(` @${r.ownerHuman}`) : "";
7025
- return `${import_picocolors26.default.dim(r.id)} ${statusTag(r.status)} ${r.title}${labels}${owner}`;
8011
+ const labels = r.labels?.length ? import_picocolors27.default.dim(` [${r.labels.join(", ")}]`) : "";
8012
+ const owner = r.ownerHuman ? import_picocolors27.default.dim(` @${r.ownerHuman}`) : "";
8013
+ return `${import_picocolors27.default.dim(r.id)} ${statusTag(r.status)} ${r.title}${labels}${owner}`;
7026
8014
  }
7027
8015
  function statusTag(status) {
7028
8016
  const label = status.padEnd(11);
7029
- if (status === "done") return import_picocolors26.default.green(label);
7030
- if (status === "in-progress") return import_picocolors26.default.yellow(label);
7031
- if (status === "in-review") return import_picocolors26.default.cyan(label);
7032
- return import_picocolors26.default.dim(label);
8017
+ if (status === "done") return import_picocolors27.default.green(label);
8018
+ if (status === "in-progress") return import_picocolors27.default.yellow(label);
8019
+ if (status === "in-review") return import_picocolors27.default.cyan(label);
8020
+ return import_picocolors27.default.dim(label);
7033
8021
  }
7034
8022
  function collect2(value, previous) {
7035
8023
  return [...previous, value];
7036
8024
  }
7037
8025
 
7038
8026
  // src/commands/task.ts
7039
- var import_picocolors27 = __toESM(require_picocolors(), 1);
8027
+ var import_picocolors28 = __toESM(require_picocolors(), 1);
7040
8028
  var STATUSES2 = ["todo", "working", "checking", "ready", "accepted", "archived"];
7041
8029
  var ROLES = ["pm", "architect", "web", "api", "automation", "qa"];
7042
8030
  var KINDS2 = ["data_reports", "web", "mobile", "service", "automation", "docs"];
@@ -7055,7 +8043,7 @@ function registerTask(program3) {
7055
8043
  }) ?? [];
7056
8044
  ok(rows, () => {
7057
8045
  if (!rows.length) {
7058
- line(import_picocolors27.default.dim("No tasks on the board yet."));
8046
+ line(import_picocolors28.default.dim("No tasks on the board yet."));
7059
8047
  return;
7060
8048
  }
7061
8049
  for (const r of rows) line(formatRow2(r));
@@ -7096,8 +8084,8 @@ function registerTask(program3) {
7096
8084
  }
7097
8085
  });
7098
8086
  ok(row, () => {
7099
- line(`${import_picocolors27.default.green("added")} ${import_picocolors27.default.bold(row.title)} ${import_picocolors27.default.dim(row.key ?? row.id)}`);
7100
- if (row.role) line(import_picocolors27.default.dim(`role: ${row.role}`));
8087
+ line(`${import_picocolors28.default.green("added")} ${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.key ?? row.id)}`);
8088
+ if (row.role) line(import_picocolors28.default.dim(`role: ${row.role}`));
7101
8089
  });
7102
8090
  })
7103
8091
  );
@@ -7108,7 +8096,7 @@ function registerTask(program3) {
7108
8096
  const rows = row.subtasks ?? [];
7109
8097
  ok(rows, () => {
7110
8098
  if (!rows.length) {
7111
- line(import_picocolors27.default.dim("No steps yet."));
8099
+ line(import_picocolors28.default.dim("No steps yet."));
7112
8100
  return;
7113
8101
  }
7114
8102
  rows.forEach((r, i) => line(formatSubtask(r, i + 1)));
@@ -7133,7 +8121,7 @@ function registerTask(program3) {
7133
8121
  }
7134
8122
  }
7135
8123
  );
7136
- ok(row, () => line(`${import_picocolors27.default.green("updated")} ${import_picocolors27.default.bold(row.title)}`));
8124
+ ok(row, () => line(`${import_picocolors28.default.green("updated")} ${import_picocolors28.default.bold(row.title)}`));
7137
8125
  })
7138
8126
  );
7139
8127
  subtask.command("remove <id>").description("Drop a step from the plan (only before the work starts)").action(
@@ -7141,7 +8129,7 @@ function registerTask(program3) {
7141
8129
  await api(ctx, `/v1/project-tasks/${encodeURIComponent(args[0])}`, {
7142
8130
  method: "DELETE"
7143
8131
  });
7144
- ok({ removed: args[0] }, () => line(import_picocolors27.default.green("removed")));
8132
+ ok({ removed: args[0] }, () => line(import_picocolors28.default.green("removed")));
7145
8133
  })
7146
8134
  );
7147
8135
  task.command("move <id> <status>").description(`Move a task or step along the board (${STATUSES2.join(" | ")})`).action(
@@ -7152,7 +8140,27 @@ function registerTask(program3) {
7152
8140
  `/v1/project-tasks/${encodeURIComponent(args[0])}/move`,
7153
8141
  { body: { status: args[1] } }
7154
8142
  );
7155
- ok(row, () => line(`${import_picocolors27.default.green("moved")} ${import_picocolors27.default.bold(row.title)} \u2192 ${args[1]}`));
8143
+ ok(row, () => line(`${import_picocolors28.default.green("moved")} ${import_picocolors28.default.bold(row.title)} \u2192 ${args[1]}`));
8144
+ })
8145
+ );
8146
+ subtask.command("send-back <id>").description("Send a finished step back to be done again, with the reason").requiredOption("--note <text>", "what was wrong with it").action(
8147
+ action(async ({ ctx, args, opts }) => {
8148
+ const row = await api(
8149
+ ctx,
8150
+ `/v1/project-subtasks/${encodeURIComponent(String(args[0]))}/reopen`,
8151
+ { method: "POST", body: { note: opts.note } }
8152
+ );
8153
+ ok(row, () => {
8154
+ if (row?.reopened) {
8155
+ line(`${import_picocolors28.default.green("sent back")} \u2014 it will be picked up again`);
8156
+ return;
8157
+ }
8158
+ line(
8159
+ import_picocolors28.default.yellow(
8160
+ row?.reason === "already_open" ? "already waiting to be picked up \u2014 nothing to send back" : row?.reason === "still_working" ? "still working \u2014 let it finish before sending it back" : "could not send that step back"
8161
+ )
8162
+ );
8163
+ });
7156
8164
  })
7157
8165
  );
7158
8166
  task.command("can-start [id]").description("Ask whether work on this task may begin. Refuses until the owner approves.").action(
@@ -7163,7 +8171,7 @@ function registerTask(program3) {
7163
8171
  `/v1/project-tasks/${encodeURIComponent(id)}/dispatch-check`,
7164
8172
  { method: "POST" }
7165
8173
  );
7166
- ok(row, () => line(import_picocolors27.default.green("approved \u2014 you may start")));
8174
+ ok(row, () => line(import_picocolors28.default.green("approved \u2014 you may start")));
7167
8175
  })
7168
8176
  );
7169
8177
  task.command("approval").description("Ask the owner to approve the plan, or record their decision").argument("<request|approve|decline>").option("--task <id>", "defaults to the task this run is inside").option("--note <text>", "why").action(
@@ -7177,7 +8185,7 @@ function registerTask(program3) {
7177
8185
  );
7178
8186
  ok({ awaiting: row2.approval_state === "awaiting", task: row2 }, () => {
7179
8187
  line(
7180
- row2.approval_state === "awaiting" ? import_picocolors27.default.yellow("The plan is waiting on the owner. They see it in the task.") : `Already ${row2.approval_state}.`
8188
+ row2.approval_state === "awaiting" ? import_picocolors28.default.yellow("The plan is waiting on the owner. They see it in the task.") : `Already ${row2.approval_state}.`
7181
8189
  );
7182
8190
  });
7183
8191
  return;
@@ -7198,7 +8206,7 @@ function registerTask(program3) {
7198
8206
  }
7199
8207
  }
7200
8208
  );
7201
- ok(row, () => line(`${import_picocolors27.default.green(row.approval_state)} ${import_picocolors27.default.bold(row.title)}`));
8209
+ ok(row, () => line(`${import_picocolors28.default.green(row.approval_state)} ${import_picocolors28.default.bold(row.title)}`));
7202
8210
  })
7203
8211
  );
7204
8212
  task.command("done [id]").description("Record what a step produced, and move it to ready").option("--summary <text>", "what changed, in the owner's words").action(
@@ -7213,7 +8221,7 @@ function registerTask(program3) {
7213
8221
  `/v1/project-tasks/${encodeURIComponent(id)}/move`,
7214
8222
  { body: { status: "ready" } }
7215
8223
  );
7216
- ok(row, () => line(`${import_picocolors27.default.green("ready")} ${import_picocolors27.default.bold(row.title)}`));
8224
+ ok(row, () => line(`${import_picocolors28.default.green("ready")} ${import_picocolors28.default.bold(row.title)}`));
7217
8225
  })
7218
8226
  );
7219
8227
  }
@@ -7258,19 +8266,19 @@ function assertOneOf(flag, value, allowed) {
7258
8266
  }
7259
8267
  }
7260
8268
  function formatRow2(r) {
7261
- const key = import_picocolors27.default.dim((r.key ?? r.id.slice(0, 8)).padEnd(10));
7262
- const steps = r.subtaskTotal ? import_picocolors27.default.dim(` ${r.subtaskDone}/${r.subtaskTotal}`) : "";
7263
- const gate = r.approval_state === "awaiting" ? import_picocolors27.default.yellow(" awaiting approval") : "";
8269
+ const key = import_picocolors28.default.dim((r.key ?? r.id.slice(0, 8)).padEnd(10));
8270
+ const steps = r.subtaskTotal ? import_picocolors28.default.dim(` ${r.subtaskDone}/${r.subtaskTotal}`) : "";
8271
+ const gate = r.approval_state === "awaiting" ? import_picocolors28.default.yellow(" awaiting approval") : "";
7264
8272
  return `${key} ${statusTag2(r.status)} ${r.title}${steps}${gate}`;
7265
8273
  }
7266
8274
  function formatSubtask(r, index) {
7267
- const n = import_picocolors27.default.dim(String(index).padStart(2, "0"));
7268
- const role = r.role ? import_picocolors27.default.dim(` [${r.role}]`) : "";
7269
- const scope = r.scope_paths?.length ? import_picocolors27.default.dim(` owns: ${r.scope_paths.join(", ")}`) : "";
8275
+ const n = import_picocolors28.default.dim(String(index).padStart(2, "0"));
8276
+ const role = r.role ? import_picocolors28.default.dim(` [${r.role}]`) : "";
8277
+ const scope = r.scope_paths?.length ? import_picocolors28.default.dim(` owns: ${r.scope_paths.join(", ")}`) : "";
7270
8278
  return `${n} ${statusTag2(r.status)} ${r.title}${role}${scope}`;
7271
8279
  }
7272
8280
  function printTask(row) {
7273
- line(`${import_picocolors27.default.bold(row.title)} ${import_picocolors27.default.dim(row.key ?? row.id)}`);
8281
+ line(`${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.key ?? row.id)}`);
7274
8282
  line(`${statusTag2(row.status)} approval: ${row.approval_state}`);
7275
8283
  if (row.summary) line(`
7276
8284
  ${row.summary}`);
@@ -7282,26 +8290,26 @@ touches: ${row.targets.map((t) => t.appName ?? t.ref ?? t.kind).join(", ")}`
7282
8290
  }
7283
8291
  if (row.subtasks?.length) {
7284
8292
  line(`
7285
- ${import_picocolors27.default.bold("steps")}`);
8293
+ ${import_picocolors28.default.bold("steps")}`);
7286
8294
  row.subtasks.forEach((s, i) => line(formatSubtask(s, i + 1)));
7287
8295
  } else {
7288
- line(import_picocolors27.default.dim("\nNo steps yet."));
8296
+ line(import_picocolors28.default.dim("\nNo steps yet."));
7289
8297
  }
7290
8298
  }
7291
8299
  function statusTag2(status) {
7292
8300
  switch (status) {
7293
8301
  case "ready":
7294
- return import_picocolors27.default.green("[ready]");
8302
+ return import_picocolors28.default.green("[ready]");
7295
8303
  case "working":
7296
- return import_picocolors27.default.blue("[working]");
8304
+ return import_picocolors28.default.blue("[working]");
7297
8305
  case "checking":
7298
- return import_picocolors27.default.cyan("[checking]");
8306
+ return import_picocolors28.default.cyan("[checking]");
7299
8307
  case "accepted":
7300
- return import_picocolors27.default.green("[accepted]");
8308
+ return import_picocolors28.default.green("[accepted]");
7301
8309
  case "archived":
7302
- return import_picocolors27.default.dim("[archived]");
8310
+ return import_picocolors28.default.dim("[archived]");
7303
8311
  default:
7304
- return import_picocolors27.default.dim("[todo]");
8312
+ return import_picocolors28.default.dim("[todo]");
7305
8313
  }
7306
8314
  }
7307
8315
 
@@ -7322,7 +8330,22 @@ var READS = [
7322
8330
  "auth",
7323
8331
  "project",
7324
8332
  "open",
7325
- "doctor"
8333
+ "doctor",
8334
+ // Both READ and report. `scan` reads files and shells out to npm; `health`
8335
+ // makes a GET request to an address that is already public. Neither can
8336
+ // change anything, which is why the roles that exist to look — qa, security,
8337
+ // sre, analyst — get them without getting anything else.
8338
+ "scan",
8339
+ "health",
8340
+ // Read-only views of what is running. Added with Phase 6a: an SRE that can
8341
+ // read logs but cannot list deployments or read the app's address is being
8342
+ // asked to diagnose an outage with one eye shut.
8343
+ "urls",
8344
+ "deployments",
8345
+ // Reading the plan and what is used against it. An agent proposing "add
8346
+ // another project" can only sensibly propose it if it can find out the plan
8347
+ // allows two and two already exist.
8348
+ "usage"
7326
8349
  ];
7327
8350
  var BUILDS = [
7328
8351
  ...READS,
@@ -7355,6 +8378,12 @@ var ROLE_VERBS = {
7355
8378
  analyst: READS,
7356
8379
  sre: [...READS, "deploy", "domain", "versions"],
7357
8380
  devops: [...BUILDS, "deploy", "domain", "versions"]
8381
+ // NOTE: `deployments` reaches READS above, so every role can LIST and
8382
+ // INSPECT. That is correct — history is a read. The two verbs that change
8383
+ // production (`promote`, `rollback`) are not gated here at all, and must not
8384
+ // be: they are gated in the DAEMON, as `deploy.prod`, which is a door "just
8385
+ // do it" cannot open. A second, verb-name-based rule here would be a weaker
8386
+ // copy of a control that already works.
7358
8387
  };
7359
8388
  var NEVER = {
7360
8389
  // Approving is the owner's, full stop. An agent that can approve the plan it
@@ -7381,7 +8410,7 @@ function assertRoleMayRun(argv) {
7381
8410
  }
7382
8411
 
7383
8412
  // src/commands/decision.ts
7384
- var import_picocolors28 = __toESM(require_picocolors(), 1);
8413
+ var import_picocolors29 = __toESM(require_picocolors(), 1);
7385
8414
  function registerDecision(program3) {
7386
8415
  const decision = program3.command("decision").description("Read and record the project's architecture decisions");
7387
8416
  decision.command("list").description("Every decision on record \u2014 read this before changing how something works").option("--limit <n>", "cap the number returned (newest first)").action(
@@ -7394,11 +8423,11 @@ function registerDecision(program3) {
7394
8423
  rows = applyLimit(rows, opts.limit);
7395
8424
  ok(rows, () => {
7396
8425
  if (!rows.length) {
7397
- line(import_picocolors28.default.dim("No decisions recorded yet."));
8426
+ line(import_picocolors29.default.dim("No decisions recorded yet."));
7398
8427
  return;
7399
8428
  }
7400
8429
  for (const r of rows) {
7401
- line(`${import_picocolors28.default.dim(r.id)} ${import_picocolors28.default.dim(shortDate(r.createdAt))} ${r.title}`);
8430
+ line(`${import_picocolors29.default.dim(r.id)} ${import_picocolors29.default.dim(shortDate(r.createdAt))} ${r.title}`);
7402
8431
  line(` ${truncate(r.decision, 100)}`);
7403
8432
  }
7404
8433
  });
@@ -7412,16 +8441,16 @@ function registerDecision(program3) {
7412
8441
  `/v1/projects/${projectId}/architecture-decisions/${args[0]}`
7413
8442
  );
7414
8443
  ok(row, () => {
7415
- line(`${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.id)}`);
7416
- line(import_picocolors28.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
8444
+ line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8445
+ line(import_picocolors29.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
7417
8446
  line(`
7418
- ${import_picocolors28.default.bold("Context")}
8447
+ ${import_picocolors29.default.bold("Context")}
7419
8448
  ${row.context}`);
7420
8449
  line(`
7421
- ${import_picocolors28.default.bold("Decision")}
8450
+ ${import_picocolors29.default.bold("Decision")}
7422
8451
  ${row.decision}`);
7423
8452
  if (row.consequences) line(`
7424
- ${import_picocolors28.default.bold("Consequences")}
8453
+ ${import_picocolors29.default.bold("Consequences")}
7425
8454
  ${row.consequences}`);
7426
8455
  });
7427
8456
  })
@@ -7450,7 +8479,7 @@ ${row.consequences}`);
7450
8479
  refId: row?.id,
7451
8480
  output: { decision: row }
7452
8481
  });
7453
- ok(row, () => line(`Recorded decision ${import_picocolors28.default.bold(row?.id ?? "")} \u2014 ${title}`));
8482
+ ok(row, () => line(`Recorded decision ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
7454
8483
  })
7455
8484
  );
7456
8485
  const requirement = program3.command("requirement").description("Read and record the project's requirements");
@@ -7462,11 +8491,11 @@ ${row.consequences}`);
7462
8491
  rows = applyLimit(rows, opts.limit);
7463
8492
  ok(rows, () => {
7464
8493
  if (!rows.length) {
7465
- line(import_picocolors28.default.dim("No requirements recorded yet."));
8494
+ line(import_picocolors29.default.dim("No requirements recorded yet."));
7466
8495
  return;
7467
8496
  }
7468
8497
  for (const r of rows) {
7469
- line(`${import_picocolors28.default.dim(r.id)} ${r.status.padEnd(9)} ${r.title}`);
8498
+ line(`${import_picocolors29.default.dim(r.id)} ${r.status.padEnd(9)} ${r.title}`);
7470
8499
  }
7471
8500
  });
7472
8501
  })
@@ -7479,8 +8508,8 @@ ${row.consequences}`);
7479
8508
  `/v1/projects/${projectId}/requirements/${args[0]}`
7480
8509
  );
7481
8510
  ok(row, () => {
7482
- line(`${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.id)}`);
7483
- line(import_picocolors28.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
8511
+ line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8512
+ line(import_picocolors29.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
7484
8513
  line(`
7485
8514
  ${row.body}`);
7486
8515
  });
@@ -7506,7 +8535,7 @@ ${row.body}`);
7506
8535
  refId: row?.id,
7507
8536
  output: { requirement: row }
7508
8537
  });
7509
- ok(row, () => line(`Recorded requirement ${import_picocolors28.default.bold(row?.id ?? "")} \u2014 ${title}`));
8538
+ ok(row, () => line(`Recorded requirement ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
7510
8539
  })
7511
8540
  );
7512
8541
  requirement.command("update <id>").description(
@@ -7535,7 +8564,7 @@ ${row.body}`);
7535
8564
  code: "bad_request"
7536
8565
  });
7537
8566
  }
7538
- ok(row, () => line(`Updated requirement ${import_picocolors28.default.bold(row.id)} \u2014 ${row.title} (${row.status})`));
8567
+ ok(row, () => line(`Updated requirement ${import_picocolors29.default.bold(row.id)} \u2014 ${row.title} (${row.status})`));
7539
8568
  })
7540
8569
  );
7541
8570
  }
@@ -7558,7 +8587,41 @@ function shortDate(iso) {
7558
8587
  }
7559
8588
 
7560
8589
  // src/commands/doc.ts
7561
- var import_picocolors29 = __toESM(require_picocolors(), 1);
8590
+ var import_picocolors30 = __toESM(require_picocolors(), 1);
8591
+
8592
+ // src/mermaid-fences.ts
8593
+ function hasDiagram(code) {
8594
+ if (!code) return false;
8595
+ return code.split("\n").some((line2) => {
8596
+ const text = line2.trim();
8597
+ return !!text && !text.startsWith("%%");
8598
+ });
8599
+ }
8600
+ function diagramKind(code) {
8601
+ if (!code) return null;
8602
+ for (const line2 of code.split("\n")) {
8603
+ const text = line2.trim();
8604
+ if (!text || text.startsWith("%%")) continue;
8605
+ const match = /^([A-Za-z][A-Za-z-]*)/.exec(text);
8606
+ return match ? match[1] : null;
8607
+ }
8608
+ return null;
8609
+ }
8610
+ function extractDiagrams(markdown) {
8611
+ if (!markdown) return [];
8612
+ const out = [];
8613
+ const pattern = /^[ \t]*```[ \t]*mermaid[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
8614
+ let match;
8615
+ while ((match = pattern.exec(markdown)) !== null) {
8616
+ const body = match[1].replace(/\s+$/, "");
8617
+ if (hasDiagram(body)) out.push(body);
8618
+ }
8619
+ return out;
8620
+ }
8621
+
8622
+ // src/commands/doc.ts
8623
+ import { readFileSync as readFileSync2 } from "fs";
8624
+ import { isAbsolute, join as join2 } from "path";
7562
8625
  function registerDoc(program3) {
7563
8626
  const doc = program3.command("doc").description("Read and write project documents");
7564
8627
  doc.command("list").description("List the project's documents \u2014 check here before writing a new one").option("--work-item <id>", "the document linked to this card, if there is one").action(
@@ -7569,13 +8632,13 @@ function registerDoc(program3) {
7569
8632
  }) ?? [];
7570
8633
  ok(rows, () => {
7571
8634
  if (!rows.length) {
7572
- line(import_picocolors29.default.dim("No documents yet."));
8635
+ line(import_picocolors30.default.dim("No documents yet."));
7573
8636
  return;
7574
8637
  }
7575
8638
  for (const r of rows) {
7576
- const link = r.workItemId ? import_picocolors29.default.dim(` \u21B3 ${r.workItemId}`) : "";
7577
- const file = r.filePath ? import_picocolors29.default.dim(` ${r.filePath}`) : "";
7578
- line(`${import_picocolors29.default.dim(r.id)} ${r.title}${link}${file}`);
8639
+ const link = r.workItemId ? import_picocolors30.default.dim(` \u21B3 ${r.workItemId}`) : "";
8640
+ const file = r.filePath ? import_picocolors30.default.dim(` ${r.filePath}`) : "";
8641
+ line(`${import_picocolors30.default.dim(r.id)} ${r.title}${link}${file}`);
7579
8642
  }
7580
8643
  });
7581
8644
  })
@@ -7589,17 +8652,17 @@ function registerDoc(program3) {
7589
8652
  );
7590
8653
  if (opts.markdown) {
7591
8654
  ok({ id: row.id, title: row.title, filePath: row.filePath }, () => {
7592
- line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8655
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
7593
8656
  line(
7594
- row.filePath ? `Read it at ${import_picocolors29.default.bold(row.filePath)} (relative to the project folder).` : import_picocolors29.default.dim("This document has no markdown mirror on disk yet.")
8657
+ row.filePath ? `Read it at ${import_picocolors30.default.bold(row.filePath)} (relative to the project folder).` : import_picocolors30.default.dim("This document has no markdown mirror on disk yet.")
7595
8658
  );
7596
8659
  });
7597
8660
  return;
7598
8661
  }
7599
8662
  ok(row, () => {
7600
- line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
7601
- if (row.workItemId) line(import_picocolors29.default.dim(`linked to work item ${row.workItemId}`));
7602
- if (row.filePath) line(import_picocolors29.default.dim(`markdown mirror: ${row.filePath}`));
8663
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
8664
+ if (row.workItemId) line(import_picocolors30.default.dim(`linked to work item ${row.workItemId}`));
8665
+ if (row.filePath) line(import_picocolors30.default.dim(`markdown mirror: ${row.filePath}`));
7603
8666
  line("");
7604
8667
  line(row.contentJson);
7605
8668
  });
@@ -7628,7 +8691,54 @@ function registerDoc(program3) {
7628
8691
  refId: row?.id,
7629
8692
  output: { document: row }
7630
8693
  });
7631
- ok(row, () => line(`Created document ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
8694
+ ok(row, () => line(`Created document ${import_picocolors30.default.bold(row?.id ?? "")} \u2014 ${title}`));
8695
+ })
8696
+ );
8697
+ doc.command("diagram <id>").description(
8698
+ "List the diagrams in a document \u2014 `--check` fails when it has none"
8699
+ ).option("--check", "exit non-zero when the document contains no diagram").action(
8700
+ action(async ({ ctx, args, opts }) => {
8701
+ const projectId = requireProject(ctx);
8702
+ const row = await api(
8703
+ ctx,
8704
+ `/v1/projects/${projectId}/documents/${args[0]}`
8705
+ );
8706
+ if (!row) {
8707
+ throw new WorkserError(`No document with id "${args[0]}" on this project.`, {
8708
+ code: "bad_request"
8709
+ });
8710
+ }
8711
+ const markdown = readMirror(ctx.cwd, row.filePath);
8712
+ const diagrams = markdown === null ? [] : extractDiagrams(markdown);
8713
+ const payload = {
8714
+ id: row.id,
8715
+ title: row.title,
8716
+ filePath: row.filePath,
8717
+ diagrams: diagrams.map((code) => ({ kind: diagramKind(code), code }))
8718
+ };
8719
+ ok(payload, () => {
8720
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
8721
+ if (markdown === null) {
8722
+ line(
8723
+ import_picocolors30.default.dim(
8724
+ row.filePath ? `Could not read ${row.filePath} from this folder.` : "This document has no markdown mirror on disk yet."
8725
+ )
8726
+ );
8727
+ } else if (!diagrams.length) {
8728
+ line(import_picocolors30.default.dim("No diagrams in this document."));
8729
+ } else {
8730
+ for (const [i, code] of diagrams.entries()) {
8731
+ const kind = diagramKind(code) ?? "diagram";
8732
+ line(`${import_picocolors30.default.dim(String(i + 1))} ${kind} ${import_picocolors30.default.dim(`${code.split("\n").length} lines`)}`);
8733
+ }
8734
+ }
8735
+ });
8736
+ if (opts.check && !diagrams.length) {
8737
+ throw new WorkserError(
8738
+ markdown === null ? `"${row.title}" has no markdown on disk to check. Save it from the Docs panel, or write it with \`workser doc update ${row.id} --markdown ...\`.` : `"${row.title}" has no diagram. Add one with a \`\`\`mermaid fence describing how the pieces fit together.`,
8739
+ { code: "bad_request" }
8740
+ );
8741
+ }
7632
8742
  })
7633
8743
  );
7634
8744
  doc.command("update <id>").description("Revise an existing document rather than creating a second copy of it").option("--title <text>", "new title").option("--markdown <text>", "replace the body with this markdown").option("--content-json <json>", "replace the body with this rich-text content JSON").action(
@@ -7655,13 +8765,21 @@ function registerDoc(program3) {
7655
8765
  code: "bad_request"
7656
8766
  });
7657
8767
  }
7658
- ok(row, () => line(`Updated document ${import_picocolors29.default.bold(row.id)} \u2014 ${row.title}`));
8768
+ ok(row, () => line(`Updated document ${import_picocolors30.default.bold(row.id)} \u2014 ${row.title}`));
7659
8769
  })
7660
8770
  );
7661
8771
  }
8772
+ function readMirror(cwd, filePath) {
8773
+ if (!filePath) return null;
8774
+ try {
8775
+ return readFileSync2(isAbsolute(filePath) ? filePath : join2(cwd, filePath), "utf8");
8776
+ } catch {
8777
+ return null;
8778
+ }
8779
+ }
7662
8780
 
7663
8781
  // src/commands/design.ts
7664
- var import_picocolors30 = __toESM(require_picocolors(), 1);
8782
+ var import_picocolors31 = __toESM(require_picocolors(), 1);
7665
8783
  function registerDesign(program3) {
7666
8784
  const design = program3.command("design").description("Read the project's brand (colours, fonts, logo)");
7667
8785
  design.command("show").description("Show this project's brand \u2014 read it before writing any UI").option("--raw", "print the generated token files verbatim instead of a summary").action(
@@ -7675,11 +8793,11 @@ function registerDesign(program3) {
7675
8793
  if (opts.raw) {
7676
8794
  ok(files, () => {
7677
8795
  if (!files.length) {
7678
- line(import_picocolors30.default.dim("No brand set for this project."));
8796
+ line(import_picocolors31.default.dim("No brand set for this project."));
7679
8797
  return;
7680
8798
  }
7681
8799
  for (const f of files) {
7682
- line(import_picocolors30.default.bold(f.path));
8800
+ line(import_picocolors31.default.bold(f.path));
7683
8801
  line(f.contents);
7684
8802
  line("");
7685
8803
  }
@@ -7696,21 +8814,21 @@ function registerDesign(program3) {
7696
8814
  } : { hasBrand: false, colors: {}, fonts: {}, brand: {}, files: [] };
7697
8815
  ok(summary, () => {
7698
8816
  if (!tokens) {
7699
- line(import_picocolors30.default.dim("No brand set for this project \u2014 choose sensible styling yourself."));
8817
+ line(import_picocolors31.default.dim("No brand set for this project \u2014 choose sensible styling yourself."));
7700
8818
  return;
7701
8819
  }
7702
8820
  for (const [name, value] of Object.entries(tokens.brand)) {
7703
- line(`${import_picocolors30.default.dim(name.padEnd(12))} ${value}`);
8821
+ line(`${import_picocolors31.default.dim(name.padEnd(12))} ${value}`);
7704
8822
  }
7705
8823
  for (const [name, value] of Object.entries(tokens.color)) {
7706
- line(`${import_picocolors30.default.dim(`color.${name}`.padEnd(12))} ${value}`);
8824
+ line(`${import_picocolors31.default.dim(`color.${name}`.padEnd(12))} ${value}`);
7707
8825
  }
7708
8826
  for (const [name, value] of Object.entries(tokens.font)) {
7709
- line(`${import_picocolors30.default.dim(`font.${name}`.padEnd(12))} ${value}`);
8827
+ line(`${import_picocolors31.default.dim(`font.${name}`.padEnd(12))} ${value}`);
7710
8828
  }
7711
8829
  line("");
7712
8830
  line(
7713
- import_picocolors30.default.dim(
8831
+ import_picocolors31.default.dim(
7714
8832
  `Generated into the working tree as ${files.map((f) => f.path).join(", ")} \u2014 wire those in, never edit them.`
7715
8833
  )
7716
8834
  );
@@ -7740,9 +8858,1005 @@ function unwrap(group) {
7740
8858
  );
7741
8859
  }
7742
8860
 
8861
+ // src/commands/api.ts
8862
+ var import_picocolors32 = __toESM(require_picocolors(), 1);
8863
+ import { readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
8864
+ import { join as join3, relative, sep } from "path";
8865
+
8866
+ // src/api-spec.ts
8867
+ var SPEC_FILES = [
8868
+ "api/openapi.json",
8869
+ "openapi.json",
8870
+ "api/openapi.yaml",
8871
+ "openapi.yaml",
8872
+ "api/openapi.yml",
8873
+ "openapi.yml"
8874
+ ];
8875
+ function fromNextRoute(file) {
8876
+ const match = /^app\/(.*\/)?route\.(t|j)sx?$/.exec(file);
8877
+ if (!match) return null;
8878
+ const segments = (match[1] ?? "").split("/").filter(Boolean).filter((s) => !(s.startsWith("(") && s.endsWith(")"))).map((s) => {
8879
+ const dynamic = /^\[\.{0,3}(.+?)\]$/.exec(s);
8880
+ return dynamic ? `{${dynamic[1]}}` : s;
8881
+ });
8882
+ return `/${segments.join("/")}`.replace(/\/+$/, "") || "/";
8883
+ }
8884
+ function fromPythonFile(file) {
8885
+ const match = /^api\/(.+)\.py$/.exec(file);
8886
+ if (!match) return null;
8887
+ const stem = match[1];
8888
+ if (stem === "index") return "/api";
8889
+ if (stem.endsWith("/index")) return `/api/${stem.slice(0, -"/index".length)}`;
8890
+ return `/api/${stem}`;
8891
+ }
8892
+ function discoverRoutes(files) {
8893
+ const seen = /* @__PURE__ */ new Map();
8894
+ for (const raw of files) {
8895
+ const file = raw.replace(/^\.\//, "");
8896
+ const path = fromNextRoute(file) ?? fromPythonFile(file);
8897
+ if (!path) continue;
8898
+ if (!seen.has(path)) seen.set(path, { path, file });
8899
+ }
8900
+ return [...seen.values()].sort((a, b) => a.path.localeCompare(b.path));
8901
+ }
8902
+ function specPaths(text) {
8903
+ if (!text) return [];
8904
+ const trimmed = text.trim();
8905
+ if (!trimmed) return [];
8906
+ if (trimmed.startsWith("{")) {
8907
+ try {
8908
+ const parsed = JSON.parse(trimmed);
8909
+ const paths = parsed?.paths;
8910
+ if (!paths || typeof paths !== "object") return [];
8911
+ return Object.keys(paths).filter((k) => k.startsWith("/")).sort();
8912
+ } catch {
8913
+ return [];
8914
+ }
8915
+ }
8916
+ const out = [];
8917
+ let inPaths = false;
8918
+ let indent = 0;
8919
+ for (const line2 of trimmed.split(/\r?\n/)) {
8920
+ if (!line2.trim() || line2.trim().startsWith("#")) continue;
8921
+ const leading = line2.length - line2.trimStart().length;
8922
+ if (!inPaths) {
8923
+ if (/^paths\s*:/.test(line2.trim()) && leading === 0) {
8924
+ inPaths = true;
8925
+ indent = -1;
8926
+ }
8927
+ continue;
8928
+ }
8929
+ if (indent === -1) {
8930
+ if (leading === 0) break;
8931
+ indent = leading;
8932
+ }
8933
+ if (leading < indent) break;
8934
+ if (leading > indent) continue;
8935
+ const key = /^\s*["']?(\/[^"':]*)["']?\s*:/.exec(line2);
8936
+ if (key) out.push(key[1].trim());
8937
+ }
8938
+ return [...new Set(out)].sort();
8939
+ }
8940
+ var EXEMPT = /* @__PURE__ */ new Set(["/api/health", "/health", "/api", "/"]);
8941
+ function specReport(routes, documented) {
8942
+ const declared = new Set(documented);
8943
+ const served = new Set(routes.map((r) => r.path));
8944
+ const missing = routes.filter(
8945
+ (r) => !declared.has(r.path) && !EXEMPT.has(r.path)
8946
+ );
8947
+ const stale = documented.filter((p) => !served.has(p)).sort();
8948
+ return {
8949
+ routes,
8950
+ documented: [...declared].sort(),
8951
+ missing,
8952
+ stale,
8953
+ ok: missing.length === 0
8954
+ };
8955
+ }
8956
+ function specSummary(report, specFile) {
8957
+ if (!specFile) {
8958
+ return `This service has no API description. Write one at ${SPEC_FILES[0]} listing the routes it serves.`;
8959
+ }
8960
+ if (report.ok) {
8961
+ const n = report.routes.length;
8962
+ return n === 1 ? `The one route this service has is described in ${specFile}.` : `All ${n} routes are described in ${specFile}.`;
8963
+ }
8964
+ const names = report.missing.map((m) => m.path).join(", ");
8965
+ return `${report.missing.length} route${report.missing.length === 1 ? " is" : "s are"} not described in ${specFile}: ${names}`;
8966
+ }
8967
+
8968
+ // src/commands/api.ts
8969
+ function registerApi(program3) {
8970
+ const cmd = program3.command("api").description("Call this service and check it describes its own routes");
8971
+ cmd.command("list").description("The requests saved with this service, in api/requests.json").option("--app <webAppId>", "the service, when this folder holds more than one").action(
8972
+ action(async ({ ctx, opts }) => {
8973
+ const appId = requireApp(opts.app);
8974
+ const res = await api(ctx, `/v1/apps/${encodeURIComponent(appId)}/api/requests`);
8975
+ ok(res, () => {
8976
+ for (const note of res?.notes ?? []) line(import_picocolors32.default.dim(note));
8977
+ for (const r of res?.requests ?? []) {
8978
+ line(
8979
+ `${import_picocolors32.default.dim(r.method.padEnd(6))}${r.path}${r.note ? import_picocolors32.default.dim(` ${r.note}`) : ""}`
8980
+ );
8981
+ }
8982
+ });
8983
+ })
8984
+ );
8985
+ cmd.command("call <path>").description(
8986
+ `workser api call /orders --method POST --body '{"item":1}' [--env local|preview|production]`
8987
+ ).option("--app <webAppId>", "the service, when this folder holds more than one").option("--method <verb>", "GET by default").option("--body <text>", "request body, already serialised").option(
8988
+ "--header <name:value>",
8989
+ "extra header; repeat for more than one",
8990
+ collectHeader,
8991
+ {}
8992
+ ).option(
8993
+ "--env <name>",
8994
+ "local (default), preview or production \u2014 the host comes from here, never from the path"
8995
+ ).action(
8996
+ action(async ({ ctx, args, opts }) => {
8997
+ const appId = requireApp(opts.app);
8998
+ const res = await api(
8999
+ ctx,
9000
+ `/v1/apps/${encodeURIComponent(appId)}/api/call`,
9001
+ {
9002
+ body: {
9003
+ environment: opts.env,
9004
+ method: opts.method,
9005
+ path: args[0],
9006
+ headers: opts.header,
9007
+ body: opts.body
9008
+ }
9009
+ }
9010
+ );
9011
+ ok(res, () => {
9012
+ if (!res?.ok) {
9013
+ line(import_picocolors32.default.red(res?.error ?? "The service did not answer."));
9014
+ return;
9015
+ }
9016
+ const code = `${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
9017
+ const colour2 = res.status && res.status < 300 ? import_picocolors32.default.green : res.status && res.status < 500 ? import_picocolors32.default.yellow : import_picocolors32.default.red;
9018
+ line(`${colour2(code)} ${import_picocolors32.default.dim(`${res.durationMs}ms ${res.url}`)}`);
9019
+ if (res.body) line(res.body);
9020
+ if (res.truncated) line(import_picocolors32.default.dim("(answer truncated)"));
9021
+ });
9022
+ if (!res?.ok) process.exitCode = 1;
9023
+ })
9024
+ );
9025
+ cmd.command("spec").description(
9026
+ "Compare the routes this repo serves with the ones it documents \u2014 `--check` fails on a gap"
9027
+ ).option("--check", "exit non-zero when a route has no entry in the spec").action(
9028
+ action(async ({ ctx, opts }) => {
9029
+ requireLocalApp(ctx, "api spec");
9030
+ const files = listRepoFiles(ctx.cwd);
9031
+ const routes = discoverRoutes(files);
9032
+ const specFile = SPEC_FILES.find((f) => files.includes(f)) ?? null;
9033
+ const documented = specFile ? specPaths(readIfPresent(join3(ctx.cwd, specFile))) : [];
9034
+ const report = specReport(routes, documented);
9035
+ const summary = specSummary(report, specFile);
9036
+ ok({ ...report, specFile, summary }, () => {
9037
+ for (const r of report.routes) {
9038
+ const known = report.missing.some((m) => m.path === r.path);
9039
+ line(
9040
+ `${known ? import_picocolors32.default.yellow("undocumented") : import_picocolors32.default.green("documented ")} ${r.path}${import_picocolors32.default.dim(` ${r.file}`)}`
9041
+ );
9042
+ }
9043
+ for (const p of report.stale) {
9044
+ line(`${import_picocolors32.default.dim("in spec only ")} ${p}`);
9045
+ }
9046
+ line("");
9047
+ if (report.ok && specFile) success(summary);
9048
+ else line(import_picocolors32.default.yellow(summary));
9049
+ });
9050
+ if (opts.check && !report.ok) {
9051
+ throw new WorkserError(summary, { code: "bad_request" });
9052
+ }
9053
+ if (opts.check && !specFile) {
9054
+ throw new WorkserError(summary, { code: "bad_request" });
9055
+ }
9056
+ })
9057
+ );
9058
+ }
9059
+ function collectHeader(raw, previous) {
9060
+ const at = raw.indexOf(":");
9061
+ if (at <= 0) return previous;
9062
+ return { ...previous, [raw.slice(0, at).trim()]: raw.slice(at + 1).trim() };
9063
+ }
9064
+ function requireApp(app) {
9065
+ const value = typeof app === "string" ? app.trim() : "";
9066
+ if (value) return value;
9067
+ throw new WorkserError(
9068
+ "Which service? Pass --app <webAppId>; `workser app list` shows them.",
9069
+ { code: "bad_request" }
9070
+ );
9071
+ }
9072
+ function readIfPresent(file) {
9073
+ try {
9074
+ return readFileSync3(file, "utf8");
9075
+ } catch {
9076
+ return null;
9077
+ }
9078
+ }
9079
+ function listRepoFiles(root, maxDepth = 8) {
9080
+ const SKIP = /* @__PURE__ */ new Set([
9081
+ "node_modules",
9082
+ ".git",
9083
+ ".next",
9084
+ "dist",
9085
+ "build",
9086
+ ".vercel",
9087
+ "__pycache__",
9088
+ ".venv",
9089
+ "venv"
9090
+ ]);
9091
+ const out = [];
9092
+ const walk = (dir, depth) => {
9093
+ if (depth > maxDepth || out.length > 5e3) return;
9094
+ let entries;
9095
+ try {
9096
+ entries = readdirSync(dir);
9097
+ } catch {
9098
+ return;
9099
+ }
9100
+ for (const name of entries) {
9101
+ if (name.startsWith(".") && name !== ".well-known") continue;
9102
+ if (SKIP.has(name)) continue;
9103
+ const full = join3(dir, name);
9104
+ let isDir = false;
9105
+ try {
9106
+ isDir = statSync2(full).isDirectory();
9107
+ } catch {
9108
+ continue;
9109
+ }
9110
+ if (isDir) walk(full, depth + 1);
9111
+ else out.push(relative(root, full).split(sep).join("/"));
9112
+ }
9113
+ };
9114
+ walk(root, 0);
9115
+ return out;
9116
+ }
9117
+
9118
+ // src/commands/analysis.ts
9119
+ var import_picocolors33 = __toESM(require_picocolors(), 1);
9120
+ import { readFileSync as readFileSync4 } from "fs";
9121
+ function registerAnalysis(program3) {
9122
+ const cmd = program3.command("analysis").description("Run Python analysis locally, recorded in the task");
9123
+ cmd.command("runtime").description("Is Python here, and does it have what an analysis needs?").option("--app <webAppId>", "check the interpreter this app would use").action(
9124
+ action(async ({ ctx, opts }) => {
9125
+ const path = opts.app ? `/v1/apps/${encodeURIComponent(String(opts.app))}/analysis/runtime` : "/v1/analysis/runtime";
9126
+ const res = await api(ctx, path);
9127
+ ok(res, () => {
9128
+ line(
9129
+ `${res?.available ? import_picocolors33.default.green("python") : import_picocolors33.default.red("python")} ${res?.version ?? "not found"} ${import_picocolors33.default.dim(res?.python ?? "")}`
9130
+ );
9131
+ for (const lib of res?.libraries ?? []) {
9132
+ line(
9133
+ `${lib.present ? import_picocolors33.default.green(lib.name) : import_picocolors33.default.yellow(lib.name)}${import_picocolors33.default.dim(lib.present ? "" : " missing")}`
9134
+ );
9135
+ }
9136
+ for (const note of res?.notes ?? []) line(import_picocolors33.default.dim(note));
9137
+ });
9138
+ if (!res?.available) process.exitCode = 1;
9139
+ })
9140
+ );
9141
+ cmd.command("run").description(
9142
+ "workser analysis run --app <id> --file report.py (or --code '<python>')"
9143
+ ).requiredOption("--app <webAppId>", "the app whose folder the script runs in").option("--file <path>", "a Python file to run").option("--code <python>", "the script itself, for something short").option("--timeout <ms>", "how long to allow, capped at 15 minutes").action(
9144
+ action(async ({ ctx, opts }) => {
9145
+ const code = readCode(opts.file, opts.code);
9146
+ const res = await api(
9147
+ ctx,
9148
+ `/v1/apps/${encodeURIComponent(String(opts.app))}/analysis/run`,
9149
+ {
9150
+ body: {
9151
+ code,
9152
+ timeoutMs: opts.timeout ? Number(opts.timeout) : void 0
9153
+ }
9154
+ }
9155
+ );
9156
+ ok(res, () => {
9157
+ if (res?.stdout) line(res.stdout.replace(/\n$/, ""));
9158
+ if (res?.stderr) line(import_picocolors33.default.dim(res.stderr.replace(/\n$/, "")));
9159
+ if (res?.truncated) line(import_picocolors33.default.dim("(output truncated)"));
9160
+ const took = `${Math.round((res?.durationMs ?? 0) / 100) / 10}s`;
9161
+ line(
9162
+ res?.ok ? import_picocolors33.default.green(`\u2713 ${res.summary ?? "It finished."}`) + import_picocolors33.default.dim(` ${took}`) : import_picocolors33.default.yellow(res?.summary ?? "It did not finish.") + import_picocolors33.default.dim(` ${took}`)
9163
+ );
9164
+ if (res && !res.sandboxed) {
9165
+ line(
9166
+ import_picocolors33.default.dim(
9167
+ "This platform has no OS sandbox, so the script ran with your own file access."
9168
+ )
9169
+ );
9170
+ }
9171
+ });
9172
+ if (!res?.ok) process.exitCode = 1;
9173
+ })
9174
+ );
9175
+ }
9176
+ function readCode(file, inline) {
9177
+ if (typeof inline === "string" && inline.trim()) return inline;
9178
+ if (typeof file === "string" && file.trim()) {
9179
+ try {
9180
+ return readFileSync4(file, "utf8");
9181
+ } catch (err) {
9182
+ throw new WorkserError(
9183
+ `Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`,
9184
+ { code: "bad_request" }
9185
+ );
9186
+ }
9187
+ }
9188
+ throw new WorkserError("Pass --file <path> or --code '<python>'.", {
9189
+ code: "bad_request"
9190
+ });
9191
+ }
9192
+
9193
+ // src/commands/scan.ts
9194
+ var import_picocolors34 = __toESM(require_picocolors(), 1);
9195
+ import { spawnSync as spawnSync2 } from "child_process";
9196
+ import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
9197
+ import { join as join4, relative as relative2, sep as sep2 } from "path";
9198
+
9199
+ // src/scan.ts
9200
+ var SECRET_PATTERNS = [
9201
+ { name: "a private key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----/, severity: "high" },
9202
+ { name: "an AWS access key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, severity: "high" },
9203
+ { name: "a GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, severity: "high" },
9204
+ { name: "a Stripe live key", re: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/, severity: "high" },
9205
+ { name: "an OpenAI key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/, severity: "high" },
9206
+ { name: "an Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/, severity: "high" },
9207
+ { name: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
9208
+ { name: "a Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: "high" },
9209
+ {
9210
+ name: "a database password in a connection string",
9211
+ re: /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/]+:[^\s:@/]+@/,
9212
+ severity: "high"
9213
+ },
9214
+ {
9215
+ // The everyday one: `API_KEY = "…"` with something real on the right.
9216
+ name: "a secret written into the code",
9217
+ re: /(?:secret|password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)\s*[:=]\s*["'`][^"'`\s]{12,}["'`]/i,
9218
+ severity: "high"
9219
+ }
9220
+ ];
9221
+ var PLACEHOLDER = /(?:example|placeholder|changeme|your[_-]?|xxx+|\.\.\.|<[^>]+>|\$\{|process\.env|os\.environ|REPLACE|dummy|sample|test[_-]?key|fake)/i;
9222
+ var SECRET_EXEMPT = /(?:\.example$|\.sample$|\.template$|(?:^|\/)(?:fixtures?|__fixtures__|__tests__|test|tests|spec|mocks?)\/|\.(?:test|spec)\.[jt]sx?$|(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$|(?:^|\/)workser-scan\.md$)/;
9223
+ function addedLines(diff) {
9224
+ const out = [];
9225
+ let file = null;
9226
+ let lineNo = 0;
9227
+ for (const raw of diff.split("\n")) {
9228
+ if (raw.startsWith("+++ ")) {
9229
+ const path = raw.slice(4).trim();
9230
+ file = path === "/dev/null" ? null : path.replace(/^b\//, "");
9231
+ continue;
9232
+ }
9233
+ if (raw.startsWith("--- ") || raw.startsWith("diff --git")) continue;
9234
+ const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
9235
+ if (hunk) {
9236
+ lineNo = Number(hunk[1]);
9237
+ continue;
9238
+ }
9239
+ if (!file) continue;
9240
+ if (raw.startsWith("+")) {
9241
+ out.push({ file, line: lineNo, text: raw.slice(1) });
9242
+ lineNo++;
9243
+ continue;
9244
+ }
9245
+ if (raw.startsWith(" ")) lineNo++;
9246
+ }
9247
+ return out;
9248
+ }
9249
+ function secretFindings(diff) {
9250
+ const findings = [];
9251
+ const seen = /* @__PURE__ */ new Set();
9252
+ for (const added of addedLines(diff)) {
9253
+ if (SECRET_EXEMPT.test(added.file)) continue;
9254
+ for (const pattern of SECRET_PATTERNS) {
9255
+ if (!pattern.re.test(added.text)) continue;
9256
+ if (PLACEHOLDER.test(added.text)) continue;
9257
+ const key = `${added.file}:${pattern.name}`;
9258
+ if (seen.has(key)) continue;
9259
+ seen.add(key);
9260
+ findings.push({
9261
+ check: "secrets",
9262
+ severity: pattern.severity,
9263
+ title: `This change adds ${pattern.name} to the code`,
9264
+ file: added.file,
9265
+ line: added.line,
9266
+ fix: "Move the value into an environment setting (`workser env set`) and use it from there. If it has already been committed, treat it as leaked and replace it at the source."
9267
+ });
9268
+ break;
9269
+ }
9270
+ }
9271
+ return findings;
9272
+ }
9273
+ function depFindings(auditJson) {
9274
+ if (!auditJson) return [];
9275
+ let parsed;
9276
+ try {
9277
+ parsed = JSON.parse(auditJson);
9278
+ } catch {
9279
+ return [];
9280
+ }
9281
+ const vulns = parsed?.vulnerabilities;
9282
+ if (!vulns || typeof vulns !== "object") return [];
9283
+ const findings = [];
9284
+ for (const [name, raw] of Object.entries(vulns)) {
9285
+ const severity = String(raw?.severity ?? "").toLowerCase();
9286
+ if (severity !== "high" && severity !== "critical") continue;
9287
+ const direct = raw?.isDirect === true;
9288
+ const fixable = raw?.fixAvailable;
9289
+ findings.push({
9290
+ check: "deps",
9291
+ severity: "high",
9292
+ title: `${name} has a known ${severity} security problem`,
9293
+ fix: fixable === true ? `Run \`npm audit fix\`${direct ? "" : " \u2014 it is pulled in by another package"}.` : typeof fixable === "object" && fixable?.name ? `Fixing it means moving to ${fixable.name}@${fixable.version}, which is a breaking change. Decide it deliberately.` : "There is no published fix yet. Decide whether this package is worth keeping."
9294
+ });
9295
+ }
9296
+ return findings.sort((a, b) => a.title.localeCompare(b.title));
9297
+ }
9298
+ function permissionFindings(files, trackedFiles = []) {
9299
+ const findings = [];
9300
+ for (const file of files) {
9301
+ for (const match of file.content.matchAll(
9302
+ /NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY|PRIVATE)[A-Z0-9_]*/g
9303
+ )) {
9304
+ findings.push({
9305
+ check: "permissions",
9306
+ severity: "high",
9307
+ title: `${match[0]} is sent to every visitor's browser`,
9308
+ file: file.path,
9309
+ fix: "Anything named NEXT_PUBLIC_ is public by design. Rename it without that prefix and read it on the server only."
9310
+ });
9311
+ }
9312
+ if (/["']Access-Control-Allow-Origin["']\s*[:,]\s*["']\*["']/.test(file.content) && /Access-Control-Allow-Credentials/.test(file.content)) {
9313
+ findings.push({
9314
+ check: "permissions",
9315
+ severity: "high",
9316
+ title: "This service accepts credentialed requests from any website",
9317
+ file: file.path,
9318
+ fix: "Name the sites allowed to call it instead of `*`, or stop sending credentials."
9319
+ });
9320
+ }
9321
+ }
9322
+ for (const tracked of trackedFiles) {
9323
+ const base = tracked.split("/").pop() ?? tracked;
9324
+ if (!/^\.env(\.[a-z0-9-]+)?$/i.test(base)) continue;
9325
+ if (/\.(example|sample|template)$/i.test(base)) continue;
9326
+ findings.push({
9327
+ check: "permissions",
9328
+ severity: "high",
9329
+ title: `${tracked} is committed to the repository`,
9330
+ file: tracked,
9331
+ fix: "Add it to .gitignore, remove it from the repo, and treat every value in it as leaked."
9332
+ });
9333
+ }
9334
+ return findings;
9335
+ }
9336
+ function buildReport(input) {
9337
+ const counts = { high: 0, medium: 0, low: 0 };
9338
+ for (const f of input.findings) counts[f.severity]++;
9339
+ return {
9340
+ findings: [...input.findings].sort(
9341
+ (a, b) => rank(b.severity) - rank(a.severity) || a.title.localeCompare(b.title)
9342
+ ),
9343
+ checked: input.checked,
9344
+ skipped: input.skipped,
9345
+ counts,
9346
+ ok: counts.high === 0
9347
+ };
9348
+ }
9349
+ function rank(s) {
9350
+ return s === "high" ? 3 : s === "medium" ? 2 : 1;
9351
+ }
9352
+ function scanSummary(report) {
9353
+ const ran = report.checked.length;
9354
+ if (!ran) return "Nothing could be checked \u2014 see the reasons above.";
9355
+ const what = report.checked.join(", ");
9356
+ const total = report.findings.length;
9357
+ if (!total) {
9358
+ return `Checked ${what} \u2014 nothing found.`;
9359
+ }
9360
+ const high = report.counts.high;
9361
+ return `Checked ${what} \u2014 ${total} ${total === 1 ? "thing" : "things"} to look at` + (high ? `, ${high} of them serious.` : ".");
9362
+ }
9363
+
9364
+ // src/commands/scan.ts
9365
+ function registerScan(program3) {
9366
+ program3.command("scan").description("Check this folder for known-bad dependencies, leaked secrets and over-broad permissions").option("--check", "exit non-zero if anything serious is found", false).option(
9367
+ "--only <checks>",
9368
+ "comma-separated subset: deps, secrets, permissions"
9369
+ ).option(
9370
+ "--staged",
9371
+ "look at staged changes only, rather than everything not yet committed",
9372
+ false
9373
+ ).action(
9374
+ action(async ({ ctx, opts }) => {
9375
+ const only = parseOnly(opts.only);
9376
+ const findings = [];
9377
+ const checked = [];
9378
+ const skipped = [];
9379
+ if (only.has("secrets")) runSecrets(ctx.cwd, !!opts.staged, findings, checked, skipped);
9380
+ if (only.has("deps")) runDeps(ctx.cwd, findings, checked, skipped);
9381
+ if (only.has("permissions")) runPermissions(ctx.cwd, findings, checked, skipped);
9382
+ const report = buildReport({ findings, checked, skipped });
9383
+ const summary = scanSummary(report);
9384
+ ok({ ...report, summary }, () => print2(report, summary));
9385
+ if (opts.check && !report.ok) {
9386
+ throw new WorkserError(summary, { code: "bad_request" });
9387
+ }
9388
+ })
9389
+ );
9390
+ }
9391
+ function parseOnly(raw) {
9392
+ const all = ["deps", "secrets", "permissions"];
9393
+ if (typeof raw !== "string" || !raw.trim()) return new Set(all);
9394
+ const wanted = raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => all.includes(s));
9395
+ if (!wanted.length) {
9396
+ throw new WorkserError(
9397
+ `--only takes any of: ${all.join(", ")}.`,
9398
+ { code: "bad_request" }
9399
+ );
9400
+ }
9401
+ return new Set(wanted);
9402
+ }
9403
+ function runSecrets(cwd, staged, findings, checked, skipped) {
9404
+ const args = staged ? ["diff", "--cached", "--unified=0"] : (
9405
+ // Against HEAD, so it covers both staged and unstaged work: the agent
9406
+ // that just wrote the key has not staged anything.
9407
+ ["diff", "HEAD", "--unified=0"]
9408
+ );
9409
+ const diff = git(cwd, args);
9410
+ if (diff === null) {
9411
+ skipped.push({
9412
+ check: "secrets",
9413
+ reason: "This folder isn\u2019t a git repository yet, so there are no changes to look through."
9414
+ });
9415
+ return;
9416
+ }
9417
+ checked.push("secrets");
9418
+ findings.push(...secretFindings(diff));
9419
+ }
9420
+ function runDeps(cwd, findings, checked, skipped) {
9421
+ const hasLock = ["package-lock.json", "npm-shrinkwrap.json"].some(
9422
+ (f) => existsSync3(join4(cwd, f))
9423
+ );
9424
+ if (!hasLock) {
9425
+ skipped.push({
9426
+ check: "deps",
9427
+ reason: existsSync3(join4(cwd, "package.json")) ? "There\u2019s no package-lock.json, so the exact versions in use aren\u2019t known. Run `npm install` once." : "This folder has no npm packages to check."
9428
+ });
9429
+ return;
9430
+ }
9431
+ const res = spawnSync2("npm", ["audit", "--json", "--audit-level=high"], {
9432
+ cwd,
9433
+ encoding: "utf8",
9434
+ timeout: 6e4,
9435
+ stdio: ["ignore", "pipe", "pipe"]
9436
+ // `npm audit` exits 1 when it FINDS something, which is a successful run.
9437
+ // The failures that matter are the ones with no JSON on stdout.
9438
+ });
9439
+ const out = (res.stdout || "").trim();
9440
+ if (!out || res.error) {
9441
+ skipped.push({
9442
+ check: "deps",
9443
+ reason: "Couldn\u2019t reach the package registry, so known problems in your dependencies weren\u2019t checked. This needs an internet connection."
9444
+ });
9445
+ return;
9446
+ }
9447
+ checked.push("deps");
9448
+ findings.push(...depFindings(out));
9449
+ }
9450
+ function runPermissions(cwd, findings, checked, skipped) {
9451
+ const paths = listRepoFiles2(cwd).filter(isReadable);
9452
+ const files = [];
9453
+ for (const path of paths.slice(0, 1500)) {
9454
+ try {
9455
+ const content = readFileSync5(join4(cwd, path), "utf8");
9456
+ if (content.length > 4e5) continue;
9457
+ files.push({ path, content });
9458
+ } catch {
9459
+ }
9460
+ }
9461
+ const tracked = git(cwd, ["ls-files"]);
9462
+ checked.push("permissions");
9463
+ findings.push(
9464
+ ...permissionFindings(
9465
+ files,
9466
+ tracked === null ? [] : tracked.split("\n").filter(Boolean)
9467
+ )
9468
+ );
9469
+ if (tracked === null) {
9470
+ skipped.push({
9471
+ check: "permissions",
9472
+ reason: "Not a git repository, so we couldn\u2019t check whether a .env file has been committed."
9473
+ });
9474
+ }
9475
+ }
9476
+ function print2(report, summary) {
9477
+ for (const s of report.skipped) {
9478
+ line(`${import_picocolors34.default.yellow("not checked")} ${s.check}${import_picocolors34.default.dim(` \u2014 ${s.reason}`)}`);
9479
+ }
9480
+ for (const f of report.findings) {
9481
+ const where = f.file ? import_picocolors34.default.dim(` ${f.file}${f.line ? `:${f.line}` : ""}`) : "";
9482
+ line(`${severityTag(f.severity)} ${f.title}${where}`);
9483
+ line(` ${import_picocolors34.default.dim(f.fix)}`);
9484
+ }
9485
+ if (report.findings.length || report.skipped.length) line("");
9486
+ if (report.ok && !report.skipped.length) success(summary);
9487
+ else if (report.ok) line(import_picocolors34.default.yellow(summary));
9488
+ else line(import_picocolors34.default.red(summary));
9489
+ }
9490
+ function severityTag(severity) {
9491
+ if (severity === "high") return import_picocolors34.default.red("serious ");
9492
+ if (severity === "medium") return import_picocolors34.default.yellow("worth fixing");
9493
+ return import_picocolors34.default.dim("minor ");
9494
+ }
9495
+ function git(cwd, args) {
9496
+ try {
9497
+ const res = spawnSync2("git", args, {
9498
+ cwd,
9499
+ encoding: "utf8",
9500
+ timeout: 2e4,
9501
+ maxBuffer: 32 * 1024 * 1024,
9502
+ stdio: ["ignore", "pipe", "ignore"]
9503
+ });
9504
+ if (res.error || res.status !== 0) return null;
9505
+ return res.stdout || "";
9506
+ } catch {
9507
+ return null;
9508
+ }
9509
+ }
9510
+ var READABLE = /\.(?:[jt]sx?|mjs|cjs|json|ya?ml|toml|env|py|rb|go|rs|java|php|sh|sql|md|txt|html|css)$/i;
9511
+ function isReadable(path) {
9512
+ return READABLE.test(path);
9513
+ }
9514
+ function listRepoFiles2(root, maxDepth = 8) {
9515
+ const SKIP = /* @__PURE__ */ new Set([
9516
+ "node_modules",
9517
+ ".git",
9518
+ ".next",
9519
+ "dist",
9520
+ "build",
9521
+ ".vercel",
9522
+ "__pycache__",
9523
+ ".venv",
9524
+ "venv"
9525
+ ]);
9526
+ const out = [];
9527
+ const walk = (dir, depth) => {
9528
+ if (depth > maxDepth || out.length > 5e3) return;
9529
+ let entries;
9530
+ try {
9531
+ entries = readdirSync2(dir);
9532
+ } catch {
9533
+ return;
9534
+ }
9535
+ for (const name of entries) {
9536
+ if (SKIP.has(name)) continue;
9537
+ if (name.startsWith(".") && !name.startsWith(".env")) continue;
9538
+ const full = join4(dir, name);
9539
+ let isDir = false;
9540
+ try {
9541
+ isDir = statSync3(full).isDirectory();
9542
+ } catch {
9543
+ continue;
9544
+ }
9545
+ if (isDir) walk(full, depth + 1);
9546
+ else out.push(relative2(root, full).split(sep2).join("/"));
9547
+ }
9548
+ };
9549
+ walk(root, 0);
9550
+ return out;
9551
+ }
9552
+
9553
+ // src/commands/health.ts
9554
+ var import_picocolors35 = __toESM(require_picocolors(), 1);
9555
+ function registerHealth(program3) {
9556
+ program3.command("health").description("Check that the published apps in this project are still answering").option("--app <webAppId>", "check one app rather than all of them").action(
9557
+ action(async ({ ctx, opts }) => {
9558
+ requireDaemon(ctx, "health", "checks your published sites from this computer");
9559
+ const path = opts.app ? `/v1/apps/${encodeURIComponent(String(opts.app))}/health` : `/v1/health`;
9560
+ const res = await api(ctx, path);
9561
+ ok(res, () => print3(res));
9562
+ if (res?.checks?.some((c) => !c.ok)) process.exitCode = 1;
9563
+ })
9564
+ );
9565
+ }
9566
+ function print3(res) {
9567
+ if (!res?.checks?.length) {
9568
+ line(import_picocolors35.default.dim(res?.note ?? "Nothing to check."));
9569
+ return;
9570
+ }
9571
+ for (const c of res.checks) {
9572
+ const mark = c.ok ? import_picocolors35.default.green("up ") : import_picocolors35.default.red("down");
9573
+ const timing = import_picocolors35.default.dim(`${c.ms}ms`);
9574
+ const detail = c.ok ? timing : import_picocolors35.default.dim(`${c.error ?? "no answer"}${c.failures > 1 ? ` \xB7 ${c.failures} in a row` : ""}`);
9575
+ line(` ${mark} ${c.appName} ${import_picocolors35.default.dim(`(${c.environment})`)} ${c.url} ${detail}`);
9576
+ if (c.incidentOpened) {
9577
+ line(import_picocolors35.default.yellow(` An incident has been opened on the board for this.`));
9578
+ }
9579
+ }
9580
+ const down = res.checks.filter((c) => !c.ok);
9581
+ line("");
9582
+ if (!down.length) {
9583
+ success(
9584
+ `Everything published is answering (${res.checks.length} ${res.checks.length === 1 ? "address" : "addresses"} checked).`
9585
+ );
9586
+ return;
9587
+ }
9588
+ const production = down.filter((c) => c.environment === "production").length;
9589
+ line(
9590
+ import_picocolors35.default.red(
9591
+ `${down.length} of ${res.checks.length} not answering` + (production ? ` \u2014 ${production} customer-facing.` : " (preview only).")
9592
+ )
9593
+ );
9594
+ }
9595
+
9596
+ // src/commands/urls.ts
9597
+ var import_picocolors36 = __toESM(require_picocolors(), 1);
9598
+ function registerUrls(program3) {
9599
+ program3.command("urls").description("The stable preview and production addresses of every app in this project").option("--app <webAppId>", "just one app").action(
9600
+ action(async ({ ctx, opts }) => {
9601
+ const projectId = requireProject(ctx);
9602
+ const apps = await api(ctx, `/v1/apps`, {
9603
+ query: { project: projectId }
9604
+ });
9605
+ const list = Array.isArray(apps) ? apps : apps?.apps ?? [];
9606
+ const wanted = opts.app ? list.filter((a) => a.id === String(opts.app)) : list;
9607
+ const rows = urlRows(wanted);
9608
+ const summary = urlsSummary(rows);
9609
+ ok({ rows, summary }, () => {
9610
+ for (const row of rows) {
9611
+ const label = import_picocolors36.default.dim(row.environment.padEnd(10));
9612
+ const value = row.url ? import_picocolors36.default.cyan(row.url) : import_picocolors36.default.dim(row.note ?? "not published");
9613
+ line(` ${row.appName.padEnd(22)} ${label} ${value}`);
9614
+ }
9615
+ line("");
9616
+ if (rows.some((r) => r.url)) success(summary);
9617
+ else line(import_picocolors36.default.yellow(summary));
9618
+ });
9619
+ })
9620
+ );
9621
+ }
9622
+
9623
+ // src/commands/deployments.ts
9624
+ var import_picocolors37 = __toESM(require_picocolors(), 1);
9625
+ function registerDeployments(program3) {
9626
+ const cmd = program3.command("deployments").description("Deployment history, and putting a build in front of customers");
9627
+ cmd.command("list").description("What has been built, newest first").option("--app <webAppId>", "just one app (default: every app in the project)").option("--env <environment>", "preview or production").option("--limit <n>", "how many to show", "20").action(
9628
+ action(async ({ ctx, opts }) => {
9629
+ const projectId = requireProject(ctx);
9630
+ const environment = readEnv2(opts.env, "deployments list");
9631
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments`, {
9632
+ query: {
9633
+ ...opts.app ? { webAppId: String(opts.app) } : {},
9634
+ ...environment ? { environment } : {},
9635
+ limit: String(opts.limit ?? "20")
9636
+ }
9637
+ });
9638
+ const items = res?.deployments ?? [];
9639
+ ok(res, () => {
9640
+ if (!items.length) {
9641
+ return line(
9642
+ import_picocolors37.default.dim(
9643
+ environment ? `Nothing has been deployed to ${environment} yet.` : "Nothing has been deployed yet. `workser deploy` builds the first one."
9644
+ )
9645
+ );
9646
+ }
9647
+ for (const d of items) line(formatDeployment(d));
9648
+ });
9649
+ })
9650
+ );
9651
+ cmd.command("inspect <id>").description("One deployment in full, with its build log").option("--logs", "include the build output", false).action(
9652
+ action(async ({ ctx, args, opts }) => {
9653
+ const projectId = requireProject(ctx);
9654
+ const id = String(args[0]);
9655
+ const dep = await api(ctx, `/v1/deployments/${encodeURIComponent(id)}`);
9656
+ const logs = opts.logs ? await api(
9657
+ ctx,
9658
+ `/v1/projects/${projectId}/deployments/${encodeURIComponent(id)}/logs`
9659
+ ).catch(() => null) : null;
9660
+ ok({ ...dep, logs }, () => {
9661
+ line(formatDeployment(dep));
9662
+ if (dep?.error_message) line(import_picocolors37.default.red(` ${dep.error_message}`));
9663
+ const events = logs?.events ?? [];
9664
+ for (const e of events) {
9665
+ line(` ${import_picocolors37.default.dim(String(e.type ?? "log"))} ${e.text ?? ""}`);
9666
+ }
9667
+ if (opts.logs && !events.length) {
9668
+ line(import_picocolors37.default.dim(" That build produced no output."));
9669
+ }
9670
+ });
9671
+ })
9672
+ );
9673
+ cmd.command("promote").description("Put the latest ready build in front of customers (asks you first)").option("--app <webAppId>", "which app (defaults to the primary app)").action(
9674
+ action(async ({ ctx, opts }) => {
9675
+ const projectId = requireProject(ctx);
9676
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments/promote`, {
9677
+ body: { ...opts.app ? { webAppId: String(opts.app) } : {} }
9678
+ });
9679
+ ok(res, () => printPromoted(res, null));
9680
+ })
9681
+ );
9682
+ cmd.command("rollback <version>").description("Put an earlier version back in front of customers (asks you first)").option("--app <webAppId>", "which app (defaults to the primary app)").action(
9683
+ action(async ({ ctx, args, opts }) => {
9684
+ const projectId = requireProject(ctx);
9685
+ const version = Number(args[0]);
9686
+ if (!Number.isInteger(version) || version < 1) {
9687
+ throw new WorkserError(
9688
+ `"${args[0]}" is not a version number. \`workser deployments list\` shows them.`,
9689
+ { code: "bad_input" }
9690
+ );
9691
+ }
9692
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments/promote`, {
9693
+ body: {
9694
+ version,
9695
+ ...opts.app ? { webAppId: String(opts.app) } : {}
9696
+ }
9697
+ });
9698
+ ok(res, () => printPromoted(res, version));
9699
+ })
9700
+ );
9701
+ }
9702
+ function readEnv2(raw, verb) {
9703
+ const parsed = parseDeployEnvironment(raw, verb);
9704
+ if (!parsed.ok) throw new WorkserError(parsed.error, { code: "bad_input" });
9705
+ return parsed.value;
9706
+ }
9707
+ function printPromoted(res, version) {
9708
+ if (!res) return;
9709
+ const what = version === null ? "the latest build" : `version ${version}`;
9710
+ const url = res.url ?? res.vercel_url;
9711
+ success(`Production is being rebuilt from ${what}.`);
9712
+ if (url) line(import_picocolors37.default.dim(`It will be at ${url}`));
9713
+ line(import_picocolors37.default.dim("`workser deploy status` follows it."));
9714
+ }
9715
+ function formatDeployment(d) {
9716
+ if (!d) return "";
9717
+ const version = d.version !== void 0 ? import_picocolors37.default.yellow(`v${d.version}`) : import_picocolors37.default.dim("v?");
9718
+ const env = import_picocolors37.default.dim((d.environment ?? "?").padEnd(10));
9719
+ const app = d.webAppName ? `${d.webAppName} ` : "";
9720
+ const when = import_picocolors37.default.dim(formatTime2(d.created_at));
9721
+ const url = d.url ? " " + import_picocolors37.default.cyan(d.url) : "";
9722
+ return `${version} ${env} ${colorStatus(d.status ?? "")} ${app}${when}${url}`;
9723
+ }
9724
+ function formatTime2(t) {
9725
+ if (!t) return "\u2014";
9726
+ const d = new Date(t);
9727
+ return Number.isNaN(d.getTime()) ? String(t) : d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
9728
+ }
9729
+
9730
+ // src/commands/usage.ts
9731
+ var import_picocolors38 = __toESM(require_picocolors(), 1);
9732
+
9733
+ // src/usage.ts
9734
+ var NEAR_LIMIT_FRACTION = 0.8;
9735
+ function isUnlimited(limit) {
9736
+ return limit === null || limit === void 0 || !Number.isFinite(limit) || limit >= Number.MAX_SAFE_INTEGER;
9737
+ }
9738
+ function fractionUsed(d) {
9739
+ if (d.used === null) return null;
9740
+ if (isUnlimited(d.limit)) return null;
9741
+ const limit = d.limit;
9742
+ if (limit <= 0) return d.used > 0 ? 1 : 0;
9743
+ return d.used / limit;
9744
+ }
9745
+ function usageState(d) {
9746
+ if (d.used === null) return "unknown";
9747
+ const fraction = fractionUsed(d);
9748
+ if (fraction === null) return "fine";
9749
+ if (fraction >= 1) return "over";
9750
+ if (fraction >= NEAR_LIMIT_FRACTION) return "near";
9751
+ return "fine";
9752
+ }
9753
+ function formatAmount(value, unit) {
9754
+ if (unit === "count") return String(Math.round(value));
9755
+ if (Number.isInteger(value)) return `${value} GB`;
9756
+ if (value < 0.01) return "<0.01 GB";
9757
+ if (value < 10) return `${value.toFixed(2)} GB`;
9758
+ return `${value.toFixed(1)} GB`;
9759
+ }
9760
+ function bar(d, width = 20) {
9761
+ const fraction = fractionUsed(d);
9762
+ if (fraction === null) return "";
9763
+ const filled = Math.min(width, Math.round(fraction * width));
9764
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
9765
+ }
9766
+ function dimensionLine(d) {
9767
+ if (d.used === null) {
9768
+ return `${d.label}: not measured${d.note ? ` \u2014 ${d.note}` : ""}`;
9769
+ }
9770
+ const used = formatAmount(d.used, d.unit);
9771
+ if (isUnlimited(d.limit)) return `${d.label}: ${used} (no limit on this plan)`;
9772
+ const limit = formatAmount(d.limit, d.unit);
9773
+ if (usageState(d) === "over") {
9774
+ return d.kind === "hard" ? `${d.label}: ${used} of ${limit} \u2014 at the limit` : `${d.label}: ${used} of ${limit} included \u2014 the rest is billed as extra`;
9775
+ }
9776
+ return `${d.label}: ${used} of ${limit}`;
9777
+ }
9778
+ function usageSummary(report) {
9779
+ const dims = report.dimensions ?? [];
9780
+ const unknown = dims.filter((d) => d.used === null);
9781
+ const over = dims.filter((d) => usageState(d) === "over");
9782
+ const near = dims.filter((d) => usageState(d) === "near");
9783
+ const parts = [];
9784
+ if (over.length) {
9785
+ parts.push(
9786
+ `${over.length === 1 ? "One thing is" : `${over.length} things are`} over the ${report.tier} plan's limit`
9787
+ );
9788
+ } else if (near.length) {
9789
+ parts.push(
9790
+ `${near.length === 1 ? "One thing is" : `${near.length} things are`} close to the ${report.tier} plan's limit`
9791
+ );
9792
+ } else if (dims.some((d) => d.used !== null)) {
9793
+ parts.push(`Everything is comfortably inside the ${report.tier} plan`);
9794
+ }
9795
+ if (unknown.length) {
9796
+ parts.push(
9797
+ `${unknown.length} ${unknown.length === 1 ? "figure" : "figures"} could not be read`
9798
+ );
9799
+ }
9800
+ if (!parts.length) return "Nothing could be measured.";
9801
+ return `${parts.join("; ")}.`;
9802
+ }
9803
+ function shouldFail(report) {
9804
+ return (report.dimensions ?? []).some(
9805
+ (d) => d.kind === "hard" && usageState(d) === "over"
9806
+ );
9807
+ }
9808
+
9809
+ // src/commands/usage.ts
9810
+ function registerUsage(program3) {
9811
+ program3.command("usage").description("What this project and your plan are using \u2014 storage, projects, apps").action(
9812
+ action(async ({ ctx }) => {
9813
+ const projectId = requireProject(ctx);
9814
+ const report = await api(
9815
+ ctx,
9816
+ `/v1/projects/${projectId}/usage`
9817
+ );
9818
+ ok(report, () => print4(report));
9819
+ if (shouldFail(report)) process.exitCode = 1;
9820
+ })
9821
+ );
9822
+ }
9823
+ function print4(report) {
9824
+ const dims = report.dimensions ?? [];
9825
+ if (!dims.length) {
9826
+ return line(import_picocolors38.default.dim("Nothing to measure for this project yet."));
9827
+ }
9828
+ const width = Math.max(...dims.map((d) => d.label.length));
9829
+ for (const d of dims) {
9830
+ line(` ${colour(d)(dimensionLine(d).padEnd(0))}${gauge(d, width)}`);
9831
+ }
9832
+ line("");
9833
+ const summary = usageSummary(report);
9834
+ const worst = dims.map(usageState);
9835
+ if (worst.includes("over")) line(import_picocolors38.default.red(summary));
9836
+ else if (worst.includes("near") || worst.includes("unknown"))
9837
+ line(import_picocolors38.default.yellow(summary));
9838
+ else success(summary);
9839
+ }
9840
+ function gauge(d, _labelWidth) {
9841
+ const drawn = bar(d);
9842
+ return drawn ? ` ${import_picocolors38.default.dim(drawn)}` : "";
9843
+ }
9844
+ function colour(d) {
9845
+ switch (usageState(d)) {
9846
+ case "over":
9847
+ return d.kind === "hard" ? import_picocolors38.default.red : import_picocolors38.default.yellow;
9848
+ case "near":
9849
+ return import_picocolors38.default.yellow;
9850
+ case "unknown":
9851
+ return import_picocolors38.default.dim;
9852
+ default:
9853
+ return (s) => s;
9854
+ }
9855
+ }
9856
+
7743
9857
  // src/index.ts
7744
9858
  var pkg = {
7745
- version: true ? "0.3.1" : "0.0.0-dev"
9859
+ version: true ? "0.6.0" : "0.0.0-dev"
7746
9860
  };
7747
9861
  var program2 = new Command();
7748
9862
  program2.name("workser").description(
@@ -7775,6 +9889,13 @@ registerOpen(program2);
7775
9889
  registerDoctor(program2);
7776
9890
  registerAgent(program2);
7777
9891
  registerVerify(program2);
9892
+ registerApi(program2);
9893
+ registerAnalysis(program2);
9894
+ registerScan(program2);
9895
+ registerHealth(program2);
9896
+ registerUrls(program2);
9897
+ registerDeployments(program2);
9898
+ registerUsage(program2);
7778
9899
  registerCheckpoint(program2);
7779
9900
  registerSync(program2);
7780
9901
  registerWorkflow(program2);