@launchsecure/launch-kit 0.0.5 → 0.0.6

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.
@@ -29,17 +29,36 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
29
29
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
30
30
  mod
31
31
  ));
32
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
33
 
33
34
  // src/server/lockfile.ts
34
- function lockDir() {
35
+ function lockDir(projectRoot) {
36
+ if (projectRoot) {
37
+ return (0, import_node_path.join)(projectRoot, ".launchsecure");
38
+ }
35
39
  return (0, import_node_path.join)((0, import_node_os.homedir)(), ".launchsecure");
36
40
  }
37
- function lockPath() {
38
- return (0, import_node_path.join)(lockDir(), "launch-chart.lock");
41
+ function lockPath(projectRoot) {
42
+ return (0, import_node_path.join)(lockDir(projectRoot), "launch-chart.lock");
39
43
  }
40
- function readLock() {
41
- const p = lockPath();
42
- if (!(0, import_node_fs.existsSync)(p)) return null;
44
+ function readLock(projectRoot) {
45
+ const root = projectRoot ?? _activeProjectRoot;
46
+ const p = lockPath(root);
47
+ if (!(0, import_node_fs.existsSync)(p)) {
48
+ if (root) {
49
+ const globalP = lockPath();
50
+ if ((0, import_node_fs.existsSync)(globalP)) {
51
+ try {
52
+ const data = JSON.parse((0, import_node_fs.readFileSync)(globalP, "utf-8"));
53
+ if (typeof data.pid === "number" && typeof data.port === "number" && data.cwd === root) {
54
+ return data;
55
+ }
56
+ } catch {
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
43
62
  try {
44
63
  const data = JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
45
64
  if (typeof data.pid !== "number" || typeof data.port !== "number") return null;
@@ -70,31 +89,35 @@ function getListenerPid(port) {
70
89
  return null;
71
90
  }
72
91
  }
73
- function getLiveLock() {
74
- const lock = readLock();
92
+ function getLiveLock(projectRoot) {
93
+ const root = projectRoot ?? _activeProjectRoot;
94
+ const lock = readLock(root);
75
95
  if (!lock) return null;
76
96
  const listenerPid = getListenerPid(lock.port);
77
97
  const live = listenerPid !== null ? listenerPid === lock.pid : isPidAlive(lock.pid);
78
98
  if (!live) {
79
99
  try {
80
- (0, import_node_fs.unlinkSync)(lockPath());
100
+ (0, import_node_fs.unlinkSync)(lockPath(root));
81
101
  } catch {
82
102
  }
83
103
  return null;
84
104
  }
85
105
  return lock;
86
106
  }
87
- function writeLock(data) {
88
- (0, import_node_fs.mkdirSync)(lockDir(), { recursive: true });
89
- (0, import_node_fs.writeFileSync)(lockPath(), JSON.stringify(data, null, 2) + "\n", "utf-8");
107
+ function writeLock(data, projectRoot) {
108
+ const root = projectRoot ?? _activeProjectRoot;
109
+ (0, import_node_fs.mkdirSync)(lockDir(root), { recursive: true });
110
+ (0, import_node_fs.writeFileSync)(lockPath(root), JSON.stringify(data, null, 2) + "\n", "utf-8");
111
+ if (root) _activeProjectRoot = root;
90
112
  }
91
- function clearLock() {
113
+ function clearLock(projectRoot) {
114
+ const root = projectRoot ?? _activeProjectRoot;
92
115
  try {
93
- (0, import_node_fs.unlinkSync)(lockPath());
116
+ (0, import_node_fs.unlinkSync)(lockPath(root));
94
117
  } catch {
95
118
  }
96
119
  }
97
- var import_node_child_process, import_node_fs, import_node_os, import_node_path;
120
+ var import_node_child_process, import_node_fs, import_node_os, import_node_path, _activeProjectRoot;
98
121
  var init_lockfile = __esm({
99
122
  "src/server/lockfile.ts"() {
100
123
  "use strict";
@@ -106,6 +129,10 @@ var init_lockfile = __esm({
106
129
  });
107
130
 
108
131
  // src/server/graph/core/config.ts
132
+ var config_exports = {};
133
+ __export(config_exports, {
134
+ loadConfig: () => loadConfig
135
+ });
109
136
  function loadConfig(rootDir) {
110
137
  const configPath = (0, import_node_path2.join)(rootDir, CONFIG_FILENAME);
111
138
  if (!(0, import_node_fs2.existsSync)(configPath)) return {};
@@ -586,34 +613,6 @@ function classifyType(id) {
586
613
  if (id.startsWith("lib/") || id.startsWith("config/")) return "lib";
587
614
  return "component";
588
615
  }
589
- function classifyModule(id) {
590
- if (/app\/\(auth\)\//.test(id)) return "auth";
591
- if (/app\/\(admin\)\//.test(id)) return "admin";
592
- if (/app\/\(settings\)\//.test(id)) return "settings";
593
- if (/app\/\(app\)\/\[orgSlug\]\/\(project-pages\)\//.test(id)) return "project";
594
- if (/app\/\(app\)\/\[orgSlug\]\/\(org-pages\)\//.test(id)) return "org";
595
- if (/app\/\(app\)\/\[orgSlug\]\//.test(id)) return "org";
596
- if (id.startsWith("app/integrations/")) return "integrations";
597
- if (id.startsWith("app/docs/")) return "admin";
598
- if (id.startsWith("client/components/ui/")) return "shared-ui";
599
- if (id.startsWith("client/components/layout/") || /client\/lib\/navigation/.test(id)) return "layout";
600
- if (/client\/components\/auth\//.test(id) || /client\/lib\/auth-/.test(id) || /client\/lib\/github-oauth/.test(id) || /client\/lib\/permission-service/.test(id) || /client\/hooks\/use-permissions/.test(id)) return "auth";
601
- if (/client\/components\/prd-/.test(id) || /client\/hooks\/use-admin/.test(id)) return "admin";
602
- if (/client\/components\/org-/.test(id) || /client\/hooks\/use-org-/.test(id) || /client\/hooks\/use-provider-def/.test(id)) return "org";
603
- if (/client\/components\/project/.test(id) || /client\/hooks\/use-project-/.test(id) || /client\/hooks\/use-pipeline/.test(id) || /client\/hooks\/use-databases/.test(id) || /client\/hooks\/use-provider-env/.test(id) || /client\/hooks\/use-role-assign/.test(id) || /client\/components\/pipeline/.test(id) || /client\/components\/deployments/.test(id)) return "project";
604
- if (/client\/hooks\/use-(profile|sessions|organizations|notification)/.test(id)) return "settings";
605
- if (id.startsWith("server/auth/")) return "auth";
606
- if (id.startsWith("server/mcp/")) return "mcp";
607
- if (id.startsWith("server/lib/")) return "server-lib";
608
- if (id.startsWith("server/middleware")) return "middleware";
609
- if (id.startsWith("server/services/")) return "services";
610
- if (id.startsWith("server/db")) return "db";
611
- if (id.startsWith("server/errors")) return "errors";
612
- if (id.startsWith("server/")) return "server-lib";
613
- if (id.startsWith("config/")) return "config";
614
- if (id.startsWith("lib/")) return "lib";
615
- return "root";
616
- }
617
616
  function extractRoute(id) {
618
617
  if (!id.endsWith("/page.tsx")) return null;
619
618
  let route = id.replace(/^app\//, "/").replace(/\/page\.tsx$/, "");
@@ -834,8 +833,7 @@ function generate(rootDir) {
834
833
  const parsed = parsedByPath.get(absPath);
835
834
  const name = parsed.name || nameFromFilename(absPath);
836
835
  const route = extractRoute(id);
837
- const module_ = classifyModule(id);
838
- nodes.push({ id, type, name, route, module: module_, exports: parsed.exports });
836
+ nodes.push({ id, type, name, route, exports: parsed.exports });
839
837
  nodeIdSet.add(id);
840
838
  nodeTypeMap.set(id, type);
841
839
  if (route) routeToNodeId.set(route, id);
@@ -939,7 +937,6 @@ function generate(rootDir) {
939
937
  type: "external",
940
938
  name: parsed.name || nameFromFilename(absPath),
941
939
  route: null,
942
- module: "external",
943
940
  exports: parsed.exports
944
941
  });
945
942
  nodeIdSet.add(externalId);
@@ -2031,29 +2028,421 @@ var init_graph_builder = __esm({
2031
2028
  }
2032
2029
  });
2033
2030
 
2031
+ // src/server/graph/taggers/module-tagger.ts
2032
+ function matchGlob(pattern, id) {
2033
+ const patParts = pattern.split("/");
2034
+ const idParts = id.split("/");
2035
+ return matchParts(patParts, 0, idParts, 0);
2036
+ }
2037
+ function matchParts(pat, pi, id, ii) {
2038
+ while (pi < pat.length && ii < id.length) {
2039
+ const p = pat[pi];
2040
+ if (p === "**") {
2041
+ for (let skip = ii; skip <= id.length; skip++) {
2042
+ if (matchParts(pat, pi + 1, id, skip)) return true;
2043
+ }
2044
+ return false;
2045
+ }
2046
+ if (p === "*") {
2047
+ pi++;
2048
+ ii++;
2049
+ continue;
2050
+ }
2051
+ if (p !== id[ii]) return false;
2052
+ pi++;
2053
+ ii++;
2054
+ }
2055
+ while (pi < pat.length && pat[pi] === "**") pi++;
2056
+ return pi === pat.length && ii === id.length;
2057
+ }
2058
+ function detectConventionDirs(rootDir) {
2059
+ const result = /* @__PURE__ */ new Map();
2060
+ const searchDirs = [
2061
+ rootDir,
2062
+ (0, import_node_path11.join)(rootDir, "src"),
2063
+ (0, import_node_path11.join)(rootDir, "app"),
2064
+ (0, import_node_path11.join)(rootDir, "lib")
2065
+ ];
2066
+ for (const base of searchDirs) {
2067
+ for (const convention of CONVENTION_DIRS) {
2068
+ const dir = (0, import_node_path11.join)(base, convention);
2069
+ if (!(0, import_node_fs10.existsSync)(dir)) continue;
2070
+ try {
2071
+ const stat = (0, import_node_fs10.statSync)(dir);
2072
+ if (!stat.isDirectory()) continue;
2073
+ const entries = (0, import_node_fs10.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
2074
+ if (entries.length > 0) {
2075
+ const relPath = dir.replace(rootDir + "/", "").replace(rootDir + "\\", "");
2076
+ result.set(relPath, entries);
2077
+ }
2078
+ } catch {
2079
+ }
2080
+ }
2081
+ }
2082
+ return result;
2083
+ }
2084
+ function extractRouteGroups(id) {
2085
+ const groups = [];
2086
+ const re = /\(([^)]+)\)/g;
2087
+ let m;
2088
+ while ((m = re.exec(id)) !== null) {
2089
+ groups.push(m[1]);
2090
+ }
2091
+ return groups;
2092
+ }
2093
+ function isRouteGroup(segment) {
2094
+ return segment.startsWith("(") && segment.endsWith(")");
2095
+ }
2096
+ function isDynamicSegment(segment) {
2097
+ return segment.startsWith("[") || segment.startsWith(":");
2098
+ }
2099
+ function isDomainDir(segment) {
2100
+ return segment.includes(".") && !segment.endsWith(".tsx") && !segment.endsWith(".ts") && !segment.endsWith(".js") && !segment.endsWith(".jsx") && !segment.endsWith(".vue");
2101
+ }
2102
+ function isTrivialGroup(name) {
2103
+ if (TRIVIAL_GROUPS.has(name)) return true;
2104
+ const lower = name.toLowerCase();
2105
+ const wrapperPatterns = [
2106
+ /^.*-?wrapper$/,
2107
+ // "page-wrapper", "use-page-wrapper"
2108
+ /^.*-?layout$/,
2109
+ // "admin-layout", "settings-layout"
2110
+ /^use-/,
2111
+ // "use-page-wrapper"
2112
+ /^default$/
2113
+ ];
2114
+ return wrapperPatterns.some((p) => p.test(lower));
2115
+ }
2116
+ function normalizeGroupName(name) {
2117
+ return name.replace(/-pages?$/, "").replace(/-layout$/, "").replace(/-wrapper$/, "");
2118
+ }
2119
+ function extractModuleFromPath(id) {
2120
+ const segments = id.split("/");
2121
+ const routeGroups = extractRouteGroups(id);
2122
+ const moduleGroups = routeGroups.filter((g) => !isTrivialGroup(g)).map(normalizeGroupName);
2123
+ if (moduleGroups.length > 0) {
2124
+ return moduleGroups[moduleGroups.length - 1];
2125
+ }
2126
+ const meaningful = [];
2127
+ for (const seg of segments) {
2128
+ if (seg.includes(".")) continue;
2129
+ if (isRouteGroup(seg)) continue;
2130
+ if (isDynamicSegment(seg)) continue;
2131
+ if (isDomainDir(seg)) continue;
2132
+ if (SKIP_SEGMENTS.has(seg)) continue;
2133
+ meaningful.push(seg);
2134
+ }
2135
+ if (meaningful.length > 0) {
2136
+ return meaningful[0];
2137
+ }
2138
+ return "root";
2139
+ }
2140
+ var import_node_fs10, import_node_path11, CONVENTION_DIRS, SKIP_SEGMENTS, TRIVIAL_GROUPS, cachedRootDir, cachedConventionDirs, moduleTagger;
2141
+ var init_module_tagger = __esm({
2142
+ "src/server/graph/taggers/module-tagger.ts"() {
2143
+ "use strict";
2144
+ import_node_fs10 = require("node:fs");
2145
+ import_node_path11 = require("node:path");
2146
+ CONVENTION_DIRS = ["features", "modules", "domains", "areas"];
2147
+ SKIP_SEGMENTS = /* @__PURE__ */ new Set([
2148
+ "src",
2149
+ "app",
2150
+ "client",
2151
+ "server",
2152
+ "lib",
2153
+ "config"
2154
+ ]);
2155
+ TRIVIAL_GROUPS = /* @__PURE__ */ new Set([
2156
+ "app",
2157
+ "all",
2158
+ "ee",
2159
+ "home",
2160
+ "root"
2161
+ ]);
2162
+ cachedRootDir = null;
2163
+ cachedConventionDirs = /* @__PURE__ */ new Map();
2164
+ moduleTagger = {
2165
+ id: "module",
2166
+ tagKey: "module",
2167
+ trackUntagged: true,
2168
+ layers: null,
2169
+ // applies to all layers
2170
+ tag(nodes, layer, rootDir) {
2171
+ if (cachedRootDir !== rootDir) {
2172
+ cachedConventionDirs = detectConventionDirs(rootDir);
2173
+ cachedRootDir = rootDir;
2174
+ }
2175
+ let configRules = [];
2176
+ try {
2177
+ const { loadConfig: loadConfig2 } = (init_config(), __toCommonJS(config_exports));
2178
+ const config = loadConfig2(rootDir);
2179
+ configRules = config.taggers?.module?.rules ?? [];
2180
+ } catch {
2181
+ }
2182
+ const result = /* @__PURE__ */ new Map();
2183
+ for (const node of nodes) {
2184
+ const id = node.id;
2185
+ let matched = false;
2186
+ for (const rule of configRules) {
2187
+ if (matchGlob(rule.match, id)) {
2188
+ result.set(id, rule.module);
2189
+ matched = true;
2190
+ break;
2191
+ }
2192
+ }
2193
+ if (matched) continue;
2194
+ matched = false;
2195
+ for (const [convDir, moduleNames] of cachedConventionDirs) {
2196
+ if (id.startsWith(convDir + "/")) {
2197
+ const rest = id.slice(convDir.length + 1);
2198
+ const firstSeg = rest.split("/")[0];
2199
+ if (moduleNames.includes(firstSeg)) {
2200
+ result.set(id, firstSeg);
2201
+ matched = true;
2202
+ break;
2203
+ }
2204
+ }
2205
+ }
2206
+ if (matched) continue;
2207
+ const module2 = extractModuleFromPath(id);
2208
+ result.set(id, module2);
2209
+ }
2210
+ return result;
2211
+ }
2212
+ };
2213
+ }
2214
+ });
2215
+
2216
+ // src/server/graph/taggers/screen-tagger.ts
2217
+ var SCREEN_TYPES, screenTagger;
2218
+ var init_screen_tagger = __esm({
2219
+ "src/server/graph/taggers/screen-tagger.ts"() {
2220
+ "use strict";
2221
+ SCREEN_TYPES = /* @__PURE__ */ new Set(["page", "layout"]);
2222
+ screenTagger = {
2223
+ id: "screen",
2224
+ tagKey: "screen",
2225
+ trackUntagged: true,
2226
+ layers: ["ui"],
2227
+ tag(nodes, layer) {
2228
+ if (layer !== "ui") return /* @__PURE__ */ new Map();
2229
+ const result = /* @__PURE__ */ new Map();
2230
+ for (const node of nodes) {
2231
+ if (SCREEN_TYPES.has(node.type)) {
2232
+ result.set(node.id, "true");
2233
+ }
2234
+ }
2235
+ return result;
2236
+ }
2237
+ };
2238
+ }
2239
+ });
2240
+
2241
+ // src/server/graph/core/tagger-registry.ts
2242
+ function registerBuiltins2(registry, disabled, config) {
2243
+ for (const tagger of BUILTIN_TAGGERS) {
2244
+ if (disabled.has(tagger.id)) continue;
2245
+ const override = config.taggers?.trackUntagged?.[tagger.id];
2246
+ if (override !== void 0) {
2247
+ tagger.trackUntagged = override;
2248
+ }
2249
+ registry.register(tagger);
2250
+ }
2251
+ }
2252
+ function loadCustomTaggers(registry, config, rootDir, disabled) {
2253
+ for (const entry of config.taggers?.custom ?? []) {
2254
+ if (disabled.has(entry.id)) continue;
2255
+ try {
2256
+ const absPath = (0, import_node_path12.resolve)(rootDir, entry.path);
2257
+ const mod = require(absPath);
2258
+ const tagger = "default" in mod ? mod.default : mod;
2259
+ const override = config.taggers?.trackUntagged?.[tagger.id];
2260
+ if (override !== void 0) {
2261
+ tagger.trackUntagged = override;
2262
+ }
2263
+ registry.register(tagger);
2264
+ } catch (err2) {
2265
+ process.stderr.write(`[launch-chart] failed to load custom tagger from ${entry.path}: ${err2}
2266
+ `);
2267
+ }
2268
+ }
2269
+ }
2270
+ function createTaggerRegistry(config, rootDir) {
2271
+ const registry = new TaggerRegistry();
2272
+ const disabled = new Set(config.taggers?.disabled ?? []);
2273
+ registerBuiltins2(registry, disabled, config);
2274
+ loadCustomTaggers(registry, config, rootDir, disabled);
2275
+ return registry;
2276
+ }
2277
+ var import_node_path12, TaggerRegistry, BUILTIN_TAGGERS;
2278
+ var init_tagger_registry = __esm({
2279
+ "src/server/graph/core/tagger-registry.ts"() {
2280
+ "use strict";
2281
+ import_node_path12 = require("node:path");
2282
+ init_module_tagger();
2283
+ init_screen_tagger();
2284
+ TaggerRegistry = class {
2285
+ constructor() {
2286
+ this.taggers = [];
2287
+ this.ids = /* @__PURE__ */ new Set();
2288
+ }
2289
+ register(tagger) {
2290
+ if (this.ids.has(tagger.id)) {
2291
+ throw new Error(`Duplicate tagger id: ${tagger.id}`);
2292
+ }
2293
+ this.ids.add(tagger.id);
2294
+ this.taggers.push(tagger);
2295
+ }
2296
+ getAll() {
2297
+ return this.taggers;
2298
+ }
2299
+ getForLayer(layer) {
2300
+ return this.taggers.filter((t) => t.layers === null || t.layers.includes(layer));
2301
+ }
2302
+ };
2303
+ BUILTIN_TAGGERS = [moduleTagger, screenTagger];
2304
+ }
2305
+ });
2306
+
2307
+ // src/server/graph/core/tag-store.ts
2308
+ function tagsFilePath(rootDir) {
2309
+ return (0, import_node_path13.join)(rootDir, GRAPHS_DIR, TAGS_FILENAME);
2310
+ }
2311
+ function readTagStore(rootDir) {
2312
+ const filePath = tagsFilePath(rootDir);
2313
+ if (!(0, import_node_fs11.existsSync)(filePath)) return {};
2314
+ const stat = (0, import_node_fs11.statSync)(filePath);
2315
+ const cached = tagCache.get(filePath);
2316
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
2317
+ return cached.store;
2318
+ }
2319
+ try {
2320
+ const content = (0, import_node_fs11.readFileSync)(filePath, "utf-8");
2321
+ const store = JSON.parse(content);
2322
+ tagCache.set(filePath, { mtimeMs: stat.mtimeMs, store });
2323
+ return store;
2324
+ } catch {
2325
+ return {};
2326
+ }
2327
+ }
2328
+ function writeTagStore(rootDir, store) {
2329
+ const filePath = tagsFilePath(rootDir);
2330
+ const dir = (0, import_node_path13.dirname)(filePath);
2331
+ (0, import_node_fs11.mkdirSync)(dir, { recursive: true });
2332
+ const cleaned = {};
2333
+ for (const [nodeId, tags] of Object.entries(store)) {
2334
+ if (Object.keys(tags).length > 0) {
2335
+ cleaned[nodeId] = tags;
2336
+ }
2337
+ }
2338
+ (0, import_node_fs11.writeFileSync)(filePath, JSON.stringify(cleaned, null, 2) + "\n", "utf-8");
2339
+ tagCache.delete(filePath);
2340
+ }
2341
+ function setTag(rootDir, nodeId, key, value) {
2342
+ const store = readTagStore(rootDir);
2343
+ if (!store[nodeId]) store[nodeId] = {};
2344
+ store[nodeId][key] = value;
2345
+ writeTagStore(rootDir, store);
2346
+ }
2347
+ function removeTag(rootDir, nodeId, key) {
2348
+ const store = readTagStore(rootDir);
2349
+ if (!store[nodeId]) return;
2350
+ delete store[nodeId][key];
2351
+ if (Object.keys(store[nodeId]).length === 0) {
2352
+ delete store[nodeId];
2353
+ }
2354
+ writeTagStore(rootDir, store);
2355
+ }
2356
+ var import_node_fs11, import_node_path13, TAGS_FILENAME, GRAPHS_DIR, tagCache;
2357
+ var init_tag_store = __esm({
2358
+ "src/server/graph/core/tag-store.ts"() {
2359
+ "use strict";
2360
+ import_node_fs11 = require("node:fs");
2361
+ import_node_path13 = require("node:path");
2362
+ TAGS_FILENAME = "tags.json";
2363
+ GRAPHS_DIR = ".launchsecure/graphs";
2364
+ tagCache = /* @__PURE__ */ new Map();
2365
+ }
2366
+ });
2367
+
2034
2368
  // src/server/graph/index.ts
2035
2369
  function graphsDir(rootDir) {
2036
- return (0, import_node_path11.join)(rootDir, GRAPHS_DIR);
2370
+ return (0, import_node_path14.join)(rootDir, GRAPHS_DIR2);
2037
2371
  }
2038
2372
  function graphFilePath(rootDir, layer) {
2039
- return (0, import_node_path11.join)(graphsDir(rootDir), `${layer}.json`);
2373
+ return (0, import_node_path14.join)(graphsDir(rootDir), `${layer}.json`);
2374
+ }
2375
+ function tagsFilePath2(rootDir) {
2376
+ return (0, import_node_path14.join)(graphsDir(rootDir), "tags.json");
2377
+ }
2378
+ function getMtimeMs(filePath) {
2379
+ if (!(0, import_node_fs12.existsSync)(filePath)) return 0;
2380
+ return (0, import_node_fs12.statSync)(filePath).mtimeMs;
2040
2381
  }
2041
2382
  function invalidateCache(filePath) {
2042
2383
  graphCache.delete(filePath);
2043
2384
  }
2044
- function readGraph(rootDir, layer) {
2385
+ function invalidateTaggedCache(rootDir, layer) {
2386
+ taggedCache.delete(`${rootDir}:${layer}`);
2387
+ }
2388
+ function applyTags(graph, layer, rootDir) {
2389
+ const config = loadConfig(rootDir);
2390
+ const registry = createTaggerRegistry(config, rootDir);
2391
+ const manualTags = readTagStore(rootDir);
2392
+ const taggedNodes = graph.nodes.map((n) => ({ ...n }));
2393
+ const taggers = registry.getForLayer(layer);
2394
+ for (const tagger of taggers) {
2395
+ const assignments = tagger.tag(taggedNodes, layer, rootDir);
2396
+ for (const node of taggedNodes) {
2397
+ if (!node.tags) node.tags = {};
2398
+ const tags = node.tags;
2399
+ const value = assignments.get(node.id);
2400
+ if (value !== void 0) {
2401
+ tags[tagger.tagKey] = value;
2402
+ } else if (tagger.trackUntagged) {
2403
+ tags[tagger.tagKey] = "untagged";
2404
+ }
2405
+ }
2406
+ }
2407
+ for (const node of taggedNodes) {
2408
+ const manual = manualTags[node.id];
2409
+ if (manual) {
2410
+ if (!node.tags) node.tags = {};
2411
+ const tags = node.tags;
2412
+ Object.assign(tags, manual);
2413
+ }
2414
+ }
2415
+ return { ...graph, nodes: taggedNodes };
2416
+ }
2417
+ function readGraphRaw(rootDir, layer) {
2045
2418
  const filePath = graphFilePath(rootDir, layer);
2046
- if (!(0, import_node_fs10.existsSync)(filePath)) return null;
2047
- const stat = (0, import_node_fs10.statSync)(filePath);
2419
+ if (!(0, import_node_fs12.existsSync)(filePath)) return null;
2420
+ const stat = (0, import_node_fs12.statSync)(filePath);
2048
2421
  const cached = graphCache.get(filePath);
2049
2422
  if (cached && cached.mtimeMs === stat.mtimeMs) {
2050
2423
  return cached.graph;
2051
2424
  }
2052
- const content = (0, import_node_fs10.readFileSync)(filePath, "utf-8");
2425
+ const content = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
2053
2426
  const graph = JSON.parse(content);
2054
2427
  graphCache.set(filePath, { mtimeMs: stat.mtimeMs, graph });
2055
2428
  return graph;
2056
2429
  }
2430
+ function readGraph(rootDir, layer) {
2431
+ const rawFilePath = graphFilePath(rootDir, layer);
2432
+ if (!(0, import_node_fs12.existsSync)(rawFilePath)) return null;
2433
+ const rawMtime = getMtimeMs(rawFilePath);
2434
+ const tagsMtime = getMtimeMs(tagsFilePath2(rootDir));
2435
+ const cacheKey = `${rootDir}:${layer}`;
2436
+ const cached = taggedCache.get(cacheKey);
2437
+ if (cached && cached.rawMtimeMs === rawMtime && cached.tagsMtimeMs === tagsMtime) {
2438
+ return cached.graph;
2439
+ }
2440
+ const raw = readGraphRaw(rootDir, layer);
2441
+ if (!raw) return null;
2442
+ const tagged = applyTags(raw, layer, rootDir);
2443
+ taggedCache.set(cacheKey, { rawMtimeMs: rawMtime, tagsMtimeMs: tagsMtime, graph: tagged });
2444
+ return tagged;
2445
+ }
2057
2446
  function readAllGraphs(rootDir) {
2058
2447
  const result = {};
2059
2448
  for (const layer of LAYERS) {
@@ -2064,25 +2453,31 @@ function readAllGraphs(rootDir) {
2064
2453
  }
2065
2454
  function generateGraph(rootDir, layer) {
2066
2455
  const dir = graphsDir(rootDir);
2067
- (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
2456
+ (0, import_node_fs12.mkdirSync)(dir, { recursive: true });
2068
2457
  const results = layer ? [generateLayer(rootDir, layer)].filter((r) => r !== null) : generateAll(rootDir);
2069
2458
  for (const result of results) {
2070
2459
  const filePath = graphFilePath(rootDir, result.layer);
2071
- (0, import_node_fs10.writeFileSync)(filePath, JSON.stringify(result.output, null, 2) + "\n", "utf-8");
2460
+ (0, import_node_fs12.writeFileSync)(filePath, JSON.stringify(result.output, null, 2) + "\n", "utf-8");
2072
2461
  invalidateCache(filePath);
2462
+ invalidateTaggedCache(rootDir, result.layer);
2073
2463
  }
2074
2464
  return results;
2075
2465
  }
2076
- var import_node_fs10, import_node_path11, GRAPHS_DIR, LAYERS, graphCache;
2466
+ var import_node_fs12, import_node_path14, GRAPHS_DIR2, LAYERS, graphCache, taggedCache;
2077
2467
  var init_graph = __esm({
2078
2468
  "src/server/graph/index.ts"() {
2079
2469
  "use strict";
2080
- import_node_fs10 = require("node:fs");
2081
- import_node_path11 = require("node:path");
2470
+ import_node_fs12 = require("node:fs");
2471
+ import_node_path14 = require("node:path");
2082
2472
  init_graph_builder();
2083
- GRAPHS_DIR = ".launchsecure/graphs";
2473
+ init_config();
2474
+ init_tagger_registry();
2475
+ init_tag_store();
2476
+ init_tag_store();
2477
+ GRAPHS_DIR2 = ".launchsecure/graphs";
2084
2478
  LAYERS = ["ui", "api", "db"];
2085
2479
  graphCache = /* @__PURE__ */ new Map();
2480
+ taggedCache = /* @__PURE__ */ new Map();
2086
2481
  }
2087
2482
  });
2088
2483
 
@@ -2092,19 +2487,22 @@ __export(chart_serve_exports, {
2092
2487
  runServeCli: () => runServeCli,
2093
2488
  startChartServer: () => startChartServer
2094
2489
  });
2095
- function findProjectRoot2(startDir) {
2490
+ function randomPort() {
2491
+ return 49152 + Math.floor(Math.random() * (65535 - 49152));
2492
+ }
2493
+ function findProjectRoot(startDir) {
2096
2494
  let dir = startDir;
2097
2495
  for (let i = 0; i < 8; i++) {
2098
- const graphsDir2 = import_node_path12.default.join(dir, ".launchsecure", "graphs");
2099
- if (import_node_fs11.default.existsSync(import_node_path12.default.join(graphsDir2, "ui.json")) || import_node_fs11.default.existsSync(import_node_path12.default.join(graphsDir2, "api.json")) || import_node_fs11.default.existsSync(import_node_path12.default.join(graphsDir2, "db.json"))) return dir;
2100
- const parent = import_node_path12.default.dirname(dir);
2496
+ const graphsDir2 = import_node_path15.default.join(dir, ".launchsecure", "graphs");
2497
+ if (import_node_fs13.default.existsSync(import_node_path15.default.join(graphsDir2, "ui.json")) || import_node_fs13.default.existsSync(import_node_path15.default.join(graphsDir2, "api.json")) || import_node_fs13.default.existsSync(import_node_path15.default.join(graphsDir2, "db.json"))) return dir;
2498
+ const parent = import_node_path15.default.dirname(dir);
2101
2499
  if (parent === dir) break;
2102
2500
  dir = parent;
2103
2501
  }
2104
2502
  dir = startDir;
2105
2503
  for (let i = 0; i < 8; i++) {
2106
- if (import_node_fs11.default.existsSync(import_node_path12.default.join(dir, ".git"))) return dir;
2107
- const parent = import_node_path12.default.dirname(dir);
2504
+ if (import_node_fs13.default.existsSync(import_node_path15.default.join(dir, ".git"))) return dir;
2505
+ const parent = import_node_path15.default.dirname(dir);
2108
2506
  if (parent === dir) break;
2109
2507
  dir = parent;
2110
2508
  }
@@ -2156,16 +2554,16 @@ function buildMergedGraph(projectRoot) {
2156
2554
  };
2157
2555
  }
2158
2556
  function serveStatic(res, filePath) {
2159
- if (!import_node_fs11.default.existsSync(filePath) || !import_node_fs11.default.statSync(filePath).isFile()) return false;
2160
- const ext = import_node_path12.default.extname(filePath).toLowerCase();
2557
+ if (!import_node_fs13.default.existsSync(filePath) || !import_node_fs13.default.statSync(filePath).isFile()) return false;
2558
+ const ext = import_node_path15.default.extname(filePath).toLowerCase();
2161
2559
  const mime = MIME_TYPES[ext] ?? "application/octet-stream";
2162
2560
  res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" });
2163
- import_node_fs11.default.createReadStream(filePath).pipe(res);
2561
+ import_node_fs13.default.createReadStream(filePath).pipe(res);
2164
2562
  return true;
2165
2563
  }
2166
2564
  function serveIndex(res, clientDir) {
2167
- const indexPath = import_node_path12.default.join(clientDir, "index.html");
2168
- if (!import_node_fs11.default.existsSync(indexPath)) {
2565
+ const indexPath = import_node_path15.default.join(clientDir, "index.html");
2566
+ if (!import_node_fs13.default.existsSync(indexPath)) {
2169
2567
  res.writeHead(500, { "Content-Type": "text/plain" });
2170
2568
  res.end(`LaunchChart client bundle not found at ${clientDir}. Run 'npm run build:chart-client'.`);
2171
2569
  return;
@@ -2173,14 +2571,14 @@ function serveIndex(res, clientDir) {
2173
2571
  serveStatic(res, indexPath);
2174
2572
  }
2175
2573
  function tryListen(server, port) {
2176
- return new Promise((resolve2, reject) => {
2574
+ return new Promise((resolve3, reject) => {
2177
2575
  const onError = (err2) => {
2178
2576
  server.off("listening", onListening);
2179
2577
  reject(err2);
2180
2578
  };
2181
2579
  const onListening = () => {
2182
2580
  server.off("error", onError);
2183
- resolve2(port);
2581
+ resolve3(port);
2184
2582
  };
2185
2583
  server.once("error", onError);
2186
2584
  server.once("listening", onListening);
@@ -2206,8 +2604,8 @@ async function bindWithFallback(server, startPort) {
2206
2604
  }
2207
2605
  async function startChartServer(opts = {}) {
2208
2606
  const cwd = opts.cwd ?? process.cwd();
2209
- const projectRoot = findProjectRoot2(cwd);
2210
- const existing = getLiveLock();
2607
+ const projectRoot = findProjectRoot(cwd);
2608
+ const existing = getLiveLock(projectRoot);
2211
2609
  if (existing) {
2212
2610
  if (!opts.quiet) {
2213
2611
  process.stderr.write(
@@ -2217,7 +2615,7 @@ async function startChartServer(opts = {}) {
2217
2615
  }
2218
2616
  return { port: existing.port, url: existing.url };
2219
2617
  }
2220
- const clientDir = opts.clientDir ?? import_node_path12.default.join(__dirname, "..", "chart-client");
2618
+ const clientDir = opts.clientDir ?? import_node_path15.default.join(__dirname, "..", "chart-client");
2221
2619
  const server = import_node_http.default.createServer((req, res) => {
2222
2620
  try {
2223
2621
  const url2 = new URL(req.url ?? "/", `http://${req.headers.host}`);
@@ -2255,6 +2653,26 @@ async function startChartServer(opts = {}) {
2255
2653
  }
2256
2654
  return;
2257
2655
  }
2656
+ if (req.method === "GET" && url2.pathname === "/api/file-content") {
2657
+ const relPath = url2.searchParams.get("path");
2658
+ if (!relPath || relPath.includes("..") || import_node_path15.default.isAbsolute(relPath)) {
2659
+ res.writeHead(400, { "Content-Type": "application/json" });
2660
+ res.end(JSON.stringify({ error: "Invalid path" }));
2661
+ return;
2662
+ }
2663
+ const filePath = import_node_path15.default.join(projectRoot, relPath);
2664
+ if (!filePath.startsWith(projectRoot) || !import_node_fs13.default.existsSync(filePath) || !import_node_fs13.default.statSync(filePath).isFile()) {
2665
+ res.writeHead(404, { "Content-Type": "application/json" });
2666
+ res.end(JSON.stringify({ error: "File not found" }));
2667
+ return;
2668
+ }
2669
+ const ext = import_node_path15.default.extname(filePath).toLowerCase();
2670
+ const langMap = { ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx", ".prisma": "prisma", ".json": "json", ".css": "css" };
2671
+ const content = import_node_fs13.default.readFileSync(filePath, "utf-8");
2672
+ res.writeHead(200, { "Content-Type": "application/json" });
2673
+ res.end(JSON.stringify({ content, language: langMap[ext] ?? "text", path: relPath }));
2674
+ return;
2675
+ }
2258
2676
  if (req.method === "GET" && url2.pathname === "/api/health") {
2259
2677
  res.writeHead(200, { "Content-Type": "application/json" });
2260
2678
  res.end(JSON.stringify({ ok: true, projectRoot }));
@@ -2284,8 +2702,94 @@ async function startChartServer(opts = {}) {
2284
2702
  req.on("end", () => {
2285
2703
  try {
2286
2704
  const newConfig = JSON.parse(body);
2287
- const configPath = import_node_path12.default.join(projectRoot, ".launchchart.json");
2288
- import_node_fs11.default.writeFileSync(configPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
2705
+ const configPath = import_node_path15.default.join(projectRoot, ".launchchart.json");
2706
+ import_node_fs13.default.writeFileSync(configPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
2707
+ res.writeHead(200, { "Content-Type": "application/json" });
2708
+ res.end(JSON.stringify({ ok: true }));
2709
+ } catch (err2) {
2710
+ res.writeHead(400, { "Content-Type": "application/json" });
2711
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2712
+ }
2713
+ });
2714
+ return;
2715
+ }
2716
+ if (req.method === "GET" && url2.pathname === "/api/tagger-config") {
2717
+ const config = loadConfig(projectRoot);
2718
+ const builtinTaggers = [
2719
+ { id: "module", tagKey: "module", trackUntagged: config.taggers?.trackUntagged?.module ?? true },
2720
+ { id: "screen", tagKey: "screen", trackUntagged: config.taggers?.trackUntagged?.screen ?? true }
2721
+ ];
2722
+ const disabled = config.taggers?.disabled ?? [];
2723
+ const customTaggers = config.taggers?.custom ?? [];
2724
+ const moduleRules = config.taggers?.module?.rules ?? [];
2725
+ res.writeHead(200, { "Content-Type": "application/json" });
2726
+ res.end(JSON.stringify({ builtinTaggers, disabled, customTaggers, moduleRules }));
2727
+ return;
2728
+ }
2729
+ if (req.method === "POST" && url2.pathname === "/api/tagger-config") {
2730
+ let body = "";
2731
+ req.on("data", (chunk) => {
2732
+ body += chunk.toString();
2733
+ });
2734
+ req.on("end", () => {
2735
+ try {
2736
+ const taggerConfig = JSON.parse(body);
2737
+ const config = loadConfig(projectRoot);
2738
+ config.taggers = taggerConfig;
2739
+ const configPath = import_node_path15.default.join(projectRoot, ".launchchart.json");
2740
+ import_node_fs13.default.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2741
+ res.writeHead(200, { "Content-Type": "application/json" });
2742
+ res.end(JSON.stringify({ ok: true }));
2743
+ } catch (err2) {
2744
+ res.writeHead(400, { "Content-Type": "application/json" });
2745
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2746
+ }
2747
+ });
2748
+ return;
2749
+ }
2750
+ if (req.method === "GET" && url2.pathname === "/api/tags") {
2751
+ const store = readTagStore(projectRoot);
2752
+ res.writeHead(200, { "Content-Type": "application/json" });
2753
+ res.end(JSON.stringify(store));
2754
+ return;
2755
+ }
2756
+ if (req.method === "POST" && url2.pathname === "/api/tags") {
2757
+ let body = "";
2758
+ req.on("data", (chunk) => {
2759
+ body += chunk.toString();
2760
+ });
2761
+ req.on("end", () => {
2762
+ try {
2763
+ const { nodeId, key, value } = JSON.parse(body);
2764
+ if (!nodeId || !key || !value) {
2765
+ res.writeHead(400, { "Content-Type": "application/json" });
2766
+ res.end(JSON.stringify({ ok: false, error: "nodeId, key, and value are required" }));
2767
+ return;
2768
+ }
2769
+ setTag(projectRoot, nodeId, key, value);
2770
+ res.writeHead(200, { "Content-Type": "application/json" });
2771
+ res.end(JSON.stringify({ ok: true }));
2772
+ } catch (err2) {
2773
+ res.writeHead(400, { "Content-Type": "application/json" });
2774
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2775
+ }
2776
+ });
2777
+ return;
2778
+ }
2779
+ if (req.method === "DELETE" && url2.pathname === "/api/tags") {
2780
+ let body = "";
2781
+ req.on("data", (chunk) => {
2782
+ body += chunk.toString();
2783
+ });
2784
+ req.on("end", () => {
2785
+ try {
2786
+ const { nodeId, key } = JSON.parse(body);
2787
+ if (!nodeId || !key) {
2788
+ res.writeHead(400, { "Content-Type": "application/json" });
2789
+ res.end(JSON.stringify({ ok: false, error: "nodeId and key are required" }));
2790
+ return;
2791
+ }
2792
+ removeTag(projectRoot, nodeId, key);
2289
2793
  res.writeHead(200, { "Content-Type": "application/json" });
2290
2794
  res.end(JSON.stringify({ ok: true }));
2291
2795
  } catch (err2) {
@@ -2296,7 +2800,7 @@ async function startChartServer(opts = {}) {
2296
2800
  return;
2297
2801
  }
2298
2802
  if (url2.pathname !== "/") {
2299
- const staticPath = import_node_path12.default.join(clientDir, url2.pathname);
2803
+ const staticPath = import_node_path15.default.join(clientDir, url2.pathname);
2300
2804
  if (serveStatic(res, staticPath)) return;
2301
2805
  }
2302
2806
  serveIndex(res, clientDir);
@@ -2305,7 +2809,8 @@ async function startChartServer(opts = {}) {
2305
2809
  res.end(JSON.stringify({ error: String(err2) }));
2306
2810
  }
2307
2811
  });
2308
- const port = await bindWithFallback(server, opts.port ?? DEFAULT_PORT);
2812
+ const startPort = opts.port ?? randomPort();
2813
+ const port = await bindWithFallback(server, startPort);
2309
2814
  const url = `http://localhost:${port}`;
2310
2815
  writeLock({
2311
2816
  pid: process.pid,
@@ -2313,9 +2818,9 @@ async function startChartServer(opts = {}) {
2313
2818
  cwd,
2314
2819
  url,
2315
2820
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
2316
- });
2821
+ }, projectRoot);
2317
2822
  const cleanup = () => {
2318
- clearLock();
2823
+ clearLock(projectRoot);
2319
2824
  server.close();
2320
2825
  };
2321
2826
  process.once("SIGINT", () => {
@@ -2350,21 +2855,20 @@ function runServeCli(argv) {
2350
2855
  process.exit(1);
2351
2856
  });
2352
2857
  }
2353
- var import_node_http, import_node_fs11, import_node_path12, DEFAULT_PORT, MAX_PORT_SCAN, MIME_TYPES;
2858
+ var import_node_http, import_node_fs13, import_node_path15, MAX_PORT_SCAN, MIME_TYPES;
2354
2859
  var init_chart_serve = __esm({
2355
2860
  "src/server/chart-serve.ts"() {
2356
2861
  "use strict";
2357
2862
  import_node_http = __toESM(require("node:http"));
2358
- import_node_fs11 = __toESM(require("node:fs"));
2359
- import_node_path12 = __toESM(require("node:path"));
2863
+ import_node_fs13 = __toESM(require("node:fs"));
2864
+ import_node_path15 = __toESM(require("node:path"));
2360
2865
  init_graph();
2361
2866
  init_lockfile();
2362
2867
  init_config();
2363
2868
  init_react_nextjs();
2364
2869
  init_nextjs_routes();
2365
2870
  init_prisma_schema();
2366
- DEFAULT_PORT = 52819;
2367
- MAX_PORT_SCAN = 20;
2871
+ MAX_PORT_SCAN = 3;
2368
2872
  MIME_TYPES = {
2369
2873
  ".html": "text/html; charset=utf-8",
2370
2874
  ".js": "application/javascript; charset=utf-8",
@@ -2395,7 +2899,7 @@ function matchesSearch(node, query) {
2395
2899
  function toMinimal(nodes) {
2396
2900
  return nodes.map((n) => {
2397
2901
  const out = { id: n.id, type: n.type, name: n.name };
2398
- if (n.module != null) out.module = n.module;
2902
+ if (n.tags != null) out.tags = n.tags;
2399
2903
  if (n.route != null) out.route = n.route;
2400
2904
  if (n.methods != null) out.methods = n.methods;
2401
2905
  return out;
@@ -2403,11 +2907,13 @@ function toMinimal(nodes) {
2403
2907
  }
2404
2908
  function toCompactNode(n) {
2405
2909
  const out = { i: n.id, t: n.type, n: n.name };
2406
- if (n.module != null) out.m = n.module;
2910
+ const tags = n.tags;
2911
+ if (tags?.module) out.m = tags.module;
2407
2912
  if (n.route != null) out.r = n.route;
2408
2913
  if (n.methods != null) out.mt = n.methods;
2409
2914
  if (n.exports != null) out.x = n.exports;
2410
2915
  if (n.columns != null) out.c = n.columns;
2916
+ if (tags != null) out.tg = tags;
2411
2917
  for (const k of Object.keys(n)) {
2412
2918
  if (!COMPACT_NODE_KNOWN_KEYS.has(k) && n[k] != null) out[k] = n[k];
2413
2919
  }
@@ -2483,7 +2989,8 @@ function layerSummary(graph) {
2483
2989
  const moduleCounts = {};
2484
2990
  for (const n of graph.nodes) {
2485
2991
  typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
2486
- const mod = n.module;
2992
+ const tags = n.tags;
2993
+ const mod = tags?.module;
2487
2994
  if (mod) moduleCounts[mod] = (moduleCounts[mod] ?? 0) + 1;
2488
2995
  }
2489
2996
  const edgeTypeCounts = {};
@@ -2540,12 +3047,14 @@ function runReadGraphQueryRaw(rootDir, args) {
2540
3047
  const search = args.search;
2541
3048
  const type = args.type;
2542
3049
  const module_ = args.module;
3050
+ const tagKey = args.tag_key;
3051
+ const tagValue = args.tag_value;
2543
3052
  const nodeId = args.node_id;
2544
3053
  const hops = args.hops ?? 1;
2545
3054
  const layerIsDb = args.layer === "db";
2546
3055
  const minimal = args.minimal ?? layerIsDb;
2547
3056
  const includeEdges = args.include_edges;
2548
- const hasFilter = !!(search || type || module_ || nodeId);
3057
+ const hasFilter = !!(search || type || module_ || nodeId || tagKey && tagValue);
2549
3058
  if (layer && !["ui", "api", "db"].includes(layer)) {
2550
3059
  return { error: `Invalid layer "${layer}". Must be one of: ui, api, db` };
2551
3060
  }
@@ -2601,7 +3110,9 @@ function runReadGraphQueryRaw(rootDir, args) {
2601
3110
  const matched = graph.nodes.filter((n) => {
2602
3111
  if (search && !matchesSearch(n, search)) return false;
2603
3112
  if (type && n.type !== type) return false;
2604
- if (module_ && n.module !== module_) return false;
3113
+ const nodeTags = n.tags;
3114
+ if (module_ && nodeTags?.module !== module_) return false;
3115
+ if (tagKey && tagValue && nodeTags?.[tagKey] !== tagValue) return false;
2605
3116
  return true;
2606
3117
  });
2607
3118
  const matchedIds = new Set(matched.map((n) => n.id));
@@ -2688,9 +3199,9 @@ function handleReadGraph(args) {
2688
3199
  return okJson(result);
2689
3200
  }
2690
3201
  function nodeToFilePath(rootDir, layer, nodeId) {
2691
- if (layer === "ui") return (0, import_node_path13.join)(rootDir, "src", nodeId);
2692
- if (layer === "api") return (0, import_node_path13.join)(rootDir, nodeId);
2693
- if (layer === "db") return (0, import_node_path13.join)(rootDir, "prisma", "schema.prisma");
3202
+ if (layer === "ui") return (0, import_node_path16.join)(rootDir, "src", nodeId);
3203
+ if (layer === "api") return (0, import_node_path16.join)(rootDir, nodeId);
3204
+ if (layer === "db") return (0, import_node_path16.join)(rootDir, "prisma", "schema.prisma");
2694
3205
  return null;
2695
3206
  }
2696
3207
  function handleGrepNodes(args) {
@@ -2750,11 +3261,11 @@ function handleGrepNodes(args) {
2750
3261
  let filesSearched = 0;
2751
3262
  let truncated = false;
2752
3263
  for (const [filePath, nodeId] of filePaths) {
2753
- if (!(0, import_node_fs12.existsSync)(filePath)) continue;
3264
+ if (!(0, import_node_fs14.existsSync)(filePath)) continue;
2754
3265
  filesSearched++;
2755
3266
  let content;
2756
3267
  try {
2757
- content = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
3268
+ content = (0, import_node_fs14.readFileSync)(filePath, "utf-8");
2758
3269
  } catch {
2759
3270
  continue;
2760
3271
  }
@@ -2792,7 +3303,8 @@ function handleGrepNodes(args) {
2792
3303
  });
2793
3304
  }
2794
3305
  function handleChartServerStatus() {
2795
- const lock = getLiveLock();
3306
+ const rootDir = process.cwd();
3307
+ const lock = getLiveLock(rootDir);
2796
3308
  if (!lock) {
2797
3309
  return okJson({ running: false });
2798
3310
  }
@@ -2806,7 +3318,8 @@ function handleChartServerStatus() {
2806
3318
  });
2807
3319
  }
2808
3320
  function handleStartChartServer(args) {
2809
- const lock = getLiveLock();
3321
+ const rootDir = process.cwd();
3322
+ const lock = getLiveLock(rootDir);
2810
3323
  if (lock) {
2811
3324
  return okJson({
2812
3325
  started: false,
@@ -2817,11 +3330,11 @@ function handleStartChartServer(args) {
2817
3330
  });
2818
3331
  }
2819
3332
  const entryPath = process.argv[1];
2820
- const logDir = (0, import_node_path13.join)((0, import_node_os2.homedir)(), ".launchsecure");
2821
- (0, import_node_fs12.mkdirSync)(logDir, { recursive: true });
2822
- const logPath = (0, import_node_path13.join)(logDir, "launch-chart.log");
2823
- const out = (0, import_node_fs12.openSync)(logPath, "a");
2824
- const err2 = (0, import_node_fs12.openSync)(logPath, "a");
3333
+ const logDir = (0, import_node_path16.join)((0, import_node_os2.homedir)(), ".launchsecure");
3334
+ (0, import_node_fs14.mkdirSync)(logDir, { recursive: true });
3335
+ const logPath = (0, import_node_path16.join)(logDir, "launch-chart.log");
3336
+ const out = (0, import_node_fs14.openSync)(logPath, "a");
3337
+ const err2 = (0, import_node_fs14.openSync)(logPath, "a");
2825
3338
  const portArgs = args.port ? ["--port", String(args.port)] : [];
2826
3339
  const child = (0, import_node_child_process2.spawn)(process.execPath, [entryPath, "serve", ...portArgs], {
2827
3340
  detached: true,
@@ -2836,7 +3349,8 @@ function handleStartChartServer(args) {
2836
3349
  });
2837
3350
  }
2838
3351
  function handleStopChartServer() {
2839
- const lock = getLiveLock();
3352
+ const rootDir = process.cwd();
3353
+ const lock = getLiveLock(rootDir);
2840
3354
  if (!lock) {
2841
3355
  return okJson({ stopped: false, reason: "not_running" });
2842
3356
  }
@@ -2846,14 +3360,45 @@ function handleStopChartServer() {
2846
3360
  } catch (e) {
2847
3361
  const code = e.code;
2848
3362
  if (code === "ESRCH") {
2849
- clearLock();
3363
+ clearLock(rootDir);
2850
3364
  return okJson({ stopped: true, pid: lock.pid, note: "process was already gone, lock cleaned up" });
2851
3365
  }
2852
3366
  return okJson({ stopped: false, reason: `kill failed: ${code ?? e}` });
2853
3367
  }
2854
3368
  }
3369
+ function handleAddTag(args) {
3370
+ const rootDir = process.cwd();
3371
+ const nodeId = args.node_id;
3372
+ const key = args.key;
3373
+ const value = args.value;
3374
+ if (!nodeId) return err("node_id is required");
3375
+ if (!key) return err("key is required");
3376
+ if (!value) return err("value is required");
3377
+ const graphs = readAllGraphs(rootDir);
3378
+ let found = false;
3379
+ for (const graph of Object.values(graphs)) {
3380
+ if (graph && graph.nodes.some((n) => n.id === nodeId)) {
3381
+ found = true;
3382
+ break;
3383
+ }
3384
+ }
3385
+ if (!found) {
3386
+ return err(`Node "${nodeId}" not found in any graph layer. Check the node_id.`);
3387
+ }
3388
+ setTag(rootDir, nodeId, key, value);
3389
+ return okJson({ ok: true, node_id: nodeId, tag: { [key]: value } });
3390
+ }
3391
+ function handleRemoveTag(args) {
3392
+ const rootDir = process.cwd();
3393
+ const nodeId = args.node_id;
3394
+ const key = args.key;
3395
+ if (!nodeId) return err("node_id is required");
3396
+ if (!key) return err("key is required");
3397
+ removeTag(rootDir, nodeId, key);
3398
+ return okJson({ ok: true, node_id: nodeId, removed_key: key });
3399
+ }
2855
3400
  function handleDetectProjectStack() {
2856
- const rootDir = findProjectRoot(process.cwd());
3401
+ const rootDir = process.cwd();
2857
3402
  const parsers = [
2858
3403
  { id: "react-nextjs", layer: "ui", detected: reactNextjsParser.detect(rootDir) },
2859
3404
  { id: "nextjs-routes", layer: "api", detected: nextjsRoutesParser.detect(rootDir) },
@@ -2871,20 +3416,20 @@ function handleDetectProjectStack() {
2871
3416
  if (f.type === "out_of_pattern") stats.out_of_pattern++;
2872
3417
  }
2873
3418
  }
2874
- const srcDir = (0, import_node_path13.join)(rootDir, "src");
2875
- if ((0, import_node_fs12.existsSync)(srcDir)) {
3419
+ const srcDir = (0, import_node_path16.join)(rootDir, "src");
3420
+ if ((0, import_node_fs14.existsSync)(srcDir)) {
2876
3421
  const scanDir = (dir) => {
2877
- if (!(0, import_node_fs12.existsSync)(dir)) return;
2878
- for (const entry of (0, import_node_fs12.readdirSync)(dir, { withFileTypes: true })) {
3422
+ if (!(0, import_node_fs14.existsSync)(dir)) return;
3423
+ for (const entry of (0, import_node_fs14.readdirSync)(dir, { withFileTypes: true })) {
2879
3424
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2880
- const full = (0, import_node_path13.join)(dir, entry.name);
3425
+ const full = (0, import_node_path16.join)(dir, entry.name);
2881
3426
  if (entry.isDirectory()) {
2882
3427
  scanDir(full);
2883
3428
  continue;
2884
3429
  }
2885
- if (![".ts", ".tsx"].includes((0, import_node_path13.extname)(entry.name))) continue;
3430
+ if (![".ts", ".tsx"].includes((0, import_node_path16.extname)(entry.name))) continue;
2886
3431
  try {
2887
- const content = (0, import_node_fs12.readFileSync)(full, "utf-8");
3432
+ const content = (0, import_node_fs14.readFileSync)(full, "utf-8");
2888
3433
  const matches = content.match(/@api\s+(GET|POST|PUT|DELETE|PATCH)\s+\/\S+/g);
2889
3434
  if (matches) stats.annotations += matches.length;
2890
3435
  } catch {
@@ -2971,6 +3516,14 @@ function handleMessage(msg) {
2971
3516
  respond(id ?? null, handleDetectProjectStack());
2972
3517
  return;
2973
3518
  }
3519
+ if (toolName === "add_tag") {
3520
+ respond(id ?? null, handleAddTag(args));
3521
+ return;
3522
+ }
3523
+ if (toolName === "remove_tag") {
3524
+ respond(id ?? null, handleRemoveTag(args));
3525
+ return;
3526
+ }
2974
3527
  respondError(id ?? null, -32601, `Unknown tool: ${toolName}`);
2975
3528
  return;
2976
3529
  }
@@ -3006,12 +3559,12 @@ function startGraphMcpServer() {
3006
3559
  process.stderr.write(`[launchsecure-graph] MCP server started (cwd: ${process.cwd()})
3007
3560
  `);
3008
3561
  }
3009
- var import_node_fs12, import_node_path13, import_node_child_process2, import_node_os2, SERVER_INFO, TOOLS, COMPACT_SCHEMA, COMPACT_NODE_KNOWN_KEYS, EST_CHARS_PER_NODE_FULL, EST_CHARS_PER_NODE_MIN, EST_CHARS_PER_EDGE, NEIGHBORHOOD_BUDGET_CHARS, BATCH_BUDGET_CHARS;
3562
+ var import_node_fs14, import_node_path16, import_node_child_process2, import_node_os2, SERVER_INFO, TOOLS, COMPACT_SCHEMA, COMPACT_NODE_KNOWN_KEYS, EST_CHARS_PER_NODE_FULL, EST_CHARS_PER_NODE_MIN, EST_CHARS_PER_EDGE, NEIGHBORHOOD_BUDGET_CHARS, BATCH_BUDGET_CHARS;
3010
3563
  var init_graph_mcp = __esm({
3011
3564
  "src/server/graph-mcp.ts"() {
3012
3565
  "use strict";
3013
- import_node_fs12 = require("node:fs");
3014
- import_node_path13 = require("node:path");
3566
+ import_node_fs14 = require("node:fs");
3567
+ import_node_path16 = require("node:path");
3015
3568
  import_node_child_process2 = require("node:child_process");
3016
3569
  import_node_os2 = require("node:os");
3017
3570
  init_graph();
@@ -3041,7 +3594,7 @@ var init_graph_mcp = __esm({
3041
3594
  },
3042
3595
  {
3043
3596
  name: "read_graph",
3044
- description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module (ui layer only: auth, admin, project, org, settings, integrations, shared-ui, layout, root)\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n- include_edges: return the actual edge list. Default: TRUE for neighborhood queries (node_id), FALSE for filter queries (search/type/module). Filter responses always include `edge_count`; only pass include_edges:true when you actually need to inspect individual edges (e.g. "which components render X"). This default cuts typical filter responses in half.\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.\n\nWIRE FORMAT (compact): responses that include nodes/edges use short keys and edge-by-index refs to cut payload ~40-60%. Every such response carries a `_schema` legend. Quick reference:\n nodes[]: { i: id, t: type, n: name, m: module, r: route, mt: methods, x: exports, c: columns }\n edges[]: { s: source_node_index, d: target_node_index, t: type, l: label }\nedges.s / edges.d are 0-based indices into THIS response\'s nodes array. If a referenced node is not in the response (boundary case), s/d may instead contain the full node id string \u2014 always check the type.\n\nBUDGET GUARDS:\n- Neighborhood queries stop expanding when the projected response exceeds budget. The response then contains `budget_exceeded: true` plus `hops_traversed < hops_requested`. When this happens, drill into a specific neighbor with another node_id call rather than retrying with larger hops \u2014 it will just truncate again.\n- Batch mode caps total response size. Once the budget is hit, later queries return `{skipped: true, reason: "batch_budget_exhausted"}` and you must re-run them individually.',
3597
+ description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module tag (computed from directory structure, e.g. "auth", "admin", "settings")\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n- include_edges: return the actual edge list. Default: TRUE for neighborhood queries (node_id), FALSE for filter queries (search/type/module). Filter responses always include `edge_count`; only pass include_edges:true when you actually need to inspect individual edges (e.g. "which components render X"). This default cuts typical filter responses in half.\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.\n\nWIRE FORMAT (compact): responses that include nodes/edges use short keys and edge-by-index refs to cut payload ~40-60%. Every such response carries a `_schema` legend. Quick reference:\n nodes[]: { i: id, t: type, n: name, m: module, r: route, mt: methods, x: exports, c: columns }\n edges[]: { s: source_node_index, d: target_node_index, t: type, l: label }\nedges.s / edges.d are 0-based indices into THIS response\'s nodes array. If a referenced node is not in the response (boundary case), s/d may instead contain the full node id string \u2014 always check the type.\n\nBUDGET GUARDS:\n- Neighborhood queries stop expanding when the projected response exceeds budget. The response then contains `budget_exceeded: true` plus `hops_traversed < hops_requested`. When this happens, drill into a specific neighbor with another node_id call rather than retrying with larger hops \u2014 it will just truncate again.\n- Batch mode caps total response size. Once the budget is hit, later queries return `{skipped: true, reason: "batch_budget_exhausted"}` and you must re-run them individually.',
3045
3598
  inputSchema: {
3046
3599
  type: "object",
3047
3600
  properties: {
@@ -3060,7 +3613,15 @@ var init_graph_mcp = __esm({
3060
3613
  },
3061
3614
  module: {
3062
3615
  type: "string",
3063
- description: 'UI layer only \u2014 filter by module (e.g. "auth", "admin", "project", "org").'
3616
+ description: 'Filter by module tag (e.g. "auth", "admin", "settings"). Works across all layers.'
3617
+ },
3618
+ tag_key: {
3619
+ type: "string",
3620
+ description: "Filter by arbitrary tag key. Must be used with tag_value."
3621
+ },
3622
+ tag_value: {
3623
+ type: "string",
3624
+ description: "Filter by tag value for the given tag_key."
3064
3625
  },
3065
3626
  node_id: {
3066
3627
  type: "string",
@@ -3184,6 +3745,46 @@ Use this when the user asks "is the chart running", "show me the project graph U
3184
3745
  type: "object",
3185
3746
  properties: {}
3186
3747
  }
3748
+ },
3749
+ {
3750
+ name: "add_tag",
3751
+ description: 'Tag a graph node with a key-value pair. Tags persist in .launchsecure/graphs/tags.json and survive graph regeneration. Use for annotating nodes with arbitrary metadata (e.g. "refactor_later", "owner", "priority"). Manual tags override computed tags (like module and screen) for the same key.',
3752
+ inputSchema: {
3753
+ type: "object",
3754
+ properties: {
3755
+ node_id: {
3756
+ type: "string",
3757
+ description: 'The node id to tag (e.g. "app/(auth)/login/page.tsx").'
3758
+ },
3759
+ key: {
3760
+ type: "string",
3761
+ description: 'Tag key (e.g. "module", "owner", "refactor_later").'
3762
+ },
3763
+ value: {
3764
+ type: "string",
3765
+ description: 'Tag value (e.g. "auth", "alice", "true").'
3766
+ }
3767
+ },
3768
+ required: ["node_id", "key", "value"]
3769
+ }
3770
+ },
3771
+ {
3772
+ name: "remove_tag",
3773
+ description: "Remove a manual tag from a graph node. Only removes tags from tags.json \u2014 computed tags (module, screen) cannot be removed (they are re-derived at read time).",
3774
+ inputSchema: {
3775
+ type: "object",
3776
+ properties: {
3777
+ node_id: {
3778
+ type: "string",
3779
+ description: "The node id to remove the tag from."
3780
+ },
3781
+ key: {
3782
+ type: "string",
3783
+ description: "Tag key to remove."
3784
+ }
3785
+ },
3786
+ required: ["node_id", "key"]
3787
+ }
3187
3788
  }
3188
3789
  ];
3189
3790
  COMPACT_SCHEMA = {
@@ -3191,11 +3792,12 @@ Use this when the user asks "is the chart running", "show me the project graph U
3191
3792
  i: "id",
3192
3793
  t: "type",
3193
3794
  n: "name",
3194
- m: "module",
3795
+ m: "module (from tags)",
3195
3796
  r: "route",
3196
3797
  mt: "methods",
3197
3798
  x: "exports",
3198
- c: "columns"
3799
+ c: "columns",
3800
+ tg: "tags"
3199
3801
  },
3200
3802
  edges: {
3201
3803
  s: "source_node_index",
@@ -3213,7 +3815,8 @@ Use this when the user asks "is the chart running", "show me the project graph U
3213
3815
  "route",
3214
3816
  "methods",
3215
3817
  "exports",
3216
- "columns"
3818
+ "columns",
3819
+ "tags"
3217
3820
  ]);
3218
3821
  EST_CHARS_PER_NODE_FULL = {
3219
3822
  ui: 300,
@@ -3237,10 +3840,10 @@ Use this when the user asks "is the chart running", "show me the project graph U
3237
3840
 
3238
3841
  // src/server/graph-mcp-entry.ts
3239
3842
  var import_node_child_process3 = require("node:child_process");
3240
- var import_node_fs13 = require("node:fs");
3241
- var import_node_path14 = __toESM(require("node:path"));
3843
+ var import_node_fs15 = require("node:fs");
3844
+ var import_node_path17 = __toESM(require("node:path"));
3242
3845
  var import_node_os3 = require("node:os");
3243
- var import_node_fs14 = require("node:fs");
3846
+ var import_node_fs16 = require("node:fs");
3244
3847
  init_lockfile();
3245
3848
  function logStderr(msg) {
3246
3849
  process.stderr.write(`[launch-chart] ${msg}
@@ -3254,11 +3857,11 @@ function maybeAutoServe() {
3254
3857
  return;
3255
3858
  }
3256
3859
  try {
3257
- const logDir = import_node_path14.default.join((0, import_node_os3.homedir)(), ".launchsecure");
3258
- (0, import_node_fs14.mkdirSync)(logDir, { recursive: true });
3259
- const logPath = import_node_path14.default.join(logDir, "launch-chart.log");
3260
- const out = (0, import_node_fs13.openSync)(logPath, "a");
3261
- const err2 = (0, import_node_fs13.openSync)(logPath, "a");
3860
+ const logDir = import_node_path17.default.join((0, import_node_os3.homedir)(), ".launchsecure");
3861
+ (0, import_node_fs16.mkdirSync)(logDir, { recursive: true });
3862
+ const logPath = import_node_path17.default.join(logDir, "launch-chart.log");
3863
+ const out = (0, import_node_fs15.openSync)(logPath, "a");
3864
+ const err2 = (0, import_node_fs15.openSync)(logPath, "a");
3262
3865
  const entryPath = process.argv[1];
3263
3866
  const child = (0, import_node_child_process3.spawn)(process.execPath, [entryPath, "serve"], {
3264
3867
  detached: true,