@faable/faable 1.28.0 → 1.30.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,30 @@ 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
+ }
172
196
  // Domains are team-scoped rows; a CLI user token carries no default team,
173
197
  // so every call pins the app's team via `x-faable-team` (same pattern as
174
198
  // createSecretsBatch).
@@ -4,6 +4,13 @@ import { log } from '../../log.js';
4
4
  import { link } from '../link/index.js';
5
5
  import { domains } from './domains/index.js';
6
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';
7
14
  import { propose_release } from './release_version.js';
8
15
  import { deploy_remote } from './remote/index.js';
9
16
  import { resolve_app_id } from './resolve_app_id.js';
@@ -19,6 +26,13 @@ const deploy = {
19
26
  return yargs
20
27
  .command(secrets)
21
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)
22
36
  .command(link)
23
37
  .positional('app_id', {
24
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,102 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { follow_remote_build } from '../remote/follow.js';
4
+ import { resolve_app_id } from '../resolve_app_id.js';
5
+ import { format_log_lines } from './format.js';
6
+
7
+ // Phases where the build is still producing output; anything else is a frozen
8
+ // record and following would just wait on nothing.
9
+ const FOLLOWABLE_PHASES = new Set(['UNKNOWN', 'QUEUED', 'BUILDING']);
10
+ const logs = {
11
+ command: 'logs',
12
+ describe: 'Show runtime logs of the app (or build logs with --build)',
13
+ builder: yargs => yargs
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .option('build', {
20
+ type: 'boolean',
21
+ default: false,
22
+ description: 'Show the build output of the latest deployment instead'
23
+ })
24
+ .option('deployment', {
25
+ alias: 'd',
26
+ type: 'string',
27
+ description: 'Scope to one deployment id'
28
+ })
29
+ .option('follow', {
30
+ alias: 'f',
31
+ type: 'boolean',
32
+ default: false,
33
+ description: 'With --build: keep tailing the output while the build runs'
34
+ })
35
+ .example('$0 deploy logs', 'Runtime logs of the linked app (last 24h)')
36
+ .example('$0 deploy logs --build', 'Build output of the latest deployment')
37
+ .example('$0 deploy logs --build --follow', 'Tail the build that is running right now')
38
+ .showHelpOnFail(false),
39
+ handler: async (args) => {
40
+ if (args.follow && !args.build) {
41
+ // Runtime logs have no follow mode (the API serves a 24h window, not a
42
+ // stream) — only the build output can be tailed.
43
+ throw new Error('--follow only works with --build');
44
+ }
45
+ const ctx = await requireApi();
46
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
47
+ const app = await ctx.api.getApp(app_id);
48
+ if (args.build) {
49
+ // Build output lives on the deployment. Default to the newest one —
50
+ // exactly what you want after a red `faable deploy`.
51
+ let deployment_id = args.deployment;
52
+ if (!deployment_id) {
53
+ const deployments = await ctx.api.listDeployments(app_id, app.team);
54
+ deployment_id = deployments[0]?.id;
55
+ if (!deployment_id) {
56
+ log.info(`📭 ${app.name} has no deployments yet.`);
57
+ return;
58
+ }
59
+ }
60
+ if (args.follow) {
61
+ // Live tail (the builder re-uploads the log every ~10s): same loop the
62
+ // deploy command uses, so it ends at the image handoff and exits red
63
+ // on BUILD_ERROR. On an already-settled deployment fall through to the
64
+ // recorded snapshot instead of waiting on nothing.
65
+ const deployment = await ctx.api.getDeployment(deployment_id);
66
+ const phase = deployment?.status?.phase ?? '';
67
+ if (FOLLOWABLE_PHASES.has(phase)) {
68
+ log.info(`🏗️ Following the build of ${deployment_id}:`);
69
+ await follow_remote_build(ctx.api, deployment_id);
70
+ return;
71
+ }
72
+ log.info(`Build of ${deployment_id} already finished (${phase}) — showing the recorded output.`);
73
+ }
74
+ const build = await ctx.api.getDeploymentLogs(deployment_id);
75
+ if (!build.content) {
76
+ log.info(`📭 No build output recorded for ${deployment_id}.`);
77
+ return;
78
+ }
79
+ log.info(`🏗️ Build output of ${deployment_id}:`);
80
+ process.stdout.write(build.content);
81
+ if (!build.content.endsWith('\n'))
82
+ process.stdout.write('\n');
83
+ if (build.truncated)
84
+ log.warn(`(output truncated)`);
85
+ return;
86
+ }
87
+ const lines = await ctx.api.getAppLogs(app_id, {
88
+ deployment_id: args.deployment
89
+ });
90
+ if (lines.length === 0) {
91
+ log.info(`📭 No runtime logs in the last 24h for ${app.name} (${app_id}).`);
92
+ log.info(`For build output, use: faable deploy logs --build`);
93
+ return;
94
+ }
95
+ log.info(`📜 Runtime logs of ${app.name} (last 24h, newest last):`);
96
+ for (const line of format_log_lines(lines)) {
97
+ process.stdout.write(line + '\n');
98
+ }
99
+ }
100
+ };
101
+
102
+ 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.28.0",
3
+ "version": "1.30.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",