@pikku/cli 0.12.93 → 0.12.96

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 (180) hide show
  1. package/console-app/assets/{index-CmAplHjZ.js → index-CeQodYfg.js} +149 -149
  2. package/console-app/assets/{index-BYj7ZbUU.css → index-CyYHTGWZ.css} +1 -1
  3. package/console-app/index.html +2 -2
  4. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  5. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  6. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  7. package/dist/.pikku/cli/pikku-cli-channel.d.ts +13 -15
  8. package/dist/.pikku/cli/pikku-cli-channel.js +62 -3
  9. package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +14 -4
  10. package/dist/.pikku/cli/pikku-cli-client.gen.js +20 -10
  11. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
  12. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
  13. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  14. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  15. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  16. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +129 -2
  17. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  18. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  19. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  20. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  21. package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
  22. package/dist/.pikku/function/pikku-function-types.gen.d.ts +55 -20
  23. package/dist/.pikku/function/pikku-function-types.gen.js +27 -1
  24. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  25. package/dist/.pikku/function/pikku-functions-meta.gen.json +138 -13
  26. package/dist/.pikku/function/pikku-functions.gen.js +11 -1
  27. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  28. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  29. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  30. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  31. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +1 -1
  32. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +1 -1
  33. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  34. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  35. package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
  36. package/dist/.pikku/pikku-meta-service.gen.js +1 -1
  37. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  38. package/dist/.pikku/pikku-types.gen.d.ts +1 -1
  39. package/dist/.pikku/pikku-types.gen.js +1 -1
  40. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  41. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  42. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  43. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  44. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  45. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  46. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +7 -0
  47. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +1 -1
  48. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +1 -1
  49. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +1 -1
  50. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +1 -1
  51. package/dist/.pikku/scenarios/schemas/register.gen.d.ts +1 -1
  52. package/dist/.pikku/scenarios/schemas/register.gen.js +1 -1
  53. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  54. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  55. package/dist/.pikku/schemas/register.gen.js +13 -3
  56. package/dist/.pikku/schemas/schemas/PersonaRunInput.schema.json +1 -0
  57. package/dist/.pikku/schemas/schemas/PersonaSyncInput.schema.json +1 -0
  58. package/dist/.pikku/schemas/schemas/PikkuCLIConfig.schema.json +1 -1
  59. package/dist/.pikku/schemas/schemas/PikkuPersonasInput.schema.json +1 -0
  60. package/dist/.pikku/schemas/schemas/PikkuRolesInput.schema.json +1 -0
  61. package/dist/.pikku/schemas/schemas/RolesAuditInput.schema.json +1 -0
  62. package/dist/.pikku/schemas/schemas/RolesPruneInput.schema.json +1 -0
  63. package/dist/.pikku/scopes/pikku-personas-meta.gen.json +1 -0
  64. package/dist/.pikku/scopes/pikku-personas.gen.d.ts +36 -0
  65. package/dist/.pikku/scopes/pikku-personas.gen.js +17 -0
  66. package/dist/.pikku/scopes/pikku-roles-meta.gen.json +1 -0
  67. package/dist/.pikku/scopes/pikku-roles.gen.d.ts +30 -0
  68. package/dist/.pikku/scopes/pikku-roles.gen.js +18 -0
  69. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +4 -2
  70. package/dist/.pikku/scopes/pikku-scope-types.gen.js +3 -2
  71. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  72. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +2 -2
  73. package/dist/.pikku/secrets/pikku-secret-types.gen.js +2 -2
  74. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  75. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  76. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  77. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  78. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +2 -2
  79. package/dist/.pikku/variables/pikku-variable-types.gen.js +2 -2
  80. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  81. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  82. package/dist/.pikku/workflow/meta/allWorkflow.gen.json +27 -3
  83. package/dist/.pikku/workflow/pikku-personas.gen.d.ts +15 -0
  84. package/dist/.pikku/workflow/pikku-personas.gen.js +17 -0
  85. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +96 -19
  86. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +22 -2
  87. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  88. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  89. package/dist/bin/pikku-bin.mjs +2 -2
  90. package/dist/bin/pikku.js +0 -0
  91. package/dist/src/cli.wiring.js +101 -2
  92. package/dist/src/deploy/analyzer/analyzer.js +14 -0
  93. package/dist/src/deploy/analyzer/manifest.d.ts +11 -0
  94. package/dist/src/fabric/functions/validate.function.js +4 -4
  95. package/dist/src/functions/commands/bootstrap.js +3 -0
  96. package/dist/src/functions/commands/dev-ai-runner.d.ts +16 -4
  97. package/dist/src/functions/commands/dev-ai-runner.js +63 -29
  98. package/dist/src/functions/commands/dev.js +9 -4
  99. package/dist/src/functions/commands/enable.js +15 -19
  100. package/dist/src/functions/commands/{scenario-environment.d.ts → environment.d.ts} +9 -6
  101. package/dist/src/functions/commands/{scenario-environment.js → environment.js} +5 -4
  102. package/dist/src/functions/commands/new-addon.js +12 -12
  103. package/dist/src/functions/commands/persona-sync.d.ts +33 -0
  104. package/dist/src/functions/commands/persona-sync.js +151 -0
  105. package/dist/src/functions/commands/persona.d.ts +58 -0
  106. package/dist/src/functions/commands/persona.js +217 -0
  107. package/dist/src/functions/commands/roles-audit.d.ts +1 -0
  108. package/dist/src/functions/commands/roles-audit.js +37 -0
  109. package/dist/src/functions/commands/roles-prune.d.ts +16 -0
  110. package/dist/src/functions/commands/roles-prune.js +52 -0
  111. package/dist/src/functions/commands/roles-shared.d.ts +40 -0
  112. package/dist/src/functions/commands/roles-shared.js +77 -0
  113. package/dist/src/functions/commands/scenario-browser.d.ts +3 -3
  114. package/dist/src/functions/commands/scenario-browser.js +1 -1
  115. package/dist/src/functions/commands/scenario-ladder.d.ts +44 -2
  116. package/dist/src/functions/commands/scenario-ladder.js +51 -2
  117. package/dist/src/functions/commands/scenario.d.ts +7 -0
  118. package/dist/src/functions/commands/scenario.js +79 -17
  119. package/dist/src/functions/commands/scopes-shared.d.ts +8 -0
  120. package/dist/src/functions/commands/scopes-shared.js +1 -1
  121. package/dist/src/functions/commands/serve.js +7 -3
  122. package/dist/src/functions/commands/virtual-user-formatter.d.ts +30 -0
  123. package/dist/src/functions/commands/virtual-user-formatter.js +65 -0
  124. package/dist/src/functions/db/local-db.js +38 -1
  125. package/dist/src/functions/validate/workspace-validate.js +1 -1
  126. package/dist/src/functions/wirings/ai-agent/pikku-command-public-agent.js +2 -1
  127. package/dist/src/functions/wirings/ai-agent/serialize-public-agent.js +1 -0
  128. package/dist/src/functions/wirings/auth/serialize-auth-gen.d.ts +2 -2
  129. package/dist/src/functions/wirings/auth/serialize-auth-gen.js +8 -8
  130. package/dist/src/functions/wirings/cli/serialize-channel-cli-client.d.ts +7 -3
  131. package/dist/src/functions/wirings/cli/serialize-channel-cli-client.js +32 -18
  132. package/dist/src/functions/wirings/cli/serialize-channel-cli.js +63 -6
  133. package/dist/src/functions/wirings/console/serialize-console-functions.js +4 -7
  134. package/dist/src/functions/wirings/functions/serialize-function-types.js +88 -28
  135. package/dist/src/functions/wirings/middleware/serialize-middleware-imports.js +25 -9
  136. package/dist/src/functions/wirings/package/serialize-package-types.js +5 -3
  137. package/dist/src/functions/wirings/personas/pikku-command-personas.d.ts +7 -0
  138. package/dist/src/functions/wirings/personas/pikku-command-personas.js +43 -0
  139. package/dist/src/functions/wirings/personas/serialize-personas-types.d.ts +18 -0
  140. package/dist/src/functions/wirings/personas/serialize-personas-types.js +76 -0
  141. package/dist/src/functions/wirings/realtime/pikku-command-events-scaffold.js +2 -1
  142. package/dist/src/functions/wirings/roles/pikku-command-roles.d.ts +7 -0
  143. package/dist/src/functions/wirings/roles/pikku-command-roles.js +33 -0
  144. package/dist/src/functions/wirings/roles/serialize-roles-types.d.ts +14 -0
  145. package/dist/src/functions/wirings/roles/serialize-roles-types.js +55 -0
  146. package/dist/src/functions/wirings/rpc/pikku-command-public-rpc.js +2 -1
  147. package/dist/src/functions/wirings/scenarios/register-scenario-instrumentation.d.ts +3 -3
  148. package/dist/src/functions/wirings/scenarios/register-scenario-instrumentation.js +3 -3
  149. package/dist/src/functions/wirings/scopes/serialize-scopes-types.d.ts +1 -1
  150. package/dist/src/functions/wirings/scopes/serialize-scopes-types.js +1 -1
  151. package/dist/src/functions/wirings/user-admin/pikku-command-user-admin-functions.js +2 -1
  152. package/dist/src/functions/wirings/user-admin/serialize-user-admin-functions.js +2 -2
  153. package/dist/src/functions/wirings/workflow/pikku-command-workflow-routes.js +2 -1
  154. package/dist/src/functions/wirings/workflow/pikku-command-workflow.js +13 -13
  155. package/dist/src/functions/wirings/workflow/serialize-personas.d.ts +10 -0
  156. package/dist/src/functions/wirings/workflow/serialize-personas.js +46 -0
  157. package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.js +11 -2
  158. package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +165 -29
  159. package/dist/src/functions/workflows/all.workflow.js +7 -1
  160. package/dist/src/server/content-signing-jwt.d.ts +13 -0
  161. package/dist/src/server/content-signing-jwt.js +51 -0
  162. package/dist/src/server/dev-server-runner.interface.d.ts +10 -2
  163. package/dist/src/server/node-server-runner.d.ts +2 -2
  164. package/dist/src/server/node-server-runner.js +2 -1
  165. package/dist/src/services.d.ts +3 -1
  166. package/dist/src/services.js +2 -1
  167. package/dist/src/utils/pikku-cli-config.js +45 -2
  168. package/dist/src/utils/resolve-personas.d.ts +32 -0
  169. package/dist/src/utils/resolve-personas.js +46 -0
  170. package/dist/src/utils/resolve-scaffold-feature.d.ts +19 -0
  171. package/dist/src/utils/resolve-scaffold-feature.js +49 -0
  172. package/dist/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +17 -14
  174. package/dist/.pikku/schemas/schemas/ScenarioRunInput.schema.json +0 -1
  175. package/dist/.pikku/workflow/pikku-scenario-actors.gen.d.ts +0 -19
  176. package/dist/.pikku/workflow/pikku-scenario-actors.gen.js +0 -17
  177. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.d.ts +0 -2
  178. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.js +0 -40
  179. package/dist/src/utils/resolve-scenario-actors.d.ts +0 -23
  180. package/dist/src/utils/resolve-scenario-actors.js +0 -74
@@ -1,18 +1,21 @@
1
1
  import { resolve, join } from 'node:path';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { pikkuSessionlessFunc } from '#pikku';
4
- import { InMemoryWorkflowService, createHttpScenarioActors, } from '@pikku/core/services';
4
+ import { InMemoryWorkflowService } from '@pikku/core/services';
5
+ import { createHttpPersonas } from '@pikku/core/persona';
5
6
  import { PikkuScenarioService } from '@pikku/core/scenario';
6
7
  import { pikkuState, getAllPackageStates } from '@pikku/core/internal';
7
8
  import { resolveFeatureScenarios } from '@pikku/core/workflow';
8
9
  import { loadScenarioBootstrap } from './load-user-project.js';
9
- import { collectScenarioStepProse, scenarioBrowserSteps, scenarioFailureFromSteps, scenarioStepRows, } from './scenario-ladder.js';
10
+ import { collectScenarioStepProse, scenarioBrowserSteps, scenarioFailureFromSteps, scenarioStepRows, scenarioStepsWithoutBinding, scenarioSurfaceCoverage, } from './scenario-ladder.js';
11
+ import { SCENARIO_SURFACES } from '@pikku/core/workflow';
10
12
  import { formatScenarioReport } from './scenario-formatter.js';
11
13
  import { resolveScenarioBrowserProvider, scenarioBrowserLifecycle, } from './scenario-browser.js';
12
- import { resolveScenarioActors } from '../../utils/resolve-scenario-actors.js';
14
+ import { resolvePersonas } from '../../utils/resolve-personas.js';
13
15
  import { spawnDevServer } from '../../server/spawn-dev-server.js';
14
16
  import { buildScenarioPlan } from './scenario-plan.js';
15
- import { resolveScenarioEnvironment } from './scenario-environment.js';
17
+ import { resolveEnvironment } from './environment.js';
18
+ import { createDevAIAgentRunner } from './dev-ai-runner.js';
16
19
  const isScenario = (wf) => wf?.scenario === true;
17
20
  const listScenarios = (state) => Object.entries(state.workflows?.meta ?? {})
18
21
  .filter(([, wf]) => isScenario(wf))
@@ -84,14 +87,17 @@ export const scenarioList = pikkuSessionlessFunc({
84
87
  },
85
88
  });
86
89
  export const scenarioRun = pikkuSessionlessFunc({
87
- func: async ({ logger, config, getInspectorState, variables }, { environment, flows, features, tags, excludeTags, coverage, browser = true, spawn = false, keepAlive = false, trace = false, apiUrl, appUrl, }) => {
90
+ func: async ({ logger, config, getInspectorState, variables }, { environment, flows, features, tags, excludeTags, coverage, browser = true, run: runSurface = 'default', strict = false, spawn = false, keepAlive = false, trace = false, apiUrl, appUrl, }) => {
91
+ if (!SCENARIO_SURFACES.includes(runSurface)) {
92
+ throw new Error(`Unknown --run surface '${runSurface}'. Expected one of: ${SCENARIO_SURFACES.join(', ')}.`);
93
+ }
88
94
  const state = await getInspectorState(true, false, false, true);
89
95
  // Resolved once, so actors, step env, the browser driver and any spawned
90
96
  // server all target the same place — including when the target only exists
91
97
  // at run time and arrives through --api-url/--app-url.
92
- const env = resolveScenarioEnvironment({
98
+ const env = resolveEnvironment({
93
99
  environment,
94
- environments: config.scenarios?.environments ?? {},
100
+ environments: config.environments ?? {},
95
101
  apiUrl,
96
102
  appUrl,
97
103
  spawn,
@@ -164,16 +170,17 @@ export const scenarioRun = pikkuSessionlessFunc({
164
170
  throw new Error('SCENARIO_ACTOR_SECRET is not set — scenario actors cannot sign in. ' +
165
171
  'Export it in the environment running this command (never put it in pikku.config.json).');
166
172
  }
167
- // Declared actors plus one per persona nobody declared a body for. Resolved
168
- // once: the HTTP actors and the Playwright provider below must see the same
169
- // registry, and codegen resolved the same way to type `ScenarioActorName`.
170
- const scenarioActors = resolveScenarioActors(config.scenarios);
171
- const actors = createHttpScenarioActors({
173
+ // Every declared persona, with its address filled in. Resolved once: the
174
+ // HTTP personas and the Playwright provider below must see the same
175
+ // registry, and codegen resolved the same way to type `PersonaName`.
176
+ const scenarioActors = resolvePersonas(state.personas?.definitions ?? [], config.scenarios?.emailDomain);
177
+ const actors = createHttpPersonas({
172
178
  apiUrl: env.apiUrl,
173
179
  secret,
174
- actors: scenarioActors,
180
+ personas: scenarioActors,
175
181
  signInPath: env.signInPath,
176
182
  rpcPath: env.rpcPath,
183
+ model: config.scenarios?.model,
177
184
  });
178
185
  const functionsMeta = state.functions?.meta ?? {};
179
186
  const scenarioNames = new Set(groups.flatMap((group) => group.entries.map((e) => e.scenarioName)));
@@ -183,6 +190,15 @@ export const scenarioRun = pikkuSessionlessFunc({
183
190
  scenarioBrowserSteps(state.workflows?.meta?.[name], functionsMeta),
184
191
  ])
185
192
  .filter(([, steps]) => steps.length > 0));
193
+ // `--no-browser` is the blunt form of `--run default`: a machine with no
194
+ // browser takes the server-side path rather than skipping the scenario.
195
+ const effectiveSurface = browser === false && runSurface === 'browser' ? 'default' : runSurface;
196
+ const unrunnableStepsByFlow = new Map([...scenarioNames]
197
+ .map((name) => [
198
+ name,
199
+ scenarioStepsWithoutBinding(state.workflows?.meta?.[name], functionsMeta, effectiveSurface),
200
+ ])
201
+ .filter(([, steps]) => steps.length > 0));
186
202
  // Two reasons a scenario is held back, both reported as SKIP rather than
187
203
  // failed. `--no-browser` is the direct replacement for cucumber's `@console`
188
204
  // tag, so a standard run stays green on a machine with no browser; a `skip`
@@ -191,8 +207,9 @@ export const scenarioRun = pikkuSessionlessFunc({
191
207
  const skipReasonFor = (entry) => {
192
208
  if (entry.skip)
193
209
  return entry.skip;
194
- if (!browser && browserStepsByFlow.has(entry.scenarioName)) {
195
- return 'browser steps, --no-browser';
210
+ const unrunnable = unrunnableStepsByFlow.get(entry.scenarioName);
211
+ if (unrunnable?.length) {
212
+ return `no ${effectiveSurface} or default binding: ${unrunnable.join(', ')}`;
196
213
  }
197
214
  return undefined;
198
215
  };
@@ -215,10 +232,27 @@ export const scenarioRun = pikkuSessionlessFunc({
215
232
  apiUrl: env.apiUrl,
216
233
  appUrl: env.appUrl,
217
234
  });
235
+ scenarioService.setRunSurface(effectiveSurface);
236
+ // Scenario steps run here, not on the target — everything they touch of the
237
+ // app goes over HTTP through an actor, which is what `guardRpc` below
238
+ // enforces. `actor.converse` is the exception, and the reason this is not
239
+ // just the three services above: the persona's own turns are LLM calls made
240
+ // in this process, and without a runner every conversing scenario fails
241
+ // before it says anything. Built the same way `pikku dev` builds its own,
242
+ // and only when the project declares agents, so a project with no agents
243
+ // does not have to have AI env set to run scenarios.
244
+ const aiAgentRunner = Object.keys(state.agents?.agentsMeta ?? {}).length > 0
245
+ ? await createDevAIAgentRunner({
246
+ logger,
247
+ projectRoot: config.rootDir,
248
+ variables,
249
+ })
250
+ : undefined;
218
251
  pikkuState(null, 'package', 'singletonServices', {
219
252
  logger,
220
253
  workflowService,
221
254
  workflowRunService: workflowService,
255
+ ...(aiAgentRunner ? { aiAgentRunner } : {}),
222
256
  });
223
257
  const guardRpc = {
224
258
  rpcWithWire: async (rpcName) => {
@@ -227,7 +261,11 @@ export const scenarioRun = pikkuSessionlessFunc({
227
261
  `'${environment}' (${env.apiUrl}), not local services.`);
228
262
  },
229
263
  };
230
- const needsBrowser = groups.some((group) => group.entries.some((entry) => browserStepsByFlow.has(entry.scenarioName)));
264
+ // Only a browser run launches one. Under `--run default` a step with a
265
+ // browser binding takes its default path instead, so there is nothing to
266
+ // drive and nothing to pay for.
267
+ const needsBrowser = effectiveSurface === 'browser' &&
268
+ groups.some((group) => group.entries.some((entry) => browserStepsByFlow.has(entry.scenarioName)));
231
269
  const failureDir = join(resolve(config.rootDir, config.outDir), 'scenario-failures');
232
270
  const browserLifecycle = scenarioBrowserLifecycle(needsBrowser
233
271
  ? await (async () => {
@@ -448,8 +486,32 @@ export const scenarioRun = pikkuSessionlessFunc({
448
486
  })) {
449
487
  logger[level](text);
450
488
  }
489
+ // How much of the run actually happened on the surface it targeted. Every
490
+ // step counts, so a step that fell back to the server lowers the ratio
491
+ // rather than needing a footnote. Assertions that fell back are named
492
+ // separately — those are sentences claiming an observation nobody made.
493
+ const surfaceCoverage = { onSurface: 0, total: 0 };
494
+ const unwitnessed = new Set();
495
+ for (const name of scenarioNames) {
496
+ const scenario = scenarioSurfaceCoverage(state.workflows?.meta?.[name], functionsMeta, effectiveSurface);
497
+ surfaceCoverage.onSurface += scenario.onSurface;
498
+ surfaceCoverage.total += scenario.total;
499
+ for (const step of scenario.unwitnessed)
500
+ unwitnessed.add(step);
501
+ }
502
+ if (effectiveSurface !== 'default' && surfaceCoverage.total > 0) {
503
+ const line = `${surfaceCoverage.onSurface}/${surfaceCoverage.total} steps ran on ${effectiveSurface}`;
504
+ if (unwitnessed.size === 0) {
505
+ logger.info(line);
506
+ }
507
+ else {
508
+ logger[strict ? 'error' : 'warn'](`${line} — asserted server-side only: ${[...unwitnessed].join(', ')}`);
509
+ }
510
+ }
451
511
  const failed = results.filter((r) => r.status === 'failed');
452
- if (failed.length > 0 || hookFailures.length > 0) {
512
+ if (failed.length > 0 ||
513
+ hookFailures.length > 0 ||
514
+ (strict && unwitnessed.size > 0)) {
453
515
  process.exitCode = 1;
454
516
  }
455
517
  },
@@ -1,5 +1,7 @@
1
+ import type { Kysely } from 'kysely';
1
2
  import type { FlatScope } from '@pikku/core/scope';
2
3
  import { KyselyScopeService } from '@pikku/kysely';
4
+ import type { KyselyPikkuDB } from '@pikku/kysely';
3
5
  type Logger = {
4
6
  info: (msg: string) => void;
5
7
  warn: (msg: string) => void;
@@ -8,6 +10,12 @@ type Logger = {
8
10
  export declare const loadDeclaredScopes: (scopesMetaJsonFile: string, logger: Logger) => Promise<FlatScope[] | null>;
9
11
  export type OpenedScopeService = {
10
12
  service: KyselyScopeService;
13
+ /**
14
+ * The same connection the service holds, for the one thing it cannot answer:
15
+ * a user's id from their address. Grants are keyed by id, and the `user`
16
+ * table those ids come from is better-auth's, not the scope schema's.
17
+ */
18
+ db: Kysely<KyselyPikkuDB>;
11
19
  destroy: () => Promise<void>;
12
20
  };
13
21
  /**
@@ -62,7 +62,7 @@ export const openScopeService = async ({ config, logger, }, declared, command) =
62
62
  const service = new KyselyScopeService(db);
63
63
  await service.init();
64
64
  await service.syncScopes(declared);
65
- return { service, destroy: () => db.destroy() };
65
+ return { service, db, destroy: () => db.destroy() };
66
66
  }
67
67
  catch (e) {
68
68
  await db.destroy();
@@ -12,6 +12,7 @@ import { loadUserBootstrap, loadUserModule } from './load-user-project.js';
12
12
  import { createDevAIAgentRunner } from './dev-ai-runner.js';
13
13
  import { resolveConsoleMount } from './serve-console.js';
14
14
  import { serverReadyLine } from '../../server/server-ready.js';
15
+ import { createEphemeralContentSigningJWT } from '../../server/content-signing-jwt.js';
15
16
  export const serve = pikkuSessionlessFunc({
16
17
  remote: true,
17
18
  func: async ({ logger, config, getInspectorState, variables, devServerRunner }, { port, console: serveConsole }) => {
@@ -54,8 +55,11 @@ export const serve = pikkuSessionlessFunc({
54
55
  sizeLimit: userConfig.content.sizeLimit,
55
56
  }
56
57
  : undefined;
57
- const localContent = localContentConfig
58
- ? new LocalContent(localContentConfig, logger)
58
+ const contentSigningJWT = localContentConfig
59
+ ? createEphemeralContentSigningJWT()
60
+ : undefined;
61
+ const localContent = localContentConfig && contentSigningJWT
62
+ ? new LocalContent(localContentConfig, logger, contentSigningJWT)
59
63
  : undefined;
60
64
  const schedulerService = new InMemorySchedulerService();
61
65
  const aiStorage = kysely
@@ -128,7 +132,7 @@ export const serve = pikkuSessionlessFunc({
128
132
  port: resolvedPort,
129
133
  content: localContentConfig,
130
134
  ...(consoleMount ? { staticMounts: [consoleMount] } : {}),
131
- }, logger);
135
+ }, logger, { contentSigningJWT });
132
136
  const lifecycle = await loadLifecycle();
133
137
  await pikkuServer.init();
134
138
  await lifecycle?.beforeStart?.(resolvedServices);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The virtual user run report, and how it is rendered.
3
+ *
4
+ * A run is not a pass/fail: a virtual user pursuing plausible goals against a
5
+ * real stage produces a story — what it tried, what it gave up on, and what
6
+ * came back that should not have. The findings are the product; everything else
7
+ * is context for reading them.
8
+ *
9
+ * Kept a pure function over plain data for the same reason the scenario
10
+ * reporter is: a second formatter (JSON for fabric, JUnit for CI) is then one
11
+ * function rather than an unpicking of the runner.
12
+ */
13
+ import type { VirtualUserRunResult } from '@pikku/core/virtual-user';
14
+ export interface VirtualUserReportContext {
15
+ persona: string;
16
+ disposition: string;
17
+ environment: string;
18
+ apiUrl: string;
19
+ /** How much of the catalogue's read/write split had to be guessed at. */
20
+ catalogue: {
21
+ total: number;
22
+ annotated: number;
23
+ inferred: number;
24
+ };
25
+ }
26
+ export interface VirtualUserReportLine {
27
+ level: 'info' | 'error' | 'warn';
28
+ text: string;
29
+ }
30
+ export declare const formatVirtualUserReport: (result: VirtualUserRunResult, context: VirtualUserReportContext) => VirtualUserReportLine[];
@@ -0,0 +1,65 @@
1
+ const STATUS_MARK = {
2
+ completed: 'DONE',
3
+ abandoned: 'DROP',
4
+ stuck: 'STUCK',
5
+ suspended: 'OPEN',
6
+ open: 'OPEN',
7
+ };
8
+ /** Why a run ended, in words rather than an enum. */
9
+ const STOPPED_BY = {
10
+ 'no-intents': 'this persona had nothing to want — no scenario names them',
11
+ 'budget-steps': 'ran out of steps',
12
+ 'budget-mutations': 'ran out of mutations',
13
+ 'budget-duration': 'ran out of time',
14
+ 'stop-hook': 'the app called a halt',
15
+ exhausted: 'saw everything it came for through',
16
+ };
17
+ const findingLine = (finding) => ` ${finding.kind.padEnd(18)} ${finding.detail} (step ${finding.step})`;
18
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
19
+ export const formatVirtualUserReport = (result, context) => {
20
+ const lines = [];
21
+ const info = (text) => lines.push({ level: 'info', text });
22
+ const error = (text) => lines.push({ level: 'error', text });
23
+ const warn = (text) => lines.push({ level: 'warn', text });
24
+ info(`Virtual user '${context.persona}' (${context.disposition}) against '${context.environment}' — ${context.apiUrl}`);
25
+ info(` seed ${result.seed} · ${plural(context.catalogue.total, 'endpoint')} offered` +
26
+ (context.catalogue.inferred
27
+ ? ` · read/write guessed for ${context.catalogue.inferred} of them`
28
+ : ''));
29
+ if (result.intents.length) {
30
+ info('');
31
+ info('What it tried');
32
+ for (const intent of result.intents) {
33
+ const mark = STATUS_MARK[intent.status] ?? intent.status.toUpperCase();
34
+ const interrupted = intent.suspensions
35
+ ? `, put down ${plural(intent.suspensions, 'time')}`
36
+ : '';
37
+ const summary = intent.summary ? ` — ${intent.summary}` : '';
38
+ info(` ${mark.padEnd(5)} ${intent.title} (${plural(intent.steps.length, 'step')}${interrupted})${summary}`);
39
+ }
40
+ }
41
+ info('');
42
+ if (result.findings.length === 0) {
43
+ info('Nothing came back that should not have.');
44
+ }
45
+ else {
46
+ error(`${plural(result.findings.length, 'finding')}`);
47
+ for (const finding of result.findings) {
48
+ error(findingLine(finding));
49
+ }
50
+ }
51
+ const { tally } = result;
52
+ info('');
53
+ info(`${plural(tally.steps, 'step')} · ${plural(tally.calls, 'call')} · ` +
54
+ `${plural(tally.mutations, 'mutation')} · ${tally.tokensIn}/${tally.tokensOut} tokens ` +
55
+ `(${tally.model}) · ${Math.round(tally.elapsedMs / 1000)}s`);
56
+ info(`Stopped because it ${STOPPED_BY[result.stoppedBy]}.`);
57
+ if (result.stoppedBy === 'no-intents') {
58
+ warn(`Give it something to want: declare a scenario naming '${context.persona}', or pass --goals.`);
59
+ }
60
+ // A seed is only worth printing when there is something to reproduce.
61
+ if (result.findings.length) {
62
+ info(`Replay this exact run with --seed ${result.seed}.`);
63
+ }
64
+ return lines;
65
+ };
@@ -123,13 +123,50 @@ async function createPostgresClient(resolved) {
123
123
  const db = await createEmbeddedPostgres(resolved.pgliteDir);
124
124
  return pgliteAsClient(db);
125
125
  }
126
+ let embeddedPostgresWasm;
127
+ /**
128
+ * Compile PGlite's two WASM modules once and hand them to every instance.
129
+ *
130
+ * PGlite already caches them across instances internally, so this saves one
131
+ * 10.5MB compile per process rather than one per instance — measured at ~5% off
132
+ * a four-instance run. It is worth the few lines because a `WebAssembly.Module`
133
+ * is stateless compiled code, safe to instantiate any number of times.
134
+ *
135
+ * Note what this does *not* fix: each instance still compiles ~4MB of its own
136
+ * WASM (~3.3MB of it the pgcrypto side module, which PGlite offers no way to
137
+ * share) across ~15 module constructions, and frees it on close. That per
138
+ * instance churn is the suspected trigger for the rare V8 abort seen in CI on
139
+ * Linux x64, where PKU-based ThreadIsolation loses track of a code allocation:
140
+ *
141
+ * Check failed: jit_page_->allocations_.erase(addr) == 1.
142
+ *
143
+ * Resolution mirrors PGlite's own `new URL('./pglite.wasm', import.meta.url)`.
144
+ * If the files are not where the package puts them (a bundled CLI, say), we
145
+ * hand back nothing and PGlite loads them itself as before.
146
+ */
147
+ async function loadEmbeddedPostgresWasm() {
148
+ try {
149
+ const dist = dirname(createRequire(import.meta.url).resolve('@electric-sql/pglite'));
150
+ const [pgliteWasmModule, initdbWasmModule] = await Promise.all([
151
+ WebAssembly.compile(readFileSync(join(dist, 'pglite.wasm'))),
152
+ WebAssembly.compile(readFileSync(join(dist, 'initdb.wasm'))),
153
+ ]);
154
+ return { pgliteWasmModule, initdbWasmModule };
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
126
160
  async function createEmbeddedPostgres(dataDir) {
127
- const [{ PGlite }, { pgcrypto }] = await Promise.all([
161
+ embeddedPostgresWasm ??= loadEmbeddedPostgresWasm();
162
+ const [{ PGlite }, { pgcrypto }, wasm] = await Promise.all([
128
163
  import('@electric-sql/pglite'),
129
164
  import('@electric-sql/pglite/contrib/pgcrypto'),
165
+ embeddedPostgresWasm,
130
166
  ]);
131
167
  return new PGlite({
132
168
  ...(dataDir ? { dataDir } : {}),
169
+ ...wasm,
133
170
  extensions: {
134
171
  pgcrypto,
135
172
  },
@@ -97,7 +97,7 @@ export async function runWorkspaceValidate(startDir = process.cwd()) {
97
97
  }
98
98
  const scaffold = pikkuConfig.scaffold;
99
99
  if (!scaffold?.console) {
100
- e('pikku-config-no-console-scaffold', 'pikku.config.json missing "scaffold.console" — Fabric cannot introspect the running app (console:getFunctionsMeta and friends 404), so the sandbox builder shows no functions', pikkuConfigPath, 'Add "console": "auth" under "scaffold" in pikku.config.json — the console requires an authenticated session, so Better Auth must be set up (see the pikku-better-auth skill)');
100
+ e('pikku-config-no-console-scaffold', 'pikku.config.json missing "scaffold.console" — Fabric cannot introspect the running app (console:getFunctionsMeta and friends 404), so the sandbox builder shows no functions', pikkuConfigPath, 'Add "console": true under "scaffold" in pikku.config.json — the console requires an authenticated session, so Better Auth must be set up (see the pikku-better-auth skill)');
101
101
  }
102
102
  }
103
103
  const rootPkgPath = join(root, 'package.json');
@@ -4,11 +4,12 @@ import { writeFileInDir } from '../../../utils/file-writer.js';
4
4
  import { logCommandInfoAndTime } from '../../../middleware/log-command-info-and-time.js';
5
5
  import { removeLegacyScaffoldFile } from '../../../utils/remove-legacy-scaffold-file.js';
6
6
  import { serializePublicAgent } from './serialize-public-agent.js';
7
+ import { resolveScaffoldFeature } from '../../../utils/resolve-scaffold-feature.js';
7
8
  export const pikkuPublicAgent = pikkuSessionlessFunc({
8
9
  func: async ({ logger, config }) => {
9
10
  if (config.scaffold?.agent && config.publicAgentSchemasFile) {
10
11
  const pathToPikkuTypes = getFileImportRelativePath(config.publicAgentFile, config.typesDeclarationFile, config.packageMappings);
11
- const { schemas, functions } = serializePublicAgent(pathToPikkuTypes, config.scaffold.agent === 'auth', config.globalHTTPPrefix || '');
12
+ const { schemas, functions } = serializePublicAgent(pathToPikkuTypes, resolveScaffoldFeature('agent', config.scaffold.agent).auth, config.globalHTTPPrefix || '');
12
13
  await writeFileInDir(logger, config.publicAgentSchemasFile, schemas);
13
14
  await writeFileInDir(logger, config.publicAgentFile, functions);
14
15
  await removeLegacyScaffoldFile(config.publicAgentFile);
@@ -81,6 +81,7 @@ export const isThreadOwner = pikkuPermission<{ threadId: string }>(
81
81
  )
82
82
 
83
83
  export const agentCaller = pikkuSessionlessFunc({
84
+ description: 'Run a named AI agent with a message and return its reply',
84
85
  tags: ['pikku'],
85
86
  auth: ${authFlag},
86
87
  input: AgentCall,
@@ -5,14 +5,14 @@ import type { AuthDefinition } from '@pikku/inspector';
5
5
  * They are split because the CLI's schema/wiring-separation rule (PKU490)
6
6
  * forbids a file from declaring Zod schemas AND `wireHTTPRoutes` together —
7
7
  * schema files are imported at runtime, which would fire HTTP wiring
8
- * side-effects without a server context. `wireSecret`/`wireVariable` are NOT
8
+ * side-effects without a server context. `defineSecret`/`defineVariable` are NOT
9
9
  * HTTP wiring, so schemas may sit alongside them; only the route wiring must be
10
10
  * separated out.
11
11
  */
12
12
  export interface AuthGenOutput {
13
13
  /** Handler + catch-all routes (+ session middleware, unless split out). */
14
14
  wiring: string;
15
- /** The secrets file: Zod schemas + wireSecret/wireVariable. */
15
+ /** The secrets file: Zod schemas + defineSecret/defineVariable. */
16
16
  secrets: string;
17
17
  /** Global session-middleware registration, split into its own file only when
18
18
  * `session.cookieCache` is enabled (stateless path); undefined otherwise. */
@@ -59,22 +59,22 @@ export const serializeAuthGen = (definition, providers, authFile, typesDeclarati
59
59
  // the package's `imports` map (e.g. a project's `pikku/` dir), where `#pikku`
60
60
  // would not resolve. This mirrors how the console/agent scaffolds import it.
61
61
  const pikkuTypesImportPath = getFileImportRelativePath(authFile, typesDeclarationFile, packageMappings);
62
- // ─── Secrets file: Zod schemas + wireSecret/wireVariable ──────────────────
62
+ // ─── Secrets file: Zod schemas + defineSecret/defineVariable ──────────────────
63
63
  const secrets = [
64
64
  '// AUTO-GENERATED by pikku CLI — do not edit',
65
65
  '',
66
- `import { wireSecret } from '@pikku/core/secret'`,
66
+ `import { defineSecret } from '@pikku/core/secret'`,
67
67
  ];
68
68
  const hasVariables = known.some((name) => PROVIDER_REGISTRY[name].variables);
69
69
  if (hasVariables) {
70
- secrets.push(`import { wireVariable } from '@pikku/core/variable'`);
70
+ secrets.push(`import { defineVariable } from '@pikku/core/variable'`);
71
71
  }
72
72
  secrets.push(`import { z } from 'zod'`, '');
73
73
  // better-auth's session-signing secret is always required (its BETTER_AUTH_SECRET
74
74
  // env convention). Wire it so the platform collects it, regardless of providers.
75
75
  secrets.push(`export const BetterAuthSecretSchema = z.string()`);
76
76
  secrets.push('');
77
- secrets.push(`wireSecret({`);
77
+ secrets.push(`defineSecret({`);
78
78
  secrets.push(` name: 'betterAuthSecret',`);
79
79
  secrets.push(` displayName: 'Better Auth Secret',`);
80
80
  secrets.push(` description: 'Signing secret for better-auth sessions',`);
@@ -92,12 +92,12 @@ export const serializeAuthGen = (definition, providers, authFile, typesDeclarati
92
92
  secrets.push(`})`);
93
93
  secrets.push('');
94
94
  }
95
- // wireSecret for each provider.
95
+ // defineSecret for each provider.
96
96
  for (const name of known) {
97
97
  const def = PROVIDER_REGISTRY[name];
98
98
  const schemaName = providerSchemaName(name);
99
99
  const secretName = providerSecretName(name);
100
- secrets.push(`wireSecret({`);
100
+ secrets.push(`defineSecret({`);
101
101
  secrets.push(` name: '${secretName}',`);
102
102
  secrets.push(` displayName: '${def.displayName}',`);
103
103
  secrets.push(` secretId: '${def.secretId}',`);
@@ -105,7 +105,7 @@ export const serializeAuthGen = (definition, providers, authFile, typesDeclarati
105
105
  secrets.push(`})`);
106
106
  secrets.push('');
107
107
  }
108
- // wireVariable for providers with non-secret config (issuer, tenantId, etc.).
108
+ // defineVariable for providers with non-secret config (issuer, tenantId, etc.).
109
109
  for (const name of known) {
110
110
  const def = PROVIDER_REGISTRY[name];
111
111
  if (!def.variables)
@@ -116,7 +116,7 @@ export const serializeAuthGen = (definition, providers, authFile, typesDeclarati
116
116
  const schemaName = variableSchemaName(name, field);
117
117
  secrets.push(`export const ${schemaName} = z.string()`);
118
118
  secrets.push('');
119
- secrets.push(`wireVariable({`);
119
+ secrets.push(`defineVariable({`);
120
120
  secrets.push(` name: '${name}_${field}',`);
121
121
  secrets.push(` displayName: '${def.displayName} ${capitalize(field)}',`);
122
122
  secrets.push(` description: '${meta.description}',`);
@@ -5,7 +5,11 @@ import type { Config } from '../../../../types/application-types.js';
5
5
  */
6
6
  export declare function collectRendererNames(programMeta: CLIProgramMeta): Set<string>;
7
7
  /**
8
- * Serializes a CLI-over-Channel client bootstrap file
9
- * Similar to local CLI bootstrap but uses WebSocket connection
8
+ * Serializes a CLI-over-Channel client bootstrap file.
9
+ *
10
+ * The client deliberately does NOT import the CLI bootstrap: loading the
11
+ * command tree here would bind the client's version to the server's, which is
12
+ * the coupling running commands remotely exists to remove. Renderers are the
13
+ * only local artefact, and they are optional.
10
14
  */
11
- export declare function serializeChannelCLIClient(programName: string, programMeta: CLIProgramMeta, clientFile: string, config: Config, cliBootstrapPath: string, channelRoute?: string, renderersMeta?: Record<string, any>): string;
15
+ export declare function serializeChannelCLIClient(programName: string, programMeta: CLIProgramMeta, clientFile: string, config: Config, _cliBootstrapPath: string, channelRoute?: string, renderersMeta?: Record<string, any>): string;
@@ -65,14 +65,16 @@ function buildRenderersMap(programMeta, renderersMeta) {
65
65
  return `{\n${entries.join(',\n')}\n }`;
66
66
  }
67
67
  /**
68
- * Serializes a CLI-over-Channel client bootstrap file
69
- * Similar to local CLI bootstrap but uses WebSocket connection
68
+ * Serializes a CLI-over-Channel client bootstrap file.
69
+ *
70
+ * The client deliberately does NOT import the CLI bootstrap: loading the
71
+ * command tree here would bind the client's version to the server's, which is
72
+ * the coupling running commands remotely exists to remove. Renderers are the
73
+ * only local artefact, and they are optional.
70
74
  */
71
- export function serializeChannelCLIClient(programName, programMeta, clientFile, config, cliBootstrapPath, channelRoute, renderersMeta) {
75
+ export function serializeChannelCLIClient(programName, programMeta, clientFile, config, _cliBootstrapPath, channelRoute, renderersMeta) {
72
76
  const capitalizedName = programName.charAt(0).toUpperCase() + programName.slice(1).replace(/-/g, '');
73
77
  const finalChannelRoute = channelRoute || `/cli/${programName}`;
74
- // Get relative import path to CLI bootstrap (for metadata)
75
- const bootstrapImportPath = getFileImportRelativePath(clientFile, cliBootstrapPath, config.packageMappings);
76
78
  // Collect all unique renderer names from CLI metadata (populated by inspector)
77
79
  const rendererNames = collectRendererNames(programMeta);
78
80
  // Generate renderer imports from their source files
@@ -108,29 +110,40 @@ export function serializeChannelCLIClient(programName, programMeta, clientFile,
108
110
  ? `,\n defaultRenderer: ${defaultRendererBinding}`
109
111
  : '';
110
112
  return `
111
- import { executeCLIViaChannel } from '@pikku/core/cli/channel'
113
+ import { executeRawCLIViaChannel } from '@pikku/core/cli/channel'
114
+ import type { Capabilities } from '@pikku/core/channel'
112
115
  import { CorePikkuWebsocket } from '@pikku/websocket'
113
- import '${bootstrapImportPath}'
114
116
  ${rendererImports}
115
117
  /**
116
118
  * ${capitalizedName} CLI Client (via WebSocket Channel)
117
- * Executes CLI commands over a WebSocket connection
119
+ *
120
+ * Forwards argv to the server untouched — the command tree is never parsed
121
+ * here, so this client stays valid as the server's commands change. Renderers
122
+ * are matched by the command id the server reports; anything it doesn't
123
+ * recognise falls back to JSON.
124
+ *
125
+ * \`capabilities\` are the functions this client agrees to run on the server's
126
+ * behalf (a git sha, a local path). It is an allowlist, not a convenience —
127
+ * nothing outside it is reachable from the server. A bare function is
128
+ * unclassified and so needs approval before every call; write
129
+ * \`{ execute, needsApproval: false }\` for one that may run unattended.
118
130
  */
119
131
  export async function ${capitalizedName}CLIClient(
120
132
  ws: WebSocket,
121
- args?: string[]
122
- ): Promise<void> {
133
+ args?: string[],
134
+ capabilities: Capabilities = {}
135
+ ): Promise<number> {
123
136
  // Create Pikku WebSocket wrapper
124
137
  const pikkuWS = new CorePikkuWebsocket(ws)
125
138
 
126
139
  // Register renderers for CLI commands
127
140
  const renderers = ${renderersMap}
128
141
 
129
- await executeCLIViaChannel({
130
- programName: '${programName}',
142
+ return executeRawCLIViaChannel({
131
143
  pikkuWS,
132
144
  args,
133
- renderers${defaultRendererCode},
145
+ renderers,
146
+ capabilities${defaultRendererCode},
134
147
  })
135
148
  }
136
149
 
@@ -183,11 +196,12 @@ if (import.meta.url === \`file://\${process.argv[1]}\`) {
183
196
  ws = new WebSocket(url)
184
197
  }
185
198
 
186
- ${capitalizedName}CLIClient(ws, process.argv.slice(2)).catch(error => {
187
- console.error('Fatal channel CLI error:', error)
188
- // TODO: We get an error code even when it exists cleanly, investigate
189
- // process.exit(1)
190
- })
199
+ ${capitalizedName}CLIClient(ws, process.argv.slice(2))
200
+ .then((exitCode) => process.exit(exitCode))
201
+ .catch(error => {
202
+ console.error('Fatal channel CLI error:', error)
203
+ process.exit(1)
204
+ })
191
205
  }
192
206
  `;
193
207
  }