@faable/faable 4.10.1 → 4.12.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
@@ -87,6 +87,9 @@ faable login
87
87
  faable deploy # deploy the current directory (= deploy launch)
88
88
  faable deploy launch --app app_x # deploy another app, from anywhere
89
89
  faable deploy apps create --repo acme/web # create, link, first deploy
90
+ faable deploy --create site --workdir ./public --yes # a new app from a folder (git or not)
91
+ faable deploy link --app web --repo acme/web # link a repository to an existing app
92
+ faable deploy apps rm --app app_x # delete an app (asks first; --yes to skip)
90
93
  faable deploy status # phase, URL and detected stack
91
94
  faable deploy logs --build -n 100 # end of the build output (--follow to tail)
92
95
  faable deploy traffic --since 7d # status codes, top and failing paths
package/dist/api/auth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FaableAuthApi, authBearer } from '@faable/auth-sdk';
2
- import os from 'os';
2
+ import os__default from 'os';
3
3
  import { CLI_CLIENT } from './client_id.js';
4
4
 
5
5
  // The CLI talks to the Faable tenant's auth server through the same SDK the
@@ -40,8 +40,8 @@ const PLATFORM_LABELS = {
40
40
  // Human-friendly device name shown on the auth confirm page so the user can
41
41
  // recognise which device they are authorising, e.g. "marcs-mbp (macOS)".
42
42
  function deviceName() {
43
- const platform = PLATFORM_LABELS[os.platform()] || os.platform();
44
- return `${os.hostname()} (${platform})`;
43
+ const platform = PLATFORM_LABELS[os__default.platform()] || os__default.platform();
44
+ return `${os__default.hostname()} (${platform})`;
45
45
  }
46
46
  async function getDeviceCode() {
47
47
  return anonymous.fetcher.post(`/oauth/device/code`, {
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs-extra';
2
- import os from 'os';
2
+ import os__default from 'os';
3
3
  import path__default from 'path';
4
4
  import { ContextStore } from '../lib/ContextStore.js';
5
5
  import { CredentialsStore } from '../lib/CredentialsStore.js';
@@ -139,7 +139,7 @@ const subject = (token) => {
139
139
  };
140
140
  class TenantTokenCache {
141
141
  file;
142
- constructor(file = path__default.join(os.homedir(), '.faable', 'tenant-tokens.json')) {
142
+ constructor(file = path__default.join(os__default.homedir(), '.faable', 'tenant-tokens.json')) {
143
143
  this.file = file;
144
144
  }
145
145
  async read() {
@@ -1,5 +1,6 @@
1
1
  import { apps_list } from '../inspect/list.js';
2
2
  import { apps_create } from './create.js';
3
+ import { apps_rm } from './rm.js';
3
4
  import { apps_set } from './set.js';
4
5
  import { status } from '../inspect/status.js';
5
6
 
@@ -13,7 +14,8 @@ const apps = {
13
14
  .command(apps_get)
14
15
  .command(apps_create)
15
16
  .command(apps_set)
16
- .demandCommand(1, 'Specify an apps command: list, get, create or set'),
17
+ .command(apps_rm)
18
+ .demandCommand(1, 'Specify an apps command: list, get, create, set or rm'),
17
19
  handler: () => {
18
20
  // Unreachable: demandCommand(1) either routes to a subcommand or fails
19
21
  // through the global .fail() in src/index.ts.
@@ -0,0 +1,21 @@
1
+ import { CliError } from '../../../lib/errors.js';
2
+ import { fetch_page } from '../../../lib/listing.js';
3
+
4
+ // Before creating an app: is the name free in the project? The api accepts a
5
+ // duplicate, but a second "web" in a project is never what someone retrying
6
+ // meant — it is the app from the last attempt, and from then on `--app web`
7
+ // is ambiguous (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F2). The error
8
+ // names the existing app, so the next step is to deploy to it.
9
+ const assert_name_free = async (api, project, name) => {
10
+ const apps = await fetch_page(p => api.listApps(p, project), {
11
+ limit: 200,
12
+ all: true
13
+ });
14
+ const wanted = name.toLowerCase();
15
+ const clash = apps.data.find(a => a.name.toLowerCase() === wanted);
16
+ if (clash) {
17
+ throw new CliError('name_taken', `${project} already has an app named "${clash.name}" (${clash.id}). Deploy to it with --app ${clash.id}, or choose another name.`);
18
+ }
19
+ };
20
+
21
+ export { assert_name_free };
@@ -0,0 +1,55 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { CliError } from '../../../lib/errors.js';
3
+ import { confirm } from '../../../lib/interactive.js';
4
+ import { print_json, json_option } from '../../../lib/listing.js';
5
+ import { log } from '../../../log.js';
6
+ import { resolve_app_ref } from '../resolve_app_id.js';
7
+
8
+ // `faable deploy apps rm` — delete an app, as the dashboard's «Delete app»
9
+ // does (same endpoint: the api applies the same holds and records who
10
+ // deleted it, `GET /app/:id/deletion`). Before this, a stray app — the empty
11
+ // one a failed `deploy --create` used to leave — could only go from the
12
+ // dashboard (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F5).
13
+ //
14
+ // The app is always named: deleting the one this checkout happens to be
15
+ // linked to is not something to infer.
16
+ const apps_rm = {
17
+ command: 'rm',
18
+ describe: 'Delete an app: its deployments, domains and secrets go with it',
19
+ builder: yargs => json_option(yargs)
20
+ .option('app', {
21
+ alias: 'a',
22
+ type: 'string',
23
+ description: 'App id, name or slug (always explicit)'
24
+ })
25
+ .option('yes', {
26
+ alias: 'y',
27
+ type: 'boolean',
28
+ default: false,
29
+ description: 'Skip the confirmation prompt'
30
+ })
31
+ .example('$0 deploy apps rm --app app_a1b2c3', 'Delete after confirmation')
32
+ .showHelpOnFail(false),
33
+ handler: async (args) => {
34
+ if (!args.app) {
35
+ throw new CliError('app_required', 'Say which app to delete: --app <id|name>. It is never taken from the current directory.');
36
+ }
37
+ const { api } = await requireApi();
38
+ const app = await api.getApp(await resolve_app_ref(api, args.app));
39
+ const go = await confirm({
40
+ message: `Delete ${app.name} (${app.id})? https://${app.url} stops being served, and its deployments, domains and secrets go with it.`,
41
+ yes: args.yes
42
+ });
43
+ if (!go) {
44
+ log.info('Cancelled.');
45
+ return;
46
+ }
47
+ await api.deleteApp(app.id, app.team);
48
+ if (args.json) {
49
+ return print_json({ deleted: true, id: app.id, name: app.name });
50
+ }
51
+ log.info(`🗑️ Deleted ${app.name} (${app.id}).`);
52
+ }
53
+ };
54
+
55
+ export { apps_rm };
@@ -8,7 +8,9 @@ import { requireProject } from '../../api/project.js';
8
8
  import { git_context } from './git_context.js';
9
9
  import { show_deploy_help } from './help.js';
10
10
  import { propose_release } from './release_version.js';
11
+ import { assert_name_free } from './apps/name_free.js';
11
12
  import { deploy_remote } from './remote/index.js';
13
+ import { collect_manifest } from './remote/manifest.js';
12
14
  import { find_app_id } from './resolve_app_id.js';
13
15
  import { is_superseded } from './superseded.js';
14
16
 
@@ -76,186 +78,213 @@ const launch = {
76
78
  const ctx = await requireApi(args.app?.startsWith('app_') ? args.app : undefined);
77
79
  const { api } = ctx;
78
80
  const config = Configuration.instance().deployConfig();
81
+ // stdout carries the JSON outcome: the build log streams to stderr then.
82
+ const log_stream = args.json || is_non_interactive() ? process.stderr : process.stdout;
79
83
  // --create: a directory with no app yet (the agent's «deploy this folder
80
84
  // and give me the URL»). The app is new, so nothing existing is touched.
81
- let created_id;
85
+ // Whatever can fail before the deploy runs first — the source, the name —
86
+ // and an app that never got a deployment is taken back: a retry must
87
+ // not leave one empty app per attempt (plan F2, backlog §365).
88
+ let created;
89
+ let manifest;
90
+ let deployment_started = false;
82
91
  if (args.create) {
83
92
  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})`);
93
+ manifest = await collect_manifest(workdir);
94
+ await assert_name_free(api, project, args.create);
95
+ const app = await api.createApp(project, { name: args.create });
96
+ created = { id: app.id, team: app.team };
97
+ log.info(`📦 Created ${app.name} (${app.id})`);
87
98
  }
88
- const app_id = created_id ?? (await find_app_id(args.app, ctx.appId, api, workdir));
89
- // Nothing to deploy from here. `faable deploy` is the entry point to the
90
- // whole product, so an unlinked directory is a perfectly normal way to
91
- // arrive: list what deploy can do (like `faable auth` does) instead of
92
- // answering a bare `faable deploy` with an error and nothing else.
93
- if (!app_id) {
94
- show_deploy_help();
95
- throw new CliError('app_required', `No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
96
- }
97
- const app = await api.getApp(app_id);
98
- // Confirm which app/dir is about to go out. Only in a real terminal — CI
99
- // (non-TTY) deploys unattended, so pipelines need no changes.
100
- if (process.stdout.isTTY &&
101
- !(await confirm({
102
- message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
103
- yes: args.yes
104
- }))) {
105
- log.info('Cancelled.');
106
- return;
107
- }
108
- // Monorepo Root Directory — single precedence rule everywhere
109
- // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
110
- // override, exceptional) > repo faable.json rootDir (the supported
111
- // user option) > repo root. Matches the remote builder.
112
- if (app.root_dir) {
113
- config.rootDir = app.root_dir;
114
- log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
115
- }
116
- else if (config.rootDir) {
117
- log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
118
- }
119
- // Capture the commit/ref/actor so the deployment records which commit
120
- // it came from and who pushed it (env in CI, git fallback locally).
121
- const git = await git_context({ workdir });
122
- // Propose the release version (--release > FAABLE_RELEASE > git tag).
123
- // Optional: when absent the platform injects no FAABLE_RELEASE and the
124
- // app falls back to its own version source.
125
- const proposed = await propose_release({ workdir, explicit: args.release });
126
- if (proposed) {
127
- log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
128
- }
129
- // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
130
- // CLI no longer builds — it uploads the source and the platform builds
131
- // server-side (framework detection, buildpacks, artifact/image output all
132
- // live in the builder). No Docker, no local fallback: a rejected admission
133
- // (build_mode=local opt-out, or the global kill-switch off) or a build
134
- // error throws and exits red.
135
- log.info(`🚀 Deploying "${app.name}" (${app.id})`);
136
- const deployment = await deploy_remote({
137
- api,
138
- app,
139
- git,
140
- release: proposed?.release,
141
- workdir
142
- });
143
- const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
144
- log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
145
- log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
146
- // Wait (up to 5 minutes) for the deployment to be promoted (live)
147
- const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
148
- const timeoutMs = 5 * 60 * 1000;
149
- const intervalMs = 5000;
150
- const start = Date.now();
151
- log.info(`⏳ Waiting for deployment to be promoted...`);
152
- let promoted = false;
153
- let failure = null;
154
- let superseded = null;
155
- // The pointer at wait-start is the PREVIOUS deployment (older than ours)
156
- // — only a pointer CHANGE to something that isn't us needs a look.
157
- const initialActiveId = app.status?.deployment;
158
- while (Date.now() - start < timeoutMs) {
159
- await wait(intervalMs);
160
- try {
161
- const current = await api.getApp(app.id);
162
- if (current.status?.deployment === deployment.id) {
163
- promoted = true;
164
- break;
99
+ const created_id = created?.id;
100
+ const take_back_created = async () => {
101
+ if (!created || deployment_started)
102
+ return;
103
+ await api.deleteApp(created.id, created.team).then(() => log.info(`🧹 Removed ${created.id}: it never got a deployment`), () => log.warn(`Could not remove ${created.id}, which never got a deployment — delete it from the dashboard.`));
104
+ };
105
+ try {
106
+ const app_id = created_id ?? (await find_app_id(args.app, ctx.appId, api, workdir));
107
+ // Nothing to deploy from here. `faable deploy` is the entry point to the
108
+ // whole product, so an unlinked directory is a perfectly normal way to
109
+ // arrive: list what deploy can do (like `faable auth` does) instead of
110
+ // answering a bare `faable deploy` with an error and nothing else.
111
+ if (!app_id) {
112
+ show_deploy_help();
113
+ throw new CliError('app_required', `No app linked to ${workdir}. Connect a repository from the dashboard (or run "faable deploy link"), or target one with --app <app_id>.`);
114
+ }
115
+ const app = await api.getApp(app_id);
116
+ // Confirm which app/dir is about to go out. Only in a real terminal — CI
117
+ // (non-TTY) deploys unattended, so pipelines need no changes.
118
+ if (process.stdout.isTTY &&
119
+ !(await confirm({
120
+ message: `Deploy "${app.name}" (${app.id}) from ${workdir}?`,
121
+ yes: args.yes
122
+ }))) {
123
+ await take_back_created();
124
+ log.info('Cancelled.');
125
+ return;
126
+ }
127
+ // Monorepo Root Directory — single precedence rule everywhere
128
+ // (arch/deploy/root-dir-faable-json.md): App.root_dir (platform
129
+ // override, exceptional) > repo faable.json rootDir (the supported
130
+ // user option) > repo root. Matches the remote builder.
131
+ if (app.root_dir) {
132
+ config.rootDir = app.root_dir;
133
+ log.info(`📁 Root directory: ${app.root_dir} (platform override)`);
134
+ }
135
+ else if (config.rootDir) {
136
+ log.info(`📁 Root directory: ${config.rootDir} (from faable.json)`);
137
+ }
138
+ // Capture the commit/ref/actor so the deployment records which commit
139
+ // it came from and who pushed it (env in CI, git fallback locally).
140
+ const git = await git_context({ workdir });
141
+ // Propose the release version (--release > FAABLE_RELEASE > git tag).
142
+ // Optional: when absent the platform injects no FAABLE_RELEASE and the
143
+ // app falls back to its own version source.
144
+ const proposed = await propose_release({ workdir, explicit: args.release });
145
+ if (proposed) {
146
+ log.info(`🏷️ Release: ${proposed.release} (from ${proposed.source})`);
147
+ }
148
+ // Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
149
+ // CLI no longer builds — it uploads the source and the platform builds
150
+ // server-side (framework detection, buildpacks, artifact/image output all
151
+ // live in the builder). No Docker, no local fallback: a rejected admission
152
+ // (build_mode=local opt-out, or the global kill-switch off) or a build
153
+ // error throws and exits red.
154
+ log.info(`🚀 Deploying "${app.name}" (${app.id})`);
155
+ const deployment = await deploy_remote({
156
+ api,
157
+ app,
158
+ git,
159
+ release: proposed?.release,
160
+ workdir,
161
+ manifest,
162
+ log_stream,
163
+ on_deployment: () => {
164
+ deployment_started = true;
165
165
  }
166
- // Superseded: the pointer moved to a deployment at least as new as
167
- // ours (twin workflows on the same push, or a rapid follow-up
168
- // deploy). It only moves forward — waiting longer can never succeed.
169
- const activeId = current.status?.deployment;
170
- if (activeId && activeId !== initialActiveId) {
171
- const active = await api.getDeployment(activeId).catch(() => null);
172
- if (active &&
173
- is_superseded(deployment, active)) {
174
- superseded = activeId;
166
+ });
167
+ const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
168
+ log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
169
+ log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
170
+ // Wait (up to 5 minutes) for the deployment to be promoted (live)
171
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
172
+ const timeoutMs = 5 * 60 * 1000;
173
+ const intervalMs = 5000;
174
+ const start = Date.now();
175
+ log.info(`⏳ Waiting for deployment to be promoted...`);
176
+ let promoted = false;
177
+ let failure = null;
178
+ let superseded = null;
179
+ // The pointer at wait-start is the PREVIOUS deployment (older than ours)
180
+ // — only a pointer CHANGE to something that isn't us needs a look.
181
+ const initialActiveId = app.status?.deployment;
182
+ while (Date.now() - start < timeoutMs) {
183
+ await wait(intervalMs);
184
+ try {
185
+ const current = await api.getApp(app.id);
186
+ if (current.status?.deployment === deployment.id) {
187
+ promoted = true;
188
+ break;
189
+ }
190
+ // Superseded: the pointer moved to a deployment at least as new as
191
+ // ours (twin workflows on the same push, or a rapid follow-up
192
+ // deploy). It only moves forward — waiting longer can never succeed.
193
+ const activeId = current.status?.deployment;
194
+ if (activeId && activeId !== initialActiveId) {
195
+ const active = await api.getDeployment(activeId).catch(() => null);
196
+ if (active &&
197
+ is_superseded(deployment, active)) {
198
+ superseded = activeId;
199
+ break;
200
+ }
201
+ }
202
+ // Watch for a terminal runtime failure so we fail fast (and red)
203
+ // instead of timing out green. The controller marks a crash-looping
204
+ // deployment ERROR — with a reason (e.g. a missing module) — within
205
+ // ~1min, long before this 5min promotion timeout.
206
+ const dep = await api.getDeployment(deployment.id);
207
+ const phase = dep.status?.phase;
208
+ if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
209
+ failure = { phase, reason: dep.status?.reason };
210
+ break;
211
+ }
212
+ // CANCELED: the platform superseded this build before it produced a
213
+ // runnable (a sibling deployment of the app won the promotion).
214
+ if (phase === 'CANCELED') {
215
+ superseded = dep.status?.reason ?? 'canceled';
175
216
  break;
176
217
  }
177
218
  }
178
- // Watch for a terminal runtime failure so we fail fast (and red)
179
- // instead of timing out green. The controller marks a crash-looping
180
- // deployment ERROR — with a reason (e.g. a missing module) — within
181
- // ~1min, long before this 5min promotion timeout.
182
- const dep = await api.getDeployment(deployment.id);
183
- const phase = dep.status?.phase;
184
- if (phase === 'ERROR' || phase === 'BUILD_ERROR') {
185
- failure = { phase, reason: dep.status?.reason };
186
- break;
219
+ catch (_error) {
220
+ // Ignore transient errors while polling and keep waiting
221
+ log.debug(`Polling app status failed, retrying...`);
187
222
  }
188
- // CANCELED: the platform superseded this build before it produced a
189
- // runnable (a sibling deployment of the app won the promotion).
190
- if (phase === 'CANCELED') {
191
- superseded = dep.status?.reason ?? 'canceled';
192
- break;
223
+ }
224
+ if (args.json) {
225
+ const outcome = promoted
226
+ ? 'live'
227
+ : superseded
228
+ ? 'superseded'
229
+ : failure
230
+ ? 'failed'
231
+ : 'timeout';
232
+ const fault = failure
233
+ ? await api.getDeploymentFault(deployment.id).catch(() => null)
234
+ : null;
235
+ const result = {
236
+ app_id: app.id,
237
+ app_name: app.name,
238
+ ...(created_id ? { created: true } : {}),
239
+ deployment_id: deployment.id,
240
+ outcome,
241
+ url: `https://${app.url}`,
242
+ ...(failure
243
+ ? {
244
+ phase: failure.phase,
245
+ reason: failure.reason,
246
+ ...(fault
247
+ ? { fault_owner: fault.fault_owner, error_code: fault.error_code }
248
+ : {})
249
+ }
250
+ : {}),
251
+ dashboard_url
252
+ };
253
+ if (failure) {
254
+ // A failed deploy still exits red (CI depends on it); the outcome
255
+ // travels in the error so a program reads it without parsing prose.
256
+ 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) });
257
+ // The api's own code when it classified the failure.
258
+ err.code = fault?.error_code ?? 'deploy_failed';
259
+ throw err;
193
260
  }
261
+ return print_json(result);
194
262
  }
195
- catch (_error) {
196
- // Ignore transient errors while polling and keep waiting
197
- log.debug(`Polling app status failed, retrying...`);
263
+ if (promoted) {
264
+ log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
198
265
  }
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;
266
+ else if (superseded) {
267
+ // Not a failure: the app IS live, just on a sibling deployment (e.g.
268
+ // duplicated workflows firing on the same push). Green exit — there is
269
+ // nothing for the user to fix in THIS run.
270
+ log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
271
+ }
272
+ else if (failure) {
273
+ // Fail the command (non-zero exit → red GitHub Action) and surface the
274
+ // reason the controller captured so the user sees WHY without digging.
275
+ log.error(`❌ Deployment failed (${failure.phase}).`);
276
+ if (failure.reason)
277
+ log.error(failure.reason);
278
+ log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
279
+ throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
280
+ }
281
+ else {
282
+ log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
236
283
  }
237
- return print_json(result);
238
- }
239
- if (promoted) {
240
- log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
241
- }
242
- else if (superseded) {
243
- // Not a failure: the app IS live, just on a sibling deployment (e.g.
244
- // duplicated workflows firing on the same push). Green exit — there is
245
- // nothing for the user to fix in THIS run.
246
- log.warn(`⚠️ Deployment superseded — a newer deployment of this app was promoted (${superseded}). The app is live: https://${app.url}`);
247
- }
248
- else if (failure) {
249
- // Fail the command (non-zero exit → red GitHub Action) and surface the
250
- // reason the controller captured so the user sees WHY without digging.
251
- log.error(`❌ Deployment failed (${failure.phase}).`);
252
- if (failure.reason)
253
- log.error(failure.reason);
254
- log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
255
- throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
256
284
  }
257
- else {
258
- log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
285
+ catch (error) {
286
+ await take_back_created();
287
+ throw error;
259
288
  }
260
289
  }
261
290
  };
@@ -12,7 +12,12 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
12
12
  * (image landed — the regular promotion poll takes over); throws on
13
13
  * BUILD_ERROR/ERROR with the server-provided reason.
14
14
  */
15
- const follow_remote_build = async (api, deployment_id) => {
15
+ const follow_remote_build = async (api, deployment_id,
16
+ // Where the build log goes. stdout for a person; stderr when stdout carries
17
+ // a JSON document (--json, or a program driving the CLI): one build line in
18
+ // front of it and the caller can't parse the outcome — the MCP reported a
19
+ // live deploy as failed that way (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F1).
20
+ out = process.stdout) => {
16
21
  let printed = 0;
17
22
  let last_phase = "";
18
23
  const started = Date.now();
@@ -21,7 +26,7 @@ const follow_remote_build = async (api, deployment_id) => {
21
26
  const logs = await api.getDeploymentLogs(deployment_id);
22
27
  const content = logs?.content ?? "";
23
28
  if (content.length > printed) {
24
- process.stdout.write(content.slice(printed));
29
+ out.write(content.slice(printed));
25
30
  printed = content.length;
26
31
  }
27
32
  else if (content.length < printed) {
@@ -1,4 +1,5 @@
1
1
  import { log } from '../../../log.js';
2
+ import { format_bytes } from '../inspect/detail.js';
2
3
  import { follow_remote_build } from './follow.js';
3
4
  import { collect_manifest } from './manifest.js';
4
5
  import { upload_missing_blobs } from './upload.js';
@@ -17,8 +18,11 @@ import { upload_missing_blobs } from './upload.js';
17
18
  const deploy_remote = async (props) => {
18
19
  const { api, app, git, release, workdir } = props;
19
20
  log.info(`☁️ Remote build`);
20
- const manifest = await collect_manifest(workdir);
21
- log.info(`🗂️ ${manifest.length} files in the source manifest`);
21
+ const manifest = props.manifest ?? (await collect_manifest(workdir));
22
+ // The total is what the api weighs against the plan when the app's last
23
+ // build came out too large — say it before anything is uploaded.
24
+ const total = manifest.reduce((sum, f) => sum + f.size, 0);
25
+ log.info(`🗂️ ${manifest.length} files in the source manifest (${format_bytes(total)})`);
22
26
  await upload_missing_blobs(api, app.id, workdir, manifest);
23
27
  // No `plan`: the builder re-detects on the assembled tree and syncs the app's
24
28
  // runtime_strategy on completion.
@@ -28,8 +32,9 @@ const deploy_remote = async (props) => {
28
32
  ...(release ? { release } : {}),
29
33
  ...git,
30
34
  });
35
+ props.on_deployment?.(deployment.id);
31
36
  log.info(`Preparing to build in faable cloud · ${deployment.id}`);
32
- await follow_remote_build(api, deployment.id);
37
+ await follow_remote_build(api, deployment.id, props.log_stream);
33
38
  return deployment;
34
39
  };
35
40
 
@@ -1,7 +1,10 @@
1
+ import { execFile } from 'child_process';
1
2
  import { createHash } from 'crypto';
2
- import { lstatSync, readFileSync } from 'fs';
3
+ import { lstatSync, readFileSync, mkdtempSync, rmSync } from 'fs';
4
+ import * as os from 'os';
3
5
  import * as path from 'path';
4
6
  import { cmd } from '../../../lib/cmd.js';
7
+ import { log } from '../../../log.js';
5
8
 
6
9
  // Server-side admission caps (mirror DeploymentSource on the API); checking
7
10
  // here fails fast with a clearer message than a 400.
@@ -15,26 +18,24 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024;
15
18
  * them like a fresh clone would.
16
19
  *
17
20
  * Symlinks are rejected (no representation in the manifest by design — see
18
- * deploy-v2-remote-build.md) and a non-git directory is unsupported (the source
19
- * tree is collected via git).
21
+ * deploy-v2-remote-build.md).
22
+ *
23
+ * A folder that is not a repository (`faable deploy` on a plain directory, the
24
+ * MCP's deploy_directory) is collected by git too, through a throwaway index:
25
+ * see list_without_repo.
20
26
  */
21
27
  const collect_manifest = async (workdir) => {
22
- const inside = await cmd(`git rev-parse --is-inside-work-tree`, {
23
- cwd: workdir,
24
- }).catch(() => null);
25
- if (!inside || String(inside.stdout).trim() !== "true") {
26
- throw new Error("Deploys need a git repository (the source tree is collected via git). Initialize one with `git init` and commit your files.");
27
- }
28
+ const in_repo = await is_git_work_tree(workdir);
28
29
  // -c/-o: tracked + untracked; --exclude-standard: .gitignore/.git/info.
29
30
  // -z: NUL-separated (paths with spaces/UTF-8 survive).
30
- const out = await cmd(`git ls-files -co --exclude-standard -z`, {
31
- cwd: workdir,
32
- });
33
- const rel_paths = String(out.stdout)
34
- .split("\0")
35
- .filter((p) => p.length > 0);
31
+ const rel_paths = in_repo
32
+ ? split_z((await cmd(`git ls-files -co --exclude-standard -z`, { cwd: workdir }))
33
+ .stdout)
34
+ : await list_without_repo(workdir);
36
35
  if (rel_paths.length === 0) {
37
- throw new Error("No files to upload (empty git tree?)");
36
+ throw new Error(in_repo
37
+ ? "No files to upload (empty git tree?)"
38
+ : `No files to upload in ${workdir}.`);
38
39
  }
39
40
  if (rel_paths.length > MAX_FILES) {
40
41
  throw new Error(`Too many files for a remote build (${rel_paths.length} > ${MAX_FILES}).`);
@@ -73,5 +74,39 @@ const collect_manifest = async (workdir) => {
73
74
  }
74
75
  return manifest;
75
76
  };
77
+ // Outside a repository git exits 128 with `fatal: not a git repository`:
78
+ // that is the answer here, not an error to show — so not through cmd(), which
79
+ // logs a failing command's stderr and exit code.
80
+ const is_git_work_tree = (workdir) => new Promise((resolve) => {
81
+ execFile("git", ["rev-parse", "--is-inside-work-tree"], { cwd: workdir }, (err, stdout) => resolve(!err && String(stdout).trim() === "true"));
82
+ });
83
+ const split_z = (out) => String(out)
84
+ .split("\0")
85
+ .filter((p) => p.length > 0);
86
+ const shell_quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
87
+ // Out of an upload even when no .gitignore says so — a plain folder has none,
88
+ // and these are the files a repository would have ignored anyway.
89
+ const NO_REPO_EXCLUDES = ["node_modules/", ".env", ".env.*", ".DS_Store"];
90
+ /**
91
+ * The files of a folder that is not a git repository. Git still lists them:
92
+ * a temporary repository holds the index and the folder is its work tree, so
93
+ * every .gitignore inside counts exactly as it would in a clone, and nothing
94
+ * is written to the folder (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md, F3).
95
+ */
96
+ const list_without_repo = async (workdir) => {
97
+ const git_dir = mkdtempSync(path.join(os.tmpdir(), "faable-manifest-"));
98
+ try {
99
+ await cmd(`git init -q ${shell_quote(git_dir)}`).catch(() => {
100
+ throw new Error("Deploys collect the source with git, and git is not available. Install git and try again.");
101
+ });
102
+ log.info(`📂 ${workdir} is not a git repository: uploading the folder (its .gitignore files apply; ${NO_REPO_EXCLUDES.join(", ")} stay out)`);
103
+ const excludes = NO_REPO_EXCLUDES.map((p) => `-x ${shell_quote(p)}`).join(" ");
104
+ const out = await cmd(`git --git-dir=${shell_quote(path.join(git_dir, ".git"))} --work-tree=${shell_quote(workdir)} ls-files -o --exclude-standard ${excludes} -z`, { cwd: workdir });
105
+ return split_z(out.stdout);
106
+ }
107
+ finally {
108
+ rmSync(git_dir, { recursive: true, force: true });
109
+ }
110
+ };
76
111
 
77
- export { collect_manifest };
112
+ export { NO_REPO_EXCLUDES, collect_manifest };
@@ -2,6 +2,11 @@ import { requireApi } from '../../api/context.js';
2
2
  import prompts from 'prompts';
3
3
  import { log } from '../../log.js';
4
4
  import { getGitRemoteUrl } from '../../lib/git_remote.js';
5
+ import { CliError } from '../../lib/errors.js';
6
+ import { is_non_interactive } from '../../lib/interactive.js';
7
+ import { print_json } from '../../lib/listing.js';
8
+ import { parse_repo } from '../deploy/apps/create.js';
9
+ import { resolve_app_ref } from '../deploy/resolve_app_id.js';
5
10
 
6
11
  const DEPLOY_DOCS_URL = "https://faable.com/docs/deploy/github-actions";
7
12
  const link = {
@@ -14,9 +19,33 @@ const link = {
14
19
  type: "string",
15
20
  description: "Working directory",
16
21
  })
22
+ .option("app", {
23
+ alias: "a",
24
+ type: "string",
25
+ description: "App id, name or slug to link (no list to pick from: what a script or agent uses)",
26
+ })
27
+ .option("repo", {
28
+ alias: "r",
29
+ type: "string",
30
+ description: "GitHub repository, owner/repo or its URL (defaults to the origin remote of --workdir)",
31
+ })
32
+ .option("branch", {
33
+ alias: "b",
34
+ type: "string",
35
+ description: "Branch to deploy (defaults to the repository default)",
36
+ })
37
+ .option("json", {
38
+ type: "boolean",
39
+ default: false,
40
+ description: "Print the link as JSON: app, repository, branch and how it deploys",
41
+ })
42
+ .example("$0 deploy link --app web --repo acme/web", "Link acme/web to the app web: from now on a push deploys it")
17
43
  .showHelpOnFail(false);
18
44
  },
19
45
  handler: async (args) => {
46
+ if (args.app || args.json || is_non_interactive()) {
47
+ return link_to_app(args);
48
+ }
20
49
  const workdir = args.workdir || process.cwd();
21
50
  // `faable link <app_id>` is deprecated. Linking is now fully interactive and
22
51
  // the repository is auto-detected from the current folder — users never need
@@ -113,6 +142,54 @@ const link = {
113
142
  }
114
143
  },
115
144
  };
145
+ // `link --app`: the app is named, so nothing is asked. What a script or an
146
+ // agent runs — the picker above needs a person, and the MCP could not link a
147
+ // repository to an existing app at all (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md,
148
+ // F4; backlog §366). Driven by a program, the repository is spelled out too:
149
+ // the origin remote of whatever directory the process runs in is a guess.
150
+ const link_to_app = async (args) => {
151
+ if (!args.app) {
152
+ throw new CliError("app_required", "Say which app to link: --app <id|name>. Without a terminal there is no list to pick from.");
153
+ }
154
+ const repository = args.repo
155
+ ? parse_repo(args.repo)
156
+ : is_non_interactive()
157
+ ? undefined
158
+ : await getGitRemoteUrl(args.workdir || process.cwd());
159
+ if (!repository) {
160
+ throw new CliError("usage", is_non_interactive()
161
+ ? "Say which repository to link: --repo owner/repo."
162
+ : "No GitHub origin remote here: pass --repo owner/repo.");
163
+ }
164
+ const { api } = await requireApi();
165
+ const app = await api.getApp(await resolve_app_ref(api, args.app));
166
+ let linked;
167
+ try {
168
+ linked = await api.linkRepository(app.id, { repository, ...(args.branch ? { github_branch: args.branch } : {}) }, app.team);
169
+ }
170
+ catch (err) {
171
+ const e = err;
172
+ const failed = new CliError("usage", `Could not link ${repository} to ${app.name}: ${e.message}`, { status: e.status, action: e.action, cause: err });
173
+ // The api's reason (github_installation_missing,
174
+ // github_repository_not_found…) says what to fix.
175
+ if (e.code)
176
+ failed.code = e.code;
177
+ throw failed;
178
+ }
179
+ const result = {
180
+ app_id: app.id,
181
+ app_name: app.name,
182
+ repository: linked?.repository ?? repository,
183
+ branch: linked?.github_branch,
184
+ deploy_trigger: linked?.deploy_trigger,
185
+ };
186
+ if (args.json)
187
+ return print_json(result);
188
+ log.info(`Linked ${result.repository} to ${app.name} (${app.id}).`);
189
+ log.info(result.deploy_trigger === "webhook"
190
+ ? `Push to deploy is on: the next push to "${result.branch ?? "the default branch"}" deploys. What is live now stays until then.`
191
+ : `This repository has its own Faable deploy workflow — pushes keep deploying through it. Docs: ${DEPLOY_DOCS_URL}`);
192
+ };
116
193
  // Top-level `faable link` predates the per-product layout (`faable deploy link`).
117
194
  // Kept as a hidden alias so existing docs/scripts don't break; remove in a
118
195
  // future major.
@@ -1,10 +1,10 @@
1
1
  import fs from 'fs-extra';
2
- import os from 'os';
2
+ import os__default from 'os';
3
3
  import path__default from 'path';
4
4
 
5
5
  class ContextStore {
6
6
  faable_home;
7
- constructor(faable_home = path__default.join(os.homedir(), '.faable')) {
7
+ constructor(faable_home = path__default.join(os__default.homedir(), '.faable')) {
8
8
  this.faable_home = faable_home;
9
9
  }
10
10
  get path() {
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs-extra';
2
- import os from 'os';
2
+ import os__default from 'os';
3
3
  import path__default from 'path';
4
4
  import { log } from '../log.js';
5
5
 
@@ -8,7 +8,7 @@ class CredentialsStore {
8
8
  faable_home;
9
9
  constructor(log$1 = log) {
10
10
  this.log = log$1;
11
- this.faable_home = path__default.join(os.homedir(), '.faable');
11
+ this.faable_home = path__default.join(os__default.homedir(), '.faable');
12
12
  }
13
13
  async deleteCredentials() {
14
14
  if (fs.existsSync(this.credentials_path)) {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
2
  import fs from 'fs-extra';
3
- import os from 'os';
3
+ import os__default from 'os';
4
4
  import path__default from 'path';
5
5
  import { log } from '../log.js';
6
6
 
@@ -10,7 +10,7 @@ const PACKUMENT_URL = `https://registry.npmjs.org/${CLI_PACKAGE.replace("/", "%2
10
10
  // Hitting the registry at most once a day keeps the background refresh rare.
11
11
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
12
  const UPGRADE_FETCH_TIMEOUT_MS = 10_000;
13
- const cache_path = () => path__default.join(os.homedir(), ".faable", "update-check.json");
13
+ const cache_path = () => path__default.join(os__default.homedir(), ".faable", "update-check.json");
14
14
  // Local dev runs with the semantic-release placeholder version.
15
15
  const isDevBuild = (version) => version.startsWith("0.0.0");
16
16
  const isCacheStale = (cache, now) => {
@@ -34,6 +34,31 @@ const portable_exec_argv = (exec_argv = process.execArgv, resolve = resolve_load
34
34
  };
35
35
  // The last JSON line on stderr is the error document; anything before it is
36
36
  // the CLI's own log lines.
37
+ // The command's JSON document on stdout. Normally stdout IS the document; if
38
+ // a stray line got in front of it (a build log once did, and a deploy that
39
+ // went fine came back as an error — arch/deploy/cli-mcp-fallos-2026-10-16-plan.md,
40
+ // F1), the document is still the last thing printed: try each line that
41
+ // opens one at column 0, from the end, through to the end of the output.
42
+ const parse_output = (stdout) => {
43
+ try {
44
+ return { ok: true, data: JSON.parse(stdout) };
45
+ }
46
+ catch {
47
+ // look for the document after the stray lines
48
+ }
49
+ const lines = stdout.split('\n');
50
+ for (let i = lines.length - 1; i >= 0; i--) {
51
+ if (!/^[[{]/.test(lines[i]))
52
+ continue;
53
+ try {
54
+ return { ok: true, data: JSON.parse(lines.slice(i).join('\n')) };
55
+ }
56
+ catch {
57
+ // not where the document starts: keep going up
58
+ }
59
+ }
60
+ return { ok: false };
61
+ };
37
62
  const parse_error = (stderr, code) => {
38
63
  const lines = stderr.trim().split('\n').reverse();
39
64
  for (const line of lines) {
@@ -89,18 +114,16 @@ const run_cli = (args, opts = {}) => new Promise(resolve => {
89
114
  clearTimeout(timer);
90
115
  if (code !== 0)
91
116
  return resolve({ ok: false, error: parse_error(stderr, code) });
92
- try {
93
- resolve({ ok: true, data: JSON.parse(stdout) });
94
- }
95
- catch {
96
- resolve({
117
+ const parsed = parse_output(stdout);
118
+ resolve(parsed.ok
119
+ ? { ok: true, data: parsed.data }
120
+ : {
97
121
  ok: false,
98
122
  error: {
99
123
  message: `faable printed something that is not JSON: ${stdout.slice(0, 200)}`
100
124
  }
101
125
  });
102
- }
103
126
  });
104
127
  });
105
128
 
106
- export { portable_exec_argv, run_cli };
129
+ export { parse_output, portable_exec_argv, run_cli };
package/dist/mcp/tools.js CHANGED
@@ -826,10 +826,38 @@ const TOOLS = [
826
826
  mode: 'push'
827
827
  }
828
828
  },
829
+ {
830
+ name: 'link_repo',
831
+ title: 'Link a repository to an app',
832
+ description: "Link a GitHub repository to an app that already exists, so a push deploys it (for a new app, create_app does both). What is live keeps serving until the next push — linking starts no deploy. Needs the Faable GitHub App installed on the repository; if not, the error says what to fix.",
833
+ input: {
834
+ app,
835
+ repo: z.string().describe('GitHub repository, owner/repo'),
836
+ branch: z
837
+ .string()
838
+ .optional()
839
+ .describe('Branch to deploy (defaults to the repository default)'),
840
+ project
841
+ },
842
+ command: ['deploy', 'link'],
843
+ flags: a => [
844
+ '--repo',
845
+ a.repo,
846
+ ...opt('--branch', a.branch),
847
+ ...target(a)
848
+ ],
849
+ write: true,
850
+ annotations: {
851
+ readOnlyHint: false,
852
+ destructiveHint: false,
853
+ openWorldHint: true
854
+ },
855
+ example: { app: 'web', repo: 'acme/web', branch: 'main' }
856
+ },
829
857
  {
830
858
  name: 'deploy_directory',
831
859
  title: 'Deploy a local directory',
832
- description: "Upload a directory of this machine and deploy it (`faable deploy`): Faable builds it and serves it. Pass `app` to deploy to an existing app, or `create` to make a new one with that name. Waits until it is live and returns the URL — or why it failed and whether that is the user's code (`user`) or Faable (`platform`). Only on the local server (`npx @faable/faable mcp`). Prefer create_app/deploy_app when the code is on GitHub: a push then deploys on its own.",
860
+ description: "Upload a directory of this machine and deploy it (`faable deploy`): Faable builds it and serves it. Any folder works, a git repository or not — in a plain folder its .gitignore files apply, and node_modules and .env files stay out. Pass `app` to deploy to an existing app, or `create` to make a new one with that name: a name already used in the project is refused (deploy to that app instead), and if the deploy cannot start the new app is removed, so a retry never leaves an empty one. Waits until it is live and returns the URL — or why it failed and whether that is the user's code (`user`) or Faable (`platform`). Only on the local server (`npx @faable/faable mcp`). Prefer create_app/deploy_app when the code is on GitHub: a push then deploys on its own.",
833
861
  input: {
834
862
  workdir: z
835
863
  .string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.10.1",
3
+ "version": "4.12.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",