@faable/faable 4.6.0 → 4.7.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.
Files changed (32) hide show
  1. package/dist/api/FaableApi.js +6 -0
  2. package/dist/api/auth_admin.js +26 -1
  3. package/dist/commands/auth/accounts/index.js +11 -4
  4. package/dist/commands/auth/connections/index.js +10 -0
  5. package/dist/commands/auth/connections/list.js +48 -0
  6. package/dist/commands/auth/index.js +5 -1
  7. package/dist/commands/auth/logs/list.js +28 -9
  8. package/dist/commands/auth/query.js +17 -9
  9. package/dist/commands/auth/sessions/index.js +15 -0
  10. package/dist/commands/auth/sessions/list.js +73 -0
  11. package/dist/commands/auth/sessions/revoke.js +96 -0
  12. package/dist/commands/auth/users/get.js +6 -4
  13. package/dist/commands/auth/users/ids.js +4 -2
  14. package/dist/commands/auth/users/index.js +3 -1
  15. package/dist/commands/auth/users/list.js +89 -5
  16. package/dist/commands/auth/users/password_setup.js +54 -0
  17. package/dist/commands/auth/users/reinstate.js +8 -3
  18. package/dist/commands/auth/users/resolve.js +36 -0
  19. package/dist/commands/auth/users/suspend.js +8 -3
  20. package/dist/commands/deploy/apps/create.js +35 -2
  21. package/dist/commands/deploy/github/index.js +10 -0
  22. package/dist/commands/deploy/github/repos.js +70 -0
  23. package/dist/commands/deploy/index.js +2 -0
  24. package/dist/commands/deploy/inspect/inspect.js +18 -1
  25. package/dist/commands/deploy/inspect/trigger.js +28 -0
  26. package/dist/commands/deploy/launch.js +62 -2
  27. package/dist/commands/deploy/wait.js +54 -0
  28. package/dist/mcp/http.js +7 -5
  29. package/dist/mcp/oauth.js +9 -5
  30. package/dist/mcp/server.js +14 -4
  31. package/dist/mcp/tools.js +427 -6
  32. package/package.json +1 -1
@@ -1,10 +1,39 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
- import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
3
+ import { list_options, print_json, print, fetch_page, from_paginator, table_lines, more_hint } from '../../../lib/listing.js';
4
4
  import { tenant_options } from '../options.js';
5
- import { compose_query } from '../query.js';
5
+ import { compose_query, term, range_term } from '../query.js';
6
6
  import { truncate, yes_no, when } from '../render.js';
7
+ import { CliError } from '../../../lib/errors.js';
7
8
 
9
+ // A Faable Auth older than ?count= / ?sort= / the date ranges answers 400
10
+ // "Unknown query parameter": say what is missing instead of passing that on.
11
+ const unsupported = (feature) => (e) => {
12
+ const res = e?.response;
13
+ const message = String(res?.data?.message ?? e?.message ?? '');
14
+ if (res?.status === 400 &&
15
+ /Unknown query parameter|Unknown field|last_login_|created_(since|until)|sort/.test(message)) {
16
+ throw new CliError('usage', `This Faable Auth does not support ${feature ?? 'that filter'} yet (${message}).`, { status: 400, cause: e });
17
+ }
18
+ throw e;
19
+ };
20
+ const USER_SORTS = [
21
+ '-last_login',
22
+ 'last_login',
23
+ '-logins_count',
24
+ 'logins_count',
25
+ '-createdAt',
26
+ 'createdAt'
27
+ ];
28
+ // The FaableQL filter of a users listing, from its flags.
29
+ const users_query = (args, now) => compose_query([
30
+ args.suspended !== undefined && `suspended:${args.suspended}`,
31
+ term('email', args.email?.toLowerCase()),
32
+ range_term('last_login_since', 'last-login-since', args.lastLoginSince, now),
33
+ range_term('last_login_until', 'last-login-until', args.lastLoginUntil, now),
34
+ range_term('created_since', 'created-since', args.createdSince, now),
35
+ range_term('created_until', 'created-until', args.createdUntil, now)
36
+ ], args.query);
8
37
  const users_list = {
9
38
  command: 'list',
10
39
  describe: 'List and filter users',
@@ -20,14 +49,69 @@ const users_list = {
20
49
  .option('suspended', {
21
50
  type: 'boolean',
22
51
  description: 'Only suspended users (shorthand for query suspended:true)'
52
+ })
53
+ .option('email', {
54
+ type: 'string',
55
+ description: 'Exact email'
56
+ })
57
+ .option('sort', {
58
+ type: 'string',
59
+ choices: USER_SORTS,
60
+ description: 'Order: -last_login (most recent login first), last_login (longest absent first), -logins_count, -createdAt (default). Sorting by last_login leaves out users who never logged in'
61
+ })
62
+ .option('last-login-since', {
63
+ type: 'string',
64
+ description: 'Logged in at or after: 30m, 24h, 7d ago, unix-millis or YYYY-MM-DD'
65
+ })
66
+ .option('last-login-until', {
67
+ type: 'string',
68
+ description: 'Last login at or before (same formats)'
69
+ })
70
+ .option('created-since', {
71
+ type: 'string',
72
+ description: 'Signed up at or after (same formats)'
73
+ })
74
+ .option('created-until', {
75
+ type: 'string',
76
+ description: 'Signed up at or before (same formats)'
77
+ })
78
+ .option('count', {
79
+ type: 'boolean',
80
+ description: 'Only count the users matching the filters ({"total": N}), across every page'
23
81
  })
24
82
  .example('$0 auth users list --suspended', 'List suspended users')
83
+ .example('$0 auth users list --sort -last_login --limit 20', 'The 20 most recent logins')
84
+ .example('$0 auth users list --last-login-since 24h --count', 'How many users logged in in the last 24 hours')
25
85
  .example('$0 auth users list --query email_verified:false --limit 50', 'First 50 unverified users')
26
86
  .showHelpOnFail(false),
27
87
  handler: withAuthHints(async (args) => {
28
88
  const api = await requireAuthAdmin(args);
29
- const query = compose_query([args.suspended !== undefined && `suspended:${args.suspended}`], args.query);
30
- const page = await fetch_page(from_paginator(api.userList({ query, q: args.q })), args);
89
+ const query = users_query(args);
90
+ if (args.count) {
91
+ // One row is enough: the server counts the whole filter (`?count=true`).
92
+ const res = await api.fetcher
93
+ .get('/user', {
94
+ params: {
95
+ pageSize: '1',
96
+ count: 'true',
97
+ ...(query ? { query } : {}),
98
+ ...(args.q ? { q: args.q } : {})
99
+ }
100
+ })
101
+ .catch(unsupported('count'));
102
+ if (typeof res?.total !== 'number') {
103
+ throw new Error('This Faable Auth does not count users yet (no `total` in the answer).');
104
+ }
105
+ if (args.json)
106
+ return print_json({ total: res.total, query: query ?? null });
107
+ print(String(res.total));
108
+ return;
109
+ }
110
+ const page = await fetch_page(p => from_paginator(api.userList({
111
+ query,
112
+ q: args.q,
113
+ ...(args.sort ? { sort: args.sort } : {})
114
+ }))(p).catch(unsupported(args.sort ? 'sort' : undefined)), args);
31
115
  if (args.json)
32
116
  return print_json(page);
33
117
  if (page.data.length === 0) {
@@ -52,4 +136,4 @@ const users_list = {
52
136
  })
53
137
  };
54
138
 
55
- export { users_list };
139
+ export { USER_SORTS, users_list, users_query };
@@ -0,0 +1,54 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { confirm } from '../../../lib/interactive.js';
3
+ import { log } from '../../../log.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { json_option, print_json } from '../../../lib/listing.js';
6
+ import { resolve_user_ref } from './resolve.js';
7
+
8
+ // Sends the user a link (or a code) to set — or reset — their password. The
9
+ // way to "invite" a user created by hand, or to unblock one who forgot it.
10
+ const users_password_setup = {
11
+ command: 'password-setup <user>',
12
+ describe: 'Send a user the email (or code) to set or reset their password',
13
+ builder: yargs => json_option(tenant_options(yargs))
14
+ .positional('user', {
15
+ type: 'string',
16
+ demandOption: true,
17
+ description: 'User id (user_…) or email'
18
+ })
19
+ .option('channel', {
20
+ type: 'string',
21
+ choices: ['email', 'sms', 'whatsapp'],
22
+ description: "How to deliver it (default: the tenant's)"
23
+ })
24
+ .option('yes', {
25
+ alias: 'y',
26
+ type: 'boolean',
27
+ default: false,
28
+ description: 'Skip the confirmation prompt'
29
+ })
30
+ .showHelpOnFail(false),
31
+ handler: withAuthHints(async (args) => {
32
+ // An email is resolved first (a read), so the prompt names one user; an
33
+ // id is confirmed before any request.
34
+ const early = args.user.includes('@') ? await requireAuthAdmin(args) : undefined;
35
+ const user_id = early ? await resolve_user_ref(early, args.user) : args.user;
36
+ const go = await confirm({
37
+ message: `Send ${args.user} a password setup ${args.channel ?? 'message'}?`,
38
+ yes: args.yes
39
+ });
40
+ if (!go) {
41
+ log.info('Cancelled.');
42
+ return;
43
+ }
44
+ const api = early ?? (await requireAuthAdmin(args));
45
+ const res = await api.userPasswordSetup(await resolve_user_ref(api, user_id), {
46
+ ...(args.channel ? { channel: args.channel } : {})
47
+ });
48
+ if (args.json)
49
+ return print_json({ user_id, ...res });
50
+ log.info(`📨 Sent to ${args.user} (${res.channel ?? 'email'}).`);
51
+ })
52
+ };
53
+
54
+ export { users_password_setup };
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, print_json } from '../../../lib/listing.js';
5
5
  import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
6
+ import { resolve_user_refs } from './resolve.js';
6
7
  import { confirm } from '../../../lib/interactive.js';
7
8
 
8
9
  const users_reinstate = {
@@ -12,7 +13,7 @@ const users_reinstate = {
12
13
  .positional('user_ids', {
13
14
  type: 'string',
14
15
  array: true,
15
- description: 'User ids (user_…); omit them to read ids from stdin'
16
+ description: 'User ids (user_…) or emails (exactly one user each); omit them to read ids from stdin'
16
17
  })
17
18
  .option('yes', {
18
19
  alias: 'y',
@@ -28,7 +29,11 @@ const users_reinstate = {
28
29
  const stdin = wants_stdin(argv_ids, !!process.stdin.isTTY)
29
30
  ? await read_stdin()
30
31
  : null;
31
- const ids = parse_user_ids(argv_ids, stdin);
32
+ const refs = parse_user_ids(argv_ids, stdin);
33
+ // Emails become ids BEFORE the prompt, so what is confirmed is what runs.
34
+ const has_email = refs.some(r => r.includes('@'));
35
+ const early = has_email ? await requireAuthAdmin(args) : undefined;
36
+ const ids = early ? await resolve_user_refs(early, refs) : refs;
32
37
  const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
33
38
  const go = await confirm({
34
39
  message: `Reinstate ${ids.length} user(s) (${preview})?`,
@@ -38,7 +43,7 @@ const users_reinstate = {
38
43
  log.info('Cancelled.');
39
44
  return;
40
45
  }
41
- const api = await requireAuthAdmin(args);
46
+ const api = early ?? (await requireAuthAdmin(args));
42
47
  const results = [];
43
48
  for (const id of ids) {
44
49
  try {
@@ -0,0 +1,36 @@
1
+ import { CliError } from '../../../lib/errors.js';
2
+
3
+ // A user given as an id (`user_…`) or as an email. An email is looked up with
4
+ // an exact `?email=` (stored lowercase) and must match ONE user: a write by
5
+ // email never acts on a partial or ambiguous match.
6
+ const resolve_user_ref = async (api, ref) => {
7
+ const value = ref.trim();
8
+ if (/^user_[0-9a-f]{24}$/.test(value))
9
+ return value;
10
+ if (!value.includes('@')) {
11
+ throw new CliError('usage', `"${ref}" is neither a user id (user_…) nor an email.`);
12
+ }
13
+ const email = value.toLowerCase();
14
+ const page = await api
15
+ .userList({ email, pageSize: '2' })
16
+ .pass({ pageSize: '2' });
17
+ const matches = (page.results ?? []);
18
+ const exact = matches.filter(u => u.email?.toLowerCase() === email);
19
+ if (exact.length === 0) {
20
+ throw new CliError('not_found', `No user with email ${email} in this tenant.`);
21
+ }
22
+ if (exact.length > 1) {
23
+ throw new CliError('usage', `${exact.length} users have email ${email} (${exact
24
+ .map(u => u.id)
25
+ .join(', ')}): pass the user id.`);
26
+ }
27
+ return exact[0].id;
28
+ };
29
+ const resolve_user_refs = async (api, refs) => {
30
+ const out = [];
31
+ for (const ref of refs)
32
+ out.push(await resolve_user_ref(api, ref));
33
+ return out;
34
+ };
35
+
36
+ export { resolve_user_ref, resolve_user_refs };
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, print_json } from '../../../lib/listing.js';
5
5
  import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
6
+ import { resolve_user_refs } from './resolve.js';
6
7
  import { confirm } from '../../../lib/interactive.js';
7
8
 
8
9
  const users_suspend = {
@@ -12,7 +13,7 @@ const users_suspend = {
12
13
  .positional('user_ids', {
13
14
  type: 'string',
14
15
  array: true,
15
- description: 'User ids (user_…); omit them to read ids from stdin'
16
+ description: 'User ids (user_…) or emails (exactly one user each); omit them to read ids from stdin'
16
17
  })
17
18
  .option('reason', {
18
19
  alias: 'r',
@@ -34,7 +35,11 @@ const users_suspend = {
34
35
  const stdin = wants_stdin(argv_ids, !!process.stdin.isTTY)
35
36
  ? await read_stdin()
36
37
  : null;
37
- const ids = parse_user_ids(argv_ids, stdin);
38
+ const refs = parse_user_ids(argv_ids, stdin);
39
+ // Emails become ids BEFORE the prompt, so what is confirmed is what runs.
40
+ const has_email = refs.some(r => r.includes('@'));
41
+ const early = has_email ? await requireAuthAdmin(args) : undefined;
42
+ const ids = early ? await resolve_user_refs(early, refs) : refs;
38
43
  const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
39
44
  const go = await confirm({
40
45
  message: `Suspend ${ids.length} user(s) (${preview})?`,
@@ -44,7 +49,7 @@ const users_suspend = {
44
49
  log.info('Cancelled.');
45
50
  return;
46
51
  }
47
- const api = await requireAuthAdmin(args);
52
+ const api = early ?? (await requireAuthAdmin(args));
48
53
  const results = [];
49
54
  for (const id of ids) {
50
55
  try {
@@ -3,6 +3,7 @@ import { requireProject } from '../../../api/project.js';
3
3
  import { CliError } from '../../../lib/errors.js';
4
4
  import { print_json, json_option } from '../../../lib/listing.js';
5
5
  import { log } from '../../../log.js';
6
+ import { wait_for_deployment, app_url } from '../wait.js';
6
7
 
7
8
  // "owner/repo" from any of the ways people paste a GitHub repository.
8
9
  const parse_repo = (input) => {
@@ -55,6 +56,16 @@ const apps_create = {
55
56
  type: 'boolean',
56
57
  default: true,
57
58
  description: 'Start the first deploy (--no-deploy to only link)'
59
+ })
60
+ .option('wait', {
61
+ type: 'boolean',
62
+ default: false,
63
+ description: 'Wait until the deploy is live or fails, and print the URL (or why it failed)'
64
+ })
65
+ .option('timeout', {
66
+ type: 'number',
67
+ default: 900,
68
+ description: 'With --wait: give up waiting after this many seconds (the build goes on)'
58
69
  })
59
70
  .check(({ repo, name }) => {
60
71
  if (!repo && !name) {
@@ -123,8 +134,30 @@ const apps_create = {
123
134
  log.warn(`⚠️ The app is ready but the first deploy did not start: ${e.message}`);
124
135
  }
125
136
  }
126
- if (args.json)
127
- return print_json({ app: linked, first_deploy });
137
+ let waited;
138
+ if (args.wait &&
139
+ first_deploy &&
140
+ 'deployment_id' in first_deploy &&
141
+ first_deploy.deployment_id) {
142
+ waited = await wait_for_deployment(api, first_deploy.deployment_id, {
143
+ timeout_s: args.timeout ?? 900,
144
+ app_url: app_url(linked.url)
145
+ });
146
+ if (!args.json) {
147
+ log.info(waited.ok
148
+ ? `✅ Live: ${waited.url}`
149
+ : waited.done
150
+ ? `❌ ${waited.phase}${waited.fault_owner ? ` (${waited.fault_owner})` : ''}: ${waited.reason ?? ''}`
151
+ : `⏳ Still ${waited.phase} after ${args.timeout}s`);
152
+ }
153
+ }
154
+ if (args.json) {
155
+ return print_json({
156
+ app: linked,
157
+ first_deploy,
158
+ ...(waited ? { result: waited } : {})
159
+ });
160
+ }
128
161
  log.info(`🌍 https://${linked.url} · follow it with: faable deploy status -a ${app.id}`);
129
162
  }
130
163
  };
@@ -0,0 +1,10 @@
1
+ import { github_repos } from './repos.js';
2
+
3
+ const github = {
4
+ command: 'github',
5
+ describe: 'The GitHub repositories Faable can deploy',
6
+ builder: yargs => yargs.command(github_repos).demandCommand(1).showHelpOnFail(false),
7
+ handler: () => { }
8
+ };
9
+
10
+ export { github };
@@ -0,0 +1,70 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { print_json, print, json_option } from '../../../lib/listing.js';
3
+ import { log } from '../../../log.js';
4
+
5
+ // Where a user installs the Faable GitHub App — the one step of "deploy this
6
+ // repository" that needs a browser (github.com), so it is handed over, never
7
+ // automated.
8
+ const GITHUB_INSTALL_URL = 'https://github.com/apps/faable-deploy/installations/new';
9
+ const github_repos_listing = async (api, opts) => {
10
+ const installations = await api.listGithubInstallations();
11
+ const repositories = [];
12
+ for (const i of installations) {
13
+ const repos = await api.listGithubRepos(i.installation_id, {
14
+ ...(opts.q ? { q: opts.q } : {}),
15
+ limit: opts.limit ?? 30
16
+ });
17
+ for (const r of repos) {
18
+ repositories.push({
19
+ full_name: r.full_name,
20
+ default_branch: r.default_branch,
21
+ private: r.private,
22
+ installation: i.account_login
23
+ });
24
+ }
25
+ }
26
+ return {
27
+ installations: installations.map(i => ({
28
+ installation_id: i.installation_id,
29
+ account: i.account_login,
30
+ account_type: i.account_type
31
+ })),
32
+ repositories,
33
+ install_url: GITHUB_INSTALL_URL
34
+ };
35
+ };
36
+ const github_repos = {
37
+ command: 'repos',
38
+ describe: 'GitHub repositories Faable can deploy (where the Faable GitHub App is installed)',
39
+ builder: yargs => json_option(yargs)
40
+ .option('q', {
41
+ type: 'string',
42
+ description: 'Filter by name'
43
+ })
44
+ .option('limit', {
45
+ alias: 'n',
46
+ type: 'number',
47
+ default: 30,
48
+ description: 'Repositories per GitHub account (max 100)'
49
+ })
50
+ .showHelpOnFail(false),
51
+ handler: async (args) => {
52
+ const { api } = await requireApi();
53
+ const listing = await github_repos_listing(api, {
54
+ q: args.q,
55
+ limit: Math.min(args.limit ?? 30, 100)
56
+ });
57
+ if (args.json)
58
+ return print_json(listing);
59
+ if (listing.installations.length === 0) {
60
+ log.info(`The Faable GitHub App is not installed on any account yet. Install it: ${GITHUB_INSTALL_URL}`);
61
+ return;
62
+ }
63
+ for (const r of listing.repositories) {
64
+ print(`${r.full_name}${r.private ? ' (private)' : ''} · ${r.default_branch}`);
65
+ }
66
+ log.info(`A repository that is not listed: give the Faable GitHub App access to it at ${GITHUB_INSTALL_URL}`);
67
+ }
68
+ };
69
+
70
+ export { GITHUB_INSTALL_URL, github_repos, github_repos_listing };
@@ -1,3 +1,4 @@
1
+ import { github } from './github/index.js';
1
2
  import { link } from '../link/index.js';
2
3
  import { apps, apps_list_legacy } from './apps/index.js';
3
4
  import { domains } from './domains/index.js';
@@ -36,6 +37,7 @@ const deploy = {
36
37
  builder: yargs => capture_deploy_help(yargs
37
38
  .command(launch)
38
39
  .command(apps)
40
+ .command(github)
39
41
  .command(secrets)
40
42
  .command(domains)
41
43
  .command(waf)
@@ -54,8 +54,25 @@ const inspect = {
54
54
  return null;
55
55
  });
56
56
  }
57
+ // Whose failure it is, from the api's attribution (absent on older apis
58
+ // and on deployments that did not fail).
59
+ const phase = deployment.status?.phase;
60
+ const fault = phase === 'BUILD_ERROR' || phase === 'ERROR'
61
+ ? await ctx.api.getDeploymentFault(deployment_id).catch(() => null)
62
+ : null;
57
63
  if (args.json) {
58
- process.stdout.write(JSON.stringify({ ...deployment, artifact }, null, 2) + '\n');
64
+ process.stdout.write(JSON.stringify({
65
+ ...deployment,
66
+ artifact,
67
+ ...(fault
68
+ ? {
69
+ fault: {
70
+ owner: fault.fault_owner,
71
+ error_code: fault.error_code
72
+ }
73
+ }
74
+ : {})
75
+ }, null, 2) + '\n');
59
76
  return;
60
77
  }
61
78
  for (const line of deployment_detail({ app, deployment, artifact })) {
@@ -2,6 +2,7 @@ import { requireApi } from '../../../api/context.js';
2
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
+ import { wait_for_deployment, app_url } from '../wait.js';
5
6
 
6
7
  const trigger = {
7
8
  command: 'trigger',
@@ -11,6 +12,16 @@ const trigger = {
11
12
  alias: 'a',
12
13
  type: 'string',
13
14
  description: 'App id, name or slug (defaults to the linked app)'
15
+ })
16
+ .option('wait', {
17
+ type: 'boolean',
18
+ default: false,
19
+ description: 'Wait until the deploy is live or fails, and print the URL (or why it failed)'
20
+ })
21
+ .option('timeout', {
22
+ type: 'number',
23
+ default: 900,
24
+ description: 'With --wait: give up waiting after this many seconds (the build goes on)'
14
25
  })
15
26
  .example('$0 deploy trigger', 'Deploy the repo HEAD without uploading anything from this machine')
16
27
  .showHelpOnFail(false),
@@ -21,6 +32,23 @@ const trigger = {
21
32
  // Takes the exact same path a git push would (same-commit dedupe
22
33
  // included) — the API answers with an actionable refusal otherwise.
23
34
  const result = await ctx.api.deployNow(app_id, app.team);
35
+ if (args.wait && result.deployment_id) {
36
+ if (!args.json) {
37
+ log.info(`⏳ Building ${result.commit.slice(0, 7)} (${result.branch}) as ${result.deployment_id}…`);
38
+ }
39
+ const waited = await wait_for_deployment(ctx.api, result.deployment_id, {
40
+ timeout_s: args.timeout ?? 900,
41
+ app_url: app_url(app.url)
42
+ });
43
+ if (args.json)
44
+ return print_json({ app_id, ...result, ...waited });
45
+ log.info(waited.ok
46
+ ? `✅ Live: ${waited.url}`
47
+ : waited.done
48
+ ? `❌ ${waited.phase}${waited.fault_owner ? ` (${waited.fault_owner})` : ''}: ${waited.reason ?? ''}`
49
+ : `⏳ Still ${waited.phase} after ${args.timeout}s — follow it with: faable deploy status -a ${app.id}`);
50
+ return;
51
+ }
24
52
  if (args.json)
25
53
  return print_json({ app_id, ...result });
26
54
  log.info(`🚀 Building ${result.commit.slice(0, 7)} (${result.branch}) of ${app.name} server-side${result.deployment_id ? ` as ${result.deployment_id}` : ''}.`);
@@ -2,7 +2,9 @@ import { requireApi } from '../../api/context.js';
2
2
  import { Configuration } from '../../lib/Configuration.js';
3
3
  import { CliError } from '../../lib/errors.js';
4
4
  import { is_non_interactive, confirm } from '../../lib/interactive.js';
5
+ import { print_json } from '../../lib/listing.js';
5
6
  import { log } from '../../log.js';
7
+ import { requireProject } from '../../api/project.js';
6
8
  import { git_context } from './git_context.js';
7
9
  import { show_deploy_help } from './help.js';
8
10
  import { propose_release } from './release_version.js';
@@ -39,6 +41,16 @@ const launch = {
39
41
  default: false,
40
42
  description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
41
43
  })
44
+ .option('create', {
45
+ type: 'string',
46
+ description: 'No app yet: create one with this name in the active project (or --project) and deploy the directory to it'
47
+ })
48
+ .option('json', {
49
+ type: 'boolean',
50
+ default: false,
51
+ description: 'Print the outcome as JSON on stdout: app, deployment, phase and the URL once live'
52
+ })
53
+ .conflicts('create', 'app')
42
54
  .example('$0 deploy', 'Deploy the app linked to the current directory')
43
55
  .example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
44
56
  .showHelpOnFail(false),
@@ -47,7 +59,7 @@ const launch = {
47
59
  // working directory, and the go-ahead is explicit (see lib/interactive).
48
60
  if (is_non_interactive()) {
49
61
  const missing = [
50
- !args.app && '--app',
62
+ !args.app && !args.create && '--app (or --create <name>)',
51
63
  !args.workdir && '--workdir',
52
64
  !args.yes && '--yes'
53
65
  ].filter(Boolean);
@@ -64,7 +76,16 @@ const launch = {
64
76
  const ctx = await requireApi(args.app?.startsWith('app_') ? args.app : undefined);
65
77
  const { api } = ctx;
66
78
  const config = Configuration.instance().deployConfig();
67
- const app_id = await find_app_id(args.app, ctx.appId, api, workdir);
79
+ // --create: a directory with no app yet (the agent's «deploy this folder
80
+ // and give me the URL»). The app is new, so nothing existing is touched.
81
+ let created_id;
82
+ if (args.create) {
83
+ const { id: project } = await requireProject(api);
84
+ const created = await api.createApp(project, { name: args.create });
85
+ created_id = created.id;
86
+ log.info(`📦 Created ${created.name} (${created.id})`);
87
+ }
88
+ const app_id = created_id ?? (await find_app_id(args.app, ctx.appId, api, workdir));
68
89
  // Nothing to deploy from here. `faable deploy` is the entry point to the
69
90
  // whole product, so an unlinked directory is a perfectly normal way to
70
91
  // arrive: list what deploy can do (like `faable auth` does) instead of
@@ -176,6 +197,45 @@ const launch = {
176
197
  log.debug(`Polling app status failed, retrying...`);
177
198
  }
178
199
  }
200
+ if (args.json) {
201
+ const outcome = promoted
202
+ ? 'live'
203
+ : superseded
204
+ ? 'superseded'
205
+ : failure
206
+ ? 'failed'
207
+ : 'timeout';
208
+ const fault = failure
209
+ ? await api.getDeploymentFault(deployment.id).catch(() => null)
210
+ : null;
211
+ const result = {
212
+ app_id: app.id,
213
+ app_name: app.name,
214
+ ...(created_id ? { created: true } : {}),
215
+ deployment_id: deployment.id,
216
+ outcome,
217
+ url: `https://${app.url}`,
218
+ ...(failure
219
+ ? {
220
+ phase: failure.phase,
221
+ reason: failure.reason,
222
+ ...(fault
223
+ ? { fault_owner: fault.fault_owner, error_code: fault.error_code }
224
+ : {})
225
+ }
226
+ : {}),
227
+ dashboard_url
228
+ };
229
+ if (failure) {
230
+ // A failed deploy still exits red (CI depends on it); the outcome
231
+ // travels in the error so a program reads it without parsing prose.
232
+ const err = new CliError('usage', `Deployment ${deployment.id} failed (${failure.phase})${fault?.fault_owner ? ` — ${fault.fault_owner}'s side` : ''}: ${failure.reason ?? 'no reason recorded'}`, { action: JSON.stringify(result) });
233
+ // The api's own code when it classified the failure.
234
+ err.code = fault?.error_code ?? 'deploy_failed';
235
+ throw err;
236
+ }
237
+ return print_json(result);
238
+ }
179
239
  if (promoted) {
180
240
  log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
181
241
  }