@raidiant/notifai 10.1.0 → 10.1.1

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 (97) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +8 -1
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +245 -87
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send.d.ts +1 -1
  32. package/dist/commands-send.js +121 -35
  33. package/dist/commands-send.js.map +1 -1
  34. package/dist/commands-update.d.ts +10 -0
  35. package/dist/commands-update.js +141 -0
  36. package/dist/commands-update.js.map +1 -0
  37. package/dist/commands.d.ts +2 -1
  38. package/dist/commands.js +2 -1
  39. package/dist/commands.js.map +1 -1
  40. package/dist/hook-adapter.d.ts +2 -0
  41. package/dist/hook-adapter.js +12 -1
  42. package/dist/hook-adapter.js.map +1 -1
  43. package/dist/hooks.d.ts +43 -2
  44. package/dist/hooks.js +163 -21
  45. package/dist/hooks.js.map +1 -1
  46. package/dist/main-run.d.ts +1 -0
  47. package/dist/main-run.js +42 -0
  48. package/dist/main-run.js.map +1 -0
  49. package/dist/main.js +10 -31
  50. package/dist/main.js.map +1 -1
  51. package/dist/node-floor.d.ts +18 -0
  52. package/dist/node-floor.js +35 -0
  53. package/dist/node-floor.js.map +1 -0
  54. package/dist/program.d.ts +2 -1
  55. package/dist/program.js +22 -5
  56. package/dist/program.js.map +1 -1
  57. package/dist/question-settlement-process.d.ts +15 -0
  58. package/dist/question-settlement-process.js +35 -0
  59. package/dist/question-settlement-process.js.map +1 -0
  60. package/dist/readiness.d.ts +25 -1
  61. package/dist/readiness.js +32 -2
  62. package/dist/readiness.js.map +1 -1
  63. package/dist/setup-destinations.d.ts +23 -0
  64. package/dist/setup-destinations.js +44 -0
  65. package/dist/setup-destinations.js.map +1 -0
  66. package/package.json +2 -2
  67. package/src/cli-bin.test.ts +145 -7
  68. package/src/cli-bin.ts +176 -10
  69. package/src/cli-update-contract.ts +9 -0
  70. package/src/cli-update.test.ts +125 -0
  71. package/src/client.ts +7 -0
  72. package/src/codex-wake.ts +5 -1
  73. package/src/commands-auth.ts +44 -11
  74. package/src/commands-core.ts +18 -4
  75. package/src/commands-devices.ts +23 -16
  76. package/src/commands-doctor.ts +116 -55
  77. package/src/commands-hooks.ts +66 -16
  78. package/src/commands-init.ts +265 -88
  79. package/src/commands-send.ts +145 -39
  80. package/src/commands-update.ts +180 -0
  81. package/src/commands.test.ts +518 -49
  82. package/src/commands.ts +2 -0
  83. package/src/hook-adapter.ts +14 -0
  84. package/src/hooks.test.ts +275 -0
  85. package/src/hooks.ts +270 -18
  86. package/src/main-run.ts +45 -0
  87. package/src/main.ts +9 -33
  88. package/src/node-floor.test.ts +27 -0
  89. package/src/node-floor.ts +37 -0
  90. package/src/program.test.ts +35 -0
  91. package/src/program.ts +28 -4
  92. package/src/question-settlement-process.ts +47 -0
  93. package/src/readiness.ts +35 -2
  94. package/src/setup-destinations.test.ts +41 -0
  95. package/src/setup-destinations.ts +50 -0
  96. package/src/skill.test.ts +11 -0
  97. package/src/commands-devices.test.ts +0 -15
@@ -1,10 +1,60 @@
1
- import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ realpathSync,
6
+ symlinkSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
2
9
  import os from 'node:os'
3
10
  import path from 'node:path'
4
11
  import { describe, expect, it } from 'vitest'
5
12
  import { cliBinReadiness, isExecutablePath, pathNotifaiEntries } from './cli-bin.js'
13
+ import { cliUpdateRecoveryCommand } from './cli-update-contract.js'
6
14
 
7
15
  describe('PATH notifai diagnosis', () => {
16
+ function posixInstall(root: string, name: string, version: string) {
17
+ const prefix = path.join(root, name)
18
+ const artifact = path.join(
19
+ prefix,
20
+ 'lib',
21
+ 'node_modules',
22
+ '@raidiant',
23
+ 'notifai',
24
+ 'dist',
25
+ 'main.js',
26
+ )
27
+ const command = path.join(prefix, 'bin', 'notifai')
28
+ mkdirSync(path.dirname(artifact), { recursive: true })
29
+ mkdirSync(path.dirname(command), { recursive: true })
30
+ writeFileSync(path.join(path.dirname(path.dirname(artifact)), 'package.json'), JSON.stringify({ version }))
31
+ writeFileSync(artifact, '#!/usr/bin/env node\n')
32
+ chmodSync(artifact, 0o755)
33
+ symlinkSync(path.relative(path.dirname(command), artifact), command)
34
+ return { prefix, artifact, command }
35
+ }
36
+
37
+ function windowsInstall(root: string, name: string, version: string) {
38
+ const prefix = path.join(root, name)
39
+ const artifact = path.join(
40
+ prefix,
41
+ 'node_modules',
42
+ '@raidiant',
43
+ 'notifai',
44
+ 'dist',
45
+ 'main.js',
46
+ )
47
+ const command = path.join(prefix, 'notifai.cmd')
48
+ mkdirSync(path.dirname(artifact), { recursive: true })
49
+ writeFileSync(path.join(path.dirname(path.dirname(artifact)), 'package.json'), JSON.stringify({ version }))
50
+ writeFileSync(artifact, '#!/usr/bin/env node\n')
51
+ writeFileSync(
52
+ command,
53
+ '@ECHO off\r\nnode "%dp0%node_modules\\@raidiant\\notifai\\dist\\main.js" %*\r\n',
54
+ )
55
+ return { prefix, artifact, command }
56
+ }
57
+
8
58
  it('reports a non-executable PATH binary as a gap', () => {
9
59
  if (process.platform === 'win32') return
10
60
  const directory = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-gap-'))
@@ -14,8 +64,10 @@ describe('PATH notifai diagnosis', () => {
14
64
  expect(isExecutablePath(file, 'darwin')).toBe(false)
15
65
  const state = cliBinReadiness({ PATH: directory }, 'darwin')
16
66
  expect(state.status).toBe('gap')
17
- expect(state.detail).toContain(file)
18
- expect(state.remedy?.command).toBe('npm install -g @raidiant/notifai')
67
+ expect(state.detail).not.toContain(file)
68
+ expect(state.technical).toMatchObject({ entries: [{ command_path: file, executable: false }] })
69
+ expect(state.remedy?.command).not.toBe('notifai update')
70
+ expect(state.remedy?.command).toMatch(/ update$/)
19
71
  })
20
72
 
21
73
  it('treats an executable PATH binary as ready', () => {
@@ -25,14 +77,100 @@ describe('PATH notifai diagnosis', () => {
25
77
  writeFileSync(file, '#!/usr/bin/env node\n')
26
78
  chmodSync(file, 0o755)
27
79
  expect(pathNotifaiEntries({ PATH: directory }, 'darwin')).toEqual([file])
28
- expect(cliBinReadiness({ PATH: directory }, 'darwin').status).toBe('ready')
80
+ expect(
81
+ cliBinReadiness({ PATH: directory }, 'darwin', {
82
+ runningArtifactPath: file,
83
+ currentVersion: null,
84
+ }).status,
85
+ ).toBe('ready')
86
+ })
87
+
88
+ it('does not call an older executable PATH winner ready when this process is the current CLI', () => {
89
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-stale-winner-'))
90
+ const old = posixInstall(root, 'old', '3.0.1')
91
+ const current = posixInstall(root, 'current', '10.1.0')
92
+
93
+ const state = cliBinReadiness(
94
+ { PATH: `${path.dirname(old.command)}:${path.dirname(current.command)}` },
95
+ 'darwin',
96
+ {
97
+ runningArtifactPath: current.artifact,
98
+ currentVersion: '10.1.0',
99
+ },
100
+ )
101
+
102
+ expect(state.status).toBe('gap')
103
+ expect(state.detail).not.toContain(old.command)
104
+ expect(state.remedy?.command).toBe(
105
+ cliUpdateRecoveryCommand(),
106
+ )
107
+ expect(state.remedy?.command).not.toContain(old.command)
108
+ expect(state.technical).toMatchObject({
109
+ current: { artifact_path: realpathSync(current.artifact), version: '10.1.0' },
110
+ effective: { command_path: old.command, artifact_path: realpathSync(old.artifact), version: '3.0.1' },
111
+ })
112
+ })
113
+
114
+ it('uses Windows Path order to reject an older executable winner', () => {
115
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-windows-winner-'))
116
+ const old = windowsInstall(root, 'old', '3.0.1')
117
+ const current = windowsInstall(root, 'current', '10.1.0')
118
+
119
+ const state = cliBinReadiness(
120
+ { Path: `${old.prefix};${current.prefix}` },
121
+ 'win32',
122
+ {
123
+ runningArtifactPath: current.artifact,
124
+ currentVersion: '10.1.0',
125
+ },
126
+ )
127
+
128
+ expect(state.status).toBe('gap')
129
+ expect(state.remedy?.command).toBe(
130
+ cliUpdateRecoveryCommand(),
131
+ )
132
+ expect(state.technical).toMatchObject({
133
+ effective: {
134
+ command_path: old.command,
135
+ artifact_path: realpathSync(old.artifact),
136
+ version: '3.0.1',
137
+ },
138
+ })
139
+ })
140
+
141
+ it('keeps a healthy winner ready while reporting a later broken duplicate for cleanup', () => {
142
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-later-broken-'))
143
+ const current = posixInstall(root, 'current', '10.1.0')
144
+ const broken = posixInstall(root, 'broken', '3.0.1')
145
+ chmodSync(broken.artifact, 0o644)
146
+
147
+ const state = cliBinReadiness(
148
+ { PATH: `${path.dirname(current.command)}:${path.dirname(broken.command)}` },
149
+ 'darwin',
150
+ { runningArtifactPath: current.artifact, currentVersion: '10.1.0' },
151
+ )
152
+
153
+ expect(state.status).toBe('optional-gap')
154
+ expect(state.detail).toContain('ready')
155
+ expect(state.detail).not.toContain(broken.command)
156
+ expect(state.technical).toMatchObject({
157
+ effective: { command_path: current.command, executable: true },
158
+ entries: [
159
+ { command_path: current.command, executable: true },
160
+ { command_path: broken.command, executable: false },
161
+ ],
162
+ })
29
163
  })
30
164
 
31
- it('is ready when PATH has no notifai entry', () => {
165
+ it('does not call a PATH with no notifai entry ready', () => {
32
166
  const directory = mkdtempSync(path.join(os.tmpdir(), 'notifai-bin-empty-'))
33
167
  mkdirSync(directory, { recursive: true })
34
168
  const state = cliBinReadiness({ PATH: directory }, process.platform)
35
- expect(state.status).toBe('ready')
36
- expect(state.detail).toContain('no notifai binary was on PATH')
169
+ // The running process proves nothing about the command every printed next
170
+ // step names — but it is running, so this cannot stand in the way either.
171
+ expect(state.status).toBe('optional-gap')
172
+ expect(state.detail).toContain('will not be found')
173
+ expect(state.remedy?.command).not.toBe('notifai update')
174
+ expect(state.remedy?.command).toMatch(/ update$/)
37
175
  })
38
176
  })
package/src/cli-bin.ts CHANGED
@@ -1,10 +1,38 @@
1
- import { accessSync, constants, existsSync, lstatSync, realpathSync } from 'node:fs'
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ lstatSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ } from 'node:fs'
2
9
  import path from 'node:path'
3
10
  import type { ReadinessState } from './readiness.js'
11
+ import { packageVersion } from './release.js'
12
+ import { cliUpdateRecoveryCommand } from './cli-update-contract.js'
4
13
 
5
14
  const POSIX_NAMES = ['notifai']
6
15
  const WINDOWS_NAMES = ['notifai.cmd', 'notifai.exe', 'notifai']
7
16
 
17
+ export interface CliBinReadinessOptions {
18
+ runningArtifactPath?: string
19
+ currentVersion?: string | null
20
+ }
21
+
22
+ export interface CliPathEntry {
23
+ command_path: string
24
+ executable: boolean
25
+ artifact_path: string | null
26
+ version: string | null
27
+ install_prefix: string | null
28
+ }
29
+
30
+ export interface CliInstallationInspection {
31
+ current: { artifact_path: string; version: string | null }
32
+ effective: CliPathEntry | null
33
+ entries: CliPathEntry[]
34
+ }
35
+
8
36
  export function pathNotifaiEntries(
9
37
  env: NodeJS.ProcessEnv,
10
38
  platform: NodeJS.Platform = process.platform,
@@ -36,32 +64,170 @@ export function isExecutablePath(file: string, platform: NodeJS.Platform = proce
36
64
  }
37
65
  }
38
66
 
67
+ function canonicalPath(file: string): string {
68
+ try {
69
+ return realpathSync(file)
70
+ } catch {
71
+ return path.resolve(file)
72
+ }
73
+ }
74
+
75
+ function windowsShimArtifact(file: string): string | null {
76
+ if (path.extname(file).toLowerCase() !== '.cmd') return null
77
+ try {
78
+ const source = readFileSync(file, 'utf8')
79
+ const match = /(?:%dp0%|%~dp0)?([^"\r\n]*node_modules[\\/]@raidiant[\\/]notifai[\\/]dist[\\/]main\.js)/i.exec(source)
80
+ if (match?.[0] === undefined) return null
81
+ const expanded = match[0]
82
+ .replace(/^%~?dp0%/i, `${path.dirname(file)}${path.sep}`)
83
+ .replaceAll('\\', path.sep)
84
+ return canonicalPath(expanded)
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ function artifactForCommand(file: string, platform: NodeJS.Platform): string | null {
91
+ const shim = platform === 'win32' ? windowsShimArtifact(file) : null
92
+ if (shim !== null) return shim
93
+ try {
94
+ return canonicalPath(file)
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ function artifactVersion(artifact: string | null): string | null {
101
+ if (artifact === null) return null
102
+ try {
103
+ const parsed: unknown = JSON.parse(
104
+ readFileSync(path.join(path.dirname(artifact), '..', 'package.json'), 'utf8'),
105
+ )
106
+ if (typeof parsed !== 'object' || parsed === null) return null
107
+ const version = (parsed as { version?: unknown }).version
108
+ return typeof version === 'string' && version !== '' ? version : null
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ function installPrefix(artifact: string | null): string | null {
115
+ if (artifact !== null) {
116
+ const marker = /[\\/]lib[\\/]node_modules[\\/]@raidiant[\\/]notifai[\\/]/i.exec(artifact)
117
+ ?? /[\\/]node_modules[\\/]@raidiant[\\/]notifai[\\/]/i.exec(artifact)
118
+ if (marker?.index !== undefined) return artifact.slice(0, marker.index)
119
+ }
120
+ return null
121
+ }
122
+
123
+ export function inspectCliInstallations(
124
+ env: NodeJS.ProcessEnv,
125
+ platform: NodeJS.Platform = process.platform,
126
+ options: CliBinReadinessOptions = {},
127
+ ): CliInstallationInspection {
128
+ const runningArtifact = canonicalPath(options.runningArtifactPath ?? process.argv[1] ?? 'notifai')
129
+ const entries = pathNotifaiEntries(env, platform).map((command): CliPathEntry => {
130
+ const artifact = artifactForCommand(command, platform)
131
+ return {
132
+ command_path: command,
133
+ executable: isExecutablePath(command, platform),
134
+ artifact_path: artifact,
135
+ version: artifactVersion(artifact),
136
+ install_prefix: installPrefix(artifact),
137
+ }
138
+ })
139
+ return {
140
+ current: {
141
+ artifact_path: runningArtifact,
142
+ version: options.currentVersion === undefined ? packageVersion() : options.currentVersion,
143
+ },
144
+ effective: entries.find((entry) => entry.executable) ?? null,
145
+ entries,
146
+ }
147
+ }
148
+
39
149
  export function cliBinReadiness(
40
150
  env: NodeJS.ProcessEnv,
41
151
  platform: NodeJS.Platform = process.platform,
152
+ options: CliBinReadinessOptions = {},
42
153
  ): ReadinessState {
43
- const entries = pathNotifaiEntries(env, platform)
44
- const broken = entries.find((file) => !isExecutablePath(file, platform))
45
- if (broken !== undefined) {
154
+ const inspection = inspectCliInstallations(env, platform, options)
155
+ const { current, effective, entries } = inspection
156
+ const updateCommand = cliUpdateRecoveryCommand()
157
+ if (effective === null && entries.length > 0) {
46
158
  return {
47
159
  id: 'cli-bin',
48
160
  title: 'notifai command',
49
161
  status: 'gap',
50
- detail: `${broken} is on PATH but is not executable`,
162
+ detail: 'the notifai command is on PATH but cannot run',
163
+ technical: inspection,
51
164
  remedy: {
52
165
  by: 'user-here',
53
166
  summary: 'repair the global notifai command so it can run',
54
- command: 'npm install -g @raidiant/notifai',
167
+ command: updateCommand,
168
+ },
169
+ }
170
+ }
171
+ // A global install that landed outside PATH used to report `ready` with
172
+ // "this process can run" — which is true of the running process and false of
173
+ // every instruction it goes on to print. Hooks embed absolute paths, so
174
+ // nothing visibly breaks until the reader types `notifai` themselves.
175
+ //
176
+ // Not a blocker: this process is already running, so the whole setup —
177
+ // pairing, the app, the delivery proof — still completes. It is the later
178
+ // `notifai …` lines that will not be found, and saying so is the fix.
179
+ if (effective === null) {
180
+ return {
181
+ id: 'cli-bin',
182
+ title: 'notifai command',
183
+ status: 'optional-gap',
184
+ detail:
185
+ 'no `notifai` on PATH — this process runs, but a typed `notifai …` command will not be found',
186
+ remedy: {
187
+ by: 'user-here',
188
+ summary: 'install notifai globally so the command is on your PATH',
189
+ command: updateCommand,
190
+ },
191
+ }
192
+ }
193
+
194
+ const effectiveIsCurrent =
195
+ effective.artifact_path === current.artifact_path ||
196
+ (effective.version !== null && current.version !== null && effective.version === current.version)
197
+ if (!effectiveIsCurrent) {
198
+ return {
199
+ id: 'cli-bin',
200
+ title: 'notifai command',
201
+ status: 'gap',
202
+ detail: 'the notifai command resolves to a different installation than this current CLI',
203
+ technical: inspection,
204
+ remedy: {
205
+ by: 'user-here',
206
+ summary: 'update the notifai command that wins PATH',
207
+ command: updateCommand,
55
208
  },
56
209
  }
57
210
  }
211
+
212
+ const duplicateNeedsCleanup = entries.some(
213
+ (entry) =>
214
+ entry !== effective &&
215
+ (!entry.executable || entry.artifact_path !== effective.artifact_path),
216
+ )
217
+ if (duplicateNeedsCleanup) {
218
+ return {
219
+ id: 'cli-bin',
220
+ title: 'notifai command',
221
+ status: 'optional-gap',
222
+ detail: 'the notifai command is ready; another installation remains for cleanup',
223
+ technical: inspection,
224
+ }
225
+ }
58
226
  return {
59
227
  id: 'cli-bin',
60
228
  title: 'notifai command',
61
229
  status: 'ready',
62
- detail:
63
- entries[0] === undefined
64
- ? 'this process can run; no notifai binary was on PATH'
65
- : `${entries[0]} is executable`,
230
+ detail: 'the notifai command is ready',
231
+ technical: inspection,
66
232
  }
67
233
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve the current published CLI independently of every installed
3
+ * `notifai` command. The fetched build owns the locally derived prefix repair,
4
+ * so this action neither invokes the stale PATH winner nor exposes local
5
+ * installation paths.
6
+ */
7
+ export function cliUpdateRecoveryCommand(): string {
8
+ return 'npx --yes @raidiant/notifai@latest update'
9
+ }
@@ -0,0 +1,125 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { describe, expect, it } from 'vitest'
14
+ import type { CommandDeps, CommandIo } from './commands-core.js'
15
+ import { cliUpdateCommand } from './commands-update.js'
16
+ import { installHookAdapter, inspectHookAdapter } from './hook-adapter.js'
17
+ import { packageVersion } from './release.js'
18
+
19
+ class CapturedIo implements CommandIo {
20
+ outLines: string[] = []
21
+ errLines: string[] = []
22
+ out(line: string) { this.outLines.push(line) }
23
+ err(line: string) { this.errLines.push(line) }
24
+ async confirm() { return false }
25
+ openUrl() {}
26
+ }
27
+
28
+ function npmInstall(root: string, name: string, version: string) {
29
+ const prefix = path.join(root, name)
30
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
31
+ const artifact = path.join(packageRoot, 'dist', 'main.js')
32
+ const command = path.join(prefix, 'bin', 'notifai')
33
+ mkdirSync(path.dirname(artifact), { recursive: true })
34
+ mkdirSync(path.dirname(command), { recursive: true })
35
+ writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version }))
36
+ writeFileSync(artifact, `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(version)} + "\\n")\n`)
37
+ chmodSync(artifact, 0o755)
38
+ symlinkSync(path.relative(path.dirname(command), artifact), command)
39
+ return { prefix, packageRoot, artifact, command }
40
+ }
41
+
42
+ describe('CLI update recovery', () => {
43
+ it('updates the PATH winner prefix and retargets the shared hook adapter in one action', () => {
44
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-loop-'))
45
+ const currentVersion = packageVersion()
46
+ if (currentVersion === null) throw new Error('test build has no package version')
47
+ const stale = npmInstall(root, 'stale-prefix', '3.0.1')
48
+ const current = npmInstall(root, 'current-prefix', currentVersion)
49
+ const managerPrefix = path.join(root, 'manager-prefix')
50
+ const managerBin = path.join(managerPrefix, 'bin')
51
+ const npm = path.join(managerBin, 'npm')
52
+ const calls = path.join(root, 'npm-calls.jsonl')
53
+ mkdirSync(managerBin, { recursive: true })
54
+ writeFileSync(
55
+ npm,
56
+ `#!${process.execPath}
57
+ const fs = require('node:fs');
58
+ const path = require('node:path');
59
+ const args = process.argv.slice(2);
60
+ fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + '\\n');
61
+ if (args[0] === 'prefix') {
62
+ process.stdout.write(${JSON.stringify(managerPrefix)} + '\\n');
63
+ process.exit(0);
64
+ }
65
+ const prefix = args[args.indexOf('--prefix') + 1];
66
+ const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai');
67
+ const artifact = path.join(packageRoot, 'dist', 'main.js');
68
+ fs.mkdirSync(path.dirname(artifact), { recursive: true });
69
+ fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: ${JSON.stringify(currentVersion)} }));
70
+ fs.writeFileSync(artifact, '#!${process.execPath}\\nprocess.stdout.write(${JSON.stringify(`${currentVersion}\\n`)})\\n', { mode: 0o755 });
71
+ `,
72
+ )
73
+ chmodSync(npm, 0o755)
74
+
75
+ const home = path.join(root, 'home')
76
+ installHookAdapter({ execPath: process.execPath, scriptPath: stale.artifact }, home)
77
+ const io = new CapturedIo()
78
+ const deps: CommandDeps = {
79
+ io,
80
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test' },
81
+ env: {
82
+ PATH: [managerBin, path.dirname(stale.command), path.dirname(current.command)].join(':'),
83
+ },
84
+ cwd: root,
85
+ hookAdapterHome: home,
86
+ hookInstallTarget: { execPath: process.execPath, scriptPath: current.artifact },
87
+ hookPlatform: 'darwin',
88
+ }
89
+
90
+ expect(cliUpdateCommand(deps, {})).toBe(0)
91
+ expect(JSON.parse(readFileSync(path.join(stale.packageRoot, 'package.json'), 'utf8'))).toMatchObject({
92
+ version: currentVersion,
93
+ })
94
+ expect(
95
+ readFileSync(calls, 'utf8').trim().split('\n').map((line) => JSON.parse(line)),
96
+ ).toEqual([
97
+ ['prefix', '--global'],
98
+ ['install', '--global', '--prefix', realpathSync(stale.prefix), '@raidiant/notifai'],
99
+ ])
100
+ expect(spawnSync(stale.command, ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe(currentVersion)
101
+ expect(inspectHookAdapter(home).target).toMatchObject({ scriptPath: realpathSync(stale.artifact) })
102
+ expect(io.outLines).toEqual(['Notifai is updated. Re-run `notifai init` to continue setup.'])
103
+ expect(io.outLines.join('\n')).not.toContain(root)
104
+ expect(io.errLines).toEqual([])
105
+
106
+ io.outLines = []
107
+ expect(cliUpdateCommand(deps, { json: true })).toBe(0)
108
+ const result = JSON.parse(io.outLines[0] ?? '{}') as Record<string, unknown>
109
+ expect(result).toMatchObject({
110
+ ok: true,
111
+ package_manager_prefix: managerPrefix,
112
+ update_prefix: realpathSync(stale.prefix),
113
+ after: {
114
+ effective: {
115
+ command_path: stale.command,
116
+ artifact_path: realpathSync(stale.artifact),
117
+ version: currentVersion,
118
+ },
119
+ },
120
+ hook_adapter: {
121
+ target: { scriptPath: realpathSync(stale.artifact) },
122
+ },
123
+ })
124
+ })
125
+ })
package/src/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ApiErrorBody,
3
+ AccountAccessRequestResponse,
3
4
  AccountAccessResponse,
4
5
  BeginPairingResponse,
5
6
  CapabilityDocument,
@@ -78,6 +79,11 @@ export interface ApiClient {
78
79
  }): Promise<BeginPairingResponse>
79
80
  pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
80
81
  accessStatus(): Promise<AccountAccessResponse>
82
+ /**
83
+ * Read-only. There is no client-side write: filing this errand is the
84
+ * User's decision, and the server refuses it from a machine credential.
85
+ */
86
+ accessRequest(): Promise<AccountAccessRequestResponse>
81
87
  listDevices(): Promise<ListDevicesResponse>
82
88
  listSounds(): Promise<ListSoundsResponse>
83
89
  capabilities(
@@ -254,6 +260,7 @@ export function createClient(
254
260
  poll_verifier: pollVerifier,
255
261
  }),
256
262
  accessStatus: () => call('GET', '/api/v1/account/access'),
263
+ accessRequest: () => call('GET', '/api/v1/account/alpha-access-request'),
257
264
  listDevices: () => call('GET', '/api/v1/devices'),
258
265
  listSounds: () => call('GET', '/api/v1/sounds'),
259
266
  capabilities: (platform = 'ios', appVersion, appBuild) => {
package/src/codex-wake.ts CHANGED
@@ -137,6 +137,8 @@ export function codexWakeRoute(options: {
137
137
  sourcePid: number
138
138
  env?: NodeJS.ProcessEnv
139
139
  adapters?: CodexWakeAdapters
140
+ /** False for a detached recovery owner whose stdout no harness will read. */
141
+ continuationActive?: boolean
140
142
  }): EscalationDeliveryRoute {
141
143
  const env = options.env ?? process.env
142
144
  const adapters = options.adapters ?? systemCodexWakeAdapters(env)
@@ -144,7 +146,9 @@ export function codexWakeRoute(options: {
144
146
  return {
145
147
  kind: 'hook-continuation',
146
148
  async deliver(event: ContinuationEvent): Promise<DeliveryOutcome> {
147
- if (adapters.sourceAlive(options.sourcePid)) return continuation.deliver(event)
149
+ if (options.continuationActive !== false && adapters.sourceAlive(options.sourcePid)) {
150
+ return continuation.deliver(event)
151
+ }
148
152
 
149
153
  // Probe immediately before the spawn, twice, and never trust the earlier
150
154
  // answer: ownership is a kernel fact that can change between two syscalls,
@@ -14,7 +14,8 @@ import {
14
14
  reportError,
15
15
  type CommandDeps,
16
16
  } from './commands-core.js'
17
- import { supportPageUrl } from './commands-devices.js'
17
+ import { setupAccessUrl } from './setup-destinations.js'
18
+ import type { ReadinessState } from './readiness.js'
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // login / logout / auth status
@@ -31,9 +32,24 @@ export function pairingApprovalUrl(approveUrl: string, confirmationSecret: strin
31
32
  return url.toString()
32
33
  }
33
34
 
35
+ /**
36
+ * Why a sign-in stopped, in the shape the close line renders.
37
+ *
38
+ * Without it, `init` fell back to the state it had before the attempt — "this
39
+ * machine is not paired … run `notifai init`" — so the last thing a User read
40
+ * contradicted the correct line three lines above it and pointed back at the
41
+ * command that had just failed, for a reason it never mentioned.
42
+ *
43
+ * Passing one also transfers the errand: whoever takes the blocker prints the
44
+ * close, so this command stops after naming what stopped. A sign-in run on its
45
+ * own has no such caller and states the errand itself.
46
+ */
47
+ export type LoginBlockedSink = (blocker: ReadinessState) => void
48
+
34
49
  export async function loginCommand(
35
50
  deps: CommandDeps,
36
51
  flags: { name?: string; baseUrl?: string; open?: boolean },
52
+ onBlocked?: LoginBlockedSink,
37
53
  ): Promise<number> {
38
54
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
39
55
  const baseUrl = config.base_url.value
@@ -122,14 +138,32 @@ export async function loginCommand(
122
138
  deps.io.err('Pairing was denied from the dashboard.')
123
139
  return EXIT.auth
124
140
  }
141
+ // Proof-gated: the server never returns this from lookup. Stop now rather
142
+ // than waiting out a TTL no approval can arrive within.
125
143
  if (poll.status === 'no_active_plan') {
126
- const next =
127
- poll.next_action ??
128
- `Open ${supportPageUrl(baseUrl)} to request Alpha access, then retry.`
129
- spinner?.error('Account has no Alpha access')
130
- deps.io.err('This account has no active plan or temporary Alpha access.')
131
- deps.io.err(`next: ${next}`)
132
- deps.io.err(`After access is granted, run \`${SETUP_COMMAND}\` again.`)
144
+ // The server owns which access errand is current — requesting it today,
145
+ // choosing a plan after cutover — so its line wins whenever it sends one.
146
+ const next = poll.next_action ?? `Open ${setupAccessUrl(baseUrl)} to set up access, then retry.`
147
+ // Ends the wait, like its three siblings above and below; the wall
148
+ // itself belongs to the line under it, which said the same sentence a
149
+ // second time when this one tried to say it first.
150
+ spinner?.error('Pairing stopped')
151
+ // One wall, said once. When a caller takes the blocker it closes the
152
+ // visit on this exact errand, so repeating the destination here — and
153
+ // naming `init` as the way past a wall no command of theirs can open —
154
+ // would leave the User three phrasings of one step to choose between.
155
+ if (onBlocked === undefined) {
156
+ deps.io.err('This account has no active plan or temporary Alpha access.')
157
+ deps.io.err(`next: ${next}`)
158
+ deps.io.err(`After access is granted, run \`${SETUP_COMMAND}\` again.`)
159
+ }
160
+ onBlocked?.({
161
+ id: 'auth',
162
+ title: 'Access',
163
+ status: 'gap',
164
+ detail: 'this account does not have access to Notifai yet',
165
+ remedy: { by: 'user-elsewhere', summary: next },
166
+ })
133
167
  return EXIT.auth
134
168
  }
135
169
  if (poll.status === 'expired') break
@@ -191,9 +225,8 @@ export async function accessStatusCommand(
191
225
  return access.status === 'active' ? EXIT.ok : EXIT.failed
192
226
  }
193
227
  if (access.status === 'no_active_plan') {
194
- const supportUrl = supportPageUrl(authed.baseUrl)
195
- deps.io.out('No active plan or temporary Alpha access for this account.')
196
- deps.io.out(`next: Open ${supportUrl} to request Alpha access, then retry.`)
228
+ deps.io.out('This account does not have access to Notifai yet.')
229
+ deps.io.out(`next: Open ${setupAccessUrl(authed.baseUrl)} to set up access, then retry.`)
197
230
  return EXIT.failed
198
231
  }
199
232
  const expiry = access.expires_at ? ` until ${access.expires_at}` : ''