@faable/faable 1.5.27 → 1.5.29

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
@@ -21,6 +21,54 @@ To install the latest version of Faable CLI:
21
21
  npm i -g @faable/faable
22
22
  ```
23
23
 
24
+ ## Runtimes
25
+
26
+ `faable deploy` auto-detects the runtime from the files in your project (no
27
+ config needed in the common case):
28
+
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) |
34
+
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.
37
+
38
+ ### Node.js / static frameworks
39
+
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.
44
+
45
+ ### Python
46
+
47
+ The start command is detected from your framework:
48
+
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` |
54
+
55
+ Dependencies are installed inside the image from `requirements.txt`,
56
+ `pyproject.toml` or `Pipfile`. `gunicorn`/`uvicorn` are installed automatically
57
+ if missing.
58
+
59
+ Pin the Python version with a `runtime.txt` (`python-3.11.3`), a
60
+ `.python-version`, or `requires-python` in `pyproject.toml`.
61
+
62
+ ### Overriding detection
63
+
64
+ When auto-detection doesn't fit, set the commands explicitly:
65
+
66
+ - `faable.json` — `{ "buildCommand": "...", "startCommand": "..." }`
67
+ - `Procfile` — a `web:` line, e.g. `web: gunicorn app:app --bind 0.0.0.0:$PORT`
68
+
69
+ Precedence: `faable.json` → `Procfile` → auto-detection. The container listens on
70
+ `$PORT` (80).
71
+
24
72
  ## Documentation
25
73
 
26
74
  For details on how to use Faable CLI, check out our
@@ -14,7 +14,9 @@ const gitRunner = (workdir) => command => new Promise(resolve => {
14
14
  // Resolve the commit / ref / actor for the current deploy. In GitHub Actions
15
15
  // these come from the standard env vars; locally we fall back to git so manual
16
16
  // deploys still record a commit. `github_actor` is CI-only (a GitHub login),
17
- // left undefined locally where no reliable login is available.
17
+ // left undefined locally where no reliable login is available. The commit
18
+ // message has no standard CI env var, so it is always read from git (for the
19
+ // resolved commit, falling back to HEAD) — the dashboard shows its first line.
18
20
  const git_context = async (opts) => {
19
21
  const env = opts?.env ?? process.env;
20
22
  const run = opts?.run ?? gitRunner(opts?.workdir);
@@ -26,6 +28,12 @@ const git_context = async (opts) => {
26
28
  github_ref = `refs/heads/${branch}`;
27
29
  }
28
30
  const github_actor = env.GITHUB_ACTOR || undefined;
31
+ // Full commit message (subject + body) for the resolved commit; the dashboard
32
+ // renders only the subject line. Fall back to HEAD when the SHA isn't
33
+ // resolvable in the local checkout (e.g. shallow clones).
34
+ const github_commit_message = (github_commit &&
35
+ (await run(`git show -s --format=%B ${github_commit}`))) ||
36
+ (await run("git log -1 --pretty=%B"));
29
37
  const ctx = {};
30
38
  if (github_commit)
31
39
  ctx.github_commit = github_commit;
@@ -33,6 +41,8 @@ const git_context = async (opts) => {
33
41
  ctx.github_ref = github_ref;
34
42
  if (github_actor)
35
43
  ctx.github_actor = github_actor;
44
+ if (github_commit_message)
45
+ ctx.github_commit_message = github_commit_message;
36
46
  return ctx;
37
47
  };
38
48
 
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
4
4
  import { check_environment } from './check_environment.js';
5
5
  import { git_context } from './git_context.js';
6
6
  import { build_node } from './node-pipeline/index.js';
7
+ import { build_python } from './python-pipeline/index.js';
7
8
  import { runtime_detection } from './runtime-detect/runtime_detection.js';
8
9
  import { upload_tag } from './upload_tag.js';
9
10
 
@@ -48,6 +49,12 @@ const deploy = {
48
49
  });
49
50
  type = node_result.type;
50
51
  }
52
+ else if (runtime.name == 'python') {
53
+ const python_result = await build_python(app, {
54
+ workdir,
55
+ runtime});
56
+ type = python_result.type;
57
+ }
51
58
  else if (runtime.name == 'docker') {
52
59
  type = 'node';
53
60
  await cmd(`docker build -t ${app.id} .`, {
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path__default from 'path';
3
3
  import { log } from '../../../log.js';
4
- import * as R from 'ramda';
4
+ import { detect_framework } from './frameworks.js';
5
5
 
6
6
  const analyze_package = async (params) => {
7
7
  const workdir = params.workdir;
@@ -11,23 +11,24 @@ const analyze_package = async (params) => {
11
11
  // Check if build is required to run
12
12
  const build_script = process.env.FAABLE_NPM_BUILD_SCRIPT
13
13
  ? process.env.FAABLE_NPM_BUILD_SCRIPT
14
- : pkg?.scripts["build"]
14
+ : pkg?.scripts?.["build"]
15
15
  ? "build"
16
16
  : null;
17
17
  if (!build_script) {
18
18
  log.info(`No build script on package.json`);
19
19
  }
20
- let type = "node";
21
- // Detect nextjs deployment type
22
- const next_dep = R.lensPath(["dependencies", "next"]);
23
- const next_devdep = R.lensPath(["devDependencies", "next"]);
24
- if (R.view(next_dep, pkg) || R.view(next_devdep, pkg)) {
25
- type = "next";
26
- }
20
+ const has_start = Boolean(pkg?.scripts?.["start"]);
21
+ const { type, start_command, inject_serve } = detect_framework({
22
+ pkg,
23
+ workdir,
24
+ has_start,
25
+ });
27
26
  log.info(`⚡️ Detected deployment type=${type}`);
28
27
  return {
29
28
  build_script,
30
29
  type,
30
+ start_command,
31
+ inject_serve,
31
32
  };
32
33
  };
33
34
 
@@ -27,7 +27,11 @@ const entrypoint_template = Handlebars.compile(entrypoint);
27
27
  const build_docker = async (props) => {
28
28
  const { app, workdir, template_context } = props;
29
29
  const entrypoint_custom = entrypoint_template(template_context);
30
- const start_command = Configuration.instance().startCommand;
30
+ // Precedence: explicit faable.json startCommand > framework-detected command
31
+ // (e.g. serving a static SPA) > default `npm run start`.
32
+ const start_command = Configuration.instance().configuredStartCommand ??
33
+ props.start_command ??
34
+ "npm run start";
31
35
  log.info(`⚙️ Start command: ${start_command}`);
32
36
  // NOTE: use slim to build projects
33
37
  const linux_distro = "slim";
@@ -0,0 +1,108 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
3
+ import * as R from 'ramda';
4
+ import { log } from '../../../log.js';
5
+
6
+ const has_dep = (pkg, name) => Boolean(R.view(R.lensPath(["dependencies", name]), pkg) ||
7
+ R.view(R.lensPath(["devDependencies", name]), pkg));
8
+ /**
9
+ * Read Angular's build output path from angular.json. Defaults to `dist` when
10
+ * it can't be resolved. Angular ≥17 (application builder) emits into
11
+ * `<outputPath>/browser`, so we append it when the project uses that builder.
12
+ */
13
+ const resolve_angular_output = (workdir) => {
14
+ const fallback = "dist";
15
+ try {
16
+ const angular_json = fs.readJSONSync(path__default.join(workdir, "angular.json"));
17
+ const projects = angular_json?.projects ?? {};
18
+ const project_name = angular_json?.defaultProject ?? Object.keys(projects)[0];
19
+ const build = projects?.[project_name]?.architect?.build;
20
+ const output = build?.options?.outputPath;
21
+ if (!output)
22
+ return fallback;
23
+ const builder = build?.builder ?? "";
24
+ const is_application_builder = builder.includes("application") || builder.includes("browser-esbuild");
25
+ return is_application_builder ? path__default.join(output, "browser") : output;
26
+ }
27
+ catch {
28
+ return fallback;
29
+ }
30
+ };
31
+ /**
32
+ * Framework registry, evaluated in order. Order matters: Astro/SvelteKit/CRA
33
+ * pull Vite in transitively, so Vite must be the last static fallback.
34
+ */
35
+ const FRAMEWORKS = [
36
+ // Next.js: handled by its own runtime_strategy/PVC, never static-served here.
37
+ { type: "next", deps: ["next"] },
38
+ {
39
+ type: "astro",
40
+ deps: ["astro"],
41
+ outputDir: "dist",
42
+ serveCommand: (dir) => `npx astro preview --host 0.0.0.0 --port $PORT`,
43
+ },
44
+ {
45
+ type: "gatsby",
46
+ deps: ["gatsby"],
47
+ outputDir: "public",
48
+ serveCommand: (dir) => `npx gatsby serve --host 0.0.0.0 --port $PORT`,
49
+ },
50
+ {
51
+ type: "cra",
52
+ deps: ["react-scripts"],
53
+ outputDir: "build",
54
+ injectServe: true,
55
+ serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
56
+ },
57
+ {
58
+ type: "vue",
59
+ deps: ["@vue/cli-service"],
60
+ outputDir: "dist",
61
+ injectServe: true,
62
+ serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
63
+ },
64
+ {
65
+ type: "angular",
66
+ deps: ["@angular/cli", "@angular-devkit/build-angular"],
67
+ injectServe: true,
68
+ resolveOutput: resolve_angular_output,
69
+ serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
70
+ },
71
+ {
72
+ type: "vite",
73
+ deps: ["vite"],
74
+ outputDir: "dist",
75
+ serveCommand: (dir) => `npx vite preview --host 0.0.0.0 --port $PORT`,
76
+ },
77
+ ];
78
+ /**
79
+ * Detect the framework from package.json and compute how to serve it.
80
+ *
81
+ * When the project defines its own `start` script we never override it (the app
82
+ * ships a real server — custom SSR, Nuxt, Remix, SvelteKit node-adapter, etc.),
83
+ * so `start_command`/`inject_serve` stay neutral.
84
+ */
85
+ const detect_framework = (params) => {
86
+ const { pkg, workdir, has_start } = params;
87
+ const framework = FRAMEWORKS.find((fw) => fw.deps.some((dep) => has_dep(pkg, dep)));
88
+ if (!framework) {
89
+ return { type: "node", start_command: null, inject_serve: false };
90
+ }
91
+ // Static frameworks only override the start command when the project doesn't
92
+ // ship its own server.
93
+ if (framework.serveCommand && !has_start) {
94
+ const output_dir = framework.resolveOutput
95
+ ? framework.resolveOutput(workdir)
96
+ : framework.outputDir ?? "dist";
97
+ const start_command = framework.serveCommand(output_dir);
98
+ log.info(`No start script on package.json, serving ${framework.type} output (${output_dir}) with [${start_command}]`);
99
+ return {
100
+ type: framework.type,
101
+ start_command,
102
+ inject_serve: Boolean(framework.injectServe),
103
+ };
104
+ }
105
+ return { type: framework.type, start_command: null, inject_serve: false };
106
+ };
107
+
108
+ export { FRAMEWORKS, detect_framework, resolve_angular_output };
@@ -1,6 +1,7 @@
1
1
  import { build_docker } from './build_docker.js';
2
2
  import { analyze_package } from './analyze_package.js';
3
3
  import { build_project } from './build_project.js';
4
+ import { inject_serve } from './inject_serve.js';
4
5
  import * as R from 'ramda';
5
6
  import { log } from '../../../log.js';
6
7
 
@@ -11,16 +12,24 @@ const build_node = async (app, options) => {
11
12
  throw new Error("Runtime version not specified for node");
12
13
  }
13
14
  // Analyze package.json to check if build is needed
14
- const { build_script, type } = await analyze_package({ workdir });
15
+ const { build_script, type, start_command, inject_serve: needs_serve } = await analyze_package({
16
+ workdir,
17
+ });
15
18
  // Environment variables
16
19
  const env = R.fromPairs(env_vars.map((e) => [e.name, e.value]));
17
20
  log.info(`Building with env variables ${Object.keys(env).join(",")}`);
18
21
  // Do build
19
22
  await build_project({ build_script, env });
23
+ // Frameworks without a bundled static server (CRA/Vue/Angular) need `serve`
24
+ // installed into node_modules before packaging, so it ships in the image.
25
+ if (needs_serve) {
26
+ await inject_serve(workdir);
27
+ }
20
28
  // Bundle project inside a docker image
21
29
  await build_docker({
22
30
  app,
23
31
  workdir,
32
+ start_command,
24
33
  template_context: {
25
34
  from: `node:${runtime.version}`,
26
35
  },
@@ -0,0 +1,20 @@
1
+ import { log } from '../../../log.js';
2
+ import { cmd } from '../../../lib/cmd.js';
3
+
4
+ // Pinned for reproducible builds. `serve` is the standalone static server used
5
+ // for frameworks without a bundled preview tool (CRA, Vue, Angular).
6
+ const SERVE_VERSION = "14";
7
+ /**
8
+ * Install `serve` into the project's node_modules so it ships inside the image
9
+ * via `COPY . .` (the Dockerfile does no `npm install`). This lets `npx serve`
10
+ * resolve the local copy at container start — no runtime download needed.
11
+ *
12
+ * `--no-save` keeps the user's package.json/lockfile untouched.
13
+ */
14
+ const inject_serve = async (workdir) => {
15
+ log.info(`📥 Injecting static server (serve@${SERVE_VERSION}) into image`);
16
+ const timeout = 5 * 60 * 1000; // 5 minute timeout
17
+ await cmd(`npm install serve@${SERVE_VERSION} --no-save --no-audit --no-fund`, { cwd: workdir, timeout, enableOutput: true });
18
+ };
19
+
20
+ export { inject_serve };
@@ -0,0 +1,158 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
3
+ import { log } from '../../../log.js';
4
+ import { Configuration } from '../../../lib/Configuration.js';
5
+ import { parse_procfile } from './parse_procfile.js';
6
+
7
+ /** Combine all dependency manifests into one lowercased blob for cheap lookups. */
8
+ const read_dependencies_text = (workdir) => {
9
+ const files = ["requirements.txt", "pyproject.toml", "Pipfile"];
10
+ return files
11
+ .map((f) => path__default.join(workdir, f))
12
+ .filter((p) => fs.existsSync(p))
13
+ .map((p) => fs.readFileSync(p).toString())
14
+ .join("\n")
15
+ .toLowerCase();
16
+ };
17
+ const has_token = (deps, token) => new RegExp(`\\b${token}\\b`).test(deps);
18
+ /** Identify which server binary a start command relies on, to ensure it's installed. */
19
+ const server_from_command = (command) => {
20
+ const bin = command.trim().split(/\s+/)[0];
21
+ if (bin === "gunicorn")
22
+ return "gunicorn";
23
+ if (bin === "uvicorn")
24
+ return "uvicorn";
25
+ return null;
26
+ };
27
+ /** Django project package = the directory that contains wsgi.py. */
28
+ const find_django_package = (workdir) => {
29
+ const entries = fs.readdirSync(workdir, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ if (entry.isDirectory() &&
32
+ fs.existsSync(path__default.join(workdir, entry.name, "wsgi.py"))) {
33
+ return entry.name;
34
+ }
35
+ }
36
+ return null;
37
+ };
38
+ /**
39
+ * Find the module that defines the app object, returning its dotted module path
40
+ * (e.g. `main`, `app.main`). Prefers a file matching `pattern`, else the first
41
+ * existing candidate.
42
+ */
43
+ const find_app_module = (workdir, pattern) => {
44
+ const candidates = [
45
+ "main.py",
46
+ "app.py",
47
+ "asgi.py",
48
+ "wsgi.py",
49
+ "application.py",
50
+ "server.py",
51
+ path__default.join("app", "main.py"),
52
+ path__default.join("app", "app.py"),
53
+ path__default.join("src", "main.py"),
54
+ ];
55
+ let first_existing = null;
56
+ for (const rel of candidates) {
57
+ const abs = path__default.join(workdir, rel);
58
+ if (!fs.existsSync(abs))
59
+ continue;
60
+ const module = rel.replace(/\.py$/, "").split(path__default.sep).join(".");
61
+ if (!first_existing)
62
+ first_existing = module;
63
+ if (pattern.test(fs.readFileSync(abs).toString()))
64
+ return module;
65
+ }
66
+ return first_existing;
67
+ };
68
+ /** Resolve the container start command and which server it needs installed. */
69
+ const resolve_start = (workdir, deps) => {
70
+ // 1. Explicit override in faable.json
71
+ const configured = Configuration.instance().configuredStartCommand;
72
+ if (configured) {
73
+ log.info(`Using start command from faable.json`);
74
+ return { start_command: configured, server: server_from_command(configured) };
75
+ }
76
+ // 2. Procfile `web:` line
77
+ const procfile = parse_procfile(workdir);
78
+ if (procfile) {
79
+ log.info(`Using start command from Procfile`);
80
+ return { start_command: procfile, server: server_from_command(procfile) };
81
+ }
82
+ // 3. Framework detection
83
+ // Django: manage.py + the package holding wsgi.py
84
+ if (fs.existsSync(path__default.join(workdir, "manage.py"))) {
85
+ const pkg = find_django_package(workdir);
86
+ if (!pkg) {
87
+ throw new Error("Detected Django (manage.py) but couldn't find the wsgi.py package. Set `startCommand` in faable.json or add a Procfile.");
88
+ }
89
+ return {
90
+ start_command: `gunicorn ${pkg}.wsgi:application --bind 0.0.0.0:$PORT`,
91
+ server: "gunicorn",
92
+ };
93
+ }
94
+ // FastAPI / ASGI
95
+ if (has_token(deps, "fastapi") ||
96
+ has_token(deps, "starlette") ||
97
+ has_token(deps, "uvicorn")) {
98
+ const module = find_app_module(workdir, /app\s*=\s*(FastAPI|Starlette)\(/i);
99
+ if (module) {
100
+ return {
101
+ start_command: `uvicorn ${module}:app --host 0.0.0.0 --port $PORT`,
102
+ server: "uvicorn",
103
+ };
104
+ }
105
+ }
106
+ // Flask
107
+ if (has_token(deps, "flask")) {
108
+ const module = find_app_module(workdir, /app\s*=\s*Flask\(/i);
109
+ if (module) {
110
+ return {
111
+ start_command: `gunicorn ${module}:app --bind 0.0.0.0:$PORT`,
112
+ server: "gunicorn",
113
+ };
114
+ }
115
+ }
116
+ throw new Error("Could not detect how to start this Python app. Set `startCommand` in faable.json or add a Procfile with a `web:` line.");
117
+ };
118
+ /** Build the dependency-install command run during the Docker build. */
119
+ const build_install_command = (workdir, server, deps) => {
120
+ const steps = [];
121
+ const configured_build = Configuration.instance().buildCommand;
122
+ if (configured_build) {
123
+ steps.push(configured_build);
124
+ }
125
+ else if (fs.existsSync(path__default.join(workdir, "requirements.txt"))) {
126
+ steps.push("pip install --no-cache-dir -r requirements.txt");
127
+ }
128
+ else if (fs.existsSync(path__default.join(workdir, "pyproject.toml"))) {
129
+ steps.push("pip install --no-cache-dir .");
130
+ }
131
+ else if (fs.existsSync(path__default.join(workdir, "Pipfile"))) {
132
+ steps.push("pip install --no-cache-dir pipenv && pipenv install --system --deploy");
133
+ }
134
+ // Ensure the WSGI/ASGI server is available when not already declared.
135
+ if (server === "gunicorn" && !has_token(deps, "gunicorn")) {
136
+ steps.push("pip install --no-cache-dir gunicorn");
137
+ }
138
+ if (server === "uvicorn" && !has_token(deps, "uvicorn")) {
139
+ steps.push("pip install --no-cache-dir uvicorn[standard]");
140
+ }
141
+ if (steps.length === 0) {
142
+ // No manifest found — nothing to install, but warn the user.
143
+ log.warn("No requirements.txt/pyproject.toml/Pipfile found");
144
+ return "true";
145
+ }
146
+ return steps.join(" && ");
147
+ };
148
+ const analyze_python = async (params) => {
149
+ const { workdir } = params;
150
+ const deps = read_dependencies_text(workdir);
151
+ const { start_command, server } = resolve_start(workdir, deps);
152
+ const install_command = build_install_command(workdir, server, deps);
153
+ log.info(`📦 Install command: ${install_command}`);
154
+ log.info(`⚙️ Start command: ${start_command}`);
155
+ return { install_command, start_command };
156
+ };
157
+
158
+ export { analyze_python };
@@ -0,0 +1,42 @@
1
+ import { log } from '../../../log.js';
2
+ import { cmd } from '../../../lib/cmd.js';
3
+ import fs from 'fs-extra';
4
+ import Handlebars from 'handlebars';
5
+ import * as path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename$1 = fileURLToPath(import.meta.url);
9
+ const __dirname$1 = path.dirname(__filename$1);
10
+ const templates_dir = path.join(__dirname$1, "templates");
11
+ const dockerfile = fs.readFileSync(`${templates_dir}/Dockerfile`).toString();
12
+ const entrypoint = fs
13
+ .readFileSync(`${templates_dir}/entrypoint.sh`)
14
+ .toString("utf-8");
15
+ Handlebars.registerHelper("escape", function (variable) {
16
+ const escaped_lines = variable
17
+ .replace(/(['`\\])/g, "\\$1")
18
+ .replace(/([$])/g, "\\$1");
19
+ return escaped_lines.split("\n").join("\\n");
20
+ });
21
+ const docker_template = Handlebars.compile(dockerfile);
22
+ const entrypoint_template = Handlebars.compile(entrypoint);
23
+ const build_docker = async (props) => {
24
+ const { app, workdir, install_command, start_command, template_context } = props;
25
+ const entrypoint_custom = entrypoint_template({});
26
+ log.info(`⚙️ Start command: ${start_command}`);
27
+ // NOTE: use slim to build projects
28
+ const linux_distro = "slim";
29
+ const from = [template_context.from, linux_distro].filter((e) => e).join("-");
30
+ log.info(`Using docker image ${from}`);
31
+ const dockerfile = docker_template({
32
+ from,
33
+ entry_script: entrypoint_custom,
34
+ install_command,
35
+ start_command,
36
+ });
37
+ log.info(`📦 Packaging inside a docker image`);
38
+ const timeout = 10 * 60 * 1000; // 10 minute timeout
39
+ await cmd(`docker build --platform linux/amd64 -t ${app.id} ${workdir} -f -<<EOF\n${dockerfile}\nEOF`, { timeout, enableOutput: true });
40
+ };
41
+
42
+ export { build_docker };
@@ -0,0 +1,25 @@
1
+ import { analyze_python } from './analyze_python.js';
2
+ import { build_docker } from './build_docker.js';
3
+
4
+ const build_python = async (app, options) => {
5
+ const { workdir, runtime } = options;
6
+ if (!runtime.version) {
7
+ throw new Error("Runtime version not specified for python");
8
+ }
9
+ // Resolve how to install deps and start the app. Unlike node, there is no
10
+ // separate local build step: dependency install happens inside the Docker
11
+ // build (where network is available), so we go straight to packaging.
12
+ const { install_command, start_command } = await analyze_python({ workdir });
13
+ await build_docker({
14
+ app,
15
+ workdir,
16
+ install_command,
17
+ start_command,
18
+ template_context: {
19
+ from: `python:${runtime.version}`,
20
+ },
21
+ });
22
+ return { type: "python" };
23
+ };
24
+
25
+ export { build_python };
@@ -0,0 +1,22 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
3
+
4
+ /**
5
+ * Read the `web:` process command from a Procfile, if present. Returns null when
6
+ * there's no Procfile or no `web` entry. Used as a manual start-command override
7
+ * (Heroku-style) before falling back to framework detection.
8
+ */
9
+ const parse_procfile = (workdir) => {
10
+ const procfile = path__default.join(workdir, "Procfile");
11
+ if (!fs.existsSync(procfile))
12
+ return null;
13
+ const content = fs.readFileSync(procfile).toString();
14
+ for (const line of content.split("\n")) {
15
+ const match = line.match(/^\s*web\s*:\s*(.+?)\s*$/);
16
+ if (match?.[1])
17
+ return match[1];
18
+ }
19
+ return null;
20
+ };
21
+
22
+ export { parse_procfile };
@@ -0,0 +1,24 @@
1
+ FROM {{from}}
2
+ LABEL com.faable.cloud="FaableCloud"
3
+ LABEL description="Faablecloud automatic deployment"
4
+
5
+ WORKDIR /faable/app
6
+
7
+ # Environment variables for runtime
8
+ ENV PORT=80
9
+ ENV PYTHONUNBUFFERED=1
10
+ # `escape` keeps `$PORT` literal so the build heredoc (unquoted bash) doesn't
11
+ # eat it; Docker then expands $PORT (=80) when building the ENV value.
12
+ ENV START_COMMAND="{{{escape start_command}}}"
13
+
14
+ # Copy Usercode
15
+ COPY . .
16
+
17
+ # Install dependencies (Python needs them built into the image, unlike node_modules).
18
+ # Triple-stache + escape: keep `&&` intact and any `$` heredoc-safe.
19
+ RUN {{{escape install_command}}}
20
+
21
+ # Entrypoint stript
22
+ RUN echo '{{{escape entry_script}}}' >> entrypoint.sh
23
+
24
+ CMD ["/bin/sh", "./entrypoint.sh"]
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+
3
+ PYTHON_VERSION=$(python --version 2>&1)
4
+ PIP_VERSION=$(pip --version 2>&1)
5
+
6
+ echo "Faable Cloud · [$PYTHON_VERSION] [$PIP_VERSION]"
7
+ eval $START_COMMAND
@@ -2,12 +2,18 @@ import * as R from 'ramda';
2
2
  import { has_any_of_files } from './helpers/has_any_of_files.js';
3
3
  import { strategy_docker } from './strategies/docker.js';
4
4
  import { strategy_nodejs } from './strategies/nodejs.js';
5
+ import { strategy_python } from './strategies/python.js';
5
6
 
6
7
  const runtime_detection = async (workdir) => {
7
8
  const has = R.curry(has_any_of_files);
9
+ // Order matters: node wins for full-stack apps that ship both a package.json
10
+ // and Python deps; Dockerfile is the explicit escape hatch evaluated last.
8
11
  const strategy = R.cond([
9
12
  [has(['package.json']), R.always(strategy_nodejs)],
10
- // [has(["requirements.txt"]), R.always(strategy_python)],
13
+ [
14
+ has(['requirements.txt', 'pyproject.toml', 'Pipfile']),
15
+ R.always(strategy_python)
16
+ ],
11
17
  [has(['Dockerfile']), R.always(strategy_docker)]
12
18
  ])(workdir);
13
19
  if (!strategy) {
@@ -0,0 +1,48 @@
1
+ import fs from 'fs-extra';
2
+ import path__default from 'path';
3
+ import { log } from '../../../../log.js';
4
+
5
+ const DEFAULT_VERSION = "3.11.3";
6
+ /**
7
+ * Resolve the Python version from (in order):
8
+ * 1. runtime.txt → `python-3.11.3`
9
+ * 2. .python-version → `3.11.3` (pyenv)
10
+ * 3. pyproject.toml → `requires-python = ">=3.11"` (first concrete X.Y[.Z])
11
+ * Falls back to DEFAULT_VERSION.
12
+ */
13
+ const resolve_python_version = (workdir) => {
14
+ const runtime_config = path__default.join(workdir, "runtime.txt");
15
+ if (fs.existsSync(runtime_config)) {
16
+ const runtime_data = fs.readFileSync(runtime_config).toString().trim();
17
+ if (!runtime_data.startsWith("python-")) {
18
+ throw new Error("runtime.txt must have runtime format with python-<version>");
19
+ }
20
+ return runtime_data.split("-")[1];
21
+ }
22
+ const python_version_file = path__default.join(workdir, ".python-version");
23
+ if (fs.existsSync(python_version_file)) {
24
+ const version = fs.readFileSync(python_version_file).toString().trim();
25
+ if (version)
26
+ return version;
27
+ }
28
+ const pyproject = path__default.join(workdir, "pyproject.toml");
29
+ if (fs.existsSync(pyproject)) {
30
+ const toml = fs.readFileSync(pyproject).toString();
31
+ const match = toml.match(/requires-python\s*=\s*["'][^0-9]*([0-9]+\.[0-9]+(?:\.[0-9]+)?)/);
32
+ if (match?.[1])
33
+ return match[1];
34
+ }
35
+ return DEFAULT_VERSION;
36
+ };
37
+ const strategy_python = async (workdir) => {
38
+ const runtime_version = resolve_python_version(workdir);
39
+ log.info(`Using python@${runtime_version}`);
40
+ return {
41
+ runtime: {
42
+ name: "python",
43
+ version: runtime_version,
44
+ },
45
+ };
46
+ };
47
+
48
+ export { strategy_python };
@@ -38,6 +38,10 @@ class Configuration {
38
38
  get startCommand() {
39
39
  return this.config.startCommand || "npm run start";
40
40
  }
41
+ /** Start command explicitly set in faable.json, or undefined when relying on the default. */
42
+ get configuredStartCommand() {
43
+ return this.config.startCommand;
44
+ }
41
45
  get buildCommand() {
42
46
  return this.config.buildCommand;
43
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.5.27",
3
+ "version": "1.5.29",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",