@faable/faable 1.27.0 → 1.29.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 +48 -0
- package/dist/commands/deploy/domains/add.js +46 -0
- package/dist/commands/deploy/domains/check.js +51 -0
- package/dist/commands/deploy/domains/format.js +24 -0
- package/dist/commands/deploy/domains/index.js +21 -0
- package/dist/commands/deploy/domains/list.js +45 -0
- package/dist/commands/deploy/domains/rm.js +59 -0
- package/dist/commands/deploy/index.js +16 -0
- package/dist/commands/deploy/inspect/deployments.js +40 -0
- package/dist/commands/deploy/inspect/format.js +60 -0
- package/dist/commands/deploy/inspect/list.js +24 -0
- package/dist/commands/deploy/inspect/logs.js +72 -0
- package/dist/commands/deploy/inspect/open.js +37 -0
- package/dist/commands/deploy/inspect/redeploy.js +43 -0
- package/dist/commands/deploy/inspect/status.js +46 -0
- package/dist/commands/deploy/inspect/trigger.js +28 -0
- package/dist/commands/login/index.js +2 -2
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -169,6 +169,54 @@ 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
|
+
}
|
|
196
|
+
// Domains are team-scoped rows; a CLI user token carries no default team,
|
|
197
|
+
// so every call pins the app's team via `x-faable-team` (same pattern as
|
|
198
|
+
// createSecretsBatch).
|
|
199
|
+
async listDomains(app_id, team) {
|
|
200
|
+
return firstPage(data(this.client.get(`/domain`, {
|
|
201
|
+
params: { app_id },
|
|
202
|
+
headers: { "x-faable-team": team },
|
|
203
|
+
})));
|
|
204
|
+
}
|
|
205
|
+
async createDomain(team, params) {
|
|
206
|
+
return data(this.client.post(`/domain`, params, {
|
|
207
|
+
headers: { "x-faable-team": team },
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
async getDomain(domain_id, team) {
|
|
211
|
+
return data(this.client.get(`/domain/${domain_id}`, {
|
|
212
|
+
headers: { "x-faable-team": team },
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
async deleteDomain(domain_id, team) {
|
|
216
|
+
return data(this.client.delete(`/domain/${domain_id}`, {
|
|
217
|
+
headers: { "x-faable-team": team },
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
172
220
|
}
|
|
173
221
|
|
|
174
222
|
export { FaableApi };
|
|
@@ -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 { cname_target } from './format.js';
|
|
5
|
+
|
|
6
|
+
const domains_add = {
|
|
7
|
+
command: 'add <fqdn>',
|
|
8
|
+
describe: 'Add a custom domain to the app',
|
|
9
|
+
builder: yargs => yargs
|
|
10
|
+
.positional('fqdn', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'Fully qualified domain name (e.g. www.example.com)'
|
|
14
|
+
})
|
|
15
|
+
.option('app', {
|
|
16
|
+
alias: 'a',
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
19
|
+
})
|
|
20
|
+
.option('tls', {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
default: true,
|
|
23
|
+
description: 'Provision a TLS certificate automatically (default)'
|
|
24
|
+
})
|
|
25
|
+
.example('$0 deploy domains add www.example.com', 'Attach www.example.com to the linked app')
|
|
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
|
+
const domain = await ctx.api.createDomain(app.team, {
|
|
32
|
+
fqdn: args.fqdn,
|
|
33
|
+
app_id,
|
|
34
|
+
tls: args.tls
|
|
35
|
+
});
|
|
36
|
+
log.info(`🌐 Domain ${domain.fqdn} added to ${app.name} (${app_id}).`);
|
|
37
|
+
log.info(``);
|
|
38
|
+
log.info(`Now create a CNAME record at your DNS provider:`);
|
|
39
|
+
log.info(` ${domain.fqdn} → ${cname_target(domain)}`);
|
|
40
|
+
log.info(``);
|
|
41
|
+
log.info(`Faable verifies the record automatically once DNS propagates${args.tls ? ' and then provisions the TLS certificate' : ''}.`);
|
|
42
|
+
log.info(`Track it with: faable deploy domains check ${domain.fqdn}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export { domains_add };
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { find_by_fqdn, dns_badge, cname_target } from './format.js';
|
|
5
|
+
|
|
6
|
+
const domains_check = {
|
|
7
|
+
command: 'check <fqdn>',
|
|
8
|
+
describe: 'Show the DNS verification status of a domain',
|
|
9
|
+
builder: yargs => yargs
|
|
10
|
+
.positional('fqdn', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'Domain to check'
|
|
14
|
+
})
|
|
15
|
+
.option('app', {
|
|
16
|
+
alias: 'a',
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
19
|
+
})
|
|
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
|
+
const domains = await ctx.api.listDomains(app_id, app.team);
|
|
26
|
+
const domain = find_by_fqdn(domains, args.fqdn);
|
|
27
|
+
if (!domain) {
|
|
28
|
+
throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
|
|
29
|
+
}
|
|
30
|
+
const status = domain.status;
|
|
31
|
+
log.info(`🌐 ${domain.fqdn} — ${dns_badge(domain)}`);
|
|
32
|
+
log.info(` Expected CNAME: ${domain.fqdn} → ${cname_target(domain)}`);
|
|
33
|
+
if (status?.dns_observed?.length) {
|
|
34
|
+
log.info(` Observed: ${status.dns_observed.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
log.info(` Observed: (no CNAME resolved yet)`);
|
|
38
|
+
}
|
|
39
|
+
if (status?.dns_message) {
|
|
40
|
+
log.info(` Diagnostic: ${status.dns_message}`);
|
|
41
|
+
}
|
|
42
|
+
if (status?.dns_checked_at) {
|
|
43
|
+
log.info(` Last checked: ${status.dns_checked_at}`);
|
|
44
|
+
}
|
|
45
|
+
if (!domain.verified) {
|
|
46
|
+
log.info(`Faable re-checks automatically — no action needed beyond the CNAME record.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export { domains_check };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Human summary of a domain's verification state, derived from the DNS
|
|
2
|
+
// verification worker's status. Kept pure for tests.
|
|
3
|
+
const dns_badge = (domain) => {
|
|
4
|
+
if (domain.verified)
|
|
5
|
+
return '✅ verified';
|
|
6
|
+
switch (domain.status?.dns_state) {
|
|
7
|
+
case 'ok':
|
|
8
|
+
return '✅ dns ok';
|
|
9
|
+
case 'misconfigured':
|
|
10
|
+
return '❌ misconfigured';
|
|
11
|
+
case 'error':
|
|
12
|
+
return '❌ dns error';
|
|
13
|
+
default:
|
|
14
|
+
return '⏳ pending verification';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// The CNAME target the user must configure. The API publishes it in
|
|
18
|
+
// `status.dns_expected` (the dashboard shows `<domain.id>.faable.link` — same
|
|
19
|
+
// value); fall back to deriving it from the id so `add` can print
|
|
20
|
+
// instructions even before the first DNS check populates the status.
|
|
21
|
+
const cname_target = (domain) => domain.status?.dns_expected?.[0] ?? `${domain.id}.faable.link`;
|
|
22
|
+
const find_by_fqdn = (domains, fqdn) => domains.find(d => d.fqdn.toLowerCase() === fqdn.toLowerCase());
|
|
23
|
+
|
|
24
|
+
export { cname_target, dns_badge, find_by_fqdn };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { domains_add } from './add.js';
|
|
2
|
+
import { domains_check } from './check.js';
|
|
3
|
+
import { domains_list } from './list.js';
|
|
4
|
+
import { domains_rm } from './rm.js';
|
|
5
|
+
|
|
6
|
+
const domains = {
|
|
7
|
+
command: 'domains <command>',
|
|
8
|
+
describe: 'Manage custom domains of an app',
|
|
9
|
+
builder: yargs => yargs
|
|
10
|
+
.command(domains_list)
|
|
11
|
+
.command(domains_add)
|
|
12
|
+
.command(domains_check)
|
|
13
|
+
.command(domains_rm)
|
|
14
|
+
.demandCommand(1, 'Specify a domains command: list, add, check or rm'),
|
|
15
|
+
handler: () => {
|
|
16
|
+
// Unreachable: demandCommand(1) either routes to a subcommand or fails
|
|
17
|
+
// through the global .fail() in src/index.ts.
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { domains };
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { dns_badge, cname_target } from './format.js';
|
|
5
|
+
|
|
6
|
+
const domains_list = {
|
|
7
|
+
command: 'list',
|
|
8
|
+
describe: 'List custom domains 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
|
+
.example('$0 deploy domains list', 'Domains of the linked app')
|
|
16
|
+
.showHelpOnFail(false),
|
|
17
|
+
handler: async (args) => {
|
|
18
|
+
const ctx = await requireApi();
|
|
19
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
20
|
+
const app = await ctx.api.getApp(app_id);
|
|
21
|
+
const domains = await ctx.api.listDomains(app_id, app.team);
|
|
22
|
+
if (domains.length === 0) {
|
|
23
|
+
log.info(`🌐 No custom domains for ${app.name} (${app_id}).`);
|
|
24
|
+
log.info(`Add one with: faable deploy domains add <yourdomain.com>. The app is always live at https://${app.url}.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
log.info(`🌐 ${domains.length} domain(s) for ${app.name} (${app_id}):`);
|
|
28
|
+
const width = Math.max(...domains.map(d => d.fqdn.length));
|
|
29
|
+
for (const domain of domains) {
|
|
30
|
+
const tls = domain.tls ? 'tls' : 'no-tls';
|
|
31
|
+
log.info(` ${domain.fqdn.padEnd(width)} ${dns_badge(domain)} (${tls})`);
|
|
32
|
+
}
|
|
33
|
+
const unverified = domains.filter(d => !d.verified);
|
|
34
|
+
if (unverified.length > 0) {
|
|
35
|
+
log.info(``);
|
|
36
|
+
log.info(`To finish verification, point each domain at Faable with a CNAME:`);
|
|
37
|
+
for (const domain of unverified) {
|
|
38
|
+
log.info(` ${domain.fqdn} → ${cname_target(domain)}`);
|
|
39
|
+
}
|
|
40
|
+
log.info(`Run "faable deploy domains check <fqdn>" to see the diagnostic.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { domains_list };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { requireApi } from '../../../api/context.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
|
+
import { find_by_fqdn } from './format.js';
|
|
6
|
+
|
|
7
|
+
const domains_rm = {
|
|
8
|
+
command: 'rm <fqdn>',
|
|
9
|
+
describe: 'Remove a custom domain from the app',
|
|
10
|
+
builder: yargs => yargs
|
|
11
|
+
.positional('fqdn', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
demandOption: true,
|
|
14
|
+
description: 'Domain to remove'
|
|
15
|
+
})
|
|
16
|
+
.option('app', {
|
|
17
|
+
alias: 'a',
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
20
|
+
})
|
|
21
|
+
.option('yes', {
|
|
22
|
+
alias: 'y',
|
|
23
|
+
type: 'boolean',
|
|
24
|
+
default: false,
|
|
25
|
+
description: 'Skip the confirmation prompt'
|
|
26
|
+
})
|
|
27
|
+
.example('$0 deploy domains rm www.example.com', 'Detach after confirmation')
|
|
28
|
+
.showHelpOnFail(false),
|
|
29
|
+
handler: async (args) => {
|
|
30
|
+
const ctx = await requireApi();
|
|
31
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
32
|
+
const app = await ctx.api.getApp(app_id);
|
|
33
|
+
const domains = await ctx.api.listDomains(app_id, app.team);
|
|
34
|
+
const domain = find_by_fqdn(domains, args.fqdn);
|
|
35
|
+
if (!domain) {
|
|
36
|
+
throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
|
|
37
|
+
}
|
|
38
|
+
if (!args.yes) {
|
|
39
|
+
// In a non-TTY run without --yes, prompts resolves undefined → cancel.
|
|
40
|
+
const { confirm } = await prompts({
|
|
41
|
+
type: 'toggle',
|
|
42
|
+
name: 'confirm',
|
|
43
|
+
message: `Remove domain "${domain.fqdn}" from ${app.name} (${app_id})? Traffic to it will stop being served.`,
|
|
44
|
+
initial: false,
|
|
45
|
+
active: 'yes',
|
|
46
|
+
inactive: 'no'
|
|
47
|
+
});
|
|
48
|
+
if (!confirm) {
|
|
49
|
+
log.info('Cancelled.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await ctx.api.deleteDomain(domain.id, app.team);
|
|
54
|
+
log.info(`🗑️ Removed domain ${domain.fqdn} from ${app_id}.`);
|
|
55
|
+
log.info(`The app stays live at https://${app.url}. Remember to delete the CNAME at your DNS provider.`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export { domains_rm };
|
|
@@ -2,7 +2,15 @@ import { requireApi } from '../../api/context.js';
|
|
|
2
2
|
import { Configuration } from '../../lib/Configuration.js';
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
4
|
import { link } from '../link/index.js';
|
|
5
|
+
import { domains } from './domains/index.js';
|
|
5
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';
|
|
6
14
|
import { propose_release } from './release_version.js';
|
|
7
15
|
import { deploy_remote } from './remote/index.js';
|
|
8
16
|
import { resolve_app_id } from './resolve_app_id.js';
|
|
@@ -17,6 +25,14 @@ const deploy = {
|
|
|
17
25
|
// app_id positional, so `faable deploy <app_id>` keeps working).
|
|
18
26
|
return yargs
|
|
19
27
|
.command(secrets)
|
|
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)
|
|
20
36
|
.command(link)
|
|
21
37
|
.positional('app_id', {
|
|
22
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,72 @@
|
|
|
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 { format_log_lines } from './format.js';
|
|
5
|
+
|
|
6
|
+
const logs = {
|
|
7
|
+
command: 'logs',
|
|
8
|
+
describe: 'Show runtime logs of the app (or build logs with --build)',
|
|
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('build', {
|
|
16
|
+
type: 'boolean',
|
|
17
|
+
default: false,
|
|
18
|
+
description: 'Show the build output of the latest deployment instead'
|
|
19
|
+
})
|
|
20
|
+
.option('deployment', {
|
|
21
|
+
alias: 'd',
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Scope to one deployment id'
|
|
24
|
+
})
|
|
25
|
+
.example('$0 deploy logs', 'Runtime logs of the linked app (last 24h)')
|
|
26
|
+
.example('$0 deploy logs --build', 'Build output of the latest deployment')
|
|
27
|
+
.showHelpOnFail(false),
|
|
28
|
+
handler: async (args) => {
|
|
29
|
+
const ctx = await requireApi();
|
|
30
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
31
|
+
const app = await ctx.api.getApp(app_id);
|
|
32
|
+
if (args.build) {
|
|
33
|
+
// Build output lives on the deployment. Default to the newest one —
|
|
34
|
+
// exactly what you want after a red `faable deploy`.
|
|
35
|
+
let deployment_id = args.deployment;
|
|
36
|
+
if (!deployment_id) {
|
|
37
|
+
const deployments = await ctx.api.listDeployments(app_id, app.team);
|
|
38
|
+
deployment_id = deployments[0]?.id;
|
|
39
|
+
if (!deployment_id) {
|
|
40
|
+
log.info(`📭 ${app.name} has no deployments yet.`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const build = await ctx.api.getDeploymentLogs(deployment_id);
|
|
45
|
+
if (!build.content) {
|
|
46
|
+
log.info(`📭 No build output recorded for ${deployment_id}.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
log.info(`🏗️ Build output of ${deployment_id}:`);
|
|
50
|
+
process.stdout.write(build.content);
|
|
51
|
+
if (!build.content.endsWith('\n'))
|
|
52
|
+
process.stdout.write('\n');
|
|
53
|
+
if (build.truncated)
|
|
54
|
+
log.warn(`(output truncated)`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const lines = await ctx.api.getAppLogs(app_id, {
|
|
58
|
+
deployment_id: args.deployment
|
|
59
|
+
});
|
|
60
|
+
if (lines.length === 0) {
|
|
61
|
+
log.info(`📭 No runtime logs in the last 24h for ${app.name} (${app_id}).`);
|
|
62
|
+
log.info(`For build output, use: faable deploy logs --build`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
log.info(`📜 Runtime logs of ${app.name} (last 24h, newest last):`);
|
|
66
|
+
for (const line of format_log_lines(lines)) {
|
|
67
|
+
process.stdout.write(line + '\n');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
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
|
|
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
|
|
123
|
+
await openBrowser(verification_uri_complete);
|
|
124
124
|
}
|
|
125
125
|
catch {
|
|
126
126
|
log.warn("Could not open browser automatically.");
|