@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/commands.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
-
import { spawn } from 'node:child_process'
|
|
3
2
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
3
|
import os from 'node:os'
|
|
5
4
|
import path from 'node:path'
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
globalConfigPath,
|
|
41
40
|
loadConfig,
|
|
42
41
|
sessionConfigPath,
|
|
42
|
+
stateDir,
|
|
43
43
|
type CliConfig,
|
|
44
44
|
type ConfigKey,
|
|
45
45
|
type FlagOverrides,
|
|
@@ -154,6 +154,7 @@ import {
|
|
|
154
154
|
type SendFlags,
|
|
155
155
|
} from './send.js'
|
|
156
156
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
157
|
+
import { openUrl } from './platform.js'
|
|
157
158
|
|
|
158
159
|
export interface CommandIo {
|
|
159
160
|
out(line: string): void
|
|
@@ -204,6 +205,8 @@ export interface CommandDeps {
|
|
|
204
205
|
hookAdapterHome?: string
|
|
205
206
|
/** Test seam; production uses this process's Node and CLI paths. */
|
|
206
207
|
hookInstallTarget?: HookAdapterTarget
|
|
208
|
+
/** Test seam; production uses process.platform for adapter format and quoting. */
|
|
209
|
+
hookPlatform?: NodeJS.Platform
|
|
207
210
|
/** Test seam; production uses fetch against base_url. */
|
|
208
211
|
clientFactory?: (baseUrl: string, bearer: string | null, options?: ClientOptions) => ApiClient
|
|
209
212
|
/** Test seam for bounded polling without wall-clock sleeps. */
|
|
@@ -1486,6 +1489,7 @@ function stopWakeRoute(
|
|
|
1486
1489
|
): EscalationDeliveryRoute | undefined {
|
|
1487
1490
|
if (sessionId === undefined) return undefined
|
|
1488
1491
|
if (harness === 'claude-code') {
|
|
1492
|
+
if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
|
|
1489
1493
|
return claudeWakeRoute({
|
|
1490
1494
|
sessionId,
|
|
1491
1495
|
cwd,
|
|
@@ -1709,7 +1713,7 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
|
|
|
1709
1713
|
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
|
|
1710
1714
|
let sessionId: string | undefined
|
|
1711
1715
|
if (active !== null) {
|
|
1712
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
1716
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
1713
1717
|
const activeInstalled = installations.some(
|
|
1714
1718
|
(installation) => installation.harness === active.harness,
|
|
1715
1719
|
)
|
|
@@ -1991,10 +1995,10 @@ function activeQuestionRouteProblems(
|
|
|
1991
1995
|
}
|
|
1992
1996
|
}
|
|
1993
1997
|
}
|
|
1994
|
-
for (const problem of inspectHookAdapter(deps.hookAdapterHome).problems) {
|
|
1998
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
|
|
1995
1999
|
problems.push(problem)
|
|
1996
2000
|
}
|
|
1997
|
-
for (const installation of matching) problems.push(...stopShapeProblems(installation))
|
|
2001
|
+
for (const installation of matching) problems.push(...stopShapeProblems(installation, deps.hookPlatform))
|
|
1998
2002
|
problems.push(...codexTrustProblems(matching, deps.env))
|
|
1999
2003
|
return [...new Set(problems)]
|
|
2000
2004
|
}
|
|
@@ -2056,7 +2060,7 @@ function diagnoseActiveHarnessSession(
|
|
|
2056
2060
|
* checked after a prompt before assuming that a new session is required.
|
|
2057
2061
|
*/
|
|
2058
2062
|
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
2059
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
2063
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
2060
2064
|
if (installations.length === 0) {
|
|
2061
2065
|
return [
|
|
2062
2066
|
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
@@ -2207,14 +2211,33 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2207
2211
|
const adapterTarget = resolveHookAdapterTarget(deps, flags)
|
|
2208
2212
|
const scriptPath =
|
|
2209
2213
|
flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2214
|
+
const hookPlatform = deps.hookPlatform ?? process.platform
|
|
2215
|
+
const nodePath = adapterTarget.execPath
|
|
2210
2216
|
let adapterPath: string
|
|
2211
2217
|
try {
|
|
2212
|
-
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome).path
|
|
2218
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path
|
|
2213
2219
|
} catch (err) {
|
|
2214
2220
|
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
2215
2221
|
return EXIT.failed
|
|
2216
2222
|
}
|
|
2217
|
-
const
|
|
2223
|
+
const wantGlobal = flags.global === true
|
|
2224
|
+
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter(
|
|
2225
|
+
(installation) => installation.harness === harness,
|
|
2226
|
+
)
|
|
2227
|
+
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
2228
|
+
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
2229
|
+
const globalFile = otherScope.find((installation) => installation.global)?.file
|
|
2230
|
+
deps.io.out(
|
|
2231
|
+
`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
|
|
2232
|
+
)
|
|
2233
|
+
return EXIT.ok
|
|
2234
|
+
}
|
|
2235
|
+
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
2236
|
+
if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
|
|
2237
|
+
return EXIT.failed
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
2218
2241
|
|
|
2219
2242
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
2220
2243
|
// merged into a settings document, so it owns the whole file.
|
|
@@ -2222,6 +2245,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2222
2245
|
return installOpencodePlugin(deps, file, {
|
|
2223
2246
|
adapterPath,
|
|
2224
2247
|
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
2248
|
+
platform: hookPlatform,
|
|
2249
|
+
nodePath,
|
|
2225
2250
|
})
|
|
2226
2251
|
}
|
|
2227
2252
|
|
|
@@ -2234,6 +2259,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2234
2259
|
buildCursorHookConfig({
|
|
2235
2260
|
adapterPath,
|
|
2236
2261
|
harness: 'cursor',
|
|
2262
|
+
platform: hookPlatform,
|
|
2263
|
+
nodePath,
|
|
2237
2264
|
}),
|
|
2238
2265
|
scriptPath,
|
|
2239
2266
|
)
|
|
@@ -2259,6 +2286,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2259
2286
|
buildHookConfig({
|
|
2260
2287
|
adapterPath,
|
|
2261
2288
|
harness,
|
|
2289
|
+
platform: hookPlatform,
|
|
2290
|
+
nodePath,
|
|
2262
2291
|
}),
|
|
2263
2292
|
scriptPath,
|
|
2264
2293
|
)
|
|
@@ -2314,7 +2343,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
|
|
|
2314
2343
|
function installOpencodePlugin(
|
|
2315
2344
|
deps: CommandDeps,
|
|
2316
2345
|
file: string,
|
|
2317
|
-
options: {
|
|
2346
|
+
options: {
|
|
2347
|
+
adapterPath: string
|
|
2348
|
+
timeoutSeconds: number
|
|
2349
|
+
platform?: NodeJS.Platform
|
|
2350
|
+
nodePath?: string
|
|
2351
|
+
},
|
|
2318
2352
|
): number {
|
|
2319
2353
|
try {
|
|
2320
2354
|
withTargetFileLock(file, () => {
|
|
@@ -2343,7 +2377,13 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2343
2377
|
const harness = resolveHarness(deps, flags.harness)
|
|
2344
2378
|
if (!harness) return EXIT.usage
|
|
2345
2379
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2346
|
-
const file = settingsFile(
|
|
2380
|
+
const file = settingsFile(
|
|
2381
|
+
harness,
|
|
2382
|
+
flags.global ?? false,
|
|
2383
|
+
deps.cwd,
|
|
2384
|
+
deps.env,
|
|
2385
|
+
deps.hookPlatform,
|
|
2386
|
+
)
|
|
2347
2387
|
if (harness === 'opencode') {
|
|
2348
2388
|
try {
|
|
2349
2389
|
return withTargetFileLock(file, () => {
|
|
@@ -3176,9 +3216,7 @@ function setupProofPath(deps: CommandDeps): string {
|
|
|
3176
3216
|
// the resolved absolute path is still a stable local identity for it.
|
|
3177
3217
|
}
|
|
3178
3218
|
const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
|
|
3179
|
-
|
|
3180
|
-
const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
|
|
3181
|
-
return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`)
|
|
3219
|
+
return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
|
|
3182
3220
|
}
|
|
3183
3221
|
|
|
3184
3222
|
function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
@@ -3562,6 +3600,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
3562
3600
|
const observed = observedCompanionReceipt(snapshot, proof.device_id)
|
|
3563
3601
|
if (observed) {
|
|
3564
3602
|
spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`)
|
|
3603
|
+
deps.io.out(
|
|
3604
|
+
`Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name}.`,
|
|
3605
|
+
)
|
|
3565
3606
|
return 'closed'
|
|
3566
3607
|
}
|
|
3567
3608
|
lastError = null
|
|
@@ -4382,7 +4423,7 @@ function remedyLine(state: ReadinessState): string {
|
|
|
4382
4423
|
* is, is each check's own call (`HookCheck`).
|
|
4383
4424
|
*/
|
|
4384
4425
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
4385
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
4426
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
4386
4427
|
const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
|
|
4387
4428
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
4388
4429
|
const settings: ReadinessState = {
|
|
@@ -4475,13 +4516,16 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
4475
4516
|
* hashes into `trusted_hash`, and an untrusted handler is simply not run.
|
|
4476
4517
|
* - Everything else blocks its turn and needs a ceiling above the wait.
|
|
4477
4518
|
*/
|
|
4478
|
-
function stopShapeProblems(
|
|
4519
|
+
function stopShapeProblems(
|
|
4520
|
+
installation: Installation,
|
|
4521
|
+
platform: NodeJS.Platform = process.platform,
|
|
4522
|
+
): string[] {
|
|
4479
4523
|
if (installation.harness === 'codex') return []
|
|
4480
4524
|
const problems: string[] = []
|
|
4481
4525
|
for (const handler of installation.handlers.filter(
|
|
4482
4526
|
(entry) => handlerEvent(entry.command) === 'stop',
|
|
4483
4527
|
)) {
|
|
4484
|
-
if (installation.harness === 'claude-code') {
|
|
4528
|
+
if (installation.harness === 'claude-code' && platform !== 'win32') {
|
|
4485
4529
|
if (handler.async !== true) {
|
|
4486
4530
|
problems.push(
|
|
4487
4531
|
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
@@ -4515,7 +4559,7 @@ interface HookCheck {
|
|
|
4515
4559
|
|
|
4516
4560
|
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
4517
4561
|
const checks: HookCheck[] = []
|
|
4518
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
4562
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
4519
4563
|
|
|
4520
4564
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
4521
4565
|
// without them. A setup that cannot work is what deserves to go red.
|
|
@@ -4634,7 +4678,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4634
4678
|
const adapterProblems = installations.flatMap((installation) =>
|
|
4635
4679
|
(installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
|
|
4636
4680
|
)
|
|
4637
|
-
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
|
|
4681
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
|
|
4638
4682
|
adapterProblems.push(...sharedAdapter.problems)
|
|
4639
4683
|
if (adapterProblems.length > 0) {
|
|
4640
4684
|
checks.push({
|
|
@@ -4666,7 +4710,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4666
4710
|
})
|
|
4667
4711
|
|
|
4668
4712
|
const shapeProblems = installations.flatMap((installation) =>
|
|
4669
|
-
stopShapeProblems(installation).map(
|
|
4713
|
+
stopShapeProblems(installation, deps.hookPlatform).map(
|
|
4670
4714
|
(problem) =>
|
|
4671
4715
|
`${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
|
|
4672
4716
|
),
|
|
@@ -4879,7 +4923,7 @@ function wakeRouteCheck(
|
|
|
4879
4923
|
if (active.harness === 'claude-code') {
|
|
4880
4924
|
const readiness = inspectClaudeInbox({
|
|
4881
4925
|
pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
4882
|
-
platform: process.platform,
|
|
4926
|
+
platform: deps.hookPlatform ?? process.platform,
|
|
4883
4927
|
readDescriptor:
|
|
4884
4928
|
deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
|
|
4885
4929
|
socketExists: (socketPath) => existsSync(socketPath),
|
|
@@ -4896,7 +4940,7 @@ function wakeRouteCheck(
|
|
|
4896
4940
|
}
|
|
4897
4941
|
if (active.harness === 'codex') {
|
|
4898
4942
|
const readiness = inspectCodexResume(deps.env, {
|
|
4899
|
-
platform: process.platform,
|
|
4943
|
+
platform: deps.hookPlatform ?? process.platform,
|
|
4900
4944
|
directoryExists: (directory) => existsSync(directory),
|
|
4901
4945
|
})
|
|
4902
4946
|
return {
|
|
@@ -5052,20 +5096,6 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
5052
5096
|
return log.error(message)
|
|
5053
5097
|
}
|
|
5054
5098
|
},
|
|
5055
|
-
openUrl
|
|
5056
|
-
try {
|
|
5057
|
-
if (process.platform === 'darwin') {
|
|
5058
|
-
spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
5059
|
-
} else if (process.platform === 'win32') {
|
|
5060
|
-
// `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
|
|
5061
|
-
// Empty title argument keeps URLs with special characters intact.
|
|
5062
|
-
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
|
|
5063
|
-
} else {
|
|
5064
|
-
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
5065
|
-
}
|
|
5066
|
-
} catch {
|
|
5067
|
-
// Browser opening is best-effort; the URL is printed anyway.
|
|
5068
|
-
}
|
|
5069
|
-
},
|
|
5099
|
+
openUrl,
|
|
5070
5100
|
}
|
|
5071
5101
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
3
|
import path from 'node:path'
|
|
5
4
|
import { parse as parseToml } from 'smol-toml'
|
|
6
5
|
import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
6
|
+
import { configHome, stateHome } from './platform.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Layered configuration with provenance. Most specific wins:
|
|
@@ -163,10 +163,11 @@ export function configDefaultValue(key: ConfigKey): unknown {
|
|
|
163
163
|
return DEFAULTS[key]
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
export function globalConfigDir(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
export function globalConfigDir(
|
|
167
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
168
|
+
platform: NodeJS.Platform = process.platform,
|
|
169
|
+
): string {
|
|
170
|
+
return path.join(configHome(env, platform), 'notifai')
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -178,10 +179,11 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
178
179
|
* session config overrides. Separate from the config dir because none of it is
|
|
179
180
|
* user-authored and none of it should ever be synced or committed.
|
|
180
181
|
*/
|
|
181
|
-
export function stateDir(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
export function stateDir(
|
|
183
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
184
|
+
platform: NodeJS.Platform = process.platform,
|
|
185
|
+
): string {
|
|
186
|
+
return path.join(stateHome(env, platform), 'notifai')
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
/** Per-harness-session override file; see the ConfigSource doc comment. */
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
FileStore,
|
|
16
|
+
WindowsDpapiStore,
|
|
17
|
+
defaultCredentialStore,
|
|
18
|
+
windowsDpapiOperation,
|
|
19
|
+
type MachineCredential,
|
|
20
|
+
type ProcessRunner,
|
|
21
|
+
type RunCommandSpec,
|
|
22
|
+
} from './credentials.js'
|
|
23
|
+
|
|
24
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cred-'))
|
|
25
|
+
afterAll(() => rmSync(tmp, { recursive: true, force: true }))
|
|
26
|
+
|
|
27
|
+
const SAMPLE: MachineCredential = {
|
|
28
|
+
machineId: 'mach_fixture',
|
|
29
|
+
secret: 'fixture-secret',
|
|
30
|
+
baseUrl: 'https://example.test',
|
|
31
|
+
machineName: 'fixture-machine',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OTHER: MachineCredential = {
|
|
35
|
+
machineId: 'mach_other',
|
|
36
|
+
secret: 'other-secret',
|
|
37
|
+
baseUrl: 'https://other.test',
|
|
38
|
+
machineName: 'other-machine',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sandbox(name: string): {
|
|
42
|
+
env: NodeJS.ProcessEnv
|
|
43
|
+
local: string
|
|
44
|
+
roaming: string
|
|
45
|
+
config: string
|
|
46
|
+
} {
|
|
47
|
+
const root = path.join(tmp, name)
|
|
48
|
+
const local = path.join(root, 'local')
|
|
49
|
+
const roaming = path.join(root, 'roaming')
|
|
50
|
+
const config = path.join(root, 'config')
|
|
51
|
+
mkdirSync(local, { recursive: true })
|
|
52
|
+
mkdirSync(roaming, { recursive: true })
|
|
53
|
+
mkdirSync(config, { recursive: true })
|
|
54
|
+
return {
|
|
55
|
+
local,
|
|
56
|
+
roaming,
|
|
57
|
+
config,
|
|
58
|
+
env: {
|
|
59
|
+
LOCALAPPDATA: local,
|
|
60
|
+
APPDATA: roaming,
|
|
61
|
+
XDG_CONFIG_HOME: config,
|
|
62
|
+
SystemRoot: 'C:\\Windows',
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeRaw(file: string, contents: string): void {
|
|
68
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
69
|
+
writeFileSync(file, contents)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mockDpapi(options: { failProtect?: boolean; failUnprotect?: boolean } = {}): {
|
|
73
|
+
runner: ProcessRunner
|
|
74
|
+
calls: RunCommandSpec[]
|
|
75
|
+
} {
|
|
76
|
+
const vault = new Map<string, string>()
|
|
77
|
+
const calls: RunCommandSpec[] = []
|
|
78
|
+
const runner: ProcessRunner = {
|
|
79
|
+
run(spec) {
|
|
80
|
+
calls.push({
|
|
81
|
+
command: spec.command,
|
|
82
|
+
args: spec.args,
|
|
83
|
+
input: spec.input,
|
|
84
|
+
...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
|
|
85
|
+
})
|
|
86
|
+
const operation = windowsDpapiOperation(spec.args)
|
|
87
|
+
if (operation === 'protect') {
|
|
88
|
+
if (options.failProtect) return { status: 1, stdout: Buffer.alloc(0) }
|
|
89
|
+
const token = Buffer.from(`dpapi:${vault.size}`, 'utf8').toString('base64')
|
|
90
|
+
vault.set(token, spec.input.toString('utf8'))
|
|
91
|
+
return { status: 0, stdout: Buffer.from(token, 'utf8') }
|
|
92
|
+
}
|
|
93
|
+
if (operation === 'unprotect') {
|
|
94
|
+
if (options.failUnprotect) return { status: 1, stdout: Buffer.alloc(0) }
|
|
95
|
+
const token = spec.input.toString('utf8').trim()
|
|
96
|
+
const plain = vault.get(token)
|
|
97
|
+
if (plain === undefined) return { status: 1, stdout: Buffer.alloc(0) }
|
|
98
|
+
return { status: 0, stdout: Buffer.from(plain, 'utf8') }
|
|
99
|
+
}
|
|
100
|
+
return { status: 1, stdout: Buffer.alloc(0) }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
return { runner, calls }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertNoSecretInArgs(calls: RunCommandSpec[]): void {
|
|
107
|
+
for (const call of calls) {
|
|
108
|
+
const argv = [call.command, ...call.args].join('\0')
|
|
109
|
+
expect(argv).not.toContain(SAMPLE.secret)
|
|
110
|
+
expect(argv).not.toContain(OTHER.secret)
|
|
111
|
+
expect(argv).not.toContain(SAMPLE.machineId)
|
|
112
|
+
expect(argv).not.toContain('machineId')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('defaultCredentialStore', () => {
|
|
117
|
+
it('selects the DPAPI store on win32', () => {
|
|
118
|
+
const { env } = sandbox('default-win32')
|
|
119
|
+
const store = defaultCredentialStore(env, { platform: 'win32', run: mockDpapi().runner })
|
|
120
|
+
expect(store).toBeInstanceOf(WindowsDpapiStore)
|
|
121
|
+
expect(store.describe()).toMatch(/Windows DPAPI current-user/)
|
|
122
|
+
expect(store.describe()).toContain(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('keeps the explicit file escape hatch on win32 under APPDATA', () => {
|
|
126
|
+
const { env } = sandbox('default-file-win32')
|
|
127
|
+
const store = defaultCredentialStore(
|
|
128
|
+
{ ...env, NOTIFAI_CREDENTIALS: 'file', XDG_CONFIG_HOME: undefined },
|
|
129
|
+
{ platform: 'win32' },
|
|
130
|
+
)
|
|
131
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
132
|
+
expect(store.describe()).toContain(path.join(env.APPDATA!, 'notifai', 'credentials.json'))
|
|
133
|
+
expect(store.describe()).toMatch(/plaintext file/)
|
|
134
|
+
expect(store.describe()).toMatch(/POSIX mode bits are not protection on NTFS/)
|
|
135
|
+
expect(store.describe()).not.toMatch(/0600/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('preserves macOS Keychain when available', () => {
|
|
139
|
+
const store = defaultCredentialStore({}, { platform: 'darwin', keychainAvailable: true })
|
|
140
|
+
expect(store.describe()).toBe('macOS Keychain (io.notifai.cli)')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('falls back to the 0600 file store on linux', () => {
|
|
144
|
+
const { env } = sandbox('default-linux')
|
|
145
|
+
const store = defaultCredentialStore(env, { platform: 'linux' })
|
|
146
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
147
|
+
expect(store.describe()).toMatch(/plaintext file/)
|
|
148
|
+
expect(store.describe()).toMatch(/mode 0600/)
|
|
149
|
+
expect(store.describe()).not.toMatch(/NTFS/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('uses the file store on macOS when Keychain is unavailable', () => {
|
|
153
|
+
const { env } = sandbox('default-darwin-file')
|
|
154
|
+
const store = defaultCredentialStore(env, { platform: 'darwin', keychainAvailable: false })
|
|
155
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
156
|
+
expect(store.describe()).toMatch(/mode 0600/)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('FileStore', () => {
|
|
161
|
+
it('saves, loads, overwrites, and clears atomically', () => {
|
|
162
|
+
const { env } = sandbox('file-roundtrip')
|
|
163
|
+
const store = new FileStore(env, { platform: 'linux' })
|
|
164
|
+
expect(store.load()).toBeNull()
|
|
165
|
+
store.save(SAMPLE)
|
|
166
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
167
|
+
expect(existsSync(file)).toBe(true)
|
|
168
|
+
if (process.platform !== 'win32') {
|
|
169
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
170
|
+
}
|
|
171
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(SAMPLE)
|
|
172
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
173
|
+
store.save(OTHER)
|
|
174
|
+
expect(store.load()).toEqual(OTHER)
|
|
175
|
+
store.clear()
|
|
176
|
+
expect(existsSync(file)).toBe(false)
|
|
177
|
+
expect(store.load()).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('treats malformed JSON as missing', () => {
|
|
181
|
+
const { env } = sandbox('file-malformed')
|
|
182
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
183
|
+
writeRaw(file, '{not-json')
|
|
184
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('treats incomplete objects as missing', () => {
|
|
188
|
+
const { env } = sandbox('file-incomplete')
|
|
189
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
190
|
+
writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
|
|
191
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('WindowsDpapiStore', () => {
|
|
196
|
+
it('protects under LOCALAPPDATA and never writes roaming or config trees', () => {
|
|
197
|
+
const { env, roaming, config } = sandbox('dpapi-location')
|
|
198
|
+
const { runner, calls } = mockDpapi()
|
|
199
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
200
|
+
store.save(SAMPLE)
|
|
201
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
202
|
+
expect(existsSync(file)).toBe(true)
|
|
203
|
+
const onDisk = readFileSync(file, 'utf8')
|
|
204
|
+
expect(onDisk).not.toContain(SAMPLE.secret)
|
|
205
|
+
expect(onDisk).not.toContain(SAMPLE.machineId)
|
|
206
|
+
expect(JSON.parse(onDisk)).toMatchObject({ format: 'notifai.dpapi.current-user.v1' })
|
|
207
|
+
expect(readdirSync(roaming, { recursive: true })).toEqual([])
|
|
208
|
+
expect(existsSync(config) ? readdirSync(config, { recursive: true }) : []).toEqual([])
|
|
209
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
210
|
+
expect(store.describe()).toBe(`Windows DPAPI current-user (${file})`)
|
|
211
|
+
expect(calls[0]?.command).toMatch(/powershell\.exe$/i)
|
|
212
|
+
expect(calls[0]?.args).toEqual(
|
|
213
|
+
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass']),
|
|
214
|
+
)
|
|
215
|
+
assertNoSecretInArgs(calls)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('overwrites and clears the protected blob', () => {
|
|
219
|
+
const { env } = sandbox('dpapi-overwrite')
|
|
220
|
+
const { runner, calls } = mockDpapi()
|
|
221
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
222
|
+
store.save(SAMPLE)
|
|
223
|
+
store.save(OTHER)
|
|
224
|
+
expect(store.load()).toEqual(OTHER)
|
|
225
|
+
store.clear()
|
|
226
|
+
expect(store.load()).toBeNull()
|
|
227
|
+
expect(
|
|
228
|
+
existsSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')),
|
|
229
|
+
).toBe(false)
|
|
230
|
+
assertNoSecretInArgs(calls)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('returns null for missing, malformed, and corrupt envelopes', () => {
|
|
234
|
+
const { env } = sandbox('dpapi-corrupt')
|
|
235
|
+
const { runner } = mockDpapi()
|
|
236
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
237
|
+
expect(store.load()).toBeNull()
|
|
238
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
239
|
+
writeRaw(file, 'not-json')
|
|
240
|
+
expect(store.load()).toBeNull()
|
|
241
|
+
writeRaw(file, `${JSON.stringify({ format: 'unknown', data: 'AAAA' })}\n`)
|
|
242
|
+
expect(store.load()).toBeNull()
|
|
243
|
+
writeRaw(
|
|
244
|
+
file,
|
|
245
|
+
`${JSON.stringify({ format: 'notifai.dpapi.current-user.v1', data: '@@@' })}\n`,
|
|
246
|
+
)
|
|
247
|
+
expect(store.load()).toBeNull()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('leaves the existing blob in place when protection fails', () => {
|
|
251
|
+
const { env } = sandbox('dpapi-protect-fail')
|
|
252
|
+
const good = mockDpapi()
|
|
253
|
+
const store = new WindowsDpapiStore(env, { run: good.runner })
|
|
254
|
+
store.save(SAMPLE)
|
|
255
|
+
const failing = mockDpapi({ failProtect: true })
|
|
256
|
+
const broken = new WindowsDpapiStore(env, { run: failing.runner })
|
|
257
|
+
expect(() => broken.save(OTHER)).toThrow(/Windows credential protection failed/)
|
|
258
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
259
|
+
const onDisk = readFileSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'), 'utf8')
|
|
260
|
+
expect(onDisk).not.toContain(OTHER.secret)
|
|
261
|
+
assertNoSecretInArgs([...good.calls, ...failing.calls])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('returns null when unprotect fails', () => {
|
|
265
|
+
const { env } = sandbox('dpapi-unprotect-fail')
|
|
266
|
+
const saved = mockDpapi()
|
|
267
|
+
new WindowsDpapiStore(env, { run: saved.runner }).save(SAMPLE)
|
|
268
|
+
const failing = mockDpapi({ failUnprotect: true })
|
|
269
|
+
expect(new WindowsDpapiStore(env, { run: failing.runner }).load()).toBeNull()
|
|
270
|
+
assertNoSecretInArgs([...saved.calls, ...failing.calls])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('refuses to save when LOCALAPPDATA is missing', () => {
|
|
274
|
+
const store = new WindowsDpapiStore({}, { run: mockDpapi().runner })
|
|
275
|
+
expect(store.load()).toBeNull()
|
|
276
|
+
expect(() => store.save(SAMPLE)).toThrow(/LOCALAPPDATA is missing/)
|
|
277
|
+
expect(store.describe()).toBe('Windows DPAPI current-user (LOCALAPPDATA unset)')
|
|
278
|
+
store.clear()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('does not invoke a process for a missing or corrupt file', () => {
|
|
282
|
+
const { env } = sandbox('dpapi-no-process')
|
|
283
|
+
const { runner, calls } = mockDpapi()
|
|
284
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
285
|
+
expect(store.load()).toBeNull()
|
|
286
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
287
|
+
writeRaw(file, 'corrupt')
|
|
288
|
+
expect(store.load()).toBeNull()
|
|
289
|
+
expect(calls).toEqual([])
|
|
290
|
+
})
|
|
291
|
+
})
|