@faable/faable 2.2.0 → 3.1.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 CHANGED
@@ -9,67 +9,105 @@
9
9
  Your React, Node.js or Python apps, up to the cloud in seconds.
10
10
  </p>
11
11
 
12
- ## Faable
12
+ ## Faable CLI
13
13
 
14
- Faable is the best platform to build modern architectures that scale precisely
15
- to meet demand. We handle the hard stuff so you can focus on building cloud
16
- ready apps. Make your business cloud driven and join those awesome companies.
14
+ `@faable/faable` is the command-line interface for the Faable platform. It covers
15
+ **Faable Deploy** (ship and operate apps) and **Faable Auth** (manage an identity
16
+ tenant: users, login-flow actions, OAuth clients and the audit log) from one binary.
17
17
 
18
- To install the latest version of Faable CLI:
18
+ Full reference: **[faable.com/docs/cli](https://faable.com/docs/cli)**.
19
+
20
+ To install the latest version:
19
21
 
20
22
  ```bash
21
23
  npm i -g @faable/faable
22
24
  ```
23
25
 
24
- ## Runtimes
26
+ ## Commands
25
27
 
26
- `faable deploy` auto-detects the runtime from the files in your project (no
27
- config needed in the common case):
28
+ | Group | What it does | Docs |
29
+ | --- | --- | --- |
30
+ | `faable login` / `whoami` / `logout` | Session management (device flow; CI uses OIDC, no login needed) | [Authentication](https://faable.com/docs/cli#authentication) |
31
+ | `faable deploy` | `launch` (the default: a bare `faable deploy` deploys the current directory), then `status`, `logs`, `deployments`, `inspect`, `trigger`, `redeploy`, `cancel`, `open`, `list` | [Deployment](https://faable.com/docs/cli#deployment) |
32
+ | `faable deploy secrets` | Environment variables: `list`, `set` (`KEY=VALUE` or `--env-file`), `rm` | [Secrets](https://faable.com/docs/cli#secrets) |
33
+ | `faable deploy domains` | Custom domains: `add` (prints the CNAME), `list`, `check`, `rm` | [Domains](https://faable.com/docs/cli#domains) |
34
+ | `faable deploy waf` | Edge rules: `block` (403), `sink` (404 without waking the app), `list`, `rm` | [Edge rules](https://faable.com/docs/cli#edge-rules-waf) |
35
+ | `faable auth users` | `list` (FaableQL filters), `get` (federated identities included), `suspend`, `reinstate` | [Users](https://faable.com/docs/cli#users) |
36
+ | `faable auth actions` | Login-flow hooks: `list`, `get --code`, `create`, `update`, `rm` | [Actions](https://faable.com/docs/cli#actions) |
37
+ | `faable auth clients` | OAuth clients: `list`, `get --secret`, `create`, `rm` | [OAuth clients](https://faable.com/docs/cli#oauth-clients) |
38
+ | `faable auth logs` | Audit log (read-only): `list` with filters, `get` | [Audit logs](https://faable.com/docs/cli#audit-logs) |
39
+
40
+ Every command has `--help`; read commands accept `--json` for output you can pipe
41
+ to `jq`. The complete table is in the
42
+ [command reference](https://faable.com/docs/cli#command-reference).
43
+
44
+ ### Deploy
28
45
 
29
- | Detected by | Runtime |
30
- | --- | --- |
31
- | `package.json` | Node.js (Next.js, Vite, Astro, Gatsby, CRA, Vue, Angular, …) |
32
- | `requirements.txt` / `pyproject.toml` / `Pipfile` | Python |
33
- | `Dockerfile` | Your own image (any language) |
46
+ ```bash
47
+ faable login
48
+ faable deploy # deploy the current directory (= deploy launch)
49
+ faable deploy launch --app app_x # deploy another app, from anywhere
50
+ faable deploy status # phase, URL and detected stack
51
+ faable deploy logs --build # build output (--follow to tail)
52
+ faable deploy secrets set KEY=value
53
+ faable deploy domains add app.example.com
54
+ ```
34
55
 
35
- > A project with a `package.json` is always treated as Node. To deploy a Python
36
- > backend that also has a `package.json`, ship a `Dockerfile` instead.
56
+ ### Faable Auth
37
57
 
38
- ### Node.js / static frameworks
58
+ `faable auth` manages a [Faable Auth](https://faable.com/docs/auth) tenant and
59
+ reuses your `faable login` session. Point it at your tenant with
60
+ `--auth-url https://<account>.auth.faable.link` (or `FAABLE_AUTH_URL`).
39
61
 
40
- SPA frameworks are built and served automatically. If the project has **no
41
- `start` script**, the built output is served statically (e.g. Vite → `npx vite
42
- preview`, CRA/Vue/Angular → `serve`). If it defines a `start` script (custom SSR,
43
- Next.js, Nuxt, Remix, …), that command is used.
62
+ ```bash
63
+ faable auth users list --query email_verified:false --limit 50
64
+ faable auth users get user_abc123 # includes GitHub/… identities
65
+ faable auth users suspend user_abc123 -r "abuse: crypto miner"
44
66
 
45
- ### Python
67
+ # Bulk: pipe ids from a filtered listing
68
+ faable auth users list --suspended --json | jq -r '.[].id' | faable auth users reinstate -y
46
69
 
47
- The start command is detected from your framework:
70
+ faable auth actions create -n add-claims -t post-login -f ./claims.js
71
+ faable auth clients create -n my-app --callback https://app.example.com/callback
72
+ faable auth logs list --origin oauth --status failed --since 2026-08-01
73
+ ```
48
74
 
49
- | Framework | Detected from | Start command |
50
- | --- | --- | --- |
51
- | Django | `manage.py` + the package with `wsgi.py` | `gunicorn <pkg>.wsgi:application` |
52
- | FastAPI / ASGI | `fastapi`/`uvicorn`/`starlette` dep | `uvicorn <module>:app` |
53
- | Flask | `flask` dep | `gunicorn <module>:app` |
75
+ See [`faable auth` in the docs](https://faable.com/docs/cli#faable-auth) for
76
+ every flag. The concepts behind each group are documented in Faable Auth:
77
+ [users & suspension](https://faable.com/docs/auth/suspend-users),
78
+ [actions](https://faable.com/docs/auth/extensibility/actions),
79
+ [login flows](https://faable.com/docs/auth/login-flows),
80
+ [OAuth clients](https://faable.com/docs/auth/clients) and
81
+ [audit logs](https://faable.com/docs/auth/logs).
54
82
 
55
- Dependencies are installed inside the image from `requirements.txt`,
56
- `pyproject.toml` or `Pipfile`. `gunicorn`/`uvicorn` are installed automatically
57
- if missing.
83
+ ## Documentation
58
84
 
59
- Pin the Python version with a `runtime.txt` (`python-3.11.3`), a
60
- `.python-version`, or `requires-python` in `pyproject.toml`.
85
+ The CLI is a thin client: what it deploys and what it manages is documented in
86
+ the public docs, not here.
61
87
 
62
- ### Overriding detection
88
+ ### CLI
63
89
 
64
- When auto-detection doesn't fit, set the commands explicitly:
90
+ - [Command reference](https://faable.com/docs/cli#command-reference) — every command and flag
91
+ - [Authentication](https://faable.com/docs/cli#authentication) — `login`, `whoami`, `logout`
92
+ - [Deployment](https://faable.com/docs/cli#deployment) · [Inspecting](https://faable.com/docs/cli#inspecting) · [Secrets](https://faable.com/docs/cli#secrets) · [Domains](https://faable.com/docs/cli#domains) · [Edge rules](https://faable.com/docs/cli#edge-rules-waf) · [Faable Auth](https://faable.com/docs/cli#faable-auth)
65
93
 
66
- - `faable.json` — `{ "buildCommand": "...", "startCommand": "..." }`
67
- - `Procfile` — a `web:` line, e.g. `web: gunicorn app:app --bind 0.0.0.0:$PORT`
94
+ ### Faable Deploy
68
95
 
69
- Precedence: `faable.json` → `Procfile` → auto-detection. The container listens on
70
- `$PORT` (80).
96
+ - [Get started](https://faable.com/docs/deploy/get-started) — first deploy in minutes
97
+ - [How deployment works](https://faable.com/docs/deploy/how-it-works) — push-to-deploy, builds and releases
98
+ - [Runtime](https://faable.com/docs/deploy/runtime) — supported languages, framework detection, `faable.json` / `Procfile` overrides
99
+ - [What the builder expects](https://faable.com/docs/deploy/build-requirements) — build and start commands, `$PORT`, monorepos
100
+ - [Environment & releases](https://faable.com/docs/deploy/environment) — secrets, env vars and release versions
101
+ - [Custom domains](https://faable.com/docs/deploy/domains/custom-domain) and [SSL certificates](https://faable.com/docs/deploy/domains/ssl-certificates)
102
+ - [Web Application Firewall](https://faable.com/docs/deploy/security-waf) — the edge rules behind `faable deploy waf`
103
+ - [Deploy from your own CI](https://faable.com/docs/deploy/github-actions) — OIDC, no login needed
104
+ - Framework guides: [Next.js](https://faable.com/docs/deploy/guides/guide-next), [Vite](https://faable.com/docs/deploy/guides/guide-vite), [Astro](https://faable.com/docs/deploy/guides/guide-astro), [Express](https://faable.com/docs/deploy/guides/guide-express), [Django](https://faable.com/docs/deploy/guides/guide-django), [FastAPI](https://faable.com/docs/deploy/guides/guide-fastapi), [Flask](https://faable.com/docs/deploy/guides/guide-flask), [PHP](https://faable.com/docs/deploy/guides/guide-php)
71
105
 
72
- ## Documentation
106
+ ### Faable Auth
73
107
 
74
- For details on how to use Faable CLI, check out our
75
- [documentation](https://faable.com/docs).
108
+ - [Get started](https://faable.com/docs/auth/get-started) — create a tenant
109
+ - [Clients](https://faable.com/docs/auth/clients) · [Connections](https://faable.com/docs/auth/connections) · [Social login](https://faable.com/docs/auth/social) · [Passwordless](https://faable.com/docs/auth/passwordless)
110
+ - [Suspend users](https://faable.com/docs/auth/suspend-users) — what `users suspend` / `reinstate` do to sessions and tokens
111
+ - [Login flows](https://faable.com/docs/auth/login-flows) and [actions](https://faable.com/docs/auth/extensibility/actions) — the hooks managed by `auth actions`
112
+ - [Logs](https://faable.com/docs/auth/logs) — the audit trail read by `auth logs`
113
+ - [OAuth 2.0 flows](https://faable.com/docs/auth/oauth-flows) · [OpenID Connect](https://faable.com/docs/auth/oidc) · [Validate access tokens](https://faable.com/docs/auth/validate-access-tokens)
@@ -20,7 +20,7 @@ const context = async (targetAppId) => {
20
20
  try {
21
21
  const idToken = process.env.FAABLE_ID_TOKEN || (await getIDToken("https://faable.com"));
22
22
  // targetAppId disambiguates a monorepo (several apps, one repo) in the
23
- // OIDC exchange — from `faable deploy <app_id>`.
23
+ // OIDC exchange — from `faable deploy --app <app_id>`.
24
24
  api = FaableApi.create({
25
25
  authStrategy: oidc_strategy,
26
26
  auth: { idToken, appId: targetAppId },
@@ -27,7 +27,7 @@ const exchangeGithubOidcToken = async (gh_token, target_app_id) => {
27
27
  // Monorepo: several apps are linked to the same repository.
28
28
  if (status === 400) {
29
29
  throw new Error(serverMessage ||
30
- "This repository has multiple linked apps. Specify which one with `faable deploy <app_id>`.", { cause: err });
30
+ "This repository has multiple linked apps. Specify which one with `faable deploy --app <app_id>`.", { cause: err });
31
31
  }
32
32
  throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`, { cause: err });
33
33
  }
@@ -0,0 +1,17 @@
1
+ import { log } from '../../log.js';
2
+
3
+ // `faable deploy` with nothing to deploy has to show what `deploy` CAN do —
4
+ // the same way `faable auth` lists its subcommands. yargs only hands the
5
+ // parser to a builder, never to a handler, so the `deploy` builder parks its
6
+ // level here for `launch` to print.
7
+ let deploy_level = null;
8
+ const capture_deploy_help = (yargs) => {
9
+ deploy_level = yargs;
10
+ return yargs;
11
+ };
12
+ const show_deploy_help = () => {
13
+ if (deploy_level)
14
+ deploy_level.showHelp(help => log.info(help));
15
+ };
16
+
17
+ export { capture_deploy_help, show_deploy_help };
@@ -1,10 +1,6 @@
1
- import prompts from 'prompts';
2
- import { requireApi } from '../../api/context.js';
3
- import { Configuration } from '../../lib/Configuration.js';
4
- import { log } from '../../log.js';
5
1
  import { link } from '../link/index.js';
6
2
  import { domains } from './domains/index.js';
7
- import { git_context } from './git_context.js';
3
+ import { capture_deploy_help } from './help.js';
8
4
  import { cancel } from './inspect/cancel.js';
9
5
  import { deployments } from './inspect/deployments.js';
10
6
  import { inspect } from './inspect/inspect.js';
@@ -14,198 +10,46 @@ import { open_app } from './inspect/open.js';
14
10
  import { redeploy } from './inspect/redeploy.js';
15
11
  import { status } from './inspect/status.js';
16
12
  import { trigger } from './inspect/trigger.js';
17
- import { propose_release } from './release_version.js';
18
- import { deploy_remote } from './remote/index.js';
19
- import { resolve_app_id } from './resolve_app_id.js';
13
+ import { launch } from './launch.js';
20
14
  import { secrets } from './secrets/index.js';
21
- import { is_superseded } from './superseded.js';
22
15
  import { waf } from './waf/index.js';
23
16
 
17
+ // `faable deploy` — the deploy product. A command group and nothing else: it
18
+ // holds no handler of its own, so every verb is a subcommand and the
19
+ // hierarchy has no exception to remember. Deploying is `launch`, registered
20
+ // as the group's default ($0) so `faable deploy` on its own still deploys the
21
+ // current directory — the shortcut is an alias, not a second code path.
22
+ //
23
+ // The group deliberately has no positional. An app id typed where a
24
+ // subcommand belongs (`faable deploy app_xxx secrets list`) used to be
25
+ // swallowed by an `[app_id]` positional and silently DEPLOYED instead of
26
+ // running the subcommand; .strictCommands() in src/index.ts now rejects it as
27
+ // an unknown command. Another app is targeted with --app on `launch` and on
28
+ // every subcommand alike.
24
29
  const deploy = {
25
- command: 'deploy [app_id]',
30
+ command: 'deploy',
26
31
  // Name the subcommand groups so `faable --help` makes them discoverable
27
32
  // without digging into `faable deploy --help`.
28
33
  describe: 'Deploy a faable app and manage it (secrets, domains, logs, deployments…)',
29
- builder: yargs => {
30
- // Product subcommands live under `deploy` (yargs matches them before the
31
- // app_id positional, so `faable deploy <app_id>` keeps working).
32
- return yargs
33
- .command(secrets)
34
- .command(domains)
35
- .command(waf)
36
- .command(logs)
37
- .command(status)
38
- .command(apps_list)
39
- .command(deployments)
40
- .command(inspect)
41
- .command(open_app)
42
- .command(trigger)
43
- .command(redeploy)
44
- .command(cancel)
45
- .command(link)
46
- .positional('app_id', {
47
- type: 'string',
48
- description: 'App Identifier'
49
- })
50
- .option('workdir', {
51
- alias: 'w',
52
- type: 'string',
53
- description: 'Working directory'
54
- })
55
- .option('release', {
56
- type: 'string',
57
- description: 'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
58
- })
59
- .option('yes', {
60
- alias: 'y',
61
- type: 'boolean',
62
- default: false,
63
- description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
64
- })
65
- .showHelpOnFail(false);
66
- },
67
- handler: async (args) => {
68
- const workdir = args.workdir || process.cwd();
69
- // Pass the explicit app target to the OIDC exchange so a monorepo (several
70
- // apps, one repo) can be disambiguated in CI.
71
- const ctx = await requireApi(args.app_id);
72
- const { api } = ctx;
73
- const config = Configuration.instance().deployConfig();
74
- const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
75
- const app = await api.getApp(app_id);
76
- // Guard against the `faable deploy <app_id> <subcommand>` typo: yargs
77
- // doesn't recognize an unmatched trailing token as the subcommand, so it
78
- // silently falls through to THIS handler and deploys `workdir` instead.
79
- // Only prompts in a real terminal — CI (non-TTY) keeps deploying
80
- // unattended exactly as before, so existing pipelines need no changes.
81
- if (!args.yes && process.stdout.isTTY) {
82
- const { confirm } = await prompts({
83
- type: 'toggle',
84
- name: 'confirm',
85
- message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
86
- initial: false,
87
- active: 'yes',
88
- inactive: 'no'
89
- });
90
- if (!confirm) {
91
- log.info('Cancelled.');
92
- return;
93
- }
94
- }
95
- // Monorepo Root Directory — single precedence rule everywhere
96
- // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
97
- // override, exceptional) > repo faable.json rootDir (the supported
98
- // user option) > repo root. Matches the remote builder.
99
- if (app.root_dir) {
100
- config.rootDir = app.root_dir;
101
- log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
102
- }
103
- else if (config.rootDir) {
104
- log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
105
- }
106
- // Capture the commit/ref/actor so the deployment records which commit
107
- // it came from and who pushed it (env in CI, git fallback locally).
108
- const git = await git_context({ workdir });
109
- // Propose the release version (--release > FAABLE_RELEASE > git tag).
110
- // Optional: when absent the platform injects no FAABLE_RELEASE and the
111
- // app falls back to its own version source.
112
- const proposed = await propose_release({ workdir, explicit: args.release });
113
- if (proposed) {
114
- log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
115
- }
116
- // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
117
- // CLI no longer builds — it uploads the source and the platform builds
118
- // server-side (framework detection, buildpacks, artifact/image output all
119
- // live in the builder). No Docker, no local fallback: a rejected admission
120
- // (build_mode=local opt-out, or the global kill-switch off) or a build
121
- // error throws and exits red.
122
- log.info(`🚀 Deploying "${app.name}" (${app.id})`);
123
- const deployment = await deploy_remote({
124
- api,
125
- app,
126
- git,
127
- release: proposed?.release,
128
- workdir
129
- });
130
- const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
131
- log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
132
- log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
133
- // Wait (up to 5 minutes) for the deployment to be promoted (live)
134
- const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
135
- const timeoutMs = 5 * 60 * 1000;
136
- const intervalMs = 5000;
137
- const start = Date.now();
138
- log.info(`⏳ Waiting for deployment to be promoted...`);
139
- let promoted = false;
140
- let failure = null;
141
- let superseded = null;
142
- // The pointer at wait-start is the PREVIOUS deployment (older than ours)
143
- // — only a pointer CHANGE to something that isn't us needs a look.
144
- const initialActiveId = app.status?.deployment;
145
- while (Date.now() - start < timeoutMs) {
146
- await wait(intervalMs);
147
- try {
148
- const current = await api.getApp(app.id);
149
- if (current.status?.deployment === deployment.id) {
150
- promoted = true;
151
- break;
152
- }
153
- // Superseded: the pointer moved to a deployment at least as new as
154
- // ours (twin workflows on the same push, or a rapid follow-up
155
- // deploy). It only moves forward — waiting longer can never succeed.
156
- const activeId = current.status?.deployment;
157
- if (activeId && activeId !== initialActiveId) {
158
- const active = await api.getDeployment(activeId).catch(() => null);
159
- if (active &&
160
- is_superseded(deployment, active)) {
161
- superseded = activeId;
162
- break;
163
- }
164
- }
165
- // Watch for a terminal runtime failure so we fail fast (and red)
166
- // instead of timing out green. The controller marks a crash-looping
167
- // deployment ERROR — with a reason (e.g. a missing module) — within
168
- // ~1min, long before this 5min promotion timeout.
169
- const dep = await api.getDeployment(deployment.id);
170
- const phase = dep.status?.phase;
171
- if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
172
- failure = { phase, reason: dep.status?.reason };
173
- break;
174
- }
175
- // CANCELED: the platform superseded this build before it produced a
176
- // runnable (a sibling deployment of the app won the promotion).
177
- if (phase === 'CANCELED') {
178
- superseded = dep.status?.reason ?? 'canceled';
179
- break;
180
- }
181
- }
182
- catch (_error) {
183
- // Ignore transient errors while polling and keep waiting
184
- log.debug(`Polling app status failed, retrying...`);
185
- }
186
- }
187
- if (promoted) {
188
- log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
189
- }
190
- else if (superseded) {
191
- // Not a failure: the app IS live, just on a sibling deployment (e.g.
192
- // duplicated workflows firing on the same push). Green exit — there is
193
- // nothing for the user to fix in THIS run.
194
- log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
195
- }
196
- else if (failure) {
197
- // Fail the command (non-zero exit → red GitHub Action) and surface the
198
- // reason the controller captured so the user sees WHY without digging.
199
- log.error(`❌ Deployment failed (${failure.phase}).`);
200
- if (failure.reason)
201
- log.error(failure.reason);
202
- log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
203
- throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
204
- }
205
- else {
206
- log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
207
- }
208
- }
34
+ builder: yargs => capture_deploy_help(yargs
35
+ .command(launch)
36
+ .command(secrets)
37
+ .command(domains)
38
+ .command(waf)
39
+ .command(logs)
40
+ .command(status)
41
+ .command(apps_list)
42
+ .command(deployments)
43
+ .command(inspect)
44
+ .command(open_app)
45
+ .command(trigger)
46
+ .command(redeploy)
47
+ .command(cancel)
48
+ .command(link)
49
+ .showHelpOnFail(false)),
50
+ // Unreachable: `launch` is the default command, so yargs routes a bare
51
+ // `faable deploy` there before it ever gets here.
52
+ handler: () => { }
209
53
  };
210
54
 
211
55
  export { deploy };
@@ -0,0 +1,194 @@
1
+ import prompts from 'prompts';
2
+ import { requireApi } from '../../api/context.js';
3
+ import { Configuration } from '../../lib/Configuration.js';
4
+ import { log } from '../../log.js';
5
+ import { git_context } from './git_context.js';
6
+ import { show_deploy_help } from './help.js';
7
+ import { propose_release } from './release_version.js';
8
+ import { deploy_remote } from './remote/index.js';
9
+ import { find_app_id } from './resolve_app_id.js';
10
+ import { is_superseded } from './superseded.js';
11
+
12
+ // `faable deploy launch` — the deploy itself, and the default command of the
13
+ // `deploy` group, so a bare `faable deploy` runs exactly this. Every verb
14
+ // under `deploy` is a subcommand now, this one included: the hierarchy holds
15
+ // with no special case, and nothing can fall through to a deploy by accident
16
+ // (that is how `faable deploy <app_id> secrets list` used to deploy).
17
+ const launch = {
18
+ command: ['launch', '$0'],
19
+ describe: 'Deploy the current directory (the default: `faable deploy`)',
20
+ builder: yargs => yargs
21
+ .option('app', {
22
+ alias: 'a',
23
+ type: 'string',
24
+ description: 'App Identifier (defaults to the app linked to this repository)'
25
+ })
26
+ .option('workdir', {
27
+ alias: 'w',
28
+ type: 'string',
29
+ description: 'Working directory'
30
+ })
31
+ .option('release', {
32
+ type: 'string',
33
+ description: 'Release version to record on the deployment (injected as FAABLE_RELEASE). Defaults to FAABLE_RELEASE env or the latest git tag'
34
+ })
35
+ .option('yes', {
36
+ alias: 'y',
37
+ type: 'boolean',
38
+ default: false,
39
+ description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
40
+ })
41
+ .example('$0 deploy', 'Deploy the app linked to the current directory')
42
+ .example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
43
+ .showHelpOnFail(false),
44
+ handler: async (args) => {
45
+ const workdir = args.workdir || process.cwd();
46
+ // Pass the explicit app target to the OIDC exchange so a monorepo (several
47
+ // apps, one repo) can be disambiguated in CI.
48
+ const ctx = await requireApi(args.app);
49
+ const { api } = ctx;
50
+ const config = Configuration.instance().deployConfig();
51
+ const app_id = await find_app_id(args.app, ctx.appId, api, workdir);
52
+ // Nothing to deploy from here. `faable deploy` is the entry point to the
53
+ // whole product, so an unlinked directory is a perfectly normal way to
54
+ // arrive: list what deploy can do (like `faable auth` does) instead of
55
+ // answering a bare `faable deploy` with an error and nothing else.
56
+ if (!app_id) {
57
+ show_deploy_help();
58
+ log.error(`❌ No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
59
+ process.exit(1);
60
+ }
61
+ const app = await api.getApp(app_id);
62
+ // Confirm which app/dir is about to go out. Only in a real terminal — CI
63
+ // (non-TTY) deploys unattended, so pipelines need no changes.
64
+ if (!args.yes && process.stdout.isTTY) {
65
+ const { confirm } = await prompts({
66
+ type: 'toggle',
67
+ name: 'confirm',
68
+ message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
69
+ initial: false,
70
+ active: 'yes',
71
+ inactive: 'no'
72
+ });
73
+ if (!confirm) {
74
+ log.info('Cancelled.');
75
+ return;
76
+ }
77
+ }
78
+ // Monorepo Root Directory — single precedence rule everywhere
79
+ // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
80
+ // override, exceptional) > repo faable.json rootDir (the supported
81
+ // user option) > repo root. Matches the remote builder.
82
+ if (app.root_dir) {
83
+ config.rootDir = app.root_dir;
84
+ log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
85
+ }
86
+ else if (config.rootDir) {
87
+ log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
88
+ }
89
+ // Capture the commit/ref/actor so the deployment records which commit
90
+ // it came from and who pushed it (env in CI, git fallback locally).
91
+ const git = await git_context({ workdir });
92
+ // Propose the release version (--release > FAABLE_RELEASE > git tag).
93
+ // Optional: when absent the platform injects no FAABLE_RELEASE and the
94
+ // app falls back to its own version source.
95
+ const proposed = await propose_release({ workdir, explicit: args.release });
96
+ if (proposed) {
97
+ log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
98
+ }
99
+ // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
100
+ // CLI no longer builds — it uploads the source and the platform builds
101
+ // server-side (framework detection, buildpacks, artifact/image output all
102
+ // live in the builder). No Docker, no local fallback: a rejected admission
103
+ // (build_mode=local opt-out, or the global kill-switch off) or a build
104
+ // error throws and exits red.
105
+ log.info(`🚀 Deploying "${app.name}" (${app.id})`);
106
+ const deployment = await deploy_remote({
107
+ api,
108
+ app,
109
+ git,
110
+ release: proposed?.release,
111
+ workdir
112
+ });
113
+ const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
114
+ log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
115
+ log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
116
+ // Wait (up to 5 minutes) for the deployment to be promoted (live)
117
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
118
+ const timeoutMs = 5 * 60 * 1000;
119
+ const intervalMs = 5000;
120
+ const start = Date.now();
121
+ log.info(`⏳ Waiting for deployment to be promoted...`);
122
+ let promoted = false;
123
+ let failure = null;
124
+ let superseded = null;
125
+ // The pointer at wait-start is the PREVIOUS deployment (older than ours)
126
+ // — only a pointer CHANGE to something that isn't us needs a look.
127
+ const initialActiveId = app.status?.deployment;
128
+ while (Date.now() - start < timeoutMs) {
129
+ await wait(intervalMs);
130
+ try {
131
+ const current = await api.getApp(app.id);
132
+ if (current.status?.deployment === deployment.id) {
133
+ promoted = true;
134
+ break;
135
+ }
136
+ // Superseded: the pointer moved to a deployment at least as new as
137
+ // ours (twin workflows on the same push, or a rapid follow-up
138
+ // deploy). It only moves forward — waiting longer can never succeed.
139
+ const activeId = current.status?.deployment;
140
+ if (activeId && activeId !== initialActiveId) {
141
+ const active = await api.getDeployment(activeId).catch(() => null);
142
+ if (active &&
143
+ is_superseded(deployment, active)) {
144
+ superseded = activeId;
145
+ break;
146
+ }
147
+ }
148
+ // Watch for a terminal runtime failure so we fail fast (and red)
149
+ // instead of timing out green. The controller marks a crash-looping
150
+ // deployment ERROR — with a reason (e.g. a missing module) — within
151
+ // ~1min, long before this 5min promotion timeout.
152
+ const dep = await api.getDeployment(deployment.id);
153
+ const phase = dep.status?.phase;
154
+ if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
155
+ failure = { phase, reason: dep.status?.reason };
156
+ break;
157
+ }
158
+ // CANCELED: the platform superseded this build before it produced a
159
+ // runnable (a sibling deployment of the app won the promotion).
160
+ if (phase === 'CANCELED') {
161
+ superseded = dep.status?.reason ?? 'canceled';
162
+ break;
163
+ }
164
+ }
165
+ catch (_error) {
166
+ // Ignore transient errors while polling and keep waiting
167
+ log.debug(`Polling app status failed, retrying...`);
168
+ }
169
+ }
170
+ if (promoted) {
171
+ log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
172
+ }
173
+ else if (superseded) {
174
+ // Not a failure: the app IS live, just on a sibling deployment (e.g.
175
+ // duplicated workflows firing on the same push). Green exit — there is
176
+ // nothing for the user to fix in THIS run.
177
+ log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
178
+ }
179
+ else if (failure) {
180
+ // Fail the command (non-zero exit → red GitHub Action) and surface the
181
+ // reason the controller captured so the user sees WHY without digging.
182
+ log.error(`❌ Deployment failed (${failure.phase}).`);
183
+ if (failure.reason)
184
+ log.error(failure.reason);
185
+ log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
186
+ throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
187
+ }
188
+ else {
189
+ log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
190
+ }
191
+ }
192
+ };
193
+
194
+ export { launch };
@@ -3,14 +3,19 @@ import { getGitRemoteUrl } from '../../lib/git_remote.js';
3
3
  import { log } from '../../log.js';
4
4
 
5
5
  // app_id resolution (the user never has to look one up):
6
- // 1. explicit (positional on `deploy`, --app on subcommands)
6
+ // 1. explicit (--app, on `deploy` and on every subcommand)
7
7
  // 2. OIDC in CI — the backend resolves the app from the linked repository
8
8
  // 3. locally — a legacy app_id in faable.json (older CLIs wrote it on
9
9
  // `faable deploy link`; the current link only persists in the API)
10
10
  // 4. locally — the app whose linked repository matches the git origin remote
11
11
  // of the working directory (repos are connected in the dashboard when the
12
12
  // app is created, or via `faable deploy link`)
13
- const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
13
+ //
14
+ // "No app here" is an answer, not an error — a bare `faable deploy` in an
15
+ // unlinked directory lists the subcommands instead of failing, and only the
16
+ // caller knows which of the two it wants. An AMBIGUOUS repository does throw:
17
+ // several apps is a question only the user can answer.
18
+ const find_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
14
19
  const app_id = explicit || ctxAppId || Configuration.instance().app_id;
15
20
  if (app_id)
16
21
  return app_id;
@@ -25,10 +30,17 @@ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd())
25
30
  }
26
31
  if (matches.length > 1) {
27
32
  const ids = matches.map(app => `${app.name} (${app.id})`).join(', ');
28
- throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass the app explicitly.`);
33
+ throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass one with --app <app_id>.`);
29
34
  }
30
35
  }
31
- throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass the app explicitly.');
36
+ return null;
37
+ };
38
+ // The same, for callers that have nothing to offer without an app.
39
+ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
40
+ const app_id = await find_app_id(explicit, ctxAppId, api, workdir);
41
+ if (app_id)
42
+ return app_id;
43
+ throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass one with --app <app_id>.');
32
44
  };
33
45
 
34
- export { resolve_app_id };
46
+ export { find_app_id, resolve_app_id };
package/dist/index.js CHANGED
@@ -72,6 +72,14 @@ yg.scriptName('faable')
72
72
  // help, then fail red — a bad invocation must not exit 0.
73
73
  yg.showHelp();
74
74
  log.error(`❌ ${msg}`);
75
+ // The one unknown command worth naming: an app id passed positionally.
76
+ // `faable deploy <app_id> secrets list` used to deploy the working
77
+ // directory instead of listing secrets, so the id now lives in a flag —
78
+ // say so, or the migration reads as the CLI having lost a feature.
79
+ const stray = /Unknown commands?: (app_[A-Za-z0-9_-]+)/.exec(msg);
80
+ if (stray) {
81
+ log.error(`The app id goes in a flag, not as an argument: faable deploy --app ${stray[1]} [subcommand]`);
82
+ }
75
83
  process.exit(1);
76
84
  }
77
85
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "2.2.0",
3
+ "version": "3.1.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",