@raidiant/notifai 7.0.2 → 9.0.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 +75 -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/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-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- 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 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- 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 +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- 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 +14 -9
- 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 +5 -487
- 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 +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- 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 +6 -4
- 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/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands-init.ts
CHANGED
|
@@ -8,10 +8,11 @@ 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 { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
15
16
|
import { firstBlocker, 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'
|
|
@@ -23,11 +24,16 @@ import {
|
|
|
23
24
|
reportError,
|
|
24
25
|
type CommandDeps,
|
|
25
26
|
} from './commands-core.js'
|
|
26
|
-
import {
|
|
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
|
+
setupProofIsStale,
|
|
34
|
+
writeSetupProof,
|
|
35
|
+
} from './commands-setup-proof.js'
|
|
36
|
+
import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
|
|
31
37
|
|
|
32
38
|
// ---------------------------------------------------------------------------
|
|
33
39
|
// init
|
|
@@ -39,6 +45,9 @@ export function projectSlugFrom(name: string): string {
|
|
|
39
45
|
return inferredProjectSlugFrom(name) ?? 'project'
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
/** Project checkout vs this machine. Drives skill, hooks, and config together. */
|
|
49
|
+
export type SetupScope = SkillScope
|
|
50
|
+
|
|
42
51
|
export interface InitFlags {
|
|
43
52
|
projectId?: string
|
|
44
53
|
/**
|
|
@@ -48,14 +57,20 @@ export interface InitFlags {
|
|
|
48
57
|
* never spawn npx against the network by default.
|
|
49
58
|
*/
|
|
50
59
|
skills?: boolean
|
|
51
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* One setup-scope for skill, hooks, and config. The unattended answer to
|
|
62
|
+
* init's single project-vs-machine question. Pairing, devices, and the CLI
|
|
63
|
+
* binary are not this flag.
|
|
64
|
+
*/
|
|
65
|
+
setupScope?: SetupScope
|
|
66
|
+
/** Alias of `--setup-scope` when installing the skill unattended. */
|
|
52
67
|
skillsScope?: SkillScope
|
|
53
68
|
/** Same tri-state, for the harness hooks. */
|
|
54
69
|
hooks?: boolean
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
|
|
58
|
-
/** Long enough for a first
|
|
73
|
+
/** Long enough for a first controlled Companion install; keep-waiting extends another budget. */
|
|
59
74
|
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000
|
|
60
75
|
const DEVICE_BRIDGE_POLL_MS = 2_000
|
|
61
76
|
const PROOF_TIMEOUT_MS = 30_000
|
|
@@ -97,12 +112,17 @@ async function closeGap(
|
|
|
97
112
|
flags: InitFlags,
|
|
98
113
|
): Promise<GapCloseResult> {
|
|
99
114
|
if (state.id === 'project') {
|
|
100
|
-
const
|
|
115
|
+
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
116
|
+
const configPath =
|
|
117
|
+
flags.setupScope === 'global'
|
|
118
|
+
? personalProjectConfigPath(deps.cwd, deps.env)
|
|
119
|
+
: path.join(deps.cwd, '.notifai', 'config.toml')
|
|
101
120
|
const existing = existsSync(configPath)
|
|
102
121
|
? (parseToml(readFileSync(configPath, 'utf8')) as Record<string, unknown>)
|
|
103
122
|
: {}
|
|
104
|
-
existing['project'] =
|
|
105
|
-
|
|
123
|
+
existing['project'] = slug
|
|
124
|
+
if (flags.setupScope === 'global') ensurePrivateDirectory(path.dirname(configPath))
|
|
125
|
+
else mkdirSync(path.dirname(configPath), { recursive: true })
|
|
106
126
|
writeFileSync(configPath, `${stringifyToml(existing)}\n`)
|
|
107
127
|
return 'closed'
|
|
108
128
|
}
|
|
@@ -111,8 +131,9 @@ async function closeGap(
|
|
|
111
131
|
const harnesses = await pickHarnessesToInstall(deps)
|
|
112
132
|
if (harnesses === null || harnesses.length === 0) return 'failed'
|
|
113
133
|
let ok = true
|
|
134
|
+
const hookFlags = flags.setupScope === 'global' ? { global: true as const } : {}
|
|
114
135
|
for (const harness of harnesses) {
|
|
115
|
-
if (hooksInstallCommand(deps, { harness }) !== EXIT.ok) ok = false
|
|
136
|
+
if (hooksInstallCommand(deps, { harness, ...hookFlags }) !== EXIT.ok) ok = false
|
|
116
137
|
}
|
|
117
138
|
return ok ? 'closed' : 'failed'
|
|
118
139
|
}
|
|
@@ -130,22 +151,43 @@ async function closeGap(
|
|
|
130
151
|
)
|
|
131
152
|
return 'failed'
|
|
132
153
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
const installScope = flags.setupScope ?? flags.skillsScope
|
|
155
|
+
if (installScope === undefined) {
|
|
156
|
+
deps.io.err(
|
|
157
|
+
'Skill installation refused — choose a setup scope: `notifai init --skills --setup-scope project` or `... global`.',
|
|
158
|
+
)
|
|
159
|
+
return 'failed'
|
|
160
|
+
}
|
|
161
|
+
const { installed } = await listScopedNotifaiSkills(deps)
|
|
162
|
+
const extras = installed.filter((skill) => skill.scope !== installScope)
|
|
163
|
+
for (const extra of extras) {
|
|
164
|
+
const code = await deps.nativeSkills.remove({
|
|
165
|
+
skill: 'notifai',
|
|
166
|
+
scope: extra.scope,
|
|
167
|
+
cwd: deps.cwd,
|
|
168
|
+
env: deps.env,
|
|
169
|
+
})
|
|
170
|
+
if (code !== 0) {
|
|
171
|
+
deps.io.err(
|
|
172
|
+
`Skill installation refused — could not uninstall the ${extra.scope} copy (${extra.ref ?? 'unknown pin'}), so installing ${installScope} would leave both active.`,
|
|
173
|
+
)
|
|
174
|
+
return 'failed'
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`)
|
|
178
|
+
const code = await deps.nativeSkills.add({
|
|
136
179
|
source: SKILLS_SOURCE,
|
|
137
180
|
skill: 'notifai',
|
|
138
181
|
cwd: deps.cwd,
|
|
139
182
|
env: deps.env,
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
const code = await deps.nativeSkills.add(addOptions)
|
|
183
|
+
scope: installScope,
|
|
184
|
+
})
|
|
143
185
|
if (code !== 0) {
|
|
144
186
|
deps.io.err('Skill installation failed — run it manually with:')
|
|
145
187
|
deps.io.err(
|
|
146
|
-
` npx
|
|
147
|
-
|
|
148
|
-
}
|
|
188
|
+
` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${
|
|
189
|
+
installScope === 'global' ? ' --global' : ''
|
|
190
|
+
} --yes`,
|
|
149
191
|
)
|
|
150
192
|
}
|
|
151
193
|
return code === 0 ? 'closed' : 'failed'
|
|
@@ -159,20 +201,21 @@ async function closeGap(
|
|
|
159
201
|
|
|
160
202
|
function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
161
203
|
if (devices.length === 0) {
|
|
162
|
-
return 'Waiting for
|
|
204
|
+
return 'Waiting for a Companion App to sign in and register…'
|
|
163
205
|
}
|
|
164
206
|
const denied = devices.find((device) => device.permission_status === 'denied')
|
|
165
207
|
if (denied) return `Waiting for notifications to be allowed on ${denied.display_name}…`
|
|
166
208
|
const undecided = devices.find((device) => device.permission_status === 'not_determined')
|
|
167
209
|
if (undecided) return `Waiting for ${undecided.display_name} to allow the notification prompt…`
|
|
168
|
-
return 'Waiting for
|
|
210
|
+
return 'Waiting for a Companion device to become ready…'
|
|
169
211
|
}
|
|
170
212
|
|
|
171
213
|
/**
|
|
172
214
|
* Observe the supported Device Installation path while the user finishes the
|
|
173
|
-
* app-side work. The live bridge is the dashboard `/support` page (
|
|
174
|
-
* steps). Interactive runs offer to open it so the user
|
|
175
|
-
* non-interactive/agent paths only print plain text and
|
|
215
|
+
* app-side work. The live bridge is the dashboard `/support` page (controlled
|
|
216
|
+
* Companion installation steps). Interactive runs offer to open it so the user
|
|
217
|
+
* never types the URL; non-interactive/agent paths only print plain text and
|
|
218
|
+
* never wait on a prompt.
|
|
176
219
|
*
|
|
177
220
|
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
178
221
|
* not the setup — and offer another budget when a human is present. Agents
|
|
@@ -197,9 +240,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
197
240
|
state.detail,
|
|
198
241
|
remedy.summary,
|
|
199
242
|
`Install steps (no typing): ${supportUrl}`,
|
|
200
|
-
`I will wait up to ${budgetLabel} for
|
|
243
|
+
`I will wait up to ${budgetLabel} for a Companion device to become ready.`,
|
|
201
244
|
].join('\n'),
|
|
202
|
-
'Finish setup on your
|
|
245
|
+
'Finish setup on your Companion device',
|
|
203
246
|
)
|
|
204
247
|
|
|
205
248
|
// Open the real support page so the user never has to type the URL. Decline
|
|
@@ -219,7 +262,9 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
219
262
|
const sleep =
|
|
220
263
|
deps.sleep ??
|
|
221
264
|
((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)))
|
|
222
|
-
const spinner = await deps.io.spinner?.(
|
|
265
|
+
const spinner = await deps.io.spinner?.(
|
|
266
|
+
`Waiting up to ${budgetLabel} for a Companion device…`,
|
|
267
|
+
)
|
|
223
268
|
let lastDevices: RoutableDevice[] = []
|
|
224
269
|
let deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
225
270
|
|
|
@@ -227,14 +272,16 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
227
272
|
while (now() < deadline) {
|
|
228
273
|
try {
|
|
229
274
|
const response = await authed.client.listDevices()
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
275
|
+
const companionDevices = response.devices.filter(
|
|
276
|
+
(device) => device.platform === 'ios' || device.platform === 'android',
|
|
277
|
+
)
|
|
278
|
+
lastDevices = companionDevices
|
|
279
|
+
const ready = readyCompanionDevices(companionDevices)[0]
|
|
233
280
|
if (ready) {
|
|
234
281
|
spinner?.stop(`${ready.display_name} is ready to receive`)
|
|
235
282
|
return 'closed'
|
|
236
283
|
}
|
|
237
|
-
spinner?.message(deviceBridgeMessage(
|
|
284
|
+
spinner?.message(deviceBridgeMessage(companionDevices))
|
|
238
285
|
} catch (err) {
|
|
239
286
|
if (!(err instanceof NetworkError)) {
|
|
240
287
|
spinner?.error('Could not check companion readiness')
|
|
@@ -263,10 +310,12 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
263
310
|
false,
|
|
264
311
|
)
|
|
265
312
|
if (!keepWaiting) {
|
|
266
|
-
deps.io.out(
|
|
313
|
+
deps.io.out(
|
|
314
|
+
'Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.',
|
|
315
|
+
)
|
|
267
316
|
return 'pending'
|
|
268
317
|
}
|
|
269
|
-
spinner?.message(`Waiting another ${budgetLabel} for
|
|
318
|
+
spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`)
|
|
270
319
|
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
271
320
|
}
|
|
272
321
|
}
|
|
@@ -282,12 +331,13 @@ function setupProofDraft(
|
|
|
282
331
|
project === null
|
|
283
332
|
? 'This real notification completed setup verification.'
|
|
284
333
|
: `This real notification completed setup verification for ${project}.`,
|
|
285
|
-
event: 'setup_verified',
|
|
286
334
|
kind: 'update',
|
|
287
|
-
platform:
|
|
335
|
+
platform: device.platform,
|
|
288
336
|
device: [device.device_id],
|
|
289
337
|
sound: 'none',
|
|
290
|
-
level
|
|
338
|
+
// Android has no caller-selected interruption level. Explicit null keeps an
|
|
339
|
+
// Apple preference in config from leaking into this Android-only proof.
|
|
340
|
+
level: device.platform === 'ios' ? 'passive' : null,
|
|
291
341
|
collapseKey: 'notifai-setup-verification',
|
|
292
342
|
})
|
|
293
343
|
}
|
|
@@ -331,8 +381,8 @@ async function submitSetupProof(
|
|
|
331
381
|
|
|
332
382
|
/**
|
|
333
383
|
* Send or resume one real setup probe, then wait for a Companion Receipt.
|
|
334
|
-
* Provider Acceptance is intentionally insufficient: it proves
|
|
335
|
-
* the
|
|
384
|
+
* Provider Acceptance is intentionally insufficient: it proves a push provider
|
|
385
|
+
* accepted the request, not that a companion process received it.
|
|
336
386
|
*/
|
|
337
387
|
async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
338
388
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
@@ -346,7 +396,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
346
396
|
reportError(deps, err)
|
|
347
397
|
return 'failed'
|
|
348
398
|
}
|
|
349
|
-
const candidates =
|
|
399
|
+
const candidates = readyCompanionDevices(devices)
|
|
350
400
|
const existing = readSetupProof(deps)
|
|
351
401
|
const target =
|
|
352
402
|
candidates.find(
|
|
@@ -354,7 +404,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
354
404
|
device.device_id === existing?.device_id && existing.project === config.project.value,
|
|
355
405
|
) ?? candidates[0]
|
|
356
406
|
if (!target) {
|
|
357
|
-
deps.io.err('Setup proof needs a receipt-capable iPhone.')
|
|
407
|
+
deps.io.err('Setup proof needs a receipt-capable iPhone or Android Companion App.')
|
|
358
408
|
return 'pending'
|
|
359
409
|
}
|
|
360
410
|
|
|
@@ -362,6 +412,22 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
362
412
|
existing?.device_id === target.device_id && existing.project === config.project.value
|
|
363
413
|
? existing
|
|
364
414
|
: null
|
|
415
|
+
const nowMs = (deps.now ?? Date.now)()
|
|
416
|
+
if (proof !== null && setupProofIsStale(proof, nowMs)) {
|
|
417
|
+
let keepStale = false
|
|
418
|
+
try {
|
|
419
|
+
const snapshot = await authed.client.evidence(proof.request_id)
|
|
420
|
+
keepStale = observedCompanionReceipt(snapshot, proof.device_id) !== null
|
|
421
|
+
} catch {
|
|
422
|
+
keepStale = false
|
|
423
|
+
}
|
|
424
|
+
if (!keepStale) {
|
|
425
|
+
deps.io.out(
|
|
426
|
+
`The saved proof ${proof.request_id} is older than 24h without a Companion Receipt; sending a replacement.`,
|
|
427
|
+
)
|
|
428
|
+
proof = null
|
|
429
|
+
}
|
|
430
|
+
}
|
|
365
431
|
if (proof === null) {
|
|
366
432
|
const receipt = await submitSetupProof(deps, authed.client, config, target)
|
|
367
433
|
if (receipt === null) return 'failed'
|
|
@@ -494,32 +560,76 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
494
560
|
* and a revoked credential are the same code path arriving at different
|
|
495
561
|
* states rather than four branches to enumerate.
|
|
496
562
|
*/
|
|
497
|
-
|
|
563
|
+
function isSetupScope(value: string | undefined): value is SetupScope {
|
|
564
|
+
return value === 'project' || value === 'global'
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function resolveSetupScope(
|
|
568
|
+
deps: CommandDeps,
|
|
569
|
+
flags: InitFlags,
|
|
570
|
+
): Promise<SetupScope | undefined | 'usage'> {
|
|
571
|
+
if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
|
|
572
|
+
deps.io.err('Invalid setup scope. Choose `project` or `global`.')
|
|
573
|
+
return 'usage'
|
|
574
|
+
}
|
|
575
|
+
if (flags.skillsScope !== undefined && !isSetupScope(flags.skillsScope)) {
|
|
576
|
+
deps.io.err('Invalid skill scope. Choose `project` or `global`.')
|
|
577
|
+
return 'usage'
|
|
578
|
+
}
|
|
498
579
|
if (
|
|
580
|
+
flags.setupScope !== undefined &&
|
|
499
581
|
flags.skillsScope !== undefined &&
|
|
500
|
-
flags.
|
|
501
|
-
flags.skillsScope !== 'global'
|
|
582
|
+
flags.setupScope !== flags.skillsScope
|
|
502
583
|
) {
|
|
503
|
-
deps.io.err('
|
|
504
|
-
return
|
|
584
|
+
deps.io.err('`--setup-scope` and `--skills-scope` disagree. Pass one, or pass the same value twice.')
|
|
585
|
+
return 'usage'
|
|
505
586
|
}
|
|
506
587
|
if (flags.skillsScope !== undefined && flags.skills !== true) {
|
|
507
|
-
deps.io.err('`--skills-scope` requires `--skills`.
|
|
508
|
-
return
|
|
588
|
+
deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
|
|
589
|
+
return 'usage'
|
|
509
590
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
591
|
+
const fromFlags = flags.setupScope ?? flags.skillsScope
|
|
592
|
+
if (fromFlags !== undefined) return fromFlags
|
|
593
|
+
if (flags.skills === false && flags.hooks === false) return undefined
|
|
594
|
+
if (deps.io.interactive === true && deps.io.select) {
|
|
595
|
+
const selected = await deps.io.select(
|
|
596
|
+
'Should this Notifai setup apply to this project only, or to every project on this machine?',
|
|
597
|
+
[
|
|
598
|
+
{
|
|
599
|
+
value: 'project',
|
|
600
|
+
label: 'This project',
|
|
601
|
+
hint: 'skill, hooks, and shared config stay in this checkout',
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
value: 'global',
|
|
605
|
+
label: 'This machine',
|
|
606
|
+
hint: 'skill and hooks follow you into every repo',
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
)
|
|
610
|
+
if (selected === 'project' || selected === 'global') return selected
|
|
611
|
+
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
612
|
+
return 'usage'
|
|
613
|
+
}
|
|
614
|
+
if (flags.skills === true) {
|
|
515
615
|
deps.io.err(
|
|
516
|
-
'Unattended skill setup requires an explicit scope: `notifai init --skills --
|
|
616
|
+
'Unattended skill setup requires an explicit scope: `notifai init --skills --setup-scope project` or `... global`.',
|
|
517
617
|
)
|
|
518
|
-
return
|
|
618
|
+
return 'usage'
|
|
619
|
+
}
|
|
620
|
+
return undefined
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
624
|
+
const setupScope = await resolveSetupScope(deps, flags)
|
|
625
|
+
if (setupScope === 'usage') return EXIT.usage
|
|
626
|
+
const resolved: InitFlags = {
|
|
627
|
+
...flags,
|
|
628
|
+
...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
|
|
519
629
|
}
|
|
520
630
|
await deps.io.intro?.('Notifai setup')
|
|
521
631
|
|
|
522
|
-
const skillOpts =
|
|
632
|
+
const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope }
|
|
523
633
|
let readiness = await assessReadiness(deps, skillOpts)
|
|
524
634
|
const reassess = (refresh?: readonly ReadinessRefresh[]) =>
|
|
525
635
|
assessReadiness(deps, {
|
|
@@ -551,9 +661,9 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
551
661
|
}
|
|
552
662
|
|
|
553
663
|
if (state.status === 'optional-gap') {
|
|
554
|
-
if (remedy.by !== 'cli' || !(await wantsOptional(deps, state,
|
|
664
|
+
if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, resolved))) continue
|
|
555
665
|
attempted.add(state.id)
|
|
556
|
-
const result = await closeGap(deps, state,
|
|
666
|
+
const result = await closeGap(deps, state, resolved)
|
|
557
667
|
if (result === 'failed') failed = true
|
|
558
668
|
if (result === 'failed' && state.status === 'optional-gap') {
|
|
559
669
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
@@ -571,7 +681,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
571
681
|
|
|
572
682
|
if (remedy.by === 'cli') {
|
|
573
683
|
attempted.add(state.id)
|
|
574
|
-
const result = await closeGap(deps, state,
|
|
684
|
+
const result = await closeGap(deps, state, resolved)
|
|
575
685
|
if (result === 'failed') failed = true
|
|
576
686
|
if (result !== 'closed') {
|
|
577
687
|
stop = true
|
|
@@ -626,7 +736,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
626
736
|
if (stop || !advanced) break
|
|
627
737
|
}
|
|
628
738
|
|
|
629
|
-
await printInitClose(deps, readiness,
|
|
739
|
+
await printInitClose(deps, readiness, resolved)
|
|
630
740
|
const blocker = firstBlocker(readiness)
|
|
631
741
|
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
632
742
|
return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
@@ -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
|
+
})
|