@uic-coe-connect/cli 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,122 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError } from "../client.js";
3
+ import { emit, info, table } from "../output.js";
4
+ import { register } from "../registry.js";
5
+ import { resolveApp } from "./apps.js";
6
+ /**
7
+ * Env values are secrets. They're masked by default and only printed in full
8
+ * behind an explicit `--show`, so an agent transcript or a shoulder-surfed
9
+ * terminal doesn't leak a database password from a routine `env list`.
10
+ */
11
+ function mask(value) {
12
+ if (value.length <= 4)
13
+ return "••••";
14
+ return `${value.slice(0, 2)}${"•".repeat(Math.min(value.length - 4, 20))}${value.slice(-2)}`;
15
+ }
16
+ /** Parses KEY=VALUE lines (a .env file), ignoring blanks and # comments. */
17
+ function parseDotenv(text) {
18
+ const out = {};
19
+ for (const raw of text.split("\n")) {
20
+ const line = raw.trim();
21
+ if (!line || line.startsWith("#"))
22
+ continue;
23
+ const eq = line.indexOf("=");
24
+ if (eq <= 0)
25
+ continue;
26
+ const key = line.slice(0, eq).trim();
27
+ let value = line.slice(eq + 1).trim();
28
+ if ((value.startsWith('"') && value.endsWith('"')) ||
29
+ (value.startsWith("'") && value.endsWith("'"))) {
30
+ value = value.slice(1, -1);
31
+ }
32
+ out[key] = value;
33
+ }
34
+ return out;
35
+ }
36
+ export function registerEnvCommands() {
37
+ register({
38
+ name: "env list",
39
+ summary: "List an app's environment variables (values masked)",
40
+ usage: "env list <app> [--show]",
41
+ async run({ args, client }) {
42
+ const c = client();
43
+ const app = await resolveApp(c, args.arg(0, "app"));
44
+ const { env } = await c.request("GET", `/registered-apps/${app.id}/env`);
45
+ const show = args.bool("show");
46
+ emit({ appId: app.id, env: show ? env : Object.fromEntries(Object.entries(env).map(([k, v]) => [k, mask(v)])) }, () => {
47
+ table(Object.entries(env).map(([key, value]) => ({ key, value: show ? value : mask(value) })), ["key", "value"]);
48
+ if (!show && Object.keys(env).length > 0)
49
+ info("\n(values masked — pass --show to reveal)");
50
+ });
51
+ },
52
+ }, {
53
+ name: "env get",
54
+ summary: "Print one variable's value",
55
+ usage: "env get <app> <KEY>",
56
+ async run({ args, client }) {
57
+ const c = client();
58
+ const app = await resolveApp(c, args.arg(0, "app"));
59
+ const key = args.arg(1, "KEY");
60
+ const { env } = await c.request("GET", `/registered-apps/${app.id}/env`);
61
+ if (!(key in env)) {
62
+ throw new CliError(`${app.id} has no variable "${key}".`, 4, `Set: ${Object.keys(env).join(", ") || "(none)"}`);
63
+ }
64
+ emit({ appId: app.id, key, value: env[key] }, () => process.stdout.write(`${env[key]}\n`));
65
+ },
66
+ }, {
67
+ name: "env set",
68
+ summary: "Set one or more variables",
69
+ usage: "env set <app> KEY=VALUE [KEY=VALUE...] | --from-file <.env>",
70
+ async run({ args, client }) {
71
+ const c = client();
72
+ const app = await resolveApp(c, args.arg(0, "app"));
73
+ const { env } = await c.request("GET", `/registered-apps/${app.id}/env`);
74
+ const updates = {};
75
+ const file = args.flag("from-file");
76
+ if (file) {
77
+ try {
78
+ Object.assign(updates, parseDotenv(readFileSync(file, "utf8")));
79
+ }
80
+ catch (error) {
81
+ throw new CliError(`Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`, 6);
82
+ }
83
+ }
84
+ for (const pair of args.positional.slice(1)) {
85
+ const eq = pair.indexOf("=");
86
+ if (eq <= 0) {
87
+ throw new CliError(`"${pair}" is not KEY=VALUE.`, 6, "Example: coe env set myapp PORT=4000");
88
+ }
89
+ updates[pair.slice(0, eq).trim()] = pair.slice(eq + 1);
90
+ }
91
+ if (Object.keys(updates).length === 0) {
92
+ throw new CliError("Nothing to set.", 6, "Pass KEY=VALUE pairs or --from-file <.env>");
93
+ }
94
+ // Read-modify-write against the granular env route, so this never
95
+ // clobbers the app's name/url/icon the way a full record PUT would.
96
+ const next = { ...env, ...updates };
97
+ await c.request("PUT", `/registered-apps/${app.id}/env`, { env: next });
98
+ emit({ appId: app.id, set: Object.keys(updates), count: Object.keys(next).length }, () => info(`✓ Set ${Object.keys(updates).join(", ")} on ${app.id}`));
99
+ },
100
+ }, {
101
+ name: "env unset",
102
+ summary: "Remove one or more variables",
103
+ usage: "env unset <app> <KEY> [KEY...]",
104
+ async run({ args, client }) {
105
+ const c = client();
106
+ const app = await resolveApp(c, args.arg(0, "app"));
107
+ const keys = args.positional.slice(1);
108
+ if (keys.length === 0)
109
+ throw new CliError("Name at least one KEY to remove.", 6);
110
+ const { env } = await c.request("GET", `/registered-apps/${app.id}/env`);
111
+ const missing = keys.filter((k) => !(k in env));
112
+ if (missing.length > 0) {
113
+ throw new CliError(`${app.id} has no variable ${missing.map((m) => `"${m}"`).join(", ")}.`, 4);
114
+ }
115
+ const next = { ...env };
116
+ for (const key of keys)
117
+ delete next[key];
118
+ await c.request("PUT", `/registered-apps/${app.id}/env`, { env: next });
119
+ emit({ appId: app.id, removed: keys, count: Object.keys(next).length }, () => info(`✓ Removed ${keys.join(", ")} from ${app.id}`));
120
+ },
121
+ });
122
+ }
@@ -0,0 +1 @@
1
+ export declare function registerPipelineCommands(): void;
@@ -0,0 +1,187 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError } from "../client.js";
3
+ import { emit, info, table } from "../output.js";
4
+ import { register } from "../registry.js";
5
+ import { resolveApp } from "./apps.js";
6
+ /**
7
+ * Steps are written as a compact `type:arg` list so a pipeline can be created
8
+ * in one command instead of hand-authoring JSON:
9
+ *
10
+ * pull scan npm:ci@backend npm:build@backend migrate:backend chown pm2:restart@my-api
11
+ *
12
+ * `--steps-file` takes the raw JSON array instead, for anything this shorthand
13
+ * can't express.
14
+ */
15
+ function parseStep(token) {
16
+ const [head, subdir] = token.split("@");
17
+ const [type, arg] = head.split(":");
18
+ switch (type) {
19
+ case "pull":
20
+ return { type: "pull" };
21
+ case "scan":
22
+ return { type: "scan" };
23
+ case "chown":
24
+ return { type: "chown" };
25
+ case "npm":
26
+ if (!arg)
27
+ throw new CliError(`npm step needs a script: npm:ci or npm:build@subdir`, 6);
28
+ return { type: "npm", script: arg, subdir: subdir || undefined };
29
+ case "migrate":
30
+ return { type: "migrate", subdir: arg || subdir || undefined };
31
+ case "pm2":
32
+ if (!arg)
33
+ throw new CliError(`pm2 step needs an action: pm2:restart@process-name`, 6);
34
+ return { type: "pm2", action: arg, process: subdir || undefined };
35
+ default:
36
+ throw new CliError(`Unknown step "${token}".`, 6, "Valid: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]");
37
+ }
38
+ }
39
+ function describeStep(step) {
40
+ switch (step.type) {
41
+ case "npm":
42
+ return `npm ${step.script}${step.subdir ? ` (${step.subdir})` : ""}`;
43
+ case "migrate":
44
+ return `migrate${step.subdir ? ` (${step.subdir})` : ""}`;
45
+ case "pm2":
46
+ return `pm2 ${step.action}${step.process ? ` ${step.process}` : ""}`;
47
+ default:
48
+ return step.type;
49
+ }
50
+ }
51
+ function stepsFrom(args) {
52
+ const file = args.flag("steps-file");
53
+ if (file) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(readFileSync(file, "utf8"));
57
+ }
58
+ catch (error) {
59
+ throw new CliError(`Could not read steps from ${file}: ${error instanceof Error ? error.message : String(error)}`, 6);
60
+ }
61
+ if (!Array.isArray(parsed))
62
+ throw new CliError(`${file} must contain a JSON array of steps.`, 6);
63
+ return parsed;
64
+ }
65
+ return [];
66
+ }
67
+ /** Saves the whole pipeline set back — the API replaces the array wholesale. */
68
+ async function savePipelines(client, appId, pipelines) {
69
+ return client.request("PUT", `/registered-apps/${appId}/pipelines`, { pipelines });
70
+ }
71
+ function findPipeline(app, ref) {
72
+ const pipelines = app.pipelines ?? [];
73
+ const found = pipelines.find((p) => p.id === ref) ??
74
+ pipelines.find((p) => p.name.toLowerCase() === ref.toLowerCase());
75
+ if (!found) {
76
+ throw new CliError(`No pipeline "${ref}" on ${app.id}.`, 4, pipelines.length > 0
77
+ ? `Available: ${pipelines.map((p) => p.name).join(", ")}`
78
+ : "That app has no pipelines yet — create one with: coe pipelines create");
79
+ }
80
+ return found;
81
+ }
82
+ export function registerPipelineCommands() {
83
+ register({
84
+ name: "pipelines list",
85
+ summary: "List an app's deploy pipelines",
86
+ usage: "pipelines list <app>",
87
+ async run({ args, client }) {
88
+ const app = await resolveApp(client(), args.arg(0, "app"));
89
+ const pipelines = app.pipelines ?? [];
90
+ emit({ appId: app.id, pipelines }, () => {
91
+ table(pipelines.map((p) => ({
92
+ id: p.id,
93
+ name: p.name,
94
+ steps: p.config.steps.map(describeStep).join(" → "),
95
+ })), ["id", "name", "steps"]);
96
+ });
97
+ },
98
+ }, {
99
+ name: "pipelines show",
100
+ summary: "Show one pipeline's steps as JSON",
101
+ usage: "pipelines show <app> <pipeline>",
102
+ async run({ args, client }) {
103
+ const app = await resolveApp(client(), args.arg(0, "app"));
104
+ const pipeline = findPipeline(app, args.arg(1, "pipeline"));
105
+ emit(pipeline, () => {
106
+ info(`${pipeline.name} (${pipeline.id})`);
107
+ pipeline.config.steps.forEach((step, i) => {
108
+ info(` ${i + 1}. ${describeStep(step)}`);
109
+ });
110
+ });
111
+ },
112
+ }, {
113
+ name: "pipelines create",
114
+ summary: "Create a pipeline from a step list",
115
+ usage: 'pipelines create <app> --name "Full deploy" <step>... | --steps-file <f.json>',
116
+ details: [
117
+ "Steps: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]",
118
+ 'Example: coe pipelines create myapp --name "Full" pull scan npm:ci@backend pm2:restart@myapp-api',
119
+ ],
120
+ async run({ args, client }) {
121
+ const c = client();
122
+ const app = await resolveApp(c, args.arg(0, "app"));
123
+ const name = args.flag("name");
124
+ if (!name)
125
+ throw new CliError("--name is required.", 6);
126
+ const steps = [
127
+ ...stepsFrom(args),
128
+ ...args.positional.slice(1).map(parseStep),
129
+ ];
130
+ if (steps.length === 0) {
131
+ throw new CliError("A pipeline needs at least one step.", 6, "Run: coe help");
132
+ }
133
+ const pipelines = [...(app.pipelines ?? [])];
134
+ if (pipelines.some((p) => p.name.toLowerCase() === name.toLowerCase())) {
135
+ throw new CliError(`${app.id} already has a pipeline named "${name}".`, 6);
136
+ }
137
+ // Ids are assigned server-side on save; a placeholder keeps the shape valid.
138
+ pipelines.push({ id: "", name, config: { steps } });
139
+ const updated = await savePipelines(c, app.id, pipelines);
140
+ const created = (updated.pipelines ?? []).find((p) => p.name === name);
141
+ emit({ appId: app.id, pipeline: created }, () => info(`✓ Created pipeline "${name}" on ${app.id} (${steps.length} steps)`));
142
+ },
143
+ }, {
144
+ name: "pipelines set-steps",
145
+ summary: "Replace an existing pipeline's steps",
146
+ usage: "pipelines set-steps <app> <pipeline> <step>... | --steps-file <f.json>",
147
+ async run({ args, client }) {
148
+ const c = client();
149
+ const app = await resolveApp(c, args.arg(0, "app"));
150
+ const pipeline = findPipeline(app, args.arg(1, "pipeline"));
151
+ const steps = [...stepsFrom(args), ...args.positional.slice(2).map(parseStep)];
152
+ if (steps.length === 0)
153
+ throw new CliError("A pipeline needs at least one step.", 6);
154
+ const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, config: { steps } } : p);
155
+ await savePipelines(c, app.id, pipelines);
156
+ emit({ appId: app.id, pipelineId: pipeline.id, steps }, () => info(`✓ Updated "${pipeline.name}" on ${app.id} (${steps.length} steps)`));
157
+ },
158
+ }, {
159
+ name: "pipelines rename",
160
+ summary: "Rename a pipeline",
161
+ usage: "pipelines rename <app> <pipeline> <new-name>",
162
+ async run({ args, client }) {
163
+ const c = client();
164
+ const app = await resolveApp(c, args.arg(0, "app"));
165
+ const pipeline = findPipeline(app, args.arg(1, "pipeline"));
166
+ const newName = args.arg(2, "new-name");
167
+ const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, name: newName } : p);
168
+ await savePipelines(c, app.id, pipelines);
169
+ emit({ appId: app.id, pipelineId: pipeline.id, name: newName }, () => info(`✓ Renamed "${pipeline.name}" → "${newName}"`));
170
+ },
171
+ }, {
172
+ name: "pipelines delete",
173
+ summary: "Delete a pipeline",
174
+ usage: "pipelines delete <app> <pipeline> --yes",
175
+ async run({ args, client }) {
176
+ const c = client();
177
+ const app = await resolveApp(c, args.arg(0, "app"));
178
+ const pipeline = findPipeline(app, args.arg(1, "pipeline"));
179
+ if (!args.bool("yes")) {
180
+ throw new CliError(`This deletes pipeline "${pipeline.name}" from ${app.id}.`, 6, "Re-run with --yes to confirm.");
181
+ }
182
+ const pipelines = (app.pipelines ?? []).filter((p) => p.id !== pipeline.id);
183
+ await savePipelines(c, app.id, pipelines);
184
+ emit({ appId: app.id, deleted: pipeline.id }, () => info(`✓ Deleted pipeline "${pipeline.name}" from ${app.id}`));
185
+ },
186
+ });
187
+ }
@@ -0,0 +1,19 @@
1
+ export interface Credentials {
2
+ server: string;
3
+ netid: string;
4
+ token: string;
5
+ expiresAt: string;
6
+ }
7
+ /**
8
+ * Which server to talk to, most specific wins: --server flag, then COE_SERVER,
9
+ * then a saved config, then the dev deployment.
10
+ */
11
+ export declare function resolveServer(flag?: string): string;
12
+ export declare function saveServer(server: string): void;
13
+ export declare function readCredentials(): Credentials | null;
14
+ export declare function saveCredentials(creds: Credentials): void;
15
+ export declare function clearCredentials(): void;
16
+ export declare function credentialsPath(): string;
17
+ export declare function hasCredentialsFile(): boolean;
18
+ /** A human-recognizable name for this session, shown on the approval page. */
19
+ export declare function sessionLabel(): string;
package/dist/config.js ADDED
@@ -0,0 +1,67 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Config and credentials live under ~/.coeconnect. Credentials are a separate
6
+ * file from config so the token can be 0600 and deleted on logout without
7
+ * disturbing the (non-secret) server URL.
8
+ */
9
+ const DIR = process.env.COE_CONFIG_DIR ?? path.join(homedir(), ".coeconnect");
10
+ const CONFIG_FILE = path.join(DIR, "config.json");
11
+ const CREDENTIALS_FILE = path.join(DIR, "credentials.json");
12
+ const DEFAULT_SERVER = "https://devcoeconnect.engineering.uic.edu";
13
+ function readJson(file) {
14
+ try {
15
+ return JSON.parse(readFileSync(file, "utf8"));
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function writeJson(file, value, mode) {
22
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
23
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode });
24
+ chmodSync(file, mode); // enforce even if the file pre-existed with looser bits
25
+ }
26
+ /**
27
+ * Which server to talk to, most specific wins: --server flag, then COE_SERVER,
28
+ * then a saved config, then the dev deployment.
29
+ */
30
+ export function resolveServer(flag) {
31
+ const chosen = flag ?? process.env.COE_SERVER ?? readJson(CONFIG_FILE)?.server ?? DEFAULT_SERVER;
32
+ return chosen.replace(/\/+$/, "");
33
+ }
34
+ export function saveServer(server) {
35
+ writeJson(CONFIG_FILE, { ...readJson(CONFIG_FILE), server }, 0o600);
36
+ }
37
+ export function readCredentials() {
38
+ const creds = readJson(CREDENTIALS_FILE);
39
+ if (!creds?.token)
40
+ return null;
41
+ // An expired token is the same as none — surface "run coe login" rather than
42
+ // letting the server return a confusing 401 on the real command.
43
+ if (Date.parse(creds.expiresAt) <= Date.now())
44
+ return null;
45
+ return creds;
46
+ }
47
+ export function saveCredentials(creds) {
48
+ writeJson(CREDENTIALS_FILE, creds, 0o600);
49
+ }
50
+ export function clearCredentials() {
51
+ try {
52
+ rmSync(CREDENTIALS_FILE);
53
+ }
54
+ catch {
55
+ /* already gone */
56
+ }
57
+ }
58
+ export function credentialsPath() {
59
+ return CREDENTIALS_FILE;
60
+ }
61
+ export function hasCredentialsFile() {
62
+ return existsSync(CREDENTIALS_FILE);
63
+ }
64
+ /** A human-recognizable name for this session, shown on the approval page. */
65
+ export function sessionLabel() {
66
+ return `${process.env.USER ?? "user"}@${hostname()}`;
67
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { CliError, createClient } from "./client.js";
3
+ import { registerAccessCommands } from "./commands/access.js";
4
+ import { registerAppCommands } from "./commands/apps.js";
5
+ import { registerAuthCommands } from "./commands/auth.js";
6
+ import { registerDeployCommands } from "./commands/deploy.js";
7
+ import { registerEnvCommands } from "./commands/env.js";
8
+ import { registerPipelineCommands } from "./commands/pipelines.js";
9
+ import { info, setJsonMode } from "./output.js";
10
+ import { allCommands, findCommand, register } from "./registry.js";
11
+ registerAuthCommands();
12
+ registerAppCommands();
13
+ registerPipelineCommands();
14
+ registerDeployCommands();
15
+ registerAccessCommands();
16
+ registerEnvCommands();
17
+ function parseArgs(argv) {
18
+ const positional = [];
19
+ const flags = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const token = argv[i];
22
+ if (!token.startsWith("--")) {
23
+ positional.push(token);
24
+ continue;
25
+ }
26
+ const body = token.slice(2);
27
+ const eq = body.indexOf("=");
28
+ if (eq >= 0) {
29
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
30
+ continue;
31
+ }
32
+ // `--flag value` unless the next token is itself a flag (then it's boolean).
33
+ const next = argv[i + 1];
34
+ if (next !== undefined && !next.startsWith("--")) {
35
+ flags[body] = next;
36
+ i++;
37
+ }
38
+ else {
39
+ flags[body] = true;
40
+ }
41
+ }
42
+ return {
43
+ positional,
44
+ flags,
45
+ arg(index, name) {
46
+ const value = positional[index];
47
+ if (!value)
48
+ throw new CliError(`Missing <${name}>.`, 6, "Run: coe help");
49
+ return value;
50
+ },
51
+ flag(name) {
52
+ const value = flags[name];
53
+ return typeof value === "string" ? value : undefined;
54
+ },
55
+ bool(name) {
56
+ return flags[name] !== undefined;
57
+ },
58
+ };
59
+ }
60
+ function printHelp() {
61
+ const lines = [
62
+ "coe — manage COEConnect apps from a terminal",
63
+ "",
64
+ "USAGE",
65
+ " coe <command> [args] [--flags]",
66
+ "",
67
+ "COMMANDS",
68
+ ];
69
+ const width = Math.max(...allCommands().map((c) => c.usage.length));
70
+ for (const command of allCommands()) {
71
+ lines.push(` ${command.usage.padEnd(width)} ${command.summary}`);
72
+ }
73
+ lines.push("", "GLOBAL FLAGS", " --json Machine-readable output on stdout (for scripts and AI agents)", " --server <url> Target a different COEConnect server", " --help Show this help", "", "AUTH", " Run `coe login` and approve the code in your browser. The session lasts 12", " hours and can only do what you can do in the web UI — you must be on an", " app's dev team (or a webadmin) to manage it.");
74
+ process.stdout.write(`${lines.join("\n")}\n`);
75
+ }
76
+ register({
77
+ name: "help",
78
+ summary: "Show this help",
79
+ usage: "help",
80
+ anonymous: true,
81
+ async run() {
82
+ printHelp();
83
+ },
84
+ });
85
+ async function main() {
86
+ const argv = process.argv.slice(2);
87
+ const args = parseArgs(argv);
88
+ setJsonMode(args.bool("json"));
89
+ if (argv.length === 0 || args.bool("help")) {
90
+ printHelp();
91
+ return;
92
+ }
93
+ const found = findCommand(args.positional);
94
+ if (!found) {
95
+ throw new CliError(`Unknown command: ${args.positional.join(" ")}`, 6, "Run: coe help");
96
+ }
97
+ // Positionals are re-based so a command sees only its own arguments.
98
+ const scoped = { ...args, positional: found.rest, arg: args.arg, flag: args.flag, bool: args.bool };
99
+ scoped.arg = (index, name) => {
100
+ const value = found.rest[index];
101
+ if (!value)
102
+ throw new CliError(`Missing <${name}>.`, 6, `Usage: coe ${found.command.usage}`);
103
+ return value;
104
+ };
105
+ await found.command.run({
106
+ args: scoped,
107
+ client: () => createClient({ server: args.flag("server"), anonymous: found.command.anonymous }),
108
+ });
109
+ }
110
+ main().catch((error) => {
111
+ if (error instanceof CliError) {
112
+ info(`error: ${error.message}`);
113
+ if (error.hint)
114
+ info(` ${error.hint}`);
115
+ process.exit(error.exitCode);
116
+ }
117
+ info(`error: ${error instanceof Error ? error.message : String(error)}`);
118
+ process.exit(1);
119
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Output helpers.
3
+ *
4
+ * Every command supports `--json`, and that isn't a nicety: an AI driving this
5
+ * CLI should parse a documented shape instead of scraping aligned columns. In
6
+ * JSON mode nothing but the payload may touch stdout — progress and warnings
7
+ * go to stderr — so the whole of stdout is always valid JSON.
8
+ */
9
+ export declare function setJsonMode(on: boolean): void;
10
+ export declare function isJsonMode(): boolean;
11
+ /** The command's result. In JSON mode this is the only thing on stdout. */
12
+ export declare function emit(data: unknown, human: () => void): void;
13
+ /** Progress/status chatter. Always stderr, so it never pollutes piped JSON. */
14
+ export declare function info(message: string): void;
15
+ export declare function warn(message: string): void;
16
+ /** Minimal column alignment — no dependency, and stable enough to eyeball. */
17
+ export declare function table(rows: Array<Record<string, string>>, columns: string[]): void;
18
+ /** Key/value block for `show`-style commands. */
19
+ export declare function details(pairs: Array<[string, string]>): void;
package/dist/output.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Output helpers.
3
+ *
4
+ * Every command supports `--json`, and that isn't a nicety: an AI driving this
5
+ * CLI should parse a documented shape instead of scraping aligned columns. In
6
+ * JSON mode nothing but the payload may touch stdout — progress and warnings
7
+ * go to stderr — so the whole of stdout is always valid JSON.
8
+ */
9
+ let jsonMode = false;
10
+ export function setJsonMode(on) {
11
+ jsonMode = on;
12
+ }
13
+ export function isJsonMode() {
14
+ return jsonMode;
15
+ }
16
+ /** The command's result. In JSON mode this is the only thing on stdout. */
17
+ export function emit(data, human) {
18
+ if (jsonMode) {
19
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
20
+ return;
21
+ }
22
+ human();
23
+ }
24
+ /** Progress/status chatter. Always stderr, so it never pollutes piped JSON. */
25
+ export function info(message) {
26
+ process.stderr.write(`${message}\n`);
27
+ }
28
+ export function warn(message) {
29
+ process.stderr.write(`warning: ${message}\n`);
30
+ }
31
+ /** Minimal column alignment — no dependency, and stable enough to eyeball. */
32
+ export function table(rows, columns) {
33
+ if (rows.length === 0) {
34
+ info("(none)");
35
+ return;
36
+ }
37
+ const width = (col) => Math.max(col.length, ...rows.map((r) => (r[col] ?? "").length));
38
+ const widths = columns.map(width);
39
+ const line = (cells) => cells.map((c, i) => c.padEnd(i === cells.length - 1 ? 0 : widths[i])).join(" ").trimEnd();
40
+ process.stdout.write(`${line(columns.map((c) => c.toUpperCase()))}\n`);
41
+ for (const row of rows) {
42
+ process.stdout.write(`${line(columns.map((c) => row[c] ?? ""))}\n`);
43
+ }
44
+ }
45
+ /** Key/value block for `show`-style commands. */
46
+ export function details(pairs) {
47
+ const width = Math.max(...pairs.map(([k]) => k.length));
48
+ for (const [k, v] of pairs) {
49
+ process.stdout.write(`${k.padEnd(width)} ${v}\n`);
50
+ }
51
+ }
@@ -0,0 +1,43 @@
1
+ import type { Client } from "./client.js";
2
+ /**
3
+ * Parsed argv: positional words plus `--flag` / `--flag=value` options.
4
+ * Deliberately tiny — the alternative is a dependency, and the surface here is
5
+ * a fixed set of long flags with no clustering or coercion to get wrong.
6
+ */
7
+ export interface Args {
8
+ positional: string[];
9
+ flags: Record<string, string | true>;
10
+ /** A required positional, by index, with a message naming what's missing. */
11
+ arg(index: number, name: string): string;
12
+ /** A flag's value, or undefined. `--flag` with no value reads as true. */
13
+ flag(name: string): string | undefined;
14
+ bool(name: string): boolean;
15
+ }
16
+ export interface CommandContext {
17
+ args: Args;
18
+ /** Built lazily so `login` and `help` can run without credentials. */
19
+ client: () => Client;
20
+ }
21
+ export interface Command {
22
+ /** Invocation name, e.g. "apps list" — may be multi-word. */
23
+ name: string;
24
+ summary: string;
25
+ /** Usage line shown in help, minus the leading `coe`. */
26
+ usage: string;
27
+ /** Longer help, one line per entry. */
28
+ details?: string[];
29
+ /** True for commands runnable without a session (login, help, version). */
30
+ anonymous?: boolean;
31
+ run(ctx: CommandContext): Promise<void>;
32
+ }
33
+ export declare function register(...toAdd: Command[]): void;
34
+ export declare function allCommands(): readonly Command[];
35
+ /**
36
+ * Resolve argv against the registry, longest name first so "apps list" wins
37
+ * over a hypothetical "apps". Returns the command and the positionals left
38
+ * after its name is consumed.
39
+ */
40
+ export declare function findCommand(words: string[]): {
41
+ command: Command;
42
+ rest: string[];
43
+ } | null;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The registry is the extension point: adding a capability means appending a
3
+ * Command here, and it automatically gains help text, --json, arg parsing, auth
4
+ * and error handling. Nothing else in the CLI needs to change.
5
+ */
6
+ const commands = [];
7
+ export function register(...toAdd) {
8
+ commands.push(...toAdd);
9
+ }
10
+ export function allCommands() {
11
+ return commands;
12
+ }
13
+ /**
14
+ * Resolve argv against the registry, longest name first so "apps list" wins
15
+ * over a hypothetical "apps". Returns the command and the positionals left
16
+ * after its name is consumed.
17
+ */
18
+ export function findCommand(words) {
19
+ const byLength = [...commands].sort((a, b) => b.name.split(" ").length - a.name.split(" ").length);
20
+ for (const command of byLength) {
21
+ const parts = command.name.split(" ");
22
+ if (parts.every((part, i) => words[i] === part)) {
23
+ return { command, rest: words.slice(parts.length) };
24
+ }
25
+ }
26
+ return null;
27
+ }