@workser/cli 0.3.1 → 0.6.1

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,28 +4685,27 @@ 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",
4193
- 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"],
4688
+ title: "Decisions and requirements",
4689
+ summary: "Read what this project already decided, and record what a future maintainer will need. Phased work itself is subtasks, not board cards \u2014 see `workser help tasks`.",
4690
+ commands: ["board", "decision", "requirement"],
4195
4691
  source: "skills/workser/reference/sdlc-entities.md",
4196
- body: `# Board cards, decisions, requirements, and docs
4692
+ body: `# 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 Project Memory panel uses, so anything here appears there too
4696
+ \u2014 and, inside an Orbit-spawned run, as an inline card in the conversation.
4697
+ Documents have their own guide: \`workser help docs\`. Phased work is tracked as
4698
+ subtasks, not here \u2014 see \`workser help tasks\`.
4699
+
4700
+ > **The Board (\`workser board ...\`) is deprecated for agent use.** It used to be
4701
+ > where a multi-phase plan went, one card per phase \u2014 and the phase was ALSO a
4702
+ > subtask the planning turn had just filed for the same piece of work. That gave
4703
+ > a task two competing plans, one of which this task's own page never reads and
4704
+ > nothing kept in sync with the other. Phases are \`project_tasks\` subtasks now,
4705
+ > full stop: \`workser task subtask add\`. Do not run \`workser board create\` for
4706
+ > planned work \u2014 see \`workser help tasks\`.
4202
4707
 
4203
4708
  \`\`\`
4204
- workser board list [--status <value>] [--label <value>] [--limit <n>]
4205
- workser board show <id>
4206
- workser board create <title> [--description <text>] [--status <value>]
4207
- [--priority <value>] [--label <value>]
4208
- [--owner <name>] [--milestone <id>]
4209
- workser board update <id> [--title|--description|--status|--priority
4210
- |--label|--owner|--milestone ...]
4211
- workser board move <id> <backlog|in-progress|in-review|done>
4212
- workser board close <id>
4213
-
4214
4709
  workser decision list [--limit <n>]
4215
4710
  workser decision show <id>
4216
4711
  workser decision create <title> --context <text> --decision <text>
@@ -4221,11 +4716,6 @@ workser requirement show <id>
4221
4716
  workser requirement create <title> --body <text> [--status <text>]
4222
4717
  workser requirement update <id> [--title <text>] [--body <text>] [--status <text>]
4223
4718
 
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
4719
  \`\`\`
4230
4720
 
4231
4721
  ## Read first \u2014 this is the part that matters
@@ -4233,7 +4723,6 @@ workser doc update <id> [--title <text>] [--markdown <text>]
4233
4723
  Before starting anything beyond a trivial edit:
4234
4724
 
4235
4725
  \`\`\`
4236
- workser board list --json # what's already tracked (don't re-file it)
4237
4726
  workser decision list --json # what was already decided (don't reverse it)
4238
4727
  \`\`\`
4239
4728
 
@@ -4243,20 +4732,18 @@ purpose \u2014 \`workser decision show <id>\` gives you the context and conseque
4243
4732
  not just the title. Reach for \`workser doc list\` / \`workser requirement list\`
4244
4733
  the same way when the task touches documented behaviour.
4245
4734
 
4246
- ## Work with phases \u2192 cards + a plan doc, before you build
4735
+ ## Work with phases \u2192 subtasks + a plan doc, before you build
4247
4736
 
4248
4737
  The moment you split a task into more than one phase, file it \u2014 not afterwards,
4249
4738
  and not only in your reply, which is gone once the conversation scrolls.
4250
4739
 
4251
4740
  \`\`\`bash
4252
- # one card per phase; only the one you're doing goes to in-progress
4253
- workser board create "Phase 1 \u2014 schema + migration" \\
4254
- --description "Add orders/line_items tables and the migration." \\
4255
- --status in-progress --json
4256
- workser board create "Phase 2 \u2014 checkout API" --description "\u2026" --json
4257
- workser board create "Phase 3 \u2014 cart UI" --description "\u2026" --json
4258
-
4259
- # the plan itself, ONE doc, deliberately NOT linked to a card
4741
+ # the phases themselves \u2014 this task's own subtask list, not the Board
4742
+ workser task subtask add "Phase 1 \u2014 schema + migration" --role api \\
4743
+ --note "Add orders/line_items tables and the migration."
4744
+ workser task subtask add "Phase 2 \u2014 checkout API" --role api --note "\u2026"
4745
+
4746
+ # the plan's narrative, ONE doc, deliberately NOT linked to a subtask
4260
4747
  workser doc create "Checkout \u2014 implementation plan" --markdown "$(cat plan.md)" --json
4261
4748
 
4262
4749
  # the approach, if the plan settled something with real alternatives
@@ -4264,49 +4751,23 @@ workser decision create "Carts live server-side" --context "\u2026" --decision "
4264
4751
  \`\`\`
4265
4752
 
4266
4753
  **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.
4269
-
4270
- The bar: if the user closed this conversation now, the Board should still show
4271
- what's left and the doc should still explain the plan to whoever continues it.
4272
-
4273
- ## Keep the Board honest while you work
4754
+ its card and is *hidden* from the Docs panel; a plan spanning three phases
4755
+ belongs to the project, not to phase 1.
4274
4756
 
4275
- A Board still reading \`backlog\` after the feature shipped tells the user the
4276
- opposite of the truth. Moving the card is part of finishing the work:
4277
-
4278
- \`\`\`
4279
- workser board move <id> in-progress # you picked it up
4280
- workser board move <id> in-review # ready for the user to look at
4281
- workser board close <id> # done and verified
4282
- \`\`\`
4283
-
4284
- \`--status\` is one of \`backlog | in-progress | in-review | done\` (default
4285
- \`backlog\`). \`--priority\` is one of \`low | normal | high | urgent\` (default
4286
- \`normal\`). \`--label\` repeats for more than one label:
4287
-
4288
- \`\`\`
4289
- workser board create "Fix the login bug" --status in-progress --priority high \\
4290
- --label bug --label auth
4291
- \`\`\`
4292
-
4293
- \`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.
4757
+ The bar: if the user closed this conversation now, the subtask list should still
4758
+ show what's left and the doc should still explain the plan to whoever continues
4759
+ it.
4297
4760
 
4298
4761
  ## Decisions are append-only
4299
4762
 
4300
4763
  \`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.
4764
+ \`--context\` is why it came up, \`--decision\` what was decided, \`--consequences\`
4765
+ the follow-on effects. There is deliberately **no \`decision update\`** \u2014 a record
4766
+ states what was decided at a point in time. When it stops being right, record a
4767
+ new decision that supersedes it and say so in its \`--context\`. Editing the
4768
+ history is how a decision log stops being worth reading.
4307
4769
 
4308
- Requirements are different: they legitimately move along, so they do have
4309
- \`update\`.
4770
+ Requirements legitimately move along, so they do have \`update\`.
4310
4771
 
4311
4772
  \`\`\`
4312
4773
  workser requirement create "Support SSO" --body "Enterprise customers need SAML." \\
@@ -4317,25 +4778,11 @@ workser requirement update <id> --status done
4317
4778
  ## Docs
4318
4779
 
4319
4780
  \`--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
4781
+ rich text the Docs panel renders and as a git-tracked mirror at
4782
+ \`.workser/docs/<id>.md\`; \`workser doc show <id> --markdown\` reports that path so
4783
+ you can read the file with your normal tools.
4334
4784
 
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.
4785
+ Revise trd saying "fix the bug" is not fixing the bug.
4339
4786
  `
4340
4787
  },
4341
4788
  {
@@ -4421,6 +4868,9 @@ below defaults to it and you rarely pass an id at all.
4421
4868
  \`\`\`
4422
4869
  workser task list [--status <value>] [--label <value>] [--limit <n>]
4423
4870
  workser task show [id] # the task you are in, with its steps
4871
+ workser task create <title> [--note <text>] [--kind <value>]
4872
+ [--label <value...>] [--app <id...>]
4873
+ [--infra <ref...>]
4424
4874
 
4425
4875
  workser task subtask add <title> [--role <value>] [--kind <value>]
4426
4876
  [--note <text>] [--app <id...>]
@@ -4429,6 +4879,7 @@ workser task subtask add <title> [--role <value>] [--kind <value>]
4429
4879
  workser task subtask list [taskId]
4430
4880
  workser task subtask update <id> [--title|--note|--role|--kind|--scope]
4431
4881
  workser task subtask remove <id>
4882
+ workser task subtask send-back <id> --note <text> # redo it, and say why
4432
4883
 
4433
4884
  workser task can-start [id] # may work begin? refuses until approved
4434
4885
  workser task approval request # tell the owner the plan is ready
@@ -4443,6 +4894,16 @@ Team's own table. Filing your plan on the Board puts it somewhere the owner's
4443
4894
  task page never reads: they see "created work item" and an empty plan. Use
4444
4895
  \`task subtask add\`.
4445
4896
 
4897
+ ## Opening work from a project channel
4898
+
4899
+ When a project-channel conversation produces actionable work, the PM may record
4900
+ it with \`workser task create\`. Orbit supplies the channel and source-message IDs;
4901
+ the command records them and posts the new task card as a Project Manager message
4902
+ automatically. Do not invent or ask for those IDs.
4903
+
4904
+ Opening a task does **not** approve it or start implementation. The task remains
4905
+ awaiting the owner. Never approve or dispatch a task you opened yourself.
4906
+
4446
4907
  ## Planning a task
4447
4908
 
4448
4909
  Read the project first, then propose. One \`subtask add\` per step:
@@ -4487,6 +4948,75 @@ workser task done --summary "The report now shows cost per KOL, with six months
4487
4948
  \`\`\`
4488
4949
 
4489
4950
  Write the summary for someone who runs a business and does not read code.
4951
+
4952
+ ## Sending a step back
4953
+
4954
+ A step that finished but is not good enough is **sent back**, not replaced:
4955
+
4956
+ \`\`\`
4957
+ workser task subtask send-back 3f2a\u2026 --note "The totals ignore refunds."
4958
+ \`\`\`
4959
+
4960
+ That puts it in the queue again as a **second attempt** on the same step. Two
4961
+ reasons it matters that this is not a new step:
4962
+
4963
+ - The owner's screen can then say *"1 send-back, fixed \u2014 2nd run passed"*. A
4964
+ replacement step says only that two steps exist, which tells them nothing
4965
+ about whether their team caught its own mistake.
4966
+ - \`--note\` is the reason, and it is recorded against the attempt being
4967
+ rejected. Without it the history can say a step ran twice but not why.
4968
+
4969
+ It refuses a step that is still working. Let it finish first \u2014 the run is
4970
+ still writing to it.
4971
+ `
4972
+ },
4973
+ {
4974
+ topic: "usage",
4975
+ title: "Usage \u2014 what is being used, against the plan",
4976
+ summary: "How much database, file storage, projects and apps are in use, and how close that is to what the plan allows.",
4977
+ commands: ["usage"],
4978
+ source: "skills/workser/reference/usage.md",
4979
+ body: `# Usage \u2014 what is being used, against the plan
4980
+
4981
+ \`\`\`
4982
+ workser usage # storage, projects, apps \u2014 and how close each is to the limit
4983
+ \`\`\`
4984
+
4985
+ Run it before you propose anything that adds to a count. "Create another
4986
+ project" is a plan you can only sensibly make if you know the plan allows two
4987
+ and two already exist.
4988
+
4989
+ ## Two scopes in one answer, on purpose
4990
+
4991
+ * **Database and files are ORGANISATION-wide.** One pool across every project.
4992
+ There is no per-project storage limit, and reporting one would invent it.
4993
+ * **Projects, and apps in this project, are counted where they apply.** These
4994
+ are the limits people actually hit.
4995
+
4996
+ ## Two kinds of limit, which do not mean the same thing
4997
+
4998
+ * **Hard cap** \u2014 projects, apps. Going over is **refused**. \`workser usage\`
4999
+ exits non-zero when one is reached, so a step can gate on it.
5000
+ * **Soft allowance** \u2014 database, files. Going over is **billed as extra**,
5001
+ never blocked. It does not fail the command, because a customer growing past
5002
+ their allowance should not have their automation start breaking that day.
5003
+
5004
+ ## "not measured" is not zero
5005
+
5006
+ A figure that could not be read prints as \`not measured\`, with the reason, and
5007
+ draws no bar. Do not report it as \`0\`, and do not tell the user they have room
5008
+ based on it \u2014 nobody looked.
5009
+
5010
+ If a scan comes back with a figure missing, say which one and why. "Your
5011
+ database is using 2.5 GB of 10; the file total could not be read" is a useful
5012
+ sentence. "You are using 2.5 GB of 20" is not, and it is wrong.
5013
+
5014
+ ## What to do with it
5015
+
5016
+ - Near a **soft** limit: tell the owner what the extra will cost them, and what
5017
+ is taking the space. Do not delete anything to make a number look better.
5018
+ - At a **hard** cap: say which plan raises it. Do not attempt the create \u2014 it
5019
+ will be refused, and a failed attempt reads to the owner as a broken product.
4490
5020
  `
4491
5021
  },
4492
5022
  {
@@ -4618,11 +5148,11 @@ function registerHelp(program3) {
4618
5148
  var import_picocolors3 = __toESM(require_picocolors(), 1);
4619
5149
 
4620
5150
  // src/context.ts
4621
- import { resolve } from "path";
5151
+ import { resolve as resolve2 } from "path";
4622
5152
 
4623
5153
  // src/config.ts
4624
5154
  import { homedir } from "os";
4625
- import { join } from "path";
5155
+ import { dirname, join, resolve } from "path";
4626
5156
  import {
4627
5157
  chmodSync,
4628
5158
  existsSync,
@@ -4630,6 +5160,8 @@ import {
4630
5160
  readFileSync,
4631
5161
  writeFileSync
4632
5162
  } from "fs";
5163
+ var PROJECT_MARKER = ".workser-project";
5164
+ var APP_MARKER = ".workser-app";
4633
5165
  var GLOBAL_DIR = join(homedir(), ".workser");
4634
5166
  var SESSION_FILE = join(GLOBAL_DIR, "session.json");
4635
5167
  function readSession() {
@@ -4660,10 +5192,82 @@ function projectLinkPath(cwd) {
4660
5192
  return join(cwd, ".workser", "project.json");
4661
5193
  }
4662
5194
  function readProjectLink(cwd) {
5195
+ const found = readFolderIdentity(cwd);
5196
+ if (!found) return null;
5197
+ return {
5198
+ projectId: found.projectId,
5199
+ name: found.projectName,
5200
+ orgId: found.orgId,
5201
+ root: found.projectRoot
5202
+ };
5203
+ }
5204
+ function readFolderIdentity(cwd) {
5205
+ let app = null;
5206
+ for (const dir of ancestors(cwd)) {
5207
+ if (!app) {
5208
+ const marker2 = readJson(join(dir, APP_MARKER));
5209
+ if (marker2 && typeof marker2.appId === "string") {
5210
+ app = { marker: marker2, root: dir };
5211
+ }
5212
+ }
5213
+ const link = readJson(projectLinkPath(dir));
5214
+ if (link && typeof link.projectId === "string") {
5215
+ return {
5216
+ projectRoot: dir,
5217
+ projectId: link.projectId,
5218
+ projectName: link.name,
5219
+ orgId: link.orgId,
5220
+ ...appFields(app)
5221
+ };
5222
+ }
5223
+ const marker = readJson(join(dir, PROJECT_MARKER));
5224
+ if (marker && typeof marker.projectId === "string") {
5225
+ return {
5226
+ projectRoot: dir,
5227
+ projectId: marker.projectId,
5228
+ projectName: marker.projectName ?? void 0,
5229
+ orgId: marker.orgId ?? void 0,
5230
+ ...appFields(app)
5231
+ };
5232
+ }
5233
+ }
5234
+ if (app && typeof app.marker.projectId === "string") {
5235
+ return {
5236
+ projectRoot: app.root,
5237
+ projectId: app.marker.projectId,
5238
+ projectName: app.marker.projectName ?? void 0,
5239
+ orgId: app.marker.orgId ?? void 0,
5240
+ ...appFields(app)
5241
+ };
5242
+ }
5243
+ return null;
5244
+ }
5245
+ function appFields(app) {
5246
+ if (!app) return {};
5247
+ return {
5248
+ appId: app.marker.appId,
5249
+ appName: app.marker.appName ?? void 0,
5250
+ appRoot: app.root
5251
+ };
5252
+ }
5253
+ function ancestors(cwd) {
5254
+ const out = [];
5255
+ const home = homedir();
5256
+ let dir = resolve(cwd);
5257
+ for (let i = 0; i < 64; i++) {
5258
+ out.push(dir);
5259
+ if (dir === home) break;
5260
+ const parent = dirname(dir);
5261
+ if (parent === dir) break;
5262
+ dir = parent;
5263
+ }
5264
+ return out;
5265
+ }
5266
+ function readJson(file) {
4663
5267
  try {
4664
- const p = projectLinkPath(cwd);
4665
- if (!existsSync(p)) return null;
4666
- return JSON.parse(readFileSync(p, "utf8"));
5268
+ if (!existsSync(file)) return null;
5269
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
5270
+ return parsed && typeof parsed === "object" ? parsed : null;
4667
5271
  } catch {
4668
5272
  return null;
4669
5273
  }
@@ -4702,7 +5306,7 @@ function cloudBaseUrl() {
4702
5306
  // src/context.ts
4703
5307
  function buildContext(opts) {
4704
5308
  const session = readSession();
4705
- const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
5309
+ const cwd = opts.cwd ? resolve2(opts.cwd) : process.cwd();
4706
5310
  const token = opts.token || process.env.WORKSER_TOKEN || session.token;
4707
5311
  const overridden = Boolean(
4708
5312
  opts.endpoint || opts.token || process.env.WORKSER_DAEMON_URL || process.env.WORKSER_TOKEN
@@ -4711,11 +5315,18 @@ function buildContext(opts) {
4711
5315
  const endpointRaw = socketPath ? "http://localhost" : opts.endpoint || process.env.WORKSER_DAEMON_URL || session.endpoint || cloudBaseUrl();
4712
5316
  const endpoint = endpointRaw.replace(/\/+$/, "");
4713
5317
  const mode2 = socketPath ? "daemon" : /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(endpoint) ? "daemon" : "cloud";
4714
- const link = readProjectLink(cwd);
4715
- const projectId = opts.project || process.env.WORKSER_PROJECT_ID || link?.projectId || session.defaultProjectId;
5318
+ const folder = readFolderIdentity(cwd);
5319
+ const projectId = opts.project || process.env.WORKSER_PROJECT_ID || folder?.projectId || session.defaultProjectId;
5320
+ const inThisProject = folder?.projectId === projectId;
4716
5321
  const runId = process.env.WORKSER_RUN_ID || void 0;
4717
5322
  const conversationId = process.env.WORKSER_CONVERSATION_ID || void 0;
4718
5323
  const projectTaskId = process.env.WORKSER_PROJECT_TASK_ID || void 0;
5324
+ const parentTaskId = process.env.WORKSER_PARENT_TASK_ID || void 0;
5325
+ const projectChannelId = process.env.WORKSER_PROJECT_CHANNEL_ID || void 0;
5326
+ const projectChannelMessageId = process.env.WORKSER_PROJECT_CHANNEL_MESSAGE_ID || void 0;
5327
+ const agentRole = process.env.WORKSER_AGENT_ROLE || void 0;
5328
+ const agentType = process.env.WORKSER_AGENT_TYPE || void 0;
5329
+ const agentModel = process.env.WORKSER_AGENT_MODEL || void 0;
4719
5330
  return {
4720
5331
  endpoint,
4721
5332
  socketPath,
@@ -4723,9 +5334,18 @@ function buildContext(opts) {
4723
5334
  mode: mode2,
4724
5335
  cwd,
4725
5336
  projectId,
5337
+ projectRoot: inThisProject ? folder?.projectRoot : void 0,
5338
+ appId: inThisProject ? folder?.appId : void 0,
5339
+ appName: inThisProject ? folder?.appName : void 0,
4726
5340
  runId,
4727
5341
  conversationId,
4728
- projectTaskId
5342
+ projectTaskId,
5343
+ parentTaskId,
5344
+ projectChannelId,
5345
+ projectChannelMessageId,
5346
+ agentRole,
5347
+ agentType,
5348
+ agentModel
4729
5349
  };
4730
5350
  }
4731
5351
  function runTarget(ctx) {
@@ -4746,10 +5366,22 @@ Commands that only read your account (projects, env, db, logs, status) work as n
4746
5366
  { code: "needs_local_app" }
4747
5367
  );
4748
5368
  }
5369
+ function requireDaemon(ctx, what, why) {
5370
+ if (ctx.mode === "daemon") return;
5371
+ throw new WorkserError(
5372
+ `\`workser ${what}\` ${why}, so it needs the Workser app running on this computer.
5373
+
5374
+ This shell is talking to ${ctx.endpoint} instead of a local app.
5375
+
5376
+ \u2022 On your own computer: open Workser and try again.
5377
+ \u2022 On a computer without Workser: install it from https://workser.ai/download and sign in.`,
5378
+ { code: "needs_local_app" }
5379
+ );
5380
+ }
4749
5381
  function requireProject(ctx) {
4750
5382
  if (!ctx.projectId) {
4751
5383
  throw new WorkserError(
4752
- "No project selected. Run `workser project use <id>` or pass --project <id>.",
5384
+ "No project selected.\n\nWorkser keeps each project in its own folder \u2014 `~/workser/<org>/<project>/`,\nwith that project's apps inside it. Running from anywhere in that tree is\nenough; this shell is not in one.\n\n \u2022 cd into the project's folder (Workser Orbit's Files tab shows where it is), or\n \u2022 pass --project <id> for a one-off.",
4753
5385
  { code: "no_project" }
4754
5386
  );
4755
5387
  }
@@ -4774,7 +5406,7 @@ function action(fn) {
4774
5406
  // src/client.ts
4775
5407
  import * as http from "http";
4776
5408
  function requestOverSocket(socketPath, pathWithQuery, init) {
4777
- return new Promise((resolve4, reject) => {
5409
+ return new Promise((resolve5, reject) => {
4778
5410
  const req = http.request(
4779
5411
  { socketPath, path: pathWithQuery, method: init.method, headers: init.headers },
4780
5412
  (res) => {
@@ -4783,7 +5415,7 @@ function requestOverSocket(socketPath, pathWithQuery, init) {
4783
5415
  res.on("data", (c) => text += c);
4784
5416
  res.on(
4785
5417
  "end",
4786
- () => resolve4({
5418
+ () => resolve5({
4787
5419
  ok: (res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300,
4788
5420
  status: res.statusCode ?? 0,
4789
5421
  statusText: res.statusMessage ?? "",
@@ -4880,16 +5512,31 @@ function registerStatus(program3) {
4880
5512
  program3.command("status").description("Show connection, current workspace/project, and latest deploy").action(
4881
5513
  action(async ({ ctx }) => {
4882
5514
  const data = await api(ctx, "/v1/status", { query: { project: ctx.projectId } });
4883
- ok(data, () => {
5515
+ const merged = {
5516
+ ...data,
5517
+ folder: {
5518
+ cwd: ctx.cwd,
5519
+ projectRoot: ctx.projectRoot ?? null,
5520
+ appId: ctx.appId ?? null,
5521
+ appName: ctx.appName ?? null
5522
+ }
5523
+ };
5524
+ ok(merged, () => {
4884
5525
  line(import_picocolors3.default.bold("Workser") + import_picocolors3.default.dim(` (${ctx.mode} \xB7 ${ctx.endpoint})`));
4885
5526
  line(` user: ${data.user?.email ?? "\u2014"}`);
4886
5527
  line(` workspace: ${data.workspace?.name ?? "\u2014"}`);
4887
5528
  line(
4888
5529
  ` project: ${data.project?.name ?? "\u2014"}` + (data.project?.id ? import_picocolors3.default.dim(` (${data.project.id})`) : "")
4889
5530
  );
4890
- if (data.latestDeploy) {
5531
+ if (ctx.projectRoot) line(` folder: ${import_picocolors3.default.dim(ctx.projectRoot)}`);
5532
+ if (ctx.appId) {
4891
5533
  line(
4892
- ` deploy: ${colorStatus(data.latestDeploy.status)}` + (data.latestDeploy.url ? ` ${import_picocolors3.default.cyan(data.latestDeploy.url)}` : "")
5534
+ ` app: ${ctx.appName ?? "\u2014"}` + import_picocolors3.default.dim(` (${ctx.appId})`)
5535
+ );
5536
+ }
5537
+ if (data.latestDeploy) {
5538
+ line(
5539
+ ` deploy: ${colorStatus(data.latestDeploy.status)}` + (data.latestDeploy.url ? ` ${import_picocolors3.default.cyan(data.latestDeploy.url)}` : "")
4893
5540
  );
4894
5541
  }
4895
5542
  });
@@ -5223,7 +5870,7 @@ function registerAuth(program3) {
5223
5870
  // src/commands/storage.ts
5224
5871
  var import_picocolors7 = __toESM(require_picocolors(), 1);
5225
5872
  import { readFile, writeFile } from "fs/promises";
5226
- import { basename, dirname } from "path";
5873
+ import { basename, dirname as dirname2 } from "path";
5227
5874
  function registerStorage(program3) {
5228
5875
  const storage = program3.command("storage").description("Provision and work with the project's object storage (Cloudflare R2)");
5229
5876
  storage.command("create [name]").description("Provision a storage bucket for the project (idempotent)").action(
@@ -5269,7 +5916,7 @@ function registerStorage(program3) {
5269
5916
  const bytes = await readFile(localPath).catch(() => {
5270
5917
  throw new WorkserError(`Can't read local file: ${localPath}`, { code: "bad_input" });
5271
5918
  });
5272
- const dir = dirname(key);
5919
+ const dir = dirname2(key);
5273
5920
  const res = await api(ctx, `/v1/projects/${projectId}/storage/upload-base64`, {
5274
5921
  body: {
5275
5922
  filename: basename(key),
@@ -5335,7 +5982,7 @@ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
5335
5982
  import { basename as basename2 } from "path";
5336
5983
  function registerNeon(program3) {
5337
5984
  const neon = program3.command("neon").description(
5338
- "The project's own Neon backend: object storage buckets and functions"
5985
+ "The project's own database: branches, databases, compute, storage and functions"
5339
5986
  );
5340
5987
  neon.command("status").description(
5341
5988
  "Whether this project can use Neon storage/functions (tenancy, toggles, region)"
@@ -5557,6 +6204,137 @@ function registerNeon(program3) {
5557
6204
  ok(res, () => success(`Deleted function ${args[0]}.`));
5558
6205
  })
5559
6206
  );
6207
+ const branch = neon.command("branch").description("Copies of the project's data, to work on without touching it");
6208
+ branch.command("list").description("Every branch, with the one the app runs on marked").action(
6209
+ action(async ({ ctx }) => {
6210
+ const projectId = requireProject(ctx);
6211
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-branches`);
6212
+ ok(items, () => {
6213
+ if (!items?.length) return line(import_picocolors8.default.dim("No branches."));
6214
+ for (const b of items) {
6215
+ const mark = b.isProjectBranch ? import_picocolors8.default.green(" \u2190 the app runs on this") : "";
6216
+ line(
6217
+ `${import_picocolors8.default.bold(b.name ?? "?")} ${import_picocolors8.default.dim(b.id ?? "")}${b.parent_id ? import_picocolors8.default.dim(" from " + b.parent_id) : ""}${mark}`
6218
+ );
6219
+ }
6220
+ });
6221
+ })
6222
+ );
6223
+ 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(
6224
+ "--no-compute",
6225
+ "create it without compute \u2014 cheaper, and nothing can connect to it"
6226
+ ).action(
6227
+ action(async ({ ctx, args, opts }) => {
6228
+ const projectId = requireProject(ctx);
6229
+ const noCompute = opts.compute === false;
6230
+ const res = await api(ctx, `/v1/projects/${projectId}/neon-branches`, {
6231
+ body: {
6232
+ name: args[0],
6233
+ ...opts.from ? { fromBranchId: String(opts.from) } : {},
6234
+ // Commander turns `--no-compute` into `compute: false`. The typed
6235
+ // opts bag is loose here, so the comparison is written against the
6236
+ // value rather than the declared type.
6237
+ ...noCompute ? { withEndpoint: false } : {}
6238
+ }
6239
+ });
6240
+ ok(res, () => {
6241
+ success(`Created branch ${args[0]}.`);
6242
+ const id = res?.branch?.id ?? res?.id;
6243
+ if (id) line(import_picocolors8.default.dim(id));
6244
+ if (noCompute) {
6245
+ line(
6246
+ import_picocolors8.default.dim(
6247
+ "It has no compute, so nothing can connect to it until one is added."
6248
+ )
6249
+ );
6250
+ }
6251
+ });
6252
+ })
6253
+ );
6254
+ branch.command("reset <branchId>").description("Throw away a branch's changes and take its source data again (asks for approval)").action(
6255
+ action(async ({ ctx, args }) => {
6256
+ const projectId = requireProject(ctx);
6257
+ const res = await api(
6258
+ ctx,
6259
+ `/v1/projects/${projectId}/neon-branches/${encodeURIComponent(args[0])}/reset`,
6260
+ { body: {} }
6261
+ );
6262
+ ok(res, () => success(`Reset ${args[0]} to its source.`));
6263
+ })
6264
+ );
6265
+ branch.command("rm <branchId>").description("Delete a branch and its data (asks for approval)").action(
6266
+ action(async ({ ctx, args }) => {
6267
+ const projectId = requireProject(ctx);
6268
+ const res = await api(
6269
+ ctx,
6270
+ `/v1/projects/${projectId}/neon-branches/${encodeURIComponent(args[0])}`,
6271
+ { method: "DELETE" }
6272
+ );
6273
+ ok(res, () => success(`Deleted branch ${args[0]}.`));
6274
+ })
6275
+ );
6276
+ const database = neon.command("database").description("Databases on a branch");
6277
+ database.command("list").description("Databases on a branch (default: the one the app uses)").option("--branch <branchId>", "look at another branch").action(
6278
+ action(async ({ ctx, opts }) => {
6279
+ const projectId = requireProject(ctx);
6280
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-databases`, {
6281
+ query: opts.branch ? { branchId: String(opts.branch) } : void 0
6282
+ });
6283
+ ok(items, () => {
6284
+ if (!items?.length) return line(import_picocolors8.default.dim("No databases."));
6285
+ for (const d of items) {
6286
+ const mark = d.isProjectDatabase ? import_picocolors8.default.green(" \u2190 the app connects to this") : "";
6287
+ line(`${import_picocolors8.default.bold(d.name ?? "?")}${import_picocolors8.default.dim(" owner " + (d.owner_name ?? "?"))}${mark}`);
6288
+ }
6289
+ });
6290
+ })
6291
+ );
6292
+ 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(
6293
+ action(async ({ ctx, args, opts }) => {
6294
+ const projectId = requireProject(ctx);
6295
+ const res = await api(ctx, `/v1/projects/${projectId}/neon-databases`, {
6296
+ body: {
6297
+ name: args[0],
6298
+ ...opts.branch ? { branchId: String(opts.branch) } : {},
6299
+ ...opts.owner ? { ownerName: String(opts.owner) } : {}
6300
+ }
6301
+ });
6302
+ ok(res, () => success(`Created database ${args[0]}.`));
6303
+ })
6304
+ );
6305
+ 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(
6306
+ action(async ({ ctx, args, opts }) => {
6307
+ const projectId = requireProject(ctx);
6308
+ const res = await api(
6309
+ ctx,
6310
+ `/v1/projects/${projectId}/neon-databases/${encodeURIComponent(args[0])}`,
6311
+ {
6312
+ method: "DELETE",
6313
+ query: opts.branch ? { branchId: String(opts.branch) } : void 0
6314
+ }
6315
+ );
6316
+ ok(res, () => success(`Deleted database ${args[0]}.`));
6317
+ })
6318
+ );
6319
+ neon.command("endpoints").description("The compute that runs the database \u2014 what is on, and what it costs idle").action(
6320
+ action(async ({ ctx }) => {
6321
+ const projectId = requireProject(ctx);
6322
+ const items = await api(ctx, `/v1/projects/${projectId}/neon-endpoints`);
6323
+ ok(items, () => {
6324
+ if (!items?.length) {
6325
+ return line(
6326
+ import_picocolors8.default.dim("No compute endpoints \u2014 nothing can connect to this database.")
6327
+ );
6328
+ }
6329
+ for (const e of items) {
6330
+ const state = e.current_state === "active" ? import_picocolors8.default.green("active") : import_picocolors8.default.dim(e.current_state ?? "?");
6331
+ line(
6332
+ `${import_picocolors8.default.bold(e.type ?? "endpoint")} ${state} ${import_picocolors8.default.dim(e.branch_id ?? "")}` + (e.host ? " " + import_picocolors8.default.cyan(e.host) : "")
6333
+ );
6334
+ }
6335
+ });
6336
+ })
6337
+ );
5560
6338
  }
5561
6339
  async function presign(ctx, projectId, bucket, key, operation, expiresInSeconds) {
5562
6340
  const res = await api(
@@ -5577,50 +6355,170 @@ async function presign(ctx, projectId, bucket, key, operation, expiresInSeconds)
5577
6355
  // src/commands/env.ts
5578
6356
  var import_picocolors9 = __toESM(require_picocolors(), 1);
5579
6357
  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)}` : "";
6358
+
6359
+ // src/environments.ts
6360
+ var ENVIRONMENTS = ["development", "preview", "production"];
6361
+ var DEPLOY_ENVIRONMENTS = ["preview", "production"];
6362
+ var ALIASES2 = {
6363
+ dev: "development",
6364
+ development: "development",
6365
+ local: "development",
6366
+ preview: "preview",
6367
+ staging: "preview",
6368
+ stage: "preview",
6369
+ test: "preview",
6370
+ prod: "production",
6371
+ production: "production",
6372
+ live: "production",
6373
+ main: "production"
6374
+ };
6375
+ function parseEnvironment(raw) {
6376
+ if (raw === void 0 || raw === null || raw === "") return { ok: true };
6377
+ const text = String(raw).trim().toLowerCase();
6378
+ const value = ALIASES2[text];
6379
+ if (!value) {
6380
+ return {
6381
+ ok: false,
6382
+ error: `--env takes ${ENVIRONMENTS.join(", ")} (or dev/prod). "${raw}" is not one of them.`
6383
+ };
6384
+ }
6385
+ return { ok: true, value };
6386
+ }
6387
+ function parseDeployEnvironment(raw, verb) {
6388
+ const parsed = parseEnvironment(raw);
6389
+ if (!parsed.ok || !parsed.value) return parsed;
6390
+ if (parsed.value === "development") {
6391
+ return {
6392
+ ok: false,
6393
+ 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.`
6394
+ };
6395
+ }
6396
+ return parsed;
6397
+ }
6398
+ function envTargets(environment) {
6399
+ if (!environment) return [...ENVIRONMENTS];
6400
+ if (environment === "preview") return ["preview", "development"];
6401
+ return [environment];
6402
+ }
6403
+ function targetSummary(environment) {
6404
+ if (!environment) return "in every environment";
6405
+ if (environment === "preview") return "in preview and development";
6406
+ return `in ${environment}`;
6407
+ }
6408
+ function urlRows(apps) {
6409
+ const rows = [];
6410
+ for (const app of apps) {
6411
+ const appId = typeof app?.id === "string" ? app.id : "";
6412
+ if (!appId) continue;
6413
+ const appName = (app.name ?? "").trim() || "Untitled app";
6414
+ for (const environment of DEPLOY_ENVIRONMENTS) {
6415
+ const url = environment === "production" ? app.productionUrl : app.previewUrl;
6416
+ rows.push({
6417
+ appId,
6418
+ appName,
6419
+ environment,
6420
+ url: url && url.trim() ? url.trim() : null,
6421
+ note: url && url.trim() ? null : noteFor(environment)
6422
+ });
6423
+ }
6424
+ }
6425
+ return rows;
6426
+ }
6427
+ function noteFor(environment) {
6428
+ return environment === "production" ? "not live yet \u2014 `workser deploy --env production` publishes it" : "no preview yet \u2014 `workser deploy` builds one";
6429
+ }
6430
+ function urlsSummary(rows) {
6431
+ if (!rows.length) return "This project has no apps yet.";
6432
+ const live = rows.filter((r) => r.environment === "production" && r.url).length;
6433
+ const apps = new Set(rows.map((r) => r.appId)).size;
6434
+ if (!live) {
6435
+ return `${apps} ${apps === 1 ? "app" : "apps"}, none live yet.`;
6436
+ }
6437
+ return `${apps} ${apps === 1 ? "app" : "apps"} \u2014 ${live} live.`;
6438
+ }
6439
+
6440
+ // src/commands/env.ts
6441
+ function appQuery(opts, environment) {
6442
+ const params = new URLSearchParams();
6443
+ if (typeof opts?.app === "string" && opts.app) params.set("webAppId", opts.app);
6444
+ if (environment) params.set("environment", environment);
6445
+ const query = params.toString();
6446
+ return query ? `?${query}` : "";
5583
6447
  }
5584
6448
  var APP_FLAG_HELP = "Which web app to target (defaults to the project's primary app)";
6449
+ var ENV_FLAG_HELP = "Which environment: production, preview or development (default: all three)";
6450
+ function readEnv(opts) {
6451
+ const parsed = parseEnvironment(opts?.env);
6452
+ if (!parsed.ok) throw new WorkserError(parsed.error, { code: "bad_input" });
6453
+ return parsed.value;
6454
+ }
5585
6455
  function registerEnv(program3) {
5586
6456
  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(
6457
+ 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(
5588
6458
  action(async ({ ctx, args, opts }) => {
5589
6459
  const projectId = requireProject(ctx);
6460
+ const environment = readEnv(opts);
5590
6461
  const pairs = args[0].map((p) => {
5591
6462
  const i = p.indexOf("=");
5592
6463
  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) };
6464
+ return {
6465
+ key: p.slice(0, i),
6466
+ value: p.slice(i + 1),
6467
+ // Sent only when asked for. Omitted means all three, which is what
6468
+ // this command has always done — and quietly narrowing that default
6469
+ // would have un-set production for every script the day it shipped.
6470
+ ...environment ? { target: envTargets(environment) } : {}
6471
+ };
5594
6472
  });
5595
6473
  const res = await api(ctx, `/v1/projects/${projectId}/env${appQuery(opts)}`, {
5596
6474
  body: { vars: pairs }
5597
6475
  });
5598
6476
  const count = typeof res?.count === "number" ? res.count : pairs.length;
5599
6477
  ok(res, () => {
5600
- success(`Set ${count} variable(s): ${pairs.map((p) => p.key).join(", ")}`);
6478
+ success(
6479
+ `Set ${count} variable(s) ${targetSummary(environment)}: ` + pairs.map((p) => p.key).join(", ")
6480
+ );
5601
6481
  if (res?.usedDefault && res?.webAppName) {
5602
6482
  line(import_picocolors9.default.dim(`on ${res.webAppName} (primary app) \u2014 use --app to target another`));
5603
6483
  }
5604
6484
  });
5605
6485
  })
5606
6486
  );
5607
- env.command("get <key>").description("Print one variable's value (sensitive)").option("--app <webAppId>", APP_FLAG_HELP).action(
6487
+ env.command("get <key>").description("Print one variable's value (sensitive)").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).action(
5608
6488
  action(async ({ ctx, args, opts }) => {
5609
6489
  const projectId = requireProject(ctx);
6490
+ const environment = readEnv(opts);
5610
6491
  const res = await api(
5611
6492
  ctx,
5612
- `/v1/projects/${projectId}/env/${encodeURIComponent(args[0])}${appQuery(opts)}`
6493
+ `/v1/projects/${projectId}/env/${encodeURIComponent(args[0])}${appQuery(opts, environment)}`
5613
6494
  );
5614
6495
  ok(res, () => line(res.value ?? ""));
5615
6496
  })
5616
6497
  );
5617
- env.command("list").description("List variable keys (values masked)").option("--app <webAppId>", APP_FLAG_HELP).action(
6498
+ env.command("list").description("List variable keys (values masked)").option("--app <webAppId>", APP_FLAG_HELP).option("--env <environment>", ENV_FLAG_HELP).action(
5618
6499
  action(async ({ ctx, opts }) => {
5619
6500
  const projectId = requireProject(ctx);
5620
- const items = await api(ctx, `/v1/projects/${projectId}/env${appQuery(opts)}`);
6501
+ const environment = readEnv(opts);
6502
+ const items = await api(
6503
+ ctx,
6504
+ `/v1/projects/${projectId}/env${appQuery(opts, environment)}`
6505
+ );
5621
6506
  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"))}`);
6507
+ if (!items?.length) {
6508
+ return line(
6509
+ import_picocolors9.default.dim(
6510
+ environment ? `No variables apply in ${environment}.` : "No variables set."
6511
+ )
6512
+ );
6513
+ }
6514
+ for (const v of items) {
6515
+ const differs = !environment && v.overriddenIn?.length ? import_picocolors9.default.yellow(` (different in ${v.overriddenIn.join(", ")})`) : "";
6516
+ line(`${v.key}${import_picocolors9.default.dim(" = " + (v.masked ?? "\u2022\u2022\u2022\u2022"))}${differs}`);
6517
+ }
6518
+ if (environment) {
6519
+ line(import_picocolors9.default.dim(`
6520
+ Showing the values that apply in ${environment}.`));
6521
+ }
5624
6522
  });
5625
6523
  })
5626
6524
  );
@@ -5633,12 +6531,17 @@ function registerEnv(program3) {
5633
6531
  })
5634
6532
  )
5635
6533
  );
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(
6534
+ 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(
6535
+ "--overwrite",
6536
+ "replace values this computer already has (default: fill in only what is missing)",
6537
+ false
6538
+ ).action(
5637
6539
  action(async ({ ctx, opts }) => {
5638
6540
  const projectId = requireProject(ctx);
6541
+ const environment = readEnv(opts);
5639
6542
  const items = await api(
5640
6543
  ctx,
5641
- `/v1/projects/${projectId}/env${appQuery(opts)}`
6544
+ `/v1/projects/${projectId}/env${appQuery(opts, environment)}`
5642
6545
  );
5643
6546
  const outPath = typeof opts?.out === "string" ? opts.out : ".env.local";
5644
6547
  if (!items?.length) {
@@ -5651,38 +6554,66 @@ function registerEnv(program3) {
5651
6554
  for (const item of items) {
5652
6555
  const res = await api(
5653
6556
  ctx,
5654
- `/v1/projects/${projectId}/env/${encodeURIComponent(item.key)}${appQuery(opts)}`
6557
+ `/v1/projects/${projectId}/env/${encodeURIComponent(item.key)}${appQuery(opts, environment)}`
5655
6558
  );
5656
6559
  pulled.push({ key: item.key, value: res?.value ?? "" });
5657
6560
  }
5658
- await mergeEnvFile(outPath, pulled);
6561
+ const result = await mergeEnvFile(outPath, pulled, {
6562
+ overwrite: !!opts.overwrite
6563
+ });
5659
6564
  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
- )
6565
+ {
6566
+ file: outPath,
6567
+ pulled: result.written,
6568
+ skipped: result.skipped,
6569
+ overwrite: !!opts.overwrite
6570
+ },
6571
+ () => {
6572
+ success(
6573
+ `Pulled ${result.written.length} variable(s) into ${import_picocolors9.default.bold(outPath)}.`
6574
+ );
6575
+ if (result.skipped.length) {
6576
+ line(
6577
+ import_picocolors9.default.dim(
6578
+ `Left alone (this computer already has ${result.skipped.length === 1 ? "it" : "them"}): ${result.skipped.join(", ")}`
6579
+ )
6580
+ );
6581
+ line(import_picocolors9.default.dim("Use --overwrite to replace them."));
6582
+ }
6583
+ }
5664
6584
  );
5665
6585
  })
5666
6586
  );
5667
6587
  }
5668
6588
  var ENV_KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=/;
5669
- async function mergeEnvFile(path, vars) {
6589
+ async function mergeEnvFile(path, vars, opts = {}) {
5670
6590
  const existing = await readFile3(path, "utf8").catch(() => "");
5671
6591
  const lines = existing.length ? existing.split(/\r?\n/) : [];
5672
6592
  const remaining = new Map(vars.map((v) => [v.key, v.value]));
6593
+ const skipped = [];
6594
+ const replaced = [];
5673
6595
  const merged = lines.map((rawLine) => {
5674
6596
  const match = ENV_KEY_LINE.exec(rawLine);
5675
6597
  if (!match || !remaining.has(match[1])) return rawLine;
5676
6598
  const key = match[1];
6599
+ if (!opts.overwrite) {
6600
+ remaining.delete(key);
6601
+ skipped.push(key);
6602
+ return rawLine;
6603
+ }
5677
6604
  const value = remaining.get(key);
5678
6605
  remaining.delete(key);
6606
+ replaced.push(key);
5679
6607
  return `${key}=${formatEnvValue(value)}`;
5680
6608
  });
5681
6609
  while (merged.length && merged[merged.length - 1] === "") merged.pop();
6610
+ const written = [...replaced];
5682
6611
  for (const [key, value] of remaining) {
5683
6612
  merged.push(`${key}=${formatEnvValue(value)}`);
6613
+ written.push(key);
5684
6614
  }
5685
6615
  await writeFile3(path, merged.join("\n") + "\n", "utf8");
6616
+ return { written, skipped };
5686
6617
  }
5687
6618
  function formatEnvValue(value) {
5688
6619
  return /[\s"'#]/.test(value) ? JSON.stringify(value) : value;
@@ -5692,16 +6623,30 @@ function formatEnvValue(value) {
5692
6623
  var import_picocolors10 = __toESM(require_picocolors(), 1);
5693
6624
  var TERMINAL = /* @__PURE__ */ new Set(["ready", "live", "success", "error", "failed", "canceled"]);
5694
6625
  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(
6626
+ 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(
6627
+ "--env <environment>",
6628
+ "which environment to deploy: preview (default) or production"
6629
+ ).option("--watch", "wait for the deploy to finish, streaming status", false).option(
5696
6630
  "--app <webAppId>",
5697
6631
  "which app to publish (default: the app this folder is linked to)"
5698
6632
  ).action(
5699
6633
  action(async ({ ctx, opts }) => {
5700
6634
  requireLocalApp(ctx, "deploy");
5701
6635
  const projectId = requireProject(ctx);
6636
+ const parsedEnv = parseDeployEnvironment(opts.env, "deploy");
6637
+ if (!parsedEnv.ok) {
6638
+ throw new WorkserError(parsedEnv.error, { code: "bad_input" });
6639
+ }
6640
+ if (opts.prod && parsedEnv.value === "preview") {
6641
+ throw new WorkserError(
6642
+ "`--prod` and `--env preview` ask for different things. Pass one.",
6643
+ { code: "bad_input" }
6644
+ );
6645
+ }
6646
+ const prod = Boolean(opts.prod) || parsedEnv.value === "production";
5702
6647
  const dep = await api(ctx, `/v1/projects/${projectId}/deploy`, {
5703
6648
  body: {
5704
- prod: Boolean(opts.prod),
6649
+ prod,
5705
6650
  cwd: ctx.cwd,
5706
6651
  ...opts.app ? { webAppId: opts.app } : {}
5707
6652
  }
@@ -5743,10 +6688,22 @@ function printDeploy(dep) {
5743
6688
  // src/commands/versions.ts
5744
6689
  var import_picocolors11 = __toESM(require_picocolors(), 1);
5745
6690
  function registerVersions(program3) {
5746
- program3.command("versions").description("List the Workser-managed versions of the project (deploy history)").action(
5747
- action(async ({ ctx }) => {
6691
+ 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(
6692
+ "--env <environment>",
6693
+ "which environment `deployed` should mean: preview or production"
6694
+ ).action(
6695
+ action(async ({ ctx, opts }) => {
5748
6696
  const projectId = requireProject(ctx);
5749
- const items = await api(ctx, `/v1/projects/${projectId}/versions`);
6697
+ const parsed = parseDeployEnvironment(opts.env, "versions");
6698
+ if (!parsed.ok) {
6699
+ throw new WorkserError(parsed.error, { code: "bad_input" });
6700
+ }
6701
+ const items = await api(ctx, `/v1/projects/${projectId}/versions`, {
6702
+ query: {
6703
+ ...opts.app ? { webAppId: String(opts.app) } : {},
6704
+ ...parsed.value ? { environment: parsed.value } : {}
6705
+ }
6706
+ });
5750
6707
  ok(items, () => {
5751
6708
  if (!items?.length) return line(import_picocolors11.default.dim("No versions yet. `workser deploy` to create one."));
5752
6709
  for (const v of items) {
@@ -5760,7 +6717,7 @@ function formatVersion(v) {
5760
6717
  const ref = import_picocolors11.default.yellow(shortRef(v.ref));
5761
6718
  const when = import_picocolors11.default.dim(formatTime(v.createdAt));
5762
6719
  const msg = (v.message ?? "").trim() || import_picocolors11.default.dim("(no message)");
5763
- const badge = v.deployed ? " " + import_picocolors11.default.green("deployed") : "";
6720
+ const badge = v.deployed ? " " + import_picocolors11.default.green(v.deployedEnvironment ? `live in ${v.deployedEnvironment}` : "deployed") : "";
5764
6721
  const url = v.url ? " " + import_picocolors11.default.cyan(v.url) : "";
5765
6722
  return `${ref} ${when} ${msg}${badge}${url}`;
5766
6723
  }
@@ -5775,19 +6732,30 @@ function formatTime(t) {
5775
6732
  }
5776
6733
 
5777
6734
  // src/commands/logs.ts
6735
+ var import_picocolors12 = __toESM(require_picocolors(), 1);
5778
6736
  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(
6737
+ 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
6738
  action(async ({ ctx, opts }) => {
5781
6739
  const projectId = requireProject(ctx);
6740
+ const parsed = parseDeployEnvironment(opts.env, "logs");
6741
+ if (!parsed.ok) {
6742
+ throw new WorkserError(parsed.error, { code: "bad_input" });
6743
+ }
6744
+ const scope = {
6745
+ ...opts.app ? { webAppId: String(opts.app) } : {},
6746
+ ...parsed.value ? { environment: parsed.value } : {}
6747
+ };
5782
6748
  const first = await api(ctx, `/v1/projects/${projectId}/logs`, {
5783
- query: { lines: opts.lines }
6749
+ query: { lines: opts.lines, ...scope }
5784
6750
  });
5785
- ok(first, () => (first.entries ?? []).forEach((e) => line(formatLog(e))));
6751
+ ok(first, () => print(first));
5786
6752
  if (opts.follow && !isJson()) {
5787
6753
  let cursor = first.cursor;
5788
6754
  for (; ; ) {
5789
6755
  await sleep(2e3);
5790
- const next = await api(ctx, `/v1/projects/${projectId}/logs`, { query: { after: cursor } });
6756
+ const next = await api(ctx, `/v1/projects/${projectId}/logs`, {
6757
+ query: { after: cursor, ...scope }
6758
+ });
5791
6759
  (next.entries ?? []).forEach((e) => line(formatLog(e)));
5792
6760
  if (next.cursor) cursor = next.cursor;
5793
6761
  }
@@ -5795,31 +6763,112 @@ function registerLogs(program3) {
5795
6763
  })
5796
6764
  );
5797
6765
  }
6766
+ function print(res) {
6767
+ const entries = res?.entries ?? [];
6768
+ for (const e of entries) line(formatLog(e));
6769
+ if (!entries.length && res?.note) line(import_picocolors12.default.dim(res.note));
6770
+ if (entries.length && res?.deploymentId) {
6771
+ const where = res.environment ? ` (${res.environment})` : "";
6772
+ line(import_picocolors12.default.dim(`\u2014 build ${res.deploymentId}${where}`));
6773
+ }
6774
+ }
5798
6775
  function formatLog(e) {
5799
6776
  if (typeof e === "string") return e;
5800
6777
  return `${e.ts ?? ""} ${e.level ? `[${e.level}] ` : ""}${e.message ?? ""}`.trim();
5801
6778
  }
5802
6779
 
5803
6780
  // src/commands/domain.ts
5804
- var import_picocolors12 = __toESM(require_picocolors(), 1);
6781
+ var import_picocolors13 = __toESM(require_picocolors(), 1);
6782
+
6783
+ // src/reserved-domains.ts
6784
+ var RESERVED_APEX = [
6785
+ "workser.ai",
6786
+ "workser.app",
6787
+ "workser.dev"
6788
+ ];
6789
+ var PROVIDER_HOSTS = ["vercel.app", "neon.tech", "expo.dev"];
6790
+ function normaliseDomain(input) {
6791
+ return input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "").replace(/\.$/, "");
6792
+ }
6793
+ function isUnder(host, suffix) {
6794
+ return host === suffix || host.endsWith(`.${suffix}`);
6795
+ }
6796
+ function checkDomain(input) {
6797
+ const host = normaliseDomain(input);
6798
+ if (!host) {
6799
+ return { allowed: false, reason: "No domain given." };
6800
+ }
6801
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(host)) {
6802
+ return {
6803
+ allowed: false,
6804
+ reason: `"${input}" is not a valid domain name.`
6805
+ };
6806
+ }
6807
+ for (const apex of RESERVED_APEX) {
6808
+ if (isUnder(host, apex)) {
6809
+ return {
6810
+ allowed: false,
6811
+ 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.`
6812
+ };
6813
+ }
6814
+ }
6815
+ for (const provider of PROVIDER_HOSTS) {
6816
+ if (isUnder(host, provider)) {
6817
+ return {
6818
+ allowed: false,
6819
+ 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.`
6820
+ };
6821
+ }
6822
+ }
6823
+ return { allowed: true };
6824
+ }
6825
+ function assertDomainAllowed(input) {
6826
+ const verdict = checkDomain(input);
6827
+ if (!verdict.allowed) throw new Error(verdict.reason);
6828
+ return normaliseDomain(input);
6829
+ }
6830
+
6831
+ // src/commands/domain.ts
5805
6832
  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
- )
6833
+ const domain = program3.command("domain").description("Manage the project's custom domains");
6834
+ 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(
6835
+ action(async ({ ctx, args, opts }) => {
6836
+ const projectId = requireProject(ctx);
6837
+ const host = assertDomainAllowed(String(args[0]));
6838
+ const result = await api(
6839
+ ctx,
6840
+ opts.app ? `/v1/projects/${projectId}/web-apps/${opts.app}/domains` : `/v1/projects/${projectId}/domains`,
6841
+ { method: "POST", body: { domain: host } }
6842
+ );
6843
+ ok(result, () => {
6844
+ line(`${import_picocolors13.default.green("Attached")} ${host}`);
6845
+ line(
6846
+ import_picocolors13.default.dim(
6847
+ "It goes live once DNS points at us. Run `workser domain list` to see its status."
6848
+ )
6849
+ );
6850
+ });
6851
+ })
6852
+ );
6853
+ domain.command("rm <domain>").description("Detach a custom domain (asks the owner to confirm)").option("--app <webAppId>", "Which app it is attached to").action(
6854
+ action(async ({ ctx, args, opts }) => {
6855
+ const projectId = requireProject(ctx);
6856
+ const host = String(args[0]).trim().toLowerCase();
6857
+ const result = await api(
6858
+ ctx,
6859
+ opts.app ? `/v1/projects/${projectId}/web-apps/${opts.app}/domains/${encodeURIComponent(host)}` : `/v1/projects/${projectId}/domains/${encodeURIComponent(host)}`,
6860
+ { method: "DELETE" }
6861
+ );
6862
+ ok(result, () => line(`${import_picocolors13.default.yellow("Detached")} ${host}`));
6863
+ })
5815
6864
  );
5816
6865
  domain.command("list").description("List domains attached to the project").action(
5817
6866
  action(async ({ ctx }) => {
5818
6867
  const projectId = requireProject(ctx);
5819
6868
  const items = await api(ctx, `/v1/projects/${projectId}/domains`);
5820
6869
  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 ?? ""))}`);
6870
+ if (!items?.length) return line(import_picocolors13.default.dim("No custom domains."));
6871
+ for (const d of items) line(`${d.domain}${import_picocolors13.default.dim(" " + (d.status ?? ""))}`);
5823
6872
  });
5824
6873
  })
5825
6874
  );
@@ -5851,7 +6900,7 @@ function openUrl(url) {
5851
6900
  }
5852
6901
 
5853
6902
  // src/commands/doctor.ts
5854
- var import_picocolors13 = __toESM(require_picocolors(), 1);
6903
+ var import_picocolors14 = __toESM(require_picocolors(), 1);
5855
6904
  import { spawnSync } from "child_process";
5856
6905
  function registerDoctor(program3) {
5857
6906
  program3.command("doctor").description("Print the resolved endpoint, mode, token presence (masked), and current project").action(
@@ -5863,14 +6912,14 @@ function registerDoctor(program3) {
5863
6912
  const tokenSource = opts.token ? "--token" : process.env.WORKSER_TOKEN ? "$WORKSER_TOKEN" : session.token ? "session" : void 0;
5864
6913
  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
6914
  const projectSource = opts.project ? "--project" : link?.projectId ? ".workser link" : session.defaultProjectId ? "session" : void 0;
5866
- const git = gitVersion();
6915
+ const git2 = gitVersion();
5867
6916
  const report = {
5868
6917
  endpoint: ctx.endpoint,
5869
6918
  endpointSource,
5870
6919
  env,
5871
6920
  envIgnored,
5872
6921
  mode: ctx.mode,
5873
- git: { present: git !== null, version: git },
6922
+ git: { present: git2 !== null, version: git2 },
5874
6923
  token: {
5875
6924
  present: Boolean(ctx.token),
5876
6925
  masked: ctx.token ? maskToken(ctx.token) : null,
@@ -5885,29 +6934,29 @@ function registerDoctor(program3) {
5885
6934
  workspace: session.workspaceName ?? null
5886
6935
  };
5887
6936
  ok(report, () => {
5888
- line(import_picocolors13.default.bold("workser doctor"));
5889
- line(` endpoint: ${ctx.endpoint} ${import_picocolors13.default.dim(`(${endpointSource})`)}`);
6937
+ line(import_picocolors14.default.bold("workser doctor"));
6938
+ line(` endpoint: ${ctx.endpoint} ${import_picocolors14.default.dim(`(${endpointSource})`)}`);
5890
6939
  line(
5891
- ` env: ${env === "prod" ? import_picocolors13.default.yellow(env) : env}` + import_picocolors13.default.dim(process.env.WORKSER_ENV ? " ($WORKSER_ENV)" : " (default)")
6940
+ ` env: ${env === "prod" ? import_picocolors14.default.yellow(env) : env}` + import_picocolors14.default.dim(process.env.WORKSER_ENV ? " ($WORKSER_ENV)" : " (default)")
5892
6941
  );
5893
6942
  line(` mode: ${ctx.mode}`);
5894
6943
  line(
5895
- ` token: ${ctx.token ? `${maskToken(ctx.token)} ${import_picocolors13.default.dim(`(${tokenSource})`)}` : import_picocolors13.default.yellow("none \u2014 run `workser login`")}`
6944
+ ` token: ${ctx.token ? `${maskToken(ctx.token)} ${import_picocolors14.default.dim(`(${tokenSource})`)}` : import_picocolors14.default.yellow("none \u2014 run `workser login`")}`
5896
6945
  );
5897
6946
  line(
5898
- ` project: ${ctx.projectId ?? import_picocolors13.default.dim("none")}` + (link?.name ? ` ${import_picocolors13.default.dim(`(${link.name})`)}` : "") + (projectSource ? import_picocolors13.default.dim(` [${projectSource}]`) : "")
6947
+ ` project: ${ctx.projectId ?? import_picocolors14.default.dim("none")}` + (link?.name ? ` ${import_picocolors14.default.dim(`(${link.name})`)}` : "") + (projectSource ? import_picocolors14.default.dim(` [${projectSource}]`) : "")
5899
6948
  );
5900
6949
  line(` cwd: ${ctx.cwd}`);
5901
- line(` git: ${git ?? import_picocolors13.default.dim("not on this shell's PATH")}`);
5902
- if (!git) {
6950
+ line(` git: ${git2 ?? import_picocolors14.default.dim("not on this shell's PATH")}`);
6951
+ if (!git2) {
5903
6952
  line("");
5904
6953
  line(
5905
- import_picocolors13.default.dim(
6954
+ import_picocolors14.default.dim(
5906
6955
  " Workser brings its own git, so syncing and publishing still work."
5907
6956
  )
5908
6957
  );
5909
6958
  line(
5910
- import_picocolors13.default.dim(
6959
+ import_picocolors14.default.dim(
5911
6960
  ` Only needed if you want to run git yourself here. ${GIT_INSTALL_HINT}`
5912
6961
  )
5913
6962
  );
@@ -5915,16 +6964,16 @@ function registerDoctor(program3) {
5915
6964
  if (envIgnored) {
5916
6965
  line("");
5917
6966
  line(
5918
- import_picocolors13.default.yellow(
6967
+ import_picocolors14.default.yellow(
5919
6968
  ` WORKSER_ENV=${env} is not in effect \u2014 ${endpointSource} wins.`
5920
6969
  )
5921
6970
  );
5922
6971
  line(
5923
- import_picocolors13.default.dim(
6972
+ import_picocolors14.default.dim(
5924
6973
  ` Re-run \`workser login\` to switch (the saved token is tied to ${ctx.endpoint}),`
5925
6974
  )
5926
6975
  );
5927
- line(import_picocolors13.default.dim(` or pass --endpoint ${ENV_BASE_URLS[env]}.`));
6976
+ line(import_picocolors14.default.dim(` or pass --endpoint ${ENV_BASE_URLS[env]}.`));
5928
6977
  }
5929
6978
  });
5930
6979
  })
@@ -5954,7 +7003,7 @@ function maskToken(token) {
5954
7003
  }
5955
7004
 
5956
7005
  // src/commands/agent.ts
5957
- var import_picocolors14 = __toESM(require_picocolors(), 1);
7006
+ var import_picocolors15 = __toESM(require_picocolors(), 1);
5958
7007
  var SPAWNABLE_AGENTS = ["claude_code", "codex", "kimi", "opencode", "grok"];
5959
7008
  function registerAgent(program3) {
5960
7009
  const agent = program3.command("agent").description("Delegate focused subtasks to your configured agent roles (each runs isolated)");
@@ -5962,20 +7011,20 @@ function registerAgent(program3) {
5962
7011
  action(async ({ ctx }) => {
5963
7012
  const cfg = await api(ctx, "/v1/agents");
5964
7013
  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")));
7014
+ line(import_picocolors15.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors15.default.dim("none")));
7015
+ line(import_picocolors15.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors15.default.dim("none")));
5967
7016
  if (cfg?.effectiveMainAgent && cfg.effectiveMainAgent !== cfg.mainAgent) {
5968
7017
  line(
5969
- import_picocolors14.default.yellow(
7018
+ import_picocolors15.default.yellow(
5970
7019
  ` \u2937 failover active: runs use ${cfg.effectiveMainAgent} (main not available)`
5971
7020
  )
5972
7021
  );
5973
7022
  }
5974
7023
  const roles = cfg?.roles ?? [];
5975
7024
  if (!roles.length) {
5976
- line(import_picocolors14.default.dim("No subagents configured. Add them in the Workser Orbit Agents screen."));
7025
+ line(import_picocolors15.default.dim("No subagents configured. Add them in the Workser Orbit Agents screen."));
5977
7026
  } else {
5978
- line(import_picocolors14.default.bold("subagents:"));
7027
+ line(import_picocolors15.default.bold("subagents:"));
5979
7028
  for (const r of roles) line(" " + formatRole(r));
5980
7029
  }
5981
7030
  const detected = cfg?.detected ?? [];
@@ -5983,7 +7032,7 @@ function registerAgent(program3) {
5983
7032
  (d) => d?.installed && d?.authed !== false
5984
7033
  );
5985
7034
  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"))
7035
+ 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
7036
  );
5988
7037
  });
5989
7038
  })
@@ -5997,8 +7046,8 @@ function registerAgent(program3) {
5997
7046
  backupAgent: cfg?.backupAgent ?? null
5998
7047
  },
5999
7048
  () => {
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")));
7049
+ line(import_picocolors15.default.bold("main agent:") + " " + (cfg?.mainAgent ?? import_picocolors15.default.dim("none")));
7050
+ line(import_picocolors15.default.bold("backup agent:") + " " + (cfg?.backupAgent ?? import_picocolors15.default.dim("none")));
6002
7051
  }
6003
7052
  );
6004
7053
  })
@@ -6023,7 +7072,10 @@ function registerAgent(program3) {
6023
7072
  ).option("--role <label>", "display label for this run (default: <agent>)").option(
6024
7073
  "--instructions <text>",
6025
7074
  "system prompt for this one run \u2014 this teammate's expertise/scope"
6026
- ).option("--model <model>", "model override for the backing CLI").option("--effort <level>", "reasoning effort, where the backing CLI supports it").action(
7075
+ ).option("--model <model>", "model override for the backing CLI").option("--effort <level>", "reasoning effort, where the backing CLI supports it").option(
7076
+ "--read-only",
7077
+ "inspection only \u2014 the teammate runs in the CLI's read-only sandbox and cannot change anything"
7078
+ ).action(
6027
7079
  action(async ({ ctx, args, opts }) => {
6028
7080
  const spawnAgent = args[0];
6029
7081
  const task = args[1].join(" ");
@@ -6034,7 +7086,10 @@ function registerAgent(program3) {
6034
7086
  role: opts.role,
6035
7087
  instructions: opts.instructions,
6036
7088
  model: opts.model,
6037
- effort: opts.effort
7089
+ effort: opts.effort,
7090
+ // Enforced by the backing CLI's own sandbox, not by asking the
7091
+ // spawned agent to behave — see the daemon's `/agents/run`.
7092
+ readOnly: opts.readOnly === true
6038
7093
  }
6039
7094
  });
6040
7095
  const exitCode = res?.exitCode ?? 0;
@@ -6052,21 +7107,21 @@ function toContractId(id) {
6052
7107
  return id === "claude" ? "claude_code" : id;
6053
7108
  }
6054
7109
  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");
7110
+ const label = import_picocolors15.default.yellow(r.role);
7111
+ const agent = import_picocolors15.default.dim("\xB7 " + (r.agent ?? "?"));
7112
+ const enabled = r.enabled === false ? import_picocolors15.default.red("disabled") : import_picocolors15.default.green("enabled");
6058
7113
  const runnable = r.installed && r.authed !== false;
6059
- const ready = runnable ? import_picocolors14.default.green("runnable") : import_picocolors14.default.dim("not runnable");
7114
+ const ready = runnable ? import_picocolors15.default.green("runnable") : import_picocolors15.default.dim("not runnable");
6060
7115
  const extras = [];
6061
7116
  if (r.model) extras.push(`model ${r.model}`);
6062
7117
  if (Array.isArray(r.apps) && r.apps.length) extras.push(`apps: ${r.apps.join(",")}`);
6063
7118
  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 ")) : "";
7119
+ const tail = extras.length ? " " + import_picocolors15.default.dim(extras.join(" \xB7 ")) : "";
6065
7120
  return `${label} ${agent} ${enabled} ${ready}${tail}`;
6066
7121
  }
6067
7122
 
6068
7123
  // src/commands/verify.ts
6069
- var import_picocolors15 = __toESM(require_picocolors(), 1);
7124
+ var import_picocolors16 = __toESM(require_picocolors(), 1);
6070
7125
  function registerVerify(program3) {
6071
7126
  program3.command("verify").description(
6072
7127
  "Run the project's checks (typecheck/lint/build) \u2014 use before declaring a task done"
@@ -6088,23 +7143,23 @@ function registerVerify(program3) {
6088
7143
  function printVerify(res) {
6089
7144
  if (!res) return;
6090
7145
  if (!res.checks?.length) {
6091
- line(import_picocolors15.default.dim(res.note ?? "No checks detected."));
7146
+ line(import_picocolors16.default.dim(res.note ?? "No checks detected."));
6092
7147
  return;
6093
7148
  }
6094
7149
  for (const c of res.checks) {
6095
7150
  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})`)}`
7151
+ ` ${c.ok ? import_picocolors16.default.green("\u2713") : import_picocolors16.default.red("\u2717")} ${c.name}${c.ok ? "" : import_picocolors16.default.dim(` (exit ${c.exitCode})`)}`
6097
7152
  );
6098
7153
  }
6099
7154
  if (res.ok) success("All checks passed");
6100
7155
  else
6101
7156
  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(".")
7157
+ 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
7158
  );
6104
7159
  }
6105
7160
 
6106
7161
  // src/commands/checkpoint.ts
6107
- var import_picocolors16 = __toESM(require_picocolors(), 1);
7162
+ var import_picocolors17 = __toESM(require_picocolors(), 1);
6108
7163
  function registerCheckpoint(program3) {
6109
7164
  program3.command("checkpoint [label]").description(
6110
7165
  "Save the current state of this folder so you can come back to it"
@@ -6121,8 +7176,8 @@ function registerCheckpoint(program3) {
6121
7176
  ok(res, () => {
6122
7177
  const p = res?.point;
6123
7178
  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`."));
7179
+ if (p?.ref) line(import_picocolors17.default.dim(` ${p.ref.slice(0, 7)}`));
7180
+ line(import_picocolors17.default.dim(" Come back to it with `workser restore`."));
6126
7181
  });
6127
7182
  })
6128
7183
  );
@@ -6148,12 +7203,12 @@ function registerCheckpoint(program3) {
6148
7203
  );
6149
7204
  if (res?.filesChanged) {
6150
7205
  line(
6151
- import_picocolors16.default.dim(
7206
+ import_picocolors17.default.dim(
6152
7207
  ` ${res.filesChanged} file${res.filesChanged === 1 ? "" : "s"} changed`
6153
7208
  )
6154
7209
  );
6155
7210
  }
6156
- line(import_picocolors16.default.dim(" This is reversible: `workser restore` again."));
7211
+ line(import_picocolors17.default.dim(" This is reversible: `workser restore` again."));
6157
7212
  });
6158
7213
  })
6159
7214
  );
@@ -6170,25 +7225,25 @@ function registerCheckpoint(program3) {
6170
7225
  function printPoints(points) {
6171
7226
  if (!points.length) {
6172
7227
  info("No checkpoints yet for this folder.");
6173
- line(import_picocolors16.default.dim(" Take one with `workser checkpoint`."));
7228
+ line(import_picocolors17.default.dim(" Take one with `workser checkpoint`."));
6174
7229
  return;
6175
7230
  }
6176
- line(import_picocolors16.default.bold("Checkpoints"));
7231
+ line(import_picocolors17.default.bold("Checkpoints"));
6177
7232
  for (const p of points) {
6178
7233
  const when = p.at ? new Date(p.at).toLocaleString() : "";
6179
7234
  line(
6180
- ` ${import_picocolors16.default.dim(p.ref.slice(0, 7))} ${p.label}${when ? import_picocolors16.default.dim(` ${when}`) : ""}`
7235
+ ` ${import_picocolors17.default.dim(p.ref.slice(0, 7))} ${p.label}${when ? import_picocolors17.default.dim(` ${when}`) : ""}`
6181
7236
  );
6182
7237
  }
6183
7238
  line(
6184
- import_picocolors16.default.dim(
7239
+ import_picocolors17.default.dim(
6185
7240
  "\nGo back with `workser restore <ref>`, or just `workser restore` for the newest."
6186
7241
  )
6187
7242
  );
6188
7243
  }
6189
7244
 
6190
7245
  // src/commands/sync.ts
6191
- var import_picocolors17 = __toESM(require_picocolors(), 1);
7246
+ var import_picocolors18 = __toESM(require_picocolors(), 1);
6192
7247
  function registerSync(program3) {
6193
7248
  program3.command("sync").description(
6194
7249
  "Reconcile this folder with the copy Workser holds (pull, then push)"
@@ -6215,7 +7270,7 @@ function registerSync(program3) {
6215
7270
  warn(res?.message ?? "Couldn't sync this folder.");
6216
7271
  if (res?.state === "diverged") {
6217
7272
  line(
6218
- import_picocolors17.default.dim(
7273
+ import_picocolors18.default.dim(
6219
7274
  " This folder and Workser's copy have both changed. Open Workser to resolve it."
6220
7275
  )
6221
7276
  );
@@ -6227,7 +7282,7 @@ function registerSync(program3) {
6227
7282
  return;
6228
7283
  }
6229
7284
  success("Synced");
6230
- if (res?.ref) line(import_picocolors17.default.dim(` ${String(res.ref).slice(0, 7)}`));
7285
+ if (res?.ref) line(import_picocolors18.default.dim(` ${String(res.ref).slice(0, 7)}`));
6231
7286
  });
6232
7287
  if (refused) process.exitCode = 1;
6233
7288
  })
@@ -6235,7 +7290,7 @@ function registerSync(program3) {
6235
7290
  }
6236
7291
 
6237
7292
  // src/commands/workflow.ts
6238
- var import_picocolors18 = __toESM(require_picocolors(), 1);
7293
+ var import_picocolors19 = __toESM(require_picocolors(), 1);
6239
7294
  function registerWorkflow(program3) {
6240
7295
  const wf = program3.command("workflow").description("Create, run, and inspect workflow automations for the project");
6241
7296
  wf.command("list").description("List the project's workflows").action(
@@ -6243,10 +7298,10 @@ function registerWorkflow(program3) {
6243
7298
  const projectId = requireProject(ctx);
6244
7299
  const items = await api(ctx, `/v1/projects/${projectId}/workflows`);
6245
7300
  ok(items, () => {
6246
- if (!items?.length) return line(import_picocolors18.default.dim("No workflows yet. `workser workflow create`."));
7301
+ if (!items?.length) return line(import_picocolors19.default.dim("No workflows yet. `workser workflow create`."));
6247
7302
  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}`);
7303
+ const status = w.is_active ? import_picocolors19.default.green("active") : import_picocolors19.default.dim("inactive");
7304
+ line(`${w.id} ${import_picocolors19.default.bold(w.name ?? "Untitled")} ${status}`);
6250
7305
  }
6251
7306
  });
6252
7307
  })
@@ -6258,7 +7313,7 @@ function registerWorkflow(program3) {
6258
7313
  const res = await api(ctx, `/v1/projects/${projectId}/workflows`, {
6259
7314
  body: { name: args[0], ...extra }
6260
7315
  });
6261
- ok(res, () => line(`Created workflow ${import_picocolors18.default.bold(res.id)}.`));
7316
+ ok(res, () => line(`Created workflow ${import_picocolors19.default.bold(res.id)}.`));
6262
7317
  })
6263
7318
  );
6264
7319
  wf.command("get <id>").description("Show a workflow's full definition").action(
@@ -6293,8 +7348,8 @@ function registerWorkflow(program3) {
6293
7348
  action(async ({ ctx, args }) => {
6294
7349
  const items = await api(ctx, `/v1/workflows/${args[0]}/executions`);
6295
7350
  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 ?? "")}`);
7351
+ if (!items?.length) return line(import_picocolors19.default.dim("No runs yet."));
7352
+ for (const e of items) line(`${e.id} ${e.status ?? ""} ${import_picocolors19.default.dim(e.started_at ?? "")}`);
6298
7353
  });
6299
7354
  })
6300
7355
  );
@@ -6302,15 +7357,15 @@ function registerWorkflow(program3) {
6302
7357
  action(async ({ ctx, args }) => {
6303
7358
  const items = await api(ctx, `/v1/node-types`, { query: { q: args[0] } });
6304
7359
  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 ?? "")}`);
7360
+ if (!items?.length) return line(import_picocolors19.default.dim("No matching node types."));
7361
+ for (const n of items) line(`${n.name ?? n.type} ${import_picocolors19.default.dim(n.category ?? "")}`);
6307
7362
  });
6308
7363
  })
6309
7364
  );
6310
7365
  }
6311
7366
 
6312
7367
  // src/commands/app.ts
6313
- var import_picocolors19 = __toESM(require_picocolors(), 1);
7368
+ var import_picocolors20 = __toESM(require_picocolors(), 1);
6314
7369
  function registerApp(program3) {
6315
7370
  const appCmd = program3.command("app").description("Connect and use third-party app integrations (Gmail, Slack, Stripe, ...)");
6316
7371
  appCmd.command("list").description("List connectable toolkits and this project's existing connections").option("--toolkit <slug>", "filter connections to one toolkit").action(
@@ -6323,8 +7378,8 @@ function registerApp(program3) {
6323
7378
  ok({ catalog, connections }, () => {
6324
7379
  const connected = new Set((connections ?? []).map((c) => c.toolkit ?? c.composio_app));
6325
7380
  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}`);
7381
+ const status = connected.has(t.slug) ? import_picocolors20.default.green("connected") : import_picocolors20.default.dim("not connected");
7382
+ line(`${t.slug} ${import_picocolors20.default.bold(t.name ?? t.slug)} ${status}`);
6328
7383
  }
6329
7384
  });
6330
7385
  })
@@ -6341,7 +7396,7 @@ function registerApp(program3) {
6341
7396
  });
6342
7397
  ok(
6343
7398
  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}.`)
7399
+ () => 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
7400
  );
6346
7401
  })
6347
7402
  );
@@ -6359,8 +7414,8 @@ function registerApp(program3) {
6359
7414
  const projectId = requireProject(ctx);
6360
7415
  const items = await api(ctx, `/v1/projects/${projectId}/integrations/${args[0]}/tools`);
6361
7416
  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 ?? "")}`);
7417
+ if (!items?.length) return line(import_picocolors20.default.dim("No tools found."));
7418
+ for (const t of items) line(`${t.slug} ${import_picocolors20.default.dim(t.description ?? "")}`);
6364
7419
  });
6365
7420
  })
6366
7421
  );
@@ -6376,7 +7431,7 @@ function registerApp(program3) {
6376
7431
  }
6377
7432
 
6378
7433
  // src/commands/tool.ts
6379
- var import_picocolors20 = __toESM(require_picocolors(), 1);
7434
+ var import_picocolors21 = __toESM(require_picocolors(), 1);
6380
7435
  function registerTool(program3) {
6381
7436
  const tool = program3.command("tool").description(
6382
7437
  "Computer-use tools: filesystem, shell, screenshot, input control, clipboard, notifications, basic browser"
@@ -6385,7 +7440,7 @@ function registerTool(program3) {
6385
7440
  action(async ({ ctx }) => {
6386
7441
  const tools = await api(ctx, "/v1/tool/list");
6387
7442
  ok(tools, () => {
6388
- if (!tools?.length) return line(import_picocolors20.default.dim("No tools available."));
7443
+ if (!tools?.length) return line(import_picocolors21.default.dim("No tools available."));
6389
7444
  const byCategory = /* @__PURE__ */ new Map();
6390
7445
  for (const t of tools) {
6391
7446
  const list = byCategory.get(t.category) ?? [];
@@ -6393,9 +7448,9 @@ function registerTool(program3) {
6393
7448
  byCategory.set(t.category, list);
6394
7449
  }
6395
7450
  for (const [category, items] of byCategory) {
6396
- line(import_picocolors20.default.bold(category) + ":");
7451
+ line(import_picocolors21.default.bold(category) + ":");
6397
7452
  for (const t of items) {
6398
- line(` ${t.name} ${import_picocolors20.default.dim(t.description ?? "")}`);
7453
+ line(` ${t.name} ${import_picocolors21.default.dim(t.description ?? "")}`);
6399
7454
  }
6400
7455
  }
6401
7456
  });
@@ -6413,7 +7468,7 @@ function registerTool(program3) {
6413
7468
  }
6414
7469
 
6415
7470
  // src/commands/memory.ts
6416
- var import_picocolors21 = __toESM(require_picocolors(), 1);
7471
+ var import_picocolors22 = __toESM(require_picocolors(), 1);
6417
7472
  function registerMemory(program3) {
6418
7473
  const memory = program3.command("memory").description("Durable, cross-conversation project memory (shared with cloud agents on the same project)");
6419
7474
  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 +7492,9 @@ function registerMemory(program3) {
6437
7492
  });
6438
7493
  ok(res, () => {
6439
7494
  const results = res?.results ?? res ?? [];
6440
- if (!results?.length) return line(import_picocolors21.default.dim("No matching memories."));
7495
+ if (!results?.length) return line(import_picocolors22.default.dim("No matching memories."));
6441
7496
  for (const r of results) {
6442
- line(`${import_picocolors21.default.dim(r.id ?? "?")} ${r.memory ?? r.content ?? ""}`);
7497
+ line(`${import_picocolors22.default.dim(r.id ?? "?")} ${r.memory ?? r.content ?? ""}`);
6443
7498
  }
6444
7499
  });
6445
7500
  })
@@ -6456,7 +7511,7 @@ function registerMemory(program3) {
6456
7511
  }
6457
7512
 
6458
7513
  // src/commands/business.ts
6459
- var import_picocolors22 = __toESM(require_picocolors(), 1);
7514
+ var import_picocolors23 = __toESM(require_picocolors(), 1);
6460
7515
  var RESOURCE_PATHS = {
6461
7516
  "business-config": "business-config",
6462
7517
  "business-settings": "business-settings",
@@ -6516,7 +7571,7 @@ function registerBusiness(program3) {
6516
7571
  const projectId = requireProject(ctx);
6517
7572
  const [resource] = args;
6518
7573
  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 ?? "")}.`));
7574
+ ok(res, () => line(`Created ${resource} ${import_picocolors23.default.bold(res?.id ?? "")}.`));
6520
7575
  })
6521
7576
  );
6522
7577
  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,9 +7613,9 @@ function businessPath(projectId, resource, subpath) {
6558
7613
  }
6559
7614
 
6560
7615
  // src/commands/artifact.ts
6561
- var import_picocolors23 = __toESM(require_picocolors(), 1);
7616
+ var import_picocolors24 = __toESM(require_picocolors(), 1);
6562
7617
  import { existsSync as existsSync2, statSync } from "fs";
6563
- import { resolve as resolve2, basename as basename3 } from "path";
7618
+ import { resolve as resolve3, basename as basename3 } from "path";
6564
7619
  var KINDS = [
6565
7620
  "file",
6566
7621
  "folder",
@@ -6575,14 +7630,28 @@ var KINDS = [
6575
7630
  "audio",
6576
7631
  "document",
6577
7632
  "archive",
6578
- "other"
7633
+ "other",
7634
+ // Deliverable shapes — see above.
7635
+ "report",
7636
+ "walkthrough",
7637
+ "before_after",
7638
+ "checks",
7639
+ "web_app",
7640
+ "service",
7641
+ "design"
6579
7642
  ];
6580
7643
  function registerArtifact(program3) {
6581
7644
  const artifact = program3.command("artifact").description("Record the files, folders and apps this task produced");
6582
7645
  artifact.command("add [path]").description("Register a deliverable so it shows on the task").option(
6583
7646
  "-k, --kind <kind>",
6584
7647
  `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(
7648
+ ).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(
7649
+ "--data <json>",
7650
+ `figures the card shows, as JSON \u2014 e.g. '{"passed":12,"total":12}'`
7651
+ ).option(
7652
+ "--promote",
7653
+ "hand it straight to the task as one of the things the owner asked for"
7654
+ ).action(
6586
7655
  action(async ({ ctx, opts, args }) => {
6587
7656
  const rawPath = args[0];
6588
7657
  const url = opts.url;
@@ -6595,7 +7664,7 @@ function registerArtifact(program3) {
6595
7664
  let absPath;
6596
7665
  let kind = opts.kind;
6597
7666
  if (rawPath) {
6598
- absPath = resolve2(ctx.cwd, rawPath);
7667
+ absPath = resolve3(ctx.cwd, rawPath);
6599
7668
  if (!existsSync2(absPath)) {
6600
7669
  throw new WorkserError(
6601
7670
  `Nothing at ${absPath}. Register the file after you've written it.`,
@@ -6612,23 +7681,64 @@ function registerArtifact(program3) {
6612
7681
  { code: "bad_request" }
6613
7682
  );
6614
7683
  }
7684
+ let data;
7685
+ if (opts.data) {
7686
+ try {
7687
+ const parsed = JSON.parse(String(opts.data));
7688
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
7689
+ throw new Error("not an object");
7690
+ }
7691
+ data = parsed;
7692
+ } catch (err) {
7693
+ throw new WorkserError(
7694
+ `--data must be a JSON object. ${err instanceof Error ? err.message : String(err)}`,
7695
+ { code: "bad_request" }
7696
+ );
7697
+ }
7698
+ }
6615
7699
  const res = await api(ctx, `/v1/runs/${runTarget(ctx)}/artifacts`, {
6616
7700
  body: {
6617
7701
  path: absPath,
6618
7702
  url,
6619
7703
  kind,
6620
7704
  title: opts.title || (absPath ? basename3(absPath) : url),
6621
- description: opts.description
7705
+ description: opts.description,
7706
+ data,
7707
+ promote: opts.promote ? true : void 0
6622
7708
  }
6623
7709
  });
6624
7710
  ok(
6625
7711
  res,
6626
7712
  () => success(
6627
- `Recorded ${import_picocolors23.default.bold(res?.title ?? "artifact")}${res?.kind ? import_picocolors23.default.dim(` (${res.kind})`) : ""}`
7713
+ `Recorded ${import_picocolors24.default.bold(res?.title ?? "artifact")}${res?.kind ? import_picocolors24.default.dim(` (${res.kind})`) : ""}`
6628
7714
  )
6629
7715
  );
6630
7716
  })
6631
7717
  );
7718
+ artifact.command("list").description("What this task's steps have produced so far").option("--task <id>", "a specific task (default: the plan this run is in)").option("--step <id>", "one step's output alone, across every attempt").option("--mine", "only what THIS step has produced").action(
7719
+ action(async ({ ctx, opts }) => {
7720
+ const stepId = opts.step || (opts.mine ? ctx.projectTaskId : void 0);
7721
+ if (opts.mine && !stepId) {
7722
+ throw new WorkserError(
7723
+ "--mine needs a step: this run isn't inside one (WORKSER_PROJECT_TASK_ID is unset)."
7724
+ );
7725
+ }
7726
+ let path;
7727
+ if (stepId) {
7728
+ path = `/v1/project-subtasks/${encodeURIComponent(stepId)}/artifacts`;
7729
+ } else {
7730
+ const taskId = opts.task || ctx.parentTaskId || ctx.projectTaskId;
7731
+ if (!taskId) {
7732
+ throw new WorkserError(
7733
+ "No task. This run isn't inside one, so pass --task <id> or --step <id>."
7734
+ );
7735
+ }
7736
+ path = `/v1/project-tasks/${encodeURIComponent(taskId)}/artifacts?scope=all`;
7737
+ }
7738
+ const rows = await api(ctx, path) ?? [];
7739
+ ok(rows, () => printArtifacts(rows));
7740
+ })
7741
+ );
6632
7742
  artifact.command("run").description("Show the task/conversation this agent run is attached to").action(
6633
7743
  action(async ({ ctx }) => {
6634
7744
  const res = await api(ctx, `/v1/runs/${runTarget(ctx)}`);
@@ -6636,18 +7746,43 @@ function registerArtifact(program3) {
6636
7746
  })
6637
7747
  );
6638
7748
  }
7749
+ function printArtifacts(rows) {
7750
+ if (!rows.length) {
7751
+ line(import_picocolors24.default.dim("Nothing produced yet."));
7752
+ return;
7753
+ }
7754
+ const byStep = /* @__PURE__ */ new Map();
7755
+ for (const r of rows) {
7756
+ const list = byStep.get(r.subtask_id) ?? [];
7757
+ list.push(r);
7758
+ byStep.set(r.subtask_id, list);
7759
+ }
7760
+ for (const [stepId, items] of byStep) {
7761
+ line(import_picocolors24.default.bold(`step ${stepId}`));
7762
+ for (const a of items) {
7763
+ const flag = a.promoted_at ? import_picocolors24.default.green(" *") : " ";
7764
+ const where = a.local_path || a.cloud_url || "";
7765
+ line(
7766
+ `${flag} ${import_picocolors24.default.dim(`[${a.kind}]`)} ${a.title ?? "(untitled)"}` + (where ? import_picocolors24.default.dim(` ${where}`) : "")
7767
+ );
7768
+ if (a.description) line(import_picocolors24.default.dim(` ${a.description}`));
7769
+ }
7770
+ line("");
7771
+ }
7772
+ line(import_picocolors24.default.dim("* = handed over as a deliverable; the rest is working material."));
7773
+ }
6639
7774
  function printRun(run) {
6640
7775
  if (!run) return;
6641
- line(` run ${import_picocolors23.default.bold(run.runId)}`);
7776
+ line(` run ${import_picocolors24.default.bold(run.runId)}`);
6642
7777
  if (run.taskId) line(` task ${run.taskId}`);
6643
7778
  if (run.conversationId) line(` chat ${run.conversationId}`);
6644
7779
  if (run.projectId) line(` project ${run.projectId}`);
6645
- if (run.cwd) line(` folder ${import_picocolors23.default.dim(run.cwd)}`);
7780
+ if (run.cwd) line(` folder ${import_picocolors24.default.dim(run.cwd)}`);
6646
7781
  }
6647
7782
 
6648
7783
  // src/commands/image.ts
6649
7784
  import { writeFile as writeFile4, mkdir } from "fs/promises";
6650
- import { dirname as dirname2, resolve as resolve3 } from "path";
7785
+ import { dirname as dirname3, resolve as resolve4 } from "path";
6651
7786
  function registerImage(program3) {
6652
7787
  const image = program3.command("image").description("Generate images from a text prompt");
6653
7788
  image.command("generate <prompt>").alias("gen").description("Generate an image and return its public URL").option(
@@ -6702,7 +7837,7 @@ function registerImage(program3) {
6702
7837
  );
6703
7838
  }
6704
7839
  async function download(url, output) {
6705
- const target = resolve3(output);
7840
+ const target = resolve4(output);
6706
7841
  const res = await fetch(url);
6707
7842
  if (!res.ok) {
6708
7843
  throw new WorkserError(
@@ -6710,13 +7845,13 @@ async function download(url, output) {
6710
7845
  { code: "download_failed" }
6711
7846
  );
6712
7847
  }
6713
- await mkdir(dirname2(target), { recursive: true });
7848
+ await mkdir(dirname3(target), { recursive: true });
6714
7849
  await writeFile4(target, Buffer.from(await res.arrayBuffer()));
6715
7850
  return target;
6716
7851
  }
6717
7852
 
6718
7853
  // src/commands/ask.ts
6719
- var import_picocolors24 = __toESM(require_picocolors(), 1);
7854
+ var import_picocolors25 = __toESM(require_picocolors(), 1);
6720
7855
  var TYPES = [
6721
7856
  "input",
6722
7857
  "choice",
@@ -6767,7 +7902,7 @@ function registerAsk(program3) {
6767
7902
  code: "bad_request"
6768
7903
  });
6769
7904
  }
6770
- info(import_picocolors24.default.dim("Waiting for the user to answer\u2026"));
7905
+ info(import_picocolors25.default.dim("Waiting for the user to answer\u2026"));
6771
7906
  const res = await api(ctx, `/v1/runs/${runTarget(ctx)}/ask`, {
6772
7907
  body: {
6773
7908
  type,
@@ -6793,12 +7928,12 @@ function deriveTitle(message) {
6793
7928
  function printAnswer(res) {
6794
7929
  if (!res) return;
6795
7930
  if (res.status === "answered") {
6796
- line(` ${import_picocolors24.default.green("answered")}`);
7931
+ line(` ${import_picocolors25.default.green("answered")}`);
6797
7932
  const value = extract(res.response);
6798
7933
  if (value) line(` ${value}`);
6799
7934
  return;
6800
7935
  }
6801
- line(` ${import_picocolors24.default.yellow(res.status)} ${import_picocolors24.default.dim(res.reason ?? "")}`);
7936
+ line(` ${import_picocolors25.default.yellow(res.status)} ${import_picocolors25.default.dim(res.reason ?? "")}`);
6802
7937
  }
6803
7938
  function extract(response) {
6804
7939
  if (response == null) return "";
@@ -6817,7 +7952,7 @@ function extract(response) {
6817
7952
  }
6818
7953
 
6819
7954
  // src/commands/search.ts
6820
- var import_picocolors25 = __toESM(require_picocolors(), 1);
7955
+ var import_picocolors26 = __toESM(require_picocolors(), 1);
6821
7956
  function registerSearch(program3) {
6822
7957
  program3.command("search <query>").description("Search the web (Google-grounded, server-side)").option("-n, --max-results <n>", "max results", "5").action(
6823
7958
  action(async ({ ctx, args, opts }) => {
@@ -6830,9 +7965,9 @@ function registerSearch(program3) {
6830
7965
  line("");
6831
7966
  }
6832
7967
  const results = res?.results ?? [];
6833
- if (!results.length) return line(import_picocolors25.default.dim("No results."));
7968
+ if (!results.length) return line(import_picocolors26.default.dim("No results."));
6834
7969
  for (const r of results) {
6835
- line(`${r.title || import_picocolors25.default.dim("(untitled)")} ${import_picocolors25.default.dim(r.url)}`);
7970
+ line(`${r.title || import_picocolors26.default.dim("(untitled)")} ${import_picocolors26.default.dim(r.url)}`);
6836
7971
  }
6837
7972
  });
6838
7973
  })
@@ -6840,7 +7975,7 @@ function registerSearch(program3) {
6840
7975
  }
6841
7976
 
6842
7977
  // src/commands/board.ts
6843
- var import_picocolors26 = __toESM(require_picocolors(), 1);
7978
+ var import_picocolors27 = __toESM(require_picocolors(), 1);
6844
7979
 
6845
7980
  // src/commands/record-step.ts
6846
7981
  async function recordEntityStep(ctx, opts) {
@@ -6879,7 +8014,7 @@ function registerBoard(program3) {
6879
8014
  }
6880
8015
  ok(rows, () => {
6881
8016
  if (!rows.length) {
6882
- line(import_picocolors26.default.dim("No cards on the Board yet."));
8017
+ line(import_picocolors27.default.dim("No cards on the Board yet."));
6883
8018
  return;
6884
8019
  }
6885
8020
  for (const r of rows) line(formatRow(r));
@@ -6894,7 +8029,7 @@ function registerBoard(program3) {
6894
8029
  `/v1/projects/${projectId}/work-items/${args[0]}`
6895
8030
  );
6896
8031
  ok(row, () => {
6897
- line(`${import_picocolors26.default.bold(row.title)} ${import_picocolors26.default.dim(row.id)}`);
8032
+ line(`${import_picocolors27.default.bold(row.title)} ${import_picocolors27.default.dim(row.id)}`);
6898
8033
  line(`${statusTag(row.status)} priority ${row.priority}`);
6899
8034
  if (row.ownerHuman) line(`owner: ${row.ownerHuman}`);
6900
8035
  if (row.labels?.length) line(`labels: ${row.labels.join(", ")}`);
@@ -6935,7 +8070,7 @@ ${row.description}`);
6935
8070
  refId: row?.id,
6936
8071
  output: { workItem: row }
6937
8072
  });
6938
- ok(row, () => line(`Created work item ${import_picocolors26.default.bold(row?.id ?? "")} \u2014 ${title}`));
8073
+ ok(row, () => line(`Created work item ${import_picocolors27.default.bold(row?.id ?? "")} \u2014 ${title}`));
6939
8074
  })
6940
8075
  );
6941
8076
  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 +8104,7 @@ ${row.description}`);
6969
8104
  );
6970
8105
  }
6971
8106
  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)}`));
8107
+ ok(row, () => line(`Updated ${import_picocolors27.default.bold(row.id)} \u2014 ${row.title} ${statusTag(row.status)}`));
6973
8108
  })
6974
8109
  );
6975
8110
  board.command("move <id> <status>").description(
@@ -6980,14 +8115,14 @@ ${row.description}`);
6980
8115
  const status = String(args[1]);
6981
8116
  assertStatus(status);
6982
8117
  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)}`));
8118
+ ok(row, () => line(`Moved ${import_picocolors27.default.bold(row.title)} \u2192 ${statusTag(row.status)}`));
6984
8119
  })
6985
8120
  );
6986
8121
  board.command("close <id>").description("Shorthand for `board move <id> done`").action(
6987
8122
  action(async ({ ctx, args }) => {
6988
8123
  const projectId = requireProject(ctx);
6989
8124
  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)}`));
8125
+ ok(row, () => line(`Closed ${import_picocolors27.default.bold(row.title)} ${statusTag(row.status)}`));
6991
8126
  })
6992
8127
  );
6993
8128
  }
@@ -7020,23 +8155,23 @@ function assertPriority(value) {
7020
8155
  }
7021
8156
  }
7022
8157
  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}`;
8158
+ const labels = r.labels?.length ? import_picocolors27.default.dim(` [${r.labels.join(", ")}]`) : "";
8159
+ const owner = r.ownerHuman ? import_picocolors27.default.dim(` @${r.ownerHuman}`) : "";
8160
+ return `${import_picocolors27.default.dim(r.id)} ${statusTag(r.status)} ${r.title}${labels}${owner}`;
7026
8161
  }
7027
8162
  function statusTag(status) {
7028
8163
  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);
8164
+ if (status === "done") return import_picocolors27.default.green(label);
8165
+ if (status === "in-progress") return import_picocolors27.default.yellow(label);
8166
+ if (status === "in-review") return import_picocolors27.default.cyan(label);
8167
+ return import_picocolors27.default.dim(label);
7033
8168
  }
7034
8169
  function collect2(value, previous) {
7035
8170
  return [...previous, value];
7036
8171
  }
7037
8172
 
7038
8173
  // src/commands/task.ts
7039
- var import_picocolors27 = __toESM(require_picocolors(), 1);
8174
+ var import_picocolors28 = __toESM(require_picocolors(), 1);
7040
8175
  var STATUSES2 = ["todo", "working", "checking", "ready", "accepted", "archived"];
7041
8176
  var ROLES = ["pm", "architect", "web", "api", "automation", "qa"];
7042
8177
  var KINDS2 = ["data_reports", "web", "mobile", "service", "automation", "docs"];
@@ -7055,7 +8190,7 @@ function registerTask(program3) {
7055
8190
  }) ?? [];
7056
8191
  ok(rows, () => {
7057
8192
  if (!rows.length) {
7058
- line(import_picocolors27.default.dim("No tasks on the board yet."));
8193
+ line(import_picocolors28.default.dim("No tasks on the board yet."));
7059
8194
  return;
7060
8195
  }
7061
8196
  for (const r of rows) line(formatRow2(r));
@@ -7063,23 +8198,91 @@ function registerTask(program3) {
7063
8198
  })
7064
8199
  );
7065
8200
  task.command("show [id]").description(
7066
- "Show one task with its subtasks. Defaults to the task this run is inside."
8201
+ "Show a task with its subtasks, what each one did, and what they produced. Defaults to the plan this run is part of."
7067
8202
  ).action(
7068
8203
  action(async ({ ctx, args }) => {
7069
- const id = resolveTaskId(ctx, args[0]);
8204
+ const id = args[0] || ctx.parentTaskId || resolveTaskId(ctx);
7070
8205
  const row = await api(ctx, `/v1/project-tasks/${encodeURIComponent(id)}`);
7071
8206
  ok(row, () => printTask(row));
7072
8207
  })
7073
8208
  );
7074
- const subtask = task.command("subtask").description("The steps a task is broken into");
7075
- subtask.command("add <title>").description(
7076
- 'Add one step, e.g. `workser task subtask add "Build the upload screen" --role web`'
7077
- ).option("--task <id>", "the parent task (defaults to the task this run is inside)").option("--role <value>", `who does it (${ROLES.join(" | ")})`).option("--kind <value>", `what it produces (${KINDS2.join(" | ")})`).option("--note <text>", "one sentence on what this step does").option("--app <id...>", "app ids this step touches").option("--infra <ref...>", "shared setup it touches (database | storage | auth | hosting | jobs)").option("--scope <path...>", "files or folders THIS step owns").option("--depends-on <id...>", "steps that must finish first (key or id)").action(
8209
+ task.command("create <title>").description("Open a new root task for the project team").option("--note <text>", "context, constraints and expected outcome").option("--kind <value>", `what it produces (${KINDS2.join(" | ")})`).option("--label <value...>", "labels to put on the task").option("--app <id...>", "apps the task touches").option("--infra <ref...>", "shared setup it touches").action(
7078
8210
  action(async ({ ctx, args, opts }) => {
7079
- const parent = resolveTaskId(ctx, opts.task);
7080
- if (opts.role !== void 0) assertOneOf("--role", opts.role, ROLES);
8211
+ requireProject(ctx);
7081
8212
  if (opts.kind !== void 0) assertOneOf("--kind", opts.kind, KINDS2);
7082
- const dependsOn = await resolveDeps(ctx, parent, opts.dependsOn ?? []);
8213
+ const hasChannelOrigin = Boolean(
8214
+ ctx.projectChannelId && ctx.projectChannelMessageId
8215
+ );
8216
+ const row = await api(ctx, "/v1/project-tasks", {
8217
+ body: {
8218
+ title: args[0],
8219
+ summary: opts.note,
8220
+ category: opts.kind,
8221
+ labels: opts.label,
8222
+ targets: [
8223
+ ...(opts.app ?? []).map((appId) => ({ kind: "app", appId })),
8224
+ ...(opts.infra ?? []).map((ref) => ({ kind: "infra", ref }))
8225
+ ],
8226
+ ...hasChannelOrigin ? {
8227
+ channelId: ctx.projectChannelId,
8228
+ createdFromMessageId: ctx.projectChannelMessageId,
8229
+ createdByKind: "agent"
8230
+ } : {}
8231
+ }
8232
+ });
8233
+ let channelMessage;
8234
+ let channelMessageError;
8235
+ if (hasChannelOrigin && ctx.projectChannelId && ctx.projectChannelMessageId) {
8236
+ try {
8237
+ channelMessage = await api(
8238
+ ctx,
8239
+ `/v1/project-channels/${encodeURIComponent(ctx.projectChannelId)}/messages`,
8240
+ {
8241
+ body: {
8242
+ content: "",
8243
+ agentRole: ctx.agentRole ?? "pm",
8244
+ agentType: ctx.agentType ?? "workser",
8245
+ agentModel: ctx.agentModel ?? "Agent default",
8246
+ attachments: [{ resourceType: "task", resourceId: row.id }]
8247
+ }
8248
+ }
8249
+ );
8250
+ } catch (error) {
8251
+ const failure = error instanceof WorkserError ? error : new WorkserError(String(error));
8252
+ channelMessageError = {
8253
+ code: failure.code,
8254
+ message: failure.message
8255
+ };
8256
+ }
8257
+ }
8258
+ const result = {
8259
+ ...row,
8260
+ ...channelMessage ? { channelMessage } : {},
8261
+ ...channelMessageError ? { channelMessageError } : {}
8262
+ };
8263
+ ok(result, () => {
8264
+ line(`${import_picocolors28.default.green("opened")} ${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.key ?? row.id)}`);
8265
+ if (channelMessage) line(import_picocolors28.default.dim("posted to the channel as Project Manager"));
8266
+ if (channelMessageError) {
8267
+ warn(
8268
+ `Task opened, but its Project Manager card could not be posted: ${channelMessageError.message}`
8269
+ );
8270
+ }
8271
+ });
8272
+ })
8273
+ );
8274
+ const subtask = task.command("subtask").description("The steps a task is broken into");
8275
+ subtask.command("add <title>").description(
8276
+ 'Add one step, e.g. `workser task subtask add "Build the upload screen" --role web`'
8277
+ ).option("--task <id>", "the parent task (defaults to the task this run is inside)").option("--role <value>", `who does it (${ROLES.join(" | ")})`).option("--kind <value>", `what it produces (${KINDS2.join(" | ")})`).option("--note <text>", "one sentence on what this step does").option(
8278
+ "--app <id...>",
8279
+ "the app this step is for \u2014 one id runs the step inside that app's folder; leave it off and the step runs at the project, seeing every app"
8280
+ ).option("--infra <ref...>", "shared setup it touches (database | storage | auth | hosting | jobs)").option("--scope <path...>", "files or folders THIS step owns").option("--depends-on <id...>", "steps that must finish first (key or id)").action(
8281
+ action(async ({ ctx, args, opts }) => {
8282
+ const parent = resolveTaskId(ctx, opts.task);
8283
+ if (opts.role !== void 0) assertOneOf("--role", opts.role, ROLES);
8284
+ if (opts.kind !== void 0) assertOneOf("--kind", opts.kind, KINDS2);
8285
+ const dependsOn = await resolveDeps(ctx, parent, opts.dependsOn ?? []);
7083
8286
  const row = await api(ctx, "/v1/project-tasks", {
7084
8287
  body: {
7085
8288
  parentTaskId: parent,
@@ -7096,8 +8299,8 @@ function registerTask(program3) {
7096
8299
  }
7097
8300
  });
7098
8301
  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}`));
8302
+ line(`${import_picocolors28.default.green("added")} ${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.key ?? row.id)}`);
8303
+ if (row.role) line(import_picocolors28.default.dim(`role: ${row.role}`));
7101
8304
  });
7102
8305
  })
7103
8306
  );
@@ -7108,7 +8311,7 @@ function registerTask(program3) {
7108
8311
  const rows = row.subtasks ?? [];
7109
8312
  ok(rows, () => {
7110
8313
  if (!rows.length) {
7111
- line(import_picocolors27.default.dim("No steps yet."));
8314
+ line(import_picocolors28.default.dim("No steps yet."));
7112
8315
  return;
7113
8316
  }
7114
8317
  rows.forEach((r, i) => line(formatSubtask(r, i + 1)));
@@ -7133,7 +8336,7 @@ function registerTask(program3) {
7133
8336
  }
7134
8337
  }
7135
8338
  );
7136
- ok(row, () => line(`${import_picocolors27.default.green("updated")} ${import_picocolors27.default.bold(row.title)}`));
8339
+ ok(row, () => line(`${import_picocolors28.default.green("updated")} ${import_picocolors28.default.bold(row.title)}`));
7137
8340
  })
7138
8341
  );
7139
8342
  subtask.command("remove <id>").description("Drop a step from the plan (only before the work starts)").action(
@@ -7141,7 +8344,7 @@ function registerTask(program3) {
7141
8344
  await api(ctx, `/v1/project-tasks/${encodeURIComponent(args[0])}`, {
7142
8345
  method: "DELETE"
7143
8346
  });
7144
- ok({ removed: args[0] }, () => line(import_picocolors27.default.green("removed")));
8347
+ ok({ removed: args[0] }, () => line(import_picocolors28.default.green("removed")));
7145
8348
  })
7146
8349
  );
7147
8350
  task.command("move <id> <status>").description(`Move a task or step along the board (${STATUSES2.join(" | ")})`).action(
@@ -7152,7 +8355,45 @@ function registerTask(program3) {
7152
8355
  `/v1/project-tasks/${encodeURIComponent(args[0])}/move`,
7153
8356
  { body: { status: args[1] } }
7154
8357
  );
7155
- ok(row, () => line(`${import_picocolors27.default.green("moved")} ${import_picocolors27.default.bold(row.title)} \u2192 ${args[1]}`));
8358
+ ok(row, () => line(`${import_picocolors28.default.green("moved")} ${import_picocolors28.default.bold(row.title)} \u2192 ${args[1]}`));
8359
+ })
8360
+ );
8361
+ task.command("resume [id]").description(
8362
+ "Put a step the owner stopped back in the queue \u2014 only when they ask you to carry on"
8363
+ ).action(
8364
+ action(async ({ ctx, args }) => {
8365
+ const id = resolveTaskId(ctx, args[0]);
8366
+ const row = await api(
8367
+ ctx,
8368
+ `/v1/project-tasks/${encodeURIComponent(id)}/retry`,
8369
+ { method: "POST" }
8370
+ );
8371
+ ok(
8372
+ row,
8373
+ () => line(
8374
+ row?.queued ? `${import_picocolors28.default.green("resumed")} \u2014 it is back in the queue and will start on the next dispatch` : import_picocolors28.default.yellow("could not resume it \u2014 check the step id")
8375
+ )
8376
+ );
8377
+ })
8378
+ );
8379
+ 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(
8380
+ action(async ({ ctx, args, opts }) => {
8381
+ const row = await api(
8382
+ ctx,
8383
+ `/v1/project-subtasks/${encodeURIComponent(String(args[0]))}/reopen`,
8384
+ { method: "POST", body: { note: opts.note } }
8385
+ );
8386
+ ok(row, () => {
8387
+ if (row?.reopened) {
8388
+ line(`${import_picocolors28.default.green("sent back")} \u2014 it will be picked up again`);
8389
+ return;
8390
+ }
8391
+ line(
8392
+ import_picocolors28.default.yellow(
8393
+ 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"
8394
+ )
8395
+ );
8396
+ });
7156
8397
  })
7157
8398
  );
7158
8399
  task.command("can-start [id]").description("Ask whether work on this task may begin. Refuses until the owner approves.").action(
@@ -7163,7 +8404,7 @@ function registerTask(program3) {
7163
8404
  `/v1/project-tasks/${encodeURIComponent(id)}/dispatch-check`,
7164
8405
  { method: "POST" }
7165
8406
  );
7166
- ok(row, () => line(import_picocolors27.default.green("approved \u2014 you may start")));
8407
+ ok(row, () => line(import_picocolors28.default.green("approved \u2014 you may start")));
7167
8408
  })
7168
8409
  );
7169
8410
  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 +8418,7 @@ function registerTask(program3) {
7177
8418
  );
7178
8419
  ok({ awaiting: row2.approval_state === "awaiting", task: row2 }, () => {
7179
8420
  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}.`
8421
+ 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
8422
  );
7182
8423
  });
7183
8424
  return;
@@ -7198,7 +8439,7 @@ function registerTask(program3) {
7198
8439
  }
7199
8440
  }
7200
8441
  );
7201
- ok(row, () => line(`${import_picocolors27.default.green(row.approval_state)} ${import_picocolors27.default.bold(row.title)}`));
8442
+ ok(row, () => line(`${import_picocolors28.default.green(row.approval_state)} ${import_picocolors28.default.bold(row.title)}`));
7202
8443
  })
7203
8444
  );
7204
8445
  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 +8454,7 @@ function registerTask(program3) {
7213
8454
  `/v1/project-tasks/${encodeURIComponent(id)}/move`,
7214
8455
  { body: { status: "ready" } }
7215
8456
  );
7216
- ok(row, () => line(`${import_picocolors27.default.green("ready")} ${import_picocolors27.default.bold(row.title)}`));
8457
+ ok(row, () => line(`${import_picocolors28.default.green("ready")} ${import_picocolors28.default.bold(row.title)}`));
7217
8458
  })
7218
8459
  );
7219
8460
  }
@@ -7258,19 +8499,24 @@ function assertOneOf(flag, value, allowed) {
7258
8499
  }
7259
8500
  }
7260
8501
  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") : "";
8502
+ const key = import_picocolors28.default.dim((r.key ?? r.id.slice(0, 8)).padEnd(10));
8503
+ const steps = r.subtaskTotal ? import_picocolors28.default.dim(` ${r.subtaskDone}/${r.subtaskTotal}`) : "";
8504
+ const gate = r.approval_state === "awaiting" ? import_picocolors28.default.yellow(" awaiting approval") : "";
7264
8505
  return `${key} ${statusTag2(r.status)} ${r.title}${steps}${gate}`;
7265
8506
  }
7266
8507
  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(", ")}`) : "";
7270
- return `${n} ${statusTag2(r.status)} ${r.title}${role}${scope}`;
8508
+ const n = import_picocolors28.default.dim(String(index).padStart(2, "0"));
8509
+ const role = r.role ? import_picocolors28.default.dim(` [${r.role}]`) : "";
8510
+ const scope = r.scope_paths?.length ? import_picocolors28.default.dim(` owns: ${r.scope_paths.join(", ")}`) : "";
8511
+ const head = `${n} ${statusTag2(r.status)} ${r.title}${role}${scope}`;
8512
+ const summary = (r.result_summary ?? "").trim();
8513
+ if (!summary) return head;
8514
+ const wrapped = summary.split("\n").map((l) => ` ${l}`).join("\n");
8515
+ return `${head}
8516
+ ${import_picocolors28.default.dim(wrapped)}`;
7271
8517
  }
7272
8518
  function printTask(row) {
7273
- line(`${import_picocolors27.default.bold(row.title)} ${import_picocolors27.default.dim(row.key ?? row.id)}`);
8519
+ line(`${import_picocolors28.default.bold(row.title)} ${import_picocolors28.default.dim(row.key ?? row.id)}`);
7274
8520
  line(`${statusTag2(row.status)} approval: ${row.approval_state}`);
7275
8521
  if (row.summary) line(`
7276
8522
  ${row.summary}`);
@@ -7282,35 +8528,51 @@ touches: ${row.targets.map((t) => t.appName ?? t.ref ?? t.kind).join(", ")}`
7282
8528
  }
7283
8529
  if (row.subtasks?.length) {
7284
8530
  line(`
7285
- ${import_picocolors27.default.bold("steps")}`);
8531
+ ${import_picocolors28.default.bold("steps")}`);
7286
8532
  row.subtasks.forEach((s, i) => line(formatSubtask(s, i + 1)));
8533
+ line(
8534
+ import_picocolors28.default.dim(
8535
+ `
8536
+ Run \`workser artifact list\` to see what these steps produced,`
8537
+ )
8538
+ );
8539
+ line(
8540
+ import_picocolors28.default.dim(
8541
+ `or \`workser artifact list --step <id>\` for one step's output alone.`
8542
+ )
8543
+ );
7287
8544
  } else {
7288
- line(import_picocolors27.default.dim("\nNo steps yet."));
8545
+ line(import_picocolors28.default.dim("\nNo steps yet."));
7289
8546
  }
7290
8547
  }
7291
8548
  function statusTag2(status) {
7292
8549
  switch (status) {
7293
8550
  case "ready":
7294
- return import_picocolors27.default.green("[ready]");
8551
+ return import_picocolors28.default.green("[ready]");
7295
8552
  case "working":
7296
- return import_picocolors27.default.blue("[working]");
8553
+ return import_picocolors28.default.blue("[working]");
7297
8554
  case "checking":
7298
- return import_picocolors27.default.cyan("[checking]");
8555
+ return import_picocolors28.default.cyan("[checking]");
7299
8556
  case "accepted":
7300
- return import_picocolors27.default.green("[accepted]");
8557
+ return import_picocolors28.default.green("[accepted]");
7301
8558
  case "archived":
7302
- return import_picocolors27.default.dim("[archived]");
8559
+ return import_picocolors28.default.dim("[archived]");
7303
8560
  default:
7304
- return import_picocolors27.default.dim("[todo]");
8561
+ return import_picocolors28.default.dim("[todo]");
7305
8562
  }
7306
8563
  }
7307
8564
 
7308
8565
  // src/role-guard.ts
7309
8566
  var READS = [
8567
+ // `requirement` sits beside `doc` and `decision` because it is the same kind
8568
+ // of thing and was simply forgotten: the verb shipped, no role could run it,
8569
+ // and on 2026-08-23 a channel PM reported to the owner that "requirements are
8570
+ // not readable by this PM role" — which was exactly true, for every role.
7310
8571
  "task",
7311
8572
  "board",
7312
8573
  "doc",
7313
8574
  "decision",
8575
+ "requirement",
7314
8576
  "memory",
7315
8577
  "search",
7316
8578
  "verify",
@@ -7322,7 +8584,22 @@ var READS = [
7322
8584
  "auth",
7323
8585
  "project",
7324
8586
  "open",
7325
- "doctor"
8587
+ "doctor",
8588
+ // Both READ and report. `scan` reads files and shells out to npm; `health`
8589
+ // makes a GET request to an address that is already public. Neither can
8590
+ // change anything, which is why the roles that exist to look — qa, security,
8591
+ // sre, analyst — get them without getting anything else.
8592
+ "scan",
8593
+ "health",
8594
+ // Read-only views of what is running. Added with Phase 6a: an SRE that can
8595
+ // read logs but cannot list deployments or read the app's address is being
8596
+ // asked to diagnose an outage with one eye shut.
8597
+ "urls",
8598
+ "deployments",
8599
+ // Reading the plan and what is used against it. An agent proposing "add
8600
+ // another project" can only sensibly propose it if it can find out the plan
8601
+ // allows two and two already exist.
8602
+ "usage"
7326
8603
  ];
7327
8604
  var BUILDS = [
7328
8605
  ...READS,
@@ -7355,6 +8632,12 @@ var ROLE_VERBS = {
7355
8632
  analyst: READS,
7356
8633
  sre: [...READS, "deploy", "domain", "versions"],
7357
8634
  devops: [...BUILDS, "deploy", "domain", "versions"]
8635
+ // NOTE: `deployments` reaches READS above, so every role can LIST and
8636
+ // INSPECT. That is correct — history is a read. The two verbs that change
8637
+ // production (`promote`, `rollback`) are not gated here at all, and must not
8638
+ // be: they are gated in the DAEMON, as `deploy.prod`, which is a door "just
8639
+ // do it" cannot open. A second, verb-name-based rule here would be a weaker
8640
+ // copy of a control that already works.
7358
8641
  };
7359
8642
  var NEVER = {
7360
8643
  // Approving is the owner's, full stop. An agent that can approve the plan it
@@ -7364,10 +8647,11 @@ var NEVER = {
7364
8647
  function assertRoleMayRun(argv) {
7365
8648
  const role = (process.env.WORKSER_ROLE ?? "").trim();
7366
8649
  if (!role) return;
7367
- const verb = argv[0];
8650
+ const commandArgv = stripLeadingGlobalOptions(argv);
8651
+ const verb = commandArgv[0];
7368
8652
  if (!verb) return;
7369
- const pair = `${argv[0]} ${argv[1] ?? ""}`.trim();
7370
- if (NEVER[pair] && !(pair === "task approval" && (argv[2] === "request" || !argv[2]))) {
8653
+ const pair = `${commandArgv[0]} ${commandArgv[1] ?? ""}`.trim();
8654
+ if (NEVER[pair] && !(pair === "task approval" && (commandArgv[2] === "request" || !commandArgv[2]))) {
7371
8655
  throw new WorkserError(NEVER[pair], { code: "role_forbidden" });
7372
8656
  }
7373
8657
  const allowed = ROLE_VERBS[role];
@@ -7379,9 +8663,38 @@ function assertRoleMayRun(argv) {
7379
8663
  );
7380
8664
  }
7381
8665
  }
8666
+ function stripLeadingGlobalOptions(argv) {
8667
+ const takesValue = /* @__PURE__ */ new Set([
8668
+ "-p",
8669
+ "--project",
8670
+ "-C",
8671
+ "--cwd",
8672
+ "--endpoint",
8673
+ "--token"
8674
+ ]);
8675
+ const flags = /* @__PURE__ */ new Set(["--json", "-q", "--quiet", "-v", "--version"]);
8676
+ let index = 0;
8677
+ while (index < argv.length) {
8678
+ const token = argv[index];
8679
+ if (flags.has(token)) {
8680
+ index += 1;
8681
+ continue;
8682
+ }
8683
+ if (takesValue.has(token)) {
8684
+ index += 2;
8685
+ continue;
8686
+ }
8687
+ if (token.startsWith("--project=") || token.startsWith("--cwd=") || token.startsWith("--endpoint=") || token.startsWith("--token=")) {
8688
+ index += 1;
8689
+ continue;
8690
+ }
8691
+ break;
8692
+ }
8693
+ return argv.slice(index);
8694
+ }
7382
8695
 
7383
8696
  // src/commands/decision.ts
7384
- var import_picocolors28 = __toESM(require_picocolors(), 1);
8697
+ var import_picocolors29 = __toESM(require_picocolors(), 1);
7385
8698
  function registerDecision(program3) {
7386
8699
  const decision = program3.command("decision").description("Read and record the project's architecture decisions");
7387
8700
  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 +8707,11 @@ function registerDecision(program3) {
7394
8707
  rows = applyLimit(rows, opts.limit);
7395
8708
  ok(rows, () => {
7396
8709
  if (!rows.length) {
7397
- line(import_picocolors28.default.dim("No decisions recorded yet."));
8710
+ line(import_picocolors29.default.dim("No decisions recorded yet."));
7398
8711
  return;
7399
8712
  }
7400
8713
  for (const r of rows) {
7401
- line(`${import_picocolors28.default.dim(r.id)} ${import_picocolors28.default.dim(shortDate(r.createdAt))} ${r.title}`);
8714
+ line(`${import_picocolors29.default.dim(r.id)} ${import_picocolors29.default.dim(shortDate(r.createdAt))} ${r.title}`);
7402
8715
  line(` ${truncate(r.decision, 100)}`);
7403
8716
  }
7404
8717
  });
@@ -7412,16 +8725,16 @@ function registerDecision(program3) {
7412
8725
  `/v1/projects/${projectId}/architecture-decisions/${args[0]}`
7413
8726
  );
7414
8727
  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)}`));
8728
+ line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8729
+ line(import_picocolors29.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
7417
8730
  line(`
7418
- ${import_picocolors28.default.bold("Context")}
8731
+ ${import_picocolors29.default.bold("Context")}
7419
8732
  ${row.context}`);
7420
8733
  line(`
7421
- ${import_picocolors28.default.bold("Decision")}
8734
+ ${import_picocolors29.default.bold("Decision")}
7422
8735
  ${row.decision}`);
7423
8736
  if (row.consequences) line(`
7424
- ${import_picocolors28.default.bold("Consequences")}
8737
+ ${import_picocolors29.default.bold("Consequences")}
7425
8738
  ${row.consequences}`);
7426
8739
  });
7427
8740
  })
@@ -7450,7 +8763,7 @@ ${row.consequences}`);
7450
8763
  refId: row?.id,
7451
8764
  output: { decision: row }
7452
8765
  });
7453
- ok(row, () => line(`Recorded decision ${import_picocolors28.default.bold(row?.id ?? "")} \u2014 ${title}`));
8766
+ ok(row, () => line(`Recorded decision ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
7454
8767
  })
7455
8768
  );
7456
8769
  const requirement = program3.command("requirement").description("Read and record the project's requirements");
@@ -7462,11 +8775,11 @@ ${row.consequences}`);
7462
8775
  rows = applyLimit(rows, opts.limit);
7463
8776
  ok(rows, () => {
7464
8777
  if (!rows.length) {
7465
- line(import_picocolors28.default.dim("No requirements recorded yet."));
8778
+ line(import_picocolors29.default.dim("No requirements recorded yet."));
7466
8779
  return;
7467
8780
  }
7468
8781
  for (const r of rows) {
7469
- line(`${import_picocolors28.default.dim(r.id)} ${r.status.padEnd(9)} ${r.title}`);
8782
+ line(`${import_picocolors29.default.dim(r.id)} ${r.status.padEnd(9)} ${r.title}`);
7470
8783
  }
7471
8784
  });
7472
8785
  })
@@ -7479,8 +8792,8 @@ ${row.consequences}`);
7479
8792
  `/v1/projects/${projectId}/requirements/${args[0]}`
7480
8793
  );
7481
8794
  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)}`));
8795
+ line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8796
+ line(import_picocolors29.default.dim(`${row.status} \xB7 ${shortDate(row.createdAt)}`));
7484
8797
  line(`
7485
8798
  ${row.body}`);
7486
8799
  });
@@ -7506,7 +8819,7 @@ ${row.body}`);
7506
8819
  refId: row?.id,
7507
8820
  output: { requirement: row }
7508
8821
  });
7509
- ok(row, () => line(`Recorded requirement ${import_picocolors28.default.bold(row?.id ?? "")} \u2014 ${title}`));
8822
+ ok(row, () => line(`Recorded requirement ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
7510
8823
  })
7511
8824
  );
7512
8825
  requirement.command("update <id>").description(
@@ -7535,7 +8848,7 @@ ${row.body}`);
7535
8848
  code: "bad_request"
7536
8849
  });
7537
8850
  }
7538
- ok(row, () => line(`Updated requirement ${import_picocolors28.default.bold(row.id)} \u2014 ${row.title} (${row.status})`));
8851
+ ok(row, () => line(`Updated requirement ${import_picocolors29.default.bold(row.id)} \u2014 ${row.title} (${row.status})`));
7539
8852
  })
7540
8853
  );
7541
8854
  }
@@ -7558,7 +8871,41 @@ function shortDate(iso) {
7558
8871
  }
7559
8872
 
7560
8873
  // src/commands/doc.ts
7561
- var import_picocolors29 = __toESM(require_picocolors(), 1);
8874
+ var import_picocolors30 = __toESM(require_picocolors(), 1);
8875
+
8876
+ // src/mermaid-fences.ts
8877
+ function hasDiagram(code) {
8878
+ if (!code) return false;
8879
+ return code.split("\n").some((line2) => {
8880
+ const text = line2.trim();
8881
+ return !!text && !text.startsWith("%%");
8882
+ });
8883
+ }
8884
+ function diagramKind(code) {
8885
+ if (!code) return null;
8886
+ for (const line2 of code.split("\n")) {
8887
+ const text = line2.trim();
8888
+ if (!text || text.startsWith("%%")) continue;
8889
+ const match = /^([A-Za-z][A-Za-z-]*)/.exec(text);
8890
+ return match ? match[1] : null;
8891
+ }
8892
+ return null;
8893
+ }
8894
+ function extractDiagrams(markdown) {
8895
+ if (!markdown) return [];
8896
+ const out = [];
8897
+ const pattern = /^[ \t]*```[ \t]*mermaid[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
8898
+ let match;
8899
+ while ((match = pattern.exec(markdown)) !== null) {
8900
+ const body = match[1].replace(/\s+$/, "");
8901
+ if (hasDiagram(body)) out.push(body);
8902
+ }
8903
+ return out;
8904
+ }
8905
+
8906
+ // src/commands/doc.ts
8907
+ import { readFileSync as readFileSync2 } from "fs";
8908
+ import { isAbsolute, join as join2 } from "path";
7562
8909
  function registerDoc(program3) {
7563
8910
  const doc = program3.command("doc").description("Read and write project documents");
7564
8911
  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 +8916,13 @@ function registerDoc(program3) {
7569
8916
  }) ?? [];
7570
8917
  ok(rows, () => {
7571
8918
  if (!rows.length) {
7572
- line(import_picocolors29.default.dim("No documents yet."));
8919
+ line(import_picocolors30.default.dim("No documents yet."));
7573
8920
  return;
7574
8921
  }
7575
8922
  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}`);
8923
+ const link = r.workItemId ? import_picocolors30.default.dim(` \u21B3 ${r.workItemId}`) : "";
8924
+ const file = r.filePath ? import_picocolors30.default.dim(` ${r.filePath}`) : "";
8925
+ line(`${import_picocolors30.default.dim(r.id)} ${r.title}${link}${file}`);
7579
8926
  }
7580
8927
  });
7581
8928
  })
@@ -7589,17 +8936,17 @@ function registerDoc(program3) {
7589
8936
  );
7590
8937
  if (opts.markdown) {
7591
8938
  ok({ id: row.id, title: row.title, filePath: row.filePath }, () => {
7592
- line(`${import_picocolors29.default.bold(row.title)} ${import_picocolors29.default.dim(row.id)}`);
8939
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
7593
8940
  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.")
8941
+ 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
8942
  );
7596
8943
  });
7597
8944
  return;
7598
8945
  }
7599
8946
  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}`));
8947
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
8948
+ if (row.workItemId) line(import_picocolors30.default.dim(`linked to work item ${row.workItemId}`));
8949
+ if (row.filePath) line(import_picocolors30.default.dim(`markdown mirror: ${row.filePath}`));
7603
8950
  line("");
7604
8951
  line(row.contentJson);
7605
8952
  });
@@ -7628,7 +8975,54 @@ function registerDoc(program3) {
7628
8975
  refId: row?.id,
7629
8976
  output: { document: row }
7630
8977
  });
7631
- ok(row, () => line(`Created document ${import_picocolors29.default.bold(row?.id ?? "")} \u2014 ${title}`));
8978
+ ok(row, () => line(`Created document ${import_picocolors30.default.bold(row?.id ?? "")} \u2014 ${title}`));
8979
+ })
8980
+ );
8981
+ doc.command("diagram <id>").description(
8982
+ "List the diagrams in a document \u2014 `--check` fails when it has none"
8983
+ ).option("--check", "exit non-zero when the document contains no diagram").action(
8984
+ action(async ({ ctx, args, opts }) => {
8985
+ const projectId = requireProject(ctx);
8986
+ const row = await api(
8987
+ ctx,
8988
+ `/v1/projects/${projectId}/documents/${args[0]}`
8989
+ );
8990
+ if (!row) {
8991
+ throw new WorkserError(`No document with id "${args[0]}" on this project.`, {
8992
+ code: "bad_request"
8993
+ });
8994
+ }
8995
+ const markdown = readMirror(ctx.cwd, row.filePath);
8996
+ const diagrams = markdown === null ? [] : extractDiagrams(markdown);
8997
+ const payload = {
8998
+ id: row.id,
8999
+ title: row.title,
9000
+ filePath: row.filePath,
9001
+ diagrams: diagrams.map((code) => ({ kind: diagramKind(code), code }))
9002
+ };
9003
+ ok(payload, () => {
9004
+ line(`${import_picocolors30.default.bold(row.title)} ${import_picocolors30.default.dim(row.id)}`);
9005
+ if (markdown === null) {
9006
+ line(
9007
+ import_picocolors30.default.dim(
9008
+ row.filePath ? `Could not read ${row.filePath} from this folder.` : "This document has no markdown mirror on disk yet."
9009
+ )
9010
+ );
9011
+ } else if (!diagrams.length) {
9012
+ line(import_picocolors30.default.dim("No diagrams in this document."));
9013
+ } else {
9014
+ for (const [i, code] of diagrams.entries()) {
9015
+ const kind = diagramKind(code) ?? "diagram";
9016
+ line(`${import_picocolors30.default.dim(String(i + 1))} ${kind} ${import_picocolors30.default.dim(`${code.split("\n").length} lines`)}`);
9017
+ }
9018
+ }
9019
+ });
9020
+ if (opts.check && !diagrams.length) {
9021
+ throw new WorkserError(
9022
+ 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.`,
9023
+ { code: "bad_request" }
9024
+ );
9025
+ }
7632
9026
  })
7633
9027
  );
7634
9028
  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 +9049,21 @@ function registerDoc(program3) {
7655
9049
  code: "bad_request"
7656
9050
  });
7657
9051
  }
7658
- ok(row, () => line(`Updated document ${import_picocolors29.default.bold(row.id)} \u2014 ${row.title}`));
9052
+ ok(row, () => line(`Updated document ${import_picocolors30.default.bold(row.id)} \u2014 ${row.title}`));
7659
9053
  })
7660
9054
  );
7661
9055
  }
9056
+ function readMirror(cwd, filePath) {
9057
+ if (!filePath) return null;
9058
+ try {
9059
+ return readFileSync2(isAbsolute(filePath) ? filePath : join2(cwd, filePath), "utf8");
9060
+ } catch {
9061
+ return null;
9062
+ }
9063
+ }
7662
9064
 
7663
9065
  // src/commands/design.ts
7664
- var import_picocolors30 = __toESM(require_picocolors(), 1);
9066
+ var import_picocolors31 = __toESM(require_picocolors(), 1);
7665
9067
  function registerDesign(program3) {
7666
9068
  const design = program3.command("design").description("Read the project's brand (colours, fonts, logo)");
7667
9069
  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 +9077,11 @@ function registerDesign(program3) {
7675
9077
  if (opts.raw) {
7676
9078
  ok(files, () => {
7677
9079
  if (!files.length) {
7678
- line(import_picocolors30.default.dim("No brand set for this project."));
9080
+ line(import_picocolors31.default.dim("No brand set for this project."));
7679
9081
  return;
7680
9082
  }
7681
9083
  for (const f of files) {
7682
- line(import_picocolors30.default.bold(f.path));
9084
+ line(import_picocolors31.default.bold(f.path));
7683
9085
  line(f.contents);
7684
9086
  line("");
7685
9087
  }
@@ -7696,21 +9098,21 @@ function registerDesign(program3) {
7696
9098
  } : { hasBrand: false, colors: {}, fonts: {}, brand: {}, files: [] };
7697
9099
  ok(summary, () => {
7698
9100
  if (!tokens) {
7699
- line(import_picocolors30.default.dim("No brand set for this project \u2014 choose sensible styling yourself."));
9101
+ line(import_picocolors31.default.dim("No brand set for this project \u2014 choose sensible styling yourself."));
7700
9102
  return;
7701
9103
  }
7702
9104
  for (const [name, value] of Object.entries(tokens.brand)) {
7703
- line(`${import_picocolors30.default.dim(name.padEnd(12))} ${value}`);
9105
+ line(`${import_picocolors31.default.dim(name.padEnd(12))} ${value}`);
7704
9106
  }
7705
9107
  for (const [name, value] of Object.entries(tokens.color)) {
7706
- line(`${import_picocolors30.default.dim(`color.${name}`.padEnd(12))} ${value}`);
9108
+ line(`${import_picocolors31.default.dim(`color.${name}`.padEnd(12))} ${value}`);
7707
9109
  }
7708
9110
  for (const [name, value] of Object.entries(tokens.font)) {
7709
- line(`${import_picocolors30.default.dim(`font.${name}`.padEnd(12))} ${value}`);
9111
+ line(`${import_picocolors31.default.dim(`font.${name}`.padEnd(12))} ${value}`);
7710
9112
  }
7711
9113
  line("");
7712
9114
  line(
7713
- import_picocolors30.default.dim(
9115
+ import_picocolors31.default.dim(
7714
9116
  `Generated into the working tree as ${files.map((f) => f.path).join(", ")} \u2014 wire those in, never edit them.`
7715
9117
  )
7716
9118
  );
@@ -7740,9 +9142,1005 @@ function unwrap(group) {
7740
9142
  );
7741
9143
  }
7742
9144
 
9145
+ // src/commands/api.ts
9146
+ var import_picocolors32 = __toESM(require_picocolors(), 1);
9147
+ import { readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
9148
+ import { join as join3, relative, sep } from "path";
9149
+
9150
+ // src/api-spec.ts
9151
+ var SPEC_FILES = [
9152
+ "api/openapi.json",
9153
+ "openapi.json",
9154
+ "api/openapi.yaml",
9155
+ "openapi.yaml",
9156
+ "api/openapi.yml",
9157
+ "openapi.yml"
9158
+ ];
9159
+ function fromNextRoute(file) {
9160
+ const match = /^app\/(.*\/)?route\.(t|j)sx?$/.exec(file);
9161
+ if (!match) return null;
9162
+ const segments = (match[1] ?? "").split("/").filter(Boolean).filter((s) => !(s.startsWith("(") && s.endsWith(")"))).map((s) => {
9163
+ const dynamic = /^\[\.{0,3}(.+?)\]$/.exec(s);
9164
+ return dynamic ? `{${dynamic[1]}}` : s;
9165
+ });
9166
+ return `/${segments.join("/")}`.replace(/\/+$/, "") || "/";
9167
+ }
9168
+ function fromPythonFile(file) {
9169
+ const match = /^api\/(.+)\.py$/.exec(file);
9170
+ if (!match) return null;
9171
+ const stem = match[1];
9172
+ if (stem === "index") return "/api";
9173
+ if (stem.endsWith("/index")) return `/api/${stem.slice(0, -"/index".length)}`;
9174
+ return `/api/${stem}`;
9175
+ }
9176
+ function discoverRoutes(files) {
9177
+ const seen = /* @__PURE__ */ new Map();
9178
+ for (const raw of files) {
9179
+ const file = raw.replace(/^\.\//, "");
9180
+ const path = fromNextRoute(file) ?? fromPythonFile(file);
9181
+ if (!path) continue;
9182
+ if (!seen.has(path)) seen.set(path, { path, file });
9183
+ }
9184
+ return [...seen.values()].sort((a, b) => a.path.localeCompare(b.path));
9185
+ }
9186
+ function specPaths(text) {
9187
+ if (!text) return [];
9188
+ const trimmed = text.trim();
9189
+ if (!trimmed) return [];
9190
+ if (trimmed.startsWith("{")) {
9191
+ try {
9192
+ const parsed = JSON.parse(trimmed);
9193
+ const paths = parsed?.paths;
9194
+ if (!paths || typeof paths !== "object") return [];
9195
+ return Object.keys(paths).filter((k) => k.startsWith("/")).sort();
9196
+ } catch {
9197
+ return [];
9198
+ }
9199
+ }
9200
+ const out = [];
9201
+ let inPaths = false;
9202
+ let indent = 0;
9203
+ for (const line2 of trimmed.split(/\r?\n/)) {
9204
+ if (!line2.trim() || line2.trim().startsWith("#")) continue;
9205
+ const leading = line2.length - line2.trimStart().length;
9206
+ if (!inPaths) {
9207
+ if (/^paths\s*:/.test(line2.trim()) && leading === 0) {
9208
+ inPaths = true;
9209
+ indent = -1;
9210
+ }
9211
+ continue;
9212
+ }
9213
+ if (indent === -1) {
9214
+ if (leading === 0) break;
9215
+ indent = leading;
9216
+ }
9217
+ if (leading < indent) break;
9218
+ if (leading > indent) continue;
9219
+ const key = /^\s*["']?(\/[^"':]*)["']?\s*:/.exec(line2);
9220
+ if (key) out.push(key[1].trim());
9221
+ }
9222
+ return [...new Set(out)].sort();
9223
+ }
9224
+ var EXEMPT = /* @__PURE__ */ new Set(["/api/health", "/health", "/api", "/"]);
9225
+ function specReport(routes, documented) {
9226
+ const declared = new Set(documented);
9227
+ const served = new Set(routes.map((r) => r.path));
9228
+ const missing = routes.filter(
9229
+ (r) => !declared.has(r.path) && !EXEMPT.has(r.path)
9230
+ );
9231
+ const stale = documented.filter((p) => !served.has(p)).sort();
9232
+ return {
9233
+ routes,
9234
+ documented: [...declared].sort(),
9235
+ missing,
9236
+ stale,
9237
+ ok: missing.length === 0
9238
+ };
9239
+ }
9240
+ function specSummary(report, specFile) {
9241
+ if (!specFile) {
9242
+ return `This service has no API description. Write one at ${SPEC_FILES[0]} listing the routes it serves.`;
9243
+ }
9244
+ if (report.ok) {
9245
+ const n = report.routes.length;
9246
+ return n === 1 ? `The one route this service has is described in ${specFile}.` : `All ${n} routes are described in ${specFile}.`;
9247
+ }
9248
+ const names = report.missing.map((m) => m.path).join(", ");
9249
+ return `${report.missing.length} route${report.missing.length === 1 ? " is" : "s are"} not described in ${specFile}: ${names}`;
9250
+ }
9251
+
9252
+ // src/commands/api.ts
9253
+ function registerApi(program3) {
9254
+ const cmd = program3.command("api").description("Call this service and check it describes its own routes");
9255
+ 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(
9256
+ action(async ({ ctx, opts }) => {
9257
+ const appId = requireApp(opts.app);
9258
+ const res = await api(ctx, `/v1/apps/${encodeURIComponent(appId)}/api/requests`);
9259
+ ok(res, () => {
9260
+ for (const note of res?.notes ?? []) line(import_picocolors32.default.dim(note));
9261
+ for (const r of res?.requests ?? []) {
9262
+ line(
9263
+ `${import_picocolors32.default.dim(r.method.padEnd(6))}${r.path}${r.note ? import_picocolors32.default.dim(` ${r.note}`) : ""}`
9264
+ );
9265
+ }
9266
+ });
9267
+ })
9268
+ );
9269
+ cmd.command("call <path>").description(
9270
+ `workser api call /orders --method POST --body '{"item":1}' [--env local|preview|production]`
9271
+ ).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(
9272
+ "--header <name:value>",
9273
+ "extra header; repeat for more than one",
9274
+ collectHeader,
9275
+ {}
9276
+ ).option(
9277
+ "--env <name>",
9278
+ "local (default), preview or production \u2014 the host comes from here, never from the path"
9279
+ ).action(
9280
+ action(async ({ ctx, args, opts }) => {
9281
+ const appId = requireApp(opts.app);
9282
+ const res = await api(
9283
+ ctx,
9284
+ `/v1/apps/${encodeURIComponent(appId)}/api/call`,
9285
+ {
9286
+ body: {
9287
+ environment: opts.env,
9288
+ method: opts.method,
9289
+ path: args[0],
9290
+ headers: opts.header,
9291
+ body: opts.body
9292
+ }
9293
+ }
9294
+ );
9295
+ ok(res, () => {
9296
+ if (!res?.ok) {
9297
+ line(import_picocolors32.default.red(res?.error ?? "The service did not answer."));
9298
+ return;
9299
+ }
9300
+ const code = `${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
9301
+ const colour2 = res.status && res.status < 300 ? import_picocolors32.default.green : res.status && res.status < 500 ? import_picocolors32.default.yellow : import_picocolors32.default.red;
9302
+ line(`${colour2(code)} ${import_picocolors32.default.dim(`${res.durationMs}ms ${res.url}`)}`);
9303
+ if (res.body) line(res.body);
9304
+ if (res.truncated) line(import_picocolors32.default.dim("(answer truncated)"));
9305
+ });
9306
+ if (!res?.ok) process.exitCode = 1;
9307
+ })
9308
+ );
9309
+ cmd.command("spec").description(
9310
+ "Compare the routes this repo serves with the ones it documents \u2014 `--check` fails on a gap"
9311
+ ).option("--check", "exit non-zero when a route has no entry in the spec").action(
9312
+ action(async ({ ctx, opts }) => {
9313
+ requireLocalApp(ctx, "api spec");
9314
+ const files = listRepoFiles(ctx.cwd);
9315
+ const routes = discoverRoutes(files);
9316
+ const specFile = SPEC_FILES.find((f) => files.includes(f)) ?? null;
9317
+ const documented = specFile ? specPaths(readIfPresent(join3(ctx.cwd, specFile))) : [];
9318
+ const report = specReport(routes, documented);
9319
+ const summary = specSummary(report, specFile);
9320
+ ok({ ...report, specFile, summary }, () => {
9321
+ for (const r of report.routes) {
9322
+ const known = report.missing.some((m) => m.path === r.path);
9323
+ line(
9324
+ `${known ? import_picocolors32.default.yellow("undocumented") : import_picocolors32.default.green("documented ")} ${r.path}${import_picocolors32.default.dim(` ${r.file}`)}`
9325
+ );
9326
+ }
9327
+ for (const p of report.stale) {
9328
+ line(`${import_picocolors32.default.dim("in spec only ")} ${p}`);
9329
+ }
9330
+ line("");
9331
+ if (report.ok && specFile) success(summary);
9332
+ else line(import_picocolors32.default.yellow(summary));
9333
+ });
9334
+ if (opts.check && !report.ok) {
9335
+ throw new WorkserError(summary, { code: "bad_request" });
9336
+ }
9337
+ if (opts.check && !specFile) {
9338
+ throw new WorkserError(summary, { code: "bad_request" });
9339
+ }
9340
+ })
9341
+ );
9342
+ }
9343
+ function collectHeader(raw, previous) {
9344
+ const at = raw.indexOf(":");
9345
+ if (at <= 0) return previous;
9346
+ return { ...previous, [raw.slice(0, at).trim()]: raw.slice(at + 1).trim() };
9347
+ }
9348
+ function requireApp(app) {
9349
+ const value = typeof app === "string" ? app.trim() : "";
9350
+ if (value) return value;
9351
+ throw new WorkserError(
9352
+ "Which service? Pass --app <webAppId>; `workser app list` shows them.",
9353
+ { code: "bad_request" }
9354
+ );
9355
+ }
9356
+ function readIfPresent(file) {
9357
+ try {
9358
+ return readFileSync3(file, "utf8");
9359
+ } catch {
9360
+ return null;
9361
+ }
9362
+ }
9363
+ function listRepoFiles(root, maxDepth = 8) {
9364
+ const SKIP = /* @__PURE__ */ new Set([
9365
+ "node_modules",
9366
+ ".git",
9367
+ ".next",
9368
+ "dist",
9369
+ "build",
9370
+ ".vercel",
9371
+ "__pycache__",
9372
+ ".venv",
9373
+ "venv"
9374
+ ]);
9375
+ const out = [];
9376
+ const walk = (dir, depth) => {
9377
+ if (depth > maxDepth || out.length > 5e3) return;
9378
+ let entries;
9379
+ try {
9380
+ entries = readdirSync(dir);
9381
+ } catch {
9382
+ return;
9383
+ }
9384
+ for (const name of entries) {
9385
+ if (name.startsWith(".") && name !== ".well-known") continue;
9386
+ if (SKIP.has(name)) continue;
9387
+ const full = join3(dir, name);
9388
+ let isDir = false;
9389
+ try {
9390
+ isDir = statSync2(full).isDirectory();
9391
+ } catch {
9392
+ continue;
9393
+ }
9394
+ if (isDir) walk(full, depth + 1);
9395
+ else out.push(relative(root, full).split(sep).join("/"));
9396
+ }
9397
+ };
9398
+ walk(root, 0);
9399
+ return out;
9400
+ }
9401
+
9402
+ // src/commands/analysis.ts
9403
+ var import_picocolors33 = __toESM(require_picocolors(), 1);
9404
+ import { readFileSync as readFileSync4 } from "fs";
9405
+ function registerAnalysis(program3) {
9406
+ const cmd = program3.command("analysis").description("Run Python analysis locally, recorded in the task");
9407
+ 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(
9408
+ action(async ({ ctx, opts }) => {
9409
+ const path = opts.app ? `/v1/apps/${encodeURIComponent(String(opts.app))}/analysis/runtime` : "/v1/analysis/runtime";
9410
+ const res = await api(ctx, path);
9411
+ ok(res, () => {
9412
+ line(
9413
+ `${res?.available ? import_picocolors33.default.green("python") : import_picocolors33.default.red("python")} ${res?.version ?? "not found"} ${import_picocolors33.default.dim(res?.python ?? "")}`
9414
+ );
9415
+ for (const lib of res?.libraries ?? []) {
9416
+ line(
9417
+ `${lib.present ? import_picocolors33.default.green(lib.name) : import_picocolors33.default.yellow(lib.name)}${import_picocolors33.default.dim(lib.present ? "" : " missing")}`
9418
+ );
9419
+ }
9420
+ for (const note of res?.notes ?? []) line(import_picocolors33.default.dim(note));
9421
+ });
9422
+ if (!res?.available) process.exitCode = 1;
9423
+ })
9424
+ );
9425
+ cmd.command("run").description(
9426
+ "workser analysis run --app <id> --file report.py (or --code '<python>')"
9427
+ ).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(
9428
+ action(async ({ ctx, opts }) => {
9429
+ const code = readCode(opts.file, opts.code);
9430
+ const res = await api(
9431
+ ctx,
9432
+ `/v1/apps/${encodeURIComponent(String(opts.app))}/analysis/run`,
9433
+ {
9434
+ body: {
9435
+ code,
9436
+ timeoutMs: opts.timeout ? Number(opts.timeout) : void 0
9437
+ }
9438
+ }
9439
+ );
9440
+ ok(res, () => {
9441
+ if (res?.stdout) line(res.stdout.replace(/\n$/, ""));
9442
+ if (res?.stderr) line(import_picocolors33.default.dim(res.stderr.replace(/\n$/, "")));
9443
+ if (res?.truncated) line(import_picocolors33.default.dim("(output truncated)"));
9444
+ const took = `${Math.round((res?.durationMs ?? 0) / 100) / 10}s`;
9445
+ line(
9446
+ 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}`)
9447
+ );
9448
+ if (res && !res.sandboxed) {
9449
+ line(
9450
+ import_picocolors33.default.dim(
9451
+ "This platform has no OS sandbox, so the script ran with your own file access."
9452
+ )
9453
+ );
9454
+ }
9455
+ });
9456
+ if (!res?.ok) process.exitCode = 1;
9457
+ })
9458
+ );
9459
+ }
9460
+ function readCode(file, inline) {
9461
+ if (typeof inline === "string" && inline.trim()) return inline;
9462
+ if (typeof file === "string" && file.trim()) {
9463
+ try {
9464
+ return readFileSync4(file, "utf8");
9465
+ } catch (err) {
9466
+ throw new WorkserError(
9467
+ `Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`,
9468
+ { code: "bad_request" }
9469
+ );
9470
+ }
9471
+ }
9472
+ throw new WorkserError("Pass --file <path> or --code '<python>'.", {
9473
+ code: "bad_request"
9474
+ });
9475
+ }
9476
+
9477
+ // src/commands/scan.ts
9478
+ var import_picocolors34 = __toESM(require_picocolors(), 1);
9479
+ import { spawnSync as spawnSync2 } from "child_process";
9480
+ import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
9481
+ import { join as join4, relative as relative2, sep as sep2 } from "path";
9482
+
9483
+ // src/scan.ts
9484
+ var SECRET_PATTERNS = [
9485
+ { name: "a private key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----/, severity: "high" },
9486
+ { name: "an AWS access key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, severity: "high" },
9487
+ { name: "a GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, severity: "high" },
9488
+ { name: "a Stripe live key", re: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/, severity: "high" },
9489
+ { name: "an OpenAI key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/, severity: "high" },
9490
+ { name: "an Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/, severity: "high" },
9491
+ { name: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
9492
+ { name: "a Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: "high" },
9493
+ {
9494
+ name: "a database password in a connection string",
9495
+ re: /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/]+:[^\s:@/]+@/,
9496
+ severity: "high"
9497
+ },
9498
+ {
9499
+ // The everyday one: `API_KEY = "…"` with something real on the right.
9500
+ name: "a secret written into the code",
9501
+ re: /(?:secret|password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)\s*[:=]\s*["'`][^"'`\s]{12,}["'`]/i,
9502
+ severity: "high"
9503
+ }
9504
+ ];
9505
+ var PLACEHOLDER = /(?:example|placeholder|changeme|your[_-]?|xxx+|\.\.\.|<[^>]+>|\$\{|process\.env|os\.environ|REPLACE|dummy|sample|test[_-]?key|fake)/i;
9506
+ 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$)/;
9507
+ function addedLines(diff) {
9508
+ const out = [];
9509
+ let file = null;
9510
+ let lineNo = 0;
9511
+ for (const raw of diff.split("\n")) {
9512
+ if (raw.startsWith("+++ ")) {
9513
+ const path = raw.slice(4).trim();
9514
+ file = path === "/dev/null" ? null : path.replace(/^b\//, "");
9515
+ continue;
9516
+ }
9517
+ if (raw.startsWith("--- ") || raw.startsWith("diff --git")) continue;
9518
+ const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
9519
+ if (hunk) {
9520
+ lineNo = Number(hunk[1]);
9521
+ continue;
9522
+ }
9523
+ if (!file) continue;
9524
+ if (raw.startsWith("+")) {
9525
+ out.push({ file, line: lineNo, text: raw.slice(1) });
9526
+ lineNo++;
9527
+ continue;
9528
+ }
9529
+ if (raw.startsWith(" ")) lineNo++;
9530
+ }
9531
+ return out;
9532
+ }
9533
+ function secretFindings(diff) {
9534
+ const findings = [];
9535
+ const seen = /* @__PURE__ */ new Set();
9536
+ for (const added of addedLines(diff)) {
9537
+ if (SECRET_EXEMPT.test(added.file)) continue;
9538
+ for (const pattern of SECRET_PATTERNS) {
9539
+ if (!pattern.re.test(added.text)) continue;
9540
+ if (PLACEHOLDER.test(added.text)) continue;
9541
+ const key = `${added.file}:${pattern.name}`;
9542
+ if (seen.has(key)) continue;
9543
+ seen.add(key);
9544
+ findings.push({
9545
+ check: "secrets",
9546
+ severity: pattern.severity,
9547
+ title: `This change adds ${pattern.name} to the code`,
9548
+ file: added.file,
9549
+ line: added.line,
9550
+ 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."
9551
+ });
9552
+ break;
9553
+ }
9554
+ }
9555
+ return findings;
9556
+ }
9557
+ function depFindings(auditJson) {
9558
+ if (!auditJson) return [];
9559
+ let parsed;
9560
+ try {
9561
+ parsed = JSON.parse(auditJson);
9562
+ } catch {
9563
+ return [];
9564
+ }
9565
+ const vulns = parsed?.vulnerabilities;
9566
+ if (!vulns || typeof vulns !== "object") return [];
9567
+ const findings = [];
9568
+ for (const [name, raw] of Object.entries(vulns)) {
9569
+ const severity = String(raw?.severity ?? "").toLowerCase();
9570
+ if (severity !== "high" && severity !== "critical") continue;
9571
+ const direct = raw?.isDirect === true;
9572
+ const fixable = raw?.fixAvailable;
9573
+ findings.push({
9574
+ check: "deps",
9575
+ severity: "high",
9576
+ title: `${name} has a known ${severity} security problem`,
9577
+ 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."
9578
+ });
9579
+ }
9580
+ return findings.sort((a, b) => a.title.localeCompare(b.title));
9581
+ }
9582
+ function permissionFindings(files, trackedFiles = []) {
9583
+ const findings = [];
9584
+ for (const file of files) {
9585
+ for (const match of file.content.matchAll(
9586
+ /NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY|PRIVATE)[A-Z0-9_]*/g
9587
+ )) {
9588
+ findings.push({
9589
+ check: "permissions",
9590
+ severity: "high",
9591
+ title: `${match[0]} is sent to every visitor's browser`,
9592
+ file: file.path,
9593
+ fix: "Anything named NEXT_PUBLIC_ is public by design. Rename it without that prefix and read it on the server only."
9594
+ });
9595
+ }
9596
+ if (/["']Access-Control-Allow-Origin["']\s*[:,]\s*["']\*["']/.test(file.content) && /Access-Control-Allow-Credentials/.test(file.content)) {
9597
+ findings.push({
9598
+ check: "permissions",
9599
+ severity: "high",
9600
+ title: "This service accepts credentialed requests from any website",
9601
+ file: file.path,
9602
+ fix: "Name the sites allowed to call it instead of `*`, or stop sending credentials."
9603
+ });
9604
+ }
9605
+ }
9606
+ for (const tracked of trackedFiles) {
9607
+ const base = tracked.split("/").pop() ?? tracked;
9608
+ if (!/^\.env(\.[a-z0-9-]+)?$/i.test(base)) continue;
9609
+ if (/\.(example|sample|template)$/i.test(base)) continue;
9610
+ findings.push({
9611
+ check: "permissions",
9612
+ severity: "high",
9613
+ title: `${tracked} is committed to the repository`,
9614
+ file: tracked,
9615
+ fix: "Add it to .gitignore, remove it from the repo, and treat every value in it as leaked."
9616
+ });
9617
+ }
9618
+ return findings;
9619
+ }
9620
+ function buildReport(input) {
9621
+ const counts = { high: 0, medium: 0, low: 0 };
9622
+ for (const f of input.findings) counts[f.severity]++;
9623
+ return {
9624
+ findings: [...input.findings].sort(
9625
+ (a, b) => rank(b.severity) - rank(a.severity) || a.title.localeCompare(b.title)
9626
+ ),
9627
+ checked: input.checked,
9628
+ skipped: input.skipped,
9629
+ counts,
9630
+ ok: counts.high === 0
9631
+ };
9632
+ }
9633
+ function rank(s) {
9634
+ return s === "high" ? 3 : s === "medium" ? 2 : 1;
9635
+ }
9636
+ function scanSummary(report) {
9637
+ const ran = report.checked.length;
9638
+ if (!ran) return "Nothing could be checked \u2014 see the reasons above.";
9639
+ const what = report.checked.join(", ");
9640
+ const total = report.findings.length;
9641
+ if (!total) {
9642
+ return `Checked ${what} \u2014 nothing found.`;
9643
+ }
9644
+ const high = report.counts.high;
9645
+ return `Checked ${what} \u2014 ${total} ${total === 1 ? "thing" : "things"} to look at` + (high ? `, ${high} of them serious.` : ".");
9646
+ }
9647
+
9648
+ // src/commands/scan.ts
9649
+ function registerScan(program3) {
9650
+ 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(
9651
+ "--only <checks>",
9652
+ "comma-separated subset: deps, secrets, permissions"
9653
+ ).option(
9654
+ "--staged",
9655
+ "look at staged changes only, rather than everything not yet committed",
9656
+ false
9657
+ ).action(
9658
+ action(async ({ ctx, opts }) => {
9659
+ const only = parseOnly(opts.only);
9660
+ const findings = [];
9661
+ const checked = [];
9662
+ const skipped = [];
9663
+ if (only.has("secrets")) runSecrets(ctx.cwd, !!opts.staged, findings, checked, skipped);
9664
+ if (only.has("deps")) runDeps(ctx.cwd, findings, checked, skipped);
9665
+ if (only.has("permissions")) runPermissions(ctx.cwd, findings, checked, skipped);
9666
+ const report = buildReport({ findings, checked, skipped });
9667
+ const summary = scanSummary(report);
9668
+ ok({ ...report, summary }, () => print2(report, summary));
9669
+ if (opts.check && !report.ok) {
9670
+ throw new WorkserError(summary, { code: "bad_request" });
9671
+ }
9672
+ })
9673
+ );
9674
+ }
9675
+ function parseOnly(raw) {
9676
+ const all = ["deps", "secrets", "permissions"];
9677
+ if (typeof raw !== "string" || !raw.trim()) return new Set(all);
9678
+ const wanted = raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => all.includes(s));
9679
+ if (!wanted.length) {
9680
+ throw new WorkserError(
9681
+ `--only takes any of: ${all.join(", ")}.`,
9682
+ { code: "bad_request" }
9683
+ );
9684
+ }
9685
+ return new Set(wanted);
9686
+ }
9687
+ function runSecrets(cwd, staged, findings, checked, skipped) {
9688
+ const args = staged ? ["diff", "--cached", "--unified=0"] : (
9689
+ // Against HEAD, so it covers both staged and unstaged work: the agent
9690
+ // that just wrote the key has not staged anything.
9691
+ ["diff", "HEAD", "--unified=0"]
9692
+ );
9693
+ const diff = git(cwd, args);
9694
+ if (diff === null) {
9695
+ skipped.push({
9696
+ check: "secrets",
9697
+ reason: "This folder isn\u2019t a git repository yet, so there are no changes to look through."
9698
+ });
9699
+ return;
9700
+ }
9701
+ checked.push("secrets");
9702
+ findings.push(...secretFindings(diff));
9703
+ }
9704
+ function runDeps(cwd, findings, checked, skipped) {
9705
+ const hasLock = ["package-lock.json", "npm-shrinkwrap.json"].some(
9706
+ (f) => existsSync3(join4(cwd, f))
9707
+ );
9708
+ if (!hasLock) {
9709
+ skipped.push({
9710
+ check: "deps",
9711
+ 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."
9712
+ });
9713
+ return;
9714
+ }
9715
+ const res = spawnSync2("npm", ["audit", "--json", "--audit-level=high"], {
9716
+ cwd,
9717
+ encoding: "utf8",
9718
+ timeout: 6e4,
9719
+ stdio: ["ignore", "pipe", "pipe"]
9720
+ // `npm audit` exits 1 when it FINDS something, which is a successful run.
9721
+ // The failures that matter are the ones with no JSON on stdout.
9722
+ });
9723
+ const out = (res.stdout || "").trim();
9724
+ if (!out || res.error) {
9725
+ skipped.push({
9726
+ check: "deps",
9727
+ reason: "Couldn\u2019t reach the package registry, so known problems in your dependencies weren\u2019t checked. This needs an internet connection."
9728
+ });
9729
+ return;
9730
+ }
9731
+ checked.push("deps");
9732
+ findings.push(...depFindings(out));
9733
+ }
9734
+ function runPermissions(cwd, findings, checked, skipped) {
9735
+ const paths = listRepoFiles2(cwd).filter(isReadable);
9736
+ const files = [];
9737
+ for (const path of paths.slice(0, 1500)) {
9738
+ try {
9739
+ const content = readFileSync5(join4(cwd, path), "utf8");
9740
+ if (content.length > 4e5) continue;
9741
+ files.push({ path, content });
9742
+ } catch {
9743
+ }
9744
+ }
9745
+ const tracked = git(cwd, ["ls-files"]);
9746
+ checked.push("permissions");
9747
+ findings.push(
9748
+ ...permissionFindings(
9749
+ files,
9750
+ tracked === null ? [] : tracked.split("\n").filter(Boolean)
9751
+ )
9752
+ );
9753
+ if (tracked === null) {
9754
+ skipped.push({
9755
+ check: "permissions",
9756
+ reason: "Not a git repository, so we couldn\u2019t check whether a .env file has been committed."
9757
+ });
9758
+ }
9759
+ }
9760
+ function print2(report, summary) {
9761
+ for (const s of report.skipped) {
9762
+ line(`${import_picocolors34.default.yellow("not checked")} ${s.check}${import_picocolors34.default.dim(` \u2014 ${s.reason}`)}`);
9763
+ }
9764
+ for (const f of report.findings) {
9765
+ const where = f.file ? import_picocolors34.default.dim(` ${f.file}${f.line ? `:${f.line}` : ""}`) : "";
9766
+ line(`${severityTag(f.severity)} ${f.title}${where}`);
9767
+ line(` ${import_picocolors34.default.dim(f.fix)}`);
9768
+ }
9769
+ if (report.findings.length || report.skipped.length) line("");
9770
+ if (report.ok && !report.skipped.length) success(summary);
9771
+ else if (report.ok) line(import_picocolors34.default.yellow(summary));
9772
+ else line(import_picocolors34.default.red(summary));
9773
+ }
9774
+ function severityTag(severity) {
9775
+ if (severity === "high") return import_picocolors34.default.red("serious ");
9776
+ if (severity === "medium") return import_picocolors34.default.yellow("worth fixing");
9777
+ return import_picocolors34.default.dim("minor ");
9778
+ }
9779
+ function git(cwd, args) {
9780
+ try {
9781
+ const res = spawnSync2("git", args, {
9782
+ cwd,
9783
+ encoding: "utf8",
9784
+ timeout: 2e4,
9785
+ maxBuffer: 32 * 1024 * 1024,
9786
+ stdio: ["ignore", "pipe", "ignore"]
9787
+ });
9788
+ if (res.error || res.status !== 0) return null;
9789
+ return res.stdout || "";
9790
+ } catch {
9791
+ return null;
9792
+ }
9793
+ }
9794
+ var READABLE = /\.(?:[jt]sx?|mjs|cjs|json|ya?ml|toml|env|py|rb|go|rs|java|php|sh|sql|md|txt|html|css)$/i;
9795
+ function isReadable(path) {
9796
+ return READABLE.test(path);
9797
+ }
9798
+ function listRepoFiles2(root, maxDepth = 8) {
9799
+ const SKIP = /* @__PURE__ */ new Set([
9800
+ "node_modules",
9801
+ ".git",
9802
+ ".next",
9803
+ "dist",
9804
+ "build",
9805
+ ".vercel",
9806
+ "__pycache__",
9807
+ ".venv",
9808
+ "venv"
9809
+ ]);
9810
+ const out = [];
9811
+ const walk = (dir, depth) => {
9812
+ if (depth > maxDepth || out.length > 5e3) return;
9813
+ let entries;
9814
+ try {
9815
+ entries = readdirSync2(dir);
9816
+ } catch {
9817
+ return;
9818
+ }
9819
+ for (const name of entries) {
9820
+ if (SKIP.has(name)) continue;
9821
+ if (name.startsWith(".") && !name.startsWith(".env")) continue;
9822
+ const full = join4(dir, name);
9823
+ let isDir = false;
9824
+ try {
9825
+ isDir = statSync3(full).isDirectory();
9826
+ } catch {
9827
+ continue;
9828
+ }
9829
+ if (isDir) walk(full, depth + 1);
9830
+ else out.push(relative2(root, full).split(sep2).join("/"));
9831
+ }
9832
+ };
9833
+ walk(root, 0);
9834
+ return out;
9835
+ }
9836
+
9837
+ // src/commands/health.ts
9838
+ var import_picocolors35 = __toESM(require_picocolors(), 1);
9839
+ function registerHealth(program3) {
9840
+ 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(
9841
+ action(async ({ ctx, opts }) => {
9842
+ requireDaemon(ctx, "health", "checks your published sites from this computer");
9843
+ const path = opts.app ? `/v1/apps/${encodeURIComponent(String(opts.app))}/health` : `/v1/health`;
9844
+ const res = await api(ctx, path);
9845
+ ok(res, () => print3(res));
9846
+ if (res?.checks?.some((c) => !c.ok)) process.exitCode = 1;
9847
+ })
9848
+ );
9849
+ }
9850
+ function print3(res) {
9851
+ if (!res?.checks?.length) {
9852
+ line(import_picocolors35.default.dim(res?.note ?? "Nothing to check."));
9853
+ return;
9854
+ }
9855
+ for (const c of res.checks) {
9856
+ const mark = c.ok ? import_picocolors35.default.green("up ") : import_picocolors35.default.red("down");
9857
+ const timing = import_picocolors35.default.dim(`${c.ms}ms`);
9858
+ const detail = c.ok ? timing : import_picocolors35.default.dim(`${c.error ?? "no answer"}${c.failures > 1 ? ` \xB7 ${c.failures} in a row` : ""}`);
9859
+ line(` ${mark} ${c.appName} ${import_picocolors35.default.dim(`(${c.environment})`)} ${c.url} ${detail}`);
9860
+ if (c.incidentOpened) {
9861
+ line(import_picocolors35.default.yellow(` An incident has been opened on the board for this.`));
9862
+ }
9863
+ }
9864
+ const down = res.checks.filter((c) => !c.ok);
9865
+ line("");
9866
+ if (!down.length) {
9867
+ success(
9868
+ `Everything published is answering (${res.checks.length} ${res.checks.length === 1 ? "address" : "addresses"} checked).`
9869
+ );
9870
+ return;
9871
+ }
9872
+ const production = down.filter((c) => c.environment === "production").length;
9873
+ line(
9874
+ import_picocolors35.default.red(
9875
+ `${down.length} of ${res.checks.length} not answering` + (production ? ` \u2014 ${production} customer-facing.` : " (preview only).")
9876
+ )
9877
+ );
9878
+ }
9879
+
9880
+ // src/commands/urls.ts
9881
+ var import_picocolors36 = __toESM(require_picocolors(), 1);
9882
+ function registerUrls(program3) {
9883
+ program3.command("urls").description("The stable preview and production addresses of every app in this project").option("--app <webAppId>", "just one app").action(
9884
+ action(async ({ ctx, opts }) => {
9885
+ const projectId = requireProject(ctx);
9886
+ const apps = await api(ctx, `/v1/apps`, {
9887
+ query: { project: projectId }
9888
+ });
9889
+ const list = Array.isArray(apps) ? apps : apps?.apps ?? [];
9890
+ const wanted = opts.app ? list.filter((a) => a.id === String(opts.app)) : list;
9891
+ const rows = urlRows(wanted);
9892
+ const summary = urlsSummary(rows);
9893
+ ok({ rows, summary }, () => {
9894
+ for (const row of rows) {
9895
+ const label = import_picocolors36.default.dim(row.environment.padEnd(10));
9896
+ const value = row.url ? import_picocolors36.default.cyan(row.url) : import_picocolors36.default.dim(row.note ?? "not published");
9897
+ line(` ${row.appName.padEnd(22)} ${label} ${value}`);
9898
+ }
9899
+ line("");
9900
+ if (rows.some((r) => r.url)) success(summary);
9901
+ else line(import_picocolors36.default.yellow(summary));
9902
+ });
9903
+ })
9904
+ );
9905
+ }
9906
+
9907
+ // src/commands/deployments.ts
9908
+ var import_picocolors37 = __toESM(require_picocolors(), 1);
9909
+ function registerDeployments(program3) {
9910
+ const cmd = program3.command("deployments").description("Deployment history, and putting a build in front of customers");
9911
+ 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(
9912
+ action(async ({ ctx, opts }) => {
9913
+ const projectId = requireProject(ctx);
9914
+ const environment = readEnv2(opts.env, "deployments list");
9915
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments`, {
9916
+ query: {
9917
+ ...opts.app ? { webAppId: String(opts.app) } : {},
9918
+ ...environment ? { environment } : {},
9919
+ limit: String(opts.limit ?? "20")
9920
+ }
9921
+ });
9922
+ const items = res?.deployments ?? [];
9923
+ ok(res, () => {
9924
+ if (!items.length) {
9925
+ return line(
9926
+ import_picocolors37.default.dim(
9927
+ environment ? `Nothing has been deployed to ${environment} yet.` : "Nothing has been deployed yet. `workser deploy` builds the first one."
9928
+ )
9929
+ );
9930
+ }
9931
+ for (const d of items) line(formatDeployment(d));
9932
+ });
9933
+ })
9934
+ );
9935
+ cmd.command("inspect <id>").description("One deployment in full, with its build log").option("--logs", "include the build output", false).action(
9936
+ action(async ({ ctx, args, opts }) => {
9937
+ const projectId = requireProject(ctx);
9938
+ const id = String(args[0]);
9939
+ const dep = await api(ctx, `/v1/deployments/${encodeURIComponent(id)}`);
9940
+ const logs = opts.logs ? await api(
9941
+ ctx,
9942
+ `/v1/projects/${projectId}/deployments/${encodeURIComponent(id)}/logs`
9943
+ ).catch(() => null) : null;
9944
+ ok({ ...dep, logs }, () => {
9945
+ line(formatDeployment(dep));
9946
+ if (dep?.error_message) line(import_picocolors37.default.red(` ${dep.error_message}`));
9947
+ const events = logs?.events ?? [];
9948
+ for (const e of events) {
9949
+ line(` ${import_picocolors37.default.dim(String(e.type ?? "log"))} ${e.text ?? ""}`);
9950
+ }
9951
+ if (opts.logs && !events.length) {
9952
+ line(import_picocolors37.default.dim(" That build produced no output."));
9953
+ }
9954
+ });
9955
+ })
9956
+ );
9957
+ 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(
9958
+ action(async ({ ctx, opts }) => {
9959
+ const projectId = requireProject(ctx);
9960
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments/promote`, {
9961
+ body: { ...opts.app ? { webAppId: String(opts.app) } : {} }
9962
+ });
9963
+ ok(res, () => printPromoted(res, null));
9964
+ })
9965
+ );
9966
+ 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(
9967
+ action(async ({ ctx, args, opts }) => {
9968
+ const projectId = requireProject(ctx);
9969
+ const version = Number(args[0]);
9970
+ if (!Number.isInteger(version) || version < 1) {
9971
+ throw new WorkserError(
9972
+ `"${args[0]}" is not a version number. \`workser deployments list\` shows them.`,
9973
+ { code: "bad_input" }
9974
+ );
9975
+ }
9976
+ const res = await api(ctx, `/v1/projects/${projectId}/deployments/promote`, {
9977
+ body: {
9978
+ version,
9979
+ ...opts.app ? { webAppId: String(opts.app) } : {}
9980
+ }
9981
+ });
9982
+ ok(res, () => printPromoted(res, version));
9983
+ })
9984
+ );
9985
+ }
9986
+ function readEnv2(raw, verb) {
9987
+ const parsed = parseDeployEnvironment(raw, verb);
9988
+ if (!parsed.ok) throw new WorkserError(parsed.error, { code: "bad_input" });
9989
+ return parsed.value;
9990
+ }
9991
+ function printPromoted(res, version) {
9992
+ if (!res) return;
9993
+ const what = version === null ? "the latest build" : `version ${version}`;
9994
+ const url = res.url ?? res.vercel_url;
9995
+ success(`Production is being rebuilt from ${what}.`);
9996
+ if (url) line(import_picocolors37.default.dim(`It will be at ${url}`));
9997
+ line(import_picocolors37.default.dim("`workser deploy status` follows it."));
9998
+ }
9999
+ function formatDeployment(d) {
10000
+ if (!d) return "";
10001
+ const version = d.version !== void 0 ? import_picocolors37.default.yellow(`v${d.version}`) : import_picocolors37.default.dim("v?");
10002
+ const env = import_picocolors37.default.dim((d.environment ?? "?").padEnd(10));
10003
+ const app = d.webAppName ? `${d.webAppName} ` : "";
10004
+ const when = import_picocolors37.default.dim(formatTime2(d.created_at));
10005
+ const url = d.url ? " " + import_picocolors37.default.cyan(d.url) : "";
10006
+ return `${version} ${env} ${colorStatus(d.status ?? "")} ${app}${when}${url}`;
10007
+ }
10008
+ function formatTime2(t) {
10009
+ if (!t) return "\u2014";
10010
+ const d = new Date(t);
10011
+ return Number.isNaN(d.getTime()) ? String(t) : d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
10012
+ }
10013
+
10014
+ // src/commands/usage.ts
10015
+ var import_picocolors38 = __toESM(require_picocolors(), 1);
10016
+
10017
+ // src/usage.ts
10018
+ var NEAR_LIMIT_FRACTION = 0.8;
10019
+ function isUnlimited(limit) {
10020
+ return limit === null || limit === void 0 || !Number.isFinite(limit) || limit >= Number.MAX_SAFE_INTEGER;
10021
+ }
10022
+ function fractionUsed(d) {
10023
+ if (d.used === null) return null;
10024
+ if (isUnlimited(d.limit)) return null;
10025
+ const limit = d.limit;
10026
+ if (limit <= 0) return d.used > 0 ? 1 : 0;
10027
+ return d.used / limit;
10028
+ }
10029
+ function usageState(d) {
10030
+ if (d.used === null) return "unknown";
10031
+ const fraction = fractionUsed(d);
10032
+ if (fraction === null) return "fine";
10033
+ if (fraction >= 1) return "over";
10034
+ if (fraction >= NEAR_LIMIT_FRACTION) return "near";
10035
+ return "fine";
10036
+ }
10037
+ function formatAmount(value, unit) {
10038
+ if (unit === "count") return String(Math.round(value));
10039
+ if (Number.isInteger(value)) return `${value} GB`;
10040
+ if (value < 0.01) return "<0.01 GB";
10041
+ if (value < 10) return `${value.toFixed(2)} GB`;
10042
+ return `${value.toFixed(1)} GB`;
10043
+ }
10044
+ function bar(d, width = 20) {
10045
+ const fraction = fractionUsed(d);
10046
+ if (fraction === null) return "";
10047
+ const filled = Math.min(width, Math.round(fraction * width));
10048
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
10049
+ }
10050
+ function dimensionLine(d) {
10051
+ if (d.used === null) {
10052
+ return `${d.label}: not measured${d.note ? ` \u2014 ${d.note}` : ""}`;
10053
+ }
10054
+ const used = formatAmount(d.used, d.unit);
10055
+ if (isUnlimited(d.limit)) return `${d.label}: ${used} (no limit on this plan)`;
10056
+ const limit = formatAmount(d.limit, d.unit);
10057
+ if (usageState(d) === "over") {
10058
+ 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`;
10059
+ }
10060
+ return `${d.label}: ${used} of ${limit}`;
10061
+ }
10062
+ function usageSummary(report) {
10063
+ const dims = report.dimensions ?? [];
10064
+ const unknown = dims.filter((d) => d.used === null);
10065
+ const over = dims.filter((d) => usageState(d) === "over");
10066
+ const near = dims.filter((d) => usageState(d) === "near");
10067
+ const parts = [];
10068
+ if (over.length) {
10069
+ parts.push(
10070
+ `${over.length === 1 ? "One thing is" : `${over.length} things are`} over the ${report.tier} plan's limit`
10071
+ );
10072
+ } else if (near.length) {
10073
+ parts.push(
10074
+ `${near.length === 1 ? "One thing is" : `${near.length} things are`} close to the ${report.tier} plan's limit`
10075
+ );
10076
+ } else if (dims.some((d) => d.used !== null)) {
10077
+ parts.push(`Everything is comfortably inside the ${report.tier} plan`);
10078
+ }
10079
+ if (unknown.length) {
10080
+ parts.push(
10081
+ `${unknown.length} ${unknown.length === 1 ? "figure" : "figures"} could not be read`
10082
+ );
10083
+ }
10084
+ if (!parts.length) return "Nothing could be measured.";
10085
+ return `${parts.join("; ")}.`;
10086
+ }
10087
+ function shouldFail(report) {
10088
+ return (report.dimensions ?? []).some(
10089
+ (d) => d.kind === "hard" && usageState(d) === "over"
10090
+ );
10091
+ }
10092
+
10093
+ // src/commands/usage.ts
10094
+ function registerUsage(program3) {
10095
+ program3.command("usage").description("What this project and your plan are using \u2014 storage, projects, apps").action(
10096
+ action(async ({ ctx }) => {
10097
+ const projectId = requireProject(ctx);
10098
+ const report = await api(
10099
+ ctx,
10100
+ `/v1/projects/${projectId}/usage`
10101
+ );
10102
+ ok(report, () => print4(report));
10103
+ if (shouldFail(report)) process.exitCode = 1;
10104
+ })
10105
+ );
10106
+ }
10107
+ function print4(report) {
10108
+ const dims = report.dimensions ?? [];
10109
+ if (!dims.length) {
10110
+ return line(import_picocolors38.default.dim("Nothing to measure for this project yet."));
10111
+ }
10112
+ const width = Math.max(...dims.map((d) => d.label.length));
10113
+ for (const d of dims) {
10114
+ line(` ${colour(d)(dimensionLine(d).padEnd(0))}${gauge(d, width)}`);
10115
+ }
10116
+ line("");
10117
+ const summary = usageSummary(report);
10118
+ const worst = dims.map(usageState);
10119
+ if (worst.includes("over")) line(import_picocolors38.default.red(summary));
10120
+ else if (worst.includes("near") || worst.includes("unknown"))
10121
+ line(import_picocolors38.default.yellow(summary));
10122
+ else success(summary);
10123
+ }
10124
+ function gauge(d, _labelWidth) {
10125
+ const drawn = bar(d);
10126
+ return drawn ? ` ${import_picocolors38.default.dim(drawn)}` : "";
10127
+ }
10128
+ function colour(d) {
10129
+ switch (usageState(d)) {
10130
+ case "over":
10131
+ return d.kind === "hard" ? import_picocolors38.default.red : import_picocolors38.default.yellow;
10132
+ case "near":
10133
+ return import_picocolors38.default.yellow;
10134
+ case "unknown":
10135
+ return import_picocolors38.default.dim;
10136
+ default:
10137
+ return (s) => s;
10138
+ }
10139
+ }
10140
+
7743
10141
  // src/index.ts
7744
10142
  var pkg = {
7745
- version: true ? "0.3.1" : "0.0.0-dev"
10143
+ version: true ? "0.6.1" : "0.0.0-dev"
7746
10144
  };
7747
10145
  var program2 = new Command();
7748
10146
  program2.name("workser").description(
@@ -7775,6 +10173,13 @@ registerOpen(program2);
7775
10173
  registerDoctor(program2);
7776
10174
  registerAgent(program2);
7777
10175
  registerVerify(program2);
10176
+ registerApi(program2);
10177
+ registerAnalysis(program2);
10178
+ registerScan(program2);
10179
+ registerHealth(program2);
10180
+ registerUrls(program2);
10181
+ registerDeployments(program2);
10182
+ registerUsage(program2);
7778
10183
  registerCheckpoint(program2);
7779
10184
  registerSync(program2);
7780
10185
  registerWorkflow(program2);
@@ -7791,6 +10196,10 @@ registerTask(program2);
7791
10196
  registerDecision(program2);
7792
10197
  registerDoc(program2);
7793
10198
  registerDesign(program2);
10199
+ configureOutput({
10200
+ json: process.argv.includes("--json"),
10201
+ quiet: process.argv.includes("--quiet") || process.argv.includes("-q")
10202
+ });
7794
10203
  try {
7795
10204
  assertRoleMayRun(process.argv.slice(2));
7796
10205
  } catch (e) {