@pikku/core 0.12.101 → 0.12.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
1
+ ## 0.12.103
2
+
3
+ ### Patch Changes
4
+
5
+ - 206dc04: `--json` now serialises the result of any command, not only the eleven that ship a renderer of their own.
6
+
7
+ The flag swapped in the JSON renderer only when a command-specific renderer existed, so on the other 94 commands it fell through to the program's default renderer — which prints forwarded log lines and drops anything else. `pikku info functions --json` therefore exited 0 and printed no JSON at all, which reads as "this command has no output" rather than as an unsupported flag.
8
+
9
+ ## 0.12.102
10
+
11
+ ### Patch Changes
12
+
13
+ - f4e2e89: Add file attachments to `EmailService`.
14
+
15
+ `BaseSendEmailInput` gains an optional `attachments: EmailAttachment[]`, so it is
16
+ available on all three input variants — text, HTML and template. The new
17
+ `EmailAttachment` type is exported from `@pikku/core/services` alongside the
18
+ input types, and is shaped so that mapping it onto Resend, SendGrid, Nodemailer
19
+ or SES v2 is a straight field rename rather than a translation.
20
+
21
+ `content` is `Uint8Array | string`, where a string is always read as base64.
22
+ Both forms are accepted because both are what callers already hold: bytes come
23
+ out of a fetch or a file read, and base64 comes out of a database column or a
24
+ provider API. `Buffer` is deliberately absent from the type — it is a subclass
25
+ of `Uint8Array`, so Node callers can still pass one, while the type stays usable
26
+ in Cloudflare Workers, where `Buffer` does not exist.
27
+
28
+ `LocalEmailService` now logs attachment metadata — filename, content type,
29
+ content id, disposition and content length — instead of dropping the field
30
+ silently. The content itself is deliberately not logged.
31
+
32
+ The template-rendering wrapper documented in the emails skill rebuilt its
33
+ delegate payload field by field and therefore dropped `attachments`; it now
34
+ forwards them.
35
+
1
36
  ## 0.12.101
2
37
 
3
38
  ### Patch Changes
@@ -4,6 +4,18 @@ export interface EmailTemplateReference {
4
4
  locale?: string;
5
5
  data?: Record<string, unknown>;
6
6
  }
7
+ export interface EmailAttachment {
8
+ filename: string;
9
+ /**
10
+ * Raw bytes, or the content already base64-encoded. A `string` is always
11
+ * read as base64 — never as a plain-text body — so text attachments must be
12
+ * encoded by the caller.
13
+ */
14
+ content: Uint8Array | string;
15
+ contentType?: string;
16
+ contentId?: string;
17
+ disposition?: 'attachment' | 'inline';
18
+ }
7
19
  export interface BaseSendEmailInput {
8
20
  to: string | string[];
9
21
  from?: string;
@@ -12,6 +24,7 @@ export interface BaseSendEmailInput {
12
24
  replyTo?: string | string[];
13
25
  headers?: Record<string, string>;
14
26
  subject?: string;
27
+ attachments?: EmailAttachment[];
15
28
  }
16
29
  export interface SendTextEmailInput extends BaseSendEmailInput {
17
30
  text: string;
@@ -20,7 +20,7 @@ export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary,
20
20
  export type { ContentService, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
21
21
  export type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, } from './personas-service.js';
22
22
  export type { JWTService } from './jwt-service.js';
23
- export type { EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
23
+ export type { EmailAttachment, EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
24
24
  export { renderEmail, type EmailAssets, type EmailTemplateAssets, type EmailTemplateHashes, type RenderEmailRequest, type RenderedEmailResult, } from './email-template.js';
25
25
  export { DEFAULT_WEBHOOK_RETRIES, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, type SendWebhookInput, type SendWebhookResult, type WebhookAttemptResult, type WebhookDeliveryRecord, type WebhookDeliveryWithAttempts, type WebhookJobData, type WebhookServiceConfig, } from './webhook-service.js';
26
26
  export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from './persona-actor-secret.js';
@@ -20,6 +20,15 @@ export class LocalEmailService {
20
20
  if ('text' in input && typeof input.text === 'string') {
21
21
  payload.textLength = input.text.length;
22
22
  }
23
+ if (input.attachments?.length) {
24
+ payload.attachments = input.attachments.map((attachment) => ({
25
+ filename: attachment.filename,
26
+ contentType: attachment.contentType ?? null,
27
+ contentId: attachment.contentId ?? null,
28
+ disposition: attachment.disposition ?? 'attachment',
29
+ contentLength: attachment.content.length,
30
+ }));
31
+ }
23
32
  console.info(JSON.stringify(payload));
24
33
  return {};
25
34
  }
@@ -245,7 +245,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
245
245
  const commandRenderer = programData?.renderers[commandId];
246
246
  const jsonMode = data.json === true ||
247
247
  data.output === 'json';
248
- const finalRenderer = jsonMode && commandRenderer !== undefined
248
+ const finalRenderer = jsonMode
249
249
  ? defaultJSONRenderer
250
250
  : (commandRenderer ?? programData?.defaultRenderer);
251
251
  if (finalRenderer !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.101",
3
+ "version": "0.12.103",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -39,6 +39,11 @@ const skipped = new Set([
39
39
  'build',
40
40
  '.git',
41
41
  'coverage',
42
+ // Agent scratch worktrees are whole Pikku projects generated by whatever CLI
43
+ // version happened to scaffold them, so discovery finds them and reports a
44
+ // barrel this repo does not own. They are gitignored and belong to no
45
+ // workspace, which is what makes them out of scope rather than merely noisy.
46
+ '.claude',
42
47
  ])
43
48
 
44
49
  const findProjects = (dir: string, out: string[] = []): string[] => {
@@ -15,6 +15,9 @@ const skipped = new Set([
15
15
  '.git',
16
16
  '.deploy',
17
17
  'coverage',
18
+ // A worktree here carries its own copy of this file, which necessarily
19
+ // contains the specifier being scanned for — the guard would report itself.
20
+ '.claude',
18
21
  ])
19
22
 
20
23
  const collectSourceFiles = (
@@ -22,11 +22,19 @@ const skipDirectory = new Set([
22
22
  '.git',
23
23
  'build',
24
24
  'coverage',
25
+ // Agent scratch worktrees are whole Pikku projects scaffolded by whatever CLI
26
+ // version happened to write them, so they carry specifiers this repo does not
27
+ // own. They are gitignored and belong to no workspace, which is what puts them
28
+ // out of scope rather than merely making them noisy.
29
+ '.claude',
25
30
  ])
26
31
 
32
+ /** A verifier's scratch project, left behind by a run that did not clean up. */
33
+ const isScratchProject = (entry: string) => entry.startsWith('.tmp-')
34
+
27
35
  const walk = (dir: string, out: string[] = []): string[] => {
28
36
  for (const entry of readdirSync(dir)) {
29
- if (skipDirectory.has(entry)) continue
37
+ if (skipDirectory.has(entry) || isScratchProject(entry)) continue
30
38
  const path = join(dir, entry)
31
39
  if (statSync(path).isDirectory()) walk(path, out)
32
40
  else if (path.endsWith('.ts') || path.endsWith('.tsx')) out.push(path)
@@ -0,0 +1,100 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import type {
5
+ EmailAttachment,
6
+ EmailService,
7
+ SendEmailInput,
8
+ SendEmailResult,
9
+ SendHTMLEmailInput,
10
+ SendTemplateEmailInput,
11
+ SendTextEmailInput,
12
+ } from './email-service.js'
13
+
14
+ const receipt: EmailAttachment = {
15
+ filename: 'receipt.pdf',
16
+ content: new Uint8Array([1, 2, 3]),
17
+ contentType: 'application/pdf',
18
+ }
19
+
20
+ const logo: EmailAttachment = {
21
+ filename: 'logo.png',
22
+ content: 'aGVsbG8=',
23
+ contentType: 'image/png',
24
+ contentId: 'logo',
25
+ disposition: 'inline',
26
+ }
27
+
28
+ test('a text email carries attachments', () => {
29
+ const input: SendTextEmailInput = {
30
+ to: 'user@example.com',
31
+ text: 'Your receipt is attached',
32
+ attachments: [receipt],
33
+ }
34
+
35
+ assert.deepEqual(input.attachments, [receipt])
36
+ })
37
+
38
+ test('an html email carries attachments', () => {
39
+ const input: SendHTMLEmailInput = {
40
+ to: 'user@example.com',
41
+ html: '<img src="cid:logo" />',
42
+ attachments: [logo],
43
+ }
44
+
45
+ assert.equal(input.attachments?.[0]?.disposition, 'inline')
46
+ assert.equal(input.attachments?.[0]?.contentId, 'logo')
47
+ })
48
+
49
+ test('a template email carries attachments', () => {
50
+ const input: SendTemplateEmailInput = {
51
+ to: 'user@example.com',
52
+ template: { name: 'receipt', locale: 'en', data: { total: 10 } },
53
+ attachments: [receipt, logo],
54
+ }
55
+
56
+ assert.equal(input.attachments?.length, 2)
57
+ })
58
+
59
+ test('attachments are optional on every variant', () => {
60
+ const inputs: SendEmailInput[] = [
61
+ { to: 'user@example.com', text: 'hello' },
62
+ { to: 'user@example.com', html: '<p>hello</p>' },
63
+ { to: 'user@example.com', template: { name: 'welcome' } },
64
+ ]
65
+
66
+ for (const input of inputs) {
67
+ assert.equal(input.attachments, undefined)
68
+ }
69
+ })
70
+
71
+ test('a wrapping service forwards attachments to its delegate', async () => {
72
+ const sent: SendEmailInput[] = []
73
+ const delegate: EmailService = {
74
+ async send(input): Promise<SendEmailResult> {
75
+ sent.push(input as SendEmailInput)
76
+ return { messageId: 'delegated' }
77
+ },
78
+ }
79
+
80
+ const wrapper: EmailService = {
81
+ async send(input): Promise<SendEmailResult> {
82
+ const { template, ...rest } = input as SendTemplateEmailInput
83
+ return delegate.send({
84
+ ...rest,
85
+ subject: `rendered:${template.name}`,
86
+ html: '<p>rendered</p>',
87
+ })
88
+ },
89
+ }
90
+
91
+ const result = await wrapper.send({
92
+ to: 'user@example.com',
93
+ template: { name: 'receipt' },
94
+ attachments: [receipt],
95
+ })
96
+
97
+ assert.equal(result.messageId, 'delegated')
98
+ assert.equal(sent.length, 1)
99
+ assert.deepEqual(sent[0]!.attachments, [receipt])
100
+ })
@@ -6,6 +6,19 @@ export interface EmailTemplateReference {
6
6
  data?: Record<string, unknown>
7
7
  }
8
8
 
9
+ export interface EmailAttachment {
10
+ filename: string
11
+ /**
12
+ * Raw bytes, or the content already base64-encoded. A `string` is always
13
+ * read as base64 — never as a plain-text body — so text attachments must be
14
+ * encoded by the caller.
15
+ */
16
+ content: Uint8Array | string
17
+ contentType?: string
18
+ contentId?: string
19
+ disposition?: 'attachment' | 'inline'
20
+ }
21
+
9
22
  export interface BaseSendEmailInput {
10
23
  to: string | string[]
11
24
  from?: string
@@ -14,6 +27,7 @@ export interface BaseSendEmailInput {
14
27
  replyTo?: string | string[]
15
28
  headers?: Record<string, string>
16
29
  subject?: string
30
+ attachments?: EmailAttachment[]
17
31
  }
18
32
 
19
33
  export interface SendTextEmailInput extends BaseSendEmailInput {
@@ -46,6 +46,7 @@ export type {
46
46
  // knowledge: decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md
47
47
  export type { JWTService } from './jwt-service.js'
48
48
  export type {
49
+ EmailAttachment,
49
50
  EmailService,
50
51
  SendEmailInput,
51
52
  SendEmailResult,
@@ -106,3 +106,77 @@ test('LocalEmailService logs null textLength for HTML email without plain text',
106
106
  assert.equal(payload.htmlLength, '<p>Hello</p>'.length)
107
107
  assert.equal(payload.textLength, null)
108
108
  })
109
+
110
+ test('LocalEmailService logs attachment metadata without the content', async () => {
111
+ const service = new LocalEmailService()
112
+ const writes: string[] = []
113
+ const originalInfo = console.info
114
+ console.info = (value?: unknown) => {
115
+ writes.push(String(value))
116
+ }
117
+
118
+ try {
119
+ await service.send({
120
+ to: 'user@example.com',
121
+ html: '<img src="cid:logo" />',
122
+ attachments: [
123
+ {
124
+ filename: 'receipt.pdf',
125
+ content: new Uint8Array([1, 2, 3, 4]),
126
+ contentType: 'application/pdf',
127
+ },
128
+ {
129
+ filename: 'logo.png',
130
+ content: 'aGVsbG8=',
131
+ contentType: 'image/png',
132
+ contentId: 'logo',
133
+ disposition: 'inline',
134
+ },
135
+ ],
136
+ })
137
+ } finally {
138
+ console.info = originalInfo
139
+ }
140
+
141
+ const payload = JSON.parse(writes[0])
142
+ assert.deepEqual(payload.attachments, [
143
+ {
144
+ filename: 'receipt.pdf',
145
+ contentType: 'application/pdf',
146
+ contentId: null,
147
+ disposition: 'attachment',
148
+ contentLength: 4,
149
+ },
150
+ {
151
+ filename: 'logo.png',
152
+ contentType: 'image/png',
153
+ contentId: 'logo',
154
+ disposition: 'inline',
155
+ contentLength: 8,
156
+ },
157
+ ])
158
+ assert.equal(writes[0].includes('aGVsbG8='), false)
159
+ })
160
+
161
+ test('LocalEmailService omits attachments when there are none', async () => {
162
+ const service = new LocalEmailService()
163
+ const writes: string[] = []
164
+ const originalInfo = console.info
165
+ console.info = (value?: unknown) => {
166
+ writes.push(String(value))
167
+ }
168
+
169
+ try {
170
+ await service.send({ to: 'user@example.com', text: 'no files' })
171
+ await service.send({
172
+ to: 'user@example.com',
173
+ text: 'no files',
174
+ attachments: [],
175
+ })
176
+ } finally {
177
+ console.info = originalInfo
178
+ }
179
+
180
+ assert.equal(JSON.parse(writes[0]).attachments, undefined)
181
+ assert.equal(JSON.parse(writes[1]).attachments, undefined)
182
+ })
@@ -27,6 +27,15 @@ export class LocalEmailService implements EmailService {
27
27
  if ('text' in input && typeof input.text === 'string') {
28
28
  payload.textLength = input.text.length
29
29
  }
30
+ if (input.attachments?.length) {
31
+ payload.attachments = input.attachments.map((attachment) => ({
32
+ filename: attachment.filename,
33
+ contentType: attachment.contentType ?? null,
34
+ contentId: attachment.contentId ?? null,
35
+ disposition: attachment.disposition ?? 'attachment',
36
+ contentLength: attachment.content.length,
37
+ }))
38
+ }
30
39
 
31
40
  console.info(JSON.stringify(payload))
32
41
  return {}
@@ -829,4 +829,141 @@ describe('CLI Runner', () => {
829
829
  assert.ok(printed.includes('at '))
830
830
  })
831
831
  })
832
+ describe('--json output', () => {
833
+ const wireGreet = (renderers: Record<string, any>, defaultRenderer: any) => {
834
+ pikkuState(null, 'cli', 'meta', {
835
+ programs: {
836
+ 'test-cli': {
837
+ program: 'test-cli',
838
+ commands: {
839
+ greet: {
840
+ command: 'greet',
841
+ pikkuFuncId: 'greetFunc',
842
+ positionals: [],
843
+ options: {},
844
+ },
845
+ },
846
+ options: {},
847
+ },
848
+ },
849
+ renderers: {},
850
+ })
851
+
852
+ pikkuState(null, 'cli', 'programs', {
853
+ 'test-cli': { defaultRenderer, middleware: [], renderers },
854
+ })
855
+
856
+ pikkuState(null, 'function', 'meta', {
857
+ greetFunc: {
858
+ pikkuFuncId: 'greetFunc',
859
+ inputSchemaName: null,
860
+ outputSchemaName: null,
861
+ sessionless: true,
862
+ },
863
+ })
864
+
865
+ addFunction('greetFunc', {
866
+ func: async () => ({ name: 'Alice' }),
867
+ auth: false,
868
+ })
869
+ }
870
+
871
+ const captureStdout = async (fn: () => Promise<unknown>) => {
872
+ const lines: string[] = []
873
+ const original = console.log
874
+ console.log = (...args: unknown[]) => {
875
+ lines.push(args.map(String).join(' '))
876
+ }
877
+ try {
878
+ await fn()
879
+ } finally {
880
+ console.log = original
881
+ }
882
+ return lines
883
+ }
884
+
885
+ test('serialises the result for a command with no renderer of its own', async () => {
886
+ const textOnly = () => {}
887
+ wireGreet({}, textOnly)
888
+
889
+ const lines = await captureStdout(() =>
890
+ runCLICommand({
891
+ program: 'test-cli',
892
+ commandPath: ['greet'],
893
+ data: { json: true },
894
+ singletonServices,
895
+ createWireServices,
896
+ })
897
+ )
898
+
899
+ assert.deepEqual(lines, ['{"name":"Alice"}'])
900
+ })
901
+
902
+ test('--output json is equivalent to --json', async () => {
903
+ const textOnly = () => {}
904
+ wireGreet({}, textOnly)
905
+
906
+ const lines = await captureStdout(() =>
907
+ runCLICommand({
908
+ program: 'test-cli',
909
+ commandPath: ['greet'],
910
+ data: { output: 'json' },
911
+ singletonServices,
912
+ createWireServices,
913
+ })
914
+ )
915
+
916
+ assert.deepEqual(lines, ['{"name":"Alice"}'])
917
+ })
918
+
919
+ test('json overrides a command renderer', async () => {
920
+ let renderedWith: unknown
921
+ wireGreet(
922
+ {
923
+ greet: (_s: any, data: any) => {
924
+ renderedWith = data
925
+ },
926
+ },
927
+ () => {}
928
+ )
929
+
930
+ const lines = await captureStdout(() =>
931
+ runCLICommand({
932
+ program: 'test-cli',
933
+ commandPath: ['greet'],
934
+ data: { json: true },
935
+ singletonServices,
936
+ createWireServices,
937
+ })
938
+ )
939
+
940
+ assert.equal(renderedWith, undefined)
941
+ assert.deepEqual(lines, ['{"name":"Alice"}'])
942
+ })
943
+
944
+ test('without json the command renderer still wins', async () => {
945
+ let renderedWith: unknown
946
+ wireGreet(
947
+ {
948
+ greet: (_s: any, data: any) => {
949
+ renderedWith = data
950
+ },
951
+ },
952
+ () => {}
953
+ )
954
+
955
+ const lines = await captureStdout(() =>
956
+ runCLICommand({
957
+ program: 'test-cli',
958
+ commandPath: ['greet'],
959
+ data: {},
960
+ singletonServices,
961
+ createWireServices,
962
+ })
963
+ )
964
+
965
+ assert.deepEqual(renderedWith, { name: 'Alice' })
966
+ assert.deepEqual(lines, [])
967
+ })
968
+ })
832
969
  })
@@ -391,10 +391,9 @@ export async function runCLICommand({
391
391
  const jsonMode =
392
392
  (data as { json?: unknown; output?: unknown }).json === true ||
393
393
  (data as { json?: unknown; output?: unknown }).output === 'json'
394
- const finalRenderer: CorePikkuCLIRender<any> | undefined =
395
- jsonMode && commandRenderer !== undefined
396
- ? defaultJSONRenderer
397
- : (commandRenderer ?? programData?.defaultRenderer)
394
+ const finalRenderer: CorePikkuCLIRender<any> | undefined = jsonMode
395
+ ? defaultJSONRenderer
396
+ : (commandRenderer ?? programData?.defaultRenderer)
398
397
  if (finalRenderer !== undefined) {
399
398
  await Promise.resolve(
400
399
  finalRenderer(