@uic-coe-connect/cli 0.1.0 → 0.2.1

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/dist/client.d.ts CHANGED
@@ -20,5 +20,9 @@ export interface Client {
20
20
  request<T>(method: string, path: string, body?: unknown): Promise<T>;
21
21
  /** Streams a text/plain response line by line — used by `deploy`. */
22
22
  stream(method: string, path: string, body: unknown, onLine: (line: string) => void): Promise<void>;
23
+ /** POSTs raw bytes with the filename in a header — the resource upload shape. */
24
+ upload<T>(path: string, filename: string, bytes: Uint8Array): Promise<T>;
25
+ /** GETs raw bytes — the resource download shape. */
26
+ download(path: string): Promise<Uint8Array>;
23
27
  }
24
28
  export declare function createClient(options?: ClientOptions): Client;
package/dist/client.js CHANGED
@@ -60,6 +60,29 @@ export function createClient(options = {}) {
60
60
  return undefined;
61
61
  return (await res.json());
62
62
  },
63
+ async upload(path, filename, bytes) {
64
+ // Raw body, not multipart — matches the server, which parses these with
65
+ // express.raw. The name travels url-encoded because header values can't
66
+ // safely carry arbitrary UTF-8.
67
+ const res = await fetch(`${server}${path}`, {
68
+ method: "POST",
69
+ headers: {
70
+ ...headers,
71
+ "Content-Type": "application/octet-stream",
72
+ "X-File-Name": encodeURIComponent(filename),
73
+ },
74
+ body: bytes,
75
+ });
76
+ if (!res.ok)
77
+ await fail(res, server);
78
+ return (await res.json());
79
+ },
80
+ async download(path) {
81
+ const res = await fetch(`${server}${path}`, { headers });
82
+ if (!res.ok)
83
+ await fail(res, server);
84
+ return new Uint8Array(await res.arrayBuffer());
85
+ },
63
86
  async stream(method, path, body, onLine) {
64
87
  const res = await fetch(`${server}${path}`, { method, headers, ...bodyInit(body) });
65
88
  if (!res.ok)
@@ -54,7 +54,7 @@ export function registerAppCommands() {
54
54
  ["Branch", app.branch ?? "—"],
55
55
  ["Pipelines", (app.pipelines ?? []).map((p) => p.name).join(", ") || "(none)"],
56
56
  ["Dev team", (app.access?.devTeam ?? []).join(", ") || "(none)"],
57
- ["Env vars", Object.keys(app.env ?? {}).join(", ") || "(none)"],
57
+ ["Env vars", (app.envKeys ?? []).join(", ") || "(none)"],
58
58
  ["Updated", new Date(app.updatedAt).toLocaleString()],
59
59
  ]);
60
60
  });
@@ -0,0 +1 @@
1
+ export declare function registerResourceCommands(): void;
@@ -0,0 +1,145 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { CliError } from "../client.js";
4
+ import { details, emit, info, table } from "../output.js";
5
+ import { register } from "../registry.js";
6
+ import { resolveApp } from "./apps.js";
7
+ function humanSize(bytes) {
8
+ if (bytes < 1024)
9
+ return `${bytes} B`;
10
+ if (bytes < 1024 * 1024)
11
+ return `${(bytes / 1024).toFixed(0)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ }
14
+ /**
15
+ * Find one resource by name or id. Names are what people actually know, and
16
+ * they're unique per app because a re-upload replaces rather than duplicates.
17
+ */
18
+ function find(resources, ref) {
19
+ const found = resources.find((r) => r.id === ref) ??
20
+ resources.find((r) => r.name.toLowerCase() === ref.toLowerCase());
21
+ if (!found) {
22
+ throw new CliError(`No resource "${ref}".`, 4, resources.length > 0
23
+ ? `Available: ${resources.map((r) => r.name).join(", ")}`
24
+ : "That app has no resources yet — add one with: coe files upload");
25
+ }
26
+ return found;
27
+ }
28
+ export function registerResourceCommands() {
29
+ register({
30
+ name: "files list",
31
+ summary: "List an app's resources (documents and links)",
32
+ usage: "files list <app>",
33
+ async run({ args, client }) {
34
+ const c = client();
35
+ const app = await resolveApp(c, args.arg(0, "app"));
36
+ const { files } = await c.request("GET", `/registered-apps/${app.id}/files`);
37
+ emit({ appId: app.id, files }, () => {
38
+ table(files.map((f) => ({
39
+ kind: f.kind,
40
+ name: f.name,
41
+ size: f.kind === "link" ? (f.url ?? "") : humanSize(f.size),
42
+ by: f.uploadedBy,
43
+ description: f.description ?? "",
44
+ })), ["kind", "name", "size", "by", "description"]);
45
+ });
46
+ },
47
+ }, {
48
+ name: "files upload",
49
+ summary: "Upload a document to an app",
50
+ usage: "files upload <app> <path> [--description <text>]",
51
+ details: [
52
+ "Markdown, PDF, Office docs, images, CSV/JSON — up to 25 MB.",
53
+ "Re-uploading the same filename replaces the previous version.",
54
+ ],
55
+ async run({ args, client }) {
56
+ const c = client();
57
+ const app = await resolveApp(c, args.arg(0, "app"));
58
+ const filePath = args.arg(1, "path");
59
+ let bytes;
60
+ try {
61
+ bytes = readFileSync(filePath);
62
+ }
63
+ catch (error) {
64
+ throw new CliError(`Could not read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 6);
65
+ }
66
+ const file = await c.upload(`/registered-apps/${app.id}/files`, path.basename(filePath), bytes);
67
+ // The description is a separate PATCH — the upload carries bytes, not
68
+ // metadata, so keeping them apart avoids a second header to encode.
69
+ const description = args.flag("description");
70
+ if (description) {
71
+ await c.request("PATCH", `/registered-apps/${app.id}/files/${file.id}`, { description });
72
+ }
73
+ emit({ appId: app.id, file }, () => info(`✓ Uploaded ${file.name} (${humanSize(file.size)}) to ${app.id}`));
74
+ },
75
+ }, {
76
+ name: "files download",
77
+ summary: "Download a resource to a local file",
78
+ usage: "files download <app> <name> [--out <path>]",
79
+ async run({ args, client }) {
80
+ const c = client();
81
+ const app = await resolveApp(c, args.arg(0, "app"));
82
+ const { files } = await c.request("GET", `/registered-apps/${app.id}/files`);
83
+ const file = find(files, args.arg(1, "name"));
84
+ if (file.kind === "link") {
85
+ throw new CliError(`"${file.name}" is a link, not a stored file.`, 6, `It points at ${file.url}`);
86
+ }
87
+ const bytes = await c.download(`/registered-apps/${app.id}/files/${file.id}`);
88
+ const out = args.flag("out") ?? file.name;
89
+ writeFileSync(out, bytes);
90
+ emit({ appId: app.id, file: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
91
+ },
92
+ }, {
93
+ name: "files delete",
94
+ summary: "Delete a resource",
95
+ usage: "files delete <app> <name> --yes",
96
+ async run({ args, client }) {
97
+ const c = client();
98
+ const app = await resolveApp(c, args.arg(0, "app"));
99
+ const { files } = await c.request("GET", `/registered-apps/${app.id}/files`);
100
+ const file = find(files, args.arg(1, "name"));
101
+ if (!args.bool("yes")) {
102
+ throw new CliError(`This deletes "${file.name}" from ${app.id}.`, 6, "Re-run with --yes to confirm.");
103
+ }
104
+ await c.request("DELETE", `/registered-apps/${app.id}/files/${file.id}`);
105
+ emit({ appId: app.id, deleted: file.name }, () => info(`✓ Deleted ${file.name} from ${app.id}`));
106
+ },
107
+ }, {
108
+ name: "files link",
109
+ summary: "Add a link to material that lives elsewhere",
110
+ usage: 'files link <app> <url> [--name "Title"]',
111
+ details: ["Only http(s) URLs — a Google Doc, a wiki page, a dashboard."],
112
+ async run({ args, client }) {
113
+ const c = client();
114
+ const app = await resolveApp(c, args.arg(0, "app"));
115
+ const url = args.arg(1, "url");
116
+ const link = await c.request("POST", `/registered-apps/${app.id}/links`, {
117
+ url,
118
+ name: args.flag("name"),
119
+ description: args.flag("description"),
120
+ });
121
+ emit({ appId: app.id, link }, () => info(`✓ Linked "${link.name}" → ${link.url}`));
122
+ },
123
+ }, {
124
+ name: "files show",
125
+ summary: "Show one resource's details",
126
+ usage: "files show <app> <name>",
127
+ async run({ args, client }) {
128
+ const c = client();
129
+ const app = await resolveApp(c, args.arg(0, "app"));
130
+ const { files } = await c.request("GET", `/registered-apps/${app.id}/files`);
131
+ const file = find(files, args.arg(1, "name"));
132
+ emit(file, () => {
133
+ details([
134
+ ["Name", file.name],
135
+ ["Kind", file.kind],
136
+ [file.kind === "link" ? "URL" : "Size", file.url ?? humanSize(file.size)],
137
+ ["Type", file.contentType],
138
+ ["Description", file.description ?? "—"],
139
+ ["Uploaded by", file.uploadedBy],
140
+ ["Uploaded at", new Date(file.uploadedAt).toLocaleString()],
141
+ ]);
142
+ });
143
+ },
144
+ });
145
+ }
@@ -0,0 +1 @@
1
+ export declare function registerRoleCommands(): void;
@@ -0,0 +1,206 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError } from "../client.js";
3
+ import { details, emit, info, table } from "../output.js";
4
+ import { register } from "../registry.js";
5
+ import { resolveApp } from "./apps.js";
6
+ const OPERATORS = ["equals", "contains", "startsWith", "regex"];
7
+ /** Comma- or space-separated NetIDs, deduped. */
8
+ function netids(raw) {
9
+ return [...new Set(raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean))];
10
+ }
11
+ function findRole(app, key) {
12
+ const roles = app.roles ?? [];
13
+ const found = roles.find((r) => r.key === key) ?? roles.find((r) => r.key.toLowerCase() === key.toLowerCase());
14
+ if (!found) {
15
+ throw new CliError(`No role "${key}" on ${app.id}.`, 4, roles.length > 0
16
+ ? `Defined: ${roles.map((r) => r.key).join(", ")}`
17
+ : "That app has no roles yet — create one with: coe roles create");
18
+ }
19
+ return found;
20
+ }
21
+ /** Saves the whole set back — the API replaces the array wholesale. */
22
+ async function saveRoles(client, appId, roles) {
23
+ return client.request("PUT", `/registered-apps/${appId}/roles`, { roles });
24
+ }
25
+ function describeRule(rule) {
26
+ return rule.all.map((c) => `${c.attribute} ${c.operator} "${c.value}"`).join(" AND ");
27
+ }
28
+ export function registerRoleCommands() {
29
+ register({
30
+ name: "roles list",
31
+ summary: "List an app's custom roles",
32
+ usage: "roles list <app>",
33
+ async run({ args, client }) {
34
+ const app = await resolveApp(client(), args.arg(0, "app"));
35
+ const roles = app.roles ?? [];
36
+ emit({ appId: app.id, roles }, () => {
37
+ table(roles.map((r) => ({
38
+ key: r.key,
39
+ label: r.label,
40
+ members: String(r.members.length),
41
+ rules: String(r.rules.length),
42
+ })), ["key", "label", "members", "rules"]);
43
+ });
44
+ },
45
+ }, {
46
+ name: "roles show",
47
+ summary: "Show one role's members and rules",
48
+ usage: "roles show <app> <key>",
49
+ async run({ args, client }) {
50
+ const app = await resolveApp(client(), args.arg(0, "app"));
51
+ const role = findRole(app, args.arg(1, "key"));
52
+ emit(role, () => {
53
+ details([
54
+ ["Key", role.key],
55
+ ["Label", role.label],
56
+ ["Description", role.description ?? "—"],
57
+ ["Members", role.members.join(", ") || "(none)"],
58
+ ]);
59
+ if (role.rules.length === 0) {
60
+ info("Rules (none)");
61
+ return;
62
+ }
63
+ info("Rules grant if ANY matches:");
64
+ role.rules.forEach((rule, i) => info(` [${i}] ${describeRule(rule)}`));
65
+ });
66
+ },
67
+ }, {
68
+ name: "roles create",
69
+ summary: "Create a role",
70
+ usage: 'roles create <app> <key> --label "Grant Reviewer" [--description <text>]',
71
+ details: [
72
+ "<key> is what the app sees in user.roles — keep it short and lowercase.",
73
+ "Members and rules are added afterwards with add-member / add-rule.",
74
+ ],
75
+ async run({ args, client }) {
76
+ const c = client();
77
+ const app = await resolveApp(c, args.arg(0, "app"));
78
+ const key = args.arg(1, "key").trim();
79
+ const label = args.flag("label");
80
+ if (!label)
81
+ throw new CliError("--label is required.", 6);
82
+ const roles = [...(app.roles ?? [])];
83
+ if (roles.some((r) => r.key.toLowerCase() === key.toLowerCase())) {
84
+ throw new CliError(`${app.id} already has a role "${key}".`, 6);
85
+ }
86
+ const role = {
87
+ key,
88
+ label,
89
+ description: args.flag("description"),
90
+ members: [],
91
+ rules: [],
92
+ };
93
+ await saveRoles(c, app.id, [...roles, role]);
94
+ emit({ appId: app.id, role }, () => info(`✓ Created role "${key}" on ${app.id} — grants nothing until you add members or a rule`));
95
+ },
96
+ }, {
97
+ name: "roles delete",
98
+ summary: "Delete a role",
99
+ usage: "roles delete <app> <key> --yes",
100
+ async run({ args, client }) {
101
+ const c = client();
102
+ const app = await resolveApp(c, args.arg(0, "app"));
103
+ const role = findRole(app, args.arg(1, "key"));
104
+ if (!args.bool("yes")) {
105
+ throw new CliError(`This deletes role "${role.key}" from ${app.id}; anyone holding it loses it on their next login.`, 6, "Re-run with --yes to confirm.");
106
+ }
107
+ await saveRoles(c, app.id, (app.roles ?? []).filter((r) => r.key !== role.key));
108
+ emit({ appId: app.id, deleted: role.key }, () => info(`✓ Deleted role "${role.key}" from ${app.id}`));
109
+ },
110
+ }, {
111
+ name: "roles add-member",
112
+ summary: "Grant a role to NetIDs directly",
113
+ usage: "roles add-member <app> <key> <netid>[,<netid>...]",
114
+ async run({ args, client }) {
115
+ const c = client();
116
+ const app = await resolveApp(c, args.arg(0, "app"));
117
+ const role = findRole(app, args.arg(1, "key"));
118
+ const toAdd = netids(args.arg(2, "netid"));
119
+ const members = [...new Set([...role.members, ...toAdd])];
120
+ await saveRoles(c, app.id, (app.roles ?? []).map((r) => (r.key === role.key ? { ...r, members } : r)));
121
+ emit({ appId: app.id, role: role.key, members, added: toAdd }, () => info(`✓ "${role.key}" members: ${members.join(", ")}`));
122
+ },
123
+ }, {
124
+ name: "roles remove-member",
125
+ summary: "Revoke a directly-granted role",
126
+ usage: "roles remove-member <app> <key> <netid>[,<netid>...]",
127
+ async run({ args, client }) {
128
+ const c = client();
129
+ const app = await resolveApp(c, args.arg(0, "app"));
130
+ const role = findRole(app, args.arg(1, "key"));
131
+ const toRemove = new Set(netids(args.arg(2, "netid")));
132
+ const members = role.members.filter((m) => !toRemove.has(m));
133
+ await saveRoles(c, app.id, (app.roles ?? []).map((r) => (r.key === role.key ? { ...r, members } : r)));
134
+ emit({ appId: app.id, role: role.key, members, removed: [...toRemove] }, () => {
135
+ info(`✓ "${role.key}" members: ${members.join(", ") || "(none)"}`);
136
+ if (role.rules.length > 0) {
137
+ info(" note: attribute rules can still grant this role — see: coe roles show");
138
+ }
139
+ });
140
+ },
141
+ }, {
142
+ name: "roles add-rule",
143
+ summary: "Auto-grant a role to anyone whose login attributes match",
144
+ usage: 'roles add-rule <app> <key> <attribute> <operator> <value> | --rules-file <f.json>',
145
+ details: [
146
+ "Operators: equals, contains, startsWith, regex.",
147
+ 'Example: coe roles add-rule myapp admin isMemberOf contains "gg-engr-admins"',
148
+ "A rule added this way has one condition. For multi-condition rules (ANDed),",
149
+ "pass --rules-file with a JSON array of { all: [{attribute,operator,value}] }.",
150
+ ],
151
+ async run({ args, client }) {
152
+ const c = client();
153
+ const app = await resolveApp(c, args.arg(0, "app"));
154
+ const role = findRole(app, args.arg(1, "key"));
155
+ let added;
156
+ const file = args.flag("rules-file");
157
+ if (file) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(readFileSync(file, "utf8"));
161
+ }
162
+ catch (error) {
163
+ throw new CliError(`Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`, 6);
164
+ }
165
+ if (!Array.isArray(parsed)) {
166
+ throw new CliError(`${file} must contain a JSON array of rules.`, 6);
167
+ }
168
+ added = parsed;
169
+ }
170
+ else {
171
+ const attribute = args.arg(2, "attribute");
172
+ const operator = args.arg(3, "operator");
173
+ const value = args.arg(4, "value");
174
+ if (!OPERATORS.includes(operator)) {
175
+ throw new CliError(`"${operator}" isn't a valid operator.`, 6, `Use one of: ${OPERATORS.join(", ")}`);
176
+ }
177
+ const condition = { attribute, operator, value };
178
+ added = [{ all: [condition] }];
179
+ }
180
+ const rules = [...role.rules, ...added];
181
+ await saveRoles(c, app.id, (app.roles ?? []).map((r) => (r.key === role.key ? { ...r, rules } : r)));
182
+ emit({ appId: app.id, role: role.key, rules }, () => {
183
+ info(`✓ "${role.key}" now has ${rules.length} rule(s):`);
184
+ rules.forEach((rule, i) => info(` [${i}] ${describeRule(rule)}`));
185
+ });
186
+ },
187
+ }, {
188
+ name: "roles remove-rule",
189
+ summary: "Remove a rule by its index (see roles show)",
190
+ usage: "roles remove-rule <app> <key> <index>",
191
+ async run({ args, client }) {
192
+ const c = client();
193
+ const app = await resolveApp(c, args.arg(0, "app"));
194
+ const role = findRole(app, args.arg(1, "key"));
195
+ const index = Number(args.arg(2, "index"));
196
+ if (!Number.isInteger(index) || index < 0 || index >= role.rules.length) {
197
+ throw new CliError(`"${role.key}" has no rule at index ${args.arg(2, "index")}.`, 6, role.rules.length > 0
198
+ ? `Valid indexes: 0–${role.rules.length - 1}. See: coe roles show ${app.id} ${role.key}`
199
+ : "That role has no rules.");
200
+ }
201
+ const rules = role.rules.filter((_, i) => i !== index);
202
+ await saveRoles(c, app.id, (app.roles ?? []).map((r) => (r.key === role.key ? { ...r, rules } : r)));
203
+ emit({ appId: app.id, role: role.key, rules }, () => info(`✓ Removed rule [${index}] from "${role.key}" (${rules.length} left)`));
204
+ },
205
+ });
206
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { registerAuthCommands } from "./commands/auth.js";
6
6
  import { registerDeployCommands } from "./commands/deploy.js";
7
7
  import { registerEnvCommands } from "./commands/env.js";
8
8
  import { registerPipelineCommands } from "./commands/pipelines.js";
9
+ import { registerResourceCommands } from "./commands/resources.js";
10
+ import { registerRoleCommands } from "./commands/roles.js";
9
11
  import { info, setJsonMode } from "./output.js";
10
12
  import { allCommands, findCommand, register } from "./registry.js";
11
13
  registerAuthCommands();
@@ -14,6 +16,8 @@ registerPipelineCommands();
14
16
  registerDeployCommands();
15
17
  registerAccessCommands();
16
18
  registerEnvCommands();
19
+ registerResourceCommands();
20
+ registerRoleCommands();
17
21
  function parseArgs(argv) {
18
22
  const positional = [];
19
23
  const flags = {};
package/dist/types.d.ts CHANGED
@@ -30,13 +30,26 @@ export interface NamedPipeline {
30
30
  name: string;
31
31
  config: DeployConfig;
32
32
  }
33
+ export type RoleOperator = "equals" | "contains" | "startsWith" | "regex";
33
34
  export interface RoleCondition {
34
35
  attribute: string;
35
- operator: "equals" | "contains" | "startsWith" | "regex";
36
+ operator: RoleOperator;
36
37
  value: string;
37
38
  }
39
+ /**
40
+ * A rule matches when ALL its conditions hold; a role is granted when ANY rule
41
+ * matches. The property is `all`, not `conditions` — the server reads `.all`
42
+ * and silently drops rules shaped any other way.
43
+ */
38
44
  export interface RoleRule {
39
- conditions: RoleCondition[];
45
+ all: RoleCondition[];
46
+ }
47
+ export interface AppRole {
48
+ key: string;
49
+ label: string;
50
+ description?: string;
51
+ members: string[];
52
+ rules: RoleRule[];
40
53
  }
41
54
  export interface StaffAccess {
42
55
  everyone: boolean;
@@ -54,8 +67,14 @@ export interface RegisteredApp {
54
67
  url: string;
55
68
  repo?: string;
56
69
  branch?: string;
57
- env: Record<string, string>;
70
+ /**
71
+ * Names of the app's env vars. Values are deliberately absent — read them
72
+ * with `coe env list --show`, so a secret only ever appears when it was
73
+ * asked for by name.
74
+ */
75
+ envKeys: string[];
58
76
  pipelines?: NamedPipeline[];
77
+ roles?: AppRole[];
59
78
  access?: AppAccess;
60
79
  createdAt: string;
61
80
  updatedAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uic-coe-connect/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The coe CLI \u2014 manage COEConnect apps (pipelines, deploys, access, env) from a terminal, with browser-approved auth. Designed to be driven by an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {