@raidiant/notifai 0.1.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.
- package/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import type {
|
|
5
|
+
CapabilityDocument,
|
|
6
|
+
ListRepliesResponse,
|
|
7
|
+
ReplyView,
|
|
8
|
+
SubmissionReceipt,
|
|
9
|
+
SubmitNotificationRequestT,
|
|
10
|
+
} from '@raidiant/notifai-protocol'
|
|
11
|
+
import { describe, expect, it } from 'vitest'
|
|
12
|
+
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
13
|
+
import {
|
|
14
|
+
askCommand,
|
|
15
|
+
capabilitiesCommand,
|
|
16
|
+
configSetCommand,
|
|
17
|
+
contradictingAnswer,
|
|
18
|
+
describeHookFailure,
|
|
19
|
+
doctorCommand,
|
|
20
|
+
EXIT,
|
|
21
|
+
hooksInstallCommand,
|
|
22
|
+
hooksUninstallCommand,
|
|
23
|
+
initCommand,
|
|
24
|
+
loginCommand,
|
|
25
|
+
projectSlugFrom,
|
|
26
|
+
repliesCommand,
|
|
27
|
+
sendCommand,
|
|
28
|
+
type CommandDeps,
|
|
29
|
+
type CommandIo,
|
|
30
|
+
type CommandSpinner,
|
|
31
|
+
} from './commands.js'
|
|
32
|
+
import { applyPlan, buildHookConfig } from './install-hooks.js'
|
|
33
|
+
|
|
34
|
+
class CapturedIo implements CommandIo {
|
|
35
|
+
outLines: string[] = []
|
|
36
|
+
errLines: string[] = []
|
|
37
|
+
|
|
38
|
+
out(line: string) {
|
|
39
|
+
this.outLines.push(line)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
err(line: string) {
|
|
43
|
+
this.errLines.push(line)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async confirm() {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
openUrl() {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class InteractiveIo extends CapturedIo {
|
|
54
|
+
interactive = true
|
|
55
|
+
selectAnswer: string | null = 'global'
|
|
56
|
+
confirmAnswer = true
|
|
57
|
+
prompts: string[] = []
|
|
58
|
+
notes: { message: string; title?: string }[] = []
|
|
59
|
+
intros: string[] = []
|
|
60
|
+
outros: string[] = []
|
|
61
|
+
spinnerEvents: string[] = []
|
|
62
|
+
checks: { ok: boolean; message: string }[] = []
|
|
63
|
+
|
|
64
|
+
override async confirm(question: string) {
|
|
65
|
+
this.prompts.push(question)
|
|
66
|
+
return this.confirmAnswer
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async select(
|
|
70
|
+
message: string,
|
|
71
|
+
_options: { value: string; label: string; hint?: string }[],
|
|
72
|
+
): Promise<string | null> {
|
|
73
|
+
this.prompts.push(message)
|
|
74
|
+
return this.selectAnswer
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async intro(title: string) {
|
|
78
|
+
this.intros.push(title)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async outro(message: string) {
|
|
82
|
+
this.outros.push(message)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async note(message: string, title?: string) {
|
|
86
|
+
this.notes.push({ message, ...(title === undefined ? {} : { title }) })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async spinner(message: string): Promise<CommandSpinner> {
|
|
90
|
+
this.spinnerEvents.push(`start:${message}`)
|
|
91
|
+
return {
|
|
92
|
+
message: (next) => this.spinnerEvents.push(`message:${next}`),
|
|
93
|
+
stop: (next) => this.spinnerEvents.push(`stop:${next}`),
|
|
94
|
+
error: (next) => this.spinnerEvents.push(`error:${next}`),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async check(ok: boolean, message: string) {
|
|
99
|
+
this.checks.push({ ok, message })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
104
|
+
return {
|
|
105
|
+
io,
|
|
106
|
+
store: {
|
|
107
|
+
load: () => ({
|
|
108
|
+
machineId: 'mac_test',
|
|
109
|
+
secret: 'test-secret',
|
|
110
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
111
|
+
machineName: 'test-machine',
|
|
112
|
+
}),
|
|
113
|
+
save: () => {},
|
|
114
|
+
clear: () => {},
|
|
115
|
+
describe: () => 'test credential store',
|
|
116
|
+
},
|
|
117
|
+
env: { XDG_CONFIG_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests') },
|
|
118
|
+
cwd: os.tmpdir(),
|
|
119
|
+
clientFactory: () => client,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const receipt: SubmissionReceipt = {
|
|
124
|
+
request_id: 'req_reply_test',
|
|
125
|
+
replayed: false,
|
|
126
|
+
overall: 'provider_accepted_all',
|
|
127
|
+
deliveries: [
|
|
128
|
+
{
|
|
129
|
+
delivery_id: 'del_reply_test',
|
|
130
|
+
device_id: 'dev_test',
|
|
131
|
+
device_name: 'iPhone',
|
|
132
|
+
state: 'provider_accepted',
|
|
133
|
+
attempts: 1,
|
|
134
|
+
provider_status: 200,
|
|
135
|
+
provider_reason: null,
|
|
136
|
+
provider_id: 'provider_test',
|
|
137
|
+
updated_at: '2026-08-01T18:00:00.000Z',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
warnings: [],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const reply: ReplyView = {
|
|
144
|
+
reply_id: 'rpl_test',
|
|
145
|
+
seq: 1,
|
|
146
|
+
delivery_id: 'del_reply_test',
|
|
147
|
+
device_id: 'dev_test',
|
|
148
|
+
device_name: 'iPhone',
|
|
149
|
+
text: 'yes, after the migration',
|
|
150
|
+
created_at: '2026-08-01T18:01:00.000Z',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
|
|
154
|
+
return {
|
|
155
|
+
request_id: receipt.request_id,
|
|
156
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
157
|
+
replies,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('command contracts', () => {
|
|
162
|
+
it('renders capability field paths instead of array indexes', async () => {
|
|
163
|
+
const io = new CapturedIo()
|
|
164
|
+
const document: CapabilityDocument = {
|
|
165
|
+
schema_version: 1,
|
|
166
|
+
platform: 'ios',
|
|
167
|
+
payload_limit_bytes: 4096,
|
|
168
|
+
sounds: ['default'],
|
|
169
|
+
interruption_levels: ['passive', 'active', 'time_sensitive'],
|
|
170
|
+
fields: [
|
|
171
|
+
{ path: 'presentation.title', status: 'supported' },
|
|
172
|
+
{ path: 'platform.ios.category', status: 'unsupported', reason: 'Deferred from V1.' },
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
const client = { capabilities: async () => document } as unknown as ApiClient
|
|
176
|
+
|
|
177
|
+
expect(await capabilitiesCommand(makeDeps(io, client), {})).toBe(EXIT.ok)
|
|
178
|
+
expect(io.outLines).toContain(' presentation.title: supported')
|
|
179
|
+
expect(io.outLines).toContain(' platform.ios.category: unsupported — Deferred from V1.')
|
|
180
|
+
expect(io.outLines.some((line) => line.startsWith(' 0:'))).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('passes the selected macOS platform through to the capability client', async () => {
|
|
184
|
+
const io = new CapturedIo()
|
|
185
|
+
let requestedPlatform: string | undefined
|
|
186
|
+
const document: CapabilityDocument = {
|
|
187
|
+
schema_version: 1,
|
|
188
|
+
platform: 'macos',
|
|
189
|
+
payload_limit_bytes: 4096,
|
|
190
|
+
sounds: ['default'],
|
|
191
|
+
interruption_levels: ['passive', 'active', 'time_sensitive'],
|
|
192
|
+
fields: [],
|
|
193
|
+
}
|
|
194
|
+
const client = {
|
|
195
|
+
capabilities: async (platform?: string) => {
|
|
196
|
+
requestedPlatform = platform
|
|
197
|
+
return document
|
|
198
|
+
},
|
|
199
|
+
} as unknown as ApiClient
|
|
200
|
+
|
|
201
|
+
expect(await capabilitiesCommand(makeDeps(io, client), { platform: 'macos' })).toBe(EXIT.ok)
|
|
202
|
+
expect(requestedPlatform).toBe('macos')
|
|
203
|
+
expect(io.outLines[0]).toBe('macos capability contract v1 (payload limit 4096 bytes)')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('rejects an invalid draft before calling submit', async () => {
|
|
207
|
+
const io = new CapturedIo()
|
|
208
|
+
let submitCalls = 0
|
|
209
|
+
const client = {
|
|
210
|
+
submit: async () => {
|
|
211
|
+
submitCalls += 1
|
|
212
|
+
throw new Error('submit should not be reached')
|
|
213
|
+
},
|
|
214
|
+
} as unknown as ApiClient
|
|
215
|
+
|
|
216
|
+
expect(await sendCommand(makeDeps(io, client), { title: 'T', body: 'B', badge: -1 })).toBe(EXIT.usage)
|
|
217
|
+
expect(submitCalls).toBe(0)
|
|
218
|
+
expect(io.errLines.join('\n')).toContain('platform.ios.badge')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('rejects a question nobody will wait for', async () => {
|
|
222
|
+
// --reply asks; --no-block declares nothing will wait. The answer would be
|
|
223
|
+
// captured server-side and then reachable only by hand, so the user taps a
|
|
224
|
+
// real button and nothing happens — worse than never asking, because it
|
|
225
|
+
// spends their attention and their trust in the channel.
|
|
226
|
+
const io = new CapturedIo()
|
|
227
|
+
let submitCalls = 0
|
|
228
|
+
const client = {
|
|
229
|
+
submit: async () => {
|
|
230
|
+
submitCalls += 1
|
|
231
|
+
return receipt
|
|
232
|
+
},
|
|
233
|
+
} as unknown as ApiClient
|
|
234
|
+
|
|
235
|
+
expect(
|
|
236
|
+
await sendCommand(makeDeps(io, client), {
|
|
237
|
+
title: 'Question',
|
|
238
|
+
body: 'Deploy?',
|
|
239
|
+
reply: true,
|
|
240
|
+
replyWindow: 3_600,
|
|
241
|
+
noBlock: true,
|
|
242
|
+
}),
|
|
243
|
+
).toBe(EXIT.usage)
|
|
244
|
+
expect(submitCalls).toBe(0)
|
|
245
|
+
expect(io.errLines.join('\n')).toContain('notifai ask')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('maps reply flags into the draft and waits for the answer', async () => {
|
|
249
|
+
const io = new CapturedIo()
|
|
250
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
251
|
+
const client = {
|
|
252
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
253
|
+
submitted = body
|
|
254
|
+
return receipt
|
|
255
|
+
},
|
|
256
|
+
replies: async () => replyResponse([reply]),
|
|
257
|
+
} as unknown as ApiClient
|
|
258
|
+
|
|
259
|
+
expect(
|
|
260
|
+
await sendCommand(makeDeps(io, client), {
|
|
261
|
+
title: 'Question',
|
|
262
|
+
body: 'Deploy?',
|
|
263
|
+
reply: true,
|
|
264
|
+
replyWindow: 3_600,
|
|
265
|
+
replyTimeout: 30,
|
|
266
|
+
}),
|
|
267
|
+
).toBe(EXIT.ok)
|
|
268
|
+
expect(submitted?.draft.reply).toEqual({ expires_in_seconds: 3_600 })
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('rejects --reply-timeout 0, the other spelling of nobody waiting', async () => {
|
|
272
|
+
const io = new CapturedIo()
|
|
273
|
+
let submitCalls = 0
|
|
274
|
+
const client = {
|
|
275
|
+
submit: async () => {
|
|
276
|
+
submitCalls += 1
|
|
277
|
+
return receipt
|
|
278
|
+
},
|
|
279
|
+
} as unknown as ApiClient
|
|
280
|
+
|
|
281
|
+
expect(
|
|
282
|
+
await sendCommand(makeDeps(io, client), {
|
|
283
|
+
title: 'Question',
|
|
284
|
+
body: 'Deploy?',
|
|
285
|
+
reply: true,
|
|
286
|
+
replyTimeout: 0,
|
|
287
|
+
}),
|
|
288
|
+
).toBe(EXIT.usage)
|
|
289
|
+
expect(submitCalls).toBe(0)
|
|
290
|
+
expect(io.errLines.join('\n')).toContain('notifai ask')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it.each([
|
|
294
|
+
{ title: 'Deploy? ', body: 'Ready.' },
|
|
295
|
+
{ title: 'Deployment', body: 'Should I deploy?\n' },
|
|
296
|
+
])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
|
|
297
|
+
const io = new CapturedIo()
|
|
298
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
299
|
+
|
|
300
|
+
expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
|
|
301
|
+
expect(io.errLines).toEqual([
|
|
302
|
+
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --reply-choice) so it can be answered from the notification.',
|
|
303
|
+
])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('suppresses the question warning when --reply is present', async () => {
|
|
307
|
+
const io = new CapturedIo()
|
|
308
|
+
const client = {
|
|
309
|
+
submit: async () => receipt,
|
|
310
|
+
replies: async () => replyResponse([reply]),
|
|
311
|
+
} as unknown as ApiClient
|
|
312
|
+
|
|
313
|
+
expect(
|
|
314
|
+
await sendCommand(makeDeps(io, client), {
|
|
315
|
+
title: 'Deploy?',
|
|
316
|
+
body: 'Choose when ready.',
|
|
317
|
+
reply: true,
|
|
318
|
+
replyTimeout: 30,
|
|
319
|
+
}),
|
|
320
|
+
).toBe(EXIT.ok)
|
|
321
|
+
expect(io.errLines).toEqual([])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('rejects --reply-choice without the --reply action it configures', async () => {
|
|
325
|
+
const io = new CapturedIo()
|
|
326
|
+
let submitCalls = 0
|
|
327
|
+
const client = {
|
|
328
|
+
submit: async () => {
|
|
329
|
+
submitCalls += 1
|
|
330
|
+
return receipt
|
|
331
|
+
},
|
|
332
|
+
} as unknown as ApiClient
|
|
333
|
+
|
|
334
|
+
expect(
|
|
335
|
+
await sendCommand(makeDeps(io, client), {
|
|
336
|
+
title: 'Deploy?',
|
|
337
|
+
body: 'Choose when ready.',
|
|
338
|
+
replyChoice: ['Now', 'Later'],
|
|
339
|
+
}),
|
|
340
|
+
).toBe(EXIT.usage)
|
|
341
|
+
expect(submitCalls).toBe(0)
|
|
342
|
+
expect(io.errLines).toEqual([
|
|
343
|
+
'Use --reply with --reply-timeout, --reply-window, --reply-choice, or --no-block.',
|
|
344
|
+
])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('keeps a warned JSON send successful and stdout machine-pure', async () => {
|
|
348
|
+
const io = new CapturedIo()
|
|
349
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
350
|
+
|
|
351
|
+
expect(
|
|
352
|
+
await sendCommand(makeDeps(io, client), {
|
|
353
|
+
title: 'Deployment',
|
|
354
|
+
body: 'Should I deploy?',
|
|
355
|
+
json: true,
|
|
356
|
+
}),
|
|
357
|
+
).toBe(EXIT.ok)
|
|
358
|
+
expect(io.outLines).toHaveLength(1)
|
|
359
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual(receipt)
|
|
360
|
+
expect(io.errLines).toHaveLength(1)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('loops in server-capped long polls until a reply arrives', async () => {
|
|
364
|
+
const io = new CapturedIo()
|
|
365
|
+
let now = 0
|
|
366
|
+
const polls: { waitSeconds: number; afterSeq: number }[] = []
|
|
367
|
+
const client = {
|
|
368
|
+
submit: async () => receipt,
|
|
369
|
+
replies: async (_requestId: string, options: { waitSeconds: number; afterSeq: number }) => {
|
|
370
|
+
polls.push(options)
|
|
371
|
+
now += options.waitSeconds * 1_000
|
|
372
|
+
return replyResponse(polls.length === 3 ? [reply] : [])
|
|
373
|
+
},
|
|
374
|
+
} as unknown as ApiClient
|
|
375
|
+
const deps = {
|
|
376
|
+
...makeDeps(io, client),
|
|
377
|
+
now: () => now,
|
|
378
|
+
sleep: async (milliseconds: number) => {
|
|
379
|
+
now += milliseconds
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
expect(
|
|
384
|
+
await sendCommand(deps, {
|
|
385
|
+
title: 'Question',
|
|
386
|
+
body: 'Deploy?',
|
|
387
|
+
reply: true,
|
|
388
|
+
replyTimeout: 60,
|
|
389
|
+
}),
|
|
390
|
+
).toBe(EXIT.ok)
|
|
391
|
+
expect(polls).toHaveLength(3)
|
|
392
|
+
expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
|
|
393
|
+
expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('backs off and retries a transient network error while waiting', async () => {
|
|
397
|
+
const io = new CapturedIo()
|
|
398
|
+
let now = 0
|
|
399
|
+
let replyCalls = 0
|
|
400
|
+
const sleeps: number[] = []
|
|
401
|
+
const client = {
|
|
402
|
+
submit: async () => receipt,
|
|
403
|
+
replies: async () => {
|
|
404
|
+
replyCalls += 1
|
|
405
|
+
if (replyCalls === 1) throw new NetworkError('temporary disconnect')
|
|
406
|
+
return replyResponse([reply])
|
|
407
|
+
},
|
|
408
|
+
} as unknown as ApiClient
|
|
409
|
+
const deps = {
|
|
410
|
+
...makeDeps(io, client),
|
|
411
|
+
now: () => now,
|
|
412
|
+
sleep: async (milliseconds: number) => {
|
|
413
|
+
sleeps.push(milliseconds)
|
|
414
|
+
now += milliseconds
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
expect(
|
|
419
|
+
await sendCommand(deps, {
|
|
420
|
+
title: 'Question',
|
|
421
|
+
body: 'Deploy?',
|
|
422
|
+
reply: true,
|
|
423
|
+
replyTimeout: 10,
|
|
424
|
+
}),
|
|
425
|
+
).toBe(EXIT.ok)
|
|
426
|
+
expect(replyCalls).toBe(2)
|
|
427
|
+
expect(sleeps).toEqual([250])
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('returns exit 3 with one JSON object when no reply arrives before the timeout', async () => {
|
|
431
|
+
const io = new CapturedIo()
|
|
432
|
+
let now = 0
|
|
433
|
+
const client = {
|
|
434
|
+
submit: async () => receipt,
|
|
435
|
+
replies: async (_requestId: string, options: { waitSeconds: number }) => {
|
|
436
|
+
now += options.waitSeconds * 1_000
|
|
437
|
+
return replyResponse()
|
|
438
|
+
},
|
|
439
|
+
} as unknown as ApiClient
|
|
440
|
+
const deps = { ...makeDeps(io, client), now: () => now, sleep: async () => {} }
|
|
441
|
+
|
|
442
|
+
expect(
|
|
443
|
+
await sendCommand(deps, {
|
|
444
|
+
title: 'Question',
|
|
445
|
+
body: 'Deploy?',
|
|
446
|
+
reply: true,
|
|
447
|
+
replyTimeout: 1,
|
|
448
|
+
json: true,
|
|
449
|
+
}),
|
|
450
|
+
).toBe(EXIT.noReply)
|
|
451
|
+
expect(io.outLines).toHaveLength(1)
|
|
452
|
+
// `degraded` is part of the shape on every reply wait, not only when it is
|
|
453
|
+
// true: an agent must be able to read it without knowing it might be absent.
|
|
454
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ receipt, replies: [], degraded: false })
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('prints the stable send JSON shape when a reply is received', async () => {
|
|
458
|
+
const io = new CapturedIo()
|
|
459
|
+
const client = {
|
|
460
|
+
submit: async () => receipt,
|
|
461
|
+
replies: async () => replyResponse([reply]),
|
|
462
|
+
} as unknown as ApiClient
|
|
463
|
+
|
|
464
|
+
expect(
|
|
465
|
+
await sendCommand(makeDeps(io, client), {
|
|
466
|
+
title: 'Question',
|
|
467
|
+
body: 'Deploy?',
|
|
468
|
+
reply: true,
|
|
469
|
+
replyTimeout: 10,
|
|
470
|
+
json: true,
|
|
471
|
+
}),
|
|
472
|
+
).toBe(EXIT.ok)
|
|
473
|
+
expect(io.outLines).toHaveLength(1)
|
|
474
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
475
|
+
receipt,
|
|
476
|
+
replies: [reply],
|
|
477
|
+
degraded: false,
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
482
|
+
const io = new CapturedIo()
|
|
483
|
+
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
484
|
+
const client = {
|
|
485
|
+
replies: async (_requestId: string, options: { waitSeconds: number; afterSeq: number }) => {
|
|
486
|
+
requested = options
|
|
487
|
+
return replyResponse([reply])
|
|
488
|
+
},
|
|
489
|
+
} as unknown as ApiClient
|
|
490
|
+
|
|
491
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, { after: 7 })).toBe(EXIT.ok)
|
|
492
|
+
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
493
|
+
expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
describe('Cursor hook commands', () => {
|
|
498
|
+
const execPath = '/usr/local/bin/node'
|
|
499
|
+
const scriptPath = '/opt/notifai/dist/main.js'
|
|
500
|
+
|
|
501
|
+
it('installs native Cursor hooks with a single bounded answer continuation', () => {
|
|
502
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
503
|
+
const io = new CapturedIo()
|
|
504
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
505
|
+
|
|
506
|
+
expect(
|
|
507
|
+
hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
|
|
508
|
+
).toBe(EXIT.ok)
|
|
509
|
+
|
|
510
|
+
const installed = JSON.parse(
|
|
511
|
+
readFileSync(path.join(cwd, '.cursor', 'hooks.json'), 'utf8'),
|
|
512
|
+
) as {
|
|
513
|
+
version: number
|
|
514
|
+
hooks: Record<string, { command: string; timeout?: number; loop_limit?: number }[]>
|
|
515
|
+
}
|
|
516
|
+
expect(installed.version).toBe(1)
|
|
517
|
+
expect(Object.keys(installed.hooks).sort()).toEqual([
|
|
518
|
+
'beforeSubmitPrompt',
|
|
519
|
+
'sessionEnd',
|
|
520
|
+
'stop',
|
|
521
|
+
])
|
|
522
|
+
expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
|
|
523
|
+
'hook user-prompt-submit --owner notifai --harness cursor',
|
|
524
|
+
)
|
|
525
|
+
expect(installed.hooks['stop']?.[0]).toMatchObject({
|
|
526
|
+
command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
|
|
527
|
+
loop_limit: 1,
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('reports a native Cursor installation through doctor', async () => {
|
|
532
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
|
|
533
|
+
const io = new CapturedIo()
|
|
534
|
+
const client = {
|
|
535
|
+
health: async () => true,
|
|
536
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
537
|
+
} as unknown as ApiClient
|
|
538
|
+
const deps: CommandDeps = {
|
|
539
|
+
...makeDeps(io, client),
|
|
540
|
+
cwd,
|
|
541
|
+
env: {
|
|
542
|
+
HOME: path.join(cwd, 'home'),
|
|
543
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
544
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
545
|
+
CODEX_HOME: path.join(cwd, 'codex'),
|
|
546
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
547
|
+
},
|
|
548
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
549
|
+
}
|
|
550
|
+
expect(
|
|
551
|
+
hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
|
|
552
|
+
).toBe(EXIT.ok)
|
|
553
|
+
io.outLines = []
|
|
554
|
+
|
|
555
|
+
await doctorCommand(deps, {})
|
|
556
|
+
|
|
557
|
+
expect(io.outLines).toContain(
|
|
558
|
+
`ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
|
|
559
|
+
)
|
|
560
|
+
expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('uninstalls only NotifAI Cursor hooks and preserves foreign hooks', () => {
|
|
564
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-uninstall-'))
|
|
565
|
+
const io = new CapturedIo()
|
|
566
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
567
|
+
expect(
|
|
568
|
+
hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
|
|
569
|
+
).toBe(EXIT.ok)
|
|
570
|
+
const file = path.join(cwd, '.cursor', 'hooks.json')
|
|
571
|
+
const installed = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
572
|
+
version: number
|
|
573
|
+
hooks: Record<string, { command: string }[]>
|
|
574
|
+
}
|
|
575
|
+
installed.hooks['stop']?.unshift({ command: './keep-my-cursor-hook.sh' })
|
|
576
|
+
writeFileSync(file, `${JSON.stringify(installed, null, 2)}\n`)
|
|
577
|
+
|
|
578
|
+
expect(
|
|
579
|
+
hooksUninstallCommand(deps, { harness: 'cursor', scriptPath }),
|
|
580
|
+
).toBe(EXIT.ok)
|
|
581
|
+
|
|
582
|
+
const remaining = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
583
|
+
version: number
|
|
584
|
+
hooks: Record<string, { command: string }[]>
|
|
585
|
+
}
|
|
586
|
+
expect(remaining.version).toBe(1)
|
|
587
|
+
expect(remaining.hooks['stop']).toEqual([{ command: './keep-my-cursor-hook.sh' }])
|
|
588
|
+
expect(JSON.stringify(remaining)).not.toContain('--owner notifai')
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
describe('harness activation guidance', () => {
|
|
593
|
+
const execPath = '/usr/local/bin/node'
|
|
594
|
+
const scriptPath = '/opt/notifai/dist/main.js'
|
|
595
|
+
|
|
596
|
+
it('does not require a Claude Code restart for project hook files', () => {
|
|
597
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
598
|
+
const io = new CapturedIo()
|
|
599
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
600
|
+
|
|
601
|
+
expect(
|
|
602
|
+
hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
|
|
603
|
+
).toBe(EXIT.ok)
|
|
604
|
+
|
|
605
|
+
expect(io.outLines.join('\n')).toContain(
|
|
606
|
+
'Claude Code reloads project hook files without a restart.',
|
|
607
|
+
)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('does not invent a Codex hook trust gate', () => {
|
|
611
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
|
|
612
|
+
const io = new CapturedIo()
|
|
613
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
614
|
+
|
|
615
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(
|
|
616
|
+
EXIT.ok,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
const output = io.outLines.join('\n')
|
|
620
|
+
expect(output).toContain('Send one Codex prompt, then check `notifai doctor`.')
|
|
621
|
+
expect(output).not.toMatch(/trust|approve/i)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it('keeps OpenCode permission prompts local and reports its continuation limit', async () => {
|
|
625
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
|
|
626
|
+
const io = new CapturedIo()
|
|
627
|
+
const client = {
|
|
628
|
+
health: async () => true,
|
|
629
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
630
|
+
listDevices: async () => ({ devices: [] }),
|
|
631
|
+
} as unknown as ApiClient
|
|
632
|
+
const deps = { ...makeDeps(io, client), cwd }
|
|
633
|
+
|
|
634
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
635
|
+
EXIT.ok,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
|
|
639
|
+
expect(io.outLines.join('\n')).toContain('cannot reliably resume an idle agent turn')
|
|
640
|
+
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
641
|
+
const plugin = readFileSync(pluginFile, 'utf8')
|
|
642
|
+
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
643
|
+
|
|
644
|
+
io.outLines = []
|
|
645
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
646
|
+
expect(io.outLines.join('\n')).toContain('hooks (opencode continuation)')
|
|
647
|
+
expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
|
|
648
|
+
|
|
649
|
+
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
650
|
+
io.outLines = []
|
|
651
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
652
|
+
expect(io.outLines.join('\n')).toContain('hooks (adapter)')
|
|
653
|
+
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
describe('projectSlugFrom', () => {
|
|
658
|
+
it('canonicalizes directory names into contract-valid slugs', () => {
|
|
659
|
+
expect(projectSlugFrom('My App')).toBe('my-app')
|
|
660
|
+
expect(projectSlugFrom('NotifAI')).toBe('notifai')
|
|
661
|
+
expect(projectSlugFrom('--weird__Name.2')).toBe('weird__name.2')
|
|
662
|
+
expect(projectSlugFrom('!!!')).toBe('project')
|
|
663
|
+
})
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
describe('interactive command UX', () => {
|
|
667
|
+
it('styles login pairing progress for a human terminal', async () => {
|
|
668
|
+
const io = new InteractiveIo()
|
|
669
|
+
let now = 0
|
|
670
|
+
let savedMachine = ''
|
|
671
|
+
let polls = 0
|
|
672
|
+
const client = {
|
|
673
|
+
beginPairing: async () => ({
|
|
674
|
+
pairing_id: 'pair_test',
|
|
675
|
+
code: 'ABCD-EFGH',
|
|
676
|
+
approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
|
|
677
|
+
expires_at: new Date(10_000).toISOString(),
|
|
678
|
+
poll_interval_seconds: 1,
|
|
679
|
+
}),
|
|
680
|
+
pollPairing: async () => {
|
|
681
|
+
polls += 1
|
|
682
|
+
return polls === 1 ? { status: 'pending' } : { status: 'approved', machine_id: 'mac_new' }
|
|
683
|
+
},
|
|
684
|
+
} as unknown as ApiClient
|
|
685
|
+
const deps: CommandDeps = {
|
|
686
|
+
...makeDeps(io, client),
|
|
687
|
+
now: () => now,
|
|
688
|
+
sleep: async (milliseconds) => {
|
|
689
|
+
now += milliseconds
|
|
690
|
+
},
|
|
691
|
+
store: {
|
|
692
|
+
load: () => null,
|
|
693
|
+
save: (credential) => {
|
|
694
|
+
savedMachine = credential.machineId
|
|
695
|
+
},
|
|
696
|
+
clear: () => {},
|
|
697
|
+
describe: () => 'test credential store',
|
|
698
|
+
},
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
expect(await loginCommand(deps, { name: 'workstation', open: false })).toBe(EXIT.ok)
|
|
702
|
+
expect(io.intros).toEqual(['NotifAI sign in'])
|
|
703
|
+
expect(io.notes).toEqual([
|
|
704
|
+
{
|
|
705
|
+
title: 'Approve this machine',
|
|
706
|
+
message: 'Code: ABCD-EFGH\nhttps://test.notifai.invalid/pair/ABCD-EFGH',
|
|
707
|
+
},
|
|
708
|
+
])
|
|
709
|
+
expect(io.spinnerEvents).toEqual([
|
|
710
|
+
'start:Waiting for approval…',
|
|
711
|
+
'message:Waiting for approval…',
|
|
712
|
+
'stop:Machine "workstation" approved',
|
|
713
|
+
])
|
|
714
|
+
expect(io.outLines).toEqual([])
|
|
715
|
+
expect(savedMachine).toBe('mac_new')
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('keeps unattended login progress plain and unstyled', async () => {
|
|
719
|
+
const io = new CapturedIo()
|
|
720
|
+
let now = 0
|
|
721
|
+
const client = {
|
|
722
|
+
beginPairing: async () => ({
|
|
723
|
+
pairing_id: 'pair_test',
|
|
724
|
+
code: 'ABCD-EFGH',
|
|
725
|
+
approve_url: 'https://test.notifai.invalid/pair/ABCD-EFGH',
|
|
726
|
+
expires_at: new Date(10_000).toISOString(),
|
|
727
|
+
poll_interval_seconds: 1,
|
|
728
|
+
}),
|
|
729
|
+
pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
|
|
730
|
+
} as unknown as ApiClient
|
|
731
|
+
const deps = {
|
|
732
|
+
...makeDeps(io, client),
|
|
733
|
+
now: () => now,
|
|
734
|
+
sleep: async (milliseconds: number) => {
|
|
735
|
+
now += milliseconds
|
|
736
|
+
},
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
|
|
740
|
+
expect(io.outLines.slice(0, 3)).toEqual([
|
|
741
|
+
'Pairing code: ABCD-EFGH',
|
|
742
|
+
'Approve this machine at: https://test.notifai.invalid/pair/ABCD-EFGH',
|
|
743
|
+
'Waiting for approval…',
|
|
744
|
+
])
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('asks a human to choose a config layer when no layer flag was passed', async () => {
|
|
748
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-layer-'))
|
|
749
|
+
const io = new InteractiveIo()
|
|
750
|
+
io.selectAnswer = 'local'
|
|
751
|
+
const deps = {
|
|
752
|
+
...makeDeps(io, {} as ApiClient),
|
|
753
|
+
cwd,
|
|
754
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
|
|
758
|
+
expect(io.prompts[0]).toBe('Where should this setting live?')
|
|
759
|
+
expect(io.prompts[1]).toContain(path.join(cwd, '.notifai', 'config.local.toml'))
|
|
760
|
+
expect(readFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'utf8')).toContain(
|
|
761
|
+
'sound = "done"',
|
|
762
|
+
)
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('bypasses interactive config selection with --yes and uses the global default', async () => {
|
|
766
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-global-'))
|
|
767
|
+
const io = new InteractiveIo()
|
|
768
|
+
const deps = {
|
|
769
|
+
...makeDeps(io, {} as ApiClient),
|
|
770
|
+
cwd,
|
|
771
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
|
|
775
|
+
expect(io.prompts).toEqual([])
|
|
776
|
+
expect(readFileSync(path.join(cwd, 'xdg', 'notifai', 'config.toml'), 'utf8')).toContain(
|
|
777
|
+
'sound = "done"',
|
|
778
|
+
)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
|
|
782
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
|
|
783
|
+
const io = new CapturedIo()
|
|
784
|
+
const configFile = path.join(cwd, 'xdg', 'notifai', 'config.toml')
|
|
785
|
+
const deps = {
|
|
786
|
+
...makeDeps(io, {} as ApiClient),
|
|
787
|
+
cwd,
|
|
788
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
expect(await configSetCommand(deps, 'ask_grace_seconds', '600', { yes: true })).toBe(
|
|
792
|
+
EXIT.usage,
|
|
793
|
+
)
|
|
794
|
+
expect(await configSetCommand(deps, 'ask_grace_seconds', '1.5', { yes: true })).toBe(
|
|
795
|
+
EXIT.usage,
|
|
796
|
+
)
|
|
797
|
+
expect(io.errLines).toEqual([
|
|
798
|
+
'ask_grace_seconds must be between 0 and 540.',
|
|
799
|
+
'"1.5" is not an integer.',
|
|
800
|
+
])
|
|
801
|
+
expect(existsSync(configFile)).toBe(false)
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('renders doctor checks through the styled seam for humans', async () => {
|
|
805
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-style-'))
|
|
806
|
+
const io = new InteractiveIo()
|
|
807
|
+
const client = {
|
|
808
|
+
health: async () => true,
|
|
809
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
810
|
+
} as unknown as ApiClient
|
|
811
|
+
const deps: CommandDeps = {
|
|
812
|
+
...makeDeps(io, client),
|
|
813
|
+
cwd,
|
|
814
|
+
env: {
|
|
815
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
816
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
817
|
+
CODEX_HOME: path.join(cwd, 'codex'),
|
|
818
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
819
|
+
},
|
|
820
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
824
|
+
expect(io.intros).toEqual(['NotifAI doctor'])
|
|
825
|
+
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
826
|
+
expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
|
|
827
|
+
expect(io.outLines).toEqual([])
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('keeps doctor JSON as one machine-readable stdout document', async () => {
|
|
831
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-json-'))
|
|
832
|
+
const io = new InteractiveIo()
|
|
833
|
+
const client = {
|
|
834
|
+
health: async () => true,
|
|
835
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
836
|
+
} as unknown as ApiClient
|
|
837
|
+
const deps: CommandDeps = {
|
|
838
|
+
...makeDeps(io, client),
|
|
839
|
+
cwd,
|
|
840
|
+
env: {
|
|
841
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
842
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
843
|
+
CODEX_HOME: path.join(cwd, 'codex'),
|
|
844
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
845
|
+
},
|
|
846
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
await doctorCommand(deps, { json: true })
|
|
850
|
+
expect(io.outLines).toHaveLength(1)
|
|
851
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toHaveProperty('states')
|
|
852
|
+
expect(io.intros).toEqual([])
|
|
853
|
+
expect(io.checks).toEqual([])
|
|
854
|
+
})
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
describe('init', () => {
|
|
858
|
+
it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
|
|
859
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
|
|
860
|
+
const io = new CapturedIo()
|
|
861
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
862
|
+
|
|
863
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
864
|
+
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
865
|
+
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
866
|
+
// Safe by default: without an explicit --skills opt-in, init only writes
|
|
867
|
+
// configuration and never spawns the skill installer.
|
|
868
|
+
expect(io.outLines.join('\n')).toContain('no published skill source configured')
|
|
869
|
+
expect(io.outLines.join('\n')).not.toContain('All set.')
|
|
870
|
+
|
|
871
|
+
io.outLines = []
|
|
872
|
+
expect(await initCommand(deps, { skills: false })).toBe(EXIT.ok)
|
|
873
|
+
// Idempotent: the second run re-derives the same slug and says so as a
|
|
874
|
+
// settled state rather than repeating the write.
|
|
875
|
+
expect(io.outLines.join('\n')).toContain('Project identity: "my-project-')
|
|
876
|
+
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it('surfaces one next step, not the whole remaining list', async () => {
|
|
880
|
+
// The behavioural core of the design: someone handed five things to do
|
|
881
|
+
// does none of them. Signing in gates the device check, so naming both
|
|
882
|
+
// would send the reader to fix something not yet known to be wrong.
|
|
883
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-one-step-'))
|
|
884
|
+
const io = new CapturedIo()
|
|
885
|
+
const deps: CommandDeps = {
|
|
886
|
+
...makeDeps(io, { health: async () => true } as unknown as ApiClient),
|
|
887
|
+
cwd,
|
|
888
|
+
env: { XDG_CONFIG_HOME: cwd, XDG_STATE_HOME: cwd },
|
|
889
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
893
|
+
const out = io.outLines.join('\n')
|
|
894
|
+
expect(out).toContain('Next: This machine')
|
|
895
|
+
expect(out).toContain('notifai login')
|
|
896
|
+
// The device gap is real and downstream; it must stay hidden until the
|
|
897
|
+
// sign-in that would let anyone actually check it has happened.
|
|
898
|
+
expect(out).not.toContain('companion app')
|
|
899
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
it('honors an explicit --project-id', async () => {
|
|
903
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-explicit-'))
|
|
904
|
+
const io = new CapturedIo()
|
|
905
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
906
|
+
|
|
907
|
+
expect(await initCommand(deps, { projectId: 'Custom Name', skills: false })).toBe(EXIT.ok)
|
|
908
|
+
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain(
|
|
909
|
+
'project = "custom-name"',
|
|
910
|
+
)
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('run unattended, names the optional steps instead of running or asking about them', async () => {
|
|
914
|
+
// An agent's init must not reach for npx or a prompt.
|
|
915
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
|
|
916
|
+
const io = new CapturedIo()
|
|
917
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
918
|
+
|
|
919
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
920
|
+
const out = io.outLines.join('\n')
|
|
921
|
+
expect(out).toContain('no published skill source configured')
|
|
922
|
+
expect(out).not.toContain('notifai init --skills')
|
|
923
|
+
expect(out).not.toContain('Installing the notifai agent skill')
|
|
924
|
+
// Never prompted, and never assumed into a change it did not request.
|
|
925
|
+
expect(io.errLines).toEqual([])
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('fails explicitly when --skills requests the unpublished source', async () => {
|
|
929
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skills-unpublished-'))
|
|
930
|
+
const io = new CapturedIo()
|
|
931
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
932
|
+
|
|
933
|
+
expect(await initCommand(deps, { skills: true })).toBe(EXIT.failed)
|
|
934
|
+
expect(io.errLines).toContain(
|
|
935
|
+
'The optional agent skill is not published yet; this build has no skill source configured.',
|
|
936
|
+
)
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
it('tells the user what only they can do when nothing is signed in', async () => {
|
|
940
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-nocred-'))
|
|
941
|
+
const io = new CapturedIo()
|
|
942
|
+
const deps: CommandDeps = {
|
|
943
|
+
...makeDeps(io, {} as ApiClient),
|
|
944
|
+
cwd,
|
|
945
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
949
|
+
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it('offers a present human the sign-in and respects a refusal', async () => {
|
|
953
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-human-'))
|
|
954
|
+
const asked: string[] = []
|
|
955
|
+
const io = new (class extends CapturedIo {
|
|
956
|
+
interactive = true
|
|
957
|
+
override async confirm(question: string) {
|
|
958
|
+
asked.push(question)
|
|
959
|
+
return false
|
|
960
|
+
}
|
|
961
|
+
})()
|
|
962
|
+
const deps: CommandDeps = {
|
|
963
|
+
...makeDeps(io, {} as ApiClient),
|
|
964
|
+
cwd,
|
|
965
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
expect(await initCommand(deps, {})).toBe(EXIT.ok)
|
|
969
|
+
expect(asked.some((q) => q.includes('Sign in'))).toBe(true)
|
|
970
|
+
// Refused, so it stays the next step rather than being treated as done.
|
|
971
|
+
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
972
|
+
})
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
describe('an outage is not an answer', () => {
|
|
976
|
+
/**
|
|
977
|
+
* The dangerous shape: the first poll succeeds, then connectivity drops and
|
|
978
|
+
* never comes back. waitForReply only throws when NO poll ever succeeded, so
|
|
979
|
+
* this used to return the stale empty response as a plain exit 3 — and an
|
|
980
|
+
* agent scripted to read exit 3 as "nobody objected" would proceed against a
|
|
981
|
+
* refusal it never saw.
|
|
982
|
+
*/
|
|
983
|
+
function outageAfterFirstPoll(io: CapturedIo): CommandDeps {
|
|
984
|
+
let now = 0
|
|
985
|
+
let polls = 0
|
|
986
|
+
const client = {
|
|
987
|
+
submit: async () => receipt,
|
|
988
|
+
replies: async () => {
|
|
989
|
+
polls += 1
|
|
990
|
+
if (polls === 1) return replyResponse([])
|
|
991
|
+
throw new NetworkError('link went down')
|
|
992
|
+
},
|
|
993
|
+
} as unknown as ApiClient
|
|
994
|
+
return {
|
|
995
|
+
...makeDeps(io, client),
|
|
996
|
+
now: () => now,
|
|
997
|
+
sleep: async (milliseconds: number) => {
|
|
998
|
+
now += milliseconds
|
|
999
|
+
},
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
it('does not report an unreachable server as "no reply yet"', async () => {
|
|
1004
|
+
const io = new CapturedIo()
|
|
1005
|
+
const exit = await sendCommand(outageAfterFirstPoll(io), {
|
|
1006
|
+
title: 'Question',
|
|
1007
|
+
body: 'Deploy to production?',
|
|
1008
|
+
reply: true,
|
|
1009
|
+
replyTimeout: 10,
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
// Whatever code this is, it must not be the one that means "asked, and the
|
|
1013
|
+
// user stayed silent".
|
|
1014
|
+
expect(exit).not.toBe(EXIT.noReply)
|
|
1015
|
+
expect(exit).toBe(EXIT.network)
|
|
1016
|
+
expect(io.errLines.join('\n')).toContain('could not find out')
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('marks the JSON so an agent reading it programmatically can tell', async () => {
|
|
1020
|
+
const io = new CapturedIo()
|
|
1021
|
+
await sendCommand(outageAfterFirstPoll(io), {
|
|
1022
|
+
title: 'Question',
|
|
1023
|
+
body: 'Deploy?',
|
|
1024
|
+
reply: true,
|
|
1025
|
+
replyTimeout: 10,
|
|
1026
|
+
json: true,
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
|
|
1030
|
+
expect(payload.degraded).toBe(true)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('still reports a genuine silence as no-reply', async () => {
|
|
1034
|
+
const io = new CapturedIo()
|
|
1035
|
+
let now = 0
|
|
1036
|
+
const client = {
|
|
1037
|
+
submit: async () => receipt,
|
|
1038
|
+
replies: async () => replyResponse([]),
|
|
1039
|
+
} as unknown as ApiClient
|
|
1040
|
+
const deps = {
|
|
1041
|
+
...makeDeps(io, client),
|
|
1042
|
+
now: () => now,
|
|
1043
|
+
sleep: async (ms: number) => {
|
|
1044
|
+
now += ms
|
|
1045
|
+
},
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
expect(
|
|
1049
|
+
await sendCommand(deps, { title: 'Q', body: 'B', reply: true, replyTimeout: 5 }),
|
|
1050
|
+
).toBe(EXIT.noReply)
|
|
1051
|
+
})
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
describe('asking before the hooks have ever run', () => {
|
|
1055
|
+
it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
|
|
1056
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
|
|
1057
|
+
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
1058
|
+
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
1059
|
+
hooks: buildHookConfig({
|
|
1060
|
+
execPath: '/usr/bin/node',
|
|
1061
|
+
scriptPath: '/opt/notifai/main.js',
|
|
1062
|
+
replyTimeoutSeconds: 180,
|
|
1063
|
+
graceSeconds: 300,
|
|
1064
|
+
}),
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
const io = new CapturedIo()
|
|
1068
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_STATE_HOME: cwd } }
|
|
1069
|
+
|
|
1070
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
1071
|
+
const said = io.errLines.join(' ')
|
|
1072
|
+
expect(said).toMatch(/project hook files reload without a restart/i)
|
|
1073
|
+
expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
it('says to install when nothing is installed at all', () => {
|
|
1077
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
|
|
1078
|
+
const io = new CapturedIo()
|
|
1079
|
+
const deps = {
|
|
1080
|
+
...makeDeps(io, {} as ApiClient),
|
|
1081
|
+
cwd,
|
|
1082
|
+
env: { XDG_STATE_HOME: cwd, CODEX_HOME: path.join(cwd, 'none'), CLAUDE_CONFIG_DIR: path.join(cwd, 'none') },
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
1086
|
+
expect(io.errLines.join(' ')).toMatch(/hooks install/)
|
|
1087
|
+
})
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* A CLI newer than its server produced "hook failed, deferring to
|
|
1092
|
+
* the terminal", which reads like a flaky network, while escalation was in
|
|
1093
|
+
* fact completely broken in production.
|
|
1094
|
+
*/
|
|
1095
|
+
describe('a server behind this CLI', () => {
|
|
1096
|
+
it('names the field the server rejected instead of swallowing it', () => {
|
|
1097
|
+
const rejection = new ApiCallError(422, 'unsupported_field', 'The draft was not accepted.', null, [
|
|
1098
|
+
{ code: 'unsupported_field', path: '/lifecycle', message: 'Unknown property.' },
|
|
1099
|
+
])
|
|
1100
|
+
|
|
1101
|
+
const said = describeHookFailure(rejection).join(' ')
|
|
1102
|
+
|
|
1103
|
+
expect(said).toContain('/lifecycle')
|
|
1104
|
+
expect(said).toContain('unsupported_field')
|
|
1105
|
+
// And says which way round the mismatch is, which is the whole diagnosis.
|
|
1106
|
+
expect(said).toMatch(/server is older than this CLI/)
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
it('still reports a plain failure for anything that is not a rejection', () => {
|
|
1110
|
+
const said = describeHookFailure(new Error('socket hang up')).join(' ')
|
|
1111
|
+
expect(said).toContain('socket hang up')
|
|
1112
|
+
expect(said).not.toMatch(/older than this CLI/)
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
it('doctor says plainly that the server needs deploying', async () => {
|
|
1116
|
+
const io = new CapturedIo()
|
|
1117
|
+
const client = {
|
|
1118
|
+
health: async () => true,
|
|
1119
|
+
// A server one schema version behind this build.
|
|
1120
|
+
capabilities: async () => ({ schema_version: 0, platform: 'ios' }),
|
|
1121
|
+
listDevices: async () => ({ devices: [] }),
|
|
1122
|
+
} as unknown as ApiClient
|
|
1123
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
|
|
1124
|
+
const deps = {
|
|
1125
|
+
...makeDeps(io, client),
|
|
1126
|
+
cwd,
|
|
1127
|
+
env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
|
|
1128
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
1129
|
+
} as CommandDeps
|
|
1130
|
+
|
|
1131
|
+
await doctorCommand(deps, {})
|
|
1132
|
+
|
|
1133
|
+
const said = io.outLines.concat(io.errLines).join(' ')
|
|
1134
|
+
// The label is the user's word for it; the detail is what must survive.
|
|
1135
|
+
expect(said).toMatch(/Protocol version/)
|
|
1136
|
+
expect(said).toMatch(/needs deploying/)
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
it('doctor is quiet when both sides agree', async () => {
|
|
1140
|
+
const io = new CapturedIo()
|
|
1141
|
+
const client = {
|
|
1142
|
+
health: async () => true,
|
|
1143
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1144
|
+
listDevices: async () => ({ devices: [] }),
|
|
1145
|
+
} as unknown as ApiClient
|
|
1146
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noskew-'))
|
|
1147
|
+
const deps = {
|
|
1148
|
+
...makeDeps(io, client),
|
|
1149
|
+
cwd,
|
|
1150
|
+
env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
|
|
1151
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
1152
|
+
} as CommandDeps
|
|
1153
|
+
|
|
1154
|
+
await doctorCommand(deps, {})
|
|
1155
|
+
|
|
1156
|
+
expect(io.outLines.concat(io.errLines).join(' ')).not.toMatch(/needs deploying|update the CLI/)
|
|
1157
|
+
})
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
/** First-reply-wins is the right default, silently is the wrong way. */
|
|
1161
|
+
describe('a second device that disagrees', () => {
|
|
1162
|
+
function view(overrides: Partial<ReplyView>): ReplyView {
|
|
1163
|
+
return {
|
|
1164
|
+
reply_id: 'rpl',
|
|
1165
|
+
seq: 1,
|
|
1166
|
+
delivery_id: 'del',
|
|
1167
|
+
device_id: 'dev',
|
|
1168
|
+
device_name: 'iPhone',
|
|
1169
|
+
text: 'Yes',
|
|
1170
|
+
choice_id: null,
|
|
1171
|
+
created_at: new Date().toISOString(),
|
|
1172
|
+
...overrides,
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
it('says which answer counted and which was discarded', () => {
|
|
1177
|
+
const said = contradictingAnswer([
|
|
1178
|
+
view({ seq: 1, device_name: 'iPhone', text: 'Yes' }),
|
|
1179
|
+
view({ seq: 2, device_name: 'FurankuMac', text: 'No' }),
|
|
1180
|
+
])
|
|
1181
|
+
expect(said).toContain('"Yes" from iPhone')
|
|
1182
|
+
expect(said).toContain('FurankuMac')
|
|
1183
|
+
expect(said).toMatch(/arrived first/)
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('is silent when the second answer agrees', () => {
|
|
1187
|
+
expect(
|
|
1188
|
+
contradictingAnswer([
|
|
1189
|
+
view({ seq: 1, device_name: 'iPhone', text: 'Ship it', choice_id: 'ship' }),
|
|
1190
|
+
view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it', choice_id: 'ship' }),
|
|
1191
|
+
]),
|
|
1192
|
+
).toBeNull()
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
it('is silent for a single answer', () => {
|
|
1196
|
+
expect(contradictingAnswer([view({})])).toBeNull()
|
|
1197
|
+
expect(contradictingAnswer([])).toBeNull()
|
|
1198
|
+
})
|
|
1199
|
+
})
|