entrify-deploy 1.2.0 → 1.3.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/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from 'node:readline';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
3
5
  import { cwd as processCwd } from 'node:process';
4
6
  import { readCredentials, clearCredentials, login, PRODUCTION_URL } from './auth.js';
5
7
  import { EntrifyApi } from './api.js';
6
8
  import { getGitInfo, validateLocal } from './detector.js';
7
- import { buildDeployUrl, cliName, normaliseRepoUrl, parseArgs } from './util.js';
9
+ import { CliError, EXIT, buildDeployUrl, cliName, normaliseRepoUrl, parseArgs, parseDotenv, previousDeployed, projectNameFromRepo, } from './util.js';
8
10
  /** `entrify` or `dropdeploy`, whichever was typed. */
9
11
  const BIN = cliName();
10
12
  // ── ANSI colours (no external dep) ──────────────────────────────────────────
@@ -16,6 +18,30 @@ const c = {
16
18
  bold: (s) => `\x1b[1m${s}\x1b[0m`,
17
19
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
18
20
  };
21
+ // ── Output: human text, or exactly one JSON object with --json ──────────────
22
+ let jsonMode = false;
23
+ /** Human-readable output. Silent with --json, so stdout carries only the JSON result. */
24
+ function say(...args) {
25
+ if (!jsonMode)
26
+ console.log(...args);
27
+ }
28
+ function sayInline(text) {
29
+ if (!jsonMode)
30
+ process.stdout.write(text);
31
+ }
32
+ /** A command's result. With --json it is printed as `{ "ok": true, ... }`; humans have already been told. */
33
+ function emit(result) {
34
+ if (jsonMode)
35
+ process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
36
+ }
37
+ function colourStatus(status) {
38
+ if (!status)
39
+ return c.dim('no deployments');
40
+ if (status === 'DEPLOYED')
41
+ return c.green(status);
42
+ return status === 'FAILED' ? c.red(status) : c.yellow(status);
43
+ }
44
+ const publicProject = (p) => ({ id: p.id, slug: p.slug, name: p.name });
19
45
  // ── Interactive prompt ───────────────────────────────────────────────────────
20
46
  async function prompt(question) {
21
47
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -102,17 +128,32 @@ function renderBar(percent, label) {
102
128
  }
103
129
  const isTTY = process.stdout.isTTY ?? false;
104
130
  function printProgress(percent, label) {
105
- if (!isTTY)
131
+ if (!isTTY || jsonMode)
106
132
  return;
107
133
  process.stdout.write('\r' + renderBar(percent, label) + ' ');
108
134
  }
109
135
  function clearProgress() {
110
- if (!isTTY)
136
+ if (!isTTY || jsonMode)
111
137
  return;
112
138
  process.stdout.write('\r' + ' '.repeat(72) + '\r');
113
139
  }
114
140
  // ── Error diagnosis from build log tail ─────────────────────────────────────
115
141
  const ERROR_PATTERNS = [
142
+ {
143
+ re: /no space left on device/i,
144
+ cause: 'The Entrify server ran out of disk space',
145
+ fix: 'Not a problem in your code: ask the Entrify admin to free space, then redeploy',
146
+ },
147
+ {
148
+ re: /did not start listening/i,
149
+ cause: 'The app never accepted connections on its port',
150
+ fix: 'Listen on 0.0.0.0 (not 127.0.0.1 or localhost) and on the PORT environment variable',
151
+ },
152
+ {
153
+ re: /exited during startup/i,
154
+ cause: 'The app crashed while starting',
155
+ fix: `Read the error above; if it asks for a setting, add it with \`${BIN} env set KEY=VALUE\` and redeploy`,
156
+ },
116
157
  {
117
158
  re: /Cannot find module|Module not found/i,
118
159
  cause: 'Missing dependency',
@@ -157,164 +198,82 @@ function diagnose(log) {
157
198
  }
158
199
  return null;
159
200
  }
160
- // ── Project selection ────────────────────────────────────────────────────────
161
- async function pickProject(api, remoteUrl, localBranch, flagProjectId) {
201
+ // ── Auth ─────────────────────────────────────────────────────────────────────
202
+ async function requireCreds() {
203
+ const creds = await readCredentials();
204
+ if (!creds)
205
+ throw new CliError(`Not logged in. Run: ${BIN} auth login`, 'NOT_LOGGED_IN', EXIT.AUTH);
206
+ return creds;
207
+ }
208
+ async function createProjectFromRemote(api, { remoteUrl, branch, detectedType }) {
209
+ const repo = normaliseRepoUrl(remoteUrl);
210
+ const host = repo.split('/')[0];
211
+ const source = host === 'github.com' ? 'GITHUB' : host.includes('gitlab') ? 'GITLAB' : null;
212
+ if (!source) {
213
+ throw new CliError(`Only GitHub and GitLab repositories can be deployed (this remote is on ${host}).`, 'UNSUPPORTED_REMOTE', EXIT.NEEDS_INPUT);
214
+ }
215
+ return api.createProject({
216
+ name: projectNameFromRepo(remoteUrl),
217
+ source,
218
+ githubUrl: `https://${repo}`,
219
+ type: detectedType ?? undefined,
220
+ branch: branch === 'HEAD' ? undefined : branch,
221
+ });
222
+ }
223
+ async function pickProject(api, query) {
162
224
  const projects = await api.listProjects();
163
- if (flagProjectId) {
164
- const p = projects.find((p) => p.id === flagProjectId || p.slug === flagProjectId);
165
- if (!p)
166
- throw new Error(`Project "${flagProjectId}" not found`);
167
- return p;
225
+ if (query.projectId) {
226
+ const p = projects.find((p) => p.id === query.projectId || p.slug === query.projectId);
227
+ if (!p) {
228
+ throw new CliError(`Project "${query.projectId}" not found. List them with: ${BIN} projects`, 'PROJECT_NOT_FOUND', EXIT.NEEDS_INPUT);
229
+ }
230
+ return { project: p, created: false };
168
231
  }
169
232
  // Auto-match by git remote URL (HTTPS or SSH); with several projects on one repo, prefer this branch's
170
- if (remoteUrl) {
171
- const needle = normaliseRepoUrl(remoteUrl);
233
+ if (query.remoteUrl) {
234
+ const needle = normaliseRepoUrl(query.remoteUrl);
172
235
  const matches = projects.filter((p) => p.githubUrl && normaliseRepoUrl(p.githubUrl) === needle);
173
- if (matches.length > 0)
174
- return matches.find((p) => p.branch === localBranch) ?? matches[0];
236
+ if (matches.length > 0) {
237
+ return { project: matches.find((p) => p.branch === query.branch) ?? matches[0], created: false };
238
+ }
239
+ if (query.create) {
240
+ const project = await createProjectFromRemote(api, { remoteUrl: query.remoteUrl, branch: query.branch, ...query.create });
241
+ return { project, created: true };
242
+ }
175
243
  }
244
+ const hint = `Pass --project-id <id|slug> (list them with: ${BIN} projects), or deploy with --create to make a new project from this repository.`;
176
245
  if (projects.length === 0) {
177
- throw new Error('No projects found on Entrify. Create one from the dashboard first.');
246
+ throw new CliError(`No projects found on Entrify. ${hint}`, 'NO_PROJECTS', EXIT.NEEDS_INPUT);
178
247
  }
179
- // Without a terminal (CI) the prompt below would wait forever.
180
- if (!process.stdin.isTTY) {
181
- throw new Error(`No project matches this repository. Pass --project-id <id|slug> (list them with: ${BIN} projects).`);
248
+ // Without a terminal (CI, coding agents) or with --json, nobody can answer the prompt below.
249
+ if (!process.stdin.isTTY || jsonMode) {
250
+ throw new CliError(`No project matches this repository. ${hint}`, 'NO_PROJECT_MATCH', EXIT.NEEDS_INPUT);
182
251
  }
183
- // Prompt user to pick
184
- console.log(`\n${c.bold('Select a project:')}`);
252
+ say(`\n${c.bold('Select a project:')}`);
185
253
  projects.forEach((p, i) => {
186
- const latest = p.deployments?.[0];
187
- const status = latest
188
- ? latest.status === 'DEPLOYED'
189
- ? c.green(latest.status)
190
- : latest.status === 'FAILED'
191
- ? c.red(latest.status)
192
- : c.yellow(latest.status)
193
- : c.dim('no deployments');
194
- console.log(` ${c.cyan(String(i + 1))}) ${p.name} ${c.dim(`(${p.slug})`)} ${status}`);
254
+ say(` ${c.cyan(String(i + 1))}) ${p.name} ${c.dim(`(${p.slug})`)} ${colourStatus(p.deployments?.[0]?.status)}`);
195
255
  });
196
256
  const answer = await prompt(`\nEnter number [1-${projects.length}]: `);
197
257
  const idx = parseInt(answer, 10) - 1;
198
258
  if (isNaN(idx) || idx < 0 || idx >= projects.length) {
199
- throw new Error('Invalid selection');
259
+ throw new CliError('Invalid selection', 'INVALID_SELECTION', EXIT.NEEDS_INPUT);
200
260
  }
201
- return projects[idx];
261
+ return { project: projects[idx], created: false };
202
262
  }
203
- // ── Commands ─────────────────────────────────────────────────────────────────
204
- async function cmdAuthLogin(flags) {
205
- const apiUrl = flags['api-url'] ?? PRODUCTION_URL;
206
- console.log(`Connecting to ${c.cyan(apiUrl)}`);
207
- const email = flags['email'] ?? (await prompt('Email: '));
208
- const password = await promptHidden('Password: ');
209
- process.stdout.write('Logging in… ');
210
- const creds = await login(apiUrl, email, password);
211
- console.log(c.green('✓'));
212
- console.log(`Logged in as ${c.bold(creds.email)}`);
213
- }
214
- async function cmdAuthStatus() {
215
- const creds = await readCredentials();
216
- if (!creds) {
217
- console.log(`${c.yellow('Not logged in.')} Run: ${BIN} auth login`);
218
- process.exit(1);
219
- }
220
- console.log(`${c.green('✓')} Logged in as ${c.bold(creds.email)}`);
221
- console.log(` API: ${c.dim(creds.apiUrl)}`);
222
- }
223
- async function cmdAuthLogout() {
224
- await clearCredentials();
225
- console.log(`${c.green('✓')} Logged out`);
226
- }
227
- async function cmdProjects() {
228
- const creds = await readCredentials();
229
- if (!creds) {
230
- console.error(c.red('Not logged in.') + ` Run: ${BIN} auth login`);
231
- process.exit(1);
232
- }
233
- const api = new EntrifyApi(creds);
234
- const projects = await api.listProjects();
235
- if (projects.length === 0) {
236
- console.log('No projects found.');
237
- return;
238
- }
239
- console.log(`\n${c.bold('Your projects:')}`);
240
- for (const p of projects) {
241
- const latest = p.deployments?.[0];
242
- const statusStr = latest
243
- ? latest.status === 'DEPLOYED'
244
- ? c.green(latest.status)
245
- : latest.status === 'FAILED'
246
- ? c.red(latest.status)
247
- : c.yellow(latest.status)
248
- : c.dim('no deployments');
249
- console.log(` ${c.cyan(p.slug)} ${p.name} ${statusStr}`);
250
- if (latest?.subdomain) {
251
- console.log(` ${c.dim('→ ' + buildDeployUrl(creds.apiUrl, latest.subdomain))}`);
252
- }
253
- }
254
- console.log();
263
+ /** The project for the repository in --dir (or the current directory), or --project-id. Never creates one. */
264
+ async function resolveProject(api, flags) {
265
+ const git = await getGitInfo(flags['dir'] ?? processCwd());
266
+ const { project } = await pickProject(api, { remoteUrl: git.remoteUrl, branch: git.branch, projectId: flags['project-id'] ?? null });
267
+ return project;
255
268
  }
256
- async function cmdDeploy(flags) {
257
- const dir = flags['dir'] ?? processCwd();
258
- // ── 1. Auth check ──────────────────────────────────────────────────────────
259
- const creds = await readCredentials();
260
- if (!creds) {
261
- console.error(c.red('Not logged in.') + ` Run: ${BIN} auth login`);
262
- process.exit(1);
263
- }
264
- const api = new EntrifyApi(creds);
265
- console.log(`\n${c.bold('▶ Entrify')}\n`);
266
- // ── 2. Git info ───────────────────────────────────────────────────────────
267
- process.stdout.write(' Checking repository… ');
268
- const gitInfo = await getGitInfo(dir);
269
- console.log(c.green('✓'));
270
- if (gitInfo.isDirty) {
271
- console.log(` ${c.yellow('⚠')} Working tree has uncommitted changes`);
272
- }
273
- // ── 3. Validate ───────────────────────────────────────────────────────────
274
- process.stdout.write(' Validating project… ');
275
- const validation = await validateLocal(dir, gitInfo);
276
- if (!validation.ok) {
277
- console.log(c.red('✗'));
278
- for (const e of validation.errors) {
279
- console.error(` ${c.red('✗')} ${e}`);
280
- }
281
- process.exit(1);
282
- }
283
- console.log(c.green('✓'));
284
- for (const w of validation.warnings) {
285
- console.log(` ${c.yellow('⚠')} ${w}`);
286
- }
287
- // ── 4. Framework detection ────────────────────────────────────────────────
288
- if (gitInfo.remoteUrl) {
289
- process.stdout.write(' Detecting framework… ');
290
- try {
291
- const detected = await api.detectType(gitInfo.remoteUrl, gitInfo.branch);
292
- console.log(`${c.green('✓')} ${c.bold(detected.type)} ${c.dim(`(${detected.hint})`)}`);
293
- }
294
- catch {
295
- console.log(c.dim('skipped'));
296
- }
297
- }
298
- // ── 5. Project resolution ──────────────────────────────────────────────────
299
- process.stdout.write(' Resolving project… ');
300
- const project = await pickProject(api, gitInfo.remoteUrl, gitInfo.branch, flags['project-id'] ?? null);
301
- console.log(`${c.green('✓')} ${c.bold(project.name)} ${c.dim(`(${project.slug})`)}`);
302
- // The server builds the project's branch, not whatever is checked out here.
303
- if (project.branch && gitInfo.branch !== 'HEAD' && gitInfo.branch !== project.branch) {
304
- console.log(` ${c.yellow('⚠')} This project deploys branch ${c.bold(project.branch)}; you are on ${c.bold(gitInfo.branch)}.`);
305
- }
306
- // ── 6. Trigger deployment ──────────────────────────────────────────────────
307
- process.stdout.write(' Triggering deployment… ');
308
- const { deploymentId, queued } = await api.triggerDeploy(project.id);
309
- console.log(c.green('✓') + (queued ? c.dim(' (queued behind active build)') : ''));
310
- console.log();
311
- // ── 7. Stream logs + live progress bar ────────────────────────────────────
269
+ /** Streams the build log (with a progress bar for humans), then waits for the final status. */
270
+ async function followDeployment(api, projectId, deploymentId) {
312
271
  const logLines = [];
313
- let currentStep = '';
272
+ let currentStep = null;
314
273
  let currentPercent = 5;
315
274
  printProgress(currentPercent, 'Waiting in queue…');
316
275
  try {
317
- for await (const evt of api.streamLogs(project.id, deploymentId)) {
276
+ for await (const evt of api.streamLogs(projectId, deploymentId)) {
318
277
  if (evt.type === 'line') {
319
278
  logLines.push(evt.text);
320
279
  const step = detectBuildStep(evt.text);
@@ -322,12 +281,12 @@ async function cmdDeploy(flags) {
322
281
  currentStep = step;
323
282
  currentPercent = STEP_PERCENT[step];
324
283
  clearProgress();
325
- console.log(`\n ${c.cyan('›')} ${c.bold(STEP_LABEL[step])}`);
284
+ say(`\n ${c.cyan('›')} ${c.bold(STEP_LABEL[step])}`);
326
285
  printProgress(currentPercent, STEP_LABEL[step] + '…');
327
286
  }
328
287
  else {
329
288
  clearProgress();
330
- console.log(` ${c.dim(evt.text)}`);
289
+ say(` ${c.dim(evt.text)}`);
331
290
  printProgress(currentPercent, currentStep ? STEP_LABEL[currentStep] + '…' : 'Building…');
332
291
  }
333
292
  }
@@ -348,26 +307,23 @@ async function cmdDeploy(flags) {
348
307
  }
349
308
  catch {
350
309
  clearProgress();
351
- console.log(` ${c.yellow('⚠')} Log stream disconnected — polling for result…`);
310
+ say(` ${c.yellow('⚠')} Log stream disconnected — polling for result…`);
352
311
  }
353
- // ── 8. Fetch final status ──────────────────────────────────────────────────
354
312
  clearProgress();
355
- let finalStatus = null;
313
+ // SSE ends slightly before DB is updated — poll with exponential backoff
356
314
  let finalLog = null;
357
315
  let subdomain = null;
358
- // SSE ends slightly before DB is updated — poll with exponential backoff
359
316
  let consecutiveErrors = 0;
360
317
  for (let attempt = 0; attempt < 20; attempt++) {
361
318
  const delay = Math.min(1000 * 2 ** attempt, 10_000);
362
319
  await new Promise((r) => setTimeout(r, delay));
363
320
  try {
364
- const s = await api.getDeploymentStatus(project.id, deploymentId);
321
+ const s = await api.getDeploymentStatus(projectId, deploymentId);
365
322
  consecutiveErrors = 0;
366
323
  finalLog = s.buildLog;
367
324
  subdomain = s.subdomain;
368
- if (['DEPLOYED', 'FAILED', 'CANCELLED'].includes(s.status)) {
369
- finalStatus = s.status;
370
- break;
325
+ if (s.status === 'DEPLOYED' || s.status === 'FAILED' || s.status === 'CANCELLED') {
326
+ return { status: s.status, subdomain, log: finalLog ?? logLines.join(''), step: currentStep };
371
327
  }
372
328
  }
373
329
  catch {
@@ -375,48 +331,305 @@ async function cmdDeploy(flags) {
375
331
  break;
376
332
  }
377
333
  }
378
- // ── 9. Final output ────────────────────────────────────────────────────────
379
- console.log();
380
- if (finalStatus === 'DEPLOYED') {
381
- console.log(c.bold(c.green('✓ Deployed successfully')));
382
- if (subdomain) {
383
- const url = buildDeployUrl(creds.apiUrl, subdomain);
384
- console.log(`\n ${c.bold('Live URL')} → ${c.cyan(url)}`);
334
+ return { status: 'TIMEOUT', subdomain, log: finalLog ?? logLines.join(''), step: currentStep };
335
+ }
336
+ /** Prints the result of a deploy or rollback; a deployment that did not go live is thrown as a CliError. */
337
+ function reportDeployment({ creds, project, deploymentId, outcome, details }) {
338
+ const base = { project: publicProject(project), deploymentId, status: outcome.status, ...details };
339
+ say();
340
+ if (outcome.status === 'DEPLOYED') {
341
+ const url = outcome.subdomain ? buildDeployUrl(creds.apiUrl, outcome.subdomain) : null;
342
+ say(c.bold(c.green('✓ Deployed successfully')));
343
+ if (url)
344
+ say(`\n ${c.bold('Live URL')} → ${c.cyan(url)}`);
345
+ say();
346
+ emit({ ...base, url });
347
+ return;
348
+ }
349
+ if (outcome.status === 'FAILED') {
350
+ say(c.bold(c.red(`✗ Deployment failed${outcome.step ? ` at: ${c.bold(outcome.step)}` : ''}`)));
351
+ say();
352
+ const lines = outcome.log.split('\n').filter(Boolean);
353
+ const diagnosis = outcome.log ? diagnose(outcome.log) : null;
354
+ if (lines.length > 0) {
355
+ say(` ${c.dim('Last 20 lines of build log:')}`);
356
+ say(' ' + '─'.repeat(52));
357
+ for (const l of lines.slice(-20))
358
+ say(` ${c.dim(l)}`);
359
+ say(' ' + '─'.repeat(52));
360
+ say();
361
+ if (diagnosis) {
362
+ say(` ${c.yellow('Probable cause:')} ${diagnosis.cause}`);
363
+ say(` ${c.cyan('Suggested fix:')} ${diagnosis.fix}`);
364
+ say();
365
+ }
366
+ }
367
+ throw new CliError(`Deployment failed${outcome.step ? ` at ${outcome.step}` : ''}`, 'DEPLOY_FAILED', EXIT.FAILED, {
368
+ ...base,
369
+ step: outcome.step,
370
+ cause: diagnosis?.cause ?? null,
371
+ fix: diagnosis?.fix ?? null,
372
+ logTail: lines.slice(-40),
373
+ });
374
+ }
375
+ if (outcome.status === 'CANCELLED') {
376
+ throw new CliError('The deployment was cancelled.', 'DEPLOY_CANCELLED', EXIT.FAILED, base);
377
+ }
378
+ const dashboard = `${creds.apiUrl}/projects/${project.id}`;
379
+ throw new CliError(`Timed out waiting for the deployment to finish. Check: ${BIN} status, or ${dashboard}`, 'DEPLOY_TIMEOUT', EXIT.FAILED, { ...base, dashboard });
380
+ }
381
+ // ── Commands ─────────────────────────────────────────────────────────────────
382
+ async function cmdAuthLogin(flags) {
383
+ const apiUrl = flags['api-url'] ?? PRODUCTION_URL;
384
+ console.log(`Connecting to ${c.cyan(apiUrl)}`);
385
+ const email = flags['email'] ?? (await prompt('Email: '));
386
+ const password = await promptHidden('Password: ');
387
+ process.stdout.write('Logging in… ');
388
+ const creds = await login(apiUrl, email, password);
389
+ console.log(c.green('✓'));
390
+ console.log(`Logged in as ${c.bold(creds.email)}`);
391
+ }
392
+ async function cmdAuthStatus() {
393
+ const creds = await requireCreds();
394
+ say(`${c.green('✓')} Logged in as ${c.bold(creds.email)}`);
395
+ say(` API: ${c.dim(creds.apiUrl)}`);
396
+ emit({ email: creds.email, apiUrl: creds.apiUrl });
397
+ }
398
+ async function cmdAuthLogout() {
399
+ await clearCredentials();
400
+ say(`${c.green('✓')} Logged out`);
401
+ emit({});
402
+ }
403
+ async function cmdProjects() {
404
+ const creds = await requireCreds();
405
+ const projects = await new EntrifyApi(creds).listProjects();
406
+ const rows = projects.map((p) => {
407
+ const latest = p.deployments?.[0];
408
+ return { ...publicProject(p), type: p.type, status: latest?.status ?? null, url: latest?.subdomain ? buildDeployUrl(creds.apiUrl, latest.subdomain) : null };
409
+ });
410
+ if (rows.length === 0)
411
+ say('No projects found.');
412
+ else {
413
+ say(`\n${c.bold('Your projects:')}`);
414
+ for (const r of rows) {
415
+ say(` ${c.cyan(r.slug)} ${r.name} ${colourStatus(r.status ?? undefined)}`);
416
+ if (r.url)
417
+ say(` ${c.dim('→ ' + r.url)}`);
418
+ }
419
+ say();
420
+ }
421
+ emit({ projects: rows });
422
+ }
423
+ async function cmdDeploy(flags) {
424
+ const dir = flags['dir'] ?? processCwd();
425
+ const creds = await requireCreds();
426
+ const api = new EntrifyApi(creds);
427
+ const warnings = [];
428
+ const warn = (w) => {
429
+ warnings.push(w);
430
+ say(` ${c.yellow('⚠')} ${w}`);
431
+ };
432
+ say(`\n${c.bold('▶ Entrify')}\n`);
433
+ sayInline(' Checking repository… ');
434
+ const gitInfo = await getGitInfo(dir);
435
+ say(c.green('✓'));
436
+ if (gitInfo.isDirty)
437
+ warn('Working tree has uncommitted changes. Entrify deploys what is pushed, not local files.');
438
+ sayInline(' Validating project… ');
439
+ const validation = await validateLocal(dir, gitInfo);
440
+ if (!validation.ok) {
441
+ say(c.red('✗'));
442
+ for (const e of validation.errors)
443
+ say(` ${c.red('✗')} ${e}`);
444
+ throw new CliError(validation.errors.join(' '), 'INVALID_PROJECT', EXIT.NEEDS_INPUT, { errors: validation.errors });
445
+ }
446
+ say(c.green('✓'));
447
+ validation.warnings.forEach(warn);
448
+ let detectedType = null;
449
+ if (gitInfo.remoteUrl) {
450
+ sayInline(' Detecting framework… ');
451
+ try {
452
+ const detected = await api.detectType(gitInfo.remoteUrl, gitInfo.branch);
453
+ detectedType = detected.type;
454
+ say(`${c.green('✓')} ${c.bold(detected.type)} ${c.dim(`(${detected.hint})`)}`);
385
455
  }
386
- console.log();
456
+ catch {
457
+ say(c.dim('skipped'));
458
+ }
459
+ }
460
+ sayInline(' Resolving project… ');
461
+ const { project, created } = await pickProject(api, {
462
+ remoteUrl: gitInfo.remoteUrl,
463
+ branch: gitInfo.branch,
464
+ projectId: flags['project-id'] ?? null,
465
+ create: flags['create'] === 'true' ? { detectedType } : undefined,
466
+ });
467
+ say(`${c.green('✓')} ${c.bold(project.name)} ${c.dim(`(${project.slug})`)}${created ? c.dim(' (new project)') : ''}`);
468
+ if (project.source === 'UPLOAD') {
469
+ throw new CliError(`"${project.slug}" was created by uploading files, so there is no repository to build. Upload a new version from the dashboard: ${creds.apiUrl}/projects/${project.id}`, 'UPLOAD_PROJECT', EXIT.NEEDS_INPUT);
470
+ }
471
+ // The server builds the project's branch, not whatever is checked out here.
472
+ if (project.branch && gitInfo.branch !== 'HEAD' && gitInfo.branch !== project.branch) {
473
+ warn(`This project deploys branch ${project.branch}; you are on ${gitInfo.branch}.`);
474
+ }
475
+ sayInline(' Triggering deployment… ');
476
+ const { deploymentId, queued } = await api.triggerDeploy(project.id);
477
+ say(c.green('✓') + (queued ? c.dim(' (queued behind active build)') : ''));
478
+ say();
479
+ const outcome = await followDeployment(api, project.id, deploymentId);
480
+ reportDeployment({ creds, project, deploymentId, outcome, details: { created, warnings } });
481
+ }
482
+ async function cmdStatus(flags) {
483
+ const creds = await requireCreds();
484
+ const api = new EntrifyApi(creds);
485
+ const project = await resolveProject(api, flags);
486
+ const deployments = await api.listDeployments(project.id, 10);
487
+ const latest = deployments[0] ?? null;
488
+ const live = deployments.find((d) => d.status === 'DEPLOYED' && d.subdomain);
489
+ const url = live?.subdomain ? buildDeployUrl(creds.apiUrl, live.subdomain) : null;
490
+ say(`\n${c.bold(project.name)} ${c.dim(`(${project.slug})`)}`);
491
+ say(` Latest: ${colourStatus(latest?.status)}${latest ? c.dim(` ${latest.createdAt} ${latest.id}`) : ''}`);
492
+ say(` Live: ${url ? c.cyan(url) : c.dim('not live')}\n`);
493
+ emit({
494
+ project: publicProject(project),
495
+ latest: latest && { id: latest.id, status: latest.status, createdAt: latest.createdAt, commitHash: latest.commitHash ?? null },
496
+ url,
497
+ });
498
+ }
499
+ async function cmdLogs(flags) {
500
+ const creds = await requireCreds();
501
+ const api = new EntrifyApi(creds);
502
+ const project = await resolveProject(api, flags);
503
+ const deploymentId = flags['deployment'] ?? (await api.listDeployments(project.id, 1))[0]?.id;
504
+ if (!deploymentId)
505
+ throw new CliError('This project has no deployments yet.', 'NO_DEPLOYMENTS', EXIT.NEEDS_INPUT);
506
+ const lineLimit = flags['lines'] === 'all' ? Infinity : Number(flags['lines'] ?? 200);
507
+ if (!(lineLimit > 0))
508
+ throw new CliError('--lines takes a positive number or "all".', 'USAGE', EXIT.NEEDS_INPUT);
509
+ const tailOf = (text) => {
510
+ const lines = text.split('\n').filter(Boolean);
511
+ return Number.isFinite(lineLimit) ? lines.slice(-lineLimit) : lines;
512
+ };
513
+ const current = await api.getDeploymentStatus(project.id, deploymentId);
514
+ let status = current.status;
515
+ let tail = tailOf(current.buildLog ?? '');
516
+ for (const l of tail)
517
+ say(l);
518
+ if (flags['follow'] === 'true' && (status === 'QUEUED' || status === 'BUILDING')) {
519
+ // Prints each new line as it arrives, after the lines above.
520
+ const outcome = await followDeployment(api, project.id, deploymentId);
521
+ status = outcome.status;
522
+ tail = tailOf(outcome.log);
523
+ }
524
+ say(`\n${c.dim('Deployment')} ${deploymentId} ${colourStatus(status)}`);
525
+ emit({ project: publicProject(project), deploymentId, status, lines: tail });
526
+ }
527
+ /** `KEY=VALUE` to a pair. Errors name the key only, never the value. */
528
+ function parsePair(arg) {
529
+ const eq = arg.indexOf('=');
530
+ if (eq < 1)
531
+ throw new CliError(`Expected KEY=VALUE, got "${arg.split('=')[0]}"`, 'USAGE', EXIT.NEEDS_INPUT);
532
+ return [arg.slice(0, eq), arg.slice(eq + 1)];
533
+ }
534
+ async function cmdEnv(sub, args, flags) {
535
+ const creds = await requireCreds();
536
+ const api = new EntrifyApi(creds);
537
+ const project = await resolveProject(api, flags);
538
+ const existing = await api.listEnv(project.id);
539
+ const applyHint = `Redeploy to apply: ${BIN} deploy`;
540
+ if (sub === undefined || sub === 'list') {
541
+ if (existing.length === 0)
542
+ say('No environment variables set.');
543
+ for (const v of existing)
544
+ say(` ${c.cyan(v.key)} ${c.dim(v.environment)}`);
545
+ emit({ project: publicProject(project), variables: existing.map((v) => ({ key: v.key, environment: v.environment })) });
387
546
  return;
388
547
  }
389
- if (finalStatus === 'FAILED') {
390
- const stepLabel = currentStep ? ` at: ${c.bold(currentStep)}` : '';
391
- console.log(c.bold(c.red(`✗ Deployment failed${stepLabel}`)));
392
- console.log();
393
- const log = finalLog ?? logLines.join('');
394
- if (log) {
395
- const tail = log
396
- .split('\n')
397
- .filter(Boolean)
398
- .slice(-20)
399
- .join('\n');
400
- console.log(` ${c.dim('Last 20 lines of build log:')}`);
401
- console.log(' ' + '─'.repeat(52));
402
- for (const l of tail.split('\n')) {
403
- console.log(` ${c.dim(l)}`);
548
+ if (sub === 'set' || sub === 'push') {
549
+ let pairs;
550
+ if (sub === 'push') {
551
+ const file = resolve(flags['dir'] ?? processCwd(), args[0] ?? '.env');
552
+ const text = await readFile(file, 'utf8').catch(() => {
553
+ throw new CliError(`Cannot read ${file}`, 'FILE_NOT_FOUND', EXIT.NEEDS_INPUT);
554
+ });
555
+ pairs = parseDotenv(text);
556
+ }
557
+ else {
558
+ pairs = args.map(parsePair);
559
+ }
560
+ if (pairs.length === 0) {
561
+ throw new CliError(`Nothing to set. Use: ${BIN} env set KEY=VALUE … or ${BIN} env push [.env file]`, 'USAGE', EXIT.NEEDS_INPUT);
562
+ }
563
+ // A key given twice keeps its last value, as in a .env file.
564
+ pairs = [...new Map(pairs)];
565
+ const created = [];
566
+ const updated = [];
567
+ for (const [key, value] of pairs) {
568
+ const found = existing.find((v) => v.key === key);
569
+ if (found) {
570
+ await api.updateEnv(project.id, found.id, value);
571
+ updated.push(key);
404
572
  }
405
- console.log(' ' + '─'.repeat(52));
406
- console.log();
407
- const diagnosis = diagnose(log);
408
- if (diagnosis) {
409
- console.log(` ${c.yellow('Probable cause:')} ${diagnosis.cause}`);
410
- console.log(` ${c.cyan('Suggested fix:')} ${diagnosis.fix}`);
411
- console.log();
573
+ else {
574
+ await api.createEnv(project.id, key, value);
575
+ created.push(key);
576
+ }
577
+ }
578
+ say(`${c.green('✓')} Set ${pairs.length} variable(s) on ${c.bold(project.slug)}: ${[...created, ...updated].join(', ')}`);
579
+ say(c.dim(applyHint));
580
+ emit({ project: publicProject(project), created, updated, hint: applyHint });
581
+ return;
582
+ }
583
+ if (sub === 'unset') {
584
+ if (args.length === 0)
585
+ throw new CliError(`Use: ${BIN} env unset KEY …`, 'USAGE', EXIT.NEEDS_INPUT);
586
+ const removed = [];
587
+ const missing = [];
588
+ for (const key of args) {
589
+ const found = existing.find((v) => v.key === key);
590
+ if (!found) {
591
+ missing.push(key);
592
+ continue;
412
593
  }
594
+ await api.deleteEnv(project.id, found.id);
595
+ removed.push(key);
413
596
  }
414
- process.exit(1);
597
+ if (removed.length)
598
+ say(`${c.green('✓')} Removed: ${removed.join(', ')}`);
599
+ if (missing.length)
600
+ say(`${c.yellow('⚠')} Not set: ${missing.join(', ')}`);
601
+ if (removed.length)
602
+ say(c.dim(applyHint));
603
+ emit({ project: publicProject(project), removed, missing, hint: applyHint });
604
+ return;
415
605
  }
416
- // Timed out
417
- console.log(`${c.yellow('⚠')} Timed out waiting for deployment to finish.`);
418
- console.log(` Check status at: ${c.cyan(`${creds.apiUrl}/projects/${project.id}`)}`);
419
- process.exit(1);
606
+ throw new CliError('Unknown env subcommand. Use: list | set KEY=VALUE … | push [file] | unset KEY …', 'USAGE', EXIT.NEEDS_INPUT);
607
+ }
608
+ async function cmdRollback(flags) {
609
+ const creds = await requireCreds();
610
+ const api = new EntrifyApi(creds);
611
+ const project = await resolveProject(api, flags);
612
+ const targetId = flags['to'] ?? previousDeployed(await api.listDeployments(project.id, 50))?.id;
613
+ if (!targetId) {
614
+ throw new CliError('There is no earlier successful deployment to roll back to.', 'NO_ROLLBACK_TARGET', EXIT.NEEDS_INPUT);
615
+ }
616
+ sayInline(` Rolling back ${c.bold(project.slug)} to ${c.dim(targetId)}… `);
617
+ const { deploymentId } = await api.rollback(project.id, targetId);
618
+ say(c.green('✓'));
619
+ const outcome = await followDeployment(api, project.id, deploymentId);
620
+ reportDeployment({ creds, project, deploymentId, outcome, details: { rolledBackTo: targetId } });
621
+ }
622
+ async function cmdCancel(flags) {
623
+ const creds = await requireCreds();
624
+ const api = new EntrifyApi(creds);
625
+ const project = await resolveProject(api, flags);
626
+ const active = flags['deployment']
627
+ ?? (await api.listDeployments(project.id, 10)).find((d) => d.status === 'QUEUED' || d.status === 'BUILDING')?.id;
628
+ if (!active)
629
+ throw new CliError('No deployment is queued or building.', 'NOTHING_TO_CANCEL', EXIT.NEEDS_INPUT);
630
+ await api.cancelDeployment(project.id, active);
631
+ say(`${c.green('✓')} Cancelled ${active}`);
632
+ emit({ project: publicProject(project), cancelled: active });
420
633
  }
421
634
  // ── Help ─────────────────────────────────────────────────────────────────────
422
635
  function cmdHelp() {
@@ -429,8 +642,20 @@ ${c.bold('USAGE')}
429
642
  ${c.bold('COMMANDS')}
430
643
  ${c.cyan('deploy')} Deploy the current project ${c.dim('(default)')}
431
644
  ${c.dim('--project-id <id|slug>')} Skip project selection and deploy to a specific project
645
+ ${c.dim('--create')} Create a project from this repository if none matches
432
646
  ${c.dim('--dir <path>')} Use a different working directory
433
-
647
+ ${c.cyan('status')} Latest deployment and live URL of the current project
648
+ ${c.cyan('logs')} Build log of the latest deployment
649
+ ${c.dim('--deployment <id>')} A specific deployment instead of the latest
650
+ ${c.dim('--lines <n|all>')} How many lines from the end ${c.dim('(default 200)')}
651
+ ${c.dim('--follow')} Stream a deployment that is still building
652
+ ${c.cyan('env list')} Environment variable names ${c.dim('(values are never shown)')}
653
+ ${c.cyan('env set KEY=VALUE …')} Add or change variables
654
+ ${c.cyan('env push [file]')} Add or change every variable in a .env file ${c.dim('(default .env)')}
655
+ ${c.cyan('env unset KEY …')} Remove variables
656
+ ${c.cyan('rollback')} Go back to the previous successful deployment
657
+ ${c.dim('--to <deployment-id>')} A specific earlier deployment
658
+ ${c.cyan('cancel')} Cancel the deployment that is queued or building
434
659
  ${c.cyan('projects')} List all your projects and their status
435
660
 
436
661
  ${c.cyan('auth login')} Log in to Entrify ${c.dim('(default: entrify.site)')}
@@ -443,6 +668,14 @@ ${c.bold('COMMANDS')}
443
668
  ${c.cyan('help')} Show this help message
444
669
  ${c.cyan('--version')} Print the CLI version
445
670
 
671
+ The project is matched from the git remote of the current directory; pass
672
+ ${c.dim('--project-id <id|slug>')} to any project command to choose one instead.
673
+
674
+ ${c.bold('FOR SCRIPTS AND AI AGENTS')}
675
+ ${c.dim('--json')} Print one JSON object: ${c.dim('{ "ok": true, ... }')} or ${c.dim('{ "ok": false, "error": { "code", "message" } }')}
676
+ Exit codes: ${c.dim('0')} ok · ${c.dim('1')} failed · ${c.dim('2')} needs input (flag, project, file) · ${c.dim('3')} not logged in
677
+ Commands never prompt without a terminal or with --json.
678
+
446
679
  ${c.bold('ENVIRONMENT')} ${c.dim('(the DROPDEPLOY_* names still work)')}
447
680
  ${c.dim('ENTRIFY_TOKEN')} Bearer token ${c.dim('(skips auth login in CI)')}
448
681
  ${c.dim('ENTRIFY_URL')} Entrify URL, e.g. https://entrify.site
@@ -451,18 +684,20 @@ ${c.bold('ENVIRONMENT')} ${c.dim('(the DROPDEPLOY_* names still work)')}
451
684
  ${c.bold('EXAMPLES')}
452
685
  ${c.dim('$')} ${BIN} auth login
453
686
  ${c.dim('$')} ${BIN} deploy
454
- ${c.dim('$')} ${BIN} deploy --project-id my-app
455
- ${c.dim('$')} ${BIN} projects
687
+ ${c.dim('$')} ${BIN} deploy --create --json
688
+ ${c.dim('$')} ${BIN} logs --follow
689
+ ${c.dim('$')} ${BIN} env push .env.production
690
+ ${c.dim('$')} ${BIN} rollback
456
691
  `);
457
692
  }
458
693
  // ── Entry point ───────────────────────────────────────────────────────────────
459
694
  async function main() {
460
695
  const { command, flags } = parseArgs(process.argv.slice(2));
461
- const [cmd, sub] = command;
696
+ const [cmd, sub, ...rest] = command;
697
+ jsonMode = flags['json'] === 'true';
462
698
  if (flags['help'] === 'true')
463
699
  return cmdHelp();
464
700
  if (cmd === 'version' || flags['version'] === 'true') {
465
- const { readFile } = await import('node:fs/promises');
466
701
  const { join, dirname } = await import('node:path');
467
702
  const { fileURLToPath } = await import('node:url');
468
703
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
@@ -477,21 +712,34 @@ async function main() {
477
712
  return cmdAuthStatus();
478
713
  if (sub === 'logout')
479
714
  return cmdAuthLogout();
480
- console.error('Unknown auth subcommand. Use: login | status | logout');
481
- process.exit(1);
715
+ throw new CliError('Unknown auth subcommand. Use: login | status | logout', 'USAGE', EXIT.NEEDS_INPUT);
482
716
  }
483
717
  if (cmd === 'projects')
484
718
  return cmdProjects();
485
719
  if (cmd === 'deploy' || cmd === undefined)
486
720
  return cmdDeploy(flags);
721
+ if (cmd === 'status')
722
+ return cmdStatus(flags);
723
+ if (cmd === 'logs')
724
+ return cmdLogs(flags);
725
+ if (cmd === 'env')
726
+ return cmdEnv(sub, rest, flags);
727
+ if (cmd === 'rollback')
728
+ return cmdRollback(flags);
729
+ if (cmd === 'cancel')
730
+ return cmdCancel(flags);
487
731
  if (cmd === 'help' || cmd === '--help' || cmd === '-h')
488
732
  return cmdHelp();
489
- console.error(`Unknown command: ${cmd}`);
490
- console.error(`Run ${BIN} help for usage.`);
491
- process.exit(1);
733
+ throw new CliError(`Unknown command: ${cmd}. Run ${BIN} help for usage.`, 'USAGE', EXIT.NEEDS_INPUT);
492
734
  }
493
735
  main().catch((err) => {
494
- console.error(`\n${c.red('Error:')} ${err.message}`);
495
- process.exit(1);
736
+ const e = err instanceof CliError ? err : new CliError(err instanceof Error ? err.message : String(err), 'ERROR');
737
+ if (jsonMode) {
738
+ process.stdout.write(JSON.stringify({ ok: false, error: { code: e.code, message: e.message }, ...e.details }) + '\n');
739
+ }
740
+ else {
741
+ console.error(`\n${c.red('Error:')} ${e.message}`);
742
+ }
743
+ process.exit(e.exitCode);
496
744
  });
497
745
  //# sourceMappingURL=cli.js.map