@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/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,15 +2211,17 @@ 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
2223
|
const wantGlobal = flags.global === true
|
|
2218
|
-
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome).filter(
|
|
2224
|
+
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter(
|
|
2219
2225
|
(installation) => installation.harness === harness,
|
|
2220
2226
|
)
|
|
2221
2227
|
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
@@ -2231,7 +2237,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2231
2237
|
return EXIT.failed
|
|
2232
2238
|
}
|
|
2233
2239
|
}
|
|
2234
|
-
const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env)
|
|
2240
|
+
const file = settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
2235
2241
|
|
|
2236
2242
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
2237
2243
|
// merged into a settings document, so it owns the whole file.
|
|
@@ -2239,6 +2245,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2239
2245
|
return installOpencodePlugin(deps, file, {
|
|
2240
2246
|
adapterPath,
|
|
2241
2247
|
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
2248
|
+
platform: hookPlatform,
|
|
2249
|
+
nodePath,
|
|
2242
2250
|
})
|
|
2243
2251
|
}
|
|
2244
2252
|
|
|
@@ -2251,6 +2259,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2251
2259
|
buildCursorHookConfig({
|
|
2252
2260
|
adapterPath,
|
|
2253
2261
|
harness: 'cursor',
|
|
2262
|
+
platform: hookPlatform,
|
|
2263
|
+
nodePath,
|
|
2254
2264
|
}),
|
|
2255
2265
|
scriptPath,
|
|
2256
2266
|
)
|
|
@@ -2276,6 +2286,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2276
2286
|
buildHookConfig({
|
|
2277
2287
|
adapterPath,
|
|
2278
2288
|
harness,
|
|
2289
|
+
platform: hookPlatform,
|
|
2290
|
+
nodePath,
|
|
2279
2291
|
}),
|
|
2280
2292
|
scriptPath,
|
|
2281
2293
|
)
|
|
@@ -2331,7 +2343,12 @@ function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { comm
|
|
|
2331
2343
|
function installOpencodePlugin(
|
|
2332
2344
|
deps: CommandDeps,
|
|
2333
2345
|
file: string,
|
|
2334
|
-
options: {
|
|
2346
|
+
options: {
|
|
2347
|
+
adapterPath: string
|
|
2348
|
+
timeoutSeconds: number
|
|
2349
|
+
platform?: NodeJS.Platform
|
|
2350
|
+
nodePath?: string
|
|
2351
|
+
},
|
|
2335
2352
|
): number {
|
|
2336
2353
|
try {
|
|
2337
2354
|
withTargetFileLock(file, () => {
|
|
@@ -2360,7 +2377,13 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2360
2377
|
const harness = resolveHarness(deps, flags.harness)
|
|
2361
2378
|
if (!harness) return EXIT.usage
|
|
2362
2379
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2363
|
-
const file = settingsFile(
|
|
2380
|
+
const file = settingsFile(
|
|
2381
|
+
harness,
|
|
2382
|
+
flags.global ?? false,
|
|
2383
|
+
deps.cwd,
|
|
2384
|
+
deps.env,
|
|
2385
|
+
deps.hookPlatform,
|
|
2386
|
+
)
|
|
2364
2387
|
if (harness === 'opencode') {
|
|
2365
2388
|
try {
|
|
2366
2389
|
return withTargetFileLock(file, () => {
|
|
@@ -3193,9 +3216,7 @@ function setupProofPath(deps: CommandDeps): string {
|
|
|
3193
3216
|
// the resolved absolute path is still a stable local identity for it.
|
|
3194
3217
|
}
|
|
3195
3218
|
const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32)
|
|
3196
|
-
|
|
3197
|
-
const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
|
|
3198
|
-
return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`)
|
|
3219
|
+
return path.join(stateDir(deps.env), 'setup-proofs', `${digest}.json`)
|
|
3199
3220
|
}
|
|
3200
3221
|
|
|
3201
3222
|
function readSetupProof(deps: CommandDeps): SetupProofRecord | null {
|
|
@@ -3579,6 +3600,9 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
3579
3600
|
const observed = observedCompanionReceipt(snapshot, proof.device_id)
|
|
3580
3601
|
if (observed) {
|
|
3581
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
|
+
)
|
|
3582
3606
|
return 'closed'
|
|
3583
3607
|
}
|
|
3584
3608
|
lastError = null
|
|
@@ -4399,7 +4423,7 @@ function remedyLine(state: ReadinessState): string {
|
|
|
4399
4423
|
* is, is each check's own call (`HookCheck`).
|
|
4400
4424
|
*/
|
|
4401
4425
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
4402
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
4426
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
4403
4427
|
const active = activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)())
|
|
4404
4428
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
4405
4429
|
const settings: ReadinessState = {
|
|
@@ -4492,13 +4516,16 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
4492
4516
|
* hashes into `trusted_hash`, and an untrusted handler is simply not run.
|
|
4493
4517
|
* - Everything else blocks its turn and needs a ceiling above the wait.
|
|
4494
4518
|
*/
|
|
4495
|
-
function stopShapeProblems(
|
|
4519
|
+
function stopShapeProblems(
|
|
4520
|
+
installation: Installation,
|
|
4521
|
+
platform: NodeJS.Platform = process.platform,
|
|
4522
|
+
): string[] {
|
|
4496
4523
|
if (installation.harness === 'codex') return []
|
|
4497
4524
|
const problems: string[] = []
|
|
4498
4525
|
for (const handler of installation.handlers.filter(
|
|
4499
4526
|
(entry) => handlerEvent(entry.command) === 'stop',
|
|
4500
4527
|
)) {
|
|
4501
|
-
if (installation.harness === 'claude-code') {
|
|
4528
|
+
if (installation.harness === 'claude-code' && platform !== 'win32') {
|
|
4502
4529
|
if (handler.async !== true) {
|
|
4503
4530
|
problems.push(
|
|
4504
4531
|
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
@@ -4532,7 +4559,7 @@ interface HookCheck {
|
|
|
4532
4559
|
|
|
4533
4560
|
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
4534
4561
|
const checks: HookCheck[] = []
|
|
4535
|
-
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome)
|
|
4562
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
4536
4563
|
|
|
4537
4564
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
4538
4565
|
// without them. A setup that cannot work is what deserves to go red.
|
|
@@ -4651,7 +4678,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4651
4678
|
const adapterProblems = installations.flatMap((installation) =>
|
|
4652
4679
|
(installation.problems ?? []).map((problem) => `${installation.file}: ${problem}`),
|
|
4653
4680
|
)
|
|
4654
|
-
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome)
|
|
4681
|
+
const sharedAdapter = inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform)
|
|
4655
4682
|
adapterProblems.push(...sharedAdapter.problems)
|
|
4656
4683
|
if (adapterProblems.length > 0) {
|
|
4657
4684
|
checks.push({
|
|
@@ -4683,7 +4710,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4683
4710
|
})
|
|
4684
4711
|
|
|
4685
4712
|
const shapeProblems = installations.flatMap((installation) =>
|
|
4686
|
-
stopShapeProblems(installation).map(
|
|
4713
|
+
stopShapeProblems(installation, deps.hookPlatform).map(
|
|
4687
4714
|
(problem) =>
|
|
4688
4715
|
`${problem} — run \`notifai hooks install --harness ${installation.harness}${installation.global ? ' --global' : ''}\``,
|
|
4689
4716
|
),
|
|
@@ -4896,7 +4923,7 @@ function wakeRouteCheck(
|
|
|
4896
4923
|
if (active.harness === 'claude-code') {
|
|
4897
4924
|
const readiness = inspectClaudeInbox({
|
|
4898
4925
|
pid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
4899
|
-
platform: process.platform,
|
|
4926
|
+
platform: deps.hookPlatform ?? process.platform,
|
|
4900
4927
|
readDescriptor:
|
|
4901
4928
|
deps.claudeWake?.readDescriptor ?? systemClaudeWakeAdapters(deps.env).readDescriptor,
|
|
4902
4929
|
socketExists: (socketPath) => existsSync(socketPath),
|
|
@@ -4913,7 +4940,7 @@ function wakeRouteCheck(
|
|
|
4913
4940
|
}
|
|
4914
4941
|
if (active.harness === 'codex') {
|
|
4915
4942
|
const readiness = inspectCodexResume(deps.env, {
|
|
4916
|
-
platform: process.platform,
|
|
4943
|
+
platform: deps.hookPlatform ?? process.platform,
|
|
4917
4944
|
directoryExists: (directory) => existsSync(directory),
|
|
4918
4945
|
})
|
|
4919
4946
|
return {
|
|
@@ -5069,20 +5096,6 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
5069
5096
|
return log.error(message)
|
|
5070
5097
|
}
|
|
5071
5098
|
},
|
|
5072
|
-
openUrl
|
|
5073
|
-
try {
|
|
5074
|
-
if (process.platform === 'darwin') {
|
|
5075
|
-
spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
5076
|
-
} else if (process.platform === 'win32') {
|
|
5077
|
-
// `start` is a cmd builtin; spawn('start', …) cannot work on Windows.
|
|
5078
|
-
// Empty title argument keeps URLs with special characters intact.
|
|
5079
|
-
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
|
|
5080
|
-
} else {
|
|
5081
|
-
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
5082
|
-
}
|
|
5083
|
-
} catch {
|
|
5084
|
-
// Browser opening is best-effort; the URL is printed anyway.
|
|
5085
|
-
}
|
|
5086
|
-
},
|
|
5099
|
+
openUrl,
|
|
5087
5100
|
}
|
|
5088
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
|
+
})
|