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.
@@ -0,0 +1,158 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { constants as osConstants } from "node:os";
5
+ import { getStoredToken } from "./auth-store.js";
6
+ import { authRequired, flyctlNotInstalled, flyTomlNotFound } from "./errors.js";
7
+ import * as out from "./output.js";
8
+ import { fetchProjectSecrets, resolveLinkScope } from "./secrets.js";
9
+ import { isHeadlessToken } from "./token-kind.js";
10
+ /**
11
+ * Parse the tail of `envspot fly deploy …`. `--app`/`--no-secrets` are ours;
12
+ * every other token forwards verbatim to `flyctl deploy`. `--app` is also
13
+ * threaded into the secret-staging call so both halves target the same app.
14
+ */
15
+ export function parseFlyArgs(parts) {
16
+ if (parts[0] !== "deploy") {
17
+ return {
18
+ ok: false,
19
+ message: "Only `envspot fly deploy` is supported. Example: envspot fly deploy --app my-app",
20
+ };
21
+ }
22
+ let app;
23
+ let noSecrets = false;
24
+ const deployArgs = [];
25
+ let i = 1;
26
+ while (i < parts.length) {
27
+ const a = parts[i];
28
+ if (a === "--no-secrets") {
29
+ noSecrets = true;
30
+ i += 1;
31
+ continue;
32
+ }
33
+ if (a === "--app" || a === "-a") {
34
+ // Capture flyctl's app flag (both forms) so secret-staging and deploy
35
+ // target the same app; otherwise `-a` would forward to deploy only and
36
+ // staging would silently hit a different app.
37
+ const v = parts[i + 1];
38
+ if (!v?.trim())
39
+ return { ok: false, message: "`--app` requires an app name." };
40
+ app = v.trim();
41
+ i += 2;
42
+ continue;
43
+ }
44
+ if (a.startsWith("--app=") || a.startsWith("-a=")) {
45
+ const v = a.slice(a.indexOf("=") + 1).trim();
46
+ if (!v)
47
+ return { ok: false, message: "`--app` requires a value." };
48
+ app = v;
49
+ i += 1;
50
+ continue;
51
+ }
52
+ deployArgs.push(a);
53
+ i += 1;
54
+ }
55
+ return { ok: true, app, noSecrets, deployArgs };
56
+ }
57
+ /** Probe `flyctl version`; ENOENT (not on PATH) resolves false, any spawn that
58
+ * starts resolves true regardless of its exit status. */
59
+ function isFlyctlInstalled() {
60
+ return new Promise((resolve) => {
61
+ const child = spawn("flyctl", ["version"], {
62
+ stdio: "ignore",
63
+ shell: false,
64
+ });
65
+ child.on("error", () => resolve(false));
66
+ child.on("close", () => resolve(true));
67
+ });
68
+ }
69
+ function spawnFlyctl(args, input) {
70
+ return new Promise((resolve) => {
71
+ const child = spawn("flyctl", args, {
72
+ // Pipe stdin only when feeding secrets; otherwise inherit so flyctl's
73
+ // interactive prompts and deploy output reach the user's terminal.
74
+ stdio: [input !== undefined ? "pipe" : "inherit", "inherit", "inherit"],
75
+ shell: false,
76
+ });
77
+ child.on("error", (err) => {
78
+ console.error(`Failed to run flyctl: ${err.message}`);
79
+ resolve(1);
80
+ });
81
+ child.on("close", (c, signal) => {
82
+ if (signal) {
83
+ const signum = osConstants.signals[signal] ?? 0;
84
+ resolve(signum ? 128 + signum : (c ?? 1));
85
+ }
86
+ else {
87
+ resolve(c ?? 0);
88
+ }
89
+ });
90
+ if (input !== undefined && child.stdin) {
91
+ // flyctl may exit before draining stdin (bad app, not logged in); an
92
+ // unhandled EPIPE on the stream would crash the CLI mid-deploy. Swallow
93
+ // it and let the `close` handler report the real exit code.
94
+ child.stdin.on("error", () => { });
95
+ child.stdin.write(input);
96
+ child.stdin.end();
97
+ }
98
+ });
99
+ }
100
+ function defaultFlyTomlExists(cwd) {
101
+ return existsSync(join(cwd, "fly.toml"));
102
+ }
103
+ /**
104
+ * `envspot fly deploy` — stage the linked project's secrets onto a Fly app via
105
+ * `flyctl secrets import --stage` (values go over stdin, never argv), then run
106
+ * `flyctl deploy`, forwarding any extra args and propagating flyctl's exit code.
107
+ */
108
+ export async function executeFlyDeploy(parsed, deps = {}) {
109
+ const checkFlyctl = deps.checkFlyctl ?? isFlyctlInstalled;
110
+ const runFlyctl = deps.runFlyctl ?? spawnFlyctl;
111
+ const flyTomlExists = deps.flyTomlExists ?? defaultFlyTomlExists;
112
+ if (!(await checkFlyctl()))
113
+ throw flyctlNotInstalled();
114
+ // flyctl needs an app: either explicit --app, or a fly.toml it reads from cwd.
115
+ if (!parsed.app && !flyTomlExists(process.cwd()))
116
+ throw flyTomlNotFound();
117
+ const appArgs = parsed.app ? ["--app", parsed.app] : [];
118
+ if (!parsed.noSecrets) {
119
+ const token = await getStoredToken();
120
+ if (!token)
121
+ throw authRequired();
122
+ // A headless token self-scopes; a session bearer reads the local link.
123
+ const secrets = isHeadlessToken(token)
124
+ ? await fetchProjectSecrets(token)
125
+ : await fetchProjectSecrets(token, resolveLinkScope());
126
+ const keys = Object.keys(secrets).sort();
127
+ out.step(`Fetched ${keys.length} secret${keys.length === 1 ? "" : "s"} from EnvSpot`);
128
+ if (keys.length === 0) {
129
+ // Likely a wrong env/scope rather than a genuinely empty project — make
130
+ // the silent "deployed with no secrets" footgun visible.
131
+ out.warn("No secrets found for this project/env; deploying without setting any. Pass --no-secrets to silence this.");
132
+ }
133
+ else {
134
+ // flyctl's stdin importer is line-based (NAME=VALUE per line), so a value
135
+ // with an embedded newline can't round-trip. Stage the single-line ones
136
+ // and skip the rest rather than corrupt them; the user sets those by hand.
137
+ const importable = [];
138
+ const skipped = [];
139
+ for (const k of keys) {
140
+ (/[\r\n]/.test(secrets[k]) ? skipped : importable).push(k);
141
+ }
142
+ if (skipped.length > 0) {
143
+ out.warn(`Skipping ${skipped.length} secret${skipped.length === 1 ? "" : "s"} with newline values (flyctl can't import these over stdin): ${skipped.join(", ")}. ` +
144
+ "Set them manually with `flyctl secrets set`.");
145
+ }
146
+ if (importable.length > 0) {
147
+ const kv = importable.map((k) => `${k}=${secrets[k]}`).join("\n");
148
+ out.step(`Staging ${importable.length} secret${importable.length === 1 ? "" : "s"} with flyctl`);
149
+ const importExit = await runFlyctl(["secrets", "import", "--stage", ...appArgs], kv);
150
+ // A failed stage means deploying would ship stale/missing secrets — stop.
151
+ if (importExit !== 0)
152
+ return importExit;
153
+ }
154
+ }
155
+ }
156
+ out.step("flyctl deploy");
157
+ return runFlyctl(["deploy", ...appArgs, ...parsed.deployArgs]);
158
+ }
package/dist/http.js ADDED
@@ -0,0 +1,84 @@
1
+ import { apiBase, cliUserAgent, statusPageHint } from "./config.js";
2
+ import { CliError, ERR } from "./errors.js";
3
+ export function apiUrl(path) {
4
+ const base = apiBase().replace(/\/$/, "");
5
+ return `${base}${path.startsWith("/") ? path : `/${path}`}`;
6
+ }
7
+ export function isLikelyTransportFailure(err) {
8
+ if (err instanceof TypeError)
9
+ return true;
10
+ if (!(err instanceof Error))
11
+ return false;
12
+ const c = err.cause;
13
+ if (c && typeof c === "object" && "code" in c) {
14
+ const code = String(c.code);
15
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ENETUNREACH|ECONNRESET/.test(code)) {
16
+ return true;
17
+ }
18
+ }
19
+ return /fetch failed|network/i.test(err.message);
20
+ }
21
+ /** Rethrow a caught error: a typed E0003 when it's a transport fault, else as-is. */
22
+ export function rethrowTransport(err) {
23
+ if (isLikelyTransportFailure(err)) {
24
+ throw new CliError(ERR.TRANSPORT, describeTransportFailure(err));
25
+ }
26
+ throw err;
27
+ }
28
+ export function describeTransportFailure(err) {
29
+ const msg = err instanceof Error
30
+ ? `${err.message}${err.cause instanceof Error ? ` (${err.cause.message})` : ""}`
31
+ : String(err);
32
+ return (`Couldn't reach EnvSpot. Check your connection.\n` +
33
+ `${statusPageHint()}\n` +
34
+ ` Detail: ${msg}`);
35
+ }
36
+ /** 3× backoff for transport faults (plus first attempt ⇒ 4 tries). */
37
+ export async function backoffTransport(exec) {
38
+ let lastErr;
39
+ for (let attempt = 0; attempt < 4; attempt++) {
40
+ try {
41
+ return await exec();
42
+ }
43
+ catch (e) {
44
+ lastErr = e;
45
+ if (attempt === 3)
46
+ break;
47
+ const ms = Math.min(3000, 250 * 2 ** attempt);
48
+ await new Promise((r) => setTimeout(r, ms));
49
+ }
50
+ }
51
+ throw lastErr;
52
+ }
53
+ export async function fetchWithCliHeaders(input, init, bearerToken) {
54
+ return fetch(input, {
55
+ ...init,
56
+ headers: {
57
+ Accept: "application/json",
58
+ "User-Agent": cliUserAgent(),
59
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
60
+ ...init?.headers,
61
+ },
62
+ });
63
+ }
64
+ export async function parseJsonSafely(res) {
65
+ const text = await res.text();
66
+ try {
67
+ return { text, json: JSON.parse(text) };
68
+ }
69
+ catch {
70
+ return { text, json: null };
71
+ }
72
+ }
73
+ export function reasonFromJson(j) {
74
+ if (!j)
75
+ return undefined;
76
+ const r = j.reason;
77
+ return typeof r === "string" ? r : undefined;
78
+ }
79
+ export function upgradeUrlFromJson(j) {
80
+ if (!j)
81
+ return undefined;
82
+ const u = j.upgradeUrl;
83
+ return typeof u === "string" ? u : undefined;
84
+ }
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ import { stdin as stdinStream } from "node:process";
3
+ import { Command } from "commander";
4
+ import { clearStoredCredential, getStoredToken, resolveToken, setStoredToken, } from "./auth-store.js";
5
+ import { apiBase, appOrigin, packageVersion } from "./config.js";
6
+ import { cliApiError } from "./cli-api-error.js";
7
+ import { runInteractiveDeviceLogin } from "./device-login.js";
8
+ import { apiUrl, backoffTransport, describeTransportFailure, fetchWithCliHeaders, isLikelyTransportFailure, parseJsonSafely, } from "./http.js";
9
+ import { executeDump } from "./dump.js";
10
+ import { runInit } from "./init-flow.js";
11
+ import { CliError, ERR, authRequired, deviceLoginRequired, notLinked, reportCliError, } from "./errors.js";
12
+ import { runLinkInteractive, runLinkWithFlags } from "./link-flow.js";
13
+ import { findEnvspotLink } from "./paths.js";
14
+ import { executeRun, parseRunTailArgs } from "./run-handler.js";
15
+ import { executeWhoami } from "./whoami.js";
16
+ import { executeFlyDeploy, parseFlyArgs } from "./fly-deploy.js";
17
+ import { isHeadlessToken, probePathForToken } from "./token-kind.js";
18
+ function experimentalEnabled() {
19
+ const v = process.env.ENVSPOT_EXPERIMENTAL?.trim();
20
+ return v === "1" || v?.toLowerCase() === "true" || v?.toLowerCase() === "yes";
21
+ }
22
+ async function main() {
23
+ const argv = process.argv;
24
+ if (argv[2] === "run") {
25
+ try {
26
+ const parsed = parseRunTailArgs(argv.slice(3));
27
+ if (!parsed.ok)
28
+ throw new CliError(ERR.INVALID_INPUT, parsed.message);
29
+ process.exit(await executeRun(parsed));
30
+ }
31
+ catch (e) {
32
+ process.exit(reportCliError(e));
33
+ }
34
+ return;
35
+ }
36
+ // `fly deploy` forwards arbitrary flyctl flags, so it bypasses commander
37
+ // (which would reject unknown options) the same way `run` does.
38
+ if (argv[2] === "fly") {
39
+ try {
40
+ const parsed = parseFlyArgs(argv.slice(3));
41
+ if (!parsed.ok)
42
+ throw new CliError(ERR.INVALID_INPUT, parsed.message);
43
+ process.exit(await executeFlyDeploy(parsed));
44
+ }
45
+ catch (e) {
46
+ process.exit(reportCliError(e));
47
+ }
48
+ return;
49
+ }
50
+ const program = new Command();
51
+ program
52
+ .name("envspot")
53
+ .description("Encrypted environment variables for your team.")
54
+ .version(packageVersion());
55
+ program.addHelpText("afterAll", `\nExamples:
56
+ envspot init
57
+ envspot login
58
+ envspot link
59
+ envspot run -- npm start
60
+ `);
61
+ program
62
+ .command("init")
63
+ .description("Create a project from this directory, link it, import your .env, and start your dev server.")
64
+ .option("--cwd <path>", "Operate against a different directory.")
65
+ .option("--no-import", "Skip .env discovery and import.")
66
+ .option("-y, --yes", "Non-interactive: accept defaults, import all .env, auto-run.")
67
+ .option("--strict", "Fail on unparsable .env lines instead of warning.")
68
+ .option("--no-run", "Skip the post-init dev-server prompt.")
69
+ .action(async (opts) => {
70
+ process.exit(await runInit(opts));
71
+ });
72
+ program
73
+ .command("login")
74
+ .description("Sign in via browser device pairing (recommended). Optionally store an API key (token login).")
75
+ .option("--token <key>", "Persist an organization API key (legacy login).")
76
+ .option("--legacy-api-token <key>", "Alias for --token (deprecated).")
77
+ .action(async (opts) => {
78
+ const leg = (opts.token ?? opts.legacyApiToken)?.trim();
79
+ if (leg) {
80
+ console.warn("Token login stores an organization API key in your credential store. Prefer device login when possible.\n" +
81
+ "Tip: run `envspot login` without flags for the device flow.");
82
+ // An org API key is a headless token (read-only, project-scoped); a
83
+ // device bearer pasted here is session-kind. Probe the endpoint that
84
+ // accepts the token's kind so the validity check doesn't false-fail.
85
+ const probePath = probePathForToken(leg);
86
+ try {
87
+ await backoffTransport(async () => {
88
+ const res = await fetchWithCliHeaders(apiUrl(probePath), { method: "GET" }, leg);
89
+ if (!res.ok) {
90
+ const t = await res.text();
91
+ throw new Error(`Auth check failed (${res.status}): ${t.slice(0, 380)}`);
92
+ }
93
+ });
94
+ }
95
+ catch (e) {
96
+ if (isLikelyTransportFailure(e)) {
97
+ console.error(describeTransportFailure(e));
98
+ process.exit(3);
99
+ }
100
+ console.error(e instanceof Error ? e.message : String(e));
101
+ process.exit(1);
102
+ }
103
+ await clearStoredCredential();
104
+ await setStoredToken(leg);
105
+ console.log("Saved credential (experimental).");
106
+ return;
107
+ }
108
+ await runInteractiveDeviceLogin();
109
+ });
110
+ program
111
+ .command("logout")
112
+ .description("Remove the stored CLI credential (keychain entry or dev file).")
113
+ .action(async () => {
114
+ await clearStoredCredential();
115
+ console.log("Logged out.");
116
+ });
117
+ program
118
+ .command("link")
119
+ .description("Write ./.envspot.json (projectId + env). Omit --project for interactive picker.")
120
+ .option("-p, --project <slugOrId>", "Project slug (Dashboard → Projects) or id.")
121
+ .option("-e, --env <name>", 'Environment shorthand (default: "development" → stored as dev).', "development")
122
+ .action(async (opts) => {
123
+ if (opts.project?.trim()) {
124
+ await runLinkWithFlags(opts.project.trim(), opts.env ?? "development");
125
+ }
126
+ else {
127
+ await runLinkInteractive();
128
+ }
129
+ });
130
+ program
131
+ .command("dump")
132
+ .description("Print the linked project's secrets as KEY=value to stdout (pipe-friendly; nothing is written to disk).")
133
+ .option("-e, --env <name>", "Override ./.envspot.json environment.")
134
+ .action(async (opts) => {
135
+ await executeDump({ env: opts.env });
136
+ });
137
+ program
138
+ .command("status")
139
+ .description("Print API/app URL and credential/link hints.")
140
+ .action(async () => {
141
+ const { token, source } = await resolveToken();
142
+ const SOURCE_LABEL = {
143
+ env: "ENVSPOT_TOKEN (env)",
144
+ "dev-file": "~/.envspot/auth.json (ENVSPOT_DEV_AUTH_JSON)",
145
+ keychain: "OS keychain (keytar)",
146
+ none: "none",
147
+ };
148
+ console.log(`API base: ${apiBase()}`);
149
+ console.log(`App origin: ${appOrigin()}`);
150
+ console.log(`Token source: ${SOURCE_LABEL[source]}`);
151
+ if (token) {
152
+ const probePath = probePathForToken(token);
153
+ try {
154
+ const res = await fetchWithCliHeaders(apiUrl(probePath), { method: "GET" }, token);
155
+ console.log(`Token probe: GET ${probePath} → HTTP ${res.status}`);
156
+ }
157
+ catch {
158
+ console.log("Token probe failed (offline).");
159
+ }
160
+ }
161
+ else {
162
+ console.log("No credential stored.");
163
+ }
164
+ const found = findEnvspotLink(process.cwd());
165
+ console.log("Project link:", found
166
+ ? `${found.link.projectId} / ${found.link.environment}`
167
+ : "none (walked cwd → parents).");
168
+ });
169
+ program
170
+ .command("whoami")
171
+ .description("Show the signed-in user, workspace, and active link or token scope.")
172
+ .option("--json", "Print the raw JSON response (for scripts/CI).")
173
+ .action(async (opts) => {
174
+ await executeWhoami({ json: opts.json });
175
+ });
176
+ registerSecretsCommands(program);
177
+ if (experimentalEnabled())
178
+ registerExperimentalNoteCommand(program);
179
+ await program.parseAsync(argv);
180
+ }
181
+ function readStdinUtf8() {
182
+ return new Promise((resolve, reject) => {
183
+ const chunks = [];
184
+ stdinStream.on("data", (c) => chunks.push(c));
185
+ stdinStream.on("end", () => {
186
+ resolve(Buffer.concat(chunks)
187
+ .toString("utf8")
188
+ .replace(/\r?\n$/, ""));
189
+ });
190
+ stdinStream.on("error", reject);
191
+ });
192
+ }
193
+ function registerSecretsCommands(program) {
194
+ program
195
+ .command("set")
196
+ .description("POST one secret variable for linked project.")
197
+ .argument("<key>", "Variable name")
198
+ .argument("[values...]", "Value; omit on TTY to require piping stdin. Words join with spaces.")
199
+ .option("-e, --env <name>", "Override ./.envspot.json environment.")
200
+ .action(async (key, valueParts, opts) => {
201
+ const token = await getStoredToken();
202
+ if (!token)
203
+ throw authRequired();
204
+ if (isHeadlessToken(token))
205
+ throw deviceLoginRequired("Setting secrets");
206
+ const found = findEnvspotLink(process.cwd());
207
+ if (!found)
208
+ throw notLinked();
209
+ const name = key.trim();
210
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
211
+ throw new CliError(ERR.INVALID_INPUT, "Invalid key: letters, digits, underscore; must start with letter or underscore.");
212
+ }
213
+ const environment = persistEnvForUpsert(opts.env ?? found.link.environment);
214
+ let value;
215
+ if (valueParts.length > 0) {
216
+ value = valueParts.join(" ");
217
+ }
218
+ else if (!process.stdin.isTTY) {
219
+ value = await readStdinUtf8();
220
+ }
221
+ else {
222
+ throw new CliError(ERR.INVALID_INPUT, "Missing value.", 'Example: envspot set MY_KEY "secret" | cat key.txt | envspot set MY_KEY');
223
+ }
224
+ const res = await fetchWithCliHeaders(apiUrl("/api/cli/secrets"), {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify({
228
+ project: found.link.projectId,
229
+ environment,
230
+ secrets: [{ key: name, value }],
231
+ }),
232
+ }, token);
233
+ if (!res.ok) {
234
+ const { json } = await parseJsonSafely(res);
235
+ throw await cliApiError(res.status, json, found.link.projectId);
236
+ }
237
+ console.log(`Set ${name} for project ${found.link.projectId}.`);
238
+ });
239
+ program
240
+ .command("unset")
241
+ .description("DELETE one secret variable for linked project.")
242
+ .argument("<key>", "Variable name")
243
+ .option("-e, --env <name>", "Override ./.envspot.json environment.")
244
+ .action(async (key, opts) => {
245
+ const token = await getStoredToken();
246
+ if (!token)
247
+ throw authRequired();
248
+ if (isHeadlessToken(token))
249
+ throw deviceLoginRequired("Removing secrets");
250
+ const found = findEnvspotLink(process.cwd());
251
+ if (!found)
252
+ throw notLinked();
253
+ const name = key.trim();
254
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
255
+ throw new CliError(ERR.INVALID_INPUT, "Invalid key: letters, digits, underscore; must start with letter or underscore.");
256
+ }
257
+ const environment = persistEnvForUpsert(opts.env ?? found.link.environment);
258
+ const qp = new URLSearchParams({
259
+ project: found.link.projectId,
260
+ env: environment,
261
+ key: name,
262
+ });
263
+ const res = await fetchWithCliHeaders(apiUrl(`/api/cli/secrets?${qp.toString()}`), { method: "DELETE" }, token);
264
+ if (!res.ok) {
265
+ const { json } = await parseJsonSafely(res);
266
+ throw await cliApiError(res.status, json, found.link.projectId);
267
+ }
268
+ console.log(`Removed ${name}.`);
269
+ });
270
+ }
271
+ function registerExperimentalNoteCommand(program) {
272
+ program
273
+ .command("_experimental")
274
+ .description("Print a note about experimental behavior toggles.")
275
+ .action(async () => {
276
+ console.warn("[ENVSPOT_EXPERIMENTAL=1 enabled]");
277
+ console.warn("Note: token login and set/unset are available without this flag.");
278
+ });
279
+ }
280
+ /** Map stored ./.envspot.json shorthand to API body `environment` field. */
281
+ function persistEnvForUpsert(shorthand) {
282
+ const s = shorthand.trim().toLowerCase();
283
+ if (s === "dev" || s === "development" || s === "local")
284
+ return "development";
285
+ if (s === "prod" || s === "production")
286
+ return "production";
287
+ if (s === "stage" || s === "staging")
288
+ return "staging";
289
+ if (s === "custom")
290
+ return "custom";
291
+ return shorthand.trim();
292
+ }
293
+ void main().catch((e) => {
294
+ process.exit(reportCliError(e));
295
+ });