@faable/faable 1.5.26 → 1.5.28
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/dist/commands/deploy/git_context.js +39 -0
- package/dist/commands/deploy/index.js +7 -2
- package/dist/commands/deploy/node-pipeline/analyze_package.js +10 -9
- package/dist/commands/deploy/node-pipeline/build_docker.js +5 -1
- package/dist/commands/deploy/node-pipeline/frameworks.js +108 -0
- package/dist/commands/deploy/node-pipeline/index.js +10 -1
- package/dist/commands/deploy/node-pipeline/inject_serve.js +20 -0
- package/dist/commands/link/index.js +32 -0
- package/dist/commands/link/workflow_template.js +37 -0
- package/dist/lib/Configuration.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
|
|
3
|
+
// Quiet git runner: returns trimmed stdout, or undefined on any failure (not a
|
|
4
|
+
// git repo, git missing, etc.). A deploy must never fail because we couldn't
|
|
5
|
+
// read git metadata, so errors are swallowed and the field is just omitted.
|
|
6
|
+
const gitRunner = (workdir) => command => new Promise(resolve => {
|
|
7
|
+
exec(command, { cwd: workdir }, (err, stdout) => {
|
|
8
|
+
if (err)
|
|
9
|
+
return resolve(undefined);
|
|
10
|
+
const out = stdout?.toString().trim();
|
|
11
|
+
resolve(out || undefined);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
// Resolve the commit / ref / actor for the current deploy. In GitHub Actions
|
|
15
|
+
// these come from the standard env vars; locally we fall back to git so manual
|
|
16
|
+
// deploys still record a commit. `github_actor` is CI-only (a GitHub login),
|
|
17
|
+
// left undefined locally where no reliable login is available.
|
|
18
|
+
const git_context = async (opts) => {
|
|
19
|
+
const env = opts?.env ?? process.env;
|
|
20
|
+
const run = opts?.run ?? gitRunner(opts?.workdir);
|
|
21
|
+
const github_commit = env.GITHUB_SHA || (await run("git rev-parse HEAD"));
|
|
22
|
+
let github_ref = env.GITHUB_REF || undefined;
|
|
23
|
+
if (!github_ref) {
|
|
24
|
+
const branch = await run("git rev-parse --abbrev-ref HEAD");
|
|
25
|
+
if (branch && branch !== "HEAD")
|
|
26
|
+
github_ref = `refs/heads/${branch}`;
|
|
27
|
+
}
|
|
28
|
+
const github_actor = env.GITHUB_ACTOR || undefined;
|
|
29
|
+
const ctx = {};
|
|
30
|
+
if (github_commit)
|
|
31
|
+
ctx.github_commit = github_commit;
|
|
32
|
+
if (github_ref)
|
|
33
|
+
ctx.github_ref = github_ref;
|
|
34
|
+
if (github_actor)
|
|
35
|
+
ctx.github_actor = github_actor;
|
|
36
|
+
return ctx;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { git_context };
|
|
@@ -2,6 +2,7 @@ import { context } from '../../api/context.js';
|
|
|
2
2
|
import { cmd } from '../../lib/cmd.js';
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
4
|
import { check_environment } from './check_environment.js';
|
|
5
|
+
import { git_context } from './git_context.js';
|
|
5
6
|
import { build_node } from './node-pipeline/index.js';
|
|
6
7
|
import { runtime_detection } from './runtime-detect/runtime_detection.js';
|
|
7
8
|
import { upload_tag } from './upload_tag.js';
|
|
@@ -58,11 +59,15 @@ const deploy = {
|
|
|
58
59
|
}
|
|
59
60
|
// Upload to Faable registry
|
|
60
61
|
const { upload_tagname } = await upload_tag({ app, api });
|
|
62
|
+
// Capture the commit/ref/actor so the deployment records which commit it
|
|
63
|
+
// came from and who pushed it (env in CI, git fallback locally).
|
|
64
|
+
const git = await git_context({ workdir });
|
|
61
65
|
// Create a deployment for this image
|
|
62
66
|
const deployment = await api.createDeployment({
|
|
63
67
|
app_id: app.id,
|
|
64
68
|
image: upload_tagname,
|
|
65
|
-
type
|
|
69
|
+
type,
|
|
70
|
+
...git
|
|
66
71
|
});
|
|
67
72
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
68
73
|
log.info(`🌍 Deployment created (${deployment.id}) -> https://${app.url}`);
|
|
@@ -83,7 +88,7 @@ const deploy = {
|
|
|
83
88
|
break;
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
|
-
catch (
|
|
91
|
+
catch (_error) {
|
|
87
92
|
// Ignore transient errors while polling and keep waiting
|
|
88
93
|
log.debug(`Polling app status failed, retrying...`);
|
|
89
94
|
}
|
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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({
|
|
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 };
|
|
@@ -3,6 +3,7 @@ import prompts from 'prompts';
|
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
4
|
import { cmd } from '../../lib/cmd.js';
|
|
5
5
|
import { Configuration } from '../../lib/Configuration.js';
|
|
6
|
+
import { workflowExists, DEPLOY_WORKFLOW_PATH, writeWorkflow, DEPLOY_DOCS_URL, DEPLOY_WORKFLOW_YAML } from './workflow_template.js';
|
|
6
7
|
|
|
7
8
|
const getGitRemoteUrl = async (workdir) => {
|
|
8
9
|
try {
|
|
@@ -117,7 +118,38 @@ const link = {
|
|
|
117
118
|
// Save locally for CLI convenience (only after the API confirms the link)
|
|
118
119
|
Configuration.instance().saveConfig({ app_slug: selectedApp.name, app_id: selectedApp.id });
|
|
119
120
|
log.info(`Successfully linked local repository to ${selectedApp.name}.`);
|
|
121
|
+
// Onboarding: deploys happen via a GitHub Actions workflow on push. Offer
|
|
122
|
+
// to scaffold it, and always explain the next steps so the user isn't left
|
|
123
|
+
// wondering why nothing deploys.
|
|
124
|
+
await setupDeployWorkflow(workdir);
|
|
120
125
|
},
|
|
121
126
|
};
|
|
127
|
+
const setupDeployWorkflow = async (workdir) => {
|
|
128
|
+
if (workflowExists(workdir)) {
|
|
129
|
+
log.info(`Deploy workflow already present at ${DEPLOY_WORKFLOW_PATH}. Commit & push to "main" to deploy.`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const { create } = await prompts({
|
|
133
|
+
type: "toggle",
|
|
134
|
+
name: "create",
|
|
135
|
+
message: `Create the GitHub Actions deploy workflow (${DEPLOY_WORKFLOW_PATH})?`,
|
|
136
|
+
initial: true,
|
|
137
|
+
active: "yes",
|
|
138
|
+
inactive: "no",
|
|
139
|
+
});
|
|
140
|
+
if (create) {
|
|
141
|
+
const filePath = await writeWorkflow(workdir);
|
|
142
|
+
log.info(`Created ${filePath}`);
|
|
143
|
+
log.info("Next steps:");
|
|
144
|
+
log.info(" 1. Commit the workflow file");
|
|
145
|
+
log.info(' 2. Push to "main" — that triggers your first deploy');
|
|
146
|
+
log.info(`Docs: ${DEPLOY_DOCS_URL}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
log.info(`Skipped. To enable automated deploys, add ${DEPLOY_WORKFLOW_PATH} with:`);
|
|
150
|
+
log.info(`\n${DEPLOY_WORKFLOW_YAML}`);
|
|
151
|
+
log.info(`Then commit & push to "main". Docs: ${DEPLOY_DOCS_URL}`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
122
154
|
|
|
123
155
|
export { link };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
|
|
5
|
+
// The canonical GitHub Actions workflow that deploys a Faable app on push.
|
|
6
|
+
// Mirrors the docs at https://faable.com/docs/deploy/github-actions.
|
|
7
|
+
const DEPLOY_WORKFLOW_PATH = ".github/workflows/deploy.yaml";
|
|
8
|
+
const DEPLOY_WORKFLOW_YAML = `name: Deploy to Faable
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
branches:
|
|
12
|
+
- main
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: write
|
|
16
|
+
pull-requests: write
|
|
17
|
+
issues: write
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
timeout-minutes: 10
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v6
|
|
24
|
+
- uses: actions/setup-node@v6
|
|
25
|
+
- run: npm ci
|
|
26
|
+
- run: npx @faable/faable@latest deploy
|
|
27
|
+
`;
|
|
28
|
+
const DEPLOY_DOCS_URL = "https://faable.com/docs/deploy/github-actions";
|
|
29
|
+
const workflowExists = (workdir) => existsSync(join(workdir, DEPLOY_WORKFLOW_PATH));
|
|
30
|
+
const writeWorkflow = async (workdir) => {
|
|
31
|
+
const filePath = join(workdir, DEPLOY_WORKFLOW_PATH);
|
|
32
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
33
|
+
await writeFile(filePath, DEPLOY_WORKFLOW_YAML, "utf8");
|
|
34
|
+
return filePath;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { DEPLOY_DOCS_URL, DEPLOY_WORKFLOW_PATH, DEPLOY_WORKFLOW_YAML, workflowExists, writeWorkflow };
|
|
@@ -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
|
}
|