envspot 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EnvSpot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # EnvSpot CLI (`envspot`)
2
+
3
+ Minimal reference for the **`login` · `link` · `run`** surface. Full command reference: **[`../docs/cli-reference.md`](../docs/cli-reference.md)**. Historical SD-02 sequence + rationale: **[`../docs/historical/SD-02-cli-sequence.md`](../docs/historical/SD-02-cli-sequence.md)**.
4
+
5
+ ## Trust model (matches SD-01 §8 / SD-02 §7)
6
+
7
+ - **Plaintext secrets** — only in process memory during `run`, after **`GET /api/cli/decrypt`** over TLS. Nothing is written to disk as a secret bundle.
8
+ - **No DEK / no master key on the client** — unwrap and decrypt happen server-side.
9
+ - **`.envspot.json`** — project id + environment label only; safe to commit unless you treat the project id as sensitive.
10
+ - **Token** — stored with **keytar** (OS keychain); on **401** / `token_invalid`, `run` clears the stored credential and exits **4** so you re-`login`.
11
+
12
+ ## User-Agent
13
+
14
+ All requests send **`User-Agent: envspot-cli/<semver>`** (`cli/src/config.ts`) for server-side audits (SD-01 §5.2).
15
+
16
+ ## Resolved product choices
17
+
18
+ Exit codes, retries, **403** tier responses, and operational note on auth-tag / decrypt failures: **[`../docs/historical/sd-open-questions-resolved.md`](../docs/historical/sd-open-questions-resolved.md)**.
19
+
20
+ ## Build
21
+
22
+ ```bash
23
+ cd cli && npm ci && npm run build
24
+ ```
25
+
26
+ Bin: **`envspot`** → **`./dist/index.js`**.
@@ -0,0 +1,67 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import keytar from "keytar";
5
+ export const KEYTAR_SERVICE = "EnvSpot";
6
+ export const KEYTAR_ACCOUNT = "cli-device-access-token";
7
+ export const CONFIG_DIR = join(homedir(), ".envspot");
8
+ export const LEGACY_AUTH_JSON = join(CONFIG_DIR, "auth.json");
9
+ /** Non-spec escape hatch when keytar cannot load (unsupported CI image, missing libsecret). */
10
+ export function devAuthJsonEnabled() {
11
+ return process.env.ENVSPOT_DEV_AUTH_JSON === "1";
12
+ }
13
+ export async function clearStoredCredential() {
14
+ try {
15
+ rmSync(LEGACY_AUTH_JSON, { force: true });
16
+ }
17
+ catch {
18
+ /* noop */
19
+ }
20
+ try {
21
+ await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
22
+ }
23
+ catch {
24
+ /* noop — may be “not found” */
25
+ }
26
+ }
27
+ function readDevFileToken() {
28
+ try {
29
+ const raw = readFileSync(LEGACY_AUTH_JSON, "utf8");
30
+ const j = JSON.parse(raw);
31
+ return typeof j.token === "string" && j.token.trim().length > 0
32
+ ? j.token.trim()
33
+ : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Resolve the CLI bearer token + its source. `ENVSPOT_TOKEN` (CI) wins, then dev-file, then keychain. */
40
+ export async function resolveToken() {
41
+ const envToken = process.env.ENVSPOT_TOKEN?.trim();
42
+ if (envToken)
43
+ return { token: envToken, source: "env" };
44
+ if (devAuthJsonEnabled()) {
45
+ const token = readDevFileToken();
46
+ return { token, source: token ? "dev-file" : "none" };
47
+ }
48
+ try {
49
+ const token = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
50
+ return { token, source: token ? "keychain" : "none" };
51
+ }
52
+ catch {
53
+ return { token: null, source: "none" };
54
+ }
55
+ }
56
+ export async function getStoredToken() {
57
+ return (await resolveToken()).token;
58
+ }
59
+ export async function setStoredToken(plaintextToken) {
60
+ if (devAuthJsonEnabled()) {
61
+ mkdirSync(CONFIG_DIR, { recursive: true });
62
+ writeFileSync(LEGACY_AUTH_JSON, `${JSON.stringify({ token: plaintextToken }, null, 2)}\n`, "utf8");
63
+ console.error("Warning: ENVSPOT_DEV_AUTH_JSON=1 writes ~/.envspot/auth.json — not spec; unset for OS keychain (keytar).");
64
+ return;
65
+ }
66
+ await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, plaintextToken);
67
+ }
@@ -0,0 +1,48 @@
1
+ import { clearStoredCredential } from "./auth-store.js";
2
+ import { upgradePageHint } from "./config.js";
3
+ import { CliError, ERR, forbiddenProject, sessionExpired } from "./errors.js";
4
+ function codeFromJson(j) {
5
+ if (!j)
6
+ return undefined;
7
+ return typeof j.code === "string" ? j.code : undefined;
8
+ }
9
+ /**
10
+ * Map a non-OK `{ ok:false, code }` response from the `/api/cli/*` endpoints to
11
+ * a typed CliError carrying the documented exit code. Clears the stored
12
+ * credential on 401 so the next command re-authenticates. `projectRef` flavors
13
+ * the forbidden message when the failure is project-scoped.
14
+ *
15
+ * The server collapses KMS/decrypt faults into a generic 500, so the old
16
+ * granular exit codes 8/9 are no longer distinguishable here.
17
+ */
18
+ export async function cliApiError(status, json, projectRef) {
19
+ const code = codeFromJson(json);
20
+ if (status === 401 || code === "UNAUTHORIZED") {
21
+ await clearStoredCredential();
22
+ return sessionExpired();
23
+ }
24
+ if (status === 429 || code === "RATE_LIMITED") {
25
+ return new CliError(ERR.RATE_LIMITED, "Rate limit hit.", "Try again shortly.");
26
+ }
27
+ if (code === "ENV_NOT_ALLOWED" || code === "SECRET_LIMIT") {
28
+ const detail = code === "SECRET_LIMIT"
29
+ ? "You've reached your plan's secret limit."
30
+ : "That environment isn't available on your plan.";
31
+ return new CliError(ERR.TIER_EXCEEDED, detail, `Upgrade: ${upgradePageHint()}`);
32
+ }
33
+ if (code === "SESSION_BEARER_REQUIRED") {
34
+ // A headless token reached a session-only endpoint — wrong kind, not a
35
+ // project-access problem.
36
+ return new CliError(ERR.AUTH_REQUIRED, "This command needs a device login.", "Run: envspot login");
37
+ }
38
+ if (status === 403 ||
39
+ status === 404 ||
40
+ code === "FORBIDDEN" ||
41
+ code === "NOT_FOUND") {
42
+ return forbiddenProject(projectRef ?? "this project");
43
+ }
44
+ if (status === 400 || code === "INVALID_REQUEST" || code === "INVALID_KEYS") {
45
+ return new CliError(ERR.INVALID_INPUT, "The request was rejected as invalid.", "Check the project, environment, and key names.");
46
+ }
47
+ return new CliError(ERR.UNEXPECTED, `Request failed (${status}).`);
48
+ }
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ export function packageVersion() {
6
+ const pjPath = join(__dirname, "..", "package.json");
7
+ const pj = JSON.parse(readFileSync(pjPath, "utf8"));
8
+ return pj.version;
9
+ }
10
+ export function cliUserAgent() {
11
+ return `envspot-cli/${packageVersion()}`;
12
+ }
13
+ export function apiBase() {
14
+ if (process.env.ENVSPOT_API_URL?.trim())
15
+ return process.env.ENVSPOT_API_URL.trim();
16
+ if (process.env.ENV_SPOT_API_URL?.trim())
17
+ return process.env.ENV_SPOT_API_URL.trim();
18
+ if (process.env.ENVV_API_URL?.trim())
19
+ return process.env.ENVV_API_URL.trim();
20
+ if (process.env.NODE_ENV === "development")
21
+ return "http://localhost:3000";
22
+ return "https://api.envspot.com";
23
+ }
24
+ /** Browser/dashboard origin for CLI login approval (dashboard lives here, not on API origin). */
25
+ export function appOrigin() {
26
+ const explicit = process.env.ENVSPOT_APP_URL?.trim();
27
+ if (explicit)
28
+ return explicit.replace(/\/$/, "");
29
+ const api = apiBase();
30
+ const u = /^https?:\/\/[^/]+/i.exec(api);
31
+ const hostOnly = u ? u[0] : api.replace(/\/$/, "");
32
+ if (/\blocalhost\b/i.test(hostOnly) || /\b127\.0\.0\.1\b/.test(hostOnly)) {
33
+ return hostOnly;
34
+ }
35
+ return "https://envspot.com";
36
+ }
37
+ export function statusPageHint() {
38
+ return process.env.ENVSPOT_STATUS_URL?.trim() || "https://status.envspot.com";
39
+ }
40
+ export function upgradePageHint(jsonUpgradeUrl) {
41
+ const u = typeof jsonUpgradeUrl === "string" && jsonUpgradeUrl.trim().length > 0
42
+ ? jsonUpgradeUrl.trim()
43
+ : null;
44
+ return u ?? "https://envspot.com/dashboard/billing";
45
+ }
@@ -0,0 +1,106 @@
1
+ import { hostname } from "node:os";
2
+ import { appOrigin, packageVersion } from "./config.js";
3
+ import { CliError, ERR } from "./errors.js";
4
+ import { apiUrl, backoffTransport, fetchWithCliHeaders, isLikelyTransportFailure, parseJsonSafely, rethrowTransport, } from "./http.js";
5
+ const LOGIN_AGAIN = "Run: envspot login";
6
+ const POLL_INTERVAL_MS = 2000;
7
+ /** Server pairing TTL is 10 min; poll a little past it before giving up. */
8
+ const POLL_WINDOW_MS = 10 * 60 * 1000 + 15_000;
9
+ /**
10
+ * Begin a device-pairing flow. The server mints the userCode + deviceCode; we
11
+ * only describe this device. Returns both codes — the userCode goes in the
12
+ * browser approve URL, the deviceCode is the polling secret.
13
+ */
14
+ export async function startDeviceFlow() {
15
+ let res;
16
+ try {
17
+ res = await backoffTransport(async () => fetchWithCliHeaders(apiUrl("/api/cli/login/start"), {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({
21
+ hostname: hostname().slice(0, 128),
22
+ platform: process.platform,
23
+ version: packageVersion(),
24
+ }),
25
+ }));
26
+ }
27
+ catch (e) {
28
+ rethrowTransport(e);
29
+ }
30
+ const { json } = await parseJsonSafely(res);
31
+ if (res.ok &&
32
+ json &&
33
+ typeof json.userCode === "string" &&
34
+ typeof json.deviceCode === "string") {
35
+ return {
36
+ userCode: json.userCode,
37
+ deviceCode: json.deviceCode,
38
+ expiresAt: typeof json.expiresAt === "string" ? json.expiresAt : "",
39
+ };
40
+ }
41
+ if (res.status === 429) {
42
+ throw new CliError(ERR.RATE_LIMITED, "Rate limit hit.", "Try again shortly.");
43
+ }
44
+ throw new CliError(ERR.UNEXPECTED, `Couldn't start CLI login (${res.status}).`, LOGIN_AGAIN);
45
+ }
46
+ /** Dashboard approve URL for a userCode. `from` deep-links a specialized mode
47
+ * (e.g. the cli-init approve card). */
48
+ export function approveUrl(userCode, opts) {
49
+ const params = new URLSearchParams({ code: userCode });
50
+ if (opts?.from)
51
+ params.set("from", opts.from);
52
+ return `${appOrigin()}/dashboard/cli/approve?${params.toString()}`;
53
+ }
54
+ /**
55
+ * Poll until the browser approves the pairing, returning the minted bearer
56
+ * token (and, for an init pairing, the created projectId). Throws a typed
57
+ * CliError on expiry, a lost session, or timeout.
58
+ */
59
+ export async function pollForBearer(deviceCode, opts) {
60
+ const now = opts?.now ?? (() => Date.now());
61
+ const intervalMs = opts?.intervalMs ?? POLL_INTERVAL_MS;
62
+ const deadline = now() + POLL_WINDOW_MS;
63
+ while (now() < deadline) {
64
+ await new Promise((r) => setTimeout(r, intervalMs));
65
+ let res;
66
+ try {
67
+ res = await fetchWithCliHeaders(apiUrl("/api/cli/login/poll"), {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ deviceCode }),
71
+ });
72
+ }
73
+ catch (e) {
74
+ // A transient blip mid-window shouldn't abort an approval the user may
75
+ // have already granted — retry on the next tick.
76
+ if (isLikelyTransportFailure(e))
77
+ continue;
78
+ throw e;
79
+ }
80
+ const { json } = await parseJsonSafely(res);
81
+ if (res.status === 410) {
82
+ throw new CliError(ERR.UNEXPECTED, "Pairing expired.", LOGIN_AGAIN);
83
+ }
84
+ if (res.status === 404) {
85
+ throw new CliError(ERR.UNEXPECTED, "Lost the pairing session.", LOGIN_AGAIN);
86
+ }
87
+ if (res.status === 409) {
88
+ throw new CliError(ERR.UNEXPECTED, "This pairing was already used.", LOGIN_AGAIN);
89
+ }
90
+ if (res.status === 429) {
91
+ const retry = Number(res.headers.get("retry-after")) || 2;
92
+ await new Promise((r) => setTimeout(r, Math.min(retry * 1000, 30_000)));
93
+ continue;
94
+ }
95
+ if (res.ok &&
96
+ json?.status === "redeemed" &&
97
+ typeof json.bearerToken === "string") {
98
+ return {
99
+ bearerToken: json.bearerToken,
100
+ projectId: typeof json.projectId === "string" ? json.projectId : undefined,
101
+ };
102
+ }
103
+ // ok + status:"pending" (or any other transient ok) → keep waiting.
104
+ }
105
+ throw new CliError(ERR.UNEXPECTED, "Timed out waiting for browser approval.", LOGIN_AGAIN);
106
+ }
@@ -0,0 +1,18 @@
1
+ import { clearStoredCredential, devAuthJsonEnabled, setStoredToken, } from "./auth-store.js";
2
+ import { approveUrl, pollForBearer, startDeviceFlow } from "./device-flow.js";
3
+ import { openApproveUrl } from "./open-browser.js";
4
+ import * as out from "./output.js";
5
+ export async function runInteractiveDeviceLogin() {
6
+ const { userCode, deviceCode } = await startDeviceFlow();
7
+ const url = approveUrl(userCode);
8
+ out.step("Authenticate this device:");
9
+ console.log(` Visit: ${url}`);
10
+ console.log(` Enter code when prompted (if blank): ${userCode}\n`);
11
+ openApproveUrl(url);
12
+ const { bearerToken } = await pollForBearer(deviceCode);
13
+ await clearStoredCredential();
14
+ await setStoredToken(bearerToken.trim());
15
+ out.success(devAuthJsonEnabled()
16
+ ? "Logged in — token saved to ~/.envspot/auth.json (ENVSPOT_DEV_AUTH_JSON)."
17
+ : "Logged in — token saved to the OS credential store.");
18
+ }
package/dist/dump.js ADDED
@@ -0,0 +1,37 @@
1
+ import { getStoredToken } from "./auth-store.js";
2
+ import { authRequired } from "./errors.js";
3
+ import * as out from "./output.js";
4
+ import { fetchProjectSecrets, resolveLinkScope } from "./secrets.js";
5
+ import { isHeadlessToken } from "./token-kind.js";
6
+ const SIMPLE_VALUE = /^[A-Za-z0-9_@%+=:,./-]+$/;
7
+ /** Render a dotenv-round-trippable `KEY=value`. Single quotes are literal in dotenv + shells; double quotes only for newline/embedded-`'` values. */
8
+ export function formatEnvLine(key, value) {
9
+ if (SIMPLE_VALUE.test(value))
10
+ return `${key}=${value}`;
11
+ if (!value.includes("'") && !/[\n\r]/.test(value)) {
12
+ return `${key}='${value}'`;
13
+ }
14
+ const escaped = value
15
+ .replace(/\n/g, "\\n")
16
+ .replace(/\r/g, "\\r")
17
+ .replace(/"/g, '\\"');
18
+ return `${key}="${escaped}"`;
19
+ }
20
+ /** `envspot dump` — print the linked project's secrets as `KEY=value` to stdout. */
21
+ export async function executeDump(opts) {
22
+ const token = await getStoredToken();
23
+ if (!token)
24
+ throw authRequired();
25
+ if (process.stdout.isTTY) {
26
+ out.warn("dump prints secrets to stdout. Prefer `envspot run` to keep them off disk; redirect (> .env) only if a tool requires a file.");
27
+ }
28
+ // Headless token self-scopes (CI, no link); session bearer reads the link.
29
+ const secrets = isHeadlessToken(token)
30
+ ? await fetchProjectSecrets(token)
31
+ : await fetchProjectSecrets(token, resolveLinkScope(opts.env));
32
+ const lines = Object.keys(secrets)
33
+ .sort()
34
+ .map((k) => formatEnvLine(k, secrets[k]));
35
+ if (lines.length > 0)
36
+ process.stdout.write(`${lines.join("\n")}\n`);
37
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ const ENV_FILE_NAMES = [
5
+ ".env",
6
+ ".env.local",
7
+ ".env.development",
8
+ ".env.production",
9
+ ];
10
+ function unquote(raw) {
11
+ const v = raw.trim();
12
+ // Double-quoted: dotenv-style — expand \n \r \t and \" (matches dump output).
13
+ if (v.length >= 2 && /^"[\s\S]*"$/.test(v)) {
14
+ return v
15
+ .slice(1, -1)
16
+ .replace(/\\n/g, "\n")
17
+ .replace(/\\r/g, "\r")
18
+ .replace(/\\t/g, "\t")
19
+ .replace(/\\"/g, '"');
20
+ }
21
+ // Single-quoted: literal, no expansion.
22
+ if (v.length >= 2 && /^'[\s\S]*'$/.test(v))
23
+ return v.slice(1, -1);
24
+ return v;
25
+ }
26
+ /** Parse a `.env` file into usable keys + a reasoned list of ignored lines. */
27
+ export function parseEnvFile(content) {
28
+ const keys = [];
29
+ const ignored = [];
30
+ const lines = content.split(/\r?\n/);
31
+ lines.forEach((line, i) => {
32
+ const lineNumber = i + 1;
33
+ const trimmed = line.trim();
34
+ if (trimmed === "") {
35
+ // Ignore blanks but not the empty final segment left by a trailing newline.
36
+ if (i < lines.length - 1)
37
+ ignored.push({ lineNumber, reason: "blank" });
38
+ return;
39
+ }
40
+ if (trimmed.startsWith("#")) {
41
+ ignored.push({ lineNumber, reason: "comment" });
42
+ return;
43
+ }
44
+ const eq = trimmed.indexOf("=");
45
+ if (eq === -1) {
46
+ ignored.push({ lineNumber, reason: "no '=' sign" });
47
+ return;
48
+ }
49
+ const key = trimmed.slice(0, eq).trim();
50
+ if (!KEY_RE.test(key)) {
51
+ ignored.push({ lineNumber, reason: "invalid key" });
52
+ return;
53
+ }
54
+ keys.push({ key, value: unquote(trimmed.slice(eq + 1)) });
55
+ });
56
+ return { keys, ignored };
57
+ }
58
+ /** Names of the `.env*` files that exist in `dir`, in a stable order. */
59
+ export function discoverEnvFiles(dir) {
60
+ return ENV_FILE_NAMES.filter((name) => existsSync(join(dir, name)));
61
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,84 @@
1
+ function code(exitCode) {
2
+ return { exitCode, slug: `E${String(exitCode).padStart(4, "0")}` };
3
+ }
4
+ export const ERR = {
5
+ UNEXPECTED: code(1),
6
+ AUTH_REQUIRED: code(2),
7
+ NOT_LINKED: code(2),
8
+ TRANSPORT: code(3),
9
+ SESSION_EXPIRED: code(4),
10
+ FORBIDDEN: code(5),
11
+ TIER_EXCEEDED: code(6),
12
+ RATE_LIMITED: code(7),
13
+ KMS_UNAVAILABLE: code(8),
14
+ DECRYPT_FAILED: code(9),
15
+ INVALID_INPUT: code(50),
16
+ TTY_REQUIRED: code(51),
17
+ MONOREPO_AMBIGUOUS: code(54),
18
+ NOT_LOGGED_IN: code(70),
19
+ OFFLINE: code(75),
20
+ INIT_LOCKED: code(76),
21
+ // Fly wrapper pre-flight failures. The slug is the user-facing contract
22
+ // (the docs URLs key off it); the >255 exit codes truncate to 8 bits at the
23
+ // OS boundary, which is fine for these interactive setup-time errors.
24
+ FLYCTL_MISSING: code(600),
25
+ FLY_TOML_MISSING: code(601),
26
+ };
27
+ export class CliError extends Error {
28
+ exitCode;
29
+ slug;
30
+ help;
31
+ constructor(errorCode, message, help) {
32
+ super(message);
33
+ this.name = "CliError";
34
+ this.exitCode = errorCode.exitCode;
35
+ this.slug = errorCode.slug;
36
+ this.help = help;
37
+ }
38
+ }
39
+ export function authRequired() {
40
+ return new CliError(ERR.AUTH_REQUIRED, "You aren't logged in.", "Run: envspot login");
41
+ }
42
+ export function notLinked() {
43
+ return new CliError(ERR.NOT_LINKED, "This directory isn't linked.", "Run: envspot link");
44
+ }
45
+ /** `whoami` has its own not-logged-in code (exits 70) so the friendly
46
+ * "run login or init" message is distinct from the bare E0002 path. */
47
+ export function notLoggedIn() {
48
+ return new CliError(ERR.NOT_LOGGED_IN, "Not logged in.", "Run `envspot login` or `envspot init` to authenticate.");
49
+ }
50
+ export function flyctlNotInstalled() {
51
+ return new CliError(ERR.FLYCTL_MISSING, "flyctl is not installed.", "Install flyctl: https://fly.io/docs/getting-started/installing-flyctl/");
52
+ }
53
+ export function flyTomlNotFound() {
54
+ return new CliError(ERR.FLY_TOML_MISSING, "No fly.toml found in this directory.", "Run in a Fly app directory, or pass --app <name>.");
55
+ }
56
+ /** A headless (read-only, CI) token was used for a command that needs an
57
+ * interactive device login (writes, listing, linking). */
58
+ export function deviceLoginRequired(action) {
59
+ return new CliError(ERR.AUTH_REQUIRED, `${action} needs a device login (headless tokens are read-only).`, "Run: envspot login");
60
+ }
61
+ export function sessionExpired() {
62
+ return new CliError(ERR.SESSION_EXPIRED, "Session expired or revoked.", "Run: envspot login");
63
+ }
64
+ export function forbiddenProject(projectRef) {
65
+ return new CliError(ERR.FORBIDDEN, `You don't have access to project ${projectRef}.`, "Run: envspot login (if switching accounts), then retry.");
66
+ }
67
+ export function ttyRequired(command, help) {
68
+ return new CliError(ERR.TTY_REQUIRED, `${command} requires an interactive terminal (TTY).`, help);
69
+ }
70
+ /**
71
+ * Print a CLI error and return its process exit code. Non-CliError throwables
72
+ * are wrapped as E0001. `write` defaults to `console.error` and is injectable
73
+ * for tests.
74
+ */
75
+ export function reportCliError(e, write = (m) => console.error(m)) {
76
+ const err = e instanceof CliError
77
+ ? e
78
+ : new CliError(ERR.UNEXPECTED, e instanceof Error ? e.message : String(e));
79
+ const lines = [`error[${err.slug}]: ${err.message}`];
80
+ if (err.help)
81
+ lines.push(` help: ${err.help}`);
82
+ write(lines.join("\n"));
83
+ return err.exitCode;
84
+ }