@stacksjs/defaults 0.72.77 → 0.72.79

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.
@@ -17,8 +17,8 @@ The complete CLI runtime for the Stacks framework with 50+ commands, lazy-loaded
17
17
  - Lazy command registry: `storage/framework/core/buddy/src/lazy-commands.ts`
18
18
  - Config system: `storage/framework/core/buddy/src/config.ts`
19
19
  - Shell entry: `buddy` (shell script at project root that invokes `bun run ./storage/framework/core/buddy/src/cli.ts`)
20
- - Application commands: `app/Commands/`
21
- - Command registry: `app/Commands.ts`
20
+ - Application commands: `app/Commands/` (auto-discovered; no registration step)
21
+ - Optional registry: `app/Commands.ts`
22
22
  - Make templates: `storage/framework/defaults/`
23
23
 
24
24
  ## CLI Aliases
@@ -250,7 +250,7 @@ buddy make:certificate # generate SSL certificate (alias: make:cert)
250
250
  buddy make:command [name] # create CLI command in app/Commands/
251
251
  --signature [sig] # CLI command name
252
252
  --description [desc] # command description
253
- --no-register # skip registering in Commands.ts
253
+ --register # also add an entry to app/Commands.ts (optional)
254
254
  buddy make:component [name] # create STX component
255
255
  buddy make:database [name] # create database
256
256
  buddy make:factory [name] # create model factory (stub)
@@ -622,34 +622,63 @@ buddy stacks # Stacks framework commands (registered as 'stack'
622
622
 
623
623
  ## Adding Custom Commands
624
624
 
625
- ### Method 1: Commands.ts Registry (preferred)
625
+ ### Method 1: Drop a file in `app/Commands/` (preferred)
626
+
627
+ Every `.ts` file there is a command - no registration, no generated file.
628
+ Nested directories work too (`app/Commands/Archive/Run.ts`).
626
629
 
627
630
  ```typescript
628
- // app/Commands.ts
629
- export default {
630
- 'inspire': 'Inspire', // simple: maps to app/Commands/Inspire.ts
631
- 'deploy-hooks': { file: 'DeployHooks', enabled: true, aliases: ['dh'] }, // with options
632
- 'disabled-cmd': { file: 'Disabled', enabled: false }, // disabled command
633
- }
631
+ // app/Commands/Inspire.ts
632
+ import { defineCommand } from '@stacksjs/cli'
633
+
634
+ // Declarative: `options` is inferred from the flags declared above it.
635
+ export default defineCommand({
636
+ name: 'inspire',
637
+ description: 'Display inspirational quote',
638
+ aliases: ['insp'],
639
+ options: {
640
+ '--two, -t': { description: 'Show two quotes', default: false },
641
+ },
642
+ handle(options) {
643
+ console.log(randomQuote())
644
+ if (options.two)
645
+ console.log(randomQuote())
646
+ },
647
+ })
634
648
  ```
635
649
 
636
650
  ```typescript
637
- // app/Commands/Inspire.ts
638
- import type { CLI } from '@stacksjs/cli'
651
+ // Imperative, for a file that registers several commands or uses cli.on()
652
+ import { defineCommand } from '@stacksjs/cli'
639
653
 
640
- export default function (buddy: CLI) {
641
- buddy
642
- .command('inspire', 'Display inspirational quote')
643
- .option('-t, --two', 'Show two quotes')
644
- .action(async (options: { two?: boolean }) => {
645
- console.log(randomQuote())
646
- if (options.two) console.log(randomQuote())
647
- })
648
- }
654
+ export default defineCommand((buddy) => {
655
+ buddy.command('inspire', 'Display inspirational quote').action(() => {})
656
+ buddy.command('inspire:two', 'Two quotes').action(() => {})
657
+ })
649
658
  ```
650
659
 
651
- ### Method 2: Auto-discovery (fallback)
652
- If `app/Commands.ts` does not exist, all `.ts` files in `app/Commands/` are auto-discovered and loaded. Each must export a default function that receives the CLI instance.
660
+ A command file can also configure itself with named exports:
661
+
662
+ ```typescript
663
+ export const aliases = ['emails', 'mail'] // extra aliases
664
+ export const enabled = false // keep the file, hide the command
665
+ ```
666
+
667
+ ### Method 2: `app/Commands.ts` (optional overlay)
668
+
669
+ Only worth keeping to control listing order, or to alias/disable a command
670
+ without editing its file. Files it does not mention still load.
671
+
672
+ ```typescript
673
+ // app/Commands.ts
674
+ import { defineCommands } from '@stacksjs/cli'
675
+
676
+ export default defineCommands({
677
+ 'inspire': 'Inspire', // maps to app/Commands/Inspire.ts
678
+ 'deploy-hooks': { file: 'DeployHooks', enabled: true, aliases: ['dh'] }, // with options
679
+ 'disabled-cmd': { file: 'Disabled', enabled: false }, // disabled command
680
+ })
681
+ ```
653
682
 
654
683
  ### Method 3: buddy.config.ts
655
684
  ```typescript
@@ -14,40 +14,92 @@ The `@stacksjs/cli` package provides the foundation for building CLI commands, u
14
14
  - Core package: `storage/framework/core/cli/src/`
15
15
  - CLI configuration: `config/cli.ts`
16
16
  - Application commands: `app/Commands/`
17
- - Command registry: `app/Commands.ts`
17
+ - Optional registry: `app/Commands.ts` (not needed - see below)
18
18
 
19
19
  ## Creating Commands
20
20
 
21
- ### Simple Command
21
+ Every `.ts` file in `app/Commands/` is a command. There is no registration step
22
+ and no generated registry file: drop the file in and it is live, nested
23
+ directories included (`app/Commands/Archive/Run.ts`).
24
+
25
+ ### Declarative form (preferred)
26
+
27
+ `defineCommand()` infers the handler's `options` from the flags declared above
28
+ it, so there is no hand-written options interface to drift out of step.
29
+
22
30
  ```typescript
23
31
  // app/Commands/Greet.ts
24
- export default {
25
- name: 'greet',
32
+ import { defineCommand, log } from '@stacksjs/cli'
33
+
34
+ export default defineCommand({
35
+ name: 'greet <who>',
26
36
  description: 'Greet a user',
27
- alias: 'g',
37
+ aliases: ['g'],
28
38
  options: {
29
- name: { alias: 'n', description: 'Name to greet', default: 'World' },
30
- loud: { alias: 'l', description: 'Shout the greeting', type: 'boolean' }
39
+ '--loud, -l': { description: 'Shout the greeting', default: false },
40
+ '--title <title>': 'Prefix the name',
41
+ '--times <n>': { description: 'How often', default: 1, type: [Number] },
31
42
  },
32
- handle: async ({ options }) => {
33
- const greeting = `Hello, ${options.name}!`
34
- console.log(options.loud ? greeting.toUpperCase() : greeting)
35
- }
36
- }
43
+ handle(options, who) {
44
+ // options.loud -> boolean
45
+ // options.title -> string | undefined
46
+ // options.times -> number
47
+ const greeting = `Hello, ${options.title ?? ''}${who}!`
48
+
49
+ for (let i = 0; i < options.times; i++)
50
+ log.info(options.loud ? greeting.toUpperCase() : greeting)
51
+ },
52
+ })
37
53
  ```
38
54
 
39
- ### Register in app/Commands.ts
55
+ Flag to property: `--dry-run` -> `dryRun`, `--two, -t` -> `two`, `--no-cache`
56
+ -> `cache`. Value type: `<x>` -> `string`, `[x]` -> `string | true`, `<x...>`
57
+ -> `string[]`, no argument -> `boolean`. A `default` makes the property always
58
+ present; a `type: [Number]` makes it a `number`.
59
+
60
+ ### Imperative form
61
+
62
+ For a file that registers several commands or needs `cli.on()`:
63
+
40
64
  ```typescript
41
- export default {
42
- 'greet': 'Greet',
43
- 'inspire': 'Inspire',
44
- }
65
+ import { defineCommand, log } from '@stacksjs/cli'
66
+
67
+ export default defineCommand((cli) => {
68
+ cli.command('inspire', 'Inspire yourself').alias('insp').action(() => {})
69
+ cli.command('inspire:two', 'Two quotes').action(() => {})
70
+
71
+ cli.on('inspire:*', () => log.error('Invalid command'))
72
+ })
73
+ ```
74
+
75
+ ### Per-file configuration
76
+
77
+ Named exports beside the default one, so a command owns its own configuration:
78
+
79
+ ```typescript
80
+ export const aliases = ['emails', 'mail'] // extra aliases
81
+ export const enabled = false // keep the file, hide the command
82
+ ```
83
+
84
+ ### The optional registry (`app/Commands.ts`)
85
+
86
+ Only worth keeping when you want to control the order commands are listed in,
87
+ or to alias/disable a command without editing its file. A command file the
88
+ registry never mentions still loads.
89
+
90
+ ```typescript
91
+ import { defineCommands } from '@stacksjs/cli'
92
+
93
+ export default defineCommands({
94
+ 'send-emails <type>': { file: 'SendEmails', aliases: ['emails'] },
95
+ 'legacy': { file: 'Legacy', enabled: false },
96
+ })
45
97
  ```
46
98
 
47
- ### Three Registration Methods
48
- 1. **app/Commands.ts** — name → file mapping
99
+ ### Registration Methods
100
+ 1. **Drop a file in `app/Commands/`** — auto-discovered, nothing else to do
49
101
  2. **Event listeners** — CLI events in `app/Listeners/Console.ts`
50
- 3. **Inline** — direct command definitions
102
+ 3. **`app/Commands.ts`** — optional, for ordering / aliases / disabling
51
103
 
52
104
  ## CLI Event Listeners
53
105
 
@@ -104,7 +156,7 @@ buddy build:cli # build CLI binary
104
156
 
105
157
  ## Gotchas
106
158
  - Application commands go in `app/Commands/`, framework commands in `storage/framework/core/buddy/src/commands/`
107
- - Commands are auto-discovered from `app/Commands.ts` registry
159
+ - Commands are auto-discovered from `app/Commands/`; `app/Commands.ts` is optional and additive
108
160
  - CLI events support wildcards (`*`) and default handlers (`!`)
109
161
  - The buddy CLI lazy-loads commands — not all load at startup
110
162
  - Output formatting uses ANSI colors from `@stacksjs/utils`
@@ -10,12 +10,18 @@ export default new Action({
10
10
 
11
11
  async handle() {
12
12
  try {
13
- const messages = await listCapturedMail()
13
+ // `problems` carries the captures that could not be parsed. They are
14
+ // reported rather than thrown so one stale file cannot 503 the inbox,
15
+ // and reported rather than dropped so the operator can still see that
16
+ // something in the capture directory needs attention.
17
+ const { messages, problems } = await listCapturedMail()
14
18
  return {
15
19
  captureDriver: 'log',
16
20
  activeDriver: process.env.MAIL_MAILER || 'log',
17
21
  total: messages.length,
18
22
  messages,
23
+ unreadable: problems.length,
24
+ problems,
19
25
  }
20
26
  }
21
27
  catch (error) {
@@ -24,7 +24,7 @@ afterEach(async () => {
24
24
  describe('captured dashboard mail', () => {
25
25
  it('returns an empty list when the capture directory does not exist', async () => {
26
26
  const root = await temporaryDirectory()
27
- expect(await listCapturedMail(join(root, 'missing'))).toEqual([])
27
+ expect(await listCapturedMail(join(root, 'missing'))).toEqual({ messages: [], problems: [] })
28
28
  })
29
29
 
30
30
  it('lists and reads a valid disk capture', async () => {
@@ -41,7 +41,8 @@ describe('captured dashboard mail', () => {
41
41
  `
42
42
  await writeFile(join(directory, filename), html)
43
43
 
44
- const messages = await listCapturedMail(directory)
44
+ const { messages, problems } = await listCapturedMail(directory)
45
+ expect(problems).toEqual([])
45
46
  expect(messages).toHaveLength(1)
46
47
  expect(messages[0]).toMatchObject({
47
48
  id: `disk:${filename}`,
@@ -62,10 +63,59 @@ describe('captured dashboard mail', () => {
62
63
  expect(message?.text).toBe('')
63
64
  })
64
65
 
65
- it('rejects malformed capture files instead of fabricating metadata', async () => {
66
+ it('reports a malformed capture instead of fabricating metadata for it', async () => {
66
67
  const directory = await temporaryDirectory()
67
68
  await writeFile(join(directory, 'broken.html'), '<p>No capture header</p>')
68
- await expect(listCapturedMail(directory)).rejects.toThrow('missing its log-driver header')
69
+
70
+ const { messages, problems } = await listCapturedMail(directory)
71
+ expect(messages).toEqual([])
72
+ expect(problems).toEqual([
73
+ { capture: 'broken.html', reason: expect.stringContaining('missing its log-driver header') },
74
+ ])
75
+ })
76
+
77
+ it('still lists the readable captures when one file alongside them is broken', async () => {
78
+ // The bug this pins: `storage/logs/mail` held 29 captures, one of them
79
+ // written before the header format settled, and the single bad file made
80
+ // the whole endpoint 503 - the dashboard inbox showed nothing at all.
81
+ const directory = await temporaryDirectory()
82
+ const good = '2026-07-29T12-00-00-000Z-Welcome.html'
83
+ await writeFile(join(directory, good), `<!--
84
+ Captured by @stacksjs/email log driver at 2026-07-29T12:00:00.000Z
85
+ From: Stacks <hello@example.com>
86
+ To: Chris <chris@example.com>
87
+ Subject: Welcome
88
+ -->
89
+ <main><p>Your account is ready.</p></main>
90
+ `)
91
+ await writeFile(join(directory, 'legacy-no-header.html'), '<p>Written before the header existed</p>')
92
+ await writeFile(join(directory, 'headers-missing-subject.html'), `<!--
93
+ Captured by @stacksjs/email log driver at 2026-07-29T12:00:00.000Z
94
+ From: Stacks <hello@example.com>
95
+ To: Chris <chris@example.com>
96
+ -->
97
+ <main><p>No subject line.</p></main>
98
+ `)
99
+
100
+ const { messages, problems } = await listCapturedMail(directory)
101
+
102
+ expect(messages).toHaveLength(1)
103
+ expect(messages[0]?.id).toBe(`disk:${good}`)
104
+ expect(problems.map(problem => problem.capture)).toEqual([
105
+ 'headers-missing-subject.html',
106
+ 'legacy-no-header.html',
107
+ ])
108
+ expect(problems[0]?.reason).toContain('missing required message headers')
109
+ expect(problems[1]?.reason).toContain('missing its log-driver header')
110
+ })
111
+
112
+ it('still throws when a broken capture is the one being asked for by id', async () => {
113
+ // Listing skips it; fetching it by id must not answer with an empty
114
+ // result, because there the broken file IS what the caller asked about.
115
+ const directory = await temporaryDirectory()
116
+ await writeFile(join(directory, 'broken.html'), '<p>No capture header</p>')
117
+ await expect(showCapturedMail('disk:broken.html', directory))
118
+ .rejects.toThrow('missing its log-driver header')
69
119
  })
70
120
 
71
121
  it('rejects disk ids that could escape the capture directory', async () => {
@@ -25,6 +25,17 @@ export interface CapturedMailMessage extends CapturedMailSummary {
25
25
  text: string
26
26
  }
27
27
 
28
+ export interface CapturedMailProblem {
29
+ /** The capture that could not be read: a disk filename, or `mem:<index>`. */
30
+ capture: string
31
+ reason: string
32
+ }
33
+
34
+ export interface CapturedMailListing {
35
+ messages: CapturedMailSummary[]
36
+ problems: CapturedMailProblem[]
37
+ }
38
+
28
39
  function capturedMailDirectory(): string {
29
40
  return process.env.LOG_MAIL_DIR || logsPath('mail')
30
41
  }
@@ -65,6 +76,10 @@ function validTimestamp(value: string, source: string): string {
65
76
  return new Date(time).toISOString()
66
77
  }
67
78
 
79
+ function reasonFor(error: unknown): string {
80
+ return error instanceof Error ? error.message : String(error)
81
+ }
82
+
68
83
  function headerValue(header: string, label: string): string {
69
84
  const match = header.match(new RegExp(`^\\s*${label}:\\s*(.*)$`, 'im'))
70
85
  return match?.[1]?.trim() || ''
@@ -123,48 +138,69 @@ async function diskMessage(name: string, directory: string): Promise<CapturedMai
123
138
  }
124
139
  }
125
140
 
141
+ type CapturedLogEmail = ReturnType<typeof LogEmailDriver.captured>[number]
142
+
143
+ function memoryMessage(email: CapturedLogEmail, index: number): CapturedMailMessage {
144
+ if (!(email.sentAt instanceof Date) || !Number.isFinite(email.sentAt.getTime()))
145
+ throw new TypeError('Captured in-memory email contains an invalid timestamp.')
146
+ if (typeof email.subject !== 'string' || !email.subject.trim())
147
+ throw new TypeError('Captured in-memory email is missing its subject.')
148
+
149
+ const html = email.rendered?.html ?? email.html ?? ''
150
+ const text = email.rendered?.text ?? email.text ?? ''
151
+ if (typeof html !== 'string' || typeof text !== 'string')
152
+ throw new TypeError('Captured in-memory email bodies must be strings.')
153
+
154
+ const from = formatAddress(email.from)
155
+ const to = formatAddress(email.to)
156
+ if (!from || !to)
157
+ throw new TypeError('Captured in-memory email is missing its sender or recipient.')
158
+
159
+ const sentAt = email.sentAt.toISOString()
160
+ return {
161
+ id: `mem:${email.sentAt.getTime()}:${index}`,
162
+ source: 'memory',
163
+ from,
164
+ to,
165
+ cc: formatAddress(email.cc),
166
+ bcc: formatAddress(email.bcc),
167
+ subject: email.subject,
168
+ preview: previewFor(html || text),
169
+ sentAt,
170
+ hasHtml: Boolean(html),
171
+ hasText: Boolean(text),
172
+ size: Buffer.byteLength(html, 'utf8') + Buffer.byteLength(text, 'utf8'),
173
+ html,
174
+ text,
175
+ }
176
+ }
177
+
126
178
  function memoryMessages(): CapturedMailMessage[] {
127
- return LogEmailDriver.captured().map((email, index) => {
128
- if (!(email.sentAt instanceof Date) || !Number.isFinite(email.sentAt.getTime()))
129
- throw new TypeError('Captured in-memory email contains an invalid timestamp.')
130
- if (typeof email.subject !== 'string' || !email.subject.trim())
131
- throw new TypeError('Captured in-memory email is missing its subject.')
132
-
133
- const html = email.rendered?.html ?? email.html ?? ''
134
- const text = email.rendered?.text ?? email.text ?? ''
135
- if (typeof html !== 'string' || typeof text !== 'string')
136
- throw new TypeError('Captured in-memory email bodies must be strings.')
137
-
138
- const from = formatAddress(email.from)
139
- const to = formatAddress(email.to)
140
- if (!from || !to)
141
- throw new TypeError('Captured in-memory email is missing its sender or recipient.')
142
-
143
- const sentAt = email.sentAt.toISOString()
144
- return {
145
- id: `mem:${email.sentAt.getTime()}:${index}`,
146
- source: 'memory',
147
- from,
148
- to,
149
- cc: formatAddress(email.cc),
150
- bcc: formatAddress(email.bcc),
151
- subject: email.subject,
152
- preview: previewFor(html || text),
153
- sentAt,
154
- hasHtml: Boolean(html),
155
- hasText: Boolean(text),
156
- size: Buffer.byteLength(html, 'utf8') + Buffer.byteLength(text, 'utf8'),
157
- html,
158
- text,
159
- }
160
- })
179
+ return LogEmailDriver.captured().map(memoryMessage)
161
180
  }
162
181
 
163
182
  function dedupeKey(message: CapturedMailSummary): string {
164
183
  return `${message.sentAt}|${message.from}|${message.to}|${message.subject}`
165
184
  }
166
185
 
167
- export async function listCapturedMail(directory = capturedMailDirectory()): Promise<CapturedMailSummary[]> {
186
+ /**
187
+ * List every capture the log mail driver has taken.
188
+ *
189
+ * One unreadable capture must not take down the listing. A stale or
190
+ * hand-edited file in the capture directory is an ordinary condition, and
191
+ * failing the whole request over it returned a 503 that hid every other
192
+ * captured email - on a real directory of 29 files, a single one written
193
+ * before the header format settled was enough to empty the inbox.
194
+ *
195
+ * Skipping it silently would be the other wrong answer, so the capture is
196
+ * reported in `problems` and the dashboard says what it could not read.
197
+ * Nothing is fabricated for it, which is what the strict parse was for.
198
+ *
199
+ * `showCapturedMail` still throws for a capture the caller asked for by id:
200
+ * there the broken file IS the answer, so the reason belongs in the response
201
+ * rather than behind an empty result.
202
+ */
203
+ export async function listCapturedMail(directory = capturedMailDirectory()): Promise<CapturedMailListing> {
168
204
  let names: string[]
169
205
  try {
170
206
  names = await readdir(directory)
@@ -176,12 +212,33 @@ export async function listCapturedMail(directory = capturedMailDirectory()): Pro
176
212
  throw error
177
213
  }
178
214
 
215
+ const problems: CapturedMailProblem[] = []
216
+
179
217
  const disk = await Promise.all(
180
- names.filter(name => name.endsWith('.html')).map(name => diskMessage(name, directory)),
218
+ names.filter(name => name.endsWith('.html')).map(async (name) => {
219
+ try {
220
+ return await diskMessage(name, directory)
221
+ }
222
+ catch (error) {
223
+ problems.push({ capture: name, reason: reasonFor(error) })
224
+ return null
225
+ }
226
+ }),
181
227
  )
228
+
229
+ const memory: CapturedMailMessage[] = []
230
+ LogEmailDriver.captured().forEach((email, index) => {
231
+ try {
232
+ memory.push(memoryMessage(email, index))
233
+ }
234
+ catch (error) {
235
+ problems.push({ capture: `mem:${index}`, reason: reasonFor(error) })
236
+ }
237
+ })
238
+
182
239
  const seen = new Set<string>()
183
240
  const messages: CapturedMailSummary[] = []
184
- for (const message of [...disk.filter((entry): entry is CapturedMailMessage => entry !== null), ...memoryMessages()]) {
241
+ for (const message of [...disk.filter((entry): entry is CapturedMailMessage => entry !== null), ...memory]) {
185
242
  const key = dedupeKey(message)
186
243
  if (seen.has(key))
187
244
  continue
@@ -202,9 +259,14 @@ export async function listCapturedMail(directory = capturedMailDirectory()): Pro
202
259
  })
203
260
  }
204
261
 
205
- return messages.sort((left, right) =>
206
- new Date(right.sentAt).getTime() - new Date(left.sentAt).getTime(),
207
- )
262
+ return {
263
+ messages: messages.sort((left, right) =>
264
+ new Date(right.sentAt).getTime() - new Date(left.sentAt).getTime(),
265
+ ),
266
+ // Settled in filename order rather than whichever read rejected first, so
267
+ // the same directory always produces the same response.
268
+ problems: problems.sort((left, right) => left.capture.localeCompare(right.capture)),
269
+ }
208
270
  }
209
271
 
210
272
  export async function showCapturedMail(id: string, directory = capturedMailDirectory()): Promise<CapturedMailMessage | null> {
@@ -1,16 +1,11 @@
1
1
  import { Action } from '@stacksjs/actions'
2
+ import { resolveCommands } from '@stacksjs/cli'
2
3
  import { existsSync, readFileSync, statSync } from 'node:fs'
3
4
  import { join, relative } from 'node:path'
4
5
  import process from 'node:process'
5
6
  import { parseCommandSource } from '../Source/source-inventory'
6
7
  import { dashboardOperationalError } from '../dashboard-response'
7
8
 
8
- interface CommandConfig {
9
- file: string
10
- enabled?: boolean
11
- aliases?: string[]
12
- }
13
-
14
9
  export default new Action({
15
10
  name: 'CommandIndexAction',
16
11
  description: 'Lists commands registered by the application.',
@@ -18,23 +13,27 @@ export default new Action({
18
13
  async handle() {
19
14
  try {
20
15
  const projectRoot = process.cwd()
21
- const registryPath = join(projectRoot, 'app/Commands.ts')
22
- const registryModule = await import(registryPath)
23
- const registry = (registryModule.default || {}) as Record<string, string | CommandConfig>
24
- const items = Object.entries(registry).flatMap(([signature, value]) => {
25
- const config = typeof value === 'string'
26
- ? { file: value, enabled: true, aliases: [] }
27
- : { enabled: true, aliases: [], ...value }
28
- const file = join(projectRoot, 'app/Commands', `${config.file}.ts`)
29
- if (!existsSync(file))
16
+ const commandsDir = join(projectRoot, 'app/Commands')
17
+
18
+ // Every file under app/Commands is a command; app/Commands.ts is an
19
+ // optional overlay. Listing only registry entries used to hide any
20
+ // command the project never bothered to register - which, now that
21
+ // registering is unnecessary, would have been most of them.
22
+ const commands = await resolveCommands({
23
+ commandsDir,
24
+ registryPath: join(projectRoot, 'app/Commands.ts'),
25
+ })
26
+
27
+ const items = commands.flatMap((command) => {
28
+ if (!existsSync(command.path))
30
29
  return []
31
30
 
32
31
  return [parseCommandSource(
33
- readFileSync(file, 'utf8'),
34
- relative(projectRoot, file),
35
- signature,
36
- config.aliases,
37
- statSync(file).mtime.toISOString(),
32
+ readFileSync(command.path, 'utf8'),
33
+ relative(projectRoot, command.path),
34
+ command.signature ?? command.file,
35
+ command.aliases,
36
+ statSync(command.path).mtime.toISOString(),
38
37
  )]
39
38
  })
40
39
 
@@ -44,7 +43,7 @@ export default new Action({
44
43
  total: items.length,
45
44
  aliases: items.reduce((sum, item) => sum + (item.aliases?.length || 0), 0),
46
45
  options: items.reduce((sum, item) => sum + (item.options?.length || 0), 0),
47
- registered: Object.keys(registry).length,
46
+ registered: commands.filter(command => command.source === 'registry').length,
48
47
  },
49
48
  }
50
49
  }
@@ -1,16 +1,17 @@
1
- import type { CLI } from '@stacksjs/types'
2
1
  // triggered via `$your-cli inspire` and `buddy inspire`
3
2
  import process from 'node:process'
4
- import { log, quotes } from '@stacksjs/cli'
3
+ import { defineCommand, log, quotes } from '@stacksjs/cli'
5
4
  import { ExitCode } from '@stacksjs/types'
6
5
 
7
- // for enhanced type-safety & autocompletion,
8
- // you may want to define the options' interface
6
+ // Every file in this directory is a command - there is nothing to register.
7
+ // `defineCommand` types the CLI it hands you; the declarative form
8
+ // (`defineCommand({ name, options, handle })`) additionally infers the
9
+ // handler's options from the flags it declares.
9
10
  interface InspireOptions {
10
11
  two: boolean
11
12
  }
12
13
 
13
- export default function (cli: CLI) {
14
+ export default defineCommand((cli) => {
14
15
  cli
15
16
  .command('inspire', 'Inspire yourself with a random quote')
16
17
  .option('--two, -t', 'Inspire yourself with two random quotes', {
@@ -53,5 +54,5 @@ export default function (cli: CLI) {
53
54
  process.exit(1)
54
55
  })
55
56
 
56
- return cli // TODO: this may not be needed
57
- }
57
+ return cli
58
+ })
@@ -7,60 +7,71 @@ Stacks allows you to easily create & manage CLIs. This is done through the use o
7
7
 
8
8
  ## Get Started
9
9
 
10
- The following command will bootstrap a new action file in the `app/Commands` directory.
10
+ The following command will bootstrap a new command file in the `app/Commands` directory.
11
11
 
12
12
  ```sh
13
13
  buddy make:command SendEmails
14
14
  ```
15
15
 
16
- Because commands are automatically registered, you can use them in your CLI immediately.
17
-
18
- ```sh
16
+ Every `.ts` file in this directory is a command. There is no registration step and no generated
17
+ registry file - drop the file in and it is live, nested directories included
18
+ (`app/Commands/Archive/Run.ts`).
19
19
 
20
20
  ### Example
21
21
 
22
- A simple example of a command that prints a random quote to the console. _For a closer look, take a peak at the [Inspire.ts](./Inspire.ts) command._
22
+ `defineCommand()` infers the handler's `options` from the flags declared above it, so there is no
23
+ hand-written options interface to keep in step.
24
+
25
+ ```ts
26
+ import { defineCommand, log } from '@stacksjs/cli'
27
+
28
+ export default defineCommand({
29
+ name: 'send-emails <type>',
30
+ description: 'Send the queued emails of one type',
31
+ aliases: ['emails'],
32
+ options: {
33
+ '--dry-run': { description: 'Report what would be sent, send nothing', default: false },
34
+ '--limit <n>': { description: 'Stop after this many', default: 100, type: [Number] },
35
+ },
36
+ async handle(options, type) {
37
+ // options.dryRun -> boolean, options.limit -> number, type -> string
38
+ log.info(`Sending ${options.limit} ${type} emails${options.dryRun ? ' (dry run)' : ''}`)
39
+ },
40
+ })
41
+ ```
42
+
43
+ For a file that registers several commands, or needs `cli.on()`, take the CLI directly - it is typed
44
+ either way:
45
+
46
+ ```ts
47
+ import { defineCommand, log } from '@stacksjs/cli'
48
+
49
+ export default defineCommand((cli) => {
50
+ cli.command('inspire', 'Inspire yourself with a random quote').alias('insp').action(() => {})
51
+ cli.command('inspire:two', 'Inspire yourself with two random quotes').action(() => {})
52
+
53
+ cli.on('inspire:*', () => log.error('Invalid command.'))
54
+ })
55
+ ```
56
+
57
+ ### Configuring a command
58
+
59
+ Named exports beside the default one, so a command owns its own configuration:
23
60
 
24
61
  ```ts
62
+ export const aliases = ['emails', 'mail'] // extra aliases
63
+ export const enabled = false // keep the file, hide the command
64
+ ```
25
65
 
26
- interface InspireOptions {
27
- two: boolean
28
- }
29
-
30
- export default function (cli: CLI) {
31
- cli
32
- .command('inspire', 'Inspire yourself with a random quote')
33
- .option('--two, -t', 'Inspire yourself with two random quotes', { default: false })
34
- .alias('insp')
35
- .action((options: InspireOptions) => {
36
- if (options.two)
37
- // @ts-expect-error - this is safe because we hard-coded the quotes
38
- quotes.random(2).map((quote, index) => log.info(`${index + 1}. ${quote}`))
39
- else
40
- log.info(quotes.random())
41
-
42
- log.success('Have a great day!')
43
- process.exit(ExitCode.Success)
44
- })
45
-
46
- cli
47
- .command('inspire:two', 'Inspire yourself with two random quotes')
48
- .action(() => {
49
- // @ts-expect-error - this is safe because we hard-coded the quotes
50
- quotes.random(2).map((quote, index) => log.info(`${index + 1}. ${quote}`))
51
-
52
- log.success('Have a great day!')
53
- process.exit(ExitCode.Success)
54
- })
55
-
56
- cli.on('inspire:*', () => {
57
- log.error('Invalid command: %s\nSee --help for a list of available commands.', cli.args.join(' '))
58
- process.exit(1)
59
- })
60
-
61
- return cli
62
- }
66
+ `app/Commands.ts` is optional. Keep one only to control the order commands are listed in, or to
67
+ alias or disable a command without editing its file - a command it never mentions still loads.
68
+
69
+ ```ts
70
+ import { defineCommands } from '@stacksjs/cli'
63
71
 
72
+ export default defineCommands({
73
+ 'send-emails <type>': { file: 'SendEmails', aliases: ['emails'] },
74
+ })
64
75
  ```
65
76
 
66
77
  ## 🚜 Contributing
@@ -20,11 +20,20 @@ export interface CapturedMailMessage extends CapturedMailSummary {
20
20
  text: string
21
21
  }
22
22
 
23
+ export interface CapturedMailProblem {
24
+ /** The capture that could not be read: a disk filename, or `mem:<index>`. */
25
+ capture: string
26
+ reason: string
27
+ }
28
+
23
29
  export interface CapturedMailIndex {
24
30
  captureDriver: 'log'
25
31
  activeDriver: string
26
32
  total: number
27
33
  messages: CapturedMailSummary[]
34
+ /** How many captures were skipped because they could not be parsed. */
35
+ unreadable: number
36
+ problems: CapturedMailProblem[]
28
37
  }
29
38
 
30
39
  export async function fetchCapturedMail(): Promise<CapturedMailIndex> {
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.72.77",
5
+ "version": "0.72.79",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.77",
5
+ "version": "0.72.79",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@iconify-json/f7": "^1.2.2",
53
53
  "@iconify-json/hugeicons": "^1.2.27",
54
- "@stacksjs/mobile": "^0.72.77",
54
+ "@stacksjs/mobile": "^0.72.79",
55
55
  "@stacksjs/sanitizer": "^0.2.113"
56
56
  },
57
57
  "scripts": {
@@ -1,5 +1,5 @@
1
1
  <script client>
2
- import type { CapturedMailMessage, CapturedMailSummary } from '../../../../functions/captured-mail'
2
+ import type { CapturedMailMessage, CapturedMailProblem, CapturedMailSummary } from '../../../../functions/captured-mail'
3
3
  import { fetchCapturedMail, fetchCapturedMailMessage } from '../../../../functions/captured-mail'
4
4
  import { pushToast } from '../../../../functions/toasts'
5
5
 
@@ -7,12 +7,22 @@ const messages = state<CapturedMailSummary[]>([])
7
7
  const selectedId = state('')
8
8
  const selectedMessage = state<CapturedMailMessage | null>(null)
9
9
  const activeDriver = state('')
10
+ const unreadable = state<CapturedMailProblem[]>([])
10
11
  const loading = state(true)
11
12
  const detailLoading = state(false)
12
13
  const error = state('')
13
14
  const detailError = state('')
14
15
  let selectionVersion = 0
15
16
 
17
+ // The thrown reason already names the file, and the row prints it in mono
18
+ // right before this, so the prefix would read the filename out twice.
19
+ function problemReason(problem: CapturedMailProblem): string {
20
+ const prefix = `Captured mail file ${problem.capture} is `
21
+ return problem.reason.startsWith(prefix)
22
+ ? problem.reason.slice(prefix.length)
23
+ : problem.reason
24
+ }
25
+
16
26
  function eventValue<T>(value: T | CustomEvent<T>): T {
17
27
  return value instanceof CustomEvent ? value.detail : value
18
28
  }
@@ -50,6 +60,9 @@ async function loadCapturedMail(): Promise<void> {
50
60
  const result = await fetchCapturedMail()
51
61
  messages.set(result.messages)
52
62
  activeDriver.set(result.activeDriver)
63
+ // A capture the server could not parse is skipped rather than failing the
64
+ // whole listing, so it has to be said out loud here or it disappears.
65
+ unreadable.set(result.problems ?? [])
53
66
 
54
67
  const current = result.messages.find(message => message.id === selectedId())
55
68
  const next = current || result.messages[0]
@@ -67,6 +80,7 @@ async function loadCapturedMail(): Promise<void> {
67
80
  const message = cause instanceof Error ? cause.message : 'Captured mail could not be loaded.'
68
81
  error.set(message)
69
82
  messages.set([])
83
+ unreadable.set([])
70
84
  selectedId.set('')
71
85
  selectedMessage.set(null)
72
86
  pushToast('error', 'Could not load captured mail', { detail: message })
@@ -92,6 +106,19 @@ onMount(() => {
92
106
  <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">
93
107
  Outbound messages recorded by the local log driver. Active mailer: {{ activeDriver() || 'log' }}.
94
108
  </p>
109
+ <div :if="unreadable().length > 0" class="mt-2 flex gap-2 items-start px-3 py-2 bg-amber-50 border border-amber-200 rounded-md text-amber-800 text-xs dark:bg-amber-900/20 dark:border-amber-800/40 dark:text-amber-200">
110
+ <span class="mt-0.5 h-3.5 w-3.5 shrink-0 i-hugeicons-alert-02"></span>
111
+ <div>
112
+ <p class="font-medium">
113
+ {{ unreadable().length }} capture{{ unreadable().length === 1 ? '' : 's' }} could not be read and {{ unreadable().length === 1 ? 'was' : 'were' }} left out of this list.
114
+ </p>
115
+ <ul class="mt-1 space-y-0.5">
116
+ <template :for="problem in unreadable()">
117
+ <li class="break-all"><span class="font-mono">{{ problem.capture }}</span> is {{ problemReason(problem) }}</li>
118
+ </template>
119
+ </ul>
120
+ </div>
121
+ </div>
95
122
  </div>
96
123
  <Button variant="secondary" :loading="loading()" @click="loadCapturedMail">
97
124
  <span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
package/app/Commands.ts DELETED
@@ -1,38 +0,0 @@
1
- export interface CommandConfig {
2
- /** The command file name (without .ts extension) */
3
- file: string
4
- /** Whether the command is enabled */
5
- enabled?: boolean
6
- /** Command aliases */
7
- aliases?: string[]
8
- }
9
-
10
- export type CommandRegistry = Record<string, string | CommandConfig>
11
-
12
- /**
13
- * The application's command registry.
14
- *
15
- * Commands listed here will be auto-loaded by the CLI.
16
- * You can use a simple string (file name) or a config object for more control.
17
- *
18
- * @example
19
- * // Simple registration
20
- * 'inspire': 'Inspire',
21
- *
22
- * // With config
23
- * 'send-emails': {
24
- * file: 'SendEmails',
25
- * enabled: true,
26
- * aliases: ['emails', 'mail'],
27
- * },
28
- */
29
- export default {
30
- 'inspire': 'Inspire',
31
- // Add more commands here
32
- // 'my-command': 'MyCommand',
33
- // 'send-emails': {
34
- // file: 'SendEmails',
35
- // enabled: true,
36
- // aliases: ['emails'],
37
- // },
38
- } satisfies CommandRegistry