@dreamlake/ml-dash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ge Yang, Tom Tao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # ml-dash
2
+
3
+ ML experiment tracking from the command line. Authenticate, inspect projects
4
+ and experiments, and move experiment data to and from an ML-Dash server.
5
+
6
+ ## Install
7
+
8
+ **macOS / Linux** — one self-contained binary, no Node or Python needed:
9
+
10
+ ```sh
11
+ curl -fsSL https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.sh | sh
12
+ ```
13
+
14
+ **Windows** (PowerShell):
15
+
16
+ ```powershell
17
+ irm https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.ps1 | iex
18
+ ```
19
+
20
+ **With Node ≥ 20.19 already installed:**
21
+
22
+ ```sh
23
+ npm install -g @dreamlake/ml-dash
24
+ ```
25
+
26
+ The command is still `ml-dash`. The package is scoped because the unscoped
27
+ name is not claimable — npm refuses `ml-dash` as too similar to the existing
28
+ `mldash`.
29
+
30
+ *Not published yet:* the npm channel is built and tested on every release run,
31
+ but no version has reached the registry. Until one does, use the installers
32
+ above. `docs/RELEASE.md` tracks what is outstanding.
33
+
34
+ Both channels run the same code — the binaries are `src/index.ts` compiled
35
+ ahead of time, the npm package is the same source compiled to `dist/`.
36
+
37
+ ### Pinning a version
38
+
39
+ ```sh
40
+ curl -fsSL https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.sh | sh -s -- --version 0.1.0
41
+ ```
42
+
43
+ ```powershell
44
+ & ([scriptblock]::Create((irm https://pub-42e1dcc7de574d4a92984865fdc95f10.r2.dev/install.ps1))) -Version 0.1.0
45
+ ```
46
+
47
+ An installer always downloads from the host it was itself served from — that
48
+ base URL is compiled into it at build time — so piping one host's installer
49
+ into a shell never fetches binaries from somewhere else. `--base-url` /
50
+ `-BaseUrl` overrides it for testing against a staging bucket.
51
+
52
+ The installers verify every download against the sha256 in that release's
53
+ manifest before writing anything, so a pinned version installs identical bytes
54
+ on every machine. They install into `~/.local/bin` (`%LOCALAPPDATA%\ml-dash\bin`
55
+ on Windows) — override with `--install-dir` / `-InstallDir` — and they never
56
+ remove or overwrite an `ml-dash` installed by npm or pip; a conflict on PATH is
57
+ reported and left for you to resolve with the tool that owns it. If the
58
+ install path is already occupied by a file this installer did not write — a
59
+ symlink, a pipx shim, anything without its receipt — it stops instead of
60
+ overwriting; `--force` / `-Force` takes over deliberately.
61
+
62
+ ## Usage
63
+
64
+ ```sh
65
+ ml-dash --help
66
+ ml-dash login
67
+ ml-dash list projects
68
+ ml-dash upload <local-path> <remote-path>
69
+ ml-dash download <remote-path> <local-path>
70
+ ```
71
+
72
+ Run `ml-dash <command> --help` for the flags of any command.
73
+
74
+ ## Supported platforms
75
+
76
+ macOS (arm64, x64), Linux (x64, arm64; glibc and musl), Windows (x64, arm64).
77
+
78
+ The binaries bundle their own runtime, so no Node and no Python is needed.
79
+ They are not statically linked, though: the musl builds link against
80
+ `libstdc++.so.6` and `libgcc_s.so.1`, which a bare Alpine image does not
81
+ ship. On Alpine, install them once:
82
+
83
+ ```sh
84
+ apk add --no-cache libstdc++
85
+ ```
86
+
87
+ `install.sh` runs the downloaded binary before installing it, so a missing
88
+ system library is reported at install time with nothing written, rather
89
+ than at your next `ml-dash` command.
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](LICENSE). Same terms and copyright as the Python `ml-dash`
94
+ package this CLI talks to.
95
+
96
+ ## Releasing
97
+
98
+ See [docs/RELEASE.md](docs/RELEASE.md).
package/bin/ml-dash.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // npm channel entry point. The R2 channel compiles src/index.ts into a
3
+ // single-file binary with `bun build --compile` instead; both run the same
4
+ // TypeScript, so there is no second implementation to drift.
5
+ import "../dist/index.js";
@@ -0,0 +1,131 @@
1
+ /**
2
+ * OAuth 2.0 device authorization against vuer-auth, then token exchange with
3
+ * the ml-dash server.
4
+ *
5
+ * This is a variant of RFC 8628, not the RFC itself: the poll request carries
6
+ * `client_id` + `device_secret_hash` and no `device_code`. Sending a standard
7
+ * poll body instead gets `authorization_pending` forever.
8
+ */
9
+ import { hashDeviceSecret } from "./device-secret.js";
10
+ export const VUER_AUTH_URL = "https://auth.vuer.ai";
11
+ export const CLIENT_ID = "ml-dash-client";
12
+ export const DEFAULT_SCOPE = "openid profile email";
13
+ export class DeviceCodeExpiredError extends Error {
14
+ }
15
+ export class AuthorizationDeniedError extends Error {
16
+ }
17
+ export class TokenExchangeError extends Error {
18
+ }
19
+ export class AuthorizationTimeoutError extends Error {
20
+ }
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ export class DeviceFlowClient {
23
+ deviceSecret;
24
+ dashUrl;
25
+ authUrl;
26
+ constructor(deviceSecret, dashUrl, authUrl = VUER_AUTH_URL) {
27
+ this.deviceSecret = deviceSecret;
28
+ this.dashUrl = dashUrl;
29
+ this.authUrl = authUrl;
30
+ this.dashUrl = dashUrl.replace(/\/+$/, "");
31
+ this.authUrl = authUrl.replace(/\/+$/, "");
32
+ }
33
+ async startDeviceFlow(scope = DEFAULT_SCOPE) {
34
+ const res = await fetch(`${this.authUrl}/api/device/start`, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({
38
+ client_id: CLIENT_ID,
39
+ scope,
40
+ device_secret_hash: hashDeviceSecret(this.deviceSecret),
41
+ }),
42
+ signal: AbortSignal.timeout(10_000),
43
+ });
44
+ if (!res.ok)
45
+ throw new Error(`Device flow start failed: ${res.status} ${await res.text()}`);
46
+ const data = await res.json();
47
+ const uri = data.verification_uri;
48
+ const userCode = data.user_code;
49
+ return {
50
+ userCode,
51
+ deviceCode: data.device_code ?? "",
52
+ verificationUri: uri,
53
+ verificationUriComplete: data.verification_uri_complete ?? `${uri}?code=${userCode.replace(/-/g, "")}`,
54
+ expiresIn: data.expires_in ?? 600,
55
+ interval: data.interval ?? 5,
56
+ };
57
+ }
58
+ /** Poll until authorized. 120 attempts at 5 s is the Python CLI's 10-minute budget. */
59
+ async pollForToken(maxAttempts = 120, onProgress) {
60
+ const hash = hashDeviceSecret(this.deviceSecret);
61
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
62
+ onProgress?.(attempt * 5);
63
+ let res;
64
+ try {
65
+ res = await fetch(`${this.authUrl}/api/device/poll`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ client_id: CLIENT_ID, device_secret_hash: hash }),
69
+ signal: AbortSignal.timeout(10_000),
70
+ });
71
+ }
72
+ catch {
73
+ // Transient network trouble mid-flow should not end the login.
74
+ await sleep(5_000);
75
+ continue;
76
+ }
77
+ if (res.status === 200)
78
+ return (await res.json()).access_token;
79
+ let error;
80
+ try {
81
+ error = (await res.json()).error;
82
+ }
83
+ catch {
84
+ error = undefined;
85
+ }
86
+ if (error === "authorization_pending") {
87
+ await sleep(5_000);
88
+ continue;
89
+ }
90
+ if (error === "slow_down") {
91
+ await sleep(10_000);
92
+ continue;
93
+ }
94
+ if (error === "expired_token") {
95
+ throw new DeviceCodeExpiredError("Device code expired. Please run 'ml-dash login' again.");
96
+ }
97
+ if (error === "access_denied") {
98
+ throw new AuthorizationDeniedError("User denied authorization request.");
99
+ }
100
+ throw new TokenExchangeError(`Device flow error: ${error ?? res.status}`);
101
+ }
102
+ throw new AuthorizationTimeoutError("Authorization timed out after 10 minutes. Please run 'ml-dash login' again.");
103
+ }
104
+ /** Trade the short-lived vuer-auth JWT for a permanent ml-dash token. */
105
+ async exchangeToken(vuerAuthToken) {
106
+ let res;
107
+ try {
108
+ res = await fetch(`${this.dashUrl}/api/auth/exchange`, {
109
+ method: "POST",
110
+ headers: { Authorization: `Bearer ${vuerAuthToken}` },
111
+ signal: AbortSignal.timeout(10_000),
112
+ });
113
+ }
114
+ catch (e) {
115
+ throw new TokenExchangeError(`Network error during token exchange: ${e}`);
116
+ }
117
+ if (res.status === 401) {
118
+ throw new TokenExchangeError("Vuer-auth token invalid or expired. Please try logging in again.");
119
+ }
120
+ if (res.status === 404) {
121
+ throw new TokenExchangeError("Token exchange endpoint not found. Please ensure ml-dash server is up to date.");
122
+ }
123
+ if (!res.ok) {
124
+ throw new TokenExchangeError(`Token exchange failed: ${res.status} ${await res.text()}`);
125
+ }
126
+ const token = (await res.json()).ml_dash_token;
127
+ if (!token)
128
+ throw new TokenExchangeError("Server response missing ml_dash_token field");
129
+ return token;
130
+ }
131
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Device identity for the authorization flow.
3
+ *
4
+ * vuer-auth's device endpoints identify this client by the SHA-256 of a
5
+ * locally generated secret rather than by a server-issued device_code, so the
6
+ * hash has to be computed the same way the Python CLI computed it or polling
7
+ * never matches the pending authorization.
8
+ */
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ /** 32 bytes of entropy, hex-encoded — the shape `secrets.token_hex(32)` produced. */
11
+ export const generateDeviceSecret = () => randomBytes(32).toString("hex");
12
+ export const hashDeviceSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
13
+ export function getOrCreateDeviceSecret(config) {
14
+ const existing = config.deviceSecret;
15
+ if (existing)
16
+ return existing;
17
+ const secret = generateDeviceSecret();
18
+ config.set("device_secret", secret);
19
+ return secret;
20
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Fernet (symmetric encryption) over Node's crypto.
3
+ *
4
+ * The Python CLI stored fallback tokens with `cryptography.fernet.Fernet` in
5
+ * ~/.dash/tokens.encrypted, keyed by ~/.dash/encryption.key. Reusing those
6
+ * credentials means reproducing the wire format exactly, so this implements
7
+ * the published spec rather than "some AES":
8
+ *
9
+ * token = base64url( 0x80 ‖ timestamp(8, big-endian) ‖ IV(16)
10
+ * ‖ AES-128-CBC(PKCS7(plaintext)) ‖ HMAC-SHA256(32) )
11
+ *
12
+ * The key is base64url of 32 bytes: the first 16 are the signing key, the last
13
+ * 16 the encryption key. The HMAC covers everything before it.
14
+ *
15
+ * Nothing here is verified by construction — a plausible-looking but wrong
16
+ * implementation still produces tokens that decrypt fine against itself. It is
17
+ * checked instead against a fixture that Python's own Fernet produced; see
18
+ * test/fernet-interop.test.ts.
19
+ */
20
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
21
+ const VERSION = 0x80;
22
+ const b64urlDecode = (s) => Buffer.from(s, "base64url");
23
+ /**
24
+ * Fernet's base64 is padded. Node's "base64url" encoding strips the `=`, and
25
+ * Python's `base64.urlsafe_b64decode` — which `cryptography.fernet` calls on
26
+ * both the key and the token — raises on a stripped one. Unpadded output would
27
+ * therefore round-trip perfectly here and be unreadable by the Python CLI, so
28
+ * the padding is restored on the way out.
29
+ */
30
+ const b64urlEncode = (b) => {
31
+ const s = b.toString("base64url");
32
+ return s + "=".repeat((4 - (s.length % 4)) % 4);
33
+ };
34
+ export class InvalidFernetToken extends Error {
35
+ }
36
+ export function parseKey(key) {
37
+ const raw = typeof key === "string" ? b64urlDecode(key.trim()) : key;
38
+ if (raw.length !== 32) {
39
+ throw new InvalidFernetToken(`Fernet key must decode to 32 bytes, got ${raw.length}. ` +
40
+ "~/.dash/encryption.key is not a Fernet key.");
41
+ }
42
+ return { signingKey: raw.subarray(0, 16), encryptionKey: raw.subarray(16, 32) };
43
+ }
44
+ /**
45
+ * A new key, in the exact text form Python's `Fernet.generate_key()` emits:
46
+ * url-safe base64 *with* padding. `base64.urlsafe_b64decode` rejects a
47
+ * stripped `=`, so an unpadded key would be written fine here and then be
48
+ * unreadable by the Python CLI sharing the same ~/.dash directory.
49
+ */
50
+ export function generateKey() {
51
+ return b64urlEncode(randomBytes(32));
52
+ }
53
+ export function encrypt(key, plaintext, opts = {}) {
54
+ const { signingKey, encryptionKey } = parseKey(key);
55
+ const iv = opts.iv ?? randomBytes(16);
56
+ if (iv.length !== 16)
57
+ throw new InvalidFernetToken("IV must be 16 bytes");
58
+ const ts = Buffer.alloc(8);
59
+ ts.writeBigUInt64BE(BigInt(opts.timestamp ?? Math.floor(Date.now() / 1000)));
60
+ const cipher = createCipheriv("aes-128-cbc", encryptionKey, iv);
61
+ // Node applies PKCS#7 padding by default, which is what Fernet specifies.
62
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
63
+ const body = Buffer.concat([Buffer.from([VERSION]), ts, iv, ciphertext]);
64
+ const hmac = createHmac("sha256", signingKey).update(body).digest();
65
+ return b64urlEncode(Buffer.concat([body, hmac]));
66
+ }
67
+ export function decrypt(key, token) {
68
+ const { signingKey, encryptionKey } = parseKey(key);
69
+ const raw = b64urlDecode(token.trim());
70
+ // 1 version + 8 timestamp + 16 IV + at least one 16-byte block + 32 HMAC.
71
+ if (raw.length < 73)
72
+ throw new InvalidFernetToken("Fernet token is too short");
73
+ if (raw[0] !== VERSION) {
74
+ throw new InvalidFernetToken(`Unsupported Fernet version byte 0x${raw[0].toString(16)}`);
75
+ }
76
+ const body = raw.subarray(0, raw.length - 32);
77
+ const mac = raw.subarray(raw.length - 32);
78
+ const expected = createHmac("sha256", signingKey).update(body).digest();
79
+ // Reject before decrypting: a wrong key must fail as a signature error, not
80
+ // as a padding error, which is the whole point of Fernet's encrypt-then-MAC.
81
+ if (mac.length !== expected.length || !timingSafeEqual(mac, expected)) {
82
+ throw new InvalidFernetToken("Fernet signature does not verify — wrong key or corrupt token");
83
+ }
84
+ const iv = body.subarray(9, 25);
85
+ const ciphertext = body.subarray(25);
86
+ if (ciphertext.length === 0 || ciphertext.length % 16 !== 0) {
87
+ throw new InvalidFernetToken("Fernet ciphertext is not a whole number of AES blocks");
88
+ }
89
+ const decipher = createDecipheriv("aes-128-cbc", encryptionKey, iv);
90
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
91
+ }
92
+ /** Seconds-since-epoch the token records. Present for parity; unused by the CLI. */
93
+ export function tokenTimestamp(token) {
94
+ const raw = b64urlDecode(token.trim());
95
+ if (raw.length < 9)
96
+ throw new InvalidFernetToken("Fernet token is too short");
97
+ return Number(raw.readBigUInt64BE(1));
98
+ }
@@ -0,0 +1,12 @@
1
+ /** Decode a JWT payload without verifying it — for display only, as the Python CLI did. */
2
+ export function decodeJwtPayload(token) {
3
+ try {
4
+ const parts = token.split(".");
5
+ if (parts.length !== 3)
6
+ return {};
7
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Where the ml-dash token lives, and how this CLI reaches credentials the
3
+ * Python CLI wrote.
4
+ *
5
+ * The Python CLI tried, in order: the OS keyring (service "ml-dash", account
6
+ * "ml-dash-token"), then a Fernet-encrypted file, then plaintext. This keeps
7
+ * the same order and the same on-disk formats, so an existing login keeps
8
+ * working rather than silently appearing logged-out.
9
+ *
10
+ * A compiled single-file binary has no Python `keyring` to call, so the OS
11
+ * keychain is reached through the platform's own tool: `security` on macOS,
12
+ * `secret-tool` on Linux. Both are read WITHOUT putting the secret on a
13
+ * command line — reads print to stdout, and the macOS write feeds the value
14
+ * over stdin — so the token never appears in `ps` output.
15
+ *
16
+ * Windows Credential Manager has no comparable tool that ships with the OS.
17
+ * Rather than pretend, `unreadableKeychainReason` reports it and the caller
18
+ * tells the user to run `ml-dash login` again. A missing credential must never
19
+ * read as an empty success.
20
+ */
21
+ import { spawnSync } from "node:child_process";
22
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import path from "node:path";
25
+ import { decrypt, encrypt, generateKey } from "./fernet.js";
26
+ export const SERVICE_NAME = "ml-dash";
27
+ export const TOKEN_KEY = "ml-dash-token";
28
+ const has = (cmd) => spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }).status === 0;
29
+ const macKeychain = {
30
+ load(key) {
31
+ const r = spawnSync("security", ["find-generic-password", "-s", SERVICE_NAME, "-a", key, "-w"], {
32
+ encoding: "utf8",
33
+ });
34
+ // 44 is `security`'s "item not found". Anything else non-zero is a real
35
+ // failure (a denied keychain prompt, a locked keychain) and must not be
36
+ // flattened into "no token".
37
+ if (r.status === 0)
38
+ return r.stdout.replace(/\n$/, "");
39
+ if (r.status === 44)
40
+ return null;
41
+ throw new Error(`macOS keychain read failed (exit ${r.status}): ${(r.stderr || "").trim()}`);
42
+ },
43
+ store(key, value) {
44
+ // `-w` with no value makes `security` read the password from stdin twice,
45
+ // which keeps it out of argv.
46
+ const r = spawnSync("security", ["add-generic-password", "-U", "-s", SERVICE_NAME, "-a", key, "-w"], {
47
+ input: `${value}\n${value}\n`,
48
+ encoding: "utf8",
49
+ });
50
+ if (r.status !== 0)
51
+ throw new Error(`macOS keychain write failed: ${(r.stderr || "").trim()}`);
52
+ },
53
+ delete(key) {
54
+ spawnSync("security", ["delete-generic-password", "-s", SERVICE_NAME, "-a", key], { stdio: "ignore" });
55
+ },
56
+ };
57
+ const secretToolKeychain = {
58
+ load(key) {
59
+ const r = spawnSync("secret-tool", ["lookup", "service", SERVICE_NAME, "username", key], {
60
+ encoding: "utf8",
61
+ });
62
+ if (r.status === 0 && r.stdout !== "")
63
+ return r.stdout.replace(/\n$/, "");
64
+ return null;
65
+ },
66
+ store(key, value) {
67
+ const r = spawnSync("secret-tool", ["store", "--label", `${SERVICE_NAME} ${key}`, "service", SERVICE_NAME, "username", key], { input: value, encoding: "utf8" });
68
+ if (r.status !== 0)
69
+ throw new Error(`secret-tool write failed: ${(r.stderr || "").trim()}`);
70
+ },
71
+ delete(key) {
72
+ spawnSync("secret-tool", ["clear", "service", SERVICE_NAME, "username", key], { stdio: "ignore" });
73
+ },
74
+ };
75
+ function keychain() {
76
+ if (process.env.ML_DASH_NO_KEYCHAIN === "1")
77
+ return null;
78
+ if (process.platform === "darwin" && has("security"))
79
+ return macKeychain;
80
+ if (process.platform === "linux" && has("secret-tool"))
81
+ return secretToolKeychain;
82
+ return null;
83
+ }
84
+ /**
85
+ * Why the keychain could not be consulted, when that is worth telling the user.
86
+ * Returns null when there is nothing unusual to report.
87
+ */
88
+ export function unreadableKeychainReason() {
89
+ if (process.env.ML_DASH_NO_KEYCHAIN === "1")
90
+ return null;
91
+ if (process.platform === "win32") {
92
+ return ("Windows Credential Manager cannot be read by this build. If you previously " +
93
+ "logged in with the Python ml-dash CLI, that token is not reachable here.");
94
+ }
95
+ if (process.platform === "linux" && !has("secret-tool")) {
96
+ return ("`secret-tool` is not installed, so the GNOME keyring cannot be read. " +
97
+ "If you previously logged in with the Python ml-dash CLI, that token is not reachable here.");
98
+ }
99
+ if (process.platform === "darwin" && !has("security")) {
100
+ return "`security` is not on PATH, so the macOS Keychain cannot be read.";
101
+ }
102
+ return null;
103
+ }
104
+ // ── file backends ────────────────────────────────────────────────────────────
105
+ export class TokenStore {
106
+ configDir;
107
+ encFile;
108
+ keyFile;
109
+ plainFile;
110
+ constructor(configDir) {
111
+ this.configDir = configDir ?? process.env.ML_DASH_CONFIG_DIR ?? path.join(homedir(), ".dash");
112
+ this.encFile = path.join(this.configDir, "tokens.encrypted");
113
+ this.keyFile = path.join(this.configDir, "encryption.key");
114
+ this.plainFile = path.join(this.configDir, "tokens.json");
115
+ }
116
+ readEncrypted() {
117
+ if (!existsSync(this.encFile) || !existsSync(this.keyFile))
118
+ return {};
119
+ const key = readFileSync(this.keyFile, "utf8");
120
+ return JSON.parse(decrypt(key, readFileSync(this.encFile, "utf8")).toString("utf8"));
121
+ }
122
+ writeEncrypted(all) {
123
+ mkdirSync(this.configDir, { recursive: true });
124
+ if (!existsSync(this.keyFile)) {
125
+ writeFileSync(this.keyFile, generateKey());
126
+ chmodSync(this.keyFile, 0o600);
127
+ }
128
+ writeFileSync(this.encFile,
129
+ // The key file stores base64 text (Python's format), not raw bytes.
130
+ encrypt(readFileSync(this.keyFile, "utf8"), Buffer.from(JSON.stringify(all), "utf8")));
131
+ chmodSync(this.encFile, 0o600);
132
+ }
133
+ readPlain() {
134
+ if (!existsSync(this.plainFile))
135
+ return {};
136
+ try {
137
+ return JSON.parse(readFileSync(this.plainFile, "utf8"));
138
+ }
139
+ catch {
140
+ return {};
141
+ }
142
+ }
143
+ /**
144
+ * Resolve a token, reporting which backend answered. Keychain first, so a
145
+ * Python-era login is found before any file this CLI wrote.
146
+ */
147
+ load(key = TOKEN_KEY) {
148
+ const kc = keychain();
149
+ if (kc) {
150
+ const v = kc.load(key);
151
+ if (v)
152
+ return { token: v, source: "keychain" };
153
+ }
154
+ if (existsSync(this.encFile)) {
155
+ // A decrypt failure here is load-bearing: the file exists, so the user
156
+ // does have a stored credential, and swallowing the error would present
157
+ // a corrupt keyfile as "never logged in".
158
+ const v = this.readEncrypted()[key];
159
+ if (v)
160
+ return { token: v, source: "encrypted-file" };
161
+ }
162
+ const p = this.readPlain()[key];
163
+ if (p)
164
+ return { token: p, source: "plaintext-file" };
165
+ return { token: null, source: null, unreadableReason: unreadableKeychainReason() ?? undefined };
166
+ }
167
+ /** Write to the keychain when one is reachable, otherwise the encrypted file. */
168
+ store(value, key = TOKEN_KEY) {
169
+ const kc = keychain();
170
+ if (kc) {
171
+ try {
172
+ kc.store(key, value);
173
+ return "keychain";
174
+ }
175
+ catch {
176
+ // Fall through — an unavailable keychain should not fail a login.
177
+ }
178
+ }
179
+ const all = existsSync(this.encFile) ? this.readEncrypted() : {};
180
+ all[key] = value;
181
+ this.writeEncrypted(all);
182
+ return "encrypted-file";
183
+ }
184
+ /** Clear every backend: logging out of one but not the others is not a logout. */
185
+ delete(key = TOKEN_KEY) {
186
+ const kc = keychain();
187
+ if (kc) {
188
+ try {
189
+ kc.delete(key);
190
+ }
191
+ catch {
192
+ /* already absent */
193
+ }
194
+ }
195
+ if (existsSync(this.encFile)) {
196
+ try {
197
+ const all = this.readEncrypted();
198
+ if (key in all) {
199
+ delete all[key];
200
+ this.writeEncrypted(all);
201
+ }
202
+ }
203
+ catch {
204
+ // Undecryptable file: remove it rather than leave a credential behind.
205
+ unlinkSync(this.encFile);
206
+ }
207
+ }
208
+ if (existsSync(this.plainFile)) {
209
+ const all = this.readPlain();
210
+ if (key in all) {
211
+ delete all[key];
212
+ writeFileSync(this.plainFile, JSON.stringify(all, null, 2));
213
+ chmodSync(this.plainFile, 0o600);
214
+ }
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared command setup: which server to talk to, and with which credential.
3
+ *
4
+ * Resolution order matches the Python CLI: an explicit `--dash-url` beats
5
+ * ~/.dash/config.json's `remote_url`, which beats the built-in default; and an
6
+ * `api_key` written into the config beats the stored login token, so a
7
+ * service-account config keeps working without a keychain.
8
+ */
9
+ import { RemoteClient } from "../client.js";
10
+ import { Config, DEFAULT_API_URL } from "../config.js";
11
+ import { TokenStore } from "../auth/token-storage.js";
12
+ export function resolveContext(args) {
13
+ const config = new Config();
14
+ const remoteUrl = (typeof args.dash_url === "string" ? args.dash_url : undefined) ||
15
+ config.remoteUrl ||
16
+ DEFAULT_API_URL;
17
+ if (config.apiKey)
18
+ return { config, remoteUrl, apiKey: config.apiKey };
19
+ const loaded = new TokenStore(config.configDir).load();
20
+ return {
21
+ config,
22
+ remoteUrl,
23
+ apiKey: loaded.token ?? undefined,
24
+ unreadableReason: loaded.unreadableReason,
25
+ };
26
+ }
27
+ export function makeClient(ctx, namespace) {
28
+ return new RemoteClient(ctx.remoteUrl, namespace, ctx.apiKey);
29
+ }
30
+ /** The single message every command uses when there is no usable credential. */
31
+ export function notAuthenticatedMessage(ctx) {
32
+ const base = "Not authenticated. Run 'ml-dash login' to authenticate.";
33
+ return ctx.unreadableReason ? `${base}\n\n${ctx.unreadableReason}` : base;
34
+ }