@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.
- package/.github/workflows/ci.yml +6 -5
- package/CHANGELOG.md +6 -0
- package/README.md +30 -15
- package/install.sh +444 -5
- package/package.json +1 -1
- package/test/install-smoke.sh +236 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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 (
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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:
|
|
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
|
|
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
|
|
93
|
-
completion, detects which of your
|
|
94
|
-
which to hire (with a model and an
|
|
95
|
-
`--rank senior --protocol acp --permission
|
|
96
|
-
**workforce manifest** (`c8 nano workforce add`),
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/test/install-smoke.sh
CHANGED
|
@@ -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
|