@nanobpm/nano-workforce 0.152.0 → 0.153.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,9 @@
1
+ ## [0.153.0](https://github.com/nanobpm/nano-workforce/compare/v0.152.0...v0.153.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **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)
6
+
1
7
  ## [0.152.0](https://github.com/nanobpm/nano-workforce/compare/v0.151.1...v0.152.0) (2026-08-28)
2
8
 
3
9
  ### Features
package/README.md CHANGED
@@ -83,41 +83,56 @@ 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
+ **Phase 2 — the app.** It then talks to the nano console the engine brought up to
101
+ install the `@nanobpm/nano-workforce` extension, scaffold a **Workforce** project
102
+ from the `nano-workforce` template, write its `ProjectConfig.env` (including
103
+ `NANO_WORKFORCE_BASE_URL` set to the console origin + `/console/app-view/Workforce`
104
+ so the fleet's hooks resolve through the proxy — see [Fleet
105
+ networking](#fleet-networking-remote-workers)), and run it. It confirms readiness
106
+ by polling the app's own `GET /app/api/version` through the proxy, then prints the
107
+ cockpit URL, the Tasks inbox, the Delivery Graphs page, and the `/app/api/agent` +
108
+ `/app/mcp` pointers. Pass `--skip-app` to stop after phase 1.
98
109
 
99
110
  > ⚠️ The hired agents run **unattended** with `--permission yolo` — full tool
100
111
  > 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.
112
+ > a confirmation summary before it hires anything (and before it installs the app);
113
+ > pass `--yes` to skip both.
106
114
 
107
115
  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):
116
+ also fully scriptable and safe to re-run (it converges rather than duplicating — an
117
+ existing project is configured and run, never re-scaffolded):
109
118
 
110
119
  ```sh
111
120
  # Non-interactive (CI / re-provisioning); repeatable --harness <name>[:<model>][:<instances>]
112
121
  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
122
 
114
- # Preview every command without changing anything
123
+ # Name the app's project, or stop after phase 1 (engine + workforce only)
124
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --project-name Workforce
125
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --yes --skip-app
126
+
127
+ # Preview every command (including the console API calls) without changing anything
115
128
  curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh -s -- --harness copilot:gpt-5.4:1 --dry-run
116
129
  ```
117
130
 
118
131
  `qwen` must always be given an explicit model (it stalls without one). With no
119
132
  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
133
+ hanging. Phase 2 needs the engine's console reachable (the default `c8 nano start`
134
+ `studio` mode); an unreachable console is reported as an early failure that changes
135
+ nothing. Because it is served from `main`, a merged change to `install.sh` is live
121
136
  immediately.
122
137
 
123
138
  ### Prerequisites
package/install.sh CHANGED
@@ -79,6 +79,8 @@ DRY_RUN=0
79
79
  ASSUME_YES=0
80
80
  SHELL_OVERRIDE=''
81
81
  INSTALL_ADAPTERS=0 # auto-install missing adapters without prompting
82
+ SKIP_APP=0 # phase 1 only: bring up engine + workforce, don't install the app
83
+ PROJECT_NAME='' # console project name for the Nano Workforce app (default: Workforce)
82
84
  CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
83
85
  SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
84
86
  FAILURES='' # newline-separated failure notes (partial-success report)
@@ -98,6 +100,23 @@ if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1
98
100
  CLI='' # resolved c8ctl / c8 binary
99
101
  TTY='' # /dev/tty if usable, else empty
100
102
 
103
+ # ---------------------------------------------------------------------------
104
+ # Phase 2 (app install) configuration + test hooks.
105
+ # NANO_INSTALL_CONSOLE_ORIGIN — override the console/engine origin (tests).
106
+ # NANO_INSTALL_APP_POLL_ATTEMPTS — readiness-poll attempt count (tests).
107
+ # NANO_INSTALL_APP_POLL_INTERVAL — seconds between readiness polls (tests).
108
+ # CONSOLE_ORIGIN is the scheme://host:port the nano console+engine listen on
109
+ # (default http://localhost:8080); the console API lives under /console there.
110
+ CONSOLE_ORIGIN=''
111
+ APPVIEW_BASE=''
112
+ PROJECT=''
113
+ APP_POLL_ATTEMPTS="${NANO_INSTALL_APP_POLL_ATTEMPTS:-60}"
114
+ APP_POLL_INTERVAL="${NANO_INSTALL_APP_POLL_INTERVAL:-2}"
115
+ # Results of the last api() call.
116
+ API_STATUS=''
117
+ API_BODY=''
118
+ API_ERR=0
119
+
101
120
  # ---------------------------------------------------------------------------
102
121
  # Command runner — honours --dry-run for mutating commands.
103
122
  # ---------------------------------------------------------------------------
@@ -200,6 +219,11 @@ Options:
200
219
  -y, --yes Skip the confirmation summary.
201
220
  --install-adapters Auto-install a selected harness's missing ACP adapter
202
221
  (claude/pi) instead of prompting/skipping.
222
+ --project-name <name>
223
+ Console project name for the Nano Workforce app
224
+ (default: Workforce). [A-Za-z0-9._-] only.
225
+ --skip-app Run phase 1 only (engine + workforce); do NOT install,
226
+ scaffold, configure, or run the Nano Workforce app.
203
227
  --shell <bash|zsh|fish>
204
228
  Override shell-completion detection.
205
229
  --dry-run Print every command that would run; change nothing.
@@ -208,6 +232,13 @@ Options:
208
232
  Non-interactive example:
209
233
  curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
210
234
 
235
+ Phases:
236
+ 1. (nanobpm/nano-workforce#576) install @camunda8/cli + the nano plugin, hire
237
+ your harnesses, compose a workforce manifest, bring up engine + workforce.
238
+ 2. (nanobpm/nano-workforce#583) install the @nanobpm/nano-workforce console
239
+ extension, scaffold + configure + run a Workforce project, and print its
240
+ app-view URL. Skip it with --skip-app.
241
+
211
242
  With no controlling terminal (/dev/tty) and no --harness, the script exits
212
243
  non-zero rather than hanging on a prompt.
213
244
  EOF
@@ -230,6 +261,11 @@ parse_args() {
230
261
  shift ;;
231
262
  --yes|-y) ASSUME_YES=1; shift ;;
232
263
  --install-adapters) INSTALL_ADAPTERS=1; shift ;;
264
+ --skip-app) SKIP_APP=1; shift ;;
265
+ --project-name)
266
+ [ $# -ge 2 ] || die "--project-name requires a value"
267
+ PROJECT_NAME=$2; shift 2 ;;
268
+ --project-name=*) PROJECT_NAME=${1#--project-name=}; shift ;;
233
269
  --dry-run) DRY_RUN=1; shift ;;
234
270
  --shell)
235
271
  [ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
@@ -438,7 +474,7 @@ live_models() { # $1 harness -> newline list on stdout
438
474
  # Step 4/5 — interactive selection + model + instances
439
475
  # ---------------------------------------------------------------------------
440
476
  add_selection() { # $1 harness $2 model $3 instances
441
- if [ -n "$2" ] && ! printf '%s' "$2" | grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
477
+ if [ -n "$2" ] && ! printf '%s' "$2" | LC_ALL=C grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
442
478
  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
479
  fi
444
480
  SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
@@ -725,13 +761,388 @@ print_status_and_hints() {
725
761
  $CLI nano supervisor logs <worker> --follow tail one worker's logs
726
762
  $CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
727
763
  $CLI nano hire --list the hired profiles
764
+ EOF
765
+ }
766
+
767
+ # ---------------------------------------------------------------------------
768
+ # Phase 2 — install & run the Nano Workforce app via the nano console API
769
+ # (nanobpm/nano-workforce#583). Everything below talks to the console that
770
+ # `c8 nano start` brought up (default http://localhost:8080, console under
771
+ # /console). Under --dry-run, all live console calls are avoided — including
772
+ # read-only GETs: --dry-run prints the exact calls and mutates (and reads) nothing.
773
+ # ---------------------------------------------------------------------------
774
+
775
+ # scheme://host:port of a URL (drops any path), e.g. http://localhost:8080/v2
776
+ # -> http://localhost:8080.
777
+ origin_of() {
778
+ _u=$1
779
+ case "$_u" in
780
+ *://*) : ;;
781
+ *) 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)." ;;
782
+ esac
783
+ _scheme=${_u%%://*}
784
+ _rest=${_u#*://}
785
+ _auth=${_rest%%/*}
786
+ printf '%s://%s' "$_scheme" "$_auth"
787
+ }
788
+
789
+ # True (exit 0) iff $1 contains a control character (newline, tab, CR, …). Such
790
+ # characters cannot appear literally in a JSON string without full \uXXXX
791
+ # escaping, so we reject them at the config gate (see build_config_body) rather
792
+ # than emit invalid JSON that fails the console API opaquely.
793
+ has_control_chars() { [ "$(printf '%s' "$1" | LC_ALL=C tr -cd '[:cntrl:]' | wc -c)" -ne 0 ]; }
794
+
795
+ # Minimal JSON string escaper (backslash + double-quote). Control characters are
796
+ # rejected up front by build_config_body via has_control_chars(), so the values
797
+ # reaching here (URLs, ports, tokens) are already control-char-free.
798
+ json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
799
+
800
+ # Print the path of a freshly created, private (0600) temp file on stdout, or
801
+ # return non-zero if none could be made. Prefers mktemp; when mktemp is absent
802
+ # it falls back to a noclobber (set -C) create loop so a pre-existing path — a
803
+ # symlink/clobber attack on a shared /tmp — can never be followed or reused, and
804
+ # the file we read back is always the empty one we just created (never stale).
805
+ mktemp_safe() {
806
+ _mt=$(mktemp 2>/dev/null) && { printf '%s' "$_mt"; return 0; }
807
+ _dir=${TMPDIR:-/tmp}; _n=0
808
+ while [ "$_n" -lt 20 ]; do
809
+ _cand="${_dir%/}/nwf-install.$$.${_n}.$(_rand)"
810
+ # `set -C` makes `>` fail if the target exists (regular file OR symlink), so
811
+ # the create is atomic and cannot clobber/follow an attacker-planted path.
812
+ if ( set -C; umask 077; : >"$_cand" ) 2>/dev/null; then
813
+ printf '%s' "$_cand"; return 0
814
+ fi
815
+ _n=$((_n + 1))
816
+ done
817
+ return 1
818
+ }
819
+
820
+ # Best-effort small random token for the mktemp fallback path (entropy only —
821
+ # security rests on the noclobber create, not on this being unguessable; the
822
+ # per-iteration counter in the candidate path guarantees uniqueness regardless).
823
+ _rand() {
824
+ awk 'BEGIN{srand();printf "%d", rand()*1000000}' 2>/dev/null && return 0
825
+ date +%s 2>/dev/null || printf '%s' "$$"
826
+ }
827
+
828
+ # api METHOD PATH [BODY] [DISPLAY]
829
+ # Sets API_STATUS (HTTP code, "000" on transport failure), API_BODY, API_ERR
830
+ # (curl exit code, 0 on success). Under --dry-run nothing is sent: the call is
831
+ # printed (DISPLAY overrides BODY in the printout, e.g. to redact a token).
832
+ api() {
833
+ _method=$1; _path=$2; _body=${3:-}; _display=${4:-}
834
+ _url="${CONSOLE_ORIGIN}${_path}"
835
+ if [ "$DRY_RUN" -eq 1 ]; then
836
+ _shown=${_display:-$_body}
837
+ if [ -n "$_shown" ]; then
838
+ printf '+ %s %s %s\n' "$_method" "$_url" "$_shown" >&2
839
+ else
840
+ printf '+ %s %s\n' "$_method" "$_url" >&2
841
+ fi
842
+ API_STATUS='000'; API_BODY=''; API_ERR=0
843
+ return 0
844
+ fi
845
+ # Bound every request so a stalled endpoint (SYN/DNS/proxy hang) can't wedge
846
+ # the otherwise-bounded phase-2 poll loop: --connect-timeout caps the connect
847
+ # phase, --max-time caps the whole request. A timeout surfaces as a non-zero
848
+ # curl exit (API_ERR) → API_STATUS='000', handled like any transport failure.
849
+ _tmp=$(mktemp_safe) || { API_STATUS='000'; API_BODY=''; API_ERR=1; return 0; }
850
+ # Assemble curl's argument list with `set --` inside a SUBSHELL so it stays
851
+ # local to that subshell and never touches the script's own positional
852
+ # parameters ($1, $2, …) — keeping api() free of any $@ side effect. (POSIX
853
+ # already saves/restores positionals across a function call, but scoping the
854
+ # `set --` here makes that independent of shell quirks and future edits.)
855
+ API_STATUS=$(
856
+ set -- -sS --connect-timeout 10 --max-time 30 -X "$_method" -H 'Accept: application/json'
857
+ if [ -n "$_body" ]; then
858
+ set -- "$@" -H 'Content-Type: application/json' --data "$_body"
859
+ fi
860
+ curl "$@" -o "$_tmp" -w '%{http_code}' "$_url" 2>/dev/null
861
+ ) && API_ERR=0 || API_ERR=$?
862
+ [ "$API_ERR" -ne 0 ] && API_STATUS='000'
863
+ API_BODY=$(cat "$_tmp" 2>/dev/null || true)
864
+ rm -f "$_tmp"
865
+ return 0
866
+ }
867
+
868
+ # Human-readable outcome of the last api() call, for a user-facing error. On a
869
+ # transport failure api() forces API_STATUS='000' (curl couldn't complete the
870
+ # request — DNS/connect/timeout), which as a bare "HTTP 000" is meaningless and
871
+ # hides the real cause; describe it as a transport failure (with curl's exit
872
+ # code) instead. Otherwise report the actual HTTP status. One source so every
873
+ # phase-2 console call site renders 000 the same way (no drift).
874
+ api_outcome() {
875
+ if [ "$API_STATUS" = '000' ]; then
876
+ printf 'transport failure (curl exit %s) — could not reach %s' "$API_ERR" "$CONSOLE_ORIGIN"
877
+ else
878
+ printf 'HTTP %s' "$API_STATUS"
879
+ fi
880
+ }
728
881
 
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.
882
+ # The env map written into ProjectConfig.env (step 5). Real body + a token-
883
+ # redacted display are built the same way so they never drift.
884
+ build_config_body() { # $1 = redact? ("redact" to mask the token)
885
+ _tok="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
886
+ # json_str() only escapes \ and " — it does NOT escape control characters — so
887
+ # a stray newline/tab (from a mis-set env var or trailing whitespace) in ANY
888
+ # interpolated value would corrupt the JSON body and fail the console API
889
+ # opaquely. The config gate, not the escaper, is the single place that rejects
890
+ # them, so validate every value that flows into the body, not just the token.
891
+ for _cc in "GITHUB_TOKEN/GH_TOKEN=$_tok" \
892
+ "NANOBPMN_BASE_URL=$CONSOLE_ORIGIN" \
893
+ "NANO_WORKFORCE_BASE_URL=$APPVIEW_BASE" \
894
+ "PR_REVIEW_PORT=${PR_REVIEW_PORT:-3000}"; do
895
+ if has_control_chars "${_cc#*=}"; then
896
+ 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."
897
+ return 1
898
+ fi
899
+ done
900
+ if [ -n "$_tok" ]; then
901
+ if [ "${1:-}" = redact ]; then _tokval='***'; else _tokval=$(json_str "$_tok"); fi
902
+ _gh="\"GITHUB_TOKEN\":\"${_tokval}\""
903
+ else
904
+ # No token in the environment: rely on the host `gh` CLI transport instead.
905
+ _gh="\"NANO_PR_GITHUB_TRANSPORT\":\"auto\""
906
+ fi
907
+ printf '{"env":{%s,"NANOBPMN_BASE_URL":"%s","NANO_WORKFORCE_BASE_URL":"%s","PR_REVIEW_PORT":"%s"}}' \
908
+ "$_gh" "$(json_str "$CONSOLE_ORIGIN")" "$(json_str "$APPVIEW_BASE")" "$(json_str "${PR_REVIEW_PORT:-3000}")"
909
+ }
910
+
911
+ # Step 9 — the console must be reachable before we mutate anything.
912
+ app_preflight() {
913
+ info "Preflighting the console at ${CONSOLE_ORIGIN}/console"
914
+ api GET /console/api/projects
915
+ if [ "$DRY_RUN" -eq 1 ]; then
916
+ note "dry-run: assuming the console is reachable."
917
+ return 0
918
+ fi
919
+ if [ "$API_ERR" -ne 0 ] || [ "$API_STATUS" = '000' ]; then
920
+ err "console unreachable at ${CONSOLE_ORIGIN}/console/api (curl exit ${API_ERR})."
921
+ note "The engine was likely started with NANOBPMN_CONSOLE=off/observe."
922
+ note "'c8 nano start' defaults to 'studio' (console on) — check your engine config."
923
+ note "Nothing was changed; phase 1 (engine + workforce) is intact."
924
+ record_failure "app: console unreachable — no changes made"
925
+ return 1
926
+ fi
927
+ case "$API_STATUS" in
928
+ 2*) ok "console reachable" ;;
929
+ *) err "console preflight GET /console/api/projects -> HTTP $API_STATUS"
930
+ record_failure "app: console preflight HTTP $API_STATUS"
931
+ return 1 ;;
932
+ esac
933
+ }
934
+
935
+ # Step 10 — confirm (keys, never values).
936
+ confirm_app() {
937
+ info "About to install the Nano Workforce app"
938
+ note "console extension : @nanobpm/nano-workforce"
939
+ note "project : ${PROJECT} (from template 'nano-workforce')"
940
+ if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then _ghkey='GITHUB_TOKEN'; else _ghkey='NANO_PR_GITHUB_TRANSPORT'; fi
941
+ note "env keys written : ${_ghkey}, NANOBPMN_BASE_URL, NANO_WORKFORCE_BASE_URL, PR_REVIEW_PORT (values not shown)"
942
+ note "app-view URL : ${APPVIEW_BASE}/"
943
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
944
+ [ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
945
+ return 0
946
+ fi
947
+ if [ -z "$TTY" ]; then
948
+ die "refusing to install the app without confirmation and no /dev/tty — re-run with --yes."
949
+ fi
950
+ if confirm "Install and run the Nano Workforce app now?"; then
951
+ return 0
952
+ fi
953
+ note "skipping app install at user request (phase 1 is up and usable)."
954
+ SKIP_APP=1 # keep report_and_exit honest: the app was explicitly skipped
955
+ return 1
956
+ }
957
+
958
+ # Steps 11.1–11.6 — install extension, scaffold, configure, run.
959
+ app_provision() {
960
+ # 11.1 — ensure the Urban toolkit (idempotent no-op when urban resolves).
961
+ # Do NOT gate on urbanAvailable: a live instance can report false while a
962
+ # Workforce project runs happily — treat the response as informational.
963
+ info "Installing the Urban toolkit"
964
+ api POST /console/api/urban/install
965
+ if [ "$DRY_RUN" -eq 0 ]; then
966
+ case "$API_STATUS" in
967
+ 2*) ok "urban toolkit ensured" ;;
968
+ *) warn "urban install returned $(api_outcome) (informational — continuing)" ;;
969
+ esac
970
+ fi
971
+
972
+ # 11.2 — install the console extension. Idempotent: an existing extension is
973
+ # upgraded (2xx) or already present (409/2xx) — either way, continue.
974
+ info "Installing the @nanobpm/nano-workforce console extension"
975
+ api POST /console/api/extensions/install '{"pkg":"@nanobpm/nano-workforce"}'
976
+ if [ "$DRY_RUN" -eq 0 ]; then
977
+ case "$API_STATUS" in
978
+ 2*) ok "extension installed/upgraded" ;;
979
+ 409) note "extension already installed — continuing." ;;
980
+ *) err "extension install -> $(api_outcome)"
981
+ [ -n "$API_BODY" ] && note "$API_BODY"
982
+ record_failure "app: extension install $(api_outcome)"
983
+ return 1 ;;
984
+ esac
985
+ fi
986
+
987
+ # 11.3 — confirm the pack contributed template id 'nano-workforce'. NB the
988
+ # templates come from GET /console/api/projects, NOT GET /console/api/config/ide
989
+ # (which returns {} live); the npm pkg name is not the template id.
990
+ info "Confirming the 'nano-workforce' template is available"
991
+ api GET /console/api/projects
992
+ if [ "$DRY_RUN" -eq 1 ]; then
993
+ note "dry-run: would confirm template 'nano-workforce' in the projects listing."
994
+ else
995
+ case "$API_STATUS" in
996
+ 2*) : ;;
997
+ *) err "listing projects/templates -> $(api_outcome)"
998
+ record_failure "app: list projects $(api_outcome)"
999
+ return 1 ;;
1000
+ esac
1001
+ if printf '%s' "$API_BODY" | grep -Eq '"id"[[:space:]]*:[[:space:]]*"nano-workforce"'; then
1002
+ ok "template 'nano-workforce' present"
1003
+ else
1004
+ err "the extension did not contribute template 'nano-workforce'."
1005
+ note "Install may be mid-flight; re-run, or check the console extensions list."
1006
+ record_failure "app: template 'nano-workforce' missing after extension install"
1007
+ return 1
1008
+ fi
1009
+ fi
1010
+
1011
+ # 11.4 — scaffold the project. 409 => it already exists: DO NOT re-scaffold
1012
+ # (app.db lives in the project); fall through to configure + run.
1013
+ info "Scaffolding project '$PROJECT' from template 'nano-workforce'"
1014
+ _create_body=$(printf '{"name":"%s","template":"nano-workforce"}' "$(json_str "$PROJECT")")
1015
+ api POST /console/api/projects "$_create_body"
1016
+ if [ "$DRY_RUN" -eq 1 ]; then
1017
+ note "dry-run: on 409 (project exists) we skip scaffolding and continue to configure + run."
1018
+ else
1019
+ case "$API_STATUS" in
1020
+ 2*) ok "project '$PROJECT' created" ;;
1021
+ 409) note "project '$PROJECT' already exists — configuring and running it (not re-scaffolding)." ;;
1022
+ *) err "createProject -> $(api_outcome)"
1023
+ [ -n "$API_BODY" ] && note "$API_BODY"
1024
+ record_failure "app: createProject $(api_outcome)"
1025
+ return 1 ;;
1026
+ esac
1027
+ fi
1028
+
1029
+ # 11.5 — write ProjectConfig.env (token redacted in dry-run output).
1030
+ info "Configuring project '$PROJECT' (ProjectConfig.env)"
1031
+ _cfg_body=$(build_config_body) || {
1032
+ record_failure "app: invalid ProjectConfig.env value (control characters)"
1033
+ return 1
1034
+ }
1035
+ _cfg_show=$(build_config_body redact) || {
1036
+ record_failure "app: invalid ProjectConfig.env value (control characters)"
1037
+ return 1
1038
+ }
1039
+ api PUT "/console/api/projects/${PROJECT}/config" "$_cfg_body" "$_cfg_show"
1040
+ if [ "$DRY_RUN" -eq 0 ]; then
1041
+ case "$API_STATUS" in
1042
+ 2*) ok "config written (NANO_WORKFORCE_BASE_URL=${APPVIEW_BASE})" ;;
1043
+ *) err "PUT project config -> $(api_outcome)"
1044
+ [ -n "$API_BODY" ] && note "$API_BODY"
1045
+ record_failure "app: PUT config $(api_outcome)"
1046
+ return 1 ;;
1047
+ esac
1048
+ fi
1049
+
1050
+ # 11.6 — run. Running an already-running project is a no-op that returns its
1051
+ # current RunState, so this is safe to re-run (converges, never force-restarts).
1052
+ info "Running project '$PROJECT'"
1053
+ api POST "/console/api/projects/${PROJECT}/run"
1054
+ if [ "$DRY_RUN" -eq 0 ]; then
1055
+ case "$API_STATUS" in
1056
+ 2*) ok "run requested" ;;
1057
+ *) err "runProject -> $(api_outcome)"
1058
+ [ -n "$API_BODY" ] && note "$API_BODY"
1059
+ record_failure "app: runProject $(api_outcome)"
1060
+ return 1 ;;
1061
+ esac
1062
+ fi
1063
+ }
1064
+
1065
+ # Step 12 — assert readiness by polling the app's OWN /app/api/version through
1066
+ # the proxy. The runProject response reports the supervisor's state, not the
1067
+ # app's readiness, so never trust it alone.
1068
+ app_verify() {
1069
+ info "Waiting for the app to answer /app/api/version"
1070
+ if [ "$DRY_RUN" -eq 1 ]; then
1071
+ note "dry-run: would poll GET ${APPVIEW_BASE}/app/api/version until HTTP 200."
1072
+ return 0
1073
+ fi
1074
+ # APP_POLL_ATTEMPTS/APP_POLL_INTERVAL come from env hooks and feed `test`
1075
+ # arithmetic + `sleep`. A non-integer value would make `set -e` abort with an
1076
+ # opaque shell error mid-loop; validate them as non-negative integers up front
1077
+ # and record a clear failure instead.
1078
+ case "$APP_POLL_ATTEMPTS" in
1079
+ ''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_ATTEMPTS must be a non-negative integer (got '${APP_POLL_ATTEMPTS}')"; return 1 ;;
1080
+ esac
1081
+ case "$APP_POLL_INTERVAL" in
1082
+ ''|*[!0-9]*) record_failure "app: NANO_INSTALL_APP_POLL_INTERVAL must be a non-negative integer (got '${APP_POLL_INTERVAL}')"; return 1 ;;
1083
+ esac
1084
+ _n=0
1085
+ while [ "$_n" -lt "$APP_POLL_ATTEMPTS" ]; do
1086
+ api GET "/console/app-view/${PROJECT}/app/api/version"
1087
+ case "$API_STATUS" in
1088
+ 2*) ok "app is up ($(printf '%s' "$API_BODY" | tr -d '[:space:]' | cut -c1-80))"; return 0 ;;
1089
+ esac
1090
+ _n=$((_n + 1))
1091
+ [ "$_n" -lt "$APP_POLL_ATTEMPTS" ] && sleep "$APP_POLL_INTERVAL"
1092
+ done
1093
+ err "the app did not answer 200 on /app/api/version within $((APP_POLL_ATTEMPTS * APP_POLL_INTERVAL))s (last HTTP ${API_STATUS})."
1094
+ note "It may have booted but not finished self-healing its Urban surface (deps + codegen)."
1095
+ note "Verify your nano-bpm build carries the self-heal-on-Run fix (Magikcraft/nano-bpm#1036); check: ${APPVIEW_BASE}/app/api/version"
1096
+ record_failure "app: readiness poll timed out (last HTTP ${API_STATUS})"
1097
+ return 1
1098
+ }
1099
+
1100
+ # Step 13 — the operator surfaces.
1101
+ app_surfaces() {
1102
+ if [ "$DRY_RUN" -eq 1 ]; then
1103
+ info "dry-run: no calls were made — the following surfaces would be available once installed:"
1104
+ else
1105
+ info "Nano Workforce is up"
1106
+ fi
1107
+ cat >&2 <<EOF
1108
+ App (cockpit) : ${APPVIEW_BASE}/
1109
+ Tasks inbox : ${APPVIEW_BASE}/tasks
1110
+ Delivery Graphs : ${APPVIEW_BASE}/delivery-graphs
1111
+ Agent guide : ${APPVIEW_BASE}/app/api/agent
1112
+ MCP endpoint : ${APPVIEW_BASE}/app/mcp
1113
+
1114
+ Drive it by pointing a coding agent at the agent guide (GET /app/api/agent),
1115
+ or add the instance's /app/mcp as an MCP server, e.g.:
1116
+ copilot mcp add --transport http workforce ${APPVIEW_BASE}/app/mcp
732
1117
  EOF
733
1118
  }
734
1119
 
1120
+ # Phase 2 entrypoint. Returns non-zero on a genuine failure (recorded) so the
1121
+ # final report exits non-zero; a benign user decline returns 1 WITHOUT recording.
1122
+ install_app() {
1123
+ info "Phase 2 — install & run the Nano Workforce app (nanobpm/nano-workforce#583)"
1124
+
1125
+ PROJECT=${PROJECT_NAME:-Workforce}
1126
+ case "$PROJECT" in
1127
+ ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0-9._-]*) die "invalid --project-name '$PROJECT': only [A-Za-z0-9._-] are allowed." ;;
1128
+ esac
1129
+ CONSOLE_ORIGIN=$(origin_of "${NANO_INSTALL_CONSOLE_ORIGIN:-${NANOBPMN_BASE_URL:-http://localhost:8080}}")
1130
+ APPVIEW_BASE="${CONSOLE_ORIGIN}/console/app-view/${PROJECT}"
1131
+
1132
+ if [ "$DRY_RUN" -eq 0 ] && ! command -v curl >/dev/null 2>&1; then
1133
+ err "curl not found — phase 2 needs curl to talk to the console API."
1134
+ note "Install curl and re-run (phase 1 is up and usable), or use --skip-app."
1135
+ record_failure "app: curl missing — phase 2 skipped"
1136
+ return 1
1137
+ fi
1138
+
1139
+ app_preflight || return 1
1140
+ confirm_app || return 1
1141
+ app_provision || return 1
1142
+ app_verify || return 1
1143
+ app_surfaces
1144
+ }
1145
+
735
1146
  # ---------------------------------------------------------------------------
736
1147
  # Partial-success report
737
1148
  # ---------------------------------------------------------------------------
@@ -741,7 +1152,19 @@ report_and_exit() {
741
1152
  printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
742
1153
  exit 1
743
1154
  fi
744
- ok "Done engine up, workforce of hired agents up."
1155
+ if [ "$SKIP_APP" -eq 1 ]; then
1156
+ if [ "$DRY_RUN" -eq 1 ]; then
1157
+ ok "Dry-run complete — no changes were made (app install skipped via --skip-app)."
1158
+ else
1159
+ ok "Done — engine up, workforce of hired agents up (app install skipped)."
1160
+ fi
1161
+ else
1162
+ if [ "$DRY_RUN" -eq 1 ]; then
1163
+ ok "Dry-run complete — no changes were made; the above is what would run."
1164
+ else
1165
+ ok "Done — engine + workforce up, and the Nano Workforce app is running."
1166
+ fi
1167
+ fi
745
1168
  exit 0
746
1169
  }
747
1170
 
@@ -776,7 +1199,23 @@ main() {
776
1199
  compose_workforce
777
1200
  bring_up || true
778
1201
  print_status_and_hints
1202
+ if [ "$SKIP_APP" -eq 1 ]; then
1203
+ info "Phase 2 skipped (--skip-app)"
1204
+ note "The Nano Workforce app was NOT installed — re-run without --skip-app to"
1205
+ note "install the console extension, scaffold + configure + run the app."
1206
+ else
1207
+ install_app || true
1208
+ fi
779
1209
  report_and_exit
780
1210
  }
781
1211
 
1212
+ # Undocumented test hook: exercise phase 2 in isolation against a stub console
1213
+ # (no CLI install, no engine), used by the hermetic readiness/convergence tests.
1214
+ if [ "${NANO_INSTALL_TEST_PHASE2_ONLY:-0}" = 1 ]; then
1215
+ parse_args "$@"
1216
+ init_tty
1217
+ install_app || true
1218
+ report_and_exit
1219
+ fi
1220
+
782
1221
  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.153.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,237 @@ 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
+ # Phase 2 — install & run the Nano Workforce app (nano-workforce#583)
104
+ # ===========================================================================
105
+
106
+ # --- Scenario 6: phase-2 dry-run emits the full console call sequence -------
107
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
108
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
109
+ assert_contains "s6: urban toolkit install" \
110
+ "POST http://localhost:8080/console/api/urban/install" "$OUT"
111
+ assert_contains "s6: extension install with pkg" \
112
+ 'POST http://localhost:8080/console/api/extensions/install {"pkg":"@nanobpm/nano-workforce"}' "$OUT"
113
+ assert_contains "s6: GET projects to confirm the template" \
114
+ "GET http://localhost:8080/console/api/projects" "$OUT"
115
+ assert_contains "s6: create project from template nano-workforce" \
116
+ '{"name":"Workforce","template":"nano-workforce"}' "$OUT"
117
+ assert_contains "s6: 409-exists convergence branch documented" \
118
+ "on 409 (project exists) we skip scaffolding" "$OUT"
119
+ assert_contains "s6: NANO_WORKFORCE_BASE_URL is console origin + app-view prefix" \
120
+ '"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Workforce"' "$OUT"
121
+ assert_contains "s6: NANOBPMN_BASE_URL written" \
122
+ '"NANOBPMN_BASE_URL":"http://localhost:8080"' "$OUT"
123
+ assert_contains "s6: PR_REVIEW_PORT written" '"PR_REVIEW_PORT":"3000"' "$OUT"
124
+ assert_contains "s6: run the project" \
125
+ "POST http://localhost:8080/console/api/projects/Workforce/run" "$OUT"
126
+ assert_contains "s6: readiness poll of the app's own /app/api/version" \
127
+ "poll GET http://localhost:8080/console/app-view/Workforce/app/api/version" "$OUT"
128
+ assert_contains "s6: surface app-view URL" \
129
+ "http://localhost:8080/console/app-view/Workforce/" "$OUT"
130
+ assert_contains "s6: surface Tasks inbox" \
131
+ "http://localhost:8080/console/app-view/Workforce/tasks" "$OUT"
132
+ assert_contains "s6: surface Delivery Graphs" \
133
+ "http://localhost:8080/console/app-view/Workforce/delivery-graphs" "$OUT"
134
+ assert_contains "s6: surface agent guide" \
135
+ "http://localhost:8080/console/app-view/Workforce/app/api/agent" "$OUT"
136
+ assert_contains "s6: surface MCP endpoint" \
137
+ "http://localhost:8080/console/app-view/Workforce/app/mcp" "$OUT"
138
+ # Dry-run must not assert readiness: it changed nothing, so it must not claim the
139
+ # app "is up" / "is running" — it states the surfaces are what *would* be available.
140
+ assert_not_contains "s6: dry-run does not falsely claim the app is up" \
141
+ "Nano Workforce is up" "$OUT"
142
+ assert_not_contains "s6: dry-run does not falsely claim the app is running" \
143
+ "the Nano Workforce app is running" "$OUT"
144
+ assert_contains "s6: dry-run frames surfaces as would-be-available" \
145
+ "would be available" "$OUT"
146
+ assert_contains "s6: dry-run final report states no changes were made" \
147
+ "Dry-run complete — no changes were made" "$OUT"
148
+
149
+ # --- Scenario 6b: --project-name flows through every URL and body ----------
150
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
151
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --project-name Fleet 2>&1)
152
+ assert_contains "s6b: project name in the create body" \
153
+ '{"name":"Fleet","template":"nano-workforce"}' "$OUT"
154
+ assert_contains "s6b: project name in the app-view base" \
155
+ '"NANO_WORKFORCE_BASE_URL":"http://localhost:8080/console/app-view/Fleet"' "$OUT"
156
+ assert_contains "s6b: project name in the run URL" \
157
+ "POST http://localhost:8080/console/api/projects/Fleet/run" "$OUT"
158
+
159
+ # --- Scenario 6c: GITHUB_TOKEN is redacted in dry-run, never printed --------
160
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" GITHUB_TOKEN="ghp_SHOULD_NOT_APPEAR" \
161
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run 2>&1)
162
+ assert_contains "s6c: token key present, masked" '"GITHUB_TOKEN":"***"' "$OUT"
163
+ assert_not_contains "s6c: token value never printed" "ghp_SHOULD_NOT_APPEAR" "$OUT"
164
+
165
+ # --- Scenario 7: --skip-app runs phase 1 only, emits no console calls -------
166
+ OUT=$(NANO_INSTALL_HARNESSES_OVERRIDE="copilot" \
167
+ sh "$SCRIPT" --harness copilot:gpt-5.4:1 --yes --dry-run --skip-app 2>&1)
168
+ assert_not_contains "s7: no console API calls under --skip-app" "/console/api/" "$OUT"
169
+ assert_contains "s7: skip note shown" "Phase 2 skipped (--skip-app)" "$OUT"
170
+ assert_contains "s7: dry-run + skip-app report states no changes were made" \
171
+ "Dry-run complete — no changes were made" "$OUT"
172
+
173
+ # --- Scenarios 8-15: live phase-2 flow against a stubbed node console -------
174
+ if command -v node >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
175
+ STUBDIR=$(mktemp -d "${TMPDIR:-/tmp}/nwf-smoke.XXXXXX")
176
+ STUB="$STUBDIR/stub-console.cjs"
177
+ # The repo's package.json sets "type":"module", so the CommonJS stub must be
178
+ # a .cjs file. It answers just enough of the console API to exercise the flow.
179
+ cat > "$STUB" <<'CJS'
180
+ const http = require('http');
181
+ const mode = process.env.STUB_MODE || 'happy'; // happy | exists | timeout
182
+ let ran = false;
183
+ const srv = http.createServer((req, res) => {
184
+ const url = req.url, m = req.method;
185
+ const json = (c, o) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
186
+ req.on('data', () => {}); req.on('end', () => {
187
+ if (m === 'GET' && url === '/console/api/projects') {
188
+ const projects = mode === 'exists' ? [{ name: 'Workforce', template: 'nano-workforce' }] : [];
189
+ return json(200, { templates: [{ id: 'nano-workforce', pack: 'nano-workforce' }], projects });
190
+ }
191
+ if (m === 'POST' && url === '/console/api/urban/install') return json(200, { urbanAvailable: false });
192
+ if (m === 'POST' && url === '/console/api/extensions/install') return json(201, { id: 'nano-workforce' });
193
+ if (m === 'POST' && url === '/console/api/projects') return mode === 'exists' ? json(409, { error: 'exists' }) : json(201, { name: 'Workforce' });
194
+ if (m === 'PUT' && url === '/console/api/projects/Workforce/config') return json(200, { ok: true });
195
+ if (m === 'POST' && url === '/console/api/projects/Workforce/run') { ran = true; return json(200, { state: 'running' }); }
196
+ if (m === 'GET' && url === '/console/app-view/Workforce/app/api/version') {
197
+ if (mode === 'timeout') return json(404, {});
198
+ return ran ? json(200, { version: '9.9.9' }) : json(404, {});
199
+ }
200
+ return json(404, { error: 'nomatch', url });
201
+ });
202
+ });
203
+ srv.listen(0, '127.0.0.1', () => console.log('PORT ' + srv.address().port));
204
+ setTimeout(() => process.exit(0), 30000); // self-terminate: never leak the stub
205
+ CJS
206
+
207
+ # run_phase2 <mode> [extra install.sh args...] -> sets PHASE2_RC, PHASE2_OUT
208
+ run_phase2() {
209
+ _mode=$1; shift
210
+ STUB_MODE="$_mode" node "$STUB" > "$STUBDIR/$_mode.log" 2>&1 &
211
+ _pid=$!
212
+ _port=''; _i=0
213
+ while [ "$_i" -lt 50 ]; do
214
+ _port=$(sed -n 's/^PORT //p' "$STUBDIR/$_mode.log" 2>/dev/null)
215
+ [ -n "$_port" ] && break
216
+ _i=$((_i + 1)); sleep 1
217
+ done
218
+ if [ -z "$_port" ]; then
219
+ kill "$_pid" 2>/dev/null || true
220
+ wait "$_pid" 2>/dev/null || true
221
+ PHASE2_OUT="stub console never reported a port for mode '$_mode'"; PHASE2_RC=127
222
+ return 0
223
+ fi
224
+ set +e
225
+ PHASE2_OUT=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 \
226
+ NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:$_port" \
227
+ NANO_INSTALL_APP_POLL_ATTEMPTS=3 NANO_INSTALL_APP_POLL_INTERVAL=0 \
228
+ sh "$SCRIPT" --yes "$@" 2>&1)
229
+ PHASE2_RC=$?
230
+ set -e
231
+ kill "$_pid" 2>/dev/null || true
232
+ wait "$_pid" 2>/dev/null || true
233
+ }
234
+
235
+ if [ -z "$STUBDIR" ] || [ ! -f "$STUB" ]; then
236
+ fail "s8-s15: could not create the stub console"
237
+ else
238
+ # Scenario 8 — happy path: success asserted via /app/api/version, exit 0.
239
+ run_phase2 happy
240
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s8: happy path exits zero"; else
241
+ fail "s8: happy path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
242
+ assert_contains "s8: readiness asserted via /app/api/version" "app is up" "$PHASE2_OUT"
243
+ assert_contains "s8: project created (not 409)" "project 'Workforce' created" "$PHASE2_OUT"
244
+
245
+ # Scenario 9 — existing project (409): configure + run, never re-scaffold.
246
+ run_phase2 exists
247
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s9: 409-exists converges (exit zero)"; else
248
+ fail "s9: 409-exists path should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
249
+ assert_contains "s9: existing project configured+run, not re-scaffolded" \
250
+ "already exists — configuring and running it (not re-scaffolding)" "$PHASE2_OUT"
251
+
252
+ # Scenario 10 — readiness poll timeout: clear message, exit non-zero.
253
+ run_phase2 timeout
254
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s10: readiness timeout exits non-zero"; else
255
+ fail "s10: readiness timeout should exit non-zero"; fi
256
+ assert_contains "s10: timeout is a clear readiness message" \
257
+ "did not answer 200 on /app/api/version" "$PHASE2_OUT"
258
+ assert_contains "s10: timeout names the self-heal-on-Run dependency" \
259
+ "self-heal-on-Run" "$PHASE2_OUT"
260
+
261
+ # Scenario 11 — console off/unreachable: early failure that mutates nothing.
262
+ set +e
263
+ OFF=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="http://127.0.0.1:1" \
264
+ sh "$SCRIPT" --yes 2>&1)
265
+ OFFRC=$?
266
+ set -e
267
+ if [ "$OFFRC" -ne 0 ]; then pass "s11: console-off exits non-zero"; else
268
+ fail "s11: console-off should exit non-zero"; fi
269
+ assert_contains "s11: console-off is a clear message" "console unreachable" "$OFF"
270
+ assert_not_contains "s11: console-off mutates nothing (no extension install)" \
271
+ "extensions/install" "$OFF"
272
+
273
+ # Scenario 12 — schemeless console origin: fail early with a clear message,
274
+ # never emit the "localhost:8080://localhost:8080"-style invalid origin.
275
+ set +e
276
+ SCHEMELESS=$(NANO_INSTALL_TEST_PHASE2_ONLY=1 NANO_INSTALL_CONSOLE_ORIGIN="localhost:8080" \
277
+ sh "$SCRIPT" --yes 2>&1)
278
+ SCHEMELESSRC=$?
279
+ set -e
280
+ if [ "$SCHEMELESSRC" -ne 0 ]; then pass "s12: schemeless origin exits non-zero"; else
281
+ fail "s12: schemeless origin should exit non-zero"; fi
282
+ assert_contains "s12: schemeless origin is a clear message" \
283
+ "expected a URL with a scheme" "$SCHEMELESS"
284
+ assert_not_contains "s12: schemeless origin never doubles the authority" \
285
+ "localhost:8080://localhost:8080" "$SCHEMELESS"
286
+
287
+ # Scenario 13 — mktemp unavailable: api() must fall back to a safe noclobber
288
+ # temp file and the happy path must still converge. A PATH-shim `mktemp` that
289
+ # always fails forces mktemp_safe() down its fallback branch for the script.
290
+ SHIMDIR="$STUBDIR/shim"
291
+ mkdir -p "$SHIMDIR"
292
+ printf '#!/bin/sh\nexit 1\n' > "$SHIMDIR/mktemp"
293
+ chmod +x "$SHIMDIR/mktemp"
294
+ _oldpath="$PATH"
295
+ PATH="$SHIMDIR:$PATH"; export PATH
296
+ run_phase2 happy
297
+ PATH="$_oldpath"; export PATH
298
+ if [ "$PHASE2_RC" -eq 0 ]; then pass "s13: mktemp-unavailable fallback converges (exit zero)"; else
299
+ fail "s13: mktemp-unavailable fallback should exit zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
300
+ assert_contains "s13: fallback still asserts readiness via /app/api/version" "app is up" "$PHASE2_OUT"
301
+
302
+ # Scenario 14 — a GITHUB_TOKEN carrying a control character (e.g. a stray
303
+ # newline) must be rejected with a clear error before any JSON config body
304
+ # is emitted, never producing invalid JSON that fails the console API
305
+ # opaquely. The token flows through json_str() unredacted on the live PUT.
306
+ _ctrl_token="$(printf 'ghp_bad\ntoken')"
307
+ export GITHUB_TOKEN="$_ctrl_token"
308
+ run_phase2 happy
309
+ unset GITHUB_TOKEN
310
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s14: control-char token rejected (exit non-zero)"; else
311
+ fail "s14: control-char token should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
312
+ assert_contains "s14: control-char token has a clear message" \
313
+ "control characters" "$PHASE2_OUT"
314
+
315
+ # Scenario 15 — the control-char gate covers EVERY interpolated config value,
316
+ # not just the token: a non-token field (here PR_REVIEW_PORT) carrying a
317
+ # control character must be rejected the same way, naming the offending key.
318
+ _ctrl_port="$(printf '3000\nbad')"
319
+ export PR_REVIEW_PORT="$_ctrl_port"
320
+ run_phase2 happy
321
+ unset PR_REVIEW_PORT
322
+ if [ "$PHASE2_RC" -ne 0 ]; then pass "s15: control-char non-token field rejected (exit non-zero)"; else
323
+ fail "s15: control-char non-token field should exit non-zero"; printf '%s\n' "$PHASE2_OUT" | sed 's/^/ | /' >&2; fi
324
+ assert_contains "s15: rejection names the offending field" \
325
+ "PR_REVIEW_PORT contains control characters" "$PHASE2_OUT"
326
+
327
+ rm -rf "$STUBDIR"
328
+ fi
329
+ else
330
+ pass "s8-s15: skipped (node or curl unavailable for the stubbed-console tests)"
331
+ fi
332
+
97
333
  if [ "$FAILED" -ne 0 ]; then
98
334
  printf '\nSMOKE TEST FAILED\n' >&2
99
335
  exit 1