@hienlh/ppm 0.2.1 → 0.2.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/CHANGELOG.md +53 -0
- package/package.json +1 -1
- package/src/cli/commands/report.ts +1 -1
- package/src/cli/commands/status.ts +31 -22
- package/src/cli/commands/stop.ts +37 -29
- package/src/index.ts +2 -1
- package/src/server/index.ts +68 -64
- package/src/version.ts +7 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.2] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Single source of truth for version (`src/version.ts` reads from `package.json`)
|
|
7
|
+
- Version shown in daemon start output
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Version no longer hardcoded in multiple files
|
|
11
|
+
|
|
12
|
+
## [0.2.1] - 2026-03-15
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `ppm logs` command — view daemon logs (`-n`, `-f`, `--clear`)
|
|
16
|
+
- `ppm report` command — open GitHub issue pre-filled with env info + logs
|
|
17
|
+
- Daemon stdout/stderr written to `~/.ppm/ppm.log`
|
|
18
|
+
- Frontend health check — detects server crash, prompts bug report
|
|
19
|
+
- Sensitive data (tokens, passwords, API keys) auto-redacted from logs
|
|
20
|
+
- `/api/logs/recent` public endpoint for bug reports
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-03-15
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `--share` / `-s` flag — public URL via Cloudflare Quick Tunnel
|
|
26
|
+
- Default daemon mode — `ppm start` runs in background
|
|
27
|
+
- `--foreground` / `-f` flag — opt-in foreground mode
|
|
28
|
+
- `ppm status` command with `--json` flag and QR code
|
|
29
|
+
- `ppm init` — interactive setup (port, auth, password, share, AI settings)
|
|
30
|
+
- Auto-init on first `ppm start`
|
|
31
|
+
- Non-interactive init via flags (`-y`, `--port`, `--auth`, `--password`)
|
|
32
|
+
- `device_name` config — shown in sidebar, login screen, page title
|
|
33
|
+
- `/api/info` public endpoint (version + device name)
|
|
34
|
+
- QR code for share URL in terminal
|
|
35
|
+
- Auth warning when sharing with auth disabled
|
|
36
|
+
- Cloudflared auto-download (macOS .tgz + Linux binary)
|
|
37
|
+
- Tunnel runs as independent process — survives server crash
|
|
38
|
+
- `ppm start --share` reuses existing tunnel if alive
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- `ppm start` defaults to daemon mode (was opt-in)
|
|
42
|
+
- Status file `~/.ppm/status.json` replaces `ppm.pid` (with fallback)
|
|
43
|
+
- `ppm stop` kills both server and tunnel, cleans up files
|
|
44
|
+
|
|
45
|
+
## [0.1.6] - 2026-03-15
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- Configurable AI provider settings via `ppm.yaml`, API, and UI
|
|
49
|
+
- Chat tool UI improvements, diff viewer, git panels, editor enhancements
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- Global focus-visible outline causing blue ring on all inputs
|
|
53
|
+
- Unified input styles across app
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import { getRecentLogs } from "./logs.ts";
|
|
|
6
6
|
const REPO = "hienlh/ppm";
|
|
7
7
|
|
|
8
8
|
export async function reportBug() {
|
|
9
|
-
const version = "
|
|
9
|
+
const { VERSION: version } = await import("../../version.ts");
|
|
10
10
|
const logs = getRecentLogs(30);
|
|
11
11
|
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
12
12
|
let statusInfo = "(not running)";
|
|
@@ -11,40 +11,46 @@ interface DaemonStatus {
|
|
|
11
11
|
port: number | null;
|
|
12
12
|
host: string | null;
|
|
13
13
|
shareUrl: string | null;
|
|
14
|
+
tunnelPid: number | null;
|
|
15
|
+
tunnelAlive: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isAlive(pid: number): boolean {
|
|
19
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
function getDaemonStatus(): DaemonStatus {
|
|
17
|
-
const
|
|
23
|
+
const dead: DaemonStatus = {
|
|
24
|
+
running: false, pid: null, port: null, host: null,
|
|
25
|
+
shareUrl: null, tunnelPid: null, tunnelAlive: false,
|
|
26
|
+
};
|
|
18
27
|
|
|
19
|
-
// Try status.json first
|
|
20
28
|
if (existsSync(STATUS_FILE)) {
|
|
21
29
|
try {
|
|
22
30
|
const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
|
|
23
31
|
const pid = data.pid as number;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const tunnelPid = (data.tunnelPid as number) ?? null;
|
|
33
|
+
const tunnelAlive = tunnelPid ? isAlive(tunnelPid) : false;
|
|
34
|
+
return {
|
|
35
|
+
running: isAlive(pid),
|
|
36
|
+
pid,
|
|
37
|
+
port: data.port,
|
|
38
|
+
host: data.host,
|
|
39
|
+
shareUrl: data.shareUrl ?? null,
|
|
40
|
+
tunnelPid,
|
|
41
|
+
tunnelAlive,
|
|
42
|
+
};
|
|
43
|
+
} catch { return dead; }
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
// Fallback to ppm.pid
|
|
37
46
|
if (existsSync(PID_FILE)) {
|
|
38
47
|
try {
|
|
39
48
|
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} catch {
|
|
43
|
-
return notRunning;
|
|
44
|
-
}
|
|
49
|
+
return { ...dead, running: isAlive(pid), pid };
|
|
50
|
+
} catch { return dead; }
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
return
|
|
53
|
+
return dead;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export async function showStatus(options: { json?: boolean }) {
|
|
@@ -55,14 +61,17 @@ export async function showStatus(options: { json?: boolean }) {
|
|
|
55
61
|
return;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
if (!status.running) {
|
|
64
|
+
if (!status.running && !status.tunnelAlive) {
|
|
59
65
|
console.log(" PPM is not running.");
|
|
60
66
|
return;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
console.log(`\n PPM daemon
|
|
64
|
-
console.log(` PID:
|
|
69
|
+
console.log(`\n PPM daemon status\n`);
|
|
70
|
+
console.log(` Server: ${status.running ? "running" : "stopped"} (PID: ${status.pid})`);
|
|
65
71
|
if (status.port) console.log(` Local: http://localhost:${status.port}/`);
|
|
72
|
+
if (status.tunnelPid) {
|
|
73
|
+
console.log(` Tunnel: ${status.tunnelAlive ? "running" : "stopped"} (PID: ${status.tunnelPid})`);
|
|
74
|
+
}
|
|
66
75
|
if (status.shareUrl) {
|
|
67
76
|
console.log(` Share: ${status.shareUrl}`);
|
|
68
77
|
const qr = await import("qrcode-terminal");
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -5,43 +5,51 @@ import { readFileSync, unlinkSync, existsSync } from "node:fs";
|
|
|
5
5
|
const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
|
|
6
6
|
const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
|
|
7
7
|
|
|
8
|
+
function killPid(pid: number, label: string): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid);
|
|
11
|
+
console.log(` Stopped ${label} (PID: ${pid})`);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
const err = e as NodeJS.ErrnoException;
|
|
15
|
+
if (err.code !== "ESRCH") console.error(` Failed to stop ${label}: ${err.message}`);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
export async function stopServer() {
|
|
9
|
-
let
|
|
21
|
+
let status: { pid?: number; tunnelPid?: number } | null = null;
|
|
10
22
|
|
|
11
|
-
//
|
|
23
|
+
// Read status.json
|
|
12
24
|
if (existsSync(STATUS_FILE)) {
|
|
13
|
-
try {
|
|
14
|
-
const status = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
|
|
15
|
-
pid = status.pid;
|
|
16
|
-
} catch {}
|
|
25
|
+
try { status = JSON.parse(readFileSync(STATUS_FILE, "utf-8")); } catch {}
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
// Fallback to ppm.pid
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
// Fallback to ppm.pid
|
|
29
|
+
const pidFromFile = existsSync(PID_FILE)
|
|
30
|
+
? parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10)
|
|
31
|
+
: NaN;
|
|
32
|
+
|
|
33
|
+
const serverPid = status?.pid ?? (isNaN(pidFromFile) ? null : pidFromFile);
|
|
34
|
+
const tunnelPid = status?.tunnelPid ?? null;
|
|
23
35
|
|
|
24
|
-
if (!
|
|
36
|
+
if (!serverPid && !tunnelPid) {
|
|
25
37
|
console.log("No PPM daemon running.");
|
|
26
|
-
|
|
27
|
-
if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
|
|
28
|
-
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
|
38
|
+
cleanup();
|
|
29
39
|
return;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
}
|
|
42
|
+
// Kill server process
|
|
43
|
+
if (serverPid) killPid(serverPid, "server");
|
|
44
|
+
|
|
45
|
+
// Kill tunnel process (independent from server)
|
|
46
|
+
if (tunnelPid) killPid(tunnelPid, "tunnel");
|
|
47
|
+
|
|
48
|
+
cleanup();
|
|
49
|
+
console.log("PPM stopped.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cleanup() {
|
|
53
|
+
if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
|
|
54
|
+
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
|
47
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { VERSION } from "./version.ts";
|
|
3
4
|
|
|
4
5
|
const program = new Command();
|
|
5
6
|
|
|
6
7
|
program
|
|
7
8
|
.name("ppm")
|
|
8
9
|
.description("Personal Project Manager — mobile-first web IDE")
|
|
9
|
-
.version(
|
|
10
|
+
.version(VERSION);
|
|
10
11
|
|
|
11
12
|
program
|
|
12
13
|
.command("start")
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import { configService } from "../services/config.service.ts";
|
|
4
|
+
import { VERSION } from "../version.ts";
|
|
4
5
|
import { authMiddleware } from "./middleware/auth.ts";
|
|
5
6
|
import { projectRoutes } from "./routes/projects.ts";
|
|
6
7
|
import { settingsRoutes } from "./routes/settings.ts";
|
|
@@ -61,7 +62,7 @@ app.use("*", cors());
|
|
|
61
62
|
// Public endpoints (before auth)
|
|
62
63
|
app.get("/api/health", (c) => c.json(ok({ status: "running" })));
|
|
63
64
|
app.get("/api/info", (c) => c.json(ok({
|
|
64
|
-
version:
|
|
65
|
+
version: VERSION,
|
|
65
66
|
device_name: configService.get("device_name") || null,
|
|
66
67
|
})));
|
|
67
68
|
|
|
@@ -129,55 +130,87 @@ export async function startServer(options: {
|
|
|
129
130
|
const pidFile = resolve(ppmDir, "ppm.pid");
|
|
130
131
|
const statusFile = resolve(ppmDir, "status.json");
|
|
131
132
|
|
|
132
|
-
// If --share, download cloudflared
|
|
133
|
+
// If --share, download cloudflared and start tunnel as independent process
|
|
134
|
+
let shareUrl: string | undefined;
|
|
135
|
+
let tunnelPid: number | undefined;
|
|
133
136
|
if (options.share) {
|
|
134
137
|
const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
|
|
135
|
-
await ensureCloudflared();
|
|
138
|
+
const bin = await ensureCloudflared();
|
|
139
|
+
|
|
140
|
+
// Check if tunnel already running (reuse from previous server crash)
|
|
141
|
+
if (existsSync(statusFile)) {
|
|
142
|
+
try {
|
|
143
|
+
const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
144
|
+
if (prev.tunnelPid && prev.shareUrl) {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(prev.tunnelPid, 0); // Check alive
|
|
147
|
+
console.log(` Reusing existing tunnel (PID: ${prev.tunnelPid})`);
|
|
148
|
+
shareUrl = prev.shareUrl;
|
|
149
|
+
tunnelPid = prev.tunnelPid;
|
|
150
|
+
} catch { /* tunnel dead, spawn new one */ }
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Spawn new tunnel if no existing one
|
|
156
|
+
if (!shareUrl) {
|
|
157
|
+
console.log(" Starting share tunnel...");
|
|
158
|
+
const { openSync: openFd } = await import("node:fs");
|
|
159
|
+
const tunnelLog = resolve(ppmDir, "tunnel.log");
|
|
160
|
+
const tfd = openFd(tunnelLog, "a");
|
|
161
|
+
const tunnelProc = Bun.spawn({
|
|
162
|
+
cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
|
|
163
|
+
stdio: ["ignore", "ignore", tfd],
|
|
164
|
+
env: process.env,
|
|
165
|
+
});
|
|
166
|
+
tunnelProc.unref();
|
|
167
|
+
tunnelPid = tunnelProc.pid;
|
|
168
|
+
|
|
169
|
+
// Parse URL from tunnel.log (poll stderr output)
|
|
170
|
+
const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
171
|
+
const pollStart = Date.now();
|
|
172
|
+
while (Date.now() - pollStart < 30_000) {
|
|
173
|
+
await Bun.sleep(500);
|
|
174
|
+
try {
|
|
175
|
+
const logContent = readFileSync(tunnelLog, "utf-8");
|
|
176
|
+
const match = logContent.match(urlRegex);
|
|
177
|
+
if (match) { shareUrl = match[0]; break; }
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected.");
|
|
181
|
+
}
|
|
136
182
|
}
|
|
137
183
|
|
|
138
|
-
// Spawn child process with log file
|
|
184
|
+
// Spawn server child process with log file
|
|
139
185
|
const { openSync } = await import("node:fs");
|
|
140
186
|
const logFile = resolve(ppmDir, "ppm.log");
|
|
141
187
|
const logFd = openSync(logFile, "a");
|
|
142
188
|
const child = Bun.spawn({
|
|
143
189
|
cmd: [
|
|
144
190
|
process.execPath, "run", import.meta.dir + "/index.ts", "__serve__",
|
|
145
|
-
String(port), host, options.config ?? "",
|
|
191
|
+
String(port), host, options.config ?? "",
|
|
146
192
|
],
|
|
147
193
|
stdio: ["ignore", logFd, logFd],
|
|
148
194
|
env: process.env,
|
|
149
195
|
});
|
|
150
196
|
child.unref();
|
|
151
|
-
writeFileSync(pidFile, String(child.pid));
|
|
152
197
|
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (existsSync(statusFile)) {
|
|
158
|
-
try {
|
|
159
|
-
status = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
160
|
-
break;
|
|
161
|
-
} catch { /* file not fully written yet */ }
|
|
162
|
-
}
|
|
163
|
-
await Bun.sleep(200);
|
|
164
|
-
}
|
|
198
|
+
// Write status file with both PIDs
|
|
199
|
+
const status = { pid: child.pid, port, host, shareUrl, tunnelPid };
|
|
200
|
+
writeFileSync(statusFile, JSON.stringify(status));
|
|
201
|
+
writeFileSync(pidFile, String(child.pid));
|
|
165
202
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
|
|
174
|
-
}
|
|
175
|
-
const qr = await import("qrcode-terminal");
|
|
176
|
-
console.log();
|
|
177
|
-
qr.generate(status.shareUrl, { small: true });
|
|
203
|
+
console.log(`\n PPM v${VERSION} daemon started (PID: ${child.pid})\n`);
|
|
204
|
+
console.log(` ➜ Local: http://localhost:${port}/`);
|
|
205
|
+
if (shareUrl) {
|
|
206
|
+
console.log(` ➜ Share: ${shareUrl}`);
|
|
207
|
+
if (!configService.get("auth").enabled) {
|
|
208
|
+
console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
|
|
209
|
+
console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
|
|
178
210
|
}
|
|
179
|
-
|
|
180
|
-
console.log(
|
|
211
|
+
const qr = await import("qrcode-terminal");
|
|
212
|
+
console.log();
|
|
213
|
+
qr.generate(shareUrl, { small: true });
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
process.exit(0);
|
|
@@ -235,7 +268,7 @@ export async function startServer(options: {
|
|
|
235
268
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
236
269
|
});
|
|
237
270
|
|
|
238
|
-
console.log(`\n PPM
|
|
271
|
+
console.log(`\n PPM v${VERSION} ready\n`);
|
|
239
272
|
console.log(` ➜ Local: http://localhost:${server.port}/`);
|
|
240
273
|
|
|
241
274
|
const { networkInterfaces } = await import("node:os");
|
|
@@ -281,16 +314,9 @@ if (process.argv.includes("__serve__")) {
|
|
|
281
314
|
const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
|
|
282
315
|
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
283
316
|
const configPath = process.argv[idx + 3] || undefined;
|
|
284
|
-
const shareFlag = process.argv[idx + 4] === "share";
|
|
285
317
|
|
|
286
318
|
configService.load(configPath);
|
|
287
|
-
|
|
288
|
-
const { resolve } = await import("node:path");
|
|
289
|
-
const { homedir } = await import("node:os");
|
|
290
|
-
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
291
|
-
|
|
292
|
-
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
293
|
-
const pidFile = resolve(homedir(), ".ppm", "ppm.pid");
|
|
319
|
+
await setupLogFile();
|
|
294
320
|
|
|
295
321
|
Bun.serve({
|
|
296
322
|
port,
|
|
@@ -342,27 +368,5 @@ if (process.argv.includes("__serve__")) {
|
|
|
342
368
|
} as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
|
|
343
369
|
});
|
|
344
370
|
|
|
345
|
-
|
|
346
|
-
let shareUrl: string | undefined;
|
|
347
|
-
const tunnel = shareFlag
|
|
348
|
-
? (await import("../services/tunnel.service.ts")).tunnelService
|
|
349
|
-
: null;
|
|
350
|
-
if (tunnel) {
|
|
351
|
-
try {
|
|
352
|
-
shareUrl = await tunnel.startTunnel(port);
|
|
353
|
-
} catch { /* non-fatal: server runs without share URL */ }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Write status file for parent to read
|
|
357
|
-
writeFileSync(statusFile, JSON.stringify({ pid: process.pid, port, host, shareUrl }));
|
|
358
|
-
|
|
359
|
-
// Cleanup on exit
|
|
360
|
-
const cleanup = () => {
|
|
361
|
-
try { unlinkSync(statusFile); } catch {}
|
|
362
|
-
try { unlinkSync(pidFile); } catch {}
|
|
363
|
-
tunnel?.stopTunnel();
|
|
364
|
-
process.exit(0);
|
|
365
|
-
};
|
|
366
|
-
process.on("SIGINT", cleanup);
|
|
367
|
-
process.on("SIGTERM", cleanup);
|
|
371
|
+
console.log(`Server child ready on port ${port}`);
|
|
368
372
|
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(readFileSync(resolve(import.meta.dir, "../package.json"), "utf-8"));
|
|
5
|
+
|
|
6
|
+
/** App version from package.json — single source of truth */
|
|
7
|
+
export const VERSION: string = pkg.version;
|