@raidiant/notifai 10.1.0 → 10.1.1
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 +17 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +8 -1
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +245 -87
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +44 -11
- package/src/commands-core.ts +18 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +116 -55
- package/src/commands-hooks.ts +66 -16
- package/src/commands-init.ts +265 -88
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +518 -49
- package/src/commands.ts +2 -0
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +28 -4
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +11 -0
- package/src/commands-devices.test.ts +0 -15
package/src/commands.test.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notif
|
|
|
2
2
|
import { execFileSync } from 'node:child_process'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import {
|
|
5
|
+
chmodSync,
|
|
5
6
|
existsSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
mkdtempSync,
|
|
8
9
|
readFileSync,
|
|
10
|
+
realpathSync,
|
|
9
11
|
readdirSync,
|
|
10
12
|
rmSync,
|
|
11
13
|
symlinkSync,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
repliesCommand,
|
|
60
62
|
sendCommand,
|
|
61
63
|
statusCommand,
|
|
64
|
+
updateCliCommand,
|
|
62
65
|
agentSessionRenameCommand,
|
|
63
66
|
type CommandDeps,
|
|
64
67
|
type CommandIo,
|
|
@@ -75,15 +78,21 @@ import {
|
|
|
75
78
|
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
76
79
|
settingsFile,
|
|
77
80
|
} from './install-hooks.js'
|
|
78
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
inspectQuestionState,
|
|
83
|
+
readSessionState,
|
|
84
|
+
writeProjectSession,
|
|
85
|
+
writeSessionState,
|
|
86
|
+
} from './hooks.js'
|
|
79
87
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
80
88
|
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
|
|
81
89
|
import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
|
|
82
90
|
import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
83
91
|
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
84
|
-
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
92
|
+
import { hookAdapterPath, inspectHookAdapter, installHookAdapter } from './hook-adapter.js'
|
|
85
93
|
import type { Tone } from './ui/theme.js'
|
|
86
94
|
import { projectBinding, projectEnabled } from './project-enablement.js'
|
|
95
|
+
import { firstRequiredBlocker } from './readiness.js'
|
|
87
96
|
|
|
88
97
|
afterEach(() => {
|
|
89
98
|
resetLatestPublishedCliVersionForTest()
|
|
@@ -469,8 +478,8 @@ describe('command contracts', () => {
|
|
|
469
478
|
|
|
470
479
|
expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
|
|
471
480
|
expect(io.outLines).toEqual([
|
|
472
|
-
'
|
|
473
|
-
'next: Open https://test.notifai.invalid/
|
|
481
|
+
'This account does not have access to Notifai yet.',
|
|
482
|
+
'next: Open https://test.notifai.invalid/setup/access to set up access, then retry.',
|
|
474
483
|
])
|
|
475
484
|
})
|
|
476
485
|
|
|
@@ -2125,6 +2134,10 @@ describe('command contracts', () => {
|
|
|
2125
2134
|
closed: [],
|
|
2126
2135
|
})
|
|
2127
2136
|
expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
|
|
2137
|
+
expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
|
|
2138
|
+
found: true,
|
|
2139
|
+
question: { state: 'withdrawn', submitted: false, request_id: null },
|
|
2140
|
+
})
|
|
2128
2141
|
})
|
|
2129
2142
|
|
|
2130
2143
|
it('close withdraws one unpushed question by stable id from another checkout', async () => {
|
|
@@ -2182,6 +2195,7 @@ describe('command contracts', () => {
|
|
|
2182
2195
|
{ question: 'Unpushed?', question_id: 'q_local' },
|
|
2183
2196
|
{
|
|
2184
2197
|
question: 'Already on a device?',
|
|
2198
|
+
question_id: 'q_live',
|
|
2185
2199
|
request_id: receipt.request_id,
|
|
2186
2200
|
collapse_key: 'question-live',
|
|
2187
2201
|
device_ids: ['dev_iphone'],
|
|
@@ -2198,6 +2212,14 @@ describe('command contracts', () => {
|
|
|
2198
2212
|
closed: [receipt.request_id],
|
|
2199
2213
|
})
|
|
2200
2214
|
expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
|
|
2215
|
+
expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
|
|
2216
|
+
found: true,
|
|
2217
|
+
question: { state: 'withdrawn', submitted: false, request_id: null },
|
|
2218
|
+
})
|
|
2219
|
+
expect(inspectQuestionState('q_live', deps.env)).toMatchObject({
|
|
2220
|
+
found: true,
|
|
2221
|
+
question: { state: 'retired', submitted: true, request_id: receipt.request_id },
|
|
2222
|
+
})
|
|
2201
2223
|
})
|
|
2202
2224
|
|
|
2203
2225
|
it('close requires a request id or --pending', async () => {
|
|
@@ -2609,6 +2631,86 @@ describe('credential origin pinning', () => {
|
|
|
2609
2631
|
'next: Ask the account owner for Alpha access.',
|
|
2610
2632
|
'After access is granted, run `notifai init` again.',
|
|
2611
2633
|
])
|
|
2634
|
+
expect(io.errLines.join('\n')).not.toMatch(/Pairing expired/i)
|
|
2635
|
+
expect(io.errLines.join('\n')).not.toContain('notifai login')
|
|
2636
|
+
expect(now).toBeLessThan(10_000)
|
|
2637
|
+
})
|
|
2638
|
+
|
|
2639
|
+
it('reports the access blocker to whoever asked for the sign-in', async () => {
|
|
2640
|
+
const io = new CapturedIo()
|
|
2641
|
+
let now = 0
|
|
2642
|
+
const client = {
|
|
2643
|
+
beginPairing: async () => ({
|
|
2644
|
+
pairing_id: 'pair_test',
|
|
2645
|
+
code: 'ABCD-EFGH',
|
|
2646
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2647
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2648
|
+
poll_interval_seconds: 1,
|
|
2649
|
+
}),
|
|
2650
|
+
pollPairing: async () => ({
|
|
2651
|
+
status: 'no_active_plan',
|
|
2652
|
+
next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
2653
|
+
}),
|
|
2654
|
+
} as unknown as ApiClient
|
|
2655
|
+
const deps: CommandDeps = {
|
|
2656
|
+
...makeDeps(io, client),
|
|
2657
|
+
now: () => now,
|
|
2658
|
+
sleep: async (milliseconds: number) => {
|
|
2659
|
+
now += milliseconds
|
|
2660
|
+
},
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
let blocked: { title: string; detail: string } | null = null
|
|
2664
|
+
expect(
|
|
2665
|
+
await loginCommand(deps, { open: false }, (state) => {
|
|
2666
|
+
blocked = state
|
|
2667
|
+
}),
|
|
2668
|
+
).toBe(EXIT.auth)
|
|
2669
|
+
// Handing the blocker over hands over the errand. Saying it here too gave
|
|
2670
|
+
// the caller's close a second, differently worded copy to contradict.
|
|
2671
|
+
expect(io.errLines).toEqual([])
|
|
2672
|
+
// Without this the caller falls back to the state it held before the
|
|
2673
|
+
// attempt — "not paired … run `notifai init`" — and prints it under the
|
|
2674
|
+
// correct line, in contradiction with it.
|
|
2675
|
+
expect(blocked).toMatchObject({
|
|
2676
|
+
id: 'auth',
|
|
2677
|
+
title: 'Access',
|
|
2678
|
+
status: 'gap',
|
|
2679
|
+
detail: 'this account does not have access to Notifai yet',
|
|
2680
|
+
remedy: {
|
|
2681
|
+
by: 'user-elsewhere',
|
|
2682
|
+
summary: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
2683
|
+
},
|
|
2684
|
+
})
|
|
2685
|
+
})
|
|
2686
|
+
|
|
2687
|
+
it('reports expiry only when pairing times out without a no-access mark', async () => {
|
|
2688
|
+
const io = new CapturedIo()
|
|
2689
|
+
let now = 0
|
|
2690
|
+
const client = {
|
|
2691
|
+
beginPairing: async () => ({
|
|
2692
|
+
pairing_id: 'pair_test',
|
|
2693
|
+
code: 'ABCD-EFGH',
|
|
2694
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2695
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2696
|
+
poll_interval_seconds: 1,
|
|
2697
|
+
}),
|
|
2698
|
+
pollPairing: async () => ({ status: 'expired' }),
|
|
2699
|
+
} as unknown as ApiClient
|
|
2700
|
+
const deps: CommandDeps = {
|
|
2701
|
+
...makeDeps(io, client),
|
|
2702
|
+
now: () => now,
|
|
2703
|
+
sleep: async (milliseconds: number) => {
|
|
2704
|
+
now += milliseconds
|
|
2705
|
+
},
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
2709
|
+
expect(io.errLines).toEqual([
|
|
2710
|
+
'Pairing expired before it was approved. Run `notifai init` again.',
|
|
2711
|
+
])
|
|
2712
|
+
expect(io.errLines.join('\n')).not.toMatch(/no active plan/i)
|
|
2713
|
+
expect(io.errLines.join('\n')).not.toContain('notifai login')
|
|
2612
2714
|
})
|
|
2613
2715
|
|
|
2614
2716
|
it('keeps notification titles out of send logs', async () => {
|
|
@@ -2705,6 +2807,96 @@ describe('delivery evidence status', () => {
|
|
|
2705
2807
|
}
|
|
2706
2808
|
}
|
|
2707
2809
|
|
|
2810
|
+
it('reports every local question state by q identity without creating or submitting anything', async () => {
|
|
2811
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-question-status-'))
|
|
2812
|
+
const env = {
|
|
2813
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
2814
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2815
|
+
}
|
|
2816
|
+
writeSessionState('question-states', env, {
|
|
2817
|
+
pending: [
|
|
2818
|
+
{ question: 'Local?', question_id: 'q_local' },
|
|
2819
|
+
{
|
|
2820
|
+
question: 'Frozen?',
|
|
2821
|
+
question_id: 'q_frozen',
|
|
2822
|
+
submission: {
|
|
2823
|
+
request_id: 'req_frozen',
|
|
2824
|
+
idempotency_key: 'idem-frozen',
|
|
2825
|
+
collapse_key: 'collapse-frozen',
|
|
2826
|
+
device_ids: ['dev_status_test'],
|
|
2827
|
+
draft: {} as never,
|
|
2828
|
+
owner_deadline_at: Date.now() + 60_000,
|
|
2829
|
+
},
|
|
2830
|
+
},
|
|
2831
|
+
{ question: 'Live?', question_id: 'q_live', request_id: 'req_live' },
|
|
2832
|
+
],
|
|
2833
|
+
question_history: [
|
|
2834
|
+
{ question_id: 'q_answered', state: 'answered', request_id: 'req_answered' },
|
|
2835
|
+
{ question_id: 'q_withdrawn', state: 'withdrawn' },
|
|
2836
|
+
{ question_id: 'q_retired', state: 'retired', request_id: 'req_retired' },
|
|
2837
|
+
],
|
|
2838
|
+
})
|
|
2839
|
+
const evidenceCalls: string[] = []
|
|
2840
|
+
const client = {
|
|
2841
|
+
evidence: async (requestId: string) => {
|
|
2842
|
+
evidenceCalls.push(requestId)
|
|
2843
|
+
return { ...snapshot({ state: 'unknown', observed_at: null, latency_ms: null }), request_id: requestId }
|
|
2844
|
+
},
|
|
2845
|
+
} as unknown as ApiClient
|
|
2846
|
+
const io = new CapturedIo()
|
|
2847
|
+
const deps = { ...makeDeps(io, client), cwd: root, env }
|
|
2848
|
+
|
|
2849
|
+
const expected = [
|
|
2850
|
+
['q_local', 'local', false, null, null],
|
|
2851
|
+
['q_frozen', 'frozen', null, null, 'req_frozen'],
|
|
2852
|
+
['q_live', 'live', true, 'req_live', null],
|
|
2853
|
+
['q_answered', 'answered', true, 'req_answered', null],
|
|
2854
|
+
['q_withdrawn', 'withdrawn', false, null, null],
|
|
2855
|
+
['q_retired', 'retired', true, 'req_retired', null],
|
|
2856
|
+
] as const
|
|
2857
|
+
for (const [questionId, state, submitted, requestId, frozenRequestId] of expected) {
|
|
2858
|
+
io.outLines = []
|
|
2859
|
+
expect(await statusCommand(deps, questionId, { json: true })).toBe(EXIT.ok)
|
|
2860
|
+
expect(JSON.parse(io.outLines.join('\n'))).toMatchObject({
|
|
2861
|
+
question_id: questionId,
|
|
2862
|
+
state,
|
|
2863
|
+
submitted,
|
|
2864
|
+
request_id: requestId,
|
|
2865
|
+
frozen_request_id: frozenRequestId,
|
|
2866
|
+
recovery: {
|
|
2867
|
+
inspect: `notifai status ${questionId}`,
|
|
2868
|
+
close: `notifai close ${questionId}`,
|
|
2869
|
+
register_replacement: false,
|
|
2870
|
+
},
|
|
2871
|
+
})
|
|
2872
|
+
}
|
|
2873
|
+
expect(evidenceCalls).toEqual(['req_live', 'req_answered', 'req_retired'])
|
|
2874
|
+
})
|
|
2875
|
+
|
|
2876
|
+
it('states plainly that a local registration has no submission or Provider Acceptance', async () => {
|
|
2877
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-local-question-status-'))
|
|
2878
|
+
const env = { XDG_STATE_HOME: path.join(root, 'state') }
|
|
2879
|
+
writeSessionState('local-question', env, {
|
|
2880
|
+
pending: [{ question: 'Ship?', question_id: 'q_local_plain' }],
|
|
2881
|
+
})
|
|
2882
|
+
const io = new CapturedIo()
|
|
2883
|
+
const client = {
|
|
2884
|
+
evidence: async () => {
|
|
2885
|
+
throw new Error('local status must not call the service')
|
|
2886
|
+
},
|
|
2887
|
+
} as unknown as ApiClient
|
|
2888
|
+
|
|
2889
|
+
expect(
|
|
2890
|
+
await statusCommand({ ...makeDeps(io, client), cwd: root, env }, 'q_local_plain', {}),
|
|
2891
|
+
).toBe(EXIT.ok)
|
|
2892
|
+
const said = io.outLines.join('\n')
|
|
2893
|
+
expect(said).toContain('question q_local_plain — local')
|
|
2894
|
+
expect(said).toContain('not submitted')
|
|
2895
|
+
expect(said).toContain('only a local registration')
|
|
2896
|
+
expect(said).toContain('Provider Acceptance: not available')
|
|
2897
|
+
expect(said).toContain('do not register a replacement')
|
|
2898
|
+
})
|
|
2899
|
+
|
|
2708
2900
|
it('calls an unobserved first-minute receipt unknown rather than failed', async () => {
|
|
2709
2901
|
const io = new CapturedIo()
|
|
2710
2902
|
const client = {
|
|
@@ -4150,7 +4342,7 @@ describe('interactive command UX', () => {
|
|
|
4150
4342
|
|
|
4151
4343
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
4152
4344
|
expect(io.outLines).toContain('A newer Notifai is available.')
|
|
4153
|
-
expect(io.outLines).toContain(
|
|
4345
|
+
expect(io.outLines).toContain(updateCliCommand(deps))
|
|
4154
4346
|
})
|
|
4155
4347
|
})
|
|
4156
4348
|
|
|
@@ -4161,7 +4353,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4161
4353
|
status: 'ready' | 'optional-gap' | 'gap'
|
|
4162
4354
|
detail: string
|
|
4163
4355
|
exit: number
|
|
4164
|
-
humanLines: string[]
|
|
4165
4356
|
}> = [
|
|
4166
4357
|
{
|
|
4167
4358
|
name: 'current',
|
|
@@ -4169,7 +4360,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4169
4360
|
status: 'ready',
|
|
4170
4361
|
detail: 'Notifai can send notifications.',
|
|
4171
4362
|
exit: EXIT.ok,
|
|
4172
|
-
humanLines: [],
|
|
4173
4363
|
},
|
|
4174
4364
|
{
|
|
4175
4365
|
name: 'optional newer release',
|
|
@@ -4182,10 +4372,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4182
4372
|
status: 'optional-gap',
|
|
4183
4373
|
detail: 'A newer Notifai is available.',
|
|
4184
4374
|
exit: EXIT.ok,
|
|
4185
|
-
humanLines: [
|
|
4186
|
-
'A newer Notifai is available.',
|
|
4187
|
-
'npm install -g @raidiant/notifai',
|
|
4188
|
-
],
|
|
4189
4375
|
},
|
|
4190
4376
|
{
|
|
4191
4377
|
name: 'scheduled Sunset',
|
|
@@ -4200,10 +4386,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4200
4386
|
status: 'optional-gap',
|
|
4201
4387
|
detail: 'Update Notifai soon to keep sending notifications.',
|
|
4202
4388
|
exit: EXIT.ok,
|
|
4203
|
-
humanLines: [
|
|
4204
|
-
'Update Notifai soon to keep sending notifications.',
|
|
4205
|
-
'npm install -g @raidiant/notifai',
|
|
4206
|
-
],
|
|
4207
4389
|
},
|
|
4208
4390
|
{
|
|
4209
4391
|
name: 'required update',
|
|
@@ -4218,16 +4400,12 @@ describe('compatibility-first update guidance', () => {
|
|
|
4218
4400
|
status: 'gap',
|
|
4219
4401
|
detail: "Notifai can't send notifications until you update.",
|
|
4220
4402
|
exit: EXIT.failed,
|
|
4221
|
-
humanLines: [
|
|
4222
|
-
"Notifai can't send notifications until you update.",
|
|
4223
|
-
'npm install -g @raidiant/notifai',
|
|
4224
|
-
],
|
|
4225
4403
|
},
|
|
4226
4404
|
]
|
|
4227
4405
|
|
|
4228
4406
|
it.each(cases)(
|
|
4229
4407
|
'maps $name policy to closed human copy and structured JSON',
|
|
4230
|
-
async ({ support, status, detail, exit
|
|
4408
|
+
async ({ support, status, detail, exit }) => {
|
|
4231
4409
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
|
|
4232
4410
|
const client = {
|
|
4233
4411
|
health: async () => true,
|
|
@@ -4248,7 +4426,9 @@ describe('compatibility-first update guidance', () => {
|
|
|
4248
4426
|
expect(
|
|
4249
4427
|
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
4250
4428
|
).toBe(exit)
|
|
4251
|
-
expect(humanIo.outLines).toEqual(
|
|
4429
|
+
expect(humanIo.outLines).toEqual(
|
|
4430
|
+
status === 'ready' ? [] : [detail, updateCliCommand(deps)],
|
|
4431
|
+
)
|
|
4252
4432
|
expect(humanIo.errLines).toEqual([])
|
|
4253
4433
|
expect(humanIo.outros).toEqual(
|
|
4254
4434
|
status === 'ready' ? ['Everything looks good'] : [],
|
|
@@ -4278,7 +4458,7 @@ describe('compatibility-first update guidance', () => {
|
|
|
4278
4458
|
states: [{ status, detail }],
|
|
4279
4459
|
})
|
|
4280
4460
|
expect(payload.states[0]?.remedy?.command ?? null).toBe(
|
|
4281
|
-
status === 'ready' ? null :
|
|
4461
|
+
status === 'ready' ? null : updateCliCommand(deps),
|
|
4282
4462
|
)
|
|
4283
4463
|
expect(jsonIo.errLines).toEqual([])
|
|
4284
4464
|
|
|
@@ -4347,7 +4527,7 @@ describe('compatibility-first update guidance', () => {
|
|
|
4347
4527
|
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
4348
4528
|
).toBe(EXIT.ok)
|
|
4349
4529
|
expect(io.outLines).toEqual(['The service is being updated; try again later.'])
|
|
4350
|
-
expect(io.outLines.join('\n')).not.toContain('
|
|
4530
|
+
expect(io.outLines.join('\n')).not.toContain('notifai update')
|
|
4351
4531
|
|
|
4352
4532
|
const sendIo = new CapturedIo()
|
|
4353
4533
|
expect(
|
|
@@ -4560,7 +4740,7 @@ describe('init', () => {
|
|
|
4560
4740
|
}),
|
|
4561
4741
|
).toBe(EXIT.failed)
|
|
4562
4742
|
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
4563
|
-
expect(io.outLines).toContain(
|
|
4743
|
+
expect(io.outLines).toContain(updateCliCommand(deps))
|
|
4564
4744
|
expect(io.outros).toEqual([])
|
|
4565
4745
|
expect(submissions).toBe(0)
|
|
4566
4746
|
|
|
@@ -4580,10 +4760,54 @@ describe('init', () => {
|
|
|
4580
4760
|
expect(io.outLines).toContain(
|
|
4581
4761
|
'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
4582
4762
|
)
|
|
4583
|
-
expect(io.outLines).not.toContain('
|
|
4763
|
+
expect(io.outLines).not.toContain('notifai update')
|
|
4584
4764
|
expect(io.errLines).toEqual([])
|
|
4585
4765
|
})
|
|
4586
4766
|
|
|
4767
|
+
it('retargets Question Routing while the post-update init resumes', async () => {
|
|
4768
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-adapter-'))
|
|
4769
|
+
const io = new CapturedIo()
|
|
4770
|
+
const nativeSkills: NativeSkills = {
|
|
4771
|
+
add: async () => 0,
|
|
4772
|
+
remove: async () => 0,
|
|
4773
|
+
list: async () => ({ skills: [] }),
|
|
4774
|
+
}
|
|
4775
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, { submit: 0 })
|
|
4776
|
+
const oldArtifact = path.join(cwd, 'old', 'dist', 'main.js')
|
|
4777
|
+
const prefix = path.join(cwd, 'current')
|
|
4778
|
+
const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
|
|
4779
|
+
const currentArtifact = path.join(packageRoot, 'dist', 'main.js')
|
|
4780
|
+
const currentCommand = path.join(prefix, 'bin', 'notifai')
|
|
4781
|
+
mkdirSync(path.dirname(oldArtifact), { recursive: true })
|
|
4782
|
+
mkdirSync(path.dirname(currentArtifact), { recursive: true })
|
|
4783
|
+
mkdirSync(path.dirname(currentCommand), { recursive: true })
|
|
4784
|
+
writeFileSync(oldArtifact, '#!/usr/bin/env node\n')
|
|
4785
|
+
writeFileSync(currentArtifact, '#!/usr/bin/env node\n')
|
|
4786
|
+
writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: RELEASE_REF.slice(1) }))
|
|
4787
|
+
chmodSync(oldArtifact, 0o755)
|
|
4788
|
+
chmodSync(currentArtifact, 0o755)
|
|
4789
|
+
symlinkSync(path.relative(path.dirname(currentCommand), currentArtifact), currentCommand)
|
|
4790
|
+
deps.env = { ...deps.env, PATH: path.dirname(currentCommand) }
|
|
4791
|
+
deps.hookInstallTarget = { execPath: process.execPath, scriptPath: currentArtifact }
|
|
4792
|
+
|
|
4793
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: oldArtifact }, deps.hookAdapterHome)
|
|
4794
|
+
applyPlan(path.join(cwd, '.codex', 'hooks.json'), {
|
|
4795
|
+
hooks: buildHookConfig({
|
|
4796
|
+
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
4797
|
+
harness: 'codex',
|
|
4798
|
+
}),
|
|
4799
|
+
})
|
|
4800
|
+
|
|
4801
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({ scriptPath: oldArtifact })
|
|
4802
|
+
expect(
|
|
4803
|
+
await initCommand(deps, { hooks: false, setupScope: 'project', skills: false }),
|
|
4804
|
+
).toBe(EXIT.ok)
|
|
4805
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({
|
|
4806
|
+
scriptPath: realpathSync(currentArtifact),
|
|
4807
|
+
})
|
|
4808
|
+
expect(io.outLines.join('\n')).not.toContain('notifai hooks install')
|
|
4809
|
+
})
|
|
4810
|
+
|
|
4587
4811
|
it('surfaces one next step, not the whole remaining list', async () => {
|
|
4588
4812
|
// The behavioural core of the design: someone handed five things to do
|
|
4589
4813
|
// does none of them. Signing in gates the device check, so naming both
|
|
@@ -4626,19 +4850,23 @@ describe('init', () => {
|
|
|
4626
4850
|
)
|
|
4627
4851
|
})
|
|
4628
4852
|
|
|
4629
|
-
it('
|
|
4853
|
+
it('closes what a process can close when no scope was passed, and asks nothing', async () => {
|
|
4630
4854
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
|
|
4631
4855
|
const io = new CapturedIo()
|
|
4632
4856
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4633
4857
|
|
|
4634
4858
|
expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
|
|
4635
|
-
|
|
4859
|
+
// Naming this checkout is not skill or hook placement, so it no longer
|
|
4860
|
+
// waits on a scope question nobody can answer here.
|
|
4861
|
+
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project = "')
|
|
4636
4862
|
const result = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4637
|
-
states: Array<{ id: string; status: string }>
|
|
4863
|
+
states: Array<{ id: string; status: string; remedy: { command?: string } | null }>
|
|
4638
4864
|
}
|
|
4639
|
-
expect(result.states.find((state) => state.id === 'project')).toMatchObject({
|
|
4640
|
-
|
|
4641
|
-
|
|
4865
|
+
expect(result.states.find((state) => state.id === 'project')).toMatchObject({ status: 'ready' })
|
|
4866
|
+
// The run stops where a human genuinely is required, and never hands back
|
|
4867
|
+
// the command that produced this output as the thing to do about it.
|
|
4868
|
+
const project = result.states.find((state) => state.id === 'project')
|
|
4869
|
+
expect(project?.remedy).toBeNull()
|
|
4642
4870
|
expect(io.errLines).toEqual([])
|
|
4643
4871
|
})
|
|
4644
4872
|
|
|
@@ -5096,7 +5324,11 @@ describe('init', () => {
|
|
|
5096
5324
|
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
|
|
5097
5325
|
expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
|
|
5098
5326
|
expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
|
|
5099
|
-
|
|
5327
|
+
// A hook diagnostic is a report line. The run it appears in still reaches
|
|
5328
|
+
// the delivery proof, which is the thing `init` is there to produce.
|
|
5329
|
+
expect(io.outLines.join('\n')).toContain('Companion Receipt')
|
|
5330
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5331
|
+
expect(io.outLines.join('\n')).not.toContain('Next: Codex hook trust')
|
|
5100
5332
|
})
|
|
5101
5333
|
|
|
5102
5334
|
it('lets a human keep a subset of the detected harnesses', async () => {
|
|
@@ -5231,11 +5463,11 @@ describe('init', () => {
|
|
|
5231
5463
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
5232
5464
|
const out = io.outLines.join('\n')
|
|
5233
5465
|
expect(out).toContain('Next: Your devices')
|
|
5234
|
-
|
|
5466
|
+
// One focused destination for this errand, never the omnibus help page.
|
|
5467
|
+
expect(out).toContain('https://test.notifai.invalid/setup/companion')
|
|
5468
|
+
expect(out).not.toContain('/support')
|
|
5235
5469
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
5236
|
-
expect(out).toContain(
|
|
5237
|
-
'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
|
|
5238
|
-
)
|
|
5470
|
+
expect(out).toContain('no active Companion App registered yet')
|
|
5239
5471
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
5240
5472
|
})
|
|
5241
5473
|
|
|
@@ -5350,6 +5582,9 @@ describe('init', () => {
|
|
|
5350
5582
|
},
|
|
5351
5583
|
}
|
|
5352
5584
|
|
|
5585
|
+
// The terminal asks which phone, in those words, and the destination it
|
|
5586
|
+
// opens is already about that answer — nobody chooses twice.
|
|
5587
|
+
io.selectAnswer = 'iphone'
|
|
5353
5588
|
expect(
|
|
5354
5589
|
await initCommand(deps, {
|
|
5355
5590
|
hooks: false,
|
|
@@ -5358,10 +5593,11 @@ describe('init', () => {
|
|
|
5358
5593
|
}),
|
|
5359
5594
|
).toBe(EXIT.ok)
|
|
5360
5595
|
expect(io.prompts).toEqual([
|
|
5361
|
-
'
|
|
5362
|
-
'
|
|
5596
|
+
'Where do you want to receive notifications?',
|
|
5597
|
+
'Open those steps in your browser?',
|
|
5598
|
+
'Wait here while you finish that on your phone?',
|
|
5363
5599
|
])
|
|
5364
|
-
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/
|
|
5600
|
+
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/setup/companion?platform=iphone'])
|
|
5365
5601
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
5366
5602
|
expect(io.spinnerEvents).toContain(
|
|
5367
5603
|
'message:Waiting for a Companion App to sign in and register…',
|
|
@@ -5375,6 +5611,11 @@ describe('init', () => {
|
|
|
5375
5611
|
expect(submitCalls).toBe(1)
|
|
5376
5612
|
expect(submittedDraft?.draft).not.toHaveProperty('event')
|
|
5377
5613
|
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
5614
|
+
// The proof is a real send that has to be seen: a normal banner and sound,
|
|
5615
|
+
// not a silent arrival in Notification Center a first-time User never
|
|
5616
|
+
// looks at. Its sound is left to the server's kind/Project/Account layers.
|
|
5617
|
+
expect(submittedDraft?.draft.platform).toEqual({ ios: { interruption_level: 'active' } })
|
|
5618
|
+
expect(submittedDraft?.draft.kind).toBe('done')
|
|
5378
5619
|
|
|
5379
5620
|
io.outLines = []
|
|
5380
5621
|
io.prompts = []
|
|
@@ -5481,7 +5722,8 @@ describe('init', () => {
|
|
|
5481
5722
|
).toBe(EXIT.failed)
|
|
5482
5723
|
const out = io.outLines.join('\n')
|
|
5483
5724
|
expect(out).toContain('Next: Your devices')
|
|
5484
|
-
expect(out).toContain('https://test.notifai.invalid/
|
|
5725
|
+
expect(out).toContain('https://test.notifai.invalid/setup/companion')
|
|
5726
|
+
expect(out).not.toContain('/support')
|
|
5485
5727
|
expect(out).toContain('sign in with the same email as this account (agent@example.com)')
|
|
5486
5728
|
expect(io.openedUrls).toEqual([])
|
|
5487
5729
|
})
|
|
@@ -5632,6 +5874,57 @@ describe('init', () => {
|
|
|
5632
5874
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
5633
5875
|
})
|
|
5634
5876
|
|
|
5877
|
+
it('writes the first notification about the User, not about the check', async () => {
|
|
5878
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-copy-'))
|
|
5879
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5880
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "orders-api"\n')
|
|
5881
|
+
const io = new CapturedIo()
|
|
5882
|
+
let submitted: SubmitNotificationRequestT | null = null
|
|
5883
|
+
const client = {
|
|
5884
|
+
health: async () => true,
|
|
5885
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5886
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
5887
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
5888
|
+
submitted = body
|
|
5889
|
+
return setupReceipt()
|
|
5890
|
+
},
|
|
5891
|
+
evidence: async (requestId: string) =>
|
|
5892
|
+
setupEvidence(requestId, {
|
|
5893
|
+
state: 'observed',
|
|
5894
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
5895
|
+
latency_ms: 1_000,
|
|
5896
|
+
}),
|
|
5897
|
+
} as unknown as ApiClient
|
|
5898
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
5899
|
+
|
|
5900
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5901
|
+
const presentation = submitted!.draft.presentation
|
|
5902
|
+
|
|
5903
|
+
// A title says what is now true for the reader, alone on a lock screen,
|
|
5904
|
+
// and carries neither the kind nor the Project — both travel as their own
|
|
5905
|
+
// fields.
|
|
5906
|
+
expect(presentation.title).toBe('Your agents can reach you')
|
|
5907
|
+
expect(presentation.title.length).toBeLessThanOrEqual(40)
|
|
5908
|
+
expect(presentation.title).not.toMatch(/orders-api|done/i)
|
|
5909
|
+
|
|
5910
|
+
// None of the vocabulary of the check that produced it: the reader has not
|
|
5911
|
+
// met "verification", a "proof", or the distinction between a real
|
|
5912
|
+
// notification and any other kind.
|
|
5913
|
+
const copy = `${presentation.title} ${presentation.body}`
|
|
5914
|
+
expect(copy).not.toMatch(/verification|verify|proof|receipt|real notification|test/i)
|
|
5915
|
+
// And nothing about where it landed or what to do to it.
|
|
5916
|
+
expect(copy).not.toMatch(/iPhone|Android|phone|tap|swipe|banner|lock screen/i)
|
|
5917
|
+
|
|
5918
|
+
expect(presentation.body).toContain('orders-api')
|
|
5919
|
+
expect(presentation.body).toMatch(/^Notifai setup is finished/)
|
|
5920
|
+
|
|
5921
|
+
// Still the same bar: a real send whose Companion Receipt was observed.
|
|
5922
|
+
expect(io.outLines.join('\n')).toContain(
|
|
5923
|
+
"Companion Receipt (the app's delivery confirmation) observed from iPhone.",
|
|
5924
|
+
)
|
|
5925
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5926
|
+
})
|
|
5927
|
+
|
|
5635
5928
|
it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
|
|
5636
5929
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
|
|
5637
5930
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
@@ -5670,7 +5963,12 @@ describe('init', () => {
|
|
|
5670
5963
|
mode: 'selected',
|
|
5671
5964
|
device_ids: [readyAndroid.device_id],
|
|
5672
5965
|
})
|
|
5673
|
-
|
|
5966
|
+
// Android has no caller-selected interruption level, so a configured Apple
|
|
5967
|
+
// preference must not reach it — and nothing else is stamped either, so the
|
|
5968
|
+
// server's kind sound applies rather than the old hard silence.
|
|
5969
|
+
expect(submitted?.draft.platform).toBeUndefined()
|
|
5970
|
+
expect(JSON.stringify(submitted?.draft)).not.toContain('interruption_level')
|
|
5971
|
+
expect(submitted?.draft.kind).toBe('done')
|
|
5674
5972
|
expect(io.outLines.join('\n')).toContain(
|
|
5675
5973
|
"Companion Receipt (the app's delivery confirmation) observed from Pixel.",
|
|
5676
5974
|
)
|
|
@@ -5702,7 +6000,7 @@ describe('init', () => {
|
|
|
5702
6000
|
expect(submitCalls).toBe(0)
|
|
5703
6001
|
const out = io.outLines.join('\n')
|
|
5704
6002
|
expect(out).toContain('Next: Your devices')
|
|
5705
|
-
expect(out).toContain('no active Companion
|
|
6003
|
+
expect(out).toContain('no active Companion App registered yet')
|
|
5706
6004
|
expect(out).not.toContain('All set.')
|
|
5707
6005
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
5708
6006
|
|
|
@@ -5719,13 +6017,159 @@ describe('init', () => {
|
|
|
5719
6017
|
),
|
|
5720
6018
|
).toBe(EXIT.failed)
|
|
5721
6019
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
5722
|
-
expect(doctorOut).toMatch(/FAIL\s+Your devices
|
|
5723
|
-
expect(doctorOut).toContain('no active Companion device registered yet')
|
|
6020
|
+
expect(doctorOut).toMatch(/FAIL\s+Your devices: no active Companion App registered yet/)
|
|
5724
6021
|
expect(doctorOut).toContain(
|
|
5725
6022
|
'Delivery proof: not checked — no iPhone or Android Companion App is ready',
|
|
5726
6023
|
)
|
|
5727
6024
|
})
|
|
5728
6025
|
|
|
6026
|
+
it('says access is already requested rather than asking for it again', async () => {
|
|
6027
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-requested-'))
|
|
6028
|
+
const io = new CapturedIo()
|
|
6029
|
+
const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
|
|
6030
|
+
const client = {
|
|
6031
|
+
health: async () => true,
|
|
6032
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6033
|
+
listDevices: async () => {
|
|
6034
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6035
|
+
},
|
|
6036
|
+
accessStatus: async () => ({
|
|
6037
|
+
status: 'no_active_plan',
|
|
6038
|
+
reason: 'no_active_grant',
|
|
6039
|
+
expires_at: null,
|
|
6040
|
+
email: 'waiting@example.test',
|
|
6041
|
+
public_v1_cutover: false,
|
|
6042
|
+
}),
|
|
6043
|
+
accessRequest: async () => ({
|
|
6044
|
+
request: {
|
|
6045
|
+
status: 'requested',
|
|
6046
|
+
requested_at: '2026-08-21T09:15:00.000Z',
|
|
6047
|
+
updated_at: '2026-08-21T09:15:00.000Z',
|
|
6048
|
+
},
|
|
6049
|
+
}),
|
|
6050
|
+
} as unknown as ApiClient
|
|
6051
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
6052
|
+
|
|
6053
|
+
const readiness = await assessReadiness(deps)
|
|
6054
|
+
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
6055
|
+
// The middle value. Without it, someone who asked in August is told to ask
|
|
6056
|
+
// again on every run, forever, for a wait no command of theirs can shorten.
|
|
6057
|
+
expect(auth?.detail).toContain('access requested on 2026-08-21')
|
|
6058
|
+
expect(auth?.detail).toContain('waiting@example.test')
|
|
6059
|
+
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere' })
|
|
6060
|
+
expect(JSON.stringify(auth)).not.toContain('/setup/access')
|
|
6061
|
+
|
|
6062
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
6063
|
+
const out = io.outLines.join('\n')
|
|
6064
|
+
expect(out).toContain('nothing is needed from you')
|
|
6065
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6066
|
+
})
|
|
6067
|
+
|
|
6068
|
+
it('asks for access only when this account has never asked', async () => {
|
|
6069
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-never-asked-'))
|
|
6070
|
+
const io = new CapturedIo()
|
|
6071
|
+
const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
|
|
6072
|
+
const client = {
|
|
6073
|
+
health: async () => true,
|
|
6074
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6075
|
+
listDevices: async () => {
|
|
6076
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6077
|
+
},
|
|
6078
|
+
accessStatus: async () => ({
|
|
6079
|
+
status: 'no_active_plan',
|
|
6080
|
+
reason: 'no_active_grant',
|
|
6081
|
+
expires_at: null,
|
|
6082
|
+
email: 'fresh@example.test',
|
|
6083
|
+
public_v1_cutover: false,
|
|
6084
|
+
}),
|
|
6085
|
+
accessRequest: async () => ({ request: null }),
|
|
6086
|
+
} as unknown as ApiClient
|
|
6087
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
6088
|
+
|
|
6089
|
+
const auth = (await assessReadiness(deps)).states.find((state) => state.id === 'auth')
|
|
6090
|
+
expect(auth?.detail).toContain('does not have access to Notifai yet')
|
|
6091
|
+
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
|
|
6092
|
+
})
|
|
6093
|
+
|
|
6094
|
+
it('does not let a duplicate agent-skill install stand in front of the proof', async () => {
|
|
6095
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-'))
|
|
6096
|
+
const io = new CapturedIo()
|
|
6097
|
+
const calls = { submit: 0 }
|
|
6098
|
+
const nativeSkills: NativeSkills = {
|
|
6099
|
+
add: async () => 0,
|
|
6100
|
+
remove: async () => 0,
|
|
6101
|
+
// Both scopes installed: the harness lists two, which is a real problem
|
|
6102
|
+
// and a human decision — and still not a reason to withhold the proof.
|
|
6103
|
+
list: async (scope) => ({ skills: [managedSkill(scope ?? 'project', cwd)] }),
|
|
6104
|
+
}
|
|
6105
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
6106
|
+
|
|
6107
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
6108
|
+
const readiness = await assessReadiness(deps)
|
|
6109
|
+
expect(readiness.states.find((state) => state.id === 'skill')?.status).toBe('gap')
|
|
6110
|
+
expect(firstRequiredBlocker(readiness)).toBeNull()
|
|
6111
|
+
expect(calls.submit).toBe(1)
|
|
6112
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
6113
|
+
})
|
|
6114
|
+
|
|
6115
|
+
it('closes on the blocker that stopped the run, not the state before it', async () => {
|
|
6116
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-login-access-'))
|
|
6117
|
+
const io = new InteractiveIo()
|
|
6118
|
+
let now = 0
|
|
6119
|
+
const client = {
|
|
6120
|
+
health: async () => true,
|
|
6121
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6122
|
+
beginPairing: async () => ({
|
|
6123
|
+
pairing_id: 'pair_test',
|
|
6124
|
+
code: 'ABCD-EFGH',
|
|
6125
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
6126
|
+
expires_at: new Date(10_000).toISOString(),
|
|
6127
|
+
poll_interval_seconds: 1,
|
|
6128
|
+
}),
|
|
6129
|
+
pollPairing: async () => ({
|
|
6130
|
+
status: 'no_active_plan',
|
|
6131
|
+
next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
6132
|
+
}),
|
|
6133
|
+
} as unknown as ApiClient
|
|
6134
|
+
const deps: CommandDeps = {
|
|
6135
|
+
...makeDeps(io, client),
|
|
6136
|
+
cwd,
|
|
6137
|
+
env: isolatedEnv(cwd),
|
|
6138
|
+
now: () => now,
|
|
6139
|
+
sleep: async (milliseconds: number) => {
|
|
6140
|
+
now += milliseconds
|
|
6141
|
+
},
|
|
6142
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
6143
|
+
}
|
|
6144
|
+
|
|
6145
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
6146
|
+
const out = io.outLines.join('\n')
|
|
6147
|
+
// One next step, and it is the one that actually stopped the run. The
|
|
6148
|
+
// pairing state that preceded it would send the reader straight back into
|
|
6149
|
+
// the command that just failed, for a reason it never names.
|
|
6150
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6151
|
+
expect(out).toContain('Next: Access')
|
|
6152
|
+
expect(out).toContain('https://app.notifai.sh/setup/access')
|
|
6153
|
+
expect(out).not.toContain('not paired with your account')
|
|
6154
|
+
|
|
6155
|
+
// …and the wall is stated once across the whole visit. It used to arrive
|
|
6156
|
+
// three ways — a lowercase `next:` from the pairing failure, an "after
|
|
6157
|
+
// access … run `notifai init`" remedy, and this close — so a stopped User
|
|
6158
|
+
// had to work out which of three destinations-and-commands was theirs.
|
|
6159
|
+
const said = [
|
|
6160
|
+
...io.outLines,
|
|
6161
|
+
...io.errLines,
|
|
6162
|
+
...io.notes.map((note) => note.message),
|
|
6163
|
+
...io.spinnerEvents,
|
|
6164
|
+
].join('\n')
|
|
6165
|
+
expect(said.match(/setup\/access/g)).toHaveLength(1)
|
|
6166
|
+
expect(said.match(/does not have access/g)).toHaveLength(1)
|
|
6167
|
+
expect(said).not.toContain('After access is granted')
|
|
6168
|
+
expect(said).not.toContain('next: ')
|
|
6169
|
+
// Rerunning is how they resume, not how they get access.
|
|
6170
|
+
expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
|
|
6171
|
+
})
|
|
6172
|
+
|
|
5729
6173
|
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
5730
6174
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
|
|
5731
6175
|
const io = new CapturedIo()
|
|
@@ -5778,7 +6222,7 @@ describe('init', () => {
|
|
|
5778
6222
|
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
5779
6223
|
expect(auth).toMatchObject({
|
|
5780
6224
|
status: 'gap',
|
|
5781
|
-
detail: '
|
|
6225
|
+
detail: 'this account does not have access to Notifai yet (rafael@example.test)',
|
|
5782
6226
|
remedy: { by: 'user-elsewhere', summary: nextAction },
|
|
5783
6227
|
})
|
|
5784
6228
|
expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
|
|
@@ -5823,7 +6267,7 @@ describe('init', () => {
|
|
|
5823
6267
|
const readiness = await assessReadiness(deps)
|
|
5824
6268
|
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
5825
6269
|
expect(auth?.status).toBe('gap')
|
|
5826
|
-
expect(auth?.detail).toContain('
|
|
6270
|
+
expect(auth?.detail).toContain('does not have access to Notifai yet')
|
|
5827
6271
|
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
|
|
5828
6272
|
expect(JSON.stringify(auth)).not.toContain('pair it again')
|
|
5829
6273
|
})
|
|
@@ -6291,7 +6735,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6291
6735
|
io.outLines = []
|
|
6292
6736
|
|
|
6293
6737
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
6294
|
-
expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
|
|
6738
|
+
expect(io.outLines.some((line) => line.startsWith('Question registered locally (q_'))).toBe(true)
|
|
6295
6739
|
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
6296
6740
|
session_id: 'codex-current-thread',
|
|
6297
6741
|
harness: 'codex',
|
|
@@ -6299,6 +6743,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6299
6743
|
expect(
|
|
6300
6744
|
readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
|
|
6301
6745
|
).toBe('Gentle Salmon')
|
|
6746
|
+
|
|
6747
|
+
io.outLines = []
|
|
6748
|
+
expect(askCommand(deps, 'Wait?', { json: true })).toBe(EXIT.ok)
|
|
6749
|
+
const registered = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
6750
|
+
expect(registered).toMatchObject({
|
|
6751
|
+
registered: true,
|
|
6752
|
+
state: 'local',
|
|
6753
|
+
submitted: false,
|
|
6754
|
+
request_id: null,
|
|
6755
|
+
provider_acceptance: 'not_available',
|
|
6756
|
+
status: `notifai status ${registered.question_id as string}`,
|
|
6757
|
+
close: `notifai close ${registered.question_id as string}`,
|
|
6758
|
+
})
|
|
6759
|
+
expect(inspectQuestionState(registered.question_id as string, env)).toMatchObject({
|
|
6760
|
+
found: true,
|
|
6761
|
+
question: {
|
|
6762
|
+
question_id: registered.question_id,
|
|
6763
|
+
state: 'local',
|
|
6764
|
+
submitted: false,
|
|
6765
|
+
request_id: null,
|
|
6766
|
+
},
|
|
6767
|
+
})
|
|
6302
6768
|
})
|
|
6303
6769
|
|
|
6304
6770
|
it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
|
|
@@ -6835,8 +7301,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6835
7301
|
|
|
6836
7302
|
const readiness = await assessReadiness(deps)
|
|
6837
7303
|
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
6838
|
-
|
|
7304
|
+
// Reported, never a blocker: the only thing that closes this is a turn
|
|
7305
|
+
// ending, which the agent standing inside that turn cannot supply.
|
|
7306
|
+
expect(fired?.status).toBe('optional-gap')
|
|
6839
7307
|
expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
|
|
7308
|
+
expect(firstRequiredBlocker(readiness)?.id).not.toBe('hooks-fired')
|
|
6840
7309
|
})
|
|
6841
7310
|
|
|
6842
7311
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
@@ -7410,7 +7879,7 @@ describe('a server behind this CLI', () => {
|
|
|
7410
7879
|
|
|
7411
7880
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
7412
7881
|
expect(said).toMatch(/service is being updated/)
|
|
7413
|
-
expect(said).not.toContain('
|
|
7882
|
+
expect(said).not.toContain('notifai update')
|
|
7414
7883
|
expect(said).not.toMatch(/Protocol version|schema v/i)
|
|
7415
7884
|
|
|
7416
7885
|
const sendIo = new CapturedIo()
|