@faable/faable 1.9.0 → 1.11.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.
@@ -0,0 +1,78 @@
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 };
@@ -0,0 +1,21 @@
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 };
@@ -46,8 +46,10 @@ const has_node_modules = (workdir, exists) => {
46
46
  */
47
47
  const ensure_dependencies = async (workdir, deps = {}) => {
48
48
  const { run = defaultRun, hasTool = defaultHasTool, exists = existsSync } = deps;
49
- if (has_node_modules(workdir, exists))
49
+ if (has_node_modules(workdir, exists)) {
50
+ log.info(`📦 Dependencies already installed — skipping install (fresh dependencies)`);
50
51
  return;
52
+ }
51
53
  const has = (file) => exists(join(workdir, file));
52
54
  let install = "npm install --no-audit --no-fund";
53
55
  if (has("package-lock.json") || has("npm-shrinkwrap.json")) {
@@ -1,22 +1,29 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
1
3
  import { log } from '../../../../log.js';
2
4
  import { render_dockerfile, build_image } from '../shared/docker_image.js';
3
5
  import { has_any_of_files } from '../shared/has_any_of_files.js';
6
+ import { cerebrium_provider } from './providers/cerebrium.js';
4
7
  import { pipfile_provider } from './providers/pipfile.js';
5
8
  import { pyproject_provider } from './providers/pyproject.js';
6
9
  import { requirements_provider } from './providers/requirements.js';
7
10
  import { resolve_python_version } from './python_version.js';
8
- import { read_dependencies_text, resolve_start, with_server_injection } from './resolve_start.js';
11
+ import { resolve_start, with_server_injection, read_dependencies_text } from './resolve_start.js';
9
12
 
10
13
  const BANNER = `PYTHON_VERSION=$(python --version 2>&1)
11
14
  PIP_VERSION=$(pip --version 2>&1)
12
15
 
13
16
  echo "Faable Cloud · [$PYTHON_VERSION] [$PIP_VERSION]"`;
17
+ const DOCS_URL = "https://faable.com/docs/deploy/build-requirements";
14
18
  // Evaluated in order: the first provider whose manifest exists supplies the
15
- // install command. Classic manifests come before platform-specific ones.
19
+ // install command. Classic manifests come before platform-specific ones, so
20
+ // a repo shipping both requirements.txt and cerebrium.toml builds from the
21
+ // standard manifest.
16
22
  const PROVIDERS = [
17
23
  requirements_provider,
18
24
  pyproject_provider,
19
25
  pipfile_provider,
26
+ cerebrium_provider,
20
27
  ];
21
28
  const detect_with_provider = (ctx, provider) => {
22
29
  const resolved = provider.resolve(ctx.workdir);
@@ -25,7 +32,19 @@ const detect_with_provider = (ctx, provider) => {
25
32
  const deps = [read_dependencies_text(ctx.workdir), resolved.deps_text ?? ""]
26
33
  .join("\n")
27
34
  .toLowerCase();
28
- const { start_command, server } = resolve_start(ctx.workdir, deps, ctx.config, resolved.start_hint);
35
+ let start_command;
36
+ let server;
37
+ try {
38
+ ({ start_command, server } = resolve_start(ctx.workdir, deps, ctx.config, resolved.start_hint));
39
+ }
40
+ catch (error) {
41
+ if (provider.name === "cerebrium") {
42
+ throw new Error("Detected a Cerebrium project (cerebrium.toml) but couldn't find a web " +
43
+ "entrypoint. Faable runs web services — expose a FastAPI/Flask app or " +
44
+ `set \`startCommand\` in faable.json. Docs: ${DOCS_URL}`, { cause: error });
45
+ }
46
+ throw error;
47
+ }
29
48
  // faable.json buildCommand overrides the provider's install. The cacheable
30
49
  // manifest layer only applies to the provider's own command — an arbitrary
31
50
  // buildCommand may need the full source.
@@ -51,12 +70,43 @@ const detect_with_provider = (ctx, provider) => {
51
70
  const python_buildpack = {
52
71
  name: "python",
53
72
  detect_files: PROVIDERS.flatMap((p) => p.files),
73
+ fallback_files: ["main.py", "app.py", "wsgi.py"],
54
74
  async detect(ctx) {
55
75
  const provider = PROVIDERS.find((p) => has_any_of_files(p.files, ctx.workdir));
56
76
  if (!provider)
57
77
  return null;
58
78
  return detect_with_provider(ctx, provider);
59
79
  },
80
+ // Weak-signal pass: a Python entrypoint with no dependency manifest at all.
81
+ // Registered as fallback so it loses against any strong trigger (Dockerfile).
82
+ async detect_fallback(ctx) {
83
+ const entries = this.fallback_files.filter((f) => fs.existsSync(path__default.join(ctx.workdir, f)));
84
+ if (entries.length === 0)
85
+ return null;
86
+ log.warn(`⚠️ No dependency manifest (requirements.txt/pyproject.toml/Pipfile) found — ` +
87
+ `building from ${entries.join(", ")} without installing dependencies. ` +
88
+ `Your app will likely need a requirements.txt; see ${DOCS_URL}`);
89
+ // Framework detection blob = the entry files themselves: an
90
+ // `from fastapi import FastAPI` line carries the token detection needs.
91
+ const deps = entries
92
+ .map((f) => fs.readFileSync(path__default.join(ctx.workdir, f)).toString())
93
+ .join("\n")
94
+ .toLowerCase();
95
+ const { start_command, server } = resolve_start(ctx.workdir, deps, ctx.config);
96
+ const install_command = with_server_injection(ctx.config.buildCommand, server, deps);
97
+ const version = resolve_python_version(ctx.workdir);
98
+ log.info(`Using python@${version}`);
99
+ log.info(`📦 Install command: ${install_command}`);
100
+ log.info(`⚙️ Start command: ${start_command}`);
101
+ return {
102
+ buildpack: "python",
103
+ runtime: { name: "python", version },
104
+ type: "python",
105
+ start_command,
106
+ install_command,
107
+ from: `python:${version}`,
108
+ };
109
+ },
60
110
  async build(ctx, plan) {
61
111
  // Dependencies install inside the image (unlike node); nothing runs on
62
112
  // the host here.
@@ -0,0 +1,43 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
3
+ import { parse_cerebrium_toml } from './parse_cerebrium_toml.js';
4
+
5
+ /**
6
+ * Cerebrium projects (cerebrium.toml + a Python entrypoint) declare their pip
7
+ * dependencies and python version in the toml instead of a requirements.txt.
8
+ * This provider translates that manifest so the standard python buildpack can
9
+ * build them — the masyosh/cere onboarding case.
10
+ */
11
+ const cerebrium_provider = {
12
+ name: "cerebrium",
13
+ files: ["cerebrium.toml"],
14
+ resolve(workdir) {
15
+ const manifest = parse_cerebrium_toml(fs.readFileSync(path__default.join(workdir, "cerebrium.toml")).toString());
16
+ let install_command;
17
+ let install_files;
18
+ if (manifest.pip_requirements_file) {
19
+ install_command = `pip install --no-cache-dir -r ${manifest.pip_requirements_file}`;
20
+ // Copying the referenced file AND the toml ties the cached layer to both.
21
+ install_files = [manifest.pip_requirements_file, "cerebrium.toml"];
22
+ }
23
+ else if (manifest.pip_packages.length > 0) {
24
+ install_command = `pip install --no-cache-dir ${manifest.pip_packages
25
+ .map((spec) => `"${spec}"`)
26
+ .join(" ")}`;
27
+ // The command inlines the packages; copying the toml keeps the layer
28
+ // cache keyed to the manifest content.
29
+ install_files = ["cerebrium.toml"];
30
+ }
31
+ return {
32
+ install_command,
33
+ install_files,
34
+ python_version: manifest.python_version,
35
+ // Feed the pip package names into the framework-detection blob so e.g.
36
+ // `fastapi` in the toml triggers the existing uvicorn start resolution.
37
+ deps_text: manifest.pip_names.join("\n"),
38
+ start_hint: manifest.entrypoint,
39
+ };
40
+ },
41
+ };
42
+
43
+ export { cerebrium_provider };
@@ -0,0 +1,56 @@
1
+ import { parse } from 'smol-toml';
2
+ import { log } from '../../../../../log.js';
3
+
4
+ const table = (value) => value && typeof value === "object" && !Array.isArray(value)
5
+ ? value
6
+ : {};
7
+ /**
8
+ * Turn the pip table into installable requirement specs. Cerebrium uses
9
+ * `"latest"` / `""` for unpinned packages; anything else is a version spec —
10
+ * exact pins (`"2.0.0"`) become `==`, ranges (`">=2.0"`) pass through.
11
+ */
12
+ const to_spec = (name, version) => {
13
+ const v = String(version ?? "").trim();
14
+ if (!v || v.toLowerCase() === "latest")
15
+ return name;
16
+ if (/^[0-9]/.test(v))
17
+ return `${name}==${v}`;
18
+ return `${name}${v}`;
19
+ };
20
+ const parse_cerebrium_toml = (content) => {
21
+ const root = table(table(parse(content)).cerebrium);
22
+ const deployment = table(root.deployment);
23
+ const dependencies = table(root.dependencies);
24
+ const pip = table(dependencies.pip);
25
+ const paths = table(dependencies.paths);
26
+ const custom = table(table(root.runtime).custom);
27
+ for (const skipped of ["apt", "conda"]) {
28
+ if (Object.keys(table(dependencies[skipped])).length > 0) {
29
+ log.warn(`cerebrium.toml declares ${skipped} dependencies — Faable only installs the pip table, ${skipped} packages are ignored`);
30
+ }
31
+ }
32
+ const pip_names = Object.keys(pip);
33
+ const pip_packages = pip_names.map((name) => to_spec(name, pip[name]));
34
+ const entrypoint_raw = custom.entrypoint;
35
+ const entrypoint = Array.isArray(entrypoint_raw)
36
+ ? entrypoint_raw.map(String).join(" ")
37
+ : typeof entrypoint_raw === "string" && entrypoint_raw.trim()
38
+ ? entrypoint_raw.trim()
39
+ : undefined;
40
+ const python_version = typeof deployment.python_version === "string" &&
41
+ deployment.python_version.trim()
42
+ ? deployment.python_version.trim()
43
+ : undefined;
44
+ const pip_requirements_file = typeof paths.pip === "string" && paths.pip.trim()
45
+ ? paths.pip.trim()
46
+ : undefined;
47
+ return {
48
+ python_version,
49
+ pip_packages,
50
+ pip_names,
51
+ pip_requirements_file,
52
+ entrypoint,
53
+ };
54
+ };
55
+
56
+ export { parse_cerebrium_toml };
@@ -1,3 +1,4 @@
1
+ import { DetectError } from './DetectError.js';
1
2
  import { docker_buildpack } from './docker/index.js';
2
3
  import { node_buildpack } from './node/index.js';
3
4
  import { python_buildpack } from './python/index.js';
@@ -54,7 +55,7 @@ const detect_buildpack = async (ctx, override) => {
54
55
  if (plan)
55
56
  return plan;
56
57
  }
57
- throw new Error("Cannot detect project type");
58
+ throw new DetectError(ctx.workdir, BUILDPACKS);
58
59
  };
59
60
 
60
61
  export { BUILDPACKS, buildpack_names, detect_buildpack, get_buildpack };
@@ -74,7 +74,7 @@ const deploy = {
74
74
  ...git
75
75
  });
76
76
  const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
77
- log.info(`🌍 Deployment created (${deployment.id}) -> https://${app.url}`);
77
+ log.info(`Preparing to deploy in faable cloud · ${deployment.id}`);
78
78
  log.info(`📊 View it in the dashboard -> ${dashboard_url}`);
79
79
  // Wait (up to 5 minutes) for the deployment to be promoted (live)
80
80
  const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
@@ -98,7 +98,7 @@ const deploy = {
98
98
  }
99
99
  }
100
100
  if (promoted) {
101
- log.info(`✅ Deployment promoted and live -> https://${app.url}`);
101
+ log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
102
102
  }
103
103
  else {
104
104
  log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -37,6 +37,7 @@
37
37
  "promisify-child-process": "^4.1.2",
38
38
  "prompts": "^2.4.2",
39
39
  "ramda": "^0.32.0",
40
+ "smol-toml": "^1.7.0",
40
41
  "tslib": "^2.8.1",
41
42
  "yaml": "^2.8.2",
42
43
  "yargs": "^18.0.0"