@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.
Files changed (156) hide show
  1. package/dist/api-types.d.ts +7 -0
  2. package/dist/api-types.js +3 -0
  3. package/dist/api-types.js.map +1 -0
  4. package/dist/api.d.ts +123 -0
  5. package/dist/api.js +271 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +4 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/build.d.ts +45 -0
  11. package/dist/build.js +188 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/commands/account.d.ts +2 -0
  14. package/dist/commands/account.js +45 -0
  15. package/dist/commands/account.js.map +1 -0
  16. package/dist/commands/apps.d.ts +2 -0
  17. package/dist/commands/apps.js +75 -0
  18. package/dist/commands/apps.js.map +1 -0
  19. package/dist/commands/common.d.ts +19 -0
  20. package/dist/commands/common.js +43 -0
  21. package/dist/commands/common.js.map +1 -0
  22. package/dist/commands/lifecycle.d.ts +3 -0
  23. package/dist/commands/lifecycle.js +105 -0
  24. package/dist/commands/lifecycle.js.map +1 -0
  25. package/dist/commands/manage.d.ts +2 -0
  26. package/dist/commands/manage.js +103 -0
  27. package/dist/commands/manage.js.map +1 -0
  28. package/dist/commands/observe.d.ts +8 -0
  29. package/dist/commands/observe.js +195 -0
  30. package/dist/commands/observe.js.map +1 -0
  31. package/dist/credentials.d.ts +13 -0
  32. package/dist/credentials.js +42 -0
  33. package/dist/credentials.js.map +1 -0
  34. package/dist/deploy.d.ts +23 -0
  35. package/dist/deploy.js +100 -0
  36. package/dist/deploy.js.map +1 -0
  37. package/dist/dev/crons.d.ts +16 -0
  38. package/dist/dev/crons.js +47 -0
  39. package/dist/dev/crons.js.map +1 -0
  40. package/dist/dev/entries.d.ts +6 -0
  41. package/dist/dev/entries.js +21 -0
  42. package/dist/dev/entries.js.map +1 -0
  43. package/dist/dev/index.d.ts +33 -0
  44. package/dist/dev/index.js +262 -0
  45. package/dist/dev/index.js.map +1 -0
  46. package/dist/dev/local-stage.d.ts +46 -0
  47. package/dist/dev/local-stage.js +116 -0
  48. package/dist/dev/local-stage.js.map +1 -0
  49. package/dist/dev/pretty.d.ts +16 -0
  50. package/dist/dev/pretty.js +91 -0
  51. package/dist/dev/pretty.js.map +1 -0
  52. package/dist/dev/proc.d.ts +7 -0
  53. package/dist/dev/proc.js +16 -0
  54. package/dist/dev/proc.js.map +1 -0
  55. package/dist/dev/router.d.ts +67 -0
  56. package/dist/dev/router.js +184 -0
  57. package/dist/dev/router.js.map +1 -0
  58. package/dist/dev/server-entry.d.ts +1 -0
  59. package/dist/dev/server-entry.js +28 -0
  60. package/dist/dev/server-entry.js.map +1 -0
  61. package/dist/dev/test.d.ts +10 -0
  62. package/dist/dev/test.js +62 -0
  63. package/dist/dev/test.js.map +1 -0
  64. package/dist/dev/worker-host.d.ts +1 -0
  65. package/dist/dev/worker-host.js +67 -0
  66. package/dist/dev/worker-host.js.map +1 -0
  67. package/dist/dev/worker.d.ts +57 -0
  68. package/dist/dev/worker.js +216 -0
  69. package/dist/dev/worker.js.map +1 -0
  70. package/dist/github.d.ts +39 -0
  71. package/dist/github.js +108 -0
  72. package/dist/github.js.map +1 -0
  73. package/dist/index.d.ts +14 -0
  74. package/dist/index.js +15 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/init.d.ts +23 -0
  77. package/dist/init.js +102 -0
  78. package/dist/init.js.map +1 -0
  79. package/dist/main.d.ts +3 -0
  80. package/dist/main.js +24 -0
  81. package/dist/main.js.map +1 -0
  82. package/dist/project.d.ts +22 -0
  83. package/dist/project.js +81 -0
  84. package/dist/project.js.map +1 -0
  85. package/dist/util/net.d.ts +4 -0
  86. package/dist/util/net.js +33 -0
  87. package/dist/util/net.js.map +1 -0
  88. package/dist/util/output.d.ts +42 -0
  89. package/dist/util/output.js +105 -0
  90. package/dist/util/output.js.map +1 -0
  91. package/dist/util/prompt.d.ts +3 -0
  92. package/dist/util/prompt.js +22 -0
  93. package/dist/util/prompt.js.map +1 -0
  94. package/dist/util/time.d.ts +11 -0
  95. package/dist/util/time.js +29 -0
  96. package/dist/util/time.js.map +1 -0
  97. package/dist/util/zip.d.ts +10 -0
  98. package/dist/util/zip.js +45 -0
  99. package/dist/util/zip.js.map +1 -0
  100. package/package.json +50 -3
  101. package/src/api-types.ts +8 -0
  102. package/src/api.ts +341 -0
  103. package/src/bin.ts +4 -0
  104. package/src/build.ts +255 -0
  105. package/src/commands/account.ts +62 -0
  106. package/src/commands/apps.ts +102 -0
  107. package/src/commands/common.ts +51 -0
  108. package/src/commands/lifecycle.ts +139 -0
  109. package/src/commands/manage.ts +169 -0
  110. package/src/commands/observe.ts +276 -0
  111. package/src/credentials.ts +50 -0
  112. package/src/deploy.ts +138 -0
  113. package/src/dev/crons.ts +70 -0
  114. package/src/dev/entries.ts +23 -0
  115. package/src/dev/index.ts +281 -0
  116. package/src/dev/local-stage.ts +176 -0
  117. package/src/dev/pretty.ts +90 -0
  118. package/src/dev/proc.ts +15 -0
  119. package/src/dev/router.ts +246 -0
  120. package/src/dev/server-entry.ts +21 -0
  121. package/src/dev/test.ts +68 -0
  122. package/src/dev/worker-host.ts +62 -0
  123. package/src/dev/worker.ts +332 -0
  124. package/src/github.ts +121 -0
  125. package/src/index.ts +20 -0
  126. package/src/init.ts +123 -0
  127. package/src/main.ts +28 -0
  128. package/src/project.ts +89 -0
  129. package/src/util/net.ts +33 -0
  130. package/src/util/output.ts +117 -0
  131. package/src/util/prompt.ts +20 -0
  132. package/src/util/time.ts +32 -0
  133. package/src/util/zip.ts +46 -0
  134. package/template/.github/workflows/platform.yml +24 -0
  135. package/template/AGENTS.md +134 -0
  136. package/template/CLAUDE.md +1 -0
  137. package/template/README.md +13 -0
  138. package/template/_gitignore +8 -0
  139. package/template/package.json +31 -0
  140. package/template/platform.json +18 -0
  141. package/template/src/server.ts +100 -0
  142. package/template/src/types.ts +21 -0
  143. package/template/src/worker.ts +32 -0
  144. package/template/test/server.test.ts +120 -0
  145. package/template/test/todos.integration.test.ts +30 -0
  146. package/template/tsconfig.json +18 -0
  147. package/template/vitest.config.ts +9 -0
  148. package/template/vitest.integration.config.ts +10 -0
  149. package/template/web/index.html +12 -0
  150. package/template/web/src/App.tsx +102 -0
  151. package/template/web/src/api.ts +11 -0
  152. package/template/web/src/main.tsx +10 -0
  153. package/template/web/src/styles.css +102 -0
  154. package/template/web/tsconfig.json +18 -0
  155. package/template/web/vite.config.ts +19 -0
  156. 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
+ }