@raidiant/notifai 1.0.0 → 1.1.0

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 (49) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +43 -38
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/config.js +5 -9
  8. package/dist/config.js.map +1 -1
  9. package/dist/credentials.d.ts +54 -3
  10. package/dist/credentials.js +237 -18
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/file-lock.js +15 -6
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/hook-adapter.d.ts +6 -2
  15. package/dist/hook-adapter.js +110 -29
  16. package/dist/hook-adapter.js.map +1 -1
  17. package/dist/hooks.js +2 -1
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +54 -22
  20. package/dist/install-hooks.js +125 -56
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/native-skills.js +5 -10
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/opencode-plugin.d.ts +7 -2
  25. package/dist/opencode-plugin.js +142 -4
  26. package/dist/opencode-plugin.js.map +1 -1
  27. package/dist/platform.d.ts +73 -0
  28. package/dist/platform.js +143 -0
  29. package/dist/platform.js.map +1 -0
  30. package/dist/ui/config-view.js +5 -1
  31. package/dist/ui/config-view.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands.test.ts +144 -34
  34. package/src/commands.ts +65 -35
  35. package/src/config.ts +11 -9
  36. package/src/credentials.test.ts +291 -0
  37. package/src/credentials.ts +274 -17
  38. package/src/file-lock.ts +34 -9
  39. package/src/hook-adapter.test.ts +136 -0
  40. package/src/hook-adapter.ts +120 -28
  41. package/src/hooks.test.ts +27 -1
  42. package/src/hooks.ts +2 -1
  43. package/src/install-hooks.test.ts +188 -27
  44. package/src/install-hooks.ts +177 -52
  45. package/src/native-skills.ts +5 -10
  46. package/src/opencode-plugin.ts +148 -3
  47. package/src/platform.test.ts +152 -0
  48. package/src/platform.ts +173 -0
  49. package/src/ui/config-view.ts +5 -5
@@ -1,6 +1,7 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1
+ import { execFileSync, spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
3
3
  import path from 'node:path'
4
+ import { atomicWriteFileSync } from './atomic-file.js'
4
5
  import { globalConfigDir } from './config.js'
5
6
 
6
7
  export interface MachineCredential {
@@ -18,7 +19,94 @@ export interface CredentialStore {
18
19
  describe(): string
19
20
  }
20
21
 
22
+ export interface RunCommandSpec {
23
+ command: string
24
+ args: readonly string[]
25
+ input: Buffer
26
+ timeoutMs?: number
27
+ }
28
+
29
+ export interface RunCommandResult {
30
+ status: number
31
+ stdout: Buffer
32
+ }
33
+
34
+ /** Injected process seam. Production uses `defaultProcessRunner`. */
35
+ export interface ProcessRunner {
36
+ run(spec: RunCommandSpec): RunCommandResult
37
+ }
38
+
39
+ export interface CredentialStoreOptions {
40
+ env?: NodeJS.ProcessEnv
41
+ platform?: NodeJS.Platform
42
+ run?: ProcessRunner
43
+ keychainAvailable?: boolean
44
+ }
45
+
21
46
  const SERVICE = 'io.notifai.cli'
47
+ const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
48
+ const DPAPI_ENTROPY = 'io.notifai.cli'
49
+ const DPAPI_TIMEOUT_MS = 15_000
50
+ const DPAPI_FILE = 'credentials.dpapi'
51
+
52
+ const PROTECT_SCRIPT = [
53
+ "$ErrorActionPreference = 'Stop'",
54
+ 'Add-Type -AssemblyName System.Security',
55
+ '$in = New-Object System.IO.MemoryStream',
56
+ '[Console]::OpenStandardInput().CopyTo($in)',
57
+ `$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
58
+ '$protected = [System.Security.Cryptography.ProtectedData]::Protect($in.ToArray(), $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
59
+ '$bytes = [System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($protected))',
60
+ '[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)',
61
+ ].join('; ')
62
+
63
+ const UNPROTECT_SCRIPT = [
64
+ "$ErrorActionPreference = 'Stop'",
65
+ 'Add-Type -AssemblyName System.Security',
66
+ '$b64 = [System.Text.Encoding]::ASCII.GetString((& { $in = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($in); $in.ToArray() })).Trim()',
67
+ '$protected = [Convert]::FromBase64String($b64)',
68
+ `$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
69
+ '$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($protected, $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
70
+ '[Console]::OpenStandardOutput().Write($plain, 0, $plain.Length)',
71
+ ].join('; ')
72
+
73
+ const PROTECT_ENCODED = encodePowerShell(PROTECT_SCRIPT)
74
+ const UNPROTECT_ENCODED = encodePowerShell(UNPROTECT_SCRIPT)
75
+
76
+ function encodePowerShell(script: string): string {
77
+ return Buffer.from(script, 'utf16le').toString('base64')
78
+ }
79
+
80
+ function powerShellInvocation(encoded: string, env: NodeJS.ProcessEnv): { command: string; args: string[] } {
81
+ const root = env['SystemRoot'] || env['SYSTEMROOT'] || 'C:\\Windows'
82
+ return {
83
+ command: path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
84
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
85
+ }
86
+ }
87
+
88
+ /** Which DPAPI operation a process invocation asked for. Args never carry secrets. */
89
+ export function windowsDpapiOperation(args: readonly string[]): 'protect' | 'unprotect' | null {
90
+ const index = args.indexOf('-EncodedCommand')
91
+ const encoded = index >= 0 ? args[index + 1] : undefined
92
+ if (encoded === PROTECT_ENCODED) return 'protect'
93
+ if (encoded === UNPROTECT_ENCODED) return 'unprotect'
94
+ return null
95
+ }
96
+
97
+ export const defaultProcessRunner: ProcessRunner = {
98
+ run(spec) {
99
+ const result = spawnSync(spec.command, [...spec.args], {
100
+ input: spec.input,
101
+ timeout: spec.timeoutMs ?? DPAPI_TIMEOUT_MS,
102
+ maxBuffer: 1024 * 1024,
103
+ windowsHide: true,
104
+ })
105
+ const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0)
106
+ if (result.error) return { status: 1, stdout: Buffer.alloc(0) }
107
+ return { status: result.status ?? 1, stdout }
108
+ },
109
+ }
22
110
 
23
111
  /** macOS Keychain via the `security` CLI — no native addons (proposal concern). */
24
112
  export class KeychainStore implements CredentialStore {
@@ -31,7 +119,7 @@ export class KeychainStore implements CredentialStore {
31
119
  )
32
120
  .toString()
33
121
  .trim()
34
- return JSON.parse(raw) as MachineCredential
122
+ return parseCredentialJson(raw)
35
123
  } catch {
36
124
  return null
37
125
  }
@@ -69,29 +157,43 @@ export class KeychainStore implements CredentialStore {
69
157
  }
70
158
  }
71
159
 
72
- /** 0600 file fallback for hosts without a keychain. */
160
+ /**
161
+ * Explicit plaintext file store.
162
+ *
163
+ * Default on Linux, where mode 0600 is a real ACL. Also the
164
+ * `NOTIFAI_CREDENTIALS=file` development and test escape hatch on every
165
+ * platform. POSIX mode bits are not an NTFS ACL, so this is not a protected
166
+ * Windows store.
167
+ */
73
168
  export class FileStore implements CredentialStore {
74
- constructor(private readonly env: NodeJS.ProcessEnv = process.env) {}
169
+ private readonly env: NodeJS.ProcessEnv
170
+ private readonly platform: NodeJS.Platform
171
+
172
+ constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
173
+ this.env = env
174
+ this.platform = options.platform ?? process.platform
175
+ }
75
176
 
76
177
  private filePath(): string {
77
- return path.join(globalConfigDir(this.env), 'credentials.json')
178
+ return path.join(globalConfigDir(this.env, this.platform), 'credentials.json')
78
179
  }
79
180
 
80
181
  load(): MachineCredential | null {
81
182
  const file = this.filePath()
82
183
  if (!existsSync(file)) return null
83
184
  try {
84
- return JSON.parse(readFileSync(file, 'utf8')) as MachineCredential
185
+ return parseCredentialJson(readFileSync(file, 'utf8'))
85
186
  } catch {
86
187
  return null
87
188
  }
88
189
  }
89
190
 
90
191
  save(credential: MachineCredential): void {
91
- const file = this.filePath()
92
- mkdirSync(path.dirname(file), { recursive: true })
93
- writeFileSync(file, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 })
94
- chmodSync(file, 0o600)
192
+ atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
193
+ mode: 0o600,
194
+ preserveMode: false,
195
+ requireCurrentUserOwner: true,
196
+ })
95
197
  }
96
198
 
97
199
  clear(): void {
@@ -99,12 +201,157 @@ export class FileStore implements CredentialStore {
99
201
  }
100
202
 
101
203
  describe(): string {
102
- return this.filePath()
204
+ const file = this.filePath()
205
+ if (this.platform === 'win32') {
206
+ return `plaintext file ${file} (NOTIFAI_CREDENTIALS=file; POSIX mode bits are not protection on NTFS)`
207
+ }
208
+ return `plaintext file ${file} (mode 0600)`
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Windows default: DPAPI CurrentUser via first-party PowerShell.
214
+ *
215
+ * The protected blob lives under LOCALAPPDATA, never roaming APPDATA.
216
+ * Plaintext crosses the process boundary only on stdin/stdout — never as a
217
+ * command-line argument.
218
+ */
219
+ export class WindowsDpapiStore implements CredentialStore {
220
+ private readonly env: NodeJS.ProcessEnv
221
+ private readonly run: ProcessRunner
222
+
223
+ constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
224
+ this.env = env
225
+ this.run = options.run ?? defaultProcessRunner
103
226
  }
227
+
228
+ private filePath(): string | null {
229
+ const local = this.env['LOCALAPPDATA']
230
+ if (typeof local !== 'string' || local.trim() === '') return null
231
+ return path.join(local, 'notifai', DPAPI_FILE)
232
+ }
233
+
234
+ load(): MachineCredential | null {
235
+ const file = this.filePath()
236
+ if (file === null || !existsSync(file)) return null
237
+ let text: string
238
+ try {
239
+ text = readFileSync(file, 'utf8')
240
+ } catch {
241
+ return null
242
+ }
243
+ const blob = parseDpapiEnvelope(text)
244
+ if (blob === null) return null
245
+ const plain = this.unprotect(blob)
246
+ return plain === null ? null : parseCredentialJson(plain)
247
+ }
248
+
249
+ save(credential: MachineCredential): void {
250
+ const file = this.filePath()
251
+ if (file === null) {
252
+ throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
253
+ }
254
+ const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
255
+ atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
256
+ mode: 0o600,
257
+ preserveMode: false,
258
+ requireCurrentUserOwner: true,
259
+ })
260
+ }
261
+
262
+ clear(): void {
263
+ const file = this.filePath()
264
+ if (file === null) return
265
+ rmSync(file, { force: true })
266
+ }
267
+
268
+ describe(): string {
269
+ const file = this.filePath()
270
+ if (file === null) return 'Windows DPAPI current-user (LOCALAPPDATA unset)'
271
+ return `Windows DPAPI current-user (${file})`
272
+ }
273
+
274
+ private protect(plaintext: Buffer): string {
275
+ const result = this.invoke('protect', plaintext)
276
+ if (result.status !== 0) {
277
+ throw new Error('Windows credential protection failed')
278
+ }
279
+ const encoded = result.stdout.toString('utf8').trim()
280
+ if (!isBase64(encoded)) {
281
+ throw new Error('Windows credential protection failed')
282
+ }
283
+ return encoded
284
+ }
285
+
286
+ private unprotect(blob: string): string | null {
287
+ const result = this.invoke('unprotect', Buffer.from(blob, 'utf8'))
288
+ if (result.status !== 0) return null
289
+ const plain = result.stdout.toString('utf8')
290
+ return plain === '' ? null : plain
291
+ }
292
+
293
+ private invoke(operation: 'protect' | 'unprotect', input: Buffer): RunCommandResult {
294
+ const encoded = operation === 'protect' ? PROTECT_ENCODED : UNPROTECT_ENCODED
295
+ const invocation = powerShellInvocation(encoded, this.env)
296
+ return this.run.run({
297
+ command: invocation.command,
298
+ args: invocation.args,
299
+ input,
300
+ timeoutMs: DPAPI_TIMEOUT_MS,
301
+ })
302
+ }
303
+ }
304
+
305
+ function parseCredentialJson(raw: string): MachineCredential | null {
306
+ try {
307
+ return parseCredential(JSON.parse(raw) as unknown)
308
+ } catch {
309
+ return null
310
+ }
311
+ }
312
+
313
+ function parseCredential(raw: unknown): MachineCredential | null {
314
+ if (raw === null || typeof raw !== 'object') return null
315
+ const value = raw as Record<string, unknown>
316
+ const machineId = value['machineId']
317
+ const secret = value['secret']
318
+ const baseUrl = value['baseUrl']
319
+ const machineName = value['machineName']
320
+ if (
321
+ typeof machineId !== 'string' ||
322
+ machineId === '' ||
323
+ typeof secret !== 'string' ||
324
+ secret === '' ||
325
+ typeof baseUrl !== 'string' ||
326
+ baseUrl === '' ||
327
+ typeof machineName !== 'string' ||
328
+ machineName === ''
329
+ ) {
330
+ return null
331
+ }
332
+ return { machineId, secret, baseUrl, machineName }
333
+ }
334
+
335
+ function parseDpapiEnvelope(text: string): string | null {
336
+ let parsed: unknown
337
+ try {
338
+ parsed = JSON.parse(text)
339
+ } catch {
340
+ return null
341
+ }
342
+ if (parsed === null || typeof parsed !== 'object') return null
343
+ const value = parsed as Record<string, unknown>
344
+ if (value['format'] !== DPAPI_FORMAT) return null
345
+ const data = value['data']
346
+ if (typeof data !== 'string' || !isBase64(data)) return null
347
+ return data
348
+ }
349
+
350
+ function isBase64(value: string): boolean {
351
+ return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
104
352
  }
105
353
 
106
- function keychainAvailable(): boolean {
107
- if (process.platform !== 'darwin') return false
354
+ function keychainProbeSucceeds(): boolean {
108
355
  try {
109
356
  execFileSync('security', ['help'], { stdio: 'ignore' })
110
357
  return true
@@ -113,7 +360,17 @@ function keychainAvailable(): boolean {
113
360
  }
114
361
  }
115
362
 
116
- export function defaultCredentialStore(env: NodeJS.ProcessEnv = process.env): CredentialStore {
117
- if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env)
118
- return keychainAvailable() ? new KeychainStore() : new FileStore(env)
363
+ export function defaultCredentialStore(
364
+ env: NodeJS.ProcessEnv = process.env,
365
+ options: CredentialStoreOptions = {},
366
+ ): CredentialStore {
367
+ const platform = options.platform ?? process.platform
368
+ if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env, { platform })
369
+ if (platform === 'win32') return new WindowsDpapiStore(env, options)
370
+ const keychain =
371
+ options.keychainAvailable !== undefined
372
+ ? options.keychainAvailable
373
+ : platform === 'darwin' && keychainProbeSucceeds()
374
+ if (keychain) return new KeychainStore()
375
+ return new FileStore(env, { platform })
119
376
  }
package/src/file-lock.ts CHANGED
@@ -48,6 +48,19 @@ interface FileIdentity {
48
48
  ino: number
49
49
  }
50
50
 
51
+ /**
52
+ * Identity for one published lock entry.
53
+ *
54
+ * Linux can immediately reuse an inode after an unlinked entry is replaced at
55
+ * the same path. The creation/change timestamp keeps that replacement distinct
56
+ * even when its device and inode numbers happen to match the removed entry.
57
+ */
58
+ interface EntryIdentity {
59
+ dev: bigint
60
+ ino: bigint
61
+ ctimeNs: bigint
62
+ }
63
+
51
64
  interface ChoosingEntry {
52
65
  kind: 'choosing'
53
66
  name: string
@@ -124,10 +137,22 @@ function assertSameDirectory(directory: string, expected: FileIdentity): void {
124
137
  }
125
138
  }
126
139
 
127
- function sameIdentity(file: string, expected: FileIdentity): boolean {
140
+ function entryIdentity(stat: { dev: bigint; ino: bigint; ctimeNs: bigint }): EntryIdentity {
141
+ return {
142
+ dev: stat.dev,
143
+ ino: stat.ino,
144
+ ctimeNs: stat.ctimeNs,
145
+ }
146
+ }
147
+
148
+ function sameEntryIdentity(file: string, expected: EntryIdentity): boolean {
128
149
  try {
129
- const current = lstatSync(file)
130
- return current.dev === expected.dev && current.ino === expected.ino
150
+ const current = lstatSync(file, { bigint: true })
151
+ return (
152
+ current.dev === expected.dev &&
153
+ current.ino === expected.ino &&
154
+ current.ctimeNs === expected.ctimeNs
155
+ )
131
156
  } catch {
132
157
  return false
133
158
  }
@@ -173,11 +198,10 @@ function liveEntries(
173
198
  return live
174
199
  }
175
200
 
176
- function publishEmpty(file: string): FileIdentity {
201
+ function publishEmpty(file: string): EntryIdentity {
177
202
  const handle = openSync(file, 'wx', 0o600)
178
203
  try {
179
- const stat = fstatSync(handle)
180
- return { dev: stat.dev, ino: stat.ino }
204
+ return entryIdentity(fstatSync(handle, { bigint: true }))
181
205
  } finally {
182
206
  closeSync(handle)
183
207
  }
@@ -188,7 +212,7 @@ function publishChoosing(
188
212
  name: string,
189
213
  deadline: number,
190
214
  observe: FileLockOptions['observe'],
191
- ): { file: string; fileIdentity: FileIdentity; directoryIdentity: FileIdentity } {
215
+ ): { file: string; fileIdentity: EntryIdentity; directoryIdentity: FileIdentity } {
192
216
  const file = path.join(directory, name)
193
217
  for (;;) {
194
218
  try {
@@ -227,7 +251,7 @@ export function withFileLock<T>(file: string, action: () => T, options: FileLock
227
251
  const token = randomBytes(FILE_LOCK_TOKEN_BYTES).toString('hex')
228
252
  const choosingName = `choosing-${pid}-${token}`
229
253
  let ownedPath: string | null = null
230
- let ownedIdentity: FileIdentity | null = null
254
+ let ownedIdentity: EntryIdentity | null = null
231
255
  let directoryIdentity: FileIdentity | null = null
232
256
  let operationFailed = false
233
257
 
@@ -249,6 +273,7 @@ export function withFileLock<T>(file: string, action: () => T, options: FileLock
249
273
  // ticket, never a gap in which a live contender has no published state.
250
274
  renameSync(ownedPath, ticketPath)
251
275
  ownedPath = ticketPath
276
+ ownedIdentity = entryIdentity(lstatSync(ticketPath, { bigint: true }))
252
277
 
253
278
  for (;;) {
254
279
  const blockers: string[] = []
@@ -273,7 +298,7 @@ export function withFileLock<T>(file: string, action: () => T, options: FileLock
273
298
  let releaseChanged = false
274
299
  if (ownedPath !== null && ownedIdentity !== null) {
275
300
  try {
276
- if (sameIdentity(ownedPath, ownedIdentity)) unlinkSync(ownedPath)
301
+ if (sameEntryIdentity(ownedPath, ownedIdentity)) unlinkSync(ownedPath)
277
302
  else releaseChanged = true
278
303
  } catch {
279
304
  // External cleanup cannot turn a release failure into a caller failure.
@@ -14,6 +14,7 @@ import path from 'node:path'
14
14
  import { describe, expect, it } from 'vitest'
15
15
  import {
16
16
  hookAdapterPath,
17
+ hookAdapterSource,
17
18
  inspectHookAdapter,
18
19
  installHookAdapter,
19
20
  isNpxAdapterTarget,
@@ -227,3 +228,138 @@ describe('stable hook adapter', () => {
227
228
  expect(run.stderr).toMatch(/target is stale/)
228
229
  })
229
230
  })
231
+
232
+ describe('Windows hook adapter', () => {
233
+ it('keeps POSIX adapter source bytes identical to the trusted /bin/sh form', () => {
234
+ const target = {
235
+ execPath: '/usr/local/bin/node',
236
+ scriptPath: '/opt/notifai/dist/main.js',
237
+ }
238
+ expect(hookAdapterSource(target, 'posix')).toBe(`#!/bin/sh
239
+ # notifai managed hook adapter
240
+ # adapter-version: 1
241
+ # target-exec-json: "/usr/local/bin/node"
242
+ # target-script-json: "/opt/notifai/dist/main.js"
243
+ set -eu
244
+
245
+ registered_exec='/usr/local/bin/node'
246
+ registered_script='/opt/notifai/dist/main.js'
247
+
248
+ if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
249
+ exec "$registered_exec" "$registered_script" "$@"
250
+ fi
251
+
252
+ printf '%s
253
+ ' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
254
+ exit 127
255
+ `)
256
+ })
257
+
258
+ it('generates a JavaScript adapter instead of a shebang script', () => {
259
+ const { root, homeDir } = isolated()
260
+ const script = path.join(root, 'target.js')
261
+ writeFileSync(script, '')
262
+ const installed = installHookAdapter(
263
+ { execPath: process.execPath, scriptPath: script },
264
+ homeDir,
265
+ 'win32',
266
+ )
267
+ const source = readFileSync(installed.path, 'utf8')
268
+
269
+ expect(source.startsWith('// notifai managed hook adapter\n')).toBe(true)
270
+ expect(source).not.toContain('#!/bin/sh')
271
+ expect(source).toContain('require("node:child_process")')
272
+ expect(source).toContain(JSON.stringify(process.execPath))
273
+ expect(source).toContain(JSON.stringify(script))
274
+ })
275
+
276
+ it('forwards argv through Node without depending on a shebang or executable bit', () => {
277
+ const { root, homeDir } = isolated()
278
+ const script = path.join(root, 'target script.js')
279
+ writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
280
+ const runtime = path.join(root, 'node-runtime')
281
+ writeFileSync(runtime, 'dummy-node')
282
+ chmodSync(runtime, 0o644)
283
+
284
+ const installed = installHookAdapter(
285
+ { execPath: process.execPath, scriptPath: script },
286
+ homeDir,
287
+ 'win32',
288
+ )
289
+ const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop', '--owner', 'notifai'], {
290
+ encoding: 'utf8',
291
+ })
292
+
293
+ expect(run.status).toBe(0)
294
+ expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
295
+ expect(() =>
296
+ installHookAdapter({ execPath: runtime, scriptPath: script }, homeDir, 'win32'),
297
+ ).not.toThrow()
298
+ })
299
+
300
+ it('does not treat 0666 as a protection or health failure', () => {
301
+ const { root, homeDir } = isolated()
302
+ const script = path.join(root, 'target.js')
303
+ writeFileSync(script, '')
304
+ const installed = installHookAdapter(
305
+ { execPath: process.execPath, scriptPath: script },
306
+ homeDir,
307
+ 'win32',
308
+ )
309
+ chmodSync(installed.path, 0o666)
310
+
311
+ expect(inspectHookAdapter(homeDir, 'win32').problems).toEqual([])
312
+ expect(
313
+ installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32'),
314
+ ).toEqual({ path: installed.path, changed: false })
315
+ })
316
+
317
+ it('fails closed when the registered Windows target vanishes', () => {
318
+ const { root, homeDir } = isolated()
319
+ const oldNode = path.join(root, 'old-node')
320
+ const oldCli = path.join(root, 'old-cli.js')
321
+ writeFileSync(oldNode, '#!/bin/sh\n')
322
+ writeFileSync(oldCli, '')
323
+ const installed = installHookAdapter(
324
+ { execPath: oldNode, scriptPath: oldCli },
325
+ homeDir,
326
+ 'win32',
327
+ )
328
+ rmSync(oldNode)
329
+ rmSync(oldCli)
330
+
331
+ const inspected = inspectHookAdapter(homeDir, 'win32')
332
+ expect(inspected.problems).toEqual([
333
+ `registered runtime ${oldNode} is missing`,
334
+ `registered CLI ${oldCli} is missing`,
335
+ ])
336
+ const run = spawnSync(process.execPath, [installed.path, 'hook', 'stop'], {
337
+ encoding: 'utf8',
338
+ })
339
+ expect(run.status).toBe(127)
340
+ expect(run.stdout).toBe('')
341
+ expect(run.stderr).toMatch(/target is stale/)
342
+ })
343
+
344
+ it('retargets the CLI without changing the adapter pathname', () => {
345
+ const { root, homeDir } = isolated()
346
+ const first = path.join(root, 'first.js')
347
+ const second = path.join(root, 'second.js')
348
+ writeFileSync(first, '')
349
+ writeFileSync(second, '')
350
+ const firstInstall = installHookAdapter(
351
+ { execPath: process.execPath, scriptPath: first },
352
+ homeDir,
353
+ 'win32',
354
+ )
355
+ const secondInstall = installHookAdapter(
356
+ { execPath: process.execPath, scriptPath: second },
357
+ homeDir,
358
+ 'win32',
359
+ )
360
+ const inspected = inspectHookAdapter(homeDir, 'win32').target
361
+
362
+ expect(secondInstall.path).toBe(firstInstall.path)
363
+ expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
364
+ })
365
+ })