@faable/faable 1.11.0 → 1.12.1

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.
@@ -79,6 +79,15 @@ class FaableApi {
79
79
  async getAppSecrets(app_id) {
80
80
  return firstPage(data(this.client.get(`/secret/${app_id}`)));
81
81
  }
82
+ // Replace the app's whole secret set (the endpoint deletes and recreates).
83
+ // This is the only mutation path that triggers an immediate restart of the
84
+ // app; the per-secret upsert/delete endpoints are not used by the CLI.
85
+ // The endpoint stamps the created secrets with the team from the request
86
+ // context, which a CLI user token does not carry — pass the app's team
87
+ // (from getApp) so it travels as the `x-faable-team` header.
88
+ async createSecretsBatch(context_id, team, secrets) {
89
+ return data(this.client.post(`/secret/createbatch`, { context_id, secrets }, { headers: { "x-faable-team": team } }));
90
+ }
82
91
  async updateApp(app_id, params) {
83
92
  return data(this.client.post(`/app/${app_id}`, params));
84
93
  }
@@ -1,17 +1,24 @@
1
1
  import { requireApi } from '../../api/context.js';
2
2
  import { Configuration } from '../../lib/Configuration.js';
3
3
  import { log } from '../../log.js';
4
+ import { link } from '../link/index.js';
4
5
  import { plan_summary } from './buildpacks/Buildpack.js';
5
6
  import { detect_buildpack, get_buildpack, buildpack_names } from './buildpacks/registry.js';
6
7
  import { check_environment } from './check_environment.js';
7
8
  import { git_context } from './git_context.js';
9
+ import { resolve_app_id } from './resolve_app_id.js';
10
+ import { secrets } from './secrets/index.js';
8
11
  import { upload_tag } from './upload_tag.js';
9
12
 
10
13
  const deploy = {
11
14
  command: 'deploy [app_id]',
12
15
  describe: 'Deploy a faable app',
13
16
  builder: yargs => {
17
+ // Product subcommands live under `deploy` (yargs matches them before the
18
+ // app_id positional, so `faable deploy <app_id>` keeps working).
14
19
  return yargs
20
+ .command(secrets)
21
+ .command(link)
15
22
  .positional('app_id', {
16
23
  type: 'string',
17
24
  description: 'App Identifier'
@@ -37,14 +44,7 @@ const deploy = {
37
44
  // build thinking happens here; build() below just executes the plan.
38
45
  const config = Configuration.instance().deployConfig();
39
46
  const plan = await detect_buildpack({ workdir, config }, args.buildpack || config.buildpack);
40
- // app_id resolution (the user never has to look one up):
41
- // 1. explicit positional (monorepo escape hatch)
42
- // 2. OIDC in CI — the backend resolves the app from the linked repository
43
- // 3. locally — the app saved by `faable link` in faable.json
44
- const app_id = args.app_id || ctx.appId || Configuration.instance().app_id;
45
- if (!app_id) {
46
- throw new Error('No app linked to this repository. Run "faable link" to link it (or link it from the dashboard).');
47
- }
47
+ const app_id = await resolve_app_id(args.app_id, ctx.appId, api, workdir);
48
48
  const app = await api.getApp(app_id);
49
49
  // Check if we can build docker images
50
50
  await check_environment();
@@ -0,0 +1,33 @@
1
+ import { Configuration } from '../../lib/Configuration.js';
2
+ import { getGitRemoteUrl } from '../../lib/git_remote.js';
3
+ import { log } from '../../log.js';
4
+
5
+ // app_id resolution (the user never has to look one up):
6
+ // 1. explicit (positional on `deploy`, --app on subcommands)
7
+ // 2. OIDC in CI — the backend resolves the app from the linked repository
8
+ // 3. locally — the app saved by `faable deploy link` in faable.json
9
+ // 4. locally — the app whose linked repository matches the git origin remote
10
+ // of the working directory (repos are connected in the dashboard when the
11
+ // app is created, so most working copies never ran `link`)
12
+ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd()) => {
13
+ const app_id = explicit || ctxAppId || Configuration.instance().app_id;
14
+ if (app_id)
15
+ return app_id;
16
+ const repository = await getGitRemoteUrl(workdir);
17
+ if (repository) {
18
+ const apps = await api.list();
19
+ const matches = apps.filter(app => app.repository === repository);
20
+ if (matches.length === 1) {
21
+ const app = matches[0];
22
+ log.info(`🔎 Detected app "${app.name}" (${app.id}) from repository ${repository}`);
23
+ return app.id;
24
+ }
25
+ if (matches.length > 1) {
26
+ const ids = matches.map(app => `${app.name} (${app.id})`).join(', ');
27
+ throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass the app explicitly.`);
28
+ }
29
+ }
30
+ throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass the app explicitly.');
31
+ };
32
+
33
+ export { resolve_app_id };
@@ -0,0 +1,19 @@
1
+ import { secrets_list } from './list.js';
2
+ import { secrets_rm } from './rm.js';
3
+ import { secrets_set } from './set.js';
4
+
5
+ const secrets = {
6
+ command: 'secrets <command>',
7
+ describe: 'Manage app secrets (environment variables)',
8
+ builder: yargs => yargs
9
+ .command(secrets_list)
10
+ .command(secrets_set)
11
+ .command(secrets_rm)
12
+ .demandCommand(1, 'Specify a secrets command: list, set or rm'),
13
+ handler: () => {
14
+ // Unreachable: demandCommand(1) either routes to a subcommand or fails
15
+ // through the global .fail() in src/index.ts.
16
+ }
17
+ };
18
+
19
+ export { secrets };
@@ -0,0 +1,45 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { mask_value } from './mask.js';
5
+
6
+ const secrets_list = {
7
+ command: 'list',
8
+ describe: 'List app secrets (values masked by default)',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .option('show', {
16
+ type: 'boolean',
17
+ default: false,
18
+ description: 'Reveal full secret values'
19
+ })
20
+ .example('$0 deploy secrets list', 'List secrets of the linked app (masked)')
21
+ .example('$0 deploy secrets list --show', 'Reveal full values')
22
+ .showHelpOnFail(false),
23
+ handler: async (args) => {
24
+ const ctx = await requireApi();
25
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
26
+ const secrets = await ctx.api.getAppSecrets(app_id);
27
+ if (secrets.length === 0) {
28
+ log.info(`🔐 No secrets set for ${app_id}.`);
29
+ return;
30
+ }
31
+ log.info(`🔐 ${secrets.length} secret(s) for ${app_id}:`);
32
+ const width = Math.max(...secrets.map(s => s.name.length));
33
+ const sorted = [...secrets].sort((a, b) => a.name.localeCompare(b.name));
34
+ for (const secret of sorted) {
35
+ const value = args.show ? secret.value : mask_value(secret.value);
36
+ const origin = secret.related_model === 'profile' ? ' (inherited from team profile)' : '';
37
+ log.info(` ${secret.name.padEnd(width)} ${value}${origin}`);
38
+ }
39
+ if (!args.show) {
40
+ log.info(`Use --show to reveal full values.`);
41
+ }
42
+ }
43
+ };
44
+
45
+ export { secrets_list };
@@ -0,0 +1,12 @@
1
+ const MIN_REVEAL_LENGTH = 8;
2
+ // Short values are fully masked with a fixed-width dot run so their real
3
+ // length is not leaked; longer ones show a 4-char prefix for recognition.
4
+ const mask_value = (value) => {
5
+ if (value.length === 0)
6
+ return '(empty)';
7
+ if (value.length < MIN_REVEAL_LENGTH)
8
+ return '••••••';
9
+ return `${value.slice(0, 4)}…`;
10
+ };
11
+
12
+ export { mask_value };
@@ -0,0 +1,34 @@
1
+ // The mutation endpoint (`/secret/createbatch`) replaces the app's whole
2
+ // secret set, so every change is a read-merge-replace over the CURRENT
3
+ // app-scoped secrets. GET /secret/:app_id also returns secrets inherited
4
+ // from the team profile — those must never be written back through the app
5
+ // context (they would be copied down as app secrets), hence the
6
+ // related_model filter.
7
+ const app_scoped = (existing) => existing
8
+ .filter(s => s.related_model === 'app')
9
+ .map(s => ({ name: s.name, value: s.value }));
10
+ // Upsert `updates` into the app's secrets: existing names are overwritten,
11
+ // new names appended. Returns the full set to send to createbatch.
12
+ const merge_app_secrets = (existing, updates) => {
13
+ const merged = new Map(app_scoped(existing).map(p => [p.name, p.value]));
14
+ for (const { name, value } of updates) {
15
+ merged.set(name, value);
16
+ }
17
+ return [...merged.entries()].map(([name, value]) => ({ name, value }));
18
+ };
19
+ // Remove one secret by name. Throws when the name is not an app-scoped
20
+ // secret — with a dedicated hint when it exists but belongs to the team
21
+ // profile (not manageable through the app context).
22
+ const remove_app_secret = (existing, name) => {
23
+ const app_secrets = app_scoped(existing);
24
+ if (!app_secrets.some(p => p.name === name)) {
25
+ if (existing.some(s => s.related_model === 'profile' && s.name === name)) {
26
+ throw new Error(`"${name}" is inherited from the team profile and cannot be removed from the app. Manage team secrets from the dashboard.`);
27
+ }
28
+ const names = app_secrets.map(p => p.name).sort().join(', ') || '(none)';
29
+ throw new Error(`Secret "${name}" not found. Existing secrets: ${names}`);
30
+ }
31
+ return app_secrets.filter(p => p.name !== name);
32
+ };
33
+
34
+ export { merge_app_secrets, remove_app_secret };
@@ -0,0 +1,26 @@
1
+ // API limits for a secret (mirrors the server-side schema).
2
+ const NAME_MAX = 255;
3
+ const VALUE_MAX = 50000;
4
+ // Split each "KEY=VALUE" on the FIRST '=' only, so values may contain '='.
5
+ // An empty value ("KEY=") is allowed — it is a legitimate way to blank a
6
+ // secret. Throws on the first invalid pair so callers can validate the whole
7
+ // input before writing anything.
8
+ const parse_pairs = (inputs) => {
9
+ return inputs.map(raw => {
10
+ const idx = raw.indexOf('=');
11
+ if (idx <= 0) {
12
+ throw new Error(`Invalid secret "${raw}". Expected KEY=VALUE (e.g. DATABASE_URL=postgres://...).`);
13
+ }
14
+ const name = raw.slice(0, idx);
15
+ const value = raw.slice(idx + 1);
16
+ if (name.length > NAME_MAX) {
17
+ throw new Error(`Secret name "${name.slice(0, 32)}…" exceeds ${NAME_MAX} characters.`);
18
+ }
19
+ if (value.length > VALUE_MAX) {
20
+ throw new Error(`Value for "${name}" exceeds ${VALUE_MAX} characters.`);
21
+ }
22
+ return { name, value };
23
+ });
24
+ };
25
+
26
+ export { NAME_MAX, VALUE_MAX, parse_pairs };
@@ -0,0 +1,59 @@
1
+ import prompts from 'prompts';
2
+ import { requireApi } from '../../../api/context.js';
3
+ import { log } from '../../../log.js';
4
+ import { resolve_app_id } from '../resolve_app_id.js';
5
+ import { remove_app_secret } from './merge.js';
6
+
7
+ const secrets_rm = {
8
+ command: 'rm <name>',
9
+ describe: 'Remove a secret by name',
10
+ builder: yargs => yargs
11
+ .positional('name', {
12
+ type: 'string',
13
+ demandOption: true,
14
+ description: 'Secret name'
15
+ })
16
+ .option('app', {
17
+ alias: 'a',
18
+ type: 'string',
19
+ description: 'App Identifier (defaults to the linked app)'
20
+ })
21
+ .option('yes', {
22
+ alias: 'y',
23
+ type: 'boolean',
24
+ default: false,
25
+ description: 'Skip the confirmation prompt'
26
+ })
27
+ .example('$0 deploy secrets rm API_KEY', 'Remove API_KEY after confirmation')
28
+ .showHelpOnFail(false),
29
+ handler: async (args) => {
30
+ const ctx = await requireApi();
31
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
32
+ // Mutations go through createbatch (replace-all), so compute the
33
+ // remaining set first — this also rejects unknown names and secrets
34
+ // inherited from the team profile.
35
+ const app = await ctx.api.getApp(app_id);
36
+ const secrets = await ctx.api.getAppSecrets(app_id);
37
+ const remaining = remove_app_secret(secrets, args.name);
38
+ if (!args.yes) {
39
+ // In a non-TTY run without --yes, prompts resolves undefined → cancel.
40
+ const { confirm } = await prompts({
41
+ type: 'toggle',
42
+ name: 'confirm',
43
+ message: `Remove secret "${args.name}" from ${app_id}?`,
44
+ initial: false,
45
+ active: 'yes',
46
+ inactive: 'no'
47
+ });
48
+ if (!confirm) {
49
+ log.info('Cancelled.');
50
+ return;
51
+ }
52
+ }
53
+ await ctx.api.createSecretsBatch(app.id, app.team, remaining);
54
+ log.info(`🗑️ Removed secret ${args.name} from ${app_id}.`);
55
+ log.info(`ℹ️ The app is restarting to apply the changes.`);
56
+ }
57
+ };
58
+
59
+ export { secrets_rm };
@@ -0,0 +1,48 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { merge_app_secrets } from './merge.js';
5
+ import { parse_pairs } from './parse_pairs.js';
6
+
7
+ const secrets_set = {
8
+ command: 'set <pairs...>',
9
+ describe: 'Set one or more secrets as KEY=VALUE',
10
+ builder: yargs => yargs
11
+ .positional('pairs', {
12
+ type: 'string',
13
+ array: true,
14
+ demandOption: true,
15
+ description: 'KEY=VALUE pairs (quote values containing spaces)'
16
+ })
17
+ .option('app', {
18
+ alias: 'a',
19
+ type: 'string',
20
+ description: 'App Identifier (defaults to the linked app)'
21
+ })
22
+ .example('$0 deploy secrets set API_KEY=abc123', 'Set a single secret')
23
+ .example('$0 deploy secrets set A=1 DB_URL=postgres://u:p@host/db', 'Set several at once (values may contain "=")')
24
+ .showHelpOnFail(false),
25
+ handler: async (args) => {
26
+ // Validate ALL pairs before writing ANY, so one malformed pair aborts the
27
+ // whole command with no partial writes.
28
+ const parsed = parse_pairs(args.pairs);
29
+ const ctx = await requireApi();
30
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
31
+ // getApp also validates access to the app and provides the team the
32
+ // batch endpoint requires as request context.
33
+ const app = await ctx.api.getApp(app_id);
34
+ const existing = await ctx.api.getAppSecrets(app_id);
35
+ const merged = merge_app_secrets(existing, parsed);
36
+ await ctx.api.createSecretsBatch(app.id, app.team, merged);
37
+ const current = new Set(existing.filter(s => s.related_model === 'app').map(s => s.name));
38
+ for (const { name } of parsed) {
39
+ log.info(current.has(name)
40
+ ? `🔑 Updated secret ${name} on ${app_id}`
41
+ : `🔑 Added secret ${name} to ${app_id}`);
42
+ }
43
+ log.info(`✅ ${parsed.length} secret(s) saved to ${app_id}.`);
44
+ log.info(`ℹ️ The app is restarting to apply the changes.`);
45
+ }
46
+ };
47
+
48
+ export { secrets_set };
@@ -1,28 +1,10 @@
1
1
  import { requireApi } from '../../api/context.js';
2
2
  import prompts from 'prompts';
3
3
  import { log } from '../../log.js';
4
- import { cmd } from '../../lib/cmd.js';
5
4
  import { Configuration } from '../../lib/Configuration.js';
5
+ import { getGitRemoteUrl } from '../../lib/git_remote.js';
6
6
  import { workflowExists, DEPLOY_WORKFLOW_PATH, writeWorkflow, DEPLOY_DOCS_URL, DEPLOY_WORKFLOW_YAML } from './workflow_template.js';
7
7
 
8
- const getGitRemoteUrl = async (workdir) => {
9
- try {
10
- const { stdout } = await cmd("git remote get-url origin", { cwd: workdir });
11
- const url = stdout?.toString().trim();
12
- if (!url)
13
- return undefined;
14
- // Extract org/repo from github urls
15
- const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
16
- if (match) {
17
- return match[1];
18
- }
19
- return url;
20
- }
21
- catch {
22
- log.warn("Could not detect git remote origin URL.");
23
- return undefined;
24
- }
25
- };
26
8
  const link = {
27
9
  command: "link",
28
10
  describe: "Link the local repository with a Faable app",
@@ -121,6 +103,18 @@ const link = {
121
103
  await setupDeployWorkflow(workdir);
122
104
  },
123
105
  };
106
+ // Top-level `faable link` predates the per-product layout (`faable deploy link`).
107
+ // Kept as a hidden alias so existing docs/scripts don't break; remove in a
108
+ // future major.
109
+ const link_deprecated = {
110
+ ...link,
111
+ describe: false,
112
+ deprecated: true,
113
+ handler: async (args) => {
114
+ log.warn('⚠️ "faable link" is deprecated, use "faable deploy link".');
115
+ await link.handler(args);
116
+ }
117
+ };
124
118
  const setupDeployWorkflow = async (workdir) => {
125
119
  if (workflowExists(workdir)) {
126
120
  log.info(`Deploy workflow already present at ${DEPLOY_WORKFLOW_PATH}. Commit & push to "main" to deploy.`);
@@ -149,4 +143,4 @@ const setupDeployWorkflow = async (workdir) => {
149
143
  }
150
144
  };
151
145
 
152
- export { link };
146
+ export { link, link_deprecated };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import yargs from 'yargs';
2
2
  import { hideBin } from 'yargs/helpers';
3
3
  import { deploy } from './commands/deploy/index.js';
4
- import { link } from './commands/link/index.js';
4
+ import { link_deprecated } from './commands/link/index.js';
5
5
  import { login } from './commands/login/index.js';
6
6
  import { logout } from './commands/logout/index.js';
7
7
  import { whoami } from './commands/whoami/index.js';
@@ -33,7 +33,7 @@ yg.scriptName('faable')
33
33
  .command(login)
34
34
  .command(logout)
35
35
  .command(whoami)
36
- .command(link)
36
+ .command(link_deprecated)
37
37
  .demandCommand(1)
38
38
  .help()
39
39
  .fail(function (msg, err) {
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'promisify-child-process';
2
+ import { log } from '../log.js';
3
+
4
+ // Returns the "org/repo" slug of the git origin remote (the format the API
5
+ // stores in `app.repository`), the raw URL for non-GitHub remotes, or
6
+ // undefined when there is no usable remote.
7
+ //
8
+ // This is a best-effort auto-detection: running outside a git repository (or
9
+ // without an `origin` remote) is an expected, benign case, so failures are
10
+ // swallowed at debug level — never surfaced as errors/warnings. It does NOT
11
+ // go through `cmd()` on purpose: that helper loudly logs stderr and the exit
12
+ // code, which is right for user-invoked build steps but pure noise here.
13
+ const getGitRemoteUrl = async (workdir) => {
14
+ try {
15
+ const child = spawn('git', ['remote', 'get-url', 'origin'], {
16
+ encoding: 'utf8',
17
+ stdio: 'pipe',
18
+ cwd: workdir
19
+ });
20
+ const { stdout } = await child;
21
+ const url = stdout?.toString().trim();
22
+ if (!url)
23
+ return undefined;
24
+ // Extract org/repo from github urls
25
+ const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
26
+ if (match) {
27
+ return match[1];
28
+ }
29
+ return url;
30
+ }
31
+ catch {
32
+ log.debug('No git origin remote detected; skipping repo-based app lookup.');
33
+ return undefined;
34
+ }
35
+ };
36
+
37
+ export { getGitRemoteUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",