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