@ericminassian/plat 0.0.0-stage → 0.1.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-types.d.ts +7 -0
- package/dist/api-types.js +3 -0
- package/dist/api-types.js.map +1 -0
- package/dist/api.d.ts +123 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/build.d.ts +45 -0
- package/dist/build.js +188 -0
- package/dist/build.js.map +1 -0
- package/dist/commands/account.d.ts +2 -0
- package/dist/commands/account.js +45 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +75 -0
- package/dist/commands/apps.js.map +1 -0
- package/dist/commands/common.d.ts +19 -0
- package/dist/commands/common.js +43 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +3 -0
- package/dist/commands/lifecycle.js +105 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/manage.d.ts +2 -0
- package/dist/commands/manage.js +103 -0
- package/dist/commands/manage.js.map +1 -0
- package/dist/commands/observe.d.ts +8 -0
- package/dist/commands/observe.js +195 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -0
- package/dist/deploy.d.ts +23 -0
- package/dist/deploy.js +100 -0
- package/dist/deploy.js.map +1 -0
- package/dist/dev/crons.d.ts +16 -0
- package/dist/dev/crons.js +47 -0
- package/dist/dev/crons.js.map +1 -0
- package/dist/dev/entries.d.ts +6 -0
- package/dist/dev/entries.js +21 -0
- package/dist/dev/entries.js.map +1 -0
- package/dist/dev/index.d.ts +33 -0
- package/dist/dev/index.js +262 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/local-stage.d.ts +46 -0
- package/dist/dev/local-stage.js +116 -0
- package/dist/dev/local-stage.js.map +1 -0
- package/dist/dev/pretty.d.ts +16 -0
- package/dist/dev/pretty.js +91 -0
- package/dist/dev/pretty.js.map +1 -0
- package/dist/dev/proc.d.ts +7 -0
- package/dist/dev/proc.js +16 -0
- package/dist/dev/proc.js.map +1 -0
- package/dist/dev/router.d.ts +67 -0
- package/dist/dev/router.js +184 -0
- package/dist/dev/router.js.map +1 -0
- package/dist/dev/server-entry.d.ts +1 -0
- package/dist/dev/server-entry.js +28 -0
- package/dist/dev/server-entry.js.map +1 -0
- package/dist/dev/test.d.ts +10 -0
- package/dist/dev/test.js +62 -0
- package/dist/dev/test.js.map +1 -0
- package/dist/dev/worker-host.d.ts +1 -0
- package/dist/dev/worker-host.js +67 -0
- package/dist/dev/worker-host.js.map +1 -0
- package/dist/dev/worker.d.ts +57 -0
- package/dist/dev/worker.js +216 -0
- package/dist/dev/worker.js.map +1 -0
- package/dist/github.d.ts +39 -0
- package/dist/github.js +108 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +102 -0
- package/dist/init.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +24 -0
- package/dist/main.js.map +1 -0
- package/dist/project.d.ts +22 -0
- package/dist/project.js +81 -0
- package/dist/project.js.map +1 -0
- package/dist/util/net.d.ts +4 -0
- package/dist/util/net.js +33 -0
- package/dist/util/net.js.map +1 -0
- package/dist/util/output.d.ts +42 -0
- package/dist/util/output.js +105 -0
- package/dist/util/output.js.map +1 -0
- package/dist/util/prompt.d.ts +3 -0
- package/dist/util/prompt.js +22 -0
- package/dist/util/prompt.js.map +1 -0
- package/dist/util/time.d.ts +11 -0
- package/dist/util/time.js +29 -0
- package/dist/util/time.js.map +1 -0
- package/dist/util/zip.d.ts +10 -0
- package/dist/util/zip.js +45 -0
- package/dist/util/zip.js.map +1 -0
- package/package.json +50 -3
- package/src/api-types.ts +8 -0
- package/src/api.ts +341 -0
- package/src/bin.ts +4 -0
- package/src/build.ts +255 -0
- package/src/commands/account.ts +62 -0
- package/src/commands/apps.ts +102 -0
- package/src/commands/common.ts +51 -0
- package/src/commands/lifecycle.ts +139 -0
- package/src/commands/manage.ts +169 -0
- package/src/commands/observe.ts +276 -0
- package/src/credentials.ts +50 -0
- package/src/deploy.ts +138 -0
- package/src/dev/crons.ts +70 -0
- package/src/dev/entries.ts +23 -0
- package/src/dev/index.ts +281 -0
- package/src/dev/local-stage.ts +176 -0
- package/src/dev/pretty.ts +90 -0
- package/src/dev/proc.ts +15 -0
- package/src/dev/router.ts +246 -0
- package/src/dev/server-entry.ts +21 -0
- package/src/dev/test.ts +68 -0
- package/src/dev/worker-host.ts +62 -0
- package/src/dev/worker.ts +332 -0
- package/src/github.ts +121 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/main.ts +28 -0
- package/src/project.ts +89 -0
- package/src/util/net.ts +33 -0
- package/src/util/output.ts +117 -0
- package/src/util/prompt.ts +20 -0
- package/src/util/time.ts +32 -0
- package/src/util/zip.ts +46 -0
- package/template/.github/workflows/platform.yml +24 -0
- package/template/AGENTS.md +134 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +13 -0
- package/template/_gitignore +8 -0
- package/template/package.json +31 -0
- package/template/platform.json +18 -0
- package/template/src/server.ts +100 -0
- package/template/src/types.ts +21 -0
- package/template/src/worker.ts +32 -0
- package/template/test/server.test.ts +120 -0
- package/template/test/todos.integration.test.ts +30 -0
- package/template/tsconfig.json +18 -0
- package/template/vitest.config.ts +9 -0
- package/template/vitest.integration.config.ts +10 -0
- package/template/web/index.html +12 -0
- package/template/web/src/App.tsx +102 -0
- package/template/web/src/api.ts +11 -0
- package/template/web/src/main.tsx +10 -0
- package/template/web/src/styles.css +102 -0
- package/template/web/tsconfig.json +18 -0
- package/template/web/vite.config.ts +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { validateAppName } from '@ericminassian/platform-core';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { CliError, EXIT, result, table } from '../util/output.ts';
|
|
5
|
+
import { confirmByTyping } from '../util/prompt.ts';
|
|
6
|
+
import { shortTime } from '../util/time.ts';
|
|
7
|
+
import { action, apiFor, type CommonOpts, withCommon } from './common.ts';
|
|
8
|
+
|
|
9
|
+
export function registerApps(program: Command): void {
|
|
10
|
+
const apps = program
|
|
11
|
+
.command('apps')
|
|
12
|
+
.description('register and inspect apps (repos are created by you, not the CLI)');
|
|
13
|
+
|
|
14
|
+
withCommon(
|
|
15
|
+
apps
|
|
16
|
+
.command('create <name>')
|
|
17
|
+
.description('register an app with the platform')
|
|
18
|
+
.requiredOption('--repo <owner/name>', 'GitHub repository whose CI may deploy this app')
|
|
19
|
+
.option('--description <text>', 'one-line description'),
|
|
20
|
+
).action(
|
|
21
|
+
action(async (name: string, opts: CommonOpts & { repo: string; description?: string }) => {
|
|
22
|
+
const err = validateAppName(name);
|
|
23
|
+
if (err) throw new CliError(err, { exitCode: EXIT.usage });
|
|
24
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) {
|
|
25
|
+
throw new CliError(`--repo must look like owner/name, got "${opts.repo}"`, { exitCode: EXIT.usage });
|
|
26
|
+
}
|
|
27
|
+
const app = await apiFor(opts).createApp({
|
|
28
|
+
name,
|
|
29
|
+
repo: opts.repo,
|
|
30
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
31
|
+
});
|
|
32
|
+
result(app, (a) =>
|
|
33
|
+
[
|
|
34
|
+
`${pc.green('✓')} registered ${pc.bold(a.name)} (deploys from ${a.repo})`,
|
|
35
|
+
'',
|
|
36
|
+
' next:',
|
|
37
|
+
' 1. push the repo with the platform workflow (.github/workflows/platform.yml)',
|
|
38
|
+
` 2. merging to main deploys https://${a.name}.<domain>; PRs get previews`,
|
|
39
|
+
].join('\n'),
|
|
40
|
+
);
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
withCommon(apps.command('list').description('list apps you can see')).action(
|
|
45
|
+
action(async (opts: CommonOpts) => {
|
|
46
|
+
const list = await apiFor(opts).listApps();
|
|
47
|
+
result(list, (l) =>
|
|
48
|
+
table(
|
|
49
|
+
l.map((s) => ({
|
|
50
|
+
app: s.app.name,
|
|
51
|
+
repo: s.app.repo,
|
|
52
|
+
stages: s.stages.map((st) => st.stage).join(','),
|
|
53
|
+
up:
|
|
54
|
+
s.health?.up === null || s.health === null
|
|
55
|
+
? '-'
|
|
56
|
+
: s.health.up
|
|
57
|
+
? pc.green('up')
|
|
58
|
+
: pc.red('down'),
|
|
59
|
+
'req/min': s.health?.reqPerMin ?? '-',
|
|
60
|
+
p95: s.health?.p95 != null ? `${s.health.p95}ms` : '-',
|
|
61
|
+
'cost MTD': s.costMonthToDateUsd != null ? `$${s.costMonthToDateUsd.toFixed(2)}` : '-',
|
|
62
|
+
})),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
withCommon(apps.command('show <name>').description('app details, stages and health')).action(
|
|
69
|
+
action(async (name: string, opts: CommonOpts) => {
|
|
70
|
+
const s = await apiFor(opts).getApp(name);
|
|
71
|
+
result(s, (v) =>
|
|
72
|
+
[
|
|
73
|
+
`${pc.bold(v.app.name)} ${pc.dim(v.app.description ?? '')}`,
|
|
74
|
+
`repo: ${v.app.repo} owners: ${v.app.owners.join(', ')} created: ${shortTime(v.app.createdAt)}`,
|
|
75
|
+
'',
|
|
76
|
+
table(
|
|
77
|
+
v.stages.map((st) => ({
|
|
78
|
+
stage: st.stage,
|
|
79
|
+
status: st.status,
|
|
80
|
+
url: st.url,
|
|
81
|
+
deployment: st.currentDeployment ?? '',
|
|
82
|
+
updated: shortTime(st.updatedAt),
|
|
83
|
+
})),
|
|
84
|
+
),
|
|
85
|
+
].join('\n'),
|
|
86
|
+
);
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
withCommon(
|
|
91
|
+
apps
|
|
92
|
+
.command('delete <name>')
|
|
93
|
+
.description('unregister an app (destroy its stages first)')
|
|
94
|
+
.option('--yes', 'skip the confirmation prompt'),
|
|
95
|
+
).action(
|
|
96
|
+
action(async (name: string, opts: CommonOpts & { yes?: boolean }) => {
|
|
97
|
+
await confirmByTyping(`unregister ${name}?`, name, opts.yes);
|
|
98
|
+
await apiFor(opts).deleteApp(name);
|
|
99
|
+
result({ app: name, deleted: true }, () => `${pc.green('✓')} unregistered ${name}`);
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Command, Option } from 'commander';
|
|
2
|
+
import { ApiClient } from '../api.ts';
|
|
3
|
+
import { resolveAppName } from '../project.ts';
|
|
4
|
+
import { reportError, setJsonMode } from '../util/output.ts';
|
|
5
|
+
|
|
6
|
+
export interface CommonOpts {
|
|
7
|
+
json?: boolean;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
app?: string;
|
|
10
|
+
stage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `--json` and `--api-url` on every command. */
|
|
14
|
+
export function withCommon(cmd: Command): Command {
|
|
15
|
+
return cmd
|
|
16
|
+
.option('--json', 'machine-readable JSON output on stdout')
|
|
17
|
+
.addOption(new Option('--api-url <url>', 'platform API base URL').env('PLATFORM_API_URL'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `--app` (defaults to platform.json) and `--stage` (defaults to prod). */
|
|
21
|
+
export function withTarget(cmd: Command, stageDefault: string | false = 'prod'): Command {
|
|
22
|
+
cmd.option('--app <name>', 'app name (default: from platform.json)');
|
|
23
|
+
if (stageDefault) cmd.option('--stage <stage>', 'stage', stageDefault);
|
|
24
|
+
else cmd.option('--stage <stage>', 'stage');
|
|
25
|
+
return withCommon(cmd);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function apiFor(opts: CommonOpts): ApiClient {
|
|
29
|
+
return new ApiClient(opts.apiUrl ? { baseUrl: opts.apiUrl } : {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function appFor(opts: CommonOpts): string {
|
|
33
|
+
return resolveAppName(opts.app);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a command action: applies `--json`, turns errors into a concise
|
|
38
|
+
* message + hint and a meaningful exit code.
|
|
39
|
+
*/
|
|
40
|
+
export function action<A extends unknown[]>(fn: (...args: A) => Promise<void> | void) {
|
|
41
|
+
return async (...args: A) => {
|
|
42
|
+
const cmd = args[args.length - 1] as Command;
|
|
43
|
+
const opts = (typeof cmd?.optsWithGlobals === 'function' ? cmd.optsWithGlobals() : {}) as CommonOpts;
|
|
44
|
+
setJsonMode(!!opts.json);
|
|
45
|
+
try {
|
|
46
|
+
await fn(...args);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
process.exitCode = reportError(err);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { PLATFORM_DEFAULTS } from '@ericminassian/platform-core';
|
|
3
|
+
import { type Command, Option } from 'commander';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { initApp } from '../init.ts';
|
|
6
|
+
import { CliError, EXIT, result, success } from '../util/output.ts';
|
|
7
|
+
import { action, apiFor, type CommonOpts, withCommon } from './common.ts';
|
|
8
|
+
|
|
9
|
+
export function registerLifecycle(program: Command): void {
|
|
10
|
+
withCommon(
|
|
11
|
+
program
|
|
12
|
+
.command('init [dir]')
|
|
13
|
+
.description('scaffold a new app from the template (local files only; create the GitHub repo yourself)')
|
|
14
|
+
.option('--name <app>', 'app name (default: directory name)')
|
|
15
|
+
.option('--force', 'write into a non-empty directory'),
|
|
16
|
+
).action(
|
|
17
|
+
action(async (dir: string | undefined, opts: CommonOpts & { name?: string; force?: boolean }) => {
|
|
18
|
+
const target = dir ?? opts.name ?? '.';
|
|
19
|
+
const r = initApp({
|
|
20
|
+
dir: target,
|
|
21
|
+
...(opts.name ? { name: opts.name } : {}),
|
|
22
|
+
...(opts.force ? { force: true } : {}),
|
|
23
|
+
});
|
|
24
|
+
const rel = relative(process.cwd(), r.dir) || '.';
|
|
25
|
+
result(r, (v) =>
|
|
26
|
+
[
|
|
27
|
+
`${pc.green('✓')} created ${pc.bold(v.name)} in ${rel} (${v.files.length} files)`,
|
|
28
|
+
'',
|
|
29
|
+
' next:',
|
|
30
|
+
rel === '.' ? '' : ` cd ${rel}`,
|
|
31
|
+
' pnpm install',
|
|
32
|
+
' pnpm dev # local, on the AWS Emulator',
|
|
33
|
+
` gh repo create <owner>/${v.name} --private --source . --push`,
|
|
34
|
+
` plat apps create ${v.name} --repo <owner>/${v.name}`,
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join('\n'),
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
withCommon(
|
|
43
|
+
program
|
|
44
|
+
.command('build')
|
|
45
|
+
.description('bundle the app into deployable artifacts (.plat/build)')
|
|
46
|
+
.option('--out <dir>', 'output directory')
|
|
47
|
+
.option('--no-minify', 'skip minification'),
|
|
48
|
+
).action(
|
|
49
|
+
action(async (opts: CommonOpts & { out?: string; minify: boolean }) => {
|
|
50
|
+
const { buildApp } = await import('../build.ts');
|
|
51
|
+
const r = await buildApp({
|
|
52
|
+
cwd: process.cwd(),
|
|
53
|
+
...(opts.out ? { outDir: opts.out } : {}),
|
|
54
|
+
minify: opts.minify,
|
|
55
|
+
});
|
|
56
|
+
result({ app: r.manifest.name, files: r.files }, (v) => {
|
|
57
|
+
success(`built ${v.app}`);
|
|
58
|
+
return Object.entries(v.files)
|
|
59
|
+
.map(([k, p]) => ` ${k.padEnd(14)} ${relative(process.cwd(), p as string)}`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
withCommon(
|
|
66
|
+
program
|
|
67
|
+
.command('deploy')
|
|
68
|
+
.description('build and deploy (CI derives the stage: PR → pr-<n>, main → prod)')
|
|
69
|
+
.option('--stage <stage>', 'target stage (required outside CI)')
|
|
70
|
+
.option('--no-wait', "don't wait for the deployment to finish"),
|
|
71
|
+
).action(
|
|
72
|
+
action(async (opts: CommonOpts & { stage?: string; wait: boolean }) => {
|
|
73
|
+
const { deploy } = await import('../deploy.ts');
|
|
74
|
+
const r = await deploy(apiFor(opts), {
|
|
75
|
+
cwd: process.cwd(),
|
|
76
|
+
...(opts.stage ? { stage: opts.stage } : {}),
|
|
77
|
+
wait: opts.wait,
|
|
78
|
+
});
|
|
79
|
+
result({ deployment: r.deployment, url: r.url }, (v) => (v.url ? v.url : undefined));
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const dev = withCommon(
|
|
84
|
+
program
|
|
85
|
+
.command('dev')
|
|
86
|
+
.description('run the app locally against the AWS Emulator')
|
|
87
|
+
.addOption(
|
|
88
|
+
new Option('--port <port>', 'local router port')
|
|
89
|
+
.default(String(PLATFORM_DEFAULTS.localPort))
|
|
90
|
+
.env('PLAT_DEV_PORT'),
|
|
91
|
+
)
|
|
92
|
+
.option('--spans', 'also print trace spans (db, queue, fetch calls)'),
|
|
93
|
+
).action(
|
|
94
|
+
action(async (opts: CommonOpts & { port: string; spans?: boolean }) => {
|
|
95
|
+
const { runDev } = await import('../dev/index.ts');
|
|
96
|
+
await runDev({
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
port: parsePort(opts.port),
|
|
99
|
+
...(opts.spans ? { spans: true } : {}),
|
|
100
|
+
});
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
withCommon(
|
|
105
|
+
dev
|
|
106
|
+
.command('run-cron <name>')
|
|
107
|
+
.description('fire a cron now in a running `plat dev` (same --port as `plat dev`)'),
|
|
108
|
+
).action(
|
|
109
|
+
action(async (name: string, _opts: CommonOpts, cmd: Command) => {
|
|
110
|
+
// `--port` belongs to `plat dev` (the parent); commander parses it there.
|
|
111
|
+
const port =
|
|
112
|
+
(cmd.parent?.opts() as { port?: string } | undefined)?.port ?? String(PLATFORM_DEFAULTS.localPort);
|
|
113
|
+
const { runCronOnDev } = await import('../dev/index.ts');
|
|
114
|
+
await runCronOnDev(name, parsePort(port));
|
|
115
|
+
result({ cron: name, triggered: true }, () => `${pc.green('✓')} ran ${name}`);
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
withCommon(
|
|
120
|
+
program
|
|
121
|
+
.command('test')
|
|
122
|
+
.description('run integration tests against a throwaway AWS Emulator with this app provisioned')
|
|
123
|
+
.argument('[vitestArgs...]', 'extra arguments for vitest')
|
|
124
|
+
.allowUnknownOption(),
|
|
125
|
+
).action(
|
|
126
|
+
action(async (vitestArgs: string[]) => {
|
|
127
|
+
const { runTests } = await import('../dev/test.ts');
|
|
128
|
+
const code = await runTests({ cwd: process.cwd(), args: vitestArgs ?? [] });
|
|
129
|
+
if (code !== 0) throw new CliError(`tests failed (vitest exit ${code})`, { exitCode: code });
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parsePort(v: string): number {
|
|
135
|
+
const n = Number(v);
|
|
136
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535)
|
|
137
|
+
throw new CliError(`invalid port ${v}`, { exitCode: EXIT.usage });
|
|
138
|
+
return n;
|
|
139
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ENV_NAME_PATTERN, PROD_STAGE } from '@ericminassian/platform-core';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { CliError, EXIT, result, table } from '../util/output.ts';
|
|
5
|
+
import { confirmByTyping, prompt } from '../util/prompt.ts';
|
|
6
|
+
import { shortTime } from '../util/time.ts';
|
|
7
|
+
import { action, apiFor, appFor, type CommonOpts, withTarget } from './common.ts';
|
|
8
|
+
import { readStdin } from './observe.ts';
|
|
9
|
+
|
|
10
|
+
type TargetOpts = CommonOpts & { stage: string };
|
|
11
|
+
|
|
12
|
+
export function registerManage(program: Command): void {
|
|
13
|
+
// ------------------------------------------------------------ secrets
|
|
14
|
+
const secrets = program.command('secrets').description('app secrets (values never shown)');
|
|
15
|
+
|
|
16
|
+
withTarget(secrets.command('list').description('secret names for a stage (and shared ones)')).action(
|
|
17
|
+
action(async (opts: TargetOpts) => {
|
|
18
|
+
const list = await apiFor(opts).listSecrets(appFor(opts), { stage: opts.stage });
|
|
19
|
+
result(list, (l) =>
|
|
20
|
+
table(
|
|
21
|
+
l.map((s) => ({
|
|
22
|
+
name: s.name,
|
|
23
|
+
scope: s.scope,
|
|
24
|
+
stage: s.stage ?? '',
|
|
25
|
+
updated: s.updatedAt ? shortTime(s.updatedAt) : '',
|
|
26
|
+
})),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
withTarget(
|
|
33
|
+
secrets
|
|
34
|
+
.command('set <name> [value]')
|
|
35
|
+
.description('set a secret (value from the argument or stdin)')
|
|
36
|
+
.option('--shared', 'app-wide: every stage without its own value uses it'),
|
|
37
|
+
).action(
|
|
38
|
+
action(async (name: string, value: string | undefined, opts: TargetOpts & { shared?: boolean }) => {
|
|
39
|
+
checkSecretName(name);
|
|
40
|
+
const v = value ?? (process.stdin.isTTY ? await prompt(`value for ${name}: `) : readStdin());
|
|
41
|
+
if (!v) throw new CliError('empty secret value', { exitCode: EXIT.usage });
|
|
42
|
+
const info = await apiFor(opts).setSecret(
|
|
43
|
+
appFor(opts),
|
|
44
|
+
name,
|
|
45
|
+
opts.shared ? { value: v, shared: true } : { value: v, stage: opts.stage },
|
|
46
|
+
);
|
|
47
|
+
result(
|
|
48
|
+
info,
|
|
49
|
+
() =>
|
|
50
|
+
`${pc.green('✓')} set ${name} (${opts.shared ? 'shared' : opts.stage}); new value applies within ~5 minutes`,
|
|
51
|
+
);
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
withTarget(
|
|
56
|
+
secrets
|
|
57
|
+
.command('rm <name>')
|
|
58
|
+
.description('delete a secret')
|
|
59
|
+
.option('--shared', 'delete the app-wide value'),
|
|
60
|
+
).action(
|
|
61
|
+
action(async (name: string, opts: TargetOpts & { shared?: boolean }) => {
|
|
62
|
+
checkSecretName(name);
|
|
63
|
+
await apiFor(opts).deleteSecret(
|
|
64
|
+
appFor(opts),
|
|
65
|
+
name,
|
|
66
|
+
opts.shared ? { shared: true } : { stage: opts.stage },
|
|
67
|
+
);
|
|
68
|
+
result({ deleted: name }, () => `${pc.green('✓')} deleted ${name}`);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ------------------------------------------------------------ deployments & stages
|
|
73
|
+
withTarget(program.command('deployments').description('recent deployments'), false).action(
|
|
74
|
+
action(async (opts: CommonOpts & { stage?: string }) => {
|
|
75
|
+
const list = await apiFor(opts).listDeployments(appFor(opts), opts.stage);
|
|
76
|
+
result(list, (l) =>
|
|
77
|
+
table(
|
|
78
|
+
l.map((d) => ({
|
|
79
|
+
id: d.id,
|
|
80
|
+
stage: d.stage,
|
|
81
|
+
status:
|
|
82
|
+
d.status === 'succeeded'
|
|
83
|
+
? pc.green(d.status)
|
|
84
|
+
: d.status === 'failed'
|
|
85
|
+
? pc.red(d.status)
|
|
86
|
+
: d.status,
|
|
87
|
+
commit: d.commit?.slice(0, 7) ?? '',
|
|
88
|
+
message: (d.message ?? '').slice(0, 50),
|
|
89
|
+
by: d.actor,
|
|
90
|
+
at: shortTime(d.createdAt),
|
|
91
|
+
})),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
withTarget(program.command('stages').description('stages of an app'), false).action(
|
|
98
|
+
action(async (opts: CommonOpts) => {
|
|
99
|
+
const list = await apiFor(opts).listStages(appFor(opts));
|
|
100
|
+
result(list, (l) =>
|
|
101
|
+
table(
|
|
102
|
+
l.map((s) => ({
|
|
103
|
+
stage: s.stage,
|
|
104
|
+
status: s.status,
|
|
105
|
+
url: s.url,
|
|
106
|
+
deployment: s.currentDeployment ?? '',
|
|
107
|
+
updated: shortTime(s.updatedAt),
|
|
108
|
+
})),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
withTarget(
|
|
115
|
+
program
|
|
116
|
+
.command('rollback')
|
|
117
|
+
.description('point a stage back at its previous deployment (instant)')
|
|
118
|
+
.option('--to <deploymentId>', 'a specific earlier deployment'),
|
|
119
|
+
).action(
|
|
120
|
+
action(async (opts: TargetOpts & { to?: string }) => {
|
|
121
|
+
const r = await apiFor(opts).rollback(appFor(opts), opts.stage, opts.to);
|
|
122
|
+
result(r, () => `${pc.green('✓')} rolled back ${opts.stage}`);
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
withTarget(
|
|
127
|
+
program
|
|
128
|
+
.command('destroy')
|
|
129
|
+
.description('delete a stage and its resources')
|
|
130
|
+
.option('--yes', 'skip the confirmation prompt')
|
|
131
|
+
.option('--force', 'required to destroy prod'),
|
|
132
|
+
false,
|
|
133
|
+
).action(
|
|
134
|
+
action(async (opts: CommonOpts & { stage?: string; yes?: boolean; force?: boolean }) => {
|
|
135
|
+
if (!opts.stage) throw new CliError('--stage is required', { exitCode: EXIT.usage });
|
|
136
|
+
if (opts.stage === PROD_STAGE && !opts.force) {
|
|
137
|
+
throw new CliError('refusing to destroy prod', {
|
|
138
|
+
hint: 'pass --force if you really mean it',
|
|
139
|
+
exitCode: EXIT.usage,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const app = appFor(opts);
|
|
143
|
+
await confirmByTyping(`destroy ${app}/${opts.stage}?`, opts.stage, opts.yes);
|
|
144
|
+
await apiFor(opts).destroyStage(app, opts.stage);
|
|
145
|
+
result(
|
|
146
|
+
{ app, stage: opts.stage, destroyed: true },
|
|
147
|
+
() => `${pc.green('✓')} destroying ${app}/${opts.stage}`,
|
|
148
|
+
);
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// ------------------------------------------------------------ crons
|
|
153
|
+
const cron = program.command('cron').description('scheduled jobs');
|
|
154
|
+
withTarget(cron.command('run <name>').description('run a cron now on a deployed stage')).action(
|
|
155
|
+
action(async (name: string, opts: TargetOpts) => {
|
|
156
|
+
await apiFor(opts).runCron(appFor(opts), name, opts.stage);
|
|
157
|
+
result(
|
|
158
|
+
{ cron: name, stage: opts.stage, triggered: true },
|
|
159
|
+
() => `${pc.green('✓')} triggered ${name} on ${opts.stage}`,
|
|
160
|
+
);
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkSecretName(name: string): void {
|
|
166
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
167
|
+
throw new CliError(`secret names are UPPER_SNAKE_CASE, got "${name}"`, { exitCode: EXIT.usage });
|
|
168
|
+
}
|
|
169
|
+
}
|