@raidiant/notifai 1.0.1 → 2.0.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 (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +17 -7
  3. package/dist/client.d.ts +5 -1
  4. package/dist/client.js +2 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands.d.ts +10 -1
  7. package/dist/commands.js +151 -54
  8. package/dist/commands.js.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.js +5 -9
  11. package/dist/config.js.map +1 -1
  12. package/dist/credentials.d.ts +54 -3
  13. package/dist/credentials.js +237 -18
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/file-lock.js +15 -6
  16. package/dist/file-lock.js.map +1 -1
  17. package/dist/hook-adapter.d.ts +6 -2
  18. package/dist/hook-adapter.js +110 -29
  19. package/dist/hook-adapter.js.map +1 -1
  20. package/dist/hooks.d.ts +15 -1
  21. package/dist/hooks.js +183 -12
  22. package/dist/hooks.js.map +1 -1
  23. package/dist/install-hooks.d.ts +50 -21
  24. package/dist/install-hooks.js +141 -57
  25. package/dist/install-hooks.js.map +1 -1
  26. package/dist/logging.d.ts +1 -1
  27. package/dist/logging.js +4 -0
  28. package/dist/logging.js.map +1 -1
  29. package/dist/main.js +14 -3
  30. package/dist/main.js.map +1 -1
  31. package/dist/native-skills.js +5 -10
  32. package/dist/native-skills.js.map +1 -1
  33. package/dist/opencode-plugin.d.ts +7 -2
  34. package/dist/opencode-plugin.js +142 -4
  35. package/dist/opencode-plugin.js.map +1 -1
  36. package/dist/platform.d.ts +73 -0
  37. package/dist/platform.js +143 -0
  38. package/dist/platform.js.map +1 -0
  39. package/dist/ui/config-view.js +5 -1
  40. package/dist/ui/config-view.js.map +1 -1
  41. package/package.json +11 -10
  42. package/src/client.test.ts +53 -0
  43. package/src/client.ts +24 -0
  44. package/src/commands.test.ts +351 -30
  45. package/src/commands.ts +207 -49
  46. package/src/config.ts +11 -9
  47. package/src/credentials.test.ts +291 -0
  48. package/src/credentials.ts +274 -17
  49. package/src/file-lock.ts +34 -9
  50. package/src/hook-adapter.test.ts +136 -0
  51. package/src/hook-adapter.ts +120 -28
  52. package/src/hooks.test.ts +175 -1
  53. package/src/hooks.ts +241 -12
  54. package/src/install-hooks.test.ts +174 -33
  55. package/src/install-hooks.ts +186 -54
  56. package/src/logging.ts +4 -0
  57. package/src/main.ts +17 -2
  58. package/src/native-skills.ts +5 -10
  59. package/src/opencode-plugin.ts +148 -3
  60. package/src/platform.test.ts +152 -0
  61. package/src/platform.ts +173 -0
  62. package/src/skill.test.ts +32 -1
  63. package/src/ui/config-view.ts +5 -5
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
+ })
@@ -12,8 +12,11 @@ import { atomicWriteFileSync } from './atomic-file.js'
12
12
  import { withTargetFileLock } from './file-lock.js'
13
13
 
14
14
  const ADAPTER_MARKER = '# notifai managed hook adapter'
15
+ const WIN32_ADAPTER_MARKER = '// notifai managed hook adapter'
15
16
  const ADAPTER_VERSION = 1
16
17
 
18
+ export type HookHostPlatform = 'posix' | 'win32'
19
+
17
20
  export interface HookAdapterFileTarget {
18
21
  kind?: 'file'
19
22
  execPath: string
@@ -39,6 +42,12 @@ export interface HookAdapterInspection {
39
42
  problems: string[]
40
43
  }
41
44
 
45
+ export function hookHostPlatform(
46
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
47
+ ): HookHostPlatform {
48
+ return platform === 'win32' ? 'win32' : 'posix'
49
+ }
50
+
42
51
  /**
43
52
  * One user-level identity for every harness and every project/global install.
44
53
  *
@@ -57,18 +66,21 @@ export function hookAdapterPath(homeDir: string = os.userInfo().homedir): string
57
66
  export function installHookAdapter(
58
67
  target: HookAdapterTarget,
59
68
  homeDir?: string,
69
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
60
70
  ): { path: string; changed: boolean } {
61
- assertUsableTarget(target)
71
+ const host = hookHostPlatform(platform)
72
+ assertUsableTarget(target, host)
62
73
  const file = hookAdapterPath(homeDir)
63
- ensureManagedDirectories(homeDir ?? os.userInfo().homedir)
64
- const source = hookAdapterSource(target)
74
+ ensureManagedDirectories(homeDir ?? os.userInfo().homedir, host)
75
+ const source = hookAdapterSource(target, host)
65
76
  return withTargetFileLock(file, () => {
66
77
  const existing = existsSync(file) ? readSafeManagedFile(file) : null
67
- const changed =
68
- existing === null || existing.contents !== source || (existing.mode & 0o777) !== 0o700
78
+ const modeDrift =
79
+ host === 'posix' && existing !== null && (existing.mode & 0o777) !== 0o700
80
+ const changed = existing === null || existing.contents !== source || modeDrift
69
81
  if (changed) {
70
82
  atomicWriteFileSync(file, source, {
71
- mode: 0o700,
83
+ mode: host === 'posix' ? 0o700 : 0o600,
72
84
  preserveMode: false,
73
85
  requireCurrentUserOwner: true,
74
86
  })
@@ -77,14 +89,14 @@ export function installHookAdapter(
77
89
  })
78
90
  }
79
91
 
80
- function assertUsableTarget(target: HookAdapterTarget): void {
92
+ function assertUsableTarget(target: HookAdapterTarget, host: HookHostPlatform): void {
81
93
  let exec
82
94
  try {
83
95
  exec = statSync(target.execPath)
84
96
  } catch {
85
97
  throw new Error(`Hook adapter runtime ${target.execPath} does not exist.`)
86
98
  }
87
- if (!exec.isFile() || (exec.mode & 0o111) === 0) {
99
+ if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
88
100
  throw new Error(`Hook adapter runtime ${target.execPath} is not executable.`)
89
101
  }
90
102
  if (isNpxAdapterTarget(target)) {
@@ -114,7 +126,9 @@ function assertUsableTarget(target: HookAdapterTarget): void {
114
126
  /** Inspect the shared adapter without executing either it or its registered CLI. */
115
127
  export function inspectHookAdapter(
116
128
  homeDir?: string,
129
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
117
130
  ): HookAdapterInspection {
131
+ const host = hookHostPlatform(platform)
118
132
  const file = hookAdapterPath(homeDir)
119
133
  if (!existsSync(file)) {
120
134
  return { path: file, target: null, problems: [`${file} is missing; rerun \`notifai hooks install\``] }
@@ -135,19 +149,24 @@ export function inspectHookAdapter(
135
149
  }
136
150
 
137
151
  const problems: string[] = []
138
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
139
- if (uid !== undefined && stat.uid !== uid) {
140
- problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
141
- }
142
- if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
143
- problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
152
+ if (host === 'posix') {
153
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
154
+ if (uid !== undefined && stat.uid !== uid) {
155
+ problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
156
+ }
157
+ if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
158
+ problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
159
+ }
144
160
  }
145
161
 
146
162
  const contents = readFileSync(file, 'utf8')
147
- if (!contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) {
163
+ const format = adapterFormat(contents)
164
+ if (format === null) {
148
165
  problems.push(`${file} has no managed adapter marker`)
166
+ } else if (format !== host) {
167
+ problems.push(`${file} is not a ${host} managed adapter; rerun \`notifai hooks install\``)
149
168
  }
150
- const version = Number(/^# adapter-version: (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
169
+ const version = Number(meta(contents, 'adapter-version') ?? Number.NaN)
151
170
  if (version !== ADAPTER_VERSION) problems.push(`${file} uses obsolete adapter version ${version}`)
152
171
  const target = parseTarget(contents)
153
172
  if (target === null) {
@@ -155,7 +174,7 @@ export function inspectHookAdapter(
155
174
  } else {
156
175
  try {
157
176
  const exec = statSync(target.execPath)
158
- if (!exec.isFile() || (exec.mode & 0o111) === 0) {
177
+ if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
159
178
  problems.push(`registered runtime ${target.execPath} is not executable`)
160
179
  }
161
180
  } catch {
@@ -183,7 +202,17 @@ export function inspectHookAdapter(
183
202
  return { path: file, target, problems }
184
203
  }
185
204
 
186
- function hookAdapterSource(target: HookAdapterTarget): string {
205
+ /** Generate the managed adapter source for one host. Exported so tests can pin bytes. */
206
+ export function hookAdapterSource(
207
+ target: HookAdapterTarget,
208
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
209
+ ): string {
210
+ return hookHostPlatform(platform) === 'win32'
211
+ ? win32HookAdapterSource(target)
212
+ : posixHookAdapterSource(target)
213
+ }
214
+
215
+ function posixHookAdapterSource(target: HookAdapterTarget): string {
187
216
  if (isNpxAdapterTarget(target)) {
188
217
  return `#!/bin/sh
189
218
  ${ADAPTER_MARKER}
@@ -225,16 +254,77 @@ exit 127
225
254
  `
226
255
  }
227
256
 
257
+ function win32HookAdapterSource(target: HookAdapterTarget): string {
258
+ const header = isNpxAdapterTarget(target)
259
+ ? `${WIN32_ADAPTER_MARKER}
260
+ // adapter-version: ${ADAPTER_VERSION}
261
+ // target-kind: npx
262
+ // target-exec-json: ${JSON.stringify(target.execPath)}
263
+ // target-npm-cli-json: ${JSON.stringify(target.npmCli)}
264
+ // target-spec-json: ${JSON.stringify(target.spec)}
265
+ `
266
+ : `${WIN32_ADAPTER_MARKER}
267
+ // adapter-version: ${ADAPTER_VERSION}
268
+ // target-exec-json: ${JSON.stringify(target.execPath)}
269
+ // target-script-json: ${JSON.stringify(target.scriptPath)}
270
+ `
271
+ const argv = isNpxAdapterTarget(target)
272
+ ? `[${JSON.stringify(target.npmCli)}, "exec", "--yes", "--package", ${JSON.stringify(target.spec)}, "--", "notifai", ...process.argv.slice(2)]`
273
+ : `[${JSON.stringify(target.scriptPath)}, ...process.argv.slice(2)]`
274
+ const companion = isNpxAdapterTarget(target)
275
+ ? JSON.stringify(target.npmCli)
276
+ : JSON.stringify(target.scriptPath)
277
+ return `${header}"use strict";
278
+ const { spawnSync } = require("node:child_process");
279
+ const { statSync } = require("node:fs");
280
+ const registeredExec = ${JSON.stringify(target.execPath)};
281
+ const registeredCompanion = ${companion};
282
+
283
+ function isFile(file) {
284
+ try {
285
+ return statSync(file).isFile();
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ if (isFile(registeredExec) && isFile(registeredCompanion)) {
292
+ const result = spawnSync(registeredExec, ${argv}, {
293
+ stdio: "inherit",
294
+ windowsHide: true,
295
+ });
296
+ if (result.error) {
297
+ process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
298
+ process.exit(127);
299
+ }
300
+ process.exit(result.status === null ? 1 : result.status);
301
+ }
302
+
303
+ process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
304
+ process.exit(127);
305
+ `
306
+ }
307
+
308
+ function adapterFormat(contents: string): HookHostPlatform | null {
309
+ if (contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) return 'posix'
310
+ if (contents.startsWith(`${WIN32_ADAPTER_MARKER}\n`)) return 'win32'
311
+ return null
312
+ }
313
+
314
+ function meta(contents: string, key: string): string | undefined {
315
+ return new RegExp(`^(?:#|//) ${key}: (.+)$`, 'm').exec(contents)?.[1]
316
+ }
317
+
228
318
  function parseTarget(contents: string): HookAdapterTarget | null {
229
- const kind = /^# target-kind: (.+)$/m.exec(contents)?.[1]
230
- const exec = /^# target-exec-json: (.+)$/m.exec(contents)?.[1]
319
+ const kind = meta(contents, 'target-kind')
320
+ const exec = meta(contents, 'target-exec-json')
231
321
  if (exec === undefined) return null
232
322
  try {
233
323
  const execPath: unknown = JSON.parse(exec)
234
324
  if (typeof execPath !== 'string') return null
235
325
  if (kind === 'npx') {
236
- const npmCliRaw = /^# target-npm-cli-json: (.+)$/m.exec(contents)?.[1]
237
- const specRaw = /^# target-spec-json: (.+)$/m.exec(contents)?.[1]
326
+ const npmCliRaw = meta(contents, 'target-npm-cli-json')
327
+ const specRaw = meta(contents, 'target-spec-json')
238
328
  if (npmCliRaw === undefined || specRaw === undefined) return null
239
329
  const npmCli: unknown = JSON.parse(npmCliRaw)
240
330
  const spec: unknown = JSON.parse(specRaw)
@@ -242,7 +332,7 @@ function parseTarget(contents: string): HookAdapterTarget | null {
242
332
  ? { kind: 'npx', execPath, npmCli, spec }
243
333
  : null
244
334
  }
245
- const script = /^# target-script-json: (.+)$/m.exec(contents)?.[1]
335
+ const script = meta(contents, 'target-script-json')
246
336
  if (script === undefined) return null
247
337
  const scriptPath: unknown = JSON.parse(script)
248
338
  return typeof scriptPath === 'string' ? { execPath, scriptPath } : null
@@ -255,7 +345,7 @@ function quote(value: string): string {
255
345
  return `'${value.replaceAll("'", `'\\''`)}'`
256
346
  }
257
347
 
258
- function ensureManagedDirectories(homeDir: string): void {
348
+ function ensureManagedDirectories(homeDir: string, host: HookHostPlatform): void {
259
349
  if (!existsSync(homeDir)) mkdirSync(homeDir, { recursive: true, mode: 0o700 })
260
350
  for (const dir of [path.join(homeDir, '.notifai'), path.join(homeDir, '.notifai', 'bin')]) {
261
351
  if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 })
@@ -263,11 +353,13 @@ function ensureManagedDirectories(homeDir: string): void {
263
353
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
264
354
  throw new Error(`${dir} is not a regular directory; refusing to install the hook adapter.`)
265
355
  }
266
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
267
- if (uid !== undefined && stat.uid !== uid) {
268
- throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
356
+ if (host === 'posix') {
357
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
358
+ if (uid !== undefined && stat.uid !== uid) {
359
+ throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
360
+ }
361
+ chmodSync(dir, 0o700)
269
362
  }
270
- chmodSync(dir, 0o700)
271
363
  }
272
364
  }
273
365