@dokebi/cli 0.4.26 → 0.4.28
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/AI.md +20 -3
- package/catalog.json +7 -7
- package/dist/bin.js +121 -3
- package/dist/check/shared-state-rules.d.ts +13 -0
- package/dist/check/shared-state-rules.js +5 -2
- package/dist/commands.js +76 -7
- package/dist/config-secrets.d.ts +54 -0
- package/dist/config-secrets.js +226 -0
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +564 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-inspector.js +17 -1
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/package.json +3 -3
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// `dokebi deploy init` and `dokebi deploy proxy` (D-0184): the deployment files of an existing
|
|
2
|
+
// project, per instance, written into deploy/ (the repository keeps them; no secret is in any).
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { parseConfig } from '@dokebi/config';
|
|
7
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
8
|
+
import { isPlaceholder } from './config-init.js';
|
|
9
|
+
import { requiredSecretPaths, schemaTypes, unreadableWarning } from './config-secrets.js';
|
|
10
|
+
import { configShow } from './config-show.js';
|
|
11
|
+
import { CONFIG_FILE, renderConfig } from './configuration.js';
|
|
12
|
+
import { BACKUP_SCRIPT, RESTORE_DRILL_SCRIPT } from './deploy-scripts.js';
|
|
13
|
+
import { backupService, backupTimer, caddyfile, composeFile, deployGuide, layout, nginxConf, systemdUnit, } from './deploy-templates.js';
|
|
14
|
+
import { DEPLOYMENT_FILES } from './deployment.js';
|
|
15
|
+
import { debug, isMissing } from './diagnostics.js';
|
|
16
|
+
import { CliError } from './errors.js';
|
|
17
|
+
import { exists, parseObject, securePath } from './filesystem.js';
|
|
18
|
+
import { optionsKey } from './manifest.js';
|
|
19
|
+
import { readState } from './project-state.js';
|
|
20
|
+
import { applyReloadableMarks, reloadSchemas } from './reloadable.js';
|
|
21
|
+
import { Transaction } from './transaction.js';
|
|
22
|
+
const EXAMPLE = 'dokebi.example.yaml';
|
|
23
|
+
const INSTANCE = /^[a-z][a-z0-9-]{0,23}$/;
|
|
24
|
+
const DEFAULT_PORTS = { production: 3000, staging: 3001 };
|
|
25
|
+
/** URL schemes whose URL is a credential on a server (it carries the user and password). */
|
|
26
|
+
const CREDENTIAL_URL = /^(postgres|postgresql|mysql|mariadb|mongodb|mongodb\+srv|redis|rediss|nats|tls|smtp|smtps|amqp|amqps):/i;
|
|
27
|
+
/** The marker line of a file deploy init wrote: a later run rewrites it only while unchanged. */
|
|
28
|
+
function marker(path, body) {
|
|
29
|
+
const digest = createHash('sha256').update(body).digest('hex').slice(0, 16);
|
|
30
|
+
const text = `generated by dokebi deploy (${digest}); edit freely, a later run then keeps it`;
|
|
31
|
+
if (path.endsWith('.md'))
|
|
32
|
+
return `<!-- ${text} -->\n`;
|
|
33
|
+
if (path.endsWith('.mjs'))
|
|
34
|
+
return `// ${text}\n`;
|
|
35
|
+
return `# ${text}\n`;
|
|
36
|
+
}
|
|
37
|
+
function withMarker(path, body) {
|
|
38
|
+
return marker(path, body) + body;
|
|
39
|
+
}
|
|
40
|
+
/** Whether `text` is a file deploy wrote and nobody changed since. */
|
|
41
|
+
function untouched(path, text) {
|
|
42
|
+
const end = text.indexOf('\n');
|
|
43
|
+
return end > 0 && text.slice(0, end + 1) === marker(path, text.slice(end + 1));
|
|
44
|
+
}
|
|
45
|
+
/** The app name of package.json, as unit, user and folder names take it. */
|
|
46
|
+
async function appName(root) {
|
|
47
|
+
const pkg = parseObject(await readFile(join(root, 'package.json'), 'utf8'), 'package.json');
|
|
48
|
+
const raw = typeof pkg.name === 'string' ? pkg.name : '';
|
|
49
|
+
const name = raw
|
|
50
|
+
.replace(/^@[^/]+\//, '')
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
53
|
+
.replace(/^[^a-z]+|-+$/g, '')
|
|
54
|
+
.slice(0, 24)
|
|
55
|
+
.replace(/-+$/, '');
|
|
56
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name))
|
|
57
|
+
throw new CliError('APP_NAME_INVALID', 'package.json has no name the server names can be made from (letters, digits, -)', {
|
|
58
|
+
phase: 'arguments',
|
|
59
|
+
details: {
|
|
60
|
+
name: raw,
|
|
61
|
+
fix: 'set "name" in package.json to a short lowercase name, e.g. "shop"',
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return name;
|
|
65
|
+
}
|
|
66
|
+
async function projectState(root) {
|
|
67
|
+
try {
|
|
68
|
+
return readState(parseObject(await readFile(await securePath(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (isMissing(error))
|
|
72
|
+
throw new CliError('NOT_A_PROJECT', 'No .dokebi/state.json: run this in a Dokebi project (the folder with package.json, dokebi.yaml and .dokebi/state.json)', { phase: 'arguments', details: { root, file: '.dokebi/state.json' } });
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function readOptional(path) {
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(path, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isMissing(error))
|
|
82
|
+
return undefined;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Every `section.key…` path whose value is a URL with a credential scheme or a placeholder of one. */
|
|
87
|
+
function credentialUrls(value, path, found) {
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
for (const [index, item] of value.entries())
|
|
90
|
+
credentialUrls(item, [...path, `${index}`], found);
|
|
91
|
+
else if (value && typeof value === 'object')
|
|
92
|
+
for (const [key, item] of Object.entries(value))
|
|
93
|
+
credentialUrls(item, [...path, key], found);
|
|
94
|
+
else if (typeof value === 'string' &&
|
|
95
|
+
path.at(-1)?.toLowerCase() === 'url' &&
|
|
96
|
+
(CREDENTIAL_URL.test(value) || (isPlaceholder(value) && path.length > 1)))
|
|
97
|
+
found.add(path.join('.'));
|
|
98
|
+
}
|
|
99
|
+
/** The settings of the development machine and the sample: which option paths are secrets. */
|
|
100
|
+
async function secretPaths(root, sample) {
|
|
101
|
+
const found = new Set();
|
|
102
|
+
const sampleOptions = parseConfig(sample).options;
|
|
103
|
+
const shown = await configShow(root, undefined, { source: sample, file: EXAMPLE });
|
|
104
|
+
// Without a plugin's schema every text of its section would count as a secret.
|
|
105
|
+
const unreadable = (shown.warnings ?? []).filter((item) => item.code === 'OPTIONS_SCHEMA_UNREADABLE');
|
|
106
|
+
if (unreadable.length)
|
|
107
|
+
throw new CliError('OPTIONS_SCHEMA_UNREADABLE', `The options schemas of ${unreadable.map((item) => item.plugin).join(', ')} could not be read, so the secrets of the settings cannot be told apart`, {
|
|
108
|
+
phase: 'arguments',
|
|
109
|
+
details: {
|
|
110
|
+
plugins: unreadable.map((item) => item.plugin),
|
|
111
|
+
fix: 'npm install, then run the command again',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
for (const path of shown.secrets)
|
|
115
|
+
found.add(path);
|
|
116
|
+
credentialUrls(sampleOptions, [], found);
|
|
117
|
+
const development = await readOptional(join(root, CONFIG_FILE));
|
|
118
|
+
if (development !== undefined) {
|
|
119
|
+
const options = parseConfig(development).options;
|
|
120
|
+
for (const path of (await configShow(root, undefined, { source: development, file: CONFIG_FILE })).secrets)
|
|
121
|
+
found.add(path);
|
|
122
|
+
credentialUrls(options, [], found);
|
|
123
|
+
}
|
|
124
|
+
const planned = await requiredSecretPaths(root, (development === undefined ? sampleOptions : parseConfig(development).options));
|
|
125
|
+
for (const path of planned.paths)
|
|
126
|
+
found.add(path);
|
|
127
|
+
// A secret inside a list item (logging.targets.0.token) moves the whole list: a list is one
|
|
128
|
+
// value of the secrets file (it cannot be split between two files).
|
|
129
|
+
const sources = [
|
|
130
|
+
sampleOptions,
|
|
131
|
+
development === undefined ? {} : parseConfig(development).options,
|
|
132
|
+
];
|
|
133
|
+
const cut = (path) => {
|
|
134
|
+
const keys = path.split('.');
|
|
135
|
+
for (let end = 1; end < keys.length; end++)
|
|
136
|
+
if (sources.some((options) => Array.isArray(valueAt(options, keys.slice(0, end)))))
|
|
137
|
+
return keys.slice(0, end).join('.');
|
|
138
|
+
return path;
|
|
139
|
+
};
|
|
140
|
+
const paths = [...new Set([...found].map(cut))].sort();
|
|
141
|
+
// A path under another listed path is covered by it.
|
|
142
|
+
return {
|
|
143
|
+
paths: paths.filter((path) => !paths.some((other) => path !== other && path.startsWith(`${other}.`))),
|
|
144
|
+
unreadable: planned.unreadable,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function valueAt(options, path) {
|
|
148
|
+
let value = options;
|
|
149
|
+
for (const key of path) {
|
|
150
|
+
if (Array.isArray(value) && /^\d+$/.test(key))
|
|
151
|
+
value = value[Number(key)];
|
|
152
|
+
else if (value && typeof value === 'object' && Object.hasOwn(value, key))
|
|
153
|
+
value = value[key];
|
|
154
|
+
else
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
/** A placeholder for a secret of the secrets sample, saying what goes there (never a value). */
|
|
160
|
+
function placeholderFor(path, at, current, types) {
|
|
161
|
+
const kind = typeof current === 'string'
|
|
162
|
+
? current.slice(0, Math.max(0, current.indexOf(':'))).toLowerCase()
|
|
163
|
+
: '';
|
|
164
|
+
if (path.endsWith('.url')) {
|
|
165
|
+
if (kind.startsWith('postgres') || (/dokebi-db/.test(path) && kind === ''))
|
|
166
|
+
return `<postgres URL: postgres://USER:PASSWORD@127.0.0.1:5432/${at.database}>`;
|
|
167
|
+
if (kind.startsWith('mongodb'))
|
|
168
|
+
return `<mongodb URL: mongodb://USER:PASSWORD@127.0.0.1:27017/${at.database}>`;
|
|
169
|
+
if (kind === 'mysql' || kind === 'mariadb')
|
|
170
|
+
return `<${kind} URL: ${kind}://USER:PASSWORD@127.0.0.1:3306/${at.database}>`;
|
|
171
|
+
if (kind.startsWith('redis'))
|
|
172
|
+
return '<redis URL: redis://:PASSWORD@127.0.0.1:6379/0>';
|
|
173
|
+
if (kind === 'nats' || kind === 'tls')
|
|
174
|
+
return '<nats URL: nats://USER:PASSWORD@127.0.0.1:4222>';
|
|
175
|
+
return '<URL with its credentials>';
|
|
176
|
+
}
|
|
177
|
+
if ((Array.isArray(current) && current.every((item) => typeof item === 'string')) ||
|
|
178
|
+
(current === undefined && types.has('array') && !types.has('string')))
|
|
179
|
+
return ['<a random value of 32 or more characters: openssl rand -base64 48>'];
|
|
180
|
+
if (Array.isArray(current) || (current && typeof current === 'object'))
|
|
181
|
+
return '<the whole value of this option from the development dokebi.yaml, with production secrets>';
|
|
182
|
+
return '<secret>';
|
|
183
|
+
}
|
|
184
|
+
/** The settings sample of one instance: the sample without secrets, instance values, secrets list. */
|
|
185
|
+
function instanceSample(sample, at, target, plugins, secrets, schemas) {
|
|
186
|
+
const document = parseDocument(sample, { version: '1.2' });
|
|
187
|
+
for (const path of secrets) {
|
|
188
|
+
// Only where the sample has the path: a section left empty (`connections:`) is null there.
|
|
189
|
+
const keys = ['options', ...path.split('.')];
|
|
190
|
+
let node = document.contents;
|
|
191
|
+
for (const key of keys.slice(0, -1))
|
|
192
|
+
node = isMap(node) || isSeq(node) ? node.get(key, true) : undefined;
|
|
193
|
+
if (isMap(node) || isSeq(node))
|
|
194
|
+
node.delete(keys.at(-1));
|
|
195
|
+
}
|
|
196
|
+
const set = (path, value, comment) => {
|
|
197
|
+
document.setIn(['options', ...path], value);
|
|
198
|
+
if (comment) {
|
|
199
|
+
const parent = document.getIn(['options', ...path.slice(0, -1)], true);
|
|
200
|
+
if (isMap(parent)) {
|
|
201
|
+
const pair = parent.items.find((item) => (isScalar(item.key) ? item.key.value : item.key) === path.at(-1));
|
|
202
|
+
if (pair) {
|
|
203
|
+
// A key added by setIn is a plain string: make it a node that can carry the comment.
|
|
204
|
+
if (!isScalar(pair.key))
|
|
205
|
+
pair.key = document.createNode(pair.key);
|
|
206
|
+
pair.key.commentBefore = ` ${comment}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
if (plugins.includes('dokebi-web')) {
|
|
212
|
+
set(['dokebi-web', 'host'], '127.0.0.1', `deploy (${at.instance}): behind the reverse proxy on this server`);
|
|
213
|
+
set(['dokebi-web', 'port'], at.port);
|
|
214
|
+
set(['dokebi-web', 'trustProxy'], ['127.0.0.1', '::1'], 'the reverse proxy on this server; add its address when it runs elsewhere');
|
|
215
|
+
}
|
|
216
|
+
if (plugins.includes('dokebi-storage'))
|
|
217
|
+
set(['dokebi-storage', 'directory'], `${at.data}/storage`, `deploy (${at.instance}): outside the release folder`);
|
|
218
|
+
if (secrets.length) {
|
|
219
|
+
const list = document.createNode([...secrets]);
|
|
220
|
+
if (isSeq(list))
|
|
221
|
+
list.flow = false;
|
|
222
|
+
const pair = document.createPair('secrets', list);
|
|
223
|
+
pair.key.commentBefore =
|
|
224
|
+
` deploy (${at.instance}, ${target}): these options come from the secrets file DOKEBI_SECRETS\n names (${at.secrets}, mode 0600); the app refuses to start while one is empty`;
|
|
225
|
+
const contents = document.contents;
|
|
226
|
+
if (isMap(contents))
|
|
227
|
+
contents.items.splice(1, 0, pair);
|
|
228
|
+
}
|
|
229
|
+
// The `# reloadable` marks stay on their key lines, as the CLI writes them (D-0154).
|
|
230
|
+
applyReloadableMarks(document, schemas);
|
|
231
|
+
return renderConfig(document);
|
|
232
|
+
}
|
|
233
|
+
/** The secrets sample of one instance: each secret path with a placeholder. */
|
|
234
|
+
function secretsSample(at, secrets, sources, schemas) {
|
|
235
|
+
const document = parseDocument('schemaVersion: 1\noptions: {}\n', { version: '1.2' });
|
|
236
|
+
for (const path of secrets) {
|
|
237
|
+
const keys = path.split('.');
|
|
238
|
+
const current = sources
|
|
239
|
+
.map((options) => valueAt(options, keys))
|
|
240
|
+
.find((value) => value !== undefined);
|
|
241
|
+
const [section = '', ...rest] = keys;
|
|
242
|
+
document.setIn(['options', ...keys], placeholderFor(path, at, current, schemaTypes(schemas.get(section), rest)));
|
|
243
|
+
}
|
|
244
|
+
const options = document.get('options', true);
|
|
245
|
+
if (isMap(options))
|
|
246
|
+
options.flow = false;
|
|
247
|
+
document.commentBefore = ` The secrets of ${at.app} (${at.instance}): the paths only. The real file is ${at.secrets}
|
|
248
|
+
(mode 0600, owner ${at.unit}); its values come from your secret store and never from Git.
|
|
249
|
+
Replace each <placeholder> there; the settings sample lists the same paths under secrets:.`;
|
|
250
|
+
return renderConfig(document);
|
|
251
|
+
}
|
|
252
|
+
/** Writes the planned files in one transaction: new, unchanged, rewritten when untouched, else kept. */
|
|
253
|
+
async function writeFiles(root, files, dryRun) {
|
|
254
|
+
const changed = [];
|
|
255
|
+
const unchanged = [];
|
|
256
|
+
const kept = [];
|
|
257
|
+
const warnings = [];
|
|
258
|
+
const tx = await Transaction.begin(root);
|
|
259
|
+
try {
|
|
260
|
+
for (const file of files) {
|
|
261
|
+
const current = await readOptional(await securePath(root, file.path));
|
|
262
|
+
if (current === file.content) {
|
|
263
|
+
unchanged.push(file.path);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const replaceable = current === undefined ||
|
|
267
|
+
untouched(file.path, current) ||
|
|
268
|
+
(file.path === 'DEPLOY.md' && current === DEPLOYMENT_FILES['DEPLOY.md']);
|
|
269
|
+
if (!replaceable) {
|
|
270
|
+
kept.push(file.path);
|
|
271
|
+
warnings.push({
|
|
272
|
+
code: 'DEPLOY_FILE_KEPT',
|
|
273
|
+
path: file.path,
|
|
274
|
+
hint: `${file.path} was edited since deploy wrote it, so it is kept as it is; move it away and run the command again for the new version`,
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const parent = dirname(file.path);
|
|
279
|
+
if (parent !== '.')
|
|
280
|
+
await tx.ensureDirectory(parent);
|
|
281
|
+
await tx.write(file.path, file.content);
|
|
282
|
+
changed.push(file.path);
|
|
283
|
+
}
|
|
284
|
+
if (!dryRun && changed.length)
|
|
285
|
+
await tx.commit();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
throw tx.failure(error);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await tx.close();
|
|
292
|
+
}
|
|
293
|
+
return { changed, unchanged, kept, warnings };
|
|
294
|
+
}
|
|
295
|
+
/** The instances deploy init wrote before (deploy/<name>/dokebi.example.yaml), with their ports. */
|
|
296
|
+
async function knownInstances(root) {
|
|
297
|
+
const found = new Map();
|
|
298
|
+
let names;
|
|
299
|
+
try {
|
|
300
|
+
names = await readdir(join(root, 'deploy'));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (isMissing(error))
|
|
304
|
+
return found;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
for (const name of names.sort()) {
|
|
308
|
+
if (!INSTANCE.test(name))
|
|
309
|
+
continue;
|
|
310
|
+
const text = await readOptional(join(root, 'deploy', name, EXAMPLE));
|
|
311
|
+
if (text === undefined)
|
|
312
|
+
continue;
|
|
313
|
+
const port = valueAt(parseConfig(text).options, ['dokebi-web', 'port']);
|
|
314
|
+
found.set(name, typeof port === 'number' ? port : (DEFAULT_PORTS[name] ?? 0));
|
|
315
|
+
}
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
/** `dokebi deploy init` (D-0184 §2). */
|
|
319
|
+
export async function deployInit(input, options) {
|
|
320
|
+
const root = await realpath(input);
|
|
321
|
+
const state = await projectState(root);
|
|
322
|
+
const app = await appName(root);
|
|
323
|
+
const plugins = state.plugins.map((plugin) => optionsKey(plugin));
|
|
324
|
+
const names = [...new Set(options.instances)];
|
|
325
|
+
for (const name of names)
|
|
326
|
+
if (!INSTANCE.test(name))
|
|
327
|
+
throw new CliError('USAGE', `--instance ${name}: lowercase letters, digits and -, 1 to 24 characters, starting with a letter`, {
|
|
328
|
+
phase: 'arguments',
|
|
329
|
+
});
|
|
330
|
+
if (options.port !== undefined && names.length !== 1)
|
|
331
|
+
throw new CliError('USAGE', '--port names the port of one instance; give one --instance with it', {
|
|
332
|
+
phase: 'arguments',
|
|
333
|
+
});
|
|
334
|
+
const known = await knownInstances(root);
|
|
335
|
+
const ports = new Map();
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
const port = options.port ?? known.get(name) ?? DEFAULT_PORTS[name];
|
|
338
|
+
if (port === undefined || port === 0)
|
|
339
|
+
throw new CliError('PORT_REQUIRED', `The instance ${name} needs a port: --port <n>`, {
|
|
340
|
+
phase: 'arguments',
|
|
341
|
+
details: {
|
|
342
|
+
instance: name,
|
|
343
|
+
fix: `npx dokebi deploy init --instance ${name} --port <a free port such as 3002>`,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
ports.set(name, port);
|
|
347
|
+
}
|
|
348
|
+
// Instances on one server never share a port.
|
|
349
|
+
for (const [name, port] of [...known, ...ports]) {
|
|
350
|
+
const other = [...known, ...ports].find(([otherName, otherPort]) => otherName !== name && otherPort === port);
|
|
351
|
+
if (other)
|
|
352
|
+
throw new CliError('PORT_IN_USE', `The instances ${name} and ${other[0]} would both listen on 127.0.0.1:${port}`, {
|
|
353
|
+
phase: 'arguments',
|
|
354
|
+
details: {
|
|
355
|
+
port,
|
|
356
|
+
instances: [name, other[0]],
|
|
357
|
+
fix: `give one of them another port: --instance ${name} --port <n>`,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const sample = await readOptional(await securePath(root, EXAMPLE));
|
|
362
|
+
if (sample === undefined)
|
|
363
|
+
throw new CliError('EXAMPLE_MISSING', `No ${EXAMPLE} to make the instance settings from`, {
|
|
364
|
+
phase: 'arguments',
|
|
365
|
+
details: {
|
|
366
|
+
file: EXAMPLE,
|
|
367
|
+
fix: `restore ${EXAMPLE} from version control (the CLI writes it and it belongs there)`,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
const { paths: secrets, unreadable } = await secretPaths(root, sample);
|
|
371
|
+
debug('Deployment secrets found', { secrets });
|
|
372
|
+
const schemas = await reloadSchemas(root, state.plugins, state.settings ?? []);
|
|
373
|
+
const development = await readOptional(join(root, CONFIG_FILE));
|
|
374
|
+
const sources = [
|
|
375
|
+
...(development === undefined ? [] : [parseConfig(development).options]),
|
|
376
|
+
parseConfig(sample).options,
|
|
377
|
+
];
|
|
378
|
+
const all = [...new Set([...known.keys(), ...names])].sort((a, b) => a === 'production' ? -1 : b === 'production' ? 1 : a.localeCompare(b));
|
|
379
|
+
const layouts = all.map((name) => layout(app, name, ports.get(name) ?? known.get(name) ?? 0));
|
|
380
|
+
const files = [];
|
|
381
|
+
const add = (path, body) => files.push({ path, content: withMarker(path, body) });
|
|
382
|
+
for (const at of layouts.filter((item) => names.includes(item.instance))) {
|
|
383
|
+
add(`deploy/${at.instance}/${EXAMPLE}`, instanceSample(sample, at, options.target, plugins, secrets, schemas));
|
|
384
|
+
add(`deploy/${at.instance}/secrets.example.yaml`, secretsSample(at, secrets, sources, schemas));
|
|
385
|
+
if (options.target === 'systemd') {
|
|
386
|
+
add(`deploy/systemd/${at.unit}.service`, systemdUnit(at));
|
|
387
|
+
if (plugins.includes('dokebi-db')) {
|
|
388
|
+
add(`deploy/systemd/${at.unit}-backup.service`, backupService(at));
|
|
389
|
+
add(`deploy/systemd/${at.unit}-backup.timer`, backupTimer(at));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else
|
|
393
|
+
add(`deploy/compose/${at.instance}.yaml`, composeFile(at));
|
|
394
|
+
}
|
|
395
|
+
if (plugins.includes('dokebi-db')) {
|
|
396
|
+
add('deploy/backup.mjs', BACKUP_SCRIPT);
|
|
397
|
+
add('deploy/restore-drill.mjs', RESTORE_DRILL_SCRIPT);
|
|
398
|
+
}
|
|
399
|
+
if (options.target === 'compose')
|
|
400
|
+
for (const name of ['Dockerfile', '.dockerignore'])
|
|
401
|
+
if (!(await exists(join(root, name))))
|
|
402
|
+
files.push({ path: name, content: DEPLOYMENT_FILES[name] });
|
|
403
|
+
add('DEPLOY.md', deployGuide({ app, target: options.target, instances: layouts, plugins, secrets }));
|
|
404
|
+
const written = await writeFiles(root, files, options.dryRun === true);
|
|
405
|
+
written.warnings.push(...unreadableWarning(unreadable));
|
|
406
|
+
debug('Deployment files planned', {
|
|
407
|
+
target: options.target,
|
|
408
|
+
instances: names,
|
|
409
|
+
changed: written.changed.length,
|
|
410
|
+
kept: written.kept.length,
|
|
411
|
+
secrets: secrets.length,
|
|
412
|
+
dryRun: options.dryRun === true,
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
app,
|
|
416
|
+
target: options.target,
|
|
417
|
+
instances: layouts.filter((item) => names.includes(item.instance)),
|
|
418
|
+
secrets,
|
|
419
|
+
changed: written.changed,
|
|
420
|
+
unchanged: written.unchanged,
|
|
421
|
+
kept: written.kept,
|
|
422
|
+
...(written.warnings.length ? { warnings: written.warnings } : {}),
|
|
423
|
+
nextSteps: [
|
|
424
|
+
{
|
|
425
|
+
command: 'edit deploy/<instance>/dokebi.example.yaml',
|
|
426
|
+
why: 'fill its placeholders and dokebi-app.domains; it is the settings file of the instance on the server (no secret goes in it)',
|
|
427
|
+
},
|
|
428
|
+
...(secrets.length
|
|
429
|
+
? [
|
|
430
|
+
{
|
|
431
|
+
command: 'npx dokebi config secrets --json',
|
|
432
|
+
why: `put ${secrets.join(', ')} into your secret store; the terminal writes them to the instance's secrets.yaml (mode 0600)`,
|
|
433
|
+
},
|
|
434
|
+
]
|
|
435
|
+
: []),
|
|
436
|
+
...(plugins.includes('dokebi-web')
|
|
437
|
+
? [
|
|
438
|
+
{
|
|
439
|
+
command: `npx dokebi deploy proxy --caddy --domain <host> --instance ${names[0]}`,
|
|
440
|
+
why: 'the reverse proxy configuration of the instance (or --nginx)',
|
|
441
|
+
},
|
|
442
|
+
]
|
|
443
|
+
: []),
|
|
444
|
+
{
|
|
445
|
+
command: 'read DEPLOY.md',
|
|
446
|
+
why: 'server setup, the release order with migrations, rollback, backups',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
|
|
452
|
+
/** `dokebi deploy proxy` (D-0184 §3). */
|
|
453
|
+
export async function deployProxy(input, options) {
|
|
454
|
+
const root = await realpath(input);
|
|
455
|
+
const state = await projectState(root);
|
|
456
|
+
const app = await appName(root);
|
|
457
|
+
const plugins = state.plugins.map((plugin) => optionsKey(plugin));
|
|
458
|
+
const domains = [...new Set(options.domains.map((domain) => domain.toLowerCase()))];
|
|
459
|
+
for (const domain of domains)
|
|
460
|
+
if (!DOMAIN.test(domain))
|
|
461
|
+
throw new CliError('USAGE', `--domain ${domain}: a host name such as shop.example.com (no scheme, port or path)`, {
|
|
462
|
+
phase: 'arguments',
|
|
463
|
+
});
|
|
464
|
+
if (!plugins.includes('dokebi-web'))
|
|
465
|
+
throw new CliError('WEB_NOT_INSTALLED', 'This project serves no HTTP (no @dokebi/web), so it needs no reverse proxy', {
|
|
466
|
+
phase: 'arguments',
|
|
467
|
+
details: { fix: 'npx dokebi add @dokebi/web, or leave the proxy out' },
|
|
468
|
+
});
|
|
469
|
+
const samplePath = `deploy/${options.instance}/${EXAMPLE}`;
|
|
470
|
+
const sample = await readOptional(await securePath(root, samplePath));
|
|
471
|
+
if (sample === undefined)
|
|
472
|
+
throw new CliError('INSTANCE_UNKNOWN', `No ${samplePath}: deploy init has not made the instance ${options.instance}`, {
|
|
473
|
+
phase: 'arguments',
|
|
474
|
+
details: { fix: `npx dokebi deploy init --instance ${options.instance}` },
|
|
475
|
+
});
|
|
476
|
+
const settings = parseConfig(sample).options;
|
|
477
|
+
const web = settings['dokebi-web'] ?? {};
|
|
478
|
+
const port = typeof web.port === 'number' && web.port > 0 ? web.port : undefined;
|
|
479
|
+
if (port === undefined)
|
|
480
|
+
throw new CliError('PORT_REQUIRED', `${samplePath} sets no dokebi-web.port`, {
|
|
481
|
+
phase: 'arguments',
|
|
482
|
+
details: { fix: `set dokebi-web.port in ${samplePath} (deploy init writes it)` },
|
|
483
|
+
});
|
|
484
|
+
const at = layout(app, options.instance, port);
|
|
485
|
+
const routes = (web.healthRoutes ?? {});
|
|
486
|
+
const live = typeof routes.live === 'string' ? routes.live : '/health/live';
|
|
487
|
+
const ready = typeof routes.ready === 'string' ? routes.ready : '/health/ready';
|
|
488
|
+
const storage = settings['dokebi-storage'] ?? {};
|
|
489
|
+
const maxBodyBytes = Math.max(typeof web.maxBodyBytes === 'number' ? web.maxBodyBytes : 1_048_576, plugins.includes('dokebi-storage')
|
|
490
|
+
? typeof storage.maxFileBytes === 'number'
|
|
491
|
+
? storage.maxFileBytes
|
|
492
|
+
: 10_485_760
|
|
493
|
+
: 0);
|
|
494
|
+
const streams = ['dokebi-sse', 'dokebi-websocket', 'dokebi-websocket-hub', 'dokebi-mcp'].some((id) => plugins.includes(id));
|
|
495
|
+
const requestSeconds = Math.ceil((typeof web.requestTimeoutMs === 'number' ? web.requestTimeoutMs : 30_000) / 1000) +
|
|
496
|
+
5;
|
|
497
|
+
const proxyInput = {
|
|
498
|
+
at,
|
|
499
|
+
domains,
|
|
500
|
+
live,
|
|
501
|
+
ready,
|
|
502
|
+
maxBodyBytes,
|
|
503
|
+
readTimeoutSeconds: streams ? Math.max(3600, requestSeconds) : requestSeconds,
|
|
504
|
+
streams,
|
|
505
|
+
};
|
|
506
|
+
const file = options.proxy === 'caddy'
|
|
507
|
+
? `deploy/proxy/${at.unit}.Caddyfile`
|
|
508
|
+
: `deploy/proxy/${at.unit}.nginx.conf`;
|
|
509
|
+
const body = options.proxy === 'caddy' ? caddyfile(proxyInput) : nginxConf(proxyInput);
|
|
510
|
+
const warnings = [];
|
|
511
|
+
const trusted = Array.isArray(web.trustProxy) ? web.trustProxy : [];
|
|
512
|
+
if (!trusted.includes('127.0.0.1') || !trusted.includes('::1'))
|
|
513
|
+
warnings.push({
|
|
514
|
+
code: 'TRUST_PROXY_MISSING',
|
|
515
|
+
path: `${samplePath}: dokebi-web.trustProxy`,
|
|
516
|
+
hint: 'the proxy connects from 127.0.0.1 or ::1, which dokebi-web.trustProxy does not list: the app would ignore X-Forwarded-Proto and -Host (http URLs, insecure cookies). Add both',
|
|
517
|
+
});
|
|
518
|
+
const appDomains = settings['dokebi-app']?.domains;
|
|
519
|
+
if (plugins.includes('dokebi-app')) {
|
|
520
|
+
const mapped = appDomains && typeof appDomains === 'object' ? Object.keys(appDomains) : [];
|
|
521
|
+
for (const domain of domains)
|
|
522
|
+
if (!mapped.includes(domain))
|
|
523
|
+
warnings.push({
|
|
524
|
+
code: 'DOMAIN_NOT_MAPPED',
|
|
525
|
+
path: `${samplePath}: dokebi-app.domains`,
|
|
526
|
+
hint: `${domain} is not in dokebi-app.domains of the instance, so the app answers it as an unknown domain: add it (the same app as its development host)`,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const written = await writeFiles(root, [{ path: file, content: withMarker(file, body) }], options.dryRun === true);
|
|
530
|
+
debug('Proxy configuration planned', {
|
|
531
|
+
proxy: options.proxy,
|
|
532
|
+
instance: options.instance,
|
|
533
|
+
domains: domains.length,
|
|
534
|
+
streams,
|
|
535
|
+
warnings: warnings.length,
|
|
536
|
+
});
|
|
537
|
+
const all = [...written.warnings, ...warnings];
|
|
538
|
+
return {
|
|
539
|
+
app,
|
|
540
|
+
proxy: options.proxy,
|
|
541
|
+
instance: options.instance,
|
|
542
|
+
file,
|
|
543
|
+
domains,
|
|
544
|
+
changed: written.changed,
|
|
545
|
+
unchanged: written.unchanged,
|
|
546
|
+
kept: written.kept,
|
|
547
|
+
...(all.length ? { warnings: all } : {}),
|
|
548
|
+
nextSteps: [
|
|
549
|
+
options.proxy === 'caddy'
|
|
550
|
+
? {
|
|
551
|
+
command: `sudo cp ${file} /etc/caddy/sites/${at.unit}.Caddyfile && sudo systemctl reload caddy`,
|
|
552
|
+
why: 'import /etc/caddy/sites/* from the main Caddyfile',
|
|
553
|
+
}
|
|
554
|
+
: {
|
|
555
|
+
command: `sudo cp ${file} /etc/nginx/conf.d/${at.unit}.conf && sudo nginx -t && sudo systemctl reload nginx`,
|
|
556
|
+
why: 'after certbot made the certificate of the first domain',
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
command: `npx -y @dokebi/cli verify --remote https://${domains[0]} --json`,
|
|
560
|
+
why: 'checks TLS, the redirect, health and security headers through the proxy',
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
};
|
|
564
|
+
}
|
package/dist/output.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.4.
|
|
1
|
+
export declare const CLI_VERSION = "0.4.28";
|
|
2
2
|
export declare function envelope(command: string, result: unknown, ok?: boolean): object;
|
|
3
3
|
export declare function failure(command: string, error: unknown, mutationStarted: boolean): object;
|
package/dist/output.js
CHANGED