@raidiant/notifai 9.0.0 → 9.2.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 +14 -0
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-doctor.js +17 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.js +3 -29
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +11 -1
- package/dist/commands-hooks.js +98 -184
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +2 -0
- package/dist/commands-init.js +41 -21
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +62 -14
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +4 -3
- package/dist/commands-setup-proof.js +29 -14
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/hooks.d.ts +16 -13
- package/dist/hooks.js +57 -32
- package/dist/hooks.js.map +1 -1
- package/dist/opencode-plugin.js +32 -5
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +21 -12
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/session-activation.d.ts +4 -8
- package/dist/session-activation.js +25 -14
- package/dist/session-activation.js.map +1 -1
- package/package.json +1 -1
- package/src/command-session.ts +38 -0
- package/src/commands-doctor.ts +18 -28
- package/src/commands-guidance.ts +3 -38
- package/src/commands-harness-context.ts +20 -1
- package/src/commands-hooks.ts +219 -220
- package/src/commands-init.ts +46 -24
- package/src/commands-send.ts +70 -14
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +33 -14
- package/src/commands.test.ts +209 -67
- package/src/commands.ts +1 -0
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +28 -0
- package/src/hooks.test.ts +42 -137
- package/src/hooks.ts +67 -46
- package/src/install-hooks.test.ts +41 -16
- package/src/opencode-plugin.ts +35 -5
- package/src/program.ts +22 -12
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/session-activation.ts +35 -18
- package/src/skill.test.ts +22 -23
package/src/commands-init.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
|
13
13
|
import { personalProjectConfigPath, type CliConfig } from './config.js'
|
|
14
14
|
import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
|
|
15
15
|
import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
16
|
-
import { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
|
|
16
|
+
import { firstBlocker, readinessJson, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
|
|
17
17
|
import { buildDraft, formatReceipt } from './send.js'
|
|
18
18
|
import { loginCommand } from './commands-auth.js'
|
|
19
19
|
import {
|
|
@@ -30,6 +30,8 @@ import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js
|
|
|
30
30
|
import {
|
|
31
31
|
observedCompanionReceipt,
|
|
32
32
|
readSetupProof,
|
|
33
|
+
setupProofProject,
|
|
34
|
+
setupProofApplies,
|
|
33
35
|
setupProofIsStale,
|
|
34
36
|
writeSetupProof,
|
|
35
37
|
} from './commands-setup-proof.js'
|
|
@@ -49,6 +51,8 @@ export function projectSlugFrom(name: string): string {
|
|
|
49
51
|
export type SetupScope = SkillScope
|
|
50
52
|
|
|
51
53
|
export interface InitFlags {
|
|
54
|
+
/** Emit the final shared readiness model and never prompt. */
|
|
55
|
+
json?: boolean
|
|
52
56
|
projectId?: string
|
|
53
57
|
/**
|
|
54
58
|
* Install the agent guidance skill. Tri-state on purpose:
|
|
@@ -397,19 +401,23 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
397
401
|
return 'failed'
|
|
398
402
|
}
|
|
399
403
|
const candidates = readyCompanionDevices(devices)
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
404
|
+
const project = setupProofProject(deps, config.project.value)
|
|
405
|
+
const existing = readSetupProof(deps, project)
|
|
406
|
+
const existingApplies = setupProofApplies(
|
|
407
|
+
existing,
|
|
408
|
+
project,
|
|
409
|
+
candidates.map((device) => device.device_id),
|
|
410
|
+
)
|
|
411
|
+
const target = (existingApplies
|
|
412
|
+
? candidates.find((device) => device.device_id === existing.device_id)
|
|
413
|
+
: undefined) ?? candidates[0]
|
|
406
414
|
if (!target) {
|
|
407
415
|
deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
|
|
408
416
|
return 'pending'
|
|
409
417
|
}
|
|
410
418
|
|
|
411
419
|
let proof =
|
|
412
|
-
|
|
420
|
+
existingApplies && existing.device_id === target.device_id
|
|
413
421
|
? existing
|
|
414
422
|
: null
|
|
415
423
|
const nowMs = (deps.now ?? Date.now)()
|
|
@@ -438,7 +446,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
438
446
|
proof = {
|
|
439
447
|
request_id: receipt.request_id,
|
|
440
448
|
device_id: target.device_id,
|
|
441
|
-
project
|
|
449
|
+
project,
|
|
442
450
|
started_at: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
443
451
|
}
|
|
444
452
|
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
@@ -486,7 +494,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
486
494
|
proof = {
|
|
487
495
|
request_id: receipt.request_id,
|
|
488
496
|
device_id: target.device_id,
|
|
489
|
-
project
|
|
497
|
+
project,
|
|
490
498
|
started_at: new Date(now()).toISOString(),
|
|
491
499
|
}
|
|
492
500
|
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
@@ -621,19 +629,32 @@ async function resolveSetupScope(
|
|
|
621
629
|
}
|
|
622
630
|
|
|
623
631
|
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
624
|
-
const
|
|
632
|
+
const workingDeps: CommandDeps = flags.json === true
|
|
633
|
+
? {
|
|
634
|
+
...deps,
|
|
635
|
+
io: {
|
|
636
|
+
interactive: false,
|
|
637
|
+
out: () => {},
|
|
638
|
+
err: (line) => deps.io.err(line),
|
|
639
|
+
confirm: async () => false,
|
|
640
|
+
openUrl: (url) => deps.io.openUrl(url),
|
|
641
|
+
},
|
|
642
|
+
}
|
|
643
|
+
: deps
|
|
644
|
+
const setupScope = await resolveSetupScope(workingDeps, flags)
|
|
625
645
|
if (setupScope === 'usage') return EXIT.usage
|
|
626
646
|
const resolved: InitFlags = {
|
|
627
647
|
...flags,
|
|
628
648
|
...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
|
|
629
649
|
}
|
|
630
|
-
await
|
|
650
|
+
await workingDeps.io.intro?.('Notifai setup')
|
|
631
651
|
|
|
632
652
|
const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
|
|
633
|
-
let readiness = await assessReadiness(
|
|
653
|
+
let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
|
|
634
654
|
const reassess = (refresh?: readonly ReadinessRefresh[]) =>
|
|
635
|
-
assessReadiness(
|
|
655
|
+
assessReadiness(workingDeps, {
|
|
636
656
|
...skillOpts,
|
|
657
|
+
...(flags.json === true ? { json: true } : {}),
|
|
637
658
|
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
638
659
|
})
|
|
639
660
|
let failed = false
|
|
@@ -661,9 +682,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
661
682
|
}
|
|
662
683
|
|
|
663
684
|
if (state.status === 'optional-gap') {
|
|
664
|
-
if (remedy.by !== 'cli' || !(await wantsOptional(
|
|
685
|
+
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
665
686
|
attempted.add(state.id)
|
|
666
|
-
const result = await closeGap(
|
|
687
|
+
const result = await closeGap(workingDeps, state, resolved)
|
|
667
688
|
if (result === 'failed') failed = true
|
|
668
689
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
669
690
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
@@ -681,7 +702,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
681
702
|
|
|
682
703
|
if (remedy.by === 'cli') {
|
|
683
704
|
attempted.add(state.id)
|
|
684
|
-
const result = await closeGap(
|
|
705
|
+
const result = await closeGap(workingDeps, state, resolved)
|
|
685
706
|
if (result === 'failed') failed = true
|
|
686
707
|
if (result !== 'closed') {
|
|
687
708
|
stop = true
|
|
@@ -697,11 +718,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
697
718
|
if (
|
|
698
719
|
remedy.by === 'user-here' &&
|
|
699
720
|
remedy.interactive === true &&
|
|
700
|
-
|
|
721
|
+
workingDeps.io.interactive === true
|
|
701
722
|
) {
|
|
702
723
|
attempted.add(state.id)
|
|
703
|
-
|
|
704
|
-
if ((await loginCommand(
|
|
724
|
+
workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
725
|
+
if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
|
|
705
726
|
failed = true
|
|
706
727
|
stop = true
|
|
707
728
|
break
|
|
@@ -714,10 +735,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
714
735
|
if (
|
|
715
736
|
state.id === 'devices' &&
|
|
716
737
|
remedy.by === 'user-elsewhere' &&
|
|
717
|
-
|
|
738
|
+
workingDeps.io.interactive === true
|
|
718
739
|
) {
|
|
719
740
|
attempted.add(state.id)
|
|
720
|
-
const result = await waitForReadyDevice(
|
|
741
|
+
const result = await waitForReadyDevice(workingDeps, state)
|
|
721
742
|
if (result === 'failed') failed = true
|
|
722
743
|
if (result !== 'closed') {
|
|
723
744
|
stop = true
|
|
@@ -736,10 +757,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
736
757
|
if (stop || !advanced) break
|
|
737
758
|
}
|
|
738
759
|
|
|
739
|
-
|
|
760
|
+
if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
|
|
761
|
+
else await printInitClose(deps, readiness, resolved)
|
|
740
762
|
const blocker = firstBlocker(readiness)
|
|
741
763
|
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
742
|
-
return failed ||
|
|
764
|
+
return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
743
765
|
}
|
|
744
766
|
|
|
745
767
|
function isHookSubstate(id: string): boolean {
|
package/src/commands-send.ts
CHANGED
|
@@ -6,10 +6,9 @@ import {
|
|
|
6
6
|
type ReplyView,
|
|
7
7
|
type SubmissionReceipt,
|
|
8
8
|
} from '@raidiant/notifai-protocol'
|
|
9
|
-
import { randomBytes } from 'node:crypto'
|
|
10
9
|
import { ApiCallError, NetworkError } from './client.js'
|
|
11
10
|
import type { FlagOverrides, loadConfig } from './config.js'
|
|
12
|
-
import { MIN_REPLY_WINDOW_SECONDS,
|
|
11
|
+
import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
|
|
13
12
|
import {
|
|
14
13
|
buildDraft,
|
|
15
14
|
formatReceipt,
|
|
@@ -18,7 +17,7 @@ import {
|
|
|
18
17
|
validateMediaInputs,
|
|
19
18
|
type SendFlags,
|
|
20
19
|
} from './send.js'
|
|
21
|
-
import { recordObservedDeliveryProof } from './commands-setup-proof.js'
|
|
20
|
+
import { recordObservedDeliveryProof, setupProofProject } from './commands-setup-proof.js'
|
|
22
21
|
import {
|
|
23
22
|
EXIT,
|
|
24
23
|
authedClient,
|
|
@@ -28,6 +27,13 @@ import {
|
|
|
28
27
|
type CommandDeps,
|
|
29
28
|
} from './commands-core.js'
|
|
30
29
|
import { activeHarnessSession } from './commands-harness-context.js'
|
|
30
|
+
import { resolveCommandSession } from './command-session.js'
|
|
31
|
+
import {
|
|
32
|
+
beginSendAttempt,
|
|
33
|
+
semanticMediaIds,
|
|
34
|
+
sendDraftFingerprint,
|
|
35
|
+
settleSendAttempt,
|
|
36
|
+
} from './send-attempts.js'
|
|
31
37
|
import {
|
|
32
38
|
acknowledgementCommand,
|
|
33
39
|
printAcknowledgementStatus,
|
|
@@ -49,9 +55,14 @@ export async function sendCommand(
|
|
|
49
55
|
noWait?: boolean
|
|
50
56
|
replyTimeout?: number
|
|
51
57
|
idempotencyKey?: string
|
|
58
|
+
retry?: boolean
|
|
52
59
|
baseUrl?: string
|
|
53
60
|
},
|
|
54
61
|
): Promise<number> {
|
|
62
|
+
if (flags.retry === true && flags.idempotencyKey !== undefined) {
|
|
63
|
+
deps.io.err('Pass --retry or --idempotency-key, not both.')
|
|
64
|
+
return EXIT.usage
|
|
65
|
+
}
|
|
55
66
|
const hasChoice = Array.isArray(flags.choice)
|
|
56
67
|
? flags.choice.length > 0
|
|
57
68
|
: flags.choice !== undefined
|
|
@@ -139,6 +150,35 @@ export async function sendCommand(
|
|
|
139
150
|
}
|
|
140
151
|
const authed = authedClient(deps, config)
|
|
141
152
|
if (!authed) return EXIT.auth
|
|
153
|
+
const credential = deps.store.load()!
|
|
154
|
+
let semanticImages: string[]
|
|
155
|
+
try {
|
|
156
|
+
semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
deps.io.err(`Could not fingerprint an image for safe retry: ${String(err)}`)
|
|
159
|
+
return EXIT.usage
|
|
160
|
+
}
|
|
161
|
+
const semanticBuild = buildDraft(
|
|
162
|
+
config,
|
|
163
|
+
{ ...flags, ...(semanticImages.length > 0 ? { image: semanticImages } : {}) },
|
|
164
|
+
source.invocation,
|
|
165
|
+
)
|
|
166
|
+
if (!semanticBuild.ok) {
|
|
167
|
+
deps.io.err(semanticBuild.error)
|
|
168
|
+
return EXIT.usage
|
|
169
|
+
}
|
|
170
|
+
const attempt = beginSendAttempt({
|
|
171
|
+
env: deps.env,
|
|
172
|
+
credential,
|
|
173
|
+
fingerprint: sendDraftFingerprint(semanticBuild.draft, credential),
|
|
174
|
+
retry: flags.retry === true,
|
|
175
|
+
...(flags.idempotencyKey === undefined ? {} : { idempotencyKey: flags.idempotencyKey }),
|
|
176
|
+
now: (deps.now ?? Date.now)(),
|
|
177
|
+
})
|
|
178
|
+
if (!attempt.ok) {
|
|
179
|
+
deps.io.err(`${attempt.code}: ${attempt.message}`)
|
|
180
|
+
return EXIT.usage
|
|
181
|
+
}
|
|
142
182
|
const mediaIds: string[] = []
|
|
143
183
|
for (const image of flags.image ?? []) {
|
|
144
184
|
if (image.startsWith('med_')) {
|
|
@@ -147,6 +187,7 @@ export async function sendCommand(
|
|
|
147
187
|
}
|
|
148
188
|
const uploaded = await uploadImage(deps, authed.client, image, config.media_origins.value)
|
|
149
189
|
if (!uploaded.ok) {
|
|
190
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
150
191
|
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
151
192
|
return uploaded.exit
|
|
152
193
|
}
|
|
@@ -155,16 +196,19 @@ export async function sendCommand(
|
|
|
155
196
|
flags = { ...flags, image: mediaIds }
|
|
156
197
|
const build = buildDraft(config, flags, source.invocation)
|
|
157
198
|
if (!build.ok) {
|
|
199
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
158
200
|
deps.io.err(build.error)
|
|
159
201
|
return EXIT.usage
|
|
160
202
|
}
|
|
161
203
|
const capabilities = CAPABILITIES_V1.describe(build.platform)
|
|
162
204
|
if (!capabilities) {
|
|
205
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
163
206
|
deps.io.err(`No capability contract is available for ${build.platform}.`)
|
|
164
207
|
return EXIT.usage
|
|
165
208
|
}
|
|
166
209
|
const validation = validateDraft(build.draft, capabilities)
|
|
167
210
|
if (!validation.ok) {
|
|
211
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
168
212
|
for (const issue of validation.errors) deps.io.err(`${issue.path}: ${issue.message}`)
|
|
169
213
|
return EXIT.usage
|
|
170
214
|
}
|
|
@@ -181,15 +225,14 @@ export async function sendCommand(
|
|
|
181
225
|
)
|
|
182
226
|
}
|
|
183
227
|
const waitSeconds = flags.noWait ? 0 : config.wait_seconds.value
|
|
184
|
-
const idempotencyKey =
|
|
228
|
+
const idempotencyKey = attempt.idempotencyKey
|
|
185
229
|
// Persist the retry identity before entering the ambiguous network boundary.
|
|
186
230
|
// If the process is killed after the server accepts but before a response,
|
|
187
231
|
// this is the only durable way to reconstruct the same Agent Event key.
|
|
188
232
|
log(deps).info('send.attempt', {
|
|
233
|
+
attempt_id: attempt.attemptId,
|
|
189
234
|
idempotency_key: idempotencyKey,
|
|
190
|
-
|
|
191
|
-
title: flags.title,
|
|
192
|
-
session_id: source.invocation.source?.session_id ?? null,
|
|
235
|
+
replay: attempt.replay,
|
|
193
236
|
})
|
|
194
237
|
let receipt: SubmissionReceipt
|
|
195
238
|
try {
|
|
@@ -202,17 +245,23 @@ export async function sendCommand(
|
|
|
202
245
|
if (exit === EXIT.network) {
|
|
203
246
|
deps.io.err(
|
|
204
247
|
'The server may have accepted this Notification Request before the connection failed. ' +
|
|
205
|
-
|
|
248
|
+
'Re-run the exact semantic send with `--retry`; the CLI will reuse its opaque attempt identity.',
|
|
206
249
|
)
|
|
207
250
|
}
|
|
208
251
|
if (err instanceof ApiCallError && err.code === 'no_active_devices') {
|
|
209
252
|
deps.io.err(
|
|
210
|
-
'After setup succeeds, repeat this original Notification Request with ' +
|
|
211
|
-
|
|
253
|
+
'After setup succeeds, repeat this exact original Notification Request with `--retry`; ' +
|
|
254
|
+
'the setup verification request does not replace this Agent Event.',
|
|
212
255
|
)
|
|
213
256
|
}
|
|
257
|
+
if (!(exit === EXIT.network || (err instanceof ApiCallError && err.code === 'no_active_devices'))) {
|
|
258
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
259
|
+
} else {
|
|
260
|
+
deps.io.err(`Opaque retry attempt: ${attempt.attemptId}. Re-run the exact semantic send with \`--retry\`; do not retry automatically.`)
|
|
261
|
+
}
|
|
214
262
|
return exit
|
|
215
263
|
}
|
|
264
|
+
settleSendAttempt(deps.env, attempt.attemptId)
|
|
216
265
|
const receiptExit = receiptExitCode(receipt)
|
|
217
266
|
// The single most useful line in the log: it ties the local invocation to
|
|
218
267
|
// the server-side request id, which is what every later question about this
|
|
@@ -383,12 +432,15 @@ export async function repliesCommand(
|
|
|
383
432
|
return EXIT.usage
|
|
384
433
|
}
|
|
385
434
|
let requestIds = requestedId === undefined ? [] : [requestedId]
|
|
435
|
+
let lifecycleSessionId: string | undefined
|
|
386
436
|
if (flags.pending === true) {
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
437
|
+
const session = resolveCommandSession(deps)
|
|
438
|
+
if (session === null) {
|
|
389
439
|
deps.io.err('No active session pointer is available in this directory.')
|
|
390
440
|
return EXIT.noReply
|
|
391
441
|
}
|
|
442
|
+
const sessionId = session.sessionId
|
|
443
|
+
lifecycleSessionId = sessionId
|
|
392
444
|
// Every delivered question in the session's queue, in registration order —
|
|
393
445
|
// an agent may have several outstanding at once.
|
|
394
446
|
const state = readSessionState(sessionId, deps.env)
|
|
@@ -445,7 +497,11 @@ export async function repliesCommand(
|
|
|
445
497
|
return EXIT.usage
|
|
446
498
|
}
|
|
447
499
|
|
|
448
|
-
const config = loadLoggedConfig(deps, {
|
|
500
|
+
const config = loadLoggedConfig(deps, {
|
|
501
|
+
cwd: deps.cwd,
|
|
502
|
+
env: deps.env,
|
|
503
|
+
...(lifecycleSessionId === undefined ? {} : { sessionId: lifecycleSessionId }),
|
|
504
|
+
})
|
|
449
505
|
const authed = authedClient(deps, config)
|
|
450
506
|
if (!authed) return EXIT.auth
|
|
451
507
|
try {
|
|
@@ -649,7 +705,7 @@ export async function statusCommand(
|
|
|
649
705
|
if (!authed) return EXIT.auth
|
|
650
706
|
try {
|
|
651
707
|
const snapshot = await authed.client.evidence(requestId)
|
|
652
|
-
recordObservedDeliveryProof(deps, snapshot, config.project.value)
|
|
708
|
+
recordObservedDeliveryProof(deps, snapshot, setupProofProject(deps, config.project.value))
|
|
653
709
|
if (flags.json) {
|
|
654
710
|
deps.io.out(JSON.stringify(snapshot, null, 2))
|
|
655
711
|
return EXIT.ok
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { CommandDeps } from './commands-core.js'
|
|
6
|
+
import type { MachineCredential } from './credentials.js'
|
|
7
|
+
import { readSetupProof, setupProofApplies, writeSetupProof } from './commands-setup-proof.js'
|
|
8
|
+
|
|
9
|
+
const roots: string[] = []
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function deps(root: string, cwd: string, credential: MachineCredential): CommandDeps {
|
|
15
|
+
return {
|
|
16
|
+
cwd,
|
|
17
|
+
env: { XDG_STATE_HOME: path.join(root, 'state'), XDG_CONFIG_HOME: path.join(root, 'config') },
|
|
18
|
+
io: { out: () => {}, err: () => {}, confirm: async () => false, openUrl: () => {} },
|
|
19
|
+
store: {
|
|
20
|
+
load: () => credential,
|
|
21
|
+
save: () => {},
|
|
22
|
+
clear: () => {},
|
|
23
|
+
describe: () => 'test',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('stable setup delivery proof identity', () => {
|
|
29
|
+
it('is shared by linked checkouts of one Project and separated by Project', () => {
|
|
30
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-identity-'))
|
|
31
|
+
roots.push(root)
|
|
32
|
+
const credential = {
|
|
33
|
+
machineId: 'mac_one',
|
|
34
|
+
machineName: 'One',
|
|
35
|
+
baseUrl: 'https://app.notifai.test',
|
|
36
|
+
secret: 'approval-one',
|
|
37
|
+
}
|
|
38
|
+
const first = deps(root, path.join(root, 'worktree-a'), credential)
|
|
39
|
+
const second = deps(root, path.join(root, 'worktree-b'), credential)
|
|
40
|
+
const proof = {
|
|
41
|
+
request_id: 'req_proof',
|
|
42
|
+
device_id: 'dev_one',
|
|
43
|
+
project: 'shared-project',
|
|
44
|
+
started_at: '2026-08-26T00:00:00.000Z',
|
|
45
|
+
}
|
|
46
|
+
expect(writeSetupProof(first, proof)).toBe(true)
|
|
47
|
+
expect(readSetupProof(second, 'shared-project')).toEqual(proof)
|
|
48
|
+
expect(readSetupProof(second, 'different-project')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('invalidates on service, machine approval, machine, or device change', () => {
|
|
52
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-proof-invalidation-'))
|
|
53
|
+
roots.push(root)
|
|
54
|
+
const credential = {
|
|
55
|
+
machineId: 'mac_one',
|
|
56
|
+
machineName: 'One',
|
|
57
|
+
baseUrl: 'https://app.notifai.test',
|
|
58
|
+
secret: 'approval-one',
|
|
59
|
+
}
|
|
60
|
+
const original = deps(root, root, credential)
|
|
61
|
+
expect(writeSetupProof(original, {
|
|
62
|
+
request_id: 'req_proof',
|
|
63
|
+
device_id: 'dev_one',
|
|
64
|
+
project: 'project',
|
|
65
|
+
started_at: '2026-08-26T00:00:00.000Z',
|
|
66
|
+
})).toBe(true)
|
|
67
|
+
expect(readSetupProof(deps(root, root, { ...credential, baseUrl: 'https://other.test' }), 'project')).toBeNull()
|
|
68
|
+
expect(readSetupProof(deps(root, root, { ...credential, machineId: 'mac_two' }), 'project')).toBeNull()
|
|
69
|
+
expect(readSetupProof(deps(root, root, { ...credential, secret: 'approval-two' }), 'project')).toBeNull()
|
|
70
|
+
expect(setupProofApplies(readSetupProof(original, 'project'), 'project', ['dev_two'])).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -4,34 +4,41 @@ import {
|
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
-
realpathSync,
|
|
8
7
|
writeFileSync,
|
|
9
8
|
} from 'node:fs'
|
|
10
9
|
import path from 'node:path'
|
|
11
10
|
import { stateDir } from './config.js'
|
|
12
11
|
import type { CommandDeps } from './commands-core.js'
|
|
12
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
13
13
|
|
|
14
|
-
interface SetupProofRecord {
|
|
14
|
+
export interface SetupProofRecord {
|
|
15
15
|
request_id: string
|
|
16
16
|
device_id: string
|
|
17
17
|
project: string | null
|
|
18
18
|
started_at: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
export function setupProofProject(deps: CommandDeps, configured: string | null): string | null {
|
|
22
|
+
return configured ?? inferInvocationContext(deps.cwd).project
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setupProofPath(deps: CommandDeps, project: string | null): string | null {
|
|
26
|
+
const credential = deps.store.load()
|
|
27
|
+
if (credential === null) return null
|
|
28
|
+
const approval = createHash('sha256')
|
|
29
|
+
.update('notifai-setup-proof-approval-v1\0')
|
|
30
|
+
.update(credential.secret)
|
|
31
|
+
.digest('base64url')
|
|
32
|
+
const digest = createHash('sha256')
|
|
33
|
+
.update(JSON.stringify({ project, machine_id: credential.machineId, service: credential.baseUrl, approval }))
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 32)
|
|
30
36
|
return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
34
|
-
const file = setupProofPath(deps)
|
|
39
|
+
export function readSetupProof(deps: CommandDeps, project: string | null): SetupProofRecord | null {
|
|
40
|
+
const file = setupProofPath(deps, project)
|
|
41
|
+
if (file === null) return null
|
|
35
42
|
if (!existsSync(file)) return null
|
|
36
43
|
try {
|
|
37
44
|
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
|
|
@@ -48,7 +55,11 @@ export function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
|
|
51
|
-
const file = setupProofPath(deps)
|
|
58
|
+
const file = setupProofPath(deps, proof.project)
|
|
59
|
+
if (file === null) {
|
|
60
|
+
deps.io.err('Could not save setup proof without an Approved Machine credential.')
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
52
63
|
try {
|
|
53
64
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
54
65
|
writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
|
|
@@ -82,6 +93,14 @@ export function setupProofIsStale(proof: SetupProofRecord, now: number): boolean
|
|
|
82
93
|
return Number.isFinite(started) && now - started > SETUP_PROOF_STALE_MS
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
export function setupProofApplies(
|
|
97
|
+
proof: SetupProofRecord | null,
|
|
98
|
+
project: string | null,
|
|
99
|
+
deviceIds: readonly string[],
|
|
100
|
+
): proof is SetupProofRecord {
|
|
101
|
+
return proof !== null && proof.project === project && deviceIds.includes(proof.device_id)
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
/**
|
|
86
105
|
* Persist ordinary send/status Companion Receipts as the project's delivery
|
|
87
106
|
* proof so doctor does not demand a second verification request.
|