@raidiant/notifai 0.1.1 → 0.1.3
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/dist/commands.d.ts +6 -0
- package/dist/commands.js +509 -94
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +68 -28
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/send.js +1 -0
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +442 -18
- package/src/commands.ts +593 -82
- package/src/hooks.test.ts +126 -1
- package/src/hooks.ts +77 -27
- package/src/main.ts +3 -3
- package/src/send.ts +3 -0
package/src/commands.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import type {
|
|
5
5
|
CapabilityDocument,
|
|
6
|
+
EvidenceSnapshot,
|
|
6
7
|
ListRepliesResponse,
|
|
7
8
|
ReplyView,
|
|
8
9
|
SubmissionReceipt,
|
|
@@ -21,10 +22,12 @@ import {
|
|
|
21
22
|
hooksInstallCommand,
|
|
22
23
|
hooksUninstallCommand,
|
|
23
24
|
initCommand,
|
|
25
|
+
SKILLS_SOURCE,
|
|
24
26
|
loginCommand,
|
|
25
27
|
projectSlugFrom,
|
|
26
28
|
repliesCommand,
|
|
27
29
|
sendCommand,
|
|
30
|
+
statusCommand,
|
|
28
31
|
type CommandDeps,
|
|
29
32
|
type CommandIo,
|
|
30
33
|
type CommandSpinner,
|
|
@@ -494,6 +497,80 @@ describe('command contracts', () => {
|
|
|
494
497
|
})
|
|
495
498
|
})
|
|
496
499
|
|
|
500
|
+
describe('delivery evidence status', () => {
|
|
501
|
+
function snapshot(
|
|
502
|
+
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
503
|
+
): EvidenceSnapshot {
|
|
504
|
+
return {
|
|
505
|
+
request_id: 'req_status_test',
|
|
506
|
+
event: 'tests_passed',
|
|
507
|
+
accepted_at: '2026-08-05T13:05:48.000Z',
|
|
508
|
+
overall: 'provider_accepted_all',
|
|
509
|
+
deliveries: [
|
|
510
|
+
{
|
|
511
|
+
delivery_id: 'del_status_test',
|
|
512
|
+
device_id: 'dev_status_test',
|
|
513
|
+
device_name: 'iPhone',
|
|
514
|
+
state: 'provider_accepted',
|
|
515
|
+
attempts: 1,
|
|
516
|
+
provider_status: 200,
|
|
517
|
+
provider_reason: null,
|
|
518
|
+
provider_id: 'provider_status_test',
|
|
519
|
+
updated_at: '2026-08-05T13:05:50.000Z',
|
|
520
|
+
companion_receipt: companionReceipt,
|
|
521
|
+
events: [
|
|
522
|
+
{
|
|
523
|
+
stage: 'attempt_started',
|
|
524
|
+
source: 'worker',
|
|
525
|
+
reason: null,
|
|
526
|
+
attempt: 1,
|
|
527
|
+
occurred_at: '2026-08-05T13:05:49.000Z',
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
stage: 'provider_accepted',
|
|
531
|
+
source: 'worker',
|
|
532
|
+
reason: null,
|
|
533
|
+
attempt: 1,
|
|
534
|
+
occurred_at: '2026-08-05T13:05:50.000Z',
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
it('calls an unobserved first-minute receipt unknown rather than failed', async () => {
|
|
543
|
+
const io = new CapturedIo()
|
|
544
|
+
const client = {
|
|
545
|
+
evidence: async () => snapshot({ state: 'unknown', observed_at: null, latency_ms: null }),
|
|
546
|
+
} as unknown as ApiClient
|
|
547
|
+
|
|
548
|
+
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
549
|
+
const said = io.outLines.join('\n')
|
|
550
|
+
expect(said).toContain('Provider Acceptance: accepted')
|
|
551
|
+
expect(said).toContain('Companion Receipt: unknown')
|
|
552
|
+
expect(said).toContain('not a failure')
|
|
553
|
+
expect(said).toContain('attempt_started')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('reports the observed device receipt and measured provider-to-companion latency', async () => {
|
|
557
|
+
const io = new CapturedIo()
|
|
558
|
+
const client = {
|
|
559
|
+
evidence: async () =>
|
|
560
|
+
snapshot({
|
|
561
|
+
state: 'observed',
|
|
562
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
563
|
+
latency_ms: 687_000,
|
|
564
|
+
}),
|
|
565
|
+
} as unknown as ApiClient
|
|
566
|
+
|
|
567
|
+
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
568
|
+
const said = io.outLines.join('\n')
|
|
569
|
+
expect(said).toContain('Companion Receipt: observed')
|
|
570
|
+
expect(said).toContain('11m 27s after Provider Acceptance')
|
|
571
|
+
})
|
|
572
|
+
})
|
|
573
|
+
|
|
497
574
|
describe('Cursor hook commands', () => {
|
|
498
575
|
const execPath = '/usr/local/bin/node'
|
|
499
576
|
const scriptPath = '/opt/notifai/dist/main.js'
|
|
@@ -855,21 +932,83 @@ describe('interactive command UX', () => {
|
|
|
855
932
|
})
|
|
856
933
|
|
|
857
934
|
describe('init', () => {
|
|
935
|
+
const readyIphone = {
|
|
936
|
+
device_id: 'dev_iphone',
|
|
937
|
+
display_name: 'iPhone',
|
|
938
|
+
platform: 'ios' as const,
|
|
939
|
+
permission_status: 'authorized',
|
|
940
|
+
registration_healthy: true,
|
|
941
|
+
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function setupEvidence(
|
|
945
|
+
requestId: string,
|
|
946
|
+
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
947
|
+
device = readyIphone,
|
|
948
|
+
): EvidenceSnapshot {
|
|
949
|
+
return {
|
|
950
|
+
request_id: requestId,
|
|
951
|
+
event: 'setup_verified',
|
|
952
|
+
accepted_at: '2026-08-05T18:00:00.000Z',
|
|
953
|
+
overall: 'provider_accepted_all',
|
|
954
|
+
deliveries: [
|
|
955
|
+
{
|
|
956
|
+
delivery_id: 'del_setup',
|
|
957
|
+
device_id: device.device_id,
|
|
958
|
+
device_name: device.display_name,
|
|
959
|
+
state: 'provider_accepted',
|
|
960
|
+
attempts: 1,
|
|
961
|
+
provider_status: 200,
|
|
962
|
+
provider_reason: null,
|
|
963
|
+
provider_id: 'provider_setup',
|
|
964
|
+
updated_at: '2026-08-05T18:00:01.000Z',
|
|
965
|
+
companion_receipt: companionReceipt,
|
|
966
|
+
events:
|
|
967
|
+
companionReceipt.state === 'observed'
|
|
968
|
+
? [
|
|
969
|
+
{
|
|
970
|
+
stage: 'companion_received',
|
|
971
|
+
source: 'companion',
|
|
972
|
+
reason: null,
|
|
973
|
+
attempt: null,
|
|
974
|
+
occurred_at: companionReceipt.observed_at!,
|
|
975
|
+
},
|
|
976
|
+
]
|
|
977
|
+
: [],
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function setupReceipt(requestId = 'req_setup'): SubmissionReceipt {
|
|
984
|
+
return {
|
|
985
|
+
...receipt,
|
|
986
|
+
request_id: requestId,
|
|
987
|
+
deliveries: [
|
|
988
|
+
{
|
|
989
|
+
...receipt.deliveries[0]!,
|
|
990
|
+
device_id: readyIphone.device_id,
|
|
991
|
+
device_name: readyIphone.display_name,
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
858
997
|
it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
|
|
859
998
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
|
|
860
999
|
const io = new CapturedIo()
|
|
861
1000
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
862
1001
|
|
|
863
|
-
expect(await initCommand(deps, {})).toBe(EXIT.
|
|
1002
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
864
1003
|
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
865
1004
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
866
1005
|
// Safe by default: without an explicit --skills opt-in, init only writes
|
|
867
1006
|
// configuration and never spawns the skill installer.
|
|
868
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1007
|
+
expect(io.outLines.join('\n')).not.toContain('Installing the notifai agent skill')
|
|
869
1008
|
expect(io.outLines.join('\n')).not.toContain('All set.')
|
|
870
1009
|
|
|
871
1010
|
io.outLines = []
|
|
872
|
-
expect(await initCommand(deps, { skills: false })).toBe(EXIT.
|
|
1011
|
+
expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
|
|
873
1012
|
// Idempotent: the second run re-derives the same slug and says so as a
|
|
874
1013
|
// settled state rather than repeating the write.
|
|
875
1014
|
expect(io.outLines.join('\n')).toContain('Project identity: "my-project-')
|
|
@@ -889,7 +1028,7 @@ describe('init', () => {
|
|
|
889
1028
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
890
1029
|
}
|
|
891
1030
|
|
|
892
|
-
expect(await initCommand(deps, {})).toBe(EXIT.
|
|
1031
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
893
1032
|
const out = io.outLines.join('\n')
|
|
894
1033
|
expect(out).toContain('Next: This machine')
|
|
895
1034
|
expect(out).toContain('notifai login')
|
|
@@ -904,7 +1043,7 @@ describe('init', () => {
|
|
|
904
1043
|
const io = new CapturedIo()
|
|
905
1044
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
906
1045
|
|
|
907
|
-
expect(await initCommand(deps, { projectId: 'Custom Name', skills: false })).toBe(EXIT.
|
|
1046
|
+
expect(await initCommand(deps, { projectId: 'Custom Name', skills: false })).toBe(EXIT.failed)
|
|
908
1047
|
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain(
|
|
909
1048
|
'project = "custom-name"',
|
|
910
1049
|
)
|
|
@@ -916,24 +1055,16 @@ describe('init', () => {
|
|
|
916
1055
|
const io = new CapturedIo()
|
|
917
1056
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
918
1057
|
|
|
919
|
-
expect(await initCommand(deps, {})).toBe(EXIT.
|
|
1058
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
920
1059
|
const out = io.outLines.join('\n')
|
|
921
|
-
expect(out).toContain('no published skill source configured')
|
|
922
|
-
expect(out).not.toContain('notifai init --skills')
|
|
923
1060
|
expect(out).not.toContain('Installing the notifai agent skill')
|
|
924
1061
|
// Never prompted, and never assumed into a change it did not request.
|
|
925
1062
|
expect(io.errLines).toEqual([])
|
|
926
1063
|
})
|
|
927
1064
|
|
|
928
|
-
it('
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
932
|
-
|
|
933
|
-
expect(await initCommand(deps, { skills: true })).toBe(EXIT.failed)
|
|
934
|
-
expect(io.errLines).toContain(
|
|
935
|
-
'The optional agent skill is not published yet; this build has no skill source configured.',
|
|
936
|
-
)
|
|
1065
|
+
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1066
|
+
expect(SKILLS_SOURCE).toBe('RafaelVidaurre/notifai#v0.1.3')
|
|
1067
|
+
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
937
1068
|
})
|
|
938
1069
|
|
|
939
1070
|
it('tells the user what only they can do when nothing is signed in', async () => {
|
|
@@ -945,7 +1076,7 @@ describe('init', () => {
|
|
|
945
1076
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
946
1077
|
}
|
|
947
1078
|
|
|
948
|
-
expect(await initCommand(deps, {})).toBe(EXIT.
|
|
1079
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
949
1080
|
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
950
1081
|
})
|
|
951
1082
|
|
|
@@ -970,6 +1101,299 @@ describe('init', () => {
|
|
|
970
1101
|
// Refused, so it stays the next step rather than being treated as done.
|
|
971
1102
|
expect(io.outLines.join('\n')).toContain('notifai login')
|
|
972
1103
|
})
|
|
1104
|
+
|
|
1105
|
+
it('never prompts or opens a browser when an agent runs it unattended', async () => {
|
|
1106
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-no-input-'))
|
|
1107
|
+
const io = new (class extends CapturedIo {
|
|
1108
|
+
override async confirm(): Promise<boolean> {
|
|
1109
|
+
throw new Error('an unattended init reached a prompt')
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
override openUrl(): void {
|
|
1113
|
+
throw new Error('an unattended init opened a browser')
|
|
1114
|
+
}
|
|
1115
|
+
})()
|
|
1116
|
+
const deps: CommandDeps = {
|
|
1117
|
+
...makeDeps(io, { health: async () => true } as unknown as ApiClient),
|
|
1118
|
+
cwd,
|
|
1119
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1120
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
1124
|
+
expect(io.outLines.join('\n')).toContain('Next: This machine')
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
it('makes the unavailable distribution bridge explicit when no app has registered', async () => {
|
|
1128
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-device-'))
|
|
1129
|
+
const io = new CapturedIo()
|
|
1130
|
+
const client = {
|
|
1131
|
+
health: async () => true,
|
|
1132
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1133
|
+
listDevices: async () => ({ devices: [] }),
|
|
1134
|
+
} as unknown as ApiClient
|
|
1135
|
+
const deps = {
|
|
1136
|
+
...makeDeps(io, client),
|
|
1137
|
+
cwd,
|
|
1138
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1142
|
+
const out = io.outLines.join('\n')
|
|
1143
|
+
expect(out).toContain('Next: Your devices')
|
|
1144
|
+
expect(out).toContain('no supported App Store/TestFlight URL')
|
|
1145
|
+
expect(out).toContain('open it, sign in with the same account, and allow notifications')
|
|
1146
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
it.each([
|
|
1150
|
+
['denied', 'system settings'],
|
|
1151
|
+
['not_determined', 'allow its notification prompt'],
|
|
1152
|
+
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
1153
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
1154
|
+
const io = new CapturedIo()
|
|
1155
|
+
const client = {
|
|
1156
|
+
health: async () => true,
|
|
1157
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1158
|
+
listDevices: async () => ({
|
|
1159
|
+
devices: [{ ...readyIphone, permission_status: permission, registration_healthy: false }],
|
|
1160
|
+
}),
|
|
1161
|
+
} as unknown as ApiClient
|
|
1162
|
+
const deps = {
|
|
1163
|
+
...makeDeps(io, client),
|
|
1164
|
+
cwd,
|
|
1165
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1169
|
+
const out = io.outLines.join('\n')
|
|
1170
|
+
expect(out).toContain(`iPhone (${permission})`)
|
|
1171
|
+
expect(out).toContain(expected)
|
|
1172
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
it('waits on the supported device registry, then ends with an observed real receipt', async () => {
|
|
1176
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
1177
|
+
const io = new InteractiveIo()
|
|
1178
|
+
let now = 0
|
|
1179
|
+
let deviceReady = false
|
|
1180
|
+
let submitCalls = 0
|
|
1181
|
+
let submittedDraft: SubmitNotificationRequestT | null = null
|
|
1182
|
+
const client = {
|
|
1183
|
+
health: async () => true,
|
|
1184
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1185
|
+
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
|
|
1186
|
+
submit: async (draft: SubmitNotificationRequestT) => {
|
|
1187
|
+
submitCalls += 1
|
|
1188
|
+
submittedDraft = draft
|
|
1189
|
+
return setupReceipt()
|
|
1190
|
+
},
|
|
1191
|
+
evidence: async (requestId: string) =>
|
|
1192
|
+
setupEvidence(requestId, {
|
|
1193
|
+
state: 'observed',
|
|
1194
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
1195
|
+
latency_ms: 1_000,
|
|
1196
|
+
}),
|
|
1197
|
+
} as unknown as ApiClient
|
|
1198
|
+
const deps: CommandDeps = {
|
|
1199
|
+
...makeDeps(io, client),
|
|
1200
|
+
cwd,
|
|
1201
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1202
|
+
now: () => now,
|
|
1203
|
+
sleep: async (milliseconds) => {
|
|
1204
|
+
now += milliseconds
|
|
1205
|
+
deviceReady = true
|
|
1206
|
+
},
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1210
|
+
expect(io.prompts).toEqual(['Wait here while you finish that on your device?'])
|
|
1211
|
+
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
1212
|
+
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
1213
|
+
expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
|
|
1214
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1215
|
+
expect(submitCalls).toBe(1)
|
|
1216
|
+
expect(submittedDraft?.draft.event).toBe('setup_verified')
|
|
1217
|
+
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
1218
|
+
|
|
1219
|
+
io.outLines = []
|
|
1220
|
+
io.prompts = []
|
|
1221
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1222
|
+
expect(submitCalls).toBe(1)
|
|
1223
|
+
expect(io.prompts).toEqual([])
|
|
1224
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it('persists a partial proof and checks the same request instead of sending again', async () => {
|
|
1228
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-partial-'))
|
|
1229
|
+
const io = new CapturedIo()
|
|
1230
|
+
let now = 0
|
|
1231
|
+
let submitCalls = 0
|
|
1232
|
+
let savedRequestMissing = false
|
|
1233
|
+
const client = {
|
|
1234
|
+
health: async () => true,
|
|
1235
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1236
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
1237
|
+
submit: async () => {
|
|
1238
|
+
submitCalls += 1
|
|
1239
|
+
return setupReceipt(submitCalls === 1 ? 'req_partial' : 'req_replacement')
|
|
1240
|
+
},
|
|
1241
|
+
evidence: async (requestId: string) => {
|
|
1242
|
+
if (savedRequestMissing && requestId === 'req_partial') {
|
|
1243
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1244
|
+
}
|
|
1245
|
+
return setupEvidence(
|
|
1246
|
+
requestId,
|
|
1247
|
+
savedRequestMissing
|
|
1248
|
+
? {
|
|
1249
|
+
state: 'observed',
|
|
1250
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
1251
|
+
latency_ms: 1_000,
|
|
1252
|
+
}
|
|
1253
|
+
: { state: 'unknown', observed_at: null, latency_ms: null },
|
|
1254
|
+
)
|
|
1255
|
+
},
|
|
1256
|
+
} as unknown as ApiClient
|
|
1257
|
+
const deps: CommandDeps = {
|
|
1258
|
+
...makeDeps(io, client),
|
|
1259
|
+
cwd,
|
|
1260
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1261
|
+
now: () => now,
|
|
1262
|
+
sleep: async (milliseconds) => {
|
|
1263
|
+
now += milliseconds
|
|
1264
|
+
},
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1268
|
+
expect(submitCalls).toBe(1)
|
|
1269
|
+
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
1270
|
+
expect(io.errLines.join('\n')).toContain('not proof of non-receipt')
|
|
1271
|
+
|
|
1272
|
+
io.outLines = []
|
|
1273
|
+
io.errLines = []
|
|
1274
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1275
|
+
expect(submitCalls).toBe(1)
|
|
1276
|
+
expect(io.outLines.join('\n')).toContain('Checking verification notification req_partial again.')
|
|
1277
|
+
|
|
1278
|
+
io.outLines = []
|
|
1279
|
+
io.errLines = []
|
|
1280
|
+
savedRequestMissing = true
|
|
1281
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
1282
|
+
expect(submitCalls).toBe(2)
|
|
1283
|
+
expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
|
|
1284
|
+
expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
1288
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
|
|
1289
|
+
const io = new CapturedIo()
|
|
1290
|
+
let submitCalls = 0
|
|
1291
|
+
const client = {
|
|
1292
|
+
health: async () => true,
|
|
1293
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1294
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
1295
|
+
submit: async () => {
|
|
1296
|
+
submitCalls += 1
|
|
1297
|
+
return setupReceipt('req_unwritable')
|
|
1298
|
+
},
|
|
1299
|
+
} as unknown as ApiClient
|
|
1300
|
+
const deps = {
|
|
1301
|
+
...makeDeps(io, client),
|
|
1302
|
+
cwd,
|
|
1303
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: '/proc/notifai-unwritable' },
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
await expect(initCommand(deps, { hooks: false, skills: false })).resolves.toBe(EXIT.failed)
|
|
1307
|
+
expect(submitCalls).toBe(1)
|
|
1308
|
+
expect(io.errLines.join('\n')).toContain('Could not save setup proof req_unwritable')
|
|
1309
|
+
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
1310
|
+
})
|
|
1311
|
+
|
|
1312
|
+
it('does not claim proof for a macOS-only setup whose receipt bridge is unavailable', async () => {
|
|
1313
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
1314
|
+
const io = new CapturedIo()
|
|
1315
|
+
let submitCalls = 0
|
|
1316
|
+
const client = {
|
|
1317
|
+
health: async () => true,
|
|
1318
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1319
|
+
listDevices: async () => ({
|
|
1320
|
+
devices: [{ ...readyIphone, device_id: 'dev_mac', display_name: 'Mac', platform: 'macos' }],
|
|
1321
|
+
}),
|
|
1322
|
+
submit: async () => {
|
|
1323
|
+
submitCalls += 1
|
|
1324
|
+
return setupReceipt()
|
|
1325
|
+
},
|
|
1326
|
+
} as unknown as ApiClient
|
|
1327
|
+
const deps = {
|
|
1328
|
+
...makeDeps(io, client),
|
|
1329
|
+
cwd,
|
|
1330
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1334
|
+
expect(submitCalls).toBe(0)
|
|
1335
|
+
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
1336
|
+
expect(io.outLines.join('\n')).toContain('no supported macOS receipt bridge')
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
1340
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
|
|
1341
|
+
const io = new CapturedIo()
|
|
1342
|
+
const client = {
|
|
1343
|
+
health: async () => true,
|
|
1344
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1345
|
+
listDevices: async () => {
|
|
1346
|
+
throw new ApiCallError(401, 'machine_revoked', 'This machine was revoked.')
|
|
1347
|
+
},
|
|
1348
|
+
} as unknown as ApiClient
|
|
1349
|
+
const deps = {
|
|
1350
|
+
...makeDeps(io, client),
|
|
1351
|
+
cwd,
|
|
1352
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
1356
|
+
const out = io.outLines.join('\n')
|
|
1357
|
+
expect(out).toContain('Next: Account')
|
|
1358
|
+
expect(out).toContain('pair it again')
|
|
1359
|
+
expect(out).toContain('notifai login')
|
|
1360
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
it('scopes proof to each project worktree even on the same paired machine', async () => {
|
|
1364
|
+
const stateRoot = mkdtempSync(path.join(os.tmpdir(), 'init-worktrees-state-'))
|
|
1365
|
+
let submitCalls = 0
|
|
1366
|
+
const client = {
|
|
1367
|
+
health: async () => true,
|
|
1368
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1369
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
1370
|
+
submit: async () => {
|
|
1371
|
+
submitCalls += 1
|
|
1372
|
+
return setupReceipt(`req_worktree_${submitCalls}`)
|
|
1373
|
+
},
|
|
1374
|
+
evidence: async (requestId: string) =>
|
|
1375
|
+
setupEvidence(requestId, {
|
|
1376
|
+
state: 'observed',
|
|
1377
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
1378
|
+
latency_ms: 1_000,
|
|
1379
|
+
}),
|
|
1380
|
+
} as unknown as ApiClient
|
|
1381
|
+
|
|
1382
|
+
for (const name of ['worktree-a', 'worktree-b']) {
|
|
1383
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `${name}-`))
|
|
1384
|
+
const io = new CapturedIo()
|
|
1385
|
+
const deps = {
|
|
1386
|
+
...makeDeps(io, client),
|
|
1387
|
+
cwd,
|
|
1388
|
+
env: { XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
|
|
1389
|
+
}
|
|
1390
|
+
expect(
|
|
1391
|
+
await initCommand(deps, { projectId: 'shared-project', hooks: false, skills: false }),
|
|
1392
|
+
).toBe(EXIT.ok)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
expect(submitCalls).toBe(2)
|
|
1396
|
+
})
|
|
973
1397
|
})
|
|
974
1398
|
|
|
975
1399
|
describe('an outage is not an answer', () => {
|