@wpelevator/tug 0.6.0 → 0.8.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 +24 -22
- package/dist/agent-credential.d.ts +39 -0
- package/dist/agent-credential.js +135 -0
- package/dist/agent.d.ts +15 -9
- package/dist/agent.js +126 -104
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +5 -0
- package/dist/backup-config.js +23 -3
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.js +28 -8
- package/dist/backup-scheduler.d.ts +7 -1
- package/dist/backup-scheduler.js +21 -6
- package/dist/cli-build.js +70 -4
- package/dist/cli-config.d.ts +9 -5
- package/dist/cli-config.js +89 -34
- package/dist/cli-install.js +1 -1
- package/dist/cli-live-transport.d.ts +3 -2
- package/dist/cli-live-transport.js +9 -5
- package/dist/cli.js +276 -142
- package/dist/compose.d.ts +3 -1
- package/dist/compose.js +14 -2
- package/dist/control-credential.d.ts +12 -0
- package/dist/control-credential.js +46 -0
- package/dist/control.d.ts +18 -1
- package/dist/control.js +586 -202
- package/dist/credentials.d.ts +111 -0
- package/dist/{tokens.js → credentials.js} +70 -54
- package/dist/db.d.ts +20 -5
- package/dist/db.js +3 -3
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +65 -0
- package/dist/ingress.js +4 -3
- package/dist/main.js +35 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +16 -2
- package/dist/pki.d.ts +119 -0
- package/dist/pki.js +339 -0
- package/dist/protocol.d.ts +53 -22
- package/dist/status-report.d.ts +11 -0
- package/dist/status-report.js +147 -0
- package/dist/templates/agent.env +1 -3
- package/dist/templates/control-compose.yml +4 -3
- package/dist/templates/control-install.sh +8 -2
- package/dist/tls-server.d.ts +11 -0
- package/dist/tls-server.js +23 -0
- package/package.json +10 -8
- package/dist/tokens.d.ts +0 -91
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 { applyConfigDefaults, integerOption, normalizeControlUrl, nodeSettings, resolveCliConfig, writeInitConfig, } from "./cli-config.js";
|
|
5
|
-
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, controlPortCheck, localImageBuildArgs, meshPortCheck, } from "./cli-install.js";
|
|
5
|
+
import { dockerRunSnippet, enrollmentCloudInit, shellQuote as sh, HOST_NETWORK_FLAG, MESH_RUN_FLAGS, CONTROL_CONTAINER, 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';
|
|
@@ -12,8 +12,11 @@ import path from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { composeHash } from "./compose.js";
|
|
14
14
|
import { normalizeCompose } from "./normalize.js";
|
|
15
|
-
import { errMessage,
|
|
16
|
-
import {
|
|
15
|
+
import { errMessage, loadTemplate } from "./util.js";
|
|
16
|
+
import { createTransport } from "./agent-credential.js";
|
|
17
|
+
import { decodeCredential, writeFileDurable } from "./pki.js";
|
|
18
|
+
import { backupCheckProblems } from "./backup-check.js";
|
|
19
|
+
import { PROJECT_NAME_PATTERN, ROLES, CREDENTIAL_NAME_PATTERN } from "./credentials.js";
|
|
17
20
|
import { packageVersion, resolveImage } from "./image.js";
|
|
18
21
|
import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
|
|
19
22
|
import { CliLiveError, parseLiveRequest, runCliLive } from "./cli-live.js";
|
|
@@ -26,11 +29,12 @@ const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tu
|
|
|
26
29
|
const program = new Command();
|
|
27
30
|
program
|
|
28
31
|
.name('tug')
|
|
29
|
-
.description('
|
|
32
|
+
.description('Deploy Docker Compose applications to your own servers')
|
|
30
33
|
.version(packageVersion())
|
|
31
34
|
.enablePositionalOptions()
|
|
32
35
|
.option('--config <file>', CONFIG_HELP)
|
|
33
|
-
.option('--control-url <url>', 'control API
|
|
36
|
+
.option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
|
|
37
|
+
.option('--credential <file>', 'credential file (overrides TUG_CREDENTIAL, TUG_CREDENTIAL_FILE and config)')
|
|
34
38
|
.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
35
39
|
let resolvedConfig;
|
|
36
40
|
async function loadCfg() {
|
|
@@ -43,22 +47,32 @@ async function readStdin() {
|
|
|
43
47
|
chunks.push(Buffer.from(chunk));
|
|
44
48
|
return Buffer.concat(chunks).toString('utf8');
|
|
45
49
|
}
|
|
50
|
+
/** The pinned mTLS transport for the resolved credential; an address override only changes the connect target. */
|
|
51
|
+
async function transportFor(c) {
|
|
52
|
+
if (!c.credential)
|
|
53
|
+
throw new Error('no credential: run `tug init <credential-file>`, set TUG_CREDENTIAL_FILE or pass --credential');
|
|
54
|
+
return createTransport(c.credential, c.controlUrl);
|
|
55
|
+
}
|
|
46
56
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
57
|
async function request(method, route, body, multipart = false) {
|
|
48
58
|
const c = await loadCfg();
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const headers = tok ? { Authorization: `Bearer ${tok}` } : {};
|
|
59
|
+
const transport = await transportFor(c);
|
|
60
|
+
const headers = {};
|
|
52
61
|
if (!multipart)
|
|
53
62
|
headers['content-type'] = 'application/json';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
try {
|
|
64
|
+
const res = await transport.http(`${transport.base}${route}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers,
|
|
67
|
+
body: multipart ? body : body ? JSON.stringify(body) : undefined,
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
71
|
+
return (await res.json());
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
void transport.close();
|
|
75
|
+
}
|
|
62
76
|
}
|
|
63
77
|
function runLocal(cmd, args, opts = {}) {
|
|
64
78
|
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
@@ -71,13 +85,6 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
71
85
|
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
72
86
|
return String(result.stdout);
|
|
73
87
|
}
|
|
74
|
-
function remoteHost(sshTarget) {
|
|
75
|
-
return sshTarget.replace(/^[^@]+@/, '').split(':')[0];
|
|
76
|
-
}
|
|
77
|
-
function assertHttps(url, allowHttp) {
|
|
78
|
-
if (!allowHttp && !String(url).startsWith('https://'))
|
|
79
|
-
throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
|
|
80
|
-
}
|
|
81
88
|
function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
|
|
82
89
|
const checks = [
|
|
83
90
|
[
|
|
@@ -129,8 +136,9 @@ function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDis
|
|
|
129
136
|
if (controlUrl)
|
|
130
137
|
checks.push([
|
|
131
138
|
'control.reachable',
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
// TCP reachability only: no credential is copied to the host, and real TLS authorization happens when the agent connects.
|
|
140
|
+
`timeout 5 bash -c ${sh(`exec 3<>/dev/tcp/${new URL(controlUrl).hostname.replace(/^\[|\]$/g, '')}/${new URL(controlUrl).port || 443}`)}`,
|
|
141
|
+
'Verify DNS and the firewall from the node to the control port (control speaks mTLS only, so only TCP reachability is checked here).',
|
|
134
142
|
]);
|
|
135
143
|
const script = `set -e
|
|
136
144
|
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
@@ -181,33 +189,49 @@ async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
|
181
189
|
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
182
190
|
}
|
|
183
191
|
program
|
|
184
|
-
.command('init')
|
|
185
|
-
.
|
|
186
|
-
.
|
|
187
|
-
.action(async (o, command) => {
|
|
192
|
+
.command('init [credentialFile]')
|
|
193
|
+
.description('create the config file if missing and import a credential (a file, or - for stdin) into a 0600 user-config file that the git-ignored .local overlay points at')
|
|
194
|
+
.action(async (credentialFile, _o, command) => {
|
|
188
195
|
const cfg = await loadCfg();
|
|
189
196
|
const { controlUrl, project } = command.optsWithGlobals();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
let text;
|
|
198
|
+
if (credentialFile === '-')
|
|
199
|
+
text = await readStdin();
|
|
200
|
+
else if (credentialFile)
|
|
201
|
+
text = await fs.readFile(credentialFile, 'utf8');
|
|
202
|
+
else if (cfg.credential)
|
|
203
|
+
text = JSON.stringify(cfg.credential);
|
|
204
|
+
if (!text)
|
|
205
|
+
throw new Error('tug init needs a credential file (or - for stdin)');
|
|
206
|
+
const credential = decodeCredential(text);
|
|
207
|
+
// TUG_CREDENTIALS_DIR keeps scripts and tests out of the real home directory.
|
|
208
|
+
const dir = process.env.TUG_CREDENTIALS_DIR ||
|
|
209
|
+
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'tug', 'credentials');
|
|
210
|
+
const source = path.join(dir, `${new URL(credential.url).host.replace(/[^A-Za-z0-9.-]/g, '_')}.json`);
|
|
211
|
+
await writeFileDurable(source, `${JSON.stringify(credential, null, 2)}\n`, 0o600);
|
|
212
|
+
for (const file of await writeInitConfig(cfg, source, { controlUrl, project }))
|
|
194
213
|
console.log(`wrote ${file}`);
|
|
214
|
+
console.log(`imported credential to ${source}`);
|
|
195
215
|
});
|
|
196
216
|
program
|
|
197
217
|
.command('node-register <name>')
|
|
198
|
-
.description('
|
|
218
|
+
.description('sign a node-generated CSR and print the identity response (the primitive behind node-install; the private key stays with whoever made the CSR)')
|
|
219
|
+
.requiredOption('--csr <file>', 'PEM certificate request generated on the node (- for stdin)')
|
|
220
|
+
.option('--mode <mode>', 'create a new node or replace an existing node key', 'create')
|
|
199
221
|
.option('--address <addr>')
|
|
200
222
|
.option('--public-address <addr>')
|
|
201
223
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
202
224
|
.action(async (name, o) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
labels: o.label,
|
|
208
|
-
});
|
|
225
|
+
if (o.mode !== 'create' && o.mode !== 'replace')
|
|
226
|
+
throw new Error("--mode must be 'create' or 'replace'");
|
|
227
|
+
const csr = o.csr === '-' ? await readStdin() : await fs.readFile(o.csr, 'utf8');
|
|
228
|
+
const r = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode: o.mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
|
|
209
229
|
console.log(JSON.stringify(r, null, 2));
|
|
210
230
|
});
|
|
231
|
+
program
|
|
232
|
+
.command('node-disable <name>')
|
|
233
|
+
.description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node-replace-identity")
|
|
234
|
+
.action(async (name) => console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {}), null, 2)));
|
|
211
235
|
program
|
|
212
236
|
.command('node-rotate-wg-key <name>')
|
|
213
237
|
.description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
|
|
@@ -221,8 +245,8 @@ program
|
|
|
221
245
|
});
|
|
222
246
|
program
|
|
223
247
|
.command('node-remove <name>')
|
|
224
|
-
.description('remove a retired node from control (its
|
|
225
|
-
.option('--force', 'remove even if the
|
|
248
|
+
.description('remove a retired node from the control plane (its identity, WireGuard peer, statuses and queued commands); stop its agent and remove its containers on the host yourself')
|
|
249
|
+
.option('--force', 'remove even if the node is still reporting')
|
|
226
250
|
.action(async (name, o) => {
|
|
227
251
|
console.log(JSON.stringify(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`), null, 2));
|
|
228
252
|
});
|
|
@@ -245,21 +269,20 @@ program
|
|
|
245
269
|
throw new Error('replicas must be a positive integer');
|
|
246
270
|
console.log(JSON.stringify(await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) }), null, 2));
|
|
247
271
|
});
|
|
248
|
-
program
|
|
249
|
-
.command('control-token')
|
|
250
|
-
.description('print local dev operator token if available')
|
|
251
|
-
.action(async () => console.log((await fs.readFile('./data/operator.token', 'utf8')).trim()));
|
|
252
272
|
program
|
|
253
273
|
.command('control-install <sshTarget>')
|
|
254
|
-
.description('install the control plane on a Docker-capable host over SSH; front it with
|
|
274
|
+
.description('install the control plane on a Docker-capable host over SSH and issue the root credential into a local 0600 file; it listens for mTLS only, so front it with TCP passthrough rather than a terminating proxy')
|
|
275
|
+
.requiredOption('--public-url <url>', 'credential-free https origin clients use, including the published port')
|
|
276
|
+
.option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
|
|
255
277
|
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
256
278
|
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
257
279
|
.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')
|
|
258
|
-
.option('--
|
|
259
|
-
.option('--
|
|
280
|
+
.option('--port <port>', 'published mTLS port', '8443')
|
|
281
|
+
.option('--credential-out <file>', 'where to write the root credential', 'tug-root.credential.json')
|
|
282
|
+
.option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
|
|
260
283
|
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
261
284
|
.action(async (sshTarget, o) => {
|
|
262
|
-
const
|
|
285
|
+
const publicUrl = normalizeControlUrl(o.publicUrl);
|
|
263
286
|
if (o.installDocker)
|
|
264
287
|
await bootstrapDocker(sshTarget, true);
|
|
265
288
|
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
|
|
@@ -269,63 +292,79 @@ program
|
|
|
269
292
|
const yamlValue = (value) => JSON.stringify(value);
|
|
270
293
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
271
294
|
IMAGE: yamlValue(image),
|
|
272
|
-
|
|
295
|
+
PUBLIC_URL: yamlValue(new URL(publicUrl).origin),
|
|
296
|
+
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
273
297
|
PORT: o.port,
|
|
274
298
|
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
275
299
|
});
|
|
276
300
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
277
301
|
COMPOSE: compose,
|
|
278
|
-
|
|
302
|
+
CONTAINER_SH: sh(CONTROL_CONTAINER),
|
|
279
303
|
});
|
|
280
304
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
305
|
+
// Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to a file.
|
|
306
|
+
const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
|
|
307
|
+
const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
|
|
308
|
+
if (out.status !== 0) {
|
|
309
|
+
if (!o.recoverRoot && /already used/.test(String(out.stderr)))
|
|
310
|
+
console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
|
|
311
|
+
else
|
|
312
|
+
throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
decodeCredential(out.stdout);
|
|
316
|
+
await writeFileDurable(o.credentialOut, `${String(out.stdout).trim()}\n`, 0o600);
|
|
317
|
+
console.error(`root credential written to ${o.credentialOut}; import it with: tug init ${o.credentialOut}`);
|
|
318
|
+
}
|
|
319
|
+
console.log(JSON.stringify({ ok: true, sshTarget, url: publicUrl }, null, 2));
|
|
287
320
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
const AGENT_STATE = '/var/lib/tug';
|
|
322
|
+
function addNodeIdentityOptions(command) {
|
|
323
|
+
return command
|
|
324
|
+
.option('--name <name>', 'node name')
|
|
325
|
+
.option('--all', 'walk every node with an ssh target in the config file, continuing after failures')
|
|
326
|
+
.option('--agent-control-url <url>', 'connect-only URL the agent dials (for example a private address); the certificate is still verified against the credential URL')
|
|
327
|
+
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
328
|
+
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
329
|
+
.option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
330
|
+
.option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required)')
|
|
331
|
+
.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')
|
|
332
|
+
.option('--no-wait', 'do not wait for the node to report after the install');
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The SSH-assisted flow: the node makes its own key and CSR, an admin credential authorizes the identity at this moment and
|
|
336
|
+
* the certificate is installed back over SSH, so no bootstrap secret travels and the private key never leaves the node.
|
|
337
|
+
*/
|
|
338
|
+
async function installNodeIdentity(sshTarget, name, mode, o, repair = false) {
|
|
303
339
|
const cfg = await loadCfg();
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
throw new Error(`node-install needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
|
|
307
|
-
const controlUrl = normalizeControlUrl(o.agentControlUrl ?? cfg.controlUrl);
|
|
308
|
-
assertHttps(controlUrl, o.httpOnlyDev);
|
|
340
|
+
const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
|
|
341
|
+
await transportFor(cfg).then((t) => t.close()); // fail before touching the host when there is no credential
|
|
309
342
|
if (o.installDocker)
|
|
310
343
|
await bootstrapDocker(sshTarget);
|
|
311
344
|
preflightRemote(sshTarget, {
|
|
312
|
-
controlUrl,
|
|
345
|
+
controlUrl: connectUrl ?? cfg.credential?.url,
|
|
313
346
|
mesh: o.mesh,
|
|
314
347
|
udpPorts: o.mesh ? [Number(o.wgPort)] : [],
|
|
315
348
|
});
|
|
316
|
-
const registered = await request('POST', '/api/v1/nodes/register', {
|
|
317
|
-
name: o.name,
|
|
318
|
-
address: o.address,
|
|
319
|
-
publicAddress: o.publicAddress,
|
|
320
|
-
labels: o.label,
|
|
321
|
-
});
|
|
322
349
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
323
350
|
if (o.buildLocalImage)
|
|
324
351
|
await transferTugImage(sshTarget, image);
|
|
352
|
+
const started = new Date().toISOString();
|
|
353
|
+
if (repair)
|
|
354
|
+
runLocal('ssh', [
|
|
355
|
+
'--',
|
|
356
|
+
sshTarget,
|
|
357
|
+
`mkdir -p ${AGENT_STATE} && cd ${AGENT_STATE} && for f in agent.json agent-pending.json; do [ -e "$f" ] && mv "$f" "$f.corrupt-$(date +%s)"; done; true`,
|
|
358
|
+
]);
|
|
359
|
+
const agent = (command, interactive = false) => `mkdir -p ${AGENT_STATE} && docker run --rm ${interactive ? '-i ' : ''}-v ${AGENT_STATE}:${AGENT_STATE} ${sh(image)} agent ${command}`;
|
|
360
|
+
const csr = runCapture('ssh', ['--', sshTarget, agent('identity')]);
|
|
361
|
+
const response = await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/identity`, { csr, mode, address: o.address, publicAddress: o.publicAddress, labels: o.label });
|
|
362
|
+
runCapture('ssh', ['--', sshTarget, agent('install-cert', true)], {
|
|
363
|
+
input: JSON.stringify(response),
|
|
364
|
+
});
|
|
325
365
|
const env = (await loadTemplate(templatesDir, 'agent.env', {
|
|
326
|
-
|
|
327
|
-
NODE_NAME:
|
|
328
|
-
AGENT_TOKEN: registered.agentToken,
|
|
366
|
+
CONTROL_URL_LINE: connectUrl ? `TUG_CONTROL_URL=${connectUrl}\n` : '',
|
|
367
|
+
NODE_NAME: name,
|
|
329
368
|
})) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
330
369
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
331
370
|
ENV: env,
|
|
@@ -333,54 +372,131 @@ program
|
|
|
333
372
|
RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
|
|
334
373
|
});
|
|
335
374
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
336
|
-
|
|
375
|
+
if (o.wait) {
|
|
376
|
+
// A report from the new identity: wait for a report after the install began, not a replaced node's stale status.
|
|
377
|
+
for (let i = 0; i < 30; i++) {
|
|
378
|
+
const { nodes } = await request('GET', '/api/v1/nodes');
|
|
379
|
+
const seen = nodes.find((n) => n.name === name)?.last_seen_at;
|
|
380
|
+
if (seen && seen > started)
|
|
381
|
+
return void console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
|
|
382
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
383
|
+
}
|
|
384
|
+
console.error(`node ${name} has not reported yet; check \`docker logs tug-agent\` on the host`);
|
|
385
|
+
}
|
|
386
|
+
console.log(JSON.stringify({ ok: true, node: response.node }, null, 2));
|
|
387
|
+
}
|
|
388
|
+
async function runNodeIdentity(sshArgument, o, mode, repair = false) {
|
|
389
|
+
const cfg = await loadCfg();
|
|
390
|
+
const verb = repair
|
|
391
|
+
? 'node-repair-identity'
|
|
392
|
+
: mode === 'create'
|
|
393
|
+
? 'node-install'
|
|
394
|
+
: 'node-replace-identity';
|
|
395
|
+
if (o.all) {
|
|
396
|
+
const results = [];
|
|
397
|
+
for (const [name, settings] of Object.entries(cfg.nodes)) {
|
|
398
|
+
const target = settings.ssh;
|
|
399
|
+
if (!target)
|
|
400
|
+
continue;
|
|
401
|
+
const merged = { ...o, ...nodeOverrides(settings), name };
|
|
402
|
+
try {
|
|
403
|
+
await installNodeIdentity(target, name, mode, merged, repair);
|
|
404
|
+
results.push({ node: name, ok: true });
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
results.push({ node: name, ok: false, error: errMessage(e) });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
console.log(JSON.stringify({ results }, null, 2));
|
|
411
|
+
if (results.some((r) => !r.ok))
|
|
412
|
+
process.exitCode = 1;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (!o.name)
|
|
416
|
+
throw new Error(`${verb} needs --name`);
|
|
417
|
+
const sshTarget = sshArgument ?? nodeSettings(cfg, o.name).ssh;
|
|
418
|
+
if (!sshTarget)
|
|
419
|
+
throw new Error(`${verb} needs an SSH target: pass <ssh-target> or set nodes.${o.name}.ssh in ${cfg.file}`);
|
|
420
|
+
await installNodeIdentity(sshTarget, o.name, mode, o, repair);
|
|
421
|
+
}
|
|
422
|
+
const nodeOverrides = (settings) => ({
|
|
423
|
+
...(settings.address ? { address: settings.address } : {}),
|
|
424
|
+
...(settings.publicAddress ? { publicAddress: settings.publicAddress } : {}),
|
|
425
|
+
...(Array.isArray(settings.label) ? { label: settings.label } : {}),
|
|
426
|
+
...(settings.mesh ? { mesh: true } : {}),
|
|
337
427
|
});
|
|
428
|
+
for (const [verb, description, mode, repair] of [
|
|
429
|
+
[
|
|
430
|
+
'node-install',
|
|
431
|
+
'create a node and install its agent over SSH (the SSH target defaults to nodes.<name>.ssh in the config file); safe to rerun after a partial failure',
|
|
432
|
+
'create',
|
|
433
|
+
false,
|
|
434
|
+
],
|
|
435
|
+
[
|
|
436
|
+
'node-replace-identity',
|
|
437
|
+
'install a new key on an existing node over SSH: for an expired, lost or suspected key, a disabled node, a CA loss or an older restore',
|
|
438
|
+
'replace',
|
|
439
|
+
false,
|
|
440
|
+
],
|
|
441
|
+
[
|
|
442
|
+
'node-repair-identity',
|
|
443
|
+
'move aside corrupt agent credential documents on the node (never service volumes) and replace its identity',
|
|
444
|
+
'replace',
|
|
445
|
+
true,
|
|
446
|
+
],
|
|
447
|
+
]) {
|
|
448
|
+
const command = program
|
|
449
|
+
.command(`${verb} [sshTarget]`)
|
|
450
|
+
.description(description)
|
|
451
|
+
.option('--address <addr>')
|
|
452
|
+
.option('--public-address <addr>')
|
|
453
|
+
.option('--label <label>', 'node label (repeatable)', collect, []);
|
|
454
|
+
addNodeIdentityOptions(command).action(async (sshArgument, o) => runNodeIdentity(sshArgument, o, mode, repair));
|
|
455
|
+
}
|
|
338
456
|
program
|
|
339
|
-
.command('node-
|
|
340
|
-
.description('mint a single-use
|
|
341
|
-
.
|
|
342
|
-
.option('--agent-control-url <url>', '
|
|
343
|
-
.option('--ttl <seconds>', '
|
|
344
|
-
.option('--uses <n>', 'how many hosts may enroll with this key', '1')
|
|
345
|
-
.option('--ca <file>', 'PEM CA cert to embed so agents trust a private/self-signed control')
|
|
346
|
-
.option('--http-only-dev', 'allow a plaintext HTTP control URL for local/private development')
|
|
457
|
+
.command('node-prepare-enrollment')
|
|
458
|
+
.description('mint a single-use, name-bound join key so a host you cannot SSH to can self-join with one docker run; a saved cloud-init document contains a credential')
|
|
459
|
+
.requiredOption('--name <name>', 'the node name the key is bound to; mint one key per instance')
|
|
460
|
+
.option('--agent-control-url <url>', 'connect-only URL the agent dials; the certificate is still verified against the credential URL')
|
|
461
|
+
.option('--ttl <seconds>', 'join key lifetime', '3600')
|
|
347
462
|
.option('--image <tag>', "agent image used in the printed docker run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
|
|
348
463
|
.option('--mesh', 'print a docker run that joins the WireGuard mesh (host networking, NET_ADMIN)')
|
|
349
464
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
350
465
|
.option('--install-docker', 'print cloud-init that installs Docker and starts the agent')
|
|
351
466
|
.option('--json', 'print JSON including the command or cloud-init payload')
|
|
352
467
|
.action(async (o) => {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
const ca = o.ca ? await fs.readFile(o.ca, 'utf8') : undefined;
|
|
357
|
-
const minted = await request('POST', '/api/v1/nodes/enroll-token', {
|
|
358
|
-
name: o.name || null,
|
|
468
|
+
const connectUrl = o.agentControlUrl ? normalizeControlUrl(o.agentControlUrl) : undefined;
|
|
469
|
+
const minted = await request('POST', '/api/v1/nodes/enrollment-credential', {
|
|
470
|
+
name: o.name,
|
|
359
471
|
ttlSeconds: Number(o.ttl),
|
|
360
|
-
|
|
472
|
+
labels: o.label.length ? o.label : undefined,
|
|
361
473
|
});
|
|
362
474
|
const keyPayload = {
|
|
363
|
-
v:
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
...(
|
|
475
|
+
v: 2,
|
|
476
|
+
credential: minted.credential,
|
|
477
|
+
name: minted.name,
|
|
478
|
+
...(connectUrl ? { connectUrl } : {}),
|
|
367
479
|
};
|
|
368
|
-
if (ca)
|
|
369
|
-
keyPayload.ca = ca;
|
|
370
480
|
const joinKey = Buffer.from(JSON.stringify(keyPayload)).toString('base64url');
|
|
371
481
|
const command = dockerRunSnippet(resolveImage(o.image), joinKey, o.mesh);
|
|
372
482
|
const cloudInit = o.installDocker
|
|
373
483
|
? enrollmentCloudInit(await dockerBootstrap(), command)
|
|
374
484
|
: undefined;
|
|
375
485
|
if (o.json) {
|
|
376
|
-
console.log(JSON.stringify({
|
|
486
|
+
console.log(JSON.stringify({
|
|
487
|
+
name: minted.name,
|
|
488
|
+
expiresAt: minted.expiresAt,
|
|
489
|
+
joinKey,
|
|
490
|
+
command,
|
|
491
|
+
...(cloudInit ? { cloudInit } : {}),
|
|
492
|
+
}, null, 2));
|
|
377
493
|
return;
|
|
378
494
|
}
|
|
379
495
|
if (cloudInit) {
|
|
380
496
|
process.stdout.write(cloudInit);
|
|
381
497
|
return;
|
|
382
498
|
}
|
|
383
|
-
console.log(`#
|
|
499
|
+
console.log(`# Join key for ${minted.name} valid until ${minted.expiresAt}, single use. Run on the fresh host:\n`);
|
|
384
500
|
console.log(command);
|
|
385
501
|
});
|
|
386
502
|
program
|
|
@@ -451,6 +567,7 @@ program
|
|
|
451
567
|
.command('backup-list')
|
|
452
568
|
.description('show each backup target with its last success and next run, then recent runs')
|
|
453
569
|
.option('--limit <n>', 'number of recent runs to show', '20')
|
|
570
|
+
.option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
|
|
454
571
|
.action(async (o) => {
|
|
455
572
|
const project = (await loadCfg()).project || 'site';
|
|
456
573
|
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
|
|
@@ -462,6 +579,13 @@ program
|
|
|
462
579
|
console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
|
|
463
580
|
for (const r of listing.runs)
|
|
464
581
|
console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
|
|
582
|
+
if (o.check) {
|
|
583
|
+
const problems = backupCheckProblems(listing);
|
|
584
|
+
for (const problem of problems)
|
|
585
|
+
console.error(`PROBLEM ${problem}`);
|
|
586
|
+
if (problems.length)
|
|
587
|
+
process.exitCode = 1;
|
|
588
|
+
}
|
|
465
589
|
});
|
|
466
590
|
program
|
|
467
591
|
.command('restore <target>')
|
|
@@ -504,6 +628,8 @@ program
|
|
|
504
628
|
const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
|
|
505
629
|
const restore = restores.find((item) => item.id === result.restoreId);
|
|
506
630
|
if (restore?.state === 'succeeded') {
|
|
631
|
+
if (restore.error)
|
|
632
|
+
console.error(`warning: ${restore.error}`);
|
|
507
633
|
console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
|
|
508
634
|
return;
|
|
509
635
|
}
|
|
@@ -563,41 +689,41 @@ program
|
|
|
563
689
|
.description('remove the backup destination and its stored credentials; existing backups stay in the repository')
|
|
564
690
|
.action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
|
|
565
691
|
program
|
|
566
|
-
.command('backup-
|
|
692
|
+
.command('backup-repository-password')
|
|
567
693
|
.description("print the project's repository and its password for offline escrow; anyone holding the password and destination credentials can read the project's backups")
|
|
568
694
|
.action(async () => {
|
|
569
695
|
const c = await loadCfg();
|
|
570
696
|
const project = c.project || 'site';
|
|
571
|
-
console.log(JSON.stringify(await request('GET', `/api/v1/backup/
|
|
697
|
+
console.log(JSON.stringify(await request('GET', `/api/v1/backup/repository-password?project=${encodeURIComponent(project)}`), null, 2));
|
|
572
698
|
});
|
|
573
699
|
program
|
|
574
|
-
.command('
|
|
575
|
-
.description('create a named
|
|
700
|
+
.command('credential-create <name>')
|
|
701
|
+
.description('create a named operator credential for CI or a teammate; it is printed once, control keeps no copy of its private key, and it expires after one year at most')
|
|
576
702
|
.addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything')
|
|
577
703
|
.choices(ROLES)
|
|
578
704
|
.makeOptionMandatory())
|
|
579
|
-
.addOption(new Option('--project <name>', 'limit a read or deploy
|
|
705
|
+
.addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
|
|
580
706
|
.default([], 'all projects')
|
|
581
707
|
.argParser((value, previous) => [...previous, value]))
|
|
582
|
-
.option('--expires-in-days <n>', 'expire the
|
|
708
|
+
.option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
|
|
583
709
|
.action(async (name, o) => {
|
|
584
|
-
const created = await request('POST', '/api/v1/
|
|
710
|
+
const created = await request('POST', '/api/v1/credentials', {
|
|
585
711
|
name,
|
|
586
712
|
role: o.role,
|
|
587
713
|
projects: o.project.length ? o.project : undefined,
|
|
588
714
|
expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined,
|
|
589
715
|
});
|
|
590
|
-
console.error('Store this
|
|
716
|
+
console.error('Store this credential now (for example as the TUG_CREDENTIAL secret in CI); it is not shown again. Replace one by creating a new name, verifying it, then revoking the old name.');
|
|
591
717
|
console.log(JSON.stringify(created, null, 2));
|
|
592
718
|
});
|
|
593
719
|
program
|
|
594
|
-
.command('
|
|
595
|
-
.description('list
|
|
596
|
-
.action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/
|
|
720
|
+
.command('credential-list')
|
|
721
|
+
.description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
|
|
722
|
+
.action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/credentials'), null, 2)));
|
|
597
723
|
program
|
|
598
|
-
.command('
|
|
599
|
-
.description('revoke an
|
|
600
|
-
.action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/
|
|
724
|
+
.command('credential-revoke <name>')
|
|
725
|
+
.description('revoke an operator credential immediately; its name stays reserved for the audit trail')
|
|
726
|
+
.action(async (name) => console.log(JSON.stringify(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`), null, 2)));
|
|
601
727
|
async function makeContext() {
|
|
602
728
|
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
603
729
|
try {
|
|
@@ -668,9 +794,9 @@ program
|
|
|
668
794
|
.action(async () => console.log(JSON.stringify(await request('GET', '/api/v1/status'), null, 2)));
|
|
669
795
|
program
|
|
670
796
|
.command('events')
|
|
671
|
-
.description('list recent events with the
|
|
797
|
+
.description('list recent events with the credential that caused each control action; defaults to the resolved project')
|
|
672
798
|
.option('--limit <n>', 'number of events', '100')
|
|
673
|
-
.option('--all', 'list events across all projects (limited to your projects for scoped
|
|
799
|
+
.option('--all', 'list events across all projects (limited to your projects for scoped credentials)')
|
|
674
800
|
.action(async (o) => {
|
|
675
801
|
if (o.all && o.project)
|
|
676
802
|
throw new Error('--all and --project cannot be combined');
|
|
@@ -681,9 +807,9 @@ program
|
|
|
681
807
|
});
|
|
682
808
|
program
|
|
683
809
|
.command('doctor')
|
|
684
|
-
.description('
|
|
685
|
-
.option('--nodes', 'also run diagnostics on every
|
|
686
|
-
.option('--node <name>', 'run diagnostics on one agent and wait')
|
|
810
|
+
.description('check this machine and the control plane: certificate expiry, node connectivity and clock skew')
|
|
811
|
+
.option('--nodes', 'also run host diagnostics on every node through its agent and wait')
|
|
812
|
+
.option('--node <name>', 'run host diagnostics on one node through its agent and wait')
|
|
687
813
|
.option('--timeout <seconds>', 'wait timeout for node diagnostics', '60')
|
|
688
814
|
.action(async (o) => {
|
|
689
815
|
const local = [];
|
|
@@ -700,8 +826,10 @@ program
|
|
|
700
826
|
}
|
|
701
827
|
}
|
|
702
828
|
const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
|
|
829
|
+
const warnings = (controlDiagnostics.checks ?? []).flatMap((check) => (check.warnings ?? []).map((warning) => `${check.name}: ${warning}`));
|
|
703
830
|
const output = {
|
|
704
831
|
ok: local.every((c) => c.ok) && controlDiagnostics.ok,
|
|
832
|
+
...(warnings.length && { warnings }),
|
|
705
833
|
local,
|
|
706
834
|
control: controlDiagnostics,
|
|
707
835
|
};
|
|
@@ -754,11 +882,11 @@ program
|
|
|
754
882
|
});
|
|
755
883
|
program
|
|
756
884
|
.command('control-backup')
|
|
757
|
-
.description('create a control-plane
|
|
885
|
+
.description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
|
|
758
886
|
.action(async () => console.log(JSON.stringify(await request('POST', '/api/v1/control/backups/run', {}), null, 2)));
|
|
759
887
|
program
|
|
760
888
|
.command('control-restore <artifactRef>')
|
|
761
|
-
.description('restore a control-plane backup; requires control restart')
|
|
889
|
+
.description('restore a control-plane backup on the control-plane host; requires a control restart')
|
|
762
890
|
.option('--confirm')
|
|
763
891
|
.action(async (artifactRef, o) => console.log(JSON.stringify(await request('POST', '/api/v1/control/restore', {
|
|
764
892
|
artifactRef,
|
|
@@ -788,7 +916,7 @@ program
|
|
|
788
916
|
});
|
|
789
917
|
async function runLive(operation, input) {
|
|
790
918
|
const cfg = await loadCfg();
|
|
791
|
-
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.
|
|
919
|
+
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
|
|
792
920
|
}
|
|
793
921
|
program
|
|
794
922
|
.command('exec <service> [command...]')
|
|
@@ -824,7 +952,7 @@ program
|
|
|
824
952
|
});
|
|
825
953
|
program
|
|
826
954
|
.command('logs <service>')
|
|
827
|
-
.description('fetch logs from the
|
|
955
|
+
.description('fetch service container logs from the node that runs the service')
|
|
828
956
|
.option('--tail <n>', 'number of lines', '200')
|
|
829
957
|
.option('-f, --follow', 'follow live logs')
|
|
830
958
|
.option('--node <name>', 'select a node for followed logs')
|
|
@@ -877,15 +1005,15 @@ program.hook('preAction', async (_root, command) => {
|
|
|
877
1005
|
if (options.name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(options.name))
|
|
878
1006
|
throw new Error('--name must contain only letters, numbers, dots, underscores or hyphens');
|
|
879
1007
|
const connection = command.optsWithGlobals();
|
|
880
|
-
if (command.name() === '
|
|
881
|
-
// --project is repeatable here and scopes the new
|
|
882
|
-
if (!
|
|
883
|
-
throw new Error('
|
|
1008
|
+
if (command.name() === 'credential-create') {
|
|
1009
|
+
// --project is repeatable here and scopes the new credential, so it must not also select the connection project.
|
|
1010
|
+
if (!CREDENTIAL_NAME_PATTERN.test(command.args[0]))
|
|
1011
|
+
throw new Error('credential name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
|
|
884
1012
|
for (const project of options.project)
|
|
885
1013
|
if (!PROJECT_NAME_PATTERN.test(project))
|
|
886
1014
|
throw new Error('--project must contain only letters, numbers, dots, underscores or hyphens');
|
|
887
1015
|
if (options.role === 'admin' && options.project.length)
|
|
888
|
-
throw new Error('admin
|
|
1016
|
+
throw new Error('admin credentials cannot be limited to projects; use --role read or --role deploy with --project');
|
|
889
1017
|
connection.project = undefined;
|
|
890
1018
|
}
|
|
891
1019
|
resolvedConfig = await resolveCliConfig(connection, process.env, process.cwd(), command.name() === 'init');
|
|
@@ -893,7 +1021,12 @@ program.hook('preAction', async (_root, command) => {
|
|
|
893
1021
|
// commands that create or register one, never by commands that talk to an existing node.
|
|
894
1022
|
const nodeName = command.name() === 'node-register'
|
|
895
1023
|
? command.args[0]
|
|
896
|
-
: [
|
|
1024
|
+
: [
|
|
1025
|
+
'node-install',
|
|
1026
|
+
'node-replace-identity',
|
|
1027
|
+
'node-repair-identity',
|
|
1028
|
+
'node-prepare-enrollment',
|
|
1029
|
+
].includes(command.name())
|
|
897
1030
|
? options.name
|
|
898
1031
|
: undefined;
|
|
899
1032
|
applyConfigDefaults(command, options, {
|
|
@@ -904,7 +1037,8 @@ program.hook('preAction', async (_root, command) => {
|
|
|
904
1037
|
for (const command of program.commands) {
|
|
905
1038
|
command
|
|
906
1039
|
.option('--config <file>', CONFIG_HELP)
|
|
907
|
-
.option('--control-url <url>', 'control API
|
|
1040
|
+
.option('--control-url <url>', 'connect target for the control API (the credential still verifies its own public URL)')
|
|
1041
|
+
.option('--credential <file>', 'credential file (overrides TUG_CREDENTIAL, TUG_CREDENTIAL_FILE and config)');
|
|
908
1042
|
if (!command.options.some((option) => option.long === '--project'))
|
|
909
1043
|
command.option('--project <name>', 'project (overrides TUG_PROJECT and config; default: site)');
|
|
910
1044
|
}
|