@raidiant/notifai 8.0.0 → 9.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 +41 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +71 -15
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +63 -28
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-hooks.d.ts +9 -4
- package/dist/commands-hooks.js +430 -32
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +114 -32
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +60 -11
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +231 -85
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +13 -8
- package/dist/program.js.map +1 -1
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +99 -13
- package/src/commands-guidance.ts +75 -29
- package/src/commands-hooks.ts +503 -34
- package/src/commands-init.ts +139 -38
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +73 -10
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1083 -101
- package/src/commands.ts +1 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance.test.ts +42 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +746 -165
- package/src/hooks.ts +299 -124
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +40 -12
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +45 -0
- package/src/skill.test.ts +120 -4
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/guidance.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
type Dirent,
|
|
12
|
+
} from 'node:fs'
|
|
2
13
|
import path from 'node:path'
|
|
3
14
|
import { globalConfigDir, personalProjectIdentity } from './config.js'
|
|
4
15
|
import { SHIPPED_GUIDANCE } from './guidance-content.js'
|
|
@@ -14,6 +25,16 @@ import { SHIPPED_GUIDANCE } from './guidance-content.js'
|
|
|
14
25
|
* not annotate it — and a file whose name matches no shipped topic is purely
|
|
15
26
|
* additive house rules. There is no session layer: an instruction given in
|
|
16
27
|
* conversation tunes the session by being followed, not persisted.
|
|
28
|
+
*
|
|
29
|
+
* Two of those layers are the User's own word and one is not. `global` and
|
|
30
|
+
* `project-local` live under the User's config home: only they can write
|
|
31
|
+
* there. `project` is `.notifai/guidance` inside the repository, so it
|
|
32
|
+
* arrives with a clone and is written by whoever wrote the repository —
|
|
33
|
+
* which may be nobody the User has ever met. It remains authoritative about
|
|
34
|
+
* *how this project's notifications should read*, because shared house rules
|
|
35
|
+
* are the feature; it is never the User speaking. `authority` carries that
|
|
36
|
+
* distinction to every reader so a cloned repository cannot borrow the
|
|
37
|
+
* User's voice.
|
|
17
38
|
*/
|
|
18
39
|
|
|
19
40
|
export type GuidanceSource =
|
|
@@ -22,14 +43,30 @@ export type GuidanceSource =
|
|
|
22
43
|
| `global:${string}`
|
|
23
44
|
| 'default'
|
|
24
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Who is speaking in a topic.
|
|
48
|
+
*
|
|
49
|
+
* - `user` — the User's own standing word, from a location only they can write.
|
|
50
|
+
* - `repository` — this checkout's shared house rules; committed content, and
|
|
51
|
+
* therefore untrusted input that speaks for the project, not the User.
|
|
52
|
+
* - `shipped` — the default that applies when nobody said otherwise.
|
|
53
|
+
*/
|
|
54
|
+
export type GuidanceAuthority = 'user' | 'repository' | 'shipped'
|
|
55
|
+
|
|
25
56
|
export interface ResolvedGuidanceTopic {
|
|
26
57
|
name: string
|
|
27
58
|
source: GuidanceSource
|
|
59
|
+
authority: GuidanceAuthority
|
|
28
60
|
/** One line for lists; user-authored topics describe themselves by content. */
|
|
29
61
|
summary: string
|
|
30
62
|
content: string
|
|
31
63
|
}
|
|
32
64
|
|
|
65
|
+
export function guidanceAuthority(source: GuidanceSource): GuidanceAuthority {
|
|
66
|
+
if (source === 'default') return 'shipped'
|
|
67
|
+
return source.startsWith('project:') ? 'repository' : 'user'
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
/**
|
|
34
71
|
* Topic names double as filenames, and the project layer arrives from a
|
|
35
72
|
* repository — untrusted input. Anything that does not match is not a topic,
|
|
@@ -47,12 +84,29 @@ export function globalGuidanceDir(env: NodeJS.ProcessEnv = process.env): string
|
|
|
47
84
|
return path.join(globalConfigDir(env), 'guidance')
|
|
48
85
|
}
|
|
49
86
|
|
|
87
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
88
|
+
const relative = path.relative(root, candidate)
|
|
89
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
/** Walk up from cwd looking for `.notifai/guidance` (shared, committed). */
|
|
51
93
|
export function findProjectGuidanceDir(startDir: string): string | null {
|
|
52
94
|
let dir = path.resolve(startDir)
|
|
53
95
|
for (;;) {
|
|
54
96
|
const candidate = path.join(dir, '.notifai', 'guidance')
|
|
55
|
-
if (existsSync(candidate))
|
|
97
|
+
if (existsSync(candidate)) {
|
|
98
|
+
try {
|
|
99
|
+
// A clone may contain symlinks. Repository guidance may read only
|
|
100
|
+
// regular content rooted in that repository; it must never turn a
|
|
101
|
+
// local private file into model context by pointing outside the tree.
|
|
102
|
+
const candidateStat = lstatSync(candidate)
|
|
103
|
+
const repositoryRoot = realpathSync(dir)
|
|
104
|
+
const resolved = realpathSync(candidate)
|
|
105
|
+
if (candidateStat.isDirectory() && isWithin(repositoryRoot, resolved)) return candidate
|
|
106
|
+
} catch {
|
|
107
|
+
// An unreadable or unstable candidate is not guidance. Keep walking.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
56
110
|
const parent = path.dirname(dir)
|
|
57
111
|
if (parent === dir) return null
|
|
58
112
|
dir = parent
|
|
@@ -74,33 +128,47 @@ export function personalProjectGuidanceDir(
|
|
|
74
128
|
function topicsInDir(dir: string): Map<string, string> {
|
|
75
129
|
const topics = new Map<string, string>()
|
|
76
130
|
if (!existsSync(dir)) return topics
|
|
77
|
-
let entries:
|
|
131
|
+
let entries: Dirent[]
|
|
78
132
|
try {
|
|
79
|
-
|
|
133
|
+
if (!lstatSync(dir).isDirectory()) return topics
|
|
134
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
80
135
|
} catch {
|
|
81
136
|
return topics
|
|
82
137
|
}
|
|
83
|
-
for (const entry of entries.sort()) {
|
|
84
|
-
|
|
85
|
-
|
|
138
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
139
|
+
// In particular, refuse symlinks. A committed link to a User-owned file
|
|
140
|
+
// must not make that file part of repository guidance output.
|
|
141
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
142
|
+
const name = entry.name.slice(0, -'.md'.length)
|
|
86
143
|
if (!GUIDANCE_TOPIC_PATTERN.test(name)) continue
|
|
87
|
-
topics.set(name, path.join(dir, entry))
|
|
144
|
+
topics.set(name, path.join(dir, entry.name))
|
|
88
145
|
}
|
|
89
146
|
return topics
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
function readTopicFile(filePath: string): string | null {
|
|
93
|
-
let
|
|
150
|
+
let descriptor: number | null = null
|
|
94
151
|
try {
|
|
95
|
-
|
|
152
|
+
descriptor = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
153
|
+
if (!fstatSync(descriptor).isFile()) return null
|
|
154
|
+
const bytes = Buffer.alloc(GUIDANCE_TOPIC_MAX_BYTES + 1)
|
|
155
|
+
let offset = 0
|
|
156
|
+
while (offset < bytes.length) {
|
|
157
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset)
|
|
158
|
+
if (count === 0) break
|
|
159
|
+
offset += count
|
|
160
|
+
}
|
|
161
|
+
const truncated = offset > GUIDANCE_TOPIC_MAX_BYTES
|
|
162
|
+
const content = bytes.subarray(0, Math.min(offset, GUIDANCE_TOPIC_MAX_BYTES)).toString('utf8')
|
|
163
|
+
if (content.trim() === '') return null
|
|
164
|
+
return truncated
|
|
165
|
+
? `${content}\n\n[Truncated: this guidance file exceeds ${GUIDANCE_TOPIC_MAX_BYTES} bytes.]\n`
|
|
166
|
+
: content
|
|
96
167
|
} catch {
|
|
97
168
|
return null
|
|
169
|
+
} finally {
|
|
170
|
+
if (descriptor !== null) closeSync(descriptor)
|
|
98
171
|
}
|
|
99
|
-
if (content.trim() === '') return null
|
|
100
|
-
if (content.length > GUIDANCE_TOPIC_MAX_BYTES) {
|
|
101
|
-
return `${content.slice(0, GUIDANCE_TOPIC_MAX_BYTES)}\n\n[Truncated: this guidance file exceeds ${GUIDANCE_TOPIC_MAX_BYTES} bytes.]\n`
|
|
102
|
-
}
|
|
103
|
-
return content
|
|
104
172
|
}
|
|
105
173
|
|
|
106
174
|
/**
|
|
@@ -135,15 +203,29 @@ export function resolveGuidance(options: {
|
|
|
135
203
|
const override = winners.get(topic.name)
|
|
136
204
|
winners.delete(topic.name)
|
|
137
205
|
return override === undefined
|
|
138
|
-
? {
|
|
139
|
-
|
|
206
|
+
? {
|
|
207
|
+
name: topic.name,
|
|
208
|
+
source: 'default' as const,
|
|
209
|
+
authority: 'shipped' as const,
|
|
210
|
+
summary: topic.summary,
|
|
211
|
+
content: topic.content,
|
|
212
|
+
}
|
|
213
|
+
: {
|
|
214
|
+
name: topic.name,
|
|
215
|
+
source: override.source,
|
|
216
|
+
authority: guidanceAuthority(override.source),
|
|
217
|
+
summary: topic.summary,
|
|
218
|
+
content: override.content,
|
|
219
|
+
}
|
|
140
220
|
})
|
|
141
221
|
for (const name of [...winners.keys()].sort()) {
|
|
142
222
|
const extra = winners.get(name)!
|
|
223
|
+
const authority = guidanceAuthority(extra.source)
|
|
143
224
|
resolved.push({
|
|
144
225
|
name,
|
|
145
226
|
source: extra.source,
|
|
146
|
-
|
|
227
|
+
authority,
|
|
228
|
+
summary: authority === 'repository' ? 'Project house rules' : 'User-added guidance',
|
|
147
229
|
content: extra.content,
|
|
148
230
|
})
|
|
149
231
|
}
|
package/src/hook-adapter.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from 'node:fs'
|
|
12
12
|
import os from 'node:os'
|
|
13
13
|
import path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
14
15
|
import { describe, expect, it } from 'vitest'
|
|
15
16
|
import {
|
|
16
17
|
hookAdapterPath,
|
|
@@ -27,6 +28,35 @@ function isolated(): { root: string; homeDir: string } {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
describe('stable hook adapter', () => {
|
|
31
|
+
it('delivers session activation through the installed adapter before setup exists', () => {
|
|
32
|
+
const { root, homeDir } = isolated()
|
|
33
|
+
const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
|
|
34
|
+
const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
|
|
35
|
+
const run = spawnSync(
|
|
36
|
+
installed.path,
|
|
37
|
+
['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
|
|
38
|
+
{
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
HOME: homeDir,
|
|
44
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
45
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(run.status).toBe(0)
|
|
51
|
+
expect(run.stderr).toBe('')
|
|
52
|
+
expect(JSON.parse(run.stdout)).toMatchObject({
|
|
53
|
+
hookSpecificOutput: {
|
|
54
|
+
hookEventName: 'SessionStart',
|
|
55
|
+
additionalContext: expect.stringMatching(/Notifai.*session/i),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
30
60
|
it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
|
|
31
61
|
const { root, homeDir } = isolated()
|
|
32
62
|
const odd = path.join(root, "odd ' $(touch should-not-run)")
|
|
@@ -122,6 +152,36 @@ describe('stable hook adapter', () => {
|
|
|
122
152
|
)
|
|
123
153
|
})
|
|
124
154
|
|
|
155
|
+
it('carries the stable POSIX harness parent through direct and npx targets', () => {
|
|
156
|
+
const { root, homeDir } = isolated()
|
|
157
|
+
const direct = path.join(root, 'direct.js')
|
|
158
|
+
const npmCli = path.join(root, 'npm-cli.js')
|
|
159
|
+
const source = 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n'
|
|
160
|
+
writeFileSync(direct, source)
|
|
161
|
+
writeFileSync(npmCli, source)
|
|
162
|
+
|
|
163
|
+
const directAdapter = installHookAdapter(
|
|
164
|
+
{ execPath: process.execPath, scriptPath: direct },
|
|
165
|
+
homeDir,
|
|
166
|
+
)
|
|
167
|
+
const directRun = spawnSync(directAdapter.path, [], { encoding: 'utf8' })
|
|
168
|
+
const npxAdapter = installHookAdapter(
|
|
169
|
+
{
|
|
170
|
+
kind: 'npx',
|
|
171
|
+
execPath: process.execPath,
|
|
172
|
+
npmCli,
|
|
173
|
+
spec: '@raidiant/notifai@0.5.1',
|
|
174
|
+
},
|
|
175
|
+
homeDir,
|
|
176
|
+
)
|
|
177
|
+
const npxRun = spawnSync(npxAdapter.path, [], { encoding: 'utf8' })
|
|
178
|
+
|
|
179
|
+
expect(directRun.status).toBe(0)
|
|
180
|
+
expect(npxRun.status).toBe(0)
|
|
181
|
+
expect(directRun.stdout).toBe(String(process.pid))
|
|
182
|
+
expect(npxRun.stdout).toBe(String(process.pid))
|
|
183
|
+
})
|
|
184
|
+
|
|
125
185
|
it('uses a fixed home-relative pathname with no XDG input surface', () => {
|
|
126
186
|
const { homeDir } = isolated()
|
|
127
187
|
expect(hookAdapterPath(homeDir)).toBe(
|
|
@@ -202,6 +262,32 @@ describe('stable hook adapter', () => {
|
|
|
202
262
|
])
|
|
203
263
|
})
|
|
204
264
|
|
|
265
|
+
it('keeps working when a node upgrade moved the pinned runtime but left the CLI', () => {
|
|
266
|
+
const { root, homeDir } = isolated()
|
|
267
|
+
const bin = path.join(root, 'fallback-bin')
|
|
268
|
+
mkdirSync(bin, { recursive: true })
|
|
269
|
+
const pathNode = path.join(bin, 'node')
|
|
270
|
+
writeFileSync(pathNode, '#!/bin/sh\nprintf "ran %s" "$1"\n')
|
|
271
|
+
chmodSync(pathNode, 0o700)
|
|
272
|
+
const pinnedNode = path.join(root, 'pinned-node')
|
|
273
|
+
const cli = path.join(root, 'cli.js')
|
|
274
|
+
writeFileSync(pinnedNode, '#!/bin/sh\n')
|
|
275
|
+
chmodSync(pinnedNode, 0o700)
|
|
276
|
+
writeFileSync(cli, '')
|
|
277
|
+
const installed = installHookAdapter({ execPath: pinnedNode, scriptPath: cli }, homeDir)
|
|
278
|
+
// What `brew upgrade node` and `nvm uninstall` both do: the interpreter
|
|
279
|
+
// moves, the installed CLI does not.
|
|
280
|
+
rmSync(pinnedNode)
|
|
281
|
+
|
|
282
|
+
const run = spawnSync(installed.path, ['hook', 'stop'], {
|
|
283
|
+
encoding: 'utf8',
|
|
284
|
+
env: { PATH: bin },
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(run.status).toBe(0)
|
|
288
|
+
expect(run.stdout).toBe(`ran ${cli}`)
|
|
289
|
+
})
|
|
290
|
+
|
|
205
291
|
it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
|
|
206
292
|
const { root, homeDir } = isolated()
|
|
207
293
|
const bin = path.join(root, 'new-bin')
|
|
@@ -237,11 +323,14 @@ describe('Windows hook adapter', () => {
|
|
|
237
323
|
}
|
|
238
324
|
expect(hookAdapterSource(target, 'posix')).toBe(`#!/bin/sh
|
|
239
325
|
# notifai managed hook adapter
|
|
240
|
-
# adapter-version:
|
|
326
|
+
# adapter-version: 2
|
|
241
327
|
# target-exec-json: "/usr/local/bin/node"
|
|
242
328
|
# target-script-json: "/opt/notifai/dist/main.js"
|
|
243
329
|
set -eu
|
|
244
330
|
|
|
331
|
+
NOTIFAI_HOOK_SOURCE_PID=$PPID
|
|
332
|
+
export NOTIFAI_HOOK_SOURCE_PID
|
|
333
|
+
|
|
245
334
|
registered_exec='/usr/local/bin/node'
|
|
246
335
|
registered_script='/opt/notifai/dist/main.js'
|
|
247
336
|
|
|
@@ -249,6 +338,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
|
|
|
249
338
|
exec "$registered_exec" "$registered_script" "$@"
|
|
250
339
|
fi
|
|
251
340
|
|
|
341
|
+
if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
|
|
342
|
+
exec node "$registered_script" "$@"
|
|
343
|
+
fi
|
|
344
|
+
|
|
252
345
|
printf '%s
|
|
253
346
|
' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
|
|
254
347
|
exit 127
|
|
@@ -297,6 +390,25 @@ exit 127
|
|
|
297
390
|
).not.toThrow()
|
|
298
391
|
})
|
|
299
392
|
|
|
393
|
+
it('carries the stable Windows harness parent into each CLI process', () => {
|
|
394
|
+
const { root, homeDir } = isolated()
|
|
395
|
+
const script = path.join(root, 'source-pid.js')
|
|
396
|
+
writeFileSync(
|
|
397
|
+
script,
|
|
398
|
+
'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n',
|
|
399
|
+
)
|
|
400
|
+
const installed = installHookAdapter(
|
|
401
|
+
{ execPath: process.execPath, scriptPath: script },
|
|
402
|
+
homeDir,
|
|
403
|
+
'win32',
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
const run = spawnSync(process.execPath, [installed.path], { encoding: 'utf8' })
|
|
407
|
+
|
|
408
|
+
expect(run.status).toBe(0)
|
|
409
|
+
expect(run.stdout).toBe(String(process.pid))
|
|
410
|
+
})
|
|
411
|
+
|
|
300
412
|
it('does not treat 0666 as a protection or health failure', () => {
|
|
301
413
|
const { root, homeDir } = isolated()
|
|
302
414
|
const script = path.join(root, 'target.js')
|
package/src/hook-adapter.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { withTargetFileLock } from './file-lock.js'
|
|
|
13
13
|
|
|
14
14
|
const ADAPTER_MARKER = '# notifai managed hook adapter'
|
|
15
15
|
const WIN32_ADAPTER_MARKER = '// notifai managed hook adapter'
|
|
16
|
-
const ADAPTER_VERSION =
|
|
16
|
+
const ADAPTER_VERSION = 2
|
|
17
17
|
|
|
18
18
|
export type HookHostPlatform = 'posix' | 'win32'
|
|
19
19
|
|
|
@@ -212,6 +212,21 @@ export function hookAdapterSource(
|
|
|
212
212
|
: posixHookAdapterSource(target)
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Two rungs, and the difference between them is the whole safety argument.
|
|
217
|
+
*
|
|
218
|
+
* The registered runtime is a resolved path, so every node version manager
|
|
219
|
+
* makes it version-scoped: a Homebrew upgrade renames its Cellar directory, an
|
|
220
|
+
* nvm version gets pruned, and the pinned interpreter stops existing. The npm
|
|
221
|
+
* shim this adapter wraps already survives that — `main.ts` starts with
|
|
222
|
+
* `#!/usr/bin/env node` — so pinning the interpreter here was stricter than the
|
|
223
|
+
* binary it stands in front of, and the strictness bought nothing.
|
|
224
|
+
*
|
|
225
|
+
* So PATH may supply the *interpreter*, and never the *code*: both rungs keep
|
|
226
|
+
* the registered script path, and no rung resolves `notifai` from PATH. Which
|
|
227
|
+
* Notifai build a trusted hook definition runs stays a property of the
|
|
228
|
+
* installation, not of whatever environment the harness happens to hand us.
|
|
229
|
+
*/
|
|
215
230
|
function posixHookAdapterSource(target: HookAdapterTarget): string {
|
|
216
231
|
if (isNpxAdapterTarget(target)) {
|
|
217
232
|
return `#!/bin/sh
|
|
@@ -223,6 +238,9 @@ ${ADAPTER_MARKER}
|
|
|
223
238
|
# target-spec-json: ${JSON.stringify(target.spec)}
|
|
224
239
|
set -eu
|
|
225
240
|
|
|
241
|
+
NOTIFAI_HOOK_SOURCE_PID=$PPID
|
|
242
|
+
export NOTIFAI_HOOK_SOURCE_PID
|
|
243
|
+
|
|
226
244
|
registered_exec=${quote(target.execPath)}
|
|
227
245
|
registered_npm_cli=${quote(target.npmCli)}
|
|
228
246
|
registered_spec=${quote(target.spec)}
|
|
@@ -231,6 +249,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_npm_cli" ]; then
|
|
|
231
249
|
exec "$registered_exec" "$registered_npm_cli" exec --yes --package "$registered_spec" -- notifai "$@"
|
|
232
250
|
fi
|
|
233
251
|
|
|
252
|
+
if [ -f "$registered_npm_cli" ] && command -v node >/dev/null 2>&1; then
|
|
253
|
+
exec node "$registered_npm_cli" exec --yes --package "$registered_spec" -- notifai "$@"
|
|
254
|
+
fi
|
|
255
|
+
|
|
234
256
|
printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
|
|
235
257
|
exit 127
|
|
236
258
|
`
|
|
@@ -242,6 +264,9 @@ ${ADAPTER_MARKER}
|
|
|
242
264
|
# target-script-json: ${JSON.stringify(target.scriptPath)}
|
|
243
265
|
set -eu
|
|
244
266
|
|
|
267
|
+
NOTIFAI_HOOK_SOURCE_PID=$PPID
|
|
268
|
+
export NOTIFAI_HOOK_SOURCE_PID
|
|
269
|
+
|
|
245
270
|
registered_exec=${quote(target.execPath)}
|
|
246
271
|
registered_script=${quote(target.scriptPath)}
|
|
247
272
|
|
|
@@ -249,6 +274,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
|
|
|
249
274
|
exec "$registered_exec" "$registered_script" "$@"
|
|
250
275
|
fi
|
|
251
276
|
|
|
277
|
+
if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
|
|
278
|
+
exec node "$registered_script" "$@"
|
|
279
|
+
fi
|
|
280
|
+
|
|
252
281
|
printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
|
|
253
282
|
exit 127
|
|
254
283
|
`
|
|
@@ -288,10 +317,15 @@ function isFile(file) {
|
|
|
288
317
|
}
|
|
289
318
|
}
|
|
290
319
|
|
|
291
|
-
|
|
292
|
-
|
|
320
|
+
// The pinned runtime is preferred; the runtime already executing this shim is
|
|
321
|
+
// the fallback. Only the interpreter is allowed to move — the code stays pinned.
|
|
322
|
+
const runtime = isFile(registeredExec) ? registeredExec : process.execPath;
|
|
323
|
+
|
|
324
|
+
if (isFile(registeredCompanion)) {
|
|
325
|
+
const result = spawnSync(runtime, ${argv}, {
|
|
293
326
|
stdio: "inherit",
|
|
294
327
|
windowsHide: true,
|
|
328
|
+
env: { ...process.env, NOTIFAI_HOOK_SOURCE_PID: String(process.ppid) },
|
|
295
329
|
});
|
|
296
330
|
if (result.error) {
|
|
297
331
|
process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
|