@devosurf/tesser 0.1.0-alpha.2 → 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,20 +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 { 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";
14
17
  import { discoverLocalAutomations, loadAutomationDef } from "./project.js";
15
18
  import { authClaudeCode, authPi } from "./commands/auth.js";
16
19
  import { deploy } from "./commands/deploy.js";
20
+ import { doctor } from "./commands/doctor.js";
17
21
  import { dev } from "./commands/dev.js";
18
22
  import { init } from "./commands/init.js";
19
23
  import { upgradeProject } from "./commands/project-docs.js";
20
24
  import { replay } from "./commands/replay.js";
21
25
  import { runTests } from "./commands/test.js";
26
+ import { cliSchema } from "./schema.js";
22
27
 
23
28
  const exec = promisify(execFile);
24
29
  const program = new Command();
@@ -26,6 +31,7 @@ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.u
26
31
 
27
32
  interface GlobalOpts {
28
33
  json?: boolean;
34
+ output?: "auto" | OutputFormat;
29
35
  url?: string;
30
36
  token?: string;
31
37
  profile?: string;
@@ -33,7 +39,20 @@ interface GlobalOpts {
33
39
 
34
40
  function setup(): { out: Output; opts: GlobalOpts } {
35
41
  const opts = program.opts<GlobalOpts>();
36
- 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";
37
56
  }
38
57
 
39
58
  function target(opts: GlobalOpts): ReturnType<typeof resolveTarget> {
@@ -61,18 +80,54 @@ program
61
80
  .name("tesser")
62
81
  .version(VERSION)
63
82
  .description("Code-first, agent-native automation. stdout = data, stderr = logs; --json everywhere.")
64
- .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"))
65
85
  .option("--url <url>", "instance URL (overrides tesser.json / profile)")
66
86
  .option("--token <token>", "API token (overrides TESSER_TOKEN / profile)")
67
- .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
+ });
68
122
 
69
123
  program
70
124
  .command("init")
71
125
  .argument("<name>", "project name (kebab-case)")
72
126
  .option("--dir <dir>", "parent directory")
73
127
  .option("--instance <url>", "instance URL to write into tesser.json")
128
+ .option("--force", "write into an existing non-empty directory")
74
129
  .description("scaffold a new Project (one repo of automations)")
75
- .action((name: string, cmdOpts: { dir?: string; instance?: string }) => {
130
+ .action((name: string, cmdOpts: { dir?: string; instance?: string; force?: boolean }) => {
76
131
  const { out } = setup();
77
132
  try {
78
133
  init(out, name, cmdOpts, VERSION);
@@ -95,22 +150,23 @@ program
95
150
 
96
151
  program
97
152
  .command("login")
98
- .option("--token <token>", "API token from the instance (printed at first boot)")
99
- .option("--instance <url>", "instance URL", "http://localhost:8377")
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")
155
+ .option("--url <url>", `instance URL (defaults to ${DEFAULT_INSTANCE_URL})`)
156
+ .option("--instance <url>", "deprecated alias for --url")
100
157
  .option("--save-profile <name>", "profile name", "default")
101
158
  .description("store instance credentials in ~/.config/tesser (or use env TESSER_TOKEN)")
102
- .action(async (cmdOpts: { token?: string; instance: string; saveProfile: string }) => {
159
+ .action(async (cmdOpts: LoginCommandOptions) => {
103
160
  const { out, opts } = setup();
104
161
  try {
105
- const token = cmdOpts.token ?? opts.token;
106
- if (!token) throw new CliError(EXIT.USAGE, "missing required option '--token <token>'");
107
- const client = new ApiClient(cmdOpts.instance, token);
162
+ const { instance, token } = await resolveLoginInputs(cmdOpts, opts);
163
+ const client = new ApiClient(instance, token);
108
164
  await client.get("/health"); // verify before storing
109
165
  const config = readConfig();
110
- config.profiles = { ...config.profiles, [cmdOpts.saveProfile]: { url: cmdOpts.instance, token } };
166
+ config.profiles = { ...config.profiles, [cmdOpts.saveProfile]: { url: instance, token } };
111
167
  config.current = cmdOpts.saveProfile;
112
168
  writeConfig(config);
113
- out.data({ profile: cmdOpts.saveProfile, url: cmdOpts.instance }, () => `logged in to ${cmdOpts.instance} (profile "${cmdOpts.saveProfile}")`);
169
+ out.data({ profile: cmdOpts.saveProfile, url: instance }, () => `logged in to ${instance} (profile "${cmdOpts.saveProfile}")`);
114
170
  } catch (err) {
115
171
  toExit(err, out);
116
172
  }
@@ -220,7 +276,8 @@ program
220
276
  const { out, opts } = setup();
221
277
  try {
222
278
  const { name, root } = requireProject(opts);
223
- 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 } : {}) });
224
281
  } catch (err) {
225
282
  toExit(err, out);
226
283
  }
@@ -263,6 +320,43 @@ auth
263
320
  }
264
321
  });
265
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
+
266
360
  program
267
361
  .command("connect")
268
362
  .option("--wait", "poll until the human completes the link")
@@ -285,19 +379,20 @@ program
285
379
  out.data({ url: null, requirements: [] }, () => "nothing missing — all requirements are satisfied");
286
380
  return;
287
381
  }
288
- out.data(minted, () => `open in a browser to connect:\n ${minted.url}`);
289
- if (cmdOpts.wait) {
290
- for (;;) {
291
- await new Promise((r) => setTimeout(r, 2000));
292
- const status = await client.get<{ status: string }>(`/connect-links/${minted.token}/status`);
293
- if (status.status === "completed") {
294
- out.log("connect link completed ✓");
295
- return;
296
- }
297
- 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;
298
393
  }
394
+ if (status.status === "expired") throw new CliError(EXIT.HALTED_CREDENTIALS, "connect link expired");
299
395
  }
300
- process.exit(EXIT.HALTED_CREDENTIALS);
301
396
  } catch (err) {
302
397
  toExit(err, out);
303
398
  }
@@ -333,7 +428,7 @@ secrets
333
428
  const value = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
334
429
  if (value.length === 0) throw new CliError(EXIT.USAGE, "empty value on stdin");
335
430
  await api(opts).put(`/secrets/${encodeURIComponent(name)}`, { value });
336
- out.data({ set: name }, () => `secret "${name}" set ✓`);
431
+ out.data({ set: name, changed: true }, () => `secret "${name}" set ✓`);
337
432
  } catch (err) {
338
433
  toExit(err, out);
339
434
  }
@@ -344,7 +439,8 @@ secrets
344
439
  .action(async (name: string) => {
345
440
  const { out, opts } = setup();
346
441
  try {
347
- 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 });
348
444
  } catch (err) {
349
445
  toExit(err, out);
350
446
  }
@@ -356,17 +452,23 @@ runs
356
452
  .option("--automation <id>")
357
453
  .option("--status <status>")
358
454
  .option("--limit <n>", "max rows", "25")
359
- .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 }) => {
360
458
  const { out, opts } = setup();
361
459
  try {
362
460
  const { name } = requireProject(opts);
363
- 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) });
364
464
  if (cmdOpts.automation) params.set("automation", cmdOpts.automation);
365
465
  if (cmdOpts.status) params.set("status", cmdOpts.status);
366
- const data = await api(opts).get<{ runs: Array<Record<string, unknown>> }>(`/runs?${params}`);
367
- out.data(data, (d: { runs: Array<{ id: string; automation_id: string; status: string; trigger_kind: string; created_at: string }> }) =>
368
- d.runs.map((r) => `${r.id} ${r.automation_id} ${r.status} (${r.trigger_kind}) ${r.created_at}`).join("\n") || "(no runs)",
369
- );
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
+ });
370
472
  } catch (err) {
371
473
  toExit(err, out);
372
474
  }
@@ -374,10 +476,14 @@ runs
374
476
  runs
375
477
  .command("show")
376
478
  .argument("<runId>")
377
- .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 }) => {
378
482
  const { out, opts } = setup();
379
483
  try {
380
- 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) })}`));
381
487
  } catch (err) {
382
488
  toExit(err, out);
383
489
  }
@@ -386,13 +492,15 @@ runs
386
492
  .command("trigger")
387
493
  .argument("<automation>")
388
494
  .option("--input <json>", "input payload as JSON")
495
+ .option("--input-file <path>", "read input JSON from a file, or '-' for stdin")
389
496
  .option("--env <env>", "environment", "production")
390
- .action(async (automation: string, cmdOpts: { input?: string; env: string }) => {
497
+ .action(async (automation: string, cmdOpts: { input?: string; inputFile?: string; env: string }) => {
391
498
  const { out, opts } = setup();
392
499
  try {
393
500
  const { name } = requireProject(opts);
394
501
  const body: Record<string, unknown> = { project: name, automation, env: cmdOpts.env };
395
- 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;
396
504
  out.data(await api(opts).post("/runs", body));
397
505
  } catch (err) {
398
506
  toExit(err, out);
@@ -403,12 +511,14 @@ runs
403
511
  .argument("<runId>")
404
512
  .argument("<name>")
405
513
  .option("--payload <json>", "signal payload as JSON")
406
- .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 }) => {
407
516
  const { out, opts } = setup();
408
517
  try {
518
+ const payload = await resolveJsonInput({ literal: cmdOpts.payload, file: cmdOpts.payloadFile, label: "payload" });
409
519
  out.data(
410
520
  await api(opts).post(`/runs/${runId}/signals/${encodeURIComponent(name)}`, {
411
- ...(cmdOpts.payload !== undefined ? { payload: JSON.parse(cmdOpts.payload) } : {}),
521
+ ...(payload !== undefined ? { payload } : {}),
412
522
  }),
413
523
  );
414
524
  } catch (err) {
@@ -421,7 +531,8 @@ runs
421
531
  .action(async (runId: string) => {
422
532
  const { out, opts } = setup();
423
533
  try {
424
- 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 });
425
536
  } catch (err) {
426
537
  toExit(err, out);
427
538
  }
@@ -431,27 +542,49 @@ program
431
542
  .command("logs")
432
543
  .argument("<runId>")
433
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")
434
547
  .description("step logs for one run")
435
- .action(async (runId: string, cmdOpts: { follow?: boolean }) => {
548
+ .action(async (runId: string, cmdOpts: { follow?: boolean; limit: string; offset: string }) => {
436
549
  const { out, opts } = setup();
437
550
  try {
438
551
  const client = api(opts);
439
- 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;
440
559
  for (;;) {
441
560
  const detail = await client.get<{
442
561
  run: { status: string };
443
562
  logs: Array<{ step: string | null; level: string; msg: string; created_at: string }>;
444
- }>(`/runs/${runId}`);
445
- const fresh = detail.logs.slice(printed);
446
- printed = detail.logs.length;
447
- if (out.json && !cmdOpts.follow) {
448
- 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
+ }
449
575
  return;
450
576
  }
451
- for (const l of fresh) out.log(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}`);
452
- if (!cmdOpts.follow || ["completed", "failed", "cancelled"].includes(detail.run.status)) {
453
- if (!out.json) out.log(`run ${detail.run.status}`);
454
- 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}`);
455
588
  return;
456
589
  }
457
590
  await new Promise((r) => setTimeout(r, 1000));
@@ -485,13 +618,27 @@ program
485
618
  const { out, opts } = setup();
486
619
  try {
487
620
  const { name } = requireProject(opts);
488
- out.data(
489
- await api(opts).post(`/projects/${name}/rollback`, {
490
- automation,
491
- toVersion: Number(cmdOpts.to),
492
- env: cmdOpts.env,
493
- }),
494
- );
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 });
495
642
  } catch (err) {
496
643
  toExit(err, out);
497
644
  }
@@ -516,6 +663,25 @@ program
516
663
  });
517
664
 
518
665
  program.parseAsync().catch((err) => {
519
- 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
+ }
520
671
  toExit(err, out);
521
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
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { DEFAULT_INSTANCE_URL, resolveLoginInputs, resolveLoginInstance } from "./login.js";
3
+
4
+ describe("tesser login target resolution", () => {
5
+ test("accepts --url on the login command", () => {
6
+ expect(resolveLoginInstance({ url: "https://inst.example" }, {})).toBe("https://inst.example");
7
+ });
8
+
9
+ test("accepts the global --url before login", () => {
10
+ expect(resolveLoginInstance({}, { url: "https://global.example" })).toBe("https://global.example");
11
+ });
12
+
13
+ test("keeps --instance as a backwards-compatible alias", () => {
14
+ expect(resolveLoginInstance({ instance: "https://old.example" }, { url: "https://global.example" })).toBe("https://old.example");
15
+ });
16
+
17
+ test("defaults to the local dev instance", () => {
18
+ expect(resolveLoginInstance({}, {})).toBe(DEFAULT_INSTANCE_URL);
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
+ });
38
+ });