paratix 0.0.1 → 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/cli.js ADDED
@@ -0,0 +1,779 @@
1
+ import {
2
+ printCliHeader,
3
+ printCommandFailure,
4
+ printModuleResult,
5
+ printRecipeHeader,
6
+ printRunContext,
7
+ printSummary,
8
+ runSignalModules,
9
+ startModuleSpinner,
10
+ withRecipeOutputScope
11
+ } from "./chunk-DUIGEB2J.js";
12
+ import {
13
+ SshConnectionImpl,
14
+ assertValidModuleMetaEntries,
15
+ collectSshConfigErrors,
16
+ isSshdPortMetaEntry,
17
+ isSystemHostMetaEntry,
18
+ isSystemRebootMetaEntry,
19
+ loadDotEnvironment,
20
+ mergeEnvironment,
21
+ mergeEnvironmentFromMeta
22
+ } from "./chunk-G3BMCQKU.js";
23
+
24
+ // src/cli.ts
25
+ import { Command } from "commander";
26
+ import { realpathSync } from "fs";
27
+ import { resolve } from "path";
28
+ import { fileURLToPath, pathToFileURL } from "url";
29
+ import { inspect } from "util";
30
+ import pc from "picocolors";
31
+
32
+ // src/dryRunRecipe.ts
33
+ function shouldExecuteApplyDuringDryRun(module) {
34
+ return module._applyDryRun != null || module._dryRunBlocker === true || module._dryRunMetaProducer === true;
35
+ }
36
+ async function executeDryRunBlockingModule(parameters) {
37
+ const { childModule, connection, environment, verbose } = parameters;
38
+ startModuleSpinner(childModule.name);
39
+ const result = childModule._applyDryRun == null ? await childModule.apply(connection, environment) : await childModule._applyDryRun(connection, environment);
40
+ const nextEnvironment = result.meta == null ? environment : await mergeEnvironmentFromMeta(environment, result.meta);
41
+ printModuleResult(childModule.name, result.status, result._dryRunDetail ?? "(dry-run)");
42
+ if (result.status === "failed" && result.error != null) {
43
+ printCommandFailure(result.error, verbose);
44
+ }
45
+ return {
46
+ env: nextEnvironment,
47
+ shouldBreak: result.status === "failed" || result._stopRun === true,
48
+ status: result.status,
49
+ stopRun: result._stopRun
50
+ };
51
+ }
52
+ async function executeDryRunChildModule(parameters) {
53
+ const { childModule, environment, ssh, verbose } = parameters;
54
+ const connection = childModule.local === true ? null : ssh;
55
+ startModuleSpinner(childModule.name);
56
+ const checkResult = await childModule.check(connection, environment);
57
+ if (checkResult !== "ok" && shouldExecuteApplyDuringDryRun(childModule)) {
58
+ return executeDryRunBlockingModule({ childModule, connection, environment, verbose });
59
+ }
60
+ const status = checkResult === "ok" ? "ok" : "changed";
61
+ const suffix = checkResult === "ok" ? void 0 : "(dry-run)";
62
+ printModuleResult(childModule.name, status, suffix);
63
+ return { env: environment, shouldBreak: false, status };
64
+ }
65
+ async function dryRunRecipeModule(parameters) {
66
+ return withRecipeOutputScope(async () => {
67
+ const { environment, recipeModule, ssh } = parameters;
68
+ printRecipeHeader(recipeModule.name);
69
+ let aggregatedStatus = "ok";
70
+ let currentEnvironment = environment;
71
+ const verbose = parameters.options?.verbose ?? false;
72
+ for (const childModule of recipeModule._modules) {
73
+ const result = await executeDryRunChildModule({
74
+ childModule,
75
+ environment: currentEnvironment,
76
+ ssh,
77
+ verbose
78
+ });
79
+ if (result.shouldBreak) return result;
80
+ currentEnvironment = result.env;
81
+ if (result.status === "changed") aggregatedStatus = "changed";
82
+ }
83
+ return { env: currentEnvironment, shouldBreak: false, status: aggregatedStatus };
84
+ });
85
+ }
86
+
87
+ // src/runnerHelpers.ts
88
+ var SIGNAL_EXIT_BASE = 128;
89
+ var SIGTERM_NUMBER = 15;
90
+ var SIGINT_NUMBER = 2;
91
+ function signalExitCode(signal) {
92
+ return SIGNAL_EXIT_BASE + (signal === "SIGTERM" ? SIGTERM_NUMBER : SIGINT_NUMBER);
93
+ }
94
+ function resolveExitCode(shutdownSignal, stats) {
95
+ if (shutdownSignal != null) {
96
+ process.exitCode = signalExitCode(shutdownSignal);
97
+ } else if (stats.failed > 0) {
98
+ process.exitCode = 1;
99
+ }
100
+ }
101
+
102
+ // src/runner.ts
103
+ function setupShutdownHandlers() {
104
+ let receivedSignal = null;
105
+ let ssh = null;
106
+ const promptAbortController = new AbortController();
107
+ const handleShutdownSignal = (signal) => {
108
+ if (receivedSignal != null) {
109
+ process.exit(signalExitCode(signal));
110
+ }
111
+ receivedSignal = signal;
112
+ promptAbortController.abort(new Error(`Terminal prompt interrupted by ${signal}`));
113
+ console.error(`
114
+ Received ${signal}, shutting down\u2026`);
115
+ ssh?.disconnect();
116
+ };
117
+ process.on("SIGINT", handleShutdownSignal);
118
+ process.on("SIGTERM", handleShutdownSignal);
119
+ return {
120
+ handleShutdownSignal,
121
+ promptAbortSignal: promptAbortController.signal,
122
+ setSsh: (connection) => {
123
+ ssh = connection;
124
+ },
125
+ shutdownSignal: () => receivedSignal
126
+ };
127
+ }
128
+ var RunStats = class {
129
+ changed = 0;
130
+ failed = 0;
131
+ ok = 0;
132
+ signals = 0;
133
+ skipped = 0;
134
+ incrementSignals() {
135
+ this.signals++;
136
+ }
137
+ update(status) {
138
+ switch (status) {
139
+ case "changed": {
140
+ this.changed++;
141
+ break;
142
+ }
143
+ case "failed": {
144
+ this.failed++;
145
+ break;
146
+ }
147
+ case "ok": {
148
+ this.ok++;
149
+ break;
150
+ }
151
+ case "skipped": {
152
+ this.skipped++;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ };
158
+ function interruptedStepResult(environment) {
159
+ return { env: environment, shouldBreak: true };
160
+ }
161
+ function shouldBreakAfterResult(result) {
162
+ return result.status === "failed" || result._stopRun === true;
163
+ }
164
+ function interruptedBeforeApply(environment, shutdownSignal) {
165
+ if (shutdownSignal() == null) return void 0;
166
+ return interruptedStepResult(environment);
167
+ }
168
+ async function applyCheckedModule(parameters) {
169
+ const interrupted = interruptedBeforeApply(
170
+ parameters.currentEnvironment,
171
+ parameters.shutdownSignal
172
+ );
173
+ if (interrupted != null) return interrupted;
174
+ return applyModule({
175
+ currentEnvironment: parameters.currentEnvironment,
176
+ dryRun: parameters.dryRun,
177
+ ssh: parameters.ssh,
178
+ targetModule: parameters.targetModule,
179
+ verbose: parameters.verbose
180
+ });
181
+ }
182
+ function shouldExecuteApplyDuringDryRun2(module) {
183
+ return module._applyDryRun != null || module._dryRunBlocker === true || module._dryRunMetaProducer === true;
184
+ }
185
+ function handleCaughtStepError(parameters) {
186
+ if (parameters.shutdownSignal() != null) {
187
+ return interruptedStepResult(parameters.environment);
188
+ }
189
+ printModuleResult(parameters.moduleName, "failed");
190
+ printCommandFailure(parameters.error, parameters.verbose);
191
+ return { env: parameters.environment, shouldBreak: true, status: "failed" };
192
+ }
193
+ function isRecipe(target) {
194
+ return "_isRecipe" in target && target._isRecipe;
195
+ }
196
+ async function initializeEnvironment(options, definition) {
197
+ const dotEnvironment = options.envFile == null ? void 0 : await loadDotEnvironment(options.envFile);
198
+ return mergeEnvironment({}, dotEnvironment, definition.env, options.envOverrides);
199
+ }
200
+ async function handlePortChange(ssh, metaEntries) {
201
+ const portEntry = metaEntries?.find((entry) => isSshdPortMetaEntry(entry));
202
+ if (portEntry == null) return;
203
+ const newPort = portEntry.port;
204
+ ssh.addPort(newPort);
205
+ if (metaEntries?.some((entry) => isSystemRebootMetaEntry(entry)) ?? false) return;
206
+ try {
207
+ await ssh.reconnect();
208
+ } catch (error) {
209
+ console.error(
210
+ `Failed to reconnect on port ${newPort} after port change: ${String(error)}. Verify that port ${newPort} is allowed by the server's firewall rules.`
211
+ );
212
+ throw error;
213
+ }
214
+ }
215
+ async function handleReboot(ssh, metaEntries) {
216
+ if (!(metaEntries?.some((entry) => isSystemRebootMetaEntry(entry)) ?? false)) return;
217
+ const hostEntry = metaEntries?.find((entry) => isSystemHostMetaEntry(entry));
218
+ if (hostEntry != null) {
219
+ ssh.updateHost(hostEntry.host);
220
+ }
221
+ try {
222
+ await ssh.reconnect();
223
+ } catch (error) {
224
+ console.error(`Failed to reconnect after reboot: ${String(error)}`);
225
+ throw error;
226
+ }
227
+ }
228
+ async function applyRunnerControlPlaneMeta(ssh, step) {
229
+ if (step.meta == null) return;
230
+ assertValidModuleMetaEntries(step.meta);
231
+ await handlePortChange(ssh, step.meta);
232
+ await handleReboot(ssh, step.meta);
233
+ }
234
+ async function handleMetaAndBuildResult(ssh, environment, result) {
235
+ let currentEnvironment = environment;
236
+ if (result.meta != null) {
237
+ currentEnvironment = await mergeEnvironmentFromMeta(currentEnvironment, result.meta);
238
+ await applyRunnerControlPlaneMeta(ssh, { meta: result.meta });
239
+ }
240
+ return {
241
+ env: currentEnvironment,
242
+ flushSignals: result._flushSignals,
243
+ shouldBreak: shouldBreakAfterResult(result),
244
+ status: result.status,
245
+ stopRun: result._stopRun
246
+ };
247
+ }
248
+ async function runDryRunRecipeModule(recipeModule, environment, ssh, verbose) {
249
+ return dryRunRecipeModule({
250
+ environment,
251
+ options: { verbose },
252
+ recipeModule,
253
+ ssh
254
+ });
255
+ }
256
+ async function runRecipeModule(recipeModule, environment, ssh, stats, verbose, dryRun, shutdownSignal) {
257
+ try {
258
+ if (dryRun) return await runDryRunRecipeModule(recipeModule, environment, ssh, verbose);
259
+ startModuleSpinner(recipeModule.name);
260
+ const checkResult = await recipeModule.check(ssh, environment);
261
+ if (checkResult === "ok") {
262
+ printModuleResult(recipeModule.name, "ok");
263
+ return { env: environment, shouldBreak: false, status: "ok" };
264
+ }
265
+ const result = await recipeModule.apply(ssh, environment, {
266
+ onChildStep: async (step) => {
267
+ await applyRunnerControlPlaneMeta(ssh, step);
268
+ },
269
+ onSignalStep: async (step) => {
270
+ await applyRunnerControlPlaneMeta(ssh, step);
271
+ },
272
+ shutdownSignal,
273
+ signalHooks: {
274
+ onSignalFinished: (status) => {
275
+ stats.update(status);
276
+ },
277
+ onSignalStarted: () => {
278
+ stats.incrementSignals();
279
+ }
280
+ },
281
+ verbose
282
+ });
283
+ return await handleMetaAndBuildResult(ssh, environment, result);
284
+ } catch (error) {
285
+ return handleCaughtStepError({
286
+ environment,
287
+ error,
288
+ moduleName: recipeModule.name,
289
+ shutdownSignal,
290
+ verbose
291
+ });
292
+ }
293
+ }
294
+ async function applyModule(parameters) {
295
+ const { currentEnvironment, dryRun = false, ssh, targetModule, verbose } = parameters;
296
+ const connection = targetModule.local === true ? null : ssh;
297
+ const result = dryRun && targetModule._applyDryRun != null ? await targetModule._applyDryRun(connection, currentEnvironment) : await targetModule.apply(connection, currentEnvironment);
298
+ const stepResult = await handleMetaAndBuildResult(ssh, currentEnvironment, result);
299
+ const detail = dryRun ? result._dryRunDetail ?? "(dry-run)" : void 0;
300
+ printModuleResult(targetModule.name, result.status, detail);
301
+ if (result.status === "failed" && result.error != null) {
302
+ printCommandFailure(result.error, verbose);
303
+ }
304
+ return stepResult;
305
+ }
306
+ async function checkRegularModule(parameters) {
307
+ const { env, ssh, targetModule } = parameters;
308
+ const connection = targetModule.local === true ? null : ssh;
309
+ startModuleSpinner(targetModule.name);
310
+ return targetModule.check(connection, env);
311
+ }
312
+ function buildDryRunChangedResult(environment) {
313
+ return { env: environment, shouldBreak: false, status: "changed" };
314
+ }
315
+ async function runRegularModule(parameters) {
316
+ const { dryRun, env, ssh, targetModule, verbose } = parameters;
317
+ const shutdownSignal = parameters.shutdownSignal;
318
+ try {
319
+ const checkResult = await checkRegularModule({ env, ssh, targetModule });
320
+ if (checkResult === "ok") {
321
+ printModuleResult(targetModule.name, "ok");
322
+ return { env, shouldBreak: false, status: "ok" };
323
+ }
324
+ if (dryRun) {
325
+ if (shouldExecuteApplyDuringDryRun2(targetModule)) {
326
+ return await applyCheckedModule({
327
+ currentEnvironment: env,
328
+ dryRun: true,
329
+ shutdownSignal,
330
+ ssh,
331
+ targetModule,
332
+ verbose
333
+ });
334
+ }
335
+ printModuleResult(targetModule.name, "changed", "(dry-run)");
336
+ return buildDryRunChangedResult(env);
337
+ }
338
+ return await applyCheckedModule({
339
+ currentEnvironment: env,
340
+ dryRun: false,
341
+ shutdownSignal,
342
+ ssh,
343
+ targetModule,
344
+ verbose
345
+ });
346
+ } catch (error) {
347
+ return handleCaughtStepError({
348
+ environment: env,
349
+ error,
350
+ moduleName: targetModule.name,
351
+ shutdownSignal,
352
+ verbose
353
+ });
354
+ }
355
+ }
356
+ function updateLoopSignalState(input) {
357
+ if (input.result.status == null) return input.currentSignalsPending;
358
+ input.stats.update(input.result.status);
359
+ return input.result.status === "changed" ? true : input.currentSignalsPending;
360
+ }
361
+ function shouldFlushTopLevelSignals(input) {
362
+ return input.stepResult.flushSignals === true && !input.dryRun && input.shutdownSignal() == null && input.signalsPending && input.stats.failed === 0 && input.definitionSignals != null;
363
+ }
364
+ async function flushPendingTopLevelSignals(input) {
365
+ return runSignals({
366
+ env: input.currentEnvironment,
367
+ shutdownSignal: input.shutdownSignal,
368
+ signals: input.definitionSignals,
369
+ ssh: input.ssh,
370
+ stats: input.stats,
371
+ verbose: input.verbose
372
+ });
373
+ }
374
+ function applyLoopResultToState(state, result, stats) {
375
+ return {
376
+ currentEnvironment: result.env,
377
+ signalsPending: updateLoopSignalState({
378
+ currentSignalsPending: state.signalsPending,
379
+ result,
380
+ stats
381
+ }),
382
+ stopRun: result.stopRun === true ? true : state.stopRun
383
+ };
384
+ }
385
+ async function flushTopLevelSignalsIfRequested(parameters) {
386
+ if (!shouldFlushTopLevelSignals({
387
+ definitionSignals: parameters.definitionSignals,
388
+ dryRun: parameters.dryRun,
389
+ shutdownSignal: parameters.shutdownSignal,
390
+ signalsPending: parameters.loopState.signalsPending,
391
+ stats: parameters.stats,
392
+ stepResult: parameters.result
393
+ })) {
394
+ return { nextSignalsPending: parameters.loopState.signalsPending, outcome: "continue" };
395
+ }
396
+ const definitionSignals = parameters.definitionSignals;
397
+ if (definitionSignals == null) {
398
+ return { nextSignalsPending: parameters.loopState.signalsPending, outcome: "continue" };
399
+ }
400
+ const signalStatus = await flushPendingTopLevelSignals({
401
+ currentEnvironment: parameters.loopState.currentEnvironment,
402
+ definitionSignals,
403
+ shutdownSignal: parameters.shutdownSignal,
404
+ ssh: parameters.ssh,
405
+ stats: parameters.stats,
406
+ verbose: parameters.verbose
407
+ });
408
+ return {
409
+ nextSignalsPending: false,
410
+ outcome: signalStatus === "failed" ? "break" : "continue"
411
+ };
412
+ }
413
+ async function createModuleStepPromise(parameters) {
414
+ const { currentEnvironment, currentModule, dryRun, shutdownSignal, ssh, stats, verbose } = parameters;
415
+ return isRecipe(currentModule) ? runRecipeModule(
416
+ currentModule,
417
+ currentEnvironment,
418
+ ssh,
419
+ stats,
420
+ verbose,
421
+ dryRun,
422
+ shutdownSignal
423
+ ) : runRegularModule({
424
+ dryRun,
425
+ env: currentEnvironment,
426
+ shutdownSignal,
427
+ ssh,
428
+ targetModule: currentModule,
429
+ verbose
430
+ });
431
+ }
432
+ async function runModuleLoop(parameters) {
433
+ const { definitionSignals, dryRun, modules, shutdownSignal, ssh, stats, verbose } = parameters;
434
+ const loopState = {
435
+ currentEnvironment: parameters.env,
436
+ signalsPending: false,
437
+ stopRun: void 0
438
+ };
439
+ for (const currentModule of modules) {
440
+ if (shutdownSignal() != null) break;
441
+ const stepPromise = createModuleStepPromise({
442
+ currentEnvironment: loopState.currentEnvironment,
443
+ currentModule,
444
+ dryRun,
445
+ shutdownSignal,
446
+ ssh,
447
+ stats,
448
+ verbose
449
+ });
450
+ const result = await stepPromise;
451
+ Object.assign(loopState, applyLoopResultToState(loopState, result, stats));
452
+ const flushResult = await flushTopLevelSignalsIfRequested({
453
+ definitionSignals,
454
+ dryRun,
455
+ loopState,
456
+ result,
457
+ shutdownSignal,
458
+ ssh,
459
+ stats,
460
+ verbose
461
+ });
462
+ loopState.signalsPending = flushResult.nextSignalsPending;
463
+ if (flushResult.outcome === "break") break;
464
+ if (result.shouldBreak) break;
465
+ }
466
+ return {
467
+ env: loopState.currentEnvironment,
468
+ signalsPending: loopState.signalsPending,
469
+ stopRun: loopState.stopRun
470
+ };
471
+ }
472
+ async function runSignals(parameters) {
473
+ const { env, shutdownSignal, signals, ssh, stats, verbose } = parameters;
474
+ return runSignalModules({
475
+ environment: env,
476
+ hooks: {
477
+ onSignalFinished: (status) => {
478
+ stats.update(status);
479
+ },
480
+ onSignalStarted: () => {
481
+ stats.incrementSignals();
482
+ }
483
+ },
484
+ onSignalStep: async (step) => {
485
+ await applyRunnerControlPlaneMeta(ssh, step);
486
+ },
487
+ shutdownSignal,
488
+ signals,
489
+ ssh,
490
+ verbose
491
+ });
492
+ }
493
+ function throwIfShutdownRequested(shutdownSignal) {
494
+ const signal = shutdownSignal();
495
+ if (signal == null) return;
496
+ throw new Error(`Bootstrap interrupted by ${signal}`);
497
+ }
498
+ async function connectAndRegister(parameters) {
499
+ const { definition, options, promptAbortSignal, setSsh, shutdownSignal } = parameters;
500
+ const sshConfig = {
501
+ ...definition.ssh,
502
+ ports: [...definition.ssh.ports],
503
+ ...options.reconnectTimeout == null ? {} : { reconnectTimeout: options.reconnectTimeout }
504
+ };
505
+ const ssh = new SshConnectionImpl(definition.host, sshConfig);
506
+ setSsh(ssh);
507
+ throwIfShutdownRequested(shutdownSignal);
508
+ await ssh.connect({ abortSignal: promptAbortSignal });
509
+ throwIfShutdownRequested(shutdownSignal);
510
+ return ssh;
511
+ }
512
+ async function executeRun(parameters) {
513
+ const { definition, dryRun, environment, shutdownSignal, ssh, stats, verbose } = parameters;
514
+ printRecipeHeader(definition.name);
515
+ const loopResult = await runModuleLoop({
516
+ definitionSignals: definition.signals,
517
+ dryRun,
518
+ env: environment,
519
+ modules: definition.run,
520
+ shutdownSignal,
521
+ ssh,
522
+ stats,
523
+ verbose
524
+ });
525
+ const finalEnvironment = loopResult.env;
526
+ if (!dryRun && shutdownSignal() == null && loopResult.signalsPending && stats.failed === 0 && definition.signals != null)
527
+ await runSignals({
528
+ env: finalEnvironment,
529
+ shutdownSignal,
530
+ signals: definition.signals,
531
+ ssh,
532
+ stats,
533
+ verbose
534
+ });
535
+ printSummary(stats);
536
+ }
537
+ function rethrowIfNotShutdown(error, shutdownSignal) {
538
+ if (shutdownSignal() == null) throw error;
539
+ }
540
+ async function runPlaybook(definition, options = {}) {
541
+ const { dryRun = false, verbose = false } = options;
542
+ const environment = await initializeEnvironment(options, definition);
543
+ const { handleShutdownSignal, promptAbortSignal, setSsh, shutdownSignal } = setupShutdownHandlers();
544
+ const stats = new RunStats();
545
+ let ssh;
546
+ printRunContext({
547
+ dryRun,
548
+ host: definition.host,
549
+ name: definition.name,
550
+ ports: definition.ssh.ports
551
+ });
552
+ try {
553
+ ssh = await connectAndRegister({
554
+ definition,
555
+ options,
556
+ promptAbortSignal,
557
+ setSsh,
558
+ shutdownSignal
559
+ });
560
+ await executeRun({ definition, dryRun, environment, shutdownSignal, ssh, stats, verbose });
561
+ } catch (error) {
562
+ rethrowIfNotShutdown(error, shutdownSignal);
563
+ } finally {
564
+ for (const signal of ["SIGINT", "SIGTERM"])
565
+ process.removeListener(signal, handleShutdownSignal);
566
+ ssh?.disconnect();
567
+ }
568
+ resolveExitCode(shutdownSignal(), stats);
569
+ }
570
+
571
+ // src/cli.ts
572
+ var SECONDS_TO_MS = 1e3;
573
+ var DEFAULT_RECONNECT_TIMEOUT_SECONDS = 300;
574
+ var ENVIRONMENT_KEY_PATTERN = new RegExp("^[A-Za-z_]\\w*$", "v");
575
+ var FIRST_RUN_ENV_NAME = "PARATIX_FIRST_RUN";
576
+ function isServerDefinitionLike(value) {
577
+ return collectDefinitionErrors(value).length === 0;
578
+ }
579
+ function collectStringErrors(object, check, errors) {
580
+ const name = check.label ?? check.key;
581
+ if (!(check.key in object)) {
582
+ errors.push(`Missing property '${name}' (expected string)`);
583
+ } else if (typeof object[check.key] !== "string") {
584
+ errors.push(`Invalid property '${name}' (expected string, got ${typeof object[check.key]})`);
585
+ } else if (object[check.key].length === 0) {
586
+ errors.push(`Property '${name}' must not be empty`);
587
+ }
588
+ }
589
+ function collectArrayErrors(object, check, errors) {
590
+ const name = check.label ?? check.key;
591
+ if (!(check.key in object)) {
592
+ errors.push(`Missing property '${name}' (expected array)`);
593
+ } else if (!Array.isArray(object[check.key])) {
594
+ errors.push(`Invalid property '${name}' (expected array, got ${typeof object[check.key]})`);
595
+ } else if (object[check.key].length === 0) {
596
+ errors.push(`Property '${name}' must not be empty`);
597
+ }
598
+ }
599
+ function collectSshErrors(value, errors) {
600
+ if (!("ssh" in value)) {
601
+ errors.push("Missing property 'ssh' (expected object)");
602
+ return;
603
+ }
604
+ errors.push(...collectSshConfigErrors(value.ssh));
605
+ }
606
+ function collectDefinitionErrors(value) {
607
+ const errors = [];
608
+ if (typeof value !== "object" || value === null) {
609
+ errors.push("Export is not an object");
610
+ return errors;
611
+ }
612
+ const object = value;
613
+ collectStringErrors(object, { key: "name" }, errors);
614
+ collectStringErrors(object, { key: "host" }, errors);
615
+ collectSshErrors(object, errors);
616
+ collectArrayErrors(object, { key: "run" }, errors);
617
+ return errors;
618
+ }
619
+ function validateServerDefinition(value, file) {
620
+ if (isServerDefinitionLike(value)) {
621
+ return;
622
+ }
623
+ const errors = collectDefinitionErrors(value);
624
+ const details = errors.map((entry) => ` - ${entry}`).join("\n");
625
+ console.error(
626
+ `Error: ${file} does not export a valid ServerDefinition.
627
+ ${details}
628
+ Use the server() helper to create a valid definition.`
629
+ );
630
+ process.exit(2);
631
+ }
632
+ function errorToString(value) {
633
+ if (value instanceof Error) return value.message;
634
+ if (typeof value === "object" && value !== null) {
635
+ try {
636
+ return JSON.stringify(value);
637
+ } catch {
638
+ return inspect(value, { breakLength: Infinity, depth: 5 });
639
+ }
640
+ }
641
+ return String(value);
642
+ }
643
+ function printCauseChain(error) {
644
+ let cause = error.cause;
645
+ while (cause != null) {
646
+ console.error(` Caused by: ${errorToString(cause)}`);
647
+ cause = cause instanceof Error ? cause.cause : void 0;
648
+ }
649
+ }
650
+ function printExceptionError(error, verbose) {
651
+ console.error(`Error: ${errorToString(error)}`);
652
+ if (error instanceof Error) {
653
+ printCauseChain(error);
654
+ if (verbose && error.stack != null) {
655
+ console.error(`
656
+ ${error.stack}`);
657
+ }
658
+ }
659
+ }
660
+ function handleTsxLoadFailure(filePath) {
661
+ if (new RegExp("\\.[cm]?ts$", "v").test(filePath)) {
662
+ console.error(
663
+ `${pc.red("Error:")} tsx is required to run TypeScript playbooks but could not be loaded.
664
+ Install it with: ${pc.bold("npm install -g tsx")} or add it as a devDependency.`
665
+ );
666
+ process.exit(2);
667
+ }
668
+ }
669
+ function isDirectCliExecution(moduleUrl, candidateEntryScript) {
670
+ if (candidateEntryScript == null) {
671
+ return false;
672
+ }
673
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(candidateEntryScript);
674
+ }
675
+ function applyCliEnvironmentOverrides(environment, options) {
676
+ if (!options.firstRun) return environment;
677
+ return { ...environment, [FIRST_RUN_ENV_NAME]: "true" };
678
+ }
679
+ function applyCliProcessEnvironment(options) {
680
+ if (!options.firstRun) return;
681
+ process.env[FIRST_RUN_ENV_NAME] = "true";
682
+ }
683
+ async function loadServerDefinitionFromFile(file, options) {
684
+ const filePath = resolve(file);
685
+ const fileUrl = pathToFileURL(filePath).href;
686
+ applyCliProcessEnvironment(options);
687
+ await import("tsx/esm/api").then((tsx) => {
688
+ tsx.register();
689
+ }).catch(() => {
690
+ handleTsxLoadFailure(filePath);
691
+ });
692
+ const imported = await import(fileUrl);
693
+ const definition = imported.default ?? imported;
694
+ validateServerDefinition(definition, filePath);
695
+ return definition;
696
+ }
697
+ var program = new Command();
698
+ program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.1.0");
699
+ program.command("apply <file>").description("Apply a server definition").option(
700
+ "--dry-run",
701
+ "Only check, do not apply. Some modules validate prospective config but cannot verify runtime restarts.",
702
+ false
703
+ ).option("--env <key=value...>", "Set env values", collectEnvironment, {}).option("--env-file <path>", "Load dotenv file").option("--first-run", "Set PARATIX_FIRST_RUN=true before loading the playbook", false).option(
704
+ "--reconnect-timeout <seconds>",
705
+ "SSH reconnect timeout",
706
+ parsePositiveNumber,
707
+ DEFAULT_RECONNECT_TIMEOUT_SECONDS
708
+ ).option("--verbose", "Show full stack traces on error", false).action(async (file, options) => {
709
+ try {
710
+ printCliHeader("0.1.0");
711
+ const environmentOverrides = applyCliEnvironmentOverrides(
712
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
713
+ options.env,
714
+ {
715
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
716
+ firstRun: options.firstRun
717
+ }
718
+ );
719
+ const definition = await loadServerDefinitionFromFile(file, {
720
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
721
+ firstRun: options.firstRun
722
+ });
723
+ await runPlaybook(definition, {
724
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
725
+ dryRun: options.dryRun,
726
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
727
+ envFile: options.envFile,
728
+ envOverrides: environmentOverrides,
729
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
730
+ reconnectTimeout: options.reconnectTimeout * SECONDS_TO_MS,
731
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
732
+ verbose: options.verbose
733
+ });
734
+ } catch (error) {
735
+ printExceptionError(error, options.verbose);
736
+ process.exit(process.exitCode ?? 2);
737
+ }
738
+ });
739
+ function parsePositiveNumber(value) {
740
+ const parsed = Number(value);
741
+ if (!Number.isFinite(parsed) || parsed <= 0) {
742
+ console.error(`Invalid --reconnect-timeout value: ${value} (expected a positive number)`);
743
+ process.exit(2);
744
+ }
745
+ return parsed;
746
+ }
747
+ function collectEnvironment(value, previous) {
748
+ const eqIndex = value.indexOf("=");
749
+ if (eqIndex === -1) {
750
+ console.error(`Invalid --env format: ${value} (expected key=value)`);
751
+ process.exit(2);
752
+ }
753
+ const key = value.slice(0, eqIndex);
754
+ const value_ = value.slice(eqIndex + 1);
755
+ if (!ENVIRONMENT_KEY_PATTERN.test(key)) {
756
+ console.error(
757
+ `Invalid --env name: ${key === "" ? "(empty)" : key} (expected [A-Za-z_][A-Za-z0-9_]*)`
758
+ );
759
+ process.exit(2);
760
+ }
761
+ return { ...previous, [key]: value_ };
762
+ }
763
+ var entryScript = process.argv[1];
764
+ if (isDirectCliExecution(import.meta.url, entryScript)) {
765
+ await program.parseAsync();
766
+ }
767
+ export {
768
+ applyCliEnvironmentOverrides,
769
+ applyCliProcessEnvironment,
770
+ collectDefinitionErrors,
771
+ collectEnvironment,
772
+ handleTsxLoadFailure,
773
+ isDirectCliExecution,
774
+ isServerDefinitionLike,
775
+ loadServerDefinitionFromFile,
776
+ parsePositiveNumber,
777
+ printExceptionError
778
+ };
779
+ //# sourceMappingURL=cli.js.map