@faable/faable 4.18.5 → 4.20.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/dist/api/FaableApi.js
CHANGED
|
@@ -290,6 +290,11 @@ class FaableApi {
|
|
|
290
290
|
headers: projectHeader(team)
|
|
291
291
|
}));
|
|
292
292
|
}
|
|
293
|
+
// Native GitLab (api §393): link a gitlab.com project; the api creates the
|
|
294
|
+
// push webhook with the caller's GitLab token.
|
|
295
|
+
async linkGitlab(app_id, params, team) {
|
|
296
|
+
return data(this.client.post(`/app/${app_id}/link-gitlab`, params, { headers: projectHeader(team) }));
|
|
297
|
+
}
|
|
293
298
|
// Organizations/accounts where the Faable GitHub App is installed.
|
|
294
299
|
async listGithubInstallations() {
|
|
295
300
|
return data(this.client.get(`/github/installations`)).then(res => res.installations);
|
|
@@ -379,6 +384,13 @@ class FaableApi {
|
|
|
379
384
|
async setDeployBranch(app_id, github_branch) {
|
|
380
385
|
return data(this.client.post(`/app/${app_id}/deploy-branch`, { github_branch }));
|
|
381
386
|
}
|
|
387
|
+
// The GitLab project (numeric id) whose CI may deploy the app keyless; null
|
|
388
|
+
// revokes it.
|
|
389
|
+
async setCiTrust(app_id, gitlab_project_id) {
|
|
390
|
+
return data(this.client.post(`/app/${app_id}/ci-trust`, {
|
|
391
|
+
gitlab_project_id
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
382
394
|
// null clears the platform override (faable.json's rootDir applies again).
|
|
383
395
|
async setRootDir(app_id, root_dir) {
|
|
384
396
|
return data(this.client.post(`/app/${app_id}/root-dir`, { root_dir }));
|
package/dist/api/context.js
CHANGED
|
@@ -38,6 +38,21 @@ const context = async (targetAppId) => {
|
|
|
38
38
|
console.error("Error fetching token, configure 'permissions: id-token: write'");
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
else if (process.env.GITLAB_CI === "true" && process.env.FAABLE_ID_TOKEN) {
|
|
42
|
+
// GitLab CI: the job declares
|
|
43
|
+
// id_tokens: { FAABLE_ID_TOKEN: { aud: https://faable.com } }
|
|
44
|
+
// and the app must trust its project (`deploy apps set --gitlab-project`).
|
|
45
|
+
// Without FAABLE_ID_TOKEN a GitLab job falls through to an API key or
|
|
46
|
+
// the local credentials, as before.
|
|
47
|
+
api = FaableApi.create({
|
|
48
|
+
authStrategy: oidc_strategy,
|
|
49
|
+
auth: {
|
|
50
|
+
idToken: process.env.FAABLE_ID_TOKEN,
|
|
51
|
+
appId: targetAppId,
|
|
52
|
+
provider: "gitlab",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
41
56
|
else {
|
|
42
57
|
const store = new CredentialsStore();
|
|
43
58
|
// Auto-refreshes an expired access token via the stored refresh_token.
|
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { create_base_client } from '../base_client.js';
|
|
3
3
|
|
|
4
|
+
// GitLab has no linked repository to infer the app from: the job names it
|
|
5
|
+
// with `faable deploy --app`, and the api checks the app trusts the token's
|
|
6
|
+
// project (`faable deploy apps set --gitlab-project <id>`).
|
|
7
|
+
const exchangeGitlabOidcToken = async (gl_token, target_app_id) => {
|
|
8
|
+
if (!target_app_id) {
|
|
9
|
+
throw new Error("Keyless deploys from GitLab CI need the app: run `faable deploy --app <app_id>`.");
|
|
10
|
+
}
|
|
11
|
+
const client = create_base_client();
|
|
12
|
+
try {
|
|
13
|
+
const res = await client.post("/auth/gitlab-oidc", {
|
|
14
|
+
token: gl_token,
|
|
15
|
+
app_id: target_app_id,
|
|
16
|
+
});
|
|
17
|
+
const { access_token, app_id } = res.data;
|
|
18
|
+
return { access_token, app_id };
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (axios.isAxiosError(err)) {
|
|
22
|
+
const status = err.response?.status;
|
|
23
|
+
const serverMessage = err.response?.data?.message;
|
|
24
|
+
throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`, { cause: err });
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
4
29
|
const exchangeGithubOidcToken = async (gh_token, target_app_id) => {
|
|
5
30
|
const client = create_base_client();
|
|
6
31
|
try {
|
|
@@ -35,14 +60,16 @@ const exchangeGithubOidcToken = async (gh_token, target_app_id) => {
|
|
|
35
60
|
}
|
|
36
61
|
};
|
|
37
62
|
const oidc_strategy = (config) => {
|
|
38
|
-
const { idToken, appId } = config;
|
|
63
|
+
const { idToken, appId, provider = "github" } = config;
|
|
39
64
|
if (!idToken) {
|
|
40
65
|
throw new Error("Missing idToken.");
|
|
41
66
|
}
|
|
42
67
|
let token_ex;
|
|
43
68
|
const ensure = async () => {
|
|
44
69
|
if (!token_ex)
|
|
45
|
-
token_ex = await
|
|
70
|
+
token_ex = await (provider === "gitlab"
|
|
71
|
+
? exchangeGitlabOidcToken(idToken, appId)
|
|
72
|
+
: exchangeGithubOidcToken(idToken, appId));
|
|
46
73
|
return token_ex;
|
|
47
74
|
};
|
|
48
75
|
return {
|
|
@@ -31,16 +31,25 @@ const apps_set = {
|
|
|
31
31
|
choices: MODES,
|
|
32
32
|
description: 'push: every push deploys · ci: once CI tags a release · workflow: your own GitHub workflow runs faable deploy'
|
|
33
33
|
})
|
|
34
|
-
.
|
|
34
|
+
.option('gitlab-project', {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'GitLab project ID (the number on its page) whose CI may deploy this app without an API key ("" revokes it)'
|
|
37
|
+
})
|
|
38
|
+
.check(({ branch, rootDir, mode, gitlabProject }) => {
|
|
35
39
|
if (branch === undefined &&
|
|
36
40
|
rootDir === undefined &&
|
|
37
|
-
mode === undefined
|
|
38
|
-
|
|
41
|
+
mode === undefined &&
|
|
42
|
+
gitlabProject === undefined) {
|
|
43
|
+
throw new Error('Nothing to change: pass --branch, --root-dir, --mode or --gitlab-project.');
|
|
44
|
+
}
|
|
45
|
+
if (gitlabProject && !/^[0-9]+$/.test(String(gitlabProject))) {
|
|
46
|
+
throw new Error('--gitlab-project takes the numeric Project ID shown on the GitLab project page, not its path.');
|
|
39
47
|
}
|
|
40
48
|
return true;
|
|
41
49
|
})
|
|
42
50
|
.example('$0 deploy apps set --branch release', 'Deploy from "release"')
|
|
43
51
|
.example('$0 deploy apps set --root-dir apps/web', 'A monorepo app')
|
|
52
|
+
.example('$0 deploy apps set --gitlab-project 12345678', 'Let that GitLab project deploy keyless')
|
|
44
53
|
.showHelpOnFail(false),
|
|
45
54
|
handler: async (args) => {
|
|
46
55
|
const ctx = await requireApi();
|
|
@@ -60,6 +69,12 @@ const apps_set = {
|
|
|
60
69
|
await ctx.api.setDeployMode(app_id, args.mode);
|
|
61
70
|
log.info(`⚙️ Deploy mode: ${args.mode}`);
|
|
62
71
|
}
|
|
72
|
+
if (args.gitlabProject !== undefined) {
|
|
73
|
+
await ctx.api.setCiTrust(app_id, String(args.gitlabProject) || null);
|
|
74
|
+
log.info(args.gitlabProject
|
|
75
|
+
? `🦊 GitLab project ${args.gitlabProject} can deploy this app without an API key`
|
|
76
|
+
: '🦊 GitLab keyless deploys revoked');
|
|
77
|
+
}
|
|
63
78
|
const app = await ctx.api.getApp(app_id);
|
|
64
79
|
if (args.json)
|
|
65
80
|
return print_json(app);
|
|
@@ -28,6 +28,10 @@ const link = {
|
|
|
28
28
|
alias: "r",
|
|
29
29
|
type: "string",
|
|
30
30
|
description: "GitHub repository, owner/repo or its URL (defaults to the origin remote of --workdir)",
|
|
31
|
+
})
|
|
32
|
+
.option("gitlab", {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "Link a gitlab.com project instead: its Project ID (the number on its page). Needs --app and GitLab connected in the dashboard",
|
|
31
35
|
})
|
|
32
36
|
.option("branch", {
|
|
33
37
|
alias: "b",
|
|
@@ -40,9 +44,12 @@ const link = {
|
|
|
40
44
|
description: "Print the link as JSON: app, repository, branch and how it deploys",
|
|
41
45
|
})
|
|
42
46
|
.example("$0 deploy link --app web --repo acme/web", "Link acme/web to the app web: from now on a push deploys it")
|
|
47
|
+
.example("$0 deploy link --app web --gitlab 12345678", "Link that gitlab.com project to the app web: pushes deploy it")
|
|
43
48
|
.showHelpOnFail(false);
|
|
44
49
|
},
|
|
45
50
|
handler: async (args) => {
|
|
51
|
+
if (args.gitlab !== undefined)
|
|
52
|
+
return link_gitlab(args);
|
|
46
53
|
if (args.app || args.json || is_non_interactive()) {
|
|
47
54
|
return link_to_app(args);
|
|
48
55
|
}
|
|
@@ -147,6 +154,39 @@ const link = {
|
|
|
147
154
|
// repository to an existing app at all (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md,
|
|
148
155
|
// F4; backlog §366). Driven by a program, the repository is spelled out too:
|
|
149
156
|
// the origin remote of whatever directory the process runs in is a guess.
|
|
157
|
+
const link_gitlab = async (args) => {
|
|
158
|
+
if (!args.app) {
|
|
159
|
+
throw new CliError("app_required", "Say which app to link: --app <id|name>.");
|
|
160
|
+
}
|
|
161
|
+
const project_id = String(args.gitlab ?? "");
|
|
162
|
+
if (!/^[0-9]+$/.test(project_id)) {
|
|
163
|
+
throw new CliError("usage", "--gitlab takes the numeric Project ID shown on the GitLab project page, not its path.");
|
|
164
|
+
}
|
|
165
|
+
const { api } = await requireApi();
|
|
166
|
+
const app = await api.getApp(await resolve_app_ref(api, args.app));
|
|
167
|
+
let linked;
|
|
168
|
+
try {
|
|
169
|
+
linked = await api.linkGitlab(app.id, { project_id, ...(args.branch ? { branch: args.branch } : {}) }, app.team);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const e = err;
|
|
173
|
+
const failed = new CliError("usage", `Could not link GitLab project ${project_id} to ${app.name}: ${e.message}`, { status: e.status, action: e.action, cause: err });
|
|
174
|
+
// gitlab_identity_missing / gitlab_token_invalid / gitlab_project_not_accessible
|
|
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
|
+
gitlab_project: linked?.gitlab_project_path ?? project_id,
|
|
183
|
+
branch: linked?.gitlab_branch,
|
|
184
|
+
};
|
|
185
|
+
if (args.json)
|
|
186
|
+
return print_json(result);
|
|
187
|
+
log.info(`Linked ${result.gitlab_project} (GitLab) to ${app.name} (${app.id}).`);
|
|
188
|
+
log.info(`Push to deploy is on: the next push to "${result.branch ?? "the default branch"}" deploys.`);
|
|
189
|
+
};
|
|
150
190
|
const link_to_app = async (args) => {
|
|
151
191
|
if (!args.app) {
|
|
152
192
|
throw new CliError("app_required", "Say which app to link: --app <id|name>. Without a terminal there is no list to pick from.");
|