@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.
- package/dist/api/FaableApi.js +11 -1
- package/dist/commands/deploy/git_context.js +23 -2
- package/dist/commands/deploy/inspect/open.js +2 -1
- package/dist/commands/deploy/inspect/trigger.js +2 -1
- package/dist/commands/deploy/launch.js +12 -5
- package/dist/commands/deploy/remote/follow.js +46 -22
- package/dist/lib/dashboard_url.js +11 -0
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -117,7 +117,17 @@ class FaableApi {
|
|
|
117
117
|
throw expired;
|
|
118
118
|
}
|
|
119
119
|
const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
|
|
120
|
-
|
|
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 ||
|
|
24
|
-
|
|
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
|
-
?
|
|
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:
|
|
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 {
|
|
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({
|
|
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 =
|
|
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
|
-
? {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 ===
|
|
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}` :
|
|
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 ===
|
|
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(
|
|
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 };
|