@faable/faable 1.10.0 โ 1.12.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 +9 -0
- package/dist/commands/deploy/buildpacks/node/ensure_dependencies.js +3 -1
- package/dist/commands/deploy/index.js +10 -10
- package/dist/commands/deploy/resolve_app_id.js +33 -0
- package/dist/commands/deploy/secrets/index.js +19 -0
- package/dist/commands/deploy/secrets/list.js +45 -0
- package/dist/commands/deploy/secrets/mask.js +12 -0
- package/dist/commands/deploy/secrets/merge.js +34 -0
- package/dist/commands/deploy/secrets/parse_pairs.js +26 -0
- package/dist/commands/deploy/secrets/rm.js +59 -0
- package/dist/commands/deploy/secrets/set.js +48 -0
- package/dist/commands/link/index.js +14 -20
- package/dist/index.js +2 -2
- package/dist/lib/git_remote.js +26 -0
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -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
|
}
|
|
@@ -46,8 +46,10 @@ const has_node_modules = (workdir, exists) => {
|
|
|
46
46
|
*/
|
|
47
47
|
const ensure_dependencies = async (workdir, deps = {}) => {
|
|
48
48
|
const { run = defaultRun, hasTool = defaultHasTool, exists = existsSync } = deps;
|
|
49
|
-
if (has_node_modules(workdir, exists))
|
|
49
|
+
if (has_node_modules(workdir, exists)) {
|
|
50
|
+
log.info(`๐ฆ Dependencies already installed โ skipping install (fresh dependencies)`);
|
|
50
51
|
return;
|
|
52
|
+
}
|
|
51
53
|
const has = (file) => exists(join(workdir, file));
|
|
52
54
|
let install = "npm install --no-audit --no-fund";
|
|
53
55
|
if (has("package-lock.json") || has("npm-shrinkwrap.json")) {
|
|
@@ -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
|
-
|
|
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();
|
|
@@ -74,7 +74,7 @@ const deploy = {
|
|
|
74
74
|
...git
|
|
75
75
|
});
|
|
76
76
|
const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`;
|
|
77
|
-
log.info(
|
|
77
|
+
log.info(`Preparing to deploy in faable cloud ยท ${deployment.id}`);
|
|
78
78
|
log.info(`๐ View it in the dashboard -> ${dashboard_url}`);
|
|
79
79
|
// Wait (up to 5 minutes) for the deployment to be promoted (live)
|
|
80
80
|
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -98,7 +98,7 @@ const deploy = {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
if (promoted) {
|
|
101
|
-
log.info(
|
|
101
|
+
log.info(`๐ Deployment promoted and live, visit: https://${app.url}`);
|
|
102
102
|
}
|
|
103
103
|
else {
|
|
104
104
|
log.warn(`โ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
|
|
@@ -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 {
|
|
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(
|
|
36
|
+
.command(link_deprecated)
|
|
37
37
|
.demandCommand(1)
|
|
38
38
|
.help()
|
|
39
39
|
.fail(function (msg, err) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cmd } from './cmd.js';
|
|
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
|
+
const getGitRemoteUrl = async (workdir) => {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await cmd('git remote get-url origin', { cwd: workdir });
|
|
10
|
+
const url = stdout?.toString().trim();
|
|
11
|
+
if (!url)
|
|
12
|
+
return undefined;
|
|
13
|
+
// Extract org/repo from github urls
|
|
14
|
+
const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
15
|
+
if (match) {
|
|
16
|
+
return match[1];
|
|
17
|
+
}
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
log.warn('Could not detect git remote origin URL.');
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export { getGitRemoteUrl };
|