@they-juanreina/compost-cli 0.1.2 → 0.1.4

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 (313) hide show
  1. package/dist/commands/agreement.d.ts +3 -0
  2. package/dist/commands/agreement.d.ts.map +1 -0
  3. package/dist/commands/agreement.js +35 -0
  4. package/dist/commands/agreement.js.map +1 -0
  5. package/dist/commands/backup.d.ts +3 -0
  6. package/dist/commands/backup.d.ts.map +1 -0
  7. package/dist/commands/backup.js +31 -0
  8. package/dist/commands/backup.js.map +1 -0
  9. package/dist/commands/chat.d.ts.map +1 -1
  10. package/dist/commands/chat.js +3 -2
  11. package/dist/commands/chat.js.map +1 -1
  12. package/dist/commands/create.d.ts +1 -0
  13. package/dist/commands/create.d.ts.map +1 -1
  14. package/dist/commands/create.js +39 -1
  15. package/dist/commands/create.js.map +1 -1
  16. package/dist/commands/export.d.ts.map +1 -1
  17. package/dist/commands/export.js +47 -4
  18. package/dist/commands/export.js.map +1 -1
  19. package/dist/commands/import.d.ts +3 -0
  20. package/dist/commands/import.d.ts.map +1 -0
  21. package/dist/commands/import.js +64 -0
  22. package/dist/commands/import.js.map +1 -0
  23. package/dist/commands/ingest.d.ts.map +1 -1
  24. package/dist/commands/ingest.js +1 -0
  25. package/dist/commands/ingest.js.map +1 -1
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +2 -0
  28. package/dist/commands/init.js.map +1 -1
  29. package/dist/commands/jobs.d.ts +3 -0
  30. package/dist/commands/jobs.d.ts.map +1 -0
  31. package/dist/commands/jobs.js +105 -0
  32. package/dist/commands/jobs.js.map +1 -0
  33. package/dist/commands/label.d.ts +3 -0
  34. package/dist/commands/label.d.ts.map +1 -0
  35. package/dist/commands/label.js +67 -0
  36. package/dist/commands/label.js.map +1 -0
  37. package/dist/commands/models.d.ts.map +1 -1
  38. package/dist/commands/models.js +2 -1
  39. package/dist/commands/models.js.map +1 -1
  40. package/dist/commands/recode.d.ts +3 -0
  41. package/dist/commands/recode.d.ts.map +1 -0
  42. package/dist/commands/recode.js +60 -0
  43. package/dist/commands/recode.js.map +1 -0
  44. package/dist/commands/reindex.d.ts.map +1 -1
  45. package/dist/commands/reindex.js +19 -7
  46. package/dist/commands/reindex.js.map +1 -1
  47. package/dist/commands/rerun.d.ts +3 -0
  48. package/dist/commands/rerun.d.ts.map +1 -0
  49. package/dist/commands/rerun.js +91 -0
  50. package/dist/commands/rerun.js.map +1 -0
  51. package/dist/commands/search.d.ts.map +1 -1
  52. package/dist/commands/search.js +12 -1
  53. package/dist/commands/search.js.map +1 -1
  54. package/dist/commands/secrets.d.ts +3 -0
  55. package/dist/commands/secrets.d.ts.map +1 -0
  56. package/dist/commands/secrets.js +145 -0
  57. package/dist/commands/secrets.js.map +1 -0
  58. package/dist/commands/setup.d.ts.map +1 -1
  59. package/dist/commands/setup.js +95 -2
  60. package/dist/commands/setup.js.map +1 -1
  61. package/dist/commands/setupItem.d.ts +26 -0
  62. package/dist/commands/setupItem.d.ts.map +1 -0
  63. package/dist/commands/setupItem.js +145 -0
  64. package/dist/commands/setupItem.js.map +1 -0
  65. package/dist/commands/status.d.ts.map +1 -1
  66. package/dist/commands/status.js +2 -1
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/transcribe.d.ts.map +1 -1
  69. package/dist/commands/transcribe.js +32 -4
  70. package/dist/commands/transcribe.js.map +1 -1
  71. package/dist/commands/validate.d.ts.map +1 -1
  72. package/dist/commands/validate.js +29 -1
  73. package/dist/commands/validate.js.map +1 -1
  74. package/dist/commands/watch.d.ts.map +1 -1
  75. package/dist/commands/watch.js +53 -6
  76. package/dist/commands/watch.js.map +1 -1
  77. package/dist/engine.d.ts +23 -0
  78. package/dist/engine.d.ts.map +1 -0
  79. package/dist/engine.js +32 -0
  80. package/dist/engine.js.map +1 -0
  81. package/dist/errors.d.ts +5 -1
  82. package/dist/errors.d.ts.map +1 -1
  83. package/dist/errors.js +6 -0
  84. package/dist/errors.js.map +1 -1
  85. package/dist/exporters/pdf.d.ts.map +1 -1
  86. package/dist/exporters/pdf.js +2 -1
  87. package/dist/exporters/pdf.js.map +1 -1
  88. package/dist/exporters/prov.d.ts +11 -0
  89. package/dist/exporters/prov.d.ts.map +1 -0
  90. package/dist/exporters/prov.js +151 -0
  91. package/dist/exporters/prov.js.map +1 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +6 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/legacy_client.d.ts.map +1 -1
  96. package/dist/legacy_client.js +2 -1
  97. package/dist/legacy_client.js.map +1 -1
  98. package/dist/lib/agreement.d.ts +77 -0
  99. package/dist/lib/agreement.d.ts.map +1 -0
  100. package/dist/lib/agreement.js +261 -0
  101. package/dist/lib/agreement.js.map +1 -0
  102. package/dist/lib/artifacts.d.ts +35 -2
  103. package/dist/lib/artifacts.d.ts.map +1 -1
  104. package/dist/lib/artifacts.js +158 -29
  105. package/dist/lib/artifacts.js.map +1 -1
  106. package/dist/lib/backup.d.ts +37 -0
  107. package/dist/lib/backup.d.ts.map +1 -0
  108. package/dist/lib/backup.js +57 -0
  109. package/dist/lib/backup.js.map +1 -0
  110. package/dist/lib/childEnv.d.ts +13 -0
  111. package/dist/lib/childEnv.d.ts.map +1 -0
  112. package/dist/lib/childEnv.js +45 -0
  113. package/dist/lib/childEnv.js.map +1 -0
  114. package/dist/lib/config.d.ts +3 -0
  115. package/dist/lib/config.d.ts.map +1 -1
  116. package/dist/lib/config.js.map +1 -1
  117. package/dist/lib/doctor.d.ts +3 -0
  118. package/dist/lib/doctor.d.ts.map +1 -1
  119. package/dist/lib/doctor.js +24 -1
  120. package/dist/lib/doctor.js.map +1 -1
  121. package/dist/lib/events.d.ts +44 -1
  122. package/dist/lib/events.d.ts.map +1 -1
  123. package/dist/lib/events.js +55 -2
  124. package/dist/lib/events.js.map +1 -1
  125. package/dist/lib/importTranscript.d.ts +16 -0
  126. package/dist/lib/importTranscript.d.ts.map +1 -0
  127. package/dist/lib/importTranscript.js +94 -0
  128. package/dist/lib/importTranscript.js.map +1 -0
  129. package/dist/lib/ingest.d.ts.map +1 -1
  130. package/dist/lib/ingest.js +12 -6
  131. package/dist/lib/ingest.js.map +1 -1
  132. package/dist/lib/journal.d.ts +13 -4
  133. package/dist/lib/journal.d.ts.map +1 -1
  134. package/dist/lib/journal.js +53 -16
  135. package/dist/lib/journal.js.map +1 -1
  136. package/dist/lib/legacyNative.d.ts +17 -0
  137. package/dist/lib/legacyNative.d.ts.map +1 -0
  138. package/dist/lib/legacyNative.js +38 -0
  139. package/dist/lib/legacyNative.js.map +1 -0
  140. package/dist/lib/migrate.d.ts.map +1 -1
  141. package/dist/lib/migrate.js +9 -2
  142. package/dist/lib/migrate.js.map +1 -1
  143. package/dist/lib/nativeRuntime.d.ts +31 -0
  144. package/dist/lib/nativeRuntime.d.ts.map +1 -1
  145. package/dist/lib/nativeRuntime.js +38 -0
  146. package/dist/lib/nativeRuntime.js.map +1 -1
  147. package/dist/lib/pathSafe.d.ts +8 -0
  148. package/dist/lib/pathSafe.d.ts.map +1 -0
  149. package/dist/lib/pathSafe.js +12 -0
  150. package/dist/lib/pathSafe.js.map +1 -0
  151. package/dist/lib/provisionNative.d.ts.map +1 -1
  152. package/dist/lib/provisionNative.js +7 -3
  153. package/dist/lib/provisionNative.js.map +1 -1
  154. package/dist/lib/queue.d.ts +25 -0
  155. package/dist/lib/queue.d.ts.map +1 -1
  156. package/dist/lib/queue.js +70 -3
  157. package/dist/lib/queue.js.map +1 -1
  158. package/dist/lib/reads.d.ts +24 -0
  159. package/dist/lib/reads.d.ts.map +1 -0
  160. package/dist/lib/reads.js +115 -0
  161. package/dist/lib/reads.js.map +1 -0
  162. package/dist/lib/recode.d.ts +19 -0
  163. package/dist/lib/recode.d.ts.map +1 -0
  164. package/dist/lib/recode.js +43 -0
  165. package/dist/lib/recode.js.map +1 -0
  166. package/dist/lib/redact.d.ts +7 -0
  167. package/dist/lib/redact.d.ts.map +1 -0
  168. package/dist/lib/redact.js +45 -0
  169. package/dist/lib/redact.js.map +1 -0
  170. package/dist/lib/rerun.d.ts +51 -0
  171. package/dist/lib/rerun.d.ts.map +1 -0
  172. package/dist/lib/rerun.js +160 -0
  173. package/dist/lib/rerun.js.map +1 -0
  174. package/dist/lib/retrieve.d.ts +8 -4
  175. package/dist/lib/retrieve.d.ts.map +1 -1
  176. package/dist/lib/retrieve.js +14 -3
  177. package/dist/lib/retrieve.js.map +1 -1
  178. package/dist/lib/saturate.js +3 -3
  179. package/dist/lib/saturate.js.map +1 -1
  180. package/dist/lib/schemas.generated.d.ts.map +1 -1
  181. package/dist/lib/schemas.generated.js +28 -0
  182. package/dist/lib/schemas.generated.js.map +1 -1
  183. package/dist/lib/secrets.d.ts +158 -0
  184. package/dist/lib/secrets.d.ts.map +1 -0
  185. package/dist/lib/secrets.js +514 -0
  186. package/dist/lib/secrets.js.map +1 -0
  187. package/dist/lib/seed.d.ts +5 -0
  188. package/dist/lib/seed.d.ts.map +1 -1
  189. package/dist/lib/seed.js +15 -2
  190. package/dist/lib/seed.js.map +1 -1
  191. package/dist/lib/seedResolve.d.ts +5 -0
  192. package/dist/lib/seedResolve.d.ts.map +1 -1
  193. package/dist/lib/seedResolve.js +12 -3
  194. package/dist/lib/seedResolve.js.map +1 -1
  195. package/dist/lib/session.d.ts +14 -0
  196. package/dist/lib/session.d.ts.map +1 -1
  197. package/dist/lib/session.js +53 -4
  198. package/dist/lib/session.js.map +1 -1
  199. package/dist/lib/sessionId.d.ts +9 -0
  200. package/dist/lib/sessionId.d.ts.map +1 -0
  201. package/dist/lib/sessionId.js +37 -0
  202. package/dist/lib/sessionId.js.map +1 -0
  203. package/dist/lib/setup.d.ts +9 -0
  204. package/dist/lib/setup.d.ts.map +1 -1
  205. package/dist/lib/setup.js +97 -29
  206. package/dist/lib/setup.js.map +1 -1
  207. package/dist/lib/setupItem.d.ts +99 -0
  208. package/dist/lib/setupItem.d.ts.map +1 -0
  209. package/dist/lib/setupItem.js +262 -0
  210. package/dist/lib/setupItem.js.map +1 -0
  211. package/dist/lib/setupWizard.d.ts +53 -0
  212. package/dist/lib/setupWizard.d.ts.map +1 -0
  213. package/dist/lib/setupWizard.js +347 -0
  214. package/dist/lib/setupWizard.js.map +1 -0
  215. package/dist/lib/snap.d.ts.map +1 -1
  216. package/dist/lib/snap.js +5 -0
  217. package/dist/lib/snap.js.map +1 -1
  218. package/dist/lib/speakers.d.ts +41 -0
  219. package/dist/lib/speakers.d.ts.map +1 -0
  220. package/dist/lib/speakers.js +78 -0
  221. package/dist/lib/speakers.js.map +1 -0
  222. package/dist/lib/status.d.ts.map +1 -1
  223. package/dist/lib/status.js +21 -0
  224. package/dist/lib/status.js.map +1 -1
  225. package/dist/lib/stdin.d.ts +5 -0
  226. package/dist/lib/stdin.d.ts.map +1 -0
  227. package/dist/lib/stdin.js +12 -0
  228. package/dist/lib/stdin.js.map +1 -0
  229. package/dist/lib/transcribeNative.d.ts +4 -9
  230. package/dist/lib/transcribeNative.d.ts.map +1 -1
  231. package/dist/lib/transcribeNative.js +11 -26
  232. package/dist/lib/transcribeNative.js.map +1 -1
  233. package/dist/lib/userConfig.d.ts +22 -0
  234. package/dist/lib/userConfig.d.ts.map +1 -0
  235. package/dist/lib/userConfig.js +67 -0
  236. package/dist/lib/userConfig.js.map +1 -0
  237. package/dist/lib/validate.d.ts +18 -0
  238. package/dist/lib/validate.d.ts.map +1 -1
  239. package/dist/lib/validate.js +71 -1
  240. package/dist/lib/validate.js.map +1 -1
  241. package/dist/lib/version.d.ts +30 -0
  242. package/dist/lib/version.d.ts.map +1 -0
  243. package/dist/lib/version.js +65 -0
  244. package/dist/lib/version.js.map +1 -0
  245. package/dist/llm/adapter.d.ts +5 -0
  246. package/dist/llm/adapter.d.ts.map +1 -1
  247. package/dist/llm/adapter.js +27 -8
  248. package/dist/llm/adapter.js.map +1 -1
  249. package/dist/llm/http.d.ts +22 -1
  250. package/dist/llm/http.d.ts.map +1 -1
  251. package/dist/llm/http.js +52 -30
  252. package/dist/llm/http.js.map +1 -1
  253. package/dist/llm/providers/anthropic.d.ts.map +1 -1
  254. package/dist/llm/providers/anthropic.js +6 -9
  255. package/dist/llm/providers/anthropic.js.map +1 -1
  256. package/dist/llm/providers/ollama.d.ts.map +1 -1
  257. package/dist/llm/providers/ollama.js +8 -7
  258. package/dist/llm/providers/ollama.js.map +1 -1
  259. package/dist/llm/providers/openai_compatible.d.ts.map +1 -1
  260. package/dist/llm/providers/openai_compatible.js +2 -7
  261. package/dist/llm/providers/openai_compatible.js.map +1 -1
  262. package/dist/logging.d.ts.map +1 -1
  263. package/dist/logging.js +3 -1
  264. package/dist/logging.js.map +1 -1
  265. package/dist/loops/embed_worker.d.ts +3 -0
  266. package/dist/loops/embed_worker.d.ts.map +1 -1
  267. package/dist/loops/embed_worker.js +11 -4
  268. package/dist/loops/embed_worker.js.map +1 -1
  269. package/dist/loops/ingest_watcher.d.ts.map +1 -1
  270. package/dist/loops/ingest_watcher.js +6 -3
  271. package/dist/loops/ingest_watcher.js.map +1 -1
  272. package/dist/loops/legacy_worker.d.ts +28 -1
  273. package/dist/loops/legacy_worker.d.ts.map +1 -1
  274. package/dist/loops/legacy_worker.js +91 -10
  275. package/dist/loops/legacy_worker.js.map +1 -1
  276. package/dist/loops/supervisor.d.ts +7 -0
  277. package/dist/loops/supervisor.d.ts.map +1 -1
  278. package/dist/loops/supervisor.js +17 -2
  279. package/dist/loops/supervisor.js.map +1 -1
  280. package/dist/loops/synthesis.d.ts.map +1 -1
  281. package/dist/loops/synthesis.js +15 -0
  282. package/dist/loops/synthesis.js.map +1 -1
  283. package/dist/loops/transcribe_worker.d.ts +3 -0
  284. package/dist/loops/transcribe_worker.d.ts.map +1 -1
  285. package/dist/loops/transcribe_worker.js +15 -7
  286. package/dist/loops/transcribe_worker.js.map +1 -1
  287. package/dist/output.d.ts +13 -1
  288. package/dist/output.d.ts.map +1 -1
  289. package/dist/output.js +27 -6
  290. package/dist/output.js.map +1 -1
  291. package/dist/render/glyphs.d.ts +30 -0
  292. package/dist/render/glyphs.d.ts.map +1 -0
  293. package/dist/render/glyphs.js +38 -0
  294. package/dist/render/glyphs.js.map +1 -0
  295. package/dist/render/human.d.ts +22 -0
  296. package/dist/render/human.d.ts.map +1 -0
  297. package/dist/render/human.js +62 -0
  298. package/dist/render/human.js.map +1 -0
  299. package/dist/router.d.ts.map +1 -1
  300. package/dist/router.js +32 -4
  301. package/dist/router.js.map +1 -1
  302. package/dist/transcriber_client.d.ts.map +1 -1
  303. package/dist/transcriber_client.js +2 -1
  304. package/dist/transcriber_client.js.map +1 -1
  305. package/package.json +12 -5
  306. package/templates/AGENTS.md +1 -1
  307. package/templates/config.toml +6 -1
  308. package/transcriber/app/diarization.py +36 -6
  309. package/transcriber/app/legacy_cli.py +90 -0
  310. package/transcriber/app/pipeline.py +13 -7
  311. package/transcriber/app/prosody.py +5 -0
  312. package/transcriber/app/transcribe_cli.py +15 -1
  313. package/transcriber/app/vad.py +82 -11
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Secret resolution + storage (#236 readiness hardening).
3
+ *
4
+ * Secrets (HuggingFace token, LLM provider API keys) are NEVER written into a
5
+ * seed, `config.toml`, or the event ledger — `config.toml` stores only the
6
+ * *name* of an env var (`api_key_env`). This module adds two secure conveniences
7
+ * around the primary env-var mechanism, with a single documented precedence:
8
+ *
9
+ * 1. environment variable (process.env[name]) — primary, always wins
10
+ * 2. OS keychain (macOS `security` / Linux `secret-tool`)
11
+ * 3. ~/.compost/secrets.env (0600-enforced dotenv; refused if loose)
12
+ *
13
+ * The keychain tier shells out to the OS-native tool rather than pulling a
14
+ * native npm dependency — keeping the supply chain tight (see SECURITY.md).
15
+ * Where no keychain exists (Windows, headless Linux without libsecret) storage
16
+ * falls back to the 0600 dotenv. The dotenv is auto-loaded into `process.env`
17
+ * at CLI startup so file-stored secrets resolve everywhere the env var does,
18
+ * without the user editing a shell profile.
19
+ */
20
+ /** Keychain service name (macOS `-s` / Linux `service` attribute). */
21
+ export declare const KEYCHAIN_SERVICE = "compost";
22
+ /** Where a secret was resolved from. */
23
+ export type SecretSource = 'env' | 'keychain' | 'file';
24
+ export interface ResolvedSecret {
25
+ value: string;
26
+ source: SecretSource;
27
+ }
28
+ /**
29
+ * Well-known secret names. Used by `compost secrets list` (which never reads
30
+ * the value, only reports presence) and to decide what's worth probing in the
31
+ * keychain. Not a hard allow-list — `set`/`get`/`rm` accept any valid env name.
32
+ */
33
+ export declare const KNOWN_SECRET_NAMES: readonly ["HUGGINGFACE_TOKEN", "HF_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
34
+ /** Default aliases checked alongside a primary name when resolving. */
35
+ export declare const HF_ALIASES: string[];
36
+ /** A pluggable keychain backend. Real impls shell out; tests inject a fake. */
37
+ export interface KeychainBackend {
38
+ /** Human label for messages, e.g. "macOS Keychain". */
39
+ readonly label: string;
40
+ /** Return the stored secret, or undefined if absent / the tool errors. */
41
+ get(name: string): string | undefined;
42
+ /** Store (or replace) the secret. Throws if the backend is unusable. */
43
+ set(name: string, value: string): void;
44
+ /** Remove the secret. Returns true if something was removed. */
45
+ del(name: string): boolean;
46
+ }
47
+ export interface SecretsDeps {
48
+ env?: NodeJS.ProcessEnv;
49
+ platform?: NodeJS.Platform;
50
+ /**
51
+ * Explicit `~/.compost` root override (tests, or a custom location). Wins over
52
+ * `$COMPOST_HOME` / `homedir()`.
53
+ */
54
+ home?: string;
55
+ /**
56
+ * Keychain backend. Omitted → auto-detected from the platform. Pass `null` to
57
+ * force "no keychain" (file-only) — used by tests and by `$COMPOST_NO_KEYCHAIN`.
58
+ */
59
+ keychain?: KeychainBackend | null;
60
+ }
61
+ /** Resolve the `~/.compost` root. `$COMPOST_HOME` overrides the default; an
62
+ * explicit `deps.home` overrides everything (mirrors nativeRuntime.ts). */
63
+ export declare function compostHome(deps?: SecretsDeps): string;
64
+ /** Path to the 0600 dotenv. `$COMPOST_SECRETS_ENV` points it at a custom file
65
+ * (e.g. an existing per-user dotenv) without moving the rest of `~/.compost`. */
66
+ export declare function secretsEnvPath(deps?: SecretsDeps): string;
67
+ export interface PermIssue {
68
+ path: string;
69
+ kind: 'file' | 'dir';
70
+ /** Octal perms as observed, e.g. "644". */
71
+ mode: string;
72
+ /** Copy-pasteable fix. */
73
+ fix: string;
74
+ detail: string;
75
+ }
76
+ /** POSIX-only secrecy test: a secret file must have no group/other bits. On
77
+ * Windows (ACL model) mode bits aren't meaningful, so we treat it as secure. */
78
+ export declare function fileIsSecure(path: string, platform?: NodeJS.Platform): boolean;
79
+ /** Validate an env-var-shaped secret name; throws INVALID_INPUT otherwise. */
80
+ export declare function assertSecretName(name: string): void;
81
+ /** Minimal dotenv parser (no dependency). Supports `KEY=value`, `export KEY=`,
82
+ * `#` comments, blank lines, and single/double-quoted values. Malformed lines
83
+ * are skipped rather than throwing — a secrets file should never hard-fail a
84
+ * CLI invocation. */
85
+ export declare function parseDotenv(text: string): Record<string, string>;
86
+ export interface SecretsFileRead {
87
+ path: string;
88
+ exists: boolean;
89
+ /** False when the file is group/world-accessible — we then refuse to read it. */
90
+ secure: boolean;
91
+ /** Parsed values; empty `{}` when the file is missing or refused. */
92
+ values: Record<string, string>;
93
+ }
94
+ /** Read the 0600 dotenv, refusing (without reading contents) when its perms are
95
+ * loose — so an insecure file can never leak its secrets through compost. */
96
+ export declare function readSecretsFile(deps?: SecretsDeps): SecretsFileRead;
97
+ /** Auto-detect the platform keychain, or null when none is usable. */
98
+ export declare function detectKeychain(deps?: SecretsDeps): KeychainBackend | null;
99
+ export interface ResolveOpts extends SecretsDeps {
100
+ /** Extra names to try (e.g. HF_TOKEN alongside HUGGINGFACE_TOKEN). */
101
+ aliases?: string[];
102
+ }
103
+ /** Resolve a secret by the documented precedence: env > keychain > 0600 file.
104
+ * Returns undefined when set nowhere (or only in an insecure, refused file). */
105
+ export declare function resolveSecret(name: string, opts?: ResolveOpts): ResolvedSecret | undefined;
106
+ export interface SetResult {
107
+ name: string;
108
+ stored_in: 'keychain' | 'file';
109
+ /** Keychain label, or the dotenv path. */
110
+ location: string;
111
+ /** Set when a keychain write was attempted but failed and we fell back. */
112
+ fallback_reason?: string;
113
+ }
114
+ /** Store a secret. Prefers the keychain; falls back to the 0600 dotenv when no
115
+ * keychain exists or a keychain write fails. */
116
+ export declare function setSecret(name: string, value: string, deps?: SecretsDeps): SetResult;
117
+ export interface RmResult {
118
+ name: string;
119
+ removed_from: SecretSource[];
120
+ }
121
+ /** Remove a secret from the keychain and the dotenv (env vars are the user's
122
+ * shell — we can't and don't touch those). */
123
+ export declare function rmSecret(name: string, deps?: SecretsDeps): RmResult;
124
+ export interface SecretListing {
125
+ name: string;
126
+ /** Sources that currently hold this secret (never the value itself). */
127
+ sources: SecretSource[];
128
+ }
129
+ /** List which secrets are set and where — never the values. Covers the
130
+ * well-known names plus anything found in the dotenv. */
131
+ export declare function listSecrets(deps?: SecretsDeps): {
132
+ items: SecretListing[];
133
+ file: SecretsFileRead;
134
+ };
135
+ export interface AutoloadResult {
136
+ path: string;
137
+ /** Names copied into the env (only those not already set). */
138
+ loaded: string[];
139
+ /** Why nothing loaded, when applicable. */
140
+ skipped: 'not-found' | 'insecure-perms' | null;
141
+ }
142
+ /** Load `~/.compost/secrets.env` into the environment at startup so file-stored
143
+ * secrets resolve everywhere an env var would — without editing a shell profile.
144
+ * Environment variables already set WIN (never overridden). An insecure file is
145
+ * refused (not read) with a warning, preserving the "0600 or it doesn't load"
146
+ * guarantee. Mutates `deps.env` (defaults to `process.env`). */
147
+ export declare function loadSecretsEnv(deps?: SecretsDeps & {
148
+ warn?: (msg: string) => void;
149
+ }): AutoloadResult;
150
+ /** Audit secret-storage permissions under `~/.compost`. Returns issues for:
151
+ * - the home dir if group/world-WRITABLE (someone could swap your secrets),
152
+ * - the managed `secrets.env` if group/world-accessible,
153
+ * - any secret-ish file (by path: token/secret/credential/.env/.key) that's
154
+ * group/world-readable — catches hand-rolled files like the world-readable
155
+ * `~/.compost/hf_token/compost.txt` a readiness test produced.
156
+ * POSIX-only; Windows uses ACLs, so it returns []. */
157
+ export declare function auditSecretsPerms(deps?: SecretsDeps): PermIssue[];
158
+ //# sourceMappingURL=secrets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../../src/lib/secrets.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,514 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative } from 'node:path';
5
+ import { CompostError, errMessage } from '../errors.js';
6
+ /**
7
+ * Secret resolution + storage (#236 readiness hardening).
8
+ *
9
+ * Secrets (HuggingFace token, LLM provider API keys) are NEVER written into a
10
+ * seed, `config.toml`, or the event ledger — `config.toml` stores only the
11
+ * *name* of an env var (`api_key_env`). This module adds two secure conveniences
12
+ * around the primary env-var mechanism, with a single documented precedence:
13
+ *
14
+ * 1. environment variable (process.env[name]) — primary, always wins
15
+ * 2. OS keychain (macOS `security` / Linux `secret-tool`)
16
+ * 3. ~/.compost/secrets.env (0600-enforced dotenv; refused if loose)
17
+ *
18
+ * The keychain tier shells out to the OS-native tool rather than pulling a
19
+ * native npm dependency — keeping the supply chain tight (see SECURITY.md).
20
+ * Where no keychain exists (Windows, headless Linux without libsecret) storage
21
+ * falls back to the 0600 dotenv. The dotenv is auto-loaded into `process.env`
22
+ * at CLI startup so file-stored secrets resolve everywhere the env var does,
23
+ * without the user editing a shell profile.
24
+ */
25
+ /** Keychain service name (macOS `-s` / Linux `service` attribute). */
26
+ export const KEYCHAIN_SERVICE = 'compost';
27
+ /**
28
+ * Names that `loadSecretsEnv` copied from the 0600 file into `process.env` this
29
+ * run. Because the autoload makes a file-stored secret resolve via `process.env`
30
+ * first, resolution would otherwise mislabel its source as `env`. Tracking the
31
+ * autoloaded names lets `resolveSecret`/`listSecrets` report the truthful
32
+ * `file` source (the value is the file's, not a shell export). Empty until
33
+ * `loadSecretsEnv` runs (so direct unit tests are unaffected).
34
+ */
35
+ const autoloadedNames = new Set();
36
+ /**
37
+ * Well-known secret names. Used by `compost secrets list` (which never reads
38
+ * the value, only reports presence) and to decide what's worth probing in the
39
+ * keychain. Not a hard allow-list — `set`/`get`/`rm` accept any valid env name.
40
+ */
41
+ export const KNOWN_SECRET_NAMES = [
42
+ 'HUGGINGFACE_TOKEN',
43
+ 'HF_TOKEN',
44
+ 'ANTHROPIC_API_KEY',
45
+ 'OPENAI_API_KEY',
46
+ ];
47
+ /** Default aliases checked alongside a primary name when resolving. */
48
+ export const HF_ALIASES = ['HF_TOKEN'];
49
+ // ---------------------------------------------------------------------------
50
+ // Paths
51
+ // ---------------------------------------------------------------------------
52
+ /** Resolve the `~/.compost` root. `$COMPOST_HOME` overrides the default; an
53
+ * explicit `deps.home` overrides everything (mirrors nativeRuntime.ts). */
54
+ export function compostHome(deps = {}) {
55
+ if (deps.home?.trim())
56
+ return deps.home;
57
+ const env = deps.env ?? process.env;
58
+ return env.COMPOST_HOME?.trim() ? env.COMPOST_HOME : join(homedir(), '.compost');
59
+ }
60
+ /** Path to the 0600 dotenv. `$COMPOST_SECRETS_ENV` points it at a custom file
61
+ * (e.g. an existing per-user dotenv) without moving the rest of `~/.compost`. */
62
+ export function secretsEnvPath(deps = {}) {
63
+ const env = deps.env ?? process.env;
64
+ if (env.COMPOST_SECRETS_ENV?.trim())
65
+ return env.COMPOST_SECRETS_ENV;
66
+ return join(compostHome(deps), 'secrets.env');
67
+ }
68
+ function octal(mode) {
69
+ return (mode & 0o777).toString(8).padStart(3, '0');
70
+ }
71
+ /** POSIX-only secrecy test: a secret file must have no group/other bits. On
72
+ * Windows (ACL model) mode bits aren't meaningful, so we treat it as secure. */
73
+ export function fileIsSecure(path, platform = process.platform) {
74
+ if (platform === 'win32')
75
+ return true;
76
+ try {
77
+ return (statSync(path).mode & 0o077) === 0;
78
+ }
79
+ catch {
80
+ return true; // absent file can't leak
81
+ }
82
+ }
83
+ /** The POSIX mode bits of `path`, or undefined when it can't be stat'd (absent
84
+ * or unreadable) — lets a perms check skip what it can't see. */
85
+ function statMode(path) {
86
+ try {
87
+ return statSync(path).mode;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Dotenv parsing
95
+ // ---------------------------------------------------------------------------
96
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
97
+ /** Validate an env-var-shaped secret name; throws INVALID_INPUT otherwise. */
98
+ export function assertSecretName(name) {
99
+ if (!ENV_NAME_RE.test(name)) {
100
+ throw new CompostError('INVALID_INPUT', `Invalid secret name "${name}". Use an environment-variable name (letters, digits, underscore; not starting with a digit), e.g. HUGGINGFACE_TOKEN.`);
101
+ }
102
+ }
103
+ /** Minimal dotenv parser (no dependency). Supports `KEY=value`, `export KEY=`,
104
+ * `#` comments, blank lines, and single/double-quoted values. Malformed lines
105
+ * are skipped rather than throwing — a secrets file should never hard-fail a
106
+ * CLI invocation. */
107
+ export function parseDotenv(text) {
108
+ const out = {};
109
+ for (const rawLine of text.split('\n')) {
110
+ const line = rawLine.trim();
111
+ if (line === '' || line.startsWith('#'))
112
+ continue;
113
+ const eq = line.indexOf('=');
114
+ if (eq === -1)
115
+ continue;
116
+ const key = line
117
+ .slice(0, eq)
118
+ .trim()
119
+ .replace(/^export\s+/, '');
120
+ if (!ENV_NAME_RE.test(key))
121
+ continue;
122
+ let val = line.slice(eq + 1).trim();
123
+ if (val.length >= 2 &&
124
+ ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) {
125
+ val = val.slice(1, -1);
126
+ }
127
+ out[key] = val;
128
+ }
129
+ return out;
130
+ }
131
+ function serializeDotenv(values) {
132
+ const header = '# compost secrets — managed by `compost secrets`. Mode 0600; never commit.\n' +
133
+ '# Each line is ENV_NAME=value. Environment variables of the same name win\n' +
134
+ '# over this file; this file wins over nothing below it.\n';
135
+ const body = Object.entries(values)
136
+ .map(([k, v]) => `${k}=${v}`)
137
+ .join('\n');
138
+ return body ? `${header}${body}\n` : header;
139
+ }
140
+ /** Read the 0600 dotenv, refusing (without reading contents) when its perms are
141
+ * loose — so an insecure file can never leak its secrets through compost. */
142
+ export function readSecretsFile(deps = {}) {
143
+ const platform = deps.platform ?? process.platform;
144
+ const path = secretsEnvPath(deps);
145
+ if (!existsSync(path))
146
+ return { path, exists: false, secure: true, values: {} };
147
+ if (!fileIsSecure(path, platform)) {
148
+ return { path, exists: true, secure: false, values: {} };
149
+ }
150
+ try {
151
+ return { path, exists: true, secure: true, values: parseDotenv(readFileSync(path, 'utf8')) };
152
+ }
153
+ catch (cause) {
154
+ throw new CompostError('IO_ERROR', `Could not read ${path}`, { cause });
155
+ }
156
+ }
157
+ /** Ensure `~/.compost` exists at 0700 and return it. */
158
+ function ensureSecureHome(deps) {
159
+ const dir = compostHome(deps);
160
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
161
+ if ((deps.platform ?? process.platform) !== 'win32') {
162
+ try {
163
+ chmodSync(dir, 0o700);
164
+ }
165
+ catch {
166
+ // best-effort: a pre-existing dir we don't own; the perms check will warn.
167
+ }
168
+ }
169
+ return dir;
170
+ }
171
+ /** Serialize the dotenv to `path` with 0600 perms — `mode` on create, plus an
172
+ * explicit chmod on POSIX so an already-loose file is tightened (the write mode
173
+ * is umask-masked and on its own won't downgrade a permissive existing file). */
174
+ function writeDotenv600(path, values, deps) {
175
+ writeFileSync(path, serializeDotenv(values), { mode: 0o600 });
176
+ if ((deps.platform ?? process.platform) !== 'win32')
177
+ chmodSync(path, 0o600);
178
+ }
179
+ /** Write/replace a single key in the dotenv, always (re)normalizing perms to
180
+ * 0600. Reads existing contents raw (bypassing the secrecy gate) so a key set
181
+ * on a previously-loose file both preserves siblings and *fixes* the perms. */
182
+ function writeSecretToFile(name, value, deps) {
183
+ ensureSecureHome(deps);
184
+ const path = secretsEnvPath(deps);
185
+ const existing = existsSync(path) ? parseDotenv(readFileSync(path, 'utf8')) : {};
186
+ existing[name] = value;
187
+ writeDotenv600(path, existing, deps);
188
+ return path;
189
+ }
190
+ /** Remove a key from the dotenv. Returns true if the key was present. */
191
+ function removeSecretFromFile(name, deps) {
192
+ const path = secretsEnvPath(deps);
193
+ if (!existsSync(path))
194
+ return false;
195
+ const existing = parseDotenv(readFileSync(path, 'utf8'));
196
+ if (!(name in existing))
197
+ return false;
198
+ delete existing[name];
199
+ if (Object.keys(existing).length === 0) {
200
+ rmSync(path, { force: true });
201
+ }
202
+ else {
203
+ writeDotenv600(path, existing, deps);
204
+ }
205
+ return true;
206
+ }
207
+ function runCmd(cmd, args, input) {
208
+ try {
209
+ const stdout = execFileSync(cmd, args, {
210
+ encoding: 'utf8',
211
+ stdio: ['pipe', 'pipe', 'ignore'],
212
+ timeout: 8000,
213
+ ...(input !== undefined ? { input } : {}),
214
+ });
215
+ return { ok: true, stdout, code: 0 };
216
+ }
217
+ catch (err) {
218
+ const e = err;
219
+ const out = typeof e.stdout === 'string' ? e.stdout : (e.stdout?.toString() ?? '');
220
+ return { ok: false, stdout: out, code: typeof e.status === 'number' ? e.status : null };
221
+ }
222
+ }
223
+ /** Map a `runCmd` result to its newline-trimmed stdout, or undefined when the
224
+ * command failed or produced no value — the shared keychain-read shape. */
225
+ function cmdValue(r) {
226
+ if (!r.ok)
227
+ return undefined;
228
+ const v = r.stdout.replace(/\n$/, '');
229
+ return v === '' ? undefined : v;
230
+ }
231
+ /** macOS Keychain via the `security` CLI. NB: `add-generic-password -w <value>`
232
+ * passes the secret as an argv element, briefly visible to `ps` for the lifetime
233
+ * of the spawned `security` process. The interactive `-w` (no value) prompt form
234
+ * reads the password from the controlling TTY, not stdin, so it can't be fed via
235
+ * our piped `runCmd` without allocating a pty — not worth it under the single-user
236
+ * threat model. The exposure is documented in SECURITY.md ("Storing your tokens"). */
237
+ function macKeychain() {
238
+ return {
239
+ label: `macOS Keychain (service "${KEYCHAIN_SERVICE}")`,
240
+ get(name) {
241
+ return cmdValue(runCmd('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name, '-w']));
242
+ },
243
+ set(name, value) {
244
+ const r = runCmd('security', [
245
+ 'add-generic-password',
246
+ '-s',
247
+ KEYCHAIN_SERVICE,
248
+ '-a',
249
+ name,
250
+ '-l',
251
+ `${KEYCHAIN_SERVICE}: ${name}`,
252
+ '-U', // update if present
253
+ '-w',
254
+ value,
255
+ ]);
256
+ if (!r.ok) {
257
+ throw new CompostError('IO_ERROR', `macOS keychain write failed (security exit ${r.code ?? 'spawn-error'})`);
258
+ }
259
+ },
260
+ del(name) {
261
+ const r = runCmd('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', name]);
262
+ return r.ok;
263
+ },
264
+ };
265
+ }
266
+ /** Linux Secret Service via libsecret's `secret-tool`. The secret is read from
267
+ * stdin on store (never an argv). Requires a running Secret Service. */
268
+ function linuxKeychain() {
269
+ const attrs = (name) => ['service', KEYCHAIN_SERVICE, 'key', name];
270
+ return {
271
+ label: `Secret Service (libsecret, service "${KEYCHAIN_SERVICE}")`,
272
+ get(name) {
273
+ return cmdValue(runCmd('secret-tool', ['lookup', ...attrs(name)]));
274
+ },
275
+ set(name, value) {
276
+ const r = runCmd('secret-tool', ['store', '--label', `${KEYCHAIN_SERVICE}: ${name}`, ...attrs(name)], value);
277
+ if (!r.ok) {
278
+ throw new CompostError('IO_ERROR', `secret-tool store failed (exit ${r.code ?? 'spawn-error'}); is a Secret Service running?`);
279
+ }
280
+ },
281
+ del(name) {
282
+ const r = runCmd('secret-tool', ['clear', ...attrs(name)]);
283
+ return r.ok;
284
+ },
285
+ };
286
+ }
287
+ /** True when `secret-tool` is on PATH (any exit code means it spawned). */
288
+ function secretToolPresent() {
289
+ return runCmd('secret-tool', []).code !== null;
290
+ }
291
+ /** Auto-detect the platform keychain, or null when none is usable. */
292
+ export function detectKeychain(deps = {}) {
293
+ if (deps.keychain !== undefined)
294
+ return deps.keychain;
295
+ const env = deps.env ?? process.env;
296
+ if (env.COMPOST_NO_KEYCHAIN?.trim())
297
+ return null;
298
+ const platform = deps.platform ?? process.platform;
299
+ if (platform === 'darwin')
300
+ return macKeychain();
301
+ if (platform === 'linux')
302
+ return secretToolPresent() ? linuxKeychain() : null;
303
+ return null; // win32 / other → dotenv fallback
304
+ }
305
+ /** Resolve a secret by the documented precedence: env > keychain > 0600 file.
306
+ * Returns undefined when set nowhere (or only in an insecure, refused file). */
307
+ export function resolveSecret(name, opts = {}) {
308
+ const env = opts.env ?? process.env;
309
+ const names = [name, ...(opts.aliases ?? [])];
310
+ for (const key of names) {
311
+ const v = env[key];
312
+ if (v && v.trim() !== '') {
313
+ // If the autoload put this here, its real home is the 0600 file — report
314
+ // that, not 'env', so the user isn't told a managed file is a shell export.
315
+ return { value: v, source: autoloadedNames.has(key) ? 'file' : 'env' };
316
+ }
317
+ }
318
+ const kc = detectKeychain(opts);
319
+ if (kc) {
320
+ for (const key of names) {
321
+ const v = kc.get(key);
322
+ if (v && v.trim() !== '')
323
+ return { value: v, source: 'keychain' };
324
+ }
325
+ }
326
+ const file = readSecretsFile(opts);
327
+ if (file.exists && file.secure) {
328
+ for (const key of names) {
329
+ const v = file.values[key];
330
+ if (v && v.trim() !== '')
331
+ return { value: v, source: 'file' };
332
+ }
333
+ }
334
+ return undefined;
335
+ }
336
+ /** Store a secret. Prefers the keychain; falls back to the 0600 dotenv when no
337
+ * keychain exists or a keychain write fails. */
338
+ export function setSecret(name, value, deps = {}) {
339
+ assertSecretName(name);
340
+ if (value.trim() === '') {
341
+ throw new CompostError('INVALID_INPUT', `Refusing to store an empty value for ${name}.`);
342
+ }
343
+ const kc = detectKeychain(deps);
344
+ if (kc) {
345
+ try {
346
+ kc.set(name, value);
347
+ return { name, stored_in: 'keychain', location: kc.label };
348
+ }
349
+ catch (err) {
350
+ const reason = errMessage(err);
351
+ const path = writeSecretToFile(name, value, deps);
352
+ return { name, stored_in: 'file', location: path, fallback_reason: reason };
353
+ }
354
+ }
355
+ const path = writeSecretToFile(name, value, deps);
356
+ return { name, stored_in: 'file', location: path };
357
+ }
358
+ /** Remove a secret from the keychain and the dotenv (env vars are the user's
359
+ * shell — we can't and don't touch those). */
360
+ export function rmSecret(name, deps = {}) {
361
+ assertSecretName(name);
362
+ const removed = [];
363
+ const kc = detectKeychain(deps);
364
+ if (kc?.del(name))
365
+ removed.push('keychain');
366
+ if (removeSecretFromFile(name, deps))
367
+ removed.push('file');
368
+ return { name, removed_from: removed };
369
+ }
370
+ /** List which secrets are set and where — never the values. Covers the
371
+ * well-known names plus anything found in the dotenv. */
372
+ export function listSecrets(deps = {}) {
373
+ const env = deps.env ?? process.env;
374
+ const kc = detectKeychain(deps);
375
+ const file = readSecretsFile(deps);
376
+ const candidates = new Set([...KNOWN_SECRET_NAMES, ...Object.keys(file.values)]);
377
+ const items = [];
378
+ for (const name of candidates) {
379
+ const sources = [];
380
+ const e = env[name];
381
+ // An autoloaded name is in env only because of the file — count it once, as
382
+ // 'file' (below), not 'env', so it doesn't masquerade/double-count.
383
+ if (e && e.trim() !== '' && !autoloadedNames.has(name))
384
+ sources.push('env');
385
+ if (kc) {
386
+ const v = kc.get(name);
387
+ if (v && v.trim() !== '')
388
+ sources.push('keychain');
389
+ }
390
+ if (file.secure && file.values[name])
391
+ sources.push('file');
392
+ if (sources.length > 0)
393
+ items.push({ name, sources });
394
+ }
395
+ items.sort((a, b) => a.name.localeCompare(b.name));
396
+ return { items, file };
397
+ }
398
+ /** Load `~/.compost/secrets.env` into the environment at startup so file-stored
399
+ * secrets resolve everywhere an env var would — without editing a shell profile.
400
+ * Environment variables already set WIN (never overridden). An insecure file is
401
+ * refused (not read) with a warning, preserving the "0600 or it doesn't load"
402
+ * guarantee. Mutates `deps.env` (defaults to `process.env`). */
403
+ export function loadSecretsEnv(deps = {}) {
404
+ const env = deps.env ?? process.env;
405
+ const read = readSecretsFile(deps);
406
+ if (!read.exists)
407
+ return { path: read.path, loaded: [], skipped: 'not-found' };
408
+ if (!read.secure) {
409
+ deps.warn?.(`compost: refusing to load ${read.path} — it is group/world-readable. Fix with: chmod 600 ${read.path}`);
410
+ return { path: read.path, loaded: [], skipped: 'insecure-perms' };
411
+ }
412
+ const loaded = [];
413
+ for (const [k, v] of Object.entries(read.values)) {
414
+ const cur = env[k];
415
+ if (cur === undefined || cur === '') {
416
+ env[k] = v;
417
+ loaded.push(k);
418
+ // Remember the file is the true source, so resolution doesn't mislabel it 'env'.
419
+ autoloadedNames.add(k);
420
+ }
421
+ }
422
+ return { path: read.path, loaded, skipped: null };
423
+ }
424
+ // ---------------------------------------------------------------------------
425
+ // Permission audit (for `compost setup`)
426
+ // ---------------------------------------------------------------------------
427
+ const SCAN_SKIP_DIRS = new Set(['transcriber-venv', 'node_modules', '.git', '__pycache__']);
428
+ const SECRETISH_RE = /(secret|token|credential|api[_-]?key|\.env\b|\.key\b)/i;
429
+ const SCAN_MAX_ENTRIES = 2000;
430
+ /** Audit secret-storage permissions under `~/.compost`. Returns issues for:
431
+ * - the home dir if group/world-WRITABLE (someone could swap your secrets),
432
+ * - the managed `secrets.env` if group/world-accessible,
433
+ * - any secret-ish file (by path: token/secret/credential/.env/.key) that's
434
+ * group/world-readable — catches hand-rolled files like the world-readable
435
+ * `~/.compost/hf_token/compost.txt` a readiness test produced.
436
+ * POSIX-only; Windows uses ACLs, so it returns []. */
437
+ export function auditSecretsPerms(deps = {}) {
438
+ const platform = deps.platform ?? process.platform;
439
+ if (platform === 'win32')
440
+ return [];
441
+ const issues = [];
442
+ const home = compostHome(deps);
443
+ // Home dir: flag group/world-writable (the dangerous case for a secrets dir).
444
+ const homeMode = statMode(home);
445
+ if (homeMode !== undefined && (homeMode & 0o022) !== 0) {
446
+ issues.push({
447
+ path: home,
448
+ kind: 'dir',
449
+ mode: octal(homeMode),
450
+ fix: `chmod 700 ${home}`,
451
+ detail: 'group/world-writable — others could replace files here',
452
+ });
453
+ }
454
+ // The managed dotenv specifically.
455
+ const sp = secretsEnvPath(deps);
456
+ const spMode = statMode(sp);
457
+ if (spMode !== undefined && (spMode & 0o077) !== 0) {
458
+ issues.push({
459
+ path: sp,
460
+ kind: 'file',
461
+ mode: octal(spMode),
462
+ fix: `chmod 600 ${sp}`,
463
+ detail: 'secrets file is group/world-readable',
464
+ });
465
+ }
466
+ // Bounded scan for other secret-ish files left around the home dir.
467
+ const seen = new Set(issues.map((i) => i.path));
468
+ let budget = SCAN_MAX_ENTRIES;
469
+ const walk = (dir, depth) => {
470
+ if (depth > 3 || budget <= 0)
471
+ return;
472
+ let entries;
473
+ try {
474
+ entries = readdirSync(dir, { withFileTypes: true });
475
+ }
476
+ catch {
477
+ return;
478
+ }
479
+ for (const ent of entries) {
480
+ if (--budget <= 0)
481
+ return;
482
+ if (ent.isSymbolicLink())
483
+ continue; // never follow links (cf. #212)
484
+ const full = join(dir, ent.name);
485
+ if (ent.isDirectory()) {
486
+ if (!SCAN_SKIP_DIRS.has(ent.name))
487
+ walk(full, depth + 1);
488
+ continue;
489
+ }
490
+ if (!ent.isFile())
491
+ continue;
492
+ const rel = relative(home, full);
493
+ if (!SECRETISH_RE.test(rel))
494
+ continue;
495
+ if (seen.has(full))
496
+ continue;
497
+ const m = statMode(full);
498
+ if (m !== undefined && (m & 0o077) !== 0) {
499
+ seen.add(full);
500
+ issues.push({
501
+ path: full,
502
+ kind: 'file',
503
+ mode: octal(m),
504
+ fix: `chmod 600 ${full}`,
505
+ detail: 'looks like a secret file and is group/world-readable',
506
+ });
507
+ }
508
+ }
509
+ };
510
+ if (existsSync(home))
511
+ walk(home, 0);
512
+ return issues;
513
+ }
514
+ //# sourceMappingURL=secrets.js.map