@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.
- package/CHANGELOG.md +50 -0
- package/README.md +17 -7
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +151 -54
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +183 -12
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +50 -21
- package/dist/install-hooks.js +141 -57
- package/dist/install-hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +351 -30
- package/src/commands.ts +207 -49
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +175 -1
- package/src/hooks.ts +241 -12
- package/src/install-hooks.test.ts +174 -33
- package/src/install-hooks.ts +186 -54
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/skill.test.ts +32 -1
- 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
|
|
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
|
|
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):
|
|
201
|
+
function publishEmpty(file: string): EntryIdentity {
|
|
177
202
|
const handle = openSync(file, 'wx', 0o600)
|
|
178
203
|
try {
|
|
179
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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.
|
package/src/hook-adapter.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/hook-adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
230
|
-
const exec =
|
|
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 =
|
|
237
|
-
const specRaw =
|
|
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 =
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|