@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.
- package/CHANGELOG.md +26 -1
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +43 -38
- 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.js +2 -1
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -22
- package/dist/install-hooks.js +125 -56
- package/dist/install-hooks.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 +3 -3
- package/src/commands.test.ts +144 -34
- package/src/commands.ts +65 -35
- 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 +27 -1
- package/src/hooks.ts +2 -1
- package/src/install-hooks.test.ts +188 -27
- package/src/install-hooks.ts +177 -52
- 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/ui/config-view.ts +5 -5
package/src/credentials.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
+
})
|