@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.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-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
package/src/harnesses.ts DELETED
@@ -1,164 +0,0 @@
1
- /** Every harness Notifai can name in Source Context when authoritatively known. */
2
- export const SOURCE_CONTEXT_HARNESSES = [
3
- 'claude-code',
4
- 'codex',
5
- 'cursor',
6
- 'opencode',
7
- 'openclaw',
8
- 'hermes',
9
- ] as const
10
-
11
- export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
12
-
13
- /**
14
- * Harnesses whose lifecycle Notifai can install and manage.
15
- *
16
- * Source Context is the broader vocabulary. Managed hook installation is a
17
- * narrower, independently proven cell: Hermes can appear in Source Context
18
- * without being hook-installable.
19
- */
20
- export const HOOK_INSTALLABLE_HARNESSES = [
21
- 'claude-code',
22
- 'codex',
23
- 'cursor',
24
- 'opencode',
25
- 'openclaw',
26
- ] as const
27
-
28
- export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
29
-
30
- export function isHookInstallableHarness(value: string): value is HookInstallableHarness {
31
- return (HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(value)
32
- }
33
-
34
- export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
35
- 'claude-code': 'Claude Code',
36
- codex: 'Codex',
37
- cursor: 'Cursor',
38
- opencode: 'OpenCode',
39
- openclaw: 'OpenClaw',
40
- hermes: 'Hermes',
41
- }
42
-
43
- export type DeliveryRoute =
44
- | 'hook-continuation'
45
- | 'inbox-socket'
46
- | 'owned-control-plane'
47
- | 'cold-resume'
48
- | 'hold-for-next-turn'
49
- | 'unsupported'
50
-
51
- export type StopContinuation = 'decision-block' | 'unsupported'
52
-
53
- export interface HarnessCapability {
54
- /** How an answer is admitted to another turn in the already-open session. */
55
- stopContinuation: StopContinuation
56
- /** Available answer-delivery routes, best current route first. */
57
- deliveryRoutes: readonly DeliveryRoute[]
58
- /** Concise, honest readiness text for doctor and installation guidance. */
59
- deliveryContract: string
60
- }
61
-
62
- const CLAUDE_CODE_CAPABILITY: HarnessCapability = {
63
- stopContinuation: 'decision-block',
64
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
65
- deliveryContract:
66
- 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
67
- }
68
-
69
- const CODEX_CAPABILITY: HarnessCapability = {
70
- stopContinuation: 'decision-block',
71
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
72
- deliveryContract:
73
- 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
74
- }
75
-
76
- const CURSOR_CAPABILITY: HarnessCapability = {
77
- stopContinuation: 'unsupported',
78
- deliveryRoutes: ['unsupported'],
79
- deliveryContract:
80
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
81
- }
82
-
83
- const OPENCODE_CAPABILITY: HarnessCapability = {
84
- stopContinuation: 'unsupported',
85
- deliveryRoutes: ['unsupported'],
86
- deliveryContract:
87
- 'no proven answer continuation after session.idle; use a blocking reply command',
88
- }
89
-
90
- const OPENCLAW_CAPABILITY: HarnessCapability = {
91
- stopContinuation: 'unsupported',
92
- deliveryRoutes: ['unsupported'],
93
- deliveryContract:
94
- 'no proven answer continuation after agent_end; use a blocking reply command',
95
- }
96
-
97
- export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
98
- stopContinuation: 'unsupported',
99
- deliveryRoutes: ['unsupported'],
100
- deliveryContract:
101
- 'Hermes asynchronous ask has no proven continuation owner on this integration surface. Use a blocking `notifai send --reply` question',
102
- }
103
-
104
- /**
105
- * The one Hermes integration instance proven by the pinned vertical probe.
106
- *
107
- * This is evidence-bearing capability data, not an adapter. Unsupported and
108
- * deferred surfaces intentionally have no empty implementation object here.
109
- */
110
- export const HERMES_CLASSIC_CLI_LOCAL_CAPABILITY = {
111
- instance: {
112
- harness: 'hermes',
113
- surface: 'classic-cli',
114
- terminalBackend: 'local',
115
- },
116
- setup: 'unsupported',
117
- activation: 'unsupported',
118
- sourceContext: 'hermes-session-id-and-invocation-cwd',
119
- continuation: 'unsupported',
120
- } as const
121
-
122
- export type HermesClassicCliLocalInstance =
123
- (typeof HERMES_CLASSIC_CLI_LOCAL_CAPABILITY)['instance']
124
-
125
- /**
126
- * Resolve only the Hermes instance whose complete marker envelope was proven.
127
- *
128
- * `HERMES_SESSION_ID` is also bridged by deferred gateway, TUI, API, ACP, and
129
- * remote-backend paths. Their first-party routing/source/backend markers keep
130
- * those cells distinct. Missing source/backend values are the pinned classic
131
- * CLI defaults; a gateway key is never part of that instance.
132
- */
133
- export function hermesClassicCliLocalInstance(
134
- env: NodeJS.ProcessEnv,
135
- ): HermesClassicCliLocalInstance | null {
136
- const sessionId = (env['HERMES_SESSION_ID'] ?? '').trim()
137
- const sessionKey = (env['HERMES_SESSION_KEY'] ?? '').trim()
138
- const platform = (env['HERMES_SESSION_PLATFORM'] ?? '').trim().toLowerCase()
139
- const source = (env['HERMES_SESSION_SOURCE'] ?? '').trim().toLowerCase()
140
- const terminalBackend = (env['TERMINAL_ENV'] ?? '').trim().toLowerCase()
141
- if (sessionId === '' || sessionKey !== '' || platform !== '') return null
142
- if (source !== '' && source !== 'cli') return null
143
- if (terminalBackend !== '' && terminalBackend !== 'local') return null
144
- return HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance
145
- }
146
-
147
- /**
148
- * Question-routing contract for hook-installable harnesses.
149
- *
150
- * Hermes is deliberately absent: a send-only surface must not occupy this
151
- * table with a fake managed-hook continuation route.
152
- */
153
- export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapability> = {
154
- 'claude-code': CLAUDE_CODE_CAPABILITY,
155
- codex: CODEX_CAPABILITY,
156
- cursor: CURSOR_CAPABILITY,
157
- opencode: OPENCODE_CAPABILITY,
158
- openclaw: OPENCLAW_CAPABILITY,
159
- }
160
-
161
- /** Question-routing capability for the active harness integration. */
162
- export function questionRoutingCapability(harness: HookInstallableHarness): HarnessCapability {
163
- return HARNESS_CAPABILITIES[harness]
164
- }
@@ -1,496 +0,0 @@
1
- import { spawnSync } from 'node:child_process'
2
- import {
3
- chmodSync,
4
- mkdirSync,
5
- mkdtempSync,
6
- readFileSync,
7
- rmSync,
8
- statSync,
9
- symlinkSync,
10
- writeFileSync,
11
- } from 'node:fs'
12
- import os from 'node:os'
13
- import path from 'node:path'
14
- import { fileURLToPath } from 'node:url'
15
- import { describe, expect, it } from 'vitest'
16
- import {
17
- hookAdapterPath,
18
- hookAdapterSource,
19
- inspectHookAdapter,
20
- installHookAdapter,
21
- isNpxAdapterTarget,
22
- } from './hook-adapter.js'
23
- import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
24
- import { enableProject, projectBinding } from './project-enablement.js'
25
-
26
- function isolated(): { root: string; homeDir: string } {
27
- const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
28
- return { root, homeDir: path.join(root, 'home') }
29
- }
30
-
31
- describe('stable hook adapter', () => {
32
- it('keeps the installed adapter silent until the Project is enabled', () => {
33
- const { root, homeDir } = isolated()
34
- const env = {
35
- ...process.env,
36
- HOME: homeDir,
37
- XDG_CONFIG_HOME: path.join(root, 'config'),
38
- XDG_STATE_HOME: path.join(root, 'state'),
39
- }
40
- const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
41
- const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
42
- const disabled = spawnSync(
43
- installed.path,
44
- ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
45
- {
46
- encoding: 'utf8',
47
- input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
48
- env,
49
- },
50
- )
51
-
52
- expect(disabled.status).toBe(0)
53
- expect(disabled.stderr).toBe('')
54
- expect(disabled.stdout).toBe('')
55
-
56
- const binding = projectBinding(root, env)
57
- if (binding === null) throw new Error('test Project binding unavailable')
58
- enableProject(binding)
59
- const run = spawnSync(
60
- installed.path,
61
- ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
62
- {
63
- encoding: 'utf8',
64
- input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
65
- env,
66
- },
67
- )
68
-
69
- expect(run.status).toBe(0)
70
- expect(run.stderr).toBe('')
71
- expect(JSON.parse(run.stdout)).toMatchObject({
72
- hookSpecificOutput: {
73
- hookEventName: 'SessionStart',
74
- additionalContext: expect.stringMatching(/Notifai.*enabled.*Project/i),
75
- },
76
- })
77
- })
78
-
79
- it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
80
- const { root, homeDir } = isolated()
81
- const odd = path.join(root, "odd ' $(touch should-not-run)")
82
- mkdirSync(odd, { recursive: true })
83
- const script = path.join(odd, 'target script.js')
84
- writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
85
-
86
- const installed = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
87
- const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
88
- encoding: 'utf8',
89
- })
90
-
91
- expect(run.status).toBe(0)
92
- expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
93
- expect(statSync(installed.path).mode & 0o777).toBe(0o700)
94
- expect(inspectHookAdapter(homeDir).problems).toEqual([])
95
- })
96
-
97
- it('retargets Node and CLI paths without changing any harness definition or Codex hash', () => {
98
- const { root, homeDir } = isolated()
99
- const first = path.join(root, 'first.js')
100
- const second = path.join(root, 'second.js')
101
- writeFileSync(first, '')
102
- writeFileSync(second, '')
103
- const adapter = installHookAdapter({ execPath: process.execPath, scriptPath: first }, homeDir)
104
- const before = buildHookConfig({
105
- adapterPath: adapter.path,
106
- harness: 'codex',
107
- })
108
- const beforeSource = readFileSync(adapter.path, 'utf8')
109
-
110
- installHookAdapter({ execPath: process.execPath, scriptPath: second }, homeDir)
111
- const after = buildHookConfig({
112
- adapterPath: hookAdapterPath(homeDir),
113
- harness: 'codex',
114
- })
115
- const afterSource = readFileSync(adapter.path, 'utf8')
116
- const stop = (config: typeof before): InstalledHandler => {
117
- const handler = config['Stop']![0]!.hooks[0]!
118
- return {
119
- event: 'Stop',
120
- groupIndex: 0,
121
- handlerIndex: 0,
122
- command: handler.command,
123
- timeout: handler.timeout,
124
- }
125
- }
126
-
127
- expect(after).toEqual(before)
128
- expect(afterSource).not.toBe(beforeSource)
129
- expect(codexHookIdentityHash(stop(after))).toBe(codexHookIdentityHash(stop(before)))
130
- const inspected = inspectHookAdapter(homeDir).target
131
- expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
132
- })
133
-
134
- it('executes a pinned npx spec through npm-cli.js without evaluating it', () => {
135
- const { root, homeDir } = isolated()
136
- const npmCli = path.join(root, 'npm-cli.js')
137
- writeFileSync(
138
- npmCli,
139
- 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n',
140
- )
141
- const installed = installHookAdapter(
142
- {
143
- kind: 'npx',
144
- execPath: process.execPath,
145
- npmCli,
146
- spec: '@raidiant/notifai@0.5.1',
147
- },
148
- homeDir,
149
- )
150
- const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
151
- encoding: 'utf8',
152
- })
153
-
154
- expect(run.status).toBe(0)
155
- expect(JSON.parse(run.stdout)).toEqual([
156
- 'exec',
157
- '--yes',
158
- '--package',
159
- '@raidiant/notifai@0.5.1',
160
- '--',
161
- 'notifai',
162
- 'hook',
163
- 'stop',
164
- '--owner',
165
- 'notifai',
166
- ])
167
- const inspected = inspectHookAdapter(homeDir)
168
- expect(inspected.problems).toEqual([])
169
- expect(inspected.target && isNpxAdapterTarget(inspected.target) ? inspected.target.spec : null).toBe(
170
- '@raidiant/notifai@0.5.1',
171
- )
172
- })
173
-
174
- it('carries the stable POSIX harness parent through direct and npx targets', () => {
175
- const { root, homeDir } = isolated()
176
- const direct = path.join(root, 'direct.js')
177
- const npmCli = path.join(root, 'npm-cli.js')
178
- const source = 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n'
179
- writeFileSync(direct, source)
180
- writeFileSync(npmCli, source)
181
-
182
- const directAdapter = installHookAdapter(
183
- { execPath: process.execPath, scriptPath: direct },
184
- homeDir,
185
- )
186
- const directRun = spawnSync(directAdapter.path, [], { encoding: 'utf8' })
187
- const npxAdapter = installHookAdapter(
188
- {
189
- kind: 'npx',
190
- execPath: process.execPath,
191
- npmCli,
192
- spec: '@raidiant/notifai@0.5.1',
193
- },
194
- homeDir,
195
- )
196
- const npxRun = spawnSync(npxAdapter.path, [], { encoding: 'utf8' })
197
-
198
- expect(directRun.status).toBe(0)
199
- expect(npxRun.status).toBe(0)
200
- expect(directRun.stdout).toBe(String(process.pid))
201
- expect(npxRun.stdout).toBe(String(process.pid))
202
- })
203
-
204
- it('uses a fixed home-relative pathname with no XDG input surface', () => {
205
- const { homeDir } = isolated()
206
- expect(hookAdapterPath(homeDir)).toBe(
207
- path.join(homeDir, '.notifai', 'bin', 'hook-adapter'),
208
- )
209
- })
210
-
211
- it('uses the OS account home even when HOME is overridden', () => {
212
- const original = process.env['HOME']
213
- process.env['HOME'] = '/tmp/attacker-selected-home'
214
- try {
215
- expect(hookAdapterPath()).toBe(
216
- path.join(os.userInfo().homedir, '.notifai', 'bin', 'hook-adapter'),
217
- )
218
- expect(hookAdapterPath()).not.toContain('attacker-selected-home')
219
- } finally {
220
- if (original === undefined) delete process.env['HOME']
221
- else process.env['HOME'] = original
222
- }
223
- })
224
-
225
- it('repairs permissive adapter permissions without changing its path', () => {
226
- const { root, homeDir } = isolated()
227
- const script = path.join(root, 'target.js')
228
- writeFileSync(script, '')
229
- const first = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
230
- chmodSync(first.path, 0o755)
231
-
232
- const repaired = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
233
-
234
- expect(repaired).toEqual({ path: first.path, changed: true })
235
- expect(statSync(first.path).mode & 0o777).toBe(0o700)
236
- })
237
-
238
- it('refuses a symlinked adapter instead of reading or replacing its target', () => {
239
- const { root, homeDir } = isolated()
240
- const target = path.join(root, 'foreign')
241
- const adapter = hookAdapterPath(homeDir)
242
- mkdirSync(path.dirname(adapter), { recursive: true })
243
- writeFileSync(target, 'leave me')
244
- symlinkSync(target, adapter)
245
-
246
- expect(() =>
247
- installHookAdapter({ execPath: process.execPath, scriptPath: target }, homeDir),
248
- ).toThrow(/not a regular file/)
249
- expect(readFileSync(target, 'utf8')).toBe('leave me')
250
- })
251
-
252
- it('refuses a symlinked Notifai-owned parent directory', () => {
253
- const { root, homeDir } = isolated()
254
- const foreign = path.join(root, 'foreign-dir')
255
- const script = path.join(root, 'target.js')
256
- mkdirSync(homeDir, { recursive: true })
257
- mkdirSync(foreign)
258
- writeFileSync(script, '')
259
- symlinkSync(foreign, path.join(homeDir, '.notifai'))
260
-
261
- expect(() =>
262
- installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir),
263
- ).toThrow(/not a regular directory/)
264
- expect(readFileSync(script, 'utf8')).toBe('')
265
- })
266
-
267
- it('diagnoses a stale registered runtime and CLI without executing either', () => {
268
- const { root, homeDir } = isolated()
269
- const missingRuntime = path.join(root, 'removed-node')
270
- const missingCli = path.join(root, 'removed-cli.js')
271
- writeFileSync(missingRuntime, '#!/bin/sh\n')
272
- chmodSync(missingRuntime, 0o700)
273
- writeFileSync(missingCli, '')
274
- installHookAdapter({ execPath: missingRuntime, scriptPath: missingCli }, homeDir)
275
- rmSync(missingRuntime)
276
- rmSync(missingCli)
277
-
278
- expect(inspectHookAdapter(homeDir).problems).toEqual([
279
- `registered runtime ${missingRuntime} is missing`,
280
- `registered CLI ${missingCli} is missing`,
281
- ])
282
- })
283
-
284
- it('keeps working when a node upgrade moved the pinned runtime but left the CLI', () => {
285
- const { root, homeDir } = isolated()
286
- const bin = path.join(root, 'fallback-bin')
287
- mkdirSync(bin, { recursive: true })
288
- const pathNode = path.join(bin, 'node')
289
- writeFileSync(pathNode, '#!/bin/sh\nprintf "ran %s" "$1"\n')
290
- chmodSync(pathNode, 0o700)
291
- const pinnedNode = path.join(root, 'pinned-node')
292
- const cli = path.join(root, 'cli.js')
293
- writeFileSync(pinnedNode, '#!/bin/sh\n')
294
- chmodSync(pinnedNode, 0o700)
295
- writeFileSync(cli, '')
296
- const installed = installHookAdapter({ execPath: pinnedNode, scriptPath: cli }, homeDir)
297
- // What `brew upgrade node` and `nvm uninstall` both do: the interpreter
298
- // moves, the installed CLI does not.
299
- rmSync(pinnedNode)
300
-
301
- const run = spawnSync(installed.path, ['hook', 'stop'], {
302
- encoding: 'utf8',
303
- env: { PATH: bin },
304
- })
305
-
306
- expect(run.status).toBe(0)
307
- expect(run.stdout).toBe(`ran ${cli}`)
308
- })
309
-
310
- it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
311
- const { root, homeDir } = isolated()
312
- const bin = path.join(root, 'new-bin')
313
- const replacement = path.join(bin, 'notifai')
314
- mkdirSync(bin, { recursive: true })
315
- writeFileSync(replacement, '#!/bin/sh\nprintf "%s" "$*"\n')
316
- chmodSync(replacement, 0o700)
317
- const oldNode = path.join(root, 'old-node')
318
- const oldCli = path.join(root, 'old-cli.js')
319
- writeFileSync(oldNode, '#!/bin/sh\n')
320
- chmodSync(oldNode, 0o700)
321
- writeFileSync(oldCli, '')
322
- const installed = installHookAdapter({ execPath: oldNode, scriptPath: oldCli }, homeDir)
323
- rmSync(oldNode)
324
- rmSync(oldCli)
325
-
326
- const run = spawnSync(installed.path, ['hook', 'stop'], {
327
- encoding: 'utf8',
328
- env: { PATH: bin },
329
- })
330
-
331
- expect(run.status).toBe(127)
332
- expect(run.stdout).toBe('')
333
- expect(run.stderr).toMatch(/target is stale/)
334
- })
335
- })
336
-
337
- describe('Windows hook adapter', () => {
338
- it('keeps POSIX adapter source bytes identical to the trusted /bin/sh form', () => {
339
- const target = {
340
- execPath: '/usr/local/bin/node',
341
- scriptPath: '/opt/notifai/dist/main.js',
342
- }
343
- expect(hookAdapterSource(target, 'posix')).toBe(`#!/bin/sh
344
- # notifai managed hook adapter
345
- # adapter-version: 2
346
- # target-exec-json: "/usr/local/bin/node"
347
- # target-script-json: "/opt/notifai/dist/main.js"
348
- set -eu
349
-
350
- NOTIFAI_HOOK_SOURCE_PID=$PPID
351
- export NOTIFAI_HOOK_SOURCE_PID
352
-
353
- registered_exec='/usr/local/bin/node'
354
- registered_script='/opt/notifai/dist/main.js'
355
-
356
- if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
357
- exec "$registered_exec" "$registered_script" "$@"
358
- fi
359
-
360
- if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
361
- exec node "$registered_script" "$@"
362
- fi
363
-
364
- printf '%s
365
- ' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
366
- exit 127
367
- `)
368
- })
369
-
370
- it('generates a JavaScript adapter instead of a shebang script', () => {
371
- const { root, homeDir } = isolated()
372
- const script = path.join(root, 'target.js')
373
- writeFileSync(script, '')
374
- const installed = installHookAdapter(
375
- { execPath: process.execPath, scriptPath: script },
376
- homeDir,
377
- 'win32',
378
- )
379
- const source = readFileSync(installed.path, 'utf8')
380
-
381
- expect(source.startsWith('// notifai managed hook adapter\n')).toBe(true)
382
- expect(source).not.toContain('#!/bin/sh')
383
- expect(source).toContain('require("node:child_process")')
384
- expect(source).toContain(JSON.stringify(process.execPath))
385
- expect(source).toContain(JSON.stringify(script))
386
- })
387
-
388
- it('forwards argv through Node without depending on a shebang or executable bit', () => {
389
- const { root, homeDir } = isolated()
390
- const script = path.join(root, 'target script.js')
391
- writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
392
- const runtime = path.join(root, 'node-runtime')
393
- writeFileSync(runtime, 'dummy-node')
394
- chmodSync(runtime, 0o644)
395
-
396
- const installed = installHookAdapter(
397
- { execPath: process.execPath, scriptPath: script },
398
- homeDir,
399
- 'win32',
400
- )
401
- const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop', '--owner', 'notifai'], {
402
- encoding: 'utf8',
403
- })
404
-
405
- expect(run.status).toBe(0)
406
- expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
407
- expect(() =>
408
- installHookAdapter({ execPath: runtime, scriptPath: script }, homeDir, 'win32'),
409
- ).not.toThrow()
410
- })
411
-
412
- it('carries the stable Windows harness parent into each CLI process', () => {
413
- const { root, homeDir } = isolated()
414
- const script = path.join(root, 'source-pid.js')
415
- writeFileSync(
416
- script,
417
- 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n',
418
- )
419
- const installed = installHookAdapter(
420
- { execPath: process.execPath, scriptPath: script },
421
- homeDir,
422
- 'win32',
423
- )
424
-
425
- const run = spawnSync(process.execPath, [installed.path], { encoding: 'utf8' })
426
-
427
- expect(run.status).toBe(0)
428
- expect(run.stdout).toBe(String(process.pid))
429
- })
430
-
431
- it('does not treat 0666 as a protection or health failure', () => {
432
- const { root, homeDir } = isolated()
433
- const script = path.join(root, 'target.js')
434
- writeFileSync(script, '')
435
- const installed = installHookAdapter(
436
- { execPath: process.execPath, scriptPath: script },
437
- homeDir,
438
- 'win32',
439
- )
440
- chmodSync(installed.path, 0o666)
441
-
442
- expect(inspectHookAdapter(homeDir, 'win32').problems).toEqual([])
443
- expect(
444
- installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32'),
445
- ).toEqual({ path: installed.path, changed: false })
446
- })
447
-
448
- it('fails closed when the registered Windows target vanishes', () => {
449
- const { root, homeDir } = isolated()
450
- const oldNode = path.join(root, 'old-node')
451
- const oldCli = path.join(root, 'old-cli.js')
452
- writeFileSync(oldNode, '#!/bin/sh\n')
453
- writeFileSync(oldCli, '')
454
- const installed = installHookAdapter(
455
- { execPath: oldNode, scriptPath: oldCli },
456
- homeDir,
457
- 'win32',
458
- )
459
- rmSync(oldNode)
460
- rmSync(oldCli)
461
-
462
- const inspected = inspectHookAdapter(homeDir, 'win32')
463
- expect(inspected.problems).toEqual([
464
- `registered runtime ${oldNode} is missing`,
465
- `registered CLI ${oldCli} is missing`,
466
- ])
467
- const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop'], {
468
- encoding: 'utf8',
469
- })
470
- expect(run.status).toBe(127)
471
- expect(run.stdout).toBe('')
472
- expect(run.stderr).toMatch(/target is stale/)
473
- })
474
-
475
- it('retargets the CLI without changing the adapter pathname', () => {
476
- const { root, homeDir } = isolated()
477
- const first = path.join(root, 'first.js')
478
- const second = path.join(root, 'second.js')
479
- writeFileSync(first, '')
480
- writeFileSync(second, '')
481
- const firstInstall = installHookAdapter(
482
- { execPath: process.execPath, scriptPath: first },
483
- homeDir,
484
- 'win32',
485
- )
486
- const secondInstall = installHookAdapter(
487
- { execPath: process.execPath, scriptPath: second },
488
- homeDir,
489
- 'win32',
490
- )
491
- const inspected = inspectHookAdapter(homeDir, 'win32').target
492
-
493
- expect(secondInstall.path).toBe(firstInstall.path)
494
- expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
495
- })
496
- })