@pikku/core 0.12.100 → 0.12.102

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 (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/services/email-service.d.ts +13 -0
  3. package/dist/services/http-personas.d.ts +36 -6
  4. package/dist/services/http-personas.js +128 -9
  5. package/dist/services/index.d.ts +1 -1
  6. package/dist/services/local-email-service.js +9 -0
  7. package/dist/services/persona-sign-in.d.ts +0 -10
  8. package/dist/services/persona-sign-in.js +11 -9
  9. package/dist/wirings/cli/cli-runner.js +2 -11
  10. package/dist/wirings/cli/format-cli-error.d.ts +24 -0
  11. package/dist/wirings/cli/format-cli-error.js +68 -0
  12. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
  13. package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
  14. package/package.json +1 -1
  15. package/src/app-leaf-surface.test.ts +5 -0
  16. package/src/no-root-barrel.test.ts +9 -1
  17. package/src/services/email-service.test.ts +100 -0
  18. package/src/services/email-service.ts +14 -0
  19. package/src/services/http-personas-converse.test.ts +29 -20
  20. package/src/services/http-personas.test.ts +66 -0
  21. package/src/services/http-personas.ts +138 -9
  22. package/src/services/index.ts +1 -0
  23. package/src/services/local-email-service.test.ts +74 -0
  24. package/src/services/local-email-service.ts +9 -0
  25. package/src/services/persona-sign-in.test.ts +22 -28
  26. package/src/services/persona-sign-in.ts +11 -19
  27. package/src/wirings/cli/cli-runner.test.ts +104 -0
  28. package/src/wirings/cli/cli-runner.ts +2 -11
  29. package/src/wirings/cli/format-cli-error.test.ts +91 -0
  30. package/src/wirings/cli/format-cli-error.ts +96 -0
  31. package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
  32. package/tsconfig.tsbuildinfo +1 -1
  33. package/tsconfig.type-tests.json +1 -0
@@ -1,4 +1,5 @@
1
1
  import { deriveActorSecret } from './persona-actor-secret.js'
2
+ import { PikkuError } from '../errors/error-handler.js'
2
3
  import type { ResolvedPersona } from './personas-service.js'
3
4
  import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
4
5
 
@@ -30,13 +31,19 @@ export interface PersonaSignIn {
30
31
  headers(): Record<string, string>
31
32
  }
32
33
 
34
+ /**
35
+ * A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
36
+ * this message alone: an expired token or a persona the stage has never seen is
37
+ * something to go and fix, and a stack trace through the fetch internals only
38
+ * buries the status and the body that say which one it is.
39
+ */
33
40
  const failed = async (
34
41
  what: string,
35
42
  personaId: string,
36
43
  res: Response
37
44
  ): Promise<Error> => {
38
45
  const body = (await res.text().catch(() => '')).slice(0, 300)
39
- return new Error(
46
+ return new PikkuError(
40
47
  `[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
41
48
  )
42
49
  }
@@ -123,16 +130,6 @@ export interface OperatorSignInOptions {
123
130
  * the function: tokens expire, and a long run re-logs-in after a 401.
124
131
  */
125
132
  token: string | (() => string | Promise<string>)
126
- /**
127
- * Create the persona's user row when the target has no account for that
128
- * address.
129
- *
130
- * Off by default, which is the whole point of the deployed path: a persona is
131
- * meant to be a real account somebody provisioned, and a test run that
132
- * silently writes users into a live database is a side effect nobody asked
133
- * for. Turn it on for throwaway stages.
134
- */
135
- createMissing?: boolean
136
133
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
137
134
  signInPath?: string
138
135
  }
@@ -171,12 +168,7 @@ export const establishOperatorSession = async (
171
168
  headers: { 'content-type': 'application/json', ...extraHeaders },
172
169
  body: JSON.stringify({
173
170
  token,
174
- actAs: {
175
- email: persona.email,
176
- name: persona.name,
177
- create: options.createMissing ?? false,
178
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
179
- },
171
+ actAs: { email: persona.email },
180
172
  }),
181
173
  })
182
174
  if (!res.ok) {
@@ -184,7 +176,7 @@ export const establishOperatorSession = async (
184
176
  }
185
177
  const setCookies = res.headers.getSetCookie?.() ?? []
186
178
  if (setCookies.length === 0) {
187
- throw new Error(
179
+ throw new PikkuError(
188
180
  `[scenario] operator sign-in for '${persona.id}' returned no session cookie`
189
181
  )
190
182
  }
@@ -194,7 +186,7 @@ export const establishOperatorSession = async (
194
186
  } | null
195
187
  const userId = body?.actAs?.userId
196
188
  if (!userId) {
197
- throw new Error(
189
+ throw new PikkuError(
198
190
  `[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
199
191
  'the target is running a @pikku/better-auth too old to resolve one'
200
192
  )
@@ -1,6 +1,7 @@
1
1
  import { test, describe, beforeEach, afterEach } from 'node:test'
2
2
  import * as assert from 'assert'
3
3
  import { NotFoundError } from '../../errors/errors.js'
4
+ import { PikkuError } from '../../errors/error-handler.js'
4
5
  import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js'
5
6
  import {
6
7
  CLIError,
@@ -724,5 +725,108 @@ describe('CLI Runner', () => {
724
725
  singletonServices
725
726
  )
726
727
  })
728
+ const wireFailingCommand = (error: unknown) => {
729
+ pikkuState(null, 'cli', 'meta', {
730
+ programs: {
731
+ 'test-cli': {
732
+ program: 'test-cli',
733
+ commands: {
734
+ boom: {
735
+ command: 'boom',
736
+ pikkuFuncId: 'boomFunc',
737
+ positionals: [],
738
+ options: {},
739
+ },
740
+ },
741
+ options: {},
742
+ },
743
+ },
744
+ renderers: {},
745
+ })
746
+ pikkuState(null, 'cli', 'programs', {
747
+ 'test-cli': {
748
+ defaultRenderer: undefined,
749
+ middleware: [],
750
+ renderers: {},
751
+ },
752
+ })
753
+ pikkuState(null, 'function', 'meta', {
754
+ boomFunc: {
755
+ pikkuFuncId: 'boomFunc',
756
+ inputSchemaName: null,
757
+ outputSchemaName: null,
758
+ sessionless: true,
759
+ },
760
+ })
761
+ addFunction('boomFunc', {
762
+ func: async () => {
763
+ throw error
764
+ },
765
+ auth: false,
766
+ })
767
+ }
768
+
769
+ const captureStderr = async (run: () => Promise<void>) => {
770
+ const errors: string[] = []
771
+ const originalError = console.error
772
+ console.error = (message?: any) => {
773
+ errors.push(String(message))
774
+ }
775
+ try {
776
+ await assert.rejects(run, CLIError)
777
+ } finally {
778
+ console.error = originalError
779
+ }
780
+ return errors
781
+ }
782
+
783
+ test('should print an expected failure as its message alone', async () => {
784
+ wireFailingCommand(new PikkuError('Refusing to run — the seed drifted.'))
785
+
786
+ const errors = await captureStderr(() =>
787
+ executeCLI({
788
+ programName: 'test-cli',
789
+ args: ['boom'],
790
+ createSingletonServices: async () => singletonServices,
791
+ })
792
+ )
793
+
794
+ assert.deepStrictEqual(errors, ['Refusing to run — the seed drifted.'])
795
+ })
796
+
797
+ test('should keep the stack of an unexpected failure', async () => {
798
+ wireFailingCommand(new TypeError('cannot read x of undefined'))
799
+
800
+ const errors = await captureStderr(() =>
801
+ executeCLI({
802
+ programName: 'test-cli',
803
+ args: ['boom'],
804
+ createSingletonServices: async () => singletonServices,
805
+ })
806
+ )
807
+
808
+ assert.strictEqual(errors.length, 1)
809
+ assert.ok(errors[0]!.startsWith('TypeError: cannot read x of undefined'))
810
+ assert.ok(errors[0]!.includes('at '))
811
+ assert.ok(!errors[0]!.includes('Error: Error:'))
812
+ })
813
+
814
+ test('should add the stack of an expected failure when --verbose is passed', async () => {
815
+ wireFailingCommand(new PikkuError('Refusing to run.'))
816
+
817
+ const errors = await captureStderr(() =>
818
+ executeCLI({
819
+ programName: 'test-cli',
820
+ args: ['boom', '--verbose'],
821
+ createSingletonServices: async () => singletonServices,
822
+ })
823
+ )
824
+
825
+ // `--verbose` is not an option this command declares, so the parser
826
+ // warns about it first — the trace is whatever it printed last.
827
+ const printed = errors.at(-1)!
828
+ assert.ok(printed.startsWith('Refusing to run.\n'))
829
+ assert.ok(printed.includes('at '))
830
+ })
727
831
  })
728
832
  })
@@ -1,5 +1,4 @@
1
1
  import { NotFoundError } from '../../errors/errors.js'
2
- import { isExpectedError } from '../../errors/error-handler.js'
3
2
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js'
4
3
  import { pikkuState } from '../../pikku-state.js'
5
4
  import type { CoreUserSession } from '../../types/core.types.js'
@@ -30,6 +29,7 @@ import {
30
29
  } from '../../services/user-session-service.js'
31
30
  import { LocalVariablesService } from '../../services/local-variables.js'
32
31
  import { generateCommandHelp, parseCLIArguments } from './command-parser.js'
32
+ import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
33
33
 
34
34
  /** The caller is expected to catch this and call `process.exit(exitCode)`. */
35
35
  export class CLIError extends Error {
@@ -532,16 +532,7 @@ export async function executeCLI({
532
532
  throw error
533
533
  }
534
534
 
535
- // An expected PikkuError's message is written to be the whole output.
536
- if (isExpectedError(error)) {
537
- console.error(error.message)
538
- } else {
539
- console.error('Error:', error)
540
- }
541
-
542
- if (args.includes('--verbose') || args.includes('-v')) {
543
- console.error('Stack trace:', error.stack)
544
- }
535
+ console.error(formatCLIError(error, { verbose: wantsStackTrace(args) }))
545
536
 
546
537
  throw new CLIError(error.message || String(error), 1)
547
538
  }
@@ -0,0 +1,91 @@
1
+ import { test, describe } from 'node:test'
2
+ import * as assert from 'assert'
3
+ import { PikkuError } from '../../errors/error-handler.js'
4
+ import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
5
+
6
+ describe('formatCLIError', () => {
7
+ test('prints an expected error as its message alone', () => {
8
+ const error = new PikkuError(
9
+ "Persona 'guest' missing guest. Refusing to run."
10
+ )
11
+ assert.strictEqual(
12
+ formatCLIError(error),
13
+ "Persona 'guest' missing guest. Refusing to run."
14
+ )
15
+ })
16
+
17
+ test('prints an error flagged as expected as its message alone', () => {
18
+ const error = Object.assign(new Error('token expired'), { expected: true })
19
+ assert.strictEqual(formatCLIError(error), 'token expired')
20
+ })
21
+
22
+ test('keeps the stack for an unexpected error', () => {
23
+ const error = new TypeError('cannot read properties of undefined')
24
+ const output = formatCLIError(error)
25
+ assert.ok(output.includes('TypeError: cannot read properties of undefined'))
26
+ assert.ok(output.includes('at '))
27
+ })
28
+
29
+ test('does not double the error name', () => {
30
+ const output = formatCLIError(new Error('boom'))
31
+ assert.ok(!output.includes('Error: Error:'))
32
+ assert.ok(output.startsWith('Error: boom'))
33
+ })
34
+
35
+ test('adds the stack to an expected error when verbose', () => {
36
+ const error = new PikkuError('nope')
37
+ const output = formatCLIError(error, { verbose: true })
38
+ assert.ok(output.startsWith('nope\n'))
39
+ assert.ok(output.includes('at '))
40
+ })
41
+
42
+ test('summarises a fetch failure without inspecting the response', () => {
43
+ const error = Object.assign(new Error('Bad Gateway'), {
44
+ status: 502,
45
+ statusText: 'Bad Gateway',
46
+ response: {
47
+ url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus',
48
+ headers: { forbidden: 'do not print me' },
49
+ body: 'a stream',
50
+ },
51
+ })
52
+ assert.strictEqual(
53
+ formatCLIError(error),
54
+ '502 Bad Gateway from https://api.pikkufabric.com/rpc/getDeploymentStatus'
55
+ )
56
+ })
57
+
58
+ test('keeps a fetch failure message that says more than the status', () => {
59
+ const error = Object.assign(new Error('Deployment not found'), {
60
+ status: 404,
61
+ statusText: 'Not Found',
62
+ response: { url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus' },
63
+ })
64
+ assert.strictEqual(
65
+ formatCLIError(error),
66
+ 'Deployment not found\n' +
67
+ ' 404 Not Found from https://api.pikkufabric.com/rpc/getDeploymentStatus'
68
+ )
69
+ })
70
+
71
+ test('falls back to a string for a thrown non-error', () => {
72
+ assert.strictEqual(formatCLIError('just a string'), 'just a string')
73
+ })
74
+ })
75
+
76
+ describe('wantsStackTrace', () => {
77
+ test('is off by default', () => {
78
+ assert.strictEqual(wantsStackTrace(['deploy'], {}), false)
79
+ })
80
+
81
+ test('honours --verbose and -v', () => {
82
+ assert.strictEqual(wantsStackTrace(['deploy', '--verbose'], {}), true)
83
+ assert.strictEqual(wantsStackTrace(['deploy', '-v'], {}), true)
84
+ })
85
+
86
+ test('honours PIKKU_DEBUG, but not when it is switched off', () => {
87
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '1' }), true)
88
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '0' }), false)
89
+ assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '' }), false)
90
+ })
91
+ })
@@ -0,0 +1,96 @@
1
+ import { isExpectedError } from '../../errors/error-handler.js'
2
+
3
+ /**
4
+ * A `PikkuFetchError` — recognised by shape, not by `instanceof`, because
5
+ * `@pikku/client-fetch` depends on core and core cannot depend back on it.
6
+ */
7
+ type FetchFailure = {
8
+ status: number
9
+ statusText: string
10
+ response: { url?: string }
11
+ message?: string
12
+ }
13
+
14
+ const isFetchFailure = (error: unknown): error is FetchFailure => {
15
+ const candidate = error as Partial<FetchFailure> | null
16
+ return (
17
+ typeof candidate?.status === 'number' &&
18
+ typeof candidate?.statusText === 'string' &&
19
+ typeof candidate?.response === 'object' &&
20
+ candidate.response !== null
21
+ )
22
+ }
23
+
24
+ /**
25
+ * The user asked to see the machinery. `--verbose`/`-v` is the flag the CLI
26
+ * already documents; `PIKKU_DEBUG` is for the times the flag cannot be typed —
27
+ * a command that does not declare the option, or a CLI run from a script.
28
+ */
29
+ export const wantsStackTrace = (
30
+ args: string[],
31
+ env: Record<string, string | undefined> = process.env
32
+ ): boolean =>
33
+ args.includes('--verbose') ||
34
+ args.includes('-v') ||
35
+ (env.PIKKU_DEBUG !== undefined &&
36
+ env.PIKKU_DEBUG !== '' &&
37
+ env.PIKKU_DEBUG !== '0')
38
+
39
+ /**
40
+ * What the CLI prints when a command throws.
41
+ *
42
+ * A stack trace is an answer to "which line of pikku broke", and almost every
43
+ * failure a user actually hits is not that question: a missing role, an expired
44
+ * token, a gateway that is down. Those errors are written to be read, so the
45
+ * message alone is the whole output — an expected error is one deliberately
46
+ * raised as `PikkuError` (or carrying `expected: true`), and everything else
47
+ * keeps its stack, because an unexpected `TypeError` with its frames removed is
48
+ * a bug nobody can diagnose.
49
+ *
50
+ * A stack already begins with `Name: message`, so it is returned as-is: the
51
+ * `console.error('Error:', error)` this replaced produced the doubled
52
+ * `Error: Error: …` prefix that made even real traces look broken.
53
+ */
54
+ export const formatCLIError = (
55
+ error: unknown,
56
+ { verbose = false }: { verbose?: boolean } = {}
57
+ ): string => {
58
+ if (isFetchFailure(error)) {
59
+ return formatFetchFailure(error, verbose)
60
+ }
61
+
62
+ const stack = (error as { stack?: unknown } | null)?.stack
63
+ const message = (error as { message?: unknown } | null)?.message
64
+
65
+ if (!isExpectedError(error)) {
66
+ return typeof stack === 'string' && stack ? stack : String(error)
67
+ }
68
+
69
+ const text = typeof message === 'string' && message ? message : String(error)
70
+ return verbose && typeof stack === 'string' && stack
71
+ ? `${text}\n${stack}`
72
+ : text
73
+ }
74
+
75
+ /**
76
+ * A failed HTTP call, as the line the user needs: which status, from which URL.
77
+ *
78
+ * Never the `Response` itself. Node inspects an error's own properties when it
79
+ * prints one, so a thrown fetch error used to dump the headers, the body stream
80
+ * and the redirect flags — pages of output whose only real content was the
81
+ * status code.
82
+ */
83
+ const formatFetchFailure = (error: FetchFailure, verbose: boolean): string => {
84
+ const url = error.response?.url
85
+ const where = url ? ` from ${url}` : ''
86
+ const summary = `${error.status} ${error.statusText}${where}`
87
+ const message = error.message
88
+ const text =
89
+ message && message !== error.statusText
90
+ ? `${message}\n ${summary}`
91
+ : summary
92
+ const stack = (error as { stack?: unknown }).stack
93
+ return verbose && typeof stack === 'string' && stack
94
+ ? `${text}\n${stack}`
95
+ : text
96
+ }
@@ -77,7 +77,6 @@ export const VIRTUAL_USER_VARIABLES = {
77
77
  * fallback for a run nobody handed a token to, which is what a schedule is.
78
78
  */
79
79
  operatorToken: 'FABRIC_OPERATOR_TOKEN',
80
- createMissing: 'PIKKU_PERSONA_CREATE_MISSING',
81
80
  } as const
82
81
 
83
82
  /**
@@ -560,9 +559,6 @@ export const executeVirtualUserRun = async ({
560
559
  )
561
560
  }
562
561
 
563
- const createMissing =
564
- String(await variables.get(VIRTUAL_USER_VARIABLES.createMissing)) ===
565
- 'true'
566
562
  const model = await variables.get(VIRTUAL_USER_VARIABLES.model)
567
563
  if (!model) {
568
564
  throw new Error(
@@ -606,7 +602,6 @@ export const executeVirtualUserRun = async ({
606
602
  ? {
607
603
  operator: {
608
604
  token,
609
- createMissing,
610
605
  signInPath: signInPathFor(configuredSignInPath, 'fabric'),
611
606
  },
612
607
  }