influx-local-cli 0.1.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.
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ // InfluxDB ships three incompatible product lines from one GitHub repository.
4
+ // An instance pins exactly one version; the "flavor" (v1/v2/v3) is derived from
5
+ // that version's major number and decides which binaries, daemon flags, auth
6
+ // model, storage layout and credentials this tool manages.
7
+ //
8
+ // Tarballs come from dl.influxdata.com: InfluxData publishes no GitHub release
9
+ // assets for these tags, so mise's github/ubi backends cannot install influxd.
10
+ // Instead every instance carries its own .mise.toml declaring a custom `http:`
11
+ // tool with the exact URL for its pinned version (see `miseTomlFor`).
12
+
13
+ const RELEASES = 'https://dl.influxdata.com/influxdb/releases';
14
+
15
+ // mise's placeholders are {{ version }}-style; we substitute the platform tag
16
+ // ourselves so the generated file stays readable.
17
+ const PLATFORMS = {
18
+ 'linux-x64': 'linux_amd64',
19
+ 'linux-arm64': 'linux_arm64',
20
+ 'darwin-x64': 'darwin_amd64',
21
+ 'darwin-arm64': 'darwin_arm64',
22
+ };
23
+
24
+ function platformTag(platform = process.platform, arch = process.arch) {
25
+ const tag = PLATFORMS[`${platform}-${arch}`];
26
+ if (!tag) {
27
+ throw new Error(
28
+ `unsupported platform ${platform}-${arch}: InfluxDB tarballs are published for linux/darwin on x64/arm64 only`,
29
+ );
30
+ }
31
+ return tag;
32
+ }
33
+
34
+ // Version catalogs: every entry below was verified to exist on
35
+ // dl.influxdata.com (HTTP 206/200). Tags exist in the git repo that have no
36
+ // artifact (e.g. 1.12.4, 2.7.12) — hence a curated list instead of "all tags".
37
+ const FLAVORS = {
38
+ v1: {
39
+ id: 'v1',
40
+ label: 'InfluxDB 1.x (InfluxQL)',
41
+ auth: 'basic',
42
+ defaultPort: 8086,
43
+ serverBin: 'influxd',
44
+ cliBin: 'influx',
45
+ // v1 is the closest match to the classic "admin user + app user with a
46
+ // database-scoped grant + a shell" model.
47
+ capabilities: { users: true, userPasswords: true, rotatePassword: true, shell: true, measurement: true },
48
+ versions: ['1.7.11', '1.8.0', '1.8.4', '1.8.6', '1.8.9', '1.8.10'],
49
+ defaultVersion: '1.8.10',
50
+ artifacts: (version) => [
51
+ {
52
+ tool: 'influxdb-1x',
53
+ exe: ['influxd', 'influx'],
54
+ url: `${RELEASES}/influxdb-${version}_${platformTag()}.tar.gz`,
55
+ stripComponents: 1,
56
+ binPath: 'usr/bin',
57
+ },
58
+ ],
59
+ },
60
+ v2: {
61
+ id: 'v2',
62
+ label: 'InfluxDB 2.x (Flux)',
63
+ auth: 'token',
64
+ defaultPort: 8086,
65
+ serverBin: 'influxd',
66
+ cliBin: 'influx',
67
+ capabilities: { users: true, userPasswords: true, rotatePassword: true, shell: false, measurement: false },
68
+ versions: ['2.7.3', '2.7.5', '2.7.7', '2.7.9', '2.7.10', '2.7.11', '2.8.0', '2.9.0', '2.9.1'],
69
+ defaultVersion: '2.7.11',
70
+ // The server tarball ships no CLI, so the client is a second artifact.
71
+ clientVersion: '2.7.5',
72
+ artifacts: (version) => [
73
+ {
74
+ tool: 'influxdb-2x',
75
+ exe: ['influxd'],
76
+ url: `${RELEASES}/influxdb2-${version}_${platformTag()}.tar.gz`,
77
+ stripComponents: 1,
78
+ binPath: 'usr/bin',
79
+ },
80
+ {
81
+ tool: 'influxdb-2x-cli',
82
+ exe: ['influx'],
83
+ artifactVersion: clientVersionFor(version),
84
+ url: `${RELEASES}/influxdb2-client-${clientVersionFor(version)}-${platformTag().replace('_', '-')}.tar.gz`,
85
+ stripComponents: 1,
86
+ binPath: '.',
87
+ },
88
+ ],
89
+ },
90
+ v3: {
91
+ id: 'v3',
92
+ label: 'InfluxDB 3 Core (SQL)',
93
+ auth: 'token',
94
+ defaultPort: 8181,
95
+ serverBin: 'influxdb3',
96
+ cliBin: 'influxdb3',
97
+ // 3 Core has a single binary that is both server (`serve`) and client
98
+ // (`query`, `create`, `show`). Only an admin token exists — no users and no
99
+ // per-database token scoping — so user/token-scoping commands are refused.
100
+ capabilities: { users: false, userPasswords: false, rotatePassword: true, shell: false, measurement: true },
101
+ versions: [
102
+ '3.0.0', '3.0.3', '3.3.0', '3.4.0', '3.9.0',
103
+ '3.10.0', '3.10.6', '3.11.2', '3.11.3', '3.11.4',
104
+ ],
105
+ defaultVersion: '3.11.4',
106
+ artifacts: (version) => [
107
+ {
108
+ tool: 'influxdb-3x',
109
+ exe: ['influxdb3'],
110
+ url: `${RELEASES}/influxdb3-core-${version}_${platformTag()}.tar.gz`,
111
+ stripComponents: 1,
112
+ binPath: '.',
113
+ },
114
+ ],
115
+ },
116
+ };
117
+
118
+ const FLAVOR_ORDER = ['v1', 'v2', 'v3'];
119
+ const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
120
+
121
+ // The client CLI tracks the server line, not its exact patch: the client
122
+ // catalog is much smaller than the server catalog.
123
+ function clientVersionFor(version) {
124
+ const major = String(version).split('.')[0];
125
+ if (major !== '2') return FLAVORS.v2.clientVersion;
126
+ const minor = parseInt(String(version).split('.')[1], 10);
127
+ if (minor <= 6) return '2.6.1';
128
+ if (minor === 7 && parseInt(String(version).split('.')[2], 10) <= 3) return '2.7.3';
129
+ return '2.7.5';
130
+ }
131
+
132
+ function flavorIdForVersion(version) {
133
+ const m = VERSION_PATTERN.exec(String(version || '').trim());
134
+ if (!m) {
135
+ throw new Error(
136
+ `invalid InfluxDB version "${version}" — expected a full version such as 1.8.10, 2.7.11 or 3.11.4 ` +
137
+ '(run "influx-local versions" for the list of published versions)',
138
+ );
139
+ }
140
+ const id = `v${m[1]}`;
141
+ if (!FLAVORS[id]) {
142
+ throw new Error(
143
+ `unsupported InfluxDB major version "${m[1]}" — this tool manages the 1.x, 2.x and 3.x lines (got "${version}")`,
144
+ );
145
+ }
146
+ return id;
147
+ }
148
+
149
+ function flavor(id) {
150
+ const f = FLAVORS[id];
151
+ if (!f) throw new Error(`unknown InfluxDB flavor "${id}"`);
152
+ return f;
153
+ }
154
+
155
+ function flavorOf(version) {
156
+ return flavor(flavorIdForVersion(version));
157
+ }
158
+
159
+ function isKnownVersion(version) {
160
+ let id;
161
+ try {
162
+ id = flavorIdForVersion(version);
163
+ } catch (err) {
164
+ return false;
165
+ }
166
+ return FLAVORS[id].versions.includes(String(version));
167
+ }
168
+
169
+ function artifactsFor(version) {
170
+ return flavorOf(version).artifacts(String(version));
171
+ }
172
+
173
+ // Accept a full version (1.8.10), a line shorthand (1.x/2.x/3.x) or a flavor id.
174
+ function resolveVersionInput(input) {
175
+ const raw = String(input == null ? '' : input).trim();
176
+ if (!raw) throw new Error('a version is required (run "influx-local versions" for the catalog)');
177
+ const line = /^(\d+)\.x$/i.exec(raw);
178
+ if (line) {
179
+ const f = FLAVORS[`v${line[1]}`];
180
+ if (!f) throw new Error(`unknown InfluxDB line "${raw}" — use 1.x, 2.x or 3.x`);
181
+ return f.defaultVersion;
182
+ }
183
+ flavorOf(raw); // validates the major and the X.Y.Z shape
184
+ return raw;
185
+ }
186
+
187
+ // Every version this tool knows how to install, grouped by line.
188
+ function versionCatalog() {
189
+ const out = [];
190
+ for (const id of FLAVOR_ORDER) {
191
+ const f = FLAVORS[id];
192
+ for (const version of f.versions) {
193
+ out.push({
194
+ version,
195
+ flavor: id,
196
+ label: f.label,
197
+ auth: f.auth,
198
+ defaultPort: f.defaultPort,
199
+ default: version === f.defaultVersion,
200
+ });
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
206
+ // The .mise.toml an instance carries: pins the version and teaches mise how to
207
+ // fetch it (there is no registry entry to fall back on).
208
+ function miseTomlFor(version) {
209
+ const f = flavorOf(version);
210
+ const lines = [
211
+ `# Generated by influx-local — do not edit by hand.`,
212
+ `# Instance toolchain: ${f.label}, version ${version}.`,
213
+ `# InfluxData publishes server tarballs on dl.influxdata.com (GitHub release`,
214
+ `# assets are empty), so mise installs them through the generic http backend.`,
215
+ '',
216
+ '[tools]',
217
+ ];
218
+ for (const a of artifactsFor(version)) {
219
+ lines.push(
220
+ `"http:${a.tool}" = { version = "${version === version && a.tool.includes('cli') ? f.clientVersion : version}", ` +
221
+ `url = "${a.url}", strip_components = "${a.stripComponents}", bin_path = "${a.binPath}" }`,
222
+ );
223
+ }
224
+ lines.push('');
225
+ return lines.join('\n');
226
+ }
227
+
228
+ // Credentials an instance keeps in creds.env, per auth model.
229
+ // v1: two InfluxQL users (admin = ALL PRIVILEGES, app = ALL on the database)
230
+ // v2: an operator user + all-access token, plus a bucket-scoped app token
231
+ // (APP_TOKEN is minted by the server during onboarding, so it is not part
232
+ // of the required set — `create` writes the file before setup runs)
233
+ // v3: an admin token only (3 Core has no per-database scoping)
234
+ const CRED_FIELDS = {
235
+ v1: ['ADMIN_USER', 'ADMIN_PW', 'APP_USER', 'APP_PW'],
236
+ v2: ['ADMIN_USER', 'ADMIN_PW', 'ADMIN_TOKEN', 'ORG', 'BUCKET'],
237
+ v3: ['ADMIN_TOKEN'],
238
+ };
239
+
240
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
241
+
242
+ function assertIdentifier(value, what) {
243
+ if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
244
+ throw new Error(
245
+ `invalid ${what} "${value}" — 1-64 chars: letters, digits, dots, dashes or underscores; must start with a letter or digit`,
246
+ );
247
+ }
248
+ return value;
249
+ }
250
+
251
+ module.exports = {
252
+ RELEASES,
253
+ PLATFORMS,
254
+ platformTag,
255
+ FLAVORS,
256
+ FLAVOR_ORDER,
257
+ VERSION_PATTERN,
258
+ NAME_PATTERN,
259
+ flavor,
260
+ flavorOf,
261
+ flavorIdForVersion,
262
+ isKnownVersion,
263
+ artifactsFor,
264
+ resolveVersionInput,
265
+ versionCatalog,
266
+ miseTomlFor,
267
+ clientVersionFor,
268
+ CRED_FIELDS,
269
+ assertIdentifier,
270
+ };
package/src/web.js ADDED
@@ -0,0 +1,387 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const express = require('express');
6
+ const manager = require('./manager');
7
+ const configMod = require('./config');
8
+ const { versionCatalog, resolveVersionInput, flavorOf, FLAVORS } = require('./versions');
9
+ const {
10
+ loadConfig,
11
+ saveConfig,
12
+ listInstances,
13
+ assertValidInstanceName,
14
+ configExists,
15
+ readCreds,
16
+ SETTABLE_KEYS,
17
+ } = configMod;
18
+
19
+ function maskSecrets(uri) {
20
+ let out = String(uri).replace(/\/\/([^:@/]+):([^@/]+)@/, '//$1:•••••••@');
21
+ out = out.replace(/\/\/([^:@/]+)@/, '//•••••••@');
22
+ return out;
23
+ }
24
+
25
+ // Single-slot operation queue: only one op runs at a time (the same server must
26
+ // not be poked concurrently). Polled by the dashboard.
27
+ let currentOp = null;
28
+ let opSeq = 0;
29
+
30
+ async function runOp(kind, instance, fn) {
31
+ const id = `op-${++opSeq}`;
32
+ currentOp = { id, instance, kind, lines: [], running: true, error: null, startedAt: new Date().toISOString() };
33
+ const emit = (line) => {
34
+ currentOp.lines.push(String(line));
35
+ if (currentOp.lines.length > 2000) currentOp.lines.splice(0, currentOp.lines.length - 2000);
36
+ };
37
+ try {
38
+ const result = await fn(emit);
39
+ currentOp.result = result;
40
+ } catch (err) {
41
+ const msg = err && err.message ? err.message : String(err);
42
+ currentOp.error = msg;
43
+ // Failed ops stay visible in /api/state (rendered by the UI).
44
+ } finally {
45
+ currentOp.running = false;
46
+ currentOp.finishedAt = new Date().toISOString();
47
+ }
48
+ return currentOp;
49
+ }
50
+
51
+ // Validate + apply a config patch (shared by the bulk and single-key routes).
52
+ async function applyConfigPatch(name, patch) {
53
+ const cfg = await loadConfig(name);
54
+ for (const [key, rawValue] of Object.entries(patch)) {
55
+ const value = rawValue == null ? '' : String(rawValue);
56
+ if (key === 'port') {
57
+ const port = parseInt(value, 10);
58
+ if (Number.isNaN(port) || port <= 0 || port > 65535) throw new Error('port must be 1-65535');
59
+ cfg.port = port;
60
+ } else if (key === 'version') {
61
+ const next = resolveVersionInput(value);
62
+ const nextFlavor = flavorOf(next);
63
+ if (nextFlavor.id !== cfg.flavor && (await manager.instanceHasData(cfg))) {
64
+ throw new Error(
65
+ `refusing to switch "${name}" from ${cfg.flavorLabel} to ${nextFlavor.label}: InfluxDB stores data in a line-specific format. Create a new instance instead.`,
66
+ );
67
+ }
68
+ cfg.version = next;
69
+ } else {
70
+ if (value === '' && key !== 'publicHost') throw new Error(`value for "${key}" must not be empty`);
71
+ cfg[key] = value;
72
+ }
73
+ }
74
+ await saveConfig(name, cfg);
75
+ if ('version' in patch) await configMod.saveMiseToml(name, cfg.version);
76
+ const updated = {};
77
+ for (const key of SETTABLE_KEYS) updated[key] = cfg[key];
78
+ return updated;
79
+ }
80
+
81
+ function startWeb({ host = '127.0.0.1', port = 8788, token = '' } = {}) {
82
+ return new Promise((resolve, reject) => {
83
+ const app = express();
84
+ app.use(express.json());
85
+
86
+ // Optional bearer-token protection for the API.
87
+ app.use('/api', (req, res, next) => {
88
+ if (!token) return next();
89
+ const provided = req.get('x-influx-local-token') || '';
90
+ const a = Buffer.from(String(provided));
91
+ const b = Buffer.from(String(token));
92
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
93
+ return res.status(401).json({ error: 'invalid or missing token (set it with --token)' });
94
+ }
95
+ next();
96
+ });
97
+
98
+ const sendErr = (res, status, message) => res.status(status).json({ error: message });
99
+
100
+ // --- API -----------------------------------------------------------------
101
+ app.get('/api/state', async (req, res) => {
102
+ try {
103
+ const statuses = await manager.listStatuses();
104
+ const op = currentOp
105
+ ? {
106
+ id: currentOp.id,
107
+ instance: currentOp.instance,
108
+ kind: currentOp.kind,
109
+ running: currentOp.running,
110
+ error: currentOp.error,
111
+ startedAt: currentOp.startedAt,
112
+ finishedAt: currentOp.finishedAt || null,
113
+ lines: currentOp.lines,
114
+ result: currentOp.result || null,
115
+ }
116
+ : null;
117
+ res.json({
118
+ instances: statuses,
119
+ op,
120
+ tokenRequired: !!token,
121
+ baseDir: configMod.BASE_DIR,
122
+ });
123
+ } catch (err) {
124
+ sendErr(res, 500, err.message);
125
+ }
126
+ });
127
+
128
+ // Catalog for the "new instance" version picker.
129
+ app.get('/api/versions', (req, res) => {
130
+ res.json({
131
+ versions: versionCatalog(),
132
+ flavors: Object.values(FLAVORS).map((f) => ({
133
+ id: f.id,
134
+ label: f.label,
135
+ auth: f.auth,
136
+ defaultPort: f.defaultPort,
137
+ defaultVersion: f.defaultVersion,
138
+ capabilities: f.capabilities,
139
+ })),
140
+ });
141
+ });
142
+
143
+ app.get('/api/instances/:name', async (req, res) => {
144
+ try {
145
+ assertValidInstanceName(req.params.name);
146
+ if (!(await configExists(req.params.name))) return sendErr(res, 404, 'instance not found');
147
+ const status = await manager.getStatus(req.params.name);
148
+ const creds = await readCreds(req.params.name, status.flavor);
149
+ const cfg = await loadConfig(req.params.name);
150
+ const { config, uris, ...plain } = status;
151
+ if (uris) {
152
+ plain.uris = {};
153
+ for (const key of Object.keys(uris)) plain.uris[key] = maskSecrets(uris[key]);
154
+ plain.uris.masked = true;
155
+ }
156
+ plain.credsPresent = !!creds;
157
+ plain.credsFields = creds ? Object.keys(creds).filter((k) => !k.endsWith('_PW')) : [];
158
+ const settable = {};
159
+ for (const key of SETTABLE_KEYS) settable[key] = cfg[key];
160
+ plain.config = settable;
161
+ plain.capabilities = cfg.capabilities;
162
+ plain.dataDir = cfg.dataDir;
163
+ plain.miseToml = cfg.miseTomlPath;
164
+ res.json(plain);
165
+ } catch (err) {
166
+ sendErr(res, 400, err.message);
167
+ }
168
+ });
169
+
170
+ app.put('/api/instances/:name/config', async (req, res) => {
171
+ try {
172
+ assertValidInstanceName(req.params.name);
173
+ if (!(await configExists(req.params.name))) return sendErr(res, 404, 'instance not found');
174
+ const body = req.body || {};
175
+
176
+ // Bulk update: body = { key: value, ... } for any settable keys.
177
+ if (!('key' in body)) {
178
+ const keys = Object.keys(body).filter((k) => SETTABLE_KEYS.includes(k));
179
+ if (keys.length === 0) {
180
+ return sendErr(res, 400, `no valid keys supplied; valid keys: ${SETTABLE_KEYS.join(', ')}`);
181
+ }
182
+ const patch = {};
183
+ for (const key of keys) patch[key] = body[key];
184
+ const updated = await applyConfigPatch(req.params.name, patch);
185
+ return res.json({ ok: true, updated, restartNote: true });
186
+ }
187
+
188
+ // Single-key update (fine-grained): body = { key, value }.
189
+ const { key, value } = body;
190
+ if (!SETTABLE_KEYS.includes(key)) {
191
+ return sendErr(res, 400, `invalid key "${key}"; valid: ${SETTABLE_KEYS.join(', ')}`);
192
+ }
193
+ const updated = await applyConfigPatch(req.params.name, { [key]: value });
194
+ res.json({ ok: true, key, value: updated[key], restartNote: true });
195
+ } catch (err) {
196
+ sendErr(res, 400, err.message);
197
+ }
198
+ });
199
+
200
+ app.post('/api/instances/:name/rename', async (req, res) => {
201
+ try {
202
+ if (currentOp && currentOp.running) return sendErr(res, 409, 'another operation is running');
203
+ const src = assertValidInstanceName(req.params.name);
204
+ const newName = assertValidInstanceName((req.body || {}).newName);
205
+ if (!(await configExists(src))) return sendErr(res, 404, 'instance not found');
206
+ const result = await manager.renameInstance(src, newName);
207
+ res.json({ ok: true, name: result });
208
+ } catch (err) {
209
+ sendErr(res, 400, err.message);
210
+ }
211
+ });
212
+
213
+ app.post('/api/instances/:name/clone', async (req, res) => {
214
+ try {
215
+ if (currentOp && currentOp.running) return sendErr(res, 409, 'another operation is running');
216
+ const src = assertValidInstanceName(req.params.name);
217
+ const body = req.body || {};
218
+ const newName = assertValidInstanceName(body.name);
219
+ if (!(await configExists(src))) return sendErr(res, 404, 'instance not found');
220
+ let port;
221
+ if (body.port !== undefined && body.port !== '') {
222
+ port = parseInt(body.port, 10);
223
+ if (Number.isNaN(port) || port <= 0 || port > 65535) return sendErr(res, 400, 'port must be 1-65535');
224
+ }
225
+ const result = await manager.cloneInstance(src, newName, { port });
226
+ res.status(201).json({ ok: true, name: result.name, port: result.port });
227
+ } catch (err) {
228
+ sendErr(res, 400, err.message);
229
+ }
230
+ });
231
+
232
+ app.get('/api/instances/:name/users', async (req, res) => {
233
+ try {
234
+ const name = assertValidInstanceName(req.params.name);
235
+ if (!(await configExists(name))) return sendErr(res, 404, 'instance not found');
236
+ res.json(await manager.listUsers(name));
237
+ } catch (err) {
238
+ sendErr(res, 400, err.message);
239
+ }
240
+ });
241
+
242
+ app.get('/api/instances/:name/secrets', async (req, res) => {
243
+ try {
244
+ assertValidInstanceName(req.params.name);
245
+ if (!(await configExists(req.params.name))) return sendErr(res, 404, 'instance not found');
246
+ const cfg = await loadConfig(req.params.name);
247
+ const creds = await readCreds(req.params.name, cfg.flavor);
248
+ if (!creds) return sendErr(res, 404, 'no credentials yet — run setup');
249
+ const status = await manager.getStatus(req.params.name, { detail: false });
250
+ res.json({ flavor: cfg.flavor, creds, uris: status.uris || null });
251
+ } catch (err) {
252
+ sendErr(res, 400, err.message);
253
+ }
254
+ });
255
+
256
+ app.get('/api/instances/:name/logs', async (req, res) => {
257
+ try {
258
+ assertValidInstanceName(req.params.name);
259
+ const lines = Math.min(Math.max(parseInt(req.query.lines, 10) || 200, 1), 5000);
260
+ res.json(await manager.readLogTail(req.params.name, lines));
261
+ } catch (err) {
262
+ sendErr(res, 400, err.message);
263
+ }
264
+ });
265
+
266
+ app.post('/api/instances', async (req, res) => {
267
+ try {
268
+ const body = req.body || {};
269
+ const name = assertValidInstanceName(body.name);
270
+ if (await configExists(name)) return sendErr(res, 409, `instance "${name}" already exists`);
271
+
272
+ const version = resolveVersionInput(body.version === undefined || body.version === '' ? configMod.DEFAULT_VERSION : body.version);
273
+ const flavor = flavorOf(version);
274
+ const patch = { version, database: body.database || name };
275
+ if (body.port !== undefined && body.port !== '') {
276
+ const port = parseInt(body.port, 10);
277
+ if (Number.isNaN(port) || port <= 0 || port > 65535) return sendErr(res, 400, 'port must be 1-65535');
278
+ patch.port = port;
279
+ }
280
+ if (body.host) patch.host = body.host;
281
+ if (body.publicHost) patch.publicHost = body.publicHost;
282
+ if (body.measurement && flavor.capabilities.measurement) patch.measurement = body.measurement;
283
+ if (flavor.id === 'v2' && body.org) patch.org = body.org;
284
+ if (flavor.id !== 'v3') patch.adminUser = body.adminUser || 'admin';
285
+ if (flavor.id === 'v1') patch.appUser = body.appUser || 'app';
286
+
287
+ const cfg = await loadConfig(name);
288
+ Object.assign(cfg, patch);
289
+ await saveConfig(name, cfg);
290
+ await configMod.saveMiseToml(name, version);
291
+
292
+ const saved = await loadConfig(name);
293
+ if (saved.flavor === 'v1') {
294
+ await configMod.writeCreds(name, 'v1', {
295
+ ADMIN_USER: saved.adminUser,
296
+ ADMIN_PW: body.adminPassword ? String(body.adminPassword) : configMod.randomPassword(),
297
+ APP_USER: saved.appUser,
298
+ APP_PW: body.appPassword ? String(body.appPassword) : configMod.randomPassword(),
299
+ }, saved);
300
+ } else if (saved.flavor === 'v2') {
301
+ await configMod.writeCreds(name, 'v2', {
302
+ ADMIN_USER: saved.adminUser,
303
+ ADMIN_PW: body.adminPassword ? String(body.adminPassword) : configMod.randomPassword(),
304
+ ADMIN_TOKEN: configMod.randomToken(),
305
+ APP_TOKEN: '',
306
+ ORG: saved.org,
307
+ BUCKET: saved.database,
308
+ }, saved);
309
+ }
310
+ res.status(201).json({ ok: true, name, version, flavor: saved.flavor });
311
+ } catch (err) {
312
+ sendErr(res, 400, err.message);
313
+ }
314
+ });
315
+
316
+ app.delete('/api/instances/:name', async (req, res) => {
317
+ try {
318
+ const name = assertValidInstanceName(req.params.name);
319
+ if (currentOp && currentOp.running) return sendErr(res, 409, 'another operation is running');
320
+ if (!(await configExists(name))) return sendErr(res, 404, 'instance not found');
321
+ if ((req.query.confirm || '') !== name) {
322
+ return sendErr(res, 400, `confirm parameter must equal the instance name ("${name}")`);
323
+ }
324
+ const op = await runOp('destroy', name, async (log) => {
325
+ await manager.destroyInstance(name, { log });
326
+ return { destroyed: true };
327
+ });
328
+ res.status(202).json({ opId: op.id });
329
+ } catch (err) {
330
+ sendErr(res, 400, err.message);
331
+ }
332
+ });
333
+
334
+ const ACTIONS = {
335
+ setup: (name, o, log) => manager.setupInstance(name, { log }),
336
+ start: (name, o, log) => manager.startInstance(name, { log }),
337
+ stop: (name, o, log) => manager.stopInstance(name, { force: !!o.force, log }),
338
+ restart: (name, o, log) => manager.restartInstance(name, { force: !!o.force, log }),
339
+ rotate: (name, o, log) => manager.rotateInstance(name, { users: o.users || 'all', log }),
340
+ install: (name, o, log) => loadConfig(name).then((cfg) => manager.installTools(cfg, log)),
341
+ 'set-password': (name, o, log) =>
342
+ manager.setPassword(name, { user: o.user || 'admin', password: o.password, log }),
343
+ 'user-add': (name, o, log) =>
344
+ manager.addUser(name, { username: o.username, db: o.db, password: o.password, privileges: o.privileges, log }),
345
+ 'user-password': (name, o, log) =>
346
+ manager.setAnyPassword(name, { username: o.username, password: o.password, log }),
347
+ 'user-rm': (name, o, log) => manager.removeUser(name, { username: o.username, log }),
348
+ };
349
+
350
+ app.post('/api/actions', async (req, res) => {
351
+ try {
352
+ const body = req.body || {};
353
+ if (!body.instance || typeof body.instance !== 'string') {
354
+ return sendErr(res, 400, 'missing "instance" in the request — reload the page and retry');
355
+ }
356
+ const name = assertValidInstanceName(body.instance);
357
+ const action = ACTIONS[body.action];
358
+ if (!action) return sendErr(res, 400, `unknown action "${body.action}"; valid: ${Object.keys(ACTIONS).join(', ')}`);
359
+ if (currentOp && currentOp.running) return sendErr(res, 409, 'another operation is running');
360
+ if (!(await configExists(name))) return sendErr(res, 404, 'instance not found');
361
+ const opts = body.opts || {};
362
+ const op = await runOp(body.action, name, (log) => action(name, opts, log));
363
+ if (op.error) return res.status(200).json({ opId: op.id, error: op.error });
364
+ res.status(202).json({ opId: op.id });
365
+ } catch (err) {
366
+ sendErr(res, 400, err.message);
367
+ }
368
+ });
369
+
370
+ app.get('/api/health', (req, res) => res.json({ ok: true }));
371
+
372
+ // --- Static dashboard -----------------------------------------------------
373
+ app.use((req, res, next) => {
374
+ if (!req.path.startsWith('/api')) res.set('Cache-Control', 'no-store');
375
+ next();
376
+ });
377
+ app.use(express.static(path.join(__dirname, '..', 'web'), { index: 'index.html' }));
378
+
379
+ const server = app.listen(port, host, () => {
380
+ const actual = server.address().port;
381
+ resolve({ server, url: `http://${host}:${actual}`, port: actual, host });
382
+ });
383
+ server.on('error', (err) => reject(err));
384
+ });
385
+ }
386
+
387
+ module.exports = { startWeb };