@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/.github/workflows/ci.yml +23 -0
- package/CHANGELOG.md +12 -0
- package/README.md +56 -0
- package/install.sh +1221 -0
- package/package.json +1 -1
- package/test/install-smoke.sh +337 -0
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 "$@"
|