@faable/faable 1.16.0 → 1.18.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 (33) hide show
  1. package/dist/api/FaableApi.js +15 -0
  2. package/dist/commands/deploy/index.js +93 -54
  3. package/dist/commands/deploy/remote/follow.js +61 -0
  4. package/dist/commands/deploy/remote/index.js +48 -0
  5. package/dist/commands/deploy/remote/manifest.js +77 -0
  6. package/dist/commands/deploy/remote/upload.js +61 -0
  7. package/package.json +2 -1
  8. package/dist/commands/deploy/buildpacks/Buildpack.js +0 -11
  9. package/dist/commands/deploy/buildpacks/DetectError.js +0 -78
  10. package/dist/commands/deploy/buildpacks/docker/index.js +0 -42
  11. package/dist/commands/deploy/buildpacks/foreign_platforms.js +0 -21
  12. package/dist/commands/deploy/buildpacks/node/analyze_package.js +0 -35
  13. package/dist/commands/deploy/buildpacks/node/build_project.js +0 -21
  14. package/dist/commands/deploy/buildpacks/node/ensure_dependencies.js +0 -78
  15. package/dist/commands/deploy/buildpacks/node/frameworks.js +0 -108
  16. package/dist/commands/deploy/buildpacks/node/index.js +0 -73
  17. package/dist/commands/deploy/buildpacks/node/inject_serve.js +0 -20
  18. package/dist/commands/deploy/buildpacks/node/node_version.js +0 -41
  19. package/dist/commands/deploy/buildpacks/python/index.js +0 -127
  20. package/dist/commands/deploy/buildpacks/python/parse_procfile.js +0 -23
  21. package/dist/commands/deploy/buildpacks/python/providers/cerebrium.js +0 -43
  22. package/dist/commands/deploy/buildpacks/python/providers/parse_cerebrium_toml.js +0 -56
  23. package/dist/commands/deploy/buildpacks/python/providers/pipfile.js +0 -13
  24. package/dist/commands/deploy/buildpacks/python/providers/pyproject.js +0 -13
  25. package/dist/commands/deploy/buildpacks/python/providers/requirements.js +0 -14
  26. package/dist/commands/deploy/buildpacks/python/python_version.js +0 -41
  27. package/dist/commands/deploy/buildpacks/python/resolve_start.js +0 -149
  28. package/dist/commands/deploy/buildpacks/registry.js +0 -61
  29. package/dist/commands/deploy/buildpacks/shared/docker_image.js +0 -49
  30. package/dist/commands/deploy/buildpacks/shared/has_any_of_files.js +0 -14
  31. package/dist/commands/deploy/buildpacks/shared/read_text_file.js +0 -31
  32. package/dist/commands/deploy/buildpacks/shared/templates/Dockerfile +0 -38
  33. package/dist/commands/deploy/buildpacks/shared/templates/entrypoint.sh +0 -4
@@ -76,9 +76,24 @@ class FaableApi {
76
76
  // `image`/`type` are optional to support the failure path: a failed build
77
77
  // is recorded as a deployment without an image (and without `type`, which
78
78
  // would otherwise rewrite the app's runtime_strategy server-side).
79
+ // `source` is the remote-build path (v2): content-addressed manifest +
80
+ // serialized BuildPlan; the platform builds and completes the image.
79
81
  async createDeployment(params) {
80
82
  return data(this.client.post(`/deployment`, params));
81
83
  }
84
+ // Remote builds: diff the source manifest against the CAS. Returns
85
+ // presigned PUTs (sha-pinned by signature) for the missing blobs only.
86
+ async uploadMissing(app_id, files) {
87
+ return data(this.client.post(`/upload/missing`, {
88
+ app_id,
89
+ files: files.map(({ path, sha, size }) => ({ path, sha, size })),
90
+ }, { timeout: 60_000 }));
91
+ }
92
+ // Remote builds: read the build output the builder attaches to the
93
+ // deployment (same endpoint the CLI writes to in local builds).
94
+ async getDeploymentLogs(deployment_id) {
95
+ return data(this.client.get(`/deployment/${deployment_id}/logs`));
96
+ }
82
97
  // Phase transitions the CLI owns (BUILDING when the build starts,
83
98
  // BUILD_ERROR on a failed build). Runtime phases stay controller-territory.
84
99
  async updateDeploymentStatus(deployment_id, status) {
@@ -2,10 +2,11 @@ import { requireApi } from '../../api/context.js';
2
2
  import { Configuration } from '../../lib/Configuration.js';
3
3
  import { log } from '../../log.js';
4
4
  import { link } from '../link/index.js';
5
- import { plan_summary } from './buildpacks/Buildpack.js';
6
- import { detect_buildpack, get_buildpack, buildpack_names } from './buildpacks/registry.js';
5
+ import { configure_buildpacks, detect_buildpack, plan_summary, get_buildpack, buildpack_names } from '@faabletools/buildpacks';
6
+ import { cmd } from '../../lib/cmd.js';
7
7
  import { check_environment } from './check_environment.js';
8
8
  import { git_context } from './git_context.js';
9
+ import { deploy_remote } from './remote/index.js';
9
10
  import { resolve_app_id } from './resolve_app_id.js';
10
11
  import { secrets } from './secrets/index.js';
11
12
  import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs.js';
@@ -35,10 +36,23 @@ const deploy = {
35
36
  choices: buildpack_names(),
36
37
  description: 'Force a specific buildpack (overrides auto-detection and faable.json)'
37
38
  })
39
+ .option('remote', {
40
+ type: 'boolean',
41
+ description: 'Build server-side (remote build), regardless of the app build_mode'
42
+ })
43
+ .option('local', {
44
+ type: 'boolean',
45
+ description: 'Build locally with Docker, even if the app is set to remote builds'
46
+ })
47
+ .conflicts('remote', 'local')
38
48
  .showHelpOnFail(false);
39
49
  },
40
50
  handler: async (args) => {
41
51
  const workdir = args.workdir || process.cwd();
52
+ // Wire the shared buildpacks package to the CLI's sinks: pino (which tees
53
+ // into the build-log buffer) and cmd() (which captures subprocess output
54
+ // into the same buffer). Must happen before any detect/build call.
55
+ configure_buildpacks({ log, exec: cmd });
42
56
  const ctx = await requireApi();
43
57
  const { api } = ctx;
44
58
  const config = Configuration.instance().deployConfig();
@@ -73,62 +87,87 @@ const deploy = {
73
87
  }
74
88
  throw error;
75
89
  }
76
- // Create-first: register the deployment BEFORE building. Gate rejections
77
- // (free-plan quota 429, disabled app 409) surface here, at second 0 —
78
- // before any build minutes are spent. The row is born QUEUED; the CLI
79
- // owns it until the built image lands (or the build fails). `type` rides
80
- // on the create — the buildpack plan is already resolved.
81
- const deployment = await api.createDeployment({
82
- app_id: app.id,
83
- type: plan.type,
84
- ...git
85
- });
86
- try {
87
- // Check if we can build docker images
88
- await check_environment();
89
- const runtime_label = plan.runtime.version
90
- ? `${plan.runtime.name}-${plan.runtime.version}`
91
- : plan.runtime.name;
92
- log.info(`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
93
- log.info(`🧩 Build plan ${plan_summary(plan)}`);
94
- // get environment variables
95
- const env_vars = await api.getAppSecrets(app.id);
96
- const buildpack = get_buildpack(plan.buildpack);
97
- if (!buildpack) {
98
- throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
90
+ // Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): the
91
+ // server decides via the app's build_mode; --remote/--local override for
92
+ // testing. Pre-build failures with a server-decided mode fall back to the
93
+ // local build below (deploy_remote returns null); with --remote they
94
+ // fail hard.
95
+ let deployment;
96
+ const want_remote = !args.local && (Boolean(args.remote) || app.build_mode === 'remote');
97
+ const remote_deployment = want_remote
98
+ ? await deploy_remote({
99
+ api,
100
+ app,
101
+ plan,
102
+ git,
103
+ workdir,
104
+ explicit: Boolean(args.remote)
105
+ })
106
+ : null;
107
+ if (remote_deployment) {
108
+ deployment = remote_deployment;
109
+ }
110
+ else {
111
+ if (want_remote) {
112
+ log.warn('↩️ Falling back to a local build');
99
113
  }
100
- // The build starts now: declare it (QUEUED BUILDING) and stream the
101
- // captured output to the deployment while it runs (best-effort).
102
- await api
103
- .updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
104
- .catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
105
- const stop_log_sync = start_log_sync(api, deployment.id);
114
+ // Create-first: register the deployment BEFORE building. Gate rejections
115
+ // (free-plan quota 429, disabled app 409) surface here, at second 0 —
116
+ // before any build minutes are spent. The row is born QUEUED; the CLI
117
+ // owns it until the built image lands (or the build fails). `type` rides
118
+ // on the create the buildpack plan is already resolved.
119
+ deployment = await api.createDeployment({
120
+ app_id: app.id,
121
+ type: plan.type,
122
+ ...git
123
+ });
106
124
  try {
107
- await buildpack.build({ workdir, config, app, env_vars, deployment }, plan);
108
- // Upload to Faable registry, tagged by version and pinned to digest
109
- const { image_ref } = await upload_tag({
110
- app,
111
- api,
112
- deployment_id: deployment.id
113
- });
114
- // Complete the deployment with the built image — this is the handoff:
115
- // the controller claims it and materializes the workload.
116
- await api.completeDeployment(deployment.id, image_ref);
125
+ // Check if we can build docker images
126
+ await check_environment();
127
+ const runtime_label = plan.runtime.version
128
+ ? `${plan.runtime.name}-${plan.runtime.version}`
129
+ : plan.runtime.name;
130
+ log.info(`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`);
131
+ log.info(`🧩 Build plan ${plan_summary(plan)}`);
132
+ // get environment variables
133
+ const env_vars = await api.getAppSecrets(app.id);
134
+ const buildpack = get_buildpack(plan.buildpack);
135
+ if (!buildpack) {
136
+ throw new Error(`No buildpack registered for plan=${plan.buildpack}`);
137
+ }
138
+ // The build starts now: declare it (QUEUED → BUILDING) and stream the
139
+ // captured output to the deployment while it runs (best-effort).
140
+ await api
141
+ .updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
142
+ .catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
143
+ const stop_log_sync = start_log_sync(api, deployment.id);
144
+ try {
145
+ await buildpack.build({ workdir, config, app, env_vars, deployment }, plan);
146
+ // Upload to Faable registry, tagged by version and pinned to digest
147
+ const { image_ref } = await upload_tag({
148
+ app,
149
+ api,
150
+ deployment_id: deployment.id
151
+ });
152
+ // Complete the deployment with the built image — this is the handoff:
153
+ // the controller claims it and materializes the workload.
154
+ await api.completeDeployment(deployment.id, image_ref);
155
+ }
156
+ finally {
157
+ stop_log_sync();
158
+ }
117
159
  }
118
- finally {
119
- stop_log_sync();
160
+ catch (error) {
161
+ // The deployment already exists (created pre-build), so a failed build
162
+ // marks THAT row BUILD_ERROR with the captured logs — no extra row, no
163
+ // second quota hit. Gate rejections can't reach here anymore: the
164
+ // create happens before any building.
165
+ await mark_build_failure(api, { deployment_id: deployment.id, app });
166
+ throw error;
120
167
  }
121
- }
122
- catch (error) {
123
- // The deployment already exists (created pre-build), so a failed build
124
- // marks THAT row BUILD_ERROR with the captured logs — no extra row, no
125
- // second quota hit. Gate rejections can't reach here anymore: the
126
- // create happens before any building.
127
- await mark_build_failure(api, { deployment_id: deployment.id, app });
128
- throw error;
129
- }
130
- // Attach the final build output to the deployment (best-effort).
131
- await upload_logs(api, deployment.id);
168
+ // Attach the final build output to the deployment (best-effort).
169
+ await upload_logs(api, deployment.id);
170
+ } // end local build path (remote builds upload their own logs server-side)
132
171
  const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
133
172
  log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
134
173
  log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
@@ -0,0 +1,61 @@
1
+ import { log } from '../../../log.js';
2
+
3
+ const POLL_INTERVAL_MS = 4000;
4
+ // Queue wait + build must fit here; the server's own stops (Job deadline
5
+ // 30min, abandoned janitor 60min) resolve the deployment first in practice.
6
+ const FOLLOW_TIMEOUT_MS = 45 * 60 * 1000;
7
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ /**
9
+ * Follow a remote build until the image handoff: poll the phase and tail the
10
+ * build logs the builder uploads every ~10s (replace semantics → print the
11
+ * suffix beyond what we already showed). Returns on INITIALIZING/READY
12
+ * (image landed — the regular promotion poll takes over); throws on
13
+ * BUILD_ERROR/ERROR with the server-provided reason.
14
+ */
15
+ const follow_remote_build = async (api, deployment_id) => {
16
+ let printed = 0;
17
+ let last_phase = "";
18
+ const started = Date.now();
19
+ const tail_logs = async () => {
20
+ try {
21
+ const logs = await api.getDeploymentLogs(deployment_id);
22
+ const content = logs?.content ?? "";
23
+ if (content.length > printed) {
24
+ process.stdout.write(content.slice(printed));
25
+ printed = content.length;
26
+ }
27
+ else if (content.length < printed) {
28
+ // Keep-tail truncation rotated the buffer — resync silently.
29
+ printed = content.length;
30
+ }
31
+ }
32
+ catch {
33
+ // No logs yet (404) or transient failure — next poll.
34
+ }
35
+ };
36
+ while (Date.now() - started < FOLLOW_TIMEOUT_MS) {
37
+ const deployment = await api
38
+ .getDeployment(deployment_id)
39
+ .catch(() => null);
40
+ const phase = deployment?.status?.phase ?? "";
41
+ if (phase !== last_phase && phase) {
42
+ log.info(`☁️ Remote build: ${phase}`);
43
+ last_phase = phase;
44
+ }
45
+ await tail_logs();
46
+ if (phase === "BUILD_ERROR" || phase === "ERROR") {
47
+ const reason = deployment?.status?.reason;
48
+ throw new Error(`Remote build failed${reason ? `: ${reason}` : ""}`.trim());
49
+ }
50
+ // Image handoff done: the controller claimed it (INITIALIZING) or it is
51
+ // already live (READY) — the promotion poll takes over from here.
52
+ if (phase === "INITIALIZING" || phase === "READY") {
53
+ await tail_logs();
54
+ return;
55
+ }
56
+ await wait(POLL_INTERVAL_MS);
57
+ }
58
+ throw new Error("Timed out waiting for the remote build. Check the dashboard for its status.");
59
+ };
60
+
61
+ export { follow_remote_build };
@@ -0,0 +1,48 @@
1
+ import { log } from '../../../log.js';
2
+ import { follow_remote_build } from './follow.js';
3
+ import { collect_manifest } from './manifest.js';
4
+ import { upload_missing_blobs } from './upload.js';
5
+
6
+ /**
7
+ * Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): upload the
8
+ * source delta to the CAS, create the deployment with `source` (manifest +
9
+ * serialized BuildPlan) and follow the server-side build until the image
10
+ * handoff. No Docker daemon needed.
11
+ *
12
+ * Fallback contract:
13
+ * - PRE-build failures (collection, upload, create — including the server
14
+ * gate `remote_build_disabled`) return null when the mode came from the
15
+ * server, so the caller falls back to a local build. With --remote they
16
+ * fail hard (testing wants to see the errors).
17
+ * - Failures AFTER the deployment exists never fall back: a BUILD_ERROR
18
+ * would fail locally too (it's the user's build), and a timeout may still
19
+ * be building — a local fallback would double-deploy.
20
+ */
21
+ const deploy_remote = async (props) => {
22
+ const { api, app, plan, git, workdir, explicit } = props;
23
+ log.info(`☁️ Remote build (${explicit ? "--remote" : `app build_mode=remote`})`);
24
+ let deployment;
25
+ try {
26
+ const manifest = await collect_manifest(workdir);
27
+ log.info(`🗂️ ${manifest.length} files in the source manifest`);
28
+ await upload_missing_blobs(api, app.id, workdir, manifest);
29
+ deployment = await api.createDeployment({
30
+ app_id: app.id,
31
+ type: plan.type,
32
+ source: { manifest, plan },
33
+ ...git,
34
+ });
35
+ }
36
+ catch (error) {
37
+ if (explicit)
38
+ throw error;
39
+ const code = error?.response?.data?.code;
40
+ log.warn(`☁️ Remote build unavailable${code ? ` (${code})` : `: ${error.message}`}`);
41
+ return null;
42
+ }
43
+ log.info(`Preparing to build in faable cloud · ${deployment.id}`);
44
+ await follow_remote_build(api, deployment.id);
45
+ return deployment;
46
+ };
47
+
48
+ export { deploy_remote };
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'crypto';
2
+ import { lstatSync, readFileSync } from 'fs';
3
+ import * as path from 'path';
4
+ import { cmd } from '../../../lib/cmd.js';
5
+
6
+ // Server-side admission caps (mirror DeploymentSource on the API); checking
7
+ // here fails fast with a clearer message than a 400.
8
+ const MAX_FILES = 20_000;
9
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
10
+ /**
11
+ * Collect the source tree for a remote build: exactly what git tracks (plus
12
+ * untracked-but-not-ignored files), hashed for the CAS. Using git as the
13
+ * collector means the upload is "the source" — build artifacts, node_modules
14
+ * and local junk stay out via .gitignore, and the remote build re-derives
15
+ * them like a fresh clone would.
16
+ *
17
+ * Symlinks are rejected (no representation in the manifest by design — see
18
+ * deploy-v2-remote-build.md) and a non-git directory is unsupported in the
19
+ * MVP (deploy with --local instead).
20
+ */
21
+ 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("Remote builds need a git repository (the source tree is collected via git). Deploy with --local instead.");
27
+ }
28
+ // -c/-o: tracked + untracked; --exclude-standard: .gitignore/.git/info.
29
+ // -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);
36
+ if (rel_paths.length === 0) {
37
+ throw new Error("No files to upload (empty git tree?)");
38
+ }
39
+ if (rel_paths.length > MAX_FILES) {
40
+ throw new Error(`Too many files for a remote build (${rel_paths.length} > ${MAX_FILES}).`);
41
+ }
42
+ const manifest = [];
43
+ for (const rel of rel_paths) {
44
+ const abs = path.join(workdir, rel);
45
+ // lstat: a symlink must be detected as such, not followed.
46
+ const stat = (() => {
47
+ try {
48
+ return lstatSync(abs, { throwIfNoEntry: false });
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ })();
54
+ // Listed but unreadable/deleted (e.g. staged deletion) → skip.
55
+ if (!stat)
56
+ continue;
57
+ if (stat.isDirectory())
58
+ continue;
59
+ if (stat.isSymbolicLink()) {
60
+ throw new Error(`Symlinks are not supported in remote builds: ${rel}. Deploy with --local instead.`);
61
+ }
62
+ if (stat.size > MAX_FILE_BYTES) {
63
+ throw new Error(`${rel} is too large for a remote build (${stat.size} bytes > ${MAX_FILE_BYTES}).`);
64
+ }
65
+ const body = readFileSync(abs);
66
+ manifest.push({
67
+ // Manifest paths are forward-slash relative (API admission contract).
68
+ path: rel.split(path.sep).join("/"),
69
+ sha: createHash("sha256").update(body).digest("hex"),
70
+ size: stat.size,
71
+ mode: stat.mode & 0o777,
72
+ });
73
+ }
74
+ return manifest;
75
+ };
76
+
77
+ export { collect_manifest };
@@ -0,0 +1,61 @@
1
+ import axios from 'axios';
2
+ import { readFileSync } from 'fs';
3
+ import * as path from 'path';
4
+ import { log } from '../../../log.js';
5
+
6
+ const PUT_CONCURRENCY = 8;
7
+ const PUT_RETRIES = 2;
8
+ /**
9
+ * Delta upload to the CAS: ask the API which blobs are missing and PUT only
10
+ * those (presigned URLs, sha256-pinned by the signature — S3 rejects content
11
+ * that doesn't match). Redeploys upload just what changed.
12
+ */
13
+ const upload_missing_blobs = async (api, app_id, workdir, manifest) => {
14
+ const { uploads } = await api.uploadMissing(app_id, manifest);
15
+ if (uploads.length === 0) {
16
+ log.info(`📦 Source already in the build store (0 files to upload)`);
17
+ return { uploaded: 0, bytes: 0 };
18
+ }
19
+ // First path wins per sha — content is identical by definition.
20
+ const by_sha = new Map();
21
+ for (const file of manifest) {
22
+ if (!by_sha.has(file.sha))
23
+ by_sha.set(file.sha, file);
24
+ }
25
+ let bytes = 0;
26
+ let queue = 0;
27
+ const worker = async () => {
28
+ while (queue < uploads.length) {
29
+ const upload = uploads[queue++];
30
+ const file = by_sha.get(upload.sha);
31
+ if (!file)
32
+ continue; // server echoed a sha we didn't send — ignore
33
+ const body = readFileSync(path.join(workdir, file.path));
34
+ let attempt = 0;
35
+ for (;;) {
36
+ try {
37
+ await axios.put(upload.url, body, {
38
+ headers: {
39
+ ...upload.headers,
40
+ "content-type": "application/octet-stream",
41
+ },
42
+ maxBodyLength: Infinity,
43
+ timeout: 120_000,
44
+ });
45
+ bytes += body.length;
46
+ break;
47
+ }
48
+ catch (error) {
49
+ if (attempt++ >= PUT_RETRIES) {
50
+ throw new Error(`Upload failed for ${file.path}: ${error?.message}`, { cause: error });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ };
56
+ await Promise.all(Array.from({ length: Math.min(PUT_CONCURRENCY, uploads.length) }, worker));
57
+ log.info(`📤 Uploaded ${uploads.length} changed files (${(bytes / 1024).toFixed(0)} KB) — delta only`);
58
+ return { uploaded: uploads.length, bytes };
59
+ };
60
+
61
+ export { upload_missing_blobs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,6 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
+ "@faabletools/buildpacks": "^1.0.0",
30
31
  "axios": "^1.13.2",
31
32
  "fs-extra": "^11.3.2",
32
33
  "handlebars": "^4.7.8",
@@ -1,11 +0,0 @@
1
- /** One-line summary logged before build so the decision is reconstructible. */
2
- const plan_summary = (plan) => JSON.stringify({
3
- buildpack: plan.buildpack,
4
- type: plan.type,
5
- runtime: `${plan.runtime.name}${plan.runtime.version ? `-${plan.runtime.version}` : ""}`,
6
- start: plan.start_command,
7
- ...(plan.install_command ? { install: plan.install_command } : {}),
8
- ...(plan.build_script ? { build_script: plan.build_script } : {}),
9
- });
10
-
11
- export { plan_summary };
@@ -1,78 +0,0 @@
1
- import fs from 'fs-extra';
2
- import path__default from 'path';
3
- import { FOREIGN_PLATFORMS } from './foreign_platforms.js';
4
-
5
- const DOCS_URL = "https://faable.com/docs/deploy/build-requirements";
6
- const MAX_LISTED_FILES = 20;
7
- /** Workdir listing for diagnostics: dirs suffixed "/", noise skipped, capped. */
8
- const list_found_files = (workdir) => {
9
- try {
10
- return fs
11
- .readdirSync(workdir, { withFileTypes: true })
12
- .filter((e) => ![".git", "node_modules"].includes(e.name))
13
- .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
14
- .sort()
15
- .slice(0, MAX_LISTED_FILES);
16
- }
17
- catch {
18
- return [];
19
- }
20
- };
21
- const render_message = (props) => {
22
- const pad = Math.max(...props.diagnostics.map((d) => d.buildpack.length + 11));
23
- const looked = props.diagnostics
24
- .flatMap((d) => {
25
- const lines = [
26
- ` ${d.buildpack.padEnd(pad)} → ${d.looked_for.join(", ")}`,
27
- ];
28
- if (d.fallback?.length) {
29
- lines.push(` ${`${d.buildpack} (fallback)`.padEnd(pad)} → ${d.fallback.join(", ")}`);
30
- }
31
- return lines;
32
- })
33
- .join("\n");
34
- const found = props.found_files.length > 0
35
- ? `\n\nFiles found in ${props.workdir}:\n ${props.found_files.join(", ")}`
36
- : `\n\nNo files found in ${props.workdir}.`;
37
- const foreign = props.foreign.length > 0
38
- ? `\n\nFound config for another platform:\n${props.foreign
39
- .map((f) => ` ${f.file} → ${f.platform} — Faable can't use it directly.`)
40
- .join("\n")}`
41
- : "";
42
- return (`Cannot detect how to build this project.\n\n` +
43
- `Faable looked for (in order):\n${looked}` +
44
- found +
45
- foreign +
46
- `\n\nFix: add one of the files above, or force a buildpack with "buildpack" in faable.json or --buildpack.\n` +
47
- `Docs: ${DOCS_URL}`);
48
- };
49
- /**
50
- * Thrown by the registry when no buildpack claims the project. The full
51
- * multi-line diagnostic lives in `message`, so the CLI's standard error path
52
- * (yargs .fail → log.error → exit 1) prints it without special handling.
53
- */
54
- class DetectError extends Error {
55
- workdir;
56
- diagnostics;
57
- found_files;
58
- foreign;
59
- constructor(workdir, buildpacks) {
60
- const diagnostics = buildpacks.map((b) => ({
61
- buildpack: b.name,
62
- looked_for: b.detect_files,
63
- ...(b.fallback_files ? { fallback: b.fallback_files } : {}),
64
- }));
65
- const found_files = list_found_files(workdir);
66
- const foreign = Object.entries(FOREIGN_PLATFORMS)
67
- .filter(([file]) => fs.existsSync(path__default.join(workdir, file)))
68
- .map(([file, platform]) => ({ file, platform }));
69
- super(render_message({ workdir, diagnostics, found_files, foreign }));
70
- this.name = "DetectError";
71
- this.workdir = workdir;
72
- this.diagnostics = diagnostics;
73
- this.found_files = found_files;
74
- this.foreign = foreign;
75
- }
76
- }
77
-
78
- export { DetectError };
@@ -1,42 +0,0 @@
1
- import path__default from 'path';
2
- import { cmd } from '../../../../lib/cmd.js';
3
- import { log } from '../../../../log.js';
4
- import { has_any_of_files } from '../shared/has_any_of_files.js';
5
- import { read_json_file } from '../shared/read_text_file.js';
6
-
7
- // A package.json with a next dependency beside the Dockerfile means this is a
8
- // Next.js app shipped with a custom image: emit type "next" so the backend
9
- // provisions the build-cache PVC (runtime_strategy). Reachable via the
10
- // --buildpack override, since the node buildpack claims package.json first.
11
- const detect_next = (workdir) => {
12
- try {
13
- const pkg = read_json_file(path__default.join(workdir, "package.json"));
14
- return Boolean(pkg?.dependencies?.next || pkg?.devDependencies?.next);
15
- }
16
- catch {
17
- return false;
18
- }
19
- };
20
- const docker_buildpack = {
21
- name: "docker",
22
- detect_files: ["Dockerfile"],
23
- async detect(ctx) {
24
- if (!has_any_of_files(this.detect_files, ctx.workdir))
25
- return null;
26
- const type = detect_next(ctx.workdir) ? "next" : "node";
27
- return {
28
- buildpack: "docker",
29
- runtime: { name: "docker" },
30
- type,
31
- // The user's image defines its own CMD/ENTRYPOINT.
32
- start_command: null,
33
- };
34
- },
35
- async build(ctx) {
36
- log.info(`📦 Building with the project's own Dockerfile`);
37
- const timeout = 10 * 60 * 1000; // 10 minute timeout
38
- await cmd(`docker build --platform linux/amd64 -t ${ctx.app.id} ${ctx.workdir} -f ${path__default.join(ctx.workdir, "Dockerfile")}`, { timeout, enableOutput: true });
39
- },
40
- };
41
-
42
- export { docker_buildpack };
@@ -1,21 +0,0 @@
1
- /**
2
- * Config files from other deployment platforms. Recognizing them turns the
3
- * detection-failure error from "nothing found" into "this repo is set up for
4
- * X — Faable can't use that file directly". cerebrium.toml is deliberately
5
- * absent (it has a real provider); Procfile too (it's a Faable input).
6
- */
7
- const FOREIGN_PLATFORMS = {
8
- "cog.yaml": "Replicate",
9
- "fly.toml": "Fly.io",
10
- "render.yaml": "Render",
11
- "vercel.json": "Vercel",
12
- "now.json": "Vercel",
13
- "netlify.toml": "Netlify",
14
- "app.yaml": "Google App Engine",
15
- "railway.json": "Railway",
16
- "railway.toml": "Railway",
17
- "heroku.yml": "Heroku",
18
- "captain-definition": "CapRover",
19
- };
20
-
21
- export { FOREIGN_PLATFORMS };