@pikku/cli 0.12.125 → 0.12.126

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 (176) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/dist/.pikku/addon/index.d.ts +1 -1
  3. package/dist/.pikku/addon/index.js +1 -1
  4. package/dist/.pikku/addon/pikku-addon-types.gen.d.ts +1 -1
  5. package/dist/.pikku/addon/pikku-addon-types.gen.js +1 -1
  6. package/dist/.pikku/agent/index.d.ts +1 -1
  7. package/dist/.pikku/agent/index.js +1 -1
  8. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  9. package/dist/.pikku/agent/pikku-model-aliases.gen.js +1 -1
  10. package/dist/.pikku/auth/index.d.ts +1 -1
  11. package/dist/.pikku/auth/index.js +1 -1
  12. package/dist/.pikku/auth/pikku-auth-types.gen.d.ts +1 -1
  13. package/dist/.pikku/auth/pikku-auth-types.gen.js +1 -1
  14. package/dist/.pikku/channel/index.d.ts +1 -1
  15. package/dist/.pikku/channel/index.js +1 -1
  16. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  17. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  18. package/dist/.pikku/cli/index.d.ts +1 -1
  19. package/dist/.pikku/cli/index.js +1 -1
  20. package/dist/.pikku/cli/pikku-cli-channel.js +6 -1
  21. package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
  22. package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
  23. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
  24. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
  25. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  26. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  27. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  28. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +13 -0
  29. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  30. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  31. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  32. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  33. package/dist/.pikku/error/index.d.ts +1 -1
  34. package/dist/.pikku/error/index.js +1 -1
  35. package/dist/.pikku/error/pikku-error-types.gen.d.ts +1 -1
  36. package/dist/.pikku/error/pikku-error-types.gen.js +1 -1
  37. package/dist/.pikku/function/index.d.ts +1 -1
  38. package/dist/.pikku/function/index.js +1 -1
  39. package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
  40. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  41. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  42. package/dist/.pikku/function/pikku-functions-meta.gen.json +35 -18
  43. package/dist/.pikku/function/pikku-functions.gen.js +1 -1
  44. package/dist/.pikku/gateway/index.d.ts +1 -1
  45. package/dist/.pikku/gateway/index.js +1 -1
  46. package/dist/.pikku/gateway/pikku-gateway-types.gen.d.ts +1 -1
  47. package/dist/.pikku/gateway/pikku-gateway-types.gen.js +1 -1
  48. package/dist/.pikku/http/index.d.ts +1 -1
  49. package/dist/.pikku/http/index.js +1 -1
  50. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  51. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  52. package/dist/.pikku/mcp/index.d.ts +1 -1
  53. package/dist/.pikku/mcp/index.js +1 -1
  54. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  55. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  56. package/dist/.pikku/middleware/index.d.ts +1 -1
  57. package/dist/.pikku/middleware/index.js +1 -1
  58. package/dist/.pikku/middleware/pikku-middleware-types.gen.d.ts +1 -1
  59. package/dist/.pikku/middleware/pikku-middleware-types.gen.js +1 -1
  60. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +1 -1
  61. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +1 -1
  62. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  63. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  64. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  65. package/dist/.pikku/queue/index.d.ts +1 -1
  66. package/dist/.pikku/queue/index.js +1 -1
  67. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  68. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  69. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  70. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  71. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  72. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  73. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +1 -0
  74. package/dist/.pikku/scenarios/index.d.ts +1 -1
  75. package/dist/.pikku/scenarios/index.js +1 -1
  76. package/dist/.pikku/scenarios/pikku-personas.gen.js +1 -1
  77. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +1 -1
  78. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +1 -1
  79. package/dist/.pikku/scenarios/pikku-scenario-types.gen.d.ts +1 -1
  80. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +1 -1
  81. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +1 -1
  82. package/dist/.pikku/scenarios/schemas/register.gen.d.ts +1 -1
  83. package/dist/.pikku/scenarios/schemas/register.gen.js +1 -1
  84. package/dist/.pikku/scheduler/index.d.ts +1 -1
  85. package/dist/.pikku/scheduler/index.js +1 -1
  86. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  87. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  88. package/dist/.pikku/schemas/register.gen.js +3 -1
  89. package/dist/.pikku/schemas/schemas/FabricSecretsListInput.schema.json +1 -1
  90. package/dist/.pikku/schemas/schemas/FabricSecretsRotateInput.schema.json +1 -1
  91. package/dist/.pikku/schemas/schemas/FabricSecretsSetInput.schema.json +1 -1
  92. package/dist/.pikku/schemas/schemas/FabricVariablesGetInput.schema.json +1 -1
  93. package/dist/.pikku/schemas/schemas/FabricVariablesSetInput.schema.json +1 -1
  94. package/dist/.pikku/schemas/schemas/PersonaSecretInput.schema.json +1 -0
  95. package/dist/.pikku/scopes/index.d.ts +1 -1
  96. package/dist/.pikku/scopes/index.js +1 -1
  97. package/dist/.pikku/scopes/pikku-personas.gen.js +1 -1
  98. package/dist/.pikku/scopes/pikku-roles.gen.d.ts +1 -1
  99. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
  100. package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
  101. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  102. package/dist/.pikku/secrets/index.d.ts +1 -1
  103. package/dist/.pikku/secrets/index.js +1 -1
  104. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  105. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  106. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  107. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  108. package/dist/.pikku/services/pikku-meta-service.gen.d.ts +1 -1
  109. package/dist/.pikku/services/pikku-meta-service.gen.js +1 -1
  110. package/dist/.pikku/setup/index.d.ts +1 -1
  111. package/dist/.pikku/setup/index.js +1 -1
  112. package/dist/.pikku/setup/pikku-setup-types.gen.d.ts +1 -1
  113. package/dist/.pikku/setup/pikku-setup-types.gen.js +1 -1
  114. package/dist/.pikku/trigger/index.d.ts +1 -1
  115. package/dist/.pikku/trigger/index.js +1 -1
  116. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  117. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  118. package/dist/.pikku/variables/index.d.ts +1 -1
  119. package/dist/.pikku/variables/index.js +1 -1
  120. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  121. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  122. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  123. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  124. package/dist/.pikku/workflow/index.d.ts +1 -1
  125. package/dist/.pikku/workflow/index.js +1 -1
  126. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
  127. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
  128. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  129. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  130. package/dist/bin/pikku-bin.mjs +2 -2
  131. package/dist/src/cli.wiring.js +6 -1
  132. package/dist/src/deploy/bundler/bun-bundler.js +12 -5
  133. package/dist/src/fabric/fabric-commands.d.ts +15 -15
  134. package/dist/src/fabric/functions/deploy.function.js +12 -2
  135. package/dist/src/fabric/functions/secrets-list.function.d.ts +4 -4
  136. package/dist/src/fabric/functions/secrets-list.function.js +4 -4
  137. package/dist/src/fabric/functions/secrets-rotate.function.d.ts +4 -4
  138. package/dist/src/fabric/functions/secrets-rotate.function.js +8 -5
  139. package/dist/src/fabric/functions/secrets-set.function.d.ts +4 -4
  140. package/dist/src/fabric/functions/secrets-set.function.js +4 -4
  141. package/dist/src/fabric/functions/validate.function.js +49 -24
  142. package/dist/src/fabric/functions/variables-get.function.d.ts +4 -4
  143. package/dist/src/fabric/functions/variables-get.function.js +4 -4
  144. package/dist/src/fabric/functions/variables-set.function.d.ts +4 -4
  145. package/dist/src/fabric/functions/variables-set.function.js +4 -4
  146. package/dist/src/fabric/lib/blank-comments.d.ts +21 -0
  147. package/dist/src/fabric/lib/blank-comments.js +71 -0
  148. package/dist/src/fabric/lib/blank-scenario-meta.d.ts +1 -0
  149. package/dist/src/fabric/lib/blank-scenario-meta.js +110 -0
  150. package/dist/src/fabric/lib/deployment.d.ts +26 -0
  151. package/dist/src/fabric/lib/deployment.js +35 -0
  152. package/dist/src/fabric/lib/stage.d.ts +19 -3
  153. package/dist/src/fabric/lib/stage.js +29 -6
  154. package/dist/src/functions/commands/db-reset.js +6 -2
  155. package/dist/src/functions/commands/dev.js +5 -1
  156. package/dist/src/functions/commands/persona.d.ts +18 -0
  157. package/dist/src/functions/commands/persona.js +41 -2
  158. package/dist/src/functions/commands/scenario-browser.d.ts +13 -5
  159. package/dist/src/functions/db/check-enums.d.ts +38 -0
  160. package/dist/src/functions/db/check-enums.js +103 -0
  161. package/dist/src/functions/db/db-codegen.js +15 -0
  162. package/dist/src/functions/db/postgres/postgres-introspector.d.ts +14 -0
  163. package/dist/src/functions/db/postgres/postgres-introspector.js +48 -0
  164. package/dist/src/functions/db/sqlite/sqlite-introspector.d.ts +3 -4
  165. package/dist/src/functions/db/sqlite/sqlite-introspector.js +5 -17
  166. package/dist/src/functions/wirings/virtual-user/serialize-virtual-user-functions.d.ts +9 -6
  167. package/dist/src/functions/wirings/virtual-user/serialize-virtual-user-functions.js +51 -27
  168. package/dist/src/server/actor-sign-in.d.ts +24 -1
  169. package/dist/src/server/actor-sign-in.js +45 -2
  170. package/dist/src/utils/persona-credentials.d.ts +23 -2
  171. package/dist/src/utils/persona-credentials.js +55 -2
  172. package/dist/src/utils/pikku-cli-config.d.ts +4 -4
  173. package/dist/src/utils/pikku-cli-config.js +30 -10
  174. package/dist/tsconfig.tsbuildinfo +1 -1
  175. package/package.json +8 -8
  176. package/surface.json +101 -51
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, relative } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { pikkuSessionlessFunc } from '../../../.pikku/function/index.js';
7
7
  import { added, changed, removed, dim } from '../lib/output.js';
@@ -10,6 +10,8 @@ import { readJsonSafe, readTextSafe, runSharedProjectChecks, } from '../../funct
10
10
  import { runTypeIdentityChecks } from '../../functions/validate/type-identity-checks.js';
11
11
  import { migrationCreatesTable } from '../../functions/validate/shared-checks.js';
12
12
  import { isGitRepo, isTracked } from '../lib/git.js';
13
+ import { blankComments, lineOfOffset } from '../lib/blank-comments.js';
14
+ import { blankScenarioMeta } from '../lib/blank-scenario-meta.js';
13
15
  const FindingSchema = z.object({
14
16
  id: z.string(),
15
17
  severity: z.enum(['error', 'warn', 'info']),
@@ -66,15 +68,6 @@ async function listSourceFiles(dir) {
66
68
  return [];
67
69
  }
68
70
  }
69
- /**
70
- * Blanks comment bodies so a raw-text scan cannot read prose as code. Prose in a
71
- * scenario description ("Converted from the Gherkin scenario...") otherwise
72
- * matches the import scanner's `from "..."` and reports a package that is really
73
- * a sentence.
74
- */
75
- const stripCommentText = (text) => text
76
- .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
77
- .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (_m, p1) => p1);
78
71
  // Heavy/generated dirs pruned during a source walk.
79
72
  const SKIP_WALK_DIRS = new Set([
80
73
  'node_modules',
@@ -489,17 +482,20 @@ export async function runValidate(startDir = process.cwd(), opts = {}) {
489
482
  // tsconfig `paths` keys (e.g. "@/*") are internal aliases, not packages.
490
483
  const tsconfig = await readJsonSafe(join(dir, 'tsconfig.json'));
491
484
  const aliasPrefixes = Object.keys(tsconfig?.compilerOptions?.paths ?? {}).map((k) => k.replace(/\*$/, ''));
485
+ // name → "<relative file>:<line>" of the first import that used it.
492
486
  const used = new Map();
493
487
  for (const file of await listSourceFiles(join(dir, 'src'))) {
494
488
  if (/\.gen\.(ts|tsx)$/.test(file))
495
489
  continue;
496
- const txt = await readTextSafe(file);
497
- if (!txt)
490
+ const raw = await readTextSafe(file);
491
+ if (!raw)
498
492
  continue;
499
- const src = stripCommentText(txt);
493
+ // Comments first, or prose becomes a dependency: `from "is fine"` in a
494
+ // sentence is indistinguishable from an import to this regex.
495
+ const txt = blankComments(raw);
500
496
  const re = /(?:from|import|require)\s*\(?\s*['"]([^'".#][^'"]*)['"]/g;
501
497
  let m;
502
- while ((m = re.exec(src))) {
498
+ while ((m = re.exec(txt))) {
503
499
  const spec = m[1];
504
500
  if (/\s/.test(spec) ||
505
501
  spec.startsWith('node:') ||
@@ -516,13 +512,15 @@ export async function runValidate(startDir = process.cwd(), opts = {}) {
516
512
  const name = pkgNameOf(spec);
517
513
  if (NODE_BUILTINS.has(name) || name === pkg.name || wsNames.has(name))
518
514
  continue;
519
- if (!used.has(name))
520
- used.set(name, file);
515
+ if (!used.has(name)) {
516
+ const where = `${relative(root, file).replace(/\\/g, '/')}:${lineOfOffset(raw, m.index)}`;
517
+ used.set(name, where);
518
+ }
521
519
  }
522
520
  }
523
521
  const missing = [...used.keys()].filter((n) => !declared.has(n)).sort();
524
522
  if (missing.length) {
525
- e(`undeclared-deps-${(pkg.name ?? dir).replace(/[@/]/g, '-')}`, `${pkg.name ?? dir} imports undeclared package(s): ${missing.join(', ')} — the deploy bundle cannot resolve them`, join(dir, 'package.json'), lines(`Add the missing package(s) to ${pkg.name ?? 'this package'}'s dependencies, e.g.:`, ...missing.map((n) => ` "${n}": "<version>"`), 'then reinstall. They import-resolve locally via tsconfig paths / root', 'hoisting, but esbuild/Bun.build resolves each package independently.'));
523
+ e(`undeclared-deps-${(pkg.name ?? dir).replace(/[@/]/g, '-')}`, `${pkg.name ?? dir} imports undeclared package(s): ${missing.join(', ')} — the deploy bundle cannot resolve them`, join(dir, 'package.json'), lines(`Add the missing package(s) to ${pkg.name ?? 'this package'}'s dependencies, e.g.:`, ...missing.map((n) => ` "${n}": "<version>" (first used at ${used.get(n)})`), 'then reinstall. They import-resolve locally via tsconfig paths / root', 'hoisting, but esbuild/Bun.build resolves each package independently.'));
526
524
  }
527
525
  // @pikku/browser pins `puppeteer-core` to the exact version that
528
526
  // @cloudflare/puppeteer forks, so headless rendering behaves identically
@@ -762,13 +760,37 @@ export async function runValidate(startDir = process.cwd(), opts = {}) {
762
760
  // readdir failure — skip
763
761
  }
764
762
  }
763
+ // ── the coercion map has to reach a Kysely instance ──────────────────
764
+ // `pikku db migrate` generates a CoercionMap from the `kind` entries in
765
+ // db/annotations.ts, and nothing wires it up on the project's behalf. An
766
+ // unwired map is invisible locally and fatal deployed: the dev sqlite
767
+ // driver hydrates a TEXT date into a Date by itself, libsql on a stage
768
+ // returns the raw string, and the generated schema types the column
769
+ // `Date` either way — so tsc flags nothing and there is no failing local
770
+ // test to write. It surfaces as `TypeError: e.getFullYear is not a
771
+ // function` on the first deployed request. Booleans diverge more quietly
772
+ // still: `1` from a stage where local gives `true`.
773
+ const outDirRel = typeof pikkuConfig?.outDir === 'string' ? pikkuConfig.outDir : '.pikku';
774
+ const coercionPath = join(root, outDirRel, 'db', 'coercion.gen.ts');
775
+ const coercionText = await readTextSafe(coercionPath);
776
+ // `{}` means no column declared a `kind`, so there is nothing to wire.
777
+ if (coercionText && /:\s*"(date|boolean|json)"/.test(coercionText)) {
778
+ const wired = (await Promise.all((await walkSourceFiles(root)).map((f) => readTextSafe(f)))).some((text) => text?.includes('createCoercionPlugin'));
779
+ if (!wired) {
780
+ e('coercion-map-not-wired', `${join(outDirRel, 'db', 'coercion.gen.ts')} declares coercions that no Kysely instance applies`, coercionPath, lines('Attach the plugin where the kysely singleton is created, in', 'createSingletonServices:', '', " import { createCoercionPlugin } from '@pikku/kysely'", " import { coercionMap } from '#pikku/db/coercion.gen.js'", '', ' kysely: existingServices.kysely.withPlugin(', ' createCoercionPlugin({ map: coercionMap }),', ' )', '', 'Without it a deployed stage returns raw strings and integers where', 'the generated schema promises Date and boolean. The dev driver', 'hydrates them for you, so this cannot fail locally.'));
781
+ }
782
+ }
765
783
  const devSeedPath = join(root, 'db', dbEngine === 'postgres' ? 'postgres-dev-seed.sql' : 'sqlite-dev-seed.sql');
784
+ // Info, not an error. The dev seed is replayed by `pikku db reset` and
785
+ // never by a deploy, so a project whose data has correctly moved into a
786
+ // migration — where anything a deployed stage needs has to live — no
787
+ // longer needs this file, and was being failed for not carrying it.
766
788
  if (!existsSync(devSeedPath)) {
767
- e('dev-seed-sql-missing', dbEngine === 'postgres'
768
- ? 'db/postgres-dev-seed.sql not found'
769
- : 'db/sqlite-dev-seed.sql not found', devSeedPath, dbEngine === 'postgres'
770
- ? lines('Create `db/postgres-dev-seed.sql`.', 'Use idempotent INSERT statements suitable for local/dev/test setup.', 'Keep it safe to re-run.')
771
- : lines('Create `db/sqlite-dev-seed.sql`.', 'Use idempotent `INSERT OR IGNORE` statements for local/dev/test data.', 'Keep it safe to re-run.'));
789
+ const seedFile = dbEngine === 'postgres'
790
+ ? 'db/postgres-dev-seed.sql'
791
+ : 'db/sqlite-dev-seed.sql';
792
+ const migrationsRel = dbEngine === 'postgres' ? 'db/postgres/' : 'db/sqlite/';
793
+ info('dev-seed-sql-missing', `${seedFile} not found — no dev seed will be applied by \`pikku db reset\``, devSeedPath, lines(`Optional. Add \`${seedFile}\` for rows you want locally and nowhere else;`, 'it is replayed by `pikku db reset` against a freshly wiped database and', 'is never applied to a deployed stage.', `Data a deployed stage needs is migration data — put it in ${migrationsRel}`, 'rather than here, whatever it looks like.'));
772
794
  }
773
795
  // audit table — info if not present (optional feature)
774
796
  if (existsSync(migrationsDir)) {
@@ -1038,7 +1060,7 @@ export async function runValidate(startDir = process.cwd(), opts = {}) {
1038
1060
  }
1039
1061
  }
1040
1062
  if (loginFiles.length > 0 && !hasQuickLogin) {
1041
- e(`app-missing-actor-quick-login-${name}`, `apps/${name} has a login screen (${loginFiles[0]}) but no one-click actor sign-in — nobody can view the app as a scenario persona without a password`, join(appPath, loginFiles[0]), lines('Render the dev-only "Sign in as …" switcher from the login screen.', `In ${loginFiles[0]}:`, " import { DevActorSwitcher } from '@pikku/mantine/dev'", ' <DevActorSwitcher', ' actors={import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined}', ' secret={import.meta.env.DEV ? import.meta.env.VITE_SCENARIO_ACTOR_SECRET : undefined}', ' apiUrl={apiUrl()}', " onSignedIn={() => navigate({ to: '/' })}", ' />', 'The sandbox dev server bakes both env vars from your declared personas;', 'neither is set in production, so the control renders null there.', 'Gate the reads on import.meta.env.DEV as above so the secret never', 'reaches a production bundle. Next.js apps read the NEXT_PUBLIC_* pair.', 'For custom UI, build on useDevActors() from @pikku/react instead.'));
1063
+ e(`app-missing-actor-quick-login-${name}`, `apps/${name} has a login screen (${loginFiles[0]}) but no one-click actor sign-in — nobody can view the app as a scenario persona without a password`, join(appPath, loginFiles[0]), lines('Render the dev-only "Sign in as …" switcher from the login screen.', `In ${loginFiles[0]}:`, " import { DevActorSwitcher } from '@pikku/mantine/dev'", ' <DevActorSwitcher', ' actors={import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined}', ' secrets={import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTOR_SECRETS : undefined}', ' apiUrl={apiUrl()}', " onSignedIn={() => navigate({ to: '/' })}", ' />', 'The sandbox dev server bakes both env vars from your declared personas;', 'neither is set in production, so the control renders null there.', 'VITE_DEV_ACTOR_SECRETS is one credential per persona, each accepted for', 'that persona only. Gate the reads on import.meta.env.DEV as above so no', 'credential reaches a production bundle. Next.js reads the NEXT_PUBLIC_* pair.', 'For custom UI, build on useDevActors() from @pikku/react instead.'));
1042
1064
  }
1043
1065
  }
1044
1066
  }
@@ -1241,7 +1263,10 @@ export async function runValidate(startDir = process.cwd(), opts = {}) {
1241
1263
  const text = await readTextSafe(file);
1242
1264
  if (!text)
1243
1265
  continue;
1244
- const code = stripCommentText(text);
1266
+ // Comments carry the copy they explain; a feature's own name and
1267
+ // description are Console meta authored in the project's locale, not
1268
+ // app copy. Neither reaches the DOM, so neither is a selector.
1269
+ const code = blankScenarioMeta(blankComments(text));
1245
1270
  const hits = [];
1246
1271
  for (const match of code.matchAll(STRING_LITERAL)) {
1247
1272
  const literal = match[2];
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export declare const FabricVariablesGetInput: z.ZodObject<{
3
3
  name: z.ZodString;
4
- branch: z.ZodString;
4
+ branch: z.ZodOptional<z.ZodString>;
5
5
  json: z.ZodOptional<z.ZodBoolean>;
6
6
  }, z.core.$strip>;
7
7
  export declare const FabricVariablesGetOutput: z.ZodObject<{
@@ -11,7 +11,7 @@ export declare const FabricVariablesGetOutput: z.ZodObject<{
11
11
  }, z.core.$strip>;
12
12
  export declare const FabricVariablesGet: import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunctionConfig<{
13
13
  name: string;
14
- branch: string;
14
+ branch?: string | undefined;
15
15
  json?: boolean | undefined;
16
16
  }, {
17
17
  name: string;
@@ -19,7 +19,7 @@ export declare const FabricVariablesGet: import("../../../.pikku/function/pikku-
19
19
  value: unknown;
20
20
  }, "rpc" | "session", import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunctionSessionless<{
21
21
  name: string;
22
- branch: string;
22
+ branch?: string | undefined;
23
23
  json?: boolean | undefined;
24
24
  }, {
25
25
  name: string;
@@ -27,7 +27,7 @@ export declare const FabricVariablesGet: import("../../../.pikku/function/pikku-
27
27
  value: unknown;
28
28
  }, "rpc" | "session", import("../../../.pikku/function/pikku-function-types.gen.js").WiredServices, unknown> | import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunction<{
29
29
  name: string;
30
- branch: string;
30
+ branch?: string | undefined;
31
31
  json?: boolean | undefined;
32
32
  }, {
33
33
  name: string;
@@ -2,10 +2,10 @@ import { z } from 'zod';
2
2
  import { pikkuSessionlessFunc } from '../../../.pikku/function/index.js';
3
3
  import { resolveApiContext } from '../lib/config.js';
4
4
  import { getFabricRPC } from '../lib/http.js';
5
- import { resolveStageId } from '../lib/stage.js';
5
+ import { resolveStage } from '../lib/stage.js';
6
6
  export const FabricVariablesGetInput = z.object({
7
7
  name: z.string(),
8
- branch: z.string(),
8
+ branch: z.string().optional(),
9
9
  json: z.boolean().optional(),
10
10
  });
11
11
  export const FabricVariablesGetOutput = z.object({
@@ -17,14 +17,14 @@ export const FabricVariablesGet = pikkuSessionlessFunc({
17
17
  description: 'Read a stage-scoped variable back, so you can see the shape it was stored in rather than the shape you meant.',
18
18
  input: FabricVariablesGetInput,
19
19
  output: FabricVariablesGetOutput,
20
- func: async (_services, { name, branch, json }) => {
20
+ func: async (_services, { name, branch: requested, json }) => {
21
21
  const ctx = await resolveApiContext();
22
22
  if (!ctx.token)
23
23
  throw new Error('Not logged in. Run `pikku fabric login` first.');
24
24
  if (!ctx.projectId)
25
25
  throw new Error('No fabric project linked. Run `pikku fabric link` first.');
26
26
  const rpc = getFabricRPC({ apiUrl: ctx.apiUrl, token: ctx.token });
27
- const stageId = await resolveStageId(rpc, ctx.projectId, branch);
27
+ const { stageId, branch } = await resolveStage(rpc, ctx.projectId, requested);
28
28
  const { exists, value } = await rpc.invoke('getStageConsoleVariable', {
29
29
  stageId,
30
30
  variableId: name,
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export declare const FabricVariablesSetInput: z.ZodObject<{
3
3
  name: z.ZodString;
4
- branch: z.ZodString;
4
+ branch: z.ZodOptional<z.ZodString>;
5
5
  value: z.ZodString;
6
6
  }, z.core.$strip>;
7
7
  export declare const FabricVariablesSetOutput: z.ZodObject<{
@@ -10,22 +10,22 @@ export declare const FabricVariablesSetOutput: z.ZodObject<{
10
10
  }, z.core.$strip>;
11
11
  export declare const FabricVariablesSet: import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunctionConfig<{
12
12
  name: string;
13
- branch: string;
14
13
  value: string;
14
+ branch?: string | undefined;
15
15
  }, {
16
16
  name: string;
17
17
  success: boolean;
18
18
  }, "rpc" | "session", import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunctionSessionless<{
19
19
  name: string;
20
- branch: string;
21
20
  value: string;
21
+ branch?: string | undefined;
22
22
  }, {
23
23
  name: string;
24
24
  success: boolean;
25
25
  }, "rpc" | "session", import("../../../.pikku/function/pikku-function-types.gen.js").WiredServices, unknown> | import("../../../.pikku/function/pikku-function-types.gen.js").PikkuFunction<{
26
26
  name: string;
27
- branch: string;
28
27
  value: string;
28
+ branch?: string | undefined;
29
29
  }, {
30
30
  name: string;
31
31
  success: boolean;
@@ -2,11 +2,11 @@ import { z } from 'zod';
2
2
  import { pikkuSessionlessFunc } from '../../../.pikku/function/index.js';
3
3
  import { resolveApiContext } from '../lib/config.js';
4
4
  import { getFabricRPC } from '../lib/http.js';
5
- import { resolveStageId } from '../lib/stage.js';
5
+ import { resolveStage } from '../lib/stage.js';
6
6
  import { parseVariableValue } from '../lib/variable-value.js';
7
7
  export const FabricVariablesSetInput = z.object({
8
8
  name: z.string(),
9
- branch: z.string(),
9
+ branch: z.string().optional(),
10
10
  value: z.string(),
11
11
  });
12
12
  export const FabricVariablesSetOutput = z.object({
@@ -17,14 +17,14 @@ export const FabricVariablesSet = pikkuSessionlessFunc({
17
17
  description: 'Set a stage-scoped variable — the deployed counterpart of a line in .env. Not a secret: the value is stored in plain form and is readable back.',
18
18
  input: FabricVariablesSetInput,
19
19
  output: FabricVariablesSetOutput,
20
- func: async (_services, { name, branch, value }) => {
20
+ func: async (_services, { name, branch: requested, value }) => {
21
21
  const ctx = await resolveApiContext();
22
22
  if (!ctx.token)
23
23
  throw new Error('Not logged in. Run `pikku fabric login` first.');
24
24
  if (!ctx.projectId)
25
25
  throw new Error('No fabric project linked. Run `pikku fabric link` first.');
26
26
  const rpc = getFabricRPC({ apiUrl: ctx.apiUrl, token: ctx.token });
27
- const stageId = await resolveStageId(rpc, ctx.projectId, branch);
27
+ const { stageId, branch } = await resolveStage(rpc, ctx.projectId, requested);
28
28
  // Deliberately not sealed, and deliberately not prompted for. A variable is
29
29
  // configuration — a port, a URL, a feature switch — and treating it like a
30
30
  // secret would mean it could not be read back, which is most of the value of
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Replace every comment in a TypeScript/JavaScript source with spaces, keeping
3
+ * the string exactly as long and every newline where it was, so offsets and
4
+ * line numbers computed against the result still point at the real file.
5
+ *
6
+ * The checks that scan source text with a regex have to run over this first.
7
+ * Scanning raw text made prose executable: a sentence reading
8
+ *
9
+ * // what separates "needs you" from "is fine" at a glance
10
+ *
11
+ * matched an import-specifier pattern and reported `is fine` as an undeclared
12
+ * dependency. Quotes are tracked rather than blanked, because the specifier a
13
+ * caller is looking for lives inside them.
14
+ *
15
+ * A regex literal holding `//` or an unpaired quote is not modelled — telling
16
+ * `/` as division from `/` as a regex needs the parser this deliberately is
17
+ * not. The failure mode is a blanked region, never a comment left readable.
18
+ */
19
+ export declare function blankComments(source: string): string;
20
+ /** 1-based line number of a character offset. */
21
+ export declare function lineOfOffset(source: string, offset: number): number;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Replace every comment in a TypeScript/JavaScript source with spaces, keeping
3
+ * the string exactly as long and every newline where it was, so offsets and
4
+ * line numbers computed against the result still point at the real file.
5
+ *
6
+ * The checks that scan source text with a regex have to run over this first.
7
+ * Scanning raw text made prose executable: a sentence reading
8
+ *
9
+ * // what separates "needs you" from "is fine" at a glance
10
+ *
11
+ * matched an import-specifier pattern and reported `is fine` as an undeclared
12
+ * dependency. Quotes are tracked rather than blanked, because the specifier a
13
+ * caller is looking for lives inside them.
14
+ *
15
+ * A regex literal holding `//` or an unpaired quote is not modelled — telling
16
+ * `/` as division from `/` as a regex needs the parser this deliberately is
17
+ * not. The failure mode is a blanked region, never a comment left readable.
18
+ */
19
+ export function blankComments(source) {
20
+ const out = source.split('');
21
+ const blank = (start, end) => {
22
+ for (let i = start; i < end && i < out.length; i++) {
23
+ if (out[i] !== '\n' && out[i] !== '\r')
24
+ out[i] = ' ';
25
+ }
26
+ };
27
+ let i = 0;
28
+ while (i < source.length) {
29
+ const ch = source[i];
30
+ const next = source[i + 1];
31
+ if (ch === '/' && next === '/') {
32
+ const end = source.indexOf('\n', i);
33
+ blank(i, end === -1 ? source.length : end);
34
+ i = end === -1 ? source.length : end;
35
+ continue;
36
+ }
37
+ if (ch === '/' && next === '*') {
38
+ const close = source.indexOf('*/', i + 2);
39
+ const end = close === -1 ? source.length : close + 2;
40
+ blank(i, end);
41
+ i = end;
42
+ continue;
43
+ }
44
+ if (ch === "'" || ch === '"' || ch === '`') {
45
+ i++;
46
+ while (i < source.length) {
47
+ if (source[i] === '\\') {
48
+ i += 2;
49
+ continue;
50
+ }
51
+ if (source[i] === ch) {
52
+ i++;
53
+ break;
54
+ }
55
+ i++;
56
+ }
57
+ continue;
58
+ }
59
+ i++;
60
+ }
61
+ return out.join('');
62
+ }
63
+ /** 1-based line number of a character offset. */
64
+ export function lineOfOffset(source, offset) {
65
+ let line = 1;
66
+ for (let i = 0; i < offset && i < source.length; i++) {
67
+ if (source[i] === '\n')
68
+ line++;
69
+ }
70
+ return line;
71
+ }
@@ -0,0 +1 @@
1
+ export declare function blankScenarioMeta(source: string): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Blank the `name`, `description` and `template` values declared directly on a
3
+ * `pikkuFeature` / `pikkuScenario` / `pikkuScenarioStep` object, keeping the
4
+ * source the same length so a later scan still reports real positions.
5
+ *
6
+ * Those three fields are Console meta — authored in the project's `locale`,
7
+ * describing the test to whoever reads the run — and are deliberately not the
8
+ * app's UI copy. The hardcoded-copy check flagged them anyway whenever a
9
+ * feature happened to be named after the screen it covers:
10
+ *
11
+ * export const downloadsFeature = pikkuFeature({
12
+ * name: 'Downloads', → ✗ "Downloads" → nav__downloads | downloads__title
13
+ *
14
+ * and its advice — read the string from the app catalogue — would tie the
15
+ * Console's language to the product's, which is the opposite of what should
16
+ * happen. Complying with the rule that puts features in `*.scenario.ts` was
17
+ * what surfaced them, so the two rules disagreed: satisfying one created
18
+ * violations of the other.
19
+ *
20
+ * Only the top level of the object is blanked. `getByRole('button', { name:
21
+ * 'Speichern' })` nested inside a step is a selector built out of UI copy —
22
+ * exactly what the check exists to catch — and stays visible.
23
+ */
24
+ const DECLARATIONS = [
25
+ 'pikkuFeature',
26
+ 'pikkuScenario',
27
+ 'pikkuScenarioStep',
28
+ 'pikkuPlatformScenarioStep',
29
+ 'pikkuAddonScenarioStep',
30
+ ];
31
+ const META_FIELDS = /^(name|description|template)$/;
32
+ const QUOTES = new Set(["'", '"', '`']);
33
+ /** Index just past the string literal opening at `start`, or -1. */
34
+ function endOfString(src, start) {
35
+ const quote = src[start];
36
+ let i = start + 1;
37
+ while (i < src.length) {
38
+ if (src[i] === '\\') {
39
+ i += 2;
40
+ continue;
41
+ }
42
+ if (src[i] === quote)
43
+ return i + 1;
44
+ i++;
45
+ }
46
+ return -1;
47
+ }
48
+ export function blankScenarioMeta(source) {
49
+ const out = source.split('');
50
+ for (const decl of DECLARATIONS) {
51
+ const call = new RegExp(`\\b${decl}\\s*\\(`, 'g');
52
+ let m;
53
+ while ((m = call.exec(source))) {
54
+ let i = m.index + m[0].length;
55
+ while (i < source.length && /\s/.test(source[i]))
56
+ i++;
57
+ if (source[i] !== '{')
58
+ continue;
59
+ let depth = 0;
60
+ while (i < source.length) {
61
+ const ch = source[i];
62
+ if (QUOTES.has(ch)) {
63
+ const end = endOfString(source, i);
64
+ if (end === -1)
65
+ break;
66
+ i = end;
67
+ continue;
68
+ }
69
+ if (ch === '{') {
70
+ depth++;
71
+ i++;
72
+ continue;
73
+ }
74
+ if (ch === '}') {
75
+ depth--;
76
+ if (depth === 0)
77
+ break;
78
+ i++;
79
+ continue;
80
+ }
81
+ if (depth === 1 && /[A-Za-z_$]/.test(ch)) {
82
+ const key = /^[A-Za-z0-9_$]+/.exec(source.slice(i))[0];
83
+ let j = i + key.length;
84
+ while (j < source.length && /\s/.test(source[j]))
85
+ j++;
86
+ if (source[j] === ':' && META_FIELDS.test(key)) {
87
+ j++;
88
+ while (j < source.length && /\s/.test(source[j]))
89
+ j++;
90
+ if (QUOTES.has(source[j])) {
91
+ const end = endOfString(source, j);
92
+ if (end !== -1) {
93
+ for (let k = j + 1; k < end - 1; k++) {
94
+ if (out[k] !== '\n' && out[k] !== '\r')
95
+ out[k] = ' ';
96
+ }
97
+ i = end;
98
+ continue;
99
+ }
100
+ }
101
+ }
102
+ i += key.length;
103
+ continue;
104
+ }
105
+ i++;
106
+ }
107
+ }
108
+ }
109
+ return out.join('');
110
+ }
@@ -118,4 +118,30 @@ export interface WaitOptions {
118
118
  now?: () => number;
119
119
  }
120
120
  export declare function waitForDeployment({ rpc, deploymentId, timeoutMs, approve, onEvent, sleep, now, }: WaitOptions): Promise<WaitResult>;
121
+ /**
122
+ * The sha a deployment actually holds, or an error naming the disagreement.
123
+ *
124
+ * `deployByStageKind` does not always cut a new deployment: with one already
125
+ * parked at `suspended` for the branch, it attaches to that plan — pinned to
126
+ * whatever commit it was created at — and returns its id. The CLI then
127
+ * reported the sha it had been *asked* for, so `apply` printed
128
+ *
129
+ * status: suspended sha: 599439e6
130
+ *
131
+ * for a deployment `deploy list` showed pinned to `5bfe84c`, five commits
132
+ * above it. Passing `--ref` explicitly changed nothing; the argument was
133
+ * echoed back. Nothing shipped only because that plan timed out.
134
+ *
135
+ * The blast radius is bounded — a second command still publishes, and only a
136
+ * commit already on the branch can go out — but the failure mode is a rollback
137
+ * that silently does not roll back, and a rollback is reached for precisely
138
+ * when production is already wrong. So whatever `apply` reports as the ref is
139
+ * read from the deployment, and a disagreement is an error rather than a line
140
+ * of output nobody can tell from the truth.
141
+ */
142
+ export declare function reconcileDeployedRef({ requested, actual, deploymentId, }: {
143
+ requested: string;
144
+ actual: string | null;
145
+ deploymentId: string;
146
+ }): string;
121
147
  export {};
@@ -286,3 +286,38 @@ export async function waitForDeployment({ rpc, deploymentId, timeoutMs, approve,
286
286
  latest = await readDeploymentStatus(rpc, deploymentId);
287
287
  }
288
288
  }
289
+ /**
290
+ * The sha a deployment actually holds, or an error naming the disagreement.
291
+ *
292
+ * `deployByStageKind` does not always cut a new deployment: with one already
293
+ * parked at `suspended` for the branch, it attaches to that plan — pinned to
294
+ * whatever commit it was created at — and returns its id. The CLI then
295
+ * reported the sha it had been *asked* for, so `apply` printed
296
+ *
297
+ * status: suspended sha: 599439e6
298
+ *
299
+ * for a deployment `deploy list` showed pinned to `5bfe84c`, five commits
300
+ * above it. Passing `--ref` explicitly changed nothing; the argument was
301
+ * echoed back. Nothing shipped only because that plan timed out.
302
+ *
303
+ * The blast radius is bounded — a second command still publishes, and only a
304
+ * commit already on the branch can go out — but the failure mode is a rollback
305
+ * that silently does not roll back, and a rollback is reached for precisely
306
+ * when production is already wrong. So whatever `apply` reports as the ref is
307
+ * read from the deployment, and a disagreement is an error rather than a line
308
+ * of output nobody can tell from the truth.
309
+ */
310
+ export function reconcileDeployedRef({ requested, actual, deploymentId, }) {
311
+ if (!actual)
312
+ return requested;
313
+ if (actual === requested)
314
+ return actual;
315
+ const short = (sha) => sha.slice(0, 8);
316
+ throw new Error([
317
+ `Refusing to continue: deployment ${deploymentId} is pinned to ${short(actual)}, not the ${short(requested)} you asked for.`,
318
+ 'A deployment already parked for this branch was attached to rather than a new one being cut,',
319
+ 'so continuing would deploy a different commit than the one named.',
320
+ `Inspect it with \`pikku fabric deploy list --json\`, then either publish it deliberately with`,
321
+ `\`pikku fabric deploy apply --deployment-id ${deploymentId} --sync\` or dismiss it and re-run.`,
322
+ ].join('\n'));
323
+ }
@@ -1,7 +1,23 @@
1
1
  import type { PikkuRPC } from '../sdk/pikku-rpc.gen.js';
2
+ export interface ResolvedStage {
3
+ stageId: string;
4
+ branch: string;
5
+ }
2
6
  /**
3
- * Resolve a stage UUID from (projectId, branch) via the `listStages` RPC.
4
- * The stageId-based read RPCs (listDeployments, listDeploymentWorkers,
7
+ * Resolve a stage from (projectId, branch) via the `listStages` RPC. The
8
+ * stageId-based read RPCs (listDeployments, listDeploymentWorkers,
5
9
  * getStageDatabaseSchema) need a UUID, but the CLI only knows the branch.
10
+ *
11
+ * `branch` is optional because it is optional in practice: nothing upstream
12
+ * guarantees the flag was passed, and interpolating what arrived produced
13
+ * `No stage for branch "undefined"` — an error naming the missing argument's
14
+ * value rather than saying the argument is missing, directly above a line
15
+ * listing the one stage it could have used. With exactly one stage there is
16
+ * nothing to disambiguate, so that stage is the answer.
17
+ *
18
+ * The branch comes back with the id so callers can name the stage they acted
19
+ * on rather than echoing the argument, which is `undefined` in exactly the
20
+ * case this function exists to handle.
6
21
  */
7
- export declare function resolveStageId(rpc: PikkuRPC, projectId: string, branch: string): Promise<string>;
22
+ export declare function resolveStage(rpc: PikkuRPC, projectId: string, branch: string | undefined): Promise<ResolvedStage>;
23
+ export declare function resolveStageId(rpc: PikkuRPC, projectId: string, branch: string | undefined): Promise<string>;
@@ -1,14 +1,37 @@
1
1
  /**
2
- * Resolve a stage UUID from (projectId, branch) via the `listStages` RPC.
3
- * The stageId-based read RPCs (listDeployments, listDeploymentWorkers,
2
+ * Resolve a stage from (projectId, branch) via the `listStages` RPC. The
3
+ * stageId-based read RPCs (listDeployments, listDeploymentWorkers,
4
4
  * getStageDatabaseSchema) need a UUID, but the CLI only knows the branch.
5
+ *
6
+ * `branch` is optional because it is optional in practice: nothing upstream
7
+ * guarantees the flag was passed, and interpolating what arrived produced
8
+ * `No stage for branch "undefined"` — an error naming the missing argument's
9
+ * value rather than saying the argument is missing, directly above a line
10
+ * listing the one stage it could have used. With exactly one stage there is
11
+ * nothing to disambiguate, so that stage is the answer.
12
+ *
13
+ * The branch comes back with the id so callers can name the stage they acted
14
+ * on rather than echoing the argument, which is `undefined` in exactly the
15
+ * case this function exists to handle.
5
16
  */
6
- export async function resolveStageId(rpc, projectId, branch) {
17
+ export async function resolveStage(rpc, projectId, branch) {
7
18
  const { stages } = await rpc.invoke('listStages', { projectId });
19
+ const known = stages.map((s) => s.branch);
20
+ if (!branch) {
21
+ const only = stages[0];
22
+ if (stages.length === 1 && only) {
23
+ return { stageId: only.stageId, branch: only.branch };
24
+ }
25
+ throw new Error(stages.length === 0
26
+ ? 'No stages deployed for this project yet — run `pikku fabric deploy apply --branch <branch>` first.'
27
+ : `--branch is required — this project has ${stages.length} stages: ${known.join(', ')}`);
28
+ }
8
29
  const stage = stages.find((s) => s.branch === branch);
9
30
  if (!stage) {
10
- const known = stages.map((s) => s.branch).join(', ');
11
- throw new Error(`No stage for branch "${branch}".${known ? ` Existing: ${known}` : ''}`);
31
+ throw new Error(`No stage for branch "${branch}".${known.length ? ` Existing: ${known.join(', ')}` : ''}`);
12
32
  }
13
- return stage.stageId;
33
+ return { stageId: stage.stageId, branch: stage.branch };
34
+ }
35
+ export async function resolveStageId(rpc, projectId, branch) {
36
+ return (await resolveStage(rpc, projectId, branch)).stageId;
14
37
  }