@raidiant/notifai 8.0.0 → 9.1.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 +48 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- 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-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- 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 +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.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/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- 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/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands-init.ts
CHANGED
|
@@ -8,11 +8,12 @@ import { randomBytes } from 'node:crypto'
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
11
|
+
import { ensurePrivateDirectory } from './atomic-file.js'
|
|
11
12
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
12
|
-
import { type CliConfig } from './config.js'
|
|
13
|
+
import { personalProjectConfigPath, type CliConfig } from './config.js'
|
|
13
14
|
import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
|
|
14
|
-
import type
|
|
15
|
-
import { firstBlocker, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
|
|
15
|
+
import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
16
|
+
import { firstBlocker, readinessJson, type Readiness, type ReadinessRefresh, type ReadinessState } from './readiness.js'
|
|
16
17
|
import { buildDraft, formatReceipt } from './send.js'
|
|
17
18
|
import { loginCommand } from './commands-auth.js'
|
|
18
19
|
import {
|
|
@@ -26,8 +27,15 @@ import {
|
|
|
26
27
|
import { readyCompanionDevices, supportPageUrl } from './commands-devices.js'
|
|
27
28
|
import { assessReadiness, remedyLine } from './commands-doctor.js'
|
|
28
29
|
import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
|
|
29
|
-
import {
|
|
30
|
-
|
|
30
|
+
import {
|
|
31
|
+
observedCompanionReceipt,
|
|
32
|
+
readSetupProof,
|
|
33
|
+
setupProofProject,
|
|
34
|
+
setupProofApplies,
|
|
35
|
+
setupProofIsStale,
|
|
36
|
+
writeSetupProof,
|
|
37
|
+
} from './commands-setup-proof.js'
|
|
38
|
+
import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
|
|
31
39
|
|
|
32
40
|
// ---------------------------------------------------------------------------
|
|
33
41
|
// init
|
|
@@ -39,7 +47,12 @@ export function projectSlugFrom(name: string): string {
|
|
|
39
47
|
return inferredProjectSlugFrom(name) ?? 'project'
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** Project checkout vs this machine. Drives skill, hooks, and config together. */
|
|
51
|
+
export type SetupScope = SkillScope
|
|
52
|
+
|
|
42
53
|
export interface InitFlags {
|
|
54
|
+
/** Emit the final shared readiness model and never prompt. */
|
|
55
|
+
json?: boolean
|
|
43
56
|
projectId?: string
|
|
44
57
|
/**
|
|
45
58
|
* Install the agent guidance skill. Tri-state on purpose:
|
|
@@ -48,7 +61,13 @@ export interface InitFlags {
|
|
|
48
61
|
* never spawn npx against the network by default.
|
|
49
62
|
*/
|
|
50
63
|
skills?: boolean
|
|
51
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* One setup-scope for skill, hooks, and config. The unattended answer to
|
|
66
|
+
* init's single project-vs-machine question. Pairing, devices, and the CLI
|
|
67
|
+
* binary are not this flag.
|
|
68
|
+
*/
|
|
69
|
+
setupScope?: SetupScope
|
|
70
|
+
/** Alias of `--setup-scope` when installing the skill unattended. */
|
|
52
71
|
skillsScope?: SkillScope
|
|
53
72
|
/** Same tri-state, for the harness hooks. */
|
|
54
73
|
hooks?: boolean
|
|
@@ -97,12 +116,17 @@ async function closeGap(
|
|
|
97
116
|
flags: InitFlags,
|
|
98
117
|
): Promise<GapCloseResult> {
|
|
99
118
|
if (state.id === 'project') {
|
|
100
|
-
const
|
|
119
|
+
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
120
|
+
const configPath =
|
|
121
|
+
flags.setupScope === 'global'
|
|
122
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
123
|
+
: path.join(deps.cwd, '.notifai', 'config.toml')
|
|
101
124
|
const existing = existsSync(configPath)
|
|
102
125
|
? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
|
|
103
126
|
: {}
|
|
104
|
-
existing['project'] =
|
|
105
|
-
|
|
127
|
+
existing['project'] = slug
|
|
128
|
+
if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
|
|
129
|
+
else mkdirSync(path.dirname(configPath), { recursive: true })
|
|
106
130
|
writeFileSync(configPath, `${stringifyToml(existing)}\n`)
|
|
107
131
|
return 'closed'
|
|
108
132
|
}
|
|
@@ -111,8 +135,9 @@ async function closeGap(
|
|
|
111
135
|
const harnesses = await pickHarnessesToInstall(deps)
|
|
112
136
|
if (harnesses === null || harnesses.length === 0) return 'failed'
|
|
113
137
|
let ok = true
|
|
138
|
+
const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
|
|
114
139
|
for (const harness of harnesses) {
|
|
115
|
-
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
|
|
140
|
+
if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
|
|
116
141
|
}
|
|
117
142
|
return ok ? 'closed' : 'failed'
|
|
118
143
|
}
|
|
@@ -130,22 +155,43 @@ async function closeGap(
|
|
|
130
155
|
)
|
|
131
156
|
return 'failed'
|
|
132
157
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
const installScope = flags.setupScope ?? flags.skillsScope
|
|
159
|
+
if (installScope === undefined) {
|
|
160
|
+
deps.io.err(
|
|
161
|
+
'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
|
|
162
|
+
)
|
|
163
|
+
return 'failed'
|
|
164
|
+
}
|
|
165
|
+
const { installed } = await listScopedNotifaiSkills(deps)
|
|
166
|
+
const extras = installed.filter((skill) => skill.scope !== installScope)
|
|
167
|
+
for (const extra of extras) {
|
|
168
|
+
const code = await deps.nativeSkills.remove({
|
|
169
|
+
skill: 'notifai',
|
|
170
|
+
scope: extra.scope,
|
|
171
|
+
cwd: deps.cwd,
|
|
172
|
+
env: deps.env,
|
|
173
|
+
})
|
|
174
|
+
if (code !== 0) {
|
|
175
|
+
deps.io.err(
|
|
176
|
+
`Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
|
|
177
|
+
)
|
|
178
|
+
return 'failed'
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
|
|
182
|
+
const code = await deps.nativeSkills.add({
|
|
136
183
|
source: SKILLS_SOURCE,
|
|
137
184
|
skill: 'notifai',
|
|
138
185
|
cwd: deps.cwd,
|
|
139
186
|
env: deps.env,
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
const code = await deps.nativeSkills.add(addOptions)
|
|
187
|
+
scope: installScope,
|
|
188
|
+
})
|
|
143
189
|
if (code !== 0) {
|
|
144
190
|
deps.io.err('Skill installation failed — run it manually with:')
|
|
145
191
|
deps.io.err(
|
|
146
|
-
` npx
|
|
147
|
-
|
|
148
|
-
}
|
|
192
|
+
` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
|
|
193
|
+
installScope === 'global' ? ' --global' : ''
|
|
194
|
+
} --yes`,
|
|
149
195
|
)
|
|
150
196
|
}
|
|
151
197
|
return code === 0 ? 'closed' : 'failed'
|
|
@@ -289,7 +335,6 @@ function setupProofDraft(
|
|
|
289
335
|
project === null
|
|
290
336
|
? 'This real notification completed setup verification.'
|
|
291
337
|
: `This real notification completed setup verification for ${project}.`,
|
|
292
|
-
event: 'setup_verified',
|
|
293
338
|
kind: 'update',
|
|
294
339
|
platform: device.platform,
|
|
295
340
|
device: [device.device_id],
|
|
@@ -356,21 +401,41 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
356
401
|
return 'failed'
|
|
357
402
|
}
|
|
358
403
|
const candidates = readyCompanionDevices(devices)
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
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]
|
|
365
414
|
if (!target) {
|
|
366
415
|
deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
|
|
367
416
|
return 'pending'
|
|
368
417
|
}
|
|
369
418
|
|
|
370
419
|
let proof =
|
|
371
|
-
|
|
420
|
+
existingApplies && existing.device_id === target.device_id
|
|
372
421
|
? existing
|
|
373
422
|
: null
|
|
423
|
+
const nowMs = (deps.now ?? Date.now)()
|
|
424
|
+
if (proof !== null && setupProofIsStale(proof, nowMs)) {
|
|
425
|
+
let keepStale = false
|
|
426
|
+
try {
|
|
427
|
+
const snapshot = await authed.client.evidence(proof.request_id)
|
|
428
|
+
keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
|
|
429
|
+
} catch {
|
|
430
|
+
keepStale = false
|
|
431
|
+
}
|
|
432
|
+
if (!keepStale) {
|
|
433
|
+
deps.io.out(
|
|
434
|
+
`The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
|
|
435
|
+
)
|
|
436
|
+
proof = null
|
|
437
|
+
}
|
|
438
|
+
}
|
|
374
439
|
if (proof === null) {
|
|
375
440
|
const receipt = await submitSetupProof(deps, authed.client, config, target)
|
|
376
441
|
if (receipt === null) return 'failed'
|
|
@@ -381,7 +446,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
381
446
|
proof = {
|
|
382
447
|
request_id: receipt.request_id,
|
|
383
448
|
device_id: target.device_id,
|
|
384
|
-
project
|
|
449
|
+
project,
|
|
385
450
|
started_at: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
386
451
|
}
|
|
387
452
|
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
@@ -429,7 +494,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
429
494
|
proof = {
|
|
430
495
|
request_id: receipt.request_id,
|
|
431
496
|
device_id: target.device_id,
|
|
432
|
-
project
|
|
497
|
+
project,
|
|
433
498
|
started_at: new Date(now()).toISOString(),
|
|
434
499
|
}
|
|
435
500
|
if (!writeSetupProof(deps, proof)) return 'failed'
|
|
@@ -503,36 +568,93 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
503
568
|
* and a revoked credential are the same code path arriving at different
|
|
504
569
|
* states rather than four branches to enumerate.
|
|
505
570
|
*/
|
|
506
|
-
|
|
571
|
+
function isSetupScope(value: string | undefined): value is SetupScope {
|
|
572
|
+
return value === 'project' || value === 'global'
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function resolveSetupScope(
|
|
576
|
+
deps: CommandDeps,
|
|
577
|
+
flags: InitFlags,
|
|
578
|
+
): Promise<SetupScope | undefined | 'usage'> {
|
|
579
|
+
if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
|
|
580
|
+
deps.io.err('Invalid setup scope. Choose `project` or `global`.')
|
|
581
|
+
return 'usage'
|
|
582
|
+
}
|
|
583
|
+
if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
|
|
584
|
+
deps.io.err('Invalid skill scope. Choose `project` or `global`.')
|
|
585
|
+
return 'usage'
|
|
586
|
+
}
|
|
507
587
|
if (
|
|
588
|
+
flags.setupScope !== undefined &&
|
|
508
589
|
flags.skillsScope !== undefined &&
|
|
509
|
-
flags.
|
|
510
|
-
flags.skillsScope !== 'global'
|
|
590
|
+
flags.setupScope !== flags.skillsScope
|
|
511
591
|
) {
|
|
512
|
-
deps.io.err('
|
|
513
|
-
return
|
|
592
|
+
deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
|
|
593
|
+
return 'usage'
|
|
514
594
|
}
|
|
515
595
|
if (flags.skillsScope !== undefined && flags.skills !== true) {
|
|
516
|
-
deps.io.err('`--skills-scope` requires `--skills`.
|
|
517
|
-
return
|
|
596
|
+
deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
|
|
597
|
+
return 'usage'
|
|
518
598
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
599
|
+
const fromFlags = flags.setupScope ?? flags.skillsScope
|
|
600
|
+
if (fromFlags !== undefined) return fromFlags
|
|
601
|
+
if (flags.skills === false && flags.hooks === false) return undefined
|
|
602
|
+
if (deps.io.interactive === true && deps.io.select) {
|
|
603
|
+
const selected = await deps.io.select(
|
|
604
|
+
'Should this Notifai setup apply to this project only, or to every project on this machine?',
|
|
605
|
+
[
|
|
606
|
+
{
|
|
607
|
+
value: 'project',
|
|
608
|
+
label: 'This project',
|
|
609
|
+
hint: 'skill, hooks, and shared config stay in this checkout',
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
value: 'global',
|
|
613
|
+
label: 'This machine',
|
|
614
|
+
hint: 'skill and hooks follow you into every repo',
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
)
|
|
618
|
+
if (selected === 'project' || selected === 'global') return selected
|
|
619
|
+
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
620
|
+
return 'usage'
|
|
621
|
+
}
|
|
622
|
+
if (flags.skills === true) {
|
|
524
623
|
deps.io.err(
|
|
525
|
-
'Unattended skill setup requires an explicit scope: `notifai init --skills --
|
|
624
|
+
'Unattended skill setup requires an explicit scope: `notifai init --skills --setup-scope project` or `... global`.',
|
|
526
625
|
)
|
|
527
|
-
return
|
|
626
|
+
return 'usage'
|
|
627
|
+
}
|
|
628
|
+
return undefined
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
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)
|
|
645
|
+
if (setupScope === 'usage') return EXIT.usage
|
|
646
|
+
const resolved: InitFlags = {
|
|
647
|
+
...flags,
|
|
648
|
+
...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
|
|
528
649
|
}
|
|
529
|
-
await
|
|
650
|
+
await workingDeps.io.intro?.('Notifai setup')
|
|
530
651
|
|
|
531
|
-
const skillOpts =
|
|
532
|
-
let readiness = await assessReadiness(
|
|
652
|
+
const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
|
|
653
|
+
let readiness = await assessReadiness(workingDeps, { ...skillOpts, ...(flags.json === true ? { json: true } : {}) })
|
|
533
654
|
const reassess = (refresh?: readonly ReadinessRefresh[]) =>
|
|
534
|
-
assessReadiness(
|
|
655
|
+
assessReadiness(workingDeps, {
|
|
535
656
|
...skillOpts,
|
|
657
|
+
...(flags.json === true ? { json: true } : {}),
|
|
536
658
|
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
537
659
|
})
|
|
538
660
|
let failed = false
|
|
@@ -560,9 +682,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
560
682
|
}
|
|
561
683
|
|
|
562
684
|
if (state.status === 'optional-gap') {
|
|
563
|
-
if (remedy.by !== 'cli' || !(await wantsOptional(
|
|
685
|
+
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
564
686
|
attempted.add(state.id)
|
|
565
|
-
const result = await closeGap(
|
|
687
|
+
const result = await closeGap(workingDeps, state, resolved)
|
|
566
688
|
if (result === 'failed') failed = true
|
|
567
689
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
568
690
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
@@ -580,7 +702,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
580
702
|
|
|
581
703
|
if (remedy.by === 'cli') {
|
|
582
704
|
attempted.add(state.id)
|
|
583
|
-
const result = await closeGap(
|
|
705
|
+
const result = await closeGap(workingDeps, state, resolved)
|
|
584
706
|
if (result === 'failed') failed = true
|
|
585
707
|
if (result !== 'closed') {
|
|
586
708
|
stop = true
|
|
@@ -596,11 +718,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
596
718
|
if (
|
|
597
719
|
remedy.by === 'user-here' &&
|
|
598
720
|
remedy.interactive === true &&
|
|
599
|
-
|
|
721
|
+
workingDeps.io.interactive === true
|
|
600
722
|
) {
|
|
601
723
|
attempted.add(state.id)
|
|
602
|
-
|
|
603
|
-
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) {
|
|
604
726
|
failed = true
|
|
605
727
|
stop = true
|
|
606
728
|
break
|
|
@@ -613,10 +735,10 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
613
735
|
if (
|
|
614
736
|
state.id === 'devices' &&
|
|
615
737
|
remedy.by === 'user-elsewhere' &&
|
|
616
|
-
|
|
738
|
+
workingDeps.io.interactive === true
|
|
617
739
|
) {
|
|
618
740
|
attempted.add(state.id)
|
|
619
|
-
const result = await waitForReadyDevice(
|
|
741
|
+
const result = await waitForReadyDevice(workingDeps, state)
|
|
620
742
|
if (result === 'failed') failed = true
|
|
621
743
|
if (result !== 'closed') {
|
|
622
744
|
stop = true
|
|
@@ -635,10 +757,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
635
757
|
if (stop || !advanced) break
|
|
636
758
|
}
|
|
637
759
|
|
|
638
|
-
|
|
760
|
+
if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
|
|
761
|
+
else await printInitClose(deps, readiness, resolved)
|
|
639
762
|
const blocker = firstBlocker(readiness)
|
|
640
763
|
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
641
|
-
return failed ||
|
|
764
|
+
return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
642
765
|
}
|
|
643
766
|
|
|
644
767
|
function isHookSubstate(id: string): boolean {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { NOTIFICATION_IMAGE_MAX_BYTES } from '@raidiant/notifai-protocol'
|
|
2
|
+
import { createServer, type Server } from 'node:http'
|
|
3
|
+
import type { AddressInfo } from 'node:net'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { EXIT, type CommandDeps, type CommandIo } from './commands-core.js'
|
|
6
|
+
import { readCappedBytes, uploadImage } from './commands-send-support.js'
|
|
7
|
+
import type { ApiClient } from './client.js'
|
|
8
|
+
|
|
9
|
+
let server: Server | undefined
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
if (server) {
|
|
13
|
+
const closing = server
|
|
14
|
+
server = undefined
|
|
15
|
+
await new Promise<void>((resolve) => closing.close(() => resolve()))
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async function serving(handler: Parameters<typeof createServer>[1]): Promise<string> {
|
|
20
|
+
server = createServer(handler)
|
|
21
|
+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
|
|
22
|
+
const { port } = server.address() as AddressInfo
|
|
23
|
+
return `http://127.0.0.1:${port}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function io(): CommandIo {
|
|
27
|
+
return {
|
|
28
|
+
out: () => undefined,
|
|
29
|
+
err: () => undefined,
|
|
30
|
+
confirm: async () => false,
|
|
31
|
+
openUrl: () => undefined,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('readCappedBytes', () => {
|
|
36
|
+
it('refuses Content-Length over the cap before reading the body', async () => {
|
|
37
|
+
const response = new Response(null, { headers: { 'content-length': '64' } })
|
|
38
|
+
expect(await readCappedBytes(response, 16)).toBe('too-large')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('stops reading once the downloaded bytes exceed the cap', async () => {
|
|
42
|
+
const response = new Response(
|
|
43
|
+
new ReadableStream({
|
|
44
|
+
start(controller) {
|
|
45
|
+
controller.enqueue(new Uint8Array(12))
|
|
46
|
+
controller.enqueue(new Uint8Array(12))
|
|
47
|
+
controller.close()
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
expect(await readCappedBytes(response, 16)).toBe('too-large')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns a payload that fits the cap', async () => {
|
|
55
|
+
const payload = new Uint8Array([1, 2, 3, 4])
|
|
56
|
+
expect(await readCappedBytes(new Response(payload), 16)).toEqual(payload)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
function uploadingClient(uploaded: number[]): ApiClient {
|
|
61
|
+
return {
|
|
62
|
+
createMediaUpload: async () => ({
|
|
63
|
+
media_id: 'med_1',
|
|
64
|
+
upload_url: 'https://upload.invalid/1',
|
|
65
|
+
upload_headers: {},
|
|
66
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
67
|
+
}),
|
|
68
|
+
uploadMedia: async (_grant: unknown, bytes: Uint8Array) => {
|
|
69
|
+
uploaded.push(bytes.byteLength)
|
|
70
|
+
},
|
|
71
|
+
} as unknown as ApiClient
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function refusingClient(): ApiClient {
|
|
75
|
+
return {
|
|
76
|
+
createMediaUpload: async () => {
|
|
77
|
+
throw new Error('must not request an upload')
|
|
78
|
+
},
|
|
79
|
+
} as unknown as ApiClient
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('uploadImage remote fetch', () => {
|
|
83
|
+
it('uploads bytes under the media limit from an allowed self-hosted origin', async () => {
|
|
84
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
|
|
85
|
+
const baseUrl = await serving((_request, response) => {
|
|
86
|
+
response.setHeader('content-type', 'image/png')
|
|
87
|
+
response.end(Buffer.from(png))
|
|
88
|
+
})
|
|
89
|
+
const uploaded: number[] = []
|
|
90
|
+
const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
|
|
91
|
+
|
|
92
|
+
const result = await uploadImage(deps, uploadingClient(uploaded), `${baseUrl}/shot.png`, [
|
|
93
|
+
baseUrl,
|
|
94
|
+
])
|
|
95
|
+
expect(result).toEqual({ ok: true, mediaId: 'med_1' })
|
|
96
|
+
expect(uploaded).toEqual([png.byteLength])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('refuses that same loopback origin when the User has not allowed it', async () => {
|
|
100
|
+
const baseUrl = await serving((_request, response) => {
|
|
101
|
+
response.setHeader('content-type', 'image/png')
|
|
102
|
+
response.end(Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])))
|
|
103
|
+
})
|
|
104
|
+
const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
|
|
105
|
+
|
|
106
|
+
const result = await uploadImage(deps, refusingClient(), `${baseUrl}/shot.png`)
|
|
107
|
+
expect(result.ok).toBe(false)
|
|
108
|
+
if (result.ok) throw new Error('expected refusal')
|
|
109
|
+
expect(result.exit).toBe(EXIT.usage)
|
|
110
|
+
expect(result.error).toContain('media_origins')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('refuses a remote image whose Content-Length exceeds the server media limit', async () => {
|
|
114
|
+
const baseUrl = await serving((_request, response) => {
|
|
115
|
+
response.setHeader('content-type', 'image/png')
|
|
116
|
+
response.setHeader('content-length', String(NOTIFICATION_IMAGE_MAX_BYTES + 1))
|
|
117
|
+
response.end()
|
|
118
|
+
})
|
|
119
|
+
const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
|
|
120
|
+
|
|
121
|
+
const result = await uploadImage(deps, refusingClient(), `${baseUrl}/huge.png`, [baseUrl])
|
|
122
|
+
expect(result.ok).toBe(false)
|
|
123
|
+
if (result.ok) throw new Error('expected refusal')
|
|
124
|
+
expect(result.exit).toBe(EXIT.usage)
|
|
125
|
+
expect(result.error).toContain(String(NOTIFICATION_IMAGE_MAX_BYTES))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('refuses a redirect out of an allowed origin into an unallowed one', async () => {
|
|
129
|
+
const target = await serving((_request, response) => {
|
|
130
|
+
response.setHeader('content-type', 'image/png')
|
|
131
|
+
response.end(Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])))
|
|
132
|
+
})
|
|
133
|
+
// A second loopback origin the User never allowed. The first hop is
|
|
134
|
+
// trusted; the destination is not, and each hop is checked on its own.
|
|
135
|
+
const redirector = createServer((_request, response) => {
|
|
136
|
+
response.writeHead(302, { location: `${target}/shot.png` })
|
|
137
|
+
response.end()
|
|
138
|
+
})
|
|
139
|
+
await new Promise<void>((resolve) => redirector.listen(0, '127.0.0.1', resolve))
|
|
140
|
+
const { port } = redirector.address() as AddressInfo
|
|
141
|
+
const redirectOrigin = `http://127.0.0.1:${port}`
|
|
142
|
+
const deps = { io: io(), env: {}, cwd: '/tmp' } as CommandDeps
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await uploadImage(deps, refusingClient(), `${redirectOrigin}/shot.png`, [
|
|
146
|
+
redirectOrigin,
|
|
147
|
+
])
|
|
148
|
+
expect(result.ok).toBe(false)
|
|
149
|
+
if (result.ok) throw new Error('expected refusal')
|
|
150
|
+
expect(result.exit).toBe(EXIT.usage)
|
|
151
|
+
expect(result.error).toContain('media_origins')
|
|
152
|
+
} finally {
|
|
153
|
+
await new Promise<void>((resolve) => redirector.close(() => resolve()))
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type ListRepliesResponse } from '@raidiant/notifai-protocol'
|
|
1
|
+
import { NOTIFICATION_IMAGE_MAX_BYTES, type ListRepliesResponse } from '@raidiant/notifai-protocol'
|
|
2
2
|
import { sha256Hex } from '@raidiant/notifai-protocol/node'
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { isRetryableReplyPollError, type ApiClient } from './client.js'
|
|
6
|
+
import { fetchMediaUrl } from './url-policy.js'
|
|
6
7
|
import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
|
|
7
8
|
import { readOrcaSessionTitle } from './orca-session-title.js'
|
|
8
9
|
import { type DraftInvocation, type SendFlags } from './send.js'
|
|
@@ -209,17 +210,78 @@ type UploadResult =
|
|
|
209
210
|
/** `error: null` means `reportError` already said it; do not print it twice. */
|
|
210
211
|
| { ok: false; error: string | null; exit: number }
|
|
211
212
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Read a remote image without buffering more than the server will accept.
|
|
215
|
+
*
|
|
216
|
+
* Content-Length is an early refusal, not trust: a lying header still hits
|
|
217
|
+
* the byte cap while the body is read. Supported schemes stay http and https.
|
|
218
|
+
*/
|
|
219
|
+
export async function readCappedBytes(
|
|
220
|
+
response: Response,
|
|
221
|
+
maxBytes: number,
|
|
222
|
+
): Promise<Uint8Array | 'too-large'> {
|
|
223
|
+
const contentLength = Number(response.headers.get('content-length'))
|
|
224
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) return 'too-large'
|
|
225
|
+
const reader = response.body?.getReader()
|
|
226
|
+
if (reader === undefined) {
|
|
227
|
+
const buf = new Uint8Array(await response.arrayBuffer())
|
|
228
|
+
return buf.byteLength > maxBytes ? 'too-large' : buf
|
|
229
|
+
}
|
|
230
|
+
const chunks: Uint8Array[] = []
|
|
231
|
+
let received = 0
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await reader.read()
|
|
234
|
+
if (done) break
|
|
235
|
+
received += value.byteLength
|
|
236
|
+
if (received > maxBytes) {
|
|
237
|
+
await reader.cancel()
|
|
238
|
+
return 'too-large'
|
|
239
|
+
}
|
|
240
|
+
chunks.push(value)
|
|
241
|
+
}
|
|
242
|
+
const out = new Uint8Array(received)
|
|
243
|
+
let offset = 0
|
|
244
|
+
for (const chunk of chunks) {
|
|
245
|
+
out.set(chunk, offset)
|
|
246
|
+
offset += chunk.byteLength
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* `--image` accepts a media id, a local file path, or an http(s) URL.
|
|
253
|
+
*
|
|
254
|
+
* A remote URL is fetched under the media URL policy (`url-policy.ts`):
|
|
255
|
+
* public HTTPS by default, every redirect hop re-validated, and non-public
|
|
256
|
+
* destinations only through the User's `media_origins` — never a
|
|
257
|
+
* repository's. `--image` values routinely arrive from an agent that read
|
|
258
|
+
* them in a working tree, so the URL is hostile input until proven otherwise.
|
|
259
|
+
*/
|
|
260
|
+
export async function uploadImage(
|
|
261
|
+
deps: CommandDeps,
|
|
262
|
+
client: ApiClient,
|
|
263
|
+
source: string,
|
|
264
|
+
allowOrigins: readonly string[] = [],
|
|
265
|
+
): Promise<UploadResult> {
|
|
214
266
|
let bytes: Uint8Array
|
|
215
267
|
let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | undefined
|
|
216
268
|
if (/^https?:\/\//.test(source)) {
|
|
217
269
|
try {
|
|
218
|
-
const
|
|
270
|
+
const fetched = await fetchMediaUrl(source, { allowOrigins }, deps.fetchImpl)
|
|
271
|
+
if (!fetched.ok) return { ok: false, error: fetched.reason, exit: EXIT.usage }
|
|
272
|
+
const response = fetched.response
|
|
219
273
|
if (!response.ok) return { ok: false, error: `Could not fetch ${source} (${response.status}).`, exit: EXIT.usage }
|
|
220
274
|
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
|
|
221
275
|
mediaType = (['image/jpeg', 'image/png', 'image/gif'] as const).find((t) => t === contentType)
|
|
222
|
-
|
|
276
|
+
const downloaded = await readCappedBytes(response, NOTIFICATION_IMAGE_MAX_BYTES)
|
|
277
|
+
if (downloaded === 'too-large') {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: `Remote image exceeds the ${NOTIFICATION_IMAGE_MAX_BYTES} byte media limit.`,
|
|
281
|
+
exit: EXIT.usage,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
bytes = downloaded
|
|
223
285
|
} catch (err) {
|
|
224
286
|
return { ok: false, error: `Could not fetch ${source}: ${String(err)}`, exit: EXIT.network }
|
|
225
287
|
}
|