@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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +8 -1
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +245 -87
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +44 -11
- package/src/commands-core.ts +18 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +116 -55
- package/src/commands-hooks.ts +66 -16
- package/src/commands-init.ts +265 -88
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +518 -49
- package/src/commands.ts +2 -0
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +28 -4
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +11 -0
- package/src/commands-devices.test.ts +0 -15
package/src/cli-bin.test.ts
CHANGED
|
@@ -1,10 +1,60 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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(
|
|
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('
|
|
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
|
-
|
|
36
|
-
|
|
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 {
|
|
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
|
|
44
|
-
const
|
|
45
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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))
|
|
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,
|
package/src/commands-auth.ts
CHANGED
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
reportError,
|
|
15
15
|
type CommandDeps,
|
|
16
16
|
} from './commands-core.js'
|
|
17
|
-
import {
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
195
|
-
deps.io.out(
|
|
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}` : ''
|