@wpelevator/tug 0.5.0 → 0.6.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 +3 -3
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +50 -5
- package/dist/backup-config.d.ts +47 -0
- package/dist/backup-config.js +256 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +297 -0
- package/dist/backup-scheduler.d.ts +54 -0
- package/dist/backup-scheduler.js +403 -0
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +15 -2
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +215 -16
- package/dist/compose.js +5 -2
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.js +253 -19
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db.d.ts +70 -0
- package/dist/db.js +7 -0
- package/dist/docker-api.d.ts +7 -0
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +116 -7
- package/dist/protocol.js +7 -0
- package/dist/tokens.js +10 -0
- package/package.json +2 -1
package/dist/cli-config.js
CHANGED
|
@@ -1,36 +1,286 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
/** Defaults for options of commands that work on the project (top level of the config file). */
|
|
6
|
+
export const COMMAND_SETTINGS = {
|
|
7
|
+
file: { type: 'path', flag: '--file', commands: ['deploy'] },
|
|
8
|
+
buildLocal: { type: 'boolean', flag: '--build-local', commands: ['deploy'] },
|
|
9
|
+
wait: { type: 'boolean', flag: '--no-wait', commands: ['deploy', 'rollback'] },
|
|
10
|
+
};
|
|
11
|
+
/** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
|
|
12
|
+
export const NODE_SETTINGS = {
|
|
13
|
+
ssh: { type: 'string', flag: '<ssh-target>', commands: ['node-install'] },
|
|
14
|
+
address: { type: 'string', flag: '--address', commands: ['node-install', 'node-register'] },
|
|
15
|
+
publicAddress: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
flag: '--public-address',
|
|
18
|
+
commands: ['node-install', 'node-register'],
|
|
19
|
+
},
|
|
20
|
+
label: {
|
|
21
|
+
type: 'list',
|
|
22
|
+
flag: '--label',
|
|
23
|
+
commands: ['node-install', 'node-register', 'node-enroll'],
|
|
24
|
+
},
|
|
25
|
+
mesh: { type: 'boolean', flag: '--mesh', commands: ['node-install', 'node-enroll'] },
|
|
26
|
+
wgPort: { type: 'port', flag: '--wg-port', commands: ['node-install'] },
|
|
27
|
+
installDocker: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
flag: '--install-docker',
|
|
30
|
+
commands: ['node-install', 'node-enroll'],
|
|
31
|
+
},
|
|
32
|
+
image: { type: 'string', flag: '--image', commands: ['node-install', 'node-enroll'] },
|
|
33
|
+
agentControlUrl: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
flag: '--agent-control-url',
|
|
36
|
+
commands: ['node-install', 'node-enroll'],
|
|
37
|
+
},
|
|
38
|
+
httpOnlyDev: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
flag: '--http-only-dev',
|
|
41
|
+
commands: ['node-install', 'node-enroll'],
|
|
42
|
+
},
|
|
43
|
+
ca: { type: 'path', flag: '--ca', commands: ['node-enroll'] },
|
|
44
|
+
};
|
|
45
|
+
const NODE_SPECIFIC_KEYS = ['ssh', 'address', 'publicAddress'];
|
|
46
|
+
const SETTINGS = { ...COMMAND_SETTINGS, ...NODE_SETTINGS };
|
|
47
|
+
/** Default file names, in lookup order; more than one present is an error. */
|
|
48
|
+
export const CONFIG_NAMES = ['tug.yml', 'tug.yaml', 'tug.json', '.tug.json'];
|
|
49
|
+
/** `tug.yml` becomes `tug.local.yml`, `.tug.json` becomes `.tug.local.json`. */
|
|
50
|
+
export function localConfigPath(file) {
|
|
51
|
+
const { dir, name, ext } = path.parse(file);
|
|
52
|
+
return path.join(dir, `${name}.local${ext}`);
|
|
53
|
+
}
|
|
3
54
|
/** Resolve adapter settings once; domain operations receive these explicit values. */
|
|
4
55
|
export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
5
56
|
for (const key of ['config', 'controlUrl', 'project'])
|
|
6
57
|
if (options[key] !== undefined && (!options[key].trim() || /[\r\n\0]/.test(options[key])))
|
|
7
58
|
throw new Error(`--${key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())} must be a nonempty single-line value`);
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (value !== undefined && (typeof value !== 'string' || !value.trim()))
|
|
17
|
-
throw new Error(`${key} must be a nonempty string`);
|
|
18
|
-
}
|
|
19
|
-
config = input;
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
const missing = error.code === 'ENOENT';
|
|
23
|
-
if (!missing || (!creating && Boolean(options.config || env.TUG_CONFIG)))
|
|
24
|
-
throw new Error(`Cannot read config ${file}: ${error.message}`, { cause: error });
|
|
25
|
-
}
|
|
59
|
+
const selected = options.config || env.TUG_CONFIG;
|
|
60
|
+
const file = selected ? path.resolve(cwd, selected) : await discoverConfigFile(cwd);
|
|
61
|
+
const localFile = localConfigPath(file);
|
|
62
|
+
const base = await readConfigFile(file, false);
|
|
63
|
+
if (!base && !creating && selected)
|
|
64
|
+
throw new Error(`Cannot read config ${file}: ENOENT: no such file`);
|
|
65
|
+
const overlay = await readConfigFile(localFile, true);
|
|
66
|
+
const config = mergeConfigs(base ?? emptyConfig(), overlay ?? emptyConfig());
|
|
26
67
|
const controlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl || 'http://localhost:8080';
|
|
27
68
|
return {
|
|
28
69
|
file,
|
|
70
|
+
fileExists: Boolean(base),
|
|
71
|
+
localFile,
|
|
29
72
|
controlUrl: normalizeControlUrl(controlUrl),
|
|
30
|
-
|
|
73
|
+
token: env.TUG_TOKEN || config.token,
|
|
31
74
|
project: options.project || env.TUG_PROJECT || config.project || 'site',
|
|
75
|
+
commandDefaults: config.commandDefaults,
|
|
76
|
+
nodeDefaults: config.nodeDefaults,
|
|
77
|
+
nodes: config.nodes,
|
|
32
78
|
};
|
|
33
79
|
}
|
|
80
|
+
/** `nodeDefaults` under the node's own keys; a list in the node replaces the default list. */
|
|
81
|
+
export function nodeSettings(config, name) {
|
|
82
|
+
return { ...config.nodeDefaults, ...(name ? config.nodes[name] : undefined) };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Use config values as defaults for the options of a command: a key applies to a command that lists it
|
|
86
|
+
* and has an option of that name, and never over a value given on the command line.
|
|
87
|
+
*/
|
|
88
|
+
export function applyConfigDefaults(command, values, settings) {
|
|
89
|
+
const attributes = new Set(command.options.map((option) => option.attributeName()));
|
|
90
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
91
|
+
if (SETTINGS[key]?.commands.includes(command.name()) &&
|
|
92
|
+
attributes.has(key) &&
|
|
93
|
+
command.getOptionValueSource(key) !== 'cli')
|
|
94
|
+
values[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function emptyConfig() {
|
|
98
|
+
return { commandDefaults: {}, nodeDefaults: {}, nodes: {} };
|
|
99
|
+
}
|
|
100
|
+
async function discoverConfigFile(cwd) {
|
|
101
|
+
const found = [];
|
|
102
|
+
for (const name of CONFIG_NAMES) {
|
|
103
|
+
try {
|
|
104
|
+
await fs.stat(path.join(cwd, name));
|
|
105
|
+
found.push(name);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code !== 'ENOENT')
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (found.length > 1)
|
|
113
|
+
throw new Error(`Found more than one config file (${found.join(', ')}) in ${cwd}; keep one or select it with --config`);
|
|
114
|
+
return path.join(cwd, found[0] ?? CONFIG_NAMES[0]);
|
|
115
|
+
}
|
|
116
|
+
function parseText(file, content) {
|
|
117
|
+
return path.extname(file) === '.json' ? JSON.parse(content) : yaml.load(content);
|
|
118
|
+
}
|
|
119
|
+
async function readConfigFile(file, overlay) {
|
|
120
|
+
let content;
|
|
121
|
+
try {
|
|
122
|
+
content = await fs.readFile(file, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT')
|
|
126
|
+
return undefined;
|
|
127
|
+
throw new Error(`Cannot read config ${file}: ${error.message}`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return parseConfig(parseText(file, content) ?? {}, path.dirname(file), overlay ? undefined : localConfigPath(file));
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw new Error(`Cannot read config ${file}: ${error.message}`, { cause: error });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
137
|
+
function text(value, where) {
|
|
138
|
+
if (typeof value !== 'string' || !value.trim() || /[\r\n\0]/.test(value))
|
|
139
|
+
throw new Error(`${where} must be a nonempty single-line string`);
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
/** Same rule as --name, so a name from the file is as safe as one typed. */
|
|
143
|
+
function nodeName(name) {
|
|
144
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name))
|
|
145
|
+
throw new Error(`nodes.${name} must be a node name of letters, numbers, dots, underscores or hyphens`);
|
|
146
|
+
return name;
|
|
147
|
+
}
|
|
148
|
+
function settingValue(setting, value, where, dir) {
|
|
149
|
+
switch (setting.type) {
|
|
150
|
+
case 'string':
|
|
151
|
+
return text(value, where);
|
|
152
|
+
case 'path':
|
|
153
|
+
return path.resolve(dir, text(value, where));
|
|
154
|
+
case 'boolean':
|
|
155
|
+
if (typeof value !== 'boolean')
|
|
156
|
+
throw new Error(`${where} must be true or false`);
|
|
157
|
+
return value;
|
|
158
|
+
case 'port':
|
|
159
|
+
return String(integerOption(String(value), where, 1, 65535));
|
|
160
|
+
case 'list':
|
|
161
|
+
return (Array.isArray(value) ? value : [value]).map((item, index) => text(item, Array.isArray(value) ? `${where}[${index}]` : where));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function parseSettings(input, table, where, dir) {
|
|
165
|
+
if (!isObject(input))
|
|
166
|
+
throw new Error(`${where} must be a mapping`);
|
|
167
|
+
const settings = {};
|
|
168
|
+
for (const [key, value] of Object.entries(input)) {
|
|
169
|
+
if (!table[key])
|
|
170
|
+
throw new Error(`${where}.${key} is not a known setting (known: ${Object.keys(table).join(', ')})`);
|
|
171
|
+
settings[key] = settingValue(table[key], value, `${where}.${key}`, dir);
|
|
172
|
+
}
|
|
173
|
+
return settings;
|
|
174
|
+
}
|
|
175
|
+
/** `overlay` names the overlay to suggest for a tracked file; undefined means this file is the overlay and may hold a token. */
|
|
176
|
+
function parseConfig(input, dir, overlay) {
|
|
177
|
+
if (!isObject(input))
|
|
178
|
+
throw new Error('expected a mapping of settings');
|
|
179
|
+
const config = emptyConfig();
|
|
180
|
+
const nodeDefaultsTable = Object.fromEntries(Object.entries(NODE_SETTINGS).filter(([key]) => !NODE_SPECIFIC_KEYS.includes(key)));
|
|
181
|
+
for (const [key, value] of Object.entries(input)) {
|
|
182
|
+
if (key === 'controlUrl' || key === 'project')
|
|
183
|
+
config[key] = text(value, key);
|
|
184
|
+
else if (key === 'token') {
|
|
185
|
+
if (overlay)
|
|
186
|
+
throw new Error(`token must not be set in a tracked file; put it in ${overlay} or TUG_TOKEN`);
|
|
187
|
+
config.token = text(value, key);
|
|
188
|
+
}
|
|
189
|
+
else if (COMMAND_SETTINGS[key])
|
|
190
|
+
config.commandDefaults[key] = settingValue(COMMAND_SETTINGS[key], value, key, dir);
|
|
191
|
+
else if (key === 'nodeDefaults')
|
|
192
|
+
config.nodeDefaults = parseSettings(value, nodeDefaultsTable, key, dir);
|
|
193
|
+
else if (key === 'nodes') {
|
|
194
|
+
if (!isObject(value))
|
|
195
|
+
throw new Error('nodes must be a mapping of node names');
|
|
196
|
+
for (const [name, node] of Object.entries(value))
|
|
197
|
+
config.nodes[nodeName(name)] = parseSettings(node ?? {}, NODE_SETTINGS, `nodes.${name}`, dir);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const known = [
|
|
201
|
+
'controlUrl',
|
|
202
|
+
'project',
|
|
203
|
+
...(overlay ? [] : ['token']),
|
|
204
|
+
...Object.keys(COMMAND_SETTINGS),
|
|
205
|
+
'nodeDefaults',
|
|
206
|
+
'nodes',
|
|
207
|
+
];
|
|
208
|
+
throw new Error(`${key} is not a known setting (known: ${known.join(', ')})`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return config;
|
|
212
|
+
}
|
|
213
|
+
/** The overlay wins key by key, including inside `nodeDefaults` and each node. */
|
|
214
|
+
function mergeConfigs(base, overlay) {
|
|
215
|
+
const nodes = {};
|
|
216
|
+
for (const name of new Set([...Object.keys(base.nodes), ...Object.keys(overlay.nodes)]))
|
|
217
|
+
nodes[name] = { ...base.nodes[name], ...overlay.nodes[name] };
|
|
218
|
+
return {
|
|
219
|
+
controlUrl: overlay.controlUrl ?? base.controlUrl,
|
|
220
|
+
project: overlay.project ?? base.project,
|
|
221
|
+
token: overlay.token ?? base.token,
|
|
222
|
+
commandDefaults: { ...base.commandDefaults, ...overlay.commandDefaults },
|
|
223
|
+
nodeDefaults: { ...base.nodeDefaults, ...overlay.nodeDefaults },
|
|
224
|
+
nodes,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function serialize(file, data, notice) {
|
|
228
|
+
if (path.extname(file) === '.json')
|
|
229
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
230
|
+
return `${notice ? `# ${notice}\n` : ''}${yaml.dump(data)}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* `tug init`: create the tracked file when it is missing and always store the token in the untracked
|
|
234
|
+
* overlay, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
|
|
235
|
+
* connection flags passed to `init` are saved to the overlay instead.
|
|
236
|
+
*/
|
|
237
|
+
export async function writeInitConfig(config, token, explicit) {
|
|
238
|
+
const written = [];
|
|
239
|
+
if (!config.fileExists) {
|
|
240
|
+
await fs.mkdir(path.dirname(config.file), { recursive: true });
|
|
241
|
+
await fs.writeFile(config.file, serialize(config.file, { controlUrl: config.controlUrl, project: config.project }, `Safe to commit: keep the API token out of this file (use TUG_TOKEN or ${path.basename(config.localFile)}).`));
|
|
242
|
+
written.push(config.file);
|
|
243
|
+
}
|
|
244
|
+
let local = {};
|
|
245
|
+
try {
|
|
246
|
+
const existing = parseText(config.localFile, await fs.readFile(config.localFile, 'utf8'));
|
|
247
|
+
if (isObject(existing))
|
|
248
|
+
local = existing;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (error.code !== 'ENOENT')
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
local.token = token;
|
|
255
|
+
if (config.fileExists) {
|
|
256
|
+
if (explicit.controlUrl)
|
|
257
|
+
local.controlUrl = config.controlUrl;
|
|
258
|
+
if (explicit.project)
|
|
259
|
+
local.project = config.project;
|
|
260
|
+
}
|
|
261
|
+
await fs.writeFile(config.localFile, serialize(config.localFile, local), { mode: 0o600 });
|
|
262
|
+
await fs.chmod(config.localFile, 0o600);
|
|
263
|
+
written.push(config.localFile);
|
|
264
|
+
await gitIgnore(config.localFile);
|
|
265
|
+
return written;
|
|
266
|
+
}
|
|
267
|
+
/** Add `file` to the .gitignore next to it when it sits in a git work tree and is not ignored yet. */
|
|
268
|
+
async function gitIgnore(file) {
|
|
269
|
+
const cwd = path.dirname(file);
|
|
270
|
+
const ignored = spawnSync('git', ['check-ignore', '-q', '--', file], { cwd });
|
|
271
|
+
if (ignored.status !== 1)
|
|
272
|
+
return; // 0: already ignored; anything else: not a git checkout
|
|
273
|
+
const ignoreFile = path.join(cwd, '.gitignore');
|
|
274
|
+
let current = '';
|
|
275
|
+
try {
|
|
276
|
+
current = await fs.readFile(ignoreFile, 'utf8');
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error.code !== 'ENOENT')
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
await fs.writeFile(ignoreFile, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${path.basename(file)}\n`);
|
|
283
|
+
}
|
|
34
284
|
export function normalizeControlUrl(value) {
|
|
35
285
|
if (!value.trim() || /[\r\n\0]/.test(value))
|
|
36
286
|
throw new Error('control URL must be a nonempty single-line URL');
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CliLiveTransport } from './cli-live.ts';
|
|
2
2
|
/** Credentials travel only in the upgrade header, never a URL or frame. */
|
|
3
|
-
export declare function createCliLiveTransport(controlUrl: string,
|
|
3
|
+
export declare function createCliLiveTransport(controlUrl: string, token?: string): CliLiveTransport;
|
|
@@ -3,7 +3,7 @@ import { CliLiveError } from "./cli-live.js";
|
|
|
3
3
|
import { LIVE_MAX_FRAME_BYTES as MAX_FRAME, LIVE_MAX_BUFFERED_BYTES as MAX_BUFFERED, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
|
|
4
4
|
const MAX_EVENTS = 1024;
|
|
5
5
|
/** Credentials travel only in the upgrade header, never a URL or frame. */
|
|
6
|
-
export function createCliLiveTransport(controlUrl,
|
|
6
|
+
export function createCliLiveTransport(controlUrl, token) {
|
|
7
7
|
const url = new URL(controlUrl);
|
|
8
8
|
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
9
9
|
throw new CliLiveError('live control URL must use http or https');
|
|
@@ -19,7 +19,7 @@ export function createCliLiveTransport(controlUrl, operatorToken) {
|
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
const socket = new WebSocket(url, {
|
|
22
|
-
headers:
|
|
22
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
23
23
|
maxPayload: MAX_FRAME,
|
|
24
24
|
handshakeTimeout: LIVE_START_TIMEOUT_MS,
|
|
25
25
|
perMessageDeflate: false,
|
package/dist/cli-live.d.ts
CHANGED
package/dist/cli-live.js
CHANGED