@raidiant/notifai 10.0.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 +31 -0
- package/README.md +4 -1
- 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 +32 -14
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +10 -1
- package/dist/commands-core.js +13 -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 +158 -71
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +8 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +169 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -91
- 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/harnesses.d.ts +2 -2
- package/dist/harnesses.js +22 -2
- package/dist/harnesses.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/install-hooks.js +15 -9
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.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/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +31 -13
- 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/session-activation.js +1 -1
- package/dist/session-activation.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/dist/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- 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 +47 -13
- package/src/commands-core.ts +21 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +197 -75
- package/src/commands-harness-context.ts +8 -0
- package/src/commands-hooks.ts +210 -18
- package/src/commands-init.ts +275 -92
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +701 -53
- package/src/commands.ts +2 -0
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +82 -0
- package/src/harnesses.test.ts +9 -1
- package/src/harnesses.ts +24 -2
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/install-hooks.test.ts +208 -0
- package/src/install-hooks.ts +19 -9
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- 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/openclaw-plugin.ts +409 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +37 -12
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/session-activation.ts +1 -1
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +13 -2
- package/src/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
- package/src/commands-devices.test.ts +0 -15
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
|
@@ -7,13 +7,15 @@ import { type FlagOverrides } from './config.js'
|
|
|
7
7
|
import { checkApproveUrl } from './url-policy.js'
|
|
8
8
|
import {
|
|
9
9
|
EXIT,
|
|
10
|
+
SETUP_COMMAND,
|
|
10
11
|
authedClient,
|
|
11
12
|
loadLoggedConfig,
|
|
12
13
|
makeClient,
|
|
13
14
|
reportError,
|
|
14
15
|
type CommandDeps,
|
|
15
16
|
} from './commands-core.js'
|
|
16
|
-
import {
|
|
17
|
+
import { setupAccessUrl } from './setup-destinations.js'
|
|
18
|
+
import type { ReadinessState } from './readiness.js'
|
|
17
19
|
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// login / logout / auth status
|
|
@@ -30,9 +32,24 @@ export function pairingApprovalUrl(approveUrl: string, confirmationSecret: strin
|
|
|
30
32
|
return url.toString()
|
|
31
33
|
}
|
|
32
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
|
+
|
|
33
49
|
export async function loginCommand(
|
|
34
50
|
deps: CommandDeps,
|
|
35
51
|
flags: { name?: string; baseUrl?: string; open?: boolean },
|
|
52
|
+
onBlocked?: LoginBlockedSink,
|
|
36
53
|
): Promise<number> {
|
|
37
54
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env, flags: { base_url: flags.baseUrl } as FlagOverrides })
|
|
38
55
|
const baseUrl = config.base_url.value
|
|
@@ -121,21 +138,39 @@ export async function loginCommand(
|
|
|
121
138
|
deps.io.err('Pairing was denied from the dashboard.')
|
|
122
139
|
return EXIT.auth
|
|
123
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.
|
|
124
143
|
if (poll.status === 'no_active_plan') {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
})
|
|
132
167
|
return EXIT.auth
|
|
133
168
|
}
|
|
134
169
|
if (poll.status === 'expired') break
|
|
135
170
|
spinner?.message(approvalWaitMessage())
|
|
136
171
|
}
|
|
137
172
|
spinner?.error('Pairing expired')
|
|
138
|
-
deps.io.err(
|
|
173
|
+
deps.io.err(`Pairing expired before it was approved. Run \`${SETUP_COMMAND}\` again.`)
|
|
139
174
|
return EXIT.auth
|
|
140
175
|
}
|
|
141
176
|
|
|
@@ -166,7 +201,7 @@ export function authStatusCommand(deps: CommandDeps, flags: { json?: boolean }):
|
|
|
166
201
|
return credential ? EXIT.ok : EXIT.auth
|
|
167
202
|
}
|
|
168
203
|
if (!credential) {
|
|
169
|
-
deps.io.err(
|
|
204
|
+
deps.io.err(`Not signed in. Run \`${SETUP_COMMAND}\`; it will coordinate machine login and device setup.`)
|
|
170
205
|
return EXIT.auth
|
|
171
206
|
}
|
|
172
207
|
deps.io.out(`Signed in as machine "${credential.machineName}" (${credential.machineId})`)
|
|
@@ -190,9 +225,8 @@ export async function accessStatusCommand(
|
|
|
190
225
|
return access.status === 'active' ? EXIT.ok : EXIT.failed
|
|
191
226
|
}
|
|
192
227
|
if (access.status === 'no_active_plan') {
|
|
193
|
-
|
|
194
|
-
deps.io.out(
|
|
195
|
-
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.`)
|
|
196
230
|
return EXIT.failed
|
|
197
231
|
}
|
|
198
232
|
const expiry = access.expires_at ? ` until ${access.expires_at}` : ''
|
package/src/commands-core.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { type HookAdapterTarget } from './hook-adapter.js'
|
|
|
14
14
|
import { logConfigResolved, logSettingsFrom, nullLogger, type Logger } from './logging.js'
|
|
15
15
|
import type { NativeSkills } from './native-skills.js'
|
|
16
16
|
import type { OrcaSessionTitleLookup } from './orca-session-title.js'
|
|
17
|
+
import type { QuestionSettlementLaunch } from './question-settlement-process.js'
|
|
17
18
|
import { packageVersion } from './release.js'
|
|
18
19
|
import type { Tone } from './ui/theme.js'
|
|
20
|
+
import { cliUpdateRecoveryCommand } from './cli-update-contract.js'
|
|
19
21
|
|
|
20
22
|
export interface CommandIo {
|
|
21
23
|
out(line: string): void
|
|
@@ -80,6 +82,8 @@ export interface CommandDeps {
|
|
|
80
82
|
/** Test seams for the Codex thread-writer probe and cold resume. */
|
|
81
83
|
codexWake?: CodexWakeAdapters
|
|
82
84
|
codexSourcePid?: number
|
|
85
|
+
/** Test seam for the detached owner recovered from a pre-Stop User prompt. */
|
|
86
|
+
spawnQuestionSettlement?: (launch: QuestionSettlementLaunch) => void
|
|
83
87
|
/** Test seam and production adapter for the external native skills installer. */
|
|
84
88
|
nativeSkills?: NativeSkills
|
|
85
89
|
/** Test seam for Orca's exact-worktree task/session title lookup. */
|
|
@@ -184,7 +188,16 @@ export function authedClient(deps: CommandDeps, config: CliConfig): { client: Ap
|
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Resolve a current CLI independently of PATH. A bare `notifai update` would
|
|
193
|
+
* be captured by the stale winner this action is meant to repair.
|
|
194
|
+
*/
|
|
195
|
+
export function updateCliCommand(_deps: Pick<CommandDeps, 'hookInstallTarget' | 'hookPlatform'>): string {
|
|
196
|
+
return cliUpdateRecoveryCommand()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The one first-run command an unsigned machine is told to run. */
|
|
200
|
+
export const SETUP_COMMAND = 'notifai init'
|
|
188
201
|
|
|
189
202
|
function affectedDeviceNames(details: unknown): string[] {
|
|
190
203
|
if (typeof details !== 'object' || details === null || !('device_names' in details)) return []
|
|
@@ -199,10 +212,14 @@ function naturalList(values: readonly string[]): string {
|
|
|
199
212
|
return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`
|
|
200
213
|
}
|
|
201
214
|
|
|
202
|
-
function localRecovery(
|
|
215
|
+
function localRecovery(
|
|
216
|
+
deps: Pick<CommandDeps, 'hookInstallTarget' | 'hookPlatform'>,
|
|
217
|
+
action: RecoveryAction | null,
|
|
218
|
+
details: unknown,
|
|
219
|
+
): string | null {
|
|
203
220
|
switch (action) {
|
|
204
221
|
case 'update_cli':
|
|
205
|
-
return `next: ${
|
|
222
|
+
return `next: ${updateCliCommand(deps)}`
|
|
206
223
|
case 'update_companion': {
|
|
207
224
|
const names = affectedDeviceNames(details)
|
|
208
225
|
return names.length > 0
|
|
@@ -268,7 +285,7 @@ export function reportError(
|
|
|
268
285
|
const recovery =
|
|
269
286
|
err.code === 'no_active_devices'
|
|
270
287
|
? 'next: Run `notifai init` to connect an active Companion App; it will complete machine setup and identify any human-only device step.'
|
|
271
|
-
: localRecovery(err.recoveryAction, err.details)
|
|
288
|
+
: localRecovery(deps, err.recoveryAction, err.details)
|
|
272
289
|
if (recovery !== null) deps.io.err(recovery)
|
|
273
290
|
else if (err.recoveryAction === null && err.nextAction) deps.io.err(`next: ${err.nextAction}`)
|
|
274
291
|
if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
|
package/src/commands-devices.ts
CHANGED
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
resolvedBaseUrl,
|
|
9
9
|
type CommandDeps,
|
|
10
10
|
} from './commands-core.js'
|
|
11
|
+
import {
|
|
12
|
+
companionPlatformLabel,
|
|
13
|
+
setupCompanionUrl,
|
|
14
|
+
type CompanionPlatform,
|
|
15
|
+
} from './setup-destinations.js'
|
|
11
16
|
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
13
18
|
// devices / capabilities
|
|
@@ -40,15 +45,15 @@ export async function devicesCommand(
|
|
|
40
45
|
deps.io.out(`No ${platform} devices registered.`)
|
|
41
46
|
return EXIT.ok
|
|
42
47
|
}
|
|
43
|
-
const
|
|
48
|
+
const setupUrl = setupCompanionUrl(authed.baseUrl)
|
|
44
49
|
let email: string | null = null
|
|
45
50
|
try {
|
|
46
51
|
email = (await authed.client.accessStatus()).email
|
|
47
52
|
} catch {
|
|
48
|
-
// Best-effort: the empty-state copy still
|
|
53
|
+
// Best-effort: the empty-state copy still names the destination.
|
|
49
54
|
}
|
|
50
55
|
deps.io.out(
|
|
51
|
-
`No devices yet.
|
|
56
|
+
`No devices yet. Set up a Companion App at ${setupUrl}, ${sameAccountSignInLine(email)}, and allow notifications.`,
|
|
52
57
|
)
|
|
53
58
|
return EXIT.ok
|
|
54
59
|
}
|
|
@@ -120,14 +125,6 @@ export async function capabilitiesCommand(
|
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
export function supportPageUrl(baseUrl: string): string {
|
|
124
|
-
const normalized = baseUrl.replace(/\/+$/, '')
|
|
125
|
-
if (normalized === 'https://api.notifai.sh' || normalized === 'https://notifai.fly.dev') {
|
|
126
|
-
return 'https://app.notifai.sh/support'
|
|
127
|
-
}
|
|
128
|
-
return `${normalized}/support`
|
|
129
|
-
}
|
|
130
|
-
|
|
131
128
|
/** Same-account line for the device hop; prefers the real email when known. */
|
|
132
129
|
export function sameAccountSignInLine(email: string | null | undefined): string {
|
|
133
130
|
return email
|
|
@@ -135,18 +132,28 @@ export function sameAccountSignInLine(email: string | null | undefined): string
|
|
|
135
132
|
: 'sign in with the same email as this account'
|
|
136
133
|
}
|
|
137
134
|
|
|
135
|
+
/**
|
|
136
|
+
* What is actually left to do on the phone.
|
|
137
|
+
*
|
|
138
|
+
* Three sub-states, three remedies. "Install the app" is useless advice to
|
|
139
|
+
* someone who installed it and then declined the permission prompt, and
|
|
140
|
+
* offering install steps to a registered device says this terminal did not
|
|
141
|
+
* read the state it just fetched.
|
|
142
|
+
*/
|
|
138
143
|
export function deviceInstallRemedy(options: {
|
|
139
144
|
baseUrl: string
|
|
140
145
|
email: string | null
|
|
141
146
|
devices: readonly RoutableDevice[]
|
|
147
|
+
platform?: CompanionPlatform
|
|
142
148
|
}): string {
|
|
143
|
-
const support = supportPageUrl(options.baseUrl)
|
|
144
149
|
const sameEmail = sameAccountSignInLine(options.email)
|
|
145
150
|
if (options.devices.length === 0) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
const destination = setupCompanionUrl(options.baseUrl, options.platform)
|
|
152
|
+
const named =
|
|
153
|
+
options.platform === undefined
|
|
154
|
+
? 'the Companion App'
|
|
155
|
+
: `Notifai on ${companionPlatformLabel(options.platform)}`
|
|
156
|
+
return `open the steps for ${named} at ${destination}, install it, ${sameEmail}, and allow notifications`
|
|
150
157
|
}
|
|
151
158
|
if (options.devices.some((d) => d.permission_status === 'denied')) {
|
|
152
159
|
return "allow notifications for Notifai in the device's Settings"
|