@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/README.md +15 -7
- package/dist/index.js +830 -220
- package/dist/index.js.map +4 -4
- package/package.json +3 -3
- package/src/commands/doctor.test.ts +39 -0
- package/src/commands/doctor.ts +117 -0
- package/src/commands/init.test.ts +14 -1
- package/src/commands/init.ts +10 -4
- package/src/commands/project-docs.ts +6 -2
- package/src/completion.test.ts +14 -0
- package/src/completion.ts +92 -0
- package/src/index.ts +220 -57
- package/src/inputs.test.ts +21 -0
- package/src/inputs.ts +64 -0
- package/src/login.test.ts +19 -1
- package/src/login.ts +92 -1
- package/src/output.ts +15 -3
- package/src/schema.test.ts +26 -0
- package/src/schema.ts +289 -0
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,
|
|
14
|
-
import {
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
});
|