@poietic-tech/cli 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,238 @@
1
+ import { Argument, Option } from "commander";
2
+ import { apiError, CliError } from "../errors.js";
3
+ import { defineModule } from "../module.js";
4
+ /** The server's AGENT_PATH segment. */
5
+ export const ID = /^[0-9a-f-]{36}$/;
6
+ const malformed = () => new CliError("server_error", "The auth server sent an answer this command cannot read.");
7
+ /**
8
+ * Calls the /agents API as the person (or the builder named by POIETIC_IDENTITY). Returns the JSON of a 2xx.
9
+ * `signal` (the run's Ctrl-C) aborts the request in flight.
10
+ * On `403 reauth_required` it steps up (`ctx.stepUp()`, which fails at once under --no-wait) and
11
+ * sends once more; a second `reauth_required` is the error. An aborted `signal` never resends.
12
+ */
13
+ export async function agentsCall(ctx, method, path, body, signal) {
14
+ const caller = await ctx.caller();
15
+ const send = () => caller.authFetch(path, {
16
+ method,
17
+ headers: { "content-type": "application/json" },
18
+ ...(method === "GET" || body === undefined ? {} : { body: JSON.stringify(body) }),
19
+ ...(signal ? { signal } : {}),
20
+ });
21
+ let res = await send();
22
+ if (res.status === 403) {
23
+ const err = await apiError(res, `${method} ${path}`);
24
+ if (err.code !== "reauth_required")
25
+ throw err;
26
+ await ctx.stepUp();
27
+ if (signal?.aborted)
28
+ throw new CliError("interrupted", "Interrupted.");
29
+ res = await send();
30
+ }
31
+ if (!res.ok)
32
+ throw await apiError(res, `${method} ${path}`);
33
+ try {
34
+ return await res.json();
35
+ }
36
+ catch {
37
+ throw malformed();
38
+ }
39
+ }
40
+ /** Only the person may change an agent: the builder view has no PATCH. Fails before any request. */
41
+ export async function personOnly(ctx, what) {
42
+ if ((await ctx.caller()).actingAs !== "login") {
43
+ ctx.fail("not_allowed", `Only you can ${what}: unset POIETIC_IDENTITY and POIETIC_AGENT_TOKEN.`);
44
+ }
45
+ }
46
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
47
+ const agentOf = (res) => {
48
+ const a = res?.agent;
49
+ if (!isObject(a) || typeof a.id !== "string" || !ID.test(a.id) || typeof a.name !== "string")
50
+ throw malformed();
51
+ return a;
52
+ };
53
+ const asciiFold = (s) => s.replace(/[A-Z]/g, (c) => c.toLowerCase());
54
+ /**
55
+ * A mirror of cleanName in packages/identity/text.ts, in the same order (collapse whitespace,
56
+ * strip controls and deceptive characters, collapse, trim). It is a copy, not an import, because
57
+ * the published CLI cannot depend on that package, so it can drift; keep the two in step.
58
+ */
59
+ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
60
+ const DECEPTIVE_CHARS = /[\u202A-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF]/g;
61
+ const normalizeName = (s) => s.normalize("NFC").replace(/\s+/g, " ").replace(CONTROL_CHARS, "").replace(DECEPTIVE_CHARS, "").replace(/\s+/g, " ").trim();
62
+ const fold = (s) => asciiFold(normalizeName(s));
63
+ export async function listAgents(ctx, signal) {
64
+ const body = await agentsCall(ctx, "GET", "/agents", undefined, signal);
65
+ const agents = body?.agents;
66
+ if (!Array.isArray(agents) || agents.some((a) => a === null || typeof a !== "object" || typeof a.id !== "string" || !ID.test(a.id) || typeof a.name !== "string")) {
67
+ throw malformed();
68
+ }
69
+ return agents;
70
+ }
71
+ /** `ref` is a name (as the server resolves POIETIC_IDENTITY) or `id:<uuid>`. */
72
+ export async function resolveAgent(ctx, ref, opts = {}) {
73
+ if (ref.startsWith("id:")) {
74
+ const id = ref.slice(3);
75
+ if (!ID.test(id))
76
+ throw new CliError("bad_usage", `"${ref}" is not an agent id.`);
77
+ if (!opts.list)
78
+ return { id };
79
+ const agent = (await listAgents(ctx, opts.signal)).find((a) => a.id === id);
80
+ if (!agent)
81
+ throw new CliError("no_such_agent", `No agent you can see has the id ${id}; see \`poietic-tech agents list\`.`);
82
+ return { id, agent };
83
+ }
84
+ const want = fold(ref);
85
+ const agent = (await listAgents(ctx, opts.signal)).find((a) => a.created_by === null && fold(a.name) === want);
86
+ if (!agent) {
87
+ throw new CliError("no_such_agent", `No agent of yours is named "${ref}". Agents a builder made are addressed as id:<uuid>; see \`poietic-tech agents list\`.`);
88
+ }
89
+ return { id: agent.id, agent };
90
+ }
91
+ export function levelOf(a) {
92
+ return a.delegator ? "delegator" : a.builder ? "builder" : "none";
93
+ }
94
+ const day = (t) => (typeof t === "number" ? new Date(t * 1000).toISOString().slice(0, 10) : "-");
95
+ export function agentColumns(all) {
96
+ return [
97
+ ["NAME", (a) => a.name],
98
+ ["LEVEL", levelOf],
99
+ ["ACT-AS", (a) => (a.act_as_allowed === undefined ? "-" : a.act_as_allowed ? "on" : "off")],
100
+ ["CREATED BY", (a) => (a.created_by === null ? "-" : (all.find((x) => x.id === a.created_by)?.name ?? a.created_by))],
101
+ ["LIVE TOKENS", (a) => a.live_tokens ?? "-"],
102
+ ["CREATED", (a) => day(a.created_at)],
103
+ ];
104
+ }
105
+ export const TOKEN_COLUMNS = [
106
+ ["ID", (t) => t.id],
107
+ ["LABEL", (t) => t.label],
108
+ ["ORIGIN", (t) => (t.created_via ? `${t.created_via}${t.created_via_label ? ` (${t.created_via_label})` : ""}` : "-")],
109
+ ["EXPIRES", (t) => (t.expires_at === null || t.expires_at === undefined ? "never" : day(t.expires_at))],
110
+ ["LAST USED", (t) => (t.last_used_at === null || t.last_used_at === undefined ? "never" : day(t.last_used_at))],
111
+ ["STATE", (t) => t.state],
112
+ ];
113
+ async function tokensOf(ctx, id, signal) {
114
+ const tokens = (await agentsCall(ctx, "GET", `/agents/${id}/tokens`, undefined, signal))?.tokens;
115
+ if (!Array.isArray(tokens))
116
+ throw malformed();
117
+ return tokens;
118
+ }
119
+ function agentsModule(host) {
120
+ return defineModule((ctx) => {
121
+ const format = () => host.globals?.format;
122
+ const { signal } = host.io;
123
+ const resolve = (ref, list = false) => resolveAgent(ctx, ref, { list, signal });
124
+ ctx.program
125
+ .command("list")
126
+ .description("List your agents")
127
+ .action(async () => {
128
+ const all = await listAgents(ctx, signal);
129
+ ctx.output.list(all, { columns: agentColumns(all), id: (a) => a.name });
130
+ });
131
+ ctx.program
132
+ .command("show")
133
+ .description("Show an agent and its tokens")
134
+ .argument("<agent>", "agent name or id:<uuid>")
135
+ .action(async (ref) => {
136
+ const { id, agent } = await resolve(ref, true);
137
+ const tokens = await tokensOf(ctx, id, signal);
138
+ const row = agent;
139
+ if (format() === "table") {
140
+ ctx.output.item(row);
141
+ host.output().text("");
142
+ ctx.output.list(tokens, { columns: TOKEN_COLUMNS });
143
+ }
144
+ else if (format() === "ids")
145
+ ctx.output.item(row);
146
+ else
147
+ ctx.output.item({ agent: row, tokens });
148
+ });
149
+ const tokens = ctx.program.command("tokens").description("Manage an agent's tokens");
150
+ tokens
151
+ .command("list")
152
+ .description("List an agent's tokens")
153
+ .argument("<agent>", "agent name or id:<uuid>")
154
+ .action(async (ref) => {
155
+ const { id } = await resolve(ref);
156
+ ctx.output.list(await tokensOf(ctx, id, signal), { columns: TOKEN_COLUMNS });
157
+ });
158
+ const call = (method, path, body) => agentsCall(ctx, method, path, body, signal);
159
+ const printAgent = (res) => {
160
+ const a = agentOf(res);
161
+ ctx.output.item(a, agentColumns([a]));
162
+ };
163
+ const patch = async (ref, what, body) => {
164
+ await personOnly(ctx, what);
165
+ const { id } = await resolve(ref);
166
+ printAgent(await call("PATCH", `/agents/${id}`, body));
167
+ };
168
+ ctx.program
169
+ .command("create")
170
+ .description("Create an agent")
171
+ .argument("<name>", "the new agent's name")
172
+ .action(async (name) => printAgent(await call("POST", "/agents", { name })));
173
+ ctx.program
174
+ .command("rename")
175
+ .description("Rename an agent")
176
+ .argument("<agent>", "agent name or id:<uuid>")
177
+ .argument("<new>", "the new name")
178
+ .action((ref, name) => patch(ref, "rename an agent", { name }));
179
+ ctx.program
180
+ .command("set")
181
+ .description("Set an agent's level")
182
+ .argument("<agent>", "agent name or id:<uuid>")
183
+ .addOption(new Option("--level <level>", "what the agent may do").choices(["none", "builder", "delegator"]).makeOptionMandatory())
184
+ .action((ref, opts) => patch(ref, "change an agent's level", { permissions: opts.level }));
185
+ ctx.program
186
+ .command("act-as")
187
+ .description("Allow or stop acting as an agent")
188
+ .argument("<agent>", "agent name or id:<uuid>")
189
+ .addArgument(new Argument("<state>", "on or off").choices(["on", "off"]))
190
+ .action((ref, state) => patch(ref, "change whether an agent can be acted as", { act_as_allowed: state === "on" }));
191
+ tokens
192
+ .command("create")
193
+ .description("Create a token for an agent; prints the secret once")
194
+ .argument("<agent>", "agent name or id:<uuid>")
195
+ .option("--label <label>", "a label for the token")
196
+ .addOption(new Option("--expires <when>", "when the token expires").choices(["never", "90d"]).default("never"))
197
+ .action(async (ref, opts) => {
198
+ const person = (await ctx.caller()).actingAs === "login";
199
+ const { id } = await resolve(ref);
200
+ const ninety = opts.expires === "90d";
201
+ const body = {
202
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
203
+ ...(ninety ? (person ? { expires: "90d" } : { expires_in: 7_776_000 }) : {}),
204
+ };
205
+ const token = (await call("POST", `/agents/${id}/tokens`, body))?.token;
206
+ if (!isObject(token) || typeof token.secret !== "string" || typeof token.id !== "string")
207
+ throw malformed();
208
+ ctx.output.secret(token.secret, "Copy this token now. It won't be shown again.", { id: token.id });
209
+ });
210
+ tokens
211
+ .command("revoke")
212
+ .description("Revoke one of an agent's tokens")
213
+ .argument("<agent>", "agent name or id:<uuid>")
214
+ .argument("<token-id>", "the token's id, from `agents tokens list`")
215
+ .action(async (ref, tid) => {
216
+ if (!ID.test(tid))
217
+ ctx.fail("bad_usage", `"${tid}" is not a token id.`);
218
+ const { id } = await resolve(ref);
219
+ await call("DELETE", `/agents/${id}/tokens/${tid}`);
220
+ ctx.output.item({ revoked: true });
221
+ });
222
+ ctx.program
223
+ .command("delete")
224
+ .description("Delete an agent")
225
+ .argument("<agent>", "agent name or id:<uuid>")
226
+ .requiredOption("--confirm <name>", "the agent's current name, exactly")
227
+ .action(async (ref, opts) => {
228
+ const { id, agent } = await resolve(ref, true);
229
+ if (opts.confirm !== agent.name)
230
+ ctx.fail("bad_usage", `--confirm must be the agent's current name, "${agent.name}".`);
231
+ const res = await call("DELETE", `/agents/${id}`, { confirm: opts.confirm });
232
+ if (typeof res?.deleted !== "number")
233
+ throw malformed();
234
+ ctx.output.item({ deleted: res.deleted });
235
+ });
236
+ });
237
+ }
238
+ export const agentsBuiltin = ["agents", "Manage your agents", agentsModule];
@@ -0,0 +1,4 @@
1
+ import type { Builtin } from "../context.js";
2
+ /** Only a link the person could safely click is handed to the browser opener. */
3
+ export declare function safeToOpen(link: string): boolean;
4
+ export declare const authBuiltins: Builtin[];
@@ -0,0 +1,129 @@
1
+ import { deviceLogin, logout, readLogin, serverOrigin } from "@poietic-tech/auth/node";
2
+ import { inline } from "../text.js";
3
+ import { defineModule } from "../module.js";
4
+ /** Only a link the person could safely click is handed to the browser opener. */
5
+ export function safeToOpen(link) {
6
+ let url;
7
+ try {
8
+ url = new URL(link);
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ if (url.protocol === "https:")
14
+ return true;
15
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
16
+ }
17
+ const signedIn = (user, server) => `Signed in as ${inline(user.name)}${user.email ? ` (${inline(user.email)})` : ""} at ${server}`;
18
+ function isTable(host) {
19
+ return host.globals?.format === "table";
20
+ }
21
+ /** Results for the table format go out as the text given; every other format gets the object. */
22
+ function result(host, ctx, text, obj, id) {
23
+ if (isTable(host))
24
+ host.output().text(text);
25
+ else if (host.globals?.format === "ids") {
26
+ if (id !== undefined)
27
+ host.io.stdout(`${inline(id)}\n`);
28
+ }
29
+ else
30
+ ctx.output.item(obj);
31
+ }
32
+ function login(host) {
33
+ return defineModule((ctx) => {
34
+ const { io } = host;
35
+ ctx.program
36
+ .option("--label <text>", "name this terminal's login", io.hostname)
37
+ .option("--no-browser", "do not open the browser")
38
+ .action(async (opts) => {
39
+ const server = serverOrigin({ server: host.globals?.server, env: io.env });
40
+ const { user } = await deviceLogin({
41
+ server,
42
+ env: io.env,
43
+ now: io.now,
44
+ sleep: io.sleep,
45
+ signal: io.signal,
46
+ label: opts.label,
47
+ onNotice: (m) => ctx.output.message(m),
48
+ onCode: (p) => {
49
+ ctx.output.message(`Open ${p.verificationUri} and enter ${p.userCode}`);
50
+ if (io.stderrIsTTY && opts.browser && safeToOpen(p.verificationUri))
51
+ io.openBrowser(p.verificationUri);
52
+ },
53
+ });
54
+ const line = signedIn(user, server);
55
+ // Whatever the format, the person is told which account the code approved.
56
+ ctx.output.message(line);
57
+ result(host, ctx, line, { user, server }, user.id);
58
+ });
59
+ });
60
+ }
61
+ function logoutModule(host) {
62
+ return defineModule((ctx) => {
63
+ const { io } = host;
64
+ ctx.program.action(async () => {
65
+ const opts = { server: host.globals?.server, env: io.env };
66
+ const origin = serverOrigin(opts);
67
+ if ((await readLogin(opts)) === null) {
68
+ ctx.output.message("Not signed in.");
69
+ if (!isTable(host))
70
+ result(host, ctx, "", { revokedOnServer: false });
71
+ return;
72
+ }
73
+ const { revokedOnServer } = await logout({
74
+ ...opts,
75
+ now: io.now,
76
+ sleep: io.sleep,
77
+ onNotice: (m) => ctx.output.message(m),
78
+ });
79
+ if (revokedOnServer) {
80
+ result(host, ctx, "Signed out.", { revokedOnServer });
81
+ }
82
+ else {
83
+ ctx.output.message(`Signed out here, but the server could not be reached, so the session is still live. Revoke it at ${origin}/account.`);
84
+ if (!isTable(host))
85
+ result(host, ctx, "", { revokedOnServer });
86
+ }
87
+ });
88
+ });
89
+ }
90
+ function whoami(host) {
91
+ return defineModule((ctx) => {
92
+ const { io } = host;
93
+ ctx.program.option("--offline", "read the saved login only; make no request").action(async (opts) => {
94
+ const base = { server: host.globals?.server, env: io.env };
95
+ if (opts.offline) {
96
+ const login = await readLogin(base);
97
+ if (login === null)
98
+ return ctx.fail("not_logged_in", "Not signed in. Run `poietic-tech login`.");
99
+ const text = signedIn(login.user, login.server) + (login.label !== null ? `\nTerminal: ${inline(login.label)}` : "");
100
+ result(host, ctx, text, { offline: true, user: login.user, server: login.server, label: login.label }, login.user.id);
101
+ return;
102
+ }
103
+ const caller = await ctx.caller();
104
+ const identity = await caller.identity();
105
+ const label = caller.actingAs === "agent-token" ? null : ((await readLogin(base))?.label ?? null);
106
+ const server = ctx.server;
107
+ const obj = {
108
+ identity: { kind: identity.kind, id: identity.id, name: identity.name, owner: identity.owner },
109
+ actingAs: caller.actingAs,
110
+ server,
111
+ label,
112
+ warnings: [...caller.warnings],
113
+ };
114
+ const who = inline(`${identity.kind}, ${identity.id}${identity.owner ? `, owned by ${identity.owner}` : ""}`);
115
+ const lines = [
116
+ ["Acting as", `${inline(identity.name)} (${who})`],
117
+ ["Via", caller.actingAs],
118
+ ["Server", server],
119
+ ...(label !== null ? [["Terminal", inline(label)]] : []),
120
+ ];
121
+ result(host, ctx, lines.map(([k, v]) => `${k}: ${v}`).join("\n"), obj, identity.id);
122
+ });
123
+ });
124
+ }
125
+ export const authBuiltins = [
126
+ ["login", "Sign this terminal in", login],
127
+ ["logout", "Sign this terminal out", logoutModule],
128
+ ["whoami", "Show who commands act as", whoami],
129
+ ];
@@ -0,0 +1,2 @@
1
+ import type { Builtin } from "../context.js";
2
+ export declare const modulesBuiltin: Builtin;
@@ -0,0 +1,75 @@
1
+ import { allow, disallow, readAllowed } from "../allowlist.js";
2
+ import { clean, discover, discoveryRoots, stateText } from "../discovery.js";
3
+ import { defineModule } from "../module.js";
4
+ import { inline } from "../text.js";
5
+ const row = (d) => ({
6
+ package: clean(d.name),
7
+ version: clean(d.version),
8
+ command: clean(d.command ?? ""),
9
+ summary: clean(d.summary),
10
+ source: d.source,
11
+ state: clean(stateText(d.state)),
12
+ });
13
+ const COLUMNS = [
14
+ ["PACKAGE", (r) => r.package],
15
+ ["VERSION", (r) => r.version],
16
+ ["COMMAND", (r) => r.command],
17
+ ["SUMMARY", (r) => r.summary],
18
+ ["SOURCE", (r) => r.source],
19
+ ["STATE", (r) => r.state],
20
+ ];
21
+ function modulesModule(host) {
22
+ return defineModule((ctx) => {
23
+ const { io } = host;
24
+ const format = () => host.globals?.format;
25
+ /** Table prints the sentence; ids prints the package; json and ndjson print the object. */
26
+ const report = (text, obj) => {
27
+ if (format() === "table") {
28
+ if (text !== "")
29
+ host.output().text(text);
30
+ }
31
+ else if (format() === "ids")
32
+ io.stdout(`${inline(obj.package)}\n`);
33
+ else
34
+ ctx.output.item(obj);
35
+ };
36
+ ctx.program
37
+ .command("list")
38
+ .description("List the modules found and whether they run")
39
+ .action(async () => {
40
+ const found = await discover(discoveryRoots(io.hostDir, io.execPath), await readAllowed(io.env), {
41
+ builtins: host.builtins,
42
+ unscoped: true,
43
+ });
44
+ ctx.output.list(found.map(row), { columns: COLUMNS, id: (r) => r.package });
45
+ });
46
+ ctx.program
47
+ .command("allow")
48
+ .description("Allow a package outside @poietic-tech to run")
49
+ .argument("<package>", "npm package name")
50
+ .action(async (name) => {
51
+ const result = await allow(io.env, name);
52
+ if (result === "scoped")
53
+ ctx.output.message("@poietic-tech packages load without being allowed.");
54
+ const text = result === "added"
55
+ ? `Allowed ${name}. Its commands now run with your login.`
56
+ : result === "already"
57
+ ? `${name} is already allowed.`
58
+ : "";
59
+ report(text, { package: name, allowed: true, changed: result === "added" });
60
+ });
61
+ ctx.program
62
+ .command("disallow")
63
+ .description("Stop allowing a package")
64
+ .argument("<package>", "npm package name")
65
+ .action(async (name) => {
66
+ const result = await disallow(io.env, name);
67
+ report(result === "removed" ? `Removed ${name}.` : `${name} was not allowed.`, {
68
+ package: name,
69
+ allowed: false,
70
+ changed: result === "removed",
71
+ });
72
+ });
73
+ });
74
+ }
75
+ export const modulesBuiltin = ["modules", "List and allow modules", modulesModule];
@@ -0,0 +1,36 @@
1
+ import type { CliIO } from "./io.js";
2
+ export type Format = "table" | "json" | "ndjson" | "ids";
3
+ export type Column<T> = [header: string, get: (row: T) => unknown];
4
+ export interface Globals {
5
+ format: Format;
6
+ color: boolean;
7
+ wait: boolean;
8
+ server?: string;
9
+ }
10
+ export declare function resolveGlobals(opts: {
11
+ json?: boolean;
12
+ format?: string;
13
+ quiet?: boolean;
14
+ color: boolean;
15
+ wait: boolean;
16
+ server?: string;
17
+ }, io: CliIO): Globals;
18
+ export declare function colourOn(flag: boolean, format: Format, io: CliIO): boolean;
19
+ /** The format `resolveGlobals` would pick from raw argv; never throws, an invalid value falls through. */
20
+ export declare function prescanFormat(argv: string[], io: CliIO): Format;
21
+ export declare class Output {
22
+ private readonly io;
23
+ private readonly g;
24
+ constructor(io: CliIO, g: Globals);
25
+ item<T>(obj: T, columns?: Column<T>[]): void;
26
+ list<T>(rows: T[], opts: {
27
+ columns: Column<T>[];
28
+ id?: (row: T) => string;
29
+ }): void;
30
+ /** Lines of text for the table format, cleaned like every other terminal text. */
31
+ text(text: string): void;
32
+ message(text: string): void;
33
+ warn(text: string): void;
34
+ secret(value: string, warning: string, extra?: Record<string, unknown>): void;
35
+ private err;
36
+ }
package/dist/output.js ADDED
@@ -0,0 +1,152 @@
1
+ import { CliError, redact } from "./errors.js";
2
+ import { inline, sanitize } from "./text.js";
3
+ const FORMATS = ["table", "json", "ndjson", "ids"];
4
+ const isFormat = (v) => typeof v === "string" && FORMATS.includes(v);
5
+ function fallbackFormat(io, strict) {
6
+ const env = io.env.POIETIC_TECH_FORMAT;
7
+ if (env !== undefined && env !== "") {
8
+ if (isFormat(env))
9
+ return env;
10
+ if (strict)
11
+ throw new CliError("bad_usage", `POIETIC_TECH_FORMAT must be one of ${FORMATS.join(", ")}.`);
12
+ }
13
+ return io.stdoutIsTTY ? "table" : "json";
14
+ }
15
+ export function resolveGlobals(opts, io) {
16
+ const named = [];
17
+ if (opts.json)
18
+ named.push("json");
19
+ if (opts.format !== undefined) {
20
+ if (!isFormat(opts.format))
21
+ throw new CliError("bad_usage", `--format must be one of ${FORMATS.join(", ")}.`);
22
+ named.push(opts.format);
23
+ }
24
+ if (opts.quiet)
25
+ named.push("ids");
26
+ if (new Set(named).size > 1) {
27
+ throw new CliError("bad_usage", "--json, --format and -q name different output formats; use only one.");
28
+ }
29
+ const format = named[0] ?? fallbackFormat(io, true);
30
+ return {
31
+ format,
32
+ color: colourOn(opts.color, format, io),
33
+ wait: opts.wait,
34
+ ...(opts.server !== undefined ? { server: opts.server } : {}),
35
+ };
36
+ }
37
+ export function colourOn(flag, format, io) {
38
+ return flag && io.stderrIsTTY && !io.env.NO_COLOR && format === "table";
39
+ }
40
+ /** The format `resolveGlobals` would pick from raw argv; never throws, an invalid value falls through. */
41
+ export function prescanFormat(argv, io) {
42
+ const named = new Set();
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a === "--")
46
+ break;
47
+ if (a === "--json")
48
+ named.add("json");
49
+ else if (a === "-q" || a === "--quiet")
50
+ named.add("ids");
51
+ else if (a === "--format") {
52
+ const v = argv[i + 1];
53
+ if (isFormat(v))
54
+ named.add(v);
55
+ i++;
56
+ }
57
+ else if (a.startsWith("--format=")) {
58
+ const v = a.slice("--format=".length);
59
+ if (isFormat(v))
60
+ named.add(v);
61
+ }
62
+ }
63
+ if (named.size === 1)
64
+ return [...named][0];
65
+ return fallbackFormat(io, false);
66
+ }
67
+ function cell(v) {
68
+ if (v === null || v === undefined || v === "")
69
+ return "-";
70
+ return inline(typeof v === "object" ? JSON.stringify(v) : String(v));
71
+ }
72
+ const isScalar = (v) => v === null || (typeof v !== "object" && typeof v !== "function" && v !== undefined);
73
+ /** The id a row stands for in the ids format: its id, else its name, else nothing. */
74
+ function idOf(r) {
75
+ const o = r;
76
+ const v = o.id ?? o.name;
77
+ return v === undefined || v === null ? null : String(v);
78
+ }
79
+ /** One ids line: cleaned like every terminal value, so a newline in an id cannot make two. */
80
+ const idLine = (v) => `${inline(String(v))}\n`;
81
+ export class Output {
82
+ io;
83
+ g;
84
+ constructor(io, g) {
85
+ this.io = io;
86
+ this.g = g;
87
+ }
88
+ item(obj, columns) {
89
+ switch (this.g.format) {
90
+ case "json":
91
+ return this.io.stdout(`${JSON.stringify(obj, null, 2)}\n`);
92
+ case "ndjson":
93
+ return this.io.stdout(`${JSON.stringify(obj)}\n`);
94
+ case "ids": {
95
+ const id = idOf(obj);
96
+ return id === null ? undefined : this.io.stdout(idLine(id));
97
+ }
98
+ case "table": {
99
+ const pairs = columns
100
+ ? columns.map(([h, get]) => [h, cell(get(obj))])
101
+ : Object.entries(obj)
102
+ .filter(([, v]) => isScalar(v))
103
+ .map(([k, v]) => [inline(k), cell(v)]);
104
+ this.io.stdout(pairs.map(([k, v]) => `${k}: ${v}\n`).join(""));
105
+ }
106
+ }
107
+ }
108
+ list(rows, opts) {
109
+ switch (this.g.format) {
110
+ case "json":
111
+ return this.io.stdout(`${JSON.stringify({ data: rows }, null, 2)}\n`);
112
+ case "ndjson":
113
+ return this.io.stdout(rows.map((r) => `${JSON.stringify(r)}\n`).join(""));
114
+ case "ids":
115
+ return this.io.stdout(rows.map((r) => idLine((opts.id ? opts.id(r) : idOf(r)) ?? "")).join(""));
116
+ case "table": {
117
+ if (rows.length === 0)
118
+ return this.err("No results.\n");
119
+ const grid = [opts.columns.map(([h]) => h), ...rows.map((r) => opts.columns.map(([, get]) => cell(get(r))))];
120
+ const widths = opts.columns.map((_, c) => Math.max(...grid.map((line) => line[c].length)));
121
+ this.io.stdout(grid
122
+ .map((line) => line.map((v, c) => (c === line.length - 1 ? v : v.padEnd(widths[c]))).join(" ") + "\n")
123
+ .join(""));
124
+ }
125
+ }
126
+ }
127
+ /** Lines of text for the table format, cleaned like every other terminal text. */
128
+ text(text) {
129
+ this.io.stdout(`${sanitize(text)}\n`);
130
+ }
131
+ message(text) {
132
+ this.err(`${sanitize(text)}\n`);
133
+ }
134
+ warn(text) {
135
+ const line = `warning: ${sanitize(text)}`;
136
+ this.err(`${this.g.color ? `\x1b[33m${line}\x1b[0m` : line}\n`);
137
+ }
138
+ secret(value, warning, extra = {}) {
139
+ const { format } = this.g;
140
+ // The secret is spread last, so `extra` can never replace it.
141
+ if (format === "json")
142
+ this.io.stdout(`${JSON.stringify({ ...extra, secret: value }, null, 2)}\n`);
143
+ else if (format === "ndjson")
144
+ this.io.stdout(`${JSON.stringify({ ...extra, secret: value })}\n`);
145
+ else
146
+ this.io.stdout(idLine(value));
147
+ this.message(warning);
148
+ }
149
+ err(text) {
150
+ this.io.stderr(redact(text));
151
+ }
152
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** One line of text safe to print: cut at the first newline, controls and hidden characters removed. */
2
+ export declare function clean(text: string): string;
3
+ /** A value for one line: newlines become spaces, then controls are removed. */
4
+ export declare function inline(text: string): string;
5
+ /** Text of several lines: newlines stay, every other control and hidden character goes. */
6
+ export declare function sanitize(text: string): string;
package/dist/text.js ADDED
@@ -0,0 +1,15 @@
1
+ /** The one place that decides what may reach a terminal: controls, bidi and zero-width characters never do. */
2
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200d\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
3
+ const CONTROL_KEEP_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200b-\u200d\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
4
+ /** One line of text safe to print: cut at the first newline, controls and hidden characters removed. */
5
+ export function clean(text) {
6
+ return text.split("\n", 1)[0].replace(CONTROL, "");
7
+ }
8
+ /** A value for one line: newlines become spaces, then controls are removed. */
9
+ export function inline(text) {
10
+ return text.replace(/\r?\n/g, " ").replace(CONTROL, "");
11
+ }
12
+ /** Text of several lines: newlines stay, every other control and hidden character goes. */
13
+ export function sanitize(text) {
14
+ return text.replace(CONTROL_KEEP_NEWLINE, "");
15
+ }