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 +21 -0
- package/README.md +26 -0
- package/dist/auth-store.js +67 -0
- package/dist/cli-api-error.js +48 -0
- package/dist/config.js +45 -0
- package/dist/device-flow.js +106 -0
- package/dist/device-login.js +18 -0
- package/dist/dump.js +37 -0
- package/dist/env-parse.js +61 -0
- package/dist/errors.js +84 -0
- package/dist/fly-deploy.js +158 -0
- package/dist/http.js +84 -0
- package/dist/index.js +295 -0
- package/dist/init-flow.js +411 -0
- package/dist/init-lock.js +64 -0
- package/dist/link-flow.js +140 -0
- package/dist/open-browser.js +29 -0
- package/dist/output.js +21 -0
- package/dist/paths.js +68 -0
- package/dist/project-detect.js +88 -0
- package/dist/run-handler.js +94 -0
- package/dist/secrets.js +50 -0
- package/dist/token-kind.js +14 -0
- package/dist/whoami.js +93 -0
- package/package.json +52 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { clearStoredCredential, setStoredToken } from "./auth-store.js";
|
|
6
|
+
import { cliApiError } from "./cli-api-error.js";
|
|
7
|
+
import { apiBase } from "./config.js";
|
|
8
|
+
import { approveUrl, pollForBearer, startDeviceFlow } from "./device-flow.js";
|
|
9
|
+
import { discoverEnvFiles, parseEnvFile } from "./env-parse.js";
|
|
10
|
+
import { CliError, ERR, ttyRequired } from "./errors.js";
|
|
11
|
+
import { apiUrl, backoffTransport, fetchWithCliHeaders, parseJsonSafely, rethrowTransport, } from "./http.js";
|
|
12
|
+
import { acquireInitLock, releaseInitLock } from "./init-lock.js";
|
|
13
|
+
import { ensureGitignorePatterns } from "./link-flow.js";
|
|
14
|
+
import { openApproveUrl } from "./open-browser.js";
|
|
15
|
+
import * as out from "./output.js";
|
|
16
|
+
import { writeLinkConfig } from "./paths.js";
|
|
17
|
+
import { deriveProjectName, findPackageJsons, findProjectRoot, } from "./project-detect.js";
|
|
18
|
+
import { executeRun, parseRunTailArgs } from "./run-handler.js";
|
|
19
|
+
const NAME_MAX = 120;
|
|
20
|
+
const SLUG_MAX = 40;
|
|
21
|
+
/** Server appends `-N` on a slug collision, so the collision base is slug[:38]. */
|
|
22
|
+
const SLUG_BASE_LEN = SLUG_MAX - 2;
|
|
23
|
+
const PREVIEW_MASK = "••••••••";
|
|
24
|
+
/** Mirrors the server's reserved project slugs (this package can't import server
|
|
25
|
+
* code). The create endpoint rejects these, and the rejection lands AFTER the
|
|
26
|
+
* user approves in-browser — so catch them here before the slug is ever sent. */
|
|
27
|
+
const RESERVED_SLUGS = new Set(["new", "_dev", "archived"]);
|
|
28
|
+
// ── Pure helpers ──────────────────────────────────────────────────────────
|
|
29
|
+
function stripControl(s) {
|
|
30
|
+
return [...s].filter((c) => c.charCodeAt(0) >= 32).join("");
|
|
31
|
+
}
|
|
32
|
+
/** Coerce a derived name + slug into something the create endpoint accepts:
|
|
33
|
+
* control-free name ≤120 chars, kebab-case slug ≤40 chars, no trailing dash. */
|
|
34
|
+
export function clampForServer(rawName, rawSlug) {
|
|
35
|
+
const projectName = stripControl(rawName).trim().slice(0, NAME_MAX).trim() || "project";
|
|
36
|
+
const slug = rawSlug
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
39
|
+
.replace(/^-+|-+$/g, "")
|
|
40
|
+
.slice(0, SLUG_MAX)
|
|
41
|
+
.replace(/-+$/, "") || "project";
|
|
42
|
+
const projectSlug = RESERVED_SLUGS.has(slug) ? `${slug}-app` : slug;
|
|
43
|
+
return { projectName, projectSlug };
|
|
44
|
+
}
|
|
45
|
+
/** Decide how to resolve the project root when several manifests are present.
|
|
46
|
+
* A pinned `--cwd` or a single manifest needs no choice; `--yes` must never
|
|
47
|
+
* silently pick one of several. */
|
|
48
|
+
export function chooseProjectRoot(input) {
|
|
49
|
+
if (input.packageJsons.length <= 1 || input.cwdPinned)
|
|
50
|
+
return { kind: "single" };
|
|
51
|
+
if (input.yes)
|
|
52
|
+
return { kind: "ambiguous-yes" };
|
|
53
|
+
return { kind: "prompt", choices: input.packageJsons };
|
|
54
|
+
}
|
|
55
|
+
/** Find the just-created project in the org list. The approve step can suffix
|
|
56
|
+
* the slug on collision (`api`→`api-2`) without telling the CLI, so resolution
|
|
57
|
+
* must consider the whole `base-N` family — and refuse to guess when the exact
|
|
58
|
+
* slug AND a suffixed sibling both exist (a real collision). */
|
|
59
|
+
export function resolveCreatedProject(projects, postedSlug) {
|
|
60
|
+
const exact = projects.find((p) => p.slug === postedSlug);
|
|
61
|
+
const base = postedSlug.slice(0, SLUG_BASE_LEN).replace(/[^a-z0-9-]/g, "");
|
|
62
|
+
const familyRe = new RegExp(`^${base}-[2-9]$`);
|
|
63
|
+
const suffixed = projects.filter((p) => familyRe.test(p.slug));
|
|
64
|
+
if (exact && suffixed.length === 0)
|
|
65
|
+
return { kind: "unique", project: exact };
|
|
66
|
+
if (exact)
|
|
67
|
+
return { kind: "ambiguous", candidates: [exact, ...suffixed] };
|
|
68
|
+
if (suffixed.length === 1)
|
|
69
|
+
return { kind: "unique", project: suffixed[0] };
|
|
70
|
+
if (suffixed.length > 1)
|
|
71
|
+
return { kind: "ambiguous", candidates: suffixed };
|
|
72
|
+
return { kind: "none" };
|
|
73
|
+
}
|
|
74
|
+
/** Key names with masked values, capped to `maxShown` plus a `+ N more` tail. */
|
|
75
|
+
export function redactedPreview(keys, maxShown = 4) {
|
|
76
|
+
const lines = keys.slice(0, maxShown).map((k) => ` ▸ ${k}=${PREVIEW_MASK}`);
|
|
77
|
+
if (keys.length > maxShown)
|
|
78
|
+
lines.push(` + ${keys.length - maxShown} more`);
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
/** Merge per-file key lists; later files win on a duplicate key. */
|
|
82
|
+
export function mergeEnvKeys(files) {
|
|
83
|
+
const map = new Map();
|
|
84
|
+
for (const file of files)
|
|
85
|
+
for (const { key, value } of file)
|
|
86
|
+
map.set(key, value);
|
|
87
|
+
return [...map].map(([key, value]) => ({ key, value }));
|
|
88
|
+
}
|
|
89
|
+
export function summarizeIgnored(ignored) {
|
|
90
|
+
const n = ignored.length;
|
|
91
|
+
if (n === 0)
|
|
92
|
+
return "";
|
|
93
|
+
return `${n} line${n === 1 ? "" : "s"} ignored`;
|
|
94
|
+
}
|
|
95
|
+
/** True if `baseUrl` answers within `timeoutMs` (any HTTP status counts —
|
|
96
|
+
* only a DNS/connect/timeout failure means offline). */
|
|
97
|
+
export async function probeReachable(baseUrl, opts = {}) {
|
|
98
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
99
|
+
const ctrl = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 2000);
|
|
101
|
+
try {
|
|
102
|
+
await fetchImpl(baseUrl, { method: "GET", signal: ctrl.signal });
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const isNo = (answer) => answer.trim().toLowerCase().startsWith("n");
|
|
113
|
+
const isYes = (answer) => answer.trim().toLowerCase().startsWith("y");
|
|
114
|
+
function relativeTime(iso, nowMs) {
|
|
115
|
+
const t = Date.parse(iso);
|
|
116
|
+
if (Number.isNaN(t))
|
|
117
|
+
return "just now";
|
|
118
|
+
const s = Math.max(0, Math.round((nowMs - t) / 1000));
|
|
119
|
+
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
|
120
|
+
}
|
|
121
|
+
/** Ask for a 1-based list pick and return its 0-based index, or throw E0050. */
|
|
122
|
+
async function promptChoice(prompt, count) {
|
|
123
|
+
const n = Number((await prompt("Enter number: ")).trim());
|
|
124
|
+
if (!Number.isInteger(n) || n < 1 || n > count) {
|
|
125
|
+
throw new CliError(ERR.INVALID_INPUT, "Invalid selection.");
|
|
126
|
+
}
|
|
127
|
+
return n - 1;
|
|
128
|
+
}
|
|
129
|
+
function readPackageScripts(root) {
|
|
130
|
+
try {
|
|
131
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
132
|
+
return pkg.scripts ?? {};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Run a request through backoff, mapping transport faults to a typed E0003. */
|
|
139
|
+
async function sendWithBackoff(exec) {
|
|
140
|
+
try {
|
|
141
|
+
return await backoffTransport(exec);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
rethrowTransport(e);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function fetchOrgProjects(token) {
|
|
148
|
+
const res = await sendWithBackoff(async () => fetchWithCliHeaders(apiUrl("/api/cli/projects"), { method: "GET" }, token));
|
|
149
|
+
const { json } = await parseJsonSafely(res);
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
throw await cliApiError(res.status, json);
|
|
152
|
+
const projects = json?.projects;
|
|
153
|
+
return Array.isArray(projects) ? projects : [];
|
|
154
|
+
}
|
|
155
|
+
async function postInitContext(body) {
|
|
156
|
+
const res = await sendWithBackoff(async () => fetchWithCliHeaders(apiUrl("/api/cli/init/context"), {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/json" },
|
|
159
|
+
body: JSON.stringify(body),
|
|
160
|
+
}));
|
|
161
|
+
if (res.ok)
|
|
162
|
+
return;
|
|
163
|
+
if (res.status === 429) {
|
|
164
|
+
throw new CliError(ERR.RATE_LIMITED, "Rate limit hit.", "Try again shortly.");
|
|
165
|
+
}
|
|
166
|
+
throw new CliError(ERR.UNEXPECTED, `Couldn't register the project for approval (${res.status}).`, "Run: envspot init");
|
|
167
|
+
}
|
|
168
|
+
async function importSecrets(token, projectId, secrets) {
|
|
169
|
+
const res = await sendWithBackoff(async () => fetchWithCliHeaders(apiUrl("/api/cli/secrets"), {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "Content-Type": "application/json" },
|
|
172
|
+
body: JSON.stringify({
|
|
173
|
+
project: projectId,
|
|
174
|
+
environment: "development",
|
|
175
|
+
secrets,
|
|
176
|
+
}),
|
|
177
|
+
}, token));
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const { json } = await parseJsonSafely(res);
|
|
180
|
+
throw await cliApiError(res.status, json, projectId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Resolve the effective project root, prompting on monorepo ambiguity. */
|
|
184
|
+
async function resolveRoot(opts, deps) {
|
|
185
|
+
const start = resolve(opts.cwd ?? process.cwd());
|
|
186
|
+
const root = findProjectRoot(start);
|
|
187
|
+
if (!root) {
|
|
188
|
+
throw new CliError(ERR.INVALID_INPUT, "No project detected.", "Run inside a project directory (package.json, go.mod, .git, …) or pass --cwd <path>.");
|
|
189
|
+
}
|
|
190
|
+
const packageJsons = findPackageJsons(root);
|
|
191
|
+
const choice = chooseProjectRoot({
|
|
192
|
+
packageJsons,
|
|
193
|
+
cwdPinned: opts.cwd != null,
|
|
194
|
+
yes: opts.yes === true,
|
|
195
|
+
});
|
|
196
|
+
if (choice.kind === "single")
|
|
197
|
+
return root;
|
|
198
|
+
if (choice.kind === "ambiguous-yes") {
|
|
199
|
+
throw new CliError(ERR.MONOREPO_AMBIGUOUS, `Multiple projects detected in ${root}; can't pick one with --yes.`, "Pass --cwd <path> to pin a project, or drop --yes and pick interactively.");
|
|
200
|
+
}
|
|
201
|
+
out.step("Multiple projects detected. Which one are you initializing?");
|
|
202
|
+
choice.choices.forEach((p, i) => out.info(` ${i + 1}. ${p}`));
|
|
203
|
+
const idx = await promptChoice(deps.prompt, choice.choices.length);
|
|
204
|
+
return join(root, dirname(choice.choices[idx]));
|
|
205
|
+
}
|
|
206
|
+
/** Collect the secrets to import from the discovered `.env*` files. */
|
|
207
|
+
async function collectImport(root, opts, deps) {
|
|
208
|
+
if (opts.import === false)
|
|
209
|
+
return [];
|
|
210
|
+
const files = discoverEnvFiles(root).flatMap((name) => {
|
|
211
|
+
try {
|
|
212
|
+
return [
|
|
213
|
+
{ name, parsed: parseEnvFile(readFileSync(join(root, name), "utf8")) },
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
out.warn(`Skipped ${name} (could not read it).`);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
if (files.length === 0)
|
|
222
|
+
return [];
|
|
223
|
+
const ignored = files.flatMap((f) => f.parsed.ignored);
|
|
224
|
+
if (opts.strict && ignored.length > 0) {
|
|
225
|
+
out.warn(`${ignored.length} .env line(s) could not be parsed:`);
|
|
226
|
+
for (const f of files) {
|
|
227
|
+
for (const ig of f.parsed.ignored) {
|
|
228
|
+
out.info(` ${f.name}:${ig.lineNumber} — ${ig.reason}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (opts.yes) {
|
|
232
|
+
throw new CliError(ERR.INVALID_INPUT, "Unparsed .env lines under --strict.", "Fix the lines, or drop --strict.");
|
|
233
|
+
}
|
|
234
|
+
if (!isYes(await deps.prompt("Continue anyway? [y/N] "))) {
|
|
235
|
+
throw new CliError(ERR.INVALID_INPUT, "Aborted due to unparsed .env lines.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (ignored.length > 0) {
|
|
239
|
+
out.warn(`${summarizeIgnored(ignored)} — re-run with --strict to inspect.`);
|
|
240
|
+
}
|
|
241
|
+
const accepted = [];
|
|
242
|
+
for (const f of files) {
|
|
243
|
+
if (f.parsed.keys.length === 0)
|
|
244
|
+
continue;
|
|
245
|
+
if (opts.yes) {
|
|
246
|
+
accepted.push(f.parsed.keys);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
out.step(`Found ${f.name} with ${f.parsed.keys.length} key(s):`);
|
|
250
|
+
for (const line of redactedPreview(f.parsed.keys.map((k) => k.key))) {
|
|
251
|
+
out.info(line);
|
|
252
|
+
}
|
|
253
|
+
if (!isNo(await deps.prompt(`Import ${f.name} to envspot? [Y/n] `))) {
|
|
254
|
+
accepted.push(f.parsed.keys);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Later files win on a shared key; surface it so a .env.production value
|
|
258
|
+
// doesn't silently shadow a .env one without the user noticing.
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
const collided = new Set();
|
|
261
|
+
for (const { key } of accepted.flat()) {
|
|
262
|
+
if (seen.has(key))
|
|
263
|
+
collided.add(key);
|
|
264
|
+
else
|
|
265
|
+
seen.add(key);
|
|
266
|
+
}
|
|
267
|
+
if (collided.size > 0) {
|
|
268
|
+
out.warn(`Defined in multiple files (last wins): ${[...collided].join(", ")}`);
|
|
269
|
+
}
|
|
270
|
+
return mergeEnvKeys(accepted);
|
|
271
|
+
}
|
|
272
|
+
/** Locate the just-created project. Prefers the projectId the poll handed back
|
|
273
|
+
* (deterministic); falls back to slug resolution when the server didn't supply
|
|
274
|
+
* one (the narrow redeem-before-write race, or a create that failed). */
|
|
275
|
+
export function selectCreatedProject(projects, target) {
|
|
276
|
+
if (target.projectId) {
|
|
277
|
+
const wantedId = target.projectId.toLowerCase();
|
|
278
|
+
const byId = projects.find((p) => p.id.toLowerCase() === wantedId);
|
|
279
|
+
if (byId)
|
|
280
|
+
return { kind: "unique", project: byId };
|
|
281
|
+
}
|
|
282
|
+
return resolveCreatedProject(projects, target.slug);
|
|
283
|
+
}
|
|
284
|
+
/** Resolve the created project, prompting to disambiguate a slug collision when
|
|
285
|
+
* the id path didn't apply. */
|
|
286
|
+
async function pickCreatedProject(token, target, opts, deps) {
|
|
287
|
+
const projects = await fetchOrgProjects(token);
|
|
288
|
+
const resolved = selectCreatedProject(projects, target);
|
|
289
|
+
if (resolved.kind === "unique")
|
|
290
|
+
return resolved.project;
|
|
291
|
+
if (resolved.kind === "none") {
|
|
292
|
+
throw new CliError(ERR.UNEXPECTED, "Approved this device, but the project wasn't created server-side.", "You're logged in. Check the dashboard, then run `envspot link` — or re-run `envspot init`.");
|
|
293
|
+
}
|
|
294
|
+
// Ambiguous: a name collision produced suffixed siblings. Never guess which
|
|
295
|
+
// one to import secrets into.
|
|
296
|
+
if (opts.yes || !deps.isTTY) {
|
|
297
|
+
throw new CliError(ERR.UNEXPECTED, "The project name collided with an existing project; can't tell which is new.", "Run: envspot link to finish linking, then envspot set/run.");
|
|
298
|
+
}
|
|
299
|
+
out.step("Project name collided. Which project did you just create?");
|
|
300
|
+
resolved.candidates.forEach((p, i) => out.info(` ${i + 1}. ${p.name} (${p.slug})`));
|
|
301
|
+
const idx = await promptChoice(deps.prompt, resolved.candidates.length);
|
|
302
|
+
return resolved.candidates[idx];
|
|
303
|
+
}
|
|
304
|
+
export async function runInit(opts, depsIn) {
|
|
305
|
+
const isTTY = depsIn?.isTTY ?? process.stdout.isTTY === true;
|
|
306
|
+
const interactive = isTTY && opts.yes !== true && !depsIn?.prompt;
|
|
307
|
+
const rl = interactive ? readline.createInterface({ input, output }) : null;
|
|
308
|
+
const deps = {
|
|
309
|
+
isTTY,
|
|
310
|
+
now: depsIn?.now ?? (() => Date.now()),
|
|
311
|
+
probeFetch: depsIn?.probeFetch ?? fetch,
|
|
312
|
+
openBrowser: depsIn?.openBrowser ?? openApproveUrl,
|
|
313
|
+
prompt: depsIn?.prompt ??
|
|
314
|
+
(rl
|
|
315
|
+
? (q) => rl.question(q)
|
|
316
|
+
: async () => {
|
|
317
|
+
throw new CliError(ERR.TTY_REQUIRED, "No interactive terminal.");
|
|
318
|
+
}),
|
|
319
|
+
};
|
|
320
|
+
let lockedRoot = null;
|
|
321
|
+
try {
|
|
322
|
+
// CI / non-interactive needs --yes; otherwise prompts would hang.
|
|
323
|
+
if (!isTTY && opts.yes !== true) {
|
|
324
|
+
throw ttyRequired("envspot init", "Pass --yes for non-interactive use, or mint a headless token for CI.");
|
|
325
|
+
}
|
|
326
|
+
if (!(await probeReachable(apiBase(), { fetchImpl: deps.probeFetch }))) {
|
|
327
|
+
throw new CliError(ERR.OFFLINE, `Could not reach ${apiBase()}.`, "Check your network. envspot init needs a browser for device authorization.");
|
|
328
|
+
}
|
|
329
|
+
const root = await resolveRoot(opts, deps);
|
|
330
|
+
const lock = acquireInitLock(root);
|
|
331
|
+
if (!lock.ok) {
|
|
332
|
+
throw new CliError(ERR.INIT_LOCKED, `Another envspot init is running here (PID ${lock.pid}, started ${relativeTime(lock.startedAt, deps.now())}).`, "Wait for it to finish, or delete .envspot.json.lock if it's stale.");
|
|
333
|
+
}
|
|
334
|
+
lockedRoot = root;
|
|
335
|
+
out.step(`Bootstrapping envspot in ${root}`);
|
|
336
|
+
const derived = deriveProjectName(root);
|
|
337
|
+
const { projectName, projectSlug } = clampForServer(derived.name, derived.slug);
|
|
338
|
+
out.info(`Project: ${projectName} (slug: ${projectSlug})`);
|
|
339
|
+
if (opts.yes !== true &&
|
|
340
|
+
isNo(await deps.prompt("Is this correct? [Y/n] "))) {
|
|
341
|
+
out.info("Cancelled.");
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
const secrets = await collectImport(root, opts, deps);
|
|
345
|
+
const { userCode, deviceCode } = await startDeviceFlow();
|
|
346
|
+
await postInitContext({ userCode, projectName, projectSlug });
|
|
347
|
+
const url = approveUrl(userCode, { from: "cli-init" });
|
|
348
|
+
out.step("Opening browser to authorize this device…");
|
|
349
|
+
out.info(` If it didn't open, visit: ${url}`);
|
|
350
|
+
out.info(` Code: ${userCode}`);
|
|
351
|
+
deps.openBrowser(url);
|
|
352
|
+
const { bearerToken, projectId } = await pollForBearer(deviceCode, {
|
|
353
|
+
now: deps.now,
|
|
354
|
+
});
|
|
355
|
+
await clearStoredCredential();
|
|
356
|
+
await setStoredToken(bearerToken.trim());
|
|
357
|
+
const project = await pickCreatedProject(bearerToken, { projectId, slug: projectSlug }, opts, deps);
|
|
358
|
+
writeLinkConfig(root, { projectId: project.id, environment: "dev" });
|
|
359
|
+
const added = ensureGitignorePatterns(root, [".envspot.json", ".envspot"]);
|
|
360
|
+
out.success(`Linked ${project.name} (${project.slug}) · development`);
|
|
361
|
+
if (added.length > 0)
|
|
362
|
+
out.step(`Added ${added.join(", ")} to .gitignore`);
|
|
363
|
+
if (secrets.length > 0) {
|
|
364
|
+
await importSecrets(bearerToken, project.id, secrets);
|
|
365
|
+
out.success(`Imported ${secrets.length} secret(s) to development.`);
|
|
366
|
+
}
|
|
367
|
+
return await maybeRunDevServer(root, projectName, opts, deps);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
if (lockedRoot)
|
|
371
|
+
releaseInitLock(lockedRoot);
|
|
372
|
+
rl?.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Offer to start the dev server, closing the terminal-to-running loop. */
|
|
376
|
+
async function maybeRunDevServer(root, projectName, opts, deps) {
|
|
377
|
+
if (opts.run === false || !existsSync(join(root, "package.json"))) {
|
|
378
|
+
out.success("Ready. Run: envspot run -- <your start command>");
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
const scripts = readPackageScripts(root);
|
|
382
|
+
let child = null;
|
|
383
|
+
if (scripts.start)
|
|
384
|
+
child = ["npm", "start"];
|
|
385
|
+
else if (scripts.dev)
|
|
386
|
+
child = ["npm", "run", "dev"];
|
|
387
|
+
if (!child) {
|
|
388
|
+
out.success("Ready. Run: envspot run -- <your start command>");
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
// Only auto-run at an interactive terminal. Under --yes in CI (no TTY) this
|
|
392
|
+
// would spawn a foreground dev server that never exits, hanging the job.
|
|
393
|
+
const cmd = `envspot run -- ${child.join(" ")}`;
|
|
394
|
+
if (!deps.isTTY) {
|
|
395
|
+
out.success(`Ready. Run: ${cmd}`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
const runNow = opts.yes === true ||
|
|
399
|
+
!isNo(await deps.prompt(`Start your dev server now? ${cmd} [Y/n] `));
|
|
400
|
+
if (!runNow) {
|
|
401
|
+
out.success(`Ready. Run: ${cmd}`);
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
const parsed = parseRunTailArgs(["--", ...child]);
|
|
405
|
+
if (!parsed.ok) {
|
|
406
|
+
out.success(`Ready. Run: ${cmd}`);
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
out.step(`Starting ${projectName}: ${cmd}`);
|
|
410
|
+
return executeRun(parsed);
|
|
411
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const INIT_LOCK_FILE = ".envspot.json.lock";
|
|
4
|
+
function isPidAlive(pid) {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
// ESRCH = no such process (dead); EPERM = exists but not signalable (alive).
|
|
11
|
+
return e.code === "EPERM";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function readLock(path) {
|
|
15
|
+
try {
|
|
16
|
+
const h = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
return typeof h.pid === "number"
|
|
18
|
+
? { pid: h.pid, startedAt: h.startedAt ?? "" }
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Acquire the per-directory init lock via an atomic exclusive create (`wx`).
|
|
27
|
+
* On contention, refuses when a live process owns it; reclaims a stale lock
|
|
28
|
+
* (dead pid or corrupt file) and retries.
|
|
29
|
+
*/
|
|
30
|
+
export function acquireInitLock(root) {
|
|
31
|
+
const path = join(root, INIT_LOCK_FILE);
|
|
32
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
33
|
+
try {
|
|
34
|
+
const fd = openSync(path, "wx");
|
|
35
|
+
writeSync(fd, JSON.stringify({
|
|
36
|
+
pid: process.pid,
|
|
37
|
+
startedAt: new Date().toISOString(),
|
|
38
|
+
}));
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e.code !== "EEXIST")
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
const held = readLock(path);
|
|
47
|
+
if (held && isPidAlive(held.pid)) {
|
|
48
|
+
return { ok: false, pid: held.pid, startedAt: held.startedAt };
|
|
49
|
+
}
|
|
50
|
+
rmSync(path, { force: true }); // stale or corrupt → reclaim, then retry
|
|
51
|
+
}
|
|
52
|
+
const held = readLock(path);
|
|
53
|
+
return held
|
|
54
|
+
? { ok: false, pid: held.pid, startedAt: held.startedAt }
|
|
55
|
+
: { ok: true };
|
|
56
|
+
}
|
|
57
|
+
export function releaseInitLock(root) {
|
|
58
|
+
try {
|
|
59
|
+
rmSync(join(root, INIT_LOCK_FILE), { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* noop */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { getStoredToken } from "./auth-store.js";
|
|
6
|
+
import { cliApiError } from "./cli-api-error.js";
|
|
7
|
+
import { CliError, ERR, authRequired, deviceLoginRequired, ttyRequired, } from "./errors.js";
|
|
8
|
+
import { apiUrl, backoffTransport, fetchWithCliHeaders, parseJsonSafely, rethrowTransport, } from "./http.js";
|
|
9
|
+
import * as out from "./output.js";
|
|
10
|
+
import { LINK_PRIMARY, isObjectIdHex, writeLinkConfig } from "./paths.js";
|
|
11
|
+
import { isHeadlessToken } from "./token-kind.js";
|
|
12
|
+
/** Convert transport faults into a typed E0003 so they bubble to the top-level handler. */
|
|
13
|
+
async function withTransport(fn) {
|
|
14
|
+
try {
|
|
15
|
+
return await fn();
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
rethrowTransport(e);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function fetchProjects(token) {
|
|
22
|
+
const res = await fetchWithCliHeaders(apiUrl("/api/cli/projects"), { method: "GET" }, token);
|
|
23
|
+
const { json } = await parseJsonSafely(res);
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
throw await cliApiError(res.status, json);
|
|
26
|
+
const projects = json?.projects;
|
|
27
|
+
return Array.isArray(projects) ? projects : [];
|
|
28
|
+
}
|
|
29
|
+
/** `.envspot.json` uses short `dev` / `prod` when user picks MVP environments. */
|
|
30
|
+
function persistEnvironmentLabel(answer) {
|
|
31
|
+
const s = answer.trim().toLowerCase();
|
|
32
|
+
if (s === "" || s === "dev" || s === "development" || s === "local")
|
|
33
|
+
return "dev";
|
|
34
|
+
if (s === "prod" || s === "production")
|
|
35
|
+
return "prod";
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
/** Append the given patterns to cwd's `.gitignore` when missing (non-fatal).
|
|
39
|
+
* Returns the patterns it actually added. */
|
|
40
|
+
export function ensureGitignorePatterns(cwd, patterns) {
|
|
41
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
42
|
+
let raw = "";
|
|
43
|
+
try {
|
|
44
|
+
raw = readFileSync(gitignorePath, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* no .gitignore */
|
|
48
|
+
}
|
|
49
|
+
const present = new Set(raw
|
|
50
|
+
.split(/\r?\n/)
|
|
51
|
+
.map((l) => l.trim())
|
|
52
|
+
.filter((l) => l.length > 0 && !l.startsWith("#")));
|
|
53
|
+
const toAdd = patterns.filter((pat) => !present.has(pat));
|
|
54
|
+
if (toAdd.length === 0)
|
|
55
|
+
return [];
|
|
56
|
+
const sep = raw.length === 0 ? "" : raw.endsWith("\n") ? "" : "\n";
|
|
57
|
+
writeFileSync(gitignorePath, `${raw}${sep}${toAdd.join("\n")}\n`, "utf8");
|
|
58
|
+
return toAdd;
|
|
59
|
+
}
|
|
60
|
+
/** Append link file paths to cwd's `.gitignore` when missing (non-fatal). */
|
|
61
|
+
export function ensureLinkPatternsInGitignore(cwd) {
|
|
62
|
+
return ensureGitignorePatterns(cwd, [LINK_PRIMARY, ".envspot"]);
|
|
63
|
+
}
|
|
64
|
+
function pickProjectFromList(projects, slugOrId) {
|
|
65
|
+
const raw = slugOrId.trim();
|
|
66
|
+
if (isObjectIdHex(raw.toLowerCase())) {
|
|
67
|
+
const row = projects.find((p) => p.id.toLowerCase() === raw.toLowerCase());
|
|
68
|
+
if (row)
|
|
69
|
+
return row;
|
|
70
|
+
}
|
|
71
|
+
const bySlug = projects.find((p) => p.slug === raw);
|
|
72
|
+
if (bySlug)
|
|
73
|
+
return bySlug;
|
|
74
|
+
throw new CliError(ERR.INVALID_INPUT, `Project not found: ${raw}.`, "Use a slug from Dashboard → Projects or the project id.");
|
|
75
|
+
}
|
|
76
|
+
function finishLink(cwd, picked, environment) {
|
|
77
|
+
const filePath = writeLinkConfig(cwd, { projectId: picked.id, environment });
|
|
78
|
+
try {
|
|
79
|
+
const added = ensureLinkPatternsInGitignore(cwd);
|
|
80
|
+
if (added.length > 0)
|
|
81
|
+
out.step(`Updated .gitignore (${added.join(", ")})`);
|
|
82
|
+
}
|
|
83
|
+
catch (gErr) {
|
|
84
|
+
out.warn(`Could not update .gitignore: ${gErr.message}`);
|
|
85
|
+
}
|
|
86
|
+
out.success(`Linked ${basename(filePath)} → ${picked.name} (${picked.slug})`);
|
|
87
|
+
out.info(`Environment: ${environment}`);
|
|
88
|
+
}
|
|
89
|
+
export async function runLinkWithFlags(projectFlag, envFlag) {
|
|
90
|
+
const token = await getStoredToken();
|
|
91
|
+
if (!token)
|
|
92
|
+
throw authRequired();
|
|
93
|
+
if (isHeadlessToken(token))
|
|
94
|
+
throw deviceLoginRequired("Linking");
|
|
95
|
+
const projects = await withTransport(async () => backoffTransport(async () => fetchProjects(token)));
|
|
96
|
+
const picked = pickProjectFromList(projects, projectFlag);
|
|
97
|
+
const environment = persistEnvironmentLabel(envFlag);
|
|
98
|
+
finishLink(process.cwd(), picked, environment);
|
|
99
|
+
}
|
|
100
|
+
export async function runLinkInteractive() {
|
|
101
|
+
const token = await getStoredToken();
|
|
102
|
+
if (!token)
|
|
103
|
+
throw authRequired();
|
|
104
|
+
if (isHeadlessToken(token))
|
|
105
|
+
throw deviceLoginRequired("Linking");
|
|
106
|
+
if (!process.stdin.isTTY) {
|
|
107
|
+
throw ttyRequired("envspot link", "Use: envspot link --project <slug-or-id> [--env dev]");
|
|
108
|
+
}
|
|
109
|
+
const projects = await withTransport(async () => backoffTransport(async () => fetchProjects(token)));
|
|
110
|
+
if (projects.length === 0) {
|
|
111
|
+
throw new CliError(ERR.UNEXPECTED, "No projects in this workspace.", "Create one in the dashboard first.");
|
|
112
|
+
}
|
|
113
|
+
const rl = readline.createInterface({ input, output });
|
|
114
|
+
try {
|
|
115
|
+
let picked;
|
|
116
|
+
if (projects.length === 1) {
|
|
117
|
+
picked = projects[0];
|
|
118
|
+
out.info(`Using project "${picked.name}" (${picked.slug}).`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
console.log("Select a project:");
|
|
122
|
+
for (let i = 0; i < projects.length; i++) {
|
|
123
|
+
const p = projects[i];
|
|
124
|
+
console.log(` ${i + 1}. ${p.name} (${p.slug})`);
|
|
125
|
+
}
|
|
126
|
+
const ans = (await rl.question("Enter number: ")).trim();
|
|
127
|
+
const n = Number(ans);
|
|
128
|
+
if (!Number.isInteger(n) || n < 1 || n > projects.length) {
|
|
129
|
+
throw new CliError(ERR.INVALID_INPUT, "Invalid selection.");
|
|
130
|
+
}
|
|
131
|
+
picked = projects[n - 1];
|
|
132
|
+
}
|
|
133
|
+
const rawEnv = (await rl.question("Environment [dev/production/staging/custom, default dev]: ")).trim() || "dev";
|
|
134
|
+
const environment = persistEnvironmentLabel(rawEnv);
|
|
135
|
+
finishLink(process.cwd(), picked, environment);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
rl.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function openApproveUrl(url) {
|
|
3
|
+
if (process.env.ENVSPOT_SKIP_BROWSER_OPEN === "1") {
|
|
4
|
+
console.log(`Skipping browser (${url}) — ENVSPOT_SKIP_BROWSER_OPEN=1`);
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === "darwin") {
|
|
9
|
+
spawn("/usr/bin/open", [url], {
|
|
10
|
+
detached: true,
|
|
11
|
+
stdio: "ignore",
|
|
12
|
+
}).unref();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
spawn("cmd.exe", ["/d", "/c", "start", "", url], {
|
|
17
|
+
detached: true,
|
|
18
|
+
stdio: "ignore",
|
|
19
|
+
shell: false,
|
|
20
|
+
}).unref();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
console.warn("Could not launch browser:", e.message);
|
|
27
|
+
console.warn("Open this URL in a signed-in browser:\n ", url);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Color is on only for an interactive stdout with NO_COLOR unset (per the NO_COLOR standard: any presence disables). */
|
|
2
|
+
export function colorEnabled() {
|
|
3
|
+
if (process.env.NO_COLOR != null)
|
|
4
|
+
return false;
|
|
5
|
+
return process.stdout.isTTY === true;
|
|
6
|
+
}
|
|
7
|
+
function paint(code, s) {
|
|
8
|
+
return colorEnabled() ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
9
|
+
}
|
|
10
|
+
const green = (s) => paint(32, s);
|
|
11
|
+
const yellow = (s) => paint(33, s);
|
|
12
|
+
const cyan = (s) => paint(36, s);
|
|
13
|
+
const dim = (s) => paint(2, s);
|
|
14
|
+
export const fmtSuccess = (msg) => `${green("✓")} ${msg}`;
|
|
15
|
+
export const fmtWarn = (msg) => `${yellow("⚠")} ${msg}`;
|
|
16
|
+
export const fmtStep = (msg) => `${cyan("▸")} ${msg}`;
|
|
17
|
+
export const fmtInfo = (msg) => dim(msg);
|
|
18
|
+
export const success = (msg) => console.log(fmtSuccess(msg));
|
|
19
|
+
export const warn = (msg) => console.error(fmtWarn(msg));
|
|
20
|
+
export const step = (msg) => console.log(fmtStep(msg));
|
|
21
|
+
export const info = (msg) => console.log(fmtInfo(msg));
|