propr-cli 0.8.3 → 0.8.4

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.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +93 -57
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +2 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/systemCommands.js +49 -2
  20. package/dist/index.js +13 -45
  21. package/dist/orchestrator/manifest.json +10 -10
  22. package/dist/orchestrator/orchestrator.mjs +513 -61
  23. package/dist/tui/AgentTableApp.js +86 -0
  24. package/dist/tui/CheckApp.js +202 -0
  25. package/dist/tui/SetupApp.js +586 -0
  26. package/dist/tui/SetupApp.test.js +172 -0
  27. package/dist/tui/app.js +84 -0
  28. package/dist/tui/render.js +11 -0
  29. package/dist/utils/envFile.js +45 -0
  30. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  31. package/dist/vendor/shared/index.js +16 -0
  32. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  33. package/dist/vendor/shared/modelDefinitions.js +4 -4
  34. package/dist/vendor/shared/proprServiceUrls.js +27 -0
  35. package/dist/vendor/shared/statusKeys.js +14 -0
  36. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  37. package/package.json +2 -2
  38. package/dist/assets/.env.example +0 -183
@@ -0,0 +1,956 @@
1
+ /**
2
+ * Setup wizard engine.
3
+ *
4
+ * `propr setup` walks a new user from a bare host to a running local
5
+ * control-plane stack. It combines what `propr check` and `propr init stack`
6
+ * already do, then sequences the remaining one-time tasks — pulling images,
7
+ * recording agent credentials, choosing GitHub auth, starting the stack and
8
+ * validating its health, configuring the whitelist, optionally connecting a
9
+ * first repository, and surfacing the UI URL.
10
+ *
11
+ * The engine is intentionally UI-agnostic. It owns the *order* of the flow and
12
+ * the *decision logic* (what to run, what to skip, what is safe), but performs
13
+ * no rendering and prompts no user directly. Two seams keep it decoupled:
14
+ *
15
+ * - {@link SetupPrompts} — callback hooks a renderer supplies to collect user
16
+ * decisions (which agents, which auth mode, whether to add a repo, …). Every
17
+ * hook is optional; a missing hook falls back to a safe, non-interactive
18
+ * default (keep what exists, skip optional work). Ink and the readline
19
+ * fallback will provide these in later issues.
20
+ * - {@link SetupActions} — the side-effecting operations (run checks, scaffold,
21
+ * pull, start, health-probe, add repo). Defaults bind to the real
22
+ * orchestrator and commands via {@link createDefaultActions}; tests inject
23
+ * mocks so the whole flow runs without Docker, the network, or a TTY.
24
+ *
25
+ * Safety contract (enforced here, not just by convention):
26
+ * - The stack is initialized only when `.env` is missing or the user picks a
27
+ * new root — an existing functional install is left intact on re-run.
28
+ * - `.env` is never overwritten wholesale; edits go through the non-destructive
29
+ * {@link applyEnvSelection} (per-key, never blanks an existing value).
30
+ * - No step deletes user data; a running stack is reused, not recreated.
31
+ * - Core images pull by default; agent images pull only for selected agents.
32
+ */
33
+ import { existsSync } from "node:fs";
34
+ import { homedir, hostname } from "node:os";
35
+ import { join } from "node:path";
36
+ import { resolveGithubEventIntakeMode, validateIntakeModePrerequisites, DEFAULT_PROPR_GH_RELAY_URL, } from "../../vendor/shared/index.js";
37
+ import { buildIntakeEnvVars, defaultIntakeChoice, intakeModeLabel, saveWhitelist, } from "./github.js";
38
+ import { createDefaultAgentSetupActions, runAgentSetup, } from "./agents.js";
39
+ import { applyEnvSelection, clearEnvKeys, createSetupState, detectGithubAuthMode, getStep, inspectStackInit, isSetupComplete, readEnvVars, resolveSetupRoot, updateStep, } from "./state.js";
40
+ function agentCatalog() {
41
+ const home = homedir();
42
+ return [
43
+ { type: "claude", imageKey: "agent-claude", credentials: [{ envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude") }] },
44
+ { type: "codex", imageKey: "agent-codex", credentials: [{ envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex") }] },
45
+ { type: "antigravity", imageKey: "agent-antigravity", credentials: [{ envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini") }] },
46
+ {
47
+ type: "opencode",
48
+ imageKey: "agent-opencode",
49
+ credentials: [
50
+ { envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode") },
51
+ { envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode") },
52
+ ],
53
+ },
54
+ { type: "vibe", imageKey: "agent-vibe", credentials: [{ envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe") }] },
55
+ ];
56
+ }
57
+ /** Agent types whose default credential directory exists on this host. */
58
+ function detectInstalledAgents(catalog) {
59
+ return catalog.filter((a) => a.credentials.some((c) => existsSync(c.defaultDir))).map((a) => a.type);
60
+ }
61
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
+ /**
63
+ * Build the production {@link SetupActions}, lazily importing the heavy
64
+ * orchestrator/command/API modules only when an action actually runs. This
65
+ * keeps `import`ing the engine cheap (and Docker-free) for tests, which replace
66
+ * these actions anyway.
67
+ */
68
+ export function createDefaultActions(configManager) {
69
+ /** A client pointed at the local stack's API port (not the saved remote URL). */
70
+ const localApiClient = async (rootDir) => {
71
+ const { getHostConfig } = await import("../../orchestrator/index.js");
72
+ const { cfg } = await getHostConfig({ configManager, root: rootDir });
73
+ const { createApiClient } = await import("../../api/client.js");
74
+ return createApiClient({ baseUrl: `http://localhost:${cfg.apiPort}` });
75
+ };
76
+ return {
77
+ // Agent enablement + image-login actions, bound to the local stack.
78
+ ...createDefaultAgentSetupActions(configManager),
79
+ async runChecks(options) {
80
+ const { runChecks } = await import("../checkCommands.js");
81
+ return runChecks(options);
82
+ },
83
+ inspectStackInit,
84
+ async scaffoldStack(options) {
85
+ const { scaffoldStack } = await import("../initStack.js");
86
+ return scaffoldStack(options);
87
+ },
88
+ async persistStackRoot(rootDir) {
89
+ // Mirror scaffoldStack's `configManager.setStackRoot` so the reuse path
90
+ // records the root too. Best-effort: without a config there is nowhere to
91
+ // persist it (tests run this way), so it is simply a no-op.
92
+ await configManager?.setStackRoot(rootDir);
93
+ },
94
+ readEnvVars,
95
+ applyEnvSelection,
96
+ clearEnvKeys,
97
+ detectGithubAuthMode,
98
+ async pullImages({ rootDir, agentTypes, onLog }) {
99
+ const { getHostConfig } = await import("../../orchestrator/index.js");
100
+ const { orch, cfg } = await getHostConfig({ configManager, root: rootDir });
101
+ const selected = new Set(agentTypes);
102
+ const result = { pulledCore: [], pulledAgents: [], failedCore: [], failedAgents: [] };
103
+ for (const [key, tag] of Object.entries(cfg.images)) {
104
+ if (key === "docs" && !cfg.docsEnabled)
105
+ continue;
106
+ const isAgent = key.startsWith("agent-");
107
+ // Only pull agent images for the agents the user selected; core images
108
+ // (api/worker/daemon/redis/…) always pull.
109
+ if (isAgent && !selected.has(key.slice("agent-".length)))
110
+ continue;
111
+ onLog?.(`pulling ${tag}…`);
112
+ // Async exec keeps the event loop free so the wizard's Ink spinner keeps
113
+ // animating while the (often slow) pull runs, instead of freezing.
114
+ const pulled = await orch.dockerAsync(["pull", tag]);
115
+ if (pulled.status === 0) {
116
+ try {
117
+ orch.tagAgentLatest(key, tag);
118
+ }
119
+ catch {
120
+ /* best-effort local retag; the pull itself succeeded */
121
+ }
122
+ (isAgent ? result.pulledAgents : result.pulledCore).push(tag);
123
+ }
124
+ else {
125
+ (isAgent ? result.failedAgents : result.failedCore).push(tag);
126
+ }
127
+ }
128
+ return result;
129
+ },
130
+ async isStackRunning(rootDir) {
131
+ const { getHostConfig } = await import("../../orchestrator/index.js");
132
+ const { orch, cfg } = await getHostConfig({ configManager, root: rootDir });
133
+ return orch.isStackRunningAsync(cfg);
134
+ },
135
+ async startStack({ rootDir, ui, docs, onLog }) {
136
+ const { getHostConfig } = await import("../../orchestrator/index.js");
137
+ const { orch, cfg } = await getHostConfig({ configManager, root: rootDir });
138
+ // Pre-create the host Vibe prompt-cache dir owned by this user so Docker
139
+ // does not auto-create it as root on first bind-mount — a root-owned dir
140
+ // would fail the writability check and block future `propr start` runs.
141
+ try {
142
+ const { ensureVibePromptCacheDir } = await import("../initStack.js");
143
+ ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir);
144
+ }
145
+ catch {
146
+ /* best-effort: startup validation will surface an actionable error */
147
+ }
148
+ // Use the async start path: `propr setup` drives this from behind a live
149
+ // Ink TUI, so the blocking synchronous startStack would freeze the spinner
150
+ // and swallow keystrokes for the seconds-to-minutes a cold start takes.
151
+ await orch.ensureNetworkAsync(cfg, onLog);
152
+ await orch.startStackAsync(cfg, {
153
+ ui: ui ?? configManager?.getUiEnabled() ?? true,
154
+ docs: docs ?? cfg.docsEnabled,
155
+ onLog,
156
+ });
157
+ },
158
+ async checkBackendHealth({ rootDir, timeoutMs = 60_000 }) {
159
+ const { getSystemStatus } = await import("../../api/system.js");
160
+ const client = await localApiClient(rootDir);
161
+ const deadline = Date.now() + timeoutMs;
162
+ let lastError = "no response";
163
+ // Containers take a few seconds to report healthy; poll until the deadline.
164
+ do {
165
+ try {
166
+ const status = await getSystemStatus(client);
167
+ if (String(status.api).toLowerCase() === "healthy") {
168
+ return { healthy: true, detail: `API healthy (daemon ${status.daemon}, worker ${status.worker})` };
169
+ }
170
+ lastError = `API reports "${status.api}"`;
171
+ }
172
+ catch (error) {
173
+ lastError = error.message;
174
+ }
175
+ if (Date.now() >= deadline)
176
+ break;
177
+ await sleep(2_000);
178
+ } while (Date.now() < deadline);
179
+ return { healthy: false, detail: `backend not healthy within ${Math.round(timeoutMs / 1000)}s (${lastError})` };
180
+ },
181
+ async addRepository({ fullName, alias, baseBranch }, rootDir) {
182
+ const { addRepo } = await import("../../api/repos.js");
183
+ // Point the client at this stack's API port rather than the saved remote.
184
+ const client = await localApiClient(rootDir);
185
+ await addRepo(fullName, { alias, baseBranch }, client);
186
+ },
187
+ async resolveUiUrl(rootDir) {
188
+ const { getHostConfig } = await import("../../orchestrator/index.js");
189
+ const { cfg } = await getHostConfig({ configManager, root: rootDir });
190
+ return `http://localhost:${cfg.uiPort}`;
191
+ },
192
+ async openUrl(url) {
193
+ // Open in the host's default browser with the platform launcher. Detached
194
+ // and unref'd so the wizard isn't held open by the child, with stdio
195
+ // ignored so the launcher can't scribble over the TUI.
196
+ const { spawn } = await import("node:child_process");
197
+ const platform = process.platform;
198
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
199
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
200
+ await new Promise((resolve, reject) => {
201
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
202
+ child.once("error", reject);
203
+ // The launcher returns immediately; once it has spawned we're done.
204
+ child.once("spawn", () => {
205
+ child.unref();
206
+ resolve();
207
+ });
208
+ });
209
+ },
210
+ async saveWhitelistSetting(rootDir, users) {
211
+ const { updateSetting } = await import("../../api/settings.js");
212
+ // Point the client at this stack's API port rather than the saved remote.
213
+ const client = await localApiClient(rootDir);
214
+ await updateSetting("github_user_whitelist", users, client);
215
+ },
216
+ hasGithubToken() {
217
+ return Boolean(configManager?.getGithubToken());
218
+ },
219
+ async fetchRelayInstallations({ relayUrl }) {
220
+ const { fetchAuthenticatedUser } = await import("../../api/relay.js");
221
+ const me = await fetchAuthenticatedUser(relayClient(relayUrl));
222
+ return { username: me.username, installations: me.installations };
223
+ },
224
+ async enrollRelay({ relayUrl, installationId, label }) {
225
+ const { enrollRelayToken } = await import("../../api/relay.js");
226
+ const client = relayClient(relayUrl);
227
+ // Default the token label to the hostname, mirroring `propr relay enroll`.
228
+ const result = await enrollRelayToken(client, { installationId, label: label ?? hostname() });
229
+ return { relayUrl: client.baseUrl, token: result.token };
230
+ },
231
+ async loginWithGithub({ onLog } = {}) {
232
+ if (!configManager)
233
+ return false;
234
+ const { loginWithGithubCli } = await import("../../auth/githubLogin.js");
235
+ const result = await loginWithGithubCli(configManager, { interactive: true, onLog });
236
+ if (!result.ok)
237
+ onLog?.(result.message);
238
+ return result.ok;
239
+ },
240
+ };
241
+ /**
242
+ * Build a relay client bound to the stored GitHub token. The hosted relay is
243
+ * the default base URL; an explicit `relayUrl` (self-hosted) overrides it.
244
+ */
245
+ function relayClient(relayUrl) {
246
+ const githubToken = configManager?.getGithubToken();
247
+ if (!githubToken) {
248
+ throw new Error("Not logged in to GitHub. Run `propr login` first.");
249
+ }
250
+ return { baseUrl: relayUrl ?? DEFAULT_PROPR_GH_RELAY_URL, githubToken };
251
+ }
252
+ }
253
+ /**
254
+ * Run the setup flow end to end, in a safe order, driven by the supplied
255
+ * prompts and reflected through the reporter. Returns the final step state and
256
+ * the environment-check outcome. Never throws for expected conditions (a failed
257
+ * required step stops the flow and is reported in the returned state); only
258
+ * truly unexpected programmer errors propagate.
259
+ */
260
+ export async function runSetup(options = {}) {
261
+ const { configManager, prompts = {}, reporter = {}, skipRemoteImageCheck } = options;
262
+ const actions = { ...createDefaultActions(configManager), ...options.actions };
263
+ const catalog = agentCatalog();
264
+ let rootDir = resolveSetupRoot(configManager, options.root);
265
+ let state = createSetupState(rootDir);
266
+ let checks;
267
+ /** Agents chosen at the pull step, reused when recording credentials. */
268
+ let selectedAgents = [];
269
+ /**
270
+ * Set when the user declines to start the stack. Starting and validating the
271
+ * backend is the central goal of setup, so a declined start must not report
272
+ * overall success (and exit 0) even though every *step* reached a terminal
273
+ * status — the work the user came to do did not happen.
274
+ */
275
+ let startDeclined = false;
276
+ const emit = () => reporter.onState?.(state);
277
+ const stepOf = (id) => getStep(state, id);
278
+ const begin = (id) => {
279
+ state = updateStep(state, id, { status: "active", detail: undefined, nextAction: undefined });
280
+ emit();
281
+ reporter.onStepStart?.(stepOf(id));
282
+ };
283
+ const settle = (id, patch) => {
284
+ state = updateStep(state, id, patch);
285
+ emit();
286
+ reporter.onStepSettled?.(stepOf(id));
287
+ };
288
+ const log = (line) => reporter.onLog?.(line);
289
+ const finish = () => ({
290
+ rootDir,
291
+ state,
292
+ checks,
293
+ // Declining to start the stack leaves the backend down, so the run is not
294
+ // complete even when every step is in a terminal, non-failed status.
295
+ completed: isSetupComplete(state) && !startDeclined,
296
+ });
297
+ /**
298
+ * Relay enrollment for the auth step. Ensures a GitHub token (offering the
299
+ * interactive login when a `confirmGithubLogin` hook is present), discovers the
300
+ * installation (auto-select one, pick among many, error on none), mints the
301
+ * relay token, and writes the relay env vars. Returns a success `detail` or a
302
+ * soft `note` (rendered as a warning). It never throws for expected problems,
303
+ * so a relay hiccup degrades to a warning instead of aborting the whole setup.
304
+ */
305
+ const enrollRelayForSetup = async (relayUrl) => {
306
+ // 1. A stored GitHub token is required. Offer interactive login when the
307
+ // renderer supports it (sequential wizard); the Ink wizard has no hook and
308
+ // falls through to the "not logged in" guidance below.
309
+ if (!actions.hasGithubToken()) {
310
+ const reason = "Relay enrollment needs a GitHub token.";
311
+ if (prompts.confirmGithubLogin && (await prompts.confirmGithubLogin({ reason }))) {
312
+ await actions.loginWithGithub({ onLog: log });
313
+ }
314
+ if (!actions.hasGithubToken()) {
315
+ return {
316
+ note: {
317
+ detail: "relay not enrolled — not logged in to GitHub",
318
+ nextAction: "Run `propr login`, then re-run `propr setup` and choose Token relay.",
319
+ },
320
+ };
321
+ }
322
+ }
323
+ try {
324
+ // 2. Discover installations: auto-select the only one, pick among many,
325
+ // error when there are none.
326
+ const { installations } = await actions.fetchRelayInstallations({ relayUrl });
327
+ if (installations.length === 0) {
328
+ return {
329
+ note: {
330
+ detail: "relay not enrolled — no GitHub App installation available",
331
+ nextAction: "Install the shared ProPR GitHub App for your account, then re-run setup.",
332
+ },
333
+ };
334
+ }
335
+ let installationId;
336
+ if (installations.length === 1) {
337
+ installationId = String(installations[0].installation_id);
338
+ log(`relay: using installation ${installationId} (${installations[0].account_login})`);
339
+ }
340
+ else if (prompts.selectInstallation) {
341
+ installationId = await prompts.selectInstallation({ installations });
342
+ }
343
+ else {
344
+ installationId = String(installations[0].installation_id);
345
+ }
346
+ // 3. Mint the relay token and write the relay env vars (overwriting only
347
+ // these keys). PROPR_DEMO_MODE=false ensures the new relay config isn't
348
+ // shadowed by a leftover demo flag (see detectGithubAuthMode).
349
+ const { relayUrl: resolvedRelayUrl, token } = await actions.enrollRelay({ relayUrl, installationId });
350
+ actions.applyEnvSelection(rootDir, {
351
+ PROPR_DEMO_MODE: "false",
352
+ GH_AUTH_MODE: "relay",
353
+ PROPR_GH_RELAY_URL: resolvedRelayUrl,
354
+ PROPR_GH_RELAY_TOKEN: token,
355
+ GH_INSTALLATION_ID: installationId,
356
+ }, { overwrite: true });
357
+ return { detail: `auth mode: relay (installation ${installationId})` };
358
+ }
359
+ catch (error) {
360
+ return {
361
+ note: {
362
+ detail: `relay enrollment failed — ${error.message}`,
363
+ nextAction: "Confirm the shared GitHub App is installed and you own the installation, then re-run setup.",
364
+ },
365
+ };
366
+ }
367
+ };
368
+ emit();
369
+ // 1. Environment checks — run first; their results steer the rest.
370
+ begin("check");
371
+ try {
372
+ checks = await actions.runChecks({ root: rootDir, skipRemoteImageCheck });
373
+ }
374
+ catch (error) {
375
+ settle("check", {
376
+ status: "failed",
377
+ detail: `could not run environment checks: ${error.message}`,
378
+ nextAction: "Resolve the error above, then re-run setup.",
379
+ });
380
+ return finish();
381
+ }
382
+ const dockerProblem = blockingDockerFailure(checks);
383
+ if (dockerProblem) {
384
+ settle("check", {
385
+ status: "failed",
386
+ detail: dockerProblem,
387
+ nextAction: "Install/start Docker and ensure this user can run `docker info`, then re-run setup.",
388
+ });
389
+ return finish();
390
+ }
391
+ const fails = checks.results.filter((r) => r.status === "fail").length;
392
+ const warns = checks.results.filter((r) => r.status === "warn").length;
393
+ settle("check", {
394
+ status: warns > 0 || fails > 0 ? "warning" : "done",
395
+ detail: `${checks.results.length} checks (${fails} failing, ${warns} warnings) — addressing them below`,
396
+ });
397
+ // 2. Initialize stack — only when `.env` is missing or the user picks a new
398
+ // root. An existing functional install is never re-scaffolded or clobbered.
399
+ begin("init-stack");
400
+ try {
401
+ let init = actions.inspectStackInit(rootDir);
402
+ let userChoseReinit = false;
403
+ if (prompts.resolveStackRoot) {
404
+ const decision = await prompts.resolveStackRoot({ currentRoot: rootDir, init });
405
+ if (decision.rootDir && decision.rootDir !== rootDir) {
406
+ rootDir = decision.rootDir;
407
+ state = { ...state, rootDir };
408
+ init = actions.inspectStackInit(rootDir);
409
+ }
410
+ userChoseReinit = decision.reinitialize;
411
+ }
412
+ // Scaffold whenever the stack is incomplete — `.env` missing *or* a required
413
+ // sub-directory (data/logs/repos) absent — or when the user explicitly chose
414
+ // to (re)initialize a root. Keying off `initialized` (not just `envExists`)
415
+ // means a half-scaffolded root with a stray `.env` but no `data/` still gets
416
+ // its directories created, instead of being silently treated as ready and
417
+ // failing later at startup. scaffoldStack runs without `force`, so an existing
418
+ // `.env` is always preserved — re-running setup never clobbers it.
419
+ const reinitialize = !init.initialized || userChoseReinit;
420
+ if (reinitialize) {
421
+ // No `force`: scaffoldStack creates a fresh `.env` only when absent and
422
+ // otherwise leaves the existing one in place.
423
+ const result = await actions.scaffoldStack({ root: rootDir });
424
+ // Adopt the absolute root scaffoldStack actually resolved. A root typed at
425
+ // the prompt may be relative or have a trailing slash; without this every
426
+ // later step (env writes, health probe, UI URL) would key off the raw
427
+ // string while the scaffold landed at the resolved path.
428
+ if (result.rootDir && result.rootDir !== rootDir) {
429
+ rootDir = result.rootDir;
430
+ state = { ...state, rootDir };
431
+ }
432
+ const created = [...result.dirsCreated];
433
+ settle("init-stack", {
434
+ status: "done",
435
+ detail: result.envCreated
436
+ ? `scaffolded stack at ${rootDir}${created.length ? ` (created ${created.join(", ")})` : ""}`
437
+ : `stack root ready at ${rootDir} (existing .env kept)`,
438
+ });
439
+ }
440
+ else {
441
+ // Reuse path: scaffolding is skipped, so nothing has recorded this root in
442
+ // config. Persist it now so a later `propr start` / `propr status` without
443
+ // --root targets this stack rather than an old saved root or the cwd.
444
+ await actions.persistStackRoot(rootDir);
445
+ settle("init-stack", { status: "skipped", detail: `using existing stack at ${rootDir} (.env preserved)` });
446
+ }
447
+ }
448
+ catch (error) {
449
+ settle("init-stack", {
450
+ status: "failed",
451
+ detail: `could not initialize stack: ${error.message}`,
452
+ nextAction: "Check directory permissions and that .env.example is available, then re-run setup.",
453
+ });
454
+ return finish();
455
+ }
456
+ // 3. Pull images — core images by default, agent images only for the agents
457
+ // the user selects (defaulting to the ones detected on this host).
458
+ begin("pull-images");
459
+ const detected = detectInstalledAgents(catalog);
460
+ try {
461
+ const requested = prompts.selectAgents
462
+ ? await prompts.selectAgents({ available: catalog.map((a) => a.type), detected })
463
+ : detected;
464
+ // Guard the engine boundary: a renderer may hand back unknown or duplicate
465
+ // agent names. Keep only types we know about, de-duped (first occurrence
466
+ // wins), so unknown names never reach pullImages() and a duplicate can't
467
+ // double-apply credentials in the configure-agents step below.
468
+ const known = new Set(catalog.map((a) => a.type));
469
+ selectedAgents = [...new Set(requested)].filter((type) => known.has(type));
470
+ const pull = await actions.pullImages({ rootDir, agentTypes: selectedAgents, onLog: log });
471
+ if (pull.failedCore.length > 0) {
472
+ settle("pull-images", {
473
+ status: "failed",
474
+ detail: `failed to pull core image(s): ${pull.failedCore.join(", ")}`,
475
+ nextAction: "Check registry access / network and re-run setup; the stack cannot start without core images.",
476
+ });
477
+ return finish();
478
+ }
479
+ const pulledCount = pull.pulledCore.length + pull.pulledAgents.length;
480
+ if (pull.failedAgents.length > 0) {
481
+ settle("pull-images", {
482
+ status: "warning",
483
+ detail: `pulled ${pulledCount} image(s); ${pull.failedAgents.length} agent image(s) unavailable`,
484
+ nextAction: "Jobs using those agents fail until their images pull. Re-run `propr images pull` later.",
485
+ });
486
+ }
487
+ else {
488
+ settle("pull-images", { status: "done", detail: `pulled ${pulledCount} image(s)` });
489
+ }
490
+ }
491
+ catch (error) {
492
+ settle("pull-images", {
493
+ status: "failed",
494
+ detail: `could not pull images: ${error.message}`,
495
+ nextAction: "Check Docker and registry access, then re-run setup.",
496
+ });
497
+ return finish();
498
+ }
499
+ // 4. Configure agents — record detected host credential dirs for the selected
500
+ // agents, non-destructively (never blanks an existing value).
501
+ begin("configure-agents");
502
+ try {
503
+ if (selectedAgents.length === 0) {
504
+ settle("configure-agents", {
505
+ status: "skipped",
506
+ detail: "no agents selected",
507
+ nextAction: "Log in with an agent CLI on this host, then re-run setup to record its credentials.",
508
+ });
509
+ }
510
+ else {
511
+ const vars = {};
512
+ for (const type of selectedAgents) {
513
+ const desc = catalog.find((a) => a.type === type);
514
+ if (!desc)
515
+ continue;
516
+ for (const cred of desc.credentials) {
517
+ if (existsSync(cred.defaultDir))
518
+ vars[cred.envKey] = cred.defaultDir;
519
+ }
520
+ }
521
+ const applied = actions.applyEnvSelection(rootDir, vars, { overwrite: false });
522
+ const detailParts = [];
523
+ detailParts.push(applied.written.length > 0 ? `recorded ${applied.written.length} credential dir(s)` : "no new credentials to record");
524
+ if (applied.skipped.length > 0)
525
+ detailParts.push(`${applied.skipped.length} already set`);
526
+ settle("configure-agents", { status: "done", detail: detailParts.join("; ") });
527
+ }
528
+ }
529
+ catch (error) {
530
+ settle("configure-agents", {
531
+ status: "failed",
532
+ detail: `could not record agent credentials: ${error.message}`,
533
+ nextAction: "Check write permissions on .env, then re-run setup.",
534
+ });
535
+ return finish();
536
+ }
537
+ // 5. GitHub authentication — keep what works; only write the keys the user
538
+ // explicitly chose. An unresolved mode is a warning, not a hard stop: the
539
+ // health probe after startup is the authoritative signal.
540
+ begin("github-auth");
541
+ let resolvedAuth;
542
+ // Set by the relay path: a soft `relayNote` drives a warning settle (and skips
543
+ // partial writes); `relayDoneDetail` carries the success line. Both stay unset
544
+ // for the keep / custom-App / no-prompt paths, which fall back to the
545
+ // mode-derived settle below.
546
+ let relayNote;
547
+ let relayDoneDetail;
548
+ try {
549
+ const currentAuth = actions.detectGithubAuthMode(rootDir);
550
+ let authDecision;
551
+ if (prompts.configureGithubAuth)
552
+ authDecision = await prompts.configureGithubAuth({ current: currentAuth });
553
+ if (authDecision?.enrollRelay) {
554
+ const outcome = await enrollRelayForSetup(authDecision.enrollRelay.relayUrl);
555
+ relayNote = outcome.note;
556
+ relayDoneDetail = outcome.detail;
557
+ }
558
+ else if (authDecision?.vars && Object.keys(authDecision.vars).length > 0) {
559
+ actions.applyEnvSelection(rootDir, authDecision.vars, { overwrite: true });
560
+ }
561
+ resolvedAuth = actions.detectGithubAuthMode(rootDir);
562
+ }
563
+ catch (error) {
564
+ settle("github-auth", {
565
+ status: "failed",
566
+ detail: `could not configure GitHub auth: ${error.message}`,
567
+ nextAction: "Check .env access and your GitHub auth settings, then re-run setup.",
568
+ });
569
+ return finish();
570
+ }
571
+ if (relayNote) {
572
+ settle("github-auth", { status: "warning", detail: relayNote.detail, nextAction: relayNote.nextAction });
573
+ }
574
+ else if (relayDoneDetail) {
575
+ settle("github-auth", { status: "done", detail: relayDoneDetail });
576
+ }
577
+ else if (resolvedAuth.mode === "none") {
578
+ settle("github-auth", {
579
+ status: "warning",
580
+ detail: "no GitHub auth configured",
581
+ nextAction: "Set a GitHub App, a token relay, or demo mode in .env (the backend will not boot otherwise).",
582
+ });
583
+ }
584
+ else if (resolvedAuth.warnings.length > 0) {
585
+ // The mode resolves, but the shared detector flagged a partial/ambiguous
586
+ // configuration — surface it so the user can fix it before it bites later.
587
+ settle("github-auth", {
588
+ status: "warning",
589
+ detail: `auth mode: ${resolvedAuth.mode} — ${resolvedAuth.warnings.join("; ")}`,
590
+ });
591
+ }
592
+ else {
593
+ settle("github-auth", { status: "done", detail: `auth mode: ${resolvedAuth.mode}` });
594
+ }
595
+ // 5b. GitHub event intake — how the backend learns about GitHub events
596
+ // (routing WebSocket, polling, or direct webhooks). Written before startup
597
+ // because the API/daemon resolve GITHUB_EVENT_INTAKE_MODE at boot. Demo
598
+ // mode has no GitHub access, so there is nothing to ingest.
599
+ begin("intake");
600
+ try {
601
+ if (resolvedAuth.mode === "demo") {
602
+ settle("intake", { status: "skipped", detail: "demo mode — no GitHub events to ingest" });
603
+ }
604
+ else {
605
+ const envNow = actions.readEnvVars(rootDir);
606
+ // Resolve the mode the backend would pick from today's `.env` (unset
607
+ // defaults to routing_websocket, the hosted relay path) so the prompt and
608
+ // any "kept current" message reflect what actually runs.
609
+ const { mode: currentMode } = resolveGithubEventIntakeMode({
610
+ eventIntakeMode: envNow.GITHUB_EVENT_INTAKE_MODE,
611
+ enableGithubWebhooks: envNow.ENABLE_GITHUB_WEBHOOKS,
612
+ });
613
+ // When `.env` already records an intake decision, default the prompt to
614
+ // "keep" so a blank Enter on a re-run can't silently flip a working config
615
+ // (e.g. disable existing direct webhooks). This also covers older `.env`
616
+ // files that only carry the legacy `ENABLE_GITHUB_WEBHOOKS` boolean: it
617
+ // still resolves to a real `currentMode`, so a blank Enter must keep that
618
+ // rather than rewrite it to the auth-derived recommendation. Only a truly
619
+ // fresh install (neither key set) falls back to the recommendation.
620
+ const intakeConfigured = envNow.GITHUB_EVENT_INTAKE_MODE !== undefined || envNow.ENABLE_GITHUB_WEBHOOKS !== undefined;
621
+ const defaultMode = defaultIntakeChoice(resolvedAuth.mode, { intakeConfigured });
622
+ let decision;
623
+ if (prompts.configureIntake) {
624
+ decision = await prompts.configureIntake({ authMode: resolvedAuth.mode, defaultMode, currentMode });
625
+ }
626
+ // The mode that will be in effect after this step — the explicit pick, or
627
+ // the current `.env` value when the user keeps it. `effectiveEnv` mirrors
628
+ // what `.env` holds *after* any write so the prerequisite check below sees
629
+ // the freshly written secret/mode, not the pre-write snapshot.
630
+ let effectiveMode = currentMode;
631
+ let effectiveEnv = envNow;
632
+ let detail;
633
+ if (decision && !decision.keep && decision.mode) {
634
+ // buildIntakeEnvVars rejects an empty webhook secret — caught below and
635
+ // surfaced as a warning rather than writing a config the API won't boot.
636
+ const vars = buildIntakeEnvVars(decision.mode, { webhookSecret: decision.webhookSecret });
637
+ actions.applyEnvSelection(rootDir, vars, { overwrite: true });
638
+ effectiveMode = decision.mode;
639
+ effectiveEnv = { ...envNow, ...vars };
640
+ detail = `intake: ${intakeModeLabel(decision.mode)}`;
641
+ }
642
+ else {
643
+ detail = `intake: kept current (${intakeModeLabel(currentMode)})`;
644
+ }
645
+ // Validate the resolved mode against the shared prerequisite rules so a
646
+ // silently-broken intake config (most commonly routing_websocket without
647
+ // relay auth + a relay token) surfaces here instead of as a backend boot
648
+ // failure after `propr start`.
649
+ const prereq = validateIntakeModePrerequisites({
650
+ intakeMode: effectiveMode,
651
+ authMode: resolvedAuth.mode,
652
+ routingUrl: effectiveEnv.PROPR_ROUTING_URL,
653
+ relayUrl: effectiveEnv.PROPR_GH_RELAY_URL,
654
+ relayToken: effectiveEnv.PROPR_GH_RELAY_TOKEN,
655
+ webhookSecret: effectiveEnv.GH_WEBHOOK_SECRET,
656
+ });
657
+ if (prereq.valid) {
658
+ settle("intake", { status: "done", detail });
659
+ }
660
+ else {
661
+ settle("intake", {
662
+ status: "warning",
663
+ detail: `${detail} — ${prereq.errors.join("; ")}`,
664
+ nextAction: effectiveMode === "routing_websocket"
665
+ ? "Enroll with the hosted relay (`propr relay enroll`) so routing_websocket has relay auth + a relay token, or choose polling."
666
+ : "Resolve the missing intake prerequisites in .env, then re-run setup.",
667
+ });
668
+ }
669
+ }
670
+ }
671
+ catch (error) {
672
+ // An IntakeConfigError (e.g. direct webhooks chosen with no secret) is
673
+ // non-blocking: leave intake as-is and tell the user how to finish it.
674
+ settle("intake", {
675
+ status: "warning",
676
+ detail: `could not configure GitHub intake: ${error.message}`,
677
+ nextAction: "Set GITHUB_EVENT_INTAKE_MODE (and GH_WEBHOOK_SECRET for direct_webhook) in .env, then re-run setup.",
678
+ });
679
+ }
680
+ // 6. Start the stack and validate backend health. A running stack is reused,
681
+ // not recreated, so user data and live work are untouched.
682
+ begin("start-stack");
683
+ try {
684
+ const alreadyRunning = await actions.isStackRunning(rootDir);
685
+ const startConfirmed = prompts.confirmStartStack ? await prompts.confirmStartStack({ rootDir, alreadyRunning }) : true;
686
+ if (!startConfirmed) {
687
+ // A declined start is recorded as skipped for the step list, but it keeps
688
+ // setup from reporting success (see `startDeclined`): the backend never
689
+ // came up, so the wizard's primary goal is unmet.
690
+ startDeclined = true;
691
+ settle("start-stack", {
692
+ status: "skipped",
693
+ detail: "stack not started — setup is incomplete until the backend is running",
694
+ nextAction: "Start it later with `propr start`, or re-run `propr setup` and confirm startup.",
695
+ });
696
+ }
697
+ else {
698
+ if (alreadyRunning) {
699
+ log("stack already running — leaving it intact");
700
+ }
701
+ else {
702
+ await actions.startStack({ rootDir, onLog: log });
703
+ }
704
+ const health = await actions.checkBackendHealth({ rootDir });
705
+ settle("start-stack", health.healthy
706
+ ? { status: "done", detail: alreadyRunning ? `stack already running — ${health.detail}` : health.detail }
707
+ : {
708
+ status: "warning",
709
+ detail: health.detail,
710
+ nextAction: "Give the services a moment, then run `propr status` / `propr remote-status` to inspect them.",
711
+ });
712
+ }
713
+ }
714
+ catch (error) {
715
+ settle("start-stack", {
716
+ status: "failed",
717
+ detail: `could not start the stack: ${error.message}`,
718
+ nextAction: "Run `propr start` to see the full startup output.",
719
+ });
720
+ return finish();
721
+ }
722
+ // 7. Enable agents in the running backend — add the selected agents that are
723
+ // missing (existing ones are never disabled or deleted) and, on
724
+ // confirmation, authenticate the ones that support an image login. This
725
+ // runs after startup because it talks to the live backend API. Any problem
726
+ // is a non-blocking warning: agents can always be configured later.
727
+ begin("enable-agents");
728
+ // This step talks to the live backend API, so it only makes sense once the
729
+ // stack is up. When the user declined to start it, skip rather than fire
730
+ // doomed API calls that would surface as confusing warnings.
731
+ if (startDeclined) {
732
+ settle("enable-agents", {
733
+ status: "skipped",
734
+ detail: "stack not started — agents are enabled through the running backend",
735
+ nextAction: "Start the stack (`propr start`), then re-run `propr setup` to enable and authenticate the selected agents.",
736
+ });
737
+ }
738
+ else {
739
+ try {
740
+ const outcome = await runAgentSetup({
741
+ rootDir,
742
+ selectedAgents,
743
+ actions,
744
+ confirmLogin: prompts.confirmAgentLogin,
745
+ onLog: log,
746
+ });
747
+ if (selectedAgents.length === 0) {
748
+ settle("enable-agents", {
749
+ status: "skipped",
750
+ detail: "no agents selected",
751
+ nextAction: "Enable agents later in the UI or with `propr agent add`.",
752
+ });
753
+ }
754
+ else {
755
+ const parts = [];
756
+ if (outcome.added.length > 0)
757
+ parts.push(`enabled ${outcome.added.join(", ")}`);
758
+ if (outcome.alreadyConfigured.length > 0)
759
+ parts.push(`${outcome.alreadyConfigured.length} already configured`);
760
+ if (outcome.authenticated.length > 0)
761
+ parts.push(`authenticated ${outcome.authenticated.join(", ")}`);
762
+ if (outcome.authFailed.length > 0)
763
+ parts.push(`${outcome.authFailed.length} login(s) did not complete`);
764
+ const detail = parts.length > 0 ? parts.join("; ") : "no changes needed";
765
+ if (outcome.errors.length > 0 || outcome.authFailed.length > 0) {
766
+ settle("enable-agents", {
767
+ status: "warning",
768
+ detail: outcome.errors.length > 0 ? `${detail}; ${outcome.errors.join("; ")}` : detail,
769
+ nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.",
770
+ });
771
+ }
772
+ else {
773
+ settle("enable-agents", { status: "done", detail });
774
+ }
775
+ }
776
+ }
777
+ catch (error) {
778
+ // runAgentSetup is built not to throw for expected conditions; anything that
779
+ // escapes is treated as a non-blocking warning so it can't abort setup.
780
+ settle("enable-agents", {
781
+ status: "warning",
782
+ detail: `could not configure agents: ${error.message}`,
783
+ nextAction: "Enable or authenticate agents later in the UI or with `propr agent add` / `propr agent login`.",
784
+ });
785
+ }
786
+ }
787
+ // 8. Whitelist — restrict who can trigger ProPR. Written non-destructively.
788
+ begin("whitelist");
789
+ try {
790
+ const envNow = actions.readEnvVars(rootDir);
791
+ const currentWhitelist = (envNow.GITHUB_USER_WHITELIST ?? "").split(",").map((s) => s.trim()).filter(Boolean);
792
+ const demoMode = resolvedAuth.mode === "demo";
793
+ let whitelist = null;
794
+ if (prompts.configureWhitelist)
795
+ whitelist = await prompts.configureWhitelist({ current: currentWhitelist, demoMode });
796
+ if (whitelist !== null) {
797
+ // Trim, drop blanks, and de-dupe (first occurrence wins) so the value
798
+ // matches saveWhitelist's "cleaned, de-duped usernames" contract — a
799
+ // duplicate entry would otherwise inflate the saved count and settings.
800
+ const cleaned = [...new Set(whitelist.map((s) => s.trim()).filter(Boolean))];
801
+ // Prefer the settings API when the backend is up so the change applies
802
+ // immediately (and never overwrites unrelated settings); always mirror into
803
+ // .env so it survives a restart. Falls back to .env if the API is down.
804
+ const backendRunning = await actions.isStackRunning(rootDir);
805
+ const saved = await saveWhitelist({
806
+ users: cleaned,
807
+ backendRunning,
808
+ saveViaSettings: (users) => actions.saveWhitelistSetting(rootDir, users),
809
+ saveViaEnv: (users) => {
810
+ // A non-empty list is written; clearing to "none" must *remove* the key
811
+ // rather than blank it. applyEnvSelection ignores blank values (so it
812
+ // never clobbers a value), which means `GITHUB_USER_WHITELIST=""` would
813
+ // be skipped and the old list would survive on the next restart — so we
814
+ // delete the key outright instead.
815
+ if (users.length > 0) {
816
+ actions.applyEnvSelection(rootDir, { GITHUB_USER_WHITELIST: users.join(",") }, { overwrite: true });
817
+ }
818
+ else {
819
+ actions.clearEnvKeys(rootDir, ["GITHUB_USER_WHITELIST"]);
820
+ }
821
+ },
822
+ });
823
+ const where = saved.target === "settings" ? "via settings API" : "in .env";
824
+ const summary = cleaned.length > 0 ? `${cleaned.length} user(s) allowed (${where})` : `whitelist cleared (${where})`;
825
+ if (saved.error) {
826
+ settle("whitelist", {
827
+ status: "warning",
828
+ detail: `${summary}; settings update failed: ${saved.error}`,
829
+ nextAction: "The whitelist is in .env; it will apply when the backend restarts.",
830
+ });
831
+ }
832
+ else {
833
+ settle("whitelist", { status: "done", detail: summary });
834
+ }
835
+ }
836
+ else if (currentWhitelist.length > 0) {
837
+ settle("whitelist", { status: "done", detail: `${currentWhitelist.length} user(s) already allowed` });
838
+ }
839
+ else if (demoMode) {
840
+ settle("whitelist", { status: "skipped", detail: "demo mode — whitelist not required" });
841
+ }
842
+ else {
843
+ settle("whitelist", {
844
+ status: "warning",
845
+ detail: "no whitelist configured — any authenticated GitHub user could trigger processing",
846
+ nextAction: "Set GITHUB_USER_WHITELIST in .env to a comma-separated list of allowed usernames.",
847
+ });
848
+ }
849
+ }
850
+ catch (error) {
851
+ settle("whitelist", {
852
+ status: "failed",
853
+ detail: `could not configure the whitelist: ${error.message}`,
854
+ nextAction: "Check .env access, then re-run setup.",
855
+ });
856
+ return finish();
857
+ }
858
+ // 9. Repository (optional) — adding a repo must never fail the whole run.
859
+ begin("repo");
860
+ // Adding a repo goes through the running backend's API, so skip it (without
861
+ // even prompting) when the user declined to start the stack — there is nothing
862
+ // to add it to yet.
863
+ if (startDeclined) {
864
+ settle("repo", {
865
+ status: "skipped",
866
+ detail: "stack not started — a repository is connected through the running backend",
867
+ nextAction: "Start the stack (`propr start`), then add one with `propr repo add <owner/repo>`.",
868
+ });
869
+ }
870
+ else {
871
+ try {
872
+ // The prompt itself is part of this optional step — a renderer that throws
873
+ // while collecting the repo must degrade to a warning, not abort the run.
874
+ const repoSelection = prompts.addRepository ? await prompts.addRepository({ rootDir }) : null;
875
+ if (!repoSelection) {
876
+ settle("repo", { status: "skipped", detail: "no repository added" });
877
+ }
878
+ else {
879
+ try {
880
+ await actions.addRepository(repoSelection, rootDir);
881
+ settle("repo", { status: "done", detail: `monitoring ${repoSelection.fullName}` });
882
+ }
883
+ catch (error) {
884
+ settle("repo", {
885
+ status: "warning",
886
+ detail: `could not add ${repoSelection.fullName}: ${error.message}`,
887
+ nextAction: "Add it later with `propr repo add <owner/repo>`.",
888
+ });
889
+ }
890
+ }
891
+ }
892
+ catch (error) {
893
+ settle("repo", {
894
+ status: "warning",
895
+ detail: `could not collect a repository to add: ${error.message}`,
896
+ nextAction: "Add it later with `propr repo add <owner/repo>`.",
897
+ });
898
+ }
899
+ }
900
+ // 10. UI (optional) — surface the URL and, when the user confirms, actually
901
+ // open it in their default browser.
902
+ begin("launch-ui");
903
+ let uiUrl = "";
904
+ try {
905
+ uiUrl = await actions.resolveUiUrl(rootDir);
906
+ }
907
+ catch {
908
+ /* non-fatal: just omit the URL */
909
+ }
910
+ let opened = false;
911
+ let openFailed = false;
912
+ try {
913
+ // The prompt only asks *whether* to open; the engine performs the open so
914
+ // both renderers behave identically and neither has to import a launcher.
915
+ const wantsOpen = uiUrl && prompts.launchUi ? await prompts.launchUi({ url: uiUrl }) : false;
916
+ if (wantsOpen) {
917
+ try {
918
+ await actions.openUrl(uiUrl);
919
+ opened = true;
920
+ }
921
+ catch {
922
+ // Headless host, no launcher, etc. — fall back to just printing the URL.
923
+ openFailed = true;
924
+ }
925
+ }
926
+ }
927
+ catch {
928
+ /* opening the UI is best-effort; a failed launch prompt must not fail setup */
929
+ }
930
+ settle("launch-ui", {
931
+ status: opened ? "done" : "skipped",
932
+ detail: uiUrl
933
+ ? openFailed
934
+ ? `UI available at ${uiUrl} (could not open a browser automatically)`
935
+ : opened
936
+ ? `opened ${uiUrl}`
937
+ : `UI available at ${uiUrl}`
938
+ : "UI URL unavailable",
939
+ });
940
+ return finish();
941
+ }
942
+ /**
943
+ * Detect an environment problem that blocks the entire flow: Docker missing or
944
+ * its daemon unreachable. Other failures (e.g. GitHub auth) are addressed by
945
+ * later steps and must not abort setup here.
946
+ *
947
+ * Keyed off the structured `Docker` check group rather than exact check names,
948
+ * so re-wording a check in checkCommands.ts can't silently let setup continue
949
+ * past a missing/unreachable engine. Within that group only the engine checks
950
+ * ("Docker installed", "Docker daemon") ever report `fail`; the socket check is
951
+ * informational and tops out at `warn`, so a `fail` here always means Docker
952
+ * itself cannot run the stack.
953
+ */
954
+ function blockingDockerFailure(outcome) {
955
+ return outcome.results.find((r) => r.group === "Docker" && r.status === "fail")?.detail;
956
+ }