@faable/faable 4.2.0 → 4.3.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/README.md +30 -6
- package/dist/api/FaableApi.js +51 -1
- package/dist/api/auth.js +1 -5
- package/dist/api/auth_admin.js +6 -9
- package/dist/api/base_client.js +4 -1
- package/dist/api/client_id.js +22 -0
- package/dist/api/context.js +3 -5
- package/dist/api/session.js +2 -2
- package/dist/commands/auth/actions/rm.js +12 -15
- package/dist/commands/auth/clients/rm.js +17 -15
- package/dist/commands/auth/users/export.js +6 -12
- package/dist/commands/auth/users/import.js +5 -9
- package/dist/commands/auth/users/reinstate.js +10 -17
- package/dist/commands/auth/users/suspend.js +10 -17
- package/dist/commands/deploy/apps/create.js +132 -0
- package/dist/commands/deploy/apps/index.js +18 -2
- package/dist/commands/deploy/apps/set.js +70 -0
- package/dist/commands/deploy/domains/add.js +8 -2
- package/dist/commands/deploy/domains/check.js +8 -3
- package/dist/commands/deploy/domains/list.js +1 -1
- package/dist/commands/deploy/domains/rm.js +14 -17
- package/dist/commands/deploy/index.js +5 -0
- package/dist/commands/deploy/inspect/cancel.js +9 -3
- package/dist/commands/deploy/inspect/deployments.js +1 -1
- package/dist/commands/deploy/inspect/format.js +35 -1
- package/dist/commands/deploy/inspect/inspect.js +1 -1
- package/dist/commands/deploy/inspect/logs.js +47 -6
- package/dist/commands/deploy/inspect/open.js +1 -1
- package/dist/commands/deploy/inspect/redeploy.js +11 -3
- package/dist/commands/deploy/inspect/status.js +9 -3
- package/dist/commands/deploy/inspect/traffic.js +84 -0
- package/dist/commands/deploy/inspect/trigger.js +10 -4
- package/dist/commands/deploy/launch.js +26 -17
- package/dist/commands/deploy/project_usage.js +83 -0
- package/dist/commands/deploy/resolve_app_id.js +42 -4
- package/dist/commands/deploy/secrets/list.js +1 -1
- package/dist/commands/deploy/secrets/merge.js +4 -2
- package/dist/commands/deploy/secrets/rm.js +14 -17
- package/dist/commands/deploy/secrets/set.js +35 -9
- package/dist/commands/deploy/waf/add_rule.js +4 -1
- package/dist/commands/deploy/waf/block.js +8 -4
- package/dist/commands/deploy/waf/list.js +1 -1
- package/dist/commands/deploy/waf/rm.js +6 -3
- package/dist/commands/deploy/waf/sink.js +8 -4
- package/dist/commands/whoami/index.js +4 -6
- package/dist/index.js +32 -17
- package/dist/lib/errors.js +31 -0
- package/dist/lib/interactive.js +45 -0
- package/dist/lib/listing.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { requireProject } from '../../../api/project.js';
|
|
3
|
+
import { CliError } from '../../../lib/errors.js';
|
|
4
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
5
|
+
import { log } from '../../../log.js';
|
|
6
|
+
|
|
7
|
+
// "owner/repo" from any of the ways people paste a GitHub repository.
|
|
8
|
+
const parse_repo = (input) => {
|
|
9
|
+
const cleaned = input
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/^git@github\.com:/, '')
|
|
12
|
+
.replace(/^(https?:\/\/)?(www\.)?github\.com\//, '')
|
|
13
|
+
.replace(/\.git$/, '')
|
|
14
|
+
.replace(/\/+$/, '');
|
|
15
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(cleaned)) {
|
|
16
|
+
throw new CliError('usage', `"${input}" is not a GitHub repository — use owner/repo.`);
|
|
17
|
+
}
|
|
18
|
+
return cleaned;
|
|
19
|
+
};
|
|
20
|
+
// `faable deploy apps create` — the dashboard's "Create & connect", same
|
|
21
|
+
// sequence (components/apps/deploy/app/app-create/AppCreateForm.tsx):
|
|
22
|
+
// create the app in the project, link the repository, and start the first
|
|
23
|
+
// deploy, because linking alone deploys nothing (deploy v4) and a new app
|
|
24
|
+
// that waits for a push is one that never deploys.
|
|
25
|
+
//
|
|
26
|
+
// One difference, on purpose: when the link fails, the app THIS call just
|
|
27
|
+
// created is deleted again. The dashboard can leave it — its app page offers
|
|
28
|
+
// to retry the link — but here the retry is running this command again, and
|
|
29
|
+
// every failed attempt would leave an empty app behind. Nothing that existed
|
|
30
|
+
// before the command is ever touched.
|
|
31
|
+
const apps_create = {
|
|
32
|
+
command: 'create',
|
|
33
|
+
describe: 'Create an app from a GitHub repository and start its first deploy',
|
|
34
|
+
builder: yargs => json_option(yargs)
|
|
35
|
+
.option('repo', {
|
|
36
|
+
alias: 'r',
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'GitHub repository (owner/repo or its URL)'
|
|
39
|
+
})
|
|
40
|
+
.option('name', {
|
|
41
|
+
alias: 'n',
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'App name (defaults to the repository name)'
|
|
44
|
+
})
|
|
45
|
+
.option('description', {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'App description'
|
|
48
|
+
})
|
|
49
|
+
.option('branch', {
|
|
50
|
+
alias: 'b',
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Branch to deploy (defaults to the repository default)'
|
|
53
|
+
})
|
|
54
|
+
.option('deploy', {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
default: true,
|
|
57
|
+
description: 'Start the first deploy (--no-deploy to only link)'
|
|
58
|
+
})
|
|
59
|
+
.check(({ repo, name }) => {
|
|
60
|
+
if (!repo && !name) {
|
|
61
|
+
throw new Error('Give the repository (--repo owner/repo), or --name for an app without one.');
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
})
|
|
65
|
+
.example('$0 deploy apps create --repo acme/web', 'Create "web" from acme/web and deploy its default branch')
|
|
66
|
+
.example('$0 deploy apps create --repo acme/mono --name api --branch release', 'Another app from a monorepo, deploying another branch')
|
|
67
|
+
.showHelpOnFail(false),
|
|
68
|
+
handler: async (args) => {
|
|
69
|
+
const repository = args.repo ? parse_repo(args.repo) : undefined;
|
|
70
|
+
const name = args.name || repository.split('/')[1];
|
|
71
|
+
const { api } = await requireApi();
|
|
72
|
+
const { id: project } = await requireProject(api);
|
|
73
|
+
const app = await api.createApp(project, {
|
|
74
|
+
name,
|
|
75
|
+
...(args.description ? { description: args.description } : {})
|
|
76
|
+
});
|
|
77
|
+
log.info(`📦 Created ${app.name} (${app.id})`);
|
|
78
|
+
let linked = app;
|
|
79
|
+
if (repository) {
|
|
80
|
+
try {
|
|
81
|
+
linked = await api.linkRepository(app.id, {
|
|
82
|
+
repository,
|
|
83
|
+
...(args.branch ? { github_branch: args.branch } : {})
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
await api.deleteApp(app.id, app.team).catch(() => {
|
|
88
|
+
log.warn(`Could not remove ${app.id} after the failed link — delete it from the dashboard.`);
|
|
89
|
+
});
|
|
90
|
+
const e = err;
|
|
91
|
+
const failed = new CliError('usage', `Could not link ${repository}, so ${app.name} was not kept: ${e.message}`, { status: e.status, action: e.action, cause: err });
|
|
92
|
+
// The api's reason (github_installation_missing, repository_already_linked…)
|
|
93
|
+
// is the useful code; ours would only say "something failed".
|
|
94
|
+
if (e.code)
|
|
95
|
+
failed.code = e.code;
|
|
96
|
+
throw failed;
|
|
97
|
+
}
|
|
98
|
+
log.info(`🔗 Linked ${repository} (${linked.github_branch ?? 'default branch'})`);
|
|
99
|
+
}
|
|
100
|
+
// Only push-to-deploy apps: a repository that brings its own Faable
|
|
101
|
+
// workflow deploys from the user's CI, and deploy_now would refuse.
|
|
102
|
+
let first_deploy = null;
|
|
103
|
+
if (repository && args.deploy && linked.deploy_trigger === 'webhook') {
|
|
104
|
+
try {
|
|
105
|
+
const started = await api.deployNow(app.id);
|
|
106
|
+
first_deploy = {
|
|
107
|
+
commit: started.commit,
|
|
108
|
+
branch: started.branch,
|
|
109
|
+
...(started.deployment_id
|
|
110
|
+
? { deployment_id: started.deployment_id }
|
|
111
|
+
: {})
|
|
112
|
+
};
|
|
113
|
+
log.info(`🚀 First deploy: building ${started.commit.slice(0, 7)} from ${started.branch}`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Not a failure of the create: an empty repository or a missing
|
|
117
|
+
// branch is something to tell, and the app is there either way.
|
|
118
|
+
const e = err;
|
|
119
|
+
first_deploy = {
|
|
120
|
+
refused: e.code ?? 'deploy_refused',
|
|
121
|
+
message: e.message
|
|
122
|
+
};
|
|
123
|
+
log.warn(`⚠️ The app is ready but the first deploy did not start: ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (args.json)
|
|
127
|
+
return print_json({ app: linked, first_deploy });
|
|
128
|
+
log.info(`🌍 https://${linked.url} · follow it with: faable deploy status -a ${app.id}`);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export { apps_create, parse_repo };
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import { apps_list } from '../inspect/list.js';
|
|
2
|
+
import { apps_create } from './create.js';
|
|
3
|
+
import { apps_set } from './set.js';
|
|
4
|
+
import { status } from '../inspect/status.js';
|
|
2
5
|
|
|
3
6
|
// `faable deploy apps list` — same shape as `secrets list`, `domains list` and
|
|
4
7
|
// `waf list`: the resource, then the verb.
|
|
5
8
|
const apps = {
|
|
6
9
|
command: 'apps <command>',
|
|
7
10
|
describe: 'Apps of the active project',
|
|
8
|
-
builder: yargs => yargs
|
|
11
|
+
builder: yargs => yargs
|
|
12
|
+
.command(apps_list)
|
|
13
|
+
.command(apps_get)
|
|
14
|
+
.command(apps_create)
|
|
15
|
+
.command(apps_set)
|
|
16
|
+
.demandCommand(1, 'Specify an apps command: list, get, create or set'),
|
|
9
17
|
handler: () => {
|
|
10
18
|
// Unreachable: demandCommand(1) either routes to a subcommand or fails
|
|
11
19
|
// through the global .fail() in src/index.ts.
|
|
12
20
|
}
|
|
13
21
|
};
|
|
22
|
+
// `faable deploy apps get` — one app: what is live, the newest deployment and
|
|
23
|
+
// the stack. The same record `faable deploy status` shows, named like the
|
|
24
|
+
// rest of the resource verbs; with --json, the app plus `latest_deployment`.
|
|
25
|
+
const apps_get = {
|
|
26
|
+
...status,
|
|
27
|
+
command: 'get',
|
|
28
|
+
describe: 'Show one app: what is live, the latest deployment, the stack'
|
|
29
|
+
};
|
|
14
30
|
// The older `faable deploy list`, kept working but out of the help: one
|
|
15
31
|
// documented way to list apps.
|
|
16
32
|
const apps_list_legacy = {
|
|
@@ -18,4 +34,4 @@ const apps_list_legacy = {
|
|
|
18
34
|
describe: false
|
|
19
35
|
};
|
|
20
36
|
|
|
21
|
-
export { apps, apps_list_legacy };
|
|
37
|
+
export { apps, apps_get, apps_list_legacy };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
|
+
|
|
6
|
+
const MODES = ['push', 'ci', 'workflow'];
|
|
7
|
+
// `faable deploy apps set` — how an app deploys from its repository: the
|
|
8
|
+
// branch, the monorepo Root Directory and the trigger. The same three
|
|
9
|
+
// settings as the dashboard's repository card; nothing redeploys by itself,
|
|
10
|
+
// the next push (or `faable deploy trigger`) uses them.
|
|
11
|
+
const apps_set = {
|
|
12
|
+
command: 'set',
|
|
13
|
+
describe: 'Change how the app deploys: branch, root directory, trigger',
|
|
14
|
+
builder: yargs => json_option(yargs)
|
|
15
|
+
.option('app', {
|
|
16
|
+
alias: 'a',
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
19
|
+
})
|
|
20
|
+
.option('branch', {
|
|
21
|
+
alias: 'b',
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Branch to deploy from'
|
|
24
|
+
})
|
|
25
|
+
.option('root-dir', {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Monorepo Root Directory, inside the repository ("" clears it: faable.json rootDir applies again)'
|
|
28
|
+
})
|
|
29
|
+
.option('mode', {
|
|
30
|
+
type: 'string',
|
|
31
|
+
choices: MODES,
|
|
32
|
+
description: 'push: every push deploys · ci: once CI tags a release · workflow: your own GitHub workflow runs faable deploy'
|
|
33
|
+
})
|
|
34
|
+
.check(({ branch, rootDir, mode }) => {
|
|
35
|
+
if (branch === undefined &&
|
|
36
|
+
rootDir === undefined &&
|
|
37
|
+
mode === undefined) {
|
|
38
|
+
throw new Error('Nothing to change: pass --branch, --root-dir or --mode.');
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
})
|
|
42
|
+
.example('$0 deploy apps set --branch release', 'Deploy from "release"')
|
|
43
|
+
.example('$0 deploy apps set --root-dir apps/web', 'A monorepo app')
|
|
44
|
+
.showHelpOnFail(false),
|
|
45
|
+
handler: async (args) => {
|
|
46
|
+
const ctx = await requireApi();
|
|
47
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
48
|
+
// One setting at a time, each its own endpoint (and its own guards).
|
|
49
|
+
if (args.branch !== undefined) {
|
|
50
|
+
await ctx.api.setDeployBranch(app_id, args.branch);
|
|
51
|
+
log.info(`🌿 Deploy branch: ${args.branch}`);
|
|
52
|
+
}
|
|
53
|
+
if (args.rootDir !== undefined) {
|
|
54
|
+
await ctx.api.setRootDir(app_id, args.rootDir || null);
|
|
55
|
+
log.info(args.rootDir
|
|
56
|
+
? `📁 Root directory: ${args.rootDir}`
|
|
57
|
+
: '📁 Root directory cleared (faable.json rootDir applies again)');
|
|
58
|
+
}
|
|
59
|
+
if (args.mode !== undefined) {
|
|
60
|
+
await ctx.api.setDeployMode(app_id, args.mode);
|
|
61
|
+
log.info(`⚙️ Deploy mode: ${args.mode}`);
|
|
62
|
+
}
|
|
63
|
+
const app = await ctx.api.getApp(app_id);
|
|
64
|
+
if (args.json)
|
|
65
|
+
return print_json(app);
|
|
66
|
+
log.info(`Applies to the next deploy of ${app.name}. Start one now with: faable deploy trigger -a ${app.id}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export { apps_set };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
5
|
import { cname_target } from './format.js';
|
|
@@ -6,7 +7,7 @@ import { cname_target } from './format.js';
|
|
|
6
7
|
const domains_add = {
|
|
7
8
|
command: 'add <fqdn>',
|
|
8
9
|
describe: 'Add a custom domain to the app',
|
|
9
|
-
builder: yargs => yargs
|
|
10
|
+
builder: yargs => json_option(yargs)
|
|
10
11
|
.positional('fqdn', {
|
|
11
12
|
type: 'string',
|
|
12
13
|
demandOption: true,
|
|
@@ -15,7 +16,7 @@ const domains_add = {
|
|
|
15
16
|
.option('app', {
|
|
16
17
|
alias: 'a',
|
|
17
18
|
type: 'string',
|
|
18
|
-
description: 'App
|
|
19
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
19
20
|
})
|
|
20
21
|
.option('tls', {
|
|
21
22
|
type: 'boolean',
|
|
@@ -33,6 +34,11 @@ const domains_add = {
|
|
|
33
34
|
app_id,
|
|
34
35
|
tls: args.tls
|
|
35
36
|
});
|
|
37
|
+
// Same shape as `domains check --json`: the record plus the CNAME to
|
|
38
|
+
// create, which is the whole next step.
|
|
39
|
+
if (args.json) {
|
|
40
|
+
return print_json({ ...domain, expected_cname: cname_target(domain) });
|
|
41
|
+
}
|
|
36
42
|
log.info(`🌐 Domain ${domain.fqdn} added to ${app.name} (${app_id}).`);
|
|
37
43
|
log.info(``);
|
|
38
44
|
log.info(`Now create a CNAME record at your DNS provider:`);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
|
-
import { find_by_fqdn,
|
|
5
|
+
import { find_by_fqdn, cname_target, dns_badge } from './format.js';
|
|
5
6
|
|
|
6
7
|
const domains_check = {
|
|
7
8
|
command: 'check <fqdn>',
|
|
8
9
|
describe: 'Show the DNS verification status of a domain',
|
|
9
|
-
builder: yargs => yargs
|
|
10
|
+
builder: yargs => json_option(yargs)
|
|
10
11
|
.positional('fqdn', {
|
|
11
12
|
type: 'string',
|
|
12
13
|
demandOption: true,
|
|
@@ -15,7 +16,7 @@ const domains_check = {
|
|
|
15
16
|
.option('app', {
|
|
16
17
|
alias: 'a',
|
|
17
18
|
type: 'string',
|
|
18
|
-
description: 'App
|
|
19
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
19
20
|
})
|
|
20
21
|
.showHelpOnFail(false),
|
|
21
22
|
handler: async (args) => {
|
|
@@ -27,6 +28,10 @@ const domains_check = {
|
|
|
27
28
|
if (!domain) {
|
|
28
29
|
throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
|
|
29
30
|
}
|
|
31
|
+
// The record plus the one thing it does not carry: the CNAME to create.
|
|
32
|
+
if (args.json) {
|
|
33
|
+
return print_json({ ...domain, expected_cname: cname_target(domain) });
|
|
34
|
+
}
|
|
30
35
|
const status = domain.status;
|
|
31
36
|
log.info(`🌐 ${domain.fqdn} — ${dns_badge(domain)}`);
|
|
32
37
|
log.info(` Expected CNAME: ${domain.fqdn} → ${cname_target(domain)}`);
|
|
@@ -11,7 +11,7 @@ const domains_list = {
|
|
|
11
11
|
.option('app', {
|
|
12
12
|
alias: 'a',
|
|
13
13
|
type: 'string',
|
|
14
|
-
description: 'App
|
|
14
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
15
15
|
})
|
|
16
16
|
.example('$0 deploy domains list', 'Domains of the linked app')
|
|
17
17
|
.showHelpOnFail(false),
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import prompts from 'prompts';
|
|
2
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_json, json_option } from '../../../lib/listing.js';
|
|
3
3
|
import { log } from '../../../log.js';
|
|
4
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
5
|
import { find_by_fqdn } from './format.js';
|
|
6
|
+
import { confirm } from '../../../lib/interactive.js';
|
|
6
7
|
|
|
7
8
|
const domains_rm = {
|
|
8
9
|
command: 'rm <fqdn>',
|
|
9
10
|
describe: 'Remove a custom domain from the app',
|
|
10
|
-
builder: yargs => yargs
|
|
11
|
+
builder: yargs => json_option(yargs)
|
|
11
12
|
.positional('fqdn', {
|
|
12
13
|
type: 'string',
|
|
13
14
|
demandOption: true,
|
|
@@ -16,7 +17,7 @@ const domains_rm = {
|
|
|
16
17
|
.option('app', {
|
|
17
18
|
alias: 'a',
|
|
18
19
|
type: 'string',
|
|
19
|
-
description: 'App
|
|
20
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
20
21
|
})
|
|
21
22
|
.option('yes', {
|
|
22
23
|
alias: 'y',
|
|
@@ -35,22 +36,18 @@ const domains_rm = {
|
|
|
35
36
|
if (!domain) {
|
|
36
37
|
throw new Error(`Domain ${args.fqdn} is not attached to ${app_id}. List them with "faable deploy domains list".`);
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
active: 'yes',
|
|
46
|
-
inactive: 'no'
|
|
47
|
-
});
|
|
48
|
-
if (!confirm) {
|
|
49
|
-
log.info('Cancelled.');
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
39
|
+
const go = await confirm({
|
|
40
|
+
message: `Remove domain "${domain.fqdn}" from ${app.name} (${app_id})? Traffic to it will stop being served.`,
|
|
41
|
+
yes: args.yes
|
|
42
|
+
});
|
|
43
|
+
if (!go) {
|
|
44
|
+
log.info('Cancelled.');
|
|
45
|
+
return;
|
|
52
46
|
}
|
|
53
47
|
await ctx.api.deleteDomain(domain.id, app.team);
|
|
48
|
+
if (args.json) {
|
|
49
|
+
return print_json({ deleted: true, id: domain.id, fqdn: domain.fqdn });
|
|
50
|
+
}
|
|
54
51
|
log.info(`🗑️ Removed domain ${domain.fqdn} from ${app_id}.`);
|
|
55
52
|
log.info(`The app stays live at https://${app.url}. Remember to delete the CNAME at your DNS provider.`);
|
|
56
53
|
}
|
|
@@ -9,8 +9,10 @@ import { logs } from './inspect/logs.js';
|
|
|
9
9
|
import { open_app } from './inspect/open.js';
|
|
10
10
|
import { redeploy } from './inspect/redeploy.js';
|
|
11
11
|
import { status } from './inspect/status.js';
|
|
12
|
+
import { traffic } from './inspect/traffic.js';
|
|
12
13
|
import { trigger } from './inspect/trigger.js';
|
|
13
14
|
import { launch } from './launch.js';
|
|
15
|
+
import { usage, quota } from './project_usage.js';
|
|
14
16
|
import { secrets } from './secrets/index.js';
|
|
15
17
|
import { waf } from './waf/index.js';
|
|
16
18
|
|
|
@@ -39,6 +41,9 @@ const deploy = {
|
|
|
39
41
|
.command(waf)
|
|
40
42
|
.command(logs)
|
|
41
43
|
.command(status)
|
|
44
|
+
.command(traffic)
|
|
45
|
+
.command(usage)
|
|
46
|
+
.command(quota)
|
|
42
47
|
.command(apps_list_legacy)
|
|
43
48
|
.command(deployments)
|
|
44
49
|
.command(inspect)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { print_noop, print_json, json_option } from '../../../lib/listing.js';
|
|
2
3
|
import { log } from '../../../log.js';
|
|
3
4
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
5
|
|
|
@@ -9,7 +10,7 @@ const IN_FLIGHT_PHASES = new Set(['UNKNOWN', 'QUEUED', 'BUILDING']);
|
|
|
9
10
|
const cancel = {
|
|
10
11
|
command: 'cancel [deployment]',
|
|
11
12
|
describe: 'Stop a deployment that is still queued or building',
|
|
12
|
-
builder: yargs => yargs
|
|
13
|
+
builder: yargs => json_option(yargs)
|
|
13
14
|
.positional('deployment', {
|
|
14
15
|
type: 'string',
|
|
15
16
|
description: 'Deployment id to cancel (defaults to the one currently in flight)'
|
|
@@ -17,7 +18,7 @@ const cancel = {
|
|
|
17
18
|
.option('app', {
|
|
18
19
|
alias: 'a',
|
|
19
20
|
type: 'string',
|
|
20
|
-
description: 'App
|
|
21
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
21
22
|
})
|
|
22
23
|
.example('$0 deploy cancel', 'Stop the build that is running right now')
|
|
23
24
|
.showHelpOnFail(false),
|
|
@@ -30,6 +31,9 @@ const cancel = {
|
|
|
30
31
|
const rows = await ctx.api.listDeployments(app_id, app.team);
|
|
31
32
|
const inFlight = rows.find(d => IN_FLIGHT_PHASES.has(d.status?.phase ?? ''));
|
|
32
33
|
if (!inFlight) {
|
|
34
|
+
if (args.json) {
|
|
35
|
+
return print_noop(`nothing is building for ${app.name}`);
|
|
36
|
+
}
|
|
33
37
|
log.info(`✅ Nothing building for ${app.name} — nothing to cancel.`);
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
@@ -39,8 +43,10 @@ const cancel = {
|
|
|
39
43
|
// deployment, and answers a repeated cancel with the same 200 so a retry
|
|
40
44
|
// is never an error the user has to read.
|
|
41
45
|
const canceled = await ctx.api.cancelDeployment(deployment_id, app.team);
|
|
46
|
+
if (args.json)
|
|
47
|
+
return print_json(canceled);
|
|
42
48
|
log.info(`🛑 Canceled ${canceled.id} (${app.name}).`);
|
|
43
|
-
log.info(`Production keeps serving the last promoted deployment. Deploy again with: faable deploy`);
|
|
49
|
+
log.info(`Production keeps serving the last promoted deployment. Deploy again with: faable deploy trigger -a ${app.id}`);
|
|
44
50
|
}
|
|
45
51
|
};
|
|
46
52
|
|
|
@@ -11,7 +11,7 @@ const deployments = {
|
|
|
11
11
|
.option('app', {
|
|
12
12
|
alias: 'a',
|
|
13
13
|
type: 'string',
|
|
14
|
-
description: 'App
|
|
14
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
15
15
|
})
|
|
16
16
|
.showHelpOnFail(false),
|
|
17
17
|
handler: async (args) => {
|
|
@@ -35,7 +35,41 @@ const format_log_lines = (lines) => [...lines]
|
|
|
35
35
|
const iso = new Date(Number(ts) / 1e6).toISOString().replace(/\.\d+Z$/, 'Z');
|
|
36
36
|
return `${iso} ${text.replace(/\n+$/, '')}`;
|
|
37
37
|
});
|
|
38
|
+
// The same lines as data, for --json: oldest first, ISO time, the stream
|
|
39
|
+
// when the api says which.
|
|
40
|
+
const log_entries = (lines) => [...lines]
|
|
41
|
+
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
|
42
|
+
.map(([ts, text, stream]) => ({
|
|
43
|
+
time: new Date(Number(ts) / 1e6).toISOString(),
|
|
44
|
+
message: text.replace(/\n+$/, ''),
|
|
45
|
+
...(stream ? { stream } : {})
|
|
46
|
+
}));
|
|
47
|
+
// The last `n` lines of a build output. A failed build's cause is at the end,
|
|
48
|
+
// and the head of a long `npm install` is context nobody — least of all an
|
|
49
|
+
// agent with a context window — needs to read.
|
|
50
|
+
const tail_lines = (content, n) => {
|
|
51
|
+
if (!n || n < 1)
|
|
52
|
+
return { content, omitted_lines: 0 };
|
|
53
|
+
const lines = content.replace(/\n$/, '').split('\n');
|
|
54
|
+
if (lines.length <= n)
|
|
55
|
+
return { content, omitted_lines: 0 };
|
|
56
|
+
return {
|
|
57
|
+
content: lines.slice(-n).join('\n') + '\n',
|
|
58
|
+
omitted_lines: lines.length - n
|
|
59
|
+
};
|
|
60
|
+
};
|
|
38
61
|
const short_commit = (sha) => sha ? sha.slice(0, 7) : '-';
|
|
62
|
+
// The future counterpart of `when`: "in 3h 20m".
|
|
63
|
+
const until = (iso) => {
|
|
64
|
+
if (!iso)
|
|
65
|
+
return '-';
|
|
66
|
+
const minutes = Math.round((new Date(iso).getTime() - Date.now()) / 60_000);
|
|
67
|
+
if (minutes <= 0)
|
|
68
|
+
return 'now';
|
|
69
|
+
if (minutes < 60)
|
|
70
|
+
return `in ${minutes}m`;
|
|
71
|
+
return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
72
|
+
};
|
|
39
73
|
const when = (iso) => {
|
|
40
74
|
if (!iso)
|
|
41
75
|
return '-';
|
|
@@ -57,4 +91,4 @@ const deployment_row = (d) => {
|
|
|
57
91
|
return `${phase_badge(d.status?.phase)} ${d.id} ${short_commit(d.github_commit)}${release} (${trigger}, ${when(d.createdAt)})`;
|
|
58
92
|
};
|
|
59
93
|
|
|
60
|
-
export { deployment_row, detected_summary, format_log_lines, phase_badge, short_commit, when };
|
|
94
|
+
export { deployment_row, detected_summary, format_log_lines, log_entries, phase_badge, short_commit, tail_lines, until, when };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { CliError } from '../../../lib/errors.js';
|
|
3
|
+
import { print_json, as_list, json_option } from '../../../lib/listing.js';
|
|
2
4
|
import { log } from '../../../log.js';
|
|
3
5
|
import { follow_remote_build } from '../remote/follow.js';
|
|
4
6
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
5
|
-
import { format_log_lines } from './format.js';
|
|
7
|
+
import { tail_lines, log_entries, format_log_lines } from './format.js';
|
|
6
8
|
|
|
7
9
|
// Phases where the build is still producing output; anything else is a frozen
|
|
8
10
|
// record and following would just wait on nothing.
|
|
@@ -10,11 +12,11 @@ const FOLLOWABLE_PHASES = new Set(['UNKNOWN', 'QUEUED', 'BUILDING']);
|
|
|
10
12
|
const logs = {
|
|
11
13
|
command: 'logs',
|
|
12
14
|
describe: 'Show runtime logs of the app (or build logs with --build)',
|
|
13
|
-
builder: yargs => yargs
|
|
15
|
+
builder: yargs => json_option(yargs)
|
|
14
16
|
.option('app', {
|
|
15
17
|
alias: 'a',
|
|
16
18
|
type: 'string',
|
|
17
|
-
description: 'App
|
|
19
|
+
description: 'App id, name or slug (defaults to the linked app)'
|
|
18
20
|
})
|
|
19
21
|
.option('build', {
|
|
20
22
|
type: 'boolean',
|
|
@@ -31,10 +33,16 @@ const logs = {
|
|
|
31
33
|
type: 'boolean',
|
|
32
34
|
default: false,
|
|
33
35
|
description: 'With --build: keep tailing the output while the build runs'
|
|
36
|
+
})
|
|
37
|
+
.option('tail', {
|
|
38
|
+
alias: 'n',
|
|
39
|
+
type: 'number',
|
|
40
|
+
description: 'Only the last N lines'
|
|
34
41
|
})
|
|
35
42
|
.example('$0 deploy logs', 'Runtime logs of the linked app (last 24h)')
|
|
36
43
|
.example('$0 deploy logs -d deployment_a1b2c3', 'Runtime logs of ONE deployment (last 24h)')
|
|
37
44
|
.example('$0 deploy logs --build', 'Build output of the latest deployment')
|
|
45
|
+
.example('$0 deploy logs --build -n 100', 'The last 100 lines of the build — where a failure says why')
|
|
38
46
|
.example('$0 deploy logs --build --follow', 'Tail the build that is running right now')
|
|
39
47
|
.showHelpOnFail(false),
|
|
40
48
|
handler: async (args) => {
|
|
@@ -43,6 +51,12 @@ const logs = {
|
|
|
43
51
|
// stream) — only the build output can be tailed.
|
|
44
52
|
throw new Error('--follow only works with --build');
|
|
45
53
|
}
|
|
54
|
+
if (args.follow && args.json) {
|
|
55
|
+
throw new CliError('usage', '--follow streams text; it has no --json');
|
|
56
|
+
}
|
|
57
|
+
if (args.tail !== undefined && !(Number.isInteger(args.tail) && args.tail > 0)) {
|
|
58
|
+
throw new CliError('usage', '--tail must be a positive integer');
|
|
59
|
+
}
|
|
46
60
|
const ctx = await requireApi();
|
|
47
61
|
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
48
62
|
const app = await ctx.api.getApp(app_id);
|
|
@@ -54,6 +68,14 @@ const logs = {
|
|
|
54
68
|
const deployments = await ctx.api.listDeployments(app_id, app.team);
|
|
55
69
|
deployment_id = deployments[0]?.id;
|
|
56
70
|
if (!deployment_id) {
|
|
71
|
+
if (args.json) {
|
|
72
|
+
return print_json({
|
|
73
|
+
deployment_id: null,
|
|
74
|
+
content: null,
|
|
75
|
+
truncated: false,
|
|
76
|
+
omitted_lines: 0
|
|
77
|
+
});
|
|
78
|
+
}
|
|
57
79
|
log.info(`📭 ${app.name} has no deployments yet.`);
|
|
58
80
|
return;
|
|
59
81
|
}
|
|
@@ -73,21 +95,40 @@ const logs = {
|
|
|
73
95
|
log.info(`Build of ${deployment_id} already finished (${phase}) — showing the recorded output.`);
|
|
74
96
|
}
|
|
75
97
|
const build = await ctx.api.getDeploymentLogs(deployment_id);
|
|
98
|
+
const { content, omitted_lines } = tail_lines(build.content ?? '', args.tail);
|
|
99
|
+
// `truncated`: the server cut the stored log; `omitted_lines`: --tail
|
|
100
|
+
// left the head out. Different things, both worth knowing.
|
|
101
|
+
if (args.json) {
|
|
102
|
+
return print_json({
|
|
103
|
+
deployment_id,
|
|
104
|
+
content: build.content ? content : null,
|
|
105
|
+
truncated: !!build.truncated,
|
|
106
|
+
omitted_lines
|
|
107
|
+
});
|
|
108
|
+
}
|
|
76
109
|
if (!build.content) {
|
|
77
110
|
log.info(`📭 No build output recorded for ${deployment_id}.`);
|
|
78
111
|
return;
|
|
79
112
|
}
|
|
80
113
|
log.info(`🏗️ Build output of ${deployment_id}:`);
|
|
81
|
-
|
|
82
|
-
|
|
114
|
+
if (omitted_lines)
|
|
115
|
+
log.info(`(… ${omitted_lines} earlier lines)`);
|
|
116
|
+
process.stdout.write(content);
|
|
117
|
+
if (!content.endsWith('\n'))
|
|
83
118
|
process.stdout.write('\n');
|
|
84
119
|
if (build.truncated)
|
|
85
120
|
log.warn(`(output truncated)`);
|
|
86
121
|
return;
|
|
87
122
|
}
|
|
88
|
-
const
|
|
123
|
+
const all = await ctx.api.getAppLogs(app_id, {
|
|
89
124
|
deployment_id: args.deployment
|
|
90
125
|
});
|
|
126
|
+
// getAppLogs order is the api's; the tail is taken on time order.
|
|
127
|
+
const sorted = [...all].sort((a, b) => Number(a[0]) - Number(b[0]));
|
|
128
|
+
const lines = args.tail ? sorted.slice(-args.tail) : sorted;
|
|
129
|
+
// The api serves one window (24h, 200 lines): a list that never pages.
|
|
130
|
+
if (args.json)
|
|
131
|
+
return print_json(as_list(log_entries(lines)));
|
|
91
132
|
// Say WHICH scope came back empty: with -d the 24h window is usually the
|
|
92
133
|
// reason (a retired deployment stopped writing when it stopped serving).
|
|
93
134
|
const scope = args.deployment
|