@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +32 -14
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +10 -1
  19. package/dist/commands-core.js +13 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +158 -71
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-harness-context.js +8 -0
  27. package/dist/commands-harness-context.js.map +1 -1
  28. package/dist/commands-hooks.js +169 -15
  29. package/dist/commands-hooks.js.map +1 -1
  30. package/dist/commands-init.d.ts +16 -0
  31. package/dist/commands-init.js +254 -91
  32. package/dist/commands-init.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-update.d.ts +10 -0
  37. package/dist/commands-update.js +141 -0
  38. package/dist/commands-update.js.map +1 -0
  39. package/dist/commands.d.ts +2 -1
  40. package/dist/commands.js +2 -1
  41. package/dist/commands.js.map +1 -1
  42. package/dist/harnesses.d.ts +2 -2
  43. package/dist/harnesses.js +22 -2
  44. package/dist/harnesses.js.map +1 -1
  45. package/dist/hook-adapter.d.ts +2 -0
  46. package/dist/hook-adapter.js +12 -1
  47. package/dist/hook-adapter.js.map +1 -1
  48. package/dist/hooks.d.ts +43 -2
  49. package/dist/hooks.js +163 -21
  50. package/dist/hooks.js.map +1 -1
  51. package/dist/install-hooks.js +15 -9
  52. package/dist/install-hooks.js.map +1 -1
  53. package/dist/interactive.js +20 -23
  54. package/dist/interactive.js.map +1 -1
  55. package/dist/main-run.d.ts +1 -0
  56. package/dist/main-run.js +42 -0
  57. package/dist/main-run.js.map +1 -0
  58. package/dist/main.js +10 -31
  59. package/dist/main.js.map +1 -1
  60. package/dist/node-floor.d.ts +18 -0
  61. package/dist/node-floor.js +35 -0
  62. package/dist/node-floor.js.map +1 -0
  63. package/dist/openclaw-plugin.d.ts +49 -0
  64. package/dist/openclaw-plugin.js +350 -0
  65. package/dist/openclaw-plugin.js.map +1 -0
  66. package/dist/program.d.ts +2 -1
  67. package/dist/program.js +31 -13
  68. package/dist/program.js.map +1 -1
  69. package/dist/question-settlement-process.d.ts +15 -0
  70. package/dist/question-settlement-process.js +35 -0
  71. package/dist/question-settlement-process.js.map +1 -0
  72. package/dist/readiness.d.ts +25 -1
  73. package/dist/readiness.js +32 -2
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/session-activation.js +1 -1
  76. package/dist/session-activation.js.map +1 -1
  77. package/dist/setup-destinations.d.ts +23 -0
  78. package/dist/setup-destinations.js +44 -0
  79. package/dist/setup-destinations.js.map +1 -0
  80. package/dist/ui/help.d.ts +4 -5
  81. package/dist/ui/help.js +5 -6
  82. package/dist/ui/help.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/cli-bin.test.ts +145 -7
  85. package/src/cli-bin.ts +176 -10
  86. package/src/cli-update-contract.ts +9 -0
  87. package/src/cli-update.test.ts +125 -0
  88. package/src/client.ts +7 -0
  89. package/src/codex-wake.ts +5 -1
  90. package/src/commands-auth.ts +47 -13
  91. package/src/commands-core.ts +21 -4
  92. package/src/commands-devices.ts +23 -16
  93. package/src/commands-doctor.ts +197 -75
  94. package/src/commands-harness-context.ts +8 -0
  95. package/src/commands-hooks.ts +210 -18
  96. package/src/commands-init.ts +275 -92
  97. package/src/commands-send.ts +145 -39
  98. package/src/commands-update.ts +180 -0
  99. package/src/commands.test.ts +701 -53
  100. package/src/commands.ts +2 -0
  101. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
  102. package/src/harness-integration.test.ts +82 -0
  103. package/src/harnesses.test.ts +9 -1
  104. package/src/harnesses.ts +24 -2
  105. package/src/hook-adapter.ts +14 -0
  106. package/src/hooks.test.ts +275 -0
  107. package/src/hooks.ts +270 -18
  108. package/src/install-hooks.test.ts +208 -0
  109. package/src/install-hooks.ts +19 -9
  110. package/src/interactive.test.ts +31 -0
  111. package/src/interactive.ts +20 -26
  112. package/src/main-run.ts +45 -0
  113. package/src/main.ts +9 -33
  114. package/src/node-floor.test.ts +27 -0
  115. package/src/node-floor.ts +37 -0
  116. package/src/openclaw-plugin.ts +409 -0
  117. package/src/program.test.ts +35 -0
  118. package/src/program.ts +37 -12
  119. package/src/question-settlement-process.ts +47 -0
  120. package/src/readiness.ts +35 -2
  121. package/src/session-activation.ts +1 -1
  122. package/src/setup-destinations.test.ts +41 -0
  123. package/src/setup-destinations.ts +50 -0
  124. package/src/skill.test.ts +13 -2
  125. package/src/ui/help.test.ts +16 -0
  126. package/src/ui/help.ts +6 -7
  127. 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
  /**
@@ -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 = opencodePluginTarget(source)
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
- ? ['obsolete OpenCode event wiring; rerun `notifai hooks install --harness opencode`']
1363
+ ? [`obsolete ${label} event wiring; rerun \`notifai hooks install --harness ${harness}\``]
1352
1364
  : []),
1353
1365
  ...(target.adapter !== hookAdapterPath(adapterHome)
1354
1366
  ? [
1355
- 'OpenCode still names a mutable CLI or runtime path; rerun `notifai hooks install --harness opencode`',
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
- // Reported as the command line the plugin will actually run, not as
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, 'opencode', {
1381
+ command: hookCommand(target.adapter, hookEvent, harness, {
1372
1382
  ...commandOptions,
1373
1383
  ...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
1374
1384
  }),
@@ -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]
@@ -15,10 +15,11 @@
15
15
  *
16
16
  * Three things shape the layout:
17
17
  *
18
- * The menu leads with whatever is actually in the way. Someone who is not
19
- * signed in is offered sign-in first; someone with no device that can receive
20
- * is offered a device; everyone else is offered the test notification, because
21
- * seeing the thing arrive on your device is what makes the product real.
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 (!signedIn) {
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: 'devices',
370
- label: 'Add a device',
371
- hint: 'install a supported Companion App',
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
- { value: 'doctor', label: 'Run diagnostics', hint: 'check every part of the setup' },
398
- { value: 'account', label: 'Account', hint: signedIn ? 'plan, machine, sign out' : 'sign in' },
399
- { value: 'quit', label: 'Quit' },
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
- const go = await clack.confirm({
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
 
@@ -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 { realIo, type CommandDeps } from './commands.js'
3
- import { defaultCredentialStore } from './credentials.js'
4
- import { nativeSkills } from './native-skills.js'
5
- import { argvFlagNames, bootstrapLogger } from './logging.js'
6
- import { buildProgram } from './program.js'
7
-
8
- /**
9
- * The local record for this invocation.
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
- const startedAt = Date.now()
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
+ }