@raidiant/notifai 1.0.1 → 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 +17 -0
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +31 -39
- 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 +50 -21
- package/dist/install-hooks.js +141 -57
- 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 +100 -26
- package/src/commands.ts +49 -36
- 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 +174 -33
- package/src/install-hooks.ts +186 -54
- 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/opencode-plugin.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
|
|
2
3
|
import { configHome } from './install-hooks.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -36,9 +37,10 @@ export function opencodePluginDir(
|
|
|
36
37
|
global: boolean,
|
|
37
38
|
cwd: string,
|
|
38
39
|
env: NodeJS.ProcessEnv = process.env,
|
|
40
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
39
41
|
): string {
|
|
40
42
|
return global
|
|
41
|
-
? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode'), 'plugins')
|
|
43
|
+
? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode', platform), 'plugins')
|
|
42
44
|
: path.join(cwd, '.opencode', 'plugins')
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -46,8 +48,9 @@ export function opencodePluginPath(
|
|
|
46
48
|
global: boolean,
|
|
47
49
|
cwd: string,
|
|
48
50
|
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
49
52
|
): string {
|
|
50
|
-
return path.join(opencodePluginDir(global, cwd, env), OPENCODE_PLUGIN_FILENAME)
|
|
53
|
+
return path.join(opencodePluginDir(global, cwd, env, platform), OPENCODE_PLUGIN_FILENAME)
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export interface OpencodePluginOptions {
|
|
@@ -55,6 +58,9 @@ export interface OpencodePluginOptions {
|
|
|
55
58
|
adapterPath: string
|
|
56
59
|
/** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
|
|
57
60
|
timeoutSeconds: number
|
|
61
|
+
platform?: NodeJS.Platform | HookHostPlatform
|
|
62
|
+
/** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
|
|
63
|
+
nodePath?: string
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/**
|
|
@@ -70,6 +76,13 @@ export interface OpencodePluginOptions {
|
|
|
70
76
|
* never changes OpenCode's own behavior.
|
|
71
77
|
*/
|
|
72
78
|
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
79
|
+
if (hookHostPlatform(options.platform) === 'win32') {
|
|
80
|
+
return win32OpencodePluginSource(options)
|
|
81
|
+
}
|
|
82
|
+
return posixOpencodePluginSource(options)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function posixOpencodePluginSource(options: OpencodePluginOptions): string {
|
|
73
86
|
const { adapterPath, timeoutSeconds } = options
|
|
74
87
|
return `${OPENCODE_PLUGIN_MARKER}
|
|
75
88
|
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
@@ -196,6 +209,136 @@ export const NotifAIPlugin = async ({ directory }) => {
|
|
|
196
209
|
`
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
function win32OpencodePluginSource(options: OpencodePluginOptions): string {
|
|
213
|
+
const { adapterPath, timeoutSeconds } = options
|
|
214
|
+
const nodePath = options.nodePath ?? process.execPath
|
|
215
|
+
return `${OPENCODE_PLUGIN_MARKER}
|
|
216
|
+
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
217
|
+
// the next install; change the CLI instead, which is where the logic lives.
|
|
218
|
+
import { spawn } from "node:child_process"
|
|
219
|
+
|
|
220
|
+
const NODE = ${JSON.stringify(nodePath)}
|
|
221
|
+
const ADAPTER = ${JSON.stringify(adapterPath)}
|
|
222
|
+
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
223
|
+
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
|
|
227
|
+
* harnesses send. Resolves to stdout, or null if anything at all went wrong.
|
|
228
|
+
*/
|
|
229
|
+
function runHook(event, envelope) {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
let child
|
|
232
|
+
try {
|
|
233
|
+
child = spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
|
|
234
|
+
env: process.env,
|
|
235
|
+
shell: false,
|
|
236
|
+
windowsHide: true,
|
|
237
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
238
|
+
})
|
|
239
|
+
} catch {
|
|
240
|
+
resolve(null)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let stdout = ""
|
|
245
|
+
let settled = false
|
|
246
|
+
const finish = (value) => {
|
|
247
|
+
if (settled) return
|
|
248
|
+
settled = true
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
resolve(value)
|
|
251
|
+
}
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
child.kill("SIGTERM")
|
|
254
|
+
finish(null)
|
|
255
|
+
}, TIMEOUT_MS)
|
|
256
|
+
|
|
257
|
+
child.stdout?.setEncoding("utf-8")
|
|
258
|
+
child.stdout?.on("data", (chunk) => {
|
|
259
|
+
stdout += chunk
|
|
260
|
+
})
|
|
261
|
+
// Hook stderr is diagnostics, not plugin output; discard it here.
|
|
262
|
+
child.stderr?.resume()
|
|
263
|
+
child.on("error", () => finish(null))
|
|
264
|
+
child.on("close", () => finish(stdout))
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
child.stdin?.end(JSON.stringify(envelope))
|
|
268
|
+
} catch {
|
|
269
|
+
finish(null)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function additionalContext(stdout) {
|
|
275
|
+
if (typeof stdout !== "string" || stdout.trim() === "") return null
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(stdout)
|
|
278
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
279
|
+
return typeof context === "string" && context.length > 0 ? context : null
|
|
280
|
+
} catch {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const NotifAIPlugin = async ({ directory }) => {
|
|
286
|
+
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
/** Exact active-harness identity for shell commands such as \`notifai ask\`. */
|
|
290
|
+
"shell.env": async (input, output) => {
|
|
291
|
+
output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
|
|
292
|
+
const sessionID = input?.sessionID
|
|
293
|
+
if (typeof sessionID === "string" && sessionID.length > 0) {
|
|
294
|
+
output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Presence. The user typing is the signal every harness shares, and it is
|
|
300
|
+
* also what publishes the project -> session pointer \`notifai ask\` reads.
|
|
301
|
+
*/
|
|
302
|
+
"chat.message": async (input, output) => {
|
|
303
|
+
const stdout = await runHook("user-prompt-submit", {
|
|
304
|
+
session_id: input?.sessionID ?? input?.info?.sessionID,
|
|
305
|
+
cwd,
|
|
306
|
+
hook_event_name: "UserPromptSubmit",
|
|
307
|
+
})
|
|
308
|
+
const context = additionalContext(stdout)
|
|
309
|
+
if (context !== null) {
|
|
310
|
+
output.parts.push({
|
|
311
|
+
id: "prt_notifai_" + Date.now(),
|
|
312
|
+
sessionID: input.sessionID,
|
|
313
|
+
messageID: output.message.id,
|
|
314
|
+
type: "text",
|
|
315
|
+
text: context,
|
|
316
|
+
synthetic: true,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/** Session lifecycle events are delivered through OpenCode's event bus. */
|
|
322
|
+
event: async ({ event }) => {
|
|
323
|
+
if (event?.type === "session.idle") {
|
|
324
|
+
await runHook("stop", {
|
|
325
|
+
session_id: event?.properties?.sessionID,
|
|
326
|
+
cwd,
|
|
327
|
+
hook_event_name: "Stop",
|
|
328
|
+
})
|
|
329
|
+
} else if (event?.type === "session.deleted") {
|
|
330
|
+
await runHook("session-end", {
|
|
331
|
+
session_id: event?.properties?.info?.id,
|
|
332
|
+
cwd,
|
|
333
|
+
hook_event_name: "SessionEnd",
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
`
|
|
340
|
+
}
|
|
341
|
+
|
|
199
342
|
/** True when a plugin file on disk is one of ours, whoever installed it. */
|
|
200
343
|
export function isOurOpencodePlugin(contents: string): boolean {
|
|
201
344
|
return contents.includes(OPENCODE_PLUGIN_MARKER)
|
|
@@ -216,10 +359,11 @@ export function isOurOpencodePlugin(contents: string): boolean {
|
|
|
216
359
|
*/
|
|
217
360
|
export function opencodePluginTarget(
|
|
218
361
|
contents: string,
|
|
219
|
-
): { adapter: string; current: boolean; timeoutSeconds?: number } | null {
|
|
362
|
+
): { adapter: string; current: boolean; timeoutSeconds?: number; nodePath?: string } | null {
|
|
220
363
|
if (!isOurOpencodePlugin(contents)) return null
|
|
221
364
|
const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
|
|
222
365
|
const legacyScript = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
|
|
366
|
+
const node = /^const NODE = (".*")$/m.exec(contents)?.[1]
|
|
223
367
|
const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
|
|
224
368
|
const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
|
|
225
369
|
if (adapter === undefined && legacyScript === undefined) return null
|
|
@@ -228,6 +372,7 @@ export function opencodePluginTarget(
|
|
|
228
372
|
adapter: JSON.parse(adapter ?? legacyScript!) as string,
|
|
229
373
|
current: version === OPENCODE_ADAPTER_VERSION,
|
|
230
374
|
...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
|
|
375
|
+
...(node === undefined ? {} : { nodePath: JSON.parse(node) as string }),
|
|
231
376
|
}
|
|
232
377
|
} catch {
|
|
233
378
|
return null
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
accountHome,
|
|
6
|
+
cmdScript,
|
|
7
|
+
configHome,
|
|
8
|
+
isWindowsAbsolute,
|
|
9
|
+
npxLaunch,
|
|
10
|
+
quoteCmdArgument,
|
|
11
|
+
stateHome,
|
|
12
|
+
urlOpenLaunch,
|
|
13
|
+
} from './platform.js'
|
|
14
|
+
import { globalConfigDir, stateDir } from './config.js'
|
|
15
|
+
|
|
16
|
+
const PAIRING_URL = 'https://notifai.example/approve?code=AB&next=1|calc'
|
|
17
|
+
|
|
18
|
+
describe('urlOpenLaunch', () => {
|
|
19
|
+
it('keeps a metacharacter pairing URL as one Windows argument and hides the console', () => {
|
|
20
|
+
const launch = urlOpenLaunch(PAIRING_URL, 'win32')
|
|
21
|
+
expect(launch.file.toLowerCase()).not.toMatch(/^cmd(\.exe)?$/)
|
|
22
|
+
expect(launch.args).toEqual(['url.dll,FileProtocolHandler', PAIRING_URL])
|
|
23
|
+
expect(launch.options.windowsHide).toBe(true)
|
|
24
|
+
expect(launch.options.detached).toBe(true)
|
|
25
|
+
expect(launch.options.stdio).toBe('ignore')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves best-effort open and xdg-open on POSIX', () => {
|
|
29
|
+
expect(urlOpenLaunch(PAIRING_URL, 'darwin')).toEqual({
|
|
30
|
+
file: 'open',
|
|
31
|
+
args: [PAIRING_URL],
|
|
32
|
+
options: { stdio: 'ignore', detached: true },
|
|
33
|
+
})
|
|
34
|
+
expect(urlOpenLaunch(PAIRING_URL, 'linux')).toEqual({
|
|
35
|
+
file: 'xdg-open',
|
|
36
|
+
args: [PAIRING_URL],
|
|
37
|
+
options: { stdio: 'ignore', detached: true },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('npxLaunch', () => {
|
|
43
|
+
const source = 'Raidiant-io/notifai#v1.0.1'
|
|
44
|
+
const args = ['-y', 'skills', 'add', source, '--skill', 'notifai', '--yes']
|
|
45
|
+
|
|
46
|
+
it('runs the .cmd shim through cmd on Windows with quoted arguments', () => {
|
|
47
|
+
const launch = npxLaunch(args, {
|
|
48
|
+
cwd: 'C:\\proj',
|
|
49
|
+
env: { ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
|
|
50
|
+
platform: 'win32',
|
|
51
|
+
})
|
|
52
|
+
expect(launch.file).toBe('C:\\Windows\\system32\\cmd.exe')
|
|
53
|
+
expect(launch.args.slice(0, 4)).toEqual(['/d', '/s', '/v:off', '/c'])
|
|
54
|
+
expect(launch.args).toHaveLength(5)
|
|
55
|
+
expect(launch.args[4]).toBe(cmdScript('npx.cmd', args))
|
|
56
|
+
expect(launch.args[4]).toContain('npx.cmd')
|
|
57
|
+
expect(launch.args[4]).toContain(quoteCmdArgument(source))
|
|
58
|
+
expect(launch.options.windowsVerbatimArguments).toBe(true)
|
|
59
|
+
expect(launch.options.windowsHide).toBe(true)
|
|
60
|
+
expect(launch.options.stdio).toBe('inherit')
|
|
61
|
+
expect(launch.options.cwd).toBe('C:\\proj')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('quotes a hostile source so cmd cannot treat & as a second command', () => {
|
|
65
|
+
const hostile = 'evil&calc.exe'
|
|
66
|
+
const launch = npxLaunch(['-y', 'skills', 'add', hostile, '--skill', 'notifai'], {
|
|
67
|
+
cwd: 'C:\\proj',
|
|
68
|
+
env: {},
|
|
69
|
+
platform: 'win32',
|
|
70
|
+
})
|
|
71
|
+
const script = launch.args[4] ?? ''
|
|
72
|
+
expect(script).toContain(quoteCmdArgument(hostile))
|
|
73
|
+
expect(script.startsWith('"') && script.endsWith('"')).toBe(true)
|
|
74
|
+
expect(launch.file).toBe('cmd.exe')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('spawns npx directly on POSIX without a shell', () => {
|
|
78
|
+
const launch = npxLaunch(args, { cwd: '/tmp/proj', env: { PATH: '/usr/bin' }, platform: 'linux' })
|
|
79
|
+
expect(launch).toEqual({
|
|
80
|
+
file: 'npx',
|
|
81
|
+
args,
|
|
82
|
+
options: { cwd: '/tmp/proj', env: { PATH: '/usr/bin' }, stdio: 'inherit' },
|
|
83
|
+
})
|
|
84
|
+
expect(launch.options.windowsVerbatimArguments).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('accountHome', () => {
|
|
89
|
+
it('rejects an MSYS HOME on Windows and uses a Windows-absolute USERPROFILE', () => {
|
|
90
|
+
expect(
|
|
91
|
+
accountHome({ HOME: '/c/Users/rafael', USERPROFILE: 'C:\\Users\\rafael' }, 'win32'),
|
|
92
|
+
).toBe('C:\\Users\\rafael')
|
|
93
|
+
expect(accountHome({ HOME: '/home/rafael', USERPROFILE: 'C:/Users/rafael' }, 'win32')).toBe(
|
|
94
|
+
'C:/Users/rafael',
|
|
95
|
+
)
|
|
96
|
+
expect(isWindowsAbsolute('/c/Users/rafael')).toBe(false)
|
|
97
|
+
expect(isWindowsAbsolute('C:\\Users\\rafael')).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('honours a Windows-absolute HOME and falls back to os.homedir()', () => {
|
|
101
|
+
expect(accountHome({ HOME: 'D:\\homes\\dev', USERPROFILE: 'C:\\Users\\rafael' }, 'win32')).toBe(
|
|
102
|
+
'D:\\homes\\dev',
|
|
103
|
+
)
|
|
104
|
+
expect(accountHome({ HOME: '/c/Users/rafael' }, 'win32')).toBe(os.homedir())
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('preserves POSIX HOME, including a POSIX-looking value', () => {
|
|
108
|
+
expect(accountHome({ HOME: '/home/rafael', USERPROFILE: 'C:\\Users\\rafael' }, 'linux')).toBe(
|
|
109
|
+
'/home/rafael',
|
|
110
|
+
)
|
|
111
|
+
expect(accountHome({ HOME: '/c/Users/rafael' }, 'darwin')).toBe('/c/Users/rafael')
|
|
112
|
+
expect(accountHome({}, 'linux')).toBe(os.homedir())
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('known-folder roots', () => {
|
|
117
|
+
it('defaults Windows config to APPDATA/notifai and state to LOCALAPPDATA/notifai', () => {
|
|
118
|
+
const env = {
|
|
119
|
+
APPDATA: 'C:\\Users\\rafael\\AppData\\Roaming',
|
|
120
|
+
LOCALAPPDATA: 'C:\\Users\\rafael\\AppData\\Local',
|
|
121
|
+
HOME: '/c/Users/rafael',
|
|
122
|
+
USERPROFILE: 'C:\\Users\\rafael',
|
|
123
|
+
}
|
|
124
|
+
expect(configHome(env, 'win32')).toBe(env.APPDATA)
|
|
125
|
+
expect(stateHome(env, 'win32')).toBe(env.LOCALAPPDATA)
|
|
126
|
+
expect(globalConfigDir(env, 'win32')).toBe(path.join(env.APPDATA, 'notifai'))
|
|
127
|
+
expect(stateDir(env, 'win32')).toBe(path.join(env.LOCALAPPDATA, 'notifai'))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('lets explicit XDG roots override Windows known folders', () => {
|
|
131
|
+
const env = {
|
|
132
|
+
XDG_CONFIG_HOME: 'D:\\xdg\\config',
|
|
133
|
+
XDG_STATE_HOME: 'D:\\xdg\\state',
|
|
134
|
+
APPDATA: 'C:\\Users\\rafael\\AppData\\Roaming',
|
|
135
|
+
LOCALAPPDATA: 'C:\\Users\\rafael\\AppData\\Local',
|
|
136
|
+
}
|
|
137
|
+
expect(globalConfigDir(env, 'win32')).toBe(path.join('D:\\xdg\\config', 'notifai'))
|
|
138
|
+
expect(stateDir(env, 'win32')).toBe(path.join('D:\\xdg\\state', 'notifai'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('keeps POSIX XDG defaults under the account home', () => {
|
|
142
|
+
const env = { HOME: '/home/rafael' }
|
|
143
|
+
expect(globalConfigDir(env, 'linux')).toBe(path.join('/home/rafael', '.config', 'notifai'))
|
|
144
|
+
expect(stateDir(env, 'linux')).toBe(path.join('/home/rafael', '.local', 'state', 'notifai'))
|
|
145
|
+
expect(globalConfigDir({ XDG_CONFIG_HOME: '/custom/config' }, 'darwin')).toBe(
|
|
146
|
+
path.join('/custom/config', 'notifai'),
|
|
147
|
+
)
|
|
148
|
+
expect(stateDir({ XDG_STATE_HOME: '/custom/state' }, 'linux')).toBe(
|
|
149
|
+
path.join('/custom/state', 'notifai'),
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { spawn, type SpawnOptions } from 'node:child_process'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How this process should launch one child. Tests inspect the plan instead of
|
|
7
|
+
* opening a browser or running npx; production hands it to `spawn`.
|
|
8
|
+
*/
|
|
9
|
+
export interface ProcessLaunch {
|
|
10
|
+
file: string
|
|
11
|
+
args: string[]
|
|
12
|
+
options: SpawnOptions
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Drive letter or UNC. MSYS `/c/Users/…` is not a Windows account home. */
|
|
16
|
+
export function isWindowsAbsolute(value: string): boolean {
|
|
17
|
+
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The account home used for Notifai's own defaults.
|
|
22
|
+
*
|
|
23
|
+
* On Windows, Git Bash and MSYS overwrite `HOME` with a POSIX path that is not
|
|
24
|
+
* a usable NTFS root. Those values are ignored; a Windows-absolute
|
|
25
|
+
* `USERPROFILE` or `os.homedir()` wins. A Windows-absolute `HOME` is still
|
|
26
|
+
* honoured. POSIX keeps `HOME` as the override it has always been.
|
|
27
|
+
*/
|
|
28
|
+
export function accountHome(
|
|
29
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
30
|
+
platform: NodeJS.Platform = process.platform,
|
|
31
|
+
): string {
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
const home = env['HOME']
|
|
34
|
+
if (typeof home === 'string' && isWindowsAbsolute(home)) return home
|
|
35
|
+
const profile = env['USERPROFILE']
|
|
36
|
+
if (typeof profile === 'string' && isWindowsAbsolute(profile)) return profile
|
|
37
|
+
return os.homedir()
|
|
38
|
+
}
|
|
39
|
+
const home = env['HOME']
|
|
40
|
+
if (typeof home === 'string' && home !== '') return home
|
|
41
|
+
return os.homedir()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Directory that contains the `notifai/` configuration folder.
|
|
46
|
+
*
|
|
47
|
+
* An explicit `XDG_CONFIG_HOME` always wins, including on Windows. Otherwise
|
|
48
|
+
* Windows uses `%APPDATA%` and POSIX uses `~/.config`.
|
|
49
|
+
*/
|
|
50
|
+
export function configHome(
|
|
51
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
52
|
+
platform: NodeJS.Platform = process.platform,
|
|
53
|
+
): string {
|
|
54
|
+
const xdg = env['XDG_CONFIG_HOME']
|
|
55
|
+
if (typeof xdg === 'string' && xdg !== '') return xdg
|
|
56
|
+
if (platform === 'win32') {
|
|
57
|
+
const appdata = env['APPDATA']
|
|
58
|
+
if (typeof appdata === 'string' && appdata !== '') return appdata
|
|
59
|
+
}
|
|
60
|
+
return path.join(accountHome(env, platform), '.config')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Directory that contains the `notifai/` machine-local state folder.
|
|
65
|
+
*
|
|
66
|
+
* An explicit `XDG_STATE_HOME` always wins. Otherwise Windows uses
|
|
67
|
+
* `%LOCALAPPDATA%` and POSIX uses `~/.local/state`.
|
|
68
|
+
*/
|
|
69
|
+
export function stateHome(
|
|
70
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
71
|
+
platform: NodeJS.Platform = process.platform,
|
|
72
|
+
): string {
|
|
73
|
+
const xdg = env['XDG_STATE_HOME']
|
|
74
|
+
if (typeof xdg === 'string' && xdg !== '') return xdg
|
|
75
|
+
if (platform === 'win32') {
|
|
76
|
+
const local = env['LOCALAPPDATA']
|
|
77
|
+
if (typeof local === 'string' && local !== '') return local
|
|
78
|
+
}
|
|
79
|
+
return path.join(accountHome(env, platform), '.local', 'state')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Open a pairing or support URL.
|
|
84
|
+
*
|
|
85
|
+
* Windows goes through `rundll32` so the URL is one CreateProcess argument and
|
|
86
|
+
* `cmd` never reparses `?a=1&b=2`. `windowsHide` keeps the helper from flashing
|
|
87
|
+
* a console. macOS and Linux stay best-effort `open` / `xdg-open`.
|
|
88
|
+
*/
|
|
89
|
+
export function urlOpenLaunch(
|
|
90
|
+
url: string,
|
|
91
|
+
platform: NodeJS.Platform = process.platform,
|
|
92
|
+
): ProcessLaunch {
|
|
93
|
+
if (platform === 'darwin') {
|
|
94
|
+
return { file: 'open', args: [url], options: { stdio: 'ignore', detached: true } }
|
|
95
|
+
}
|
|
96
|
+
if (platform === 'win32') {
|
|
97
|
+
return {
|
|
98
|
+
file: 'rundll32.exe',
|
|
99
|
+
args: ['url.dll,FileProtocolHandler', url],
|
|
100
|
+
options: { stdio: 'ignore', detached: true, windowsHide: true },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { file: 'xdg-open', args: [url], options: { stdio: 'ignore', detached: true } }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Best-effort browser open. Failure is silent: the URL is printed either way. */
|
|
107
|
+
export function openUrl(url: string, platform: NodeJS.Platform = process.platform): void {
|
|
108
|
+
try {
|
|
109
|
+
const launch = urlOpenLaunch(url, platform)
|
|
110
|
+
spawn(launch.file, launch.args, launch.options).unref()
|
|
111
|
+
} catch {
|
|
112
|
+
// The caller already printed the URL.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Quote one cmd.exe argument so it cannot start a second command.
|
|
118
|
+
*
|
|
119
|
+
* `%` expands even inside quotes, so it is doubled. Quotes themselves are
|
|
120
|
+
* doubled. Everything else is inert once quoted, including `& | > < ^`.
|
|
121
|
+
*/
|
|
122
|
+
export function quoteCmdArgument(value: string): string {
|
|
123
|
+
return `"${value.replace(/%/g, '%%').replace(/"/g, '""')}"`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A single `/c` argument for `cmd /d /s /c`.
|
|
128
|
+
*
|
|
129
|
+
* `/s` strips the first and last quote when both ends are quotes, so the
|
|
130
|
+
* payload is wrapped once more around the already-quoted argv. What cmd then
|
|
131
|
+
* runs is `"npx.cmd" "-y" …` with no unquoted metacharacters.
|
|
132
|
+
*/
|
|
133
|
+
export function cmdScript(command: string, args: readonly string[]): string {
|
|
134
|
+
return `"${[command, ...args].map(quoteCmdArgument).join(' ')}"`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Launch `npx` so Windows can run the `.cmd` shim.
|
|
139
|
+
*
|
|
140
|
+
* `spawn('npx', …)` is CreateProcess of a file named `npx`, which does not
|
|
141
|
+
* exist — npm installs `npx.cmd`. cmd.exe can run that shim; CreateProcess
|
|
142
|
+
* cannot. Arguments stay in one quoted `/c` string rather than `shell: true`.
|
|
143
|
+
*/
|
|
144
|
+
export function npxLaunch(
|
|
145
|
+
args: readonly string[],
|
|
146
|
+
options: {
|
|
147
|
+
cwd: string
|
|
148
|
+
env: NodeJS.ProcessEnv
|
|
149
|
+
platform?: NodeJS.Platform
|
|
150
|
+
stdio?: SpawnOptions['stdio']
|
|
151
|
+
},
|
|
152
|
+
): ProcessLaunch {
|
|
153
|
+
const platform = options.platform ?? process.platform
|
|
154
|
+
const stdio = options.stdio ?? 'inherit'
|
|
155
|
+
if (platform === 'win32') {
|
|
156
|
+
return {
|
|
157
|
+
file: options.env['ComSpec'] && options.env['ComSpec'] !== '' ? options.env['ComSpec'] : 'cmd.exe',
|
|
158
|
+
args: ['/d', '/s', '/v:off', '/c', cmdScript('npx.cmd', args)],
|
|
159
|
+
options: {
|
|
160
|
+
cwd: options.cwd,
|
|
161
|
+
env: options.env,
|
|
162
|
+
stdio,
|
|
163
|
+
windowsHide: true,
|
|
164
|
+
windowsVerbatimArguments: true,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
file: 'npx',
|
|
170
|
+
args: [...args],
|
|
171
|
+
options: { cwd: options.cwd, env: options.env, stdio },
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/ui/config-view.ts
CHANGED
|
@@ -64,11 +64,11 @@ export function renderConfigList(
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
lines.push('')
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
67
|
+
const command = 'notifai config explain <key>'
|
|
68
|
+
const help = `${glyph.arrow} ${command} for what one of these means and how to change it.`
|
|
69
|
+
for (const line of wrap(help, columns)) {
|
|
70
|
+
lines.push(style.dim(line.replace(command, style.code(command))))
|
|
71
|
+
}
|
|
72
72
|
return lines
|
|
73
73
|
}
|
|
74
74
|
|