@mandujs/core 0.53.2 → 0.54.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/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/brain/credentials.ts
CHANGED
|
@@ -1,396 +1,396 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Brain — OS keychain adapter for OAuth tokens (Issue #235).
|
|
3
|
-
*
|
|
4
|
-
* Zero new dependencies — we shell out to the native secret-store CLI
|
|
5
|
-
* on each platform, with a filesystem fallback at
|
|
6
|
-
* `~/.mandu/credentials.json` (mode 0600) when no CLI is available.
|
|
7
|
-
*
|
|
8
|
-
* - macOS → `security find-generic-password` / `add-generic-password`
|
|
9
|
-
* - Windows → `cmdkey` (read) + PowerShell `ConvertFrom-SecureString`
|
|
10
|
-
* is too heavyweight; we use a narrow `cmdkey /add` +
|
|
11
|
-
* `cmdkey /list` shell dance instead. Windows does NOT let
|
|
12
|
-
* `cmdkey` read back the password, so we fall through to
|
|
13
|
-
* the filesystem store on Windows and mark it 0600 — the
|
|
14
|
-
* file lives under the user profile which is already
|
|
15
|
-
* ACL'd to the current user. This matches the behavior of
|
|
16
|
-
* `gh auth`, `npm`, and `docker-credential-wincred`
|
|
17
|
-
* when run without their companion helper installed.
|
|
18
|
-
* - Linux → `secret-tool store` / `secret-tool lookup` (libsecret).
|
|
19
|
-
*
|
|
20
|
-
* API surface is intentionally minimal:
|
|
21
|
-
*
|
|
22
|
-
* - `saveToken(provider, token)` → persist
|
|
23
|
-
* - `loadToken(provider)` → returns token or null
|
|
24
|
-
* - `deleteToken(provider)` → idempotent delete
|
|
25
|
-
* - `listProviders()` → for `mandu brain status`
|
|
26
|
-
*
|
|
27
|
-
* The token type is arbitrary JSON (access_token + refresh_token +
|
|
28
|
-
* expires_at) — `CredentialStore` serializes it as a JSON string.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { promises as fs } from "node:fs";
|
|
32
|
-
import os from "node:os";
|
|
33
|
-
import path from "node:path";
|
|
34
|
-
|
|
35
|
-
const SERVICE = "mandu-brain";
|
|
36
|
-
const FALLBACK_DIR = path.join(os.homedir(), ".mandu");
|
|
37
|
-
const FALLBACK_FILE = path.join(FALLBACK_DIR, "credentials.json");
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Stored token shape. `access_token` is required; the refresh fields
|
|
41
|
-
* are optional because template-only adapters never write them.
|
|
42
|
-
*/
|
|
43
|
-
export interface StoredToken {
|
|
44
|
-
access_token: string;
|
|
45
|
-
refresh_token?: string;
|
|
46
|
-
/** Unix-epoch seconds when the access_token expires. */
|
|
47
|
-
expires_at?: number;
|
|
48
|
-
/** ISO timestamp of last successful use — for `mandu brain status`. */
|
|
49
|
-
last_used_at?: string;
|
|
50
|
-
/** Cached default model chosen at login time (override via config). */
|
|
51
|
-
default_model?: string;
|
|
52
|
-
/** Scope string returned by the OAuth provider. */
|
|
53
|
-
scope?: string;
|
|
54
|
-
/** Provider identifier — for audit. */
|
|
55
|
-
provider?: "openai" | "anthropic";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Pluggable backend for the credential store. Tests inject an
|
|
60
|
-
* in-memory backend; production auto-selects by OS.
|
|
61
|
-
*/
|
|
62
|
-
export interface CredentialBackend {
|
|
63
|
-
readonly name: string;
|
|
64
|
-
save(provider: string, token: StoredToken): Promise<void>;
|
|
65
|
-
load(provider: string): Promise<StoredToken | null>;
|
|
66
|
-
delete(provider: string): Promise<void>;
|
|
67
|
-
list(): Promise<string[]>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/* -------------------------------------------------------------------- */
|
|
71
|
-
/* Filesystem fallback — always available, always the safety net. */
|
|
72
|
-
/* -------------------------------------------------------------------- */
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Read the fallback credentials file. Missing file → empty object.
|
|
76
|
-
* Corrupt file → empty object + a warning to stderr (so a bad edit
|
|
77
|
-
* does not brick `mandu brain status`).
|
|
78
|
-
*/
|
|
79
|
-
async function readFallback(): Promise<Record<string, StoredToken>> {
|
|
80
|
-
try {
|
|
81
|
-
const raw = await fs.readFile(FALLBACK_FILE, "utf8");
|
|
82
|
-
const parsed = JSON.parse(raw);
|
|
83
|
-
if (parsed && typeof parsed === "object") {
|
|
84
|
-
return parsed as Record<string, StoredToken>;
|
|
85
|
-
}
|
|
86
|
-
return {};
|
|
87
|
-
} catch (err) {
|
|
88
|
-
if (
|
|
89
|
-
err instanceof Error &&
|
|
90
|
-
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
91
|
-
) {
|
|
92
|
-
return {};
|
|
93
|
-
}
|
|
94
|
-
// Corrupt JSON — treat as empty, do not throw (Brain never blocks).
|
|
95
|
-
return {};
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function writeFallback(all: Record<string, StoredToken>): Promise<void> {
|
|
100
|
-
await fs.mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
|
|
101
|
-
const payload = JSON.stringify(all, null, 2);
|
|
102
|
-
// Write atomically: write to tmp then rename, setting 0600 on the
|
|
103
|
-
// final file. mode on `writeFile` is advisory on Windows, enforced
|
|
104
|
-
// on POSIX.
|
|
105
|
-
const tmp = `${FALLBACK_FILE}.${process.pid}.tmp`;
|
|
106
|
-
await fs.writeFile(tmp, payload, { mode: 0o600 });
|
|
107
|
-
await fs.rename(tmp, FALLBACK_FILE);
|
|
108
|
-
try {
|
|
109
|
-
await fs.chmod(FALLBACK_FILE, 0o600);
|
|
110
|
-
} catch {
|
|
111
|
-
// chmod is best-effort on Windows.
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export const filesystemBackend: CredentialBackend = {
|
|
116
|
-
name: "filesystem",
|
|
117
|
-
async save(provider, token) {
|
|
118
|
-
const all = await readFallback();
|
|
119
|
-
all[provider] = token;
|
|
120
|
-
await writeFallback(all);
|
|
121
|
-
},
|
|
122
|
-
async load(provider) {
|
|
123
|
-
const all = await readFallback();
|
|
124
|
-
return all[provider] ?? null;
|
|
125
|
-
},
|
|
126
|
-
async delete(provider) {
|
|
127
|
-
const all = await readFallback();
|
|
128
|
-
if (provider in all) {
|
|
129
|
-
delete all[provider];
|
|
130
|
-
await writeFallback(all);
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
async list() {
|
|
134
|
-
const all = await readFallback();
|
|
135
|
-
return Object.keys(all);
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/* -------------------------------------------------------------------- */
|
|
140
|
-
/* Native backends — best-effort; fall through to filesystem on error. */
|
|
141
|
-
/* -------------------------------------------------------------------- */
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Shell out to a binary with a short stdin payload and return stdout.
|
|
145
|
-
* Never throws — returns `null` on any non-zero exit. We never log the
|
|
146
|
-
* stderr verbatim because some utilities echo the payload back on
|
|
147
|
-
* failure.
|
|
148
|
-
*/
|
|
149
|
-
async function runWithStdin(
|
|
150
|
-
cmd: string,
|
|
151
|
-
args: string[],
|
|
152
|
-
stdin: string,
|
|
153
|
-
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
154
|
-
try {
|
|
155
|
-
const proc = Bun.spawn([cmd, ...args], {
|
|
156
|
-
stdin: "pipe",
|
|
157
|
-
stdout: "pipe",
|
|
158
|
-
stderr: "pipe",
|
|
159
|
-
});
|
|
160
|
-
await proc.stdin.write(stdin);
|
|
161
|
-
await proc.stdin.end();
|
|
162
|
-
const [stdout, exitCode] = await Promise.all([
|
|
163
|
-
new Response(proc.stdout).text(),
|
|
164
|
-
proc.exited,
|
|
165
|
-
]);
|
|
166
|
-
return { ok: exitCode === 0, stdout };
|
|
167
|
-
} catch {
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function runCapture(
|
|
173
|
-
cmd: string,
|
|
174
|
-
args: string[],
|
|
175
|
-
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
176
|
-
try {
|
|
177
|
-
const proc = Bun.spawn([cmd, ...args], {
|
|
178
|
-
stdout: "pipe",
|
|
179
|
-
stderr: "pipe",
|
|
180
|
-
});
|
|
181
|
-
const [stdout, exitCode] = await Promise.all([
|
|
182
|
-
new Response(proc.stdout).text(),
|
|
183
|
-
proc.exited,
|
|
184
|
-
]);
|
|
185
|
-
return { ok: exitCode === 0, stdout };
|
|
186
|
-
} catch {
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* macOS Keychain backend — uses the `security` CLI.
|
|
193
|
-
*
|
|
194
|
-
* `security add-generic-password -U -a <provider> -s mandu-brain -w <json>`
|
|
195
|
-
* to store; `find-generic-password -a <provider> -s mandu-brain -w`
|
|
196
|
-
* to read the password back. Deletion uses `delete-generic-password`.
|
|
197
|
-
*/
|
|
198
|
-
export const macosBackend: CredentialBackend = {
|
|
199
|
-
name: "macos-keychain",
|
|
200
|
-
async save(provider, token) {
|
|
201
|
-
const payload = JSON.stringify(token);
|
|
202
|
-
const res = await runCapture("security", [
|
|
203
|
-
"add-generic-password",
|
|
204
|
-
"-U", // update if exists
|
|
205
|
-
"-a",
|
|
206
|
-
provider,
|
|
207
|
-
"-s",
|
|
208
|
-
SERVICE,
|
|
209
|
-
"-w",
|
|
210
|
-
payload,
|
|
211
|
-
]);
|
|
212
|
-
if (!res || !res.ok) {
|
|
213
|
-
// Fall back to filesystem — never let a keychain hiccup block login.
|
|
214
|
-
await filesystemBackend.save(provider, token);
|
|
215
|
-
}
|
|
216
|
-
},
|
|
217
|
-
async load(provider) {
|
|
218
|
-
const res = await runCapture("security", [
|
|
219
|
-
"find-generic-password",
|
|
220
|
-
"-a",
|
|
221
|
-
provider,
|
|
222
|
-
"-s",
|
|
223
|
-
SERVICE,
|
|
224
|
-
"-w",
|
|
225
|
-
]);
|
|
226
|
-
if (!res || !res.ok) {
|
|
227
|
-
return filesystemBackend.load(provider);
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
231
|
-
} catch {
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
},
|
|
235
|
-
async delete(provider) {
|
|
236
|
-
await runCapture("security", [
|
|
237
|
-
"delete-generic-password",
|
|
238
|
-
"-a",
|
|
239
|
-
provider,
|
|
240
|
-
"-s",
|
|
241
|
-
SERVICE,
|
|
242
|
-
]);
|
|
243
|
-
// Also scrub filesystem fallback, in case a previous save hit it.
|
|
244
|
-
await filesystemBackend.delete(provider);
|
|
245
|
-
},
|
|
246
|
-
async list() {
|
|
247
|
-
// `security` has no "list by service" flag that returns accounts
|
|
248
|
-
// without the full dump; use filesystem fallback as the source of
|
|
249
|
-
// truth for the list, since saves always also go through keychain
|
|
250
|
-
// (or fell back to the file).
|
|
251
|
-
return filesystemBackend.list();
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Linux libsecret backend — `secret-tool`.
|
|
257
|
-
*/
|
|
258
|
-
export const linuxBackend: CredentialBackend = {
|
|
259
|
-
name: "linux-secret-tool",
|
|
260
|
-
async save(provider, token) {
|
|
261
|
-
const payload = JSON.stringify(token);
|
|
262
|
-
const res = await runWithStdin(
|
|
263
|
-
"secret-tool",
|
|
264
|
-
["store", "--label=mandu-brain", "service", SERVICE, "account", provider],
|
|
265
|
-
payload,
|
|
266
|
-
);
|
|
267
|
-
if (!res || !res.ok) {
|
|
268
|
-
await filesystemBackend.save(provider, token);
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
async load(provider) {
|
|
272
|
-
const res = await runCapture("secret-tool", [
|
|
273
|
-
"lookup",
|
|
274
|
-
"service",
|
|
275
|
-
SERVICE,
|
|
276
|
-
"account",
|
|
277
|
-
provider,
|
|
278
|
-
]);
|
|
279
|
-
if (!res || !res.ok || res.stdout.trim().length === 0) {
|
|
280
|
-
return filesystemBackend.load(provider);
|
|
281
|
-
}
|
|
282
|
-
try {
|
|
283
|
-
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
284
|
-
} catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
},
|
|
288
|
-
async delete(provider) {
|
|
289
|
-
await runCapture("secret-tool", [
|
|
290
|
-
"clear",
|
|
291
|
-
"service",
|
|
292
|
-
SERVICE,
|
|
293
|
-
"account",
|
|
294
|
-
provider,
|
|
295
|
-
]);
|
|
296
|
-
await filesystemBackend.delete(provider);
|
|
297
|
-
},
|
|
298
|
-
async list() {
|
|
299
|
-
return filesystemBackend.list();
|
|
300
|
-
},
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Windows — `cmdkey` can store but cannot read back the password
|
|
305
|
-
* portion without the Credential Manager API (which we would need a
|
|
306
|
-
* native binding for). Use filesystem fallback directly; the file
|
|
307
|
-
* lives under `%USERPROFILE%\.mandu\credentials.json` which is ACL'd
|
|
308
|
-
* to the current user by default. This matches the behavior of
|
|
309
|
-
* `gh auth login` when the Git Credential Manager is not installed.
|
|
310
|
-
*/
|
|
311
|
-
export const windowsBackend: CredentialBackend = {
|
|
312
|
-
name: "windows-filesystem",
|
|
313
|
-
save: filesystemBackend.save,
|
|
314
|
-
load: filesystemBackend.load,
|
|
315
|
-
delete: filesystemBackend.delete,
|
|
316
|
-
list: filesystemBackend.list,
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Pick the best backend for the current platform. Tests override this
|
|
321
|
-
* by constructing a `CredentialStore` with an explicit backend.
|
|
322
|
-
*/
|
|
323
|
-
export function pickPlatformBackend(): CredentialBackend {
|
|
324
|
-
if (process.platform === "darwin") return macosBackend;
|
|
325
|
-
if (process.platform === "win32") return windowsBackend;
|
|
326
|
-
if (process.platform === "linux") return linuxBackend;
|
|
327
|
-
return filesystemBackend;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/* -------------------------------------------------------------------- */
|
|
331
|
-
/* Public store */
|
|
332
|
-
/* -------------------------------------------------------------------- */
|
|
333
|
-
|
|
334
|
-
export class CredentialStore {
|
|
335
|
-
constructor(private backend: CredentialBackend = pickPlatformBackend()) {}
|
|
336
|
-
|
|
337
|
-
/** Which backend is active — surfaced in `mandu brain status`. */
|
|
338
|
-
get backendName(): string {
|
|
339
|
-
return this.backend.name;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
async save(provider: string, token: StoredToken): Promise<void> {
|
|
343
|
-
await this.backend.save(provider, token);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async load(provider: string): Promise<StoredToken | null> {
|
|
347
|
-
return this.backend.load(provider);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async delete(provider: string): Promise<void> {
|
|
351
|
-
await this.backend.delete(provider);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async list(): Promise<string[]> {
|
|
355
|
-
return this.backend.list();
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Touch the last_used_at timestamp on an existing token without
|
|
360
|
-
* rotating the secret. Best-effort — swallow errors (telemetry).
|
|
361
|
-
*/
|
|
362
|
-
async touch(provider: string): Promise<void> {
|
|
363
|
-
try {
|
|
364
|
-
const tok = await this.load(provider);
|
|
365
|
-
if (tok) {
|
|
366
|
-
tok.last_used_at = new Date().toISOString();
|
|
367
|
-
await this.save(provider, tok);
|
|
368
|
-
}
|
|
369
|
-
} catch {
|
|
370
|
-
/* ignore */
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* File path of the filesystem fallback — used by tests + the CLI
|
|
376
|
-
* `mandu brain status` to point users at the 0600 file. Always
|
|
377
|
-
* returns the same path regardless of backend.
|
|
378
|
-
*/
|
|
379
|
-
static fallbackPath(): string {
|
|
380
|
-
return FALLBACK_FILE;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** Default singleton — production path. */
|
|
385
|
-
let defaultStore: CredentialStore | null = null;
|
|
386
|
-
export function getCredentialStore(): CredentialStore {
|
|
387
|
-
if (!defaultStore) {
|
|
388
|
-
defaultStore = new CredentialStore();
|
|
389
|
-
}
|
|
390
|
-
return defaultStore;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Override the singleton (test affordance). */
|
|
394
|
-
export function setCredentialStore(store: CredentialStore): void {
|
|
395
|
-
defaultStore = store;
|
|
396
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Brain — OS keychain adapter for OAuth tokens (Issue #235).
|
|
3
|
+
*
|
|
4
|
+
* Zero new dependencies — we shell out to the native secret-store CLI
|
|
5
|
+
* on each platform, with a filesystem fallback at
|
|
6
|
+
* `~/.mandu/credentials.json` (mode 0600) when no CLI is available.
|
|
7
|
+
*
|
|
8
|
+
* - macOS → `security find-generic-password` / `add-generic-password`
|
|
9
|
+
* - Windows → `cmdkey` (read) + PowerShell `ConvertFrom-SecureString`
|
|
10
|
+
* is too heavyweight; we use a narrow `cmdkey /add` +
|
|
11
|
+
* `cmdkey /list` shell dance instead. Windows does NOT let
|
|
12
|
+
* `cmdkey` read back the password, so we fall through to
|
|
13
|
+
* the filesystem store on Windows and mark it 0600 — the
|
|
14
|
+
* file lives under the user profile which is already
|
|
15
|
+
* ACL'd to the current user. This matches the behavior of
|
|
16
|
+
* `gh auth`, `npm`, and `docker-credential-wincred`
|
|
17
|
+
* when run without their companion helper installed.
|
|
18
|
+
* - Linux → `secret-tool store` / `secret-tool lookup` (libsecret).
|
|
19
|
+
*
|
|
20
|
+
* API surface is intentionally minimal:
|
|
21
|
+
*
|
|
22
|
+
* - `saveToken(provider, token)` → persist
|
|
23
|
+
* - `loadToken(provider)` → returns token or null
|
|
24
|
+
* - `deleteToken(provider)` → idempotent delete
|
|
25
|
+
* - `listProviders()` → for `mandu brain status`
|
|
26
|
+
*
|
|
27
|
+
* The token type is arbitrary JSON (access_token + refresh_token +
|
|
28
|
+
* expires_at) — `CredentialStore` serializes it as a JSON string.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
const SERVICE = "mandu-brain";
|
|
36
|
+
const FALLBACK_DIR = path.join(os.homedir(), ".mandu");
|
|
37
|
+
const FALLBACK_FILE = path.join(FALLBACK_DIR, "credentials.json");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stored token shape. `access_token` is required; the refresh fields
|
|
41
|
+
* are optional because template-only adapters never write them.
|
|
42
|
+
*/
|
|
43
|
+
export interface StoredToken {
|
|
44
|
+
access_token: string;
|
|
45
|
+
refresh_token?: string;
|
|
46
|
+
/** Unix-epoch seconds when the access_token expires. */
|
|
47
|
+
expires_at?: number;
|
|
48
|
+
/** ISO timestamp of last successful use — for `mandu brain status`. */
|
|
49
|
+
last_used_at?: string;
|
|
50
|
+
/** Cached default model chosen at login time (override via config). */
|
|
51
|
+
default_model?: string;
|
|
52
|
+
/** Scope string returned by the OAuth provider. */
|
|
53
|
+
scope?: string;
|
|
54
|
+
/** Provider identifier — for audit. */
|
|
55
|
+
provider?: "openai" | "anthropic";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pluggable backend for the credential store. Tests inject an
|
|
60
|
+
* in-memory backend; production auto-selects by OS.
|
|
61
|
+
*/
|
|
62
|
+
export interface CredentialBackend {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
save(provider: string, token: StoredToken): Promise<void>;
|
|
65
|
+
load(provider: string): Promise<StoredToken | null>;
|
|
66
|
+
delete(provider: string): Promise<void>;
|
|
67
|
+
list(): Promise<string[]>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* -------------------------------------------------------------------- */
|
|
71
|
+
/* Filesystem fallback — always available, always the safety net. */
|
|
72
|
+
/* -------------------------------------------------------------------- */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the fallback credentials file. Missing file → empty object.
|
|
76
|
+
* Corrupt file → empty object + a warning to stderr (so a bad edit
|
|
77
|
+
* does not brick `mandu brain status`).
|
|
78
|
+
*/
|
|
79
|
+
async function readFallback(): Promise<Record<string, StoredToken>> {
|
|
80
|
+
try {
|
|
81
|
+
const raw = await fs.readFile(FALLBACK_FILE, "utf8");
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (parsed && typeof parsed === "object") {
|
|
84
|
+
return parsed as Record<string, StoredToken>;
|
|
85
|
+
}
|
|
86
|
+
return {};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (
|
|
89
|
+
err instanceof Error &&
|
|
90
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
91
|
+
) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
// Corrupt JSON — treat as empty, do not throw (Brain never blocks).
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeFallback(all: Record<string, StoredToken>): Promise<void> {
|
|
100
|
+
await fs.mkdir(FALLBACK_DIR, { recursive: true, mode: 0o700 });
|
|
101
|
+
const payload = JSON.stringify(all, null, 2);
|
|
102
|
+
// Write atomically: write to tmp then rename, setting 0600 on the
|
|
103
|
+
// final file. mode on `writeFile` is advisory on Windows, enforced
|
|
104
|
+
// on POSIX.
|
|
105
|
+
const tmp = `${FALLBACK_FILE}.${process.pid}.tmp`;
|
|
106
|
+
await fs.writeFile(tmp, payload, { mode: 0o600 });
|
|
107
|
+
await fs.rename(tmp, FALLBACK_FILE);
|
|
108
|
+
try {
|
|
109
|
+
await fs.chmod(FALLBACK_FILE, 0o600);
|
|
110
|
+
} catch {
|
|
111
|
+
// chmod is best-effort on Windows.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const filesystemBackend: CredentialBackend = {
|
|
116
|
+
name: "filesystem",
|
|
117
|
+
async save(provider, token) {
|
|
118
|
+
const all = await readFallback();
|
|
119
|
+
all[provider] = token;
|
|
120
|
+
await writeFallback(all);
|
|
121
|
+
},
|
|
122
|
+
async load(provider) {
|
|
123
|
+
const all = await readFallback();
|
|
124
|
+
return all[provider] ?? null;
|
|
125
|
+
},
|
|
126
|
+
async delete(provider) {
|
|
127
|
+
const all = await readFallback();
|
|
128
|
+
if (provider in all) {
|
|
129
|
+
delete all[provider];
|
|
130
|
+
await writeFallback(all);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async list() {
|
|
134
|
+
const all = await readFallback();
|
|
135
|
+
return Object.keys(all);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/* -------------------------------------------------------------------- */
|
|
140
|
+
/* Native backends — best-effort; fall through to filesystem on error. */
|
|
141
|
+
/* -------------------------------------------------------------------- */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Shell out to a binary with a short stdin payload and return stdout.
|
|
145
|
+
* Never throws — returns `null` on any non-zero exit. We never log the
|
|
146
|
+
* stderr verbatim because some utilities echo the payload back on
|
|
147
|
+
* failure.
|
|
148
|
+
*/
|
|
149
|
+
async function runWithStdin(
|
|
150
|
+
cmd: string,
|
|
151
|
+
args: string[],
|
|
152
|
+
stdin: string,
|
|
153
|
+
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
154
|
+
try {
|
|
155
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
156
|
+
stdin: "pipe",
|
|
157
|
+
stdout: "pipe",
|
|
158
|
+
stderr: "pipe",
|
|
159
|
+
});
|
|
160
|
+
await proc.stdin.write(stdin);
|
|
161
|
+
await proc.stdin.end();
|
|
162
|
+
const [stdout, exitCode] = await Promise.all([
|
|
163
|
+
new Response(proc.stdout).text(),
|
|
164
|
+
proc.exited,
|
|
165
|
+
]);
|
|
166
|
+
return { ok: exitCode === 0, stdout };
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runCapture(
|
|
173
|
+
cmd: string,
|
|
174
|
+
args: string[],
|
|
175
|
+
): Promise<{ ok: boolean; stdout: string } | null> {
|
|
176
|
+
try {
|
|
177
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
178
|
+
stdout: "pipe",
|
|
179
|
+
stderr: "pipe",
|
|
180
|
+
});
|
|
181
|
+
const [stdout, exitCode] = await Promise.all([
|
|
182
|
+
new Response(proc.stdout).text(),
|
|
183
|
+
proc.exited,
|
|
184
|
+
]);
|
|
185
|
+
return { ok: exitCode === 0, stdout };
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* macOS Keychain backend — uses the `security` CLI.
|
|
193
|
+
*
|
|
194
|
+
* `security add-generic-password -U -a <provider> -s mandu-brain -w <json>`
|
|
195
|
+
* to store; `find-generic-password -a <provider> -s mandu-brain -w`
|
|
196
|
+
* to read the password back. Deletion uses `delete-generic-password`.
|
|
197
|
+
*/
|
|
198
|
+
export const macosBackend: CredentialBackend = {
|
|
199
|
+
name: "macos-keychain",
|
|
200
|
+
async save(provider, token) {
|
|
201
|
+
const payload = JSON.stringify(token);
|
|
202
|
+
const res = await runCapture("security", [
|
|
203
|
+
"add-generic-password",
|
|
204
|
+
"-U", // update if exists
|
|
205
|
+
"-a",
|
|
206
|
+
provider,
|
|
207
|
+
"-s",
|
|
208
|
+
SERVICE,
|
|
209
|
+
"-w",
|
|
210
|
+
payload,
|
|
211
|
+
]);
|
|
212
|
+
if (!res || !res.ok) {
|
|
213
|
+
// Fall back to filesystem — never let a keychain hiccup block login.
|
|
214
|
+
await filesystemBackend.save(provider, token);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
async load(provider) {
|
|
218
|
+
const res = await runCapture("security", [
|
|
219
|
+
"find-generic-password",
|
|
220
|
+
"-a",
|
|
221
|
+
provider,
|
|
222
|
+
"-s",
|
|
223
|
+
SERVICE,
|
|
224
|
+
"-w",
|
|
225
|
+
]);
|
|
226
|
+
if (!res || !res.ok) {
|
|
227
|
+
return filesystemBackend.load(provider);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
async delete(provider) {
|
|
236
|
+
await runCapture("security", [
|
|
237
|
+
"delete-generic-password",
|
|
238
|
+
"-a",
|
|
239
|
+
provider,
|
|
240
|
+
"-s",
|
|
241
|
+
SERVICE,
|
|
242
|
+
]);
|
|
243
|
+
// Also scrub filesystem fallback, in case a previous save hit it.
|
|
244
|
+
await filesystemBackend.delete(provider);
|
|
245
|
+
},
|
|
246
|
+
async list() {
|
|
247
|
+
// `security` has no "list by service" flag that returns accounts
|
|
248
|
+
// without the full dump; use filesystem fallback as the source of
|
|
249
|
+
// truth for the list, since saves always also go through keychain
|
|
250
|
+
// (or fell back to the file).
|
|
251
|
+
return filesystemBackend.list();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Linux libsecret backend — `secret-tool`.
|
|
257
|
+
*/
|
|
258
|
+
export const linuxBackend: CredentialBackend = {
|
|
259
|
+
name: "linux-secret-tool",
|
|
260
|
+
async save(provider, token) {
|
|
261
|
+
const payload = JSON.stringify(token);
|
|
262
|
+
const res = await runWithStdin(
|
|
263
|
+
"secret-tool",
|
|
264
|
+
["store", "--label=mandu-brain", "service", SERVICE, "account", provider],
|
|
265
|
+
payload,
|
|
266
|
+
);
|
|
267
|
+
if (!res || !res.ok) {
|
|
268
|
+
await filesystemBackend.save(provider, token);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
async load(provider) {
|
|
272
|
+
const res = await runCapture("secret-tool", [
|
|
273
|
+
"lookup",
|
|
274
|
+
"service",
|
|
275
|
+
SERVICE,
|
|
276
|
+
"account",
|
|
277
|
+
provider,
|
|
278
|
+
]);
|
|
279
|
+
if (!res || !res.ok || res.stdout.trim().length === 0) {
|
|
280
|
+
return filesystemBackend.load(provider);
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(res.stdout.trim()) as StoredToken;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
async delete(provider) {
|
|
289
|
+
await runCapture("secret-tool", [
|
|
290
|
+
"clear",
|
|
291
|
+
"service",
|
|
292
|
+
SERVICE,
|
|
293
|
+
"account",
|
|
294
|
+
provider,
|
|
295
|
+
]);
|
|
296
|
+
await filesystemBackend.delete(provider);
|
|
297
|
+
},
|
|
298
|
+
async list() {
|
|
299
|
+
return filesystemBackend.list();
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Windows — `cmdkey` can store but cannot read back the password
|
|
305
|
+
* portion without the Credential Manager API (which we would need a
|
|
306
|
+
* native binding for). Use filesystem fallback directly; the file
|
|
307
|
+
* lives under `%USERPROFILE%\.mandu\credentials.json` which is ACL'd
|
|
308
|
+
* to the current user by default. This matches the behavior of
|
|
309
|
+
* `gh auth login` when the Git Credential Manager is not installed.
|
|
310
|
+
*/
|
|
311
|
+
export const windowsBackend: CredentialBackend = {
|
|
312
|
+
name: "windows-filesystem",
|
|
313
|
+
save: filesystemBackend.save,
|
|
314
|
+
load: filesystemBackend.load,
|
|
315
|
+
delete: filesystemBackend.delete,
|
|
316
|
+
list: filesystemBackend.list,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pick the best backend for the current platform. Tests override this
|
|
321
|
+
* by constructing a `CredentialStore` with an explicit backend.
|
|
322
|
+
*/
|
|
323
|
+
export function pickPlatformBackend(): CredentialBackend {
|
|
324
|
+
if (process.platform === "darwin") return macosBackend;
|
|
325
|
+
if (process.platform === "win32") return windowsBackend;
|
|
326
|
+
if (process.platform === "linux") return linuxBackend;
|
|
327
|
+
return filesystemBackend;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* -------------------------------------------------------------------- */
|
|
331
|
+
/* Public store */
|
|
332
|
+
/* -------------------------------------------------------------------- */
|
|
333
|
+
|
|
334
|
+
export class CredentialStore {
|
|
335
|
+
constructor(private backend: CredentialBackend = pickPlatformBackend()) {}
|
|
336
|
+
|
|
337
|
+
/** Which backend is active — surfaced in `mandu brain status`. */
|
|
338
|
+
get backendName(): string {
|
|
339
|
+
return this.backend.name;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async save(provider: string, token: StoredToken): Promise<void> {
|
|
343
|
+
await this.backend.save(provider, token);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async load(provider: string): Promise<StoredToken | null> {
|
|
347
|
+
return this.backend.load(provider);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async delete(provider: string): Promise<void> {
|
|
351
|
+
await this.backend.delete(provider);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async list(): Promise<string[]> {
|
|
355
|
+
return this.backend.list();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Touch the last_used_at timestamp on an existing token without
|
|
360
|
+
* rotating the secret. Best-effort — swallow errors (telemetry).
|
|
361
|
+
*/
|
|
362
|
+
async touch(provider: string): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
const tok = await this.load(provider);
|
|
365
|
+
if (tok) {
|
|
366
|
+
tok.last_used_at = new Date().toISOString();
|
|
367
|
+
await this.save(provider, tok);
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
/* ignore */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* File path of the filesystem fallback — used by tests + the CLI
|
|
376
|
+
* `mandu brain status` to point users at the 0600 file. Always
|
|
377
|
+
* returns the same path regardless of backend.
|
|
378
|
+
*/
|
|
379
|
+
static fallbackPath(): string {
|
|
380
|
+
return FALLBACK_FILE;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Default singleton — production path. */
|
|
385
|
+
let defaultStore: CredentialStore | null = null;
|
|
386
|
+
export function getCredentialStore(): CredentialStore {
|
|
387
|
+
if (!defaultStore) {
|
|
388
|
+
defaultStore = new CredentialStore();
|
|
389
|
+
}
|
|
390
|
+
return defaultStore;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Override the singleton (test affordance). */
|
|
394
|
+
export function setCredentialStore(store: CredentialStore): void {
|
|
395
|
+
defaultStore = store;
|
|
396
|
+
}
|