@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
|
@@ -22,6 +22,17 @@ import {
|
|
|
22
22
|
opencodePluginSource,
|
|
23
23
|
opencodePluginTarget,
|
|
24
24
|
} from './opencode-plugin.js'
|
|
25
|
+
import {
|
|
26
|
+
OPENCLAW_PLUGIN_MARKER,
|
|
27
|
+
isOurOpenclawPlugin,
|
|
28
|
+
mergeOpenclawNotifaiEntry,
|
|
29
|
+
openclawHasGlobalEvidence,
|
|
30
|
+
openclawPluginPath,
|
|
31
|
+
openclawPluginSource,
|
|
32
|
+
openclawPluginTarget,
|
|
33
|
+
parseOpenclawConfig,
|
|
34
|
+
removeOpenclawNotifaiEntry,
|
|
35
|
+
} from './openclaw-plugin.js'
|
|
25
36
|
import {
|
|
26
37
|
MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
|
|
27
38
|
WORKER_ACTIVATION_CONTEXT,
|
|
@@ -1068,6 +1079,179 @@ describe('the OpenCode adapter', () => {
|
|
|
1068
1079
|
})
|
|
1069
1080
|
})
|
|
1070
1081
|
|
|
1082
|
+
describe('the OpenClaw adapter', () => {
|
|
1083
|
+
const source = openclawPluginSource({
|
|
1084
|
+
adapterPath: ADAPTER,
|
|
1085
|
+
timeoutSeconds: 240,
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
async function loadPlugin() {
|
|
1089
|
+
return (await import(
|
|
1090
|
+
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
|
|
1091
|
+
)) as {
|
|
1092
|
+
default: {
|
|
1093
|
+
id: string
|
|
1094
|
+
register(api: {
|
|
1095
|
+
on(name: string, handler: (...args: never[]) => unknown): void
|
|
1096
|
+
}): void
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function handlersOf(mod: Awaited<ReturnType<typeof loadPlugin>>) {
|
|
1102
|
+
const handlers = new Map<string, (...args: never[]) => unknown>()
|
|
1103
|
+
mod.default.register({
|
|
1104
|
+
on(name, handler) {
|
|
1105
|
+
handlers.set(name, handler)
|
|
1106
|
+
},
|
|
1107
|
+
})
|
|
1108
|
+
return handlers
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
it('shells out to the same hook commands the other harnesses run', () => {
|
|
1112
|
+
expect(source).toContain('"hook", event')
|
|
1113
|
+
expect(source).toContain('"--harness", "openclaw"')
|
|
1114
|
+
expect(source).toContain(JSON.stringify(ADAPTER))
|
|
1115
|
+
expect(source).not.toContain('command:stop')
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
it('wires current plugin joints and leaves user abort alone', () => {
|
|
1119
|
+
expect(source).toContain('api.on("before_prompt_build"')
|
|
1120
|
+
expect(source).toContain('api.on("message_received"')
|
|
1121
|
+
expect(source).toContain('api.on("agent_end"')
|
|
1122
|
+
expect(source).toContain('api.on("session_end"')
|
|
1123
|
+
expect(source).toContain('api.on("resolve_exec_env"')
|
|
1124
|
+
expect(source).not.toContain('command:stop')
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
it('classifies root, subagent, ACP, and missing identity safely', async () => {
|
|
1128
|
+
const handlers = handlersOf(await loadPlugin())
|
|
1129
|
+
const activate = handlers.get('before_prompt_build') as (
|
|
1130
|
+
event: object,
|
|
1131
|
+
ctx: object,
|
|
1132
|
+
) => Promise<{ prependContext?: string } | undefined>
|
|
1133
|
+
|
|
1134
|
+
const root = await activate({}, { sessionKey: 'agent:main:main', workspaceDir: '/ws' })
|
|
1135
|
+
expect(root?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
|
|
1136
|
+
|
|
1137
|
+
const worker = await activate({}, { sessionKey: 'agent:main:subagent:abc', workspaceDir: '/ws' })
|
|
1138
|
+
expect(worker?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
|
|
1139
|
+
|
|
1140
|
+
const acp = await activate({}, { sessionKey: 'agent:main:acp-child', targetKind: 'acp' })
|
|
1141
|
+
expect(acp?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
|
|
1142
|
+
|
|
1143
|
+
const missing = await activate({}, {})
|
|
1144
|
+
expect(missing?.prependContext).toBe(WORKER_ACTIVATION_CONTEXT)
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
it('does not treat heartbeat or cron traffic as User presence', async () => {
|
|
1148
|
+
const handlers = handlersOf(await loadPlugin())
|
|
1149
|
+
const presence = handlers.get('message_received') as (
|
|
1150
|
+
event: object,
|
|
1151
|
+
ctx: object,
|
|
1152
|
+
) => Promise<void>
|
|
1153
|
+
await presence({ from: '' }, { sessionKey: 'agent:main:main' })
|
|
1154
|
+
await presence({ from: 'cron' }, { sessionKey: 'agent:main:main', trigger: 'cron' })
|
|
1155
|
+
await presence({ from: 'hb' }, { sessionKey: 'agent:main:main', trigger: 'heartbeat' })
|
|
1156
|
+
expect(source).toContain('trigger === "cron" || trigger === "heartbeat"')
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
it('publishes exact sessionKey markers into exec without PATH', async () => {
|
|
1160
|
+
const handlers = handlersOf(await loadPlugin())
|
|
1161
|
+
const resolve = handlers.get('resolve_exec_env') as (
|
|
1162
|
+
event: object,
|
|
1163
|
+
ctx: object,
|
|
1164
|
+
) => Record<string, string> | undefined
|
|
1165
|
+
expect(resolve({}, { sessionKey: 'agent:main:telegram:dm:1' })).toEqual({
|
|
1166
|
+
NOTIFAI_ACTIVE_HARNESS: 'openclaw',
|
|
1167
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:telegram:dm:1',
|
|
1168
|
+
})
|
|
1169
|
+
expect(resolve({}, {})).toBeUndefined()
|
|
1170
|
+
expect(source).not.toContain('PATH:')
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it('activates a parent session only once', async () => {
|
|
1174
|
+
const handlers = handlersOf(await loadPlugin())
|
|
1175
|
+
const activate = handlers.get('before_prompt_build') as (
|
|
1176
|
+
event: object,
|
|
1177
|
+
ctx: object,
|
|
1178
|
+
) => Promise<{ prependContext?: string } | undefined>
|
|
1179
|
+
const first = await activate({}, { sessionKey: 'agent:main:main' })
|
|
1180
|
+
const second = await activate({}, { sessionKey: 'agent:main:main' })
|
|
1181
|
+
expect(first?.prependContext).toBe(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)
|
|
1182
|
+
expect(second).toBeUndefined()
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
it('carries the ownership marker so a second checkout replaces it', () => {
|
|
1186
|
+
expect(source).toContain(OPENCLAW_PLUGIN_MARKER)
|
|
1187
|
+
expect(isOurOpenclawPlugin(source)).toBe(true)
|
|
1188
|
+
expect(isOurOpenclawPlugin('export default { id: "other" }')).toBe(false)
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
it('installs beside the OpenClaw config rather than into a hook document', () => {
|
|
1192
|
+
const local = openclawPluginPath(false, '/repo', {})
|
|
1193
|
+
expect(local).toBe(path.join('/repo', '.openclaw', 'extensions', 'notifai', 'index.js'))
|
|
1194
|
+
const global = openclawPluginPath(true, '/repo', { OPENCLAW_STATE_DIR: '/cfg/openclaw' })
|
|
1195
|
+
expect(global).toBe(path.join('/cfg/openclaw', 'extensions', 'notifai', 'index.js'))
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it('is a harness `hooks install` knows about', () => {
|
|
1199
|
+
expect(HOOK_INSTALLABLE_HARNESSES).toContain('openclaw')
|
|
1200
|
+
expect(settingsFile('openclaw', false, '/repo', {})).toContain('index.js')
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('reports the same stable adapter identity as command-hook harnesses', () => {
|
|
1204
|
+
expect(openclawPluginTarget(source)).toEqual({
|
|
1205
|
+
adapter: ADAPTER,
|
|
1206
|
+
current: true,
|
|
1207
|
+
timeoutSeconds: 240,
|
|
1208
|
+
})
|
|
1209
|
+
expect(openclawPluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
|
|
1210
|
+
adapter: ADAPTER,
|
|
1211
|
+
current: false,
|
|
1212
|
+
timeoutSeconds: 240,
|
|
1213
|
+
})
|
|
1214
|
+
expect(openclawPluginTarget('export default { id: "other" }')).toBeNull()
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
it('merges only the Notifai plugin entry into OpenClaw config', () => {
|
|
1218
|
+
const merged = mergeOpenclawNotifaiEntry(
|
|
1219
|
+
{ gateway: { port: 18789 }, plugins: { entries: { memory: { enabled: true } } } },
|
|
1220
|
+
'/repo/.openclaw/extensions/notifai',
|
|
1221
|
+
false,
|
|
1222
|
+
)
|
|
1223
|
+
expect(merged).toMatchObject({
|
|
1224
|
+
gateway: { port: 18789 },
|
|
1225
|
+
plugins: {
|
|
1226
|
+
entries: {
|
|
1227
|
+
memory: { enabled: true },
|
|
1228
|
+
notifai: { enabled: true, hooks: { allowConversationAccess: true } },
|
|
1229
|
+
},
|
|
1230
|
+
load: { paths: ['/repo/.openclaw/extensions/notifai'] },
|
|
1231
|
+
},
|
|
1232
|
+
})
|
|
1233
|
+
const stripped = removeOpenclawNotifaiEntry(merged, '/repo/.openclaw/extensions/notifai')
|
|
1234
|
+
expect(stripped).toEqual({
|
|
1235
|
+
gateway: { port: 18789 },
|
|
1236
|
+
plugins: { entries: { memory: { enabled: true } } },
|
|
1237
|
+
})
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
it('parses JSON with comments without requiring a JSON5 dependency', () => {
|
|
1241
|
+
expect(parseOpenclawConfig('{\n // port\n "gateway": { "port": 1 },\n}\n')).toEqual({
|
|
1242
|
+
gateway: { port: 1 },
|
|
1243
|
+
})
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
it('does not treat a skills-only ~/.openclaw tree as OpenClaw evidence', () => {
|
|
1247
|
+
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-skills-only-'))
|
|
1248
|
+
mkdirSync(path.join(home, '.openclaw', 'skills'), { recursive: true })
|
|
1249
|
+
expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(false)
|
|
1250
|
+
writeFileSync(path.join(home, '.openclaw', 'openclaw.json'), '{}\n')
|
|
1251
|
+
expect(openclawHasGlobalEvidence(existsSync, { HOME: home })).toBe(true)
|
|
1252
|
+
})
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1071
1255
|
describe('detectHarness', () => {
|
|
1072
1256
|
function project(...entries: string[]): string {
|
|
1073
1257
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-'))
|
|
@@ -1113,6 +1297,7 @@ describe('detectHarness', () => {
|
|
|
1113
1297
|
expect(detectHarness(project('.codex'))).toBe('codex')
|
|
1114
1298
|
expect(detectHarness(project('.cursor'))).toBe('cursor')
|
|
1115
1299
|
expect(detectHarness(project('.opencode'))).toBe('opencode')
|
|
1300
|
+
expect(detectHarness(project('.openclaw'))).toBe('openclaw')
|
|
1116
1301
|
})
|
|
1117
1302
|
|
|
1118
1303
|
it('returns null when the project itself names two harnesses', () => {
|
|
@@ -1233,6 +1418,9 @@ describe('Windows hook commands and discovery', () => {
|
|
|
1233
1418
|
expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
|
|
1234
1419
|
path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
|
|
1235
1420
|
)
|
|
1421
|
+
expect(openclawPluginPath(true, '/repo', env, 'win32')).toBe(
|
|
1422
|
+
path.join('C:\\Users\\Ada', '.openclaw', 'extensions', 'notifai', 'index.js'),
|
|
1423
|
+
)
|
|
1236
1424
|
})
|
|
1237
1425
|
|
|
1238
1426
|
it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
|
|
@@ -1254,6 +1442,26 @@ describe('Windows hook commands and discovery', () => {
|
|
|
1254
1442
|
nodePath: winNode,
|
|
1255
1443
|
})
|
|
1256
1444
|
})
|
|
1445
|
+
|
|
1446
|
+
it('makes OpenClaw spawn the adapter through Node on Windows with shell:false', () => {
|
|
1447
|
+
const source = openclawPluginSource({
|
|
1448
|
+
adapterPath: winAdapter,
|
|
1449
|
+
timeoutSeconds: 240,
|
|
1450
|
+
platform: 'win32',
|
|
1451
|
+
nodePath: winNode,
|
|
1452
|
+
})
|
|
1453
|
+
expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
|
|
1454
|
+
expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "openclaw"]')
|
|
1455
|
+
expect(source).toContain('shell: false')
|
|
1456
|
+
expect(source).toContain('windowsHide: true')
|
|
1457
|
+
expect(source).not.toContain('spawn(ADAPTER,')
|
|
1458
|
+
expect(openclawPluginTarget(source)).toEqual({
|
|
1459
|
+
adapter: winAdapter,
|
|
1460
|
+
current: true,
|
|
1461
|
+
timeoutSeconds: 240,
|
|
1462
|
+
nodePath: winNode,
|
|
1463
|
+
})
|
|
1464
|
+
})
|
|
1257
1465
|
})
|
|
1258
1466
|
|
|
1259
1467
|
/**
|
package/src/install-hooks.ts
CHANGED
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
type HookHostPlatform,
|
|
21
21
|
} from './hook-adapter.js'
|
|
22
22
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
23
|
+
import {
|
|
24
|
+
OPENCLAW_EVENTS,
|
|
25
|
+
openclawHasGlobalEvidence,
|
|
26
|
+
openclawPluginPath,
|
|
27
|
+
openclawPluginTarget,
|
|
28
|
+
} from './openclaw-plugin.js'
|
|
23
29
|
import { HOOK_INSTALLABLE_HARNESSES, type HookInstallableHarness } from './harnesses.js'
|
|
24
30
|
import { accountHome } from './platform.js'
|
|
25
31
|
import {
|
|
@@ -364,6 +370,8 @@ export function settingsFile(
|
|
|
364
370
|
// generated plugin module, so it owns a whole file instead.
|
|
365
371
|
case 'opencode':
|
|
366
372
|
return opencodePluginPath(global, cwd, env, platform)
|
|
373
|
+
case 'openclaw':
|
|
374
|
+
return openclawPluginPath(global, cwd, env, platform)
|
|
367
375
|
case 'cursor': {
|
|
368
376
|
const home = harnessAccountHome(env, platform)
|
|
369
377
|
return global
|
|
@@ -747,6 +755,7 @@ function localHarnessEvidence(cwd: string): HookInstallableHarness[] {
|
|
|
747
755
|
if (existsSync(path.join(cwd, '.codex'))) found.push('codex')
|
|
748
756
|
if (existsSync(path.join(cwd, '.cursor'))) found.push('cursor')
|
|
749
757
|
if (existsSync(path.join(cwd, '.opencode'))) found.push('opencode')
|
|
758
|
+
if (existsSync(path.join(cwd, '.openclaw'))) found.push('openclaw')
|
|
750
759
|
return found
|
|
751
760
|
}
|
|
752
761
|
|
|
@@ -761,6 +770,7 @@ function globalHarnessEvidence(
|
|
|
761
770
|
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
762
771
|
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
763
772
|
if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
|
|
773
|
+
if (openclawHasGlobalEvidence(existsSync, env, platform)) found.push('openclaw')
|
|
764
774
|
return found
|
|
765
775
|
}
|
|
766
776
|
|
|
@@ -1337,38 +1347,38 @@ export function findInstallations(
|
|
|
1337
1347
|
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
1338
1348
|
// is reported as one installation covering all three events rather than
|
|
1339
1349
|
// parsed for handlers.
|
|
1340
|
-
if (harness === 'opencode') {
|
|
1350
|
+
if (harness === 'opencode' || harness === 'openclaw') {
|
|
1341
1351
|
let source: string
|
|
1342
1352
|
try {
|
|
1343
1353
|
source = readOwnedRegularFile(file)
|
|
1344
1354
|
} catch {
|
|
1345
1355
|
continue
|
|
1346
1356
|
}
|
|
1347
|
-
const target =
|
|
1357
|
+
const target =
|
|
1358
|
+
harness === 'openclaw' ? openclawPluginTarget(source) : opencodePluginTarget(source)
|
|
1348
1359
|
if (target === null) continue
|
|
1360
|
+
const label = harness === 'openclaw' ? 'OpenClaw' : 'OpenCode'
|
|
1349
1361
|
const problems = [
|
|
1350
1362
|
...(!target.current
|
|
1351
|
-
? [
|
|
1363
|
+
? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
|
|
1352
1364
|
: []),
|
|
1353
1365
|
...(target.adapter !== hookAdapterPath(adapterHome)
|
|
1354
1366
|
? [
|
|
1355
|
-
|
|
1367
|
+
`${label} still names a mutable CLI or runtime path; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1356
1368
|
]
|
|
1357
1369
|
: []),
|
|
1358
1370
|
]
|
|
1371
|
+
const events = harness === 'openclaw' ? OPENCLAW_EVENTS : OPENCODE_EVENTS
|
|
1359
1372
|
found.push({
|
|
1360
1373
|
harness,
|
|
1361
1374
|
file,
|
|
1362
1375
|
global,
|
|
1363
1376
|
...(problems.length > 0 ? { problems } : {}),
|
|
1364
|
-
|
|
1365
|
-
// the plugin's own path: every check downstream asks which build a
|
|
1366
|
-
// handler invokes, and for a module that answer lives inside it.
|
|
1367
|
-
handlers: OPENCODE_EVENTS.map(([event, hookEvent]) => ({
|
|
1377
|
+
handlers: events.map(([event, hookEvent]) => ({
|
|
1368
1378
|
event,
|
|
1369
1379
|
groupIndex: 0,
|
|
1370
1380
|
handlerIndex: 0,
|
|
1371
|
-
command: hookCommand(target.adapter, hookEvent,
|
|
1381
|
+
command: hookCommand(target.adapter, hookEvent, harness, {
|
|
1372
1382
|
...commandOptions,
|
|
1373
1383
|
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1374
1384
|
}),
|
package/src/interactive.test.ts
CHANGED
|
@@ -226,6 +226,37 @@ describe('interactiveCommand', () => {
|
|
|
226
226
|
answers = []
|
|
227
227
|
})
|
|
228
228
|
|
|
229
|
+
it('enters init on first-run instead of a parallel Sign in path', async () => {
|
|
230
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-interactive-first-run-'))
|
|
231
|
+
const out: string[] = []
|
|
232
|
+
const deps = makeDeps(cwd)
|
|
233
|
+
deps.store.load = () => null
|
|
234
|
+
deps.io.out = (line: string) => {
|
|
235
|
+
out.push(line)
|
|
236
|
+
}
|
|
237
|
+
answers = [CANCEL]
|
|
238
|
+
|
|
239
|
+
expect(await interactiveCommand(deps, '0.0.0-test')).toBe(EXIT.ok)
|
|
240
|
+
expect(out.join('\n')).toContain('Next: This machine')
|
|
241
|
+
expect(out.join('\n')).toContain('notifai init')
|
|
242
|
+
expect(out.join('\n')).not.toContain('notifai login')
|
|
243
|
+
const menu = promptLog.find((entry) => entry.message === 'What would you like to do?')
|
|
244
|
+
expect(menu?.options?.[0]).toMatchObject({ value: 'setup', label: 'Finish setup' })
|
|
245
|
+
expect(optionLabels(menu!)).not.toContain('Sign in')
|
|
246
|
+
expect(optionLabels(menu!)).not.toContain('Add a device')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('offers Finish setup instead of a parallel Add a device path', async () => {
|
|
250
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-interactive-finish-setup-'))
|
|
251
|
+
answers = [CANCEL]
|
|
252
|
+
|
|
253
|
+
expect(await interactiveCommand(makeDeps(cwd), '0.0.0-test')).toBe(EXIT.ok)
|
|
254
|
+
const menu = promptLog.find((entry) => entry.message === 'What would you like to do?')
|
|
255
|
+
expect(menu?.options?.[0]).toMatchObject({ value: 'setup', label: 'Finish setup' })
|
|
256
|
+
expect(optionLabels(menu!)).not.toContain('Add a device')
|
|
257
|
+
expect(optionLabels(menu!)).not.toContain('Sign in')
|
|
258
|
+
})
|
|
259
|
+
|
|
229
260
|
it('quits from the root menu on Escape', async () => {
|
|
230
261
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-interactive-root-esc-'))
|
|
231
262
|
answers = [CANCEL]
|
package/src/interactive.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Three things shape the layout:
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* seeing the thing arrive on
|
|
18
|
+
* First-run is `init`, not a second sign-in/device split. An unsigned machine
|
|
19
|
+
* enters that journey immediately; a remaining blocker is offered as Finish
|
|
20
|
+
* setup. Once send-prerequisites are ready, the menu leads with the test
|
|
21
|
+
* notification, because seeing the thing arrive on a device is what makes the
|
|
22
|
+
* product real.
|
|
22
23
|
*
|
|
23
24
|
* Detail is earned, not given. A settings group shows fourteen one-line
|
|
24
25
|
* summaries, and the paragraph explaining a key appears once the reader has
|
|
@@ -180,6 +181,10 @@ export async function interactiveCommand(deps: CommandDeps, version: string): Pr
|
|
|
180
181
|
process.stdout.write(` ${tagline(version)}\n`)
|
|
181
182
|
|
|
182
183
|
let readiness = await assess(deps)
|
|
184
|
+
if (stateById(readiness, 'credential')?.status !== 'ready') {
|
|
185
|
+
await initCommand(deps, {})
|
|
186
|
+
readiness = await assess(deps)
|
|
187
|
+
}
|
|
183
188
|
let first = true
|
|
184
189
|
|
|
185
190
|
for (;;) {
|
|
@@ -362,15 +367,13 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
362
367
|
|
|
363
368
|
const options: { value: Screen; label: string; hint?: string }[] = []
|
|
364
369
|
|
|
365
|
-
if (
|
|
366
|
-
options.push({ value: 'account', label: 'Sign in', hint: 'approve this machine in your browser' })
|
|
367
|
-
} else if (!hasDevice) {
|
|
370
|
+
if (blocker !== null) {
|
|
368
371
|
options.push({
|
|
369
|
-
value: '
|
|
370
|
-
label: '
|
|
371
|
-
hint:
|
|
372
|
+
value: 'setup',
|
|
373
|
+
label: 'Finish setup',
|
|
374
|
+
hint: shorten(blocker.title.toLowerCase(), 40),
|
|
372
375
|
})
|
|
373
|
-
} else {
|
|
376
|
+
} else if (signedIn && hasDevice) {
|
|
374
377
|
options.push({
|
|
375
378
|
value: 'test',
|
|
376
379
|
label: 'Send a test notification',
|
|
@@ -378,10 +381,6 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
378
381
|
})
|
|
379
382
|
}
|
|
380
383
|
|
|
381
|
-
if (blocker !== null && signedIn) {
|
|
382
|
-
options.push({ value: 'setup', label: 'Finish setup', hint: shorten(blocker.title.toLowerCase(), 40) })
|
|
383
|
-
}
|
|
384
|
-
|
|
385
384
|
options.push(
|
|
386
385
|
{ value: 'settings', label: 'Settings', hint: 'sounds, timing, presence, project' },
|
|
387
386
|
{
|
|
@@ -393,11 +392,11 @@ function menuFor(readiness: Readiness): { value: Screen; label: string; hint?: s
|
|
|
393
392
|
if (signedIn && hasDevice) {
|
|
394
393
|
options.push({ value: 'devices', label: 'Devices', hint: 'what can receive right now' })
|
|
395
394
|
}
|
|
396
|
-
options.push(
|
|
397
|
-
|
|
398
|
-
{ value: 'account', label: 'Account', hint:
|
|
399
|
-
|
|
400
|
-
)
|
|
395
|
+
options.push({ value: 'doctor', label: 'Run diagnostics', hint: 'check every part of the setup' })
|
|
396
|
+
if (signedIn) {
|
|
397
|
+
options.push({ value: 'account', label: 'Account', hint: 'plan, machine, sign out' })
|
|
398
|
+
}
|
|
399
|
+
options.push({ value: 'quit', label: 'Quit' })
|
|
401
400
|
return options
|
|
402
401
|
}
|
|
403
402
|
|
|
@@ -822,12 +821,7 @@ async function accountScreen(deps: CommandDeps, readiness: Readiness): Promise<b
|
|
|
822
821
|
const signedIn = stateById(readiness, 'credential')?.status === 'ready'
|
|
823
822
|
|
|
824
823
|
if (!signedIn) {
|
|
825
|
-
|
|
826
|
-
message: 'Sign in now? This opens your browser to approve this machine.',
|
|
827
|
-
initialValue: true,
|
|
828
|
-
})
|
|
829
|
-
if (cancelled(go) || go !== true) return false
|
|
830
|
-
await loginCommand(deps, {})
|
|
824
|
+
await initCommand(deps, {})
|
|
831
825
|
return true
|
|
832
826
|
}
|
|
833
827
|
|
package/src/main-run.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the CLI needs to actually run a command.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `main.ts` so that the Node floor check there can decide
|
|
5
|
+
* whether this file is safe to parse at all: a static import would be hoisted
|
|
6
|
+
* above the check and fail first on the very runtimes the check exists to
|
|
7
|
+
* turn away.
|
|
8
|
+
*/
|
|
9
|
+
import { realIo, type CommandDeps } from './commands.js'
|
|
10
|
+
import { defaultCredentialStore } from './credentials.js'
|
|
11
|
+
import { nativeSkills } from './native-skills.js'
|
|
12
|
+
import { argvFlagNames, bootstrapLogger } from './logging.js'
|
|
13
|
+
import { buildProgram } from './program.js'
|
|
14
|
+
import { spawnQuestionSettlement } from './question-settlement-process.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The local record for this invocation.
|
|
18
|
+
*
|
|
19
|
+
* Built before the command tree so that the very first thing recorded is the
|
|
20
|
+
* command starting — including for a command that goes on to fail before it has
|
|
21
|
+
* resolved anything. It configures itself from disk and disables itself if it
|
|
22
|
+
* cannot write, so nothing below has to handle it failing.
|
|
23
|
+
*/
|
|
24
|
+
const logger = bootstrapLogger()
|
|
25
|
+
|
|
26
|
+
const deps: CommandDeps = {
|
|
27
|
+
io: realIo(),
|
|
28
|
+
store: defaultCredentialStore(),
|
|
29
|
+
env: process.env,
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
nativeSkills,
|
|
32
|
+
spawnQuestionSettlement,
|
|
33
|
+
logger,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const startedAt = Date.now()
|
|
37
|
+
process.on('exit', (code) => {
|
|
38
|
+
logger.info('cli.end', {
|
|
39
|
+
exit: code,
|
|
40
|
+
duration_ms: Date.now() - startedAt,
|
|
41
|
+
flags: argvFlagNames(process.argv.slice(2)),
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await buildProgram(deps).parseAsync(process.argv)
|
package/src/main.ts
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
* Built before the command tree so that the very first thing recorded is the
|
|
12
|
-
* command starting — including for a command that goes on to fail before it has
|
|
13
|
-
* resolved anything. It configures itself from disk and disables itself if it
|
|
14
|
-
* cannot write, so nothing below has to handle it failing.
|
|
15
|
-
*/
|
|
16
|
-
const logger = bootstrapLogger()
|
|
17
|
-
|
|
18
|
-
const deps: CommandDeps = {
|
|
19
|
-
io: realIo(),
|
|
20
|
-
store: defaultCredentialStore(),
|
|
21
|
-
env: process.env,
|
|
22
|
-
cwd: process.cwd(),
|
|
23
|
-
nativeSkills,
|
|
24
|
-
logger,
|
|
2
|
+
import { belowNodeFloor, nodeFloorMessage } from './node-floor.js'
|
|
3
|
+
|
|
4
|
+
// Before anything else is parsed. `node-floor.js` imports nothing and uses no
|
|
5
|
+
// syntax newer than the floor it checks, so it can report on the runtimes that
|
|
6
|
+
// would otherwise fail somewhere in a dependency with no mention of Notifai.
|
|
7
|
+
if (belowNodeFloor(process.version)) {
|
|
8
|
+
for (const line of nodeFloorMessage(process.version)) process.stderr.write(`${line}\n`)
|
|
9
|
+
process.exit(2)
|
|
25
10
|
}
|
|
26
11
|
|
|
27
|
-
|
|
28
|
-
process.on('exit', (code) => {
|
|
29
|
-
logger.info('cli.end', {
|
|
30
|
-
exit: code,
|
|
31
|
-
duration_ms: Date.now() - startedAt,
|
|
32
|
-
flags: argvFlagNames(process.argv.slice(2)),
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
await buildProgram(deps).parseAsync(process.argv)
|
|
12
|
+
await import('./main-run.js')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { NODE_MAJOR_FLOOR, belowNodeFloor, nodeFloorMessage, nodeMajor } from './node-floor.js'
|
|
3
|
+
|
|
4
|
+
describe('the Node runtime floor', () => {
|
|
5
|
+
it('turns away a runtime below the floor', () => {
|
|
6
|
+
expect(belowNodeFloor('v20.11.1')).toBe(true)
|
|
7
|
+
expect(belowNodeFloor('v18.0.0')).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('admits the floor and everything above it', () => {
|
|
11
|
+
expect(belowNodeFloor(`v${NODE_MAJOR_FLOOR}.0.0`)).toBe(false)
|
|
12
|
+
expect(belowNodeFloor('v24.3.0')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('does not strand a reader whose version it cannot read', () => {
|
|
16
|
+
expect(nodeMajor('bun-1.2')).toBeNull()
|
|
17
|
+
expect(belowNodeFloor('bun-1.2')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('says what is wrong and exactly one thing to do', () => {
|
|
21
|
+
const [problem, next] = nodeFloorMessage('v20.11.1')
|
|
22
|
+
expect(problem).toContain('Node 22 or newer')
|
|
23
|
+
expect(problem).toContain('v20.11.1')
|
|
24
|
+
expect(next).toMatch(/^next: /)
|
|
25
|
+
expect(next).toContain('nodejs.org')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node version this CLI needs, asserted before anything else loads.
|
|
3
|
+
*
|
|
4
|
+
* `engines` in package.json is advisory: npm prints a warning at install time
|
|
5
|
+
* and runs the binary anyway. So a reader on an older Node met a parser or
|
|
6
|
+
* module error naming a file they did not write, with no mention of Notifai,
|
|
7
|
+
* of Node, or of anything to do about it.
|
|
8
|
+
*
|
|
9
|
+
* This module is deliberately plain — no imports, no syntax newer than the
|
|
10
|
+
* floor it is checking — so it can run and report on the runtimes it exists to
|
|
11
|
+
* turn away. Everything else is loaded after it passes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const NODE_MAJOR_FLOOR = 22
|
|
15
|
+
|
|
16
|
+
/** The major from a `process.version` string, or null when unrecognisable. */
|
|
17
|
+
export function nodeMajor(version: string): number | null {
|
|
18
|
+
const match = /^v?(\d+)\./.exec(version)
|
|
19
|
+
if (match === null) return null
|
|
20
|
+
const major = Number(match[1])
|
|
21
|
+
return Number.isFinite(major) ? major : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function belowNodeFloor(version: string): boolean {
|
|
25
|
+
const major = nodeMajor(version)
|
|
26
|
+
// An unrecognisable version is not evidence of an old runtime, and refusing
|
|
27
|
+
// to run on one would strand a reader whose Node is fine.
|
|
28
|
+
return major !== null && major < NODE_MAJOR_FLOOR
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One sentence for what is wrong, one for what to do about it. */
|
|
32
|
+
export function nodeFloorMessage(version: string): string[] {
|
|
33
|
+
return [
|
|
34
|
+
`Notifai needs Node ${NODE_MAJOR_FLOOR} or newer; this is Node ${version}.`,
|
|
35
|
+
`next: install Node ${NODE_MAJOR_FLOOR}+ (https://nodejs.org), then run this again.`,
|
|
36
|
+
]
|
|
37
|
+
}
|