@letta-ai/dreams 0.0.1

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 ADDED
@@ -0,0 +1,146 @@
1
+ # @letta-ai/dreams
2
+
3
+ CLI for **Dream** — Letta's shared organizational memory built from
4
+ coding-agent transcripts.
5
+
6
+ ## Setup
7
+
8
+ Start setup from a coding agent with:
9
+
10
+ ```sh
11
+ npx @letta-ai/dreams@latest onboard --json
12
+ ```
13
+
14
+ `onboard`:
15
+
16
+ 1. verifies Node >= 22.13 through the bin preflight
17
+ 2. installs the exact CLI version under `~/.dreams/cli/<version>/`
18
+ 3. creates or preserves the stable launcher at `~/.dreams/bin/dreams`
19
+ 4. creates or preserves the installation id in `~/.dreams/installation.json`
20
+ 5. installs `~/.claude/skills/dreams-setup/SKILL.md`
21
+ 6. initializes and migrates `~/.dreams/dreams.db`
22
+ 7. returns the next agent action based on authentication state
23
+
24
+ It does not start login or `orient` itself. An unauthenticated response directs
25
+ the agent to run `auth login` and then rerun `onboard`; an authenticated
26
+ response directs the agent to run `orient`. Repeated calls are safe and return
27
+ the same action until authentication state changes.
28
+
29
+ ### Commands
30
+
31
+ - `dreams onboard [--json]` — install or repair local setup and return the next
32
+ setup action.
33
+ - `dreams status [--json]` — installation, durable CLI, SQLite, setup skill,
34
+ and authentication state.
35
+ - `dreams auth login [--json]` — OAuth device code sign-in (see Auth below).
36
+ - `dreams auth status [--json]` — authentication state; tokens are masked
37
+ (`at-let-…abcd`), never printed.
38
+ - `dreams auth logout [--json]` — best-effort revoke of Dreams' refresh token,
39
+ then clear stored credentials.
40
+
41
+ Local state lives in `~/.dreams/`. Requires Node >= 22.13, where built-in
42
+ `node:sqlite` no longer requires the `--experimental-sqlite` flag.
43
+
44
+ ## Local sources (Claude Code)
45
+
46
+ The CLI maintains a crash-safe local SQLite store (`~/.dreams/dreams.db`) that
47
+ discovers Claude Code transcripts and prepares immutable upload segments before
48
+ they are sent to Letta Cloud.
49
+
50
+ - `dreams source approve-root <path> [--json]` — approve a work root. Sources
51
+ are **deny-by-default**: no transcript bytes are queued until their working
52
+ directory is under an approved root.
53
+ - `dreams source scan [--json]` — discover top-level Claude Code sessions
54
+ (`~/.claude/projects/<project>/<sessionId>.jsonl`), read only complete new
55
+ lines after the durable cursor, and enqueue deterministic upload segments.
56
+ Repeated scans are idempotent; `.dreamignore` and unapproved roots are honored
57
+ before anything is enqueued.
58
+ - `dreams source list [--json]` — show the DB path, approved roots, sources, and
59
+ the pending upload queue.
60
+ - `dreams upload [--json]` — authenticate, register the source, and upload due
61
+ queue entries to Letta Cloud.
62
+
63
+ Local cursors track a `queued_through_offset` (advanced as segments are queued)
64
+ separately from an `acked_offset` (advanced only after durable Cloud
65
+ acceptance, in a later step).
66
+
67
+ ## Auth
68
+
69
+ `dreams auth login` runs Letta's **OAuth 2.0 Device Code Flow** against
70
+ `https://app.letta.com` (override with `LETTA_AUTH_BASE_URL`; the API host is
71
+ `https://api.letta.com`, override with `LETTA_BASE_URL`):
72
+
73
+ 1. The CLI requests a device + user code and prints a verification URL.
74
+ 2. The user approves in the browser; the CLI polls the token endpoint
75
+ (handling `authorization_pending`, `slow_down`, `access_denied`,
76
+ `expired_token`) until it receives an access token (`at-let-…`, ~30 days)
77
+ and a refresh token (`rt-let-…`, 1 year).
78
+ 3. In `--json` mode, `auth login` emits **two JSON lines** on stdout: first
79
+ `{"status":"awaiting_approval",…}` (show the user the URL/code), then
80
+ `{"status":"authenticated",…}` after approval.
81
+
82
+ **Storage.** Credentials go to the OS keychain via `@napi-rs/keyring`
83
+ (an *optional* dependency — the CLI keeps zero hard runtime deps), service
84
+ `letta-dreams`, accounts `refresh-token` and `access-token`. When the keychain
85
+ is unavailable, the CLI falls back to `~/.dreams/credentials.json`, written
86
+ atomically with mode 0600 (a one-line stderr warning notes the fallback).
87
+ Tokens are never written to local setup state or logs, and status output always
88
+ masks them. `DREAMS_CREDENTIALS_BACKEND=file|keychain` forces a backend
89
+ (tests use `file`).
90
+
91
+ **Precedence** when resolving an auth header (`getAuthHeader()` in
92
+ `src/auth/index.ts`):
93
+
94
+ 1. `LETTA_API_KEY` env var — used verbatim, never refreshed, never stored.
95
+ 2. Stored Dreams credentials — the access token is refreshed automatically
96
+ when it is missing or expires within 5 minutes.
97
+ 3. Otherwise: a structured not-authenticated error (`dreams auth login`).
98
+
99
+ **Refresh rotation.** Refreshes use `refresh_token_mode: "new"`: the server
100
+ always returns a *new* refresh token that revokes the old one for this
101
+ (user, client, device). The CLI persists the rotated token immediately and
102
+ atomically before using the new access token, singleflights concurrent
103
+ in-process refreshes, and holds a lockfile (`~/.dreams/auth.lock`, stale
104
+ after 30s) so two dreams processes on one machine cannot interleave
105
+ rotate-then-persist. A refresh failing with `invalid_grant` (stale rotated
106
+ token) clears stored credentials.
107
+
108
+ **Why Dreams does not reuse letta-code's keychain entries.** Dreams shares
109
+ letta-code's *public client id*, but deliberately keeps its own credentials
110
+ (service `letta-dreams` vs `letta-code`) and its own `device_id` (the Dreams
111
+ `installation_id`). Rotation is scoped per (user, client, device): if both
112
+ CLIs shared one refresh token, each refresh by one tool would revoke the
113
+ token out from under the other (rotation collision). Separate device ids
114
+ guarantee the two token chains never collide, and `dreams auth logout` can
115
+ never revoke letta-code's session.
116
+
117
+ ## Development
118
+
119
+ ```sh
120
+ npm install
121
+ npm run dev -- onboard --json # run from source via tsx
122
+ npm run typecheck
123
+ npm run build # compile to dist/
124
+ npm test # build + node:test suite (uses a temp HOME)
125
+ ```
126
+
127
+ ## Testing the npx bootstrap via tarball (no publish)
128
+
129
+ ```sh
130
+ npm pack # produces letta-ai-dreams-0.0.1.tgz
131
+ cd "$(mktemp -d)"
132
+ npx -y -p /absolute/path/to/dreams/letta-ai-dreams-0.0.1.tgz dreams onboard
133
+ ```
134
+
135
+ (The `-p <tarball> dreams` form is required: a bare `npx <tarball>` treats the
136
+ path as a script to execute, not a package to install.)
137
+
138
+ `onboard` writes to `~/.dreams/` and installs
139
+ `~/.claude/skills/dreams-setup/SKILL.md`. Set `HOME` to a temporary directory
140
+ for a sandboxed run.
141
+
142
+ ## Deferred
143
+
144
+ - `orient` and its Cloud-backed setup actions
145
+ - Shared-memory generation and review
146
+ - Automated source configuration and historical backfill
package/bin/dreams.cjs ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Preflight Node version check. This shim must stay ES5-safe so it can run
3
+ // (and fail gracefully) on old Node versions before any modern syntax loads.
4
+ // Node >= 22.13 is required: that is where node:sqlite stopped needing the
5
+ // --experimental-sqlite flag, which the local source store depends on.
6
+ var REQUIRED_MAJOR = 22;
7
+ var REQUIRED_MINOR = 13;
8
+
9
+ var version = process.versions.node;
10
+ var parts = version.split('.');
11
+ var major = parseInt(parts[0], 10) || 0;
12
+ var minor = parseInt(parts[1], 10) || 0;
13
+
14
+ if (major < REQUIRED_MAJOR || (major === REQUIRED_MAJOR && minor < REQUIRED_MINOR)) {
15
+ console.error('Dream requires Node 22.13+ — found v' + version + '. Please upgrade.');
16
+ process.exit(1);
17
+ }
18
+
19
+ require('../dist/cli.js').main();
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openCommand = openCommand;
4
+ exports.openBrowser = openBrowser;
5
+ const node_child_process_1 = require("node:child_process");
6
+ /** Platform command for opening a URL in the default browser. */
7
+ function openCommand(platform) {
8
+ switch (platform) {
9
+ case "darwin":
10
+ return { cmd: "open", args: [] };
11
+ case "win32":
12
+ // `start` is a cmd builtin; empty title arg guards URLs with ampersands.
13
+ return { cmd: "cmd", args: ["/c", "start", '""'] };
14
+ case "linux":
15
+ return { cmd: "xdg-open", args: [] };
16
+ default:
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * Best-effort: open the verification URL in the user's default browser.
22
+ * Never throws, never blocks — the printed URL is always the fallback.
23
+ * Suppressed by DREAMS_NO_BROWSER=1 (CI, ssh sessions, tests).
24
+ */
25
+ function openBrowser(url) {
26
+ if (process.env.DREAMS_NO_BROWSER === "1")
27
+ return false;
28
+ const command = openCommand(process.platform);
29
+ if (!command)
30
+ return false;
31
+ try {
32
+ const child = (0, node_child_process_1.spawn)(command.cmd, [...command.args, url], {
33
+ detached: true,
34
+ stdio: "ignore",
35
+ });
36
+ child.on("error", () => { });
37
+ child.unref();
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ /**
3
+ * Command handlers for `dreams auth login|status|logout`.
4
+ *
5
+ * JSON mode of `auth login` is JSON-lines on stdout (agents drive this):
6
+ * one {"status":"awaiting_approval",…} object immediately, then a second
7
+ * {"status":"authenticated",…} object once the user approves in the browser.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.commandAuthLogin = commandAuthLogin;
11
+ exports.renderAuthStatus = renderAuthStatus;
12
+ exports.commandAuthStatus = commandAuthStatus;
13
+ exports.commandAuthLogout = commandAuthLogout;
14
+ const browser_1 = require("./browser");
15
+ const store_1 = require("../store");
16
+ const index_1 = require("./index");
17
+ const credentials_1 = require("./credentials");
18
+ const oauth_refresh_1 = require("./oauth-refresh");
19
+ const oauth_1 = require("./oauth");
20
+ /** `auth login` short-circuits when the stored token is valid for >7 days. */
21
+ const REAUTH_MARGIN_MS = 7 * 24 * 60 * 60 * 1000;
22
+ function printJsonLine(value) {
23
+ console.log(JSON.stringify(value));
24
+ }
25
+ async function commandAuthLogin(json) {
26
+ const stored = (0, credentials_1.loadCredentials)();
27
+ if (stored && stored.credentials.access_token && !(0, credentials_1.expiresWithin)(stored.credentials, REAUTH_MARGIN_MS)) {
28
+ if (json) {
29
+ printJsonLine({ status: "already_authenticated", expires_at: stored.credentials.expires_at });
30
+ }
31
+ else {
32
+ console.log(`Already authenticated (access token valid until ${stored.credentials.expires_at}).\n` +
33
+ `Run \`dreams auth logout\` first to connect a different account.`);
34
+ }
35
+ return 0;
36
+ }
37
+ const installation = (0, store_1.loadOrCreateInstallation)();
38
+ try {
39
+ const device = await (0, oauth_1.requestDeviceCode)();
40
+ const verifyUrl = device.verification_uri_complete || device.verification_uri;
41
+ const opened = (0, browser_1.openBrowser)(verifyUrl);
42
+ if (json) {
43
+ printJsonLine({
44
+ status: "awaiting_approval",
45
+ verification_uri: device.verification_uri,
46
+ verification_uri_complete: device.verification_uri_complete ?? null,
47
+ user_code: device.user_code,
48
+ expires_in: device.expires_in,
49
+ });
50
+ }
51
+ else {
52
+ console.log([
53
+ ``,
54
+ `To connect this device to Letta, open:`,
55
+ ``,
56
+ ` ${verifyUrl}`,
57
+ ``,
58
+ `and confirm the code: ${device.user_code}`,
59
+ ``,
60
+ opened ? `(opened in your browser)` : ``,
61
+ `Waiting for approval (expires in ${Math.round(device.expires_in / 60)} minutes)...`,
62
+ ].join("\n"));
63
+ }
64
+ let pendingPolls = 0;
65
+ const token = await (0, oauth_1.pollForToken)({
66
+ deviceCode: device.device_code,
67
+ deviceId: installation.installation_id,
68
+ intervalSeconds: device.interval,
69
+ expiresInSeconds: device.expires_in,
70
+ onPending: () => {
71
+ pendingPolls += 1;
72
+ if (!json && pendingPolls % 6 === 0)
73
+ console.log("Still waiting for approval...");
74
+ },
75
+ });
76
+ const credentials = (0, oauth_refresh_1.credentialsFromToken)(token, "");
77
+ (0, credentials_1.saveCredentials)(credentials);
78
+ if (json) {
79
+ printJsonLine({ status: "authenticated", expires_at: credentials.expires_at });
80
+ }
81
+ else {
82
+ console.log(`\nAuthenticated. Access token expires ${credentials.expires_at}.`);
83
+ if (process.env.LETTA_API_KEY) {
84
+ process.stderr.write("Note: LETTA_API_KEY is set and takes precedence over stored credentials.\n");
85
+ }
86
+ }
87
+ return 0;
88
+ }
89
+ catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ if (json)
92
+ printJsonLine({ status: "error", message });
93
+ else
94
+ process.stderr.write(`Login failed: ${message}\n`);
95
+ return 1;
96
+ }
97
+ }
98
+ function renderAuthStatus(info) {
99
+ if (!info.authenticated) {
100
+ return [
101
+ `Not authenticated — run \`dreams auth login\` to connect this device.`,
102
+ ``,
103
+ ` Device id: ${info.device_id}`,
104
+ ].join("\n");
105
+ }
106
+ return [
107
+ `Authenticated via ${info.source}`,
108
+ ``,
109
+ ` Access token: ${info.access_token ?? "(none — will refresh on next use)"}`,
110
+ ` Expires at: ${info.expires_at ?? "(n/a — LETTA_API_KEY)"}`,
111
+ ` Device id: ${info.device_id}`,
112
+ ].join("\n");
113
+ }
114
+ function commandAuthStatus(json) {
115
+ const info = (0, index_1.authStatusInfo)();
116
+ if (json) {
117
+ console.log(JSON.stringify(info, null, 2));
118
+ }
119
+ else {
120
+ console.log(renderAuthStatus(info));
121
+ }
122
+ return 0;
123
+ }
124
+ async function commandAuthLogout(json) {
125
+ const stored = (0, credentials_1.loadCredentials)();
126
+ if (!stored) {
127
+ if (json)
128
+ printJsonLine({ status: "not_authenticated" });
129
+ else
130
+ console.log("No stored credentials to clear.");
131
+ return 0;
132
+ }
133
+ // Best-effort revoke of Dreams' own refresh token (never letta-code's).
134
+ await (0, oauth_1.revokeToken)(stored.credentials.refresh_token);
135
+ (0, credentials_1.clearCredentials)();
136
+ if (json)
137
+ printJsonLine({ status: "logged_out" });
138
+ else
139
+ console.log("Logged out: refresh token revoked and local credentials cleared.");
140
+ return 0;
141
+ }
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ /**
3
+ * Credential storage for the Dreams CLI.
4
+ *
5
+ * Primary backend: OS keychain via @napi-rs/keyring (an OPTIONAL dependency —
6
+ * loaded with a try/require guard), service "letta-dreams", accounts
7
+ * "refresh-token" and "access-token" (the latter stores JSON with expires_at).
8
+ * Fallback: ~/.dreams/credentials.json written atomically with mode 0600.
9
+ *
10
+ * Tokens must NEVER be written to local setup state or logs. This module never
11
+ * touches letta-code's keychain entries (its service name is "letta-code").
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.EXPIRY_MARGIN_MS = exports.NotAuthenticatedError = void 0;
48
+ exports.credentialsFilePath = credentialsFilePath;
49
+ exports.loadCredentials = loadCredentials;
50
+ exports.saveCredentials = saveCredentials;
51
+ exports.clearCredentials = clearCredentials;
52
+ exports.expiresWithin = expiresWithin;
53
+ exports.maskToken = maskToken;
54
+ const fs = __importStar(require("node:fs"));
55
+ const path = __importStar(require("node:path"));
56
+ const store_1 = require("../store");
57
+ class NotAuthenticatedError extends Error {
58
+ code = "not_authenticated";
59
+ constructor(message = "Not authenticated. Run `dreams auth login` to connect this device.") {
60
+ super(message);
61
+ this.name = "NotAuthenticatedError";
62
+ }
63
+ }
64
+ exports.NotAuthenticatedError = NotAuthenticatedError;
65
+ const KEYCHAIN_SERVICE = "letta-dreams";
66
+ const REFRESH_ACCOUNT = "refresh-token";
67
+ const ACCESS_ACCOUNT = "access-token";
68
+ /** Refresh when the access token is missing or expires within 5 minutes. */
69
+ exports.EXPIRY_MARGIN_MS = 5 * 60 * 1000;
70
+ const EPOCH_ISO = new Date(0).toISOString();
71
+ let cachedKeyringCtor;
72
+ function requireKeyring() {
73
+ if (cachedKeyringCtor === undefined) {
74
+ try {
75
+ // Optional dependency: may be missing (install skipped/failed) — fall back.
76
+ const mod = require("@napi-rs/keyring");
77
+ cachedKeyringCtor = mod.Entry ?? null;
78
+ }
79
+ catch {
80
+ cachedKeyringCtor = null;
81
+ }
82
+ }
83
+ return cachedKeyringCtor;
84
+ }
85
+ function backendOverride() {
86
+ const value = process.env.DREAMS_CREDENTIALS_BACKEND;
87
+ return value === "file" || value === "keychain" ? value : null;
88
+ }
89
+ /** null means "do not use the keychain" (forced file backend or module absent). */
90
+ function keyringCtor() {
91
+ const override = backendOverride();
92
+ if (override === "file")
93
+ return null;
94
+ const ctor = requireKeyring();
95
+ if (override === "keychain" && ctor === null) {
96
+ throw new Error("DREAMS_CREDENTIALS_BACKEND=keychain but @napi-rs/keyring is unavailable");
97
+ }
98
+ return ctor;
99
+ }
100
+ function keychainRead(account) {
101
+ const ctor = keyringCtor();
102
+ if (!ctor)
103
+ return null;
104
+ try {
105
+ return new ctor(KEYCHAIN_SERVICE, account).getPassword() ?? null;
106
+ }
107
+ catch {
108
+ return null; // no entry, or keychain unusable — fall through to file
109
+ }
110
+ }
111
+ function keychainDelete(account) {
112
+ const ctor = keyringCtor();
113
+ if (!ctor)
114
+ return;
115
+ try {
116
+ new ctor(KEYCHAIN_SERVICE, account).deleteCredential();
117
+ }
118
+ catch {
119
+ // already gone or keychain unusable
120
+ }
121
+ }
122
+ // --- file backend ------------------------------------------------------------
123
+ function credentialsFilePath() {
124
+ return path.join((0, store_1.dreamsDir)(), "credentials.json");
125
+ }
126
+ function fileRead() {
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(fs.readFileSync(credentialsFilePath(), "utf8"));
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ if (typeof parsed.refresh_token !== "string" || parsed.refresh_token === "")
135
+ return null;
136
+ return {
137
+ access_token: typeof parsed.access_token === "string" ? parsed.access_token : "",
138
+ refresh_token: parsed.refresh_token,
139
+ expires_at: typeof parsed.expires_at === "string" ? parsed.expires_at : EPOCH_ISO,
140
+ };
141
+ }
142
+ /** Atomic write (temp + rename) with owner-only permissions. */
143
+ function fileWrite(credentials) {
144
+ const file = credentialsFilePath();
145
+ fs.mkdirSync(path.dirname(file), { recursive: true });
146
+ const tmp = `${file}.tmp`;
147
+ fs.writeFileSync(tmp, JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
148
+ fs.renameSync(tmp, file);
149
+ }
150
+ let warnedKeychainUnavailable = false;
151
+ function warnKeychainUnavailable() {
152
+ if (warnedKeychainUnavailable)
153
+ return;
154
+ warnedKeychainUnavailable = true;
155
+ process.stderr.write("Warning: OS keychain unavailable — storing credentials in ~/.dreams/credentials.json (mode 0600).\n");
156
+ }
157
+ // --- public API ---------------------------------------------------------------
158
+ /**
159
+ * Load stored credentials: keychain first, then the file fallback (covers
160
+ * installs where an earlier save fell back to the file).
161
+ */
162
+ function loadCredentials() {
163
+ const refresh = keychainRead(REFRESH_ACCOUNT);
164
+ if (refresh) {
165
+ let access_token = "";
166
+ let expires_at = EPOCH_ISO;
167
+ const accessRaw = keychainRead(ACCESS_ACCOUNT);
168
+ if (accessRaw) {
169
+ try {
170
+ const parsed = JSON.parse(accessRaw);
171
+ if (typeof parsed.access_token === "string")
172
+ access_token = parsed.access_token;
173
+ if (typeof parsed.expires_at === "string")
174
+ expires_at = parsed.expires_at;
175
+ }
176
+ catch {
177
+ // stale/foreign entry shape — treat access token as expired
178
+ }
179
+ }
180
+ return { credentials: { access_token, refresh_token: refresh, expires_at }, source: "keychain" };
181
+ }
182
+ const fromFile = fileRead();
183
+ return fromFile ? { credentials: fromFile, source: "file" } : null;
184
+ }
185
+ /** Persist credentials: keychain if usable, else the 0600 file (with a warning). */
186
+ function saveCredentials(credentials) {
187
+ const ctor = keyringCtor();
188
+ if (ctor) {
189
+ try {
190
+ new ctor(KEYCHAIN_SERVICE, REFRESH_ACCOUNT).setPassword(credentials.refresh_token);
191
+ new ctor(KEYCHAIN_SERVICE, ACCESS_ACCOUNT).setPassword(JSON.stringify({ access_token: credentials.access_token, expires_at: credentials.expires_at }));
192
+ // Remove any stale file copy so there is a single source of truth.
193
+ fs.rmSync(credentialsFilePath(), { force: true });
194
+ return;
195
+ }
196
+ catch {
197
+ // keychain present but unusable (locked, headless, …) — fall back
198
+ }
199
+ }
200
+ if (backendOverride() !== "file")
201
+ warnKeychainUnavailable();
202
+ fileWrite(credentials);
203
+ }
204
+ /** Clear both backends (login/logout/invalid_grant paths). */
205
+ function clearCredentials() {
206
+ keychainDelete(REFRESH_ACCOUNT);
207
+ keychainDelete(ACCESS_ACCOUNT);
208
+ fs.rmSync(credentialsFilePath(), { force: true });
209
+ }
210
+ function expiresWithin(credentials, marginMs = exports.EXPIRY_MARGIN_MS) {
211
+ const at = Date.parse(credentials.expires_at);
212
+ if (Number.isNaN(at))
213
+ return true;
214
+ return at - Date.now() <= marginMs;
215
+ }
216
+ /** Mask a token for display: `at-let-…abcd`. Never print tokens unmasked. */
217
+ function maskToken(token) {
218
+ const prefixMatch = token.match(/^[a-z]{2}-let-/);
219
+ const prefix = prefixMatch ? prefixMatch[0] : token.slice(0, 3);
220
+ const suffix = token.length >= prefix.length + 8 ? token.slice(-4) : "";
221
+ return `${prefix}…${suffix}`;
222
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ /**
3
+ * Auth entry point for the Dreams CLI.
4
+ *
5
+ * Precedence for resolving an auth header:
6
+ * 1. LETTA_API_KEY env var — used verbatim; never refreshed, never stored.
7
+ * 2. Stored Dreams credentials — refreshed when missing/expiring within 5 min.
8
+ * 3. Otherwise: NotAuthenticatedError telling the caller to run
9
+ * `dreams auth login`.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NotAuthenticatedError = void 0;
13
+ exports.resolveAccessToken = resolveAccessToken;
14
+ exports.getAuthHeader = getAuthHeader;
15
+ exports.authStatusInfo = authStatusInfo;
16
+ const store_1 = require("../store");
17
+ const credentials_1 = require("./credentials");
18
+ Object.defineProperty(exports, "NotAuthenticatedError", { enumerable: true, get: function () { return credentials_1.NotAuthenticatedError; } });
19
+ const oauth_refresh_1 = require("./oauth-refresh");
20
+ async function resolveAccessToken() {
21
+ const envKey = process.env.LETTA_API_KEY;
22
+ if (envKey) {
23
+ return { token: envKey, source: "env" };
24
+ }
25
+ const stored = (0, credentials_1.loadCredentials)();
26
+ if (!stored) {
27
+ throw new credentials_1.NotAuthenticatedError();
28
+ }
29
+ if (stored.credentials.access_token && !(0, credentials_1.expiresWithin)(stored.credentials)) {
30
+ return { token: stored.credentials.access_token, source: stored.source };
31
+ }
32
+ const deviceId = (0, store_1.loadOrCreateInstallation)().installation_id;
33
+ const refreshed = await (0, oauth_refresh_1.refreshCredentialsSingleFlight)(stored.credentials, deviceId);
34
+ return { token: refreshed.access_token, source: stored.source };
35
+ }
36
+ /** Auth header for the (future) upload client. */
37
+ async function getAuthHeader() {
38
+ const { token } = await resolveAccessToken();
39
+ return { authorization: `Bearer ${token}` };
40
+ }
41
+ /** Snapshot of auth state for `dreams auth status` and `dreams status`. Never refreshes. */
42
+ function authStatusInfo() {
43
+ const device_id = (0, store_1.loadOrCreateInstallation)().installation_id;
44
+ const envKey = process.env.LETTA_API_KEY;
45
+ if (envKey) {
46
+ return {
47
+ authenticated: true,
48
+ source: "env",
49
+ access_token: (0, credentials_1.maskToken)(envKey),
50
+ expires_at: null,
51
+ device_id,
52
+ };
53
+ }
54
+ const stored = (0, credentials_1.loadCredentials)();
55
+ if (!stored) {
56
+ return { authenticated: false, source: null, access_token: null, expires_at: null, device_id };
57
+ }
58
+ return {
59
+ authenticated: true,
60
+ source: stored.source,
61
+ access_token: stored.credentials.access_token ? (0, credentials_1.maskToken)(stored.credentials.access_token) : null,
62
+ expires_at: stored.credentials.expires_at,
63
+ device_id,
64
+ };
65
+ }