@raidiant/notifai 5.0.0 → 7.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.
- package/CHANGELOG.md +51 -0
- package/README.md +2 -2
- package/dist/atomic-file.js +13 -0
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +65 -0
- package/dist/commands-harness-context.js +97 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +183 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -387
- package/dist/commands.js +12 -4536
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +1 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.js +6 -4
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/generated-session-label.d.ts +2 -0
- package/dist/generated-session-label.js +152 -0
- package/dist/generated-session-label.js.map +1 -0
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +127 -99
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.js +4 -4
- package/dist/interactive.js.map +1 -1
- package/dist/invocation-context.d.ts +3 -0
- package/dist/invocation-context.js +23 -6
- package/dist/invocation-context.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.js +18 -3
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/session-labels.d.ts +30 -0
- package/dist/session-labels.js +278 -0
- package/dist/session-labels.js.map +1 -0
- package/package.json +2 -2
- package/src/atomic-file.ts +12 -0
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +134 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +227 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +567 -42
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +10 -5
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/generated-session-label.ts +155 -0
- package/src/hooks.test.ts +355 -16
- package/src/hooks.ts +145 -97
- package/src/install-hooks.test.ts +84 -1
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/invocation-context.test.ts +115 -16
- package/src/invocation-context.ts +32 -11
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +9 -3
- package/src/opencode-plugin.ts +18 -3
- package/src/readiness.ts +3 -1
- package/src/session-labels.test.ts +303 -0
- package/src/session-labels.ts +360 -0
- package/src/skill.test.ts +13 -0
package/src/commands.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
1
|
+
import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
@@ -15,12 +15,16 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
16
16
|
import type {
|
|
17
17
|
CapabilityDocument,
|
|
18
|
+
CompatibilityResponse,
|
|
18
19
|
EvidenceSnapshot,
|
|
19
20
|
ListRepliesResponse,
|
|
21
|
+
Platform,
|
|
20
22
|
ReplyView,
|
|
21
23
|
SubmissionReceipt,
|
|
22
24
|
SubmitNotificationRequestT,
|
|
25
|
+
SupportAssessment,
|
|
23
26
|
} from '@raidiant/notifai-protocol'
|
|
27
|
+
import { parse as parseToml } from 'smol-toml'
|
|
24
28
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
25
29
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
26
30
|
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
@@ -85,6 +89,71 @@ const RELEASE_REF = `v${
|
|
|
85
89
|
).version
|
|
86
90
|
}`
|
|
87
91
|
|
|
92
|
+
const currentSupport: SupportAssessment = {
|
|
93
|
+
state: 'current',
|
|
94
|
+
reason: 'current',
|
|
95
|
+
affected_operation: null,
|
|
96
|
+
recovery_action: null,
|
|
97
|
+
current_version: '5.0.0',
|
|
98
|
+
current_build: null,
|
|
99
|
+
recommended_version: '5.0.0',
|
|
100
|
+
recommended_build: null,
|
|
101
|
+
minimum_version: null,
|
|
102
|
+
minimum_build: null,
|
|
103
|
+
deprecation: null,
|
|
104
|
+
sunset: null,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const currentCompatibility: CompatibilityResponse = {
|
|
108
|
+
cli: currentSupport,
|
|
109
|
+
platforms: [
|
|
110
|
+
{
|
|
111
|
+
platform: 'ios',
|
|
112
|
+
recommended_version: null,
|
|
113
|
+
recommended_build: null,
|
|
114
|
+
minimum_receive_build: null,
|
|
115
|
+
minimum_answer_build: null,
|
|
116
|
+
deprecation: null,
|
|
117
|
+
sunset: null,
|
|
118
|
+
replacement_available: false,
|
|
119
|
+
rollout_complete: false,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
platform: 'macos',
|
|
123
|
+
recommended_version: null,
|
|
124
|
+
recommended_build: null,
|
|
125
|
+
minimum_receive_build: null,
|
|
126
|
+
minimum_answer_build: null,
|
|
127
|
+
deprecation: null,
|
|
128
|
+
sunset: null,
|
|
129
|
+
replacement_available: false,
|
|
130
|
+
rollout_complete: false,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compatibilityWithCli(
|
|
137
|
+
overrides: Partial<SupportAssessment>,
|
|
138
|
+
): CompatibilityResponse {
|
|
139
|
+
return {
|
|
140
|
+
...currentCompatibility,
|
|
141
|
+
cli: { ...currentSupport, ...overrides },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withCompatibilityDefaults(client: ApiClient): ApiClient {
|
|
146
|
+
return {
|
|
147
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
148
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
149
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
150
|
+
return document
|
|
151
|
+
},
|
|
152
|
+
compatibility: async () => currentCompatibility,
|
|
153
|
+
...client,
|
|
154
|
+
} as ApiClient
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
class CapturedIo implements CommandIo {
|
|
89
158
|
outLines: string[] = []
|
|
90
159
|
errLines: string[] = []
|
|
@@ -107,6 +176,10 @@ class CapturedIo implements CommandIo {
|
|
|
107
176
|
}
|
|
108
177
|
}
|
|
109
178
|
|
|
179
|
+
class PlainInteractiveIo extends CapturedIo {
|
|
180
|
+
interactive = true
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
class InteractiveIo extends CapturedIo {
|
|
111
184
|
interactive = true
|
|
112
185
|
selectAnswer: string | null = 'global'
|
|
@@ -234,7 +307,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
234
307
|
},
|
|
235
308
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
236
309
|
cwd: os.tmpdir(),
|
|
237
|
-
clientFactory: () => client,
|
|
310
|
+
clientFactory: () => withCompatibilityDefaults(client),
|
|
238
311
|
}
|
|
239
312
|
}
|
|
240
313
|
|
|
@@ -427,11 +500,82 @@ describe('command contracts', () => {
|
|
|
427
500
|
session_id: 'opaque-claude-session-42',
|
|
428
501
|
harness: 'claude-code',
|
|
429
502
|
})
|
|
430
|
-
expect(submitted?.draft.source?.session_label).
|
|
503
|
+
expect(submitted?.draft.source?.session_label).toBe('Rapid Antelope')
|
|
431
504
|
expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
432
505
|
expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
433
506
|
})
|
|
434
507
|
|
|
508
|
+
it('uses the trusted OpenCode session title published by its managed adapter', async () => {
|
|
509
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-title-'))
|
|
510
|
+
const io = new CapturedIo()
|
|
511
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
512
|
+
const client = {
|
|
513
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
514
|
+
submitted = body
|
|
515
|
+
return receipt
|
|
516
|
+
},
|
|
517
|
+
} as unknown as ApiClient
|
|
518
|
+
const deps = {
|
|
519
|
+
...makeDeps(io, client),
|
|
520
|
+
cwd,
|
|
521
|
+
env: {
|
|
522
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
523
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
524
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
525
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
|
|
526
|
+
NOTIFAI_ACTIVE_SESSION_LABEL: 'Semantic session names',
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
expect(
|
|
531
|
+
await sendCommand(deps, {
|
|
532
|
+
title: 'Resolver implemented',
|
|
533
|
+
body: 'The first-party title was frozen locally.',
|
|
534
|
+
kind: 'done',
|
|
535
|
+
}),
|
|
536
|
+
).toBe(EXIT.ok)
|
|
537
|
+
|
|
538
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
539
|
+
session_id: 'opencode-session',
|
|
540
|
+
session_label: 'Semantic session names',
|
|
541
|
+
harness: 'opencode',
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('does not freeze or submit an OpenCode placeholder title', async () => {
|
|
546
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-pending-'))
|
|
547
|
+
const io = new CapturedIo()
|
|
548
|
+
let submitted = false
|
|
549
|
+
const client = {
|
|
550
|
+
submit: async () => {
|
|
551
|
+
submitted = true
|
|
552
|
+
return receipt
|
|
553
|
+
},
|
|
554
|
+
} as unknown as ApiClient
|
|
555
|
+
const deps = {
|
|
556
|
+
...makeDeps(io, client),
|
|
557
|
+
cwd,
|
|
558
|
+
env: {
|
|
559
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
560
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
561
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
562
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-session',
|
|
563
|
+
NOTIFAI_ACTIVE_SESSION_LABEL_PENDING: '1',
|
|
564
|
+
},
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
expect(
|
|
568
|
+
await sendCommand(deps, {
|
|
569
|
+
title: 'Resolver implemented',
|
|
570
|
+
body: 'Wait for the semantic title before freezing a name.',
|
|
571
|
+
kind: 'done',
|
|
572
|
+
}),
|
|
573
|
+
).toBe(EXIT.usage)
|
|
574
|
+
expect(submitted).toBe(false)
|
|
575
|
+
expect(io.errLines.join('\n')).toContain('still generating this session')
|
|
576
|
+
expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
|
|
577
|
+
})
|
|
578
|
+
|
|
435
579
|
it('uploads repeatable images in order and sends only canonical media references', async () => {
|
|
436
580
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
|
|
437
581
|
const first = path.join(root, 'first.png')
|
|
@@ -580,7 +724,7 @@ describe('command contracts', () => {
|
|
|
580
724
|
expect(grammar).not.toContain(".option('--session <")
|
|
581
725
|
expect(grammar).toContain(".option('--body-file")
|
|
582
726
|
expect(grammar).toContain(".option('--session-id")
|
|
583
|
-
expect(grammar).toContain("
|
|
727
|
+
expect(grammar).toContain("'--session-label <text>'")
|
|
584
728
|
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
585
729
|
expect(grammar).toContain("'--image-alt <text>'")
|
|
586
730
|
}
|
|
@@ -1672,7 +1816,7 @@ describe('Cursor hook commands', () => {
|
|
|
1672
1816
|
|
|
1673
1817
|
it('reports a native Cursor installation through doctor', async () => {
|
|
1674
1818
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
|
|
1675
|
-
const io = new
|
|
1819
|
+
const io = new PlainInteractiveIo()
|
|
1676
1820
|
const client = {
|
|
1677
1821
|
health: async () => true,
|
|
1678
1822
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -1841,7 +1985,7 @@ describe('Codex hook representation', () => {
|
|
|
1841
1985
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1842
1986
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1843
1987
|
const env = isolatedEnv(cwd)
|
|
1844
|
-
const io = new
|
|
1988
|
+
const io = new PlainInteractiveIo()
|
|
1845
1989
|
const client = {
|
|
1846
1990
|
health: async () => false,
|
|
1847
1991
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -2434,6 +2578,39 @@ describe('config surfaces', () => {
|
|
|
2434
2578
|
expect(resolved.sound.source).toMatch(/^global:/)
|
|
2435
2579
|
})
|
|
2436
2580
|
|
|
2581
|
+
it('round-trips unknown root and table keys through config set and unset', async () => {
|
|
2582
|
+
const io = new CapturedIo()
|
|
2583
|
+
const deps = configDeps(io)
|
|
2584
|
+
const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
2585
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
2586
|
+
writeFileSync(
|
|
2587
|
+
configFile,
|
|
2588
|
+
[
|
|
2589
|
+
'future_root = "keep-root"',
|
|
2590
|
+
'wait_seconds = 20',
|
|
2591
|
+
'',
|
|
2592
|
+
'[future_table]',
|
|
2593
|
+
'future_key = "keep-table"',
|
|
2594
|
+
'',
|
|
2595
|
+
].join('\n'),
|
|
2596
|
+
)
|
|
2597
|
+
|
|
2598
|
+
expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
|
|
2599
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2600
|
+
future_root: 'keep-root',
|
|
2601
|
+
wait_seconds: 20,
|
|
2602
|
+
sound: 'done',
|
|
2603
|
+
future_table: { future_key: 'keep-table' },
|
|
2604
|
+
})
|
|
2605
|
+
|
|
2606
|
+
expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
|
|
2607
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2608
|
+
future_root: 'keep-root',
|
|
2609
|
+
sound: 'done',
|
|
2610
|
+
future_table: { future_key: 'keep-table' },
|
|
2611
|
+
})
|
|
2612
|
+
})
|
|
2613
|
+
|
|
2437
2614
|
it('refuses to create a redundant machine override equal to the shipped default', async () => {
|
|
2438
2615
|
const io = new CapturedIo()
|
|
2439
2616
|
const deps = configDeps(io)
|
|
@@ -2638,7 +2815,7 @@ describe('interactive command UX', () => {
|
|
|
2638
2815
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2639
2816
|
expect(io.intros).toEqual(['Notifai doctor'])
|
|
2640
2817
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
2641
|
-
expect(io.checks.some((check) => check.
|
|
2818
|
+
expect(io.checks.some((check) => check.message.startsWith('Notifai update:'))).toBe(false)
|
|
2642
2819
|
expect(io.outLines).toEqual([])
|
|
2643
2820
|
|
|
2644
2821
|
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
@@ -2647,7 +2824,6 @@ describe('interactive command UX', () => {
|
|
|
2647
2824
|
const tone = (prefix: string): Tone | undefined =>
|
|
2648
2825
|
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
2649
2826
|
expect(tone('This machine:')).toBe('bad')
|
|
2650
|
-
expect(tone('Protocol version:')).toBe('ok')
|
|
2651
2827
|
// Never evaluated: the account cannot be checked without a credential.
|
|
2652
2828
|
expect(tone('Account:')).toBe('pending')
|
|
2653
2829
|
// Legitimately declined rather than broken.
|
|
@@ -2681,6 +2857,210 @@ describe('interactive command UX', () => {
|
|
|
2681
2857
|
})
|
|
2682
2858
|
})
|
|
2683
2859
|
|
|
2860
|
+
describe('compatibility-first update guidance', () => {
|
|
2861
|
+
const cases: Array<{
|
|
2862
|
+
name: string
|
|
2863
|
+
support: Partial<SupportAssessment>
|
|
2864
|
+
status: 'ready' | 'optional-gap' | 'gap'
|
|
2865
|
+
detail: string
|
|
2866
|
+
exit: number
|
|
2867
|
+
humanLines: string[]
|
|
2868
|
+
}> = [
|
|
2869
|
+
{
|
|
2870
|
+
name: 'current',
|
|
2871
|
+
support: {},
|
|
2872
|
+
status: 'ready',
|
|
2873
|
+
detail: 'Notifai can send notifications.',
|
|
2874
|
+
exit: EXIT.ok,
|
|
2875
|
+
humanLines: [],
|
|
2876
|
+
},
|
|
2877
|
+
{
|
|
2878
|
+
name: 'optional newer release',
|
|
2879
|
+
support: {
|
|
2880
|
+
state: 'update_available',
|
|
2881
|
+
reason: 'newer_release',
|
|
2882
|
+
recovery_action: 'update_cli',
|
|
2883
|
+
recommended_version: '6.0.0',
|
|
2884
|
+
},
|
|
2885
|
+
status: 'optional-gap',
|
|
2886
|
+
detail: 'A newer Notifai is available.',
|
|
2887
|
+
exit: EXIT.ok,
|
|
2888
|
+
humanLines: [
|
|
2889
|
+
'A newer Notifai is available.',
|
|
2890
|
+
'npm install -g @raidiant/notifai',
|
|
2891
|
+
],
|
|
2892
|
+
},
|
|
2893
|
+
{
|
|
2894
|
+
name: 'scheduled Sunset',
|
|
2895
|
+
support: {
|
|
2896
|
+
state: 'update_available',
|
|
2897
|
+
reason: 'sunset_scheduled',
|
|
2898
|
+
recovery_action: 'update_cli',
|
|
2899
|
+
recommended_version: '6.0.0',
|
|
2900
|
+
deprecation: 'Tue, 18 Aug 2026 00:00:00 GMT',
|
|
2901
|
+
sunset: 'Tue, 15 Sep 2026 00:00:00 GMT',
|
|
2902
|
+
},
|
|
2903
|
+
status: 'optional-gap',
|
|
2904
|
+
detail: 'Update Notifai soon to keep sending notifications.',
|
|
2905
|
+
exit: EXIT.ok,
|
|
2906
|
+
humanLines: [
|
|
2907
|
+
'Update Notifai soon to keep sending notifications.',
|
|
2908
|
+
'npm install -g @raidiant/notifai',
|
|
2909
|
+
],
|
|
2910
|
+
},
|
|
2911
|
+
{
|
|
2912
|
+
name: 'required update',
|
|
2913
|
+
support: {
|
|
2914
|
+
state: 'must_update',
|
|
2915
|
+
reason: 'minimum_not_met',
|
|
2916
|
+
affected_operation: 'send_notifications',
|
|
2917
|
+
recovery_action: 'update_cli',
|
|
2918
|
+
recommended_version: '6.0.0',
|
|
2919
|
+
minimum_version: '6.0.0',
|
|
2920
|
+
},
|
|
2921
|
+
status: 'gap',
|
|
2922
|
+
detail: "Notifai can't send notifications until you update.",
|
|
2923
|
+
exit: EXIT.failed,
|
|
2924
|
+
humanLines: [
|
|
2925
|
+
"Notifai can't send notifications until you update.",
|
|
2926
|
+
'npm install -g @raidiant/notifai',
|
|
2927
|
+
],
|
|
2928
|
+
},
|
|
2929
|
+
]
|
|
2930
|
+
|
|
2931
|
+
it.each(cases)(
|
|
2932
|
+
'maps $name policy to closed human copy and structured JSON',
|
|
2933
|
+
async ({ support, status, detail, exit, humanLines }) => {
|
|
2934
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
|
|
2935
|
+
const client = {
|
|
2936
|
+
health: async () => true,
|
|
2937
|
+
compatibility: async () => compatibilityWithCli(support),
|
|
2938
|
+
listDevices: async () => ({ devices: [] }),
|
|
2939
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
2940
|
+
} as unknown as ApiClient
|
|
2941
|
+
const humanIo = new PlainInteractiveIo()
|
|
2942
|
+
const deps = {
|
|
2943
|
+
...makeDeps(humanIo, client),
|
|
2944
|
+
cwd,
|
|
2945
|
+
env: isolatedEnv(cwd),
|
|
2946
|
+
}
|
|
2947
|
+
const readiness = await assessReadiness(deps)
|
|
2948
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
2949
|
+
expect(contract).toMatchObject({ status, detail })
|
|
2950
|
+
|
|
2951
|
+
expect(
|
|
2952
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
2953
|
+
).toBe(exit)
|
|
2954
|
+
expect(humanIo.outLines).toEqual(humanLines)
|
|
2955
|
+
expect(humanIo.errLines).toEqual([])
|
|
2956
|
+
|
|
2957
|
+
const jsonIo = new CapturedIo()
|
|
2958
|
+
expect(
|
|
2959
|
+
await doctorCommand(
|
|
2960
|
+
{ ...deps, io: jsonIo },
|
|
2961
|
+
{ json: true },
|
|
2962
|
+
{ readiness: { states: [contract!] } },
|
|
2963
|
+
),
|
|
2964
|
+
).toBe(exit)
|
|
2965
|
+
expect(jsonIo.outLines).toHaveLength(1)
|
|
2966
|
+
const payload = JSON.parse(jsonIo.outLines[0] ?? '{}') as {
|
|
2967
|
+
ok: boolean
|
|
2968
|
+
exit_code: number
|
|
2969
|
+
states: Array<{
|
|
2970
|
+
status: string
|
|
2971
|
+
detail: string
|
|
2972
|
+
remedy?: { command?: string }
|
|
2973
|
+
}>
|
|
2974
|
+
}
|
|
2975
|
+
expect(payload).toMatchObject({
|
|
2976
|
+
ok: exit === EXIT.ok,
|
|
2977
|
+
exit_code: exit,
|
|
2978
|
+
states: [{ status, detail }],
|
|
2979
|
+
})
|
|
2980
|
+
expect(payload.states[0]?.remedy?.command ?? null).toBe(
|
|
2981
|
+
status === 'ready' ? null : 'npm install -g @raidiant/notifai',
|
|
2982
|
+
)
|
|
2983
|
+
expect(jsonIo.errLines).toEqual([])
|
|
2984
|
+
|
|
2985
|
+
const nonTtyIo = new CapturedIo()
|
|
2986
|
+
expect(
|
|
2987
|
+
await doctorCommand(
|
|
2988
|
+
{ ...deps, io: nonTtyIo },
|
|
2989
|
+
{},
|
|
2990
|
+
{ readiness: { states: [contract!] } },
|
|
2991
|
+
),
|
|
2992
|
+
).toBe(exit)
|
|
2993
|
+
expect(nonTtyIo.outLines).toHaveLength(1)
|
|
2994
|
+
expect(JSON.parse(nonTtyIo.outLines[0] ?? '{}')).toEqual(payload)
|
|
2995
|
+
expect(nonTtyIo.errLines).toEqual([])
|
|
2996
|
+
},
|
|
2997
|
+
)
|
|
2998
|
+
|
|
2999
|
+
it('reports a missing named CLI capability without blocking ordinary sends', async () => {
|
|
3000
|
+
const requestedPlatforms: Platform[] = []
|
|
3001
|
+
let submissions = 0
|
|
3002
|
+
const client = {
|
|
3003
|
+
health: async () => true,
|
|
3004
|
+
compatibility: async (): Promise<CompatibilityResponse> => ({
|
|
3005
|
+
...currentCompatibility,
|
|
3006
|
+
// This service can still accept baseline Notification Requests, but it
|
|
3007
|
+
// cannot honor this CLI's Agent Acknowledgement feature yet.
|
|
3008
|
+
server_capabilities: ['answer'],
|
|
3009
|
+
}),
|
|
3010
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
3011
|
+
requestedPlatforms.push(platform)
|
|
3012
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
3013
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
3014
|
+
return document
|
|
3015
|
+
},
|
|
3016
|
+
listDevices: async () => ({ devices: [] }),
|
|
3017
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
3018
|
+
submit: async () => {
|
|
3019
|
+
submissions += 1
|
|
3020
|
+
return receipt
|
|
3021
|
+
},
|
|
3022
|
+
} as unknown as ApiClient
|
|
3023
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-capability-guidance-'))
|
|
3024
|
+
const io = new PlainInteractiveIo()
|
|
3025
|
+
const deps = {
|
|
3026
|
+
...makeDeps(io, client),
|
|
3027
|
+
cwd,
|
|
3028
|
+
env: isolatedEnv(cwd),
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
const readiness = await assessReadiness(deps)
|
|
3032
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
3033
|
+
expect(requestedPlatforms).toEqual([...PLATFORMS])
|
|
3034
|
+
expect(contract).toMatchObject({
|
|
3035
|
+
status: 'optional-gap',
|
|
3036
|
+
detail: 'The service is being updated; try again later.',
|
|
3037
|
+
technical: {
|
|
3038
|
+
capability_documents: PLATFORMS.map((platform) => ({ platform, schema_version: 1 })),
|
|
3039
|
+
cli_capability_intersection: {
|
|
3040
|
+
available: [],
|
|
3041
|
+
missing_on_server: ['agent_acknowledgement'],
|
|
3042
|
+
},
|
|
3043
|
+
},
|
|
3044
|
+
})
|
|
3045
|
+
|
|
3046
|
+
expect(
|
|
3047
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
3048
|
+
).toBe(EXIT.ok)
|
|
3049
|
+
expect(io.outLines).toEqual(['The service is being updated; try again later.'])
|
|
3050
|
+
expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
|
|
3051
|
+
|
|
3052
|
+
const sendIo = new CapturedIo()
|
|
3053
|
+
expect(
|
|
3054
|
+
await sendCommand(
|
|
3055
|
+
{ ...deps, io: sendIo },
|
|
3056
|
+
{ kind: 'update', title: 'Build finished', body: 'All checks passed.' },
|
|
3057
|
+
),
|
|
3058
|
+
).toBe(EXIT.ok)
|
|
3059
|
+
expect(submissions).toBe(1)
|
|
3060
|
+
expect(sendIo.errLines).toEqual([])
|
|
3061
|
+
})
|
|
3062
|
+
})
|
|
3063
|
+
|
|
2684
3064
|
describe('init', () => {
|
|
2685
3065
|
const readyIphone = {
|
|
2686
3066
|
device_id: 'dev_iphone',
|
|
@@ -2688,9 +3068,22 @@ describe('init', () => {
|
|
|
2688
3068
|
platform: 'ios' as const,
|
|
2689
3069
|
permission_status: 'authorized',
|
|
2690
3070
|
registration_healthy: true,
|
|
2691
|
-
|
|
3071
|
+
app_version: '0.1.0',
|
|
3072
|
+
app_build: '42',
|
|
3073
|
+
os_version: '19.0',
|
|
3074
|
+
capabilities: ['answer'] as const,
|
|
3075
|
+
support: currentSupport,
|
|
3076
|
+
support_state: 'current' as const,
|
|
3077
|
+
derived_status: 'working' as const,
|
|
3078
|
+
status_message: null,
|
|
2692
3079
|
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2693
3080
|
}
|
|
3081
|
+
const readyMac = {
|
|
3082
|
+
...readyIphone,
|
|
3083
|
+
device_id: 'dev_mac',
|
|
3084
|
+
display_name: 'Mac',
|
|
3085
|
+
platform: 'macos' as const,
|
|
3086
|
+
}
|
|
2694
3087
|
|
|
2695
3088
|
function setupEvidence(
|
|
2696
3089
|
requestId: string,
|
|
@@ -2816,6 +3209,58 @@ describe('init', () => {
|
|
|
2816
3209
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
2817
3210
|
})
|
|
2818
3211
|
|
|
3212
|
+
it('resumes the same init after a required CLI update becomes current', async () => {
|
|
3213
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-resume-'))
|
|
3214
|
+
const io = new CapturedIo()
|
|
3215
|
+
let cliSupport = compatibilityWithCli({
|
|
3216
|
+
state: 'must_update',
|
|
3217
|
+
reason: 'minimum_not_met',
|
|
3218
|
+
affected_operation: 'send_notifications',
|
|
3219
|
+
recovery_action: 'update_cli',
|
|
3220
|
+
recommended_version: '6.0.0',
|
|
3221
|
+
minimum_version: '6.0.0',
|
|
3222
|
+
})
|
|
3223
|
+
let submissions = 0
|
|
3224
|
+
const client = {
|
|
3225
|
+
health: async () => true,
|
|
3226
|
+
compatibility: async () => cliSupport,
|
|
3227
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
3228
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
3229
|
+
submit: async () => {
|
|
3230
|
+
submissions += 1
|
|
3231
|
+
return setupReceipt('req_after_update')
|
|
3232
|
+
},
|
|
3233
|
+
evidence: async () =>
|
|
3234
|
+
setupEvidence('req_after_update', {
|
|
3235
|
+
state: 'observed',
|
|
3236
|
+
observed_at: '2026-08-18T18:00:02.000Z',
|
|
3237
|
+
latency_ms: 1_000,
|
|
3238
|
+
}),
|
|
3239
|
+
} as unknown as ApiClient
|
|
3240
|
+
const deps = {
|
|
3241
|
+
...makeDeps(io, client),
|
|
3242
|
+
cwd,
|
|
3243
|
+
env: isolatedEnv(cwd),
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3247
|
+
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
3248
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3249
|
+
expect(submissions).toBe(0)
|
|
3250
|
+
|
|
3251
|
+
cliSupport = currentCompatibility
|
|
3252
|
+
io.outLines = []
|
|
3253
|
+
io.errLines = []
|
|
3254
|
+
|
|
3255
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
3256
|
+
expect(submissions).toBe(1)
|
|
3257
|
+
expect(io.outLines).toContain(
|
|
3258
|
+
'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
|
|
3259
|
+
)
|
|
3260
|
+
expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
|
|
3261
|
+
expect(io.errLines).toEqual([])
|
|
3262
|
+
})
|
|
3263
|
+
|
|
2819
3264
|
it('surfaces one next step, not the whole remaining list', async () => {
|
|
2820
3265
|
// The behavioural core of the design: someone handed five things to do
|
|
2821
3266
|
// does none of them. Signing in gates the device check, so naming both
|
|
@@ -3197,7 +3642,7 @@ describe('init', () => {
|
|
|
3197
3642
|
expect(out).toContain('Next: Your devices')
|
|
3198
3643
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
3199
3644
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
3200
|
-
expect(out).toContain('install Notifai on iPhone
|
|
3645
|
+
expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
|
|
3201
3646
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3202
3647
|
})
|
|
3203
3648
|
|
|
@@ -3246,7 +3691,7 @@ describe('init', () => {
|
|
|
3246
3691
|
})
|
|
3247
3692
|
|
|
3248
3693
|
it.each([
|
|
3249
|
-
['denied', '
|
|
3694
|
+
['denied', 'iPhone Settings'],
|
|
3250
3695
|
['not_determined', 'allow its notification prompt'],
|
|
3251
3696
|
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
3252
3697
|
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
@@ -3271,7 +3716,7 @@ describe('init', () => {
|
|
|
3271
3716
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3272
3717
|
})
|
|
3273
3718
|
|
|
3274
|
-
it('
|
|
3719
|
+
it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
|
|
3275
3720
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
3276
3721
|
const io = new InteractiveIo()
|
|
3277
3722
|
let now = 0
|
|
@@ -3281,7 +3726,7 @@ describe('init', () => {
|
|
|
3281
3726
|
const client = {
|
|
3282
3727
|
health: async () => true,
|
|
3283
3728
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3284
|
-
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
|
|
3729
|
+
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
|
|
3285
3730
|
accessStatus: async () => ({
|
|
3286
3731
|
status: 'active',
|
|
3287
3732
|
reason: 'alpha_grant',
|
|
@@ -3318,6 +3763,7 @@ describe('init', () => {
|
|
|
3318
3763
|
])
|
|
3319
3764
|
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
3320
3765
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
3766
|
+
expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
|
|
3321
3767
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
3322
3768
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3323
3769
|
expect(io.outLines.join('\n')).toContain(
|
|
@@ -3508,7 +3954,7 @@ describe('init', () => {
|
|
|
3508
3954
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
3509
3955
|
})
|
|
3510
3956
|
|
|
3511
|
-
it('
|
|
3957
|
+
it('does not treat a macOS-only installation as active-release readiness', async () => {
|
|
3512
3958
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
3513
3959
|
const io = new CapturedIo()
|
|
3514
3960
|
let submitCalls = 0
|
|
@@ -3516,7 +3962,7 @@ describe('init', () => {
|
|
|
3516
3962
|
health: async () => true,
|
|
3517
3963
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3518
3964
|
listDevices: async () => ({
|
|
3519
|
-
devices: [
|
|
3965
|
+
devices: [readyMac],
|
|
3520
3966
|
}),
|
|
3521
3967
|
submit: async () => {
|
|
3522
3968
|
submitCalls += 1
|
|
@@ -3529,16 +3975,16 @@ describe('init', () => {
|
|
|
3529
3975
|
env: isolatedEnv(cwd),
|
|
3530
3976
|
}
|
|
3531
3977
|
|
|
3532
|
-
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.
|
|
3978
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3533
3979
|
expect(submitCalls).toBe(0)
|
|
3534
3980
|
const out = io.outLines.join('\n')
|
|
3535
|
-
expect(out).toContain('
|
|
3536
|
-
expect(out).toContain('
|
|
3537
|
-
expect(out).not.toContain('
|
|
3981
|
+
expect(out).toContain('Next: Your devices')
|
|
3982
|
+
expect(out).toContain('no iPhone registered yet')
|
|
3983
|
+
expect(out).not.toContain('All set.')
|
|
3538
3984
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
3539
3985
|
|
|
3540
|
-
// doctor must render the same
|
|
3541
|
-
const doctorIo = new
|
|
3986
|
+
// A human doctor must render the same iPhone-readiness failure.
|
|
3987
|
+
const doctorIo = new PlainInteractiveIo()
|
|
3542
3988
|
expect(
|
|
3543
3989
|
await doctorCommand(
|
|
3544
3990
|
{
|
|
@@ -3548,11 +3994,11 @@ describe('init', () => {
|
|
|
3548
3994
|
},
|
|
3549
3995
|
{},
|
|
3550
3996
|
),
|
|
3551
|
-
).toBe(EXIT.
|
|
3997
|
+
).toBe(EXIT.failed)
|
|
3552
3998
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
3553
|
-
expect(doctorOut).toMatch(
|
|
3554
|
-
expect(doctorOut).toContain('
|
|
3555
|
-
expect(doctorOut).
|
|
3999
|
+
expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
|
|
4000
|
+
expect(doctorOut).toContain('no iPhone registered yet')
|
|
4001
|
+
expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
|
|
3556
4002
|
})
|
|
3557
4003
|
|
|
3558
4004
|
it('stops login when the approval page reports no Alpha access', async () => {
|
|
@@ -3977,7 +4423,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3977
4423
|
})
|
|
3978
4424
|
expect(
|
|
3979
4425
|
readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
|
|
3980
|
-
).
|
|
4426
|
+
).toBe('Gentle Salmon')
|
|
3981
4427
|
})
|
|
3982
4428
|
|
|
3983
4429
|
it('uploads ask images before registration and freezes canonical body media', async () => {
|
|
@@ -4228,7 +4674,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4228
4674
|
|
|
4229
4675
|
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
4230
4676
|
const cwd = scratchDir('notifai-codex-stale-trust-')
|
|
4231
|
-
const io = new
|
|
4677
|
+
const io = new PlainInteractiveIo()
|
|
4232
4678
|
const env = {
|
|
4233
4679
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4234
4680
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4493,7 +4939,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4493
4939
|
|
|
4494
4940
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
4495
4941
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
|
|
4496
|
-
const io = new
|
|
4942
|
+
const io = new PlainInteractiveIo()
|
|
4497
4943
|
const env = {
|
|
4498
4944
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4499
4945
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4553,7 +4999,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4553
4999
|
const localFile = personalProjectConfigPath(cwd, env)
|
|
4554
5000
|
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
4555
5001
|
writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
|
|
4556
|
-
const io = new
|
|
5002
|
+
const io = new PlainInteractiveIo()
|
|
4557
5003
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
4558
5004
|
|
|
4559
5005
|
await doctorCommand(deps, {})
|
|
@@ -4569,7 +5015,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4569
5015
|
|
|
4570
5016
|
it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
|
|
4571
5017
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
4572
|
-
const io = new
|
|
5018
|
+
const io = new PlainInteractiveIo()
|
|
4573
5019
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4574
5020
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
4575
5021
|
EXIT.ok,
|
|
@@ -4584,7 +5030,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4584
5030
|
|
|
4585
5031
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
4586
5032
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
4587
|
-
const io = new
|
|
5033
|
+
const io = new PlainInteractiveIo()
|
|
4588
5034
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4589
5035
|
// Exactly what every build before the asynchronous waiter wrote: a blocking
|
|
4590
5036
|
// handler with the old ceiling. It looks installed and it fires, but the
|
|
@@ -4634,7 +5080,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4634
5080
|
}
|
|
4635
5081
|
|
|
4636
5082
|
it('reports a live Claude session as reachable without connecting to its socket', async () => {
|
|
4637
|
-
const io = new
|
|
5083
|
+
const io = new PlainInteractiveIo()
|
|
4638
5084
|
const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
|
|
4639
5085
|
writeFileSync(socket, '')
|
|
4640
5086
|
const { deps } = claudeWakeDeps(io, (pid) => ({
|
|
@@ -4660,7 +5106,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4660
5106
|
})
|
|
4661
5107
|
|
|
4662
5108
|
it('names a --bare session as the reason an answer would wait for the next turn', async () => {
|
|
4663
|
-
const io = new
|
|
5109
|
+
const io = new PlainInteractiveIo()
|
|
4664
5110
|
const { deps } = claudeWakeDeps(io, () => {
|
|
4665
5111
|
throw new Error('ENOENT: no such file or directory')
|
|
4666
5112
|
})
|
|
@@ -4698,7 +5144,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4698
5144
|
|
|
4699
5145
|
it('reports Codex resume readiness from its writer-lock directory alone', async () => {
|
|
4700
5146
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
|
|
4701
|
-
const io = new
|
|
5147
|
+
const io = new PlainInteractiveIo()
|
|
4702
5148
|
const env = {
|
|
4703
5149
|
...isolatedEnv(cwd),
|
|
4704
5150
|
CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
|
|
@@ -4833,29 +5279,45 @@ describe('a server behind this CLI', () => {
|
|
|
4833
5279
|
expect(said).not.toMatch(/older than this CLI/)
|
|
4834
5280
|
})
|
|
4835
5281
|
|
|
4836
|
-
it('
|
|
4837
|
-
const io = new
|
|
5282
|
+
it('keeps baseline sends available while an older server lacks compatibility metadata', async () => {
|
|
5283
|
+
const io = new PlainInteractiveIo()
|
|
5284
|
+
let submissions = 0
|
|
4838
5285
|
const client = {
|
|
4839
5286
|
health: async () => true,
|
|
4840
|
-
|
|
4841
|
-
|
|
5287
|
+
compatibility: async () => {
|
|
5288
|
+
throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
|
|
5289
|
+
},
|
|
4842
5290
|
listDevices: async () => ({ devices: [] }),
|
|
5291
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
5292
|
+
submit: async () => {
|
|
5293
|
+
submissions += 1
|
|
5294
|
+
return receipt
|
|
5295
|
+
},
|
|
4843
5296
|
} as unknown as ApiClient
|
|
4844
5297
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
|
|
4845
5298
|
const deps = {
|
|
4846
5299
|
...makeDeps(io, client),
|
|
4847
5300
|
cwd,
|
|
4848
5301
|
env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
|
|
4849
|
-
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
4850
5302
|
} as CommandDeps
|
|
4851
5303
|
|
|
4852
5304
|
await doctorCommand(deps, {})
|
|
4853
5305
|
|
|
4854
5306
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
4855
|
-
// The label is the user's word for it; the detail is what must survive.
|
|
4856
|
-
expect(said).toMatch(/Protocol version/)
|
|
4857
5307
|
expect(said).toMatch(/service is being updated/)
|
|
4858
|
-
expect(said).not.
|
|
5308
|
+
expect(said).not.toContain('npm install -g @raidiant/notifai')
|
|
5309
|
+
expect(said).not.toMatch(/Protocol version|schema v/i)
|
|
5310
|
+
|
|
5311
|
+
const sendIo = new CapturedIo()
|
|
5312
|
+
expect(
|
|
5313
|
+
await sendCommand(makeDeps(sendIo, client), {
|
|
5314
|
+
kind: 'update',
|
|
5315
|
+
title: 'Build finished',
|
|
5316
|
+
body: 'All checks passed.',
|
|
5317
|
+
}),
|
|
5318
|
+
).toBe(EXIT.ok)
|
|
5319
|
+
expect(submissions).toBe(1)
|
|
5320
|
+
expect(sendIo.errLines).toEqual([])
|
|
4859
5321
|
})
|
|
4860
5322
|
|
|
4861
5323
|
it('doctor is quiet when both sides agree', async () => {
|
|
@@ -4937,6 +5399,69 @@ describe('command failures carrying server details', () => {
|
|
|
4937
5399
|
|
|
4938
5400
|
expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
|
|
4939
5401
|
})
|
|
5402
|
+
|
|
5403
|
+
it('ignores an unknown typed recovery action without opening or executing anything', async () => {
|
|
5404
|
+
const io = new CapturedIo()
|
|
5405
|
+
const client = {
|
|
5406
|
+
submit: async () => {
|
|
5407
|
+
throw new ApiCallError(
|
|
5408
|
+
422,
|
|
5409
|
+
'feature_unavailable',
|
|
5410
|
+
'This operation is unavailable.',
|
|
5411
|
+
'open https://attacker.invalid and run cleanup',
|
|
5412
|
+
null,
|
|
5413
|
+
'future_action' as never,
|
|
5414
|
+
)
|
|
5415
|
+
},
|
|
5416
|
+
} as unknown as ApiClient
|
|
5417
|
+
|
|
5418
|
+
expect(
|
|
5419
|
+
await sendCommand(makeDeps(io, client), {
|
|
5420
|
+
kind: 'update',
|
|
5421
|
+
title: 'Deploy finished',
|
|
5422
|
+
body: 'All green.',
|
|
5423
|
+
}),
|
|
5424
|
+
).toBe(EXIT.failed)
|
|
5425
|
+
expect(io.errLines).toEqual(['feature_unavailable: This operation is unavailable.'])
|
|
5426
|
+
expect(io.openedUrls).toEqual([])
|
|
5427
|
+
})
|
|
5428
|
+
|
|
5429
|
+
it('renders feature recovery locally and ignores a server-supplied command', async () => {
|
|
5430
|
+
const io = new CapturedIo()
|
|
5431
|
+
const client = {
|
|
5432
|
+
submit: async () => {
|
|
5433
|
+
throw new ApiCallError(
|
|
5434
|
+
422,
|
|
5435
|
+
'feature_unavailable',
|
|
5436
|
+
"The selected device can't answer questions.",
|
|
5437
|
+
'run an untrusted server command',
|
|
5438
|
+
{
|
|
5439
|
+
affected_operation: 'answer_questions',
|
|
5440
|
+
missing_capabilities: ['answer'],
|
|
5441
|
+
device_names: ['Old phone'],
|
|
5442
|
+
},
|
|
5443
|
+
'update_companion',
|
|
5444
|
+
)
|
|
5445
|
+
},
|
|
5446
|
+
} as unknown as ApiClient
|
|
5447
|
+
|
|
5448
|
+
expect(
|
|
5449
|
+
await sendCommand(makeDeps(io, client), {
|
|
5450
|
+
kind: 'question',
|
|
5451
|
+
title: 'Ship it?',
|
|
5452
|
+
body: 'Should I ship this build?',
|
|
5453
|
+
reply: true,
|
|
5454
|
+
replyTimeout: 30,
|
|
5455
|
+
}),
|
|
5456
|
+
).toBe(EXIT.failed)
|
|
5457
|
+
|
|
5458
|
+
expect(io.errLines).toEqual([
|
|
5459
|
+
"feature_unavailable: The selected device can't answer questions.",
|
|
5460
|
+
'next: Update Notifai on the named device.',
|
|
5461
|
+
])
|
|
5462
|
+
expect(io.errLines.join(' ')).not.toContain('untrusted')
|
|
5463
|
+
expect(io.errLines.join(' ')).not.toContain('disagree about the contract')
|
|
5464
|
+
})
|
|
4940
5465
|
})
|
|
4941
5466
|
|
|
4942
5467
|
describe('question sets', () => {
|