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.
package/src/config.js ADDED
@@ -0,0 +1,349 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const {
8
+ FLAVORS,
9
+ flavorOf,
10
+ miseTomlFor,
11
+ CRED_FIELDS,
12
+ } = require('./versions');
13
+
14
+ // Overridable for tests: INFLUX_LOCAL_HOME points at an alternate base dir,
15
+ // INFLUX_LOCAL_MISE points at an alternate mise executable (or stub).
16
+ const BASE_DIR = process.env.INFLUX_LOCAL_HOME
17
+ ? path.resolve(process.env.INFLUX_LOCAL_HOME)
18
+ : path.join(os.homedir(), '.influx-local');
19
+
20
+ function miseBin() {
21
+ return process.env.INFLUX_LOCAL_MISE || 'mise';
22
+ }
23
+
24
+ function getInstanceDir(name) {
25
+ return path.join(BASE_DIR, 'instances', name);
26
+ }
27
+
28
+ function getConfigPath(name) {
29
+ return path.join(getInstanceDir(name), 'config.json');
30
+ }
31
+
32
+ function getCredsPath(name) {
33
+ return path.join(getInstanceDir(name), 'creds.env');
34
+ }
35
+
36
+ // Each instance carries its own mise config: it pins the exact InfluxDB version
37
+ // and teaches mise where to fetch that version's tarball from.
38
+ function getMiseTomlPath(name) {
39
+ return path.join(getInstanceDir(name), '.mise.toml');
40
+ }
41
+
42
+ // InfluxDB 2.x's CLI writes a client config (host/token/org shortcuts) on
43
+ // `influx setup`. Keep it inside the instance dir so the user's
44
+ // ~/.influxdbv2/configs is never touched.
45
+ function getCliConfigsPath(name) {
46
+ return path.join(getInstanceDir(name), 'influx-cli-configs');
47
+ }
48
+
49
+ function getPidPath(name, serverBin = 'influxd') {
50
+ return path.join(getInstanceDir(name), `${serverBin}.pid`);
51
+ }
52
+
53
+ function getLogPath(name, serverBin = 'influxd') {
54
+ return path.join(getInstanceDir(name), `${serverBin}.log`);
55
+ }
56
+
57
+ function getBootPidPath(name, serverBin = 'influxd') {
58
+ return path.join(getInstanceDir(name), `${serverBin}-bootstrap.pid`);
59
+ }
60
+
61
+ function getBootLogPath(name, serverBin = 'influxd') {
62
+ return path.join(getInstanceDir(name), `${serverBin}-bootstrap.log`);
63
+ }
64
+
65
+ // Config keys the user can persist / edit.
66
+ const SETTABLE_KEYS = [
67
+ 'version',
68
+ 'port',
69
+ 'host',
70
+ 'database',
71
+ 'measurement',
72
+ 'org',
73
+ 'adminUser',
74
+ 'appUser',
75
+ 'publicHost',
76
+ ];
77
+
78
+ // The default is the newest 1.x: that line maps 1:1 onto the
79
+ // pg-local-cli/mongo-local-cli model (admin user + database-scoped app user +
80
+ // a shell). Pick another version in the wizard for 2.x/3.x.
81
+ const DEFAULT_VERSION = FLAVORS.v1.defaultVersion;
82
+
83
+ const DEFAULT_CONFIG = {
84
+ version: DEFAULT_VERSION,
85
+ host: '127.0.0.1',
86
+ database: null, // falls back to the instance name
87
+ measurement: 'items',
88
+ org: null, // falls back to the instance name (2.x only)
89
+ adminUser: 'admin',
90
+ appUser: 'app',
91
+ publicHost: '',
92
+ };
93
+
94
+ // Instance names become directory names under ~/.influx-local. Restrict them to
95
+ // a safe charset so a crafted name cannot traverse out of the data dir.
96
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
97
+ const NAME_MAX_LENGTH = 64;
98
+
99
+ function assertValidInstanceName(name) {
100
+ if (
101
+ typeof name !== 'string' ||
102
+ name.length === 0 ||
103
+ name.length > NAME_MAX_LENGTH ||
104
+ !NAME_PATTERN.test(name)
105
+ ) {
106
+ throw new Error(
107
+ `Invalid instance name "${name}". Use 1-${NAME_MAX_LENGTH} characters: letters, digits, dots, dashes or underscores; must start with a letter or digit.`,
108
+ );
109
+ }
110
+ return name;
111
+ }
112
+
113
+ async function ensureBaseDir() {
114
+ await fs.mkdir(BASE_DIR, { recursive: true });
115
+ await fs.mkdir(path.join(BASE_DIR, 'instances'), { recursive: true });
116
+ }
117
+
118
+ async function loadConfig(name = 'default') {
119
+ assertValidInstanceName(name);
120
+ await ensureBaseDir();
121
+ const instanceDir = getInstanceDir(name);
122
+
123
+ let loaded = {};
124
+ try {
125
+ loaded = JSON.parse(await fs.readFile(getConfigPath(name), 'utf8'));
126
+ } catch (err) {
127
+ // Config doesn't exist yet: defaults are used below and persisted on save.
128
+ }
129
+
130
+ const version = loaded.version || DEFAULT_CONFIG.version;
131
+ const f = flavorOf(version); // throws with a clear message for bogus versions
132
+ const database = loaded.database || name;
133
+ return {
134
+ name,
135
+ version: String(version),
136
+ flavor: f.id,
137
+ flavorLabel: f.label,
138
+ auth: f.auth,
139
+ capabilities: f.capabilities,
140
+ serverBin: f.serverBin,
141
+ cliBin: f.cliBin,
142
+ port: loaded.port || f.defaultPort,
143
+ host: loaded.host || DEFAULT_CONFIG.host,
144
+ database,
145
+ measurement: loaded.measurement || DEFAULT_CONFIG.measurement,
146
+ org: loaded.org || DEFAULT_CONFIG.org || name,
147
+ adminUser: loaded.adminUser || DEFAULT_CONFIG.adminUser,
148
+ appUser: loaded.appUser || DEFAULT_CONFIG.appUser,
149
+ publicHost: loaded.publicHost || DEFAULT_CONFIG.publicHost,
150
+ instanceDir,
151
+ dataDir: loaded.dataDir || path.join(instanceDir, 'data'),
152
+ credsPath: getCredsPath(name),
153
+ miseTomlPath: getMiseTomlPath(name),
154
+ cliConfigsPath: getCliConfigsPath(name),
155
+ pidPath: getPidPath(name, f.serverBin),
156
+ logPath: getLogPath(name, f.serverBin),
157
+ bootPidPath: getBootPidPath(name, f.serverBin),
158
+ bootLogPath: getBootLogPath(name, f.serverBin),
159
+ };
160
+ }
161
+
162
+ async function saveConfig(name, config) {
163
+ assertValidInstanceName(name);
164
+ await ensureBaseDir();
165
+ const instanceDir = getInstanceDir(name);
166
+ await fs.mkdir(instanceDir, { recursive: true });
167
+
168
+ const toPersist = {};
169
+ for (const key of SETTABLE_KEYS) {
170
+ if (config[key] !== undefined && config[key] !== null && config[key] !== '') {
171
+ toPersist[key] = config[key];
172
+ }
173
+ }
174
+ await fs.writeFile(getConfigPath(name), JSON.stringify(toPersist, null, 2) + '\n', 'utf8');
175
+ }
176
+
177
+ // (Re)generate the instance's .mise.toml from its pinned version.
178
+ async function saveMiseToml(name, version) {
179
+ const instanceDir = getInstanceDir(name);
180
+ await fs.mkdir(instanceDir, { recursive: true });
181
+ await fs.writeFile(getMiseTomlPath(name), miseTomlFor(version), 'utf8');
182
+ }
183
+
184
+ async function configExists(name) {
185
+ try {
186
+ await fs.stat(getConfigPath(name));
187
+ return true;
188
+ } catch (err) {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ async function listInstances() {
194
+ await ensureBaseDir();
195
+ const instancesDir = path.join(BASE_DIR, 'instances');
196
+ let entries = [];
197
+ try {
198
+ entries = await fs.readdir(instancesDir, { withFileTypes: true });
199
+ } catch (err) {
200
+ return [];
201
+ }
202
+ const names = [];
203
+ for (const entry of entries) {
204
+ if (!entry.isDirectory()) continue;
205
+ try {
206
+ await fs.stat(path.join(instancesDir, entry.name, 'config.json'));
207
+ names.push(entry.name);
208
+ } catch (err) {
209
+ // Not an instance directory.
210
+ }
211
+ }
212
+ return names.sort();
213
+ }
214
+
215
+ async function deleteInstanceDir(name) {
216
+ assertValidInstanceName(name);
217
+ const instanceDir = getInstanceDir(name);
218
+ const instancesBase = path.join(BASE_DIR, 'instances');
219
+ // Safety: the resolved instance dir must live directly under the instances
220
+ // base so a crafted name cannot make us delete ~/.influx-local itself.
221
+ if (path.dirname(instanceDir) !== instancesBase) {
222
+ throw new Error(`Refusing to delete unexpected path: ${instanceDir}`);
223
+ }
224
+ await fs.rm(instanceDir, { recursive: true, force: true });
225
+ }
226
+
227
+ // --- Secrets (per-instance creds.env, mode 0600) ----------------------------
228
+
229
+ // Parse "KEY=VALUE" lines (comments and blank lines allowed, '#' starts a
230
+ // comment). Values keep everything after the first '=' (tokens may contain it).
231
+ async function readCreds(name, flavorId) {
232
+ const credsPath = getCredsPath(name);
233
+ let data = '';
234
+ try {
235
+ data = await fs.readFile(credsPath, 'utf8');
236
+ } catch (err) {
237
+ return null;
238
+ }
239
+ const creds = {};
240
+ for (const line of data.split(/\r?\n/)) {
241
+ const trimmed = line.trim();
242
+ if (!trimmed || trimmed.startsWith('#')) continue;
243
+ const eq = trimmed.indexOf('=');
244
+ if (eq <= 0) continue;
245
+ creds[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1);
246
+ }
247
+ const required = CRED_FIELDS[flavorId] || [];
248
+ for (const key of required) {
249
+ if (!creds[key]) return null;
250
+ }
251
+ return creds;
252
+ }
253
+
254
+ function credsHeader(flavorId, cfg) {
255
+ if (flavorId === 'v1') {
256
+ return [
257
+ '# InfluxDB 1.x: ADMIN_USER has ALL PRIVILEGES, APP_USER has ALL on the database.',
258
+ ];
259
+ }
260
+ if (flavorId === 'v2') {
261
+ return [
262
+ '# InfluxDB 2.x: ADMIN_TOKEN is an all-access (operator) token, APP_TOKEN is scoped',
263
+ `# read/write on bucket "${cfg ? cfg.database : 'BUCKET'}"; ADMIN_PW is the operator login password.`,
264
+ ];
265
+ }
266
+ return [
267
+ '# InfluxDB 3 Core: a single admin token (3 Core has no per-database tokens).',
268
+ ];
269
+ }
270
+
271
+ // Atomically write creds.env with restrictive permissions: a random 0600 temp
272
+ // file is created, written, then renamed over the target.
273
+ async function writeCreds(name, flavorId, creds, cfg) {
274
+ const credsPath = getCredsPath(name);
275
+ const instanceDir = getInstanceDir(name);
276
+ await fs.mkdir(instanceDir, { recursive: true });
277
+ const tmp = path.join(instanceDir, `.creds.env.${crypto.randomBytes(6).toString('hex')}.tmp`);
278
+
279
+ const order = CRED_FIELDS[flavorId] || Object.keys(creds);
280
+ const lines = [...credsHeader(flavorId, cfg)];
281
+ for (const key of order) {
282
+ if (creds[key] !== undefined && creds[key] !== '') lines.push(`${key}=${creds[key]}`);
283
+ }
284
+ for (const key of Object.keys(creds)) {
285
+ if (!order.includes(key) && creds[key] !== undefined && creds[key] !== '') {
286
+ lines.push(`${key}=${creds[key]}`);
287
+ }
288
+ }
289
+ const body = `${lines.join('\n')}\n`;
290
+
291
+ try {
292
+ const handle = await fs.open(tmp, 'wx', 0o600);
293
+ try {
294
+ await handle.writeFile(body, 'utf8');
295
+ } finally {
296
+ await handle.close();
297
+ }
298
+ await fs.rename(tmp, credsPath);
299
+ await fs.chmod(credsPath, 0o600);
300
+ } catch (err) {
301
+ await fs.rm(tmp, { force: true }).catch(() => {});
302
+ throw err;
303
+ }
304
+ }
305
+
306
+ // 20 random characters from [A-Za-z0-9] (same shape pg-local-cli/mongo-local-cli
307
+ // generate with `openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 20`).
308
+ function randomPassword() {
309
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
310
+ let out = '';
311
+ for (let i = 0; i < 20; i++) {
312
+ out += alphabet[crypto.randomInt(0, alphabet.length)];
313
+ }
314
+ return out;
315
+ }
316
+
317
+ // 64 hex chars — the shape InfluxDB itself uses for API tokens.
318
+ function randomToken() {
319
+ return crypto.randomBytes(32).toString('hex');
320
+ }
321
+
322
+ module.exports = {
323
+ BASE_DIR,
324
+ miseBin,
325
+ getInstanceDir,
326
+ getConfigPath,
327
+ getCredsPath,
328
+ getMiseTomlPath,
329
+ getCliConfigsPath,
330
+ getPidPath,
331
+ getLogPath,
332
+ getBootPidPath,
333
+ getBootLogPath,
334
+ SETTABLE_KEYS,
335
+ DEFAULT_CONFIG,
336
+ DEFAULT_VERSION,
337
+ assertValidInstanceName,
338
+ ensureBaseDir,
339
+ loadConfig,
340
+ saveConfig,
341
+ saveMiseToml,
342
+ configExists,
343
+ listInstances,
344
+ deleteInstanceDir,
345
+ readCreds,
346
+ writeCreds,
347
+ randomPassword,
348
+ randomToken,
349
+ };