@raidiant/notifai 0.1.1 → 0.1.2
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.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import os from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
CAPABILITIES_V1,
|
|
9
9
|
REPLY_MAX_WINDOW_SECONDS,
|
|
10
10
|
validateDraft,
|
|
11
|
+
type EvidenceSnapshot,
|
|
11
12
|
type ListRepliesResponse,
|
|
12
13
|
type Platform,
|
|
13
14
|
type ReplyView,
|
|
15
|
+
type RoutableDevice,
|
|
16
|
+
type SubmissionReceipt,
|
|
14
17
|
} from '@raidiant/notifai-protocol'
|
|
15
18
|
import { sha256Hex } from '@raidiant/notifai-protocol/node'
|
|
16
19
|
import {
|
|
@@ -668,9 +671,24 @@ export async function statusCommand(
|
|
|
668
671
|
}
|
|
669
672
|
deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`)
|
|
670
673
|
for (const d of snapshot.deliveries) {
|
|
671
|
-
deps.io.out(` ${d.device_name}
|
|
674
|
+
deps.io.out(` ${d.device_name}:`)
|
|
675
|
+
deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`)
|
|
676
|
+
deps.io.out(
|
|
677
|
+
` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`,
|
|
678
|
+
)
|
|
679
|
+
if (d.companion_receipt.state === 'observed') {
|
|
680
|
+
const latency = d.companion_receipt.latency_ms
|
|
681
|
+
deps.io.out(
|
|
682
|
+
` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
|
|
683
|
+
(latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`),
|
|
684
|
+
)
|
|
685
|
+
} else {
|
|
686
|
+
deps.io.out(
|
|
687
|
+
' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt',
|
|
688
|
+
)
|
|
689
|
+
}
|
|
672
690
|
for (const e of d.events) {
|
|
673
|
-
deps.io.out(`
|
|
691
|
+
deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`)
|
|
674
692
|
}
|
|
675
693
|
}
|
|
676
694
|
return EXIT.ok
|
|
@@ -679,6 +697,13 @@ export async function statusCommand(
|
|
|
679
697
|
}
|
|
680
698
|
}
|
|
681
699
|
|
|
700
|
+
function formatElapsed(milliseconds: number): string {
|
|
701
|
+
const totalSeconds = Math.floor(milliseconds / 1_000)
|
|
702
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
703
|
+
const seconds = totalSeconds % 60
|
|
704
|
+
return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`
|
|
705
|
+
}
|
|
706
|
+
|
|
682
707
|
const MEDIA_TYPES: Record<string, 'image/jpeg' | 'image/png' | 'image/gif'> = {
|
|
683
708
|
'.jpg': 'image/jpeg',
|
|
684
709
|
'.jpeg': 'image/jpeg',
|
|
@@ -944,15 +969,20 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
944
969
|
deps.io.err(CHOICE_USAGE)
|
|
945
970
|
return EXIT.usage
|
|
946
971
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
972
|
+
try {
|
|
973
|
+
registerQuestion(
|
|
974
|
+
sessionId,
|
|
975
|
+
deps.env,
|
|
976
|
+
{
|
|
977
|
+
question: question.trim(),
|
|
978
|
+
...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
|
|
979
|
+
},
|
|
980
|
+
(deps.now ?? Date.now)(),
|
|
981
|
+
)
|
|
982
|
+
} catch (err) {
|
|
983
|
+
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
984
|
+
return EXIT.failed
|
|
985
|
+
}
|
|
956
986
|
if (choices !== null) {
|
|
957
987
|
deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`)
|
|
958
988
|
}
|
|
@@ -1419,14 +1449,11 @@ export async function configSetCommand(
|
|
|
1419
1449
|
// ---------------------------------------------------------------------------
|
|
1420
1450
|
|
|
1421
1451
|
/**
|
|
1422
|
-
* Where `npx skills add` fetches the optional agent skill from.
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
* keeps `--skills` from installing anything until a tagged release exists to
|
|
1426
|
-
* pin this to. Never point this at the private repository — the installer
|
|
1427
|
-
* command is printed to users, and only public sources belong in it.
|
|
1452
|
+
* Where `npx skills add` fetches the optional agent skill from. In skills CLI
|
|
1453
|
+
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1454
|
+
* Keep this immutable and public because the command is printed to users.
|
|
1428
1455
|
*/
|
|
1429
|
-
const SKILLS_SOURCE = ''
|
|
1456
|
+
export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.2'
|
|
1430
1457
|
|
|
1431
1458
|
/** Derive a contract-valid project slug from a directory name. */
|
|
1432
1459
|
export function projectSlugFrom(name: string): string {
|
|
@@ -1451,6 +1478,72 @@ export interface InitFlags {
|
|
|
1451
1478
|
hooks?: boolean
|
|
1452
1479
|
}
|
|
1453
1480
|
|
|
1481
|
+
interface SetupProofRecord {
|
|
1482
|
+
request_id: string
|
|
1483
|
+
device_id: string
|
|
1484
|
+
project: string | null
|
|
1485
|
+
started_at: string
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000
|
|
1489
|
+
const DEVICE_BRIDGE_POLL_MS = 2_000
|
|
1490
|
+
const PROOF_TIMEOUT_MS = 30_000
|
|
1491
|
+
const PROOF_POLL_MS = 1_000
|
|
1492
|
+
|
|
1493
|
+
function setupProofPath(deps: CommandDeps): string {
|
|
1494
|
+
let projectDir = path.resolve(deps.cwd)
|
|
1495
|
+
try {
|
|
1496
|
+
projectDir = realpathSync(projectDir)
|
|
1497
|
+
} catch {
|
|
1498
|
+
// A deleted or not-yet-created cwd cannot collide with a real directory:
|
|
1499
|
+
// the resolved absolute path is still a stable local identity for it.
|
|
1500
|
+
}
|
|
1501
|
+
const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
|
|
1502
|
+
const xdg = deps.env['XDG_STATE_HOME']
|
|
1503
|
+
const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
|
|
1504
|
+
return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
1508
|
+
const file = setupProofPath(deps)
|
|
1509
|
+
if (!existsSync(file)) return null
|
|
1510
|
+
try {
|
|
1511
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<SetupProofRecord>
|
|
1512
|
+
return typeof parsed.request_id === 'string' &&
|
|
1513
|
+
typeof parsed.device_id === 'string' &&
|
|
1514
|
+
(typeof parsed.project === 'string' || parsed.project === null) &&
|
|
1515
|
+
typeof parsed.started_at === 'string'
|
|
1516
|
+
? (parsed as SetupProofRecord)
|
|
1517
|
+
: null
|
|
1518
|
+
} catch {
|
|
1519
|
+
// Corrupt local evidence is not readiness. A fresh proof replaces it.
|
|
1520
|
+
return null
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function writeSetupProof(deps: CommandDeps, proof: SetupProofRecord): boolean {
|
|
1525
|
+
const file = setupProofPath(deps)
|
|
1526
|
+
try {
|
|
1527
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
1528
|
+
writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 })
|
|
1529
|
+
return true
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
deps.io.err(
|
|
1532
|
+
`Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`,
|
|
1533
|
+
)
|
|
1534
|
+
return false
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function observedCompanionReceipt(
|
|
1539
|
+
snapshot: EvidenceSnapshot,
|
|
1540
|
+
deviceId: string,
|
|
1541
|
+
): { delivery: EvidenceSnapshot['deliveries'][number]; observedAt: string } | null {
|
|
1542
|
+
const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId)
|
|
1543
|
+
const receipt = delivery?.events.find((event) => event.stage === 'companion_received')
|
|
1544
|
+
return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1454
1547
|
/**
|
|
1455
1548
|
* The setup coordinator that observes each prerequisite and advances the ones
|
|
1456
1549
|
* this build can perform.
|
|
@@ -1469,10 +1562,16 @@ export interface InitFlags {
|
|
|
1469
1562
|
* this stays silent about what it did — the re-assessment that follows reports
|
|
1470
1563
|
* the new state, and narrating both is how a setup log becomes unreadable.
|
|
1471
1564
|
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1565
|
+
* `pending` means the action is real but its evidence has not arrived yet;
|
|
1566
|
+
* `failed` means the action itself could not be performed.
|
|
1474
1567
|
*/
|
|
1475
|
-
|
|
1568
|
+
type GapCloseResult = 'closed' | 'pending' | 'failed'
|
|
1569
|
+
|
|
1570
|
+
async function closeGap(
|
|
1571
|
+
deps: CommandDeps,
|
|
1572
|
+
state: ReadinessState,
|
|
1573
|
+
flags: InitFlags,
|
|
1574
|
+
): Promise<GapCloseResult> {
|
|
1476
1575
|
if (state.id === 'project') {
|
|
1477
1576
|
const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
|
|
1478
1577
|
const existing = existsSync(configPath)
|
|
@@ -1481,7 +1580,7 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
|
|
|
1481
1580
|
existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
1482
1581
|
mkdirSync(path.dirname(configPath), { recursive: true })
|
|
1483
1582
|
writeFileSync(configPath, `${stringifyToml(existing)}\n`)
|
|
1484
|
-
return
|
|
1583
|
+
return 'closed'
|
|
1485
1584
|
}
|
|
1486
1585
|
|
|
1487
1586
|
if (state.id === 'hooks') {
|
|
@@ -1497,9 +1596,9 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
|
|
|
1497
1596
|
deps.io.err(
|
|
1498
1597
|
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
1499
1598
|
)
|
|
1500
|
-
return
|
|
1599
|
+
return 'failed'
|
|
1501
1600
|
}
|
|
1502
|
-
return hooksInstallCommand(deps, { harness }) === EXIT.ok
|
|
1601
|
+
return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed'
|
|
1503
1602
|
}
|
|
1504
1603
|
|
|
1505
1604
|
if (state.id === 'skill') {
|
|
@@ -1516,10 +1615,263 @@ async function closeGap(deps: CommandDeps, state: ReadinessState, flags: InitFla
|
|
|
1516
1615
|
deps.io.err('Skill installation failed — run it manually with:')
|
|
1517
1616
|
deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`)
|
|
1518
1617
|
}
|
|
1519
|
-
return code === 0
|
|
1618
|
+
return code === 0 ? 'closed' : 'failed'
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (state.id === 'proof') return await runSetupProof(deps)
|
|
1622
|
+
|
|
1623
|
+
return 'failed'
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function deviceCanReceive(device: RoutableDevice): boolean {
|
|
1627
|
+
return (
|
|
1628
|
+
device.registration_healthy &&
|
|
1629
|
+
(device.permission_status === 'authorized' || device.permission_status === 'provisional')
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function readyIosDevices(devices: readonly RoutableDevice[]): RoutableDevice[] {
|
|
1634
|
+
return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device))
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
1638
|
+
if (devices.length === 0) {
|
|
1639
|
+
return 'Waiting for the companion app to sign in and register…'
|
|
1640
|
+
}
|
|
1641
|
+
const denied = devices.find((device) => device.permission_status === 'denied')
|
|
1642
|
+
if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
|
|
1643
|
+
const undecided = devices.find((device) => device.permission_status === 'not_determined')
|
|
1644
|
+
if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
|
|
1645
|
+
return 'Waiting for a companion device to become ready…'
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Observe the supported Device Installation path while the user finishes the
|
|
1650
|
+
* app-side work. There is deliberately no invented QR or download URL here:
|
|
1651
|
+
* this release exposes neither an App Store/TestFlight URL nor a device-pairing
|
|
1652
|
+
* endpoint, and a placeholder bridge is worse than an explicit boundary.
|
|
1653
|
+
*/
|
|
1654
|
+
async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Promise<GapCloseResult> {
|
|
1655
|
+
const remedy = state.remedy
|
|
1656
|
+
if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere') return 'pending'
|
|
1657
|
+
|
|
1658
|
+
await deps.io.note?.(
|
|
1659
|
+
`${state.detail}\n${remedy.summary}`,
|
|
1660
|
+
'Finish setup on your companion device',
|
|
1661
|
+
)
|
|
1662
|
+
if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
|
|
1663
|
+
return 'pending'
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1667
|
+
const authed = authedClient(deps, config)
|
|
1668
|
+
if (!authed) return 'failed'
|
|
1669
|
+
const now = deps.now ?? Date.now
|
|
1670
|
+
const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
1671
|
+
const deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
1672
|
+
const spinner = await deps.io.spinner?.('Waiting for a companion device…')
|
|
1673
|
+
let lastDevices: RoutableDevice[] = []
|
|
1674
|
+
|
|
1675
|
+
while (now() < deadline) {
|
|
1676
|
+
try {
|
|
1677
|
+
const response = await authed.client.listDevices()
|
|
1678
|
+
lastDevices = response.devices
|
|
1679
|
+
const ready = response.devices.find(deviceCanReceive)
|
|
1680
|
+
if (ready) {
|
|
1681
|
+
spinner?.stop(`${ready.display_name} is ready to receive`)
|
|
1682
|
+
return 'closed'
|
|
1683
|
+
}
|
|
1684
|
+
spinner?.message(deviceBridgeMessage(response.devices))
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
if (!(err instanceof NetworkError)) {
|
|
1687
|
+
spinner?.error('Could not check companion readiness')
|
|
1688
|
+
reportError(deps, err)
|
|
1689
|
+
return 'failed'
|
|
1690
|
+
}
|
|
1691
|
+
spinner?.message('Connection lost — still watching…')
|
|
1692
|
+
}
|
|
1693
|
+
await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())))
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
spinner?.error('No companion device became ready')
|
|
1697
|
+
deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
|
|
1698
|
+
return 'pending'
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function setupProofDraft(
|
|
1702
|
+
config: CliConfig,
|
|
1703
|
+
device: RoutableDevice,
|
|
1704
|
+
): ReturnType<typeof buildDraft> {
|
|
1705
|
+
const project = config.project.value
|
|
1706
|
+
return buildDraft(config, {
|
|
1707
|
+
title: 'NotifAI is ready',
|
|
1708
|
+
body:
|
|
1709
|
+
project === null
|
|
1710
|
+
? 'This real notification completed setup verification.'
|
|
1711
|
+
: `This real notification completed setup verification for ${project}.`,
|
|
1712
|
+
event: 'setup_verified',
|
|
1713
|
+
kind: 'update',
|
|
1714
|
+
platform: 'ios',
|
|
1715
|
+
device: [device.device_id],
|
|
1716
|
+
sound: 'none',
|
|
1717
|
+
level: 'passive',
|
|
1718
|
+
collapseKey: 'notifai-setup-verification',
|
|
1719
|
+
})
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function submitSetupProof(
|
|
1723
|
+
deps: CommandDeps,
|
|
1724
|
+
client: ApiClient,
|
|
1725
|
+
config: CliConfig,
|
|
1726
|
+
device: RoutableDevice,
|
|
1727
|
+
): Promise<SubmissionReceipt | null> {
|
|
1728
|
+
const build = setupProofDraft(config, device)
|
|
1729
|
+
if (!build.ok) {
|
|
1730
|
+
deps.io.err(`Could not build the setup verification notification: ${build.error}`)
|
|
1731
|
+
return null
|
|
1732
|
+
}
|
|
1733
|
+
const capabilities = CAPABILITIES_V1.describe(build.platform)
|
|
1734
|
+
if (!capabilities) {
|
|
1735
|
+
deps.io.err(`No capability contract is available for ${build.platform}.`)
|
|
1736
|
+
return null
|
|
1737
|
+
}
|
|
1738
|
+
const validation = validateDraft(build.draft, capabilities)
|
|
1739
|
+
if (!validation.ok) {
|
|
1740
|
+
for (const issue of validation.errors) {
|
|
1741
|
+
deps.io.err(`Setup verification ${issue.path}: ${issue.message}`)
|
|
1742
|
+
}
|
|
1743
|
+
return null
|
|
1744
|
+
}
|
|
1745
|
+
try {
|
|
1746
|
+
return await client.submit(
|
|
1747
|
+
{
|
|
1748
|
+
idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
|
|
1749
|
+
draft: build.draft,
|
|
1750
|
+
},
|
|
1751
|
+
config.wait_seconds.value,
|
|
1752
|
+
)
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
reportError(deps, err)
|
|
1755
|
+
return null
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Send or resume one real setup probe, then wait for a Companion Receipt.
|
|
1761
|
+
* Provider Acceptance is intentionally insufficient: it proves APNs accepted
|
|
1762
|
+
* the push, not that a companion process received it.
|
|
1763
|
+
*/
|
|
1764
|
+
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
1765
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1766
|
+
const authed = authedClient(deps, config)
|
|
1767
|
+
if (!authed) return 'failed'
|
|
1768
|
+
|
|
1769
|
+
let devices: RoutableDevice[]
|
|
1770
|
+
try {
|
|
1771
|
+
devices = (await authed.client.listDevices()).devices
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
reportError(deps, err)
|
|
1774
|
+
return 'failed'
|
|
1775
|
+
}
|
|
1776
|
+
const candidates = readyIosDevices(devices)
|
|
1777
|
+
const existing = readSetupProof(deps)
|
|
1778
|
+
const target =
|
|
1779
|
+
candidates.find(
|
|
1780
|
+
(device) =>
|
|
1781
|
+
device.device_id === existing?.device_id && existing.project === config.project.value,
|
|
1782
|
+
) ?? candidates[0]
|
|
1783
|
+
if (!target) {
|
|
1784
|
+
deps.io.err(
|
|
1785
|
+
'Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.',
|
|
1786
|
+
)
|
|
1787
|
+
return 'pending'
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
let proof =
|
|
1791
|
+
existing?.device_id === target.device_id && existing.project === config.project.value
|
|
1792
|
+
? existing
|
|
1793
|
+
: null
|
|
1794
|
+
if (proof === null) {
|
|
1795
|
+
const receipt = await submitSetupProof(deps, authed.client, config, target)
|
|
1796
|
+
if (receipt === null) return 'failed'
|
|
1797
|
+
if (receipt.overall === 'provider_rejected_all') {
|
|
1798
|
+
deps.io.err(formatReceipt(receipt))
|
|
1799
|
+
return 'failed'
|
|
1800
|
+
}
|
|
1801
|
+
proof = {
|
|
1802
|
+
request_id: receipt.request_id,
|
|
1803
|
+
device_id: target.device_id,
|
|
1804
|
+
project: config.project.value,
|
|
1805
|
+
started_at: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
1806
|
+
}
|
|
1807
|
+
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
1808
|
+
deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`)
|
|
1809
|
+
} else {
|
|
1810
|
+
deps.io.out(`Checking verification notification ${proof.request_id} again.`)
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const now = deps.now ?? Date.now
|
|
1814
|
+
const sleep = deps.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
1815
|
+
const deadline = now() + PROOF_TIMEOUT_MS
|
|
1816
|
+
const spinner = deps.io.interactive === true
|
|
1817
|
+
? await deps.io.spinner?.('Waiting for a Companion Receipt…')
|
|
1818
|
+
: null
|
|
1819
|
+
let lastError: unknown = null
|
|
1820
|
+
let replacedMissingProof = false
|
|
1821
|
+
|
|
1822
|
+
for (;;) {
|
|
1823
|
+
try {
|
|
1824
|
+
const snapshot = await authed.client.evidence(proof.request_id)
|
|
1825
|
+
const observed = observedCompanionReceipt(snapshot, proof.device_id)
|
|
1826
|
+
if (observed) {
|
|
1827
|
+
spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
|
|
1828
|
+
return 'closed'
|
|
1829
|
+
}
|
|
1830
|
+
lastError = null
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
lastError = err
|
|
1833
|
+
if (
|
|
1834
|
+
err instanceof ApiCallError &&
|
|
1835
|
+
err.code === 'not_found' &&
|
|
1836
|
+
!replacedMissingProof
|
|
1837
|
+
) {
|
|
1838
|
+
const receipt = await submitSetupProof(deps, authed.client, config, target)
|
|
1839
|
+
if (receipt === null) return 'failed'
|
|
1840
|
+
if (receipt.overall === 'provider_rejected_all') {
|
|
1841
|
+
deps.io.err(formatReceipt(receipt))
|
|
1842
|
+
return 'failed'
|
|
1843
|
+
}
|
|
1844
|
+
proof = {
|
|
1845
|
+
request_id: receipt.request_id,
|
|
1846
|
+
device_id: target.device_id,
|
|
1847
|
+
project: config.project.value,
|
|
1848
|
+
started_at: new Date(now()).toISOString(),
|
|
1849
|
+
}
|
|
1850
|
+
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
1851
|
+
replacedMissingProof = true
|
|
1852
|
+
lastError = null
|
|
1853
|
+
deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`)
|
|
1854
|
+
continue
|
|
1855
|
+
}
|
|
1856
|
+
if (!(err instanceof NetworkError)) {
|
|
1857
|
+
spinner?.error('Could not read Companion Receipt evidence')
|
|
1858
|
+
reportError(deps, err)
|
|
1859
|
+
return 'failed'
|
|
1860
|
+
}
|
|
1861
|
+
spinner?.message('Connection lost — still checking the same request…')
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (now() >= deadline) break
|
|
1865
|
+
await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())))
|
|
1520
1866
|
}
|
|
1521
1867
|
|
|
1522
|
-
|
|
1868
|
+
spinner?.error('Companion Receipt not observed yet')
|
|
1869
|
+
if (lastError instanceof NetworkError) deps.io.err(lastError.message)
|
|
1870
|
+
deps.io.err(
|
|
1871
|
+
`No Companion Receipt was observed for ${proof.request_id} within ${PROOF_TIMEOUT_MS / 1000}s. ` +
|
|
1872
|
+
'That is not proof of non-receipt; re-run `notifai init` to check this same notification again.',
|
|
1873
|
+
)
|
|
1874
|
+
return 'pending'
|
|
1523
1875
|
}
|
|
1524
1876
|
|
|
1525
1877
|
/** Whether an optional gap should be closed, given flags and who is watching. */
|
|
@@ -1559,37 +1911,104 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
1559
1911
|
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
1560
1912
|
await deps.io.intro?.('NotifAI setup')
|
|
1561
1913
|
|
|
1562
|
-
if (SKILLS_SOURCE === '' && flags.skills === true) {
|
|
1563
|
-
deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.')
|
|
1564
|
-
return EXIT.failed
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
1914
|
let readiness = await assessReadiness(deps)
|
|
1568
1915
|
let failed = false
|
|
1916
|
+
const attempted = new Set<string>()
|
|
1917
|
+
|
|
1918
|
+
// Re-assess after every successful action. This is how a browser approval or
|
|
1919
|
+
// companion registration can unlock the next state while the user is still
|
|
1920
|
+
// here, without copying the dependency graph into a second setup script.
|
|
1921
|
+
for (;;) {
|
|
1922
|
+
let advanced = false
|
|
1923
|
+
let stop = false
|
|
1924
|
+
|
|
1925
|
+
for (const state of readiness.states) {
|
|
1926
|
+
if (state.status === 'ready') continue
|
|
1927
|
+
if (state.status === 'unknown') {
|
|
1928
|
+
stop = true
|
|
1929
|
+
break
|
|
1930
|
+
}
|
|
1569
1931
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1932
|
+
const remedy = state.remedy
|
|
1933
|
+
if (remedy === undefined || attempted.has(state.id)) {
|
|
1934
|
+
if (state.status === 'gap') stop = true
|
|
1935
|
+
if (stop) break
|
|
1936
|
+
continue
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (state.status === 'optional-gap') {
|
|
1940
|
+
if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags))) continue
|
|
1941
|
+
attempted.add(state.id)
|
|
1942
|
+
const result = await closeGap(deps, state, flags)
|
|
1943
|
+
if (result === 'failed') failed = true
|
|
1944
|
+
if (result !== 'closed') {
|
|
1945
|
+
stop = true
|
|
1946
|
+
break
|
|
1947
|
+
}
|
|
1948
|
+
readiness = await assessReadiness(deps)
|
|
1949
|
+
advanced = true
|
|
1950
|
+
break
|
|
1951
|
+
}
|
|
1586
1952
|
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1953
|
+
if (remedy.by === 'cli') {
|
|
1954
|
+
attempted.add(state.id)
|
|
1955
|
+
const result = await closeGap(deps, state, flags)
|
|
1956
|
+
if (result === 'failed') failed = true
|
|
1957
|
+
if (result !== 'closed') {
|
|
1958
|
+
stop = true
|
|
1959
|
+
break
|
|
1960
|
+
}
|
|
1961
|
+
readiness = await assessReadiness(deps)
|
|
1962
|
+
advanced = true
|
|
1963
|
+
break
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// Its to launch, theirs to complete. Offering to start the sign-in is
|
|
1967
|
+
// useful only when someone is demonstrably watching; an agent never
|
|
1968
|
+
// reaches this prompt or opens a browser.
|
|
1969
|
+
if (
|
|
1970
|
+
remedy.by === 'user-here' &&
|
|
1971
|
+
remedy.interactive === true &&
|
|
1972
|
+
deps.io.interactive === true
|
|
1973
|
+
) {
|
|
1974
|
+
attempted.add(state.id)
|
|
1975
|
+
if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
|
|
1976
|
+
stop = true
|
|
1977
|
+
break
|
|
1978
|
+
}
|
|
1979
|
+
if ((await loginCommand(deps, {})) !== EXIT.ok) {
|
|
1980
|
+
failed = true
|
|
1981
|
+
stop = true
|
|
1982
|
+
break
|
|
1983
|
+
}
|
|
1984
|
+
readiness = await assessReadiness(deps)
|
|
1985
|
+
advanced = true
|
|
1986
|
+
break
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
if (
|
|
1990
|
+
state.id === 'devices' &&
|
|
1991
|
+
remedy.by === 'user-elsewhere' &&
|
|
1992
|
+
deps.io.interactive === true
|
|
1993
|
+
) {
|
|
1994
|
+
attempted.add(state.id)
|
|
1995
|
+
const result = await waitForReadyDevice(deps, state)
|
|
1996
|
+
if (result === 'failed') failed = true
|
|
1997
|
+
if (result !== 'closed') {
|
|
1998
|
+
stop = true
|
|
1999
|
+
break
|
|
2000
|
+
}
|
|
2001
|
+
readiness = await assessReadiness(deps)
|
|
2002
|
+
advanced = true
|
|
2003
|
+
break
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// A human-only remedy is the first blocker for an unattended agent.
|
|
2007
|
+
stop = true
|
|
2008
|
+
break
|
|
1592
2009
|
}
|
|
2010
|
+
|
|
2011
|
+
if (stop || !advanced) break
|
|
1593
2012
|
}
|
|
1594
2013
|
|
|
1595
2014
|
readiness = await assessReadiness(deps)
|
|
@@ -1616,7 +2035,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
1616
2035
|
deps.io.out(' Then re-run `notifai init` and it will pick up from here.')
|
|
1617
2036
|
}
|
|
1618
2037
|
await deps.io.outro?.('One step remains (above)')
|
|
1619
|
-
|
|
2038
|
+
// An agent must be able to branch on setup being blocked without parsing
|
|
2039
|
+
// prose. A present human may deliberately leave and resume later.
|
|
2040
|
+
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
1620
2041
|
}
|
|
1621
2042
|
|
|
1622
2043
|
// ---------------------------------------------------------------------------
|
|
@@ -1675,6 +2096,8 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
|
|
|
1675
2096
|
export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
1676
2097
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1677
2098
|
const states: ReadinessState[] = []
|
|
2099
|
+
let accountClient: ApiClient | null = null
|
|
2100
|
+
let accountDevices: RoutableDevice[] | null = null
|
|
1678
2101
|
|
|
1679
2102
|
const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
|
|
1680
2103
|
const projectSlug = config.project.value
|
|
@@ -1784,9 +2207,11 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
|
1784
2207
|
states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: `not checked — ${why}` })
|
|
1785
2208
|
} else {
|
|
1786
2209
|
const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`)
|
|
2210
|
+
accountClient = client
|
|
1787
2211
|
try {
|
|
1788
2212
|
const { devices } = await client.listDevices()
|
|
1789
|
-
|
|
2213
|
+
accountDevices = devices
|
|
2214
|
+
const ready = devices.filter(deviceCanReceive)
|
|
1790
2215
|
states.push({
|
|
1791
2216
|
id: 'auth',
|
|
1792
2217
|
title: 'Account',
|
|
@@ -1812,14 +2237,16 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
|
1812
2237
|
// permission prompt.
|
|
1813
2238
|
detail:
|
|
1814
2239
|
devices.length === 0
|
|
1815
|
-
? 'nothing registered yet'
|
|
2240
|
+
? 'nothing registered yet; this release has no supported App Store/TestFlight URL for the CLI to open'
|
|
1816
2241
|
: `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
|
|
1817
2242
|
remedy: {
|
|
1818
2243
|
by: 'user-elsewhere',
|
|
1819
2244
|
summary:
|
|
1820
2245
|
devices.length === 0
|
|
1821
|
-
? 'install
|
|
1822
|
-
:
|
|
2246
|
+
? 'install NotifAI through a supported distribution you already have, open it, sign in with the same account, and allow notifications'
|
|
2247
|
+
: devices.some((d) => d.permission_status === 'denied')
|
|
2248
|
+
? 'allow notifications for NotifAI in that device’s system settings'
|
|
2249
|
+
: 'open NotifAI on that device and allow its notification prompt',
|
|
1823
2250
|
},
|
|
1824
2251
|
},
|
|
1825
2252
|
)
|
|
@@ -1843,33 +2270,117 @@ export async function assessReadiness(deps: CommandDeps): Promise<Readiness> {
|
|
|
1843
2270
|
|
|
1844
2271
|
states.push(...hookStates(deps))
|
|
1845
2272
|
|
|
1846
|
-
states.push(
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
title: 'Agent guidance skill',
|
|
1860
|
-
status: 'optional-gap',
|
|
1861
|
-
detail: 'not installed in this project',
|
|
1862
|
-
remedy: {
|
|
1863
|
-
by: 'cli',
|
|
1864
|
-
summary: 'install the skill agents follow when deciding to notify',
|
|
1865
|
-
command: 'notifai init --skills',
|
|
1866
|
-
},
|
|
1867
|
-
},
|
|
1868
|
-
)
|
|
2273
|
+
states.push({
|
|
2274
|
+
id: 'skill',
|
|
2275
|
+
title: 'Agent guidance skill',
|
|
2276
|
+
status: 'optional-gap',
|
|
2277
|
+
detail: `not installed from ${SKILLS_SOURCE} in this project`,
|
|
2278
|
+
remedy: {
|
|
2279
|
+
by: 'cli',
|
|
2280
|
+
summary: 'install the skill agents follow when deciding to notify',
|
|
2281
|
+
command: 'notifai init --skills',
|
|
2282
|
+
},
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
states.push(await setupProofState(deps, config, accountClient, accountDevices))
|
|
1869
2286
|
|
|
1870
2287
|
return { states }
|
|
1871
2288
|
}
|
|
1872
2289
|
|
|
2290
|
+
async function setupProofState(
|
|
2291
|
+
deps: CommandDeps,
|
|
2292
|
+
config: CliConfig,
|
|
2293
|
+
client: ApiClient | null,
|
|
2294
|
+
devices: RoutableDevice[] | null,
|
|
2295
|
+
): Promise<ReadinessState> {
|
|
2296
|
+
if (client === null || devices === null) {
|
|
2297
|
+
return {
|
|
2298
|
+
id: 'proof',
|
|
2299
|
+
title: 'Delivery proof',
|
|
2300
|
+
status: 'unknown',
|
|
2301
|
+
detail: 'not checked — account and device readiness must be established first',
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const ready = devices.filter(deviceCanReceive)
|
|
2306
|
+
if (ready.length === 0) {
|
|
2307
|
+
return {
|
|
2308
|
+
id: 'proof',
|
|
2309
|
+
title: 'Delivery proof',
|
|
2310
|
+
status: 'unknown',
|
|
2311
|
+
detail: 'not checked — no companion device is ready',
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const ios = readyIosDevices(devices)
|
|
2316
|
+
if (ios.length === 0) {
|
|
2317
|
+
return {
|
|
2318
|
+
id: 'proof',
|
|
2319
|
+
title: 'Delivery proof',
|
|
2320
|
+
status: 'gap',
|
|
2321
|
+
detail:
|
|
2322
|
+
'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
|
|
2323
|
+
remedy: {
|
|
2324
|
+
by: 'user-elsewhere',
|
|
2325
|
+
summary:
|
|
2326
|
+
'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
|
|
2327
|
+
},
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const proof = readSetupProof(deps)
|
|
2332
|
+
const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id)
|
|
2333
|
+
if (proof === null || proof.project !== config.project.value || target === undefined) {
|
|
2334
|
+
return {
|
|
2335
|
+
id: 'proof',
|
|
2336
|
+
title: 'Delivery proof',
|
|
2337
|
+
status: 'gap',
|
|
2338
|
+
detail: 'no Companion Receipt has proven this project on this machine yet',
|
|
2339
|
+
remedy: {
|
|
2340
|
+
by: 'cli',
|
|
2341
|
+
summary: 'send one real verification notification and wait for its Companion Receipt',
|
|
2342
|
+
command: 'notifai init',
|
|
2343
|
+
},
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
try {
|
|
2348
|
+
const snapshot = await client.evidence(proof.request_id)
|
|
2349
|
+
const observed = observedCompanionReceipt(snapshot, proof.device_id)
|
|
2350
|
+
if (observed) {
|
|
2351
|
+
return {
|
|
2352
|
+
id: 'proof',
|
|
2353
|
+
title: 'Delivery proof',
|
|
2354
|
+
status: 'ready',
|
|
2355
|
+
detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
return {
|
|
2359
|
+
id: 'proof',
|
|
2360
|
+
title: 'Delivery proof',
|
|
2361
|
+
status: 'gap',
|
|
2362
|
+
detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
|
|
2363
|
+
remedy: {
|
|
2364
|
+
by: 'cli',
|
|
2365
|
+
summary: 'check the same verification notification again',
|
|
2366
|
+
command: 'notifai init',
|
|
2367
|
+
},
|
|
2368
|
+
}
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
return {
|
|
2371
|
+
id: 'proof',
|
|
2372
|
+
title: 'Delivery proof',
|
|
2373
|
+
status: 'gap',
|
|
2374
|
+
detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
|
|
2375
|
+
remedy: {
|
|
2376
|
+
by: 'cli',
|
|
2377
|
+
summary: 'retry the existing verification evidence check',
|
|
2378
|
+
command: 'notifai init',
|
|
2379
|
+
},
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
1873
2384
|
export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }): Promise<number> {
|
|
1874
2385
|
const readiness = await assessReadiness(deps)
|
|
1875
2386
|
const blocker = firstBlocker(readiness)
|