@devosurf/tesser 0.1.0-alpha.3 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,21 +5,25 @@
5
5
  import { execFile } from "node:child_process";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { promisify } from "node:util";
8
- import { Command } from "commander";
8
+ import { Command, Option } from "commander";
9
9
  import { extractAutomationManifest } from "@devosurf/tesser-sdk/internal";
10
10
  import { ApiClient } from "./client.js";
11
11
  import { readConfig, readLinkManifest, resolveTarget, writeConfig } from "./config.js";
12
12
  import { EXIT } from "./exit-codes.js";
13
- import { DEFAULT_INSTANCE_URL, resolveLoginInstance, type LoginCommandOptions } from "./login.js";
14
- import { CliError, Output, toExit } from "./output.js";
13
+ import { DEFAULT_INSTANCE_URL, resolveLoginInputs, type LoginCommandOptions } from "./login.js";
14
+ import { completionScript } from "./completion.js";
15
+ import { parseIntOption, projectFields, resolveJsonInput } from "./inputs.js";
16
+ import { CliError, Output, toExit, type OutputFormat } from "./output.js";
15
17
  import { discoverLocalAutomations, loadAutomationDef } from "./project.js";
16
18
  import { authClaudeCode, authPi } from "./commands/auth.js";
17
19
  import { deploy } from "./commands/deploy.js";
20
+ import { doctor } from "./commands/doctor.js";
18
21
  import { dev } from "./commands/dev.js";
19
22
  import { init } from "./commands/init.js";
20
23
  import { upgradeProject } from "./commands/project-docs.js";
21
24
  import { replay } from "./commands/replay.js";
22
25
  import { runTests } from "./commands/test.js";
26
+ import { cliSchema } from "./schema.js";
23
27
 
24
28
  const exec = promisify(execFile);
25
29
  const program = new Command();
@@ -27,6 +31,7 @@ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.u
27
31
 
28
32
  interface GlobalOpts {
29
33
  json?: boolean;
34
+ output?: "auto" | OutputFormat;
30
35
  url?: string;
31
36
  token?: string;
32
37
  profile?: string;
@@ -34,7 +39,20 @@ interface GlobalOpts {
34
39
 
35
40
  function setup(): { out: Output; opts: GlobalOpts } {
36
41
  const opts = program.opts<GlobalOpts>();
37
- return { out: new Output(opts.json ?? false), opts };
42
+ return { out: new Output(resolveOutputFormat(opts)), opts };
43
+ }
44
+
45
+ function resolveOutputFormat(opts: GlobalOpts): OutputFormat {
46
+ if (opts.json) return "json";
47
+ const requested = opts.output ?? "auto";
48
+ if (requested === "auto") return process.stdout.isTTY ? "text" : "json";
49
+ return requested;
50
+ }
51
+
52
+ function isJsonRequestedFromArgv(): boolean {
53
+ const idx = process.argv.findIndex((arg) => arg === "--output" || arg === "-o");
54
+ const explicitOutput = idx >= 0 ? process.argv[idx + 1] : undefined;
55
+ return process.argv.includes("--json") || explicitOutput === "json" || explicitOutput === "ndjson";
38
56
  }
39
57
 
40
58
  function target(opts: GlobalOpts): ReturnType<typeof resolveTarget> {
@@ -62,18 +80,54 @@ program
62
80
  .name("tesser")
63
81
  .version(VERSION)
64
82
  .description("Code-first, agent-native automation. stdout = data, stderr = logs; --json everywhere.")
65
- .option("--json", "machine output: one JSON document on stdout")
83
+ .option("--json", "machine output: one JSON document on stdout (alias for --output json)")
84
+ .addOption(new Option("-o, --output <format>", "output format").choices(["auto", "text", "json", "ndjson"]).default("auto"))
66
85
  .option("--url <url>", "instance URL (overrides tesser.json / profile)")
67
86
  .option("--token <token>", "API token (overrides TESSER_TOKEN / profile)")
68
- .option("--profile <name>", "config profile");
87
+ .option("--profile <name>", "config profile")
88
+ .exitOverride()
89
+ .configureOutput({ outputError: () => {} })
90
+ .action(() => {
91
+ const out = new Output(resolveOutputFormat(program.opts<GlobalOpts>()));
92
+ if (out.json) out.fail(EXIT.USAGE, "missing command");
93
+ program.outputHelp({ error: true });
94
+ process.exit(EXIT.USAGE);
95
+ });
96
+
97
+ program
98
+ .command("completion")
99
+ .argument("<shell>", "bash or zsh")
100
+ .description("print shell completion script")
101
+ .action((shell: string) => {
102
+ const { out } = setup();
103
+ try {
104
+ out.data(completionScript(shell), (script: string) => script.replace(/\n$/, ""));
105
+ } catch (err) {
106
+ toExit(err, out);
107
+ }
108
+ });
109
+
110
+ program
111
+ .command("schema")
112
+ .argument("[command...]", "optional command subtree, e.g. runs list")
113
+ .description("emit the machine-readable CLI contract (no auth, config, or network needed)")
114
+ .action((parts: string[]) => {
115
+ const { out } = setup();
116
+ try {
117
+ out.data(cliSchema(VERSION, parts.length > 0 ? parts.join(" ") : undefined));
118
+ } catch (err) {
119
+ toExit(err, out);
120
+ }
121
+ });
69
122
 
70
123
  program
71
124
  .command("init")
72
125
  .argument("<name>", "project name (kebab-case)")
73
126
  .option("--dir <dir>", "parent directory")
74
127
  .option("--instance <url>", "instance URL to write into tesser.json")
128
+ .option("--force", "write into an existing non-empty directory")
75
129
  .description("scaffold a new Project (one repo of automations)")
76
- .action((name: string, cmdOpts: { dir?: string; instance?: string }) => {
130
+ .action((name: string, cmdOpts: { dir?: string; instance?: string; force?: boolean }) => {
77
131
  const { out } = setup();
78
132
  try {
79
133
  init(out, name, cmdOpts, VERSION);
@@ -96,7 +150,8 @@ program
96
150
 
97
151
  program
98
152
  .command("login")
99
- .option("--token <token>", "API token from the instance (printed at first boot)")
153
+ .option("--token <token>", "API token from the instance (discouraged: argv can leak; prefer --token-stdin)")
154
+ .option("--token-stdin", "read the API token from stdin")
100
155
  .option("--url <url>", `instance URL (defaults to ${DEFAULT_INSTANCE_URL})`)
101
156
  .option("--instance <url>", "deprecated alias for --url")
102
157
  .option("--save-profile <name>", "profile name", "default")
@@ -104,9 +159,7 @@ program
104
159
  .action(async (cmdOpts: LoginCommandOptions) => {
105
160
  const { out, opts } = setup();
106
161
  try {
107
- const token = cmdOpts.token ?? opts.token;
108
- if (!token) throw new CliError(EXIT.USAGE, "missing required option '--token <token>'");
109
- const instance = resolveLoginInstance(cmdOpts, opts);
162
+ const { instance, token } = await resolveLoginInputs(cmdOpts, opts);
110
163
  const client = new ApiClient(instance, token);
111
164
  await client.get("/health"); // verify before storing
112
165
  const config = readConfig();
@@ -223,7 +276,8 @@ program
223
276
  const { out, opts } = setup();
224
277
  try {
225
278
  const { name, root } = requireProject(opts);
226
- await dev(out, root, name, { port: Number(cmdOpts.port), ...(cmdOpts.watch === false ? { watch: false } : {}) });
279
+ const port = parseIntOption(cmdOpts.port, "--port", { min: 1, max: 65535 });
280
+ await dev(out, root, name, { port, ...(cmdOpts.watch === false ? { watch: false } : {}) });
227
281
  } catch (err) {
228
282
  toExit(err, out);
229
283
  }
@@ -266,6 +320,43 @@ auth
266
320
  }
267
321
  });
268
322
 
323
+ const harnessConnect = program.command("harness").description("brokered Harness helpers").command("connect").description("connect a Harness credential through a Tesser connect link");
324
+ harnessConnect
325
+ .command("claude-code")
326
+ .requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token")
327
+ .option("--mode <mode>", "subscription or apiKey", "subscription")
328
+ .option("--token-stdin", "read token from stdin instead of running claude setup-token")
329
+ .option("--from-env <name>", "read token from an environment variable")
330
+ .option("--scope <scope>", "workspace or per_user", "workspace")
331
+ .option("--end-user-id <id>", "end-user id for per_user connections")
332
+ .option("--bin <path>", "claude binary", "claude")
333
+ .description("connect Claude Code as a brokered Harness; alias of `tesser auth claude-code`")
334
+ .action(async (cmdOpts: { connect: string; mode?: string; tokenStdin?: boolean; fromEnv?: string; scope?: string; endUserId?: string; bin?: string }) => {
335
+ const { out, opts } = setup();
336
+ try {
337
+ await authClaudeCode(out, target(opts).url, cmdOpts);
338
+ } catch (err) {
339
+ toExit(err, out);
340
+ }
341
+ });
342
+ harnessConnect
343
+ .command("pi")
344
+ .requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token")
345
+ .option("--mode <mode>", "anthropicOAuth or anthropicApiKey", "anthropicOAuth")
346
+ .option("--token-stdin", "read token from stdin")
347
+ .option("--from-env <name>", "read token from an environment variable")
348
+ .option("--scope <scope>", "workspace or per_user", "workspace")
349
+ .option("--end-user-id <id>", "end-user id for per_user connections")
350
+ .description("connect Pi as a brokered Harness; alias of `tesser auth pi`")
351
+ .action(async (cmdOpts: { connect: string; mode?: string; tokenStdin?: boolean; fromEnv?: string; scope?: string; endUserId?: string }) => {
352
+ const { out, opts } = setup();
353
+ try {
354
+ await authPi(out, target(opts).url, cmdOpts);
355
+ } catch (err) {
356
+ toExit(err, out);
357
+ }
358
+ });
359
+
269
360
  program
270
361
  .command("connect")
271
362
  .option("--wait", "poll until the human completes the link")
@@ -288,19 +379,20 @@ program
288
379
  out.data({ url: null, requirements: [] }, () => "nothing missing — all requirements are satisfied");
289
380
  return;
290
381
  }
291
- out.data(minted, () => `open in a browser to connect:\n ${minted.url}`);
292
- if (cmdOpts.wait) {
293
- for (;;) {
294
- await new Promise((r) => setTimeout(r, 2000));
295
- const status = await client.get<{ status: string }>(`/connect-links/${minted.token}/status`);
296
- if (status.status === "completed") {
297
- out.log("connect link completed ✓");
298
- return;
299
- }
300
- if (status.status === "expired") throw new CliError(EXIT.HALTED_CREDENTIALS, "connect link expired");
382
+ if (!cmdOpts.wait) {
383
+ out.data(minted, () => `open in a browser to connect:\n ${minted.url}`);
384
+ process.exit(EXIT.HALTED_CREDENTIALS);
385
+ }
386
+ out.log(`open in a browser to connect:\n ${minted.url}`);
387
+ for (;;) {
388
+ await new Promise((r) => setTimeout(r, 2000));
389
+ const status = await client.get<{ status: string }>(`/connect-links/${minted.token}/status`);
390
+ if (status.status === "completed") {
391
+ out.data({ ...minted, status: "completed" }, () => "connect link completed ✓");
392
+ return;
301
393
  }
394
+ if (status.status === "expired") throw new CliError(EXIT.HALTED_CREDENTIALS, "connect link expired");
302
395
  }
303
- process.exit(EXIT.HALTED_CREDENTIALS);
304
396
  } catch (err) {
305
397
  toExit(err, out);
306
398
  }
@@ -336,7 +428,7 @@ secrets
336
428
  const value = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
337
429
  if (value.length === 0) throw new CliError(EXIT.USAGE, "empty value on stdin");
338
430
  await api(opts).put(`/secrets/${encodeURIComponent(name)}`, { value });
339
- out.data({ set: name }, () => `secret "${name}" set ✓`);
431
+ out.data({ set: name, changed: true }, () => `secret "${name}" set ✓`);
340
432
  } catch (err) {
341
433
  toExit(err, out);
342
434
  }
@@ -347,7 +439,8 @@ secrets
347
439
  .action(async (name: string) => {
348
440
  const { out, opts } = setup();
349
441
  try {
350
- out.data(await api(opts).delete(`/secrets/${encodeURIComponent(name)}`));
442
+ const result = await api(opts).delete<{ deleted: boolean }>(`/secrets/${encodeURIComponent(name)}`);
443
+ out.data({ ...result, changed: result.deleted });
351
444
  } catch (err) {
352
445
  toExit(err, out);
353
446
  }
@@ -359,17 +452,23 @@ runs
359
452
  .option("--automation <id>")
360
453
  .option("--status <status>")
361
454
  .option("--limit <n>", "max rows", "25")
362
- .action(async (cmdOpts: { automation?: string; status?: string; limit: string }) => {
455
+ .option("--offset <n>", "rows to skip", "0")
456
+ .option("--fields <csv>", "comma-separated fields to include in each run")
457
+ .action(async (cmdOpts: { automation?: string; status?: string; limit: string; offset: string; fields?: string }) => {
363
458
  const { out, opts } = setup();
364
459
  try {
365
460
  const { name } = requireProject(opts);
366
- const params = new URLSearchParams({ project: name, limit: cmdOpts.limit });
461
+ const limit = parseIntOption(cmdOpts.limit, "--limit", { min: 1, max: 200 });
462
+ const offset = parseIntOption(cmdOpts.offset, "--offset", { min: 0 });
463
+ const params = new URLSearchParams({ project: name, limit: String(limit), offset: String(offset) });
367
464
  if (cmdOpts.automation) params.set("automation", cmdOpts.automation);
368
465
  if (cmdOpts.status) params.set("status", cmdOpts.status);
369
- const data = await api(opts).get<{ runs: Array<Record<string, unknown>> }>(`/runs?${params}`);
370
- out.data(data, (d: { runs: Array<{ id: string; automation_id: string; status: string; trigger_kind: string; created_at: string }> }) =>
371
- d.runs.map((r) => `${r.id} ${r.automation_id} ${r.status} (${r.trigger_kind}) ${r.created_at}`).join("\n") || "(no runs)",
372
- );
466
+ const data = await api(opts).get<{ runs: Array<Record<string, unknown>>; total?: number; limit?: number; offset?: number; truncated?: boolean }>(`/runs?${params}`);
467
+ const projected = { ...data, runs: projectFields(data.runs, cmdOpts.fields) };
468
+ out.data(projected, (d: { runs: Array<{ id?: string; automation_id?: string; status?: string; trigger_kind?: string; created_at?: string }>; total?: number; truncated?: boolean }) => {
469
+ const rows = d.runs.map((r) => `${r.id ?? "?"} ${r.automation_id ?? "?"} ${r.status ?? "?"} (${r.trigger_kind ?? "?"}) ${r.created_at ?? "?"}`).join("\n") || "(no runs)";
470
+ return d.truncated ? `${rows}\n(showing ${d.runs.length} of ${d.total ?? "?"}; use --offset for more)` : rows;
471
+ });
373
472
  } catch (err) {
374
473
  toExit(err, out);
375
474
  }
@@ -377,10 +476,14 @@ runs
377
476
  runs
378
477
  .command("show")
379
478
  .argument("<runId>")
380
- .action(async (runId: string) => {
479
+ .option("--log-limit <n>", "max log rows", "100")
480
+ .option("--log-offset <n>", "log rows to skip", "0")
481
+ .action(async (runId: string, cmdOpts: { logLimit: string; logOffset: string }) => {
381
482
  const { out, opts } = setup();
382
483
  try {
383
- out.data(await api(opts).get(`/runs/${runId}`));
484
+ const logLimit = parseIntOption(cmdOpts.logLimit, "--log-limit", { min: 1, max: 500 });
485
+ const logOffset = parseIntOption(cmdOpts.logOffset, "--log-offset", { min: 0 });
486
+ out.data(await api(opts).get(`/runs/${runId}?${new URLSearchParams({ logLimit: String(logLimit), logOffset: String(logOffset) })}`));
384
487
  } catch (err) {
385
488
  toExit(err, out);
386
489
  }
@@ -389,13 +492,15 @@ runs
389
492
  .command("trigger")
390
493
  .argument("<automation>")
391
494
  .option("--input <json>", "input payload as JSON")
495
+ .option("--input-file <path>", "read input JSON from a file, or '-' for stdin")
392
496
  .option("--env <env>", "environment", "production")
393
- .action(async (automation: string, cmdOpts: { input?: string; env: string }) => {
497
+ .action(async (automation: string, cmdOpts: { input?: string; inputFile?: string; env: string }) => {
394
498
  const { out, opts } = setup();
395
499
  try {
396
500
  const { name } = requireProject(opts);
397
501
  const body: Record<string, unknown> = { project: name, automation, env: cmdOpts.env };
398
- if (cmdOpts.input !== undefined) body["input"] = JSON.parse(cmdOpts.input);
502
+ const input = await resolveJsonInput({ literal: cmdOpts.input, file: cmdOpts.inputFile, label: "input" });
503
+ if (input !== undefined) body["input"] = input;
399
504
  out.data(await api(opts).post("/runs", body));
400
505
  } catch (err) {
401
506
  toExit(err, out);
@@ -406,12 +511,14 @@ runs
406
511
  .argument("<runId>")
407
512
  .argument("<name>")
408
513
  .option("--payload <json>", "signal payload as JSON")
409
- .action(async (runId: string, name: string, cmdOpts: { payload?: string }) => {
514
+ .option("--payload-file <path>", "read signal payload JSON from a file, or '-' for stdin")
515
+ .action(async (runId: string, name: string, cmdOpts: { payload?: string; payloadFile?: string }) => {
410
516
  const { out, opts } = setup();
411
517
  try {
518
+ const payload = await resolveJsonInput({ literal: cmdOpts.payload, file: cmdOpts.payloadFile, label: "payload" });
412
519
  out.data(
413
520
  await api(opts).post(`/runs/${runId}/signals/${encodeURIComponent(name)}`, {
414
- ...(cmdOpts.payload !== undefined ? { payload: JSON.parse(cmdOpts.payload) } : {}),
521
+ ...(payload !== undefined ? { payload } : {}),
415
522
  }),
416
523
  );
417
524
  } catch (err) {
@@ -424,7 +531,8 @@ runs
424
531
  .action(async (runId: string) => {
425
532
  const { out, opts } = setup();
426
533
  try {
427
- out.data(await api(opts).post(`/runs/${runId}/cancel`));
534
+ const result = await api(opts).post<{ cancelled: boolean }>(`/runs/${runId}/cancel`);
535
+ out.data({ ...result, changed: result.cancelled });
428
536
  } catch (err) {
429
537
  toExit(err, out);
430
538
  }
@@ -434,27 +542,49 @@ program
434
542
  .command("logs")
435
543
  .argument("<runId>")
436
544
  .option("--follow", "poll until the run settles")
545
+ .option("--limit <n>", "max log rows per page", "100")
546
+ .option("--offset <n>", "log rows to skip", "0")
437
547
  .description("step logs for one run")
438
- .action(async (runId: string, cmdOpts: { follow?: boolean }) => {
548
+ .action(async (runId: string, cmdOpts: { follow?: boolean; limit: string; offset: string }) => {
439
549
  const { out, opts } = setup();
440
550
  try {
441
551
  const client = api(opts);
442
- let printed = 0;
552
+ const limit = parseIntOption(cmdOpts.limit, "--limit", { min: 1, max: 500 });
553
+ let offset = parseIntOption(cmdOpts.offset, "--offset", { min: 0 });
554
+ let finalDetail: {
555
+ run: { status: string };
556
+ logs: Array<{ step: string | null; level: string; msg: string; created_at: string }>;
557
+ logsTruncated?: boolean;
558
+ } | null = null;
443
559
  for (;;) {
444
560
  const detail = await client.get<{
445
561
  run: { status: string };
446
562
  logs: Array<{ step: string | null; level: string; msg: string; created_at: string }>;
447
- }>(`/runs/${runId}`);
448
- const fresh = detail.logs.slice(printed);
449
- printed = detail.logs.length;
450
- if (out.json && !cmdOpts.follow) {
451
- out.data(detail);
563
+ logsTotal?: number;
564
+ logsLimit?: number;
565
+ logsOffset?: number;
566
+ logsTruncated?: boolean;
567
+ }>(`/runs/${runId}?${new URLSearchParams({ logLimit: String(limit), logOffset: String(offset) })}`);
568
+ finalDetail = detail;
569
+ if (!cmdOpts.follow) {
570
+ if (out.json) out.data(detail);
571
+ else {
572
+ for (const l of detail.logs) process.stdout.write(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}\n`);
573
+ if (detail.logsTruncated) out.log(`showing ${detail.logs.length} of ${detail.logsTotal ?? "?"}; use --offset for more`);
574
+ }
452
575
  return;
453
576
  }
454
- for (const l of fresh) out.log(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}`);
455
- if (!cmdOpts.follow || ["completed", "failed", "cancelled"].includes(detail.run.status)) {
456
- if (!out.json) out.log(`run ${detail.run.status}`);
457
- else out.data(detail);
577
+ if (out.format === "ndjson") {
578
+ for (const l of detail.logs) process.stdout.write(JSON.stringify({ log: l }) + "\n");
579
+ } else if (!out.json) {
580
+ for (const l of detail.logs) process.stdout.write(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}\n`);
581
+ }
582
+ offset += detail.logs.length;
583
+ if (detail.logsTruncated) continue;
584
+ if (["completed", "failed", "cancelled"].includes(detail.run.status)) {
585
+ if (out.format === "ndjson") process.stdout.write(JSON.stringify({ run: detail.run }) + "\n");
586
+ else if (out.json) out.data(finalDetail);
587
+ else out.log(`run ${detail.run.status}`);
458
588
  return;
459
589
  }
460
590
  await new Promise((r) => setTimeout(r, 1000));
@@ -488,13 +618,27 @@ program
488
618
  const { out, opts } = setup();
489
619
  try {
490
620
  const { name } = requireProject(opts);
491
- out.data(
492
- await api(opts).post(`/projects/${name}/rollback`, {
493
- automation,
494
- toVersion: Number(cmdOpts.to),
495
- env: cmdOpts.env,
496
- }),
497
- );
621
+ const toVersion = parseIntOption(cmdOpts.to, "--to", { min: 1 });
622
+ const result = await api(opts).post<Record<string, unknown>>(`/projects/${name}/rollback`, {
623
+ automation,
624
+ toVersion,
625
+ env: cmdOpts.env,
626
+ });
627
+ out.data({ ...result, changed: true });
628
+ } catch (err) {
629
+ toExit(err, out);
630
+ }
631
+ });
632
+
633
+ program
634
+ .command("doctor")
635
+ .option("--no-network", "skip Instance health checks")
636
+ .description("agent preflight: local Project, auth, pins, and optional Instance health")
637
+ .action(async (cmdOpts: { network?: boolean }) => {
638
+ const { out, opts } = setup();
639
+ try {
640
+ const resolved = target(opts);
641
+ await doctor(out, { version: VERSION, target: resolved, client: new ApiClient(resolved.url, resolved.token), network: cmdOpts.network !== false });
498
642
  } catch (err) {
499
643
  toExit(err, out);
500
644
  }
@@ -519,6 +663,25 @@ program
519
663
  });
520
664
 
521
665
  program.parseAsync().catch((err) => {
522
- const out = new Output(process.argv.includes("--json"));
666
+ if (isCommanderHelp(err)) process.exit(err.exitCode ?? EXIT.OK);
667
+ const out = new Output(isJsonRequestedFromArgv() ? "json" : process.stdout.isTTY ? "text" : "json");
668
+ if (isCommanderUsageError(err)) {
669
+ out.fail(EXIT.USAGE, err.message.replace(/^error: /, ""));
670
+ }
523
671
  toExit(err, out);
524
672
  });
673
+
674
+ function isCommanderHelp(err: unknown): err is { code: string; exitCode?: number } {
675
+ return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "commander.helpDisplayed";
676
+ }
677
+
678
+ function isCommanderUsageError(err: unknown): err is { code: string; message: string } {
679
+ return (
680
+ typeof err === "object" &&
681
+ err !== null &&
682
+ typeof (err as { code?: unknown }).code === "string" &&
683
+ (err as { code: string }).code.startsWith("commander.") &&
684
+ typeof (err as { message?: unknown }).message === "string"
685
+ );
686
+ }
687
+
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { parseIntOption, parseJsonLiteral, projectFields } from "./inputs.js";
3
+
4
+
5
+ describe("CLI input helpers", () => {
6
+ test("turns malformed JSON into a usage error", () => {
7
+ expect(() => parseJsonLiteral("{bad}", "--input")).toThrow("invalid JSON for --input");
8
+ });
9
+
10
+ test("validates integer options", () => {
11
+ expect(parseIntOption("25", "--limit", { min: 1, max: 200 })).toBe(25);
12
+ expect(() => parseIntOption("0", "--limit", { min: 1 })).toThrow("--limit must be >= 1");
13
+ expect(() => parseIntOption("abc", "--limit")).toThrow("--limit must be an integer");
14
+ });
15
+
16
+ test("projects list fields without mutating the source rows", () => {
17
+ const rows = [{ id: "r1", status: "completed", secret: "hidden" }];
18
+ expect(projectFields(rows, "id,status")).toEqual([{ id: "r1", status: "completed" }]);
19
+ expect(rows[0]?.secret).toBe("hidden");
20
+ });
21
+ });
package/src/inputs.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { EXIT } from "./exit-codes.js";
3
+ import { CliError } from "./output.js";
4
+
5
+ export function parseJsonLiteral(value: string, label: string): unknown {
6
+ try {
7
+ return JSON.parse(value) as unknown;
8
+ } catch (cause) {
9
+ const detail = cause instanceof Error ? cause.message : String(cause);
10
+ throw new CliError(EXIT.USAGE, `invalid JSON for ${label}: ${detail}`);
11
+ }
12
+ }
13
+
14
+ export async function readTextInput(pathOrDash: string, label: string): Promise<string> {
15
+ if (pathOrDash === "-") {
16
+ const chunks: Buffer[] = [];
17
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
18
+ return Buffer.concat(chunks).toString("utf8");
19
+ }
20
+ try {
21
+ return await readFile(pathOrDash, "utf8");
22
+ } catch (cause) {
23
+ const detail = cause instanceof Error ? cause.message : String(cause);
24
+ throw new CliError(EXIT.USAGE, `cannot read ${label} file ${pathOrDash}: ${detail}`);
25
+ }
26
+ }
27
+
28
+ export async function resolveJsonInput(opts: {
29
+ literal?: string | undefined;
30
+ file?: string | undefined;
31
+ label: string;
32
+ }): Promise<unknown | undefined> {
33
+ if (opts.literal !== undefined && opts.file !== undefined) {
34
+ throw new CliError(EXIT.USAGE, `use either --${opts.label} or --${opts.label}-file, not both`);
35
+ }
36
+ if (opts.literal !== undefined) return parseJsonLiteral(opts.literal, `--${opts.label}`);
37
+ if (opts.file !== undefined) return parseJsonLiteral(await readTextInput(opts.file, `--${opts.label}`), `--${opts.label}-file`);
38
+ return undefined;
39
+ }
40
+
41
+ export function parseIntOption(value: string, label: string, opts: { min?: number; max?: number } = {}): number {
42
+ if (!/^\d+$/.test(value)) throw new CliError(EXIT.USAGE, `${label} must be an integer`);
43
+ const parsed = Number(value);
44
+ if (!Number.isSafeInteger(parsed)) throw new CliError(EXIT.USAGE, `${label} is too large`);
45
+ if (opts.min !== undefined && parsed < opts.min) throw new CliError(EXIT.USAGE, `${label} must be >= ${opts.min}`);
46
+ if (opts.max !== undefined && parsed > opts.max) throw new CliError(EXIT.USAGE, `${label} must be <= ${opts.max}`);
47
+ return parsed;
48
+ }
49
+
50
+ export function projectFields<T extends Record<string, unknown>>(items: T[], fieldsCsv?: string | undefined): Array<Record<string, unknown>> {
51
+ if (fieldsCsv === undefined || fieldsCsv.trim() === "") return items;
52
+ const fields = fieldsCsv
53
+ .split(",")
54
+ .map((field) => field.trim())
55
+ .filter(Boolean);
56
+ if (fields.length === 0) return items;
57
+ return items.map((item) => {
58
+ const out: Record<string, unknown> = {};
59
+ for (const field of fields) {
60
+ if (field in item) out[field] = item[field];
61
+ }
62
+ return out;
63
+ });
64
+ }
package/src/login.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { DEFAULT_INSTANCE_URL, resolveLoginInstance } from "./login.js";
2
+ import { DEFAULT_INSTANCE_URL, resolveLoginInputs, resolveLoginInstance } from "./login.js";
3
3
 
4
4
  describe("tesser login target resolution", () => {
5
5
  test("accepts --url on the login command", () => {
@@ -17,4 +17,22 @@ describe("tesser login target resolution", () => {
17
17
  test("defaults to the local dev instance", () => {
18
18
  expect(resolveLoginInstance({}, {})).toBe(DEFAULT_INSTANCE_URL);
19
19
  });
20
+
21
+ test("rejects ambiguous token sources before prompting", async () => {
22
+ await expect(
23
+ resolveLoginInputs(
24
+ { token: "cmd-token", tokenStdin: true, saveProfile: "default" },
25
+ { url: "https://inst.example" },
26
+ ),
27
+ ).rejects.toThrow("use either --token/--token-stdin");
28
+ });
29
+
30
+ test("accepts a command token without prompting", async () => {
31
+ await expect(
32
+ resolveLoginInputs(
33
+ { token: "cmd-token", saveProfile: "default", url: "https://inst.example" },
34
+ {},
35
+ ),
36
+ ).resolves.toEqual({ instance: "https://inst.example", token: "cmd-token" });
37
+ });
20
38
  });