@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.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -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 { SkillScope } from './native-skills.js'
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 { readyIosDevices, supportPageUrl } from './commands-devices.js'
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 { observedCompanionReceipt, readSetupProof, writeSetupProof } from './commands-setup-proof.js'
30
- import { SKILLS_SOURCE } from './commands-skill.js'
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
- /** Scope selected by an unattended caller; humans choose inside npx skills. */
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 TestFlight install; keep-waiting extends another budget. */
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 configPath = path.join(deps.cwd, '.notifai', 'config.toml')
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'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
105
- mkdirSync(path.dirname(configPath), { recursive: true })
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 scopeText = flags.skillsScope === undefined ? 'the scope you choose' : `${flags.skillsScope} scope`
134
- deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${scopeText})...`)
135
- const addOptions = {
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
- ...(flags.skillsScope === undefined ? {} : { scope: flags.skillsScope }),
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 skills add ${SKILLS_SOURCE} --skill notifai${
147
- flags.skillsScope === 'global' ? ' --global' : ''
148
- }${flags.skillsScope === undefined ? '' : ' --yes'}`,
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 the iPhone app to sign in and register…'
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 an iPhone to become ready…'
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 (TestFlight
174
- * steps). Interactive runs offer to open it so the user never types the URL;
175
- * non-interactive/agent paths only print plain text and never wait on a prompt.
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 your iPhone to become ready.`,
243
+ `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
201
244
  ].join('\n'),
202
- 'Finish setup on your iPhone',
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?.(`Waiting up to ${budgetLabel} for your iPhone…`)
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 iphoneDevices = response.devices.filter((device) => device.platform === 'ios')
231
- lastDevices = iphoneDevices
232
- const ready = readyIosDevices(iphoneDevices)[0]
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(iphoneDevices))
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('Stopping the wait. iPhone setup can continue; re-run `notifai init` when ready.')
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 your iPhone…`)
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: 'ios',
335
+ platform: device.platform,
288
336
  device: [device.device_id],
289
337
  sound: 'none',
290
- level: 'passive',
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 APNs accepted
335
- * the push, not that a companion process received it.
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 = readyIosDevices(devices)
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
- export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
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.skillsScope !== 'project' &&
501
- flags.skillsScope !== 'global'
582
+ flags.setupScope !== flags.skillsScope
502
583
  ) {
503
- deps.io.err('Invalid skill scope. Choose `project` or `global`.')
504
- return EXIT.usage
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`. Choose project or global in the native installer.')
508
- return EXIT.usage
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
- if (
511
- flags.skills === true &&
512
- deps.io.interactive !== true &&
513
- flags.skillsScope === undefined
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 --skills-scope project` or `... global`.',
616
+ 'Unattended skill setup requires an explicit scope: `notifai init --skills --setup-scope project` or `... global`.',
517
617
  )
518
- return EXIT.usage
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 = flags.skillsScope === undefined ? {} : { skillScope: flags.skillsScope }
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, flags))) continue
664
+ if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, resolved))) continue
555
665
  attempted.add(state.id)
556
- const result = await closeGap(deps, state, flags)
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, flags)
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, flags)
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
+ })