@nanobpm/nano-workforce 0.151.1 → 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.
package/install.sh ADDED
@@ -0,0 +1,1221 @@
1
+ #!/bin/sh
2
+ # Nano Workforce — one-command onboarding installer.
3
+ #
4
+ # curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
5
+ #
6
+ # Takes a machine from "has some coding-agent CLIs installed" to "a running Nano
7
+ # engine with a supervised workforce of hired agents". It installs @camunda8/cli
8
+ # and the c8ctl-plugin-nano plugin, lets you pick which of your installed coding
9
+ # harnesses to hire (and with which model / how many instances), composes a
10
+ # declarative workforce manifest from those choices, and brings the engine and
11
+ # the workforce up.
12
+ #
13
+ # Scope: this script stops at "engine up, workforce up, agents polling". It does
14
+ # NOT install/deploy/run the Nano Workforce app itself (that is a follow-up).
15
+ #
16
+ # Constraints (see nanobpm/nano-workforce#576):
17
+ # - POSIX sh only (piped to `sh`, which is dash on many distros). No bashisms:
18
+ # no arrays, no `[[`, no `local`, no `read -a`, no `set -o pipefail`.
19
+ # - stdin is the curl pipe, NOT the keyboard: every prompt reads from /dev/tty.
20
+ # - never sudo on the user's behalf.
21
+ # - shellcheck -s sh clean (enforced in CI).
22
+
23
+ set -eu
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Curated data tables — refresh these against each harness's current docs.
27
+ # Kept together near the top so they are cheap to update.
28
+ #
29
+ # ACP invocation per harness (the base command line the worker spawns). Every
30
+ # hire uses --protocol acp; two harnesses ride an adapter binary:
31
+ #
32
+ # copilot copilot --acp (native; plugin appends --acp)
33
+ # kimi kimi acp (native subcommand)
34
+ # qwen qwen --experimental-acp (native, hidden flag)
35
+ # claude claude-code-acp (adapter: @zed-industries/claude-code-acp)
36
+ # pi pi-acp (adapter: pi-acp)
37
+ #
38
+ # The chosen model is baked into the --command as `--model <id>` so it actually
39
+ # reaches the harness (c8 nano hire --model only sets AGENT_MODEL in the env,
40
+ # which nothing forwards to the CLI automatically). --model is still passed to
41
+ # `hire` for bookkeeping (it shows in `hire --list` / `supervisor status`).
42
+ # ---------------------------------------------------------------------------
43
+
44
+ ALL_HARNESSES='kimi qwen copilot claude pi'
45
+
46
+ # Curated, known-good model ids for the non-queryable harnesses (copilot, claude,
47
+ # qwen). pi/kimi are queried live and fall back to these. Verify at refresh time.
48
+ MODELS_copilot='gpt-5.4 claude-sonnet-4.6 claude-opus-4.8'
49
+ MODELS_claude='sonnet opus haiku'
50
+ MODELS_qwen='qwen3-coder-plus qwen3-coder-flash qwen-max'
51
+ MODELS_pi='pi-fast pi-balanced pi-max'
52
+ MODELS_kimi='kimi-k2 kimi-k2-turbo'
53
+
54
+ MIN_NODE='22.18.0' # @camunda8/cli engines floor
55
+ PLUGIN='c8ctl-plugin-nano'
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Output helpers
59
+ # ---------------------------------------------------------------------------
60
+ if [ -t 2 ]; then
61
+ C_RESET=$(printf '\033[0m'); C_BOLD=$(printf '\033[1m')
62
+ C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m'); C_GRN=$(printf '\033[32m')
63
+ else
64
+ C_RESET=''; C_BOLD=''; C_RED=''; C_YEL=''; C_GRN=''
65
+ fi
66
+
67
+ info() { printf '%s\n' "${C_BOLD}==>${C_RESET} $*" >&2; }
68
+ note() { printf '%s\n' " $*" >&2; }
69
+ warn() { printf '%s\n' "${C_YEL}warning:${C_RESET} $*" >&2; }
70
+ err() { printf '%s\n' "${C_RED}error:${C_RESET} $*" >&2; }
71
+ ok() { printf '%s\n' "${C_GRN}ok:${C_RESET} $*" >&2; }
72
+
73
+ die() { err "$*"; exit 1; }
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Global state
77
+ # ---------------------------------------------------------------------------
78
+ DRY_RUN=0
79
+ ASSUME_YES=0
80
+ SHELL_OVERRIDE=''
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)
84
+ CLI_HARNESS_SPECS='' # newline-separated name[:model][:instances] from --harness
85
+ SELECTIONS='' # newline-separated "name<US>model<US>instances" (US = non-whitespace 0x1f, so an empty model field does not collapse under read)
86
+ FAILURES='' # newline-separated failure notes (partial-success report)
87
+ SEP=$(printf '\037') # US (unit separator): non-whitespace, so `read` never collapses adjacent/empty fields
88
+
89
+ # Test hooks (undocumented; used by the CI dry-run smoke test to stay hermetic).
90
+ # NANO_INSTALL_HARNESSES_OVERRIDE — space list of "detected" harnesses.
91
+ # NANO_INSTALL_ADAPTERS_PRESENT — space list of adapter bins to treat present.
92
+ HARNESS_OVERRIDE="${NANO_INSTALL_HARNESSES_OVERRIDE:-}"
93
+ ADAPTERS_PRESENT="${NANO_INSTALL_ADAPTERS_PRESENT:-}"
94
+ # Whether each override was *set at all* (even to ""). A set-but-empty value means
95
+ # "none detected/present" and must NOT fall back to command -v, or the smoke test
96
+ # stops being hermetic on a runner image that happens to ship a harness/adapter binary.
97
+ if [ "${NANO_INSTALL_HARNESSES_OVERRIDE+set}" = set ]; then HARNESS_OVERRIDE_SET=1; else HARNESS_OVERRIDE_SET=0; fi
98
+ if [ "${NANO_INSTALL_ADAPTERS_PRESENT+set}" = set ]; then ADAPTERS_PRESENT_SET=1; else ADAPTERS_PRESENT_SET=0; fi
99
+
100
+ CLI='' # resolved c8ctl / c8 binary
101
+ TTY='' # /dev/tty if usable, else empty
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
+
120
+ # ---------------------------------------------------------------------------
121
+ # Command runner — honours --dry-run for mutating commands.
122
+ # ---------------------------------------------------------------------------
123
+ run() {
124
+ if [ "$DRY_RUN" -eq 1 ]; then
125
+ printf '%s\n' "+ $*" >&2
126
+ return 0
127
+ fi
128
+ "$@"
129
+ }
130
+
131
+ # Like run() but for a command whose printed form differs from argv (a string we
132
+ # assembled). $1 is the human/dry-run string; the rest is the argv to execute.
133
+ run_as() {
134
+ _show=$1; shift
135
+ if [ "$DRY_RUN" -eq 1 ]; then
136
+ printf '%s\n' "+ $_show" >&2
137
+ return 0
138
+ fi
139
+ "$@"
140
+ }
141
+
142
+ record_failure() { FAILURES="${FAILURES}$1
143
+ "; }
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # TTY-backed prompting (stdin is the curl pipe, so read from /dev/tty)
147
+ # ---------------------------------------------------------------------------
148
+ init_tty() {
149
+ if [ -e /dev/tty ] && (: >/dev/tty) 2>/dev/null && (: </dev/tty) 2>/dev/null; then
150
+ TTY=/dev/tty
151
+ else
152
+ TTY=''
153
+ fi
154
+ }
155
+
156
+ # ask "<prompt>" -> sets ANS (empty string on EOF)
157
+ ask() {
158
+ ANS=''
159
+ [ -n "$TTY" ] || return 0
160
+ printf '%s' "$1" >"$TTY"
161
+ IFS= read -r ANS <"$TTY" || ANS=''
162
+ }
163
+
164
+ # confirm "<prompt>" -> returns 0 for yes
165
+ confirm() {
166
+ ask "$1 [y/N] "
167
+ case "$ANS" in
168
+ y|Y|yes|YES|Yes) return 0 ;;
169
+ *) return 1 ;;
170
+ esac
171
+ }
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Version comparison — is $1 >= $2 (dotted numeric, e.g. 22.18.0)?
175
+ # ---------------------------------------------------------------------------
176
+ ver_field() { # ver index(1..3) -> numeric field, missing = 0
177
+ _v=$1; _i=$2
178
+ _v=${_v%%[!0-9.]*}
179
+ case "$_i" in
180
+ 1) printf '%s' "${_v%%.*}" ;;
181
+ 2) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }; printf '%s' "${_r%%.*}" ;;
182
+ 3) _r=${_v#*.}; [ "$_r" = "$_v" ] && { printf '0'; return; }
183
+ _p=${_r#*.}; [ "$_p" = "$_r" ] && { printf '0'; return; }
184
+ printf '%s' "${_p%%.*}" ;;
185
+ esac
186
+ }
187
+
188
+ ver_ge() { # $1 >= $2 ?
189
+ _i=1
190
+ while [ "$_i" -le 3 ]; do
191
+ _a=$(ver_field "$1" "$_i"); _b=$(ver_field "$2" "$_i")
192
+ [ -z "$_a" ] && _a=0; [ -z "$_b" ] && _b=0
193
+ if [ "$_a" -gt "$_b" ]; then return 0; fi
194
+ if [ "$_a" -lt "$_b" ]; then return 1; fi
195
+ _i=$((_i + 1))
196
+ done
197
+ return 0
198
+ }
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Usage
202
+ # ---------------------------------------------------------------------------
203
+ usage() {
204
+ cat >&2 <<'EOF'
205
+ Nano Workforce installer — hire your coding harnesses and bring up a workforce.
206
+
207
+ Usage:
208
+ curl -fsSL https://raw.githubusercontent.com/nanobpm/nano-workforce/main/install.sh | sh
209
+ sh install.sh [options]
210
+
211
+ Options:
212
+ --harness <name>[:<model>][:<instances>]
213
+ Non-interactive selection; repeatable. <name> is one of
214
+ kimi|qwen|copilot|claude|pi. Omit :model for the harness
215
+ default (NOT allowed for qwen). Omit :instances for 5 on the
216
+ first selection, 1 thereafter. To set instances WITHOUT a
217
+ model, leave the model field empty: name::instances (e.g.
218
+ copilot::2 — 'copilot:2' means model "2", not 2 instances).
219
+ -y, --yes Skip the confirmation summary.
220
+ --install-adapters Auto-install a selected harness's missing ACP adapter
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.
227
+ --shell <bash|zsh|fish>
228
+ Override shell-completion detection.
229
+ --dry-run Print every command that would run; change nothing.
230
+ -h, --help Show this help.
231
+
232
+ Non-interactive example:
233
+ curl -fsSL .../install.sh | sh -s -- --harness copilot:gpt-5.4:5 --harness claude:opus:1 --yes
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
+
242
+ With no controlling terminal (/dev/tty) and no --harness, the script exits
243
+ non-zero rather than hanging on a prompt.
244
+ EOF
245
+ }
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Argument parsing
249
+ # ---------------------------------------------------------------------------
250
+ parse_args() {
251
+ while [ $# -gt 0 ]; do
252
+ case "$1" in
253
+ --harness)
254
+ [ $# -ge 2 ] || die "--harness requires a value (name[:model][:instances])"
255
+ CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}$2
256
+ "
257
+ shift 2 ;;
258
+ --harness=*)
259
+ CLI_HARNESS_SPECS="${CLI_HARNESS_SPECS}${1#--harness=}
260
+ "
261
+ shift ;;
262
+ --yes|-y) ASSUME_YES=1; shift ;;
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 ;;
269
+ --dry-run) DRY_RUN=1; shift ;;
270
+ --shell)
271
+ [ $# -ge 2 ] || die "--shell requires a value (bash|zsh|fish)"
272
+ SHELL_OVERRIDE=$2; shift 2 ;;
273
+ --shell=*) SHELL_OVERRIDE=${1#--shell=}; shift ;;
274
+ -h|--help) usage; exit 0 ;;
275
+ *) err "unknown option: $1"; usage; exit 2 ;;
276
+ esac
277
+ done
278
+ }
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Step 1 — Preflight
282
+ # ---------------------------------------------------------------------------
283
+ preflight() {
284
+ info "Preflight"
285
+ command -v node >/dev/null 2>&1 || die "node not found. Install Node >= ${MIN_NODE} (https://nodejs.org or use nvm), then re-run."
286
+ command -v npm >/dev/null 2>&1 || die "npm not found. Install Node/npm >= ${MIN_NODE} (https://nodejs.org), then re-run."
287
+ _nv=$(node -v 2>/dev/null | sed 's/^v//')
288
+ if ! ver_ge "$_nv" "$MIN_NODE"; then
289
+ die "node $_nv is too old — @camunda8/cli needs >= ${MIN_NODE}. Upgrade via https://nodejs.org or nvm, then re-run."
290
+ fi
291
+ ok "node $_nv (>= ${MIN_NODE}), npm present"
292
+
293
+ # Non-fatal: agents need GitHub access to do useful work.
294
+ if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
295
+ if command -v gh >/dev/null 2>&1; then
296
+ if ! gh auth status >/dev/null 2>&1; then
297
+ warn "gh is installed but not authenticated, and no GITHUB_TOKEN/GH_TOKEN is set."
298
+ note "Agents need GitHub access — run 'gh auth login' or export GITHUB_TOKEN before they start pulling work."
299
+ fi
300
+ else
301
+ warn "no gh CLI and no GITHUB_TOKEN/GH_TOKEN in the environment."
302
+ note "Agents need GitHub access — install gh (https://cli.github.com) and 'gh auth login', or export GITHUB_TOKEN."
303
+ fi
304
+ fi
305
+ }
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Step 2 — Install the CLI + plugin, version-gate on `nano workforce`
309
+ # ---------------------------------------------------------------------------
310
+ resolve_cli() {
311
+ if command -v c8ctl >/dev/null 2>&1; then CLI=c8ctl
312
+ elif command -v c8 >/dev/null 2>&1; then CLI=c8
313
+ else CLI=c8ctl; fi
314
+ }
315
+
316
+ install_cli() {
317
+ info "Installing @camunda8/cli"
318
+ if ! run npm i -g @camunda8/cli; then
319
+ err "'npm i -g @camunda8/cli' failed."
320
+ note "If this was an EACCES permissions error, do NOT sudo blindly. Options:"
321
+ note " - set a user npm prefix: npm config set prefix ~/.npm-global (then add its bin to PATH)"
322
+ note " - use nvm so global installs land in your user dir"
323
+ note " - run 'sudo npm i -g @camunda8/cli' yourself if you understand the implications"
324
+ exit 1
325
+ fi
326
+ resolve_cli
327
+ if [ "$DRY_RUN" -eq 0 ]; then
328
+ command -v "$CLI" >/dev/null 2>&1 || die "@camunda8/cli installed but '$CLI' is not on PATH — add npm's global bin dir to PATH and re-run."
329
+ fi
330
+ ok "@camunda8/cli installed ($CLI)"
331
+
332
+ # Shell completion — non-fatal.
333
+ info "Installing shell completion"
334
+ if [ -n "$SHELL_OVERRIDE" ]; then
335
+ if run "$CLI" completion install --shell "$SHELL_OVERRIDE"; then ok "completion installed (--shell $SHELL_OVERRIDE)"; else warn "completion install failed (non-fatal) — continuing."; fi
336
+ else
337
+ if run "$CLI" completion install; then ok "completion installed"; else warn "completion install failed (non-fatal) — continuing."; fi
338
+ fi
339
+
340
+ # Load the nano plugin — idempotent (load, else upgrade, else already present).
341
+ info "Loading the $PLUGIN plugin"
342
+ if run "$CLI" load plugin "$PLUGIN"; then
343
+ ok "plugin loaded"
344
+ elif run "$CLI" upgrade plugin "$PLUGIN"; then
345
+ ok "plugin upgraded"
346
+ elif [ "$DRY_RUN" -eq 1 ] || "$CLI" nano >/dev/null 2>&1; then
347
+ note "plugin already loaded — continuing."
348
+ else
349
+ die "could not load the $PLUGIN plugin. Try: $CLI load plugin $PLUGIN"
350
+ fi
351
+
352
+ version_gate
353
+ }
354
+
355
+ # Version-gate: `c8 nano workforce` must exist. The command supports `list --json`
356
+ # (documented "for the install script / CI"); on a plugin new enough to have the
357
+ # workforce subcommand that emits a JSON object, older plugins print top-level
358
+ # usage text instead. Detect the JSON.
359
+ version_gate() {
360
+ info "Checking plugin version (needs 'nano workforce')"
361
+ if [ "$DRY_RUN" -eq 1 ]; then
362
+ note "dry-run: skipping live version-gate probe."
363
+ return 0
364
+ fi
365
+ _out=$("$CLI" nano workforce list --json 2>/dev/null || true)
366
+ # Require the first non-whitespace character to be '{' — a real JSON object.
367
+ # An older plugin prints usage/help text, which never starts with '{'. We strip
368
+ # all whitespace and inspect the first character so leading blank lines or
369
+ # indentation don't fool the gate, and so help text that merely mentions
370
+ # "workers"/"version"/"name" can't produce a false positive.
371
+ if [ "$(printf '%s' "$_out" | tr -d '[:space:]' | cut -c1)" = '{' ]; then
372
+ ok "'nano workforce' available"
373
+ else
374
+ die "the installed $PLUGIN is too old: it has no 'nano workforce' command (needs jwulf/c8ctl-plugin-nano#117). Upgrade with: $CLI upgrade plugin $PLUGIN"
375
+ fi
376
+ }
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Step 3 — Detect installed harnesses
380
+ # ---------------------------------------------------------------------------
381
+ harness_detected() { # $1 harness -> 0 if present
382
+ if [ "$HARNESS_OVERRIDE_SET" -eq 1 ]; then
383
+ case " $HARNESS_OVERRIDE " in *" $1 "*) return 0 ;; *) return 1 ;; esac
384
+ fi
385
+ command -v "$1" >/dev/null 2>&1
386
+ }
387
+
388
+ DETECTED=''
389
+ detect_harnesses() {
390
+ info "Detecting installed coding harnesses"
391
+ DETECTED=''
392
+ for _h in $ALL_HARNESSES; do
393
+ if harness_detected "$_h"; then
394
+ DETECTED="${DETECTED}${_h} "
395
+ note "found: $_h"
396
+ else
397
+ note "not installed (skipping): $_h"
398
+ fi
399
+ done
400
+ DETECTED=$(printf '%s' "$DETECTED" | sed 's/ *$//')
401
+ [ -n "$DETECTED" ] || die "none of ${ALL_HARNESSES} are installed — install a coding-agent CLI first, then re-run."
402
+ }
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # ACP command / adapter tables
406
+ # ---------------------------------------------------------------------------
407
+ acp_base() { # $1 harness -> base ACP invocation
408
+ case "$1" in
409
+ copilot) printf 'copilot --acp' ;;
410
+ kimi) printf 'kimi acp' ;;
411
+ qwen) printf 'qwen --experimental-acp' ;;
412
+ claude) printf 'claude-code-acp' ;;
413
+ pi) printf 'pi-acp' ;;
414
+ *) printf '%s' "$1" ;;
415
+ esac
416
+ }
417
+
418
+ adapter_bin() { # $1 harness -> adapter binary name (empty if native)
419
+ case "$1" in
420
+ claude) printf 'claude-code-acp' ;;
421
+ pi) printf 'pi-acp' ;;
422
+ *) printf '' ;;
423
+ esac
424
+ }
425
+
426
+ adapter_pkg() { # $1 harness -> npm package for its adapter (empty if native)
427
+ case "$1" in
428
+ claude) printf '@zed-industries/claude-code-acp' ;;
429
+ pi) printf 'pi-acp' ;;
430
+ *) printf '' ;;
431
+ esac
432
+ }
433
+
434
+ adapter_present() { # $1 adapter bin -> 0 if present
435
+ if [ "$ADAPTERS_PRESENT_SET" -eq 1 ]; then
436
+ case " $ADAPTERS_PRESENT " in *" $1 "*) return 0 ;; *) return 1 ;; esac
437
+ fi
438
+ command -v "$1" >/dev/null 2>&1
439
+ }
440
+
441
+ # Assemble the full ACP command line for a harness+model (model baked in).
442
+ build_command() { # $1 harness $2 model
443
+ _cmd=$(acp_base "$1")
444
+ if [ -n "$2" ]; then _cmd="$_cmd --model $2"; fi
445
+ printf '%s' "$_cmd"
446
+ }
447
+
448
+ curated_models() { # $1 harness -> space list
449
+ case "$1" in
450
+ copilot) printf '%s' "$MODELS_copilot" ;;
451
+ claude) printf '%s' "$MODELS_claude" ;;
452
+ qwen) printf '%s' "$MODELS_qwen" ;;
453
+ pi) printf '%s' "$MODELS_pi" ;;
454
+ kimi) printf '%s' "$MODELS_kimi" ;;
455
+ *) printf '' ;;
456
+ esac
457
+ }
458
+
459
+ # Live model query for the queryable harnesses. Prints models (best-effort), one
460
+ # per line, or nothing if the query fails / is unavailable.
461
+ live_models() { # $1 harness -> newline list on stdout
462
+ case "$1" in
463
+ pi)
464
+ pi --list-models 2>/dev/null | awk 'NR>1 && $2 != "" {print $2}' ;;
465
+ kimi)
466
+ kimi provider list --json 2>/dev/null \
467
+ | tr ',' '\n' \
468
+ | sed -n 's/.*"\([A-Za-z0-9._]*-[A-Za-z0-9._-]*\)".*/\1/p' ;;
469
+ *) : ;;
470
+ esac
471
+ }
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # Step 4/5 — interactive selection + model + instances
475
+ # ---------------------------------------------------------------------------
476
+ add_selection() { # $1 harness $2 model $3 instances
477
+ if [ -n "$2" ] && ! printf '%s' "$2" | LC_ALL=C grep -Eq '^[A-Za-z0-9._/@+-]+$'; then
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)."
479
+ fi
480
+ SELECTIONS="${SELECTIONS}$1${SEP}$2${SEP}$3
481
+ "
482
+ }
483
+
484
+ # Prompt for a model for a harness. Sets ANS to the chosen model (may be empty
485
+ # for "harness default"; never empty for qwen).
486
+ choose_model() { # $1 harness
487
+ _h=$1
488
+ # Build the candidate list: live query for pi/kimi, else curated. Model ids
489
+ # contain no spaces, so a space-separated list + `for` keeps this in the
490
+ # current shell (no subshell to lose _map/_count to).
491
+ _live=$(live_models "$_h" 2>/dev/null | sed '/^$/d' | head -n 20 | tr '\n' ' ' || true)
492
+ if [ -n "$(printf '%s' "$_live" | tr -d ' ')" ]; then
493
+ _cands=$_live
494
+ note "live models for $_h:"
495
+ else
496
+ _cands=$(curated_models "$_h")
497
+ [ -n "$_cands" ] && note "known-good models for $_h:"
498
+ fi
499
+
500
+ _n=0
501
+ _map=''
502
+ for _m in $_cands; do
503
+ [ -n "$_m" ] || continue
504
+ _n=$((_n + 1))
505
+ printf ' %s) %s\n' "$_n" "$_m" >"$TTY"
506
+ _map="${_map}${_n}=${_m} "
507
+ done
508
+ _count=$_n
509
+
510
+ if [ "$_h" = qwen ]; then
511
+ note "qwen requires an explicit model (it stalls with none)."
512
+ _extra=" c) enter your own"
513
+ else
514
+ _extra=" c) enter your own
515
+ d) use the harness default (no model)"
516
+ fi
517
+ printf '%s\n' "$_extra" >"$TTY"
518
+
519
+ while :; do
520
+ if [ "$_h" = qwen ]; then
521
+ ask "Model for $_h (1-${_count} or c): "
522
+ else
523
+ ask "Model for $_h (1-${_count}, c, or d): "
524
+ fi
525
+ case "$ANS" in
526
+ c|C)
527
+ ask "Enter model id for $_h: "
528
+ _m=$(printf '%s' "$ANS" | awk '{print $1}')
529
+ if [ -n "$_m" ]; then ANS=$_m; return 0; fi
530
+ warn "empty model id" ;;
531
+ d|D)
532
+ if [ "$_h" = qwen ]; then warn "qwen needs an explicit model"; continue; fi
533
+ ANS=''; return 0 ;;
534
+ ''|*[!0-9]*)
535
+ warn "please choose one of the listed options" ;;
536
+ *)
537
+ _sel=$(printf '%s' "$_map" | tr ' ' '\n' | grep "^${ANS}=" | head -n 1 | cut -d= -f2-)
538
+ if [ -n "$_sel" ]; then ANS=$_sel; return 0; fi
539
+ warn "no such option: $ANS" ;;
540
+ esac
541
+ done
542
+ }
543
+
544
+ # Ensure a harness's ACP adapter is present; may install (with consent) or skip.
545
+ # Returns 0 to keep the harness, 1 to skip it.
546
+ ensure_adapter() { # $1 harness; $2 "interactive" to allow the install prompt
547
+ _h=$1
548
+ _interactive=${2:-}
549
+ _bin=$(adapter_bin "$_h")
550
+ [ -n "$_bin" ] || return 0 # native, nothing to do
551
+ if adapter_present "$_bin"; then return 0; fi
552
+ _pkg=$(adapter_pkg "$_h")
553
+ if [ "$INSTALL_ADAPTERS" -eq 1 ]; then
554
+ info "Installing $_h ACP adapter ($_pkg)"
555
+ if run npm i -g "$_pkg"; then return 0; fi
556
+ warn "adapter install failed for $_h — skipping this harness."
557
+ record_failure "$_h: adapter install ($_pkg) failed"
558
+ return 1
559
+ fi
560
+ # Only prompt in the interactive selection path. A flag-driven (--harness) run
561
+ # must stay fully scriptable even from a terminal — use --install-adapters as
562
+ # the explicit non-interactive install mechanism.
563
+ if [ "$_interactive" = interactive ] && [ -n "$TTY" ] && confirm "$_h needs the ACP adapter '$_bin' ($_pkg). Install it globally now?"; then
564
+ info "Installing $_h ACP adapter ($_pkg)"
565
+ if run npm i -g "$_pkg"; then return 0; fi
566
+ warn "adapter install failed for $_h — skipping this harness."
567
+ record_failure "$_h: adapter install ($_pkg) failed"
568
+ return 1
569
+ fi
570
+ warn "skipping $_h — ACP adapter '$_bin' not installed (install with: npm i -g $_pkg)."
571
+ record_failure "$_h: skipped (missing ACP adapter $_bin)"
572
+ return 1
573
+ }
574
+
575
+ interactive_select() {
576
+ info "Select harnesses to hire"
577
+ _i=0
578
+ _idxmap=''
579
+ for _h in $DETECTED; do
580
+ _i=$((_i + 1))
581
+ printf ' %s) %s\n' "$_i" "$_h" >"$TTY"
582
+ _idxmap="${_idxmap}${_i}=${_h}
583
+ "
584
+ done
585
+ _total=$_i
586
+
587
+ while :; do
588
+ ask "Which to hire? (e.g. '1 3' or 'all'): "
589
+ _pick=$ANS
590
+ [ -n "$_pick" ] || { warn "select at least one, or Ctrl-C to abort"; continue; }
591
+ _chosen=''
592
+ if [ "$_pick" = all ] || [ "$_pick" = ALL ]; then
593
+ _chosen=$DETECTED
594
+ else
595
+ _bad=0
596
+ for _tok in $_pick; do
597
+ case "$_tok" in
598
+ ''|*[!0-9]*) warn "not a number: $_tok"; _bad=1; break ;;
599
+ esac
600
+ _m=$(printf '%s' "$_idxmap" | grep "^${_tok}=" | head -n 1 | cut -d= -f2-)
601
+ if [ -z "$_m" ]; then warn "no such option: $_tok"; _bad=1; break; fi
602
+ case " $_chosen " in *" $_m "*) : ;; *) _chosen="$_chosen $_m" ;; esac
603
+ done
604
+ [ "$_bad" -eq 0 ] || continue
605
+ fi
606
+ _chosen=$(printf '%s' "$_chosen" | sed 's/^ *//;s/ *$//')
607
+ [ -n "$_chosen" ] && break
608
+ done
609
+
610
+ _first=1
611
+ for _h in $_chosen; do
612
+ ensure_adapter "$_h" interactive || continue
613
+ choose_model "$_h"; _model=$ANS
614
+ if [ "$_first" -eq 1 ]; then _def=5; else _def=1; fi
615
+ while :; do
616
+ ask "How many '$_h' workers? [default $_def]: "
617
+ if [ -z "$ANS" ]; then _inst=$_def; break; fi
618
+ case "$ANS" in
619
+ ''|*[!0-9]*) warn "enter a whole number" ;;
620
+ *) if [ "$ANS" -ge 1 ]; then _inst=$ANS; break; else warn "must be >= 1"; fi ;;
621
+ esac
622
+ done
623
+ add_selection "$_h" "$_model" "$_inst"
624
+ _first=0
625
+ done
626
+ }
627
+
628
+ # Non-interactive selection from --harness specs.
629
+ noninteractive_select() {
630
+ info "Composing workforce from --harness flags"
631
+ _first=1
632
+ # Specs are name[:model][:instances] with no spaces, so iterate with `for`
633
+ # in the current shell (add_selection must mutate SELECTIONS here).
634
+ for _spec in $CLI_HARNESS_SPECS; do
635
+ [ -n "$_spec" ] || continue
636
+ _name=$(printf '%s' "$_spec" | cut -d: -f1)
637
+ _model=$(printf '%s' "$_spec" | cut -s -d: -f2)
638
+ _inst=$(printf '%s' "$_spec" | cut -s -d: -f3)
639
+ case " $ALL_HARNESSES " in
640
+ *" $_name "*) : ;;
641
+ *) die "unknown harness in --harness: '$_name' (expected one of ${ALL_HARNESSES})" ;;
642
+ esac
643
+ if ! harness_detected "$_name"; then
644
+ warn "--harness $_name: not installed on this host — skipping."
645
+ record_failure "$_name: not installed"
646
+ continue
647
+ fi
648
+ if [ "$_name" = qwen ] && [ -z "$_model" ]; then
649
+ die "--harness qwen requires an explicit model (qwen stalls with none): use qwen:<model>[:<instances>]"
650
+ fi
651
+ ensure_adapter "$_name" || continue
652
+ if [ -z "$_inst" ]; then
653
+ if [ "$_first" -eq 1 ]; then _inst=5; else _inst=1; fi
654
+ fi
655
+ case "$_inst" in
656
+ ''|*[!0-9]*) die "--harness $_name: instances must be a whole number, got '$_inst'" ;;
657
+ esac
658
+ [ "$_inst" -ge 1 ] || die "--harness $_name: instances must be >= 1"
659
+ add_selection "$_name" "$_model" "$_inst"
660
+ _first=0
661
+ done
662
+ }
663
+
664
+ # ---------------------------------------------------------------------------
665
+ # Step 7 — confirm
666
+ # ---------------------------------------------------------------------------
667
+ print_summary() {
668
+ info "Workforce to compose"
669
+ printf '%s\n' "$SELECTIONS" | sed '/^$/d' | while IFS="$SEP" read -r _h _model _inst; do
670
+ _shown=${_model:-'(harness default)'}
671
+ note "$_h ×$_inst model: $_shown"
672
+ done
673
+ }
674
+
675
+ confirm_or_die() {
676
+ [ -n "$SELECTIONS" ] || die "no harnesses selected — nothing to do."
677
+ print_summary
678
+ warn "These agents run UNATTENDED with --permission yolo: full tool access"
679
+ warn "(shell, file writes, network) as $(id -un 2>/dev/null || printf 'the current user') on this host."
680
+ if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ]; then
681
+ [ "$DRY_RUN" -eq 1 ] && note "dry-run: not asking for confirmation."
682
+ return 0
683
+ fi
684
+ if [ -z "$TTY" ]; then
685
+ die "refusing to proceed without confirmation and no /dev/tty — re-run with --yes."
686
+ fi
687
+ confirm "Proceed and bring up this unattended workforce?" || die "aborted by user."
688
+ }
689
+
690
+ # ---------------------------------------------------------------------------
691
+ # Step 6/7 — hire, compose, bring up
692
+ # ---------------------------------------------------------------------------
693
+ hire_all() {
694
+ info "Hiring agents"
695
+ while IFS="$SEP" read -r _h _model _inst; do
696
+ [ -n "$_h" ] || continue
697
+ _cmd=$(build_command "$_h" "$_model")
698
+ _show="$CLI nano hire --name $_h --rank senior --command '$_cmd' --model '$_model' --capabilities '' --protocol acp --permission yolo"
699
+ if run_as "$_show" "$CLI" nano hire \
700
+ --name "$_h" \
701
+ --rank senior \
702
+ --command "$_cmd" \
703
+ --model "$_model" \
704
+ --capabilities '' \
705
+ --protocol acp \
706
+ --permission yolo; then
707
+ ok "hired $_h"
708
+ else
709
+ warn "hire failed for $_h — continuing with the rest."
710
+ record_failure "$_h: hire failed"
711
+ fi
712
+ done <<EOF
713
+ $(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
714
+ EOF
715
+ }
716
+
717
+ compose_workforce() {
718
+ info "Composing the workforce manifest"
719
+ while IFS="$SEP" read -r _h _model _inst; do
720
+ [ -n "$_h" ] || continue
721
+ if run "$CLI" nano workforce add "$_h" --instances "$_inst" --auto; then
722
+ ok "workforce add $_h ×$_inst"
723
+ else
724
+ warn "workforce add failed for $_h."
725
+ record_failure "$_h: workforce add failed"
726
+ fi
727
+ done <<EOF
728
+ $(printf '%s\n' "$SELECTIONS" | sed '/^$/d')
729
+ EOF
730
+ }
731
+
732
+ bring_up() {
733
+ info "Starting the engine"
734
+ if ! run "$CLI" nano start; then
735
+ err "'$CLI nano start' failed."
736
+ record_failure "engine: nano start failed"
737
+ return 1
738
+ fi
739
+ info "Starting the workforce"
740
+ if ! run "$CLI" nano workforce start; then
741
+ err "'$CLI nano workforce start' failed."
742
+ record_failure "workforce: nano workforce start failed"
743
+ return 1
744
+ fi
745
+ }
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # Step 8 — output + hints
749
+ # ---------------------------------------------------------------------------
750
+ print_status_and_hints() {
751
+ info "Workforce status"
752
+ run "$CLI" nano workforce status || true
753
+
754
+ info "What now"
755
+ cat >&2 <<EOF
756
+ $CLI nano status engine health
757
+ $CLI nano workforce list the composed fleet (desired vs actual)
758
+ $CLI nano workforce add <profile> --instances N --auto grow the fleet, then 'workforce start'
759
+ $CLI nano workforce remove <profile> shrink it, then 'workforce start'
760
+ $CLI nano supervisor status per-worker state
761
+ $CLI nano supervisor logs <worker> --follow tail one worker's logs
762
+ $CLI nano workforce stop / $CLI nano stop shut the fleet / engine down
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
+ }
881
+
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
1117
+ EOF
1118
+ }
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
+
1146
+ # ---------------------------------------------------------------------------
1147
+ # Partial-success report
1148
+ # ---------------------------------------------------------------------------
1149
+ report_and_exit() {
1150
+ if [ -n "$FAILURES" ]; then
1151
+ warn "Completed with some steps skipped or failed:"
1152
+ printf '%s\n' "$FAILURES" | sed '/^$/d' | while IFS= read -r _f; do note "- $_f"; done
1153
+ exit 1
1154
+ fi
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
1168
+ exit 0
1169
+ }
1170
+
1171
+ # ---------------------------------------------------------------------------
1172
+ # main
1173
+ # ---------------------------------------------------------------------------
1174
+ main() {
1175
+ parse_args "$@"
1176
+ init_tty
1177
+
1178
+ # Decide interactive vs non-interactive up front.
1179
+ _have_specs=0
1180
+ [ -n "$(printf '%s' "$CLI_HARNESS_SPECS" | sed '/^$/d')" ] && _have_specs=1
1181
+ if [ "$_have_specs" -eq 0 ] && [ -z "$TTY" ]; then
1182
+ err "no controlling terminal (/dev/tty) and no --harness given — cannot prompt."
1183
+ usage
1184
+ exit 2
1185
+ fi
1186
+
1187
+ preflight
1188
+ install_cli
1189
+ detect_harnesses
1190
+
1191
+ if [ "$_have_specs" -eq 1 ]; then
1192
+ noninteractive_select
1193
+ else
1194
+ interactive_select
1195
+ fi
1196
+
1197
+ confirm_or_die
1198
+ hire_all
1199
+ compose_workforce
1200
+ bring_up || true
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
1209
+ report_and_exit
1210
+ }
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
+
1221
+ main "$@"