@faable/faable 1.37.0 → 1.38.1

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.
@@ -108,9 +108,6 @@ class FaableApi {
108
108
  async getApp(app_id) {
109
109
  return data(this.client.get(`/app/${app_id}`));
110
110
  }
111
- async getRegistry(app_id) {
112
- return data(this.client.get(`/app/${app_id}/registry`));
113
- }
114
111
  // `image`/`type` are optional to support the failure path: a failed build
115
112
  // is recorded as a deployment without an image (and without `type`, which
116
113
  // would otherwise rewrite the app's runtime_strategy server-side).
@@ -151,6 +148,12 @@ class FaableApi {
151
148
  async getDeployment(deployment_id) {
152
149
  return data(this.client.get(`/deployment/${deployment_id}`));
153
150
  }
151
+ // Build payload of a deployment (runnable descriptor: runtime, profile,
152
+ // size, checksum). Authorized by the caller's access to the parent
153
+ // deployment, so no team header is needed — same posture as getDeployment.
154
+ async getArtifact(artifact_id) {
155
+ return data(this.client.get(`/artifact/${artifact_id}`));
156
+ }
154
157
  // Attach the captured build/deploy output to a deployment. The base client
155
158
  // timeout (10s) is too short for a multi-MB body on a slow uplink.
156
159
  async uploadDeploymentLogs(deployment_id, body) {
@@ -5,6 +5,7 @@ import { link } from '../link/index.js';
5
5
  import { domains } from './domains/index.js';
6
6
  import { git_context } from './git_context.js';
7
7
  import { deployments } from './inspect/deployments.js';
8
+ import { inspect } from './inspect/inspect.js';
8
9
  import { apps_list } from './inspect/list.js';
9
10
  import { logs } from './inspect/logs.js';
10
11
  import { open_app } from './inspect/open.js';
@@ -32,6 +33,7 @@ const deploy = {
32
33
  .command(status)
33
34
  .command(apps_list)
34
35
  .command(deployments)
36
+ .command(inspect)
35
37
  .command(open_app)
36
38
  .command(trigger)
37
39
  .command(redeploy)
@@ -34,6 +34,7 @@ const deployments = {
34
34
  const live = d.id === app.status?.deployment ? ' ← live' : '';
35
35
  log.info(` ${deployment_row(d)}${live}`);
36
36
  }
37
+ log.info(`Full record of one: faable deploy inspect <deployment_id>`);
37
38
  }
38
39
  };
39
40
 
@@ -0,0 +1,121 @@
1
+ import { phase_badge, when, short_commit, detected_summary } from './format.js';
2
+
3
+ // Pure rendering of `faable deploy inspect` — everything the platform
4
+ // recorded about ONE deployment, in reading order. Kept out of the handler
5
+ // so the layout is testable without an API.
6
+ const format_bytes = (bytes) => {
7
+ if (!bytes || bytes < 0)
8
+ return '-';
9
+ const units = ['B', 'KB', 'MB', 'GB'];
10
+ let value = bytes;
11
+ let unit = 0;
12
+ while (value >= 1024 && unit < units.length - 1) {
13
+ value /= 1024;
14
+ unit++;
15
+ }
16
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
17
+ };
18
+ // "refs/heads/main" → "main"; a bare branch name passes through.
19
+ const short_ref = (ref) => ref ? ref.replace(/^refs\/(heads|tags)\//, '') : null;
20
+ // "next-standalone · node 22 · 41.2 MB" from the deploy-v3 runnable
21
+ // descriptor; null while the build has not sealed one yet.
22
+ const artifact_summary = (artifact) => {
23
+ const a = artifact?.artifact;
24
+ if (!a)
25
+ return null;
26
+ const runtime = [a.runtime.name, a.runtime.version].filter(Boolean).join(' ');
27
+ return [a.profile, runtime, format_bytes(a.size)].filter(Boolean).join(' · ');
28
+ };
29
+ // First line of a commit message (the subject), quoted.
30
+ const commit_subject = (message) => {
31
+ const subject = message?.split('\n')[0].trim();
32
+ return subject ? `"${subject}"` : null;
33
+ };
34
+ const label = (name, value) => ` ${`${name}:`.padEnd(12)}${value}`;
35
+ // Command suggestions that match the phase: what you would actually run next,
36
+ // rendered as labeled lines so they stay in the same column as the record.
37
+ // A BUILD_ERROR never ran, so it has no runtime logs to offer; a retired
38
+ // deployment does (for 24h — the runtime window), plus its frozen build output.
39
+ const next_steps = (d, is_live) => {
40
+ const phase = d.status?.phase ?? '';
41
+ const runtime = label('Logs', `faable deploy logs -d ${d.id}`);
42
+ const build = label('Build', `faable deploy logs --build -d ${d.id}`);
43
+ const retry = label('Retry', `faable deploy redeploy ${d.id}`);
44
+ if (phase === 'BUILD_ERROR')
45
+ return [build, retry];
46
+ if (phase === 'ERROR')
47
+ return [runtime, build, retry];
48
+ if (phase === 'BUILDING' || phase === 'QUEUED' || phase === 'UNKNOWN') {
49
+ return [label('Follow', `faable deploy logs --build -d ${d.id} --follow`)];
50
+ }
51
+ if (is_live)
52
+ return [label('Logs', 'faable deploy logs')];
53
+ return [runtime, build];
54
+ };
55
+ const deployment_detail = (args) => {
56
+ const { app, deployment: d, artifact } = args;
57
+ const is_live = d.id === app.status?.deployment;
58
+ const lines = [];
59
+ lines.push(`${phase_badge(d.status?.phase)} ${d.id}`);
60
+ lines.push(label('App', `${app.name} (${app.id})`));
61
+ if (d.createdAt) {
62
+ lines.push(label('Created', `${when(d.createdAt)} (${d.createdAt})`));
63
+ }
64
+ lines.push(label('Trigger', d.trigger === 'webhook' ? 'push (webhook)' : 'cli'));
65
+ lines.push(label('Serving', is_live ? `yes — https://${app.url}` : 'no (not live)'));
66
+ if (d.github_commit) {
67
+ const attribution = [
68
+ short_ref(d.github_ref),
69
+ d.github_actor && `by ${d.github_actor}`
70
+ ]
71
+ .filter(Boolean)
72
+ .join(' ');
73
+ const commit = [
74
+ short_commit(d.github_commit),
75
+ commit_subject(d.github_commit_message),
76
+ attribution && `(${attribution})`
77
+ ]
78
+ .filter(Boolean)
79
+ .join(' ');
80
+ lines.push(label('Commit', commit));
81
+ }
82
+ if (d.release)
83
+ lines.push(label('Release', d.release));
84
+ // Stack the builder detected for THIS build (falls back to the app's, which
85
+ // is the last successful detection).
86
+ const stack = detected_summary(d.detected ?? app.detected);
87
+ if (stack)
88
+ lines.push(label('Stack', stack));
89
+ const runnable = artifact_summary(artifact);
90
+ if (runnable) {
91
+ const ready = d.artifact_ready_at
92
+ ? ` (sealed ${when(d.artifact_ready_at)})`
93
+ : '';
94
+ lines.push(label('Artifact', `${runnable}${ready}`));
95
+ }
96
+ if (artifact?.artifact?.start_command) {
97
+ lines.push(label('Start', artifact.artifact.start_command));
98
+ }
99
+ if (artifact?.purged_at) {
100
+ lines.push(label('Purged', `${when(artifact.purged_at)} — source no longer stored`));
101
+ }
102
+ // Deliberately NOT rendered: `image` and `status.runtime_image`. Both are
103
+ // full registry references to OUR infrastructure (account, region, repo
104
+ // naming) — platform internals with no use to the app's owner. The runtime
105
+ // that matters is already on the Artifact line.
106
+ if (d.redeploy_of)
107
+ lines.push(label('Rebuild of', d.redeploy_of));
108
+ if (d.quota_released_at) {
109
+ lines.push(label('Quota', `hold released ${when(d.quota_released_at)}`));
110
+ }
111
+ if (d.status?.reason) {
112
+ lines.push(' Reason:');
113
+ for (const line of d.status.reason.split('\n')) {
114
+ lines.push(` ${line}`);
115
+ }
116
+ }
117
+ lines.push(...next_steps(d, is_live));
118
+ return lines;
119
+ };
120
+
121
+ export { artifact_summary, deployment_detail, format_bytes, short_ref };
@@ -0,0 +1,67 @@
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_detail } from './detail.js';
5
+
6
+ const inspect = {
7
+ command: 'inspect [deployment]',
8
+ describe: 'Show everything recorded about one deployment',
9
+ builder: yargs => yargs
10
+ .positional('deployment', {
11
+ type: 'string',
12
+ description: 'Deployment id (defaults to the latest one of the app)'
13
+ })
14
+ .option('app', {
15
+ alias: 'a',
16
+ type: 'string',
17
+ description: 'App Identifier (defaults to the linked app)'
18
+ })
19
+ .option('json', {
20
+ type: 'boolean',
21
+ default: false,
22
+ description: 'Output raw JSON (for scripting)'
23
+ })
24
+ .example('$0 deploy inspect deployment_a1b2c3', 'Everything about that deployment: phase, commit, artifact, failure reason')
25
+ .example('$0 deploy inspect', 'Same, for the latest deployment')
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
+ let deployment_id = args.deployment;
32
+ if (!deployment_id) {
33
+ const rows = await ctx.api.listDeployments(app_id, app.team);
34
+ deployment_id = rows[0]?.id;
35
+ if (!deployment_id) {
36
+ log.info(`📭 ${app.name} has no deployments yet.`);
37
+ return;
38
+ }
39
+ }
40
+ const deployment = await ctx.api.getDeployment(deployment_id);
41
+ // A deployment id is globally unique but the app comes from the link/--app
42
+ // flag: refuse to render one against the wrong app instead of printing a
43
+ // mismatched "Serving"/"App" header.
44
+ if (deployment.app_id && deployment.app_id !== app_id) {
45
+ throw new Error(`${deployment_id} belongs to another app (${deployment.app_id}), not ${app.name} (${app_id}). Pass --app ${deployment.app_id}.`);
46
+ }
47
+ // The runnable descriptor (runtime, profile, size) lives on the artifact
48
+ // row. Absent on image deploys, and a purged/expired row must not break
49
+ // the read — the rest of the record is still worth showing.
50
+ let artifact = null;
51
+ if (deployment.artifact_id) {
52
+ artifact = await ctx.api.getArtifact(deployment.artifact_id).catch(() => {
53
+ log.debug(`Could not read artifact ${deployment.artifact_id}`);
54
+ return null;
55
+ });
56
+ }
57
+ if (args.json) {
58
+ process.stdout.write(JSON.stringify({ ...deployment, artifact }, null, 2) + '\n');
59
+ return;
60
+ }
61
+ for (const line of deployment_detail({ app, deployment, artifact })) {
62
+ log.info(line);
63
+ }
64
+ }
65
+ };
66
+
67
+ export { inspect };
@@ -33,6 +33,7 @@ const logs = {
33
33
  description: 'With --build: keep tailing the output while the build runs'
34
34
  })
35
35
  .example('$0 deploy logs', 'Runtime logs of the linked app (last 24h)')
36
+ .example('$0 deploy logs -d deployment_a1b2c3', 'Runtime logs of ONE deployment (last 24h)')
36
37
  .example('$0 deploy logs --build', 'Build output of the latest deployment')
37
38
  .example('$0 deploy logs --build --follow', 'Tail the build that is running right now')
38
39
  .showHelpOnFail(false),
@@ -87,12 +88,17 @@ const logs = {
87
88
  const lines = await ctx.api.getAppLogs(app_id, {
88
89
  deployment_id: args.deployment
89
90
  });
91
+ // Say WHICH scope came back empty: with -d the 24h window is usually the
92
+ // reason (a retired deployment stopped writing when it stopped serving).
93
+ const scope = args.deployment
94
+ ? `${args.deployment} (${app.name})`
95
+ : `${app.name} (${app_id})`;
90
96
  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`);
97
+ log.info(`📭 No runtime logs in the last 24h for ${scope}.`);
98
+ log.info(`For build output, use: faable deploy logs --build${args.deployment ? ` -d ${args.deployment}` : ''}`);
93
99
  return;
94
100
  }
95
- log.info(`📜 Runtime logs of ${app.name} (last 24h, newest last):`);
101
+ log.info(`📜 Runtime logs of ${app.name}${args.deployment ? ` · ${args.deployment}` : ''} (last 24h, newest last):`);
96
102
  for (const line of format_log_lines(lines)) {
97
103
  process.stdout.write(line + '\n');
98
104
  }
package/dist/log.js CHANGED
@@ -8,11 +8,34 @@ import { buildLog } from './lib/log_buffer.js';
8
8
  //
9
9
  // Tee: terminal stream (colorized) + a plain-text copy into the build-log
10
10
  // buffer so CLI messages land in the logs attached to the deployment.
11
+ // What the terminal shows: just the message. pino's envelope
12
+ // (`[12:03:44.101] INFO (54834):`) is noise for a CLI — a timestamp and a pid
13
+ // per line are for a log file, not for someone waiting on a deploy.
14
+ //
15
+ // Dropping the level token would also drop its color (pino-pretty tints the
16
+ // whole line cyan otherwise), so the level is carried by the MESSAGE color
17
+ // instead: warnings yellow, errors red, everything else the usual cyan.
18
+ const LEVEL_COLOR = {
19
+ 40: "\u001b[33m", // warn
20
+ 50: "\u001b[31m", // error
21
+ 60: "\u001b[31m", // fatal
22
+ };
23
+ const TERMINAL_FORMAT = {
24
+ ignore: "pid,hostname,time,level",
25
+ messageFormat: (log, key) => {
26
+ const message = String(log[key] ?? "");
27
+ const color = LEVEL_COLOR[Number(log.level)];
28
+ return color ? `${color}${message}\u001b[39m` : message;
29
+ },
30
+ };
31
+ // The archived copy KEEPS the envelope: these lines are attached to the
32
+ // deployment and read later (support, post-mortems), where the timestamp and
33
+ // level are the whole point.
11
34
  const toPlainText = prettyFactory({ colorize: false, sync: true });
12
35
  // NB: the two-arg form matters — pino(multistream) alone would treat the
13
36
  // multistream object as the options bag and log raw JSON to stdout.
14
37
  const log = pino({}, pino.multistream([
15
- { stream: pretty({ colorize: true, sync: true }) },
38
+ { stream: pretty({ colorize: true, sync: true, ...TERMINAL_FORMAT }) },
16
39
  {
17
40
  stream: {
18
41
  write(line) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.37.0",
3
+ "version": "1.38.1",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",