@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.
@@ -0,0 +1,341 @@
1
+ import { makeClient, notAuthenticatedMessage, resolveContext } from "../cli/context.js";
2
+ import { hasWildcard } from "../util/glob.js";
3
+ import { cyan, dim, green, red, renderTable, yellow } from "../util/ansi.js";
4
+ export const PAGE_SIZE = 50;
5
+ export const spec = {
6
+ name: "list",
7
+ help: "List projects and experiments on remote server",
8
+ description: "Discover projects and experiments available on the remote ML-Dash server.",
9
+ options: [
10
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (defaults to config or https://api.dash.ml)" },
11
+ { flags: ["-n", "--namespace"], dest: "namespace", metavar: "NS", help: "Namespace slug for all queries (defaults to authenticated user's namespace)" },
12
+ { flags: ["-p", "--pref", "--prefix", "--proj", "--project"], dest: "project", metavar: "PROJECT", help: "List experiments in this project. Supports glob patterns — always quote them: -p 'tom/tut*'" },
13
+ { flags: ["--status"], dest: "status", metavar: "STATUS", choices: ["COMPLETED", "RUNNING", "FAILED", "ARCHIVED"], help: "Filter experiments by status" },
14
+ { flags: ["--tags"], dest: "tags", metavar: "TAGS", help: "Filter experiments by tags (comma-separated)" },
15
+ { flags: ["--tracks"], dest: "tracks", boolean: true, help: "List tracks in experiment (requires --project as 'namespace/project/experiment')" },
16
+ { flags: ["--topic-filter"], dest: "topic_filter", metavar: "TOPIC", help: "Filter tracks by topic (e.g., 'robot/*')" },
17
+ { flags: ["--detailed"], dest: "detailed", boolean: true, help: "Show detailed information" },
18
+ { flags: ["-v", "--verbose"], dest: "verbose", boolean: true, help: "Verbose output" },
19
+ ],
20
+ };
21
+ // ── formatting ───────────────────────────────────────────────────────────────
22
+ export function formatTimestamp(iso) {
23
+ const then = new Date(iso);
24
+ if (Number.isNaN(then.getTime()))
25
+ return iso;
26
+ const seconds = Math.floor((Date.now() - then.getTime()) / 1000);
27
+ const days = Math.floor(seconds / 86400);
28
+ if (days > 365) {
29
+ const y = Math.floor(days / 365);
30
+ return `${y} year${y > 1 ? "s" : ""} ago`;
31
+ }
32
+ if (days > 30) {
33
+ const m = Math.floor(days / 30);
34
+ return `${m} month${m > 1 ? "s" : ""} ago`;
35
+ }
36
+ if (days > 0)
37
+ return `${days} day${days > 1 ? "s" : ""} ago`;
38
+ const rest = seconds % 86400;
39
+ if (rest > 3600) {
40
+ const h = Math.floor(rest / 3600);
41
+ return `${h} hour${h > 1 ? "s" : ""} ago`;
42
+ }
43
+ if (rest > 60) {
44
+ const m = Math.floor(rest / 60);
45
+ return `${m} minute${m > 1 ? "s" : ""} ago`;
46
+ }
47
+ return "just now";
48
+ }
49
+ const styleStatus = (status) => {
50
+ if (status === "COMPLETED")
51
+ return green(status);
52
+ if (status === "RUNNING")
53
+ return yellow(status);
54
+ if (status === "FAILED")
55
+ return red(status);
56
+ if (status === "ARCHIVED")
57
+ return dim(status);
58
+ return status;
59
+ };
60
+ const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
61
+ const summariseTags = (tags) => {
62
+ const shown = tags.slice(0, 3).join(", ");
63
+ return (tags.length > 3 ? `${shown} +${tags.length - 3}` : shown) || "-";
64
+ };
65
+ const caption = (offset, total, noun) => {
66
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
67
+ const page = Math.floor(offset / PAGE_SIZE) + 1;
68
+ return `Page ${page}/${totalPages} · ${total} ${noun}${total !== 1 ? "s" : ""} total`;
69
+ };
70
+ // ── paging ───────────────────────────────────────────────────────────────────
71
+ /** One keypress, no Enter. 'next' | 'prev' | 'quit'. */
72
+ function readKey() {
73
+ return new Promise((resolve) => {
74
+ const stdin = process.stdin;
75
+ stdin.setRawMode(true);
76
+ stdin.resume();
77
+ stdin.once("data", (buf) => {
78
+ stdin.setRawMode(false);
79
+ stdin.pause();
80
+ const s = buf.toString("utf8");
81
+ if (s === "\u001b[C" || s === "n" || s === "\r" || s === "\n" || s === " ")
82
+ return resolve("next");
83
+ if (s === "\u001b[D" || s === "p" || s === "b")
84
+ return resolve("prev");
85
+ resolve("quit");
86
+ });
87
+ });
88
+ }
89
+ /** Fetch → print → wait for a key, until the user leaves or the pages run out. */
90
+ async function paginate(fetchPage, emptyMessage) {
91
+ let offset = 0;
92
+ for (;;) {
93
+ const { total, table } = await fetchPage(offset);
94
+ if (!table) {
95
+ if (offset === 0)
96
+ console.log(yellow(emptyMessage));
97
+ return 0;
98
+ }
99
+ console.log(table);
100
+ const hasNext = offset + PAGE_SIZE < total;
101
+ const hasPrev = offset > 0;
102
+ if (!hasNext && !hasPrev)
103
+ return 0;
104
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
105
+ if (hasNext) {
106
+ console.log(dim("More results available — run in a terminal to page, or narrow with -p/--status."));
107
+ }
108
+ return 0;
109
+ }
110
+ const nav = [hasPrev ? "[b/←] prev" : "", hasNext ? "[n/→] next" : "", "[q] quit"].filter(Boolean);
111
+ process.stdout.write(`${dim(nav.join(" "))} `);
112
+ const key = await readKey();
113
+ process.stdout.write("\n");
114
+ if (key === "next" && hasNext)
115
+ offset += PAGE_SIZE;
116
+ else if (key === "prev" && hasPrev)
117
+ offset -= PAGE_SIZE;
118
+ else
119
+ return 0;
120
+ }
121
+ }
122
+ // ── modes ────────────────────────────────────────────────────────────────────
123
+ const EXPERIMENT_COLUMNS = [
124
+ { header: "Experiment" },
125
+ { header: "Status", align: "center" },
126
+ { header: "Metrics", align: "right" },
127
+ { header: "Logs", align: "right" },
128
+ { header: "Tracks", align: "right" },
129
+ { header: "Files", align: "right" },
130
+ ];
131
+ function experimentRow(exp, detailed, timeField) {
132
+ const row = [
133
+ cyan(exp.displayPath || exp.name),
134
+ styleStatus(exp.status ?? "UNKNOWN"),
135
+ String((exp.metrics ?? []).length),
136
+ String(exp.logMetadata?.totalLogs ?? 0),
137
+ String(exp.trackCount ?? 0),
138
+ String((exp.files ?? []).length),
139
+ ];
140
+ if (detailed) {
141
+ row.push(summariseTags(exp.tags ?? []));
142
+ row.push(exp[timeField] ? formatTimestamp(exp[timeField]) : "-");
143
+ }
144
+ return row;
145
+ }
146
+ const matchesTags = (exp, tags) => !tags || (exp.tags ?? []).some((t) => tags.includes(t));
147
+ export async function listTracks(client, experimentPath, topicFilter, verbose) {
148
+ const parts = experimentPath.replace(/^\/+|\/+$/g, "").split("/");
149
+ if (parts.length < 3) {
150
+ console.error(`${red("Error:")} Experiment path must be 'namespace/project/experiment'`);
151
+ return 1;
152
+ }
153
+ const [, project, experiment] = parts;
154
+ try {
155
+ const exp = await client.getExperimentGraphql(project, experiment);
156
+ if (!exp) {
157
+ console.error(`${red("Error:")} Experiment '${experiment}' not found in project '${project}'`);
158
+ return 1;
159
+ }
160
+ const tracks = await client.listTracks(String(exp.id), topicFilter);
161
+ if (tracks.length === 0) {
162
+ console.log(yellow("No tracks found"));
163
+ return 0;
164
+ }
165
+ const rows = tracks.map((track) => {
166
+ const cols = track.columns ?? [];
167
+ const columns = cols.slice(0, 5).join(", ") + (cols.length > 5 ? `, ... (+${cols.length - 5})` : "");
168
+ const first = track.firstTimestamp;
169
+ const last = track.lastTimestamp;
170
+ const range = first != null && last != null ? `${Number(first).toFixed(3)} - ${Number(last).toFixed(3)}` : "N/A";
171
+ return [cyan(track.topic), String(track.totalEntries ?? 0), dim(columns), dim(range)];
172
+ });
173
+ console.log(renderTable([{ header: "Topic" }, { header: "Entries", align: "right" }, { header: "Columns" }, { header: "Time Range" }], rows, { title: `\nTracks in ${experimentPath}\n` }));
174
+ return 0;
175
+ }
176
+ catch (e) {
177
+ console.error(`${red("Error listing tracks:")} ${e.message}`);
178
+ if (verbose)
179
+ console.error(e.stack);
180
+ return 1;
181
+ }
182
+ }
183
+ async function listProjects(client, namespaceSlug, verbose) {
184
+ try {
185
+ return await paginate(async (offset) => {
186
+ const { projects, totalCount } = await client.listProjectsGraphql(namespaceSlug, PAGE_SIZE, offset);
187
+ if (projects.length === 0)
188
+ return { total: totalCount, table: null };
189
+ const rows = projects.map((p) => [
190
+ cyan(p.slug),
191
+ String(p.experimentCount ?? 0),
192
+ dim(truncate(p.description ?? "", 50)),
193
+ ]);
194
+ return {
195
+ total: totalCount,
196
+ table: renderTable([{ header: "Project" }, { header: "Experiments", align: "right" }, { header: "Description" }], rows, { title: "\nProjects", caption: caption(offset, totalCount, "project") }),
197
+ };
198
+ }, "No projects found");
199
+ }
200
+ catch (e) {
201
+ console.error(`${red("Error listing projects:")} ${e.message}`);
202
+ if (verbose)
203
+ console.error(e.stack);
204
+ return 1;
205
+ }
206
+ }
207
+ async function listExperiments(client, project, opts) {
208
+ try {
209
+ return await paginate(async (offset) => {
210
+ const { experiments, totalCount } = await client.listExperimentsGraphql(project, {
211
+ status: opts.status,
212
+ namespaceSlug: opts.namespaceSlug,
213
+ limit: PAGE_SIZE,
214
+ offset,
215
+ });
216
+ const filtered = experiments.filter((e) => matchesTags(e, opts.tags));
217
+ if (filtered.length === 0)
218
+ return { total: totalCount, table: null };
219
+ const columns = opts.detailed
220
+ ? [...EXPERIMENT_COLUMNS, { header: "Tags" }, { header: "Created" }]
221
+ : EXPERIMENT_COLUMNS;
222
+ return {
223
+ total: totalCount,
224
+ table: renderTable(columns, filtered.map((e) => experimentRow(e, opts.detailed, "createdAt")), {
225
+ title: `\nExperiments in project: ${project}`,
226
+ caption: caption(offset, totalCount, "experiment"),
227
+ }),
228
+ };
229
+ }, `No experiments found in project: ${project}`);
230
+ }
231
+ catch (e) {
232
+ console.error(`${red("Error listing experiments:")} ${e.message}`);
233
+ if (opts.verbose)
234
+ console.error(e.stack);
235
+ return 1;
236
+ }
237
+ }
238
+ /** Expand a one- or two-segment pattern to the three-segment path the server matches. */
239
+ export function searchPattern(project, namespace) {
240
+ if (!project.includes("/"))
241
+ return `${namespace}/${project}/*`;
242
+ if (project.split("/").length === 2)
243
+ return `${project}/*`;
244
+ return project;
245
+ }
246
+ async function searchExperiments(client, pattern, opts) {
247
+ try {
248
+ return await paginate(async (offset) => {
249
+ const { experiments, totalCount } = await client.searchExperimentsGraphql(pattern, PAGE_SIZE, offset);
250
+ // The search endpoint does not filter on status or tags, so both are
251
+ // applied here — which means they narrow the page, not the total.
252
+ const filtered = experiments
253
+ .filter((e) => !opts.status || e.status === opts.status)
254
+ .filter((e) => matchesTags(e, opts.tags));
255
+ if (filtered.length === 0)
256
+ return { total: totalCount, table: null };
257
+ const columns = [
258
+ { header: "Project" },
259
+ ...EXPERIMENT_COLUMNS,
260
+ ...(opts.detailed ? [{ header: "Tags" }, { header: "Started" }] : []),
261
+ ];
262
+ const rows = filtered.map((e) => [
263
+ dim(e.project?.slug ?? ""),
264
+ ...experimentRow(e, opts.detailed, "startedAt"),
265
+ ]);
266
+ return {
267
+ total: totalCount,
268
+ table: renderTable(columns, rows, {
269
+ title: `\nSearch results for: ${pattern}`,
270
+ caption: caption(offset, totalCount, "experiment"),
271
+ }),
272
+ };
273
+ }, `No experiments match pattern: ${pattern}`);
274
+ }
275
+ catch (e) {
276
+ console.error(`${red("Error searching experiments:")} ${e.message}`);
277
+ if (opts.verbose)
278
+ console.error(e.stack);
279
+ return 1;
280
+ }
281
+ }
282
+ // ── entry point ──────────────────────────────────────────────────────────────
283
+ export async function run(args) {
284
+ const ctx = resolveContext(args);
285
+ if (!ctx.apiKey) {
286
+ console.error(`${red("Error:")} ${notAuthenticatedMessage(ctx)}`);
287
+ return 1;
288
+ }
289
+ const verbose = args.verbose === true;
290
+ const detailed = args.detailed === true;
291
+ const project = typeof args.project === "string" ? args.project : undefined;
292
+ const tags = typeof args.tags === "string" ? args.tags.split(",").map((t) => t.trim()) : null;
293
+ const status = typeof args.status === "string" ? args.status : undefined;
294
+ if (args.tracks) {
295
+ if (!project) {
296
+ console.error(`${red("Error:")} --project is required for listing tracks`);
297
+ console.error("Example: ml-dash list --tracks --project namespace/project/experiment");
298
+ return 1;
299
+ }
300
+ const parts = project.replace(/^\/+|\/+$/g, "").split("/");
301
+ if (parts.length < 3) {
302
+ console.error(`${red("Error:")} For tracks, --project must be 'namespace/project/experiment'`);
303
+ return 1;
304
+ }
305
+ return listTracks(makeClient(ctx, parts[0]), project, typeof args.topic_filter === "string" ? args.topic_filter : undefined, verbose);
306
+ }
307
+ const wildcard = project ? hasWildcard(project) : false;
308
+ let namespace;
309
+ let projectSlug;
310
+ if (project) {
311
+ const parts = project.replace(/^\/+|\/+$/g, "").split("/");
312
+ if (project.includes("/")) {
313
+ namespace = parts[0];
314
+ projectSlug = parts[1];
315
+ }
316
+ else if (!wildcard) {
317
+ projectSlug = project;
318
+ }
319
+ }
320
+ const client = makeClient(ctx, namespace);
321
+ let effectiveNamespace;
322
+ try {
323
+ effectiveNamespace =
324
+ (typeof args.namespace === "string" ? args.namespace : undefined) ||
325
+ namespace ||
326
+ (await client.namespace());
327
+ }
328
+ catch (e) {
329
+ console.error(`${red("Error connecting to remote:")} ${e.message}`);
330
+ if (verbose)
331
+ console.error(e.stack);
332
+ return 1;
333
+ }
334
+ if (!project)
335
+ return listProjects(client, effectiveNamespace, verbose);
336
+ if (wildcard) {
337
+ return searchExperiments(client, searchPattern(project, effectiveNamespace), { status, tags, detailed, verbose });
338
+ }
339
+ console.log(dim(`Using namespace: ${effectiveNamespace}`));
340
+ return listExperiments(client, projectSlug, { namespaceSlug: effectiveNamespace, status, tags, detailed, verbose });
341
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `ml-dash login` — OAuth 2.0 device authorization against vuer-auth, then a
3
+ * token exchange with the ml-dash server.
4
+ *
5
+ * The user never types a password here: the CLI shows a short code (and a QR
6
+ * for a phone), the browser does the authorization, and polling picks up a
7
+ * short-lived vuer-auth JWT which is immediately traded for the long-lived
8
+ * ml-dash token that actually gets stored.
9
+ *
10
+ * The stored token is printed nowhere — only where it was stored — so a shared
11
+ * terminal or a pasted transcript never leaks a credential.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { AuthorizationDeniedError, AuthorizationTimeoutError, DeviceFlowClient, TokenExchangeError, VUER_AUTH_URL, DeviceCodeExpiredError, } from "../auth/device-flow.js";
15
+ import { getOrCreateDeviceSecret } from "../auth/device-secret.js";
16
+ import { TokenStore } from "../auth/token-storage.js";
17
+ import { Config, DEFAULT_API_URL } from "../config.js";
18
+ import { bold, blue, cyan, dim, green, red, renderPanel } from "../util/ansi.js";
19
+ export const spec = {
20
+ name: "login",
21
+ help: "Authenticate with ml-dash using device authorization flow",
22
+ description: `Login to ml-dash server using the OAuth2 device authorization flow.
23
+
24
+ After logging in, you can:
25
+ • Upload/download experiments via CLI
26
+ • View projects, experiments, and statistics at https://dash.ml
27
+ • Create interactive plots and dashboards`,
28
+ options: [
29
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (e.g., https://api.dash.ml)" },
30
+ { flags: ["--auth-url"], dest: "auth_url", metavar: "URL", help: "OAuth authorization server URL (e.g., https://auth.vuer.ai)" },
31
+ { flags: ["--no-browser"], dest: "no_browser", boolean: true, help: "Don't automatically open browser for authorization" },
32
+ ],
33
+ };
34
+ /** Block-character QR, the shape the Python CLI drew. null when unavailable. */
35
+ async function qrCodeAscii(url) {
36
+ try {
37
+ const { create } = await import("qrcode");
38
+ const { modules } = create(url, {});
39
+ const size = modules.size;
40
+ const lines = [];
41
+ // A one-module quiet zone, matching `qrcode.QRCode(border=1)`.
42
+ lines.push(" ".repeat(size + 2));
43
+ for (let y = 0; y < size; y++) {
44
+ let line = " ";
45
+ for (let x = 0; x < size; x++)
46
+ line += modules.data[y * size + x] ? "██" : " ";
47
+ lines.push(line + " ");
48
+ }
49
+ lines.push(" ".repeat(size + 2));
50
+ return lines.join("\n");
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function openBrowser(url) {
57
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
58
+ try {
59
+ const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
60
+ child.unref();
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ export async function run(args) {
68
+ const config = new Config();
69
+ const remoteUrl = (typeof args.dash_url === "string" ? args.dash_url : undefined) || config.remoteUrl || DEFAULT_API_URL;
70
+ const authUrl = (typeof args.auth_url === "string" ? args.auth_url : undefined) || config.authUrl || VUER_AUTH_URL;
71
+ try {
72
+ console.log(`${bold("Initializing device authorization...")}\n`);
73
+ const deviceSecret = getOrCreateDeviceSecret(config);
74
+ const client = new DeviceFlowClient(deviceSecret, remoteUrl, authUrl);
75
+ const flow = await client.startDeviceFlow();
76
+ let body = `${cyan(bold("1. Visit this URL:"))}\n\n ${flow.verificationUri}\n\n` +
77
+ `${cyan(bold("2. Enter this code:"))}\n\n ${green(bold(flow.userCode))}\n`;
78
+ const qr = await qrCodeAscii(flow.verificationUriComplete);
79
+ if (qr)
80
+ body += `\n${cyan(bold("Or scan QR code:"))}\n\n${qr}\n`;
81
+ body += `\n${dim(`Code expires in ${Math.floor(flow.expiresIn / 60)} minutes`)}`;
82
+ console.log(renderPanel(body, { title: blue(bold("DEVICE AUTHORIZATION REQUIRED")) }));
83
+ console.log();
84
+ if (!args.no_browser && openBrowser(flow.verificationUriComplete)) {
85
+ console.log(`${dim("✓ Opened browser automatically")}\n`);
86
+ }
87
+ console.log(bold("Waiting for authorization..."));
88
+ const vuerAuthToken = await client.pollForToken(120, (elapsed) => {
89
+ if (process.stdout.isTTY)
90
+ process.stdout.write(`\r${dim(`Waiting (${elapsed}s)`)} `);
91
+ });
92
+ if (process.stdout.isTTY)
93
+ process.stdout.write("\r\u001b[K");
94
+ console.log(`${green("✓ Authorization successful!")}\n`);
95
+ console.log(bold("Exchanging token with ml-dash server..."));
96
+ const mlDashToken = await client.exchangeToken(vuerAuthToken);
97
+ const where = new TokenStore(config.configDir).store(mlDashToken);
98
+ console.log(`${green("✓ Token exchanged successfully!")}\n`);
99
+ console.log(`${green(bold("✓ Logged in successfully!"))}\n\n` +
100
+ `Your authentication token has been stored (${where}).\n\n` +
101
+ `${cyan(bold("View your data online:"))}\n https://dash.ml\n\n` +
102
+ "Access your projects, experiments, statistics, and interactive plots.\n\n" +
103
+ `${bold("CLI Commands:")}\n ml-dash list\n ml-dash profile`);
104
+ return 0;
105
+ }
106
+ catch (e) {
107
+ if (e instanceof DeviceCodeExpiredError) {
108
+ console.error(`\n${red("✗ Device code expired")}\n\nThe authorization code expired after 10 minutes.\n` +
109
+ "Please run 'ml-dash login' again.");
110
+ return 1;
111
+ }
112
+ if (e instanceof AuthorizationDeniedError) {
113
+ console.error(`\n${red("✗ Authorization denied")}\n\nYou declined the authorization request in your browser.\n\n` +
114
+ "To try again:\n ml-dash login");
115
+ return 1;
116
+ }
117
+ if (e instanceof AuthorizationTimeoutError) {
118
+ console.error(`\n${red("✗ Authorization timed out")}\n\nNo response after 10 minutes.\n\nPlease run 'ml-dash login' again.`);
119
+ return 1;
120
+ }
121
+ if (e instanceof TokenExchangeError) {
122
+ console.error(`\n${red("✗ Token exchange failed:")} ${e.message}\n`);
123
+ return 1;
124
+ }
125
+ console.error(`\n${red("✗ Unexpected error:")} ${e.message}`);
126
+ return 1;
127
+ }
128
+ }
@@ -0,0 +1,22 @@
1
+ /** Clear the stored token from every backend it might live in. */
2
+ import { TokenStore } from "../auth/token-storage.js";
3
+ import { Config } from "../config.js";
4
+ import { green, red } from "../util/ansi.js";
5
+ export const spec = {
6
+ name: "logout",
7
+ help: "Clear stored authentication token",
8
+ description: "Logout from ml-dash by clearing the stored authentication token.",
9
+ options: [],
10
+ };
11
+ export function run() {
12
+ try {
13
+ new TokenStore(new Config().configDir).delete();
14
+ console.log(green("✓ Logged out successfully!") +
15
+ "\n\nYour authentication token has been cleared.\n\nTo log in again:\n ml-dash login");
16
+ return 0;
17
+ }
18
+ catch (e) {
19
+ console.error(`${red("✗ Storage error:")} ${e.message}`);
20
+ return 1;
21
+ }
22
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `ml-dash profile` — who am I, according to the token and to the server.
3
+ *
4
+ * The token is a JWT, so the identity it carries can be shown without a
5
+ * network round trip (`--cached`). By default the server is asked instead,
6
+ * because a username can change after the token was issued; if that call
7
+ * fails the token's own claims are shown with a warning rather than an error,
8
+ * matching the Python CLI.
9
+ *
10
+ * One deliberate divergence: the Python CLI embedded rich markup
11
+ * (`[red]Token expired[/red]`) inside `--json` output. Here `--json` carries
12
+ * plain text, so the output can be consumed by a script; the styled form is
13
+ * used only in the human table.
14
+ */
15
+ import { userInfo } from "node:os";
16
+ import { decodeJwtPayload } from "../auth/jwt.js";
17
+ import { TokenStore } from "../auth/token-storage.js";
18
+ import { RemoteClient } from "../client.js";
19
+ import { Config, DEFAULT_API_URL } from "../config.js";
20
+ import { bold, cyan, dim, green, renderPanel, yellow } from "../util/ansi.js";
21
+ export const spec = {
22
+ name: "profile",
23
+ help: "Show current user profile",
24
+ description: "Display the current authenticated user profile and configuration.",
25
+ options: [
26
+ { flags: ["--dash-url", "--api-url"], dest: "dash_url", metavar: "URL", help: "ML-Dash server URL (default: from config)" },
27
+ { flags: ["--json"], dest: "json", boolean: true, help: "Output as JSON" },
28
+ { flags: ["--cached"], dest: "cached", boolean: true, help: "Use cached token data (default: fetch fresh from server)" },
29
+ ],
30
+ };
31
+ /** (expired, human status) for a JWT `exp` claim. */
32
+ export function checkTokenExpiration(payload) {
33
+ const exp = payload.exp;
34
+ if (!exp)
35
+ return [false, null];
36
+ const timeLeft = Number(exp) - Math.floor(Date.now() / 1000);
37
+ if (timeLeft < 0)
38
+ return [true, "Token expired"];
39
+ if (timeLeft < 86400)
40
+ return [false, `Token expires in ${Math.floor(timeLeft / 3600)} hours`];
41
+ return [false, `Expires in ${Math.floor(timeLeft / 86400)} days`];
42
+ }
43
+ async function fetchFreshProfile(remoteUrl, token) {
44
+ try {
45
+ const user = await new RemoteClient(remoteUrl, undefined, token).getCurrentUser();
46
+ if (!user)
47
+ return null;
48
+ return {
49
+ sub: user.id,
50
+ username: user.username,
51
+ name: user.name,
52
+ email: user.email,
53
+ given_name: user.given_name,
54
+ family_name: user.family_name,
55
+ };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export async function run(args) {
62
+ const config = new Config();
63
+ const store = new TokenStore(config.configDir);
64
+ const loaded = store.load();
65
+ const token = loaded.token;
66
+ const remoteUrl = (typeof args.dash_url === "string" ? args.dash_url : undefined) || config.remoteUrl;
67
+ const info = {
68
+ authenticated: false,
69
+ remote_url: remoteUrl,
70
+ local_user: safeUsername(),
71
+ };
72
+ if (!token && loaded.unreadableReason)
73
+ info.warning = loaded.unreadableReason;
74
+ if (token) {
75
+ info.authenticated = true;
76
+ const payload = decodeJwtPayload(token);
77
+ const [expired, expiryMessage] = checkTokenExpiration(payload);
78
+ if (expired) {
79
+ info.authenticated = false;
80
+ info.error = "Token expired. Please run 'ml-dash login' to re-authenticate.";
81
+ }
82
+ else {
83
+ if (args.cached) {
84
+ info.user = payload;
85
+ info.source = "token";
86
+ }
87
+ else {
88
+ const fresh = await fetchFreshProfile(remoteUrl, token);
89
+ if (fresh) {
90
+ info.user = fresh;
91
+ info.source = "server";
92
+ }
93
+ else {
94
+ info.user = payload;
95
+ info.source = "token";
96
+ info.warning = "Could not fetch fresh profile from server, using cached token data";
97
+ }
98
+ }
99
+ if (expiryMessage)
100
+ info.token_status = expiryMessage;
101
+ }
102
+ }
103
+ if (args.json) {
104
+ console.log(JSON.stringify(info, null, 2));
105
+ return 0;
106
+ }
107
+ if (!info.authenticated) {
108
+ console.log(renderPanel(`${cyan(bold("OS Username:"))} ${info.local_user}\n\n` +
109
+ `${yellow(info.error ?? "Not authenticated")}\n\n` +
110
+ `Run ${cyan("ml-dash login")} to authenticate.`, { title: bold("ML-Dash Info") }));
111
+ return 0;
112
+ }
113
+ const user = info.user ?? {};
114
+ const rows = [];
115
+ rows.push(["Username", user.username ?? "Unavailable"]);
116
+ if (user.sub)
117
+ rows.push(["User ID", String(user.sub)]);
118
+ rows.push(["Name", user.name ?? "Unknown"]);
119
+ if (user.email)
120
+ rows.push(["Email", user.email]);
121
+ rows.push(["Remote", info.remote_url || DEFAULT_API_URL]);
122
+ if (info.token_status)
123
+ rows.push(["Token Status", info.token_status]);
124
+ rows.push(["Data Source", info.source === "server" ? green("Server (Fresh)") : yellow("Token (Cached)")]);
125
+ const keyWidth = Math.max(...rows.map(([k]) => k.length));
126
+ let body = rows.map(([k, v]) => `${cyan(bold(k.padEnd(keyWidth)))} ${v}`).join("\n");
127
+ if (info.warning)
128
+ body += `\n\n${yellow(`⚠ ${info.warning}`)}`;
129
+ if (info.source === "server") {
130
+ body += `\n${dim("Tip: Use --cached to use cached token data (faster but may be outdated)")}`;
131
+ }
132
+ console.log(renderPanel(body, { title: green(bold("✓ Authenticated")) }));
133
+ return 0;
134
+ }
135
+ /** `os.userInfo()` throws on hosts where the uid has no passwd entry. */
136
+ function safeUsername() {
137
+ try {
138
+ return userInfo().username;
139
+ }
140
+ catch {
141
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
142
+ }
143
+ }