@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/README.md +15 -7
- package/dist/index.js +840 -223
- 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/config.ts +3 -1
- package/src/index.ts +226 -60
- package/src/inputs.test.ts +21 -0
- package/src/inputs.ts +64 -0
- package/src/login.test.ts +38 -0
- package/src/login.ts +109 -0
- 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,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 {
|
|
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
|
|
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 (
|
|
99
|
-
.option("--
|
|
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:
|
|
159
|
+
.action(async (cmdOpts: LoginCommandOptions) => {
|
|
103
160
|
const { out, opts } = setup();
|
|
104
161
|
try {
|
|
105
|
-
const token = cmdOpts
|
|
106
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
+
});
|