@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +31 -39
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/config.js +5 -9
  8. package/dist/config.js.map +1 -1
  9. package/dist/credentials.d.ts +54 -3
  10. package/dist/credentials.js +237 -18
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/file-lock.js +15 -6
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/hook-adapter.d.ts +6 -2
  15. package/dist/hook-adapter.js +110 -29
  16. package/dist/hook-adapter.js.map +1 -1
  17. package/dist/hooks.js +2 -1
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +50 -21
  20. package/dist/install-hooks.js +141 -57
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/native-skills.js +5 -10
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/opencode-plugin.d.ts +7 -2
  25. package/dist/opencode-plugin.js +142 -4
  26. package/dist/opencode-plugin.js.map +1 -1
  27. package/dist/platform.d.ts +73 -0
  28. package/dist/platform.js +143 -0
  29. package/dist/platform.js.map +1 -0
  30. package/dist/ui/config-view.js +5 -1
  31. package/dist/ui/config-view.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands.test.ts +100 -26
  34. package/src/commands.ts +49 -36
  35. package/src/config.ts +11 -9
  36. package/src/credentials.test.ts +291 -0
  37. package/src/credentials.ts +274 -17
  38. package/src/file-lock.ts +34 -9
  39. package/src/hook-adapter.test.ts +136 -0
  40. package/src/hook-adapter.ts +120 -28
  41. package/src/hooks.test.ts +27 -1
  42. package/src/hooks.ts +2 -1
  43. package/src/install-hooks.test.ts +174 -33
  44. package/src/install-hooks.ts +186 -54
  45. package/src/native-skills.ts +5 -10
  46. package/src/opencode-plugin.ts +148 -3
  47. package/src/platform.test.ts +152 -0
  48. package/src/platform.ts +173 -0
  49. 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: { adapterPath: string; timeoutSeconds: number },
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(harness, flags.global ?? false, deps.cwd, deps.env)
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
- const xdg = deps.env['XDG_STATE_HOME']
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(installation: Installation): string[] {
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: (url) => {
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(env: NodeJS.ProcessEnv = process.env): string {
167
- const xdg = env['XDG_CONFIG_HOME']
168
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
169
- return path.join(base, 'notifai')
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(env: NodeJS.ProcessEnv = process.env): string {
182
- const xdg = env['XDG_STATE_HOME']
183
- const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state')
184
- return path.join(base, 'notifai')
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
+ })