@faable/faable 1.27.0 → 1.29.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.
@@ -169,6 +169,54 @@ class FaableApi {
169
169
  async getMe() {
170
170
  return data(this.client.get(`/auth/me`));
171
171
  }
172
+ // Runtime logs of the app (Loki-backed; last 24h, up to 200 lines, newest
173
+ // first). Optionally scoped to one deployment.
174
+ async getAppLogs(app_id, params = {}) {
175
+ return data(this.client.get(`/app/${app_id}/logs`, { params }));
176
+ }
177
+ // Deployments of an app, newest first (the API's list index sorts
178
+ // createdAt desc). Team pinned via header — same reason as domains.
179
+ async listDeployments(app_id, team) {
180
+ return firstPage(data(this.client.get(`/deployment`, {
181
+ params: { app_id },
182
+ headers: { "x-faable-team": team },
183
+ })));
184
+ }
185
+ // Build and deploy the current head of the deploy branch server-side —
186
+ // the same path a push webhook takes, same-commit dedupe included.
187
+ async deployNow(app_id) {
188
+ return data(this.client.post(`/app/${app_id}/deploy`));
189
+ }
190
+ // Rebuild a failed deployment from its recorded source (CAS manifest or
191
+ // git ref). The API enforces the guards: failed phase only, never older
192
+ // than what production serves.
193
+ async redeployDeployment(deployment_id, team) {
194
+ return data(this.client.post(`/deployment/${deployment_id}/redeploy`, undefined, { headers: { "x-faable-team": team } }));
195
+ }
196
+ // Domains are team-scoped rows; a CLI user token carries no default team,
197
+ // so every call pins the app's team via `x-faable-team` (same pattern as
198
+ // createSecretsBatch).
199
+ async listDomains(app_id, team) {
200
+ return firstPage(data(this.client.get(`/domain`, {
201
+ params: { app_id },
202
+ headers: { "x-faable-team": team },
203
+ })));
204
+ }
205
+ async createDomain(team, params) {
206
+ return data(this.client.post(`/domain`, params, {
207
+ headers: { "x-faable-team": team },
208
+ }));
209
+ }
210
+ async getDomain(domain_id, team) {
211
+ return data(this.client.get(`/domain/${domain_id}`, {
212
+ headers: { "x-faable-team": team },
213
+ }));
214
+ }
215
+ async deleteDomain(domain_id, team) {
216
+ return data(this.client.delete(`/domain/${domain_id}`, {
217
+ headers: { "x-faable-team": team },
218
+ }));
219
+ }
172
220
  }
173
221
 
174
222
  export { FaableApi };
@@ -0,0 +1,46 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { cname_target } from './format.js';
5
+
6
+ const domains_add = {
7
+ command: 'add <fqdn>',
8
+ describe: 'Add a custom domain to the app',
9
+ builder: yargs => yargs
10
+ .positional('fqdn', {
11
+ type: 'string',
12
+ demandOption: true,
13
+ description: 'Fully qualified domain name (e.g. www.example.com)'
14
+ })
15
+ .option('app', {
16
+ alias: 'a',
17
+ type: 'string',
18
+ description: 'App Identifier (defaults to the linked app)'
19
+ })
20
+ .option('tls', {
21
+ type: 'boolean',
22
+ default: true,
23
+ description: 'Provision a TLS certificate automatically (default)'
24
+ })
25
+ .example('$0 deploy domains add www.example.com', 'Attach www.example.com to the linked app')
26
+ .showHelpOnFail(false),
27
+ handler: async (args) => {
28
+ const ctx = await requireApi();
29
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
30
+ const app = await ctx.api.getApp(app_id);
31
+ const domain = await ctx.api.createDomain(app.team, {
32
+ fqdn: args.fqdn,
33
+ app_id,
34
+ tls: args.tls
35
+ });
36
+ log.info(`🌐 Domain ${domain.fqdn} added to ${app.name} (${app_id}).`);
37
+ log.info(``);
38
+ log.info(`Now create a CNAME record at your DNS provider:`);
39
+ log.info(` ${domain.fqdn} → ${cname_target(domain)}`);
40
+ log.info(``);
41
+ log.info(`Faable verifies the record automatically once DNS propagates${args.tls ? ' and then provisions the TLS certificate' : ''}.`);
42
+ log.info(`Track it with: faable deploy domains check ${domain.fqdn}`);
43
+ }
44
+ };
45
+
46
+ export { domains_add };
@@ -0,0 +1,51 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { find_by_fqdn, dns_badge, cname_target } from './format.js';
5
+
6
+ const domains_check = {
7
+ command: 'check <fqdn>',
8
+ describe: 'Show the DNS verification status of a domain',
9
+ builder: yargs => yargs
10
+ .positional('fqdn', {
11
+ type: 'string',
12
+ demandOption: true,
13
+ description: 'Domain to check'
14
+ })
15
+ .option('app', {
16
+ alias: 'a',
17
+ type: 'string',
18
+ description: 'App Identifier (defaults to the linked app)'
19
+ })
20
+ .showHelpOnFail(false),
21
+ handler: async (args) => {
22
+ const ctx = await requireApi();
23
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
24
+ const app = await ctx.api.getApp(app_id);
25
+ const domains = await ctx.api.listDomains(app_id, app.team);
26
+ const domain = find_by_fqdn(domains, args.fqdn);
27
+ if (!domain) {
28
+ throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
29
+ }
30
+ const status = domain.status;
31
+ log.info(`🌐 ${domain.fqdn} — ${dns_badge(domain)}`);
32
+ log.info(` Expected CNAME: ${domain.fqdn} → ${cname_target(domain)}`);
33
+ if (status?.dns_observed?.length) {
34
+ log.info(` Observed: ${status.dns_observed.join(', ')}`);
35
+ }
36
+ else {
37
+ log.info(` Observed: (no CNAME resolved yet)`);
38
+ }
39
+ if (status?.dns_message) {
40
+ log.info(` Diagnostic: ${status.dns_message}`);
41
+ }
42
+ if (status?.dns_checked_at) {
43
+ log.info(` Last checked: ${status.dns_checked_at}`);
44
+ }
45
+ if (!domain.verified) {
46
+ log.info(`Faable re-checks automatically — no action needed beyond the CNAME record.`);
47
+ }
48
+ }
49
+ };
50
+
51
+ export { domains_check };
@@ -0,0 +1,24 @@
1
+ // Human summary of a domain's verification state, derived from the DNS
2
+ // verification worker's status. Kept pure for tests.
3
+ const dns_badge = (domain) => {
4
+ if (domain.verified)
5
+ return '✅ verified';
6
+ switch (domain.status?.dns_state) {
7
+ case 'ok':
8
+ return '✅ dns ok';
9
+ case 'misconfigured':
10
+ return '❌ misconfigured';
11
+ case 'error':
12
+ return '❌ dns error';
13
+ default:
14
+ return '⏳ pending verification';
15
+ }
16
+ };
17
+ // The CNAME target the user must configure. The API publishes it in
18
+ // `status.dns_expected` (the dashboard shows `<domain.id>.faable.link` — same
19
+ // value); fall back to deriving it from the id so `add` can print
20
+ // instructions even before the first DNS check populates the status.
21
+ const cname_target = (domain) => domain.status?.dns_expected?.[0] ?? `${domain.id}.faable.link`;
22
+ const find_by_fqdn = (domains, fqdn) => domains.find(d => d.fqdn.toLowerCase() === fqdn.toLowerCase());
23
+
24
+ export { cname_target, dns_badge, find_by_fqdn };
@@ -0,0 +1,21 @@
1
+ import { domains_add } from './add.js';
2
+ import { domains_check } from './check.js';
3
+ import { domains_list } from './list.js';
4
+ import { domains_rm } from './rm.js';
5
+
6
+ const domains = {
7
+ command: 'domains <command>',
8
+ describe: 'Manage custom domains of an app',
9
+ builder: yargs => yargs
10
+ .command(domains_list)
11
+ .command(domains_add)
12
+ .command(domains_check)
13
+ .command(domains_rm)
14
+ .demandCommand(1, 'Specify a domains command: list, add, check or rm'),
15
+ handler: () => {
16
+ // Unreachable: demandCommand(1) either routes to a subcommand or fails
17
+ // through the global .fail() in src/index.ts.
18
+ }
19
+ };
20
+
21
+ export { domains };
@@ -0,0 +1,45 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { dns_badge, cname_target } from './format.js';
5
+
6
+ const domains_list = {
7
+ command: 'list',
8
+ describe: 'List custom domains of the app',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .example('$0 deploy domains list', 'Domains of the linked app')
16
+ .showHelpOnFail(false),
17
+ handler: async (args) => {
18
+ const ctx = await requireApi();
19
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
20
+ const app = await ctx.api.getApp(app_id);
21
+ const domains = await ctx.api.listDomains(app_id, app.team);
22
+ if (domains.length === 0) {
23
+ log.info(`🌐 No custom domains for ${app.name} (${app_id}).`);
24
+ log.info(`Add one with: faable deploy domains add <yourdomain.com>. The app is always live at https://${app.url}.`);
25
+ return;
26
+ }
27
+ log.info(`🌐 ${domains.length} domain(s) for ${app.name} (${app_id}):`);
28
+ const width = Math.max(...domains.map(d => d.fqdn.length));
29
+ for (const domain of domains) {
30
+ const tls = domain.tls ? 'tls' : 'no-tls';
31
+ log.info(` ${domain.fqdn.padEnd(width)} ${dns_badge(domain)} (${tls})`);
32
+ }
33
+ const unverified = domains.filter(d => !d.verified);
34
+ if (unverified.length > 0) {
35
+ log.info(``);
36
+ log.info(`To finish verification, point each domain at Faable with a CNAME:`);
37
+ for (const domain of unverified) {
38
+ log.info(` ${domain.fqdn} → ${cname_target(domain)}`);
39
+ }
40
+ log.info(`Run "faable deploy domains check <fqdn>" to see the diagnostic.`);
41
+ }
42
+ }
43
+ };
44
+
45
+ export { domains_list };
@@ -0,0 +1,59 @@
1
+ import prompts from 'prompts';
2
+ import { requireApi } from '../../../api/context.js';
3
+ import { log } from '../../../log.js';
4
+ import { resolve_app_id } from '../resolve_app_id.js';
5
+ import { find_by_fqdn } from './format.js';
6
+
7
+ const domains_rm = {
8
+ command: 'rm <fqdn>',
9
+ describe: 'Remove a custom domain from the app',
10
+ builder: yargs => yargs
11
+ .positional('fqdn', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Domain to remove'
15
+ })
16
+ .option('app', {
17
+ alias: 'a',
18
+ type: 'string',
19
+ description: 'App Identifier (defaults to the linked app)'
20
+ })
21
+ .option('yes', {
22
+ alias: 'y',
23
+ type: 'boolean',
24
+ default: false,
25
+ description: 'Skip the confirmation prompt'
26
+ })
27
+ .example('$0 deploy domains rm www.example.com', 'Detach after confirmation')
28
+ .showHelpOnFail(false),
29
+ handler: async (args) => {
30
+ const ctx = await requireApi();
31
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
32
+ const app = await ctx.api.getApp(app_id);
33
+ const domains = await ctx.api.listDomains(app_id, app.team);
34
+ const domain = find_by_fqdn(domains, args.fqdn);
35
+ if (!domain) {
36
+ throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
37
+ }
38
+ if (!args.yes) {
39
+ // In a non-TTY run without --yes, prompts resolves undefined → cancel.
40
+ const { confirm } = await prompts({
41
+ type: 'toggle',
42
+ name: 'confirm',
43
+ message: `Remove domain "${domain.fqdn}" from ${app.name} (${app_id})? Traffic to it will stop being served.`,
44
+ initial: false,
45
+ active: 'yes',
46
+ inactive: 'no'
47
+ });
48
+ if (!confirm) {
49
+ log.info('Cancelled.');
50
+ return;
51
+ }
52
+ }
53
+ await ctx.api.deleteDomain(domain.id, app.team);
54
+ log.info(`🗑️ Removed domain ${domain.fqdn} from ${app_id}.`);
55
+ log.info(`The app stays live at https://${app.url}. Remember to delete the CNAME at your DNS provider.`);
56
+ }
57
+ };
58
+
59
+ export { domains_rm };
@@ -2,7 +2,15 @@ import { requireApi } from '../../api/context.js';
2
2
  import { Configuration } from '../../lib/Configuration.js';
3
3
  import { log } from '../../log.js';
4
4
  import { link } from '../link/index.js';
5
+ import { domains } from './domains/index.js';
5
6
  import { git_context } from './git_context.js';
7
+ import { deployments } from './inspect/deployments.js';
8
+ import { apps_list } from './inspect/list.js';
9
+ import { logs } from './inspect/logs.js';
10
+ import { open_app } from './inspect/open.js';
11
+ import { redeploy } from './inspect/redeploy.js';
12
+ import { status } from './inspect/status.js';
13
+ import { trigger } from './inspect/trigger.js';
6
14
  import { propose_release } from './release_version.js';
7
15
  import { deploy_remote } from './remote/index.js';
8
16
  import { resolve_app_id } from './resolve_app_id.js';
@@ -17,6 +25,14 @@ const deploy = {
17
25
  // app_id positional, so `faable deploy <app_id>` keeps working).
18
26
  return yargs
19
27
  .command(secrets)
28
+ .command(domains)
29
+ .command(logs)
30
+ .command(status)
31
+ .command(apps_list)
32
+ .command(deployments)
33
+ .command(open_app)
34
+ .command(trigger)
35
+ .command(redeploy)
20
36
  .command(link)
21
37
  .positional('app_id', {
22
38
  type: 'string',
@@ -0,0 +1,40 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { deployment_row } from './format.js';
5
+
6
+ const deployments = {
7
+ command: 'deployments',
8
+ describe: 'List recent deployments of the app',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .option('limit', {
16
+ alias: 'n',
17
+ type: 'number',
18
+ default: 10,
19
+ description: 'How many to show'
20
+ })
21
+ .showHelpOnFail(false),
22
+ handler: async (args) => {
23
+ const ctx = await requireApi();
24
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
25
+ const app = await ctx.api.getApp(app_id);
26
+ const rows = await ctx.api.listDeployments(app_id, app.team);
27
+ if (rows.length === 0) {
28
+ log.info(`📭 ${app.name} has no deployments yet.`);
29
+ return;
30
+ }
31
+ const shown = rows.slice(0, args.limit ?? 10);
32
+ log.info(`🚀 Last ${shown.length} deployment(s) of ${app.name}:`);
33
+ for (const d of shown) {
34
+ const live = d.id === app.status?.deployment ? ' ← live' : '';
35
+ log.info(` ${deployment_row(d)}${live}`);
36
+ }
37
+ }
38
+ };
39
+
40
+ export { deployments };
@@ -0,0 +1,60 @@
1
+ // Pure formatting helpers for the read commands (logs/status/list/
2
+ // deployments), kept out of the handlers for tests.
3
+ const PHASE_ICONS = {
4
+ READY: '🟢',
5
+ INITIALIZING: '🔵',
6
+ BUILDING: '🔵',
7
+ QUEUED: '⚪',
8
+ UNKNOWN: '⚪',
9
+ QUOTA_HOLD: '🟡',
10
+ SUPERSEDED: '⚪',
11
+ CANCELED: '⚪',
12
+ TERMINATING: '⚪',
13
+ ERROR: '🔴',
14
+ BUILD_ERROR: '🔴'
15
+ };
16
+ const phase_badge = (phase) => {
17
+ const p = phase || 'UNKNOWN';
18
+ return `${PHASE_ICONS[p] ?? '⚪'} ${p}`;
19
+ };
20
+ // "python 3.11.3 (django)" from the platform-detected metadata; null when
21
+ // nothing was ever detected (no build yet).
22
+ const detected_summary = (detected) => {
23
+ if (!detected)
24
+ return null;
25
+ const runtime = [detected.runtime.name, detected.runtime.version]
26
+ .filter(Boolean)
27
+ .join(' ');
28
+ return detected.framework ? `${runtime} (${detected.framework})` : runtime;
29
+ };
30
+ // Loki serves [ns_timestamp, text, deployment_id] newest first; render
31
+ // oldest first (reading order) with an ISO second-precision prefix.
32
+ const format_log_lines = (lines) => [...lines]
33
+ .sort((a, b) => Number(a[0]) - Number(b[0]))
34
+ .map(([ts, text]) => {
35
+ const iso = new Date(Number(ts) / 1e6).toISOString().replace(/\.\d+Z$/, 'Z');
36
+ return `${iso} ${text.replace(/\n+$/, '')}`;
37
+ });
38
+ const short_commit = (sha) => sha ? sha.slice(0, 7) : '-';
39
+ const when = (iso) => {
40
+ if (!iso)
41
+ return '-';
42
+ const ms = Date.now() - new Date(iso).getTime();
43
+ const minutes = Math.floor(ms / 60_000);
44
+ if (minutes < 1)
45
+ return 'just now';
46
+ if (minutes < 60)
47
+ return `${minutes}m ago`;
48
+ const hours = Math.floor(minutes / 60);
49
+ if (hours < 48)
50
+ return `${hours}h ago`;
51
+ return `${Math.floor(hours / 24)}d ago`;
52
+ };
53
+ // One row per deployment for the `deployments` table.
54
+ const deployment_row = (d) => {
55
+ const release = d.release ? ` ${d.release}` : '';
56
+ const trigger = d.trigger === 'webhook' ? 'push' : 'cli';
57
+ return `${phase_badge(d.status?.phase)} ${d.id} ${short_commit(d.github_commit)}${release} (${trigger}, ${when(d.createdAt)})`;
58
+ };
59
+
60
+ export { deployment_row, detected_summary, format_log_lines, phase_badge, short_commit, when };
@@ -0,0 +1,24 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { phase_badge } from './format.js';
4
+
5
+ const apps_list = {
6
+ command: 'list',
7
+ describe: 'List your apps',
8
+ builder: yargs => yargs.showHelpOnFail(false),
9
+ handler: async () => {
10
+ const ctx = await requireApi();
11
+ const apps = await ctx.api.list();
12
+ if (apps.length === 0) {
13
+ log.info(`📭 No apps yet. Create one in the dashboard (https://dashboard.faable.com) and link your repo.`);
14
+ return;
15
+ }
16
+ log.info(`📦 ${apps.length} app(s):`);
17
+ const width = Math.max(...apps.map(a => a.name.length));
18
+ for (const app of apps) {
19
+ log.info(` ${app.name.padEnd(width)} ${phase_badge(app.status?.phase)} ${app.id} https://${app.url}`);
20
+ }
21
+ }
22
+ };
23
+
24
+ export { apps_list };
@@ -0,0 +1,72 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { format_log_lines } from './format.js';
5
+
6
+ const logs = {
7
+ command: 'logs',
8
+ describe: 'Show runtime logs of the app (or build logs with --build)',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .option('build', {
16
+ type: 'boolean',
17
+ default: false,
18
+ description: 'Show the build output of the latest deployment instead'
19
+ })
20
+ .option('deployment', {
21
+ alias: 'd',
22
+ type: 'string',
23
+ description: 'Scope to one deployment id'
24
+ })
25
+ .example('$0 deploy logs', 'Runtime logs of the linked app (last 24h)')
26
+ .example('$0 deploy logs --build', 'Build output of the latest deployment')
27
+ .showHelpOnFail(false),
28
+ handler: async (args) => {
29
+ const ctx = await requireApi();
30
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
31
+ const app = await ctx.api.getApp(app_id);
32
+ if (args.build) {
33
+ // Build output lives on the deployment. Default to the newest one —
34
+ // exactly what you want after a red `faable deploy`.
35
+ let deployment_id = args.deployment;
36
+ if (!deployment_id) {
37
+ const deployments = await ctx.api.listDeployments(app_id, app.team);
38
+ deployment_id = deployments[0]?.id;
39
+ if (!deployment_id) {
40
+ log.info(`📭 ${app.name} has no deployments yet.`);
41
+ return;
42
+ }
43
+ }
44
+ const build = await ctx.api.getDeploymentLogs(deployment_id);
45
+ if (!build.content) {
46
+ log.info(`📭 No build output recorded for ${deployment_id}.`);
47
+ return;
48
+ }
49
+ log.info(`🏗️ Build output of ${deployment_id}:`);
50
+ process.stdout.write(build.content);
51
+ if (!build.content.endsWith('\n'))
52
+ process.stdout.write('\n');
53
+ if (build.truncated)
54
+ log.warn(`(output truncated)`);
55
+ return;
56
+ }
57
+ const lines = await ctx.api.getAppLogs(app_id, {
58
+ deployment_id: args.deployment
59
+ });
60
+ if (lines.length === 0) {
61
+ log.info(`📭 No runtime logs in the last 24h for ${app.name} (${app_id}).`);
62
+ log.info(`For build output, use: faable deploy logs --build`);
63
+ return;
64
+ }
65
+ log.info(`📜 Runtime logs of ${app.name} (last 24h, newest last):`);
66
+ for (const line of format_log_lines(lines)) {
67
+ process.stdout.write(line + '\n');
68
+ }
69
+ }
70
+ };
71
+
72
+ export { logs };
@@ -0,0 +1,37 @@
1
+ import openBrowser from 'open';
2
+ import { requireApi } from '../../../api/context.js';
3
+ import { log } from '../../../log.js';
4
+ import { resolve_app_id } from '../resolve_app_id.js';
5
+
6
+ const open_app = {
7
+ command: 'open',
8
+ describe: 'Open the app in the browser',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .option('dashboard', {
16
+ type: 'boolean',
17
+ default: false,
18
+ description: 'Open the Faable dashboard page of the app instead'
19
+ })
20
+ .example('$0 deploy open', 'Open the live app URL')
21
+ .example('$0 deploy open --dashboard', 'Open the app in the dashboard')
22
+ .showHelpOnFail(false),
23
+ handler: async (args) => {
24
+ const ctx = await requireApi();
25
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
26
+ const app = await ctx.api.getApp(app_id);
27
+ const url = args.dashboard
28
+ ? `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`
29
+ : `https://${app.url}`;
30
+ log.info(`🌍 Opening ${url}`);
31
+ await openBrowser(url).catch(() => {
32
+ log.warn(`Could not open the browser automatically — visit: ${url}`);
33
+ });
34
+ }
35
+ };
36
+
37
+ export { open_app };
@@ -0,0 +1,43 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+
5
+ const FAILED_PHASES = new Set(['ERROR', 'BUILD_ERROR']);
6
+ const redeploy = {
7
+ command: 'redeploy [deployment]',
8
+ describe: 'Rebuild a failed deployment from its recorded source',
9
+ builder: yargs => yargs
10
+ .positional('deployment', {
11
+ type: 'string',
12
+ description: 'Deployment id to rebuild (defaults to the latest failed one)'
13
+ })
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .example('$0 deploy redeploy', 'Retry the latest failed deployment')
20
+ .showHelpOnFail(false),
21
+ handler: async (args) => {
22
+ const ctx = await requireApi();
23
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
24
+ const app = await ctx.api.getApp(app_id);
25
+ let deployment_id = args.deployment;
26
+ if (!deployment_id) {
27
+ const rows = await ctx.api.listDeployments(app_id, app.team);
28
+ const failed = rows.find(d => FAILED_PHASES.has(d.status?.phase ?? ''));
29
+ if (!failed) {
30
+ log.info(`✅ No failed deployments to retry for ${app.name}. To rebuild the repo HEAD use: faable deploy trigger`);
31
+ return;
32
+ }
33
+ deployment_id = failed.id;
34
+ }
35
+ // The API enforces the guards (failed phase only, never older than what
36
+ // production serves) and answers with an actionable refusal otherwise.
37
+ const clone = await ctx.api.redeployDeployment(deployment_id, app.team);
38
+ log.info(`🔁 Rebuilding ${deployment_id} as ${clone.id}.`);
39
+ log.info(`Track it with: faable deploy status · build output: faable deploy logs --build`);
40
+ }
41
+ };
42
+
43
+ export { redeploy };
@@ -0,0 +1,46 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { phase_badge, detected_summary, short_commit, when } from './format.js';
5
+
6
+ const status = {
7
+ command: 'status',
8
+ describe: 'Show what is live for the app',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .showHelpOnFail(false),
16
+ handler: async (args) => {
17
+ const ctx = await requireApi();
18
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
19
+ const app = await ctx.api.getApp(app_id);
20
+ const deployments = await ctx.api.listDeployments(app_id, app.team);
21
+ const latest = deployments[0];
22
+ log.info(`${phase_badge(app.status?.phase)} ${app.name} (${app.id})`);
23
+ log.info(` URL: https://${app.url}`);
24
+ const stack = detected_summary(app.detected);
25
+ if (stack)
26
+ log.info(` Stack: ${stack}`);
27
+ if (app.repository) {
28
+ log.info(` Repository: ${app.repository} (${app.github_branch || 'main'}${app.deploy_trigger === 'webhook' ? ', push-to-deploy' : ''})`);
29
+ }
30
+ if (app.status?.deployment) {
31
+ log.info(` Live: ${app.status.deployment}`);
32
+ }
33
+ if (latest && latest.id !== app.status?.deployment) {
34
+ log.info(` Latest: ${latest.id} — ${phase_badge(latest.status?.phase)} (${short_commit(latest.github_commit)}, ${when(latest.createdAt)})`);
35
+ if (latest.status?.reason) {
36
+ log.info(` Reason: ${latest.status.reason.split('\n')[0]}`);
37
+ log.info(` Full error: faable deploy logs --build`);
38
+ }
39
+ }
40
+ if (deployments.length === 0) {
41
+ log.info(` No deployments yet — push to deploy, or run: faable deploy`);
42
+ }
43
+ }
44
+ };
45
+
46
+ export { status };
@@ -0,0 +1,28 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+
5
+ const trigger = {
6
+ command: 'trigger',
7
+ describe: 'Build and deploy the latest commit of the deploy branch, server-side',
8
+ builder: yargs => yargs
9
+ .option('app', {
10
+ alias: 'a',
11
+ type: 'string',
12
+ description: 'App Identifier (defaults to the linked app)'
13
+ })
14
+ .example('$0 deploy trigger', 'Deploy the repo HEAD without uploading anything from this machine')
15
+ .showHelpOnFail(false),
16
+ handler: async (args) => {
17
+ const ctx = await requireApi();
18
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
19
+ const app = await ctx.api.getApp(app_id);
20
+ // Takes the exact same path a git push would (same-commit dedupe
21
+ // included) — the API answers with an actionable refusal otherwise.
22
+ const result = await ctx.api.deployNow(app_id);
23
+ log.info(`🚀 Building ${result.commit.slice(0, 7)} (${result.branch}) of ${app.name} server-side.`);
24
+ log.info(`Track it with: faable deploy status · or in the dashboard: https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
25
+ }
26
+ };
27
+
28
+ export { trigger };
@@ -2,7 +2,7 @@ import { FaableApi } from '../../api/FaableApi.js';
2
2
  import { getDeviceCode, getDeviceToken, getMe } from '../../api/auth.js';
3
3
  import { isTokenLive } from '../../api/session.js';
4
4
  import { CredentialsStore } from '../../lib/CredentialsStore.js';
5
- import open from 'open';
5
+ import openBrowser from 'open';
6
6
  import ora from 'ora';
7
7
  import prompts from 'prompts';
8
8
  import { log } from '../../log.js';
@@ -120,7 +120,7 @@ const login = {
120
120
  process.stdout.write(renderUserCodeBlock(user_code));
121
121
  log.info(`If your browser doesn't open automatically, visit: ${verification_uri}`);
122
122
  try {
123
- await open(verification_uri_complete);
123
+ await openBrowser(verification_uri_complete);
124
124
  }
125
125
  catch {
126
126
  log.warn("Could not open browser automatically.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",