@hyperdrive.bot/cli 1.0.13 → 1.0.17

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 (157) hide show
  1. package/README.md +4526 -780
  2. package/dist/commands/deploy.d.ts +18 -0
  3. package/dist/commands/deploy.js +239 -0
  4. package/dist/commands/deployment/create.js +10 -2
  5. package/dist/commands/domain/{switch.d.ts → set-production.d.ts} +1 -1
  6. package/dist/commands/domain/set-production.js +27 -0
  7. package/dist/commands/git/list-open-prs.d.ts +12 -0
  8. package/dist/commands/git/list-open-prs.js +87 -0
  9. package/dist/commands/hook/add.d.ts +22 -0
  10. package/dist/commands/hook/add.js +299 -0
  11. package/dist/commands/hook/list.d.ts +11 -0
  12. package/dist/commands/hook/list.js +111 -0
  13. package/dist/commands/hook/logs.d.ts +13 -0
  14. package/dist/commands/hook/logs.js +124 -0
  15. package/dist/commands/hook/remove.d.ts +12 -0
  16. package/dist/commands/hook/remove.js +115 -0
  17. package/dist/commands/hook/toggle.d.ts +12 -0
  18. package/dist/commands/hook/toggle.js +125 -0
  19. package/dist/commands/init.d.ts +1 -1
  20. package/dist/commands/init.js +49 -9
  21. package/dist/commands/module/bindings.d.ts +14 -0
  22. package/dist/commands/module/bindings.js +125 -0
  23. package/dist/commands/module/create.d.ts +3 -0
  24. package/dist/commands/module/create.js +156 -78
  25. package/dist/commands/module/list.d.ts +1 -0
  26. package/dist/commands/module/list.js +22 -1
  27. package/dist/commands/module/sync.d.ts +29 -0
  28. package/dist/commands/module/sync.js +409 -0
  29. package/dist/commands/module/unlink.d.ts +11 -0
  30. package/dist/commands/module/unlink.js +77 -0
  31. package/dist/commands/module/update.d.ts +10 -0
  32. package/dist/commands/module/update.js +168 -5
  33. package/dist/commands/network/discover.d.ts +12 -0
  34. package/dist/commands/network/discover.js +210 -0
  35. package/dist/commands/network/get.d.ts +13 -0
  36. package/dist/commands/network/get.js +90 -0
  37. package/dist/commands/{auth/logout.d.ts → network/list.d.ts} +2 -9
  38. package/dist/commands/network/list.js +71 -0
  39. package/dist/commands/network/register.d.ts +16 -0
  40. package/dist/commands/network/register.js +144 -0
  41. package/dist/commands/parameter/sync.d.ts +13 -0
  42. package/dist/commands/parameter/sync.js +69 -1
  43. package/dist/commands/project/sync.d.ts +5 -11
  44. package/dist/commands/project/sync.js +12 -381
  45. package/dist/commands/seed.d.ts +93 -0
  46. package/dist/commands/seed.js +324 -0
  47. package/dist/commands/service/backup.d.ts +17 -0
  48. package/dist/commands/service/backup.js +156 -0
  49. package/dist/commands/service/backups.d.ts +14 -0
  50. package/dist/commands/service/backups.js +110 -0
  51. package/dist/commands/service/bind.d.ts +16 -0
  52. package/dist/commands/service/bind.js +106 -0
  53. package/dist/commands/service/bindings.d.ts +13 -0
  54. package/dist/commands/service/bindings.js +78 -0
  55. package/dist/commands/service/clone.d.ts +19 -0
  56. package/dist/commands/service/clone.js +153 -0
  57. package/dist/commands/service/create.d.ts +16 -0
  58. package/dist/commands/service/create.js +212 -0
  59. package/dist/commands/service/get.d.ts +13 -0
  60. package/dist/commands/service/get.js +97 -0
  61. package/dist/commands/service/list.d.ts +12 -0
  62. package/dist/commands/service/list.js +86 -0
  63. package/dist/commands/service/register.d.ts +21 -0
  64. package/dist/commands/service/register.js +215 -0
  65. package/dist/commands/service/restore.d.ts +19 -0
  66. package/dist/commands/service/restore.js +158 -0
  67. package/dist/commands/service/seed.d.ts +17 -0
  68. package/dist/commands/service/seed.js +173 -0
  69. package/dist/commands/service/templates.d.ts +10 -0
  70. package/dist/commands/service/templates.js +66 -0
  71. package/dist/commands/service/unbind.d.ts +15 -0
  72. package/dist/commands/service/unbind.js +74 -0
  73. package/dist/commands/stage/create.d.ts +23 -0
  74. package/dist/commands/stage/create.js +145 -6
  75. package/dist/commands/stage/delete.d.ts +11 -0
  76. package/dist/commands/stage/delete.js +85 -0
  77. package/dist/commands/stage/deploy.d.ts +34 -0
  78. package/dist/commands/stage/deploy.js +294 -0
  79. package/dist/commands/stage/ensure-branches.d.ts +23 -0
  80. package/dist/commands/stage/ensure-branches.js +101 -0
  81. package/dist/commands/stage/list.js +4 -0
  82. package/dist/commands/stage/status.d.ts +14 -0
  83. package/dist/commands/stage/status.js +100 -0
  84. package/dist/commands/{jira → tracker}/connect.js +32 -23
  85. package/dist/commands/tracker/hook/add.d.ts +25 -0
  86. package/dist/commands/tracker/hook/add.js +284 -0
  87. package/dist/commands/{jira → tracker}/hook/list.js +20 -11
  88. package/dist/commands/{jira/hook/add.d.ts → tracker/hook/logs.d.ts} +2 -3
  89. package/dist/commands/tracker/hook/logs.js +126 -0
  90. package/dist/commands/{jira → tracker}/hook/remove.js +9 -8
  91. package/dist/commands/{jira → tracker}/hook/toggle.js +14 -12
  92. package/dist/commands/tracker/project/init.d.ts +17 -0
  93. package/dist/commands/tracker/project/init.js +178 -0
  94. package/dist/commands/tracker/project/link-module.d.ts +17 -0
  95. package/dist/commands/tracker/project/link-module.js +287 -0
  96. package/dist/commands/tracker/project/list-modules.d.ts +11 -0
  97. package/dist/commands/tracker/project/list-modules.js +117 -0
  98. package/dist/commands/tracker/project/list.d.ts +10 -0
  99. package/dist/commands/tracker/project/list.js +90 -0
  100. package/dist/commands/tracker/project/status.d.ts +13 -0
  101. package/dist/commands/tracker/project/status.js +168 -0
  102. package/dist/commands/tracker/project/unlink-module.d.ts +13 -0
  103. package/dist/commands/tracker/project/unlink-module.js +251 -0
  104. package/dist/commands/{jira → tracker}/status.js +3 -3
  105. package/dist/lib/ensure-branches.d.ts +53 -0
  106. package/dist/lib/ensure-branches.js +149 -0
  107. package/dist/lib/git-providers/github.d.ts +16 -0
  108. package/dist/lib/git-providers/github.js +157 -0
  109. package/dist/lib/git-providers/gitlab.d.ts +16 -0
  110. package/dist/lib/git-providers/gitlab.js +148 -0
  111. package/dist/lib/git-providers/index.d.ts +67 -0
  112. package/dist/lib/git-providers/index.js +39 -0
  113. package/dist/lib/lambda-warmer.d.ts +106 -0
  114. package/dist/lib/lambda-warmer.js +189 -0
  115. package/dist/services/hyperdrive-sigv4.d.ts +359 -5
  116. package/dist/services/hyperdrive-sigv4.js +177 -12
  117. package/dist/utils/hook-flow.d.ts +60 -3
  118. package/dist/utils/hook-flow.js +437 -2
  119. package/dist/utils/hook-normalize.d.ts +6 -0
  120. package/dist/utils/hook-normalize.js +33 -0
  121. package/dist/utils/lifecycle-poller.d.ts +32 -0
  122. package/dist/utils/lifecycle-poller.js +72 -0
  123. package/dist/utils/retry.d.ts +43 -0
  124. package/dist/utils/retry.js +88 -0
  125. package/dist/utils/summary-display.js +1 -1
  126. package/dist/utils/tracker-project-flow.d.ts +84 -0
  127. package/dist/utils/tracker-project-flow.js +564 -0
  128. package/package.json +41 -13
  129. package/dist/commands/auth/login.d.ts +0 -16
  130. package/dist/commands/auth/login.js +0 -179
  131. package/dist/commands/auth/logout.js +0 -116
  132. package/dist/commands/auth/refresh.d.ts +0 -6
  133. package/dist/commands/auth/refresh.js +0 -66
  134. package/dist/commands/auth/status.d.ts +0 -6
  135. package/dist/commands/auth/status.js +0 -63
  136. package/dist/commands/config/get.d.ts +0 -9
  137. package/dist/commands/config/get.js +0 -37
  138. package/dist/commands/config/set.d.ts +0 -10
  139. package/dist/commands/config/set.js +0 -48
  140. package/dist/commands/config/show.d.ts +0 -6
  141. package/dist/commands/config/show.js +0 -10
  142. package/dist/commands/domain/current.d.ts +0 -6
  143. package/dist/commands/domain/current.js +0 -18
  144. package/dist/commands/domain/list.d.ts +0 -6
  145. package/dist/commands/domain/list.js +0 -42
  146. package/dist/commands/domain/switch.js +0 -40
  147. package/dist/commands/jira/hook/add.js +0 -147
  148. package/dist/services/tenant-service.d.ts +0 -127
  149. package/dist/services/tenant-service.js +0 -396
  150. package/dist/utils/auth-flow.d.ts +0 -147
  151. package/dist/utils/auth-flow.js +0 -479
  152. package/oclif.manifest.json +0 -3519
  153. /package/dist/commands/{jira → tracker}/connect.d.ts +0 -0
  154. /package/dist/commands/{jira → tracker}/hook/list.d.ts +0 -0
  155. /package/dist/commands/{jira → tracker}/hook/remove.d.ts +0 -0
  156. /package/dist/commands/{jira → tracker}/hook/toggle.d.ts +0 -0
  157. /package/dist/commands/{jira → tracker}/status.d.ts +0 -0
@@ -0,0 +1,324 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { Command, Flags } from '@oclif/core';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import { HyperdriveSigV4Service } from '../services/hyperdrive-sigv4.js';
8
+ import { pollLifecycle } from '../utils/lifecycle-poller.js';
9
+ const PHASES = ['runMigrations', 'copyData', 'resetSequences', 'seedFixtures'];
10
+ const PHASE_LABEL = {
11
+ runMigrations: 'Schema migration',
12
+ copyData: 'Data copy',
13
+ resetSequences: 'Sequence reset',
14
+ seedFixtures: 'Fixtures',
15
+ };
16
+ export default class Seed extends Command {
17
+ static description = 'Per-stage Postgres schema migration + data copy + sequence reset, orchestrating per-app seed scripts';
18
+ static examples = [
19
+ '<%= config.bin %> seed --stage feat-e2e-expansion --apps core,alunos,professores --from-schema public --to-schema feat_e2e_expansion --cf-domain alunos-feat-e2e-expansion.tutory.com.br',
20
+ '<%= config.bin %> seed --stage feat-e2e-expansion --apps core --from-schema public --cf-domain alunos.example.com --dry-run',
21
+ '<%= config.bin %> seed --stage ss-99 --apps core,alunos --from-schema public --cf-domain alunos.example.com --service rds-postgres-tutory-dev',
22
+ ];
23
+ static flags = {
24
+ stage: Flags.string({
25
+ description: 'Target stage slug (e.g. ss-11, feat-e2e-expansion). Becomes the destination schema name when --to-schema is omitted.',
26
+ required: true,
27
+ }),
28
+ apps: Flags.string({
29
+ description: 'Comma-separated list of apps that own seed scripts (e.g. core,alunos,professores). Order is significant — apps run sequentially.',
30
+ required: true,
31
+ }),
32
+ 'from-schema': Flags.string({
33
+ description: 'Postgres schema to copy reference rows from (typically `public`).',
34
+ required: true,
35
+ }),
36
+ 'to-schema': Flags.string({
37
+ description: 'Destination Postgres schema. Defaults to the stage name.',
38
+ }),
39
+ 'cf-domain': Flags.string({
40
+ description: 'Per-stage CloudFront hostname passed to seed scripts as ctx.cfDomain (templated into professores.domain etc.).',
41
+ required: true,
42
+ }),
43
+ service: Flags.string({
44
+ description: 'DB service slug to run SQL against (resolved from the stage if omitted).',
45
+ }),
46
+ 'apps-dir': Flags.string({
47
+ description: 'Root directory containing `apps/<app>/scripts/seed.ts` files. Defaults to ./apps under the current working directory.',
48
+ default: 'apps',
49
+ }),
50
+ 'dry-run': Flags.boolean({
51
+ description: 'Compute and print SQL + phase summary without executing any seed run.',
52
+ default: false,
53
+ }),
54
+ 'allow-production': Flags.boolean({
55
+ description: 'Allow seeding against a production-bound service (forwarded to `hd service seed`).',
56
+ default: false,
57
+ }),
58
+ json: Flags.boolean({
59
+ description: 'Output a structured JSON summary of the orchestration.',
60
+ default: false,
61
+ }),
62
+ 'no-wait': Flags.boolean({
63
+ description: 'Submit each phase without polling for completion.',
64
+ default: false,
65
+ }),
66
+ domain: Flags.string({
67
+ char: 'd',
68
+ description: 'Tenant domain (for multi-domain setups).',
69
+ }),
70
+ };
71
+ async run() {
72
+ const { flags } = await this.parse(Seed);
73
+ const isJson = flags.json;
74
+ const stage = flags.stage;
75
+ const fromSchema = flags['from-schema'];
76
+ const toSchema = flags['to-schema'] ?? stage.replaceAll('-', '_');
77
+ const cfDomain = flags['cf-domain'];
78
+ const apps = flags.apps
79
+ .split(',')
80
+ .map((s) => s.trim())
81
+ .filter((s) => s.length > 0);
82
+ if (apps.length === 0) {
83
+ this.log(chalk.red('Error: --apps must list at least one app'));
84
+ this.exit(1);
85
+ return;
86
+ }
87
+ const appsRoot = isAbsolute(flags['apps-dir'])
88
+ ? flags['apps-dir']
89
+ : resolve(process.cwd(), flags['apps-dir']);
90
+ // 1. Load per-app seed scripts
91
+ const scripts = new Map();
92
+ for (const app of apps) {
93
+ const candidates = [
94
+ join(appsRoot, app, 'scripts', 'seed.ts'),
95
+ join(appsRoot, app, 'scripts', 'seed.js'),
96
+ join(appsRoot, app, 'scripts', 'seed.mjs'),
97
+ ];
98
+ const found = candidates.find((p) => existsSync(p));
99
+ if (!found) {
100
+ this.log(chalk.red(`Error: missing seed script for app "${app}" — looked for: ${candidates.join(', ')}`));
101
+ this.log(chalk.gray(` Each app must export a default AppSeedScript from apps/${app}/scripts/seed.{ts,js,mjs}.`));
102
+ this.log(chalk.gray(` See: hd seed --help for the contract.`));
103
+ this.exit(1);
104
+ return;
105
+ }
106
+ try {
107
+ const mod = (await import(pathToFileURL(found).href));
108
+ const script = mod.default ?? mod;
109
+ if (!script || typeof script !== 'object') {
110
+ throw new Error(`module did not export an object as default`);
111
+ }
112
+ scripts.set(app, script);
113
+ }
114
+ catch (error) {
115
+ this.log(chalk.red(`Error loading seed script for "${app}" at ${found}: ${error.message}`));
116
+ this.exit(1);
117
+ return;
118
+ }
119
+ }
120
+ // 2. Build the plan: for each app, for each phase, call the function and collect SQL
121
+ const ctxBase = { stage, fromSchema, toSchema, cfDomain };
122
+ const plan = [];
123
+ const planSpinner = isJson ? null : ora('Planning seed phases...').start();
124
+ try {
125
+ for (const app of apps) {
126
+ const script = scripts.get(app);
127
+ for (const phase of PHASES) {
128
+ const fn = script[phase];
129
+ if (typeof fn !== 'function')
130
+ continue;
131
+ const result = await fn({ ...ctxBase, app });
132
+ const sql = (result?.sql ?? '').trim();
133
+ if (sql.length === 0)
134
+ continue;
135
+ plan.push({ app, phase, sql, description: result?.description });
136
+ }
137
+ }
138
+ planSpinner?.succeed(chalk.green(`Planned ${plan.length} phase(s) across ${apps.length} app(s)`));
139
+ }
140
+ catch (error) {
141
+ planSpinner?.fail('Planning failed');
142
+ this.log(chalk.red(`Error: ${error.message}`));
143
+ this.exit(1);
144
+ return;
145
+ }
146
+ if (plan.length === 0) {
147
+ this.log(chalk.yellow('No SQL produced by any phase. Nothing to do.'));
148
+ if (isJson)
149
+ this.log(JSON.stringify({ stage, apps, phases: [] }, null, 2));
150
+ return;
151
+ }
152
+ // 3. --dry-run: print the plan and exit (no service call, no DB writes)
153
+ if (flags['dry-run']) {
154
+ this.printDryRun(plan, { stage, fromSchema, toSchema, cfDomain, apps }, isJson);
155
+ return;
156
+ }
157
+ // 4. Resolve the target DB service
158
+ const service = new HyperdriveSigV4Service(flags.domain);
159
+ const targetSlug = await this.resolveServiceSlug(service, flags.service, stage, isJson);
160
+ if (!targetSlug) {
161
+ this.exit(1);
162
+ return;
163
+ }
164
+ // 5. Execute each planned phase via `service.serviceSeed`
165
+ const runs = [];
166
+ let allOk = true;
167
+ for (const item of plan) {
168
+ const label = `[${item.app}/${PHASE_LABEL[item.phase]}]`;
169
+ const spinner = isJson ? null : ora(`${label} submitting...`).start();
170
+ try {
171
+ const seedRun = await service.serviceSeed(targetSlug, {
172
+ scriptSource: item.sql,
173
+ scriptPath: `${item.app}.${item.phase}.sql`,
174
+ allowProduction: flags['allow-production'] || undefined,
175
+ });
176
+ spinner?.succeed(chalk.green(`${label} submitted: ${seedRun.id}`));
177
+ if (flags['no-wait']) {
178
+ runs.push({ app: item.app, phase: item.phase, seedRun, ok: true });
179
+ continue;
180
+ }
181
+ const result = await pollLifecycle({
182
+ pollFn: () => service.serviceGetSeedRun(targetSlug, seedRun.id),
183
+ getStatus: (sr) => sr.status,
184
+ terminalStates: new Set(['completed', 'failed']),
185
+ successStates: new Set(['completed']),
186
+ getErrorMessage: (sr) => sr.errorMessage,
187
+ operationLabel: `${label} seeding`,
188
+ }, isJson);
189
+ const ok = result.success && !result.timedOut;
190
+ runs.push({ app: item.app, phase: item.phase, seedRun: result.entity ?? seedRun, ok });
191
+ if (!ok) {
192
+ allOk = false;
193
+ // Halt — don't keep submitting phases on a broken DB
194
+ break;
195
+ }
196
+ }
197
+ catch (error) {
198
+ spinner?.fail(`${label} failed`);
199
+ const axiosError = error;
200
+ const status = axiosError.response?.status;
201
+ const errorMessage = axiosError.response?.data?.message ?? axiosError.message;
202
+ if (status === 403) {
203
+ this.log(chalk.red(`Production safety: ${errorMessage}`));
204
+ this.log(chalk.yellow('Use --allow-production to override.'));
205
+ }
206
+ else if (status === 404) {
207
+ this.log(chalk.red(`Not found: ${errorMessage}`));
208
+ }
209
+ else {
210
+ this.log(chalk.red(`Error: ${errorMessage}`));
211
+ }
212
+ allOk = false;
213
+ break;
214
+ }
215
+ }
216
+ // 6. Final summary
217
+ if (isJson) {
218
+ this.log(JSON.stringify({
219
+ stage,
220
+ fromSchema,
221
+ toSchema,
222
+ cfDomain,
223
+ apps,
224
+ service: targetSlug,
225
+ phases: runs.map((r) => ({
226
+ app: r.app,
227
+ phase: r.phase,
228
+ seedRunId: r.seedRun.id,
229
+ status: r.seedRun.status,
230
+ ok: r.ok,
231
+ })),
232
+ ok: allOk,
233
+ }, null, 2));
234
+ }
235
+ else {
236
+ this.log('');
237
+ this.log(chalk.bold('Summary'));
238
+ for (const r of runs) {
239
+ const mark = r.ok ? chalk.green('OK') : chalk.red('FAIL');
240
+ this.log(` ${mark} ${r.app}/${PHASE_LABEL[r.phase]} ${chalk.gray(r.seedRun.id)} ${r.seedRun.status}`);
241
+ }
242
+ this.log('');
243
+ if (!allOk)
244
+ this.log(chalk.red('One or more phases failed. See log group for details.'));
245
+ }
246
+ if (!allOk)
247
+ this.exit(1);
248
+ }
249
+ /**
250
+ * Resolve the DB service slug to seed against.
251
+ *
252
+ * If `--service` was passed, trust it. Otherwise list services and pick the
253
+ * single rds-postgres service registered for this stage. Fail loudly when
254
+ * resolution is ambiguous — never guess silently.
255
+ */
256
+ async resolveServiceSlug(service, explicit, stage, isJson) {
257
+ if (explicit)
258
+ return explicit;
259
+ const spinner = isJson ? null : ora(`Resolving DB service for stage ${stage}...`).start();
260
+ try {
261
+ const services = await service.serviceList({ type: 'rds-postgres' });
262
+ const matches = services.filter((s) => {
263
+ const stageTag = (s.metadata && s.metadata.stage) ?? '';
264
+ return stageTag === stage;
265
+ });
266
+ if (matches.length === 1) {
267
+ spinner?.succeed(chalk.gray(`Service: ${matches[0].slug}`));
268
+ return matches[0].slug;
269
+ }
270
+ if (matches.length > 1) {
271
+ spinner?.fail(`Multiple rds-postgres services tagged stage=${stage}: ${matches.map((m) => m.slug).join(', ')}`);
272
+ this.log(chalk.yellow('Pass --service <slug> to disambiguate.'));
273
+ return null;
274
+ }
275
+ // Fallback: if there's exactly one rds-postgres service in the tenant, use it.
276
+ if (services.length === 1) {
277
+ spinner?.succeed(chalk.gray(`Service: ${services[0].slug} (only rds-postgres in tenant)`));
278
+ return services[0].slug;
279
+ }
280
+ spinner?.fail(`Could not resolve a DB service for stage ${stage} (found ${services.length} rds-postgres service(s) in tenant).`);
281
+ this.log(chalk.yellow('Pass --service <slug> to choose explicitly.'));
282
+ return null;
283
+ }
284
+ catch (error) {
285
+ spinner?.fail('Service resolution failed');
286
+ this.log(chalk.red(`Error: ${error.message}`));
287
+ this.log(chalk.yellow('Pass --service <slug> to skip auto-resolution.'));
288
+ return null;
289
+ }
290
+ }
291
+ printDryRun(plan, summary, isJson) {
292
+ if (isJson) {
293
+ this.log(JSON.stringify({
294
+ ...summary,
295
+ dryRun: true,
296
+ phases: plan.map((p) => ({
297
+ app: p.app,
298
+ phase: p.phase,
299
+ description: p.description,
300
+ sqlLength: p.sql.length,
301
+ sqlPreview: p.sql.slice(0, 200),
302
+ })),
303
+ }, null, 2));
304
+ return;
305
+ }
306
+ this.log(chalk.bold('Dry run — no SQL will be executed'));
307
+ this.log(` Stage: ${chalk.cyan(summary.stage)}`);
308
+ this.log(` Schema: ${chalk.cyan(summary.fromSchema)} -> ${chalk.cyan(summary.toSchema)}`);
309
+ this.log(` CF domain: ${chalk.cyan(summary.cfDomain)}`);
310
+ this.log(` Apps: ${chalk.cyan(summary.apps.join(', '))}`);
311
+ this.log('');
312
+ for (const item of plan) {
313
+ this.log(chalk.bold(`[${item.app}] ${PHASE_LABEL[item.phase]} ${chalk.gray(`(${item.sql.length} chars)`)}`));
314
+ if (item.description)
315
+ this.log(chalk.gray(` ${item.description}`));
316
+ const preview = item.sql.length > 800 ? `${item.sql.slice(0, 800)}\n... [+${item.sql.length - 800} chars]` : item.sql;
317
+ for (const line of preview.split('\n')) {
318
+ this.log(chalk.gray(` ${line}`));
319
+ }
320
+ this.log('');
321
+ }
322
+ this.log(chalk.gray(`Total: ${plan.length} phase(s). Re-run without --dry-run to execute.`));
323
+ }
324
+ }
@@ -0,0 +1,17 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class ServiceBackup extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ 'no-wait': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ metadata: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ schedule: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ private handleSchedule;
17
+ }
@@ -0,0 +1,156 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { HyperdriveSigV4Service } from '../../services/hyperdrive-sigv4.js';
5
+ import { pollLifecycle } from '../../utils/lifecycle-poller.js';
6
+ export default class ServiceBackup extends Command {
7
+ static description = 'Create a backup (snapshot) of a service, or configure a backup schedule';
8
+ static examples = [
9
+ '<%= config.bin %> service backup rds-postgres-live',
10
+ '<%= config.bin %> service backup rds-postgres-live --no-wait',
11
+ '<%= config.bin %> service backup rds-postgres-live --json',
12
+ '<%= config.bin %> service backup rds-postgres-live --metadata env=staging --metadata reason=migration',
13
+ '<%= config.bin %> service backup rds-postgres-live --schedule daily',
14
+ ];
15
+ static args = {
16
+ slug: Args.string({
17
+ description: 'Service slug to back up',
18
+ required: true,
19
+ }),
20
+ };
21
+ static flags = {
22
+ domain: Flags.string({
23
+ char: 'd',
24
+ description: 'Tenant domain (for multi-domain setups)',
25
+ }),
26
+ json: Flags.boolean({
27
+ description: 'Output raw JSON response',
28
+ default: false,
29
+ }),
30
+ 'no-wait': Flags.boolean({
31
+ description: 'Do not wait for backup to complete; print backup ID and exit',
32
+ default: false,
33
+ }),
34
+ metadata: Flags.string({
35
+ description: 'Metadata key=value pairs to attach to the backup',
36
+ multiple: true,
37
+ }),
38
+ schedule: Flags.string({
39
+ description: 'Configure automatic backup schedule instead of a one-time backup',
40
+ options: ['daily', 'weekly'],
41
+ }),
42
+ };
43
+ async run() {
44
+ const { args, flags } = await this.parse(ServiceBackup);
45
+ const service = new HyperdriveSigV4Service(flags.domain);
46
+ const isJson = flags.json;
47
+ // Handle --schedule: configure schedule, not a one-time backup
48
+ if (flags.schedule) {
49
+ await this.handleSchedule(service, args.slug, flags.schedule, isJson);
50
+ return;
51
+ }
52
+ // Parse metadata key=value pairs
53
+ const metadata = flags.metadata
54
+ ? Object.fromEntries(flags.metadata.map(kv => {
55
+ const eqIdx = kv.indexOf('=');
56
+ if (eqIdx === -1)
57
+ return [kv, 'true'];
58
+ return [kv.slice(0, eqIdx), kv.slice(eqIdx + 1)];
59
+ }))
60
+ : undefined;
61
+ const spinner = isJson ? null : ora(`Creating backup for "${args.slug}"...`).start();
62
+ try {
63
+ const backup = await service.serviceCreateBackup(args.slug, metadata);
64
+ if (isJson && flags['no-wait']) {
65
+ this.log(JSON.stringify(backup, null, 2));
66
+ return;
67
+ }
68
+ spinner?.succeed(chalk.green(`Backup initiated: ${backup.id}`));
69
+ if (!isJson) {
70
+ this.log(` Service: ${chalk.cyan(args.slug)}`);
71
+ this.log(` Backup ID: ${chalk.cyan(backup.id)}`);
72
+ this.log(` Type: ${backup.type}`);
73
+ this.log(` Status: ${chalk.yellow(backup.status)}`);
74
+ this.log('');
75
+ }
76
+ if (flags['no-wait']) {
77
+ if (!isJson) {
78
+ this.log(chalk.gray(`Check status: hd service backups ${args.slug}`));
79
+ }
80
+ return;
81
+ }
82
+ // Poll for completion using shared poller
83
+ const result = await pollLifecycle({
84
+ pollFn: () => service.serviceGetBackup(args.slug, backup.id),
85
+ getStatus: (b) => b.status,
86
+ terminalStates: new Set(['completed', 'failed']),
87
+ successStates: new Set(['completed']),
88
+ getErrorMessage: (b) => b.errorMessage,
89
+ operationLabel: 'Backing up',
90
+ }, isJson);
91
+ if (isJson) {
92
+ this.log(JSON.stringify(result.entity, null, 2));
93
+ }
94
+ if (result.timedOut) {
95
+ if (!isJson) {
96
+ this.log(chalk.yellow('The backup may still be running.'));
97
+ this.log(chalk.gray(`Check status: hd service backups ${args.slug}`));
98
+ }
99
+ this.exit(1);
100
+ }
101
+ if (!result.success) {
102
+ this.exit(1);
103
+ }
104
+ // Success — print summary
105
+ if (!isJson && result.entity) {
106
+ const b = result.entity;
107
+ if (b.snapshotArn)
108
+ this.log(` Snapshot ARN: ${chalk.gray(b.snapshotArn)}`);
109
+ if (b.completedAt)
110
+ this.log(` Completed: ${new Date(b.completedAt).toLocaleString()}`);
111
+ }
112
+ }
113
+ catch (error) {
114
+ spinner?.fail('Backup request failed');
115
+ const axiosError = error;
116
+ const status = axiosError.response?.status;
117
+ const errorMessage = axiosError.response?.data?.message ?? axiosError.message;
118
+ if (status === 404) {
119
+ this.log(chalk.red(`Service not found: ${args.slug}`));
120
+ }
121
+ else if (status === 400) {
122
+ this.log(chalk.red(`Validation error: ${errorMessage}`));
123
+ }
124
+ else if (status === 409) {
125
+ this.log(chalk.red(`Conflict: ${errorMessage}`));
126
+ }
127
+ else {
128
+ this.log(chalk.red(`Error: ${errorMessage}`));
129
+ }
130
+ this.exit(1);
131
+ }
132
+ }
133
+ async handleSchedule(service, slug, schedule, isJson) {
134
+ const spinner = isJson ? null : ora(`Configuring ${schedule} backup schedule for "${slug}"...`).start();
135
+ try {
136
+ await service.serviceBackupSchedule(slug, schedule);
137
+ spinner?.succeed(chalk.green(`Backup schedule configured: ${schedule}`));
138
+ if (isJson) {
139
+ this.log(JSON.stringify({ slug, backupSchedule: schedule, message: 'Schedule configured' }, null, 2));
140
+ }
141
+ else {
142
+ this.log(` Service: ${chalk.cyan(slug)}`);
143
+ this.log(` Schedule: ${chalk.cyan(schedule)}`);
144
+ this.log('');
145
+ this.log(chalk.gray('Automatic backups will run on this schedule.'));
146
+ }
147
+ }
148
+ catch (error) {
149
+ spinner?.fail('Failed to configure backup schedule');
150
+ const axiosError = error;
151
+ const errorMessage = axiosError.response?.data?.message ?? axiosError.message;
152
+ this.log(chalk.red(`Error: ${errorMessage}`));
153
+ this.exit(1);
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class ServiceBackups extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,110 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { HyperdriveSigV4Service } from '../../services/hyperdrive-sigv4.js';
5
+ import { printTable } from '../../utils/table.js';
6
+ export default class ServiceBackups extends Command {
7
+ static description = 'List all backups for a service';
8
+ static examples = [
9
+ '<%= config.bin %> service backups rds-postgres-live',
10
+ '<%= config.bin %> service backups rds-postgres-live --limit 5',
11
+ '<%= config.bin %> service backups rds-postgres-live --json',
12
+ ];
13
+ static args = {
14
+ slug: Args.string({
15
+ description: 'Service slug',
16
+ required: true,
17
+ }),
18
+ };
19
+ static flags = {
20
+ domain: Flags.string({
21
+ char: 'd',
22
+ description: 'Tenant domain (for multi-domain setups)',
23
+ }),
24
+ json: Flags.boolean({
25
+ description: 'Output raw JSON response',
26
+ default: false,
27
+ }),
28
+ limit: Flags.integer({
29
+ description: 'Maximum number of backups to return',
30
+ default: 20,
31
+ }),
32
+ };
33
+ async run() {
34
+ const { args, flags } = await this.parse(ServiceBackups);
35
+ const service = new HyperdriveSigV4Service(flags.domain);
36
+ const isJson = flags.json;
37
+ const spinner = isJson ? null : ora(`Fetching backups for "${args.slug}"...`).start();
38
+ try {
39
+ const result = await service.serviceListBackups(args.slug, {
40
+ limit: String(flags.limit),
41
+ });
42
+ spinner?.stop();
43
+ const backups = result.backups ?? [];
44
+ if (isJson) {
45
+ this.log(JSON.stringify(backups, null, 2));
46
+ return;
47
+ }
48
+ if (backups.length === 0) {
49
+ this.log(chalk.yellow(`\nNo backups found for service "${args.slug}".`));
50
+ this.log(chalk.gray(`Create one: hd service backup ${args.slug}`));
51
+ return;
52
+ }
53
+ this.log(chalk.green(`\n${backups.length} backup(s) found:\n`));
54
+ printTable(backups, {
55
+ id: {
56
+ header: 'ID',
57
+ minWidth: 20,
58
+ get: (row) => {
59
+ const id = row.id;
60
+ return chalk.cyan(id.length > 18 ? id.slice(0, 18) + '...' : id);
61
+ },
62
+ },
63
+ status: {
64
+ header: 'Status',
65
+ get: (row) => {
66
+ const status = row.status;
67
+ if (status === 'completed')
68
+ return chalk.green(status);
69
+ if (status === 'failed')
70
+ return chalk.red(status);
71
+ return chalk.yellow(status);
72
+ },
73
+ },
74
+ type: {
75
+ header: 'Type',
76
+ },
77
+ serviceType: {
78
+ header: 'Service Type',
79
+ },
80
+ createdAt: {
81
+ header: 'Created At',
82
+ get: (row) => new Date(row.createdAt).toLocaleString(),
83
+ },
84
+ snapshotArn: {
85
+ header: 'Snapshot ARN',
86
+ get: (row) => {
87
+ const arn = row.snapshotArn;
88
+ if (!arn)
89
+ return chalk.gray('-');
90
+ return arn.length > 30
91
+ ? '...' + arn.slice(-27)
92
+ : arn;
93
+ },
94
+ },
95
+ }, (msg) => this.log(msg));
96
+ }
97
+ catch (error) {
98
+ spinner?.fail('Failed to fetch backups');
99
+ const axiosError = error;
100
+ const status = axiosError.response?.status;
101
+ if (status === 404) {
102
+ this.log(chalk.red(`Service not found: ${args.slug}`));
103
+ }
104
+ else {
105
+ this.log(chalk.red(`Error: ${axiosError.response?.data?.message ?? axiosError.message}`));
106
+ }
107
+ this.exit(1);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,16 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class ServiceBind extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ service: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ module: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'env-var-mapping': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ stage: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'template-vars': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ }