pinggy 0.5.0 → 0.5.2

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 CHANGED
@@ -24,6 +24,8 @@ Create secure, shareable tunnels to your localhost and manage them from the comm
24
24
  ## Architecture at a glance
25
25
  The CLI runs as two processes. A short-lived foreground process is what you invoke. A long-running daemon owns every tunnel and the `@pinggy/pinggy` SDK. They talk over HTTP and WebSocket on `127.0.0.1`.
26
26
 
27
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full breakdown of the two-process model, IPC routes, daemon discovery, session ownership, and filesystem layout.
28
+
27
29
  ## Requirements
28
30
  - Node.js 18+ (recommended). The CLI uses modern ESM and WebSocket features.
29
31
  - A network connection that allows outgoing WebSocket/HTTPS traffic.
@@ -209,11 +209,23 @@ var ForwardingEntryV2Schema = z.object({
209
209
  address: z.string(),
210
210
  type: z.enum([TunnelType.Http, TunnelType.Tcp, TunnelType.Udp, TunnelType.Tls, TunnelType.TlsTcp])
211
211
  });
212
+ var OptionalSchema = z.object({
213
+ sniServerName: z.string().optional(),
214
+ ssl: z.boolean().optional(),
215
+ additionalArguments: z.string().optional(),
216
+ serve: z.string().optional(),
217
+ manage: z.string().optional(),
218
+ remoteManagement: z.object({ serverUrl: z.string(), apiKey: z.string() }).optional(),
219
+ noTui: z.boolean().optional()
220
+ }).catchall(z.unknown());
212
221
  var TunnelConfigV1Schema = z.object({
213
222
  // Meta Info
214
223
  version: z.string(),
215
224
  name: z.string(),
216
225
  configId: z.string(),
226
+ hostKeyCheck: z.boolean().optional(),
227
+ platformValue: z.string().optional(),
228
+ isQRCode: z.boolean().optional(),
217
229
  // General tunnel configurations
218
230
  serverAddress: z.string().optional(),
219
231
  token: z.string().optional(),
@@ -239,9 +251,11 @@ var TunnelConfigV1Schema = z.object({
239
251
  httpsOnly: z.boolean().optional(),
240
252
  originalRequestUrl: z.boolean().optional(),
241
253
  allowPreflight: z.boolean().optional(),
242
- serve: z.string().optional(),
243
- optional: z.record(z.string(), z.unknown()).optional()
254
+ optional: OptionalSchema.optional()
244
255
  });
256
+ var TUNNEL_CONFIG_V1_KEYS = Object.keys(
257
+ TunnelConfigV1Schema.shape
258
+ );
245
259
  var StartV2Schema = z.object({
246
260
  tunnelID: z.string().nullable().optional(),
247
261
  tunnelConfig: TunnelConfigV1Schema
@@ -490,11 +504,11 @@ var TunnelOperations = class {
490
504
  startPromise.catch((err) => {
491
505
  logger.error("No-wait startTunnel failed", { tunnelid, err: String(err) });
492
506
  });
493
- return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.serve);
507
+ return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.optional?.serve);
494
508
  }
495
509
  await startPromise;
496
510
  const tunnelPconfig = await this.tunnelManager.getTunnelConfig("", tunnelid);
497
- return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.serve);
511
+ return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.optional?.serve);
498
512
  } catch (err) {
499
513
  if (err instanceof TunnelAlreadyRunningError) {
500
514
  return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
@@ -1491,7 +1505,7 @@ async function ensureDaemonRunning() {
1491
1505
  const existing = getDaemonInfo();
1492
1506
  if (existing) return existing;
1493
1507
  if (process.versions.electron) {
1494
- const { runDaemonChild } = await import("./daemonChild-E2CORSSB.js");
1508
+ const { runDaemonChild } = await import("./daemonChild-6QA4AVAN.js");
1495
1509
  const handle = await runDaemonChild({
1496
1510
  installSignalHandlers: false,
1497
1511
  exitOnFailure: false
@@ -2139,6 +2153,8 @@ export {
2139
2153
  TunnelWarningCode,
2140
2154
  ErrorCode,
2141
2155
  isErrorResponse,
2156
+ TunnelConfigV1Schema,
2157
+ TUNNEL_CONFIG_V1_KEYS,
2142
2158
  TunnelOperations,
2143
2159
  RemoteManagementUnauthorizedError,
2144
2160
  buildRemoteManagementWsUrl,
@@ -10,7 +10,7 @@ import {
10
10
  startDaemon,
11
11
  startRemoteManagement,
12
12
  stopDaemon
13
- } from "./chunk-FVLXFHBL.js";
13
+ } from "./chunk-C3ZMLIC4.js";
14
14
  import {
15
15
  readDaemonConfig
16
16
  } from "./chunk-MT44NAXX.js";
@@ -24,15 +24,17 @@ import {
24
24
  Subcommand,
25
25
  deleteConfig,
26
26
  findConfig,
27
+ findConfigByName,
27
28
  getAutoStartConfigs,
28
29
  listSavedConfigs,
29
30
  printConfigDetail,
30
31
  printConfigList,
32
+ sanitizeName,
31
33
  saveConfig,
32
34
  updateConfigAutoStart,
33
35
  updateTunnelConfig,
34
36
  validateName
35
- } from "./chunk-HUP6YWH6.js";
37
+ } from "./chunk-WM7I57AA.js";
36
38
  import {
37
39
  TunnelManager,
38
40
  detachAllTunnelLoggers,
@@ -274,7 +276,7 @@ import { isIP } from "net";
274
276
  function parseExtendedOptions(options, config, localServerTls) {
275
277
  if (!options) return localServerTls;
276
278
  for (const opt of options) {
277
- const [key, value] = opt.replace(/^"|"$/g, "").split(/:(.+)/).filter(Boolean);
279
+ const [key, value] = opt.replace(/^"|"$/g, "").split(/:(.*)/).filter(Boolean);
278
280
  switch (key) {
279
281
  case "x":
280
282
  switch (value) {
@@ -335,7 +337,7 @@ function parseExtendedOptions(options, config, localServerTls) {
335
337
  break;
336
338
  case "b":
337
339
  if (value && value.includes(":")) {
338
- const [username, password] = value.split(/:(.+)/);
340
+ const [username, password] = value.split(/:(.*)/);
339
341
  if (!config.basicAuth) config.basicAuth = [];
340
342
  config.basicAuth.push({ username, password });
341
343
  } else {
@@ -345,7 +347,7 @@ function parseExtendedOptions(options, config, localServerTls) {
345
347
  break;
346
348
  case "a":
347
349
  if (value && value.includes(":")) {
348
- const [key2, val] = value.split(/:(.+)/);
350
+ const [key2, val] = value.split(/:(.*)/);
349
351
  if (!config.headerModification) config.headerModification = [];
350
352
  config.headerModification.push({ type: "add", key: key2, value: [val] });
351
353
  } else {
@@ -355,7 +357,7 @@ function parseExtendedOptions(options, config, localServerTls) {
355
357
  break;
356
358
  case "u":
357
359
  if (value && value.includes(":")) {
358
- const [key2, val] = value.split(/:(.+)/);
360
+ const [key2, val] = value.split(/:(.*)/);
359
361
  if (!config.headerModification) config.headerModification = [];
360
362
  config.headerModification.push({ type: "update", key: key2, value: [val] });
361
363
  } else {
@@ -757,6 +759,13 @@ function parseToken(finalConfig, explicitToken) {
757
759
  finalConfig.token = explicitToken;
758
760
  }
759
761
  }
762
+ function normalizeForwardingToArray(finalConfig, type) {
763
+ const fwd = finalConfig.forwarding;
764
+ if (typeof fwd !== "string") {
765
+ return;
766
+ }
767
+ finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || TunnelType.Http, address: fwd.trim() }];
768
+ }
760
769
  function parseArgs2(finalConfig, remainingPositionals) {
761
770
  let localserverTls = "";
762
771
  localserverTls = parseExtendedOptions(remainingPositionals, finalConfig, localserverTls);
@@ -877,6 +886,7 @@ function buildFinalConfig(values, positionals, baseConfig) {
877
886
  if (forceFlag || values.force) {
878
887
  finalConfig.force = true;
879
888
  }
889
+ normalizeForwardingToArray(finalConfig, type);
880
890
  parseArgs2(finalConfig, remainingPositionals);
881
891
  storeJson(finalConfig, saveconf);
882
892
  return finalConfig;
@@ -1298,7 +1308,7 @@ async function startBackgroundTunnels(configs, values, positionals) {
1298
1308
  client.close();
1299
1309
  }
1300
1310
  async function startAutoStartTunnels() {
1301
- const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-TSGRNOE3.js");
1311
+ const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-HP3NXPH4.js");
1302
1312
  const configs = getAutoStartConfigs2();
1303
1313
  if (configs.length === 0) {
1304
1314
  printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
@@ -2217,10 +2227,10 @@ function handleConfig(args) {
2217
2227
  printConfigList();
2218
2228
  return;
2219
2229
  case ConfigVerb.Show: {
2220
- const names = requireNames(rest, "config show");
2230
+ const names = clubSpacedName(requireNames(rest, "config show"));
2221
2231
  for (const name of names) {
2222
- const saved2 = resolveConfig(name);
2223
- if (saved2) printConfigDetail(saved2);
2232
+ const saved = resolveConfig(name, "config show");
2233
+ if (saved) printConfigDetail(saved);
2224
2234
  }
2225
2235
  return;
2226
2236
  }
@@ -2230,7 +2240,7 @@ function handleConfig(args) {
2230
2240
  return;
2231
2241
  }
2232
2242
  case ConfigVerb.Delete: {
2233
- const names = requireNames(rest, "config delete");
2243
+ const names = clubSpacedName(requireNames(rest, "config delete"));
2234
2244
  for (const name of names) {
2235
2245
  const deletedName = deleteConfig(name);
2236
2246
  if (deletedName) {
@@ -2247,7 +2257,7 @@ function handleConfig(args) {
2247
2257
  return;
2248
2258
  }
2249
2259
  case ConfigVerb.Auto: {
2250
- const names = requireNames(rest, "config auto");
2260
+ const names = clubSpacedName(requireNames(rest, "config auto"));
2251
2261
  for (const name of names) {
2252
2262
  const updated = updateConfigAutoStart(name, true);
2253
2263
  if (updated) {
@@ -2259,7 +2269,7 @@ function handleConfig(args) {
2259
2269
  return;
2260
2270
  }
2261
2271
  case ConfigVerb.Noauto: {
2262
- const names = requireNames(rest, "config noauto");
2272
+ const names = clubSpacedName(requireNames(rest, "config noauto"));
2263
2273
  for (const name of names) {
2264
2274
  const updated = updateConfigAutoStart(name, false);
2265
2275
  if (updated) {
@@ -2270,10 +2280,14 @@ function handleConfig(args) {
2270
2280
  }
2271
2281
  return;
2272
2282
  }
2273
- default:
2274
- const saved = resolveConfig(verb);
2275
- if (saved) printConfigDetail(saved);
2283
+ default: {
2284
+ const names = clubSpacedName([verb, ...rest]);
2285
+ for (const name of names) {
2286
+ const saved = resolveConfig(name, "config");
2287
+ if (saved) printConfigDetail(saved);
2288
+ }
2276
2289
  return;
2290
+ }
2277
2291
  }
2278
2292
  }
2279
2293
  function handleConfigSave(name, remainingArgs) {
@@ -2291,7 +2305,7 @@ function handleConfigSave(name, remainingArgs) {
2291
2305
  printer_default.success(`Config "${name}" saved.`);
2292
2306
  }
2293
2307
  function handleConfigUpdate(nameOrId, remainingArgs) {
2294
- const saved = resolveConfig(nameOrId);
2308
+ const saved = resolveConfig(nameOrId, "config update");
2295
2309
  if (!saved) return;
2296
2310
  const { values, positionals } = parseCliArgs(cliOptions, remainingArgs);
2297
2311
  logger.debug("Building updated config", { nameOrId, values, positionals });
@@ -2325,7 +2339,7 @@ async function handleStart(args) {
2325
2339
  return;
2326
2340
  }
2327
2341
  const resolved = [];
2328
- for (const name of names) {
2342
+ for (const name of clubSpacedName(names)) {
2329
2343
  const saved = resolveConfig(name);
2330
2344
  if (!saved) return;
2331
2345
  resolved.push(saved);
@@ -2350,13 +2364,34 @@ async function handleStart(args) {
2350
2364
  await startMultipleForegroundViaDaemon(resolved, values, positionals);
2351
2365
  }
2352
2366
  }
2353
- function resolveConfig(nameOrId) {
2354
- const saved = findConfig(nameOrId);
2355
- if (!saved) {
2356
- printer_default.error(`No config found matching "${nameOrId}". Use: pinggy config list`);
2357
- return null;
2367
+ function clubSpacedName(names) {
2368
+ if (names.length > 1) {
2369
+ const joined = names.join(" ");
2370
+ if (findConfig(joined) ?? findConfigByName(joined)) {
2371
+ return [joined];
2372
+ }
2358
2373
  }
2359
- return saved;
2374
+ return names;
2375
+ }
2376
+ function resolveConfig(nameOrId, command = "start") {
2377
+ const saved = findConfig(nameOrId) ?? findConfigByName(nameOrId);
2378
+ if (saved) return saved;
2379
+ const sanitizedQuery = sanitizeName(nameOrId);
2380
+ const suggestions = listSavedConfigs().filter(
2381
+ (c) => c.name !== nameOrId && sanitizeName(c.name) === sanitizedQuery
2382
+ );
2383
+ printer_default.error(`No config found matching "${nameOrId}".`);
2384
+ if (suggestions.length > 0) {
2385
+ for (const c of suggestions.slice(0, 3)) {
2386
+ printer_default.info(` Did you mean "${c.name}"? \u2192 pinggy ${command} "${c.name}"`);
2387
+ }
2388
+ if (suggestions.length > 3) {
2389
+ printer_default.info(` \u2026and ${suggestions.length - 3} more. Use: pinggy config list`);
2390
+ }
2391
+ } else {
2392
+ printer_default.info(" Use: pinggy config list");
2393
+ }
2394
+ return null;
2360
2395
  }
2361
2396
  function requireName(args, command) {
2362
2397
  if (args.length === 0 || args[0].startsWith("-")) {
@@ -3364,7 +3399,7 @@ async function main() {
3364
3399
  const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
3365
3400
  configureLogger(values);
3366
3401
  if (values["_daemon-child"]) {
3367
- const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-E2CORSSB.js");
3402
+ const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-6QA4AVAN.js");
3368
3403
  await runDaemonChild2();
3369
3404
  return;
3370
3405
  }
@@ -10,6 +10,14 @@ import {
10
10
  import fs from "fs";
11
11
  import path from "path";
12
12
  import pico from "picocolors";
13
+ var storageLogger = {
14
+ info: (message, ...meta) => logger.info(message, ...meta),
15
+ warn: (message, ...meta) => logger.warn(message, ...meta),
16
+ error: (message, ...meta) => logger.error(message, ...meta)
17
+ };
18
+ function configureStorageLogger(l) {
19
+ storageLogger = l;
20
+ }
13
21
  function buildFilename(name, configId) {
14
22
  return `${name}_${configId}.json`;
15
23
  }
@@ -41,7 +49,7 @@ var ConfigVerb = {
41
49
  var SUBCOMMANDS = Object.values(Subcommand);
42
50
  var CONFIG_VERBS = Object.values(ConfigVerb);
43
51
  var RESERVED_NAMES = /* @__PURE__ */ new Set([...SUBCOMMANDS, ...CONFIG_VERBS]);
44
- function validateName(name) {
52
+ function validateNameStrict(name) {
45
53
  if (!name || name.trim().length === 0) {
46
54
  return new Error("Tunnel name cannot be empty.");
47
55
  }
@@ -56,17 +64,39 @@ function validateName(name) {
56
64
  }
57
65
  return null;
58
66
  }
67
+ function validateNameForStorage(name) {
68
+ if (!name || name.trim().length === 0) {
69
+ return new Error("Tunnel name cannot be empty.");
70
+ }
71
+ if (/[\u0000-\u001F\/\\]/.test(name)) {
72
+ return new Error("Tunnel name cannot contain path separators or control characters.");
73
+ }
74
+ if (RESERVED_NAMES.has(name.toLowerCase())) {
75
+ return new Error(`"${name}" is a reserved subcommand name. Use a different name.`);
76
+ }
77
+ return null;
78
+ }
79
+ var validateName = validateNameStrict;
59
80
  function readConfigFile(filePath) {
60
81
  try {
61
82
  const data = fs.readFileSync(filePath, { encoding: "utf-8" });
62
83
  return JSON.parse(data);
63
84
  } catch (err) {
64
- logger.warn(`Failed to read config file ${filePath}:`, err);
85
+ storageLogger.warn(`Failed to read config file ${filePath}: ${String(err)}`);
65
86
  return null;
66
87
  }
67
88
  }
68
89
  function writeConfigFile(filePath, config) {
69
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2), { encoding: "utf-8" });
90
+ const tmp = `${filePath}.tmp.${process.pid}`;
91
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { encoding: "utf-8" });
92
+ fs.renameSync(tmp, filePath);
93
+ }
94
+ function nextTimestamp(prev) {
95
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
96
+ if (prev && ts <= prev) {
97
+ return new Date(new Date(prev).getTime() + 1).toISOString();
98
+ }
99
+ return ts;
70
100
  }
71
101
  function listSavedConfigs() {
72
102
  const dir = getTunnelConfigDir();
@@ -88,15 +118,15 @@ function findConfigFile(nameOrId) {
88
118
  if (!fs.existsSync(dir)) return null;
89
119
  const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
90
120
  const sanitized = sanitizeName(nameOrId);
91
- const nameMatch = files.find((f) => f.startsWith(sanitized + "_"));
92
- if (nameMatch) {
93
- const filePath = path.join(dir, nameMatch);
121
+ const nameCandidates = files.filter((f) => f.startsWith(sanitized + "_"));
122
+ for (const f of nameCandidates) {
123
+ const filePath = path.join(dir, f);
94
124
  const config = readConfigFile(filePath);
95
125
  if (config && config.name === nameOrId) return { filePath, config };
96
126
  }
97
127
  const idCandidates = files.filter((f) => {
98
128
  const withoutExt = f.replace(/\.json$/, "");
99
- const lastUnderscore = withoutExt.indexOf("_");
129
+ const lastUnderscore = withoutExt.lastIndexOf("_");
100
130
  if (lastUnderscore === -1) return false;
101
131
  const idPart = withoutExt.slice(lastUnderscore + 1);
102
132
  return idPart.startsWith(nameOrId);
@@ -108,15 +138,33 @@ function findConfigFile(nameOrId) {
108
138
  }
109
139
  return null;
110
140
  }
141
+ function findConfigFileByConfigId(configId) {
142
+ const dir = getTunnelConfigDir();
143
+ if (!fs.existsSync(dir)) return null;
144
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
145
+ for (const file of files) {
146
+ const filePath = path.join(dir, file);
147
+ const config = readConfigFile(filePath);
148
+ if (config && config.configId === configId) {
149
+ return { filePath, config };
150
+ }
151
+ }
152
+ return null;
153
+ }
111
154
  function findConfigByName(name) {
112
- const resolved = findConfigFile(name);
113
- return resolved?.config.name === name ? resolved.config : null;
155
+ const dir = getTunnelConfigDir();
156
+ if (!fs.existsSync(dir)) return null;
157
+ const prefix = `${sanitizeName(name)}_`;
158
+ const matches = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && f.startsWith(prefix)).map((f) => readConfigFile(path.join(dir, f))).filter((c) => c !== null && c.name === name);
159
+ if (matches.length === 0) return null;
160
+ return matches.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""))[0];
114
161
  }
115
162
  function findConfig(nameOrId) {
116
163
  return findConfigFile(nameOrId)?.config ?? null;
117
164
  }
118
- function saveConfig(name, configId, tunnelConfig, autoStart = false) {
119
- const nameErr = validateName(name);
165
+ function saveConfig(name, configId, tunnelConfig, autoStart = false, options = {}) {
166
+ const { strict = true } = options;
167
+ const nameErr = strict ? validateNameStrict(name) : validateNameForStorage(name);
120
168
  if (nameErr) {
121
169
  throw nameErr;
122
170
  }
@@ -136,12 +184,58 @@ function saveConfig(name, configId, tunnelConfig, autoStart = false) {
136
184
  updatedAt: now,
137
185
  tunnelConfig
138
186
  };
139
- const filename = buildFilename(sanitizeName(name), configId);
140
- const filePath = path.join(dir, filename);
141
- fs.writeFileSync(filePath, JSON.stringify(saved, null, 2), { encoding: "utf-8" });
142
- logger.info(`Config "${name}" saved to ${filePath}`);
187
+ const filePath = path.join(dir, buildFilename(sanitizeName(name), configId));
188
+ writeConfigFile(filePath, saved);
189
+ storageLogger.info(`Config "${name}" saved to ${filePath}`);
143
190
  return saved;
144
191
  }
192
+ function upsertConfig(saved, options = {}) {
193
+ const { strict = true } = options;
194
+ const dir = ensureTunnelConfigDir();
195
+ const targetPath = path.join(dir, buildFilename(sanitizeName(saved.name), saved.configId));
196
+ const existing = findConfigFileByConfigId(saved.configId);
197
+ if (existing) {
198
+ if (path.resolve(existing.filePath) !== path.resolve(targetPath)) {
199
+ try {
200
+ fs.unlinkSync(existing.filePath);
201
+ } catch (err) {
202
+ storageLogger.warn(`upsertConfig: failed to remove old file ${existing.filePath}: ${String(err)}`);
203
+ }
204
+ }
205
+ writeConfigFile(targetPath, saved);
206
+ storageLogger.info(`Config "${saved.name}" (${saved.configId}) updated at ${targetPath}`);
207
+ return saved;
208
+ }
209
+ const nameErr = strict ? validateNameStrict(saved.name) : validateNameForStorage(saved.name);
210
+ if (nameErr) {
211
+ throw nameErr;
212
+ }
213
+ writeConfigFile(targetPath, saved);
214
+ storageLogger.info(`Config "${saved.name}" (${saved.configId}) saved to ${targetPath}`);
215
+ return saved;
216
+ }
217
+ function bulkReplace(configs, knownConfigIds, options = {}) {
218
+ const written = [];
219
+ const deleted = [];
220
+ const incomingIds = /* @__PURE__ */ new Set();
221
+ for (const cfg of configs) {
222
+ upsertConfig(cfg, options);
223
+ written.push(cfg.configId);
224
+ incomingIds.add(cfg.configId);
225
+ }
226
+ for (const id of knownConfigIds) {
227
+ if (incomingIds.has(id)) continue;
228
+ const existing = findConfigFileByConfigId(id);
229
+ if (!existing) continue;
230
+ try {
231
+ fs.unlinkSync(existing.filePath);
232
+ deleted.push(id);
233
+ } catch (err) {
234
+ storageLogger.warn(`bulkReplace: failed to delete ${existing.filePath}: ${String(err)}`);
235
+ }
236
+ }
237
+ return { written, deleted };
238
+ }
145
239
  function loadConfigByName(name) {
146
240
  return findConfigByName(name);
147
241
  }
@@ -149,25 +243,25 @@ function deleteConfig(nameOrId) {
149
243
  const resolved = findConfigFile(nameOrId);
150
244
  if (!resolved) return null;
151
245
  fs.unlinkSync(resolved.filePath);
152
- logger.info(`Config "${resolved.config.name}" deleted.`);
246
+ storageLogger.info(`Config "${resolved.config.name}" deleted.`);
153
247
  return resolved.config.name;
154
248
  }
155
249
  function updateConfigAutoStart(nameOrId, autoStart) {
156
250
  const resolved = findConfigFile(nameOrId);
157
251
  if (!resolved) return null;
158
252
  resolved.config.autoStart = autoStart;
159
- resolved.config.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
253
+ resolved.config.updatedAt = nextTimestamp(resolved.config.updatedAt);
160
254
  writeConfigFile(resolved.filePath, resolved.config);
161
- logger.info(`Config "${resolved.config.name}" auto-start set to ${autoStart}`);
255
+ storageLogger.info(`Config "${resolved.config.name}" auto-start set to ${autoStart}`);
162
256
  return resolved.config;
163
257
  }
164
258
  function updateTunnelConfig(nameOrId, tunnelConfig) {
165
259
  const resolved = findConfigFile(nameOrId);
166
260
  if (!resolved) return null;
167
261
  resolved.config.tunnelConfig = tunnelConfig;
168
- resolved.config.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
262
+ resolved.config.updatedAt = nextTimestamp(resolved.config.updatedAt);
169
263
  writeConfigFile(resolved.filePath, resolved.config);
170
- logger.info(`Config "${resolved.config.name}" tunnel configuration updated`);
264
+ storageLogger.info(`Config "${resolved.config.name}" tunnel configuration updated`);
171
265
  return resolved.config;
172
266
  }
173
267
  function getAutoStartConfigs() {
@@ -248,17 +342,22 @@ Tunnel Config: ${pico.cyanBright(config.name)}`));
248
342
  }
249
343
 
250
344
  export {
345
+ configureStorageLogger,
251
346
  sanitizeName,
252
347
  Subcommand,
253
348
  ConfigVerb,
254
349
  SUBCOMMANDS,
255
350
  CONFIG_VERBS,
256
351
  RESERVED_NAMES,
352
+ validateNameStrict,
353
+ validateNameForStorage,
257
354
  validateName,
258
355
  listSavedConfigs,
259
356
  findConfigByName,
260
357
  findConfig,
261
358
  saveConfig,
359
+ upsertConfig,
360
+ bulkReplace,
262
361
  loadConfigByName,
263
362
  deleteConfig,
264
363
  updateConfigAutoStart,
@@ -4,6 +4,8 @@ import {
4
4
  RESERVED_NAMES,
5
5
  SUBCOMMANDS,
6
6
  Subcommand,
7
+ bulkReplace,
8
+ configureStorageLogger,
7
9
  deleteConfig,
8
10
  findConfig,
9
11
  findConfigByName,
@@ -16,8 +18,11 @@ import {
16
18
  saveConfig,
17
19
  updateConfigAutoStart,
18
20
  updateTunnelConfig,
19
- validateName
20
- } from "./chunk-HUP6YWH6.js";
21
+ upsertConfig,
22
+ validateName,
23
+ validateNameForStorage,
24
+ validateNameStrict
25
+ } from "./chunk-WM7I57AA.js";
21
26
  import "./chunk-7G6SJEEA.js";
22
27
  import "./chunk-GBYF2H4H.js";
23
28
  export {
@@ -26,6 +31,8 @@ export {
26
31
  RESERVED_NAMES,
27
32
  SUBCOMMANDS,
28
33
  Subcommand,
34
+ bulkReplace,
35
+ configureStorageLogger,
29
36
  deleteConfig,
30
37
  findConfig,
31
38
  findConfigByName,
@@ -38,5 +45,8 @@ export {
38
45
  saveConfig,
39
46
  updateConfigAutoStart,
40
47
  updateTunnelConfig,
41
- validateName
48
+ upsertConfig,
49
+ validateName,
50
+ validateNameForStorage,
51
+ validateNameStrict
42
52
  };
@@ -5,11 +5,11 @@ import {
5
5
  trackIPCTunnelStart,
6
6
  trackTunnelStart,
7
7
  trackTunnelStop
8
- } from "./chunk-YJQC6LQN.js";
9
- import "./chunk-FVLXFHBL.js";
8
+ } from "./chunk-LFK74BMU.js";
9
+ import "./chunk-C3ZMLIC4.js";
10
10
  import "./chunk-MT44NAXX.js";
11
11
  import "./chunk-BFARGPGP.js";
12
- import "./chunk-HUP6YWH6.js";
12
+ import "./chunk-WM7I57AA.js";
13
13
  import "./chunk-DLNUDW6G.js";
14
14
  import "./chunk-UB26QJ4T.js";
15
15
  import "./chunk-7G6SJEEA.js";