@nanobpm/nano-workforce 0.152.0 → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,10 +92,11 @@ jobs:
92
92
  # blocker, and a self-proving canary asserts the oracle's red path genuinely fires so the gate can
93
93
  # never rot into a vacuous green while the corpus is parked. Hermetic (in-process wasm engine, no
94
94
  # sockets), so it runs on every PR/push as its own job.
95
- # install.sh onboarding script (issue #576): POSIX-sh cleanliness + a hermetic
96
- # dry-run smoke test asserting the emitted command sequence. The script is
97
- # served from `main` for `curl | sh`, so a broken change is live the instant
98
- # it merges this gate keeps that surface honest.
95
+ # install.sh onboarding script (issues #576 + #583): POSIX-sh cleanliness + a
96
+ # hermetic smoke test asserting the emitted command sequence (phase 1) and the
97
+ # phase-2 console call flow (dry-run + a stubbed-console live run). The script
98
+ # is served from `main` for `curl | sh`, so a broken change is live the
99
+ # instant it merges — this gate keeps that surface honest.
99
100
  install-script:
100
101
  name: install.sh (shellcheck + dry-run smoke)
101
102
  runs-on: ubuntu-latest
@@ -111,7 +112,7 @@ jobs:
111
112
  - name: shellcheck (POSIX sh)
112
113
  run: shellcheck -s sh install.sh test/install-smoke.sh
113
114
 
114
- - name: Dry-run smoke test
115
+ - name: Smoke test (dry-run sequence + stubbed-console phase 2)
115
116
  run: sh test/install-smoke.sh
116
117
 
117
118
  derivation-parity:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.154.0](https://github.com/nanobpm/nano-workforce/compare/v0.153.0...v0.154.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **onboarding:** three-state GitHub credential preflight in install.sh ([#599](https://github.com/nanobpm/nano-workforce/issues/599)) ([7e9f592](https://github.com/nanobpm/nano-workforce/commit/7e9f592632fc85cfea9f89843252c0128cec6c8d)), closes [#588](https://github.com/nanobpm/nano-workforce/issues/588)
6
+
7
+ ## [0.153.0](https://github.com/nanobpm/nano-workforce/compare/v0.152.0...v0.153.0) (2026-08-29)
8
+
9
+ ### Features
10
+
11
+ * **onboarding:** install-script phase 2 — install, scaffold, configure & run the Workforce app ([#598](https://github.com/nanobpm/nano-workforce/issues/598)) ([e86f9ca](https://github.com/nanobpm/nano-workforce/commit/e86f9ca292998481e1fd194859832e9040bf0b51)), closes [nanobpm/nano-workforce#583](https://github.com/nanobpm/nano-workforce/issues/583) [Magikcraft/nano-bpm#1036](https://github.com/Magikcraft/nano-bpm/issues/1036) [#583](https://github.com/nanobpm/nano-workforce/issues/583)
12
+
1
13
  ## [0.152.0](https://github.com/nanobpm/nano-workforce/compare/v0.151.1...v0.152.0) (2026-08-28)
2
14
 
3
15
  ### Features
package/README.md CHANGED
@@ -83,41 +83,73 @@ it's the same app and the same behaviour.
83
83
 
84
84
  If you already have one or more coding-agent CLIs installed (`kimi`, `qwen`,
85
85
  `copilot`, `claude`, `pi`), one command takes you from there to a **running Nano
86
- engine with a supervised workforce of hired agents**:
86
+ Workforce**: engine, a supervised workforce of hired agents, **and the Nano
87
+ Workforce app itself, installed, configured, and running**:
87
88
 
88
89
  ```sh
89
90
  curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
90
91
  ```
91
92
 
92
- It installs `@camunda8/cli` and the `c8ctl-plugin-nano` plugin, installs shell
93
- completion, detects which of your harnesses are installed, lets you multi-select
94
- which to hire (with a model and an instance count each), hires each with
95
- `--rank senior --protocol acp --permission yolo`, composes a declarative
96
- **workforce manifest** (`c8 nano workforce add`), and brings it up
97
- (`c8 nano start` then `c8 nano workforce start`).
93
+ **Phase 1 — engine + workforce.** It installs `@camunda8/cli` and the
94
+ `c8ctl-plugin-nano` plugin, installs shell completion, detects which of your
95
+ harnesses are installed, lets you multi-select which to hire (with a model and an
96
+ instance count each), hires each with `--rank senior --protocol acp --permission
97
+ yolo`, composes a declarative **workforce manifest** (`c8 nano workforce add`),
98
+ and brings it up (`c8 nano start` then `c8 nano workforce start`).
99
+
100
+ **GitHub access preflight.** Because a workforce with no GitHub credential can
101
+ neither review, push, nor merge, the preflight checks for a **usable** credential
102
+ on this host before hiring: a `GITHUB_TOKEN`/`GH_TOKEN` in the environment
103
+ *(validated with `gh api user` / a raw `api.github.com/user` probe, not trusted
104
+ merely for being set — in the rare case where neither `gh` nor `curl` is present
105
+ to probe with, it cannot validate and warns that it is proceeding on trust)*, or
106
+ the `gh` CLI *installed, authenticated, and proven with
107
+ `gh api user`*. It
108
+ distinguishes gh missing, installed-but-unauthenticated, and
109
+ authenticated-but-unusable (expired token, missing `repo` scope, SAML SSO), warns
110
+ (but does not fail) on a missing `workflow` scope, and checks `git` too. It prints
111
+ a platform-aware install hint and the `gh auth login` / `GITHUB_TOKEN` remediation
112
+ — it never runs `gh auth login` for you. Interactive runs let you fix it and
113
+ re-check, or confirm-to-continue (default no); non-interactive runs **fail** unless
114
+ you pass `--allow-no-github`. The check covers **this host only** — remote worker
115
+ hosts each need their own credential.
116
+
117
+ **Phase 2 — the app.** It then talks to the nano console the engine brought up to
118
+ install the `@nanobpm/nano-workforce` extension, scaffold a **Workforce** project
119
+ from the `nano-workforce` template, write its `ProjectConfig.env` (including
120
+ `NANO_WORKFORCE_BASE_URL` set to the console origin + `/console/app-view/Workforce`
121
+ so the fleet's hooks resolve through the proxy — see [Fleet
122
+ networking](#fleet-networking-remote-workers)), and run it. It confirms readiness
123
+ by polling the app's own `GET /app/api/version` through the proxy, then prints the
124
+ cockpit URL, the Tasks inbox, the Delivery Graphs page, and the `/app/api/agent` +
125
+ `/app/mcp` pointers. Pass `--skip-app` to stop after phase 1.
98
126
 
99
127
  > ⚠️ The hired agents run **unattended** with `--permission yolo` — full tool
100
128
  > access (shell, file writes, network) as your user on this host. The script shows
101
- > a confirmation summary before it hires anything; pass `--yes` to skip it.
102
-
103
- **What it does *not* do:** it stops at "engine up, workforce up, agents polling".
104
- It does **not** install/deploy/run the Nano Workforce **app** itself — for that,
105
- use Nano Studio (above) or the manual steps below.
129
+ > a confirmation summary before it hires anything (and before it installs the app);
130
+ > pass `--yes` to skip both.
106
131
 
107
132
  Prompts read from `/dev/tty`, so the `curl … | sh` pipe works interactively. It is
108
- also fully scriptable and safe to re-run (it converges rather than duplicating):
133
+ also fully scriptable and safe to re-run (it converges rather than duplicating — an
134
+ existing project is configured and run, never re-scaffolded):
109
135
 
110
136
  ```sh
111
137
  # Non-interactive (CI / re-provisioning); repeatable --harness <name>[:<model>][:<instances>]
112
138
  curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
113
139
 
114
- # Preview every command without changing anything
140
+ # Name the app's project, or stop after phase 1 (engine + workforce only)
141
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --project-name Workforce
142
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --skip-app
143
+
144
+ # Preview every command (including the console API calls) without changing anything
115
145
  curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:1 --dry-run
116
146
  ```
117
147
 
118
148
  `qwen` must always be given an explicit model (it stalls without one). With no
119
149
  controlling terminal and no `--harness`, the script exits with usage rather than
120
- hanging. Because it is served from `main`, a merged change to `install.sh` is live
150
+ hanging. Phase 2 needs the engine's console reachable (the default `c8 nano start`
151
+ `studio` mode); an unreachable console is reported as an early failure that changes
152
+ nothing. Because it is served from `main`, a merged change to `install.sh` is live
121
153
  immediately.
122
154
 
123
155
  ### Prerequisites
package/install.sh CHANGED
@@ -79,6 +79,10 @@ DRY_RUN=0
79
79
  ASSUME_YES=0
80
80
  SHELL_OVERRIDE=''
81
81
  INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
82
+ ALLOW_NO_GITHUB=0 # non-interactive: proceed even without usable GitHub access
83
+ GITHUB_DEGRADED='' # non-empty => the GitHub preflight passed in a degraded state; repeated in the final summary
84
+ SKIP_APP=0 # phase 1 only: bring up engine + workforce, don't install the app
85
+ PROJECT_NAME='' # console project name for the Nano Workforce app (default: Workforce)
82
86
  CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
83
87
  SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
84
88
  FAILURES='' # newline-separated failure notes (partial-success report)
@@ -95,9 +99,39 @@ ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
95
99
  if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
96
100
  if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
97
101
 
102
+ # Test hooks for the GitHub credential preflight (undocumented; keep the smoke
103
+ # test hermetic on runners that may or may not ship an authenticated gh):
104
+ # NANO_INSTALL_GH_STATE — force gh state: missing|unauthed|unusable|ok
105
+ # NANO_INSTALL_GH_SCOPES — comma/space list of token scopes gh reports
106
+ # NANO_INSTALL_GIT_STATE — force git state: missing|present
107
+ # NANO_INSTALL_TOKEN_STATE — force env-token validity: ok|bad (skips the real
108
+ # gh/api.github.com probe so tests need no network)
109
+ # A set-but-empty override still counts as set, so the probe never falls back to
110
+ # the real command -v / gh and the smoke test stays hermetic.
111
+ if [ "${NANO_INSTALL_GH_STATE+set}" = set ]; then GH_STATE_OVERRIDE_SET=1; else GH_STATE_OVERRIDE_SET=0; fi
112
+ if [ "${NANO_INSTALL_GIT_STATE+set}" = set ]; then GIT_STATE_OVERRIDE_SET=1; else GIT_STATE_OVERRIDE_SET=0; fi
113
+ if [ "${NANO_INSTALL_TOKEN_STATE+set}" = set ]; then TOKEN_STATE_OVERRIDE_SET=1; else TOKEN_STATE_OVERRIDE_SET=0; fi
114
+
98
115
  CLI='' # resolved c8ctl / c8 binary
99
116
  TTY='' # /dev/tty if usable, else empty
100
117
 
118
+ # ---------------------------------------------------------------------------
119
+ # Phase 2 (app install) configuration + test hooks.
120
+ # NANO_INSTALL_CONSOLE_ORIGIN — override the console/engine origin (tests).
121
+ # NANO_INSTALL_APP_POLL_ATTEMPTS — readiness-poll attempt count (tests).
122
+ # NANO_INSTALL_APP_POLL_INTERVAL — seconds between readiness polls (tests).
123
+ # CONSOLE_ORIGIN is the scheme://host:port the nano console+engine listen on
124
+ # (default http://localhost:8080); the console API lives under /console there.
125
+ CONSOLE_ORIGIN=''
126
+ APPVIEW_BASE=''
127
+ PROJECT=''
128
+ APP_POLL_ATTEMPTS="${NANO_INSTALL_APP_POLL_ATTEMPTS:-60}"
129
+ APP_POLL_INTERVAL="${NANO_INSTALL_APP_POLL_INTERVAL:-2}"
130
+ # Results of the last api() call.
131
+ API_STATUS=''
132
+ API_BODY=''
133
+ API_ERR=0
134
+
101
135
  # ---------------------------------------------------------------------------
102
136
  # Command runner — honours --dry-run for mutating commands.
103
137
  # ---------------------------------------------------------------------------
@@ -200,6 +234,15 @@ Options:
200
234
  -y, --yes Skip the confirmation summary.
201
235
  --install-adapters Auto-install a selected harness's missing ACP adapter
202
236
  (claude/pi) instead of prompting/skipping.
237
+ --allow-no-github Proceed even when no usable GitHub access is detected. In a
238
+ non-interactive run the preflight otherwise fails, since a
239
+ workforce with no GitHub credential cannot review, push, or
240
+ merge. Interactive runs prompt instead.
241
+ --project-name <name>
242
+ Console project name for the Nano Workforce app
243
+ (default: Workforce). [A-Za-z0-9._-] only.
244
+ --skip-app Run phase 1 only (engine + workforce); do NOT install,
245
+ scaffold, configure, or run the Nano Workforce app.
203
246
  --shell <bash|zsh|fish>
204
247
  Override shell-completion detection.
205
248
  --dry-run Print every command that would run; change nothing.
@@ -208,6 +251,13 @@ Options:
208
251
  Non-interactive example:
209
252
  curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
210
253
 
254
+ Phases:
255
+ 1. (nanobpm/nano-workforce#576) install @camunda8/cli + the nano plugin, hire
256
+ your harnesses, compose a workforce manifest, bring up engine + workforce.
257
+ 2. (nanobpm/nano-workforce#583) install the @nanobpm/nano-workforce console
258
+ extension, scaffold + configure + run a Workforce project, and print its
259
+ app-view URL. Skip it with --skip-app.
260
+
211
261
  With no controlling terminal (/dev/tty) and no --harness, the script exits
212
262
  non-zero rather than hanging on a prompt.
213
263
  EOF
@@ -230,6 +280,12 @@ parse_args() {
230
280
  shift ;;
231
281
  --yes|-y) ASSUME_YES=1; shift ;;
232
282
  --install-adapters) INSTALL_ADAPTERS=1; shift ;;
283
+ --allow-no-github) ALLOW_NO_GITHUB=1; shift ;;
284
+ --skip-app) SKIP_APP=1; shift ;;
285
+ --project-name)
286
+ [ $# -ge 2 ] || die "--project-name requires a value"
287
+ PROJECT_NAME=$2; shift 2 ;;
288
+ --project-name=*) PROJECT_NAME=${1#--project-name=}; shift ;;
233
289
  --dry-run) DRY_RUN=1; shift ;;
234
290
  --shell)
235
291
  [ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
@@ -241,6 +297,210 @@ parse_args() {
241
297
  done
242
298
  }
243
299
 
300
+ # ---------------------------------------------------------------------------
301
+ # GitHub credential preflight (nanobpm/nano-workforce#588)
302
+ #
303
+ # Two consumers need GitHub access and fail differently: the nwf app (review
304
+ # poller + merge stage) and each hired agent worker (shells out to gh/git). The
305
+ # real predicate is "a usable credential exists on this host", not "gh is
306
+ # installed" — a token in GITHUB_TOKEN/GH_TOKEN satisfies both without gh.
307
+ #
308
+ # Detection distinguishes three states beyond present/absent: not installed,
309
+ # installed-but-unauthenticated, and authenticated-but-unusable (a push/PR that
310
+ # 403s only later — expired token, missing scope, SAML SSO, fine-grained PAT
311
+ # without repo access). We prove usability with `gh api user`, never trusting
312
+ # `gh auth status` alone.
313
+ # ---------------------------------------------------------------------------
314
+ gh_present() { # 0 if the gh CLI is available (honours the test hook)
315
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
316
+ case "$NANO_INSTALL_GH_STATE" in missing) return 1 ;; *) return 0 ;; esac
317
+ fi
318
+ command -v gh >/dev/null 2>&1
319
+ }
320
+
321
+ gh_authed() { # 0 if gh auth status succeeds (honours the test hook)
322
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
323
+ case "$NANO_INSTALL_GH_STATE" in missing|unauthed) return 1 ;; *) return 0 ;; esac
324
+ fi
325
+ gh auth status >/dev/null 2>&1
326
+ }
327
+
328
+ gh_usable() { # 0 if gh api user actually works — validates token + network + SSO
329
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
330
+ case "$NANO_INSTALL_GH_STATE" in ok) return 0 ;; *) return 1 ;; esac
331
+ fi
332
+ gh api user --jq .login >/dev/null 2>&1
333
+ }
334
+
335
+ gh_token_usable() { # 0 if the env GITHUB_TOKEN/GH_TOKEN actually authenticates
336
+ # An expired/revoked/underscoped token is the *exact* late failure this
337
+ # preflight exists to catch, so a non-empty token is not trusted blindly — we
338
+ # prove it works (gh api user, or a raw api.github.com/user probe when gh is
339
+ # absent). Honours the test hook so the smoke suite needs no network.
340
+ if [ "$TOKEN_STATE_OVERRIDE_SET" -eq 1 ]; then
341
+ case "$NANO_INSTALL_TOKEN_STATE" in ok) return 0 ;; *) return 1 ;; esac
342
+ fi
343
+ _tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
344
+ if gh_present; then
345
+ GITHUB_TOKEN="$_tok" GH_TOKEN="$_tok" gh api user --jq .login >/dev/null 2>&1
346
+ elif command -v curl >/dev/null 2>&1; then
347
+ # Pass the token via --config on stdin so it never lands in curl's argv
348
+ # (readable by other users via `ps` on a multi-user host).
349
+ printf 'header = "Authorization: Bearer %s"\n' "$_tok" | \
350
+ curl -fsS --connect-timeout 5 --max-time 10 --config - \
351
+ -H "User-Agent: nano-workforce-install" \
352
+ https://api.github.com/user >/dev/null 2>&1
353
+ else
354
+ # No gh and no curl to validate with — we cannot prove the token works,
355
+ # so warn explicitly rather than let an unvalidated token look "usable".
356
+ warn "cannot validate GITHUB_TOKEN/GH_TOKEN — neither gh nor curl is available on this host; proceeding on trust (an expired or under-scoped token will only fail later)."
357
+ return 0
358
+ fi
359
+ }
360
+
361
+ gh_scopes() { # print space-separated token scopes gh reports (honours the test hook)
362
+ if [ "$GH_STATE_OVERRIDE_SET" -eq 1 ]; then
363
+ printf '%s' "${NANO_INSTALL_GH_SCOPES:-}" | tr ',' ' '
364
+ return 0
365
+ fi
366
+ # gh auth status prints e.g.: - Token scopes: 'gist', 'read:org', 'repo', 'workflow'
367
+ gh auth status 2>&1 | sed -n "s/.*Token scopes:[ ]*//p" | tr -d "'" | tr ',' ' '
368
+ }
369
+
370
+ git_present() { # 0 if git is available (honours the test hook)
371
+ if [ "$GIT_STATE_OVERRIDE_SET" -eq 1 ]; then
372
+ case "$NANO_INSTALL_GIT_STATE" in missing) return 1 ;; *) return 0 ;; esac
373
+ fi
374
+ command -v git >/dev/null 2>&1
375
+ }
376
+
377
+ # Platform-aware install + remediation hint. We print the command; we NEVER run
378
+ # `gh auth login` for the user (it is an interactive device/OAuth flow that
379
+ # should stay in their hands).
380
+ gh_remediation_hint() {
381
+ note "GitHub access not detected. The workforce cannot review, push, or merge without it."
382
+ note ""
383
+ note " macOS brew install gh"
384
+ note " Debian/Ubuntu sudo apt install gh"
385
+ note " Fedora sudo dnf install gh"
386
+ note " Arch sudo pacman -S gh"
387
+ note " other https://github.com/cli/cli#installation"
388
+ note ""
389
+ note "Then: gh auth login (or export GITHUB_TOKEN=…)"
390
+ }
391
+
392
+ # One probe pass. Sets GH_PROBE_MSG (human summary) and returns 0 when a usable
393
+ # credential exists, 1 otherwise. Warns (non-fatal) when the workflow scope is
394
+ # absent, since that only bites on workflow-file changes.
395
+ probe_github() {
396
+ GH_PROBE_MSG=''
397
+ # 1. token in env → validate it (don't trust a non-empty value), skip gh
398
+ # scope checks (a raw token exposes no scopes here) once it authenticates.
399
+ if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
400
+ if ! gh_token_usable; then
401
+ GH_PROBE_MSG="a GITHUB_TOKEN/GH_TOKEN is set but it failed validation (gh api user / api.github.com/user) — it is expired, revoked, or lacks access. Fix or unset it."
402
+ return 1
403
+ fi
404
+ if gh_present; then
405
+ GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN); gh CLI also present."
406
+ else
407
+ GH_PROBE_MSG="GitHub token detected in the environment (GITHUB_TOKEN/GH_TOKEN)."
408
+ note "A token is set, so the nwf app is fine (NANO_PR_GITHUB_TRANSPORT=token)."
409
+ note "But gh is NOT installed on this host — harnesses that shell out to 'gh' still won't work. Install gh too."
410
+ fi
411
+ return 0
412
+ fi
413
+ # 2. otherwise the host CLI must be present, authenticated, and usable.
414
+ if ! gh_present; then
415
+ GH_PROBE_MSG="no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
416
+ return 1
417
+ fi
418
+ if ! gh_authed; then
419
+ GH_PROBE_MSG="gh is installed but not authenticated (gh auth status failed)."
420
+ return 1
421
+ fi
422
+ # 3. prove it actually works — don't just trust status.
423
+ if ! gh_usable; then
424
+ GH_PROBE_MSG="gh reports authenticated but 'gh api user' failed — the credential is unusable (expired/revoked token, network, or SAML SSO not authorized for the target org)."
425
+ return 1
426
+ fi
427
+ # 4. authenticated + usable: the repo scope is required, workflow only warns.
428
+ _scopes=$(gh_scopes)
429
+ case " $_scopes " in
430
+ *" repo "*) : ;;
431
+ *)
432
+ GH_PROBE_MSG="gh is authenticated but the token is missing the 'repo' scope — pushes and PR creation will 403. Re-auth with: gh auth refresh -s repo"
433
+ return 1 ;;
434
+ esac
435
+ case " $_scopes " in
436
+ *" workflow "*) : ;;
437
+ *) warn "gh token is missing the 'workflow' scope — harmless unless agents edit workflow files; add it with: gh auth refresh -s workflow" ;;
438
+ esac
439
+ GH_PROBE_MSG="GitHub access OK via the host gh CLI."
440
+ return 0
441
+ }
442
+
443
+ # The full preflight: probe, remediate, and decide whether to continue. Records
444
+ # a degraded state (repeated in the final summary) whenever we proceed without a
445
+ # usable credential, so a degraded install never *looks* complete.
446
+ add_degraded() { # accumulate a degraded reason so a later one never masks an earlier
447
+ if [ -n "$GITHUB_DEGRADED" ]; then
448
+ GITHUB_DEGRADED="${GITHUB_DEGRADED}; $1"
449
+ else
450
+ GITHUB_DEGRADED="$1"
451
+ fi
452
+ }
453
+ github_preflight() {
454
+ info "Checking GitHub access (this host only)"
455
+
456
+ # git is needed by every worker (clone/commit/push) — check it alongside gh.
457
+ if git_present; then
458
+ ok "git present"
459
+ else
460
+ warn "git not found on this host — agents cannot clone, commit, or push without it."
461
+ note "Install git from https://git-scm.com/downloads, then re-run."
462
+ add_degraded "git is not installed on this host"
463
+ fi
464
+
465
+ while : ; do
466
+ if probe_github; then
467
+ ok "$GH_PROBE_MSG"
468
+ note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
469
+ break
470
+ fi
471
+
472
+ warn "$GH_PROBE_MSG"
473
+ gh_remediation_hint
474
+ note "This check covers THIS host only — remote worker hosts each need their own gh auth / token."
475
+
476
+ # Non-interactive (--yes, no /dev/tty, or --dry-run): no prompt is possible.
477
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ] || [ -z "$TTY" ]; then
478
+ if [ "$ALLOW_NO_GITHUB" -eq 1 ]; then
479
+ warn "continuing without GitHub access (--allow-no-github) — the agents will not be able to do GitHub work."
480
+ add_degraded "$GH_PROBE_MSG"
481
+ break
482
+ fi
483
+ # Dry-run changes nothing, so never fail on it — just report what a real run would do.
484
+ if [ "$DRY_RUN" -eq 1 ]; then
485
+ note "dry-run: not blocking on GitHub access (a real run would fail here without --allow-no-github)."
486
+ break
487
+ fi
488
+ die "GitHub access not detected and running non-interactively — fix it (see above) and re-run, or pass --allow-no-github to proceed anyway."
489
+ fi
490
+
491
+ # Interactive: let the user fix it and re-check without re-running the whole
492
+ # script, else confirm-to-continue (default no).
493
+ if confirm "I've set up GitHub access (installed gh / ran gh auth login / exported a token). Re-check now?"; then
494
+ continue
495
+ fi
496
+ if confirm "Continue anyway? The agents won't be able to do GitHub work."; then
497
+ add_degraded "$GH_PROBE_MSG"
498
+ break
499
+ fi
500
+ die "aborted — set up GitHub access (see above) and re-run."
501
+ done
502
+ }
503
+
244
504
  # ---------------------------------------------------------------------------
245
505
  # Step 1 — Preflight
246
506
  # ---------------------------------------------------------------------------
@@ -254,18 +514,10 @@ preflight() {
254
514
  fi
255
515
  ok "node $_nv (>= ${MIN_NODE}), npm present"
256
516
 
257
- # Non-fatal: agents need GitHub access to do useful work.
258
- if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
259
- if command -v gh >/dev/null 2>&1; then
260
- if ! gh auth status >/dev/null 2>&1; then
261
- warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
262
- note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
263
- fi
264
- else
265
- warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
266
- note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
267
- fi
268
- fi
517
+ # GitHub access a workforce with no usable credential can do nothing useful,
518
+ # so this is a real three-state check with remediation (nanobpm/nano-workforce#588),
519
+ # not a passing mention. Covers this host only; remote worker hosts need their own.
520
+ github_preflight
269
521
  }
270
522
 
271
523
  # ---------------------------------------------------------------------------
@@ -438,7 +690,7 @@ live_models() { # $1 harness -> newline list on stdout
438
690
  # Step 4/5 — interactive selection + model + instances
439
691
  # ---------------------------------------------------------------------------
440
692
  add_selection() { # $1 harness $2 model $3 instances
441
- if [ -n "$2" ] && ! printf '%s' "$2" | grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
693
+ if [ -n "$2" ] && ! printf '%s' "$2" | LC_ALL=C grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
442
694
  die "refusing model id '$2' for $1: only [A-Za-z0-9._/@+-] are allowed (':' is reserved as the --harness field separator, so it must not appear in a model id; guards against shell-metacharacter injection into the hire --command)."
443
695
  fi
444
696
  SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
@@ -725,23 +977,418 @@ print_status_and_hints() {
725
977
  $CLI nano supervisor logs <worker> --follow tail one worker's logs
726
978
  $CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
727
979
  $CLI nano hire --list the hired profiles
980
+ EOF
981
+ }
982
+
983
+ # ---------------------------------------------------------------------------
984
+ # Phase 2 — install & run the Nano Workforce app via the nano console API
985
+ # (nanobpm/nano-workforce#583). Everything below talks to the console that
986
+ # `c8 nano start` brought up (default http://localhost:8080, console under
987
+ # /console). Under --dry-run, all live console calls are avoided — including
988
+ # read-only GETs: --dry-run prints the exact calls and mutates (and reads) nothing.
989
+ # ---------------------------------------------------------------------------
990
+
991
+ # scheme://host:port of a URL (drops any path), e.g. http://localhost:8080/v2
992
+ # -> http://localhost:8080.
993
+ origin_of() {
994
+ _u=$1
995
+ case "$_u" in
996
+ *://*) : ;;
997
+ *) die "invalid console origin '$_u': expected a URL with a scheme, e.g. http://localhost:8080 (set NANO_INSTALL_CONSOLE_ORIGIN or NANOBPMN_BASE_URL accordingly)." ;;
998
+ esac
999
+ _scheme=${_u%%://*}
1000
+ _rest=${_u#*://}
1001
+ _auth=${_rest%%/*}
1002
+ printf '%s://%s' "$_scheme" "$_auth"
1003
+ }
1004
+
1005
+ # True (exit 0) iff $1 contains a control character (newline, tab, CR, …). Such
1006
+ # characters cannot appear literally in a JSON string without full \uXXXX
1007
+ # escaping, so we reject them at the config gate (see build_config_body) rather
1008
+ # than emit invalid JSON that fails the console API opaquely.
1009
+ has_control_chars() { [ "$(printf '%s' "$1" | LC_ALL=C tr -cd '[:cntrl:]' | wc -c)" -ne 0 ]; }
1010
+
1011
+ # Minimal JSON string escaper (backslash + double-quote). Control characters are
1012
+ # rejected up front by build_config_body via has_control_chars(), so the values
1013
+ # reaching here (URLs, ports, tokens) are already control-char-free.
1014
+ json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
1015
+
1016
+ # Print the path of a freshly created, private (0600) temp file on stdout, or
1017
+ # return non-zero if none could be made. Prefers mktemp; when mktemp is absent
1018
+ # it falls back to a noclobber (set -C) create loop so a pre-existing path — a
1019
+ # symlink/clobber attack on a shared /tmp — can never be followed or reused, and
1020
+ # the file we read back is always the empty one we just created (never stale).
1021
+ mktemp_safe() {
1022
+ _mt=$(mktemp 2>/dev/null) && { printf '%s' "$_mt"; return 0; }
1023
+ _dir=${TMPDIR:-/tmp}; _n=0
1024
+ while [ "$_n" -lt 20 ]; do
1025
+ _cand="${_dir%/}/nwf-install.$$.${_n}.$(_rand)"
1026
+ # `set -C` makes `>` fail if the target exists (regular file OR symlink), so
1027
+ # the create is atomic and cannot clobber/follow an attacker-planted path.
1028
+ if ( set -C; umask 077; : >"$_cand" ) 2>/dev/null; then
1029
+ printf '%s' "$_cand"; return 0
1030
+ fi
1031
+ _n=$((_n + 1))
1032
+ done
1033
+ return 1
1034
+ }
1035
+
1036
+ # Best-effort small random token for the mktemp fallback path (entropy only —
1037
+ # security rests on the noclobber create, not on this being unguessable; the
1038
+ # per-iteration counter in the candidate path guarantees uniqueness regardless).
1039
+ _rand() {
1040
+ awk 'BEGIN{srand();printf "%d", rand()*1000000}' 2>/dev/null && return 0
1041
+ date +%s 2>/dev/null || printf '%s' "$$"
1042
+ }
1043
+
1044
+ # api METHOD PATH [BODY] [DISPLAY]
1045
+ # Sets API_STATUS (HTTP code, "000" on transport failure), API_BODY, API_ERR
1046
+ # (curl exit code, 0 on success). Under --dry-run nothing is sent: the call is
1047
+ # printed (DISPLAY overrides BODY in the printout, e.g. to redact a token).
1048
+ api() {
1049
+ _method=$1; _path=$2; _body=${3:-}; _display=${4:-}
1050
+ _url="${CONSOLE_ORIGIN}${_path}"
1051
+ if [ "$DRY_RUN" -eq 1 ]; then
1052
+ _shown=${_display:-$_body}
1053
+ if [ -n "$_shown" ]; then
1054
+ printf '+ %s %s %s\n' "$_method" "$_url" "$_shown" >&2
1055
+ else
1056
+ printf '+ %s %s\n' "$_method" "$_url" >&2
1057
+ fi
1058
+ API_STATUS='000'; API_BODY=''; API_ERR=0
1059
+ return 0
1060
+ fi
1061
+ # Bound every request so a stalled endpoint (SYN/DNS/proxy hang) can't wedge
1062
+ # the otherwise-bounded phase-2 poll loop: --connect-timeout caps the connect
1063
+ # phase, --max-time caps the whole request. A timeout surfaces as a non-zero
1064
+ # curl exit (API_ERR) → API_STATUS='000', handled like any transport failure.
1065
+ _tmp=$(mktemp_safe) || { API_STATUS='000'; API_BODY=''; API_ERR=1; return 0; }
1066
+ # Assemble curl's argument list with `set --` inside a SUBSHELL so it stays
1067
+ # local to that subshell and never touches the script's own positional
1068
+ # parameters ($1, $2, …) — keeping api() free of any $@ side effect. (POSIX
1069
+ # already saves/restores positionals across a function call, but scoping the
1070
+ # `set --` here makes that independent of shell quirks and future edits.)
1071
+ API_STATUS=$(
1072
+ set -- -sS --connect-timeout 10 --max-time 30 -X "$_method" -H 'Accept: application/json'
1073
+ if [ -n "$_body" ]; then
1074
+ set -- "$@" -H 'Content-Type: application/json' --data "$_body"
1075
+ fi
1076
+ curl "$@" -o "$_tmp" -w '%{http_code}' "$_url" 2>/dev/null
1077
+ ) && API_ERR=0 || API_ERR=$?
1078
+ [ "$API_ERR" -ne 0 ] && API_STATUS='000'
1079
+ API_BODY=$(cat "$_tmp" 2>/dev/null || true)
1080
+ rm -f "$_tmp"
1081
+ return 0
1082
+ }
1083
+
1084
+ # Human-readable outcome of the last api() call, for a user-facing error. On a
1085
+ # transport failure api() forces API_STATUS='000' (curl couldn't complete the
1086
+ # request — DNS/connect/timeout), which as a bare "HTTP 000" is meaningless and
1087
+ # hides the real cause; describe it as a transport failure (with curl's exit
1088
+ # code) instead. Otherwise report the actual HTTP status. One source so every
1089
+ # phase-2 console call site renders 000 the same way (no drift).
1090
+ api_outcome() {
1091
+ if [ "$API_STATUS" = '000' ]; then
1092
+ printf 'transport failure (curl exit %s) — could not reach %s' "$API_ERR" "$CONSOLE_ORIGIN"
1093
+ else
1094
+ printf 'HTTP %s' "$API_STATUS"
1095
+ fi
1096
+ }
1097
+
1098
+ # The env map written into ProjectConfig.env (step 5). Real body + a token-
1099
+ # redacted display are built the same way so they never drift.
1100
+ build_config_body() { # $1 = redact? ("redact" to mask the token)
1101
+ _tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
1102
+ # json_str() only escapes \ and " — it does NOT escape control characters — so
1103
+ # a stray newline/tab (from a mis-set env var or trailing whitespace) in ANY
1104
+ # interpolated value would corrupt the JSON body and fail the console API
1105
+ # opaquely. The config gate, not the escaper, is the single place that rejects
1106
+ # them, so validate every value that flows into the body, not just the token.
1107
+ for _cc in "GITHUB_TOKEN/GH_TOKEN=$_tok" \
1108
+ "NANOBPMN_BASE_URL=$CONSOLE_ORIGIN" \
1109
+ "NANO_WORKFORCE_BASE_URL=$APPVIEW_BASE" \
1110
+ "PR_REVIEW_PORT=${PR_REVIEW_PORT:-3000}"; do
1111
+ if has_control_chars "${_cc#*=}"; then
1112
+ err "${_cc%%=*} contains control characters (e.g. a stray newline or tab); refusing to build an invalid JSON config body — check the value for trailing whitespace."
1113
+ return 1
1114
+ fi
1115
+ done
1116
+ if [ -n "$_tok" ]; then
1117
+ if [ "${1:-}" = redact ]; then _tokval='***'; else _tokval=$(json_str "$_tok"); fi
1118
+ _gh="\"GITHUB_TOKEN\":\"${_tokval}\""
1119
+ else
1120
+ # No token in the environment: rely on the host `gh` CLI transport instead.
1121
+ _gh="\"NANO_PR_GITHUB_TRANSPORT\":\"auto\""
1122
+ fi
1123
+ printf '{"env":{%s,"NANOBPMN_BASE_URL":"%s","NANO_WORKFORCE_BASE_URL":"%s","PR_REVIEW_PORT":"%s"}}' \
1124
+ "$_gh" "$(json_str "$CONSOLE_ORIGIN")" "$(json_str "$APPVIEW_BASE")" "$(json_str "${PR_REVIEW_PORT:-3000}")"
1125
+ }
1126
+
1127
+ # Step 9 — the console must be reachable before we mutate anything.
1128
+ app_preflight() {
1129
+ info "Preflighting the console at ${CONSOLE_ORIGIN}/console"
1130
+ api GET /console/api/projects
1131
+ if [ "$DRY_RUN" -eq 1 ]; then
1132
+ note "dry-run: assuming the console is reachable."
1133
+ return 0
1134
+ fi
1135
+ if [ "$API_ERR" -ne 0 ] || [ "$API_STATUS" = '000' ]; then
1136
+ err "console unreachable at ${CONSOLE_ORIGIN}/console/api (curl exit ${API_ERR})."
1137
+ note "The engine was likely started with NANOBPMN_CONSOLE=off/observe."
1138
+ note "'c8 nano start' defaults to 'studio' (console on) — check your engine config."
1139
+ note "Nothing was changed; phase 1 (engine + workforce) is intact."
1140
+ record_failure "app: console unreachable — no changes made"
1141
+ return 1
1142
+ fi
1143
+ case "$API_STATUS" in
1144
+ 2*) ok "console reachable" ;;
1145
+ *) err "console preflight GET /console/api/projects -> HTTP $API_STATUS"
1146
+ record_failure "app: console preflight HTTP $API_STATUS"
1147
+ return 1 ;;
1148
+ esac
1149
+ }
1150
+
1151
+ # Step 10 — confirm (keys, never values).
1152
+ confirm_app() {
1153
+ info "About to install the Nano Workforce app"
1154
+ note "console extension : @nanobpm/nano-workforce"
1155
+ note "project : ${PROJECT} (from template 'nano-workforce')"
1156
+ if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then _ghkey='GITHUB_TOKEN'; else _ghkey='NANO_PR_GITHUB_TRANSPORT'; fi
1157
+ note "env keys written : ${_ghkey}, NANOBPMN_BASE_URL, NANO_WORKFORCE_BASE_URL, PR_REVIEW_PORT (values not shown)"
1158
+ if [ -z "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
1159
+ note "no token in env : the app relies on the host gh CLI (NANO_PR_GITHUB_TRANSPORT=auto)."
1160
+ fi
1161
+ note "app-view URL : ${APPVIEW_BASE}/"
1162
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
1163
+ [ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
1164
+ return 0
1165
+ fi
1166
+ if [ -z "$TTY" ]; then
1167
+ die "refusing to install the app without confirmation and no /dev/tty — re-run with --yes."
1168
+ fi
1169
+ if confirm "Install and run the Nano Workforce app now?"; then
1170
+ return 0
1171
+ fi
1172
+ note "skipping app install at user request (phase 1 is up and usable)."
1173
+ SKIP_APP=1 # keep report_and_exit honest: the app was explicitly skipped
1174
+ return 1
1175
+ }
1176
+
1177
+ # Steps 11.1–11.6 — install extension, scaffold, configure, run.
1178
+ app_provision() {
1179
+ # 11.1 — ensure the Urban toolkit (idempotent no-op when urban resolves).
1180
+ # Do NOT gate on urbanAvailable: a live instance can report false while a
1181
+ # Workforce project runs happily — treat the response as informational.
1182
+ info "Installing the Urban toolkit"
1183
+ api POST /console/api/urban/install
1184
+ if [ "$DRY_RUN" -eq 0 ]; then
1185
+ case "$API_STATUS" in
1186
+ 2*) ok "urban toolkit ensured" ;;
1187
+ *) warn "urban install returned $(api_outcome) (informational — continuing)" ;;
1188
+ esac
1189
+ fi
1190
+
1191
+ # 11.2 — install the console extension. Idempotent: an existing extension is
1192
+ # upgraded (2xx) or already present (409/2xx) — either way, continue.
1193
+ info "Installing the @nanobpm/nano-workforce console extension"
1194
+ api POST /console/api/extensions/install '{"pkg":"@nanobpm/nano-workforce"}'
1195
+ if [ "$DRY_RUN" -eq 0 ]; then
1196
+ case "$API_STATUS" in
1197
+ 2*) ok "extension installed/upgraded" ;;
1198
+ 409) note "extension already installed — continuing." ;;
1199
+ *) err "extension install -> $(api_outcome)"
1200
+ [ -n "$API_BODY" ] && note "$API_BODY"
1201
+ record_failure "app: extension install $(api_outcome)"
1202
+ return 1 ;;
1203
+ esac
1204
+ fi
1205
+
1206
+ # 11.3 — confirm the pack contributed template id 'nano-workforce'. NB the
1207
+ # templates come from GET /console/api/projects, NOT GET /console/api/config/ide
1208
+ # (which returns {} live); the npm pkg name is not the template id.
1209
+ info "Confirming the 'nano-workforce' template is available"
1210
+ api GET /console/api/projects
1211
+ if [ "$DRY_RUN" -eq 1 ]; then
1212
+ note "dry-run: would confirm template 'nano-workforce' in the projects listing."
1213
+ else
1214
+ case "$API_STATUS" in
1215
+ 2*) : ;;
1216
+ *) err "listing projects/templates -> $(api_outcome)"
1217
+ record_failure "app: list projects $(api_outcome)"
1218
+ return 1 ;;
1219
+ esac
1220
+ if printf '%s' "$API_BODY" | grep -Eq '"id"[[:space:]]*:[[:space:]]*"nano-workforce"'; then
1221
+ ok "template 'nano-workforce' present"
1222
+ else
1223
+ err "the extension did not contribute template 'nano-workforce'."
1224
+ note "Install may be mid-flight; re-run, or check the console extensions list."
1225
+ record_failure "app: template 'nano-workforce' missing after extension install"
1226
+ return 1
1227
+ fi
1228
+ fi
1229
+
1230
+ # 11.4 — scaffold the project. 409 => it already exists: DO NOT re-scaffold
1231
+ # (app.db lives in the project); fall through to configure + run.
1232
+ info "Scaffolding project '$PROJECT' from template 'nano-workforce'"
1233
+ _create_body=$(printf '{"name":"%s","template":"nano-workforce"}' "$(json_str "$PROJECT")")
1234
+ api POST /console/api/projects "$_create_body"
1235
+ if [ "$DRY_RUN" -eq 1 ]; then
1236
+ note "dry-run: on 409 (project exists) we skip scaffolding and continue to configure + run."
1237
+ else
1238
+ case "$API_STATUS" in
1239
+ 2*) ok "project '$PROJECT' created" ;;
1240
+ 409) note "project '$PROJECT' already exists — configuring and running it (not re-scaffolding)." ;;
1241
+ *) err "createProject -> $(api_outcome)"
1242
+ [ -n "$API_BODY" ] && note "$API_BODY"
1243
+ record_failure "app: createProject $(api_outcome)"
1244
+ return 1 ;;
1245
+ esac
1246
+ fi
728
1247
 
729
- Note: the Nano Workforce APP itself is not installed by this script yet — this
730
- brings up the engine + a workforce of hired agents. See the README's "Run from
731
- the CLI" section for installing/running the app.
1248
+ # 11.5 write ProjectConfig.env (token redacted in dry-run output).
1249
+ info "Configuring project '$PROJECT' (ProjectConfig.env)"
1250
+ _cfg_body=$(build_config_body) || {
1251
+ record_failure "app: invalid ProjectConfig.env value (control characters)"
1252
+ return 1
1253
+ }
1254
+ _cfg_show=$(build_config_body redact) || {
1255
+ record_failure "app: invalid ProjectConfig.env value (control characters)"
1256
+ return 1
1257
+ }
1258
+ api PUT "/console/api/projects/${PROJECT}/config" "$_cfg_body" "$_cfg_show"
1259
+ if [ "$DRY_RUN" -eq 0 ]; then
1260
+ case "$API_STATUS" in
1261
+ 2*) ok "config written (NANO_WORKFORCE_BASE_URL=${APPVIEW_BASE})" ;;
1262
+ *) err "PUT project config -> $(api_outcome)"
1263
+ [ -n "$API_BODY" ] && note "$API_BODY"
1264
+ record_failure "app: PUT config $(api_outcome)"
1265
+ return 1 ;;
1266
+ esac
1267
+ fi
1268
+
1269
+ # 11.6 — run. Running an already-running project is a no-op that returns its
1270
+ # current RunState, so this is safe to re-run (converges, never force-restarts).
1271
+ info "Running project '$PROJECT'"
1272
+ api POST "/console/api/projects/${PROJECT}/run"
1273
+ if [ "$DRY_RUN" -eq 0 ]; then
1274
+ case "$API_STATUS" in
1275
+ 2*) ok "run requested" ;;
1276
+ *) err "runProject -> $(api_outcome)"
1277
+ [ -n "$API_BODY" ] && note "$API_BODY"
1278
+ record_failure "app: runProject $(api_outcome)"
1279
+ return 1 ;;
1280
+ esac
1281
+ fi
1282
+ }
1283
+
1284
+ # Step 12 — assert readiness by polling the app's OWN /app/api/version through
1285
+ # the proxy. The runProject response reports the supervisor's state, not the
1286
+ # app's readiness, so never trust it alone.
1287
+ app_verify() {
1288
+ info "Waiting for the app to answer /app/api/version"
1289
+ if [ "$DRY_RUN" -eq 1 ]; then
1290
+ note "dry-run: would poll GET ${APPVIEW_BASE}/app/api/version until HTTP 200."
1291
+ return 0
1292
+ fi
1293
+ # APP_POLL_ATTEMPTS/APP_POLL_INTERVAL come from env hooks and feed `test`
1294
+ # arithmetic + `sleep`. A non-integer value would make `set -e` abort with an
1295
+ # opaque shell error mid-loop; validate them as non-negative integers up front
1296
+ # and record a clear failure instead.
1297
+ case "$APP_POLL_ATTEMPTS" in
1298
+ ''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_ATTEMPTS must be a non-negative integer (got '${APP_POLL_ATTEMPTS}')"; return 1 ;;
1299
+ esac
1300
+ case "$APP_POLL_INTERVAL" in
1301
+ ''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_INTERVAL must be a non-negative integer (got '${APP_POLL_INTERVAL}')"; return 1 ;;
1302
+ esac
1303
+ _n=0
1304
+ while [ "$_n" -lt "$APP_POLL_ATTEMPTS" ]; do
1305
+ api GET "/console/app-view/${PROJECT}/app/api/version"
1306
+ case "$API_STATUS" in
1307
+ 2*) ok "app is up ($(printf '%s' "$API_BODY" | tr -d '[:space:]' | cut -c1-80))"; return 0 ;;
1308
+ esac
1309
+ _n=$((_n + 1))
1310
+ [ "$_n" -lt "$APP_POLL_ATTEMPTS" ] && sleep "$APP_POLL_INTERVAL"
1311
+ done
1312
+ err "the app did not answer 200 on /app/api/version within $((APP_POLL_ATTEMPTS * APP_POLL_INTERVAL))s (last HTTP ${API_STATUS})."
1313
+ note "It may have booted but not finished self-healing its Urban surface (deps + codegen)."
1314
+ note "Verify your nano-bpm build carries the self-heal-on-Run fix (Magikcraft/nano-bpm#1036); check: ${APPVIEW_BASE}/app/api/version"
1315
+ record_failure "app: readiness poll timed out (last HTTP ${API_STATUS})"
1316
+ return 1
1317
+ }
1318
+
1319
+ # Step 13 — the operator surfaces.
1320
+ app_surfaces() {
1321
+ if [ "$DRY_RUN" -eq 1 ]; then
1322
+ info "dry-run: no calls were made — the following surfaces would be available once installed:"
1323
+ else
1324
+ info "Nano Workforce is up"
1325
+ fi
1326
+ cat >&2 <<EOF
1327
+ App (cockpit) : ${APPVIEW_BASE}/
1328
+ Tasks inbox : ${APPVIEW_BASE}/tasks
1329
+ Delivery Graphs : ${APPVIEW_BASE}/delivery-graphs
1330
+ Agent guide : ${APPVIEW_BASE}/app/api/agent
1331
+ MCP endpoint : ${APPVIEW_BASE}/app/mcp
1332
+
1333
+ Drive it by pointing a coding agent at the agent guide (GET /app/api/agent),
1334
+ or add the instance's /app/mcp as an MCP server, e.g.:
1335
+ copilot mcp add --transport http workforce ${APPVIEW_BASE}/app/mcp
732
1336
  EOF
733
1337
  }
734
1338
 
1339
+ # Phase 2 entrypoint. Returns non-zero on a genuine failure (recorded) so the
1340
+ # final report exits non-zero; a benign user decline returns 1 WITHOUT recording.
1341
+ install_app() {
1342
+ info "Phase 2 — install & run the Nano Workforce app (nanobpm/nano-workforce#583)"
1343
+
1344
+ PROJECT=${PROJECT_NAME:-Workforce}
1345
+ case "$PROJECT" in
1346
+ ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0-9._-]*) die "invalid --project-name '$PROJECT': only [A-Za-z0-9._-] are allowed." ;;
1347
+ esac
1348
+ CONSOLE_ORIGIN=$(origin_of "${NANO_INSTALL_CONSOLE_ORIGIN:-${NANOBPMN_BASE_URL:-http://localhost:8080}}")
1349
+ APPVIEW_BASE="${CONSOLE_ORIGIN}/console/app-view/${PROJECT}"
1350
+
1351
+ if [ "$DRY_RUN" -eq 0 ] && ! command -v curl >/dev/null 2>&1; then
1352
+ err "curl not found — phase 2 needs curl to talk to the console API."
1353
+ note "Install curl and re-run (phase 1 is up and usable), or use --skip-app."
1354
+ record_failure "app: curl missing — phase 2 skipped"
1355
+ return 1
1356
+ fi
1357
+
1358
+ app_preflight || return 1
1359
+ confirm_app || return 1
1360
+ app_provision || return 1
1361
+ app_verify || return 1
1362
+ app_surfaces
1363
+ }
1364
+
735
1365
  # ---------------------------------------------------------------------------
736
1366
  # Partial-success report
737
1367
  # ---------------------------------------------------------------------------
738
1368
  report_and_exit() {
1369
+ # A degraded GitHub-access install must never *look* complete — repeat the
1370
+ # warning here and exit non-zero so the failure is visible, not deferred.
1371
+ if [ -n "$GITHUB_DEGRADED" ] && [ "$DRY_RUN" -eq 0 ]; then
1372
+ record_failure "GitHub/git preflight degraded (this host only): ${GITHUB_DEGRADED} — the workforce cannot review, push, or merge until it is fixed"
1373
+ fi
739
1374
  if [ -n "$FAILURES" ]; then
740
1375
  warn "Completed with some steps skipped or failed:"
741
1376
  printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
742
1377
  exit 1
743
1378
  fi
744
- ok "Done engine up, workforce of hired agents up."
1379
+ if [ "$SKIP_APP" -eq 1 ]; then
1380
+ if [ "$DRY_RUN" -eq 1 ]; then
1381
+ ok "Dry-run complete — no changes were made (app install skipped via --skip-app)."
1382
+ else
1383
+ ok "Done — engine up, workforce of hired agents up (app install skipped)."
1384
+ fi
1385
+ else
1386
+ if [ "$DRY_RUN" -eq 1 ]; then
1387
+ ok "Dry-run complete — no changes were made; the above is what would run."
1388
+ else
1389
+ ok "Done — engine + workforce up, and the Nano Workforce app is running."
1390
+ fi
1391
+ fi
745
1392
  exit 0
746
1393
  }
747
1394
 
@@ -776,7 +1423,23 @@ main() {
776
1423
  compose_workforce
777
1424
  bring_up || true
778
1425
  print_status_and_hints
1426
+ if [ "$SKIP_APP" -eq 1 ]; then
1427
+ info "Phase 2 skipped (--skip-app)"
1428
+ note "The Nano Workforce app was NOT installed — re-run without --skip-app to"
1429
+ note "install the console extension, scaffold + configure + run the app."
1430
+ else
1431
+ install_app || true
1432
+ fi
779
1433
  report_and_exit
780
1434
  }
781
1435
 
1436
+ # Undocumented test hook: exercise phase 2 in isolation against a stub console
1437
+ # (no CLI install, no engine), used by the hermetic readiness/convergence tests.
1438
+ if [ "${NANO_INSTALL_TEST_PHASE2_ONLY:-0}" = 1 ]; then
1439
+ parse_args "$@"
1440
+ init_tty
1441
+ install_app || true
1442
+ report_and_exit
1443
+ fi
1444
+
782
1445
  main "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.152.0",
3
+ "version": "0.154.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -7,6 +7,11 @@
7
7
  # NANO_INSTALL_ADAPTERS_PRESENT. Only `node` and `npm` are required (the
8
8
  # preflight probe still runs), so run it where both are available.
9
9
  #
10
+ # The phase-2 (app install, nano-workforce#583) scenarios also drive the real
11
+ # curl-backed console flow against an in-process node stub console via the
12
+ # NANO_INSTALL_TEST_PHASE2_ONLY hook — those need `node` + `curl`, and are
13
+ # skipped (still PASS) if either is missing.
14
+ #
10
15
  # Run from the repo root: sh test/install-smoke.sh
11
16
  set -eu
12
17
 
@@ -94,6 +99,347 @@ assert_contains "s5: later selection defaults to 1 instance" \
94
99
  assert_contains "s5: default model => bare adapter command, empty --model" \
95
100
  "--command 'claude-code-acp' --model ''" "$OUT"
96
101
 
102
+ # ===========================================================================
103
+ # GitHub credential preflight — three-state check (nano-workforce#588)
104
+ # ===========================================================================
105
+ # The probe is stubbed hermetically via NANO_INSTALL_GH_STATE / NANO_INSTALL_GH_SCOPES
106
+ # / NANO_INSTALL_GIT_STATE so no real gh/git auth is needed. All run under
107
+ # --dry-run (never blocks) unless a scenario specifically exercises the
108
+ # non-interactive fail path.
109
+
110
+ # --- Scenario G1: token in env is usable, gh absent → app-fine caveat -------
111
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
112
+ NANO_INSTALL_TOKEN_STATE="ok" GITHUB_TOKEN="fake-usable-token" \
113
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
114
+ assert_contains "sG1: token detected treated as usable" \
115
+ "GitHub token detected in the environment" "$OUT"
116
+ assert_contains "sG1: token+no-gh caveat about harnesses shelling out to gh" \
117
+ "harnesses that shell out to 'gh' still won't work" "$OUT"
118
+
119
+ # --- Scenario G1b: a set-but-invalid token FAILS validation (not trusted) ---
120
+ # A non-empty token is not trusted blindly: an expired/revoked/underscoped
121
+ # token is the exact late failure this preflight exists to surface early.
122
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
123
+ NANO_INSTALL_TOKEN_STATE="bad" GITHUB_TOKEN="fake-invalid-token" \
124
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
125
+ assert_contains "sG1b: invalid token reported as failing validation" \
126
+ "failed validation" "$OUT"
127
+ assert_contains "sG1b: invalid token names remediation (fix or unset)" \
128
+ "expired, revoked, or lacks access" "$OUT"
129
+
130
+ # --- Scenario G2: gh missing, no token → not-detected + platform hint -------
131
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
132
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
133
+ assert_contains "sG2: no gh + no token reported" \
134
+ "no gh CLI and no GITHUB_TOKEN/GH_TOKEN" "$OUT"
135
+ assert_contains "sG2: not-detected headline" \
136
+ "GitHub access not detected" "$OUT"
137
+ assert_contains "sG2: Debian/Ubuntu install hint" "sudo apt install gh" "$OUT"
138
+ assert_contains "sG2: macOS install hint" "brew install gh" "$OUT"
139
+ assert_contains "sG2: gh auth login remediation printed" "gh auth login" "$OUT"
140
+ assert_contains "sG2: this-host-only caveat" "covers THIS host only" "$OUT"
141
+ # The script must NEVER run gh auth login itself — only print it.
142
+ assert_not_contains "sG2: never runs gh auth login (no 'Running' style exec)" \
143
+ "would run: gh auth login" "$OUT"
144
+
145
+ # --- Scenario G3: gh present but unauthenticated ---------------------------
146
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unauthed" \
147
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
148
+ assert_contains "sG3: installed-but-unauthenticated state" \
149
+ "gh is installed but not authenticated" "$OUT"
150
+
151
+ # --- Scenario G4: gh authenticated but unusable (gh api user fails) ---------
152
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="unusable" \
153
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
154
+ assert_contains "sG4: authenticated-but-unusable state (gh api user failed)" \
155
+ "'gh api user' failed" "$OUT"
156
+ assert_contains "sG4: names SAML SSO as a likely cause" "SAML SSO" "$OUT"
157
+
158
+ # --- Scenario G5: authenticated + usable, missing repo scope FAILS ----------
159
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
160
+ NANO_INSTALL_GH_SCOPES="gist,read:org" \
161
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
162
+ assert_contains "sG5: missing repo scope fails the check" \
163
+ "missing the 'repo' scope" "$OUT"
164
+
165
+ # --- Scenario G6: usable with repo but missing workflow scope only WARNS ----
166
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
167
+ NANO_INSTALL_GH_SCOPES="repo,read:org" \
168
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
169
+ assert_contains "sG6: missing workflow scope warns (non-fatal)" \
170
+ "missing the 'workflow' scope" "$OUT"
171
+ assert_contains "sG6: repo+ passes the check" "GitHub access OK via the host gh CLI" "$OUT"
172
+
173
+ # --- Scenario G7: git absence is flagged -----------------------------------
174
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="ok" \
175
+ NANO_INSTALL_GH_SCOPES="repo,workflow" NANO_INSTALL_GIT_STATE="missing" \
176
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
177
+ assert_contains "sG7: git presence checked alongside gh" \
178
+ "git not found on this host" "$OUT"
179
+
180
+ # --- Scenario G8: non-interactive (--yes) FAILS without --allow-no-github ----
181
+ if NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
182
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes >/dev/null 2>&1; then
183
+ fail "sG8: non-interactive run with no GitHub access should exit non-zero"
184
+ else
185
+ pass "sG8: non-interactive run with no GitHub access exits non-zero"
186
+ fi
187
+
188
+ # --- Scenario G9: --allow-no-github lets a non-interactive run proceed -------
189
+ # Under --dry-run this exercises the --allow-no-github *continue* decision (the
190
+ # preflight records GITHUB_DEGRADED and breaks instead of dying). The final
191
+ # summary's degraded-failure line is intentionally NOT emitted here: dry-run
192
+ # makes no changes, so report_and_exit() only records the degraded failure when
193
+ # DRY_RUN=0 — that real-run summary path is a deliberate no-op under --dry-run.
194
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
195
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
196
+ assert_contains "sG9: --allow-no-github continues" \
197
+ "continuing without GitHub access" "$OUT"
198
+
199
+ # --- Scenario G9b: git-missing AND github-missing both surface (no masking) --
200
+ # The GitHub-degraded reason must ACCUMULATE onto the earlier git-missing one,
201
+ # not overwrite it — so a host lacking both never loses the git problem behind
202
+ # the GitHub message. Both warnings must appear in the same run.
203
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" NANO_INSTALL_GH_STATE="missing" \
204
+ NANO_INSTALL_GIT_STATE="missing" \
205
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --allow-no-github 2>&1)
206
+ assert_contains "sG9b: git-missing warning still shown alongside github" \
207
+ "git not found on this host" "$OUT"
208
+ assert_contains "sG9b: github-missing continue also shown" \
209
+ "continuing without GitHub access" "$OUT"
210
+
211
+ # ===========================================================================
212
+ # Phase 2 — install & run the Nano Workforce app (nano-workforce#583)
213
+ # ===========================================================================
214
+
215
+ # --- Scenario 6: phase-2 dry-run emits the full console call sequence -------
216
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
217
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
218
+ assert_contains "s6: urban toolkit install" \
219
+ "POST http://localhost:8080/console/api/urban/install" "$OUT"
220
+ assert_contains "s6: extension install with pkg" \
221
+ 'POST http://localhost:8080/console/api/extensions/install {"pkg":"@nanobpm/nano-workforce"}' "$OUT"
222
+ assert_contains "s6: GET projects to confirm the template" \
223
+ "GET http://localhost:8080/console/api/projects" "$OUT"
224
+ assert_contains "s6: create project from template nano-workforce" \
225
+ '{"name":"Workforce","template":"nano-workforce"}' "$OUT"
226
+ assert_contains "s6: 409-exists convergence branch documented" \
227
+ "on 409 (project exists) we skip scaffolding" "$OUT"
228
+ assert_contains "s6: NANO_WORKFORCE_BASE_URL is console origin + app-view prefix" \
229
+ '"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Workforce"' "$OUT"
230
+ assert_contains "s6: NANOBPMN_BASE_URL written" \
231
+ '"NANOBPMN_BASE_URL":"http://localhost:8080"' "$OUT"
232
+ assert_contains "s6: PR_REVIEW_PORT written" '"PR_REVIEW_PORT":"3000"' "$OUT"
233
+ assert_contains "s6: run the project" \
234
+ "POST http://localhost:8080/console/api/projects/Workforce/run" "$OUT"
235
+ assert_contains "s6: readiness poll of the app's own /app/api/version" \
236
+ "poll GET http://localhost:8080/console/app-view/Workforce/app/api/version" "$OUT"
237
+ assert_contains "s6: surface app-view URL" \
238
+ "http://localhost:8080/console/app-view/Workforce/" "$OUT"
239
+ assert_contains "s6: surface Tasks inbox" \
240
+ "http://localhost:8080/console/app-view/Workforce/tasks" "$OUT"
241
+ assert_contains "s6: surface Delivery Graphs" \
242
+ "http://localhost:8080/console/app-view/Workforce/delivery-graphs" "$OUT"
243
+ assert_contains "s6: surface agent guide" \
244
+ "http://localhost:8080/console/app-view/Workforce/app/api/agent" "$OUT"
245
+ assert_contains "s6: surface MCP endpoint" \
246
+ "http://localhost:8080/console/app-view/Workforce/app/mcp" "$OUT"
247
+ # Dry-run must not assert readiness: it changed nothing, so it must not claim the
248
+ # app "is up" / "is running" — it states the surfaces are what *would* be available.
249
+ assert_not_contains "s6: dry-run does not falsely claim the app is up" \
250
+ "Nano Workforce is up" "$OUT"
251
+ assert_not_contains "s6: dry-run does not falsely claim the app is running" \
252
+ "the Nano Workforce app is running" "$OUT"
253
+ assert_contains "s6: dry-run frames surfaces as would-be-available" \
254
+ "would be available" "$OUT"
255
+ assert_contains "s6: dry-run final report states no changes were made" \
256
+ "Dry-run complete — no changes were made" "$OUT"
257
+
258
+ # --- Scenario 6b: --project-name flows through every URL and body ----------
259
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
260
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --project-name Fleet 2>&1)
261
+ assert_contains "s6b: project name in the create body" \
262
+ '{"name":"Fleet","template":"nano-workforce"}' "$OUT"
263
+ assert_contains "s6b: project name in the app-view base" \
264
+ '"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Fleet"' "$OUT"
265
+ assert_contains "s6b: project name in the run URL" \
266
+ "POST http://localhost:8080/console/api/projects/Fleet/run" "$OUT"
267
+
268
+ # --- Scenario 6c: GITHUB_TOKEN is redacted in dry-run, never printed --------
269
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" GITHUB_TOKEN="SHOULD-NOT-APPEAR-TOKEN" \
270
+ NANO_INSTALL_TOKEN_STATE="ok" \
271
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
272
+ assert_contains "s6c: token key present, masked" '"GITHUB_TOKEN":"***"' "$OUT"
273
+ assert_not_contains "s6c: token value never printed" "SHOULD-NOT-APPEAR-TOKEN" "$OUT"
274
+
275
+ # --- Scenario 7: --skip-app runs phase 1 only, emits no console calls -------
276
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
277
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --skip-app 2>&1)
278
+ assert_not_contains "s7: no console API calls under --skip-app" "/console/api/" "$OUT"
279
+ assert_contains "s7: skip note shown" "Phase 2 skipped (--skip-app)" "$OUT"
280
+ assert_contains "s7: dry-run + skip-app report states no changes were made" \
281
+ "Dry-run complete — no changes were made" "$OUT"
282
+
283
+ # --- Scenarios 8-15: live phase-2 flow against a stubbed node console -------
284
+ if command -v node >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
285
+ STUBDIR=$(mktemp -d "${TMPDIR:-/tmp}/nwf-smoke.XXXXXX")
286
+ STUB="$STUBDIR/stub-console.cjs"
287
+ # The repo's package.json sets "type":"module", so the CommonJS stub must be
288
+ # a .cjs file. It answers just enough of the console API to exercise the flow.
289
+ cat > "$STUB" <<'CJS'
290
+ const http = require('http');
291
+ const mode = process.env.STUB_MODE || 'happy'; // happy | exists | timeout
292
+ let ran = false;
293
+ const srv = http.createServer((req, res) => {
294
+ const url = req.url, m = req.method;
295
+ const json = (c, o) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
296
+ req.on('data', () => {}); req.on('end', () => {
297
+ if (m === 'GET' && url === '/console/api/projects') {
298
+ const projects = mode === 'exists' ? [{ name: 'Workforce', template: 'nano-workforce' }] : [];
299
+ return json(200, { templates: [{ id: 'nano-workforce', pack: 'nano-workforce' }], projects });
300
+ }
301
+ if (m === 'POST' && url === '/console/api/urban/install') return json(200, { urbanAvailable: false });
302
+ if (m === 'POST' && url === '/console/api/extensions/install') return json(201, { id: 'nano-workforce' });
303
+ if (m === 'POST' && url === '/console/api/projects') return mode === 'exists' ? json(409, { error: 'exists' }) : json(201, { name: 'Workforce' });
304
+ if (m === 'PUT' && url === '/console/api/projects/Workforce/config') return json(200, { ok: true });
305
+ if (m === 'POST' && url === '/console/api/projects/Workforce/run') { ran = true; return json(200, { state: 'running' }); }
306
+ if (m === 'GET' && url === '/console/app-view/Workforce/app/api/version') {
307
+ if (mode === 'timeout') return json(404, {});
308
+ return ran ? json(200, { version: '9.9.9' }) : json(404, {});
309
+ }
310
+ return json(404, { error: 'nomatch', url });
311
+ });
312
+ });
313
+ srv.listen(0, '127.0.0.1', () => console.log('PORT ' + srv.address().port));
314
+ setTimeout(() => process.exit(0), 30000); // self-terminate: never leak the stub
315
+ CJS
316
+
317
+ # run_phase2 <mode> [extra install.sh args...] -> sets PHASE2_RC, PHASE2_OUT
318
+ run_phase2() {
319
+ _mode=$1; shift
320
+ STUB_MODE="$_mode" node "$STUB" > "$STUBDIR/$_mode.log" 2>&1 &
321
+ _pid=$!
322
+ _port=''; _i=0
323
+ while [ "$_i" -lt 50 ]; do
324
+ _port=$(sed -n 's/^PORT //p' "$STUBDIR/$_mode.log" 2>/dev/null)
325
+ [ -n "$_port" ] && break
326
+ _i=$((_i + 1)); sleep 1
327
+ done
328
+ if [ -z "$_port" ]; then
329
+ kill "$_pid" 2>/dev/null || true
330
+ wait "$_pid" 2>/dev/null || true
331
+ PHASE2_OUT="stub console never reported a port for mode '$_mode'"; PHASE2_RC=127
332
+ return 0
333
+ fi
334
+ set +e
335
+ PHASE2_OUT=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 \
336
+ NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:$_port" \
337
+ NANO_INSTALL_APP_POLL_ATTEMPTS=3 NANO_INSTALL_APP_POLL_INTERVAL=0 \
338
+ sh "$SCRIPT" --yes "$@" 2>&1)
339
+ PHASE2_RC=$?
340
+ set -e
341
+ kill "$_pid" 2>/dev/null || true
342
+ wait "$_pid" 2>/dev/null || true
343
+ }
344
+
345
+ if [ -z "$STUBDIR" ] || [ ! -f "$STUB" ]; then
346
+ fail "s8-s15: could not create the stub console"
347
+ else
348
+ # Scenario 8 — happy path: success asserted via /app/api/version, exit 0.
349
+ run_phase2 happy
350
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s8: happy path exits zero"; else
351
+ fail "s8: happy path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
352
+ assert_contains "s8: readiness asserted via /app/api/version" "app is up" "$PHASE2_OUT"
353
+ assert_contains "s8: project created (not 409)" "project 'Workforce' created" "$PHASE2_OUT"
354
+
355
+ # Scenario 9 — existing project (409): configure + run, never re-scaffold.
356
+ run_phase2 exists
357
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s9: 409-exists converges (exit zero)"; else
358
+ fail "s9: 409-exists path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
359
+ assert_contains "s9: existing project configured+run, not re-scaffolded" \
360
+ "already exists — configuring and running it (not re-scaffolding)" "$PHASE2_OUT"
361
+
362
+ # Scenario 10 — readiness poll timeout: clear message, exit non-zero.
363
+ run_phase2 timeout
364
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s10: readiness timeout exits non-zero"; else
365
+ fail "s10: readiness timeout should exit non-zero"; fi
366
+ assert_contains "s10: timeout is a clear readiness message" \
367
+ "did not answer 200 on /app/api/version" "$PHASE2_OUT"
368
+ assert_contains "s10: timeout names the self-heal-on-Run dependency" \
369
+ "self-heal-on-Run" "$PHASE2_OUT"
370
+
371
+ # Scenario 11 — console off/unreachable: early failure that mutates nothing.
372
+ set +e
373
+ OFF=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:1" \
374
+ sh "$SCRIPT" --yes 2>&1)
375
+ OFFRC=$?
376
+ set -e
377
+ if [ "$OFFRC" -ne 0 ]; then pass "s11: console-off exits non-zero"; else
378
+ fail "s11: console-off should exit non-zero"; fi
379
+ assert_contains "s11: console-off is a clear message" "console unreachable" "$OFF"
380
+ assert_not_contains "s11: console-off mutates nothing (no extension install)" \
381
+ "extensions/install" "$OFF"
382
+
383
+ # Scenario 12 — schemeless console origin: fail early with a clear message,
384
+ # never emit the "localhost:8080://localhost:8080"-style invalid origin.
385
+ set +e
386
+ SCHEMELESS=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="localhost:8080" \
387
+ sh "$SCRIPT" --yes 2>&1)
388
+ SCHEMELESSRC=$?
389
+ set -e
390
+ if [ "$SCHEMELESSRC" -ne 0 ]; then pass "s12: schemeless origin exits non-zero"; else
391
+ fail "s12: schemeless origin should exit non-zero"; fi
392
+ assert_contains "s12: schemeless origin is a clear message" \
393
+ "expected a URL with a scheme" "$SCHEMELESS"
394
+ assert_not_contains "s12: schemeless origin never doubles the authority" \
395
+ "localhost:8080://localhost:8080" "$SCHEMELESS"
396
+
397
+ # Scenario 13 — mktemp unavailable: api() must fall back to a safe noclobber
398
+ # temp file and the happy path must still converge. A PATH-shim `mktemp` that
399
+ # always fails forces mktemp_safe() down its fallback branch for the script.
400
+ SHIMDIR="$STUBDIR/shim"
401
+ mkdir -p "$SHIMDIR"
402
+ printf '#!/bin/sh\nexit 1\n' > "$SHIMDIR/mktemp"
403
+ chmod +x "$SHIMDIR/mktemp"
404
+ _oldpath="$PATH"
405
+ PATH="$SHIMDIR:$PATH"; export PATH
406
+ run_phase2 happy
407
+ PATH="$_oldpath"; export PATH
408
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s13: mktemp-unavailable fallback converges (exit zero)"; else
409
+ fail "s13: mktemp-unavailable fallback should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
410
+ assert_contains "s13: fallback still asserts readiness via /app/api/version" "app is up" "$PHASE2_OUT"
411
+
412
+ # Scenario 14 — a GITHUB_TOKEN carrying a control character (e.g. a stray
413
+ # newline) must be rejected with a clear error before any JSON config body
414
+ # is emitted, never producing invalid JSON that fails the console API
415
+ # opaquely. The token flows through json_str() unredacted on the live PUT.
416
+ _ctrl_token="$(printf 'fake-bad\ntoken')"
417
+ export GITHUB_TOKEN="$_ctrl_token"
418
+ run_phase2 happy
419
+ unset GITHUB_TOKEN
420
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s14: control-char token rejected (exit non-zero)"; else
421
+ fail "s14: control-char token should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
422
+ assert_contains "s14: control-char token has a clear message" \
423
+ "control characters" "$PHASE2_OUT"
424
+
425
+ # Scenario 15 — the control-char gate covers EVERY interpolated config value,
426
+ # not just the token: a non-token field (here PR_REVIEW_PORT) carrying a
427
+ # control character must be rejected the same way, naming the offending key.
428
+ _ctrl_port="$(printf '3000\nbad')"
429
+ export PR_REVIEW_PORT="$_ctrl_port"
430
+ run_phase2 happy
431
+ unset PR_REVIEW_PORT
432
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s15: control-char non-token field rejected (exit non-zero)"; else
433
+ fail "s15: control-char non-token field should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
434
+ assert_contains "s15: rejection names the offending field" \
435
+ "PR_REVIEW_PORT contains control characters" "$PHASE2_OUT"
436
+
437
+ rm -rf "$STUBDIR"
438
+ fi
439
+ else
440
+ pass "s8-s15: skipped (node or curl unavailable for the stubbed-console tests)"
441
+ fi
442
+
97
443
  if [ "$FAILED" -ne 0 ]; then
98
444
  printf '\nSMOKE TEST FAILED\n' >&2
99
445
  exit 1