entrify-deploy 0.0.0-stage → 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 ADDED
@@ -0,0 +1,745 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { cwd as processCwd } from 'node:process';
6
+ import { readCredentials, clearCredentials, login, PRODUCTION_URL } from './auth.js';
7
+ import { EntrifyApi } from './api.js';
8
+ import { getGitInfo, validateLocal } from './detector.js';
9
+ import { CliError, EXIT, buildDeployUrl, cliName, normaliseRepoUrl, parseArgs, parseDotenv, previousDeployed, projectNameFromRepo, } from './util.js';
10
+ /** `entrify` or `dropdeploy`, whichever was typed. */
11
+ const BIN = cliName();
12
+ // ── ANSI colours (no external dep) ──────────────────────────────────────────
13
+ const c = {
14
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
15
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
16
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
17
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
18
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
19
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
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 });
45
+ // ── Interactive prompt ───────────────────────────────────────────────────────
46
+ async function prompt(question) {
47
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
48
+ return new Promise((resolve) => {
49
+ rl.question(question, (answer) => {
50
+ rl.close();
51
+ resolve(answer.trim());
52
+ });
53
+ });
54
+ }
55
+ async function promptHidden(question) {
56
+ process.stdout.write(question);
57
+ if (!process.stdin.isTTY) {
58
+ const rl = createInterface({ input: process.stdin });
59
+ return new Promise((resolve) => {
60
+ rl.once('line', (line) => { rl.close(); resolve(line.trim()); });
61
+ });
62
+ }
63
+ return new Promise((resolve, reject) => {
64
+ let password = '';
65
+ const cleanup = () => {
66
+ process.stdin.setRawMode(false);
67
+ process.stdin.pause();
68
+ process.stdin.removeListener('data', onData);
69
+ };
70
+ const onData = (char) => {
71
+ try {
72
+ if (char === '\r' || char === '\n') {
73
+ cleanup();
74
+ process.stdout.write('\n');
75
+ resolve(password);
76
+ }
77
+ else if (char === '\u0003') {
78
+ cleanup();
79
+ process.stdout.write('\n');
80
+ process.exit(0);
81
+ }
82
+ else if (char === '\u007f' || char === '\u0008') {
83
+ password = password.slice(0, -1);
84
+ }
85
+ else {
86
+ password += char;
87
+ }
88
+ }
89
+ catch (e) {
90
+ cleanup();
91
+ reject(e);
92
+ }
93
+ };
94
+ process.stdin.setRawMode(true);
95
+ process.stdin.resume();
96
+ process.stdin.setEncoding('utf8');
97
+ process.stdin.on('data', onData);
98
+ });
99
+ }
100
+ const STEP_PERCENT = {
101
+ CLONING: 20,
102
+ SCANNING: 40,
103
+ BUILDING_IMAGE: 70,
104
+ STARTING_CONTAINER: 90,
105
+ };
106
+ const STEP_LABEL = {
107
+ CLONING: 'Cloning repository',
108
+ SCANNING: 'Scanning packages',
109
+ BUILDING_IMAGE: 'Building Docker image',
110
+ STARTING_CONTAINER: 'Starting container',
111
+ };
112
+ function detectBuildStep(line) {
113
+ if (line.includes('▶ Cloning'))
114
+ return 'CLONING';
115
+ if (line.includes('▶ Scanning'))
116
+ return 'SCANNING';
117
+ if (line.includes('▶ Building image'))
118
+ return 'BUILDING_IMAGE';
119
+ if (line.includes('▶ Starting'))
120
+ return 'STARTING_CONTAINER';
121
+ return null;
122
+ }
123
+ function renderBar(percent, label) {
124
+ const width = 20;
125
+ const filled = Math.round((percent / 100) * width);
126
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
127
+ return ` [${c.cyan(bar)}] ${String(percent).padStart(3)}% ${c.dim(label)}`;
128
+ }
129
+ const isTTY = process.stdout.isTTY ?? false;
130
+ function printProgress(percent, label) {
131
+ if (!isTTY || jsonMode)
132
+ return;
133
+ process.stdout.write('\r' + renderBar(percent, label) + ' ');
134
+ }
135
+ function clearProgress() {
136
+ if (!isTTY || jsonMode)
137
+ return;
138
+ process.stdout.write('\r' + ' '.repeat(72) + '\r');
139
+ }
140
+ // ── Error diagnosis from build log tail ─────────────────────────────────────
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
+ },
157
+ {
158
+ re: /Cannot find module|Module not found/i,
159
+ cause: 'Missing dependency',
160
+ fix: 'Run `npm install` locally and push your changes',
161
+ },
162
+ {
163
+ re: /ENOENT.*package\.json/i,
164
+ cause: 'package.json not found',
165
+ fix: 'Ensure package.json is committed to the repo',
166
+ },
167
+ {
168
+ re: /Authentication failed|could not read Username/i,
169
+ cause: 'Git authentication failed',
170
+ fix: 'Reconnect your Git provider in Entrify → Settings',
171
+ },
172
+ {
173
+ re: /Blocked package/i,
174
+ cause: 'Blocked package detected',
175
+ fix: 'Remove the flagged package from package.json and push',
176
+ },
177
+ {
178
+ re: /npm ERR!/i,
179
+ cause: 'npm build error',
180
+ fix: 'Run `npm run build` locally to reproduce and debug',
181
+ },
182
+ {
183
+ re: /error TS\d+/i,
184
+ cause: 'TypeScript compile error',
185
+ fix: 'Run `tsc --noEmit` locally to see type errors',
186
+ },
187
+ {
188
+ re: /exit code: 1|exited with code 1/i,
189
+ cause: 'Build script exited with an error',
190
+ fix: 'Run the build command locally to debug the output',
191
+ },
192
+ ];
193
+ function diagnose(log) {
194
+ const tail = log.split('\n').slice(-40).join('\n');
195
+ for (const { re, cause, fix } of ERROR_PATTERNS) {
196
+ if (re.test(tail))
197
+ return { cause, fix };
198
+ }
199
+ return null;
200
+ }
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) {
224
+ const projects = await api.listProjects();
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 };
231
+ }
232
+ // Auto-match by git remote URL (HTTPS or SSH); with several projects on one repo, prefer this branch's
233
+ if (query.remoteUrl) {
234
+ const needle = normaliseRepoUrl(query.remoteUrl);
235
+ const matches = projects.filter((p) => p.githubUrl && normaliseRepoUrl(p.githubUrl) === needle);
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
+ }
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.`;
245
+ if (projects.length === 0) {
246
+ throw new CliError(`No projects found on Entrify. ${hint}`, 'NO_PROJECTS', EXIT.NEEDS_INPUT);
247
+ }
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);
251
+ }
252
+ say(`\n${c.bold('Select a project:')}`);
253
+ projects.forEach((p, i) => {
254
+ say(` ${c.cyan(String(i + 1))}) ${p.name} ${c.dim(`(${p.slug})`)} ${colourStatus(p.deployments?.[0]?.status)}`);
255
+ });
256
+ const answer = await prompt(`\nEnter number [1-${projects.length}]: `);
257
+ const idx = parseInt(answer, 10) - 1;
258
+ if (isNaN(idx) || idx < 0 || idx >= projects.length) {
259
+ throw new CliError('Invalid selection', 'INVALID_SELECTION', EXIT.NEEDS_INPUT);
260
+ }
261
+ return { project: projects[idx], created: false };
262
+ }
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;
268
+ }
269
+ /** Streams the build log (with a progress bar for humans), then waits for the final status. */
270
+ async function followDeployment(api, projectId, deploymentId) {
271
+ const logLines = [];
272
+ let currentStep = null;
273
+ let currentPercent = 5;
274
+ printProgress(currentPercent, 'Waiting in queue…');
275
+ try {
276
+ for await (const evt of api.streamLogs(projectId, deploymentId)) {
277
+ if (evt.type === 'line') {
278
+ logLines.push(evt.text);
279
+ const step = detectBuildStep(evt.text);
280
+ if (step) {
281
+ currentStep = step;
282
+ currentPercent = STEP_PERCENT[step];
283
+ clearProgress();
284
+ say(`\n ${c.cyan('›')} ${c.bold(STEP_LABEL[step])}`);
285
+ printProgress(currentPercent, STEP_LABEL[step] + '…');
286
+ }
287
+ else {
288
+ clearProgress();
289
+ say(` ${c.dim(evt.text)}`);
290
+ printProgress(currentPercent, currentStep ? STEP_LABEL[currentStep] + '…' : 'Building…');
291
+ }
292
+ }
293
+ else if (evt.type === 'existing') {
294
+ for (const l of evt.log.split('\n')) {
295
+ const step = detectBuildStep(l);
296
+ if (step) {
297
+ currentStep = step;
298
+ currentPercent = STEP_PERCENT[step];
299
+ }
300
+ }
301
+ logLines.push(evt.log);
302
+ }
303
+ else if (evt.type === 'done') {
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ catch {
309
+ clearProgress();
310
+ say(` ${c.yellow('⚠')} Log stream disconnected — polling for result…`);
311
+ }
312
+ clearProgress();
313
+ // SSE ends slightly before DB is updated — poll with exponential backoff
314
+ let finalLog = null;
315
+ let subdomain = null;
316
+ let consecutiveErrors = 0;
317
+ for (let attempt = 0; attempt < 20; attempt++) {
318
+ const delay = Math.min(1000 * 2 ** attempt, 10_000);
319
+ await new Promise((r) => setTimeout(r, delay));
320
+ try {
321
+ const s = await api.getDeploymentStatus(projectId, deploymentId);
322
+ consecutiveErrors = 0;
323
+ finalLog = s.buildLog;
324
+ subdomain = s.subdomain;
325
+ if (s.status === 'DEPLOYED' || s.status === 'FAILED' || s.status === 'CANCELLED') {
326
+ return { status: s.status, subdomain, log: finalLog ?? logLines.join(''), step: currentStep };
327
+ }
328
+ }
329
+ catch {
330
+ if (++consecutiveErrors >= 5)
331
+ break;
332
+ }
333
+ }
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})`)}`);
455
+ }
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 })) });
546
+ return;
547
+ }
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);
572
+ }
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;
593
+ }
594
+ await api.deleteEnv(project.id, found.id);
595
+ removed.push(key);
596
+ }
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;
605
+ }
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 });
633
+ }
634
+ // ── Help ─────────────────────────────────────────────────────────────────────
635
+ function cmdHelp() {
636
+ console.log(`
637
+ ${c.bold(BIN)} — Deploy projects to Entrify from the terminal
638
+
639
+ ${c.bold('USAGE')}
640
+ ${BIN} <command> [flags]
641
+
642
+ ${c.bold('COMMANDS')}
643
+ ${c.cyan('deploy')} Deploy the current project ${c.dim('(default)')}
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
646
+ ${c.dim('--dir <path>')} Use a different working directory
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
659
+ ${c.cyan('projects')} List all your projects and their status
660
+
661
+ ${c.cyan('auth login')} Log in to Entrify ${c.dim('(default: entrify.site)')}
662
+ ${c.dim('--api-url <url>')} Override the default URL (e.g. for local dev)
663
+ ${c.dim('--email <email>')} Your email
664
+ ${c.dim('(password is always prompted interactively)')}
665
+ ${c.cyan('auth status')} Show current login status
666
+ ${c.cyan('auth logout')} Clear saved credentials
667
+
668
+ ${c.cyan('help')} Show this help message
669
+ ${c.cyan('--version')} Print the CLI version
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
+
679
+ ${c.bold('ENVIRONMENT')} ${c.dim('(the DROPDEPLOY_* names still work)')}
680
+ ${c.dim('ENTRIFY_TOKEN')} Bearer token ${c.dim('(skips auth login in CI)')}
681
+ ${c.dim('ENTRIFY_URL')} Entrify URL, e.g. https://entrify.site
682
+ ${c.dim('ENTRIFY_EMAIL')} Email ${c.dim('(optional, cosmetic)')}
683
+
684
+ ${c.bold('EXAMPLES')}
685
+ ${c.dim('$')} ${BIN} auth login
686
+ ${c.dim('$')} ${BIN} deploy
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
691
+ `);
692
+ }
693
+ // ── Entry point ───────────────────────────────────────────────────────────────
694
+ async function main() {
695
+ const { command, flags } = parseArgs(process.argv.slice(2));
696
+ const [cmd, sub, ...rest] = command;
697
+ jsonMode = flags['json'] === 'true';
698
+ if (flags['help'] === 'true')
699
+ return cmdHelp();
700
+ if (cmd === 'version' || flags['version'] === 'true') {
701
+ const { join, dirname } = await import('node:path');
702
+ const { fileURLToPath } = await import('node:url');
703
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
704
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
705
+ console.log(pkg.version);
706
+ return;
707
+ }
708
+ if (cmd === 'auth') {
709
+ if (sub === 'login')
710
+ return cmdAuthLogin(flags);
711
+ if (sub === 'status')
712
+ return cmdAuthStatus();
713
+ if (sub === 'logout')
714
+ return cmdAuthLogout();
715
+ throw new CliError('Unknown auth subcommand. Use: login | status | logout', 'USAGE', EXIT.NEEDS_INPUT);
716
+ }
717
+ if (cmd === 'projects')
718
+ return cmdProjects();
719
+ if (cmd === 'deploy' || cmd === undefined)
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);
731
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h')
732
+ return cmdHelp();
733
+ throw new CliError(`Unknown command: ${cmd}. Run ${BIN} help for usage.`, 'USAGE', EXIT.NEEDS_INPUT);
734
+ }
735
+ main().catch((err) => {
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);
744
+ });
745
+ //# sourceMappingURL=cli.js.map