@wpelevator/tug 0.12.0 → 0.13.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 (68) hide show
  1. package/README.md +7 -3
  2. package/dist/backup-restore.d.ts +1 -1
  3. package/dist/backup-restore.js +3 -1
  4. package/dist/backup-scheduler.js +3 -1
  5. package/dist/cli-build.d.ts +1 -1
  6. package/dist/cli-build.js +4 -1
  7. package/dist/cli-process.d.ts +13 -0
  8. package/dist/cli-process.js +46 -0
  9. package/dist/cli-progress.d.ts +11 -0
  10. package/dist/cli-progress.js +37 -0
  11. package/dist/cli-prompt.js +5 -4
  12. package/dist/cli-selection.d.ts +13 -0
  13. package/dist/cli-selection.js +39 -0
  14. package/dist/commands/backup.js +48 -31
  15. package/dist/commands/context.d.ts +2 -5
  16. package/dist/commands/context.js +50 -28
  17. package/dist/commands/control.js +55 -42
  18. package/dist/commands/credential.js +21 -2
  19. package/dist/commands/deploy.js +97 -70
  20. package/dist/commands/hold.js +12 -4
  21. package/dist/commands/host.d.ts +2 -2
  22. package/dist/commands/host.js +13 -8
  23. package/dist/commands/node.d.ts +1 -0
  24. package/dist/commands/node.js +103 -71
  25. package/dist/commands/observe.js +7 -2
  26. package/dist/commands/restore.js +71 -50
  27. package/dist/commands/secret.js +8 -3
  28. package/dist/commands/selection.d.ts +22 -0
  29. package/dist/commands/selection.js +90 -0
  30. package/dist/commands/service.js +17 -6
  31. package/dist/commands/volume.js +3 -2
  32. package/dist/control.js +95 -47
  33. package/dist/credentials.js +5 -0
  34. package/dist/db-backup-destination.d.ts +14 -0
  35. package/dist/db-backup-destination.js +28 -0
  36. package/dist/db-locks.d.ts +10 -0
  37. package/dist/db-locks.js +23 -0
  38. package/dist/db-transaction.d.ts +3 -0
  39. package/dist/db-transaction.js +24 -0
  40. package/dist/image.d.ts +9 -0
  41. package/dist/image.js +19 -0
  42. package/dist/util.d.ts +2 -0
  43. package/dist/util.js +6 -0
  44. package/docs/access-control.md +166 -0
  45. package/docs/architecture.md +254 -0
  46. package/docs/backups.md +160 -0
  47. package/docs/cli.md +182 -0
  48. package/docs/compose.md +245 -0
  49. package/docs/configuration.md +52 -0
  50. package/docs/control-plane.md +112 -0
  51. package/docs/development.md +140 -0
  52. package/docs/getting-started.md +75 -0
  53. package/docs/healthchecks.md +51 -0
  54. package/docs/migration.md +113 -0
  55. package/docs/networking.md +150 -0
  56. package/docs/nodes.md +143 -0
  57. package/docs/observability.md +213 -0
  58. package/docs/real-host-evaluation.md +224 -0
  59. package/docs/releases.md +39 -0
  60. package/docs/roadmap.md +234 -0
  61. package/docs/security.md +198 -0
  62. package/docs/service-placement.md +72 -0
  63. package/docs/standalone-binary.md +79 -0
  64. package/docs/swarm-comparison.md +197 -0
  65. package/docs/terminology.md +74 -0
  66. package/docs/testing.md +254 -0
  67. package/docs/troubleshooting.md +33 -0
  68. package/package.json +6 -3
package/README.md CHANGED
@@ -6,9 +6,11 @@ A VPS is a durable computer with enough resources to host multiple sites or a me
6
6
 
7
7
  Tug runs a small control plane and an agent, which can share the same VPS with your applications. It builds application images on the control server or your machine and delivers them to your nodes, so you do not need to run or configure an image registry for those builds. Published images are still pulled from their upstream registries.
8
8
 
9
+ Existing service and resource targets can be chosen directly in an interactive terminal: try `tug logs`, `tug deploy cancel` or `tug restore run`. Use `--select` for backup and move-hold subsets; see [interactive CLI selection](docs/cli.md#interactive-selection) for all supported commands and scripting behavior.
10
+
9
11
  ## What you can do with it
10
12
 
11
- - **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
13
+ - **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. It reports deployment stages and elapsed time while work is pending. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
12
14
  - **Choose where things run.** Pin a database and its volume to one node, spread web containers over several, and let Tug's managed Caddy ingress serve them over HTTPS with certificates it obtains for you.
13
15
  - **Update without downtime.** Opt-in rolling rollouts start the new version, wait until it is healthy and only then switch traffic.
14
16
  - **Keep secrets out of your repository.** `tug secret set DB_PASSWORD` stores them encrypted and sends each one only to the nodes whose services use it.
@@ -34,6 +36,8 @@ The control plane and agent share the VPS with your application. Once your servi
34
36
 
35
37
  In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
36
38
 
39
+ Long-running deployments, rollbacks, setup, image transfers, backups, restores and agent command waits report their current stage and elapsed time to stderr every ten seconds. Progress also works in CI and keeps JSON results separate. See [CLI progress reporting](docs/cli.md).
40
+
37
41
  ## Your Compose file, plus a little `x-tug`
38
42
 
39
43
  This example builds on the [quickstart](docs/getting-started.md). It pins the app, database and database volume to `app1`. Placement can be omitted with one node, but explicit placement keeps the database on its chosen server when you add more nodes. Enable ingress with `tug node ingress app1`, point the hostname's DNS at the VPS, and allow public ports 80 and 443. Save this as `compose.yaml` and replace the hostname with yours:
@@ -94,7 +98,7 @@ tug logs wordpress --follow
94
98
 
95
99
  The named volume holds data on `app1`; it is not copied to another node automatically. Back it up and restore or move its data deliberately if you replace the server.
96
100
 
97
- See the [examples directory](examples/) for stack-specific applications. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
101
+ See the [examples directory](examples/) for stack-specific applications and [local stack benchmarks](docs/testing.md#example-stack-benchmarks) for measured behavior of those examples. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
98
102
 
99
103
  ## Everyday commands
100
104
 
@@ -199,4 +203,4 @@ npm run lint
199
203
  npm run test:integration
200
204
  ```
201
205
 
202
- See [Development](docs/development.md) for details.
206
+ See [Development](docs/development.md) for details. Database code uses direct SQLite queries; [SQLite storage](docs/architecture.md#sqlite-storage) describes query boundaries and synchronous transactions.
@@ -69,6 +69,6 @@ export declare function createRestoreService(deps: RestoreDeps): {
69
69
  status: number;
70
70
  };
71
71
  holds: () => Record<string, string[]>;
72
- list: (project: string, limit?: number) => RestoreRunRow[];
72
+ list: (project: string, limit?: number, interruptedOnly?: boolean) => RestoreRunRow[];
73
73
  };
74
74
  export type RestoreService = ReturnType<typeof createRestoreService>;
@@ -374,7 +374,9 @@ export function createRestoreService(deps) {
374
374
  (result[row.project_id] ??= []).push(row.service);
375
375
  return result;
376
376
  }
377
- function list(project, limit = 20) {
377
+ function list(project, limit = 20, interruptedOnly = false) {
378
+ if (interruptedOnly)
379
+ return db.all("SELECT * FROM restore_runs WHERE project_id=? AND state='interrupted' ORDER BY started_at DESC,id", project);
378
380
  return db.all('SELECT * FROM restore_runs WHERE project_id=? ORDER BY started_at DESC LIMIT ?', project, limit);
379
381
  }
380
382
  return { plan, start, advance, releaseInterrupted, resume, holds, list };
@@ -4,6 +4,7 @@ import { projectRepository } from "./backup-destination.js";
4
4
  import { volumeName } from "./container-spec.js";
5
5
  import { id } from "./util.js";
6
6
  import { isUniqueViolation } from "./db-driver.js";
7
+ import { backupDestinationQueries } from "./db-backup-destination.js";
7
8
  /** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
8
9
  export const DEFAULT_RESTIC_IMAGE = 'restic/restic:0.17.3';
9
10
  const MINUTE = 60_000;
@@ -63,7 +64,8 @@ export function createBackupService(deps) {
63
64
  const started = clock().getTime();
64
65
  const stamp = () => clock().toISOString();
65
66
  const heartbeats = new Map();
66
- const destination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
67
+ const destinationQueries = backupDestinationQueries(db);
68
+ const destination = () => destinationQueries.find();
67
69
  const nodeId = (name) => db.get('SELECT id FROM nodes WHERE name=?', name)?.id;
68
70
  const nodeName = (nodeIdValue) => nodeIdValue
69
71
  ? (db.get('SELECT name FROM nodes WHERE id=?', nodeIdValue)?.name ?? null)
@@ -14,4 +14,4 @@ export declare function createSourceArchive(contextDir: string, archive: string,
14
14
  /** Build from the same filtered, validated source snapshot used for remote builds. */
15
15
  export declare function createLocalBuildSnapshot(contextDir: string, directory: string, configFiles: string[]): Promise<string>;
16
16
  /** Build only application build services; callers own temporary files and upload transport. */
17
- export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies): Promise<LocalImageUpload[]>;
17
+ export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies, onProgress?: (message: string) => void): Promise<LocalImageUpload[]>;
package/dist/cli-build.js CHANGED
@@ -143,7 +143,7 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
143
143
  remove: async (imageRef) => {
144
144
  await run('docker', ['image', 'rm', imageRef]);
145
145
  },
146
- }) {
146
+ }, onProgress) {
147
147
  const images = [];
148
148
  const nonce = dependencies.nonce();
149
149
  for (const [service, spec] of Object.entries(compose.services)) {
@@ -152,16 +152,19 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
152
152
  const field = `image-${images.length}`;
153
153
  const imageRef = `tug-build/${nonce}/${field}:local`;
154
154
  try {
155
+ onProgress?.(`Building image for ${service} locally`);
155
156
  await dependencies.build({
156
157
  contextDir,
157
158
  service,
158
159
  buildSpec: spec.build,
159
160
  imageRef,
160
161
  });
162
+ onProgress?.(`Exporting image for ${service}`);
161
163
  const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
162
164
  images.push({ service, imageRef, ...archive, field });
163
165
  }
164
166
  finally {
167
+ onProgress?.(`Cleaning up temporary image for ${service}`);
165
168
  try {
166
169
  await dependencies.remove(imageRef);
167
170
  }
@@ -0,0 +1,13 @@
1
+ /** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
2
+ export declare function runProcess(cmd: string, args: string[], { capture, input }?: {
3
+ capture?: boolean;
4
+ input?: string;
5
+ }): Promise<{
6
+ status: number | null;
7
+ stdout: string;
8
+ stderr: string;
9
+ }>;
10
+ export declare function runLocal(cmd: string, args: string[]): Promise<void>;
11
+ export declare function runCapture(cmd: string, args: string[], opts?: {
12
+ input?: string;
13
+ }): Promise<string>;
@@ -0,0 +1,46 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { RUN_OUTPUT_LIMIT } from "./build.js";
3
+ /** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
4
+ export function runProcess(cmd, args, { capture = false, input } = {}) {
5
+ return new Promise((resolve, reject) => {
6
+ const child = spawn(cmd, args, {
7
+ stdio: [
8
+ input === undefined ? 'inherit' : 'pipe',
9
+ capture ? 'pipe' : 'inherit',
10
+ capture ? 'pipe' : 'inherit',
11
+ ],
12
+ });
13
+ let stdout = '';
14
+ let stderr = '';
15
+ child.stdout?.on('data', (chunk) => {
16
+ stdout = (stdout + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
17
+ });
18
+ child.stderr?.on('data', (chunk) => {
19
+ stderr = (stderr + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
20
+ });
21
+ child.on('error', reject);
22
+ child.stdin?.on('error', (error) => {
23
+ if (error.code !== 'EPIPE')
24
+ reject(error);
25
+ });
26
+ if (input !== undefined)
27
+ child.stdin?.end(input);
28
+ child.on('close', (status, signal) => {
29
+ if (signal)
30
+ reject(new Error(`${cmd} terminated by ${signal}`));
31
+ else
32
+ resolve({ status, stdout, stderr });
33
+ });
34
+ });
35
+ }
36
+ export async function runLocal(cmd, args) {
37
+ const result = await runProcess(cmd, args);
38
+ if (result.status !== 0)
39
+ throw new Error(`${cmd} failed (exit ${result.status})`);
40
+ }
41
+ export async function runCapture(cmd, args, opts = {}) {
42
+ const result = await runProcess(cmd, args, { ...opts, capture: true });
43
+ if (result.status !== 0)
44
+ throw new Error(`${cmd} failed (exit ${result.status})\n${result.stderr}`);
45
+ return result.stdout;
46
+ }
@@ -0,0 +1,11 @@
1
+ /** Line-based stderr progress works in terminals and CI without changing stdout results. */
2
+ export declare function createProgress({ write, now, intervalMs, }?: {
3
+ write?: ((line: string) => void) | undefined;
4
+ now?: (() => number) | undefined;
5
+ intervalMs?: number | undefined;
6
+ }): {
7
+ stage(next: string): void;
8
+ stop(): void;
9
+ };
10
+ /** Own the timer for one operation, including failures and early returns. */
11
+ export declare function withProgress<T>(label: string, operation: (progress: ReturnType<typeof createProgress>) => Promise<T>): Promise<T>;
@@ -0,0 +1,37 @@
1
+ /** Line-based stderr progress works in terminals and CI without changing stdout results. */
2
+ export function createProgress({ write = (line) => {
3
+ process.stderr.write(`${line}\n`);
4
+ }, now = Date.now, intervalMs = 10_000, } = {}) {
5
+ let label = '';
6
+ let started = now();
7
+ let timer;
8
+ const report = () => write(`[${Math.floor((now() - started) / 1000)}s] ${label}`);
9
+ return {
10
+ stage(next) {
11
+ if (next === label)
12
+ return;
13
+ label = next;
14
+ started = now();
15
+ report();
16
+ if (!timer) {
17
+ timer = setInterval(report, intervalMs);
18
+ timer.unref();
19
+ }
20
+ },
21
+ stop() {
22
+ clearInterval(timer);
23
+ timer = undefined;
24
+ },
25
+ };
26
+ }
27
+ /** Own the timer for one operation, including failures and early returns. */
28
+ export async function withProgress(label, operation) {
29
+ const progress = createProgress();
30
+ try {
31
+ progress.stage(label);
32
+ return await operation(progress);
33
+ }
34
+ finally {
35
+ progress.stop();
36
+ }
37
+ }
@@ -10,24 +10,25 @@ export const ask = async (question, hidden = false) => {
10
10
  const output = new Writable({
11
11
  write(chunk, _encoding, callback) {
12
12
  if (!muted.on)
13
- process.stdout.write(chunk);
13
+ process.stderr.write(chunk);
14
14
  callback();
15
15
  },
16
16
  });
17
17
  const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
18
18
  try {
19
19
  return await new Promise((resolve, reject) => {
20
- rl.once('close', () => reject(new Error('input closed before an answer was given')));
20
+ const onClose = () => reject(new Error('input closed before an answer was given'));
21
+ rl.once('close', onClose);
21
22
  rl.question(question, (answer) => {
23
+ rl.off('close', onClose);
22
24
  if (hidden)
23
- process.stdout.write('\n');
25
+ process.stderr.write('\n');
24
26
  resolve(answer);
25
27
  });
26
28
  muted.on = hidden;
27
29
  });
28
30
  }
29
31
  finally {
30
- rl.removeAllListeners('close');
31
32
  rl.close();
32
33
  }
33
34
  };
@@ -0,0 +1,13 @@
1
+ import type { Prompter } from './cli-prompt.ts';
2
+ export interface Choice {
3
+ value: string;
4
+ label: string;
5
+ }
6
+ export interface SelectionIO {
7
+ interactive: boolean;
8
+ prompt: Prompter;
9
+ write: (message: string) => void;
10
+ }
11
+ /** Resolve lazily so explicit arguments never need a discovery request. */
12
+ export declare function selectOne(value: string | undefined, question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string>;
13
+ export declare function selectMany(question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string[]>;
@@ -0,0 +1,39 @@
1
+ import { ask, canPrompt } from "./cli-prompt.js";
2
+ const terminal = () => ({
3
+ interactive: canPrompt(),
4
+ prompt: ask,
5
+ write: console.error,
6
+ });
7
+ /** Resolve lazily so explicit arguments never need a discovery request. */
8
+ export async function selectOne(value, question, load, io = terminal()) {
9
+ if (value)
10
+ return value;
11
+ const selected = await selectChoices(question, load, false, io);
12
+ return selected[0];
13
+ }
14
+ export async function selectMany(question, load, io = terminal()) {
15
+ return selectChoices(question, load, true, io);
16
+ }
17
+ async function selectChoices(question, load, multiple, io) {
18
+ if (!io.interactive)
19
+ throw new Error(`${question}: pass an explicit target; selection requires a terminal outside CI`);
20
+ const options = await load();
21
+ if (!options.length)
22
+ throw new Error(`${question}: no eligible options`);
23
+ io.write(question);
24
+ options.forEach((option, index) => io.write(` ${index + 1}) ${option.label}`));
25
+ for (;;) {
26
+ const answer = (await io.prompt(multiple
27
+ ? 'Choose numbers separated by commas (or q to cancel): '
28
+ : 'Choose a number (or q to cancel): ')).trim();
29
+ if (/^(q|quit|cancel)$/i.test(answer))
30
+ throw new Error('cancelled; nothing was changed');
31
+ const parts = answer.split(',').map((part) => part.trim());
32
+ if ((!multiple && parts.length !== 1) || parts.some((part) => !/^[1-9]\d*$/.test(part)))
33
+ continue;
34
+ const indexes = parts.map((part) => Number(part) - 1);
35
+ if (indexes.some((index) => index >= options.length))
36
+ continue;
37
+ return [...new Set(indexes)].map((index) => options[index].value);
38
+ }
39
+ }
@@ -1,3 +1,5 @@
1
+ import { selectBackups } from "./selection.js";
2
+ import { withProgress } from "../cli-progress.js";
1
3
  import { backupCheckProblems } from "../backup-check.js";
2
4
  import { collect, loadCfg, print, readStdin, request } from "./context.js";
3
5
  const formatBytes = (bytes) => bytes === null
@@ -13,42 +15,57 @@ export function registerBackup(program) {
13
15
  backup
14
16
  .command('run [targets...]')
15
17
  .description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
18
+ .option('--select', 'choose one or more backup targets instead of backing up all')
16
19
  .option('--no-wait', 'return once the backups are queued')
17
20
  .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
18
21
  .action(async (targets, o) => {
19
- const project = (await loadCfg()).project || 'site';
20
- const queued = await request('POST', '/api/v1/backups/run', {
21
- project,
22
- targets: targets.length ? targets : undefined,
23
- });
24
- for (const message of queued.errors)
25
- console.error(message);
26
- for (const run of queued.runs)
27
- console.log(`queued ${run.target} as ${run.runId}`);
28
- let failed = queued.errors.length > 0;
29
- if (o.wait) {
30
- const deadline = Date.now() + Number(o.timeout) * 1000;
31
- const pending = new Set(queued.runs.map((run) => run.runId));
32
- while (pending.size && Date.now() < deadline) {
33
- await new Promise((resolve) => setTimeout(resolve, 2000));
34
- const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
35
- for (const run of listing.runs)
36
- if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
37
- pending.delete(run.id);
38
- const ok = run.state === 'succeeded';
39
- failed ||= !ok;
40
- console.log(ok
41
- ? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
42
- : `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
22
+ if (o.select && targets.length)
23
+ throw new Error('--select cannot be combined with explicit targets');
24
+ if (o.select)
25
+ targets = await selectBackups();
26
+ return withProgress('Queueing backups', async (progress) => {
27
+ const project = (await loadCfg()).project || 'site';
28
+ const queued = await request('POST', '/api/v1/backups/run', {
29
+ project,
30
+ targets: targets.length ? targets : undefined,
31
+ });
32
+ for (const message of queued.errors)
33
+ console.error(message);
34
+ for (const run of queued.runs)
35
+ console.log(`queued ${run.target} as ${run.runId}`);
36
+ let failed = queued.errors.length > 0;
37
+ if (o.wait) {
38
+ const deadline = Date.now() + Number(o.timeout) * 1000;
39
+ const pending = new Set(queued.runs.map((run) => run.runId));
40
+ if (pending.size)
41
+ progress.stage(`Waiting for backups: ${queued.runs.map((run) => run.target).join(', ')}`);
42
+ const states = new Map();
43
+ while (pending.size && Date.now() < deadline) {
44
+ await new Promise((resolve) => setTimeout(resolve, 2000));
45
+ const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
46
+ for (const run of listing.runs) {
47
+ if (pending.has(run.id))
48
+ states.set(run.id, `${run.target}: ${run.state}`);
49
+ if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
50
+ pending.delete(run.id);
51
+ const ok = run.state === 'succeeded';
52
+ failed ||= !ok;
53
+ console.log(ok
54
+ ? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
55
+ : `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
56
+ }
43
57
  }
58
+ if (pending.size)
59
+ progress.stage(`Backups: ${[...pending].map((id) => states.get(id) ?? id).join(', ')}`);
60
+ }
61
+ if (pending.size) {
62
+ console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
63
+ failed = true;
64
+ }
44
65
  }
45
- if (pending.size) {
46
- console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
47
- failed = true;
48
- }
49
- }
50
- if (failed)
51
- process.exitCode = 1;
66
+ if (failed)
67
+ process.exitCode = 1;
68
+ });
52
69
  });
53
70
  backup
54
71
  .command('list')
@@ -1,4 +1,4 @@
1
- import type { SpawnSyncOptions } from 'node:child_process';
1
+ export { runCapture, runLocal } from '../cli-process.ts';
2
2
  import type { ResolvedCliConfig } from '../cli-config.ts';
3
3
  import type { LiveInput, LiveRequest } from '../cli-live.ts';
4
4
  import type { DeploymentState } from '../protocol.ts';
@@ -50,9 +50,6 @@ export declare function request<T = any>(method: string, route: string, body?: u
50
50
  /** The project a project-scoped command works on: `--project`, then the resolved config, then `site`. */
51
51
  export declare function projectOf(o: CommonOpts): Promise<string>;
52
52
  export declare const print: (value: unknown) => void;
53
- export declare function runLocal(cmd: string, args: string[], opts?: SpawnSyncOptions): void;
54
- export declare function runCapture(cmd: string, args: string[], opts?: SpawnSyncOptions): string;
55
- export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number): Promise<DeploymentRecord>;
53
+ export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number, onProgress?: (deployment: DeploymentRecord) => void): Promise<DeploymentRecord>;
56
54
  export declare function waitForCommand(commandId: string, timeoutSeconds?: number): Promise<CommandRecord>;
57
55
  export declare function runLive(operation: LiveRequest['operation'], input: Omit<LiveInput, 'project'>): Promise<void>;
58
- export {};
@@ -1,10 +1,14 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { canPrompt } from "../cli-prompt.js";
2
+ import { selectOne } from "../cli-selection.js";
3
+ import { withProgress } from "../cli-progress.js";
4
+ export { runCapture, runLocal } from "../cli-process.js";
2
5
  import path from 'node:path';
3
6
  import { fileURLToPath } from 'node:url';
4
7
  import { createTransport } from "../agent-credential.js";
5
8
  import { createCliLiveTransport } from "../cli-live-transport.js";
6
9
  import { parseLiveRequest, runCliLive } from "../cli-live.js";
7
10
  import { TERMINAL_DEPLOYMENT_STATES } from "../protocol.js";
11
+ import { describeFetchError, errMessage } from "../util.js";
8
12
  // Templates live next to the commands' parent: src/templates in dev (`node src/cli.ts`), dist/templates in the
9
13
  // built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
10
14
  export const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
@@ -53,11 +57,17 @@ export async function request(method, route, body, multipart = false) {
53
57
  if (!multipart)
54
58
  headers['content-type'] = 'application/json';
55
59
  try {
56
- const res = await transport.http(`${transport.base}${route}`, {
57
- method,
58
- headers,
59
- body: multipart ? body : body ? JSON.stringify(body) : undefined,
60
- });
60
+ let res;
61
+ try {
62
+ res = await transport.http(`${transport.base}${route}`, {
63
+ method,
64
+ headers,
65
+ body: multipart ? body : body ? JSON.stringify(body) : undefined,
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw new Error(`${method} ${route}: could not reach control at ${transport.base}: ${describeFetchError(error)}`, { cause: error });
70
+ }
61
71
  if (!res.ok)
62
72
  throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
63
73
  return (await res.json());
@@ -71,21 +81,17 @@ export async function projectOf(o) {
71
81
  return o.project || (await loadCfg()).project || 'site';
72
82
  }
73
83
  export const print = (value) => console.log(JSON.stringify(value, null, 2));
74
- export function runLocal(cmd, args, opts = {}) {
75
- const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
76
- if (result.status !== 0)
77
- throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
78
- }
79
- export function runCapture(cmd, args, opts = {}) {
80
- const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
81
- if (result.status !== 0)
82
- throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
83
- return String(result.stdout);
84
- }
85
- export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
84
+ export async function waitForDeployment(deploymentId, timeoutSeconds = 300, onProgress) {
86
85
  const deadline = Date.now() + timeoutSeconds * 1000;
87
86
  while (Date.now() < deadline) {
88
- const deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
87
+ let deployment;
88
+ try {
89
+ deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
90
+ }
91
+ catch (error) {
92
+ throw new Error(`lost contact with control while waiting for deployment ${deploymentId} (${errMessage(error)}); it may still be rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`, { cause: error });
93
+ }
94
+ onProgress?.(deployment);
89
95
  if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
90
96
  if (deployment.state !== 'succeeded')
91
97
  throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
@@ -93,19 +99,35 @@ export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
93
99
  }
94
100
  await new Promise((r) => setTimeout(r, 2000));
95
101
  }
96
- throw new Error(`timed out waiting for deployment ${deploymentId}`);
102
+ throw new Error(`timed out waiting for deployment ${deploymentId}; it is still rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`);
97
103
  }
98
104
  export async function waitForCommand(commandId, timeoutSeconds = 60) {
99
- const deadline = Date.now() + timeoutSeconds * 1000;
100
- while (Date.now() < deadline) {
101
- const cmd = await request('GET', `/api/v1/commands/${commandId}`);
102
- if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
103
- return cmd;
104
- await new Promise((r) => setTimeout(r, 1000));
105
- }
106
- throw new Error(`timed out waiting for command ${commandId}`);
105
+ return withProgress(`Waiting for command ${commandId}`, async (progress) => {
106
+ const deadline = Date.now() + timeoutSeconds * 1000;
107
+ while (Date.now() < deadline) {
108
+ const cmd = await request('GET', `/api/v1/commands/${commandId}`);
109
+ if (cmd)
110
+ progress.stage(`Command ${commandId}: ${cmd.state}`);
111
+ if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
112
+ return cmd;
113
+ await new Promise((r) => setTimeout(r, 1000));
114
+ }
115
+ throw new Error(`timed out waiting for command ${commandId}`);
116
+ });
107
117
  }
108
118
  export async function runLive(operation, input) {
109
119
  const cfg = await loadCfg();
120
+ if (!input.node && canPrompt()) {
121
+ const { services: listing } = await request('GET', `/api/v1/services?project=${encodeURIComponent(cfg.project || 'site')}`);
122
+ const candidates = new Map(listing
123
+ .filter((service) => operation === 'ps' || service.name === input.service)
124
+ .flatMap((service) => service.nodes)
125
+ .map((node) => [node.name, node]));
126
+ if (candidates.size > 1)
127
+ input.node = await selectOne(undefined, 'Select a node for this operation', async () => [...candidates.values()].map((node) => ({
128
+ value: node.name,
129
+ label: `${node.name} (${node.status})`,
130
+ })));
131
+ }
110
132
  process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
111
133
  }