@faable/faable 4.18.3 → 4.18.5

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.
@@ -117,7 +117,17 @@ class FaableApi {
117
117
  throw expired;
118
118
  }
119
119
  const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
120
- const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
120
+ // A 4xx with the api's own sentence is written for the user — say
121
+ // just that ("New accounts can have up to 2 apps…"), the way the
122
+ // dashboard does (`friendlyErrorMessage`). The "FaableApi <url>
123
+ // <status>:" prefix is for us: it stays on 5xx and on bodies with
124
+ // no sentence, and url/status ride on the error for --json and
125
+ // for whoever catches it (backlog §390).
126
+ const userFacing = res.status >= 400 && res.status < 500 && !!res.data?.message;
127
+ const wrapped = new Error(userFacing
128
+ ? serverMessage
129
+ : `FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
130
+ wrapped.url = url;
121
131
  wrapped.status = res.status;
122
132
  // The api's own code wins; a bare 403/404 still gets a stable one.
123
133
  if (res.data?.code)
@@ -11,6 +11,19 @@ const gitRunner = (workdir) => command => new Promise(resolve => {
11
11
  resolve(out || undefined);
12
12
  });
13
13
  });
14
+ // Ref from GitLab CI / Bitbucket Pipelines env, as a full `refs/...` name.
15
+ // Tags first: on a tag pipeline GitLab's CI_COMMIT_REF_NAME is the tag name.
16
+ // CI_COMMIT_REF_NAME (not CI_COMMIT_BRANCH) so merge request pipelines still
17
+ // record their source branch.
18
+ const ci_ref = (env) => {
19
+ const tag = env.CI_COMMIT_TAG || env.BITBUCKET_TAG;
20
+ if (tag)
21
+ return `refs/tags/${tag}`;
22
+ const branch = env.CI_COMMIT_REF_NAME || env.BITBUCKET_BRANCH;
23
+ if (branch)
24
+ return `refs/heads/${branch}`;
25
+ return undefined;
26
+ };
14
27
  // Resolve the commit / ref / actor for the current deploy. In GitHub Actions
15
28
  // these come from the standard env vars; locally we fall back to git so manual
16
29
  // deploys still record a commit. `github_actor` is CI-only (a GitHub login),
@@ -20,8 +33,16 @@ const gitRunner = (workdir) => command => new Promise(resolve => {
20
33
  const git_context = async (opts) => {
21
34
  const env = opts?.env ?? process.env;
22
35
  const run = opts?.run ?? gitRunner(opts?.workdir);
23
- const github_commit = env.GITHUB_SHA || (await run("git rev-parse HEAD"));
24
- let github_ref = env.GITHUB_REF || undefined;
36
+ const github_commit = env.GITHUB_SHA ||
37
+ env.CI_COMMIT_SHA ||
38
+ env.BITBUCKET_COMMIT ||
39
+ (await run("git rev-parse HEAD"));
40
+ // GitLab CI and Bitbucket Pipelines check out a detached HEAD, so the branch
41
+ // only survives in their env vars. Their actor vars are NOT mapped to
42
+ // `github_actor`: the dashboard renders it as a GitHub login (avatar and
43
+ // profile link), and a GitLab username would show whoever owns that name on
44
+ // GitHub.
45
+ let github_ref = env.GITHUB_REF || ci_ref(env);
25
46
  if (!github_ref) {
26
47
  const branch = await run("git rev-parse --abbrev-ref HEAD");
27
48
  if (branch && branch !== "HEAD")
@@ -1,5 +1,6 @@
1
1
  import openBrowser from 'open';
2
2
  import { requireApi } from '../../../api/context.js';
3
+ import { dashboardAppUrl } from '../../../lib/dashboard_url.js';
3
4
  import { log } from '../../../log.js';
4
5
  import { resolve_app_id } from '../resolve_app_id.js';
5
6
 
@@ -25,7 +26,7 @@ const open_app = {
25
26
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
26
27
  const app = await ctx.api.getApp(app_id);
27
28
  const url = args.dashboard
28
- ? `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`
29
+ ? dashboardAppUrl(String(app.team), app.id)
29
30
  : `https://${app.url}`;
30
31
  log.info(`🌍 Opening ${url}`);
31
32
  await openBrowser(url).catch(() => {
@@ -1,4 +1,5 @@
1
1
  import { requireApi } from '../../../api/context.js';
2
+ import { dashboardAppUrl } from '../../../lib/dashboard_url.js';
2
3
  import { print_json, json_option } from '../../../lib/listing.js';
3
4
  import { log } from '../../../log.js';
4
5
  import { resolve_app_id } from '../resolve_app_id.js';
@@ -55,7 +56,7 @@ const trigger = {
55
56
  if (result.deployment_id) {
56
57
  log.info(`Follow the build: faable deploy logs --build -d ${result.deployment_id} -a ${app.id} --follow`);
57
58
  }
58
- log.info(`Track it with: faable deploy status -a ${app.id} · or in the dashboard: https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`);
59
+ log.info(`Track it with: faable deploy status -a ${app.id} · or in the dashboard: ${dashboardAppUrl(String(app.team), app.id)}`);
59
60
  }
60
61
  };
61
62
 
@@ -1,15 +1,16 @@
1
1
  import { requireApi } from '../../api/context.js';
2
+ import { requireProject } from '../../api/project.js';
2
3
  import { Configuration } from '../../lib/Configuration.js';
4
+ import { dashboardAppUrl } from '../../lib/dashboard_url.js';
3
5
  import { CliError } from '../../lib/errors.js';
4
6
  import { is_non_interactive, confirm } from '../../lib/interactive.js';
5
7
  import { print_json } from '../../lib/listing.js';
6
8
  import { log } from '../../log.js';
7
- import { requireProject } from '../../api/project.js';
9
+ import { assert_name_free } from './apps/name_free.js';
8
10
  import { git_context } from './git_context.js';
9
11
  import { show_deploy_help } from './help.js';
10
12
  import { reachable_timeout_ms, wait_reachable } from './reachable.js';
11
13
  import { propose_release } from './release_version.js';
12
- import { assert_name_free } from './apps/name_free.js';
13
14
  import { deploy_remote } from './remote/index.js';
14
15
  import { collect_manifest } from './remote/manifest.js';
15
16
  import { find_app_id } from './resolve_app_id.js';
@@ -142,7 +143,10 @@ const launch = {
142
143
  // Propose the release version (--release > FAABLE_RELEASE > git tag).
143
144
  // Optional: when absent the platform injects no FAABLE_RELEASE and the
144
145
  // app falls back to its own version source.
145
- const proposed = await propose_release({ workdir, explicit: args.release });
146
+ const proposed = await propose_release({
147
+ workdir,
148
+ explicit: args.release
149
+ });
146
150
  if (proposed) {
147
151
  log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
148
152
  }
@@ -165,7 +169,7 @@ const launch = {
165
169
  deployment_started = true;
166
170
  }
167
171
  });
168
- const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
172
+ const dashboard_url = dashboardAppUrl(String(app.team), app.id);
169
173
  log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
170
174
  log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
171
175
  // Wait (up to 5 minutes) for the deployment to be promoted (live)
@@ -253,7 +257,10 @@ const launch = {
253
257
  phase: failure.phase,
254
258
  reason: failure.reason,
255
259
  ...(fault
256
- ? { fault_owner: fault.fault_owner, error_code: fault.error_code }
260
+ ? {
261
+ fault_owner: fault.fault_owner,
262
+ error_code: fault.error_code
263
+ }
257
264
  : {})
258
265
  }
259
266
  : {}),
@@ -4,14 +4,14 @@ const POLL_INTERVAL_MS = 4000;
4
4
  // Queue wait + build must fit here; the server's own stops (Job deadline
5
5
  // 30min, abandoned janitor 60min) resolve the deployment first in practice.
6
6
  const FOLLOW_TIMEOUT_MS = 45 * 60 * 1000;
7
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ const wait = (ms) => new Promise(r => setTimeout(r, ms));
8
8
  /** The clone the platform created to retry this failed build, if any. */
9
9
  const retry_of = (deployment) => {
10
10
  const retry = deployment?.runtime_fallback_retry;
11
11
  if (!retry?.deployment_id)
12
12
  return null;
13
13
  // The api stores the clone as bare hex (a `.lean()` ObjectId).
14
- const id = retry.deployment_id.startsWith("deployment_")
14
+ const id = retry.deployment_id.startsWith('deployment_')
15
15
  ? retry.deployment_id
16
16
  : `deployment_${retry.deployment_id}`;
17
17
  return { id, to: retry.to };
@@ -30,6 +30,32 @@ const retry_of = (deployment) => {
30
30
  * that will go live. Until 2026-10-18 the CLI exited red for a deploy that
31
31
  * was about to succeed (backlog §379).
32
32
  */
33
+ const TAIL_CHARS = 200;
34
+ /**
35
+ * The part of `content` not shown yet, given how much was `printed` and the
36
+ * last characters of it (`seen_tail`).
37
+ *
38
+ * Three cases. The same buffer, grown: print from the offset. A buffer that
39
+ * rotated (keep-tail truncation dropped its head) but still holds the tail we
40
+ * showed: print what comes after that tail. A DIFFERENT buffer — the build
41
+ * Job's log replacing the detect Job's — that does not hold it: print it
42
+ * whole. The old rule only knew the first case and "shrank → resync to its
43
+ * length", so the build log started being printed at the detect log's
44
+ * offset, and its head ("📦 installing dependencies…", most of "added 68
45
+ * packages") never reached the terminal (backlog §390, 2026-10-19).
46
+ */
47
+ const unseen = (content, printed, seen_tail) => {
48
+ if (!seen_tail)
49
+ return { text: content.slice(printed) };
50
+ const at = printed - seen_tail.length;
51
+ if (at >= 0 && content.slice(at, printed) === seen_tail) {
52
+ return { text: content.slice(printed) };
53
+ }
54
+ const found = content.lastIndexOf(seen_tail);
55
+ if (found >= 0)
56
+ return { text: content.slice(found + seen_tail.length) };
57
+ return { text: content };
58
+ };
33
59
  const follow_remote_build = async (api,
34
60
  // `let`: a platform retry switches the follow to its clone.
35
61
  initial_deployment_id,
@@ -39,59 +65,57 @@ initial_deployment_id,
39
65
  // live deploy as failed that way (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F1).
40
66
  out = process.stdout) => {
41
67
  let printed = 0;
42
- let last_phase = "";
68
+ let last_phase = '';
43
69
  let followed_retry = false;
44
70
  let deployment_id = initial_deployment_id;
45
71
  const started = Date.now();
72
+ // What we already showed, as its last characters: enough to recognise the
73
+ // same buffer, a rotated one, or a different one.
74
+ let seen_tail = '';
46
75
  const tail_logs = async () => {
47
76
  try {
48
77
  const logs = await api.getDeploymentLogs(deployment_id);
49
- const content = logs?.content ?? "";
50
- if (content.length > printed) {
51
- out.write(content.slice(printed));
52
- printed = content.length;
53
- }
54
- else if (content.length < printed) {
55
- // Keep-tail truncation rotated the buffer — resync silently.
56
- printed = content.length;
57
- }
78
+ const content = logs?.content ?? '';
79
+ const fresh = unseen(content, printed, seen_tail);
80
+ if (fresh.text)
81
+ out.write(fresh.text);
82
+ printed = content.length;
83
+ seen_tail = content.slice(-TAIL_CHARS);
58
84
  }
59
85
  catch {
60
86
  // No logs yet (404) or transient failure — next poll.
61
87
  }
62
88
  };
63
89
  while (Date.now() - started < FOLLOW_TIMEOUT_MS) {
64
- const deployment = await api
65
- .getDeployment(deployment_id)
66
- .catch(() => null);
67
- const phase = deployment?.status?.phase ?? "";
90
+ const deployment = await api.getDeployment(deployment_id).catch(() => null);
91
+ const phase = deployment?.status?.phase ?? '';
68
92
  if (phase !== last_phase && phase) {
69
93
  log.info(`☁️ Remote build: ${phase}`);
70
94
  last_phase = phase;
71
95
  }
72
96
  await tail_logs();
73
- if (phase === "BUILD_ERROR" || phase === "ERROR") {
97
+ if (phase === 'BUILD_ERROR' || phase === 'ERROR') {
74
98
  const retry = retry_of(deployment);
75
99
  if (retry && !followed_retry) {
76
100
  followed_retry = true;
77
101
  log.info(`↩️ The default Python failed with your dependencies — the platform is retrying on Python ${retry.to} · ${retry.id}`);
78
102
  deployment_id = retry.id;
79
103
  printed = 0;
80
- last_phase = "";
104
+ last_phase = '';
81
105
  continue;
82
106
  }
83
107
  const reason = deployment?.status?.reason;
84
- throw new Error(`Remote build failed${reason ? `: ${reason}` : ""}`.trim());
108
+ throw new Error(`Remote build failed${reason ? `: ${reason}` : ''}`.trim());
85
109
  }
86
110
  // Image handoff done: the controller claimed it (INITIALIZING) or it is
87
111
  // already live (READY) — the promotion poll takes over from here.
88
- if (phase === "INITIALIZING" || phase === "READY") {
112
+ if (phase === 'INITIALIZING' || phase === 'READY') {
89
113
  await tail_logs();
90
114
  return { id: deployment_id };
91
115
  }
92
116
  await wait(POLL_INTERVAL_MS);
93
117
  }
94
- throw new Error("Timed out waiting for the remote build. Check the dashboard for its status.");
118
+ throw new Error('Timed out waiting for the remote build. Check the dashboard for its status.');
95
119
  };
96
120
 
97
- export { follow_remote_build };
121
+ export { follow_remote_build, unseen };
@@ -0,0 +1,11 @@
1
+ // Dashboard links to an app, in the canonical `project_` form.
2
+ //
3
+ // The api returns `app.team` as `team_<hex>`. The dashboard accepts it, but a
4
+ // session that lands on a `team_` URL keeps navigating with it and pays a 400
5
+ // on `/project-favorites/recents` each time — so links leave as `project_`
6
+ // (same rule as the api's emails, arch backlog §387).
7
+ const DASHBOARD = 'https://dashboard.faable.com';
8
+ const toProjectId = (team) => team.replace(/^team_/, 'project_');
9
+ const dashboardAppUrl = (team, app_id) => `${DASHBOARD}/deploy/${toProjectId(team)}/app/${app_id}`;
10
+
11
+ export { dashboardAppUrl, toProjectId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.18.3",
3
+ "version": "4.18.5",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",