@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.
- package/ai/skills/stacks-buddy/SKILL.md +52 -23
- package/ai/skills/stacks-cli/SKILL.md +73 -21
- package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +7 -1
- package/app/Actions/Dashboard/Email/captured-mail.test.ts +54 -4
- package/app/Actions/Dashboard/Email/captured-mail.ts +102 -40
- package/app/Actions/Dashboard/Infrastructure/CommandIndexAction.ts +20 -21
- package/app/Commands/Inspire.ts +8 -7
- package/app/Commands/README.md +53 -42
- package/functions/captured-mail.ts +9 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Email/CapturedMailDashboard.stx +28 -1
- package/app/Commands.ts +0 -38
|
@@ -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
|
-
-
|
|
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
|
-
--
|
|
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
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
//
|
|
638
|
-
import
|
|
651
|
+
// Imperative, for a file that registers several commands or uses cli.on()
|
|
652
|
+
import { defineCommand } from '@stacksjs/cli'
|
|
639
653
|
|
|
640
|
-
export default
|
|
641
|
-
buddy
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
652
|
-
|
|
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
|
-
-
|
|
17
|
+
- Optional registry: `app/Commands.ts` (not needed - see below)
|
|
18
18
|
|
|
19
19
|
## Creating Commands
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
32
|
+
import { defineCommand, log } from '@stacksjs/cli'
|
|
33
|
+
|
|
34
|
+
export default defineCommand({
|
|
35
|
+
name: 'greet <who>',
|
|
26
36
|
description: 'Greet a user',
|
|
27
|
-
|
|
37
|
+
aliases: ['g'],
|
|
28
38
|
options: {
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
###
|
|
48
|
-
1. **app/Commands
|
|
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.
|
|
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`
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
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), ...
|
|
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
|
|
206
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
34
|
-
relative(projectRoot,
|
|
35
|
-
signature,
|
|
36
|
-
|
|
37
|
-
statSync(
|
|
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:
|
|
46
|
+
registered: commands.filter(command => command.source === 'registry').length,
|
|
48
47
|
},
|
|
49
48
|
}
|
|
50
49
|
}
|
package/app/Commands/Inspire.ts
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
|
57
|
-
}
|
|
57
|
+
return cli
|
|
58
|
+
})
|
package/app/Commands/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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> {
|
package/ide/vscode/package.json
CHANGED
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.
|
|
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.
|
|
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
|