@hobble-env/cli 3.0.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.
Files changed (47) hide show
  1. package/README.md +148 -0
  2. package/SKILL.md +91 -0
  3. package/dist/bin/hobble.d.ts +2 -0
  4. package/dist/bin/hobble.js +46 -0
  5. package/dist/commands/delete.d.ts +2 -0
  6. package/dist/commands/delete.js +43 -0
  7. package/dist/commands/env-pull.d.ts +2 -0
  8. package/dist/commands/env-pull.js +56 -0
  9. package/dist/commands/env-push.d.ts +2 -0
  10. package/dist/commands/env-push.js +135 -0
  11. package/dist/commands/get.d.ts +2 -0
  12. package/dist/commands/get.js +43 -0
  13. package/dist/commands/init.d.ts +2 -0
  14. package/dist/commands/init.js +70 -0
  15. package/dist/commands/list.d.ts +4 -0
  16. package/dist/commands/list.js +52 -0
  17. package/dist/commands/login.d.ts +2 -0
  18. package/dist/commands/login.js +223 -0
  19. package/dist/commands/logout.d.ts +2 -0
  20. package/dist/commands/logout.js +25 -0
  21. package/dist/commands/run.d.ts +2 -0
  22. package/dist/commands/run.js +40 -0
  23. package/dist/commands/set.d.ts +2 -0
  24. package/dist/commands/set.js +65 -0
  25. package/dist/commands/shell.d.ts +2 -0
  26. package/dist/commands/shell.js +88 -0
  27. package/dist/commands/switch.d.ts +2 -0
  28. package/dist/commands/switch.js +37 -0
  29. package/dist/commands/tags.d.ts +2 -0
  30. package/dist/commands/tags.js +33 -0
  31. package/dist/commands/whoami.d.ts +2 -0
  32. package/dist/commands/whoami.js +45 -0
  33. package/dist/lib/api.d.ts +10 -0
  34. package/dist/lib/api.js +88 -0
  35. package/dist/lib/browser-login.d.ts +38 -0
  36. package/dist/lib/browser-login.js +88 -0
  37. package/dist/lib/config.d.ts +29 -0
  38. package/dist/lib/config.js +102 -0
  39. package/dist/lib/env-format.d.ts +6 -0
  40. package/dist/lib/env-format.js +35 -0
  41. package/dist/lib/output.d.ts +9 -0
  42. package/dist/lib/output.js +36 -0
  43. package/dist/lib/project-config.d.ts +21 -0
  44. package/dist/lib/project-config.js +55 -0
  45. package/dist/lib/shell-prompt.d.ts +31 -0
  46. package/dist/lib/shell-prompt.js +101 -0
  47. package/package.json +29 -0
@@ -0,0 +1,52 @@
1
+ import { Command } from "commander";
2
+ import { api } from "../lib/api.js";
3
+ import { resolveContext } from "../lib/project-config.js";
4
+ import { error, table } from "../lib/output.js";
5
+ async function resolveProjectId(projectName) {
6
+ const data = (await api.get("/api/v1/projects"));
7
+ const proj = data.projects.find((p) => p.name === projectName);
8
+ if (!proj) {
9
+ throw new Error(`Project "${projectName}" not found. Run \`hobble init\` to select a project.`);
10
+ }
11
+ return proj.id;
12
+ }
13
+ export { resolveProjectId };
14
+ export const listCommand = new Command("list")
15
+ .description("List secrets in the current environment")
16
+ .option("--project <name>", "Project name")
17
+ .option("--env <name>", "Environment name")
18
+ .option("--json", "Output as JSON")
19
+ .option("--tag <name>", "Filter by tag (repeatable)", (val, prev) => prev.concat(val), [])
20
+ .action(async (opts) => {
21
+ try {
22
+ const ctx = await resolveContext(opts);
23
+ const projectId = await resolveProjectId(ctx.project);
24
+ const response = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
25
+ const secrets = response.data;
26
+ const tagFilter = opts.tag ?? [];
27
+ const filtered = tagFilter.length
28
+ ? secrets.filter((s) => {
29
+ const names = (s.tags ?? []).map((t) => t.name.toLowerCase());
30
+ return tagFilter.every((t) => names.includes(t.toLowerCase()));
31
+ })
32
+ : secrets;
33
+ if (opts.json) {
34
+ process.stdout.write(JSON.stringify(filtered, null, 2) + "\n");
35
+ return;
36
+ }
37
+ if (filtered.length === 0) {
38
+ console.log("No secrets found.");
39
+ return;
40
+ }
41
+ table(["Key", "Tags", "Created", "Updated"], filtered.map((s) => [
42
+ s.key,
43
+ (s.tags ?? []).map((t) => t.name).join(", "),
44
+ new Date(s.createdAt).toLocaleDateString(),
45
+ new Date(s.updatedAt).toLocaleDateString(),
46
+ ]));
47
+ }
48
+ catch (err) {
49
+ error(err.message);
50
+ process.exit(1);
51
+ }
52
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const loginCommand: Command;
@@ -0,0 +1,223 @@
1
+ import { Command } from "commander";
2
+ import inquirer from "inquirer";
3
+ import { loadConfig, saveConfig, normalizeApiUrl, } from "../lib/config.js";
4
+ import { success, error, debug, info } from "../lib/output.js";
5
+ import { requestDeviceCode, pollForToken, normalizeExpiresAt, DeviceFlowDeniedError, DeviceFlowExpiredError, } from "../lib/browser-login.js";
6
+ const DEFAULT_API_URL = "http://localhost:3000";
7
+ async function getDefaultApiUrl() {
8
+ const existing = await loadConfig();
9
+ return existing?.apiUrl ?? DEFAULT_API_URL;
10
+ }
11
+ async function fetchWhoami(apiUrl, headers) {
12
+ const url = `${apiUrl}/api/v1/whoami`;
13
+ debug(`GET ${url}`);
14
+ const res = await fetch(url, { headers });
15
+ debug(`${res.status} ${res.statusText}`);
16
+ if (!res.ok)
17
+ throw new Error(`whoami failed (${res.status})`);
18
+ return (await res.json());
19
+ }
20
+ function printSuccess(data) {
21
+ const membership = data.memberships[0];
22
+ if (membership) {
23
+ success(`Logged in as ${data.user.email} (${membership.name ?? membership.organizationId}, ${membership.role})`);
24
+ }
25
+ else {
26
+ success(`Logged in as ${data.user.email}`);
27
+ }
28
+ }
29
+ async function readStdinLines(n) {
30
+ return new Promise((resolve) => {
31
+ const lines = [];
32
+ let buf = "";
33
+ process.stdin.setEncoding("utf-8");
34
+ process.stdin.on("data", (chunk) => {
35
+ buf += chunk;
36
+ const parts = buf.split("\n");
37
+ buf = parts.pop() ?? "";
38
+ for (const line of parts) {
39
+ lines.push(line);
40
+ if (lines.length >= n) {
41
+ process.stdin.pause();
42
+ resolve(lines);
43
+ return;
44
+ }
45
+ }
46
+ });
47
+ process.stdin.on("end", () => {
48
+ if (buf.length > 0)
49
+ lines.push(buf);
50
+ resolve(lines);
51
+ });
52
+ process.stdin.resume();
53
+ });
54
+ }
55
+ async function loginWithApiKey() {
56
+ let answers;
57
+ const defaultApiUrl = await getDefaultApiUrl();
58
+ if (!process.stdin.isTTY) {
59
+ // Non-interactive mode: read two lines from stdin (apiKey, apiUrl)
60
+ const lines = await readStdinLines(2);
61
+ const apiKey = lines[0] ?? "";
62
+ const rawApiUrl = lines[1]?.trim() || defaultApiUrl;
63
+ if (!apiKey) {
64
+ error("API key is required");
65
+ process.exit(1);
66
+ }
67
+ answers = { apiKey, apiUrl: normalizeApiUrl(rawApiUrl) };
68
+ }
69
+ else {
70
+ const prompted = await inquirer.prompt([
71
+ {
72
+ type: "password",
73
+ name: "apiKey",
74
+ message: "API Key:",
75
+ mask: "*",
76
+ validate: (v) => (v.length > 0 ? true : "API key is required"),
77
+ },
78
+ {
79
+ type: "input",
80
+ name: "apiUrl",
81
+ message: "API URL:",
82
+ default: defaultApiUrl,
83
+ },
84
+ ]);
85
+ answers = { apiKey: prompted.apiKey, apiUrl: normalizeApiUrl(prompted.apiUrl) };
86
+ }
87
+ const headers = {
88
+ "x-api-key": answers.apiKey,
89
+ "Content-Type": "application/json",
90
+ };
91
+ let data;
92
+ try {
93
+ data = await fetchWhoami(answers.apiUrl, headers);
94
+ }
95
+ catch (err) {
96
+ const msg = err.message;
97
+ if (msg.includes("401") || msg.toLowerCase().includes("invalid")) {
98
+ error("API key is invalid or expired.");
99
+ process.exit(1);
100
+ }
101
+ if (msg.includes("fetch") ||
102
+ msg.includes("ECONNREFUSED") ||
103
+ msg.includes("ENOTFOUND")) {
104
+ error(`Could not reach the server at ${answers.apiUrl}. Check the URL and try again.`);
105
+ process.exit(1);
106
+ }
107
+ error(`Login failed: ${msg}`);
108
+ process.exit(1);
109
+ }
110
+ const credential = { type: "apiKey", token: answers.apiKey };
111
+ await saveConfig({ apiUrl: answers.apiUrl, auth: credential });
112
+ printSuccess(data);
113
+ }
114
+ async function loginWithBrowser(opts) {
115
+ const defaultApiUrl = await getDefaultApiUrl();
116
+ const { apiUrl: rawApiUrl } = await inquirer.prompt([
117
+ {
118
+ type: "input",
119
+ name: "apiUrl",
120
+ message: "API URL:",
121
+ default: defaultApiUrl,
122
+ },
123
+ ]);
124
+ const apiUrl = normalizeApiUrl(rawApiUrl);
125
+ let codeRes;
126
+ try {
127
+ codeRes = await requestDeviceCode(apiUrl);
128
+ }
129
+ catch (err) {
130
+ const msg = err.message;
131
+ if (msg.includes("fetch") ||
132
+ msg.includes("ECONNREFUSED") ||
133
+ msg.includes("ENOTFOUND")) {
134
+ error(`Could not reach the server at ${apiUrl}. Check the URL and try again.`);
135
+ process.exit(1);
136
+ }
137
+ error(`Could not start device login: ${msg}`);
138
+ process.exit(1);
139
+ }
140
+ const verificationUrl = codeRes.verification_uri_complete ?? codeRes.verification_uri;
141
+ const absoluteUrl = verificationUrl.startsWith("http")
142
+ ? verificationUrl
143
+ : `${apiUrl}${verificationUrl}`;
144
+ info(`First copy your one-time code: ${codeRes.user_code}`);
145
+ if (opts.noBrowser) {
146
+ info(`Open this URL in your browser to continue:`);
147
+ info(absoluteUrl);
148
+ }
149
+ else {
150
+ info(`Press Enter to open ${absoluteUrl} in your browser…`);
151
+ await inquirer.prompt([{ type: "input", name: "_", message: "" }]);
152
+ if (process.env.HOBBLE_NO_OPEN !== "1") {
153
+ try {
154
+ const { default: open } = await import("open");
155
+ await open(absoluteUrl);
156
+ }
157
+ catch (err) {
158
+ debug(`open() failed: ${err.message}`);
159
+ // URL is already on stdout — no further action needed
160
+ }
161
+ }
162
+ }
163
+ info(`Waiting for authorization…`);
164
+ let token;
165
+ try {
166
+ token = await pollForToken({
167
+ apiUrl,
168
+ deviceCode: codeRes.device_code,
169
+ intervalSeconds: codeRes.interval ?? 5,
170
+ });
171
+ }
172
+ catch (err) {
173
+ if (err instanceof DeviceFlowDeniedError) {
174
+ error("Authorization denied.");
175
+ process.exit(1);
176
+ }
177
+ if (err instanceof DeviceFlowExpiredError) {
178
+ error("The login code expired. Run `hobble login` to try again.");
179
+ process.exit(1);
180
+ }
181
+ error(`Login failed: ${err.message}`);
182
+ process.exit(1);
183
+ }
184
+ // Better-Auth's device token endpoint returns `expires_in` (seconds) rather than
185
+ // `expires_at`. Fall back to computing the absolute expiry from `expires_in` when
186
+ // `expires_at` is absent.
187
+ const rawExpiresAt = token.expires_at !== undefined
188
+ ? token.expires_at
189
+ : token.expires_in !== undefined
190
+ ? Date.now() / 1000 + token.expires_in
191
+ : Date.now() / 1000 + 604800; // default: 7 days
192
+ const expiresAt = normalizeExpiresAt(rawExpiresAt);
193
+ const headers = {
194
+ Authorization: `Bearer ${token.access_token}`,
195
+ "Content-Type": "application/json",
196
+ };
197
+ let data;
198
+ try {
199
+ data = await fetchWhoami(apiUrl, headers);
200
+ }
201
+ catch (err) {
202
+ error(`Token validation failed: ${err.message}`);
203
+ process.exit(1);
204
+ }
205
+ await saveConfig({
206
+ apiUrl,
207
+ auth: { type: "session", token: token.access_token, expiresAt },
208
+ });
209
+ printSuccess(data);
210
+ }
211
+ export const loginCommand = new Command("login")
212
+ .description("Authenticate with the secret management API")
213
+ .option("--api-key", "Sign in with a long-lived API key (instead of browser session)")
214
+ .option("--no-browser", "Skip opening the browser; print the URL only")
215
+ .action(async (opts) => {
216
+ // Commander represents `--no-browser` as `opts.browser === false`
217
+ const noBrowser = opts.browser === false;
218
+ if (opts.apiKey) {
219
+ await loginWithApiKey();
220
+ return;
221
+ }
222
+ await loginWithBrowser({ noBrowser });
223
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const logoutCommand: Command;
@@ -0,0 +1,25 @@
1
+ import { Command } from "commander";
2
+ import inquirer from "inquirer";
3
+ import { deleteConfig, loadConfig } from "../lib/config.js";
4
+ import { success, info } from "../lib/output.js";
5
+ export const logoutCommand = new Command("logout")
6
+ .description("Remove stored credentials")
7
+ .action(async () => {
8
+ const config = await loadConfig();
9
+ if (!config) {
10
+ info("Not logged in.");
11
+ return;
12
+ }
13
+ const { confirm } = await inquirer.prompt([
14
+ {
15
+ type: "confirm",
16
+ name: "confirm",
17
+ message: "Remove stored credentials?",
18
+ default: false,
19
+ },
20
+ ]);
21
+ if (!confirm)
22
+ return;
23
+ await deleteConfig();
24
+ success("Logged out.");
25
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const runCommand: Command;
@@ -0,0 +1,40 @@
1
+ import { Command } from "commander";
2
+ import { spawn } from "node:child_process";
3
+ import { api } from "../lib/api.js";
4
+ import { resolveContext } from "../lib/project-config.js";
5
+ import { resolveProjectId } from "./list.js";
6
+ import { error, info } from "../lib/output.js";
7
+ export const runCommand = new Command("run")
8
+ .description("Run a command with secrets injected as environment variables")
9
+ .option("--project <name>", "Project name")
10
+ .option("--env <name>", "Environment name")
11
+ .argument("<command...>", "Command to run")
12
+ .passThroughOptions()
13
+ .action(async (commandArgs, opts) => {
14
+ try {
15
+ const ctx = await resolveContext(opts);
16
+ const projectId = await resolveProjectId(ctx.project);
17
+ // Fetch all secrets
18
+ const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
19
+ // Fetch each secret's value
20
+ const secrets = {};
21
+ for (const s of listResponse.data) {
22
+ const detailResponse = (await api.get(`/api/v1/secrets/${s.id}`));
23
+ secrets[detailResponse.data.key] = detailResponse.data.value;
24
+ }
25
+ info(`Injecting ${Object.keys(secrets).length} secrets from ${ctx.project}/${ctx.environment}`);
26
+ const [cmd, ...args] = commandArgs;
27
+ const child = spawn(cmd, args, {
28
+ env: { ...process.env, ...secrets },
29
+ stdio: "inherit",
30
+ shell: true,
31
+ });
32
+ child.on("close", (code) => {
33
+ process.exit(code ?? 1);
34
+ });
35
+ }
36
+ catch (err) {
37
+ error(err.message);
38
+ process.exit(1);
39
+ }
40
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const setCommand: Command;
@@ -0,0 +1,65 @@
1
+ import { Command } from "commander";
2
+ import { api } from "../lib/api.js";
3
+ import { resolveContext } from "../lib/project-config.js";
4
+ import { resolveProjectId } from "./list.js";
5
+ import { success, error } from "../lib/output.js";
6
+ async function readStdin() {
7
+ const chunks = [];
8
+ for await (const chunk of process.stdin) {
9
+ chunks.push(chunk);
10
+ }
11
+ return Buffer.concat(chunks).toString("utf-8").trimEnd();
12
+ }
13
+ export const setCommand = new Command("set")
14
+ .description("Create or update a secret")
15
+ .argument("<key>", "Secret key name")
16
+ .argument("[value]", "Secret value (use - to read from stdin)")
17
+ .option("--project <name>", "Project name")
18
+ .option("--env <name>", "Environment name")
19
+ .option("--comment <text>", "Comment to attach to this secret (use empty string to clear)")
20
+ .option("--tag <name>", "Tag to attach (repeatable; replaces existing tags)", (val, prev) => prev.concat(val), [])
21
+ .action(async (key, rawValue, opts) => {
22
+ try {
23
+ let value;
24
+ if (rawValue === "-" || rawValue === undefined) {
25
+ value = await readStdin();
26
+ }
27
+ else {
28
+ value = rawValue;
29
+ }
30
+ const ctx = await resolveContext(opts);
31
+ const projectId = await resolveProjectId(ctx.project);
32
+ // Check if key already exists
33
+ const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(ctx.environment)}`));
34
+ const existing = listResponse.data.find((s) => s.key === key);
35
+ const hasComment = opts.comment !== undefined;
36
+ const tags = opts.tag && opts.tag.length ? opts.tag : undefined;
37
+ if (existing) {
38
+ const body = { value };
39
+ if (hasComment)
40
+ body.comment = opts.comment;
41
+ if (tags)
42
+ body.tags = tags;
43
+ await api.put(`/api/v1/secrets/${existing.id}`, body);
44
+ success(`Updated "${key}"`);
45
+ }
46
+ else {
47
+ const body = {
48
+ projectId,
49
+ environment: ctx.environment,
50
+ key,
51
+ value,
52
+ };
53
+ if (hasComment)
54
+ body.comment = opts.comment;
55
+ if (tags)
56
+ body.tags = tags;
57
+ await api.post("/api/v1/secrets", body);
58
+ success(`Created "${key}"`);
59
+ }
60
+ }
61
+ catch (err) {
62
+ error(err.message);
63
+ process.exit(1);
64
+ }
65
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const shellCommand: Command;
@@ -0,0 +1,88 @@
1
+ import { Command } from "commander";
2
+ import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { rm } from "node:fs/promises";
5
+ import { api } from "../lib/api.js";
6
+ import { resolveContext } from "../lib/project-config.js";
7
+ import { resolveProjectId } from "./list.js";
8
+ import { error, info } from "../lib/output.js";
9
+ import { detectShellKind, hasStarship, planSpawn, writeHobbleBinDir, } from "../lib/shell-prompt.js";
10
+ async function fetchSecrets(projectId, environment) {
11
+ const listResponse = (await api.get(`/api/v1/secrets?projectId=${projectId}&environment=${encodeURIComponent(environment)}`));
12
+ const secrets = {};
13
+ for (const s of listResponse.data) {
14
+ const detail = (await api.get(`/api/v1/secrets/${s.id}`));
15
+ secrets[detail.data.key] = detail.data.value;
16
+ }
17
+ return secrets;
18
+ }
19
+ async function cleanup(paths) {
20
+ await Promise.all(paths.map((p) => rm(p, { recursive: true, force: true }).catch(() => { })));
21
+ }
22
+ export const shellCommand = new Command("shell")
23
+ .description("Start an interactive subshell with secrets injected as env vars")
24
+ .option("--project <name>", "Project name")
25
+ .option("--env <name>", "Environment name")
26
+ .action(async (opts) => {
27
+ try {
28
+ const ctx = await resolveContext(opts);
29
+ const projectId = await resolveProjectId(ctx.project);
30
+ const secrets = await fetchSecrets(projectId, ctx.environment);
31
+ const shellPath = process.env.SHELL ?? "/bin/bash";
32
+ const kind = detectShellKind(shellPath);
33
+ const envLabel = `${ctx.project}/${ctx.environment}`;
34
+ const starship = hasStarship();
35
+ let plan;
36
+ try {
37
+ plan = await planSpawn({
38
+ kind,
39
+ home: homedir(),
40
+ zdotdir: process.env.ZDOTDIR,
41
+ starship,
42
+ });
43
+ }
44
+ catch {
45
+ plan = { args: [], envOverrides: {}, cleanupPaths: [] };
46
+ }
47
+ let binDir = null;
48
+ try {
49
+ const wrapper = await writeHobbleBinDir({
50
+ nodePath: process.execPath,
51
+ scriptPath: process.argv[1],
52
+ });
53
+ binDir = wrapper.dir;
54
+ }
55
+ catch {
56
+ binDir = null;
57
+ }
58
+ const pathOverride = binDir
59
+ ? { PATH: `${binDir}:${process.env.PATH ?? ""}` }
60
+ : {};
61
+ const cleanupPaths = [
62
+ ...plan.cleanupPaths,
63
+ ...(binDir ? [binDir] : []),
64
+ ];
65
+ info(`Starting shell with ${Object.keys(secrets).length} secrets from ${envLabel} — type exit to return.`);
66
+ const childEnv = {
67
+ ...process.env,
68
+ ...secrets,
69
+ ...plan.envOverrides,
70
+ ...pathOverride,
71
+ HOBBLE_PROJECT: ctx.project,
72
+ HOBBLE_ENVIRONMENT: ctx.environment,
73
+ HOBBLE_SHELL: envLabel,
74
+ };
75
+ const child = spawn(shellPath, plan.args, {
76
+ env: childEnv,
77
+ stdio: "inherit",
78
+ });
79
+ child.on("close", async (code) => {
80
+ await cleanup(cleanupPaths);
81
+ process.exit(code ?? 1);
82
+ });
83
+ }
84
+ catch (err) {
85
+ error(err.message);
86
+ process.exit(1);
87
+ }
88
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const switchCommand: Command;
@@ -0,0 +1,37 @@
1
+ import { Command } from "commander";
2
+ import { api } from "../lib/api.js";
3
+ import { saveProjectConfig } from "../lib/project-config.js";
4
+ import { success, error } from "../lib/output.js";
5
+ export const switchCommand = new Command("switch")
6
+ .description("Switch project and environment")
7
+ .argument("<project/env>", "Project and environment (e.g. my-app/staging)")
8
+ .action(async (slug) => {
9
+ const parts = slug.split("/");
10
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
11
+ error("Format: hobble switch <project>/<environment>");
12
+ process.exit(1);
13
+ }
14
+ const [projectName, envName] = parts;
15
+ try {
16
+ // Verify project exists
17
+ const projectData = (await api.get("/api/v1/projects"));
18
+ const proj = projectData.projects.find((p) => p.name === projectName);
19
+ if (!proj) {
20
+ error(`Project "${projectName}" not found. Run \`hobble init\` to select a project.`);
21
+ process.exit(1);
22
+ }
23
+ // Verify environment exists
24
+ const envData = (await api.get(`/api/v1/projects/${proj.id}/environments`));
25
+ const env = envData.environments.find((e) => e.name === envName);
26
+ if (!env) {
27
+ error(`Environment "${envName}" not found in project "${projectName}".`);
28
+ process.exit(1);
29
+ }
30
+ await saveProjectConfig({ project: projectName, environment: envName });
31
+ success(`Switched to ${projectName}/${envName}`);
32
+ }
33
+ catch (err) {
34
+ error(err.message);
35
+ process.exit(1);
36
+ }
37
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const tagsCommand: Command;
@@ -0,0 +1,33 @@
1
+ import { Command } from "commander";
2
+ import { api } from "../lib/api.js";
3
+ import { resolveContext } from "../lib/project-config.js";
4
+ import { resolveProjectId } from "./list.js";
5
+ import { error, table } from "../lib/output.js";
6
+ export const tagsCommand = new Command("tags")
7
+ .description("List tags available to the current project")
8
+ .option("--project <name>", "Project name")
9
+ // --env isn't used by the tags API (tags are project/org-scoped), but
10
+ // resolveContext needs project+env (or a project config) to resolve the project.
11
+ .option("--env <name>", "Environment name")
12
+ .option("--json", "Output as JSON")
13
+ .action(async (opts) => {
14
+ try {
15
+ const ctx = await resolveContext(opts);
16
+ const projectId = await resolveProjectId(ctx.project);
17
+ const response = (await api.get(`/api/v1/tags?projectId=${projectId}`));
18
+ const tags = response.data;
19
+ if (opts.json) {
20
+ process.stdout.write(JSON.stringify(tags, null, 2) + "\n");
21
+ return;
22
+ }
23
+ if (tags.length === 0) {
24
+ console.log("No tags found.");
25
+ return;
26
+ }
27
+ table(["Name", "Color"], tags.map((t) => [t.name, t.color]));
28
+ }
29
+ catch (err) {
30
+ error(err.message);
31
+ process.exit(1);
32
+ }
33
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const whoamiCommand: Command;
@@ -0,0 +1,45 @@
1
+ import { Command } from "commander";
2
+ import { api } from "../lib/api.js";
3
+ import { loadConfig } from "../lib/config.js";
4
+ import { error } from "../lib/output.js";
5
+ function describeExpiry(expiresAt) {
6
+ const ms = Date.parse(expiresAt);
7
+ if (!Number.isFinite(ms))
8
+ return "expires at unknown time";
9
+ const now = Date.now();
10
+ if (ms <= now)
11
+ return "expired";
12
+ const days = Math.floor((ms - now) / (1000 * 60 * 60 * 24));
13
+ if (days >= 1)
14
+ return `expires in ${days} day${days === 1 ? "" : "s"}`;
15
+ const hours = Math.max(1, Math.floor((ms - now) / (1000 * 60 * 60)));
16
+ return `expires in ${hours} hour${hours === 1 ? "" : "s"}`;
17
+ }
18
+ export const whoamiCommand = new Command("whoami")
19
+ .description("Show current user and organization info")
20
+ .action(async () => {
21
+ try {
22
+ const data = (await api.get("/api/v1/whoami"));
23
+ console.log(`User: ${data.user.name} <${data.user.email}>`);
24
+ if (data.memberships.length > 0) {
25
+ for (const m of data.memberships) {
26
+ console.log(`Org: ${m.name ?? m.organizationId} (${m.slug ?? "no slug"})`);
27
+ console.log(`Role: ${m.role}`);
28
+ }
29
+ }
30
+ else {
31
+ console.log("Org: (no memberships)");
32
+ }
33
+ const config = await loadConfig();
34
+ if (config?.auth.type === "session") {
35
+ console.log(`Auth: session (${describeExpiry(config.auth.expiresAt)})`);
36
+ }
37
+ else if (config?.auth.type === "apiKey") {
38
+ console.log(`Auth: api key`);
39
+ }
40
+ }
41
+ catch (err) {
42
+ error(err.message);
43
+ process.exit(1);
44
+ }
45
+ });
@@ -0,0 +1,10 @@
1
+ export declare class ApiError extends Error {
2
+ status: number;
3
+ constructor(status: number, message: string);
4
+ }
5
+ export declare const api: {
6
+ get: (path: string) => Promise<unknown>;
7
+ post: (path: string, body: unknown) => Promise<unknown>;
8
+ put: (path: string, body: unknown) => Promise<unknown>;
9
+ delete: (path: string) => Promise<unknown>;
10
+ };