@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. 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
- })