@faable/faable 1.5.25 → 1.5.27

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.
@@ -34,9 +34,15 @@ class FaableApi {
34
34
  const url = e.config?.url || "";
35
35
  if (res) {
36
36
  const serverMessage = res.data?.message || res.statusText || "Unknown Error";
37
- throw new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, {
38
- cause: error,
39
- });
37
+ const wrapped = new Error(`FaableApi ${url} ${res.status}: ${serverMessage}`, { cause: error });
38
+ // Surface the structured error contract (e.g. the repository-link
39
+ // flow returns { code, action }) so callers can branch on it.
40
+ wrapped.status = res.status;
41
+ if (res.data?.code)
42
+ wrapped.code = res.data.code;
43
+ if (res.data?.action)
44
+ wrapped.action = res.data.action;
45
+ throw wrapped;
40
46
  }
41
47
  else {
42
48
  throw new Error(`FaableApi ${url} ${e.message}`, { cause: error });
@@ -69,6 +75,17 @@ class FaableApi {
69
75
  async updateApp(app_id, params) {
70
76
  return data(this.client.post(`/app/${app_id}`, params));
71
77
  }
78
+ async linkRepository(app_id, params) {
79
+ return data(this.client.post(`/app/${app_id}/link-repository`, params));
80
+ }
81
+ // Organizations/accounts where the Faable GitHub App is installed.
82
+ async listGithubInstallations() {
83
+ return data(this.client.get(`/github/installations`)).then((res) => res.installations);
84
+ }
85
+ // Top repositories for a single installation (org), optionally filtered.
86
+ async listGithubRepos(installation_id, params = {}) {
87
+ return data(this.client.get(`/github/installations/${installation_id}/repositories`, { params })).then((res) => res.repositories);
88
+ }
72
89
  async getMe() {
73
90
  return data(this.client.get(`/auth/me`));
74
91
  }
@@ -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 (error) {
91
+ catch (_error) {
87
92
  // Ignore transient errors while polling and keep waiting
88
93
  log.debug(`Polling app status failed, retrying...`);
89
94
  }
@@ -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 {
@@ -84,18 +85,71 @@ const link = {
84
85
  return;
85
86
  }
86
87
  log.info(`Linking to "${selectedApp.name}" (${selectedApp.id})...`);
87
- // Update the app in the API
88
- if (gitUrl) {
89
- await api.updateApp(selectedApp.id, { repository: gitUrl });
90
- log.info(`Updated app with github_repo: ${gitUrl}`);
88
+ if (!gitUrl) {
89
+ log.error("No git remote URL detected. Add a GitHub 'origin' remote and try again.");
90
+ return;
91
91
  }
92
- else {
93
- log.warn("No git remote URL detected. Skipping API update for github_repo.");
92
+ // The API verifies that the user has a connected GitHub identity AND
93
+ // access to the repository before persisting the link.
94
+ try {
95
+ await api.linkRepository(selectedApp.id, { repository: gitUrl });
96
+ }
97
+ catch (err) {
98
+ const code = err?.code;
99
+ switch (code) {
100
+ case "github_identity_missing":
101
+ log.error("You have not connected a GitHub account to Faable. Connect GitHub in the dashboard, then re-run `faable link`.");
102
+ break;
103
+ case "github_token_invalid":
104
+ log.error("Your GitHub authorization has expired. Reconnect GitHub in the dashboard, then re-run `faable link`.");
105
+ break;
106
+ case "github_installation_missing":
107
+ log.error("The Faable GitHub App is not installed on your repositories. Install it from the Faable dashboard, then re-run `faable link`.");
108
+ break;
109
+ case "github_repository_not_found":
110
+ log.error(`Faable can't access "${gitUrl}". Check the repository name and that the Faable GitHub App is installed on it.`);
111
+ break;
112
+ default:
113
+ log.error(`Could not link repository: ${err?.message ?? err}`);
114
+ }
115
+ return;
94
116
  }
95
- // Save locally for CLI convenience
117
+ log.info(`Linked repository ${gitUrl} to ${selectedApp.name}.`);
118
+ // Save locally for CLI convenience (only after the API confirms the link)
96
119
  Configuration.instance().saveConfig({ app_slug: selectedApp.name, app_id: selectedApp.id });
97
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);
98
125
  },
99
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
+ };
100
154
 
101
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.5.25",
3
+ "version": "1.5.27",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",