@catalyst-cloud/cli 0.16.23 → 0.16.25
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/args.js +17 -4
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/env/draft.js +56 -29
- package/dist/env/machine-values.js +63 -0
- package/dist/env/machine.js +119 -0
- package/dist/env/scan-claude-mcp.js +274 -0
- package/dist/env/scan-envrc.js +271 -0
- package/dist/env/settings-toml.js +8 -0
- package/dist/env/shell-plan.js +4 -1
- package/dist/env-import.js +274 -0
- package/dist/env.js +101 -9
- package/dist/hosts.js +2 -14
- package/dist/mcp-status.js +111 -0
- package/dist/mcp.js +5 -2
- package/dist/onboard-next.js +5 -1
- package/dist/onboard-runner.js +112 -20
- package/dist/prompt.js +15 -2
- package/dist/runner-command.js +8 -3
- package/dist/runner-update-fleet.js +442 -0
- package/dist/runner-update.js +7 -7
- package/dist/secret.js +3 -3
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/declaring-a-repository.md +6 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
package/dist/onboard-runner.js
CHANGED
|
@@ -20,19 +20,21 @@ import { onboardStateRoot, } from "./onboard.js";
|
|
|
20
20
|
/** The Compose project name the vendored file declares. */
|
|
21
21
|
export const RUNNER_PROJECT = "catalyst-host";
|
|
22
22
|
export const RUNNER_SESSION_NETWORK = "catalyst-session-v1";
|
|
23
|
-
/** Catalyst's approved host release, generation
|
|
24
|
-
* runner
|
|
23
|
+
/** Catalyst's approved host release, generation 48 (hr-598fd0ef2675bfd8-69310962e37ad7f2-2d264b793edb843e):
|
|
24
|
+
* the runner built from catalyst-cloud caf921a4, the supervisor and watchdog from a4165555, the
|
|
25
|
+
* first supervisor that serves several teams (CTC-4455). A machine set up from these
|
|
25
26
|
* pins then follows newer approved releases itself (`catalyst runner update`, CTC-4646).
|
|
26
27
|
* Explicit environment values override these pins; a saved runner image is preserved. */
|
|
27
28
|
export const RUNNER_HOST_IMAGES = {
|
|
28
|
-
supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:
|
|
29
|
-
watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:
|
|
30
|
-
runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:
|
|
29
|
+
supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:69310962e37ad7f21ccab5c131b0734f106a6b0cc9fb163cb5d8917c9d7a2fc1",
|
|
30
|
+
watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:2d264b793edb843e47b4c5a86fa6133dd2bd0b5ecdc0b7e997368893de074b28",
|
|
31
|
+
runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:598fd0ef2675bfd8e0665f70ef4ed8bf30706fba25fa2c39fc0df0801512a423",
|
|
31
32
|
};
|
|
32
33
|
export const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
|
|
33
34
|
const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
34
35
|
const JOIN_TOKEN = /^[A-Za-z0-9._~+/=-]{16,1024}$/;
|
|
35
|
-
const
|
|
36
|
+
const HOSTS = "/api/v1/hosts";
|
|
37
|
+
const ENROLLMENTS = `${HOSTS}/enrollments`;
|
|
36
38
|
const JOIN_TOKENS = "/api/v1/hosts/join-tokens";
|
|
37
39
|
const CAPACITY = "/api/v1/me/runner-capacity";
|
|
38
40
|
const RUNNER_UID = "10001:10001";
|
|
@@ -549,6 +551,7 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
549
551
|
},
|
|
550
552
|
};
|
|
551
553
|
}
|
|
554
|
+
const serves = (host, teamKey) => host.teams.includes(teamKey);
|
|
552
555
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
553
556
|
? value
|
|
554
557
|
: null;
|
|
@@ -565,6 +568,8 @@ function parseHosts(body) {
|
|
|
565
568
|
typeof host.hostId !== "string" ||
|
|
566
569
|
typeof host.hostName !== "string" ||
|
|
567
570
|
typeof host.team !== "string" ||
|
|
571
|
+
(host.teams !== undefined &&
|
|
572
|
+
(!Array.isArray(host.teams) || !host.teams.every((team) => typeof team === "string"))) ||
|
|
568
573
|
!(host.revokedAtMs === null || typeof host.revokedAtMs === "number") ||
|
|
569
574
|
(host.capability !== null &&
|
|
570
575
|
(!capability ||
|
|
@@ -578,6 +583,9 @@ function parseHosts(body) {
|
|
|
578
583
|
hostId: host.hostId,
|
|
579
584
|
hostName: host.hostName,
|
|
580
585
|
team: host.team,
|
|
586
|
+
teams: Array.isArray(host.teams) && host.teams.includes(host.team)
|
|
587
|
+
? host.teams
|
|
588
|
+
: [host.team],
|
|
581
589
|
revoked: host.revokedAtMs !== null,
|
|
582
590
|
capacity: capability ? capability.runtimeLive
|
|
583
591
|
? capability.placeableCapacity
|
|
@@ -755,8 +763,8 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
755
763
|
const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
|
|
756
764
|
if (!teamId || !teamKey)
|
|
757
765
|
return waiting("runner_context_unverified", selected);
|
|
758
|
-
|
|
759
|
-
|
|
766
|
+
// An installation enrolled for another team serves this one too once setup adds it (CTC-4455).
|
|
767
|
+
const otherTeamHost = credential && !isUnenrolled(credential) && credential.team !== teamKey ? credential.hostId : undefined;
|
|
760
768
|
const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
|
|
761
769
|
if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
|
|
762
770
|
return waiting(runnerSupport.reason, selected);
|
|
@@ -773,7 +781,8 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
773
781
|
return waiting("runner_native_egress_setup_required", selected);
|
|
774
782
|
return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images,
|
|
775
783
|
...(installation && installation !== "missing" ? { installedName: installation.hostName,
|
|
776
|
-
installedSupervisorImage: installation.supervisorImage } : {})
|
|
784
|
+
installedSupervisorImage: installation.supervisorImage } : {}),
|
|
785
|
+
...(otherTeamHost ? { otherTeamHost } : {}) };
|
|
777
786
|
}
|
|
778
787
|
async function desiredEnv(p, ctx, joinToken) {
|
|
779
788
|
const saved = readEnvFile(join(p.dir, ".env")) ?? {};
|
|
@@ -857,6 +866,58 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
857
866
|
return waiting(signal?.aborted ? "interrupted" : "runner_join_token_unavailable", selected);
|
|
858
867
|
}
|
|
859
868
|
}
|
|
869
|
+
/**
|
|
870
|
+
* CTC-4455: adds the selected team to this machine's enrollment. The cloud refuses until the
|
|
871
|
+
* machine's supervisor advertises that it serves a set of teams. Before setup starts the new
|
|
872
|
+
* supervisor that refusal is `deferred`; after, it is retried for as long as setup waits for the
|
|
873
|
+
* supervisor. An older cloud without the route answers 404, and setup refuses as it used to.
|
|
874
|
+
*/
|
|
875
|
+
async function addTeam(ctx, p, hostId, retry, signal) {
|
|
876
|
+
const evidence = { ...selected, hostId };
|
|
877
|
+
const cfg = loadConfig(ctx.home);
|
|
878
|
+
const expiry = cfg?.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
|
|
879
|
+
const bearer = cfg?.key ||
|
|
880
|
+
(cfg?.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
|
|
881
|
+
if (!bearer)
|
|
882
|
+
return waiting("runner_login_refresh_required", evidence);
|
|
883
|
+
for (let attempt = 1;; attempt++) {
|
|
884
|
+
let status;
|
|
885
|
+
let body;
|
|
886
|
+
try {
|
|
887
|
+
const response = await ctx.fetch(`${p.baseUrl}${HOSTS}/${encodeURIComponent(hostId)}/teams?account=${encodeURIComponent(p.account)}`, {
|
|
888
|
+
method: "POST",
|
|
889
|
+
redirect: "error",
|
|
890
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
891
|
+
headers: {
|
|
892
|
+
authorization: `Bearer ${bearer}`,
|
|
893
|
+
"content-type": "application/json",
|
|
894
|
+
accept: "application/json",
|
|
895
|
+
},
|
|
896
|
+
body: JSON.stringify({ version: 1, team: p.teamKey }),
|
|
897
|
+
});
|
|
898
|
+
status = response.status;
|
|
899
|
+
body = object(await response.json().catch(() => null));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
return waiting(signal?.aborted ? "interrupted" : "runner_team_add_failed", evidence);
|
|
903
|
+
}
|
|
904
|
+
if (status === 200 && body?.ok === true && Array.isArray(body.teams) && body.teams.includes(p.teamKey))
|
|
905
|
+
return true;
|
|
906
|
+
if (status === 404 && body?.error !== "unknown_host")
|
|
907
|
+
return waiting("runner_enrolled_for_other_team", evidence);
|
|
908
|
+
if (status === 401 || status === 403)
|
|
909
|
+
return waiting("runner_identity_unverified", evidence);
|
|
910
|
+
if (body?.error === "team_limit")
|
|
911
|
+
return waiting("runner_team_limit", evidence);
|
|
912
|
+
if (body?.error !== "supervisor_outdated")
|
|
913
|
+
return waiting("runner_team_add_failed", evidence);
|
|
914
|
+
if (!retry)
|
|
915
|
+
return "deferred";
|
|
916
|
+
if (attempt >= Math.max(1, Math.ceil(waitMs / pollMs)) || signal?.aborted)
|
|
917
|
+
return waiting("runner_supervisor_outdated", evidence);
|
|
918
|
+
await sleep(pollMs);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
860
921
|
function orgKeyFromFile(ctx) {
|
|
861
922
|
const path = ctx.env.CATALYST_RUNNER_ORG_KEY_FILE;
|
|
862
923
|
if (!path)
|
|
@@ -887,13 +948,13 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
887
948
|
if (p.info.vm && persistedCustody(p.dir, saved) !== "installed")
|
|
888
949
|
return mayAct && !afterAct ? { state: "pending" } : waiting(mayAct ? "runner_thoughts_custody_unavailable" : "runner_needs_runner_flag", { ...selected, hostName: name });
|
|
889
950
|
let enrolled = null;
|
|
890
|
-
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host
|
|
951
|
+
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && serves(host, p.teamKey) && !host.revoked);
|
|
891
952
|
let hosts = await listHosts(ctx, p, signal);
|
|
892
953
|
if (!hosts)
|
|
893
954
|
return waiting("runner_enrollment_unavailable", selected);
|
|
894
955
|
const existingCredential = await engine.enrollment(p.dir, signal);
|
|
895
956
|
if ((!existingCredential || isUnenrolled(existingCredential)) &&
|
|
896
|
-
hosts.some((host) => host.hostName === name && !host.revoked && host
|
|
957
|
+
hosts.some((host) => host.hostName === name && !host.revoked && !serves(host, p.teamKey)))
|
|
897
958
|
return waiting("runner_enrolled_for_other_team", {
|
|
898
959
|
...selected,
|
|
899
960
|
hostName: name,
|
|
@@ -902,8 +963,11 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
902
963
|
!isUnenrolled(existingCredential) &&
|
|
903
964
|
hosts.some((host) => host.hostId === existingCredential.hostId &&
|
|
904
965
|
!host.revoked &&
|
|
905
|
-
host
|
|
906
|
-
|
|
966
|
+
!serves(host, p.teamKey)))
|
|
967
|
+
// CTC-4455: setup's act adds the selected team to a runner enrolled for another one.
|
|
968
|
+
return mayAct && !afterAct
|
|
969
|
+
? { state: "pending" }
|
|
970
|
+
: waiting(mayAct ? "runner_team_not_added" : "runner_needs_runner_flag", { ...selected, hostName: name });
|
|
907
971
|
const running = await engine.composeRunning(p.dir, signal);
|
|
908
972
|
let composeMatches = false;
|
|
909
973
|
try {
|
|
@@ -931,13 +995,11 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
931
995
|
return waiting("runner_enrollment_unavailable", hostEvidence);
|
|
932
996
|
if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
|
|
933
997
|
return waiting("runner_identity_unverified", hostEvidence);
|
|
934
|
-
if (enrolled && enrolled.team !== p.teamKey)
|
|
935
|
-
return waiting("runner_enrolled_for_other_team", hostEvidence);
|
|
936
998
|
if (enrolled) {
|
|
937
999
|
const exact = hosts.find((host) => host.hostId === enrolled.hostId);
|
|
938
1000
|
if (exact?.revoked)
|
|
939
1001
|
return waiting("runner_enrollment_revoked", { ...hostEvidence, hostId: enrolled.hostId });
|
|
940
|
-
if (!exact || exact
|
|
1002
|
+
if (!exact || !serves(exact, p.teamKey))
|
|
941
1003
|
return waiting("runner_enrollment_unverified", hostEvidence);
|
|
942
1004
|
}
|
|
943
1005
|
const host = live(hosts);
|
|
@@ -986,6 +1048,23 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
986
1048
|
return p;
|
|
987
1049
|
if (!mayAct)
|
|
988
1050
|
return waiting("runner_needs_runner_flag", selected);
|
|
1051
|
+
// Settle a team the installed runner does not serve before anything on this machine changes.
|
|
1052
|
+
let addTeamTo = null;
|
|
1053
|
+
if (p.otherTeamHost) {
|
|
1054
|
+
const hosts = await listHosts(ctx, p, signal);
|
|
1055
|
+
if (!hosts)
|
|
1056
|
+
return waiting("runner_enrollment_unavailable", selected);
|
|
1057
|
+
const exact = hosts.find((host) => host.hostId === p.otherTeamHost && !host.revoked);
|
|
1058
|
+
if (!exact)
|
|
1059
|
+
return waiting("runner_enrolled_for_other_team", selected);
|
|
1060
|
+
if (!serves(exact, p.teamKey)) {
|
|
1061
|
+
const added = await addTeam(ctx, p, exact.hostId, false, signal);
|
|
1062
|
+
if (added === "deferred")
|
|
1063
|
+
addTeamTo = exact.hostId;
|
|
1064
|
+
else if (added !== true)
|
|
1065
|
+
return added;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
989
1068
|
let replacingContainers = false;
|
|
990
1069
|
try {
|
|
991
1070
|
mkdirSync(p.dir, { recursive: true, mode: 0o700 });
|
|
@@ -1104,20 +1183,25 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
1104
1183
|
if (!hosts)
|
|
1105
1184
|
return waiting("runner_enrollment_unavailable", selected);
|
|
1106
1185
|
if (!enrolled &&
|
|
1107
|
-
hosts.some((host) => host.hostName === name && !host.revoked && host
|
|
1186
|
+
hosts.some((host) => host.hostName === name && !host.revoked && !serves(host, p.teamKey)))
|
|
1108
1187
|
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
1109
1188
|
if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
|
|
1110
1189
|
return waiting("runner_identity_unverified", { ...selected, hostName: name });
|
|
1111
|
-
if (enrolled && enrolled.team !== p.teamKey)
|
|
1112
|
-
return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
|
|
1113
1190
|
if (hasCredential && !isUnenrolled(credential)) {
|
|
1114
1191
|
if (!enrolled)
|
|
1115
1192
|
return waiting("runner_credential_unverified", { ...selected, hostName: name });
|
|
1116
1193
|
const exact = hosts.find((host) => host.hostId === enrolled.hostId);
|
|
1117
1194
|
if (exact?.revoked)
|
|
1118
1195
|
return waiting("runner_enrollment_revoked", { ...selected, hostName: name, hostId: enrolled.hostId });
|
|
1119
|
-
if (!exact
|
|
1196
|
+
if (!exact)
|
|
1120
1197
|
return waiting("runner_enrollment_unverified", { ...selected, hostName: name });
|
|
1198
|
+
if (!serves(exact, p.teamKey) && addTeamTo === null) {
|
|
1199
|
+
const added = await addTeam(ctx, p, exact.hostId, false, signal);
|
|
1200
|
+
if (added === "deferred")
|
|
1201
|
+
addTeamTo = exact.hostId;
|
|
1202
|
+
else if (added !== true)
|
|
1203
|
+
return added;
|
|
1204
|
+
}
|
|
1121
1205
|
}
|
|
1122
1206
|
// A lost volume also lost the secret that redeemed the old token. Mint a new token and match
|
|
1123
1207
|
// the new host id from the supervisor's credential, never an old advertisement with this name.
|
|
@@ -1184,6 +1268,14 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
1184
1268
|
if (!up.ok)
|
|
1185
1269
|
return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
|
|
1186
1270
|
project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
|
|
1271
|
+
// The supervisor just started can serve a set of teams; the cloud accepts the team once it says so.
|
|
1272
|
+
if (addTeamTo) {
|
|
1273
|
+
const added = await addTeam(ctx, p, addTeamTo, true, signal);
|
|
1274
|
+
if (added === "deferred")
|
|
1275
|
+
return waiting("runner_supervisor_outdated", selected);
|
|
1276
|
+
if (added !== true)
|
|
1277
|
+
return added;
|
|
1278
|
+
}
|
|
1187
1279
|
acted = true;
|
|
1188
1280
|
if (input.autoUpdate) {
|
|
1189
1281
|
try {
|
package/dist/prompt.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
// prompt.ts — the
|
|
2
|
-
// echoing it. The streams are parameters so the tests can drive it without a real TTY.
|
|
1
|
+
// prompt.ts — the CLI's interactive reads: asking for the personal key on a terminal without
|
|
2
|
+
// echoing it, and one [y/N] question. The streams are parameters so the tests can drive it without a real TTY.
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
|
+
import { createInterface as createPromiseInterface } from "node:readline/promises";
|
|
4
5
|
/** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
|
|
5
6
|
export function stdinIsTty() {
|
|
6
7
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
7
8
|
}
|
|
9
|
+
/** Ask a `[y/N]` question on stderr and read the answer. False without a terminal, and for anything but y/yes. */
|
|
10
|
+
export async function confirmYesNo(question) {
|
|
11
|
+
if (!stdinIsTty())
|
|
12
|
+
return false;
|
|
13
|
+
const prompt = createPromiseInterface({ input: process.stdin, output: process.stderr });
|
|
14
|
+
try {
|
|
15
|
+
return /^(y|yes)$/i.test((await prompt.question(question)).trim());
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
prompt.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Read one line without echoing it. Everything readline would write after the question itself goes
|
|
10
23
|
* to a muted stream, so the key never reaches the screen or a scrollback buffer; the caller still
|
package/dist/runner-command.js
CHANGED
|
@@ -3,7 +3,8 @@ import { flagBool, flagString, positionals } from "./args.js";
|
|
|
3
3
|
import { UsageError } from "./errors.js";
|
|
4
4
|
import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
|
|
5
5
|
import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
|
|
6
|
-
|
|
6
|
+
import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
|
|
7
|
+
const USAGE = "catalyst runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
|
|
7
8
|
function schedulerDeps(ctx, deps) {
|
|
8
9
|
const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
|
|
9
10
|
return {
|
|
@@ -47,8 +48,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
47
48
|
if (action === "update" && parts.length === 1) {
|
|
48
49
|
const check = flagBool(args, "check");
|
|
49
50
|
const auto = flagBool(args, "auto");
|
|
50
|
-
const
|
|
51
|
-
|
|
51
|
+
const fleet = flagBool(args, "fleet");
|
|
52
|
+
if (fleet && dirFlag === undefined)
|
|
53
|
+
throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
|
|
54
|
+
const result = fleet
|
|
55
|
+
? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
|
|
56
|
+
: await runRunnerUpdate(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), { dir: dir(), check });
|
|
52
57
|
if (args.json)
|
|
53
58
|
ctx.stdout(JSON.stringify(result));
|
|
54
59
|
else if (auto)
|