@launchsecure/launch-kit 0.0.5 → 0.0.7

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,39 @@ 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");
43
+ }
44
+ function setProjectRoot(root) {
45
+ _activeProjectRoot = root;
39
46
  }
40
- function readLock() {
41
- const p = lockPath();
42
- if (!(0, import_node_fs.existsSync)(p)) return null;
47
+ function readLock(projectRoot) {
48
+ const root = projectRoot ?? _activeProjectRoot;
49
+ const p = lockPath(root);
50
+ if (!(0, import_node_fs.existsSync)(p)) {
51
+ if (root) {
52
+ const globalP = lockPath();
53
+ if ((0, import_node_fs.existsSync)(globalP)) {
54
+ try {
55
+ const data = JSON.parse((0, import_node_fs.readFileSync)(globalP, "utf-8"));
56
+ if (typeof data.pid === "number" && typeof data.port === "number" && data.cwd === root) {
57
+ return data;
58
+ }
59
+ } catch {
60
+ }
61
+ }
62
+ }
63
+ return null;
64
+ }
43
65
  try {
44
66
  const data = JSON.parse((0, import_node_fs.readFileSync)(p, "utf-8"));
45
67
  if (typeof data.pid !== "number" || typeof data.port !== "number") return null;
@@ -70,31 +92,35 @@ function getListenerPid(port) {
70
92
  return null;
71
93
  }
72
94
  }
73
- function getLiveLock() {
74
- const lock = readLock();
95
+ function getLiveLock(projectRoot) {
96
+ const root = projectRoot ?? _activeProjectRoot;
97
+ const lock = readLock(root);
75
98
  if (!lock) return null;
76
99
  const listenerPid = getListenerPid(lock.port);
77
100
  const live = listenerPid !== null ? listenerPid === lock.pid : isPidAlive(lock.pid);
78
101
  if (!live) {
79
102
  try {
80
- (0, import_node_fs.unlinkSync)(lockPath());
103
+ (0, import_node_fs.unlinkSync)(lockPath(root));
81
104
  } catch {
82
105
  }
83
106
  return null;
84
107
  }
85
108
  return lock;
86
109
  }
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");
110
+ function writeLock(data, projectRoot) {
111
+ const root = projectRoot ?? _activeProjectRoot;
112
+ (0, import_node_fs.mkdirSync)(lockDir(root), { recursive: true });
113
+ (0, import_node_fs.writeFileSync)(lockPath(root), JSON.stringify(data, null, 2) + "\n", "utf-8");
114
+ if (root) _activeProjectRoot = root;
90
115
  }
91
- function clearLock() {
116
+ function clearLock(projectRoot) {
117
+ const root = projectRoot ?? _activeProjectRoot;
92
118
  try {
93
- (0, import_node_fs.unlinkSync)(lockPath());
119
+ (0, import_node_fs.unlinkSync)(lockPath(root));
94
120
  } catch {
95
121
  }
96
122
  }
97
- var import_node_child_process, import_node_fs, import_node_os, import_node_path;
123
+ var import_node_child_process, import_node_fs, import_node_os, import_node_path, _activeProjectRoot;
98
124
  var init_lockfile = __esm({
99
125
  "src/server/lockfile.ts"() {
100
126
  "use strict";
@@ -106,6 +132,10 @@ var init_lockfile = __esm({
106
132
  });
107
133
 
108
134
  // src/server/graph/core/config.ts
135
+ var config_exports = {};
136
+ __export(config_exports, {
137
+ loadConfig: () => loadConfig
138
+ });
109
139
  function loadConfig(rootDir) {
110
140
  const configPath = (0, import_node_path2.join)(rootDir, CONFIG_FILENAME);
111
141
  if (!(0, import_node_fs2.existsSync)(configPath)) return {};
@@ -586,34 +616,6 @@ function classifyType(id) {
586
616
  if (id.startsWith("lib/") || id.startsWith("config/")) return "lib";
587
617
  return "component";
588
618
  }
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
619
  function extractRoute(id) {
618
620
  if (!id.endsWith("/page.tsx")) return null;
619
621
  let route = id.replace(/^app\//, "/").replace(/\/page\.tsx$/, "");
@@ -834,8 +836,7 @@ function generate(rootDir) {
834
836
  const parsed = parsedByPath.get(absPath);
835
837
  const name = parsed.name || nameFromFilename(absPath);
836
838
  const route = extractRoute(id);
837
- const module_ = classifyModule(id);
838
- nodes.push({ id, type, name, route, module: module_, exports: parsed.exports });
839
+ nodes.push({ id, type, name, route, exports: parsed.exports });
839
840
  nodeIdSet.add(id);
840
841
  nodeTypeMap.set(id, type);
841
842
  if (route) routeToNodeId.set(route, id);
@@ -939,7 +940,6 @@ function generate(rootDir) {
939
940
  type: "external",
940
941
  name: parsed.name || nameFromFilename(absPath),
941
942
  route: null,
942
- module: "external",
943
943
  exports: parsed.exports
944
944
  });
945
945
  nodeIdSet.add(externalId);
@@ -2031,29 +2031,443 @@ var init_graph_builder = __esm({
2031
2031
  }
2032
2032
  });
2033
2033
 
2034
+ // src/server/graph/taggers/module-tagger.ts
2035
+ function matchGlob(pattern, id) {
2036
+ const patParts = pattern.split("/");
2037
+ const idParts = id.split("/");
2038
+ return matchParts(patParts, 0, idParts, 0);
2039
+ }
2040
+ function matchParts(pat, pi, id, ii) {
2041
+ while (pi < pat.length && ii < id.length) {
2042
+ const p = pat[pi];
2043
+ if (p === "**") {
2044
+ for (let skip = ii; skip <= id.length; skip++) {
2045
+ if (matchParts(pat, pi + 1, id, skip)) return true;
2046
+ }
2047
+ return false;
2048
+ }
2049
+ if (p === "*") {
2050
+ pi++;
2051
+ ii++;
2052
+ continue;
2053
+ }
2054
+ if (p !== id[ii]) return false;
2055
+ pi++;
2056
+ ii++;
2057
+ }
2058
+ while (pi < pat.length && pat[pi] === "**") pi++;
2059
+ return pi === pat.length && ii === id.length;
2060
+ }
2061
+ function detectConventionDirs(rootDir) {
2062
+ const result = /* @__PURE__ */ new Map();
2063
+ const searchDirs = [
2064
+ rootDir,
2065
+ (0, import_node_path11.join)(rootDir, "src"),
2066
+ (0, import_node_path11.join)(rootDir, "app"),
2067
+ (0, import_node_path11.join)(rootDir, "lib")
2068
+ ];
2069
+ for (const base of searchDirs) {
2070
+ for (const convention of CONVENTION_DIRS) {
2071
+ const dir = (0, import_node_path11.join)(base, convention);
2072
+ if (!(0, import_node_fs10.existsSync)(dir)) continue;
2073
+ try {
2074
+ const stat = (0, import_node_fs10.statSync)(dir);
2075
+ if (!stat.isDirectory()) continue;
2076
+ const entries = (0, import_node_fs10.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
2077
+ if (entries.length > 0) {
2078
+ const relPath = dir.replace(rootDir + "/", "").replace(rootDir + "\\", "");
2079
+ result.set(relPath, entries);
2080
+ }
2081
+ } catch {
2082
+ }
2083
+ }
2084
+ }
2085
+ return result;
2086
+ }
2087
+ function extractRouteGroups(id) {
2088
+ const groups = [];
2089
+ const re = /\(([^)]+)\)/g;
2090
+ let m;
2091
+ while ((m = re.exec(id)) !== null) {
2092
+ groups.push(m[1]);
2093
+ }
2094
+ return groups;
2095
+ }
2096
+ function isRouteGroup(segment) {
2097
+ return segment.startsWith("(") && segment.endsWith(")");
2098
+ }
2099
+ function isDynamicSegment(segment) {
2100
+ return segment.startsWith("[") || segment.startsWith(":");
2101
+ }
2102
+ function isDomainDir(segment) {
2103
+ return segment.includes(".") && !segment.endsWith(".tsx") && !segment.endsWith(".ts") && !segment.endsWith(".js") && !segment.endsWith(".jsx") && !segment.endsWith(".vue");
2104
+ }
2105
+ function isTrivialGroup(name, extraTrivial) {
2106
+ if (TRIVIAL_GROUPS.has(name)) return true;
2107
+ if (extraTrivial?.has(name)) return true;
2108
+ const lower = name.toLowerCase();
2109
+ const wrapperPatterns = [
2110
+ /^.*-?wrapper$/,
2111
+ // "page-wrapper", "use-page-wrapper"
2112
+ /^.*-?layout$/,
2113
+ // "admin-layout", "settings-layout"
2114
+ /^use-/,
2115
+ // "use-page-wrapper"
2116
+ /^default$/
2117
+ ];
2118
+ return wrapperPatterns.some((p) => p.test(lower));
2119
+ }
2120
+ function normalizeGroupName(name) {
2121
+ return name.replace(/-pages?$/, "").replace(/-layout$/, "").replace(/-wrapper$/, "");
2122
+ }
2123
+ function extractModuleFromPath(id, extraTrivial) {
2124
+ const segments = id.split("/");
2125
+ const routeGroups = extractRouteGroups(id);
2126
+ const moduleGroups = routeGroups.filter((g) => !isTrivialGroup(g, extraTrivial)).map(normalizeGroupName);
2127
+ if (moduleGroups.length > 0) {
2128
+ return moduleGroups[moduleGroups.length - 1];
2129
+ }
2130
+ const meaningful = [];
2131
+ for (const seg of segments) {
2132
+ if (seg.includes(".")) continue;
2133
+ if (isRouteGroup(seg)) continue;
2134
+ if (isDynamicSegment(seg)) continue;
2135
+ if (isDomainDir(seg)) continue;
2136
+ if (SKIP_SEGMENTS.has(seg)) continue;
2137
+ meaningful.push(seg);
2138
+ }
2139
+ if (meaningful.length > 0) {
2140
+ return meaningful[0];
2141
+ }
2142
+ return "root";
2143
+ }
2144
+ var import_node_fs10, import_node_path11, CONVENTION_DIRS, SKIP_SEGMENTS, TRIVIAL_GROUPS, cachedRootDir, cachedConventionDirs, moduleTagger;
2145
+ var init_module_tagger = __esm({
2146
+ "src/server/graph/taggers/module-tagger.ts"() {
2147
+ "use strict";
2148
+ import_node_fs10 = require("node:fs");
2149
+ import_node_path11 = require("node:path");
2150
+ CONVENTION_DIRS = ["features", "modules", "domains", "areas"];
2151
+ SKIP_SEGMENTS = /* @__PURE__ */ new Set([
2152
+ "src",
2153
+ "app",
2154
+ "client",
2155
+ "server",
2156
+ "lib",
2157
+ "config"
2158
+ ]);
2159
+ TRIVIAL_GROUPS = /* @__PURE__ */ new Set([
2160
+ // Generic app wrappers
2161
+ "app",
2162
+ "all",
2163
+ "ee",
2164
+ "home",
2165
+ "root",
2166
+ "main",
2167
+ "site",
2168
+ // Auth/access boundary wrappers — protect routes, not feature modules
2169
+ "protected",
2170
+ "authenticated",
2171
+ "authed",
2172
+ "private",
2173
+ "public",
2174
+ "logged-in",
2175
+ "logged-out",
2176
+ "unprotected",
2177
+ "unauthenticated",
2178
+ "auth-required",
2179
+ "no-auth",
2180
+ "guest-only"
2181
+ ]);
2182
+ cachedRootDir = null;
2183
+ cachedConventionDirs = /* @__PURE__ */ new Map();
2184
+ moduleTagger = {
2185
+ id: "module",
2186
+ tagKey: "module",
2187
+ trackUntagged: true,
2188
+ layers: null,
2189
+ // applies to all layers
2190
+ tag(nodes, layer, rootDir) {
2191
+ if (cachedRootDir !== rootDir) {
2192
+ cachedConventionDirs = detectConventionDirs(rootDir);
2193
+ cachedRootDir = rootDir;
2194
+ }
2195
+ let configRules = [];
2196
+ let extraTrivial;
2197
+ try {
2198
+ const { loadConfig: loadConfig2 } = (init_config(), __toCommonJS(config_exports));
2199
+ const config = loadConfig2(rootDir);
2200
+ configRules = config.taggers?.module?.rules ?? [];
2201
+ const trivialFromConfig = config.taggers?.module?.trivialGroups;
2202
+ if (trivialFromConfig?.length) {
2203
+ extraTrivial = new Set(trivialFromConfig);
2204
+ }
2205
+ } catch {
2206
+ }
2207
+ const result = /* @__PURE__ */ new Map();
2208
+ for (const node of nodes) {
2209
+ const id = node.id;
2210
+ let matched = false;
2211
+ for (const rule of configRules) {
2212
+ if (matchGlob(rule.match, id)) {
2213
+ result.set(id, rule.module);
2214
+ matched = true;
2215
+ break;
2216
+ }
2217
+ }
2218
+ if (matched) continue;
2219
+ matched = false;
2220
+ for (const [convDir, moduleNames] of cachedConventionDirs) {
2221
+ if (id.startsWith(convDir + "/")) {
2222
+ const rest = id.slice(convDir.length + 1);
2223
+ const firstSeg = rest.split("/")[0];
2224
+ if (moduleNames.includes(firstSeg)) {
2225
+ result.set(id, firstSeg);
2226
+ matched = true;
2227
+ break;
2228
+ }
2229
+ }
2230
+ }
2231
+ if (matched) continue;
2232
+ const module2 = extractModuleFromPath(id, extraTrivial);
2233
+ result.set(id, module2);
2234
+ }
2235
+ return result;
2236
+ }
2237
+ };
2238
+ }
2239
+ });
2240
+
2241
+ // src/server/graph/taggers/screen-tagger.ts
2242
+ var SCREEN_TYPES, screenTagger;
2243
+ var init_screen_tagger = __esm({
2244
+ "src/server/graph/taggers/screen-tagger.ts"() {
2245
+ "use strict";
2246
+ SCREEN_TYPES = /* @__PURE__ */ new Set(["page", "layout"]);
2247
+ screenTagger = {
2248
+ id: "screen",
2249
+ tagKey: "screen",
2250
+ trackUntagged: true,
2251
+ layers: ["ui"],
2252
+ tag(nodes, layer) {
2253
+ if (layer !== "ui") return /* @__PURE__ */ new Map();
2254
+ const result = /* @__PURE__ */ new Map();
2255
+ for (const node of nodes) {
2256
+ if (SCREEN_TYPES.has(node.type)) {
2257
+ result.set(node.id, "true");
2258
+ }
2259
+ }
2260
+ return result;
2261
+ }
2262
+ };
2263
+ }
2264
+ });
2265
+
2266
+ // src/server/graph/core/tagger-registry.ts
2267
+ function registerBuiltins2(registry, disabled, config) {
2268
+ for (const tagger of BUILTIN_TAGGERS) {
2269
+ if (disabled.has(tagger.id)) continue;
2270
+ const override = config.taggers?.trackUntagged?.[tagger.id];
2271
+ if (override !== void 0) {
2272
+ tagger.trackUntagged = override;
2273
+ }
2274
+ registry.register(tagger);
2275
+ }
2276
+ }
2277
+ function loadCustomTaggers(registry, config, rootDir, disabled) {
2278
+ for (const entry of config.taggers?.custom ?? []) {
2279
+ if (disabled.has(entry.id)) continue;
2280
+ try {
2281
+ const absPath = (0, import_node_path12.resolve)(rootDir, entry.path);
2282
+ const mod = require(absPath);
2283
+ const tagger = "default" in mod ? mod.default : mod;
2284
+ const override = config.taggers?.trackUntagged?.[tagger.id];
2285
+ if (override !== void 0) {
2286
+ tagger.trackUntagged = override;
2287
+ }
2288
+ registry.register(tagger);
2289
+ } catch (err2) {
2290
+ process.stderr.write(`[launch-chart] failed to load custom tagger from ${entry.path}: ${err2}
2291
+ `);
2292
+ }
2293
+ }
2294
+ }
2295
+ function createTaggerRegistry(config, rootDir) {
2296
+ const registry = new TaggerRegistry();
2297
+ const disabled = new Set(config.taggers?.disabled ?? []);
2298
+ registerBuiltins2(registry, disabled, config);
2299
+ loadCustomTaggers(registry, config, rootDir, disabled);
2300
+ return registry;
2301
+ }
2302
+ var import_node_path12, TaggerRegistry, BUILTIN_TAGGERS;
2303
+ var init_tagger_registry = __esm({
2304
+ "src/server/graph/core/tagger-registry.ts"() {
2305
+ "use strict";
2306
+ import_node_path12 = require("node:path");
2307
+ init_module_tagger();
2308
+ init_screen_tagger();
2309
+ TaggerRegistry = class {
2310
+ constructor() {
2311
+ this.taggers = [];
2312
+ this.ids = /* @__PURE__ */ new Set();
2313
+ }
2314
+ register(tagger) {
2315
+ if (this.ids.has(tagger.id)) {
2316
+ throw new Error(`Duplicate tagger id: ${tagger.id}`);
2317
+ }
2318
+ this.ids.add(tagger.id);
2319
+ this.taggers.push(tagger);
2320
+ }
2321
+ getAll() {
2322
+ return this.taggers;
2323
+ }
2324
+ getForLayer(layer) {
2325
+ return this.taggers.filter((t) => t.layers === null || t.layers.includes(layer));
2326
+ }
2327
+ };
2328
+ BUILTIN_TAGGERS = [moduleTagger, screenTagger];
2329
+ }
2330
+ });
2331
+
2332
+ // src/server/graph/core/tag-store.ts
2333
+ function tagsFilePath(rootDir) {
2334
+ return (0, import_node_path13.join)(rootDir, GRAPHS_DIR, TAGS_FILENAME);
2335
+ }
2336
+ function readTagStore(rootDir) {
2337
+ const filePath = tagsFilePath(rootDir);
2338
+ if (!(0, import_node_fs11.existsSync)(filePath)) return {};
2339
+ const stat = (0, import_node_fs11.statSync)(filePath);
2340
+ const cached = tagCache.get(filePath);
2341
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
2342
+ return cached.store;
2343
+ }
2344
+ try {
2345
+ const content = (0, import_node_fs11.readFileSync)(filePath, "utf-8");
2346
+ const store = JSON.parse(content);
2347
+ tagCache.set(filePath, { mtimeMs: stat.mtimeMs, store });
2348
+ return store;
2349
+ } catch {
2350
+ return {};
2351
+ }
2352
+ }
2353
+ function writeTagStore(rootDir, store) {
2354
+ const filePath = tagsFilePath(rootDir);
2355
+ const dir = (0, import_node_path13.dirname)(filePath);
2356
+ (0, import_node_fs11.mkdirSync)(dir, { recursive: true });
2357
+ const cleaned = {};
2358
+ for (const [nodeId, tags] of Object.entries(store)) {
2359
+ if (Object.keys(tags).length > 0) {
2360
+ cleaned[nodeId] = tags;
2361
+ }
2362
+ }
2363
+ (0, import_node_fs11.writeFileSync)(filePath, JSON.stringify(cleaned, null, 2) + "\n", "utf-8");
2364
+ tagCache.delete(filePath);
2365
+ }
2366
+ function setTag(rootDir, nodeId, key, value) {
2367
+ const store = readTagStore(rootDir);
2368
+ if (!store[nodeId]) store[nodeId] = {};
2369
+ store[nodeId][key] = value;
2370
+ writeTagStore(rootDir, store);
2371
+ }
2372
+ function removeTag(rootDir, nodeId, key) {
2373
+ const store = readTagStore(rootDir);
2374
+ if (!store[nodeId]) return;
2375
+ delete store[nodeId][key];
2376
+ if (Object.keys(store[nodeId]).length === 0) {
2377
+ delete store[nodeId];
2378
+ }
2379
+ writeTagStore(rootDir, store);
2380
+ }
2381
+ var import_node_fs11, import_node_path13, TAGS_FILENAME, GRAPHS_DIR, tagCache;
2382
+ var init_tag_store = __esm({
2383
+ "src/server/graph/core/tag-store.ts"() {
2384
+ "use strict";
2385
+ import_node_fs11 = require("node:fs");
2386
+ import_node_path13 = require("node:path");
2387
+ TAGS_FILENAME = "tags.json";
2388
+ GRAPHS_DIR = ".launchsecure/graphs";
2389
+ tagCache = /* @__PURE__ */ new Map();
2390
+ }
2391
+ });
2392
+
2034
2393
  // src/server/graph/index.ts
2035
2394
  function graphsDir(rootDir) {
2036
- return (0, import_node_path11.join)(rootDir, GRAPHS_DIR);
2395
+ return (0, import_node_path14.join)(rootDir, GRAPHS_DIR2);
2037
2396
  }
2038
2397
  function graphFilePath(rootDir, layer) {
2039
- return (0, import_node_path11.join)(graphsDir(rootDir), `${layer}.json`);
2398
+ return (0, import_node_path14.join)(graphsDir(rootDir), `${layer}.json`);
2399
+ }
2400
+ function tagsFilePath2(rootDir) {
2401
+ return (0, import_node_path14.join)(graphsDir(rootDir), "tags.json");
2402
+ }
2403
+ function getMtimeMs(filePath) {
2404
+ if (!(0, import_node_fs12.existsSync)(filePath)) return 0;
2405
+ return (0, import_node_fs12.statSync)(filePath).mtimeMs;
2040
2406
  }
2041
2407
  function invalidateCache(filePath) {
2042
2408
  graphCache.delete(filePath);
2043
2409
  }
2044
- function readGraph(rootDir, layer) {
2410
+ function invalidateTaggedCache(rootDir, layer) {
2411
+ taggedCache.delete(`${rootDir}:${layer}`);
2412
+ }
2413
+ function applyTags(graph, layer, rootDir) {
2414
+ const config = loadConfig(rootDir);
2415
+ const registry = createTaggerRegistry(config, rootDir);
2416
+ const manualTags = readTagStore(rootDir);
2417
+ const taggedNodes = graph.nodes.map((n) => ({ ...n }));
2418
+ const taggers = registry.getForLayer(layer);
2419
+ for (const tagger of taggers) {
2420
+ const assignments = tagger.tag(taggedNodes, layer, rootDir);
2421
+ for (const node of taggedNodes) {
2422
+ if (!node.tags) node.tags = {};
2423
+ const tags = node.tags;
2424
+ const value = assignments.get(node.id);
2425
+ if (value !== void 0) {
2426
+ tags[tagger.tagKey] = value;
2427
+ } else if (tagger.trackUntagged) {
2428
+ tags[tagger.tagKey] = "untagged";
2429
+ }
2430
+ }
2431
+ }
2432
+ for (const node of taggedNodes) {
2433
+ const manual = manualTags[node.id];
2434
+ if (manual) {
2435
+ if (!node.tags) node.tags = {};
2436
+ const tags = node.tags;
2437
+ Object.assign(tags, manual);
2438
+ }
2439
+ }
2440
+ return { ...graph, nodes: taggedNodes };
2441
+ }
2442
+ function readGraphRaw(rootDir, layer) {
2045
2443
  const filePath = graphFilePath(rootDir, layer);
2046
- if (!(0, import_node_fs10.existsSync)(filePath)) return null;
2047
- const stat = (0, import_node_fs10.statSync)(filePath);
2444
+ if (!(0, import_node_fs12.existsSync)(filePath)) return null;
2445
+ const stat = (0, import_node_fs12.statSync)(filePath);
2048
2446
  const cached = graphCache.get(filePath);
2049
2447
  if (cached && cached.mtimeMs === stat.mtimeMs) {
2050
2448
  return cached.graph;
2051
2449
  }
2052
- const content = (0, import_node_fs10.readFileSync)(filePath, "utf-8");
2450
+ const content = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
2053
2451
  const graph = JSON.parse(content);
2054
2452
  graphCache.set(filePath, { mtimeMs: stat.mtimeMs, graph });
2055
2453
  return graph;
2056
2454
  }
2455
+ function readGraph(rootDir, layer) {
2456
+ const rawFilePath = graphFilePath(rootDir, layer);
2457
+ if (!(0, import_node_fs12.existsSync)(rawFilePath)) return null;
2458
+ const rawMtime = getMtimeMs(rawFilePath);
2459
+ const tagsMtime = getMtimeMs(tagsFilePath2(rootDir));
2460
+ const cacheKey = `${rootDir}:${layer}`;
2461
+ const cached = taggedCache.get(cacheKey);
2462
+ if (cached && cached.rawMtimeMs === rawMtime && cached.tagsMtimeMs === tagsMtime) {
2463
+ return cached.graph;
2464
+ }
2465
+ const raw = readGraphRaw(rootDir, layer);
2466
+ if (!raw) return null;
2467
+ const tagged = applyTags(raw, layer, rootDir);
2468
+ taggedCache.set(cacheKey, { rawMtimeMs: rawMtime, tagsMtimeMs: tagsMtime, graph: tagged });
2469
+ return tagged;
2470
+ }
2057
2471
  function readAllGraphs(rootDir) {
2058
2472
  const result = {};
2059
2473
  for (const layer of LAYERS) {
@@ -2064,25 +2478,31 @@ function readAllGraphs(rootDir) {
2064
2478
  }
2065
2479
  function generateGraph(rootDir, layer) {
2066
2480
  const dir = graphsDir(rootDir);
2067
- (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
2481
+ (0, import_node_fs12.mkdirSync)(dir, { recursive: true });
2068
2482
  const results = layer ? [generateLayer(rootDir, layer)].filter((r) => r !== null) : generateAll(rootDir);
2069
2483
  for (const result of results) {
2070
2484
  const filePath = graphFilePath(rootDir, result.layer);
2071
- (0, import_node_fs10.writeFileSync)(filePath, JSON.stringify(result.output, null, 2) + "\n", "utf-8");
2485
+ (0, import_node_fs12.writeFileSync)(filePath, JSON.stringify(result.output, null, 2) + "\n", "utf-8");
2072
2486
  invalidateCache(filePath);
2487
+ invalidateTaggedCache(rootDir, result.layer);
2073
2488
  }
2074
2489
  return results;
2075
2490
  }
2076
- var import_node_fs10, import_node_path11, GRAPHS_DIR, LAYERS, graphCache;
2491
+ var import_node_fs12, import_node_path14, GRAPHS_DIR2, LAYERS, graphCache, taggedCache;
2077
2492
  var init_graph = __esm({
2078
2493
  "src/server/graph/index.ts"() {
2079
2494
  "use strict";
2080
- import_node_fs10 = require("node:fs");
2081
- import_node_path11 = require("node:path");
2495
+ import_node_fs12 = require("node:fs");
2496
+ import_node_path14 = require("node:path");
2082
2497
  init_graph_builder();
2083
- GRAPHS_DIR = ".launchsecure/graphs";
2498
+ init_config();
2499
+ init_tagger_registry();
2500
+ init_tag_store();
2501
+ init_tag_store();
2502
+ GRAPHS_DIR2 = ".launchsecure/graphs";
2084
2503
  LAYERS = ["ui", "api", "db"];
2085
2504
  graphCache = /* @__PURE__ */ new Map();
2505
+ taggedCache = /* @__PURE__ */ new Map();
2086
2506
  }
2087
2507
  });
2088
2508
 
@@ -2092,19 +2512,22 @@ __export(chart_serve_exports, {
2092
2512
  runServeCli: () => runServeCli,
2093
2513
  startChartServer: () => startChartServer
2094
2514
  });
2095
- function findProjectRoot2(startDir) {
2515
+ function randomPort() {
2516
+ return 49152 + Math.floor(Math.random() * (65535 - 49152));
2517
+ }
2518
+ function findProjectRoot(startDir) {
2096
2519
  let dir = startDir;
2097
2520
  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);
2521
+ const graphsDir2 = import_node_path15.default.join(dir, ".launchsecure", "graphs");
2522
+ 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;
2523
+ const parent = import_node_path15.default.dirname(dir);
2101
2524
  if (parent === dir) break;
2102
2525
  dir = parent;
2103
2526
  }
2104
2527
  dir = startDir;
2105
2528
  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);
2529
+ if (import_node_fs13.default.existsSync(import_node_path15.default.join(dir, ".git"))) return dir;
2530
+ const parent = import_node_path15.default.dirname(dir);
2108
2531
  if (parent === dir) break;
2109
2532
  dir = parent;
2110
2533
  }
@@ -2156,16 +2579,16 @@ function buildMergedGraph(projectRoot) {
2156
2579
  };
2157
2580
  }
2158
2581
  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();
2582
+ if (!import_node_fs13.default.existsSync(filePath) || !import_node_fs13.default.statSync(filePath).isFile()) return false;
2583
+ const ext = import_node_path15.default.extname(filePath).toLowerCase();
2161
2584
  const mime = MIME_TYPES[ext] ?? "application/octet-stream";
2162
2585
  res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" });
2163
- import_node_fs11.default.createReadStream(filePath).pipe(res);
2586
+ import_node_fs13.default.createReadStream(filePath).pipe(res);
2164
2587
  return true;
2165
2588
  }
2166
2589
  function serveIndex(res, clientDir) {
2167
- const indexPath = import_node_path12.default.join(clientDir, "index.html");
2168
- if (!import_node_fs11.default.existsSync(indexPath)) {
2590
+ const indexPath = import_node_path15.default.join(clientDir, "index.html");
2591
+ if (!import_node_fs13.default.existsSync(indexPath)) {
2169
2592
  res.writeHead(500, { "Content-Type": "text/plain" });
2170
2593
  res.end(`LaunchChart client bundle not found at ${clientDir}. Run 'npm run build:chart-client'.`);
2171
2594
  return;
@@ -2173,14 +2596,14 @@ function serveIndex(res, clientDir) {
2173
2596
  serveStatic(res, indexPath);
2174
2597
  }
2175
2598
  function tryListen(server, port) {
2176
- return new Promise((resolve2, reject) => {
2599
+ return new Promise((resolve3, reject) => {
2177
2600
  const onError = (err2) => {
2178
2601
  server.off("listening", onListening);
2179
2602
  reject(err2);
2180
2603
  };
2181
2604
  const onListening = () => {
2182
2605
  server.off("error", onError);
2183
- resolve2(port);
2606
+ resolve3(port);
2184
2607
  };
2185
2608
  server.once("error", onError);
2186
2609
  server.once("listening", onListening);
@@ -2206,8 +2629,8 @@ async function bindWithFallback(server, startPort) {
2206
2629
  }
2207
2630
  async function startChartServer(opts = {}) {
2208
2631
  const cwd = opts.cwd ?? process.cwd();
2209
- const projectRoot = findProjectRoot2(cwd);
2210
- const existing = getLiveLock();
2632
+ const projectRoot = findProjectRoot(cwd);
2633
+ const existing = getLiveLock(projectRoot);
2211
2634
  if (existing) {
2212
2635
  if (!opts.quiet) {
2213
2636
  process.stderr.write(
@@ -2217,7 +2640,7 @@ async function startChartServer(opts = {}) {
2217
2640
  }
2218
2641
  return { port: existing.port, url: existing.url };
2219
2642
  }
2220
- const clientDir = opts.clientDir ?? import_node_path12.default.join(__dirname, "..", "chart-client");
2643
+ const clientDir = opts.clientDir ?? import_node_path15.default.join(__dirname, "..", "chart-client");
2221
2644
  const server = import_node_http.default.createServer((req, res) => {
2222
2645
  try {
2223
2646
  const url2 = new URL(req.url ?? "/", `http://${req.headers.host}`);
@@ -2255,6 +2678,26 @@ async function startChartServer(opts = {}) {
2255
2678
  }
2256
2679
  return;
2257
2680
  }
2681
+ if (req.method === "GET" && url2.pathname === "/api/file-content") {
2682
+ const relPath = url2.searchParams.get("path");
2683
+ if (!relPath || relPath.includes("..") || import_node_path15.default.isAbsolute(relPath)) {
2684
+ res.writeHead(400, { "Content-Type": "application/json" });
2685
+ res.end(JSON.stringify({ error: "Invalid path" }));
2686
+ return;
2687
+ }
2688
+ const filePath = import_node_path15.default.join(projectRoot, relPath);
2689
+ if (!filePath.startsWith(projectRoot) || !import_node_fs13.default.existsSync(filePath) || !import_node_fs13.default.statSync(filePath).isFile()) {
2690
+ res.writeHead(404, { "Content-Type": "application/json" });
2691
+ res.end(JSON.stringify({ error: "File not found" }));
2692
+ return;
2693
+ }
2694
+ const ext = import_node_path15.default.extname(filePath).toLowerCase();
2695
+ const langMap = { ".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx", ".prisma": "prisma", ".json": "json", ".css": "css" };
2696
+ const content = import_node_fs13.default.readFileSync(filePath, "utf-8");
2697
+ res.writeHead(200, { "Content-Type": "application/json" });
2698
+ res.end(JSON.stringify({ content, language: langMap[ext] ?? "text", path: relPath }));
2699
+ return;
2700
+ }
2258
2701
  if (req.method === "GET" && url2.pathname === "/api/health") {
2259
2702
  res.writeHead(200, { "Content-Type": "application/json" });
2260
2703
  res.end(JSON.stringify({ ok: true, projectRoot }));
@@ -2284,8 +2727,94 @@ async function startChartServer(opts = {}) {
2284
2727
  req.on("end", () => {
2285
2728
  try {
2286
2729
  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");
2730
+ const configPath = import_node_path15.default.join(projectRoot, ".launchchart.json");
2731
+ import_node_fs13.default.writeFileSync(configPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
2732
+ res.writeHead(200, { "Content-Type": "application/json" });
2733
+ res.end(JSON.stringify({ ok: true }));
2734
+ } catch (err2) {
2735
+ res.writeHead(400, { "Content-Type": "application/json" });
2736
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2737
+ }
2738
+ });
2739
+ return;
2740
+ }
2741
+ if (req.method === "GET" && url2.pathname === "/api/tagger-config") {
2742
+ const config = loadConfig(projectRoot);
2743
+ const builtinTaggers = [
2744
+ { id: "module", tagKey: "module", trackUntagged: config.taggers?.trackUntagged?.module ?? true },
2745
+ { id: "screen", tagKey: "screen", trackUntagged: config.taggers?.trackUntagged?.screen ?? true }
2746
+ ];
2747
+ const disabled = config.taggers?.disabled ?? [];
2748
+ const customTaggers = config.taggers?.custom ?? [];
2749
+ const moduleRules = config.taggers?.module?.rules ?? [];
2750
+ res.writeHead(200, { "Content-Type": "application/json" });
2751
+ res.end(JSON.stringify({ builtinTaggers, disabled, customTaggers, moduleRules }));
2752
+ return;
2753
+ }
2754
+ if (req.method === "POST" && url2.pathname === "/api/tagger-config") {
2755
+ let body = "";
2756
+ req.on("data", (chunk) => {
2757
+ body += chunk.toString();
2758
+ });
2759
+ req.on("end", () => {
2760
+ try {
2761
+ const taggerConfig = JSON.parse(body);
2762
+ const config = loadConfig(projectRoot);
2763
+ config.taggers = taggerConfig;
2764
+ const configPath = import_node_path15.default.join(projectRoot, ".launchchart.json");
2765
+ import_node_fs13.default.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2766
+ res.writeHead(200, { "Content-Type": "application/json" });
2767
+ res.end(JSON.stringify({ ok: true }));
2768
+ } catch (err2) {
2769
+ res.writeHead(400, { "Content-Type": "application/json" });
2770
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2771
+ }
2772
+ });
2773
+ return;
2774
+ }
2775
+ if (req.method === "GET" && url2.pathname === "/api/tags") {
2776
+ const store = readTagStore(projectRoot);
2777
+ res.writeHead(200, { "Content-Type": "application/json" });
2778
+ res.end(JSON.stringify(store));
2779
+ return;
2780
+ }
2781
+ if (req.method === "POST" && url2.pathname === "/api/tags") {
2782
+ let body = "";
2783
+ req.on("data", (chunk) => {
2784
+ body += chunk.toString();
2785
+ });
2786
+ req.on("end", () => {
2787
+ try {
2788
+ const { nodeId, key, value } = JSON.parse(body);
2789
+ if (!nodeId || !key || !value) {
2790
+ res.writeHead(400, { "Content-Type": "application/json" });
2791
+ res.end(JSON.stringify({ ok: false, error: "nodeId, key, and value are required" }));
2792
+ return;
2793
+ }
2794
+ setTag(projectRoot, nodeId, key, value);
2795
+ res.writeHead(200, { "Content-Type": "application/json" });
2796
+ res.end(JSON.stringify({ ok: true }));
2797
+ } catch (err2) {
2798
+ res.writeHead(400, { "Content-Type": "application/json" });
2799
+ res.end(JSON.stringify({ ok: false, error: String(err2) }));
2800
+ }
2801
+ });
2802
+ return;
2803
+ }
2804
+ if (req.method === "DELETE" && url2.pathname === "/api/tags") {
2805
+ let body = "";
2806
+ req.on("data", (chunk) => {
2807
+ body += chunk.toString();
2808
+ });
2809
+ req.on("end", () => {
2810
+ try {
2811
+ const { nodeId, key } = JSON.parse(body);
2812
+ if (!nodeId || !key) {
2813
+ res.writeHead(400, { "Content-Type": "application/json" });
2814
+ res.end(JSON.stringify({ ok: false, error: "nodeId and key are required" }));
2815
+ return;
2816
+ }
2817
+ removeTag(projectRoot, nodeId, key);
2289
2818
  res.writeHead(200, { "Content-Type": "application/json" });
2290
2819
  res.end(JSON.stringify({ ok: true }));
2291
2820
  } catch (err2) {
@@ -2296,7 +2825,7 @@ async function startChartServer(opts = {}) {
2296
2825
  return;
2297
2826
  }
2298
2827
  if (url2.pathname !== "/") {
2299
- const staticPath = import_node_path12.default.join(clientDir, url2.pathname);
2828
+ const staticPath = import_node_path15.default.join(clientDir, url2.pathname);
2300
2829
  if (serveStatic(res, staticPath)) return;
2301
2830
  }
2302
2831
  serveIndex(res, clientDir);
@@ -2305,7 +2834,8 @@ async function startChartServer(opts = {}) {
2305
2834
  res.end(JSON.stringify({ error: String(err2) }));
2306
2835
  }
2307
2836
  });
2308
- const port = await bindWithFallback(server, opts.port ?? DEFAULT_PORT);
2837
+ const startPort = opts.port ?? randomPort();
2838
+ const port = await bindWithFallback(server, startPort);
2309
2839
  const url = `http://localhost:${port}`;
2310
2840
  writeLock({
2311
2841
  pid: process.pid,
@@ -2313,9 +2843,9 @@ async function startChartServer(opts = {}) {
2313
2843
  cwd,
2314
2844
  url,
2315
2845
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
2316
- });
2846
+ }, projectRoot);
2317
2847
  const cleanup = () => {
2318
- clearLock();
2848
+ clearLock(projectRoot);
2319
2849
  server.close();
2320
2850
  };
2321
2851
  process.once("SIGINT", () => {
@@ -2350,21 +2880,20 @@ function runServeCli(argv) {
2350
2880
  process.exit(1);
2351
2881
  });
2352
2882
  }
2353
- var import_node_http, import_node_fs11, import_node_path12, DEFAULT_PORT, MAX_PORT_SCAN, MIME_TYPES;
2883
+ var import_node_http, import_node_fs13, import_node_path15, MAX_PORT_SCAN, MIME_TYPES;
2354
2884
  var init_chart_serve = __esm({
2355
2885
  "src/server/chart-serve.ts"() {
2356
2886
  "use strict";
2357
2887
  import_node_http = __toESM(require("node:http"));
2358
- import_node_fs11 = __toESM(require("node:fs"));
2359
- import_node_path12 = __toESM(require("node:path"));
2888
+ import_node_fs13 = __toESM(require("node:fs"));
2889
+ import_node_path15 = __toESM(require("node:path"));
2360
2890
  init_graph();
2361
2891
  init_lockfile();
2362
2892
  init_config();
2363
2893
  init_react_nextjs();
2364
2894
  init_nextjs_routes();
2365
2895
  init_prisma_schema();
2366
- DEFAULT_PORT = 52819;
2367
- MAX_PORT_SCAN = 20;
2896
+ MAX_PORT_SCAN = 3;
2368
2897
  MIME_TYPES = {
2369
2898
  ".html": "text/html; charset=utf-8",
2370
2899
  ".js": "application/javascript; charset=utf-8",
@@ -2395,7 +2924,7 @@ function matchesSearch(node, query) {
2395
2924
  function toMinimal(nodes) {
2396
2925
  return nodes.map((n) => {
2397
2926
  const out = { id: n.id, type: n.type, name: n.name };
2398
- if (n.module != null) out.module = n.module;
2927
+ if (n.tags != null) out.tags = n.tags;
2399
2928
  if (n.route != null) out.route = n.route;
2400
2929
  if (n.methods != null) out.methods = n.methods;
2401
2930
  return out;
@@ -2403,11 +2932,13 @@ function toMinimal(nodes) {
2403
2932
  }
2404
2933
  function toCompactNode(n) {
2405
2934
  const out = { i: n.id, t: n.type, n: n.name };
2406
- if (n.module != null) out.m = n.module;
2935
+ const tags = n.tags;
2936
+ if (tags?.module) out.m = tags.module;
2407
2937
  if (n.route != null) out.r = n.route;
2408
2938
  if (n.methods != null) out.mt = n.methods;
2409
2939
  if (n.exports != null) out.x = n.exports;
2410
2940
  if (n.columns != null) out.c = n.columns;
2941
+ if (tags != null) out.tg = tags;
2411
2942
  for (const k of Object.keys(n)) {
2412
2943
  if (!COMPACT_NODE_KNOWN_KEYS.has(k) && n[k] != null) out[k] = n[k];
2413
2944
  }
@@ -2483,7 +3014,8 @@ function layerSummary(graph) {
2483
3014
  const moduleCounts = {};
2484
3015
  for (const n of graph.nodes) {
2485
3016
  typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
2486
- const mod = n.module;
3017
+ const tags = n.tags;
3018
+ const mod = tags?.module;
2487
3019
  if (mod) moduleCounts[mod] = (moduleCounts[mod] ?? 0) + 1;
2488
3020
  }
2489
3021
  const edgeTypeCounts = {};
@@ -2540,12 +3072,14 @@ function runReadGraphQueryRaw(rootDir, args) {
2540
3072
  const search = args.search;
2541
3073
  const type = args.type;
2542
3074
  const module_ = args.module;
3075
+ const tagKey = args.tag_key;
3076
+ const tagValue = args.tag_value;
2543
3077
  const nodeId = args.node_id;
2544
3078
  const hops = args.hops ?? 1;
2545
3079
  const layerIsDb = args.layer === "db";
2546
3080
  const minimal = args.minimal ?? layerIsDb;
2547
3081
  const includeEdges = args.include_edges;
2548
- const hasFilter = !!(search || type || module_ || nodeId);
3082
+ const hasFilter = !!(search || type || module_ || nodeId || tagKey && tagValue);
2549
3083
  if (layer && !["ui", "api", "db"].includes(layer)) {
2550
3084
  return { error: `Invalid layer "${layer}". Must be one of: ui, api, db` };
2551
3085
  }
@@ -2601,7 +3135,9 @@ function runReadGraphQueryRaw(rootDir, args) {
2601
3135
  const matched = graph.nodes.filter((n) => {
2602
3136
  if (search && !matchesSearch(n, search)) return false;
2603
3137
  if (type && n.type !== type) return false;
2604
- if (module_ && n.module !== module_) return false;
3138
+ const nodeTags = n.tags;
3139
+ if (module_ && nodeTags?.module !== module_) return false;
3140
+ if (tagKey && tagValue && nodeTags?.[tagKey] !== tagValue) return false;
2605
3141
  return true;
2606
3142
  });
2607
3143
  const matchedIds = new Set(matched.map((n) => n.id));
@@ -2688,9 +3224,9 @@ function handleReadGraph(args) {
2688
3224
  return okJson(result);
2689
3225
  }
2690
3226
  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");
3227
+ if (layer === "ui") return (0, import_node_path16.join)(rootDir, "src", nodeId);
3228
+ if (layer === "api") return (0, import_node_path16.join)(rootDir, nodeId);
3229
+ if (layer === "db") return (0, import_node_path16.join)(rootDir, "prisma", "schema.prisma");
2694
3230
  return null;
2695
3231
  }
2696
3232
  function handleGrepNodes(args) {
@@ -2750,11 +3286,11 @@ function handleGrepNodes(args) {
2750
3286
  let filesSearched = 0;
2751
3287
  let truncated = false;
2752
3288
  for (const [filePath, nodeId] of filePaths) {
2753
- if (!(0, import_node_fs12.existsSync)(filePath)) continue;
3289
+ if (!(0, import_node_fs14.existsSync)(filePath)) continue;
2754
3290
  filesSearched++;
2755
3291
  let content;
2756
3292
  try {
2757
- content = (0, import_node_fs12.readFileSync)(filePath, "utf-8");
3293
+ content = (0, import_node_fs14.readFileSync)(filePath, "utf-8");
2758
3294
  } catch {
2759
3295
  continue;
2760
3296
  }
@@ -2792,7 +3328,8 @@ function handleGrepNodes(args) {
2792
3328
  });
2793
3329
  }
2794
3330
  function handleChartServerStatus() {
2795
- const lock = getLiveLock();
3331
+ const rootDir = process.cwd();
3332
+ const lock = getLiveLock(rootDir);
2796
3333
  if (!lock) {
2797
3334
  return okJson({ running: false });
2798
3335
  }
@@ -2806,7 +3343,8 @@ function handleChartServerStatus() {
2806
3343
  });
2807
3344
  }
2808
3345
  function handleStartChartServer(args) {
2809
- const lock = getLiveLock();
3346
+ const rootDir = process.cwd();
3347
+ const lock = getLiveLock(rootDir);
2810
3348
  if (lock) {
2811
3349
  return okJson({
2812
3350
  started: false,
@@ -2817,11 +3355,11 @@ function handleStartChartServer(args) {
2817
3355
  });
2818
3356
  }
2819
3357
  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");
3358
+ const logDir = (0, import_node_path16.join)((0, import_node_os2.homedir)(), ".launchsecure");
3359
+ (0, import_node_fs14.mkdirSync)(logDir, { recursive: true });
3360
+ const logPath = (0, import_node_path16.join)(logDir, "launch-chart.log");
3361
+ const out = (0, import_node_fs14.openSync)(logPath, "a");
3362
+ const err2 = (0, import_node_fs14.openSync)(logPath, "a");
2825
3363
  const portArgs = args.port ? ["--port", String(args.port)] : [];
2826
3364
  const child = (0, import_node_child_process2.spawn)(process.execPath, [entryPath, "serve", ...portArgs], {
2827
3365
  detached: true,
@@ -2836,7 +3374,8 @@ function handleStartChartServer(args) {
2836
3374
  });
2837
3375
  }
2838
3376
  function handleStopChartServer() {
2839
- const lock = getLiveLock();
3377
+ const rootDir = process.cwd();
3378
+ const lock = getLiveLock(rootDir);
2840
3379
  if (!lock) {
2841
3380
  return okJson({ stopped: false, reason: "not_running" });
2842
3381
  }
@@ -2846,14 +3385,45 @@ function handleStopChartServer() {
2846
3385
  } catch (e) {
2847
3386
  const code = e.code;
2848
3387
  if (code === "ESRCH") {
2849
- clearLock();
3388
+ clearLock(rootDir);
2850
3389
  return okJson({ stopped: true, pid: lock.pid, note: "process was already gone, lock cleaned up" });
2851
3390
  }
2852
3391
  return okJson({ stopped: false, reason: `kill failed: ${code ?? e}` });
2853
3392
  }
2854
3393
  }
3394
+ function handleAddTag(args) {
3395
+ const rootDir = process.cwd();
3396
+ const nodeId = args.node_id;
3397
+ const key = args.key;
3398
+ const value = args.value;
3399
+ if (!nodeId) return err("node_id is required");
3400
+ if (!key) return err("key is required");
3401
+ if (!value) return err("value is required");
3402
+ const graphs = readAllGraphs(rootDir);
3403
+ let found = false;
3404
+ for (const graph of Object.values(graphs)) {
3405
+ if (graph && graph.nodes.some((n) => n.id === nodeId)) {
3406
+ found = true;
3407
+ break;
3408
+ }
3409
+ }
3410
+ if (!found) {
3411
+ return err(`Node "${nodeId}" not found in any graph layer. Check the node_id.`);
3412
+ }
3413
+ setTag(rootDir, nodeId, key, value);
3414
+ return okJson({ ok: true, node_id: nodeId, tag: { [key]: value } });
3415
+ }
3416
+ function handleRemoveTag(args) {
3417
+ const rootDir = process.cwd();
3418
+ const nodeId = args.node_id;
3419
+ const key = args.key;
3420
+ if (!nodeId) return err("node_id is required");
3421
+ if (!key) return err("key is required");
3422
+ removeTag(rootDir, nodeId, key);
3423
+ return okJson({ ok: true, node_id: nodeId, removed_key: key });
3424
+ }
2855
3425
  function handleDetectProjectStack() {
2856
- const rootDir = findProjectRoot(process.cwd());
3426
+ const rootDir = process.cwd();
2857
3427
  const parsers = [
2858
3428
  { id: "react-nextjs", layer: "ui", detected: reactNextjsParser.detect(rootDir) },
2859
3429
  { id: "nextjs-routes", layer: "api", detected: nextjsRoutesParser.detect(rootDir) },
@@ -2871,20 +3441,20 @@ function handleDetectProjectStack() {
2871
3441
  if (f.type === "out_of_pattern") stats.out_of_pattern++;
2872
3442
  }
2873
3443
  }
2874
- const srcDir = (0, import_node_path13.join)(rootDir, "src");
2875
- if ((0, import_node_fs12.existsSync)(srcDir)) {
3444
+ const srcDir = (0, import_node_path16.join)(rootDir, "src");
3445
+ if ((0, import_node_fs14.existsSync)(srcDir)) {
2876
3446
  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 })) {
3447
+ if (!(0, import_node_fs14.existsSync)(dir)) return;
3448
+ for (const entry of (0, import_node_fs14.readdirSync)(dir, { withFileTypes: true })) {
2879
3449
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2880
- const full = (0, import_node_path13.join)(dir, entry.name);
3450
+ const full = (0, import_node_path16.join)(dir, entry.name);
2881
3451
  if (entry.isDirectory()) {
2882
3452
  scanDir(full);
2883
3453
  continue;
2884
3454
  }
2885
- if (![".ts", ".tsx"].includes((0, import_node_path13.extname)(entry.name))) continue;
3455
+ if (![".ts", ".tsx"].includes((0, import_node_path16.extname)(entry.name))) continue;
2886
3456
  try {
2887
- const content = (0, import_node_fs12.readFileSync)(full, "utf-8");
3457
+ const content = (0, import_node_fs14.readFileSync)(full, "utf-8");
2888
3458
  const matches = content.match(/@api\s+(GET|POST|PUT|DELETE|PATCH)\s+\/\S+/g);
2889
3459
  if (matches) stats.annotations += matches.length;
2890
3460
  } catch {
@@ -2971,6 +3541,14 @@ function handleMessage(msg) {
2971
3541
  respond(id ?? null, handleDetectProjectStack());
2972
3542
  return;
2973
3543
  }
3544
+ if (toolName === "add_tag") {
3545
+ respond(id ?? null, handleAddTag(args));
3546
+ return;
3547
+ }
3548
+ if (toolName === "remove_tag") {
3549
+ respond(id ?? null, handleRemoveTag(args));
3550
+ return;
3551
+ }
2974
3552
  respondError(id ?? null, -32601, `Unknown tool: ${toolName}`);
2975
3553
  return;
2976
3554
  }
@@ -3006,12 +3584,12 @@ function startGraphMcpServer() {
3006
3584
  process.stderr.write(`[launchsecure-graph] MCP server started (cwd: ${process.cwd()})
3007
3585
  `);
3008
3586
  }
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;
3587
+ 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
3588
  var init_graph_mcp = __esm({
3011
3589
  "src/server/graph-mcp.ts"() {
3012
3590
  "use strict";
3013
- import_node_fs12 = require("node:fs");
3014
- import_node_path13 = require("node:path");
3591
+ import_node_fs14 = require("node:fs");
3592
+ import_node_path16 = require("node:path");
3015
3593
  import_node_child_process2 = require("node:child_process");
3016
3594
  import_node_os2 = require("node:os");
3017
3595
  init_graph();
@@ -3041,7 +3619,7 @@ var init_graph_mcp = __esm({
3041
3619
  },
3042
3620
  {
3043
3621
  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.',
3622
+ 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
3623
  inputSchema: {
3046
3624
  type: "object",
3047
3625
  properties: {
@@ -3060,7 +3638,15 @@ var init_graph_mcp = __esm({
3060
3638
  },
3061
3639
  module: {
3062
3640
  type: "string",
3063
- description: 'UI layer only \u2014 filter by module (e.g. "auth", "admin", "project", "org").'
3641
+ description: 'Filter by module tag (e.g. "auth", "admin", "settings"). Works across all layers.'
3642
+ },
3643
+ tag_key: {
3644
+ type: "string",
3645
+ description: "Filter by arbitrary tag key. Must be used with tag_value."
3646
+ },
3647
+ tag_value: {
3648
+ type: "string",
3649
+ description: "Filter by tag value for the given tag_key."
3064
3650
  },
3065
3651
  node_id: {
3066
3652
  type: "string",
@@ -3184,6 +3770,46 @@ Use this when the user asks "is the chart running", "show me the project graph U
3184
3770
  type: "object",
3185
3771
  properties: {}
3186
3772
  }
3773
+ },
3774
+ {
3775
+ name: "add_tag",
3776
+ 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.',
3777
+ inputSchema: {
3778
+ type: "object",
3779
+ properties: {
3780
+ node_id: {
3781
+ type: "string",
3782
+ description: 'The node id to tag (e.g. "app/(auth)/login/page.tsx").'
3783
+ },
3784
+ key: {
3785
+ type: "string",
3786
+ description: 'Tag key (e.g. "module", "owner", "refactor_later").'
3787
+ },
3788
+ value: {
3789
+ type: "string",
3790
+ description: 'Tag value (e.g. "auth", "alice", "true").'
3791
+ }
3792
+ },
3793
+ required: ["node_id", "key", "value"]
3794
+ }
3795
+ },
3796
+ {
3797
+ name: "remove_tag",
3798
+ 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).",
3799
+ inputSchema: {
3800
+ type: "object",
3801
+ properties: {
3802
+ node_id: {
3803
+ type: "string",
3804
+ description: "The node id to remove the tag from."
3805
+ },
3806
+ key: {
3807
+ type: "string",
3808
+ description: "Tag key to remove."
3809
+ }
3810
+ },
3811
+ required: ["node_id", "key"]
3812
+ }
3187
3813
  }
3188
3814
  ];
3189
3815
  COMPACT_SCHEMA = {
@@ -3191,11 +3817,12 @@ Use this when the user asks "is the chart running", "show me the project graph U
3191
3817
  i: "id",
3192
3818
  t: "type",
3193
3819
  n: "name",
3194
- m: "module",
3820
+ m: "module (from tags)",
3195
3821
  r: "route",
3196
3822
  mt: "methods",
3197
3823
  x: "exports",
3198
- c: "columns"
3824
+ c: "columns",
3825
+ tg: "tags"
3199
3826
  },
3200
3827
  edges: {
3201
3828
  s: "source_node_index",
@@ -3213,7 +3840,8 @@ Use this when the user asks "is the chart running", "show me the project graph U
3213
3840
  "route",
3214
3841
  "methods",
3215
3842
  "exports",
3216
- "columns"
3843
+ "columns",
3844
+ "tags"
3217
3845
  ]);
3218
3846
  EST_CHARS_PER_NODE_FULL = {
3219
3847
  ui: 300,
@@ -3237,10 +3865,10 @@ Use this when the user asks "is the chart running", "show me the project graph U
3237
3865
 
3238
3866
  // src/server/graph-mcp-entry.ts
3239
3867
  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"));
3868
+ var import_node_fs15 = require("node:fs");
3869
+ var import_node_path17 = __toESM(require("node:path"));
3242
3870
  var import_node_os3 = require("node:os");
3243
- var import_node_fs14 = require("node:fs");
3871
+ var import_node_fs16 = require("node:fs");
3244
3872
  init_lockfile();
3245
3873
  function logStderr(msg) {
3246
3874
  process.stderr.write(`[launch-chart] ${msg}
@@ -3248,17 +3876,19 @@ function logStderr(msg) {
3248
3876
  }
3249
3877
  function maybeAutoServe() {
3250
3878
  if (process.env.LAUNCH_CHART_AUTOSERVE !== "1") return;
3251
- const existing = getLiveLock();
3879
+ const rootDir = process.cwd();
3880
+ setProjectRoot(rootDir);
3881
+ const existing = getLiveLock(rootDir);
3252
3882
  if (existing) {
3253
3883
  logStderr(`autoserve: reusing existing server at ${existing.url}`);
3254
3884
  return;
3255
3885
  }
3256
3886
  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");
3887
+ const logDir = import_node_path17.default.join((0, import_node_os3.homedir)(), ".launchsecure");
3888
+ (0, import_node_fs16.mkdirSync)(logDir, { recursive: true });
3889
+ const logPath = import_node_path17.default.join(logDir, "launch-chart.log");
3890
+ const out = (0, import_node_fs15.openSync)(logPath, "a");
3891
+ const err2 = (0, import_node_fs15.openSync)(logPath, "a");
3262
3892
  const entryPath = process.argv[1];
3263
3893
  const child = (0, import_node_child_process3.spawn)(process.execPath, [entryPath, "serve"], {
3264
3894
  detached: true,
@@ -3272,6 +3902,7 @@ function maybeAutoServe() {
3272
3902
  }
3273
3903
  }
3274
3904
  async function main() {
3905
+ setProjectRoot(process.cwd());
3275
3906
  const argv = process.argv.slice(2);
3276
3907
  const subcommand = argv[0];
3277
3908
  if (subcommand === "serve") {