@faable/faable 1.38.4 → 1.39.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.
package/dist/api/FaableApi.js
CHANGED
|
@@ -3,11 +3,11 @@ import { create_base_client } from './base_client.js';
|
|
|
3
3
|
// Socket-level failures where no response ever arrived: the connection died
|
|
4
4
|
// before the server produced anything, so a single retry is safe for any
|
|
5
5
|
// method (a reset mid-flight means the request was not processed).
|
|
6
|
-
const RESET_CODES = new Set([
|
|
6
|
+
const RESET_CODES = new Set(['ECONNRESET', 'EPIPE']);
|
|
7
7
|
const RETRY_DELAY_MS = 300;
|
|
8
8
|
const is_connection_reset = (e) => e.isAxiosError &&
|
|
9
9
|
!e.response &&
|
|
10
|
-
(RESET_CODES.has(e.code ??
|
|
10
|
+
(RESET_CODES.has(e.code ?? '') || e.message.includes('socket hang up'));
|
|
11
11
|
const firstPage = async (res) => {
|
|
12
12
|
const items = (await res).results;
|
|
13
13
|
return items;
|
|
@@ -58,28 +58,26 @@ class FaableApi {
|
|
|
58
58
|
const config = e.config;
|
|
59
59
|
if (config && !config._retried && is_connection_reset(e)) {
|
|
60
60
|
config._retried = true;
|
|
61
|
-
await new Promise(
|
|
61
|
+
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
62
62
|
return client.request(config);
|
|
63
63
|
}
|
|
64
64
|
throw error;
|
|
65
65
|
});
|
|
66
|
-
this.client.interceptors.response.use(
|
|
66
|
+
this.client.interceptors.response.use(response => response, error => {
|
|
67
67
|
const e = error;
|
|
68
68
|
if (e.isAxiosError) {
|
|
69
69
|
const res = e.response;
|
|
70
|
-
const url = e.config?.url ||
|
|
70
|
+
const url = e.config?.url || '';
|
|
71
71
|
if (res) {
|
|
72
72
|
// Uniform handling for an expired/invalid session across every
|
|
73
73
|
// command, regardless of which endpoint returned the 401.
|
|
74
74
|
if (res.status === 401) {
|
|
75
|
-
const expired = new Error(
|
|
75
|
+
const expired = new Error('Your Faable session has expired or is invalid. Run `faable login` to sign in again.', { cause: error });
|
|
76
76
|
expired.status = 401;
|
|
77
77
|
throw expired;
|
|
78
78
|
}
|
|
79
|
-
const serverMessage = res.data?.message || res.statusText ||
|
|
79
|
+
const serverMessage = res.data?.message || res.statusText || 'Unknown Error';
|
|
80
80
|
const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
|
|
81
|
-
// Surface the structured error contract (e.g. the repository-link
|
|
82
|
-
// flow returns { code, action }) so callers can branch on it.
|
|
83
81
|
wrapped.status = res.status;
|
|
84
82
|
if (res.data?.code)
|
|
85
83
|
wrapped.code = res.data.code;
|
|
@@ -98,8 +96,8 @@ class FaableApi {
|
|
|
98
96
|
return new FaableApi(config);
|
|
99
97
|
}
|
|
100
98
|
async list() {
|
|
101
|
-
return allPages(
|
|
102
|
-
params: { pageSize: 200, ...(next ? { next } : {}) }
|
|
99
|
+
return allPages(next => data(this.client.get(`/app`, {
|
|
100
|
+
params: { pageSize: 200, ...(next ? { next } : {}) }
|
|
103
101
|
})));
|
|
104
102
|
}
|
|
105
103
|
async getBySlug(slug) {
|
|
@@ -121,7 +119,7 @@ class FaableApi {
|
|
|
121
119
|
async uploadMissing(app_id, files) {
|
|
122
120
|
return data(this.client.post(`/upload/missing`, {
|
|
123
121
|
app_id,
|
|
124
|
-
files: files.map(({ path, sha, size }) => ({ path, sha, size }))
|
|
122
|
+
files: files.map(({ path, sha, size }) => ({ path, sha, size }))
|
|
125
123
|
}, { timeout: 60_000 }));
|
|
126
124
|
}
|
|
127
125
|
// Remote builds: read the build output the builder attaches to the
|
|
@@ -139,7 +137,7 @@ class FaableApi {
|
|
|
139
137
|
// deployment and materialize it.
|
|
140
138
|
async completeDeployment(deployment_id, image) {
|
|
141
139
|
return data(this.client.post(`/deployment/${deployment_id}`, {
|
|
142
|
-
image
|
|
140
|
+
image
|
|
143
141
|
}));
|
|
144
142
|
}
|
|
145
143
|
// Fetch a deployment with its runtime status, so the deploy command can
|
|
@@ -169,7 +167,7 @@ class FaableApi {
|
|
|
169
167
|
// context, which a CLI user token does not carry — pass the app's team
|
|
170
168
|
// (from getApp) so it travels as the `x-faable-team` header.
|
|
171
169
|
async createSecretsBatch(context_id, team, secrets) {
|
|
172
|
-
return data(this.client.post(`/secret/createbatch`, { context_id, secrets }, { headers: {
|
|
170
|
+
return data(this.client.post(`/secret/createbatch`, { context_id, secrets }, { headers: { 'x-faable-team': team } }));
|
|
173
171
|
}
|
|
174
172
|
async updateApp(app_id, params) {
|
|
175
173
|
return data(this.client.post(`/app/${app_id}`, params));
|
|
@@ -179,11 +177,11 @@ class FaableApi {
|
|
|
179
177
|
}
|
|
180
178
|
// Organizations/accounts where the Faable GitHub App is installed.
|
|
181
179
|
async listGithubInstallations() {
|
|
182
|
-
return data(this.client.get(`/github/installations`)).then(
|
|
180
|
+
return data(this.client.get(`/github/installations`)).then(res => res.installations);
|
|
183
181
|
}
|
|
184
182
|
// Top repositories for a single installation (org), optionally filtered.
|
|
185
183
|
async listGithubRepos(installation_id, params = {}) {
|
|
186
|
-
return data(this.client.get(`/github/installations/${installation_id}/repositories`, { params })).then(
|
|
184
|
+
return data(this.client.get(`/github/installations/${installation_id}/repositories`, { params })).then(res => res.repositories);
|
|
187
185
|
}
|
|
188
186
|
async getMe() {
|
|
189
187
|
return data(this.client.get(`/auth/me`));
|
|
@@ -198,7 +196,7 @@ class FaableApi {
|
|
|
198
196
|
async listDeployments(app_id, team) {
|
|
199
197
|
return firstPage(data(this.client.get(`/deployment`, {
|
|
200
198
|
params: { app_id },
|
|
201
|
-
headers: {
|
|
199
|
+
headers: { 'x-faable-team': team }
|
|
202
200
|
})));
|
|
203
201
|
}
|
|
204
202
|
// Build and deploy the current head of the deploy branch server-side —
|
|
@@ -210,7 +208,14 @@ class FaableApi {
|
|
|
210
208
|
// git ref). The API enforces the guards: failed phase only, never older
|
|
211
209
|
// than what production serves.
|
|
212
210
|
async redeployDeployment(deployment_id, team) {
|
|
213
|
-
return data(this.client.post(`/deployment/${deployment_id}/redeploy`, undefined, { headers: {
|
|
211
|
+
return data(this.client.post(`/deployment/${deployment_id}/redeploy`, undefined, { headers: { 'x-faable-team': team } }));
|
|
212
|
+
}
|
|
213
|
+
// Give up on a build still in the pre-handoff window (QUEUED/BUILDING).
|
|
214
|
+
// A route of its own rather than a `{phase: 'CANCELED'}` status write:
|
|
215
|
+
// phase transitions belong to the controller, and the api only lets a user
|
|
216
|
+
// token touch the phases the CLI owns.
|
|
217
|
+
async cancelDeployment(deployment_id, team) {
|
|
218
|
+
return data(this.client.post(`/deployment/${deployment_id}/cancel`, undefined, { headers: { 'x-faable-team': team } }));
|
|
214
219
|
}
|
|
215
220
|
// Domains are team-scoped rows; a CLI user token carries no default team,
|
|
216
221
|
// so every call pins the app's team via `x-faable-team` (same pattern as
|
|
@@ -218,22 +223,22 @@ class FaableApi {
|
|
|
218
223
|
async listDomains(app_id, team) {
|
|
219
224
|
return firstPage(data(this.client.get(`/domain`, {
|
|
220
225
|
params: { app_id },
|
|
221
|
-
headers: {
|
|
226
|
+
headers: { 'x-faable-team': team }
|
|
222
227
|
})));
|
|
223
228
|
}
|
|
224
229
|
async createDomain(team, params) {
|
|
225
230
|
return data(this.client.post(`/domain`, params, {
|
|
226
|
-
headers: {
|
|
231
|
+
headers: { 'x-faable-team': team }
|
|
227
232
|
}));
|
|
228
233
|
}
|
|
229
234
|
async getDomain(domain_id, team) {
|
|
230
235
|
return data(this.client.get(`/domain/${domain_id}`, {
|
|
231
|
-
headers: {
|
|
236
|
+
headers: { 'x-faable-team': team }
|
|
232
237
|
}));
|
|
233
238
|
}
|
|
234
239
|
async deleteDomain(domain_id, team) {
|
|
235
240
|
return data(this.client.delete(`/domain/${domain_id}`, {
|
|
236
|
-
headers: {
|
|
241
|
+
headers: { 'x-faable-team': team }
|
|
237
242
|
}));
|
|
238
243
|
}
|
|
239
244
|
}
|
package/dist/api/session.js
CHANGED
|
@@ -44,7 +44,16 @@ const loadLiveCredentials = async (store = new CredentialsStore()) => {
|
|
|
44
44
|
log.debug?.("Refreshed access token");
|
|
45
45
|
return next;
|
|
46
46
|
}
|
|
47
|
-
catch {
|
|
47
|
+
catch (e) {
|
|
48
|
+
// The auth server marks a suspension with a stable machine code on the
|
|
49
|
+
// token endpoint's RFC 6749 error body. "Run `faable login` again"
|
|
50
|
+
// would be a lie here — login is denied too — so say what actually
|
|
51
|
+
// happened and stop instead of letting a generic 401 mislead.
|
|
52
|
+
const body = e?.response?.data;
|
|
53
|
+
if (body?.error_code === "user_suspended") {
|
|
54
|
+
log.error("❌ Your account has been suspended. Contact support@faable.com.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
48
57
|
// Refresh failed — keep the stale config; the API call's 401 (or
|
|
49
58
|
// requireApi) will tell the user to run `faable login` again.
|
|
50
59
|
}
|
|
@@ -4,6 +4,7 @@ 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 { cancel } from './inspect/cancel.js';
|
|
7
8
|
import { deployments } from './inspect/deployments.js';
|
|
8
9
|
import { inspect } from './inspect/inspect.js';
|
|
9
10
|
import { apps_list } from './inspect/list.js';
|
|
@@ -37,6 +38,7 @@ const deploy = {
|
|
|
37
38
|
.command(open_app)
|
|
38
39
|
.command(trigger)
|
|
39
40
|
.command(redeploy)
|
|
41
|
+
.command(cancel)
|
|
40
42
|
.command(link)
|
|
41
43
|
.positional('app_id', {
|
|
42
44
|
type: 'string',
|
|
@@ -138,14 +140,14 @@ const deploy = {
|
|
|
138
140
|
// ~1min, long before this 5min promotion timeout.
|
|
139
141
|
const dep = await api.getDeployment(deployment.id);
|
|
140
142
|
const phase = dep.status?.phase;
|
|
141
|
-
if (phase ===
|
|
143
|
+
if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
|
|
142
144
|
failure = { phase, reason: dep.status?.reason };
|
|
143
145
|
break;
|
|
144
146
|
}
|
|
145
147
|
// CANCELED: the platform superseded this build before it produced a
|
|
146
148
|
// runnable (a sibling deployment of the app won the promotion).
|
|
147
|
-
if (phase ===
|
|
148
|
-
superseded = dep.status?.reason ??
|
|
149
|
+
if (phase === 'CANCELED') {
|
|
150
|
+
superseded = dep.status?.reason ?? 'canceled';
|
|
149
151
|
break;
|
|
150
152
|
}
|
|
151
153
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
// Mirrors CANCELABLE_PHASES in the api (deployment/api/cancel.ts). Used only
|
|
6
|
+
// to pick a default target; the api is the one that enforces it, so a phase
|
|
7
|
+
// that changed between the list and the call is still refused server-side.
|
|
8
|
+
const IN_FLIGHT_PHASES = new Set(['UNKNOWN', 'QUEUED', 'BUILDING']);
|
|
9
|
+
const cancel = {
|
|
10
|
+
command: 'cancel [deployment]',
|
|
11
|
+
describe: 'Stop a deployment that is still queued or building',
|
|
12
|
+
builder: yargs => yargs
|
|
13
|
+
.positional('deployment', {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Deployment id to cancel (defaults to the one currently in flight)'
|
|
16
|
+
})
|
|
17
|
+
.option('app', {
|
|
18
|
+
alias: 'a',
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
21
|
+
})
|
|
22
|
+
.example('$0 deploy cancel', 'Stop the build that is running right now')
|
|
23
|
+
.showHelpOnFail(false),
|
|
24
|
+
handler: async (args) => {
|
|
25
|
+
const ctx = await requireApi();
|
|
26
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
27
|
+
const app = await ctx.api.getApp(app_id);
|
|
28
|
+
let deployment_id = args.deployment;
|
|
29
|
+
if (!deployment_id) {
|
|
30
|
+
const rows = await ctx.api.listDeployments(app_id, app.team);
|
|
31
|
+
const inFlight = rows.find(d => IN_FLIGHT_PHASES.has(d.status?.phase ?? ''));
|
|
32
|
+
if (!inFlight) {
|
|
33
|
+
log.info(`✅ Nothing building for ${app.name} — nothing to cancel.`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
deployment_id = inFlight.id;
|
|
37
|
+
}
|
|
38
|
+
// The api owns the guards: it refuses once a controller has claimed the
|
|
39
|
+
// deployment, and answers a repeated cancel with the same 200 so a retry
|
|
40
|
+
// is never an error the user has to read.
|
|
41
|
+
const canceled = await ctx.api.cancelDeployment(deployment_id, app.team);
|
|
42
|
+
log.info(`🛑 Canceled ${canceled.id} (${app.name}).`);
|
|
43
|
+
log.info(`Production keeps serving the last promoted deployment. Deploy again with: faable deploy`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { cancel };
|