@raidiant/notifai 7.0.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands-devices.d.ts +2 -1
  3. package/dist/commands-devices.js +20 -8
  4. package/dist/commands-devices.js.map +1 -1
  5. package/dist/commands-doctor.js +14 -12
  6. package/dist/commands-doctor.js.map +1 -1
  7. package/dist/commands-guidance.d.ts +23 -0
  8. package/dist/commands-guidance.js +128 -0
  9. package/dist/commands-guidance.js.map +1 -0
  10. package/dist/commands-hooks.d.ts +7 -1
  11. package/dist/commands-hooks.js +3 -6
  12. package/dist/commands-hooks.js.map +1 -1
  13. package/dist/commands-init.js +25 -22
  14. package/dist/commands-init.js.map +1 -1
  15. package/dist/commands-send-support.js +5 -3
  16. package/dist/commands-send-support.js.map +1 -1
  17. package/dist/commands-send.d.ts +0 -1
  18. package/dist/commands-send.js +45 -28
  19. package/dist/commands-send.js.map +1 -1
  20. package/dist/commands.d.ts +1 -0
  21. package/dist/commands.js +1 -0
  22. package/dist/commands.js.map +1 -1
  23. package/dist/config-schema.js +6 -15
  24. package/dist/config-schema.js.map +1 -1
  25. package/dist/config.d.ts +1 -3
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/guidance-content.d.ts +30 -0
  29. package/dist/guidance-content.js +175 -0
  30. package/dist/guidance-content.js.map +1 -0
  31. package/dist/guidance.d.ts +48 -0
  32. package/dist/guidance.js +118 -0
  33. package/dist/guidance.js.map +1 -0
  34. package/dist/hooks.js +7 -7
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/interactive.d.ts +1 -1
  37. package/dist/interactive.js +13 -8
  38. package/dist/interactive.js.map +1 -1
  39. package/dist/main.js +3 -474
  40. package/dist/main.js.map +1 -1
  41. package/dist/program.d.ts +49 -0
  42. package/dist/program.js +571 -0
  43. package/dist/program.js.map +1 -0
  44. package/dist/send.d.ts +4 -9
  45. package/dist/send.js +48 -28
  46. package/dist/send.js.map +1 -1
  47. package/dist/session-labels.d.ts +9 -3
  48. package/dist/session-labels.js +38 -7
  49. package/dist/session-labels.js.map +1 -1
  50. package/package.json +5 -3
  51. package/src/commands-devices.ts +28 -9
  52. package/src/commands-doctor.ts +17 -12
  53. package/src/commands-guidance.ts +170 -0
  54. package/src/commands-hooks.ts +10 -8
  55. package/src/commands-init.ts +31 -22
  56. package/src/commands-send-support.ts +5 -3
  57. package/src/commands-send.ts +50 -28
  58. package/src/commands.test.ts +374 -63
  59. package/src/commands.ts +5 -0
  60. package/src/config-schema.ts +6 -17
  61. package/src/config.test.ts +56 -1
  62. package/src/config.ts +0 -5
  63. package/src/guidance-content.ts +190 -0
  64. package/src/guidance.test.ts +268 -0
  65. package/src/guidance.ts +151 -0
  66. package/src/hooks.test.ts +37 -5
  67. package/src/hooks.ts +7 -7
  68. package/src/interactive.ts +13 -8
  69. package/src/main.ts +4 -600
  70. package/src/program.test.ts +202 -0
  71. package/src/program.ts +723 -0
  72. package/src/send.ts +53 -32
  73. package/src/session-labels.test.ts +120 -3
  74. package/src/session-labels.ts +38 -7
  75. package/src/skill.test.ts +37 -12
@@ -0,0 +1,202 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { buildProgram, type ProgramRunners } from './program.js'
6
+ import type { CommandDeps } from './commands.js'
7
+
8
+ /**
9
+ * The layer these tests own is the argv boundary: what a real command line
10
+ * turns into by the time a command implementation receives it. Collector
11
+ * defaults, `--no-wait` disentangling, `--body-file` reading — none of that is
12
+ * visible to the unit tests on the commands themselves, and it used to be
13
+ * covered by nothing but reading `program.ts` with human eyes.
14
+ */
15
+
16
+ class ExitSentinel extends Error {
17
+ constructor(readonly code: number) {
18
+ super(`exit ${code}`)
19
+ }
20
+ }
21
+
22
+ function makeDeps(): CommandDeps & { outLines: string[]; errLines: string[] } {
23
+ const outLines: string[] = []
24
+ const errLines: string[] = []
25
+ return {
26
+ io: {
27
+ out: (line: string) => outLines.push(line),
28
+ err: (line: string) => errLines.push(line),
29
+ confirm: async () => false,
30
+ openUrl: () => {},
31
+ },
32
+ store: {
33
+ load: () => null,
34
+ save: () => {},
35
+ clear: () => {},
36
+ describe: () => 'test credential store',
37
+ },
38
+ env: {},
39
+ cwd: os.tmpdir(),
40
+ outLines,
41
+ errLines,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Parse a real command line and capture what would have been dispatched.
47
+ * `exit` throws so post-exit code cannot run — the same property
48
+ * `process.exit` gives production.
49
+ */
50
+ async function parse(
51
+ argv: string[],
52
+ runners: Partial<ProgramRunners>,
53
+ deps = makeDeps(),
54
+ ): Promise<{ exitCode: number | undefined; deps: ReturnType<typeof makeDeps> }> {
55
+ let exitCode: number | undefined
56
+ const program = buildProgram(deps, {
57
+ runners,
58
+ exit: (code: number) => {
59
+ exitCode = code
60
+ throw new ExitSentinel(code)
61
+ },
62
+ })
63
+ try {
64
+ await program.parseAsync(['node', 'notifai', ...argv])
65
+ } catch (err) {
66
+ if (!(err instanceof ExitSentinel)) throw err
67
+ }
68
+ return { exitCode, deps }
69
+ }
70
+
71
+ describe('program argv parsing', () => {
72
+ it('maps a full send command line onto sendCommand flags', async () => {
73
+ let seen: Record<string, unknown> | undefined
74
+ const { exitCode } = await parse(
75
+ [
76
+ 'send',
77
+ '--kind', 'done',
78
+ '--title', 'Build finished',
79
+ '--body', 'All green.',
80
+ '--choice', 'Ship it',
81
+ '--choice', 'Hold',
82
+ '--multi',
83
+ '--reply',
84
+ '--reply-timeout', '30',
85
+ '--event', 'tests_passed',
86
+ ],
87
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
88
+ )
89
+ expect(exitCode).toBe(0)
90
+ expect(seen).toMatchObject({
91
+ kind: 'done',
92
+ title: 'Build finished',
93
+ body: 'All green.',
94
+ choice: ['Ship it', 'Hold'],
95
+ multi: true,
96
+ reply: true,
97
+ replyTimeout: 30,
98
+ event: 'tests_passed',
99
+ noWait: false,
100
+ })
101
+ })
102
+
103
+ it('drops empty collector defaults so "not passed" stays absent', async () => {
104
+ let seen: Record<string, unknown> | undefined
105
+ await parse(
106
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B'],
107
+ { send: (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send'] },
108
+ )
109
+ expect(seen).toBeDefined()
110
+ expect(seen).not.toHaveProperty('choice')
111
+ expect(seen).not.toHaveProperty('image')
112
+ expect(seen).not.toHaveProperty('imageAlt')
113
+ expect(seen).not.toHaveProperty('wait')
114
+ })
115
+
116
+ it('disentangles --no-wait and --wait from their shared commander flag', async () => {
117
+ const capture = (sink: Record<string, unknown>[]): ProgramRunners['send'] =>
118
+ (async (_deps, flags) => (sink.push(flags as Record<string, unknown>), 0)) as ProgramRunners['send']
119
+ const seen: Record<string, unknown>[] = []
120
+ await parse(['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--no-wait'], { send: capture(seen) })
121
+ await parse(['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--wait', '5'], { send: capture(seen) })
122
+ expect(seen[0]).toMatchObject({ noWait: true })
123
+ expect(seen[0]).not.toHaveProperty('wait')
124
+ expect(seen[1]).toMatchObject({ noWait: false, wait: 5 })
125
+ })
126
+
127
+ it('reads --body-file and refuses it alongside --body', async () => {
128
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-program-test-'))
129
+ const file = path.join(dir, 'body.md')
130
+ writeFileSync(file, 'From a file.\n')
131
+ let seen: Record<string, unknown> | undefined
132
+ const send = (async (_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['send']
133
+
134
+ const ok = await parse(['send', '--kind', 'update', '--title', 'T', '--body-file', file], { send })
135
+ expect(ok.exitCode).toBe(0)
136
+ expect(seen).toMatchObject({ body: 'From a file.\n' })
137
+ expect(seen).not.toHaveProperty('bodyFile')
138
+
139
+ const both = await parse(
140
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--body-file', file],
141
+ { send },
142
+ )
143
+ expect(both.exitCode).toBe(2)
144
+ expect(both.deps.errLines.join('\n')).toContain('either --body or --body-file')
145
+
146
+ const missing = await parse(
147
+ ['send', '--kind', 'update', '--title', 'T', '--body-file', path.join(dir, 'absent.md')],
148
+ { send },
149
+ )
150
+ expect(missing.exitCode).toBe(2)
151
+ expect(missing.deps.errLines.join('\n')).toContain('Could not read')
152
+ })
153
+
154
+ it('builds ask flags without empty collectors and passes the question through', async () => {
155
+ let seenQuestion: string | undefined
156
+ let seenFlags: Record<string, unknown> | undefined
157
+ const ask = (async (_deps, question, flags) => {
158
+ seenQuestion = question
159
+ seenFlags = flags as Record<string, unknown>
160
+ return 0
161
+ }) as ProgramRunners['ask']
162
+
163
+ await parse(
164
+ ['ask', 'Deploy where?', '--choice', 'Staging', '--choice', 'Production', '--session-label', 'release run', '--json'],
165
+ { ask },
166
+ )
167
+ expect(seenQuestion).toBe('Deploy where?')
168
+ expect(seenFlags).toEqual({
169
+ choice: ['Staging', 'Production'],
170
+ sessionLabel: 'release run',
171
+ json: true,
172
+ })
173
+
174
+ await parse(['ask', 'Free text?'], { ask })
175
+ expect(seenFlags).toEqual({})
176
+ })
177
+
178
+ it('rejects --no-block as unknown: the flag is gone, not tombstoned', async () => {
179
+ const { exitCode, deps } = await parse(
180
+ ['send', '--kind', 'update', '--title', 'T', '--body', 'B', '--no-block'],
181
+ { send: (async () => 0) as ProgramRunners['send'] },
182
+ )
183
+ expect(exitCode).toBe(2)
184
+ expect(deps.errLines).toHaveLength(0)
185
+ })
186
+
187
+ it('names an unknown command and exits 2', async () => {
188
+ const { exitCode, deps } = await parse(['sendd'], {})
189
+ expect(exitCode).toBe(2)
190
+ expect(deps.errLines.join('\n')).toContain('Unknown command "sendd"')
191
+ expect(deps.errLines.join('\n')).toContain('send')
192
+ })
193
+
194
+ it('drops the empty --event collector for logs', async () => {
195
+ let seen: Record<string, unknown> | undefined
196
+ const logs = ((_deps, flags) => ((seen = flags as Record<string, unknown>), 0)) as ProgramRunners['logs']
197
+ await parse(['logs'], { logs })
198
+ expect(seen).not.toHaveProperty('event')
199
+ await parse(['logs', '--event', 'send.submitted'], { logs })
200
+ expect(seen).toMatchObject({ event: ['send.submitted'] })
201
+ })
202
+ })