dev-memory-cli 0.18.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.
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+
3
+ const http = require("node:http");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+ const { spawn } = require("node:child_process");
8
+
9
+ const DEFAULT_STORAGE_ROOT = path.join(os.homedir(), ".dev-memory", "repos");
10
+ const APP_HTML_PATH = path.join(__dirname, "ui-app.html");
11
+
12
+ function getStorageRoot() {
13
+ return process.env.DEV_ASSETS_ROOT || DEFAULT_STORAGE_ROOT;
14
+ }
15
+
16
+ function safeReadDir(dir) {
17
+ try {
18
+ return fs.readdirSync(dir, { withFileTypes: true });
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function safeReadJson(p) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(p, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function safeStat(p) {
33
+ try {
34
+ return fs.statSync(p);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function listTextFiles(dir) {
41
+ const entries = safeReadDir(dir);
42
+ const files = [];
43
+ for (const ent of entries) {
44
+ if (!ent.isFile()) continue;
45
+ if (!ent.name.endsWith(".md") && ent.name !== "manifest.json") continue;
46
+ const full = path.join(dir, ent.name);
47
+ const st = safeStat(full);
48
+ files.push({
49
+ name: ent.name,
50
+ size: st?.size ?? 0,
51
+ mtime: st?.mtime ? st.mtime.toISOString() : null,
52
+ });
53
+ }
54
+ files.sort((a, b) => {
55
+ if (a.name === "manifest.json") return 1;
56
+ if (b.name === "manifest.json") return -1;
57
+ return a.name.localeCompare(b.name);
58
+ });
59
+ return files;
60
+ }
61
+
62
+ function manifestSummary(manifest) {
63
+ if (!manifest) return null;
64
+ return {
65
+ updated_at: manifest.updated_at ?? null,
66
+ last_update_title: manifest.last_update_title ?? null,
67
+ setup_completed: manifest.setup_completed ?? null,
68
+ schema_version: manifest.schema_version ?? null,
69
+ last_seen_head: manifest.last_seen_head ?? null,
70
+ last_recorded_commit: manifest.last_recorded_commit ?? null,
71
+ };
72
+ }
73
+
74
+ function buildBranchInfo(branchDir, branchName, archived) {
75
+ const manifest = safeReadJson(path.join(branchDir, "manifest.json"));
76
+ return {
77
+ name: branchName,
78
+ archived,
79
+ files: listTextFiles(branchDir),
80
+ manifest: manifestSummary(manifest),
81
+ };
82
+ }
83
+
84
+ function buildTree() {
85
+ const root = getStorageRoot();
86
+ const rootStat = safeStat(root);
87
+ if (!rootStat) {
88
+ return { storageRoot: root, exists: false, repos: [] };
89
+ }
90
+ const repos = [];
91
+ for (const ent of safeReadDir(root)) {
92
+ if (!ent.isDirectory()) continue;
93
+ const key = ent.name;
94
+ const repoDir = path.join(root, key);
95
+ const repoManifest = safeReadJson(path.join(repoDir, "repo", "manifest.json"));
96
+ const repoFiles = listTextFiles(path.join(repoDir, "repo"));
97
+
98
+ const branchesDir = path.join(repoDir, "branches");
99
+ const activeBranches = [];
100
+ const archivedBranches = [];
101
+ for (const b of safeReadDir(branchesDir)) {
102
+ if (!b.isDirectory()) continue;
103
+ if (b.name === "_archived") {
104
+ const archDir = path.join(branchesDir, "_archived");
105
+ for (const a of safeReadDir(archDir)) {
106
+ if (!a.isDirectory()) continue;
107
+ archivedBranches.push(buildBranchInfo(path.join(archDir, a.name), a.name, true));
108
+ }
109
+ } else {
110
+ activeBranches.push(buildBranchInfo(path.join(branchesDir, b.name), b.name, false));
111
+ }
112
+ }
113
+ activeBranches.sort((a, b) => a.name.localeCompare(b.name));
114
+ archivedBranches.sort((a, b) => a.name.localeCompare(b.name));
115
+
116
+ repos.push({
117
+ key,
118
+ repoRoot: repoManifest?.repo_root ?? null,
119
+ identity: repoManifest?.repo_identity ?? null,
120
+ updatedAt: repoManifest?.updated_at ?? null,
121
+ lastSeenBranch: repoManifest?.last_seen_branch ?? null,
122
+ repoLevel: {
123
+ files: repoFiles,
124
+ hasManifest: !!repoManifest,
125
+ },
126
+ branches: activeBranches,
127
+ archived: archivedBranches,
128
+ });
129
+ }
130
+ repos.sort((a, b) => {
131
+ const ta = a.updatedAt ?? "";
132
+ const tb = b.updatedAt ?? "";
133
+ return tb.localeCompare(ta);
134
+ });
135
+ return { storageRoot: root, exists: true, repos };
136
+ }
137
+
138
+ function resolveSafePath(relPath) {
139
+ const root = getStorageRoot();
140
+ if (!relPath || typeof relPath !== "string") return null;
141
+ const normalized = path.normalize(relPath).replace(/^[/\\]+/, "");
142
+ if (normalized.split(path.sep).includes("..")) return null;
143
+ const full = path.resolve(root, normalized);
144
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
145
+ if (!full.startsWith(rootWithSep)) return null;
146
+ if (!full.endsWith(".md") && !full.endsWith(".json")) return null;
147
+ return full;
148
+ }
149
+
150
+ const MAX_WRITE_BYTES = 5 * 1024 * 1024;
151
+
152
+ function readRequestBody(req) {
153
+ return new Promise((resolve, reject) => {
154
+ const chunks = [];
155
+ let total = 0;
156
+ let aborted = false;
157
+ req.on("data", (chunk) => {
158
+ if (aborted) return;
159
+ total += chunk.length;
160
+ if (total > MAX_WRITE_BYTES) {
161
+ aborted = true;
162
+ reject(new Error("payload too large"));
163
+ req.destroy();
164
+ return;
165
+ }
166
+ chunks.push(chunk);
167
+ });
168
+ req.on("end", () => {
169
+ if (!aborted) resolve(Buffer.concat(chunks).toString("utf8"));
170
+ });
171
+ req.on("error", (err) => {
172
+ if (!aborted) reject(err);
173
+ });
174
+ });
175
+ }
176
+
177
+ function writeFileSafely(fullPath, content) {
178
+ if (!fs.existsSync(fullPath)) {
179
+ return { ok: false, status: 404, error: "file not found" };
180
+ }
181
+ if (fullPath.endsWith(".json")) {
182
+ try {
183
+ JSON.parse(content);
184
+ } catch (err) {
185
+ return { ok: false, status: 400, error: `invalid JSON: ${err.message}` };
186
+ }
187
+ }
188
+ const tmp = `${fullPath}.tmp.${process.pid}.${Date.now()}`;
189
+ try {
190
+ fs.writeFileSync(tmp, content, "utf8");
191
+ fs.renameSync(tmp, fullPath);
192
+ } catch (err) {
193
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
194
+ return { ok: false, status: 500, error: `write failed: ${err.message}` };
195
+ }
196
+ const st = fs.statSync(fullPath);
197
+ return {
198
+ ok: true,
199
+ size: st.size,
200
+ mtime: st.mtime ? st.mtime.toISOString() : null,
201
+ };
202
+ }
203
+
204
+ function readAppHtml() {
205
+ return fs.readFileSync(APP_HTML_PATH, "utf8");
206
+ }
207
+
208
+ function openBrowser(url) {
209
+ const platform = process.platform;
210
+ let cmd;
211
+ let args;
212
+ if (platform === "darwin") {
213
+ cmd = "open";
214
+ args = [url];
215
+ } else if (platform === "win32") {
216
+ cmd = "cmd";
217
+ args = ["/c", "start", "", url];
218
+ } else {
219
+ cmd = "xdg-open";
220
+ args = [url];
221
+ }
222
+ try {
223
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
224
+ child.unref();
225
+ } catch {
226
+ // ignore — user can open the URL manually from stdout
227
+ }
228
+ }
229
+
230
+ function start({ host = "127.0.0.1", port = 0, openBrowserFlag = true, readOnly = false } = {}) {
231
+ const server = http.createServer((req, res) => {
232
+ let url;
233
+ try {
234
+ url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
235
+ } catch {
236
+ res.writeHead(400);
237
+ res.end("bad request");
238
+ return;
239
+ }
240
+ const method = req.method;
241
+ const isRead = method === "GET" || method === "HEAD";
242
+ const isFileWrite = method === "PUT" && url.pathname === "/api/file";
243
+ if (!isRead && !isFileWrite) {
244
+ res.writeHead(405);
245
+ res.end("method not allowed");
246
+ return;
247
+ }
248
+ if (url.pathname === "/") {
249
+ try {
250
+ const html = readAppHtml();
251
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
252
+ res.end(html);
253
+ } catch (err) {
254
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
255
+ res.end(`ui-app.html missing: ${err.message}`);
256
+ }
257
+ return;
258
+ }
259
+ if (url.pathname === "/api/tree") {
260
+ const tree = buildTree();
261
+ tree.readOnly = !!readOnly;
262
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
263
+ res.end(JSON.stringify(tree));
264
+ return;
265
+ }
266
+ if (url.pathname === "/api/file" && isRead) {
267
+ const rel = url.searchParams.get("path") || "";
268
+ const full = resolveSafePath(rel);
269
+ if (!full) {
270
+ res.writeHead(400, { "content-type": "text/plain" });
271
+ res.end("invalid path");
272
+ return;
273
+ }
274
+ try {
275
+ const body = fs.readFileSync(full, "utf8");
276
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
277
+ res.end(body);
278
+ } catch {
279
+ res.writeHead(404);
280
+ res.end("not found");
281
+ }
282
+ return;
283
+ }
284
+ if (isFileWrite) {
285
+ if (readOnly) {
286
+ res.writeHead(403, { "content-type": "text/plain" });
287
+ res.end("read-only mode");
288
+ return;
289
+ }
290
+ const rel = url.searchParams.get("path") || "";
291
+ const full = resolveSafePath(rel);
292
+ if (!full) {
293
+ res.writeHead(400, { "content-type": "text/plain" });
294
+ res.end("invalid path");
295
+ return;
296
+ }
297
+ readRequestBody(req).then((content) => {
298
+ const result = writeFileSafely(full, content);
299
+ if (!result.ok) {
300
+ res.writeHead(result.status || 500, { "content-type": "text/plain; charset=utf-8" });
301
+ res.end(result.error || "write failed");
302
+ return;
303
+ }
304
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
305
+ res.end(JSON.stringify({ ok: true, size: result.size, mtime: result.mtime }));
306
+ }).catch((err) => {
307
+ const status = err && err.message === "payload too large" ? 413 : 400;
308
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
309
+ res.end(err && err.message ? err.message : "bad request");
310
+ });
311
+ return;
312
+ }
313
+ res.writeHead(404);
314
+ res.end("not found");
315
+ });
316
+
317
+ server.listen(port, host, () => {
318
+ const addr = server.address();
319
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
320
+ const url = `http://${host}:${actualPort}`;
321
+ process.stdout.write(`dev-memory-cli ui: ${url}${readOnly ? " (read-only)" : ""}\n`);
322
+ process.stdout.write(`storage root: ${getStorageRoot()}\n`);
323
+ process.stdout.write(`press Ctrl+C to stop.\n`);
324
+ if (openBrowserFlag) openBrowser(url);
325
+ });
326
+
327
+ return server;
328
+ }
329
+
330
+ module.exports = { start, buildTree, getStorageRoot };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "dev-memory-cli",
3
+ "version": "0.18.2",
4
+ "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/xluos/dev-memory-skill-suite.git"
9
+ },
10
+ "homepage": "https://github.com/xluos/dev-memory-skill-suite#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/xluos/dev-memory-skill-suite/issues"
13
+ },
14
+ "scripts": {
15
+ "check": "node scripts/npm/check-package.mjs",
16
+ "build": "node scripts/npm/build-package.mjs",
17
+ "pack:dry-run": "npm pack --dry-run",
18
+ "publish:dry-run": "npm publish --access public --registry https://registry.npmjs.org --dry-run",
19
+ "publish:npm": "npm publish --access public --registry https://registry.npmjs.org",
20
+ "prepublishOnly": "npm run check"
21
+ },
22
+ "bin": {
23
+ "dev-memory-cli": "bin/dev-memory.js",
24
+ "dev-assets": "bin/dev-memory.js"
25
+ },
26
+ "files": [
27
+ "bin/dev-memory.js",
28
+ "hooks/*.json",
29
+ "hooks/README.md",
30
+ "lib/*.py",
31
+ "lib/ui-server.js",
32
+ "lib/ui-app.html",
33
+ "lib/assets/*",
34
+ "scripts/hooks/_common.py",
35
+ "scripts/hooks/session_start.py",
36
+ "scripts/hooks/pre_compact.py",
37
+ "scripts/hooks/stop.py",
38
+ "scripts/hooks/session_end.py",
39
+ "suite-manifest.json",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "dependencies": {
50
+ "@clack/prompts": "^1.3.0"
51
+ }
52
+ }