@faable/faable 1.26.1 → 1.28.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.
@@ -169,6 +169,30 @@ class FaableApi {
169
169
  async getMe() {
170
170
  return data(this.client.get(`/auth/me`));
171
171
  }
172
+ // Domains are team-scoped rows; a CLI user token carries no default team,
173
+ // so every call pins the app's team via `x-faable-team` (same pattern as
174
+ // createSecretsBatch).
175
+ async listDomains(app_id, team) {
176
+ return firstPage(data(this.client.get(`/domain`, {
177
+ params: { app_id },
178
+ headers: { "x-faable-team": team },
179
+ })));
180
+ }
181
+ async createDomain(team, params) {
182
+ return data(this.client.post(`/domain`, params, {
183
+ headers: { "x-faable-team": team },
184
+ }));
185
+ }
186
+ async getDomain(domain_id, team) {
187
+ return data(this.client.get(`/domain/${domain_id}`, {
188
+ headers: { "x-faable-team": team },
189
+ }));
190
+ }
191
+ async deleteDomain(domain_id, team) {
192
+ return data(this.client.delete(`/domain/${domain_id}`, {
193
+ headers: { "x-faable-team": team },
194
+ }));
195
+ }
172
196
  }
173
197
 
174
198
  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,6 +2,7 @@ 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';
6
7
  import { propose_release } from './release_version.js';
7
8
  import { deploy_remote } from './remote/index.js';
@@ -17,6 +18,7 @@ const deploy = {
17
18
  // app_id positional, so `faable deploy <app_id>` keeps working).
18
19
  return yargs
19
20
  .command(secrets)
21
+ .command(domains)
20
22
  .command(link)
21
23
  .positional('app_id', {
22
24
  type: 'string',
@@ -42,12 +44,16 @@ const deploy = {
42
44
  const config = Configuration.instance().deployConfig();
43
45
  const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
44
46
  const app = await api.getApp(app_id);
45
- // Monorepo Root Directory: the server is the source of truth
46
- // (App.root_dir), so no repo config file is needed. A local
47
- // faable.json rootDir (dev override) still wins if set.
48
- if (!config.rootDir && app.root_dir) {
47
+ // Monorepo Root Directory single precedence rule everywhere
48
+ // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
49
+ // override, exceptional) > repo faable.json rootDir (the supported
50
+ // user option) > repo root. Matches the remote builder.
51
+ if (app.root_dir) {
49
52
  config.rootDir = app.root_dir;
50
- log.info(`📁 Monorepo root directory (from app): ${app.root_dir}`);
53
+ log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
54
+ }
55
+ else if (config.rootDir) {
56
+ log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
51
57
  }
52
58
  // Capture the commit/ref/actor so the deployment records which commit
53
59
  // it came from and who pushed it (env in CI, git fallback locally).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.26.1",
3
+ "version": "1.28.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",