@wpelevator/tug 0.2.1 → 0.4.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 (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. package/dist/rollout.js +0 -319
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command, Option } from 'commander';
3
3
  import { openAsBlob } from 'node:fs';
4
4
  import { resolveCliConfig, integerOption, normalizeControlUrl } from "./cli-config.js";
5
- import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, MESH_RUN_FLAGS, meshPortCheck } from "./cli-install.js";
5
+ import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
6
6
  import { createSourceArchive, prepareLocalImages } from "./cli-build.js";
7
7
  import { saveImageArchive } from "./artifacts.js";
8
8
  import { spawnSync } from 'node:child_process';
@@ -10,24 +10,37 @@ import fs from 'node:fs/promises';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import { readCompose, composeHash } from "./compose.js";
13
+ import { composeHash } from "./compose.js";
14
+ import { normalizeCompose } from "./normalize.js";
14
15
  import { errMessage, token, loadTemplate } from "./util.js";
15
16
  import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
16
17
  import { packageVersion, resolveImage } from "./image.js";
17
18
  import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
19
+ import { CliLiveError, parseLiveRequest, runCliLive } from "./cli-live.js";
20
+ import { createCliLiveTransport } from "./cli-live-transport.js";
18
21
  // Templates live next to this file: src/templates in dev (`node src/cli.ts`), dist/templates in the
19
22
  // built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
20
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
24
  const templatesDir = path.join(__dirname, 'templates');
22
25
  const program = new Command();
23
- program.name('tug').description('Portable VPS deployments with Docker Compose').version(packageVersion()).enablePositionalOptions()
26
+ program
27
+ .name('tug')
28
+ .description('Portable VPS deployments with Docker Compose')
29
+ .version(packageVersion())
30
+ .enablePositionalOptions()
24
31
  .option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
25
32
  .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)')
26
33
  .option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
27
34
  let resolvedConfig;
28
35
  const cfgPath = () => resolvedConfig.file;
29
- async function loadCfg() { return resolvedConfig; }
30
- async function saveCfg(c) { await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2), { mode: 0o600 }); }
36
+ async function loadCfg() {
37
+ return resolvedConfig;
38
+ }
39
+ const collect = (v, a) => [...a, v];
40
+ async function saveCfg(c) {
41
+ await fs.writeFile(cfgPath(), JSON.stringify(c, null, 2), { mode: 0o600 });
42
+ }
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
44
  async function request(method, route, body, multipart = false) {
32
45
  const c = await loadCfg();
33
46
  const base = c.controlUrl;
@@ -35,7 +48,11 @@ async function request(method, route, body, multipart = false) {
35
48
  const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
36
49
  if (!multipart)
37
50
  headers['content-type'] = 'application/json';
38
- const res = await fetch(`${base}${route}`, { method, headers, body: multipart ? body : body ? JSON.stringify(body) : undefined });
51
+ const res = await fetch(`${base}${route}`, {
52
+ method,
53
+ headers,
54
+ body: multipart ? body : body ? JSON.stringify(body) : undefined,
55
+ });
39
56
  if (!res.ok)
40
57
  throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
41
58
  return res.json();
@@ -51,29 +68,67 @@ function runCapture(cmd, args, opts = {}) {
51
68
  throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
52
69
  return String(result.stdout);
53
70
  }
54
- function remoteHost(sshTarget) { return sshTarget.replace(/^[^@]+@/, '').split(':')[0]; }
71
+ function remoteHost(sshTarget) {
72
+ return sshTarget.replace(/^[^@]+@/, '').split(':')[0];
73
+ }
55
74
  function assertHttps(url, allowHttp) {
56
75
  if (!allowHttp && !String(url).startsWith('https://'))
57
76
  throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
58
77
  }
59
- function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false } = {}) {
78
+ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
60
79
  const checks = [
61
- ['docker.version', 'docker version >/dev/null', 'Install Docker Engine and ensure the SSH user can access it.'],
62
- ['docker.compose.version', 'docker compose version >/dev/null', 'Install Docker Compose v2.'],
63
- ['paths', 'mkdir -p /opt/tug-preflight /var/lib/tug-preflight /etc/tug && test -w /opt/tug-preflight && test -w /var/lib/tug-preflight && test -w /etc/tug', 'Ensure /opt, /var/lib, and /etc/tug are writable by the installer.'],
64
- ['disk.space', `[ "$(df -Pm /var/lib | tail -1 | awk '{print $4}')" -ge ${Number(minDiskMb)} ]`, `Free at least ${minDiskMb}MB on the filesystem backing /var/lib.`]
80
+ [
81
+ 'docker.version',
82
+ 'docker version >/dev/null',
83
+ 'Install Docker Engine and ensure the SSH user can access it.',
84
+ ],
85
+ ...(compose
86
+ ? [
87
+ [
88
+ 'docker.compose.version',
89
+ 'docker compose version >/dev/null',
90
+ 'Install Docker Compose v2.',
91
+ ],
92
+ ]
93
+ : []),
94
+ [
95
+ 'paths',
96
+ 'mkdir -p /opt/tug-preflight /var/lib/tug-preflight /etc/tug && test -w /opt/tug-preflight && test -w /var/lib/tug-preflight && test -w /etc/tug',
97
+ 'Ensure /opt, /var/lib, and /etc/tug are writable by the installer.',
98
+ ],
99
+ [
100
+ 'disk.space',
101
+ `[ "$(df -Pm /var/lib | tail -1 | awk '{print $4}')" -ge ${Number(minDiskMb)} ]`,
102
+ `Free at least ${minDiskMb}MB on the filesystem backing /var/lib.`,
103
+ ],
65
104
  ];
66
105
  for (const port of ports) {
67
- checks.push([`port.${port}.free`, `! ( command -v ss >/dev/null 2>&1 && ss -ltn | awk '{print $4}' | grep -qE "[.:]${port}$" )`, `Port ${port} is already in use on the target host; stop the conflicting service or choose another port.`]);
106
+ checks.push([
107
+ `port.${port}.free`,
108
+ controlPortCheck(port),
109
+ `Port ${port} is already in use on the target host by something other than the Tug control container; stop the conflicting service or choose another port.`,
110
+ ]);
68
111
  }
69
112
  if (mesh) {
70
- checks.push(['wireguard.kernel', 'ip link add tugwgtest type wireguard && ip link del tugwgtest || modprobe -n wireguard 2>/dev/null', 'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.']);
113
+ checks.push([
114
+ 'wireguard.kernel',
115
+ 'ip link add tugwgtest type wireguard && ip link del tugwgtest || modprobe -n wireguard 2>/dev/null',
116
+ 'Install WireGuard (kernel 5.6+ has it built in; older kernels need the wireguard-dkms package) and ensure iproute2 is present.',
117
+ ]);
71
118
  }
72
119
  for (const port of udpPorts) {
73
- checks.push([`udp.${port}.free`, meshPortCheck(port), `UDP port ${port} is in use by something other than this node's running Tug mesh agent (or the ss tool is missing). Stop the conflicting service, or choose another port with --wg-port and set TUG_WG_PORT on control.`]);
120
+ checks.push([
121
+ `udp.${port}.free`,
122
+ meshPortCheck(port),
123
+ `UDP port ${port} is in use by something other than this node's running Tug mesh agent (or the ss tool is missing). Stop the conflicting service, or choose another port with --wg-port and set TUG_WG_PORT on control.`,
124
+ ]);
74
125
  }
75
126
  if (controlUrl)
76
- checks.push(['control.reachable', `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`, 'Verify DNS/firewall/TLS for the control URL from the node.']);
127
+ checks.push([
128
+ 'control.reachable',
129
+ `curl -fsS ${sh(controlUrl)}/healthz >/dev/null`,
130
+ 'Verify DNS/firewall/TLS for the control URL from the node.',
131
+ ]);
77
132
  const script = `set -e
78
133
  ${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
79
134
  `;
@@ -84,17 +139,31 @@ async function transferTugImage(target, imageTag) {
84
139
  const archive = path.join(directory, 'image.tar.gz');
85
140
  const remote = `/tmp/${path.basename(directory)}.tar.gz`;
86
141
  try {
87
- runLocal('docker', ['build', '-t', imageTag, '.']);
142
+ const uname = runCapture('ssh', ['--', target, 'uname -m']);
143
+ runLocal('docker', localImageBuildArgs(imageTag, uname));
88
144
  await saveImageArchive(imageTag, archive);
89
145
  runLocal('scp', ['--', archive, `${target}:${remote}`]);
90
- runLocal('ssh', ['--', target, `set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`]);
146
+ runLocal('ssh', [
147
+ '--',
148
+ target,
149
+ `set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`,
150
+ ]);
91
151
  }
92
152
  finally {
93
153
  await fs.rm(directory, { recursive: true, force: true });
94
154
  }
95
155
  }
96
- async function dockerBootstrap() { return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8'); }
97
- async function bootstrapDocker(target) { runLocal('ssh', ['--', target, `sh -eu -c ${sh(await dockerBootstrap())}`]); }
156
+ async function dockerBootstrap() {
157
+ return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8');
158
+ }
159
+ /** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
160
+ async function bootstrapDocker(target, compose = false) {
161
+ runLocal('ssh', [
162
+ '--',
163
+ target,
164
+ `${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
165
+ ]);
166
+ }
98
167
  async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
99
168
  const deadline = Date.now() + timeoutSeconds * 1000;
100
169
  while (Date.now() < deadline) {
@@ -104,79 +173,144 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
104
173
  throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
105
174
  return deployment;
106
175
  }
107
- await new Promise(r => setTimeout(r, 2000));
176
+ await new Promise((r) => setTimeout(r, 2000));
108
177
  }
109
178
  throw new Error(`timed out waiting for deployment ${deploymentId}`);
110
179
  }
111
- program.command('init').option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)').action(async (o) => {
180
+ program
181
+ .command('init')
182
+ .option('--token <token>', 'operator token (prefer TUG_TOKEN to avoid process-list exposure)')
183
+ .action(async (o) => {
112
184
  const cfg = await loadCfg();
113
185
  const operatorToken = o.token || cfg.operatorToken || token('tug_op');
114
186
  await saveCfg({ controlUrl: cfg.controlUrl, operatorToken, project: cfg.project });
115
187
  console.log(`wrote ${cfgPath()}`);
116
188
  });
117
- program.command('node-register <name>').description('register a node with the control plane').option('--address <addr>').option('--public-address <addr>').action(async (name, o) => {
118
- const r = await request('POST', '/api/v1/nodes/register', { name, address: o.address, publicAddress: o.publicAddress });
189
+ program
190
+ .command('node-register <name>')
191
+ .description('register a node with the control plane')
192
+ .option('--address <addr>')
193
+ .option('--public-address <addr>')
194
+ .option('--label <label>', 'node label (repeatable)', collect, [])
195
+ .action(async (name, o) => {
196
+ const r = await request('POST', '/api/v1/nodes/register', {
197
+ name,
198
+ address: o.address,
199
+ publicAddress: o.publicAddress,
200
+ labels: o.label,
201
+ });
119
202
  console.log(JSON.stringify(r, null, 2));
120
203
  });
121
- program.command('node-rotate-registry-token <name>').description('issue a new registry pull token for a node, invalidating the old one').action(async (name) => {
204
+ program
205
+ .command('node-rotate-registry-token <name>')
206
+ .description('issue a new registry pull token for a node, invalidating the old one')
207
+ .action(async (name) => {
122
208
  console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
123
209
  });
124
- program.command('node-rotate-wg-key <name>').description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll').option('--timeout <seconds>', 'wait timeout', '60').action(async (name, o) => {
210
+ program
211
+ .command('node-rotate-wg-key <name>')
212
+ .description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
213
+ .option('--timeout <seconds>', 'wait timeout', '60')
214
+ .action(async (name, o) => {
125
215
  const created = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-wg-key`, {});
126
216
  const cmd = await waitForCommand(created.commandId, Number(o.timeout));
127
217
  if (cmd.state === 'failed')
128
218
  throw new Error(JSON.parse(cmd.result_json || '{}').error || 'key rotation failed');
129
219
  console.log(JSON.stringify({ ok: true, node: name, ...JSON.parse(cmd.result_json || '{}') }, null, 2));
130
220
  });
131
- program.command('node-ingress <name>').description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable').option('--disable', 'stop acting as an ingress node').action(async (name, o) => {
132
- console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, { enabled: !o.disable }), null, 2));
221
+ program
222
+ .command('node-remove <name>')
223
+ .description('remove a retired node from control (its agent token, WireGuard peer and queued commands)')
224
+ .option('--force', 'remove even if the agent is still reporting')
225
+ .action(async (name, o) => {
226
+ console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
227
+ });
228
+ program
229
+ .command('node-ingress <name>')
230
+ .description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
231
+ .option('--disable', 'stop acting as an ingress node')
232
+ .action(async (name, o) => {
233
+ console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/ingress`, {
234
+ enabled: !o.disable,
235
+ }), null, 2));
133
236
  });
134
- program.command('node-revoke-registry-token <name>').description('revoke a node\'s registry pull token, blocking future pulls until rotated').action(async (name) => {
237
+ program
238
+ .command('node-revoke-registry-token <name>')
239
+ .description("revoke a node's registry pull token, blocking future pulls until rotated")
240
+ .action(async (name) => {
135
241
  console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
136
242
  });
137
- program.command('control-token').description('print local dev operator token if available').action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
138
- program.command('control-install <sshTarget>')
243
+ program
244
+ .command('scale <service> <replicas>')
245
+ .description('scale a service to N replicas across matching nodes')
246
+ .action(async (service, replicas, o) => {
247
+ const cfg = await loadCfg();
248
+ const project = o.project || cfg.project || 'site';
249
+ if (!/^[1-9]\d*$/.test(replicas))
250
+ throw new Error('replicas must be a positive integer');
251
+ console.log(JSON.stringify(await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) }), null, 2));
252
+ });
253
+ program
254
+ .command('control-token')
255
+ .description('print local dev operator token if available')
256
+ .action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
257
+ program
258
+ .command('control-install <sshTarget>')
139
259
  .description('install the control plane on a Docker-capable host over SSH; front it with your own reverse proxy/TLS if exposing it publicly')
140
- .option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
260
+ .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
141
261
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
142
262
  .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
143
263
  .option('--token <token>', 'operator token')
144
264
  .option('--port <port>', 'published API port', '8080')
265
+ .option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
145
266
  .option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
146
267
  .option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
147
- .option('--registry-container <name>', 'docker container name to restart when a node\'s registry credentials change, if your registry uses an htpasswd file')
268
+ .option('--registry-container <name>', "docker container name to restart when a node's registry credentials change, if your registry uses an htpasswd file")
148
269
  .action(async (sshTarget, o) => {
149
270
  const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
150
271
  if (o.installDocker)
151
- await bootstrapDocker(sshTarget);
152
- preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
272
+ await bootstrapDocker(sshTarget, true);
273
+ preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
153
274
  const image = resolveImage(o.image, o.buildLocalImage);
154
275
  if (o.buildLocalImage)
155
276
  await transferTugImage(sshTarget, image);
156
277
  const registryHost = o.registry || '';
157
278
  const yamlValue = (value) => JSON.stringify(value);
158
279
  const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
159
- IMAGE: yamlValue(image), OPERATOR_TOKEN: yamlValue(operatorToken), REGISTRY_HOST: yamlValue(registryHost), PORT: o.port,
280
+ IMAGE: yamlValue(image),
281
+ OPERATOR_TOKEN: yamlValue(operatorToken),
282
+ REGISTRY_HOST: yamlValue(registryHost),
283
+ PORT: o.port,
284
+ MESH: yamlValue(o.mesh ? 'true' : 'false'),
160
285
  REGISTRY_AUTH_PATH: yamlValue(o.registryAuthPath || ''),
161
- REGISTRY_CONTAINER: yamlValue(o.registryContainer || '')
286
+ REGISTRY_CONTAINER: yamlValue(o.registryContainer || ''),
162
287
  });
163
288
  const remote = await loadTemplate(templatesDir, 'control-install.sh', {
164
- COMPOSE: compose, OPERATOR_TOKEN_SH: sh(operatorToken)
289
+ COMPOSE: compose,
290
+ OPERATOR_TOKEN_SH: sh(operatorToken),
165
291
  });
166
292
  runLocal('ssh', ['--', sshTarget, remote]);
167
- console.log(JSON.stringify({ ok: true, sshTarget, operatorToken, url: `http://${remoteHost(sshTarget)}:${o.port}`, registry: registryHost }, null, 2));
293
+ console.log(JSON.stringify({
294
+ ok: true,
295
+ sshTarget,
296
+ operatorToken,
297
+ url: `http://${remoteHost(sshTarget)}:${o.port}`,
298
+ registry: registryHost,
299
+ }, null, 2));
168
300
  });
169
- program.command('node-install <sshTarget>')
301
+ program
302
+ .command('node-install <sshTarget>')
170
303
  .description('register and install an agent container on a Docker-capable host over SSH')
171
304
  .requiredOption('--name <name>')
172
305
  .option('--address <addr>')
173
306
  .option('--public-address <addr>')
307
+ .option('--label <label>', 'node label (repeatable)', collect, [])
174
308
  .option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
175
309
  .option('--registry <host>', 'registry host for docker login')
176
310
  .option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
177
311
  .option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
178
312
  .option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
179
- .option('--image <tag>', 'Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
313
+ .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
180
314
  .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
181
315
  .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
182
316
  .action(async (sshTarget, o) => {
@@ -185,22 +319,39 @@ program.command('node-install <sshTarget>')
185
319
  assertHttps(controlUrl, o.httpOnlyDev);
186
320
  if (o.installDocker)
187
321
  await bootstrapDocker(sshTarget);
188
- preflightRemote(sshTarget, { controlUrl, mesh: o.mesh, udpPorts: o.mesh ? [Number(o.wgPort)] : [] });
189
- const registered = await request('POST', '/api/v1/nodes/register', { name: o.name, address: o.address, publicAddress: o.publicAddress });
322
+ preflightRemote(sshTarget, {
323
+ controlUrl,
324
+ mesh: o.mesh,
325
+ udpPorts: o.mesh ? [Number(o.wgPort)] : [],
326
+ });
327
+ const registered = await request('POST', '/api/v1/nodes/register', {
328
+ name: o.name,
329
+ address: o.address,
330
+ publicAddress: o.publicAddress,
331
+ labels: o.label,
332
+ });
190
333
  const image = resolveImage(o.image, o.buildLocalImage);
191
334
  if (o.buildLocalImage)
192
335
  await transferTugImage(sshTarget, image);
193
- const env = await loadTemplate(templatesDir, 'agent.env', {
194
- CONTROL_URL: controlUrl, NODE_NAME: o.name, AGENT_TOKEN: registered.agentToken
195
- }) + (o.mesh ? 'TUG_MESH=true\n' : '');
196
- const dockerLogin = o.registry ? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin` : 'true';
336
+ const env = (await loadTemplate(templatesDir, 'agent.env', {
337
+ CONTROL_URL: controlUrl,
338
+ NODE_NAME: o.name,
339
+ AGENT_TOKEN: registered.agentToken,
340
+ })) + (o.mesh ? 'TUG_MESH=true\n' : '');
341
+ const dockerLogin = o.registry
342
+ ? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin`
343
+ : 'true';
197
344
  const remote = await loadTemplate(templatesDir, 'node-install.sh', {
198
- ENV: env, DOCKER_LOGIN: dockerLogin, IMAGE_SH: sh(image), RUN_FLAGS: o.mesh ? `${MESH_RUN_FLAGS} ` : ''
345
+ ENV: env,
346
+ DOCKER_LOGIN: dockerLogin,
347
+ IMAGE_SH: sh(image),
348
+ RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
199
349
  });
200
350
  runLocal('ssh', ['--', sshTarget, remote]);
201
351
  console.log(JSON.stringify({ ok: true, node: registered.node, registryToken: registered.registryToken }, null, 2));
202
352
  });
203
- program.command('node-enroll')
353
+ program
354
+ .command('node-enroll')
204
355
  .description('mint a single-use enrollment key so a fresh host can self-join with one docker run (no SSH)')
205
356
  .option('--name <name>', 'pre-bind the node name (default: the agent uses its own hostname)')
206
357
  .option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
@@ -208,8 +359,9 @@ program.command('node-enroll')
208
359
  .option('--uses <n>', 'how many hosts may enroll with this key', '1')
209
360
  .option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
210
361
  .option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
211
- .option('--image <tag>', 'agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI\'s version>)')
362
+ .option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
212
363
  .option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
364
+ .option('--label <label>', 'node label (repeatable)', collect, [])
213
365
  .option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
214
366
  .option('--json', 'print JSON including the command or cloud-init payload')
215
367
  .action(async (o) => {
@@ -217,13 +369,24 @@ program.command('node-enroll')
217
369
  const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
218
370
  assertHttps(controlUrl, o.httpOnlyDev);
219
371
  const ca = o.ca ? await fs.readFile(o.ca, 'utf8') : undefined;
220
- const minted = await request('POST', '/api/v1/nodes/enroll-token', { name: o.name || null, ttlSeconds: Number(o.ttl), uses: Number(o.uses) });
221
- const keyPayload = { v: 1, controlUrl, enrollToken: minted.enrollToken };
372
+ const minted = await request('POST', '/api/v1/nodes/enroll-token', {
373
+ name: o.name || null,
374
+ ttlSeconds: Number(o.ttl),
375
+ uses: Number(o.uses),
376
+ });
377
+ const keyPayload = {
378
+ v: 1,
379
+ controlUrl,
380
+ enrollToken: minted.enrollToken,
381
+ ...(o.label.length ? { labels: o.label } : {}),
382
+ };
222
383
  if (ca)
223
384
  keyPayload.ca = ca;
224
385
  const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
225
386
  const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
226
- const cloudInit = o.installDocker ? enrollmentCloudInit(await dockerBootstrap(), command) : undefined;
387
+ const cloudInit = o.installDocker
388
+ ? enrollmentCloudInit(await dockerBootstrap(), command)
389
+ : undefined;
227
390
  if (o.json) {
228
391
  console.log(JSON.stringify({ ...minted, controlUrl, joinKey, command, ...(cloudInit ? { cloudInit } : {}) }, null, 2));
229
392
  return;
@@ -235,24 +398,55 @@ program.command('node-enroll')
235
398
  console.log(`# Enrollment key valid until ${minted.expiresAt} (${minted.usesRemaining} use(s)). Run on each fresh host:\n`);
236
399
  console.log(command);
237
400
  });
238
- program.command('secret-set <name>').option('--value <value>').action(async (name, o) => {
239
- const value = o.value ?? await new Promise(resolve => { process.stdout.write(`${name}: `); process.stdin.once('data', d => resolve(String(d).trim())); });
240
- console.log(await request('POST', '/api/v1/secrets', { project: o.project || (await loadCfg()).project || 'site', name, value }));
401
+ program
402
+ .command('secret-set <name>')
403
+ .option('--value <value>')
404
+ .action(async (name, o) => {
405
+ const value = o.value ??
406
+ (await new Promise((resolve) => {
407
+ process.stdout.write(`${name}: `);
408
+ process.stdin.once('data', (d) => resolve(String(d).trim()));
409
+ }));
410
+ console.log(await request('POST', '/api/v1/secrets', {
411
+ project: o.project || (await loadCfg()).project || 'site',
412
+ name,
413
+ value,
414
+ }));
241
415
  });
242
- program.command('secret-list').action(async (o) => console.log(JSON.stringify(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`), null, 2)));
243
- program.command('secret-remove <name>').action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
244
- program.command('token-create <name>')
416
+ program
417
+ .command('secret-list')
418
+ .action(async (o) => console.log(JSON.stringify(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`), null, 2)));
419
+ program
420
+ .command('secret-remove <name>')
421
+ .action(async (name, o) => console.log(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(o.project || (await loadCfg()).project || 'site')}`)));
422
+ program
423
+ .command('token-create <name>')
245
424
  .description('create a named API token for CI or a teammate; the secret is printed once and cannot be shown again')
246
- .addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything').choices(ROLES).makeOptionMandatory())
247
- .addOption(new Option('--project <name>', 'limit a read or deploy token to this project; repeat for several').default([], 'all projects').argParser((value, previous) => [...previous, value]))
425
+ .addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything')
426
+ .choices(ROLES)
427
+ .makeOptionMandatory())
428
+ .addOption(new Option('--project <name>', 'limit a read or deploy token to this project; repeat for several')
429
+ .default([], 'all projects')
430
+ .argParser((value, previous) => [...previous, value]))
248
431
  .option('--expires-in-days <n>', 'expire the token after this many days (default: never)')
249
432
  .action(async (name, o) => {
250
- const created = await request('POST', '/api/v1/tokens', { name, role: o.role, projects: o.project.length ? o.project : undefined, expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined });
433
+ const created = await request('POST', '/api/v1/tokens', {
434
+ name,
435
+ role: o.role,
436
+ projects: o.project.length ? o.project : undefined,
437
+ expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined,
438
+ });
251
439
  console.error('Store this token now (for example as the TUG_TOKEN secret in CI); it is not shown again.');
252
440
  console.log(JSON.stringify(created, null, 2));
253
441
  });
254
- program.command('token-list').description('list API tokens with role, projects, expiry and last use; secrets are never shown').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/tokens'), null, 2)));
255
- program.command('token-revoke <name>').description('revoke an API token immediately; its name stays reserved for the audit trail').action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/tokens/${encodeURIComponent(name)}`), null, 2)));
442
+ program
443
+ .command('token-list')
444
+ .description('list API tokens with role, projects, expiry and last use; secrets are never shown')
445
+ .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/tokens'), null, 2)));
446
+ program
447
+ .command('token-revoke <name>')
448
+ .description('revoke an API token immediately; its name stays reserved for the audit trail')
449
+ .action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/tokens/${encodeURIComponent(name)}`), null, 2)));
256
450
  async function makeContext() {
257
451
  const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
258
452
  try {
@@ -264,10 +458,20 @@ async function makeContext() {
264
458
  }
265
459
  return tmp;
266
460
  }
267
- program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-compose.yml').option('--version <version>', 'display version (default: git-<Compose hash>)').option('--build-local', 'build application images locally and upload them without source').option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local').option('--no-wait', 'return after desired state is created').option('--timeout <seconds>', 'wait timeout in seconds', '300').action(async (o) => {
461
+ program
462
+ .command('deploy')
463
+ .option('-f, --file <file>', 'compose file', 'docker-compose.yml')
464
+ .option('--version <version>', 'display version (default: git-<Compose hash>)')
465
+ .option('--build-local', 'build application images locally and upload them without source')
466
+ .option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local')
467
+ .option('--no-wait', 'return after desired state is created')
468
+ .option('--timeout <seconds>', 'wait timeout in seconds', '300')
469
+ .action(async (o) => {
268
470
  const cfg = await loadCfg();
269
- const compose = await readCompose(o.file);
270
471
  const project = o.project || cfg.project || 'site';
472
+ // Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
473
+ const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
474
+ const compose = await normalizeCompose(o.file, secrets.map((secret) => secret.name), { project });
271
475
  const version = o.version || `git-${composeHash(compose)}`;
272
476
  let response;
273
477
  if (o.buildLocal) {
@@ -284,7 +488,8 @@ program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-co
284
488
  await fs.rm(directory, { recursive: true, force: true });
285
489
  }
286
490
  }
287
- else if (o.buildUpload && Object.values(compose.services).some(service => service.build)) {
491
+ else if (o.buildUpload &&
492
+ Object.values(compose.services).some((service) => service.build)) {
288
493
  const tarball = await makeContext();
289
494
  try {
290
495
  const form = new FormData();
@@ -297,13 +502,24 @@ program.command('deploy').option('-f, --file <file>', 'compose file', 'docker-co
297
502
  }
298
503
  }
299
504
  else
300
- response = await request('POST', '/api/v1/deployments', { project, version, compose });
505
+ response = await request('POST', '/api/v1/deployments', {
506
+ project,
507
+ version,
508
+ compose,
509
+ });
301
510
  if (o.wait)
302
511
  response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
303
512
  console.log(JSON.stringify(response, null, 2));
304
513
  });
305
- program.command('status').action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
306
- program.command('events').description('list recent events with the token that caused each control action; defaults to the resolved project').option('--limit <n>', 'number of events', '100').option('--all', 'list events across all projects (limited to your projects for scoped tokens)').action(async (o) => {
514
+ program
515
+ .command('status')
516
+ .action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
517
+ program
518
+ .command('events')
519
+ .description('list recent events with the token that caused each control action; defaults to the resolved project')
520
+ .option('--limit <n>', 'number of events', '100')
521
+ .option('--all', 'list events across all projects (limited to your projects for scoped tokens)')
522
+ .action(async (o) => {
307
523
  if (o.all && o.project)
308
524
  throw new Error('--all and --project cannot be combined');
309
525
  const cfg = await loadCfg();
@@ -311,9 +527,19 @@ program.command('events').description('list recent events with the token that ca
311
527
  const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
312
528
  console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
313
529
  });
314
- program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also run diagnostics on every registered agent and wait').option('--node <name>', 'run diagnostics on one agent and wait').option('--timeout <seconds>', 'wait timeout for node diagnostics', '60').action(async (o) => {
530
+ program
531
+ .command('doctor')
532
+ .description('run local and control-plane diagnostics')
533
+ .option('--nodes', 'also run diagnostics on every registered agent and wait')
534
+ .option('--node <name>', 'run diagnostics on one agent and wait')
535
+ .option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
536
+ .action(async (o) => {
315
537
  const local = [];
316
- for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
538
+ for (const [name, cmd, args] of [
539
+ ['node', 'node', ['--version']],
540
+ ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']],
541
+ ['docker_compose', 'docker', ['compose', 'version']],
542
+ ]) {
317
543
  try {
318
544
  local.push({ name, ok: true, output: runCapture(cmd, args).trim() });
319
545
  }
@@ -322,50 +548,84 @@ program.command('doctor').description('run local and control-plane diagnostics')
322
548
  }
323
549
  }
324
550
  const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
325
- const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
551
+ const output = {
552
+ ok: local.every((c) => c.ok) && controlDiagnostics.ok,
553
+ local,
554
+ control: controlDiagnostics,
555
+ };
326
556
  if (o.nodes || o.node) {
327
557
  const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
328
558
  const commands = [];
329
559
  for (const command of queued.commands) {
330
560
  const finished = await waitForCommand(command.commandId, Number(o.timeout));
331
- commands.push({ nodeId: command.nodeId, state: finished.state, result: JSON.parse(finished.result_json || '{}') });
561
+ commands.push({
562
+ nodeId: command.nodeId,
563
+ state: finished.state,
564
+ result: JSON.parse(finished.result_json || '{}'),
565
+ });
332
566
  }
333
567
  output.nodeDiagnostics = { commands };
334
- if (commands.some(command => command.state !== 'succeeded'))
568
+ if (commands.some((command) => command.state !== 'succeeded'))
335
569
  output.ok = false;
336
570
  }
337
571
  console.log(JSON.stringify(output, null, 2));
338
572
  });
339
- program.command('rollback').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
573
+ program
574
+ .command('rollback')
575
+ .option('--force')
576
+ .option('--no-wait', 'return immediately after activating rollback')
577
+ .option('--timeout <seconds>', 'wait timeout', '300')
578
+ .action(async (o) => {
340
579
  const cfg = await loadCfg();
341
- const response = await request('POST', '/api/v1/rollback', { project: o.project || cfg.project || 'site', force: Boolean(o.force) });
580
+ const response = await request('POST', '/api/v1/rollback', {
581
+ project: o.project || cfg.project || 'site',
582
+ force: Boolean(o.force),
583
+ });
342
584
  if (o.wait)
343
585
  response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
344
586
  console.log(JSON.stringify(response, null, 2));
345
587
  });
346
- program.command('deploy-cancel <id>').action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
588
+ program
589
+ .command('deploy-cancel <id>')
590
+ .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {}), null, 2)));
347
591
  program.command('volume-list').action(async (o) => {
348
592
  const cfg = await loadCfg();
349
593
  console.log(JSON.stringify(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(o.project || cfg.project || 'site')}`), null, 2));
350
594
  });
351
- program.command('volume-remove <name>').option('--delete-data', 'delete underlying Docker volume data').action(async (name, o) => {
595
+ program
596
+ .command('volume-remove <name>')
597
+ .option('--delete-data', 'delete underlying Docker volume data')
598
+ .action(async (name, o) => {
352
599
  const cfg = await loadCfg();
353
600
  const project = o.project || cfg.project || 'site';
354
601
  console.log(JSON.stringify(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}&deleteData=${Boolean(o.deleteData)}`), null, 2));
355
602
  });
356
- program.command('control-backup').description('create a control-plane SQLite/master-key backup').action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
357
- program.command('control-restore <artifactRef>').description('restore a control-plane backup; requires control restart').option('--confirm').action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', { artifactRef, confirm: Boolean(o.confirm) }), null, 2)));
603
+ program
604
+ .command('control-backup')
605
+ .description('create a control-plane SQLite/master-key backup')
606
+ .action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
607
+ program
608
+ .command('control-restore <artifactRef>')
609
+ .description('restore a control-plane backup; requires control restart')
610
+ .option('--confirm')
611
+ .action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', {
612
+ artifactRef,
613
+ confirm: Boolean(o.confirm),
614
+ }), null, 2)));
358
615
  async function waitForCommand(commandId, timeoutSeconds = 60) {
359
616
  const deadline = Date.now() + timeoutSeconds * 1000;
360
617
  while (Date.now() < deadline) {
361
618
  const cmd = await request('GET', `/api/v1/commands/${commandId}`);
362
619
  if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
363
620
  return cmd;
364
- await new Promise(r => setTimeout(r, 1000));
621
+ await new Promise((r) => setTimeout(r, 1000));
365
622
  }
366
623
  throw new Error(`timed out waiting for command ${commandId}`);
367
624
  }
368
- program.command('restart <service>').option('--timeout <seconds>', 'wait timeout', '60').action(async (svc, o) => {
625
+ program
626
+ .command('restart <service>')
627
+ .option('--timeout <seconds>', 'wait timeout', '60')
628
+ .action(async (svc, o) => {
369
629
  const cfg = await loadCfg();
370
630
  const project = o.project || cfg.project || 'site';
371
631
  const created = await request('POST', '/api/v1/restart', { project, service: svc });
@@ -374,10 +634,53 @@ program.command('restart <service>').option('--timeout <seconds>', 'wait timeout
374
634
  throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
375
635
  console.log(JSON.stringify(cmd, null, 2));
376
636
  });
377
- program.command('logs <service>').description('fetch logs from the assigned node agent').option('--tail <n>', 'number of lines', '200').action(async (svc, o) => {
637
+ async function runLive(operation, input) {
638
+ const cfg = await loadCfg();
639
+ process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.controlUrl, cfg.operatorToken));
640
+ }
641
+ program
642
+ .command('exec <service> [command...]')
643
+ .description('execute a command on a service container (default: sh)')
644
+ .option('-i, --interactive', 'forward stdin to the remote command')
645
+ .option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
646
+ .option('--node <name>', 'select the node when a service has multiple placements')
647
+ .action(async (service, command, o) => {
648
+ await runLive('exec', { service, command, ...o });
649
+ });
650
+ program
651
+ .command('ps')
652
+ .description('print per-container state as JSON')
653
+ .option('--node <name>', 'select one node')
654
+ .action(async (o) => {
655
+ await runLive('ps', o);
656
+ });
657
+ program
658
+ .command('inspect <service>')
659
+ .description('print container details as JSON')
660
+ .option('--node <name>', 'select one node')
661
+ .action(async (service, o) => {
662
+ await runLive('inspect', { service, ...o });
663
+ });
664
+ program
665
+ .command('logs <service>')
666
+ .description('fetch logs from the assigned node agent')
667
+ .option('--tail <n>', 'number of lines', '200')
668
+ .option('-f, --follow', 'follow live logs')
669
+ .option('--node <name>', 'select a node for followed logs')
670
+ .action(async (svc, o) => {
671
+ if (o.follow) {
672
+ await runLive('logs', { service: svc, node: o.node, tail: o.tail });
673
+ return;
674
+ }
675
+ if (o.node)
676
+ throw new Error('--node requires --follow for logs');
378
677
  const cfg = await loadCfg();
379
678
  const project = o.project || cfg.project || 'site';
380
- const created = await request('POST', '/api/v1/logs', { project, service: svc, tail: Number(o.tail) });
679
+ const created = await request('POST', '/api/v1/logs', {
680
+ project,
681
+ service: svc,
682
+ tail: Number(o.tail),
683
+ });
381
684
  const cmd = await waitForCommand(created.commandId, 60);
382
685
  const result = JSON.parse(cmd.result_json || '{}');
383
686
  if (result.stdout)
@@ -389,9 +692,18 @@ program.command('logs <service>').description('fetch logs from the assigned node
389
692
  });
390
693
  program.hook('preAction', async (_root, command) => {
391
694
  const options = command.opts();
392
- for (const [key, min, max] of [['port', 1, 65535], ['wgPort', 1, 65535], ['timeout', 1, 86400], ['ttl', 1, 31536000], ['uses', 1, 1000000], ['expiresInDays', 1, 3650], ['limit', 1, 500], ['tail', 1, 1000000]]) {
695
+ for (const [key, min, max] of [
696
+ ['port', 1, 65535],
697
+ ['wgPort', 1, 65535],
698
+ ['timeout', 1, 86400],
699
+ ['ttl', 1, 31536000],
700
+ ['uses', 1, 1000000],
701
+ ['expiresInDays', 1, 3650],
702
+ ['limit', 1, 500],
703
+ ['tail', 0, 1000000],
704
+ ]) {
393
705
  if (options[key] !== undefined)
394
- integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, c => '-' + c.toLowerCase()), min, max);
706
+ integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), min, max);
395
707
  }
396
708
  if (options.buildLocal && options.buildUpload === false)
397
709
  throw new Error('--build-local and --no-build-upload cannot be combined');
@@ -399,9 +711,12 @@ program.hook('preAction', async (_root, command) => {
399
711
  throw new Error('--registry-auth-path and --registry-container require --registry');
400
712
  if (command.name() === 'control-restore' && !options.confirm)
401
713
  throw new Error('control-restore requires --confirm');
402
- for (const value of [...command.args, ...Object.values(options), ...Object.values(program.opts())])
714
+ const positional = command.name() === 'exec' ? command.args.slice(0, 1) : command.args;
715
+ for (const value of [...positional, ...Object.values(options), ...Object.values(program.opts())])
403
716
  if (typeof value === 'string' && /[\r\n\0]/.test(value))
404
717
  throw new Error('arguments and option values must not contain line breaks or NUL');
718
+ if (command.name() === 'exec' && command.args.slice(1).some((value) => value.includes('\0')))
719
+ throw new Error('exec command arguments must not contain NUL');
405
720
  if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
406
721
  throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
407
722
  const connection = command.optsWithGlobals();
@@ -422,10 +737,13 @@ for (const command of program.commands) {
422
737
  command
423
738
  .option('--config <file>', 'CLI JSON config file (default: TUG_CONFIG or .tug.json)')
424
739
  .option('--control-url <url>', 'control API URL (overrides TUG_CONTROL_URL and config)');
425
- if (!command.options.some(option => option.long === '--project'))
740
+ if (!command.options.some((option) => option.long === '--project'))
426
741
  command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
427
742
  }
428
743
  for (const command of [program, ...program.commands])
429
744
  command.allowExcessArguments(false);
430
745
  program.showHelpAfterError();
431
- program.parseAsync().catch((e) => { console.error(`${errMessage(e)}\nRun tug <command> --help for usage.`); process.exit(1); });
746
+ program.parseAsync().catch((e) => {
747
+ console.error(`${errMessage(e)}\nRun tug <command> --help for usage.`);
748
+ process.exitCode = e instanceof CliLiveError ? e.exitCode : 1;
749
+ });