pinggy 0.5.1 → 0.5.3

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.
@@ -21,6 +21,14 @@ var Route = {
21
21
  SetTunnelLogging: "POST /config/tunnel-logging",
22
22
  GetLogPaths: "GET /logs/paths"
23
23
  };
24
+ var DaemonHost = {
25
+ CLI: "cli",
26
+ APP: "app"
27
+ };
28
+ var ShutdownStatus = {
29
+ ShuttingDown: "shutting_down",
30
+ RefusedInApp: "refused_in_app"
31
+ };
24
32
  var SessionMode = {
25
33
  Foreground: "foreground",
26
34
  Detached: "detached"
@@ -159,6 +167,8 @@ var IPCClient = class {
159
167
 
160
168
  export {
161
169
  Route,
170
+ DaemonHost,
171
+ ShutdownStatus,
162
172
  SessionMode,
163
173
  IPCClient
164
174
  };
@@ -10,29 +10,33 @@ import {
10
10
  startDaemon,
11
11
  startRemoteManagement,
12
12
  stopDaemon
13
- } from "./chunk-VJ4VSZGX.js";
13
+ } from "./chunk-GK2PQ7KX.js";
14
14
  import {
15
15
  readDaemonConfig
16
16
  } from "./chunk-MT44NAXX.js";
17
17
  import {
18
+ DaemonHost,
18
19
  Route,
19
- SessionMode
20
- } from "./chunk-BFARGPGP.js";
20
+ SessionMode,
21
+ ShutdownStatus
22
+ } from "./chunk-5YHD6M35.js";
21
23
  import {
22
24
  ConfigVerb,
23
25
  SUBCOMMANDS,
24
26
  Subcommand,
25
27
  deleteConfig,
26
28
  findConfig,
29
+ findConfigByName,
27
30
  getAutoStartConfigs,
28
31
  listSavedConfigs,
29
32
  printConfigDetail,
30
33
  printConfigList,
34
+ sanitizeName,
31
35
  saveConfig,
32
36
  updateConfigAutoStart,
33
37
  updateTunnelConfig,
34
38
  validateName
35
- } from "./chunk-HUP6YWH6.js";
39
+ } from "./chunk-WM7I57AA.js";
36
40
  import {
37
41
  TunnelManager,
38
42
  detachAllTunnelLoggers,
@@ -757,6 +761,13 @@ function parseToken(finalConfig, explicitToken) {
757
761
  finalConfig.token = explicitToken;
758
762
  }
759
763
  }
764
+ function normalizeForwardingToArray(finalConfig, type) {
765
+ const fwd = finalConfig.forwarding;
766
+ if (typeof fwd !== "string") {
767
+ return;
768
+ }
769
+ finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || TunnelType.Http, address: fwd.trim() }];
770
+ }
760
771
  function parseArgs2(finalConfig, remainingPositionals) {
761
772
  let localserverTls = "";
762
773
  localserverTls = parseExtendedOptions(remainingPositionals, finalConfig, localserverTls);
@@ -877,6 +888,7 @@ function buildFinalConfig(values, positionals, baseConfig) {
877
888
  if (forceFlag || values.force) {
878
889
  finalConfig.force = true;
879
890
  }
891
+ normalizeForwardingToArray(finalConfig, type);
880
892
  parseArgs2(finalConfig, remainingPositionals);
881
893
  storeJson(finalConfig, saveconf);
882
894
  return finalConfig;
@@ -1298,7 +1310,7 @@ async function startBackgroundTunnels(configs, values, positionals) {
1298
1310
  client.close();
1299
1311
  }
1300
1312
  async function startAutoStartTunnels() {
1301
- const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-TSGRNOE3.js");
1313
+ const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-HP3NXPH4.js");
1302
1314
  const configs = getAutoStartConfigs2();
1303
1315
  if (configs.length === 0) {
1304
1316
  printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
@@ -1767,6 +1779,8 @@ async function handleDaemonStop() {
1767
1779
  const result = await stopDaemon();
1768
1780
  if (result.ok) {
1769
1781
  printer_default.success("Daemon stopped.");
1782
+ } else if (result.reason === DaemonHost.APP) {
1783
+ printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
1770
1784
  } else {
1771
1785
  printer_default.error(result.error);
1772
1786
  }
@@ -1787,6 +1801,7 @@ function handleDaemonStatus() {
1787
1801
  printer_default.print(` Port: ${info.port}`);
1788
1802
  printer_default.print(` Started: ${info.startedAt}`);
1789
1803
  printer_default.print(` Uptime: ${uptimeStr}`);
1804
+ printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
1790
1805
  }
1791
1806
 
1792
1807
  // src/cli/subcommand/handlers/psCommand.ts
@@ -2217,10 +2232,10 @@ function handleConfig(args) {
2217
2232
  printConfigList();
2218
2233
  return;
2219
2234
  case ConfigVerb.Show: {
2220
- const names = requireNames(rest, "config show");
2235
+ const names = clubSpacedName(requireNames(rest, "config show"));
2221
2236
  for (const name of names) {
2222
- const saved2 = resolveConfig(name);
2223
- if (saved2) printConfigDetail(saved2);
2237
+ const saved = resolveConfig(name, "config show");
2238
+ if (saved) printConfigDetail(saved);
2224
2239
  }
2225
2240
  return;
2226
2241
  }
@@ -2230,7 +2245,7 @@ function handleConfig(args) {
2230
2245
  return;
2231
2246
  }
2232
2247
  case ConfigVerb.Delete: {
2233
- const names = requireNames(rest, "config delete");
2248
+ const names = clubSpacedName(requireNames(rest, "config delete"));
2234
2249
  for (const name of names) {
2235
2250
  const deletedName = deleteConfig(name);
2236
2251
  if (deletedName) {
@@ -2247,7 +2262,7 @@ function handleConfig(args) {
2247
2262
  return;
2248
2263
  }
2249
2264
  case ConfigVerb.Auto: {
2250
- const names = requireNames(rest, "config auto");
2265
+ const names = clubSpacedName(requireNames(rest, "config auto"));
2251
2266
  for (const name of names) {
2252
2267
  const updated = updateConfigAutoStart(name, true);
2253
2268
  if (updated) {
@@ -2259,7 +2274,7 @@ function handleConfig(args) {
2259
2274
  return;
2260
2275
  }
2261
2276
  case ConfigVerb.Noauto: {
2262
- const names = requireNames(rest, "config noauto");
2277
+ const names = clubSpacedName(requireNames(rest, "config noauto"));
2263
2278
  for (const name of names) {
2264
2279
  const updated = updateConfigAutoStart(name, false);
2265
2280
  if (updated) {
@@ -2270,10 +2285,14 @@ function handleConfig(args) {
2270
2285
  }
2271
2286
  return;
2272
2287
  }
2273
- default:
2274
- const saved = resolveConfig(verb);
2275
- if (saved) printConfigDetail(saved);
2288
+ default: {
2289
+ const names = clubSpacedName([verb, ...rest]);
2290
+ for (const name of names) {
2291
+ const saved = resolveConfig(name, "config");
2292
+ if (saved) printConfigDetail(saved);
2293
+ }
2276
2294
  return;
2295
+ }
2277
2296
  }
2278
2297
  }
2279
2298
  function handleConfigSave(name, remainingArgs) {
@@ -2291,7 +2310,7 @@ function handleConfigSave(name, remainingArgs) {
2291
2310
  printer_default.success(`Config "${name}" saved.`);
2292
2311
  }
2293
2312
  function handleConfigUpdate(nameOrId, remainingArgs) {
2294
- const saved = resolveConfig(nameOrId);
2313
+ const saved = resolveConfig(nameOrId, "config update");
2295
2314
  if (!saved) return;
2296
2315
  const { values, positionals } = parseCliArgs(cliOptions, remainingArgs);
2297
2316
  logger.debug("Building updated config", { nameOrId, values, positionals });
@@ -2325,7 +2344,7 @@ async function handleStart(args) {
2325
2344
  return;
2326
2345
  }
2327
2346
  const resolved = [];
2328
- for (const name of names) {
2347
+ for (const name of clubSpacedName(names)) {
2329
2348
  const saved = resolveConfig(name);
2330
2349
  if (!saved) return;
2331
2350
  resolved.push(saved);
@@ -2350,13 +2369,34 @@ async function handleStart(args) {
2350
2369
  await startMultipleForegroundViaDaemon(resolved, values, positionals);
2351
2370
  }
2352
2371
  }
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;
2372
+ function clubSpacedName(names) {
2373
+ if (names.length > 1) {
2374
+ const joined = names.join(" ");
2375
+ if (findConfig(joined) ?? findConfigByName(joined)) {
2376
+ return [joined];
2377
+ }
2358
2378
  }
2359
- return saved;
2379
+ return names;
2380
+ }
2381
+ function resolveConfig(nameOrId, command = "start") {
2382
+ const saved = findConfig(nameOrId) ?? findConfigByName(nameOrId);
2383
+ if (saved) return saved;
2384
+ const sanitizedQuery = sanitizeName(nameOrId);
2385
+ const suggestions = listSavedConfigs().filter(
2386
+ (c) => c.name !== nameOrId && sanitizeName(c.name) === sanitizedQuery
2387
+ );
2388
+ printer_default.error(`No config found matching "${nameOrId}".`);
2389
+ if (suggestions.length > 0) {
2390
+ for (const c of suggestions.slice(0, 3)) {
2391
+ printer_default.info(` Did you mean "${c.name}"? \u2192 pinggy ${command} "${c.name}"`);
2392
+ }
2393
+ if (suggestions.length > 3) {
2394
+ printer_default.info(` \u2026and ${suggestions.length - 3} more. Use: pinggy config list`);
2395
+ }
2396
+ } else {
2397
+ printer_default.info(" Use: pinggy config list");
2398
+ }
2399
+ return null;
2360
2400
  }
2361
2401
  function requireName(args, command) {
2362
2402
  if (args.length === 0 || args[0].startsWith("-")) {
@@ -2662,6 +2702,10 @@ var IPCServer = class {
2662
2702
  },
2663
2703
  [Route.Shutdown]: () => {
2664
2704
  logger.info("Daemon shutdown requested via IPC");
2705
+ if (getDaemonHost() === DaemonHost.APP) {
2706
+ logger.info("Shutdown refused: daemon is hosted in-process by the Pinggy app");
2707
+ return { status: ShutdownStatus.RefusedInApp, errors: [] };
2708
+ }
2665
2709
  const errors = [];
2666
2710
  const step = (label, fn) => {
2667
2711
  try {
@@ -2676,7 +2720,7 @@ var IPCServer = class {
2676
2720
  step("clearDaemonState", clearDaemonState);
2677
2721
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
2678
2722
  setTimeout(() => process.exit(0), 200);
2679
- return { status: "shutting_down", errors };
2723
+ return { status: ShutdownStatus.ShuttingDown, errors };
2680
2724
  }
2681
2725
  };
2682
2726
  }
@@ -3119,6 +3163,10 @@ var SessionTracker = class {
3119
3163
 
3120
3164
  // src/daemon/lifecycle/daemonChild.ts
3121
3165
  var daemonState = { tunnels: [], lastUpdated: "" };
3166
+ var daemonHost = DaemonHost.CLI;
3167
+ function getDaemonHost() {
3168
+ return daemonHost;
3169
+ }
3122
3170
  var sessionTrackerRef = null;
3123
3171
  function setDaemonSessionTracker(st) {
3124
3172
  sessionTrackerRef = st;
@@ -3248,6 +3296,7 @@ async function restoreCrashedTunnels(manager) {
3248
3296
  async function runDaemonChild(opts = {}) {
3249
3297
  const installSignalHandlers = opts.installSignalHandlers ?? true;
3250
3298
  const exitOnFailure = opts.exitOnFailure ?? true;
3299
+ daemonHost = opts.host ?? DaemonHost.CLI;
3251
3300
  ensurePinggyConfigDir();
3252
3301
  const persistedLevel = readDaemonConfig()?.logLevel;
3253
3302
  const envLevel = process.env.PINGGY_LOG_LEVEL;
@@ -3323,7 +3372,8 @@ async function runDaemonChild(opts = {}) {
3323
3372
  const info = {
3324
3373
  pid: process.pid,
3325
3374
  port,
3326
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
3375
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3376
+ host: daemonHost
3327
3377
  };
3328
3378
  writeDaemonInfo(info);
3329
3379
  logger.info("Daemon info written", info);
@@ -3364,7 +3414,7 @@ async function main() {
3364
3414
  const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
3365
3415
  configureLogger(values);
3366
3416
  if (values["_daemon-child"]) {
3367
- const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-KXERF36J.js");
3417
+ const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-WIRWKBKB.js");
3368
3418
  await runDaemonChild2();
3369
3419
  return;
3370
3420
  }
@@ -3398,6 +3448,7 @@ if (entryFile && entryFile === currentFile) {
3398
3448
  }
3399
3449
 
3400
3450
  export {
3451
+ getDaemonHost,
3401
3452
  setDaemonSessionTracker,
3402
3453
  removeDaemonInfo,
3403
3454
  trackTunnelStart,
@@ -1,7 +1,9 @@
1
1
  import {
2
+ DaemonHost,
2
3
  IPCClient,
3
- SessionMode
4
- } from "./chunk-BFARGPGP.js";
4
+ SessionMode,
5
+ ShutdownStatus
6
+ } from "./chunk-5YHD6M35.js";
5
7
  import {
6
8
  TunnelAlreadyRunningError,
7
9
  TunnelManager,
@@ -209,11 +211,23 @@ var ForwardingEntryV2Schema = z.object({
209
211
  address: z.string(),
210
212
  type: z.enum([TunnelType.Http, TunnelType.Tcp, TunnelType.Udp, TunnelType.Tls, TunnelType.TlsTcp])
211
213
  });
214
+ var OptionalSchema = z.object({
215
+ sniServerName: z.string().optional(),
216
+ ssl: z.boolean().optional(),
217
+ additionalArguments: z.string().optional(),
218
+ serve: z.string().optional(),
219
+ manage: z.string().optional(),
220
+ remoteManagement: z.object({ serverUrl: z.string(), apiKey: z.string() }).optional(),
221
+ noTui: z.boolean().optional()
222
+ }).catchall(z.unknown());
212
223
  var TunnelConfigV1Schema = z.object({
213
224
  // Meta Info
214
225
  version: z.string(),
215
226
  name: z.string(),
216
227
  configId: z.string(),
228
+ hostKeyCheck: z.boolean().optional(),
229
+ platformValue: z.string().optional(),
230
+ isQRCode: z.boolean().optional(),
217
231
  // General tunnel configurations
218
232
  serverAddress: z.string().optional(),
219
233
  token: z.string().optional(),
@@ -239,9 +253,11 @@ var TunnelConfigV1Schema = z.object({
239
253
  httpsOnly: z.boolean().optional(),
240
254
  originalRequestUrl: z.boolean().optional(),
241
255
  allowPreflight: z.boolean().optional(),
242
- serve: z.string().optional(),
243
- optional: z.record(z.string(), z.unknown()).optional()
256
+ optional: OptionalSchema.optional()
244
257
  });
258
+ var TUNNEL_CONFIG_V1_KEYS = Object.keys(
259
+ TunnelConfigV1Schema.shape
260
+ );
245
261
  var StartV2Schema = z.object({
246
262
  tunnelID: z.string().nullable().optional(),
247
263
  tunnelConfig: TunnelConfigV1Schema
@@ -490,11 +506,11 @@ var TunnelOperations = class {
490
506
  startPromise.catch((err) => {
491
507
  logger.error("No-wait startTunnel failed", { tunnelid, err: String(err) });
492
508
  });
493
- return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.serve);
509
+ return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.optional?.serve);
494
510
  }
495
511
  await startPromise;
496
512
  const tunnelPconfig = await this.tunnelManager.getTunnelConfig("", tunnelid);
497
- return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.serve);
513
+ return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.optional?.serve);
498
514
  } catch (err) {
499
515
  if (err instanceof TunnelAlreadyRunningError) {
500
516
  return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
@@ -1491,16 +1507,18 @@ async function ensureDaemonRunning() {
1491
1507
  const existing = getDaemonInfo();
1492
1508
  if (existing) return existing;
1493
1509
  if (process.versions.electron) {
1494
- const { runDaemonChild } = await import("./daemonChild-KXERF36J.js");
1510
+ const { runDaemonChild } = await import("./daemonChild-WIRWKBKB.js");
1495
1511
  const handle = await runDaemonChild({
1496
1512
  installSignalHandlers: false,
1497
- exitOnFailure: false
1513
+ exitOnFailure: false,
1514
+ host: DaemonHost.APP
1498
1515
  });
1499
1516
  inProcessHandle = handle;
1500
1517
  return getDaemonInfo() ?? {
1501
1518
  pid: handle.pid,
1502
1519
  port: handle.port,
1503
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
1520
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1521
+ host: DaemonHost.APP
1504
1522
  };
1505
1523
  }
1506
1524
  return startDaemon();
@@ -1511,7 +1529,7 @@ function getInProcessDaemonHandle() {
1511
1529
  async function getActiveTunnelSummaries(origin = "app") {
1512
1530
  const info = getDaemonInfo();
1513
1531
  if (!info) return [];
1514
- const { IPCClient: IPCClient2 } = await import("./ipcClient-LZQCCNMR.js");
1532
+ const { IPCClient: IPCClient2 } = await import("./ipcClient-Z2RXDQUQ.js");
1515
1533
  const client = new IPCClient2(info.port, origin);
1516
1534
  let tunnels;
1517
1535
  try {
@@ -1558,10 +1576,17 @@ async function stopDaemon() {
1558
1576
  if (!info) return { ok: false, error: "No daemon is running." };
1559
1577
  let daemonErrors = [];
1560
1578
  try {
1561
- const { IPCClient: IPCClient2 } = await import("./ipcClient-LZQCCNMR.js");
1579
+ const { IPCClient: IPCClient2 } = await import("./ipcClient-Z2RXDQUQ.js");
1562
1580
  const client = new IPCClient2(info.port);
1563
1581
  const result = await client.shutdown();
1564
1582
  logger.debug("Sent shutdown command to daemon", { result });
1583
+ if (result?.status === ShutdownStatus.RefusedInApp) {
1584
+ return {
1585
+ ok: false,
1586
+ reason: DaemonHost.APP,
1587
+ error: "The daemon is running inside the Pinggy app."
1588
+ };
1589
+ }
1565
1590
  if (Array.isArray(result?.errors)) daemonErrors = result.errors;
1566
1591
  } catch (e) {
1567
1592
  return { ok: false, error: `Failed to reach daemon: ${errorMessage(e)}` };
@@ -1847,6 +1872,13 @@ var DaemonHealth = class {
1847
1872
  bindPid(pid) {
1848
1873
  this.originalPid = pid;
1849
1874
  }
1875
+ /**
1876
+ * Forget a previous loss verdict. Called when the client re-binds to a
1877
+ * live daemon via ensureDaemon():
1878
+ */
1879
+ reset() {
1880
+ this.lost = false;
1881
+ }
1850
1882
  isLost() {
1851
1883
  return this.lost;
1852
1884
  }
@@ -1951,6 +1983,7 @@ var TunnelClient = class _TunnelClient {
1951
1983
  const info = await ensureDaemonRunning();
1952
1984
  this.ipc = new IPCClient(info.port, this.origin);
1953
1985
  this.health.bindPid(info.pid);
1986
+ this.health.reset();
1954
1987
  }
1955
1988
  static async forRemoteManagement() {
1956
1989
  const client = new _TunnelClient({ origin: "remote" });
@@ -2024,6 +2057,21 @@ var TunnelClient = class _TunnelClient {
2024
2057
  await this.ipc.shutdown();
2025
2058
  this.close();
2026
2059
  }
2060
+ /**
2061
+ * Tear down the daemon hosted in THIS process, if any. Stops all tunnels
2062
+ * and removes daemon.json + daemon-state.json so the next CLI run doesn't
2063
+ * "crash-recover" tunnels that died with the host application.
2064
+ */
2065
+ shutdownInProcessDaemon() {
2066
+ const handle = getInProcessDaemonHandle();
2067
+ if (!handle) return false;
2068
+ try {
2069
+ this.close();
2070
+ } catch {
2071
+ }
2072
+ handle.shutdown();
2073
+ return true;
2074
+ }
2027
2075
  async getLogLevel() {
2028
2076
  this.assertClient();
2029
2077
  const res = await this.ipc.getLogLevel();
@@ -2139,6 +2187,8 @@ export {
2139
2187
  TunnelWarningCode,
2140
2188
  ErrorCode,
2141
2189
  isErrorResponse,
2190
+ TunnelConfigV1Schema,
2191
+ TUNNEL_CONFIG_V1_KEYS,
2142
2192
  TunnelOperations,
2143
2193
  RemoteManagementUnauthorizedError,
2144
2194
  buildRemoteManagementWsUrl,
@@ -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,