helpshelf 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ import {
2
+ crawlApp
3
+ } from "./chunk-DT7ACGIP.js";
4
+ export {
5
+ crawlApp
6
+ };
@@ -0,0 +1,212 @@
1
+ // src/crawler/app-crawler.ts
2
+ import { chromium } from "playwright-core";
3
+ import { join } from "path";
4
+ import { existsSync } from "fs";
5
+ async function crawlApp(options) {
6
+ const { url, email, password, snapshot, screenshotDir, maxPages, onProgress } = options;
7
+ const possiblePaths = [
8
+ process.env.CHROME_PATH,
9
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
10
+ `${process.env.HOME}/Library/Caches/ms-playwright/chromium-1208/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`
11
+ ].filter(Boolean);
12
+ let execPath;
13
+ for (const p of possiblePaths) {
14
+ if (existsSync(p)) {
15
+ execPath = p;
16
+ break;
17
+ }
18
+ }
19
+ const browser = await chromium.launch({
20
+ headless: true,
21
+ ...execPath ? { executablePath: execPath } : {}
22
+ });
23
+ const context = await browser.newContext({
24
+ viewport: { width: 1280, height: 800 },
25
+ deviceScaleFactor: 2
26
+ // Retina screenshots
27
+ });
28
+ const page = await context.newPage();
29
+ const pages = [];
30
+ const visited = /* @__PURE__ */ new Set();
31
+ let screenshotCount = 0;
32
+ try {
33
+ onProgress("Navigating to app...");
34
+ await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
35
+ await page.waitForTimeout(1e3);
36
+ if (email && password) {
37
+ onProgress("Logging in...");
38
+ const loggedIn = await attemptLogin(page, email, password, url);
39
+ if (!loggedIn) {
40
+ onProgress("Login failed \u2014 continuing as anonymous");
41
+ } else {
42
+ onProgress("Logged in successfully");
43
+ await page.waitForTimeout(2e3);
44
+ }
45
+ }
46
+ onProgress("Discovering navigation...");
47
+ const navigation = await discoverNavigation(page);
48
+ const urlsToVisit = buildUrlList(page.url(), navigation, snapshot, maxPages);
49
+ onProgress(`Found ${urlsToVisit.length} pages to document`);
50
+ for (let i = 0; i < urlsToVisit.length; i++) {
51
+ const targetUrl = urlsToVisit[i];
52
+ const path = new URL(targetUrl).pathname;
53
+ if (visited.has(path)) continue;
54
+ visited.add(path);
55
+ onProgress(`[${i + 1}/${urlsToVisit.length}] Capturing ${path}`);
56
+ try {
57
+ await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 15e3 });
58
+ await page.waitForTimeout(800);
59
+ const screenshotName = pathToFilename(path) + ".png";
60
+ const screenshotPath = join(screenshotDir, screenshotName);
61
+ await page.screenshot({ path: screenshotPath, fullPage: false });
62
+ screenshotCount++;
63
+ const capture = await extractPageInfo(page, path, screenshotName);
64
+ pages.push(capture);
65
+ } catch (err) {
66
+ onProgress(` \u26A0 Skipped ${path}: ${err instanceof Error ? err.message : "timeout"}`);
67
+ }
68
+ }
69
+ const appName = await page.title() || snapshot?.project.name || null;
70
+ return {
71
+ baseUrl: url,
72
+ pages,
73
+ totalScreenshots: screenshotCount,
74
+ appName,
75
+ navigation
76
+ };
77
+ } finally {
78
+ await browser.close();
79
+ }
80
+ }
81
+ async function attemptLogin(page, email, password, baseUrl) {
82
+ const currentUrl = page.url();
83
+ const isLoginPage = currentUrl.includes("login") || currentUrl.includes("signin") || currentUrl.includes("auth");
84
+ if (!isLoginPage) {
85
+ for (const loginPath of ["/login", "/signin", "/auth/login", "/auth/signin"]) {
86
+ try {
87
+ await page.goto(new URL(loginPath, baseUrl).toString(), { waitUntil: "networkidle", timeout: 1e4 });
88
+ if (page.url().includes("login") || page.url().includes("signin") || page.url().includes("auth")) break;
89
+ } catch {
90
+ continue;
91
+ }
92
+ }
93
+ }
94
+ try {
95
+ const emailInput = await page.$('input[type="email"], input[name="email"], input[placeholder*="email" i], input[placeholder*="username" i]');
96
+ const passwordInput = await page.$('input[type="password"]');
97
+ if (!emailInput || !passwordInput) return false;
98
+ await emailInput.fill(email);
99
+ await passwordInput.fill(password);
100
+ const submitBtn = await page.$('button[type="submit"], button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign In")');
101
+ if (submitBtn) {
102
+ await submitBtn.click();
103
+ await page.waitForNavigation({ waitUntil: "networkidle", timeout: 1e4 }).catch(() => {
104
+ });
105
+ await page.waitForTimeout(2e3);
106
+ }
107
+ const afterUrl = page.url();
108
+ return !afterUrl.includes("login") && !afterUrl.includes("signin");
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+ async function discoverNavigation(page) {
114
+ const sections = [];
115
+ try {
116
+ const navLinks = await page.$$eval(
117
+ 'nav a[href], aside a[href], [role="navigation"] a[href]',
118
+ (anchors) => anchors.map((a) => ({
119
+ text: a.textContent?.trim() || "",
120
+ href: a.getAttribute("href") || ""
121
+ })).filter((l) => l.text && l.href && !l.href.startsWith("#") && !l.href.startsWith("http"))
122
+ );
123
+ if (navLinks.length > 0) {
124
+ sections.push({
125
+ label: "Navigation",
126
+ links: navLinks
127
+ });
128
+ }
129
+ } catch {
130
+ }
131
+ return { sections };
132
+ }
133
+ function buildUrlList(currentUrl, navigation, snapshot, maxPages) {
134
+ const baseUrl = new URL(currentUrl).origin;
135
+ const urls = /* @__PURE__ */ new Set();
136
+ urls.add(currentUrl);
137
+ for (const section of navigation.sections) {
138
+ for (const link of section.links) {
139
+ try {
140
+ const fullUrl = new URL(link.href, baseUrl).toString();
141
+ urls.add(fullUrl);
142
+ } catch {
143
+ }
144
+ }
145
+ }
146
+ if (snapshot) {
147
+ const pagePaths = /* @__PURE__ */ new Set();
148
+ for (const route of snapshot.apiRoutes) {
149
+ if (route.path.startsWith("/api/")) continue;
150
+ pagePaths.add(route.path);
151
+ }
152
+ for (const path of pagePaths) {
153
+ try {
154
+ urls.add(new URL(path, baseUrl).toString());
155
+ } catch {
156
+ }
157
+ }
158
+ }
159
+ return Array.from(urls).slice(0, maxPages);
160
+ }
161
+ async function extractPageInfo(page, path, screenshot) {
162
+ const title = await page.title();
163
+ const headings = await page.$$eval(
164
+ "h1, h2, h3",
165
+ (els) => els.map((el) => el.textContent?.trim() || "").filter(Boolean).slice(0, 10)
166
+ );
167
+ const elements = await page.$$eval(
168
+ "button:visible, a:visible, input:visible, select:visible",
169
+ (els) => els.slice(0, 30).map((el) => ({
170
+ type: el.tagName.toLowerCase() === "button" ? "button" : el.tagName.toLowerCase() === "a" ? "link" : el.tagName.toLowerCase() === "input" ? "input" : el.tagName.toLowerCase() === "select" ? "select" : "button",
171
+ text: el.textContent?.trim().slice(0, 100) || "",
172
+ ariaLabel: el.getAttribute("aria-label")
173
+ }))
174
+ );
175
+ const navItems = await page.$$eval(
176
+ "nav a, aside a",
177
+ (els) => els.map((el) => el.textContent?.trim() || "").filter(Boolean)
178
+ );
179
+ const category = detectCategory(path);
180
+ return {
181
+ url: page.url(),
182
+ path,
183
+ title,
184
+ screenshot,
185
+ headings,
186
+ elements,
187
+ navItems,
188
+ category
189
+ };
190
+ }
191
+ function detectCategory(path) {
192
+ const p = path.toLowerCase();
193
+ if (p.includes("setting")) return "Settings";
194
+ if (p.includes("billing") || p.includes("plan") || p.includes("subscription")) return "Billing";
195
+ if (p.includes("team") || p.includes("member") || p.includes("invite")) return "Team Management";
196
+ if (p.includes("analytic") || p.includes("stat") || p.includes("report")) return "Analytics";
197
+ if (p.includes("content") || p.includes("article") || p.includes("doc")) return "Content Management";
198
+ if (p.includes("integrat") || p.includes("connect") || p.includes("source")) return "Integrations";
199
+ if (p.includes("widget") || p.includes("floatie") || p.includes("preview")) return "Widget";
200
+ if (p.includes("announce")) return "Announcements";
201
+ if (p.includes("help") || p.includes("center")) return "Help Center";
202
+ if (p.includes("login") || p.includes("signup") || p.includes("auth")) return "Authentication";
203
+ if (p === "/" || p.includes("dashboard") || p.includes("overview")) return "Getting Started";
204
+ return "Features";
205
+ }
206
+ function pathToFilename(path) {
207
+ return (path || "home").replace(/^\//, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "") || "home";
208
+ }
209
+
210
+ export {
211
+ crawlApp
212
+ };
package/dist/index.js CHANGED
@@ -1,28 +1,37 @@
1
1
  #!/usr/bin/env node
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
2
+ import {
3
+ crawlApp
4
+ } from "./chunk-DT7ACGIP.js";
8
5
 
9
6
  // src/index.ts
10
7
  import { Command } from "commander";
11
8
 
12
9
  // src/commands/scan.ts
13
- import { resolve, relative as relative4 } from "path";
10
+ import { resolve, join as join8, relative as relative4 } from "path";
14
11
  import { writeFileSync, existsSync as existsSync8, mkdirSync } from "fs";
15
12
  import chalk from "chalk";
16
13
  import ora from "ora";
17
14
 
18
15
  // src/scanners/project.ts
19
16
  import { join, basename } from "path";
20
- import { readFileSync, existsSync } from "fs";
17
+ import { existsSync } from "fs";
18
+
19
+ // src/utils/fs.ts
20
+ import { readFileSync } from "fs";
21
+ function readFileSafe(path, encoding = "utf-8") {
22
+ try {
23
+ return readFileSync(path, encoding);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // src/scanners/project.ts
21
30
  function scanProjectMeta(dir) {
22
31
  const pkgPath = join(dir, "package.json");
23
32
  if (existsSync(pkgPath)) {
24
33
  try {
25
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
34
+ const pkg = JSON.parse(readFileSafe(pkgPath) || "{}");
26
35
  return {
27
36
  name: pkg.name || basename(dir),
28
37
  description: pkg.description || null,
@@ -37,7 +46,7 @@ function scanProjectMeta(dir) {
37
46
  const pyprojectPath = join(dir, "pyproject.toml");
38
47
  if (existsSync(pyprojectPath)) {
39
48
  try {
40
- const content = readFileSync(pyprojectPath, "utf-8");
49
+ const content = readFileSafe(pyprojectPath) || "";
41
50
  const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
42
51
  const versionMatch = content.match(/^version\s*=\s*"([^"]+)"/m);
43
52
  const descMatch = content.match(/^description\s*=\s*"([^"]+)"/m);
@@ -64,7 +73,7 @@ function scanProjectMeta(dir) {
64
73
 
65
74
  // src/scanners/stack.ts
66
75
  import { join as join2 } from "path";
67
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
76
+ import { existsSync as existsSync2 } from "fs";
68
77
  function scanStack(dir) {
69
78
  const result = {
70
79
  framework: null,
@@ -101,7 +110,7 @@ function scanStack(dir) {
101
110
  const pkgPath = join2(dir, "package.json");
102
111
  if (existsSync2(pkgPath)) {
103
112
  try {
104
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
113
+ const pkg = JSON.parse(readFileSafe(pkgPath) || "{}");
105
114
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
106
115
  if (allDeps["next"]) {
107
116
  result.framework = "next";
@@ -161,7 +170,7 @@ function scanStack(dir) {
161
170
  if (result.language === "python") {
162
171
  const reqPath = join2(dir, "requirements.txt");
163
172
  if (existsSync2(reqPath)) {
164
- const reqs = readFileSync2(reqPath, "utf-8").toLowerCase();
173
+ const reqs = (readFileSafe(reqPath) || "").toLowerCase();
165
174
  if (reqs.includes("django")) {
166
175
  result.framework = "django";
167
176
  result.technologies.push("Django");
@@ -180,7 +189,7 @@ function scanStack(dir) {
180
189
 
181
190
  // src/scanners/readme.ts
182
191
  import { join as join3 } from "path";
183
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
192
+ import { existsSync as existsSync3 } from "fs";
184
193
  var README_NAMES = ["README.md", "readme.md", "README.MD", "Readme.md", "README", "readme"];
185
194
  function scanReadme(dir) {
186
195
  let readmePath = null;
@@ -192,7 +201,8 @@ function scanReadme(dir) {
192
201
  }
193
202
  }
194
203
  if (!readmePath) return null;
195
- const content = readFileSync3(readmePath, "utf-8");
204
+ const content = readFileSafe(readmePath);
205
+ if (!content) return null;
196
206
  return parseMarkdownSections(content);
197
207
  }
198
208
  function parseMarkdownSections(markdown) {
@@ -235,7 +245,7 @@ function parseMarkdownSections(markdown) {
235
245
 
236
246
  // src/scanners/api-routes.ts
237
247
  import { join as join4, relative } from "path";
238
- import { readFileSync as readFileSync4, existsSync as existsSync4, readdirSync, statSync } from "fs";
248
+ import { existsSync as existsSync4, readdirSync, statSync } from "fs";
239
249
  function scanApiRoutes(dir, stack) {
240
250
  const routes = [];
241
251
  if (stack.framework === "next") {
@@ -267,7 +277,8 @@ function scanNextAppRoutes(projectDir, appDir, routes) {
267
277
  if (!filePath.includes("/api/")) return;
268
278
  const relPath = relative(appDir, filePath);
269
279
  const routePath = "/" + relPath.replace(/\/route\.(ts|js)$/, "").replace(/\[([^\]]+)\]/g, ":$1");
270
- const content = readFileSync4(filePath, "utf-8");
280
+ const content = readFileSafe(filePath);
281
+ if (!content) return;
271
282
  const methods = extractHttpMethods(content);
272
283
  const description = extractRouteDescription(content);
273
284
  for (const method of methods) {
@@ -287,7 +298,8 @@ function scanNextPagesRoutes(projectDir, pagesDir, routes) {
287
298
  if (!filePath.match(/\.(ts|js|tsx|jsx)$/)) return;
288
299
  const relPath = relative(apiDir, filePath);
289
300
  const routePath = "/api/" + relPath.replace(/\.(ts|js|tsx|jsx)$/, "").replace(/\[([^\]]+)\]/g, ":$1").replace(/\/index$/, "");
290
- const content = readFileSync4(filePath, "utf-8");
301
+ const content = readFileSafe(filePath);
302
+ if (!content) return;
291
303
  const description = extractRouteDescription(content);
292
304
  routes.push({
293
305
  method: "ALL",
@@ -305,7 +317,8 @@ function scanExpressRoutes(dir, routes) {
305
317
  if (!existsSync4(fullPath)) continue;
306
318
  walkDir(fullPath, (filePath) => {
307
319
  if (!filePath.match(/\.(ts|js)$/)) return;
308
- const content = readFileSync4(filePath, "utf-8");
320
+ const content = readFileSafe(filePath);
321
+ if (!content) return;
309
322
  let match;
310
323
  while ((match = routePattern.exec(content)) !== null) {
311
324
  routes.push({
@@ -361,7 +374,7 @@ function walkDir(dir, callback) {
361
374
 
362
375
  // src/scanners/env-vars.ts
363
376
  import { join as join5, relative as relative2 } from "path";
364
- import { readFileSync as readFileSync5, existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
377
+ import { existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
365
378
  var PUBLIC_PREFIXES = ["NEXT_PUBLIC_", "VITE_", "NUXT_PUBLIC_", "EXPO_PUBLIC_"];
366
379
  function scanEnvVars(dir) {
367
380
  const envMap = /* @__PURE__ */ new Map();
@@ -371,7 +384,8 @@ function scanEnvVars(dir) {
371
384
  const fullPath = join5(dir, srcDir);
372
385
  if (!existsSync5(fullPath)) continue;
373
386
  walkSourceFiles(fullPath, (filePath) => {
374
- const content = readFileSync5(filePath, "utf-8");
387
+ const content = readFileSafe(filePath);
388
+ if (!content) return;
375
389
  const relFile = relative2(dir, filePath);
376
390
  const processEnvPattern = /process\.env\.([A-Z][A-Z0-9_]*)/g;
377
391
  let match;
@@ -392,7 +406,8 @@ function scanEnvVars(dir) {
392
406
  for (const config of rootConfigs) {
393
407
  const configPath = join5(dir, config);
394
408
  if (!existsSync5(configPath)) continue;
395
- const content = readFileSync5(configPath, "utf-8");
409
+ const content = readFileSafe(configPath);
410
+ if (!content) continue;
396
411
  const relFile = config;
397
412
  const processEnvPattern = /process\.env\.([A-Z][A-Z0-9_]*)/g;
398
413
  let match;
@@ -434,7 +449,9 @@ function parseEnvExample(dir) {
434
449
  for (const name of candidates) {
435
450
  const path = join5(dir, name);
436
451
  if (!existsSync5(path)) continue;
437
- const lines = readFileSync5(path, "utf-8").split("\n");
452
+ const content = readFileSafe(path);
453
+ if (!content) continue;
454
+ const lines = content.split("\n");
438
455
  let lastComment = "";
439
456
  for (const line of lines) {
440
457
  const trimmed = line.trim();
@@ -472,7 +489,7 @@ function walkSourceFiles(dir, callback) {
472
489
 
473
490
  // src/scanners/dependencies.ts
474
491
  import { join as join6 } from "path";
475
- import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
492
+ import { existsSync as existsSync6 } from "fs";
476
493
  var CATEGORY_MAP = {
477
494
  // Frameworks
478
495
  "next": "framework",
@@ -570,7 +587,7 @@ function scanDependencies(dir) {
570
587
  const pkgPath = join6(dir, "package.json");
571
588
  if (!existsSync6(pkgPath)) return [];
572
589
  try {
573
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf-8"));
590
+ const pkg = JSON.parse(readFileSafe(pkgPath) || "{}");
574
591
  const deps = [];
575
592
  if (pkg.dependencies) {
576
593
  for (const [name, version] of Object.entries(pkg.dependencies)) {
@@ -919,34 +936,55 @@ async function scanCommand(options) {
919
936
  let readme = null;
920
937
  if (options.readme !== false) {
921
938
  spinner.start("Parsing README...");
922
- readme = scanReadme(projectDir);
923
- if (readme) {
924
- spinner.succeed(`README: ${chalk.cyan(readme.length + " sections")}`);
925
- } else {
926
- spinner.warn("No README found");
939
+ try {
940
+ readme = scanReadme(projectDir);
941
+ if (readme) {
942
+ spinner.succeed(`README: ${chalk.cyan(readme.length + " sections")}`);
943
+ } else {
944
+ spinner.warn("No README found");
945
+ }
946
+ } catch {
947
+ spinner.warn("Could not read README (file may be on iCloud or network drive)");
927
948
  }
928
949
  }
929
950
  let apiRoutes = [];
930
951
  if (options.api !== false) {
931
952
  spinner.start("Detecting API routes...");
932
- apiRoutes = scanApiRoutes(projectDir, stack);
933
- spinner.succeed(`API routes: ${chalk.cyan(apiRoutes.length + " endpoints")}`);
953
+ try {
954
+ apiRoutes = scanApiRoutes(projectDir, stack);
955
+ spinner.succeed(`API routes: ${chalk.cyan(apiRoutes.length + " endpoints")}`);
956
+ } catch {
957
+ spinner.warn("Could not scan API routes");
958
+ }
934
959
  }
935
960
  let envVars = [];
936
961
  if (options.env !== false) {
937
962
  spinner.start("Scanning environment variables...");
938
- envVars = scanEnvVars(projectDir);
939
- spinner.succeed(`Env vars: ${chalk.cyan(envVars.length + " variables")}`);
963
+ try {
964
+ envVars = scanEnvVars(projectDir);
965
+ spinner.succeed(`Env vars: ${chalk.cyan(envVars.length + " variables")}`);
966
+ } catch {
967
+ spinner.warn("Could not scan environment variables");
968
+ }
940
969
  }
941
970
  let dependencies = [];
942
971
  if (options.deps !== false) {
943
972
  spinner.start("Analyzing dependencies...");
944
- dependencies = scanDependencies(projectDir);
945
- spinner.succeed(`Dependencies: ${chalk.cyan(dependencies.length + " packages")}`);
973
+ try {
974
+ dependencies = scanDependencies(projectDir);
975
+ spinner.succeed(`Dependencies: ${chalk.cyan(dependencies.length + " packages")}`);
976
+ } catch {
977
+ spinner.warn("Could not analyze dependencies");
978
+ }
946
979
  }
947
980
  spinner.start("Mapping file structure...");
948
- const structure = scanFileStructure(projectDir);
949
- spinner.succeed(`Structure: ${chalk.cyan(structure.sourceFileCount + " source files")}`);
981
+ let structure = { topLevel: [], sourceFileCount: 0, keyDirs: [] };
982
+ try {
983
+ structure = scanFileStructure(projectDir);
984
+ spinner.succeed(`Structure: ${chalk.cyan(structure.sourceFileCount + " source files")}`);
985
+ } catch {
986
+ spinner.warn("Could not map file structure");
987
+ }
950
988
  spinner.start("Generating FAQ items...");
951
989
  const faqs = generateFaqs({ project, stack, readme, apiRoutes, envVars, dependencies });
952
990
  spinner.succeed(`FAQs: ${chalk.cyan(faqs.length + " items generated")}`);
@@ -990,6 +1028,50 @@ async function scanCommand(options) {
990
1028
  log(` ${chalk.dim("Snapshot:")} ${chalk.underline(relative4(process.cwd(), outputPath))}`);
991
1029
  log(` ${chalk.dim("Docs template:")} ${chalk.underline(relative4(process.cwd(), templatePath))}`);
992
1030
  log(` ${chalk.dim("Duration:")} ${scanDurationMs}ms`);
1031
+ if (options.url) {
1032
+ log();
1033
+ log(chalk.bold("\u{1F4F8} Capturing screenshots..."));
1034
+ try {
1035
+ const { crawlApp: crawlApp2 } = await import("./app-crawler-LPVXXFOR.js");
1036
+ const screenshotDir = resolve(outputDir, "screenshots");
1037
+ mkdirSync(screenshotDir, { recursive: true });
1038
+ const maxPages = parseInt(options.maxPages || "20", 10);
1039
+ const screenshotSpinner = ora("Launching browser...").start();
1040
+ const result = await crawlApp2({
1041
+ url: options.url,
1042
+ email: options.email,
1043
+ password: options.password,
1044
+ snapshot,
1045
+ screenshotDir,
1046
+ maxPages,
1047
+ onProgress: (msg) => {
1048
+ screenshotSpinner.text = msg;
1049
+ }
1050
+ });
1051
+ screenshotSpinner.succeed(`Captured ${result.totalScreenshots} screenshots from ${result.pages.length} pages`);
1052
+ const manifest = {
1053
+ version: 1,
1054
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1055
+ baseUrl: result.baseUrl,
1056
+ pages: result.pages.map((p) => ({
1057
+ path: p.path,
1058
+ title: p.title,
1059
+ screenshot: p.screenshot,
1060
+ category: p.category,
1061
+ headings: p.headings,
1062
+ elements: p.elements.length,
1063
+ buttons: p.elements.filter((e) => e.type === "button").map((e) => e.text).filter(Boolean),
1064
+ inputs: p.elements.filter((e) => e.type === "input").length
1065
+ })),
1066
+ navigation: result.navigation
1067
+ };
1068
+ writeFileSync(join8(screenshotDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1069
+ log(` ${chalk.dim("Screenshots:")} ${chalk.underline(relative4(process.cwd(), screenshotDir))}`);
1070
+ } catch (err) {
1071
+ log(chalk.yellow(` \u26A0 Screenshots skipped: ${err instanceof Error ? err.message : "browser not available"}`));
1072
+ log(chalk.dim(" Install Playwright with: npx playwright install chromium"));
1073
+ }
1074
+ }
993
1075
  log();
994
1076
  log(` ${chalk.dim("Next steps:")}`);
995
1077
  log(` 1. Write docs to ${chalk.cyan(".helpshelf/docs/")} (or let your AI agent write them using the template)`);
@@ -1106,7 +1188,7 @@ GUIDELINES:
1106
1188
 
1107
1189
  // src/commands/push.ts
1108
1190
  import { resolve as resolve2, join as join9, basename as basename2 } from "path";
1109
- import { readFileSync as readFileSync8, existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
1191
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync9, readdirSync as readdirSync4, mkdirSync as mkdirSync2 } from "fs";
1110
1192
  import chalk2 from "chalk";
1111
1193
  import ora2 from "ora";
1112
1194
  async function pushCommand(options) {
@@ -1217,10 +1299,10 @@ async function pushCommand(options) {
1217
1299
  }
1218
1300
  function readDocsDirectory(dir) {
1219
1301
  if (!existsSync9(dir)) return [];
1220
- const files = readdirSync5(dir).filter((f) => f.endsWith(".md"));
1302
+ const files = readdirSync4(dir).filter((f) => f.endsWith(".md"));
1221
1303
  const articles = [];
1222
1304
  for (const file of files) {
1223
- const content = readFileSync8(join9(dir, file), "utf-8");
1305
+ const content = readFileSync2(join9(dir, file), "utf-8");
1224
1306
  const slug = basename2(file, ".md");
1225
1307
  const { frontmatter, body } = parseFrontmatter(content);
1226
1308
  const title = frontmatter.title || slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
@@ -1278,22 +1360,21 @@ function loadConfig() {
1278
1360
  try {
1279
1361
  const configPath = resolve2(".helpshelf/config.json");
1280
1362
  if (!existsSync9(configPath)) return null;
1281
- return JSON.parse(readFileSync8(configPath, "utf-8"));
1363
+ return JSON.parse(readFileSync2(configPath, "utf-8"));
1282
1364
  } catch {
1283
1365
  return null;
1284
1366
  }
1285
1367
  }
1286
1368
  function saveConfig(config) {
1287
1369
  try {
1288
- const { writeFileSync: ws, mkdirSync: md } = __require("fs");
1289
- md(resolve2(".helpshelf"), { recursive: true });
1290
- ws(resolve2(".helpshelf/config.json"), JSON.stringify(config, null, 2));
1370
+ mkdirSync2(resolve2(".helpshelf"), { recursive: true });
1371
+ writeFileSync2(resolve2(".helpshelf/config.json"), JSON.stringify(config, null, 2));
1291
1372
  } catch {
1292
1373
  }
1293
1374
  }
1294
1375
 
1295
1376
  // src/commands/init.ts
1296
- import { writeFileSync as writeFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync2 } from "fs";
1377
+ import { writeFileSync as writeFileSync3, readFileSync as readFileSync3, existsSync as existsSync10, mkdirSync as mkdirSync3 } from "fs";
1297
1378
  import { resolve as resolve3, join as join10 } from "path";
1298
1379
  import chalk3 from "chalk";
1299
1380
  async function initCommand() {
@@ -1303,12 +1384,12 @@ async function initCommand() {
1303
1384
  console.log(chalk3.bold("\u{1F680} Initializing HelpShelf"));
1304
1385
  console.log();
1305
1386
  if (!existsSync10(helpshelfDir)) {
1306
- mkdirSync2(helpshelfDir, { recursive: true });
1387
+ mkdirSync3(helpshelfDir, { recursive: true });
1307
1388
  console.log(chalk3.green(" \u2713"), "Created .helpshelf/ directory");
1308
1389
  }
1309
1390
  const configPath = join10(helpshelfDir, "config.json");
1310
1391
  if (!existsSync10(configPath)) {
1311
- writeFileSync2(configPath, JSON.stringify({
1392
+ writeFileSync3(configPath, JSON.stringify({
1312
1393
  siteHash: "",
1313
1394
  apiKey: "",
1314
1395
  autoSync: false
@@ -1317,9 +1398,9 @@ async function initCommand() {
1317
1398
  }
1318
1399
  const gitignorePath = join10(dir, ".gitignore");
1319
1400
  if (existsSync10(gitignorePath)) {
1320
- const gitignore = __require("fs").readFileSync(gitignorePath, "utf-8");
1401
+ const gitignore = readFileSync3(gitignorePath, "utf-8");
1321
1402
  if (!gitignore.includes(".helpshelf/snapshot.json")) {
1322
- writeFileSync2(gitignorePath, gitignore.trimEnd() + "\n\n# HelpShelf\n.helpshelf/snapshot.json\n");
1403
+ writeFileSync3(gitignorePath, gitignore.trimEnd() + "\n\n# HelpShelf\n.helpshelf/snapshot.json\n");
1323
1404
  console.log(chalk3.green(" \u2713"), "Added .helpshelf/snapshot.json to .gitignore");
1324
1405
  }
1325
1406
  }
@@ -1332,238 +1413,27 @@ async function initCommand() {
1332
1413
  }
1333
1414
 
1334
1415
  // src/commands/screenshot.ts
1335
- import { resolve as resolve5, join as join12 } from "path";
1336
- import { readFileSync as readFileSync9, existsSync as existsSync12, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
1416
+ import { resolve as resolve4, join as join11 } from "path";
1417
+ import { readFileSync as readFileSync4, existsSync as existsSync11, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
1337
1418
  import chalk4 from "chalk";
1338
1419
  import ora3 from "ora";
1339
-
1340
- // src/crawler/app-crawler.ts
1341
- import { chromium } from "playwright-core";
1342
- import { join as join11 } from "path";
1343
- import { existsSync as existsSync11 } from "fs";
1344
- async function crawlApp(options) {
1345
- const { url, email, password, snapshot, screenshotDir, maxPages, onProgress } = options;
1346
- const possiblePaths = [
1347
- process.env.CHROME_PATH,
1348
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1349
- `${process.env.HOME}/Library/Caches/ms-playwright/chromium-1208/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing`
1350
- ].filter(Boolean);
1351
- let execPath;
1352
- for (const p of possiblePaths) {
1353
- if (existsSync11(p)) {
1354
- execPath = p;
1355
- break;
1356
- }
1357
- }
1358
- const browser = await chromium.launch({
1359
- headless: true,
1360
- ...execPath ? { executablePath: execPath } : {}
1361
- });
1362
- const context = await browser.newContext({
1363
- viewport: { width: 1280, height: 800 },
1364
- deviceScaleFactor: 2
1365
- // Retina screenshots
1366
- });
1367
- const page = await context.newPage();
1368
- const pages = [];
1369
- const visited = /* @__PURE__ */ new Set();
1370
- let screenshotCount = 0;
1371
- try {
1372
- onProgress("Navigating to app...");
1373
- await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 });
1374
- await page.waitForTimeout(1e3);
1375
- if (email && password) {
1376
- onProgress("Logging in...");
1377
- const loggedIn = await attemptLogin(page, email, password, url);
1378
- if (!loggedIn) {
1379
- onProgress("Login failed \u2014 continuing as anonymous");
1380
- } else {
1381
- onProgress("Logged in successfully");
1382
- await page.waitForTimeout(2e3);
1383
- }
1384
- }
1385
- onProgress("Discovering navigation...");
1386
- const navigation = await discoverNavigation(page);
1387
- const urlsToVisit = buildUrlList(page.url(), navigation, snapshot, maxPages);
1388
- onProgress(`Found ${urlsToVisit.length} pages to document`);
1389
- for (let i = 0; i < urlsToVisit.length; i++) {
1390
- const targetUrl = urlsToVisit[i];
1391
- const path = new URL(targetUrl).pathname;
1392
- if (visited.has(path)) continue;
1393
- visited.add(path);
1394
- onProgress(`[${i + 1}/${urlsToVisit.length}] Capturing ${path}`);
1395
- try {
1396
- await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 15e3 });
1397
- await page.waitForTimeout(800);
1398
- const screenshotName = pathToFilename(path) + ".png";
1399
- const screenshotPath = join11(screenshotDir, screenshotName);
1400
- await page.screenshot({ path: screenshotPath, fullPage: false });
1401
- screenshotCount++;
1402
- const capture = await extractPageInfo(page, path, screenshotName);
1403
- pages.push(capture);
1404
- } catch (err) {
1405
- onProgress(` \u26A0 Skipped ${path}: ${err instanceof Error ? err.message : "timeout"}`);
1406
- }
1407
- }
1408
- const appName = await page.title() || snapshot?.project.name || null;
1409
- return {
1410
- baseUrl: url,
1411
- pages,
1412
- totalScreenshots: screenshotCount,
1413
- appName,
1414
- navigation
1415
- };
1416
- } finally {
1417
- await browser.close();
1418
- }
1419
- }
1420
- async function attemptLogin(page, email, password, baseUrl) {
1421
- const currentUrl = page.url();
1422
- const isLoginPage = currentUrl.includes("login") || currentUrl.includes("signin") || currentUrl.includes("auth");
1423
- if (!isLoginPage) {
1424
- for (const loginPath of ["/login", "/signin", "/auth/login", "/auth/signin"]) {
1425
- try {
1426
- await page.goto(new URL(loginPath, baseUrl).toString(), { waitUntil: "networkidle", timeout: 1e4 });
1427
- if (page.url().includes("login") || page.url().includes("signin") || page.url().includes("auth")) break;
1428
- } catch {
1429
- continue;
1430
- }
1431
- }
1432
- }
1433
- try {
1434
- const emailInput = await page.$('input[type="email"], input[name="email"], input[placeholder*="email" i], input[placeholder*="username" i]');
1435
- const passwordInput = await page.$('input[type="password"]');
1436
- if (!emailInput || !passwordInput) return false;
1437
- await emailInput.fill(email);
1438
- await passwordInput.fill(password);
1439
- const submitBtn = await page.$('button[type="submit"], button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign In")');
1440
- if (submitBtn) {
1441
- await submitBtn.click();
1442
- await page.waitForNavigation({ waitUntil: "networkidle", timeout: 1e4 }).catch(() => {
1443
- });
1444
- await page.waitForTimeout(2e3);
1445
- }
1446
- const afterUrl = page.url();
1447
- return !afterUrl.includes("login") && !afterUrl.includes("signin");
1448
- } catch {
1449
- return false;
1450
- }
1451
- }
1452
- async function discoverNavigation(page) {
1453
- const sections = [];
1454
- try {
1455
- const navLinks = await page.$$eval(
1456
- 'nav a[href], aside a[href], [role="navigation"] a[href]',
1457
- (anchors) => anchors.map((a) => ({
1458
- text: a.textContent?.trim() || "",
1459
- href: a.getAttribute("href") || ""
1460
- })).filter((l) => l.text && l.href && !l.href.startsWith("#") && !l.href.startsWith("http"))
1461
- );
1462
- if (navLinks.length > 0) {
1463
- sections.push({
1464
- label: "Navigation",
1465
- links: navLinks
1466
- });
1467
- }
1468
- } catch {
1469
- }
1470
- return { sections };
1471
- }
1472
- function buildUrlList(currentUrl, navigation, snapshot, maxPages) {
1473
- const baseUrl = new URL(currentUrl).origin;
1474
- const urls = /* @__PURE__ */ new Set();
1475
- urls.add(currentUrl);
1476
- for (const section of navigation.sections) {
1477
- for (const link of section.links) {
1478
- try {
1479
- const fullUrl = new URL(link.href, baseUrl).toString();
1480
- urls.add(fullUrl);
1481
- } catch {
1482
- }
1483
- }
1484
- }
1485
- if (snapshot) {
1486
- const pagePaths = /* @__PURE__ */ new Set();
1487
- for (const route of snapshot.apiRoutes) {
1488
- if (route.path.startsWith("/api/")) continue;
1489
- pagePaths.add(route.path);
1490
- }
1491
- for (const path of pagePaths) {
1492
- try {
1493
- urls.add(new URL(path, baseUrl).toString());
1494
- } catch {
1495
- }
1496
- }
1497
- }
1498
- return Array.from(urls).slice(0, maxPages);
1499
- }
1500
- async function extractPageInfo(page, path, screenshot) {
1501
- const title = await page.title();
1502
- const headings = await page.$$eval(
1503
- "h1, h2, h3",
1504
- (els) => els.map((el) => el.textContent?.trim() || "").filter(Boolean).slice(0, 10)
1505
- );
1506
- const elements = await page.$$eval(
1507
- "button:visible, a:visible, input:visible, select:visible",
1508
- (els) => els.slice(0, 30).map((el) => ({
1509
- type: el.tagName.toLowerCase() === "button" ? "button" : el.tagName.toLowerCase() === "a" ? "link" : el.tagName.toLowerCase() === "input" ? "input" : el.tagName.toLowerCase() === "select" ? "select" : "button",
1510
- text: el.textContent?.trim().slice(0, 100) || "",
1511
- ariaLabel: el.getAttribute("aria-label")
1512
- }))
1513
- );
1514
- const navItems = await page.$$eval(
1515
- "nav a, aside a",
1516
- (els) => els.map((el) => el.textContent?.trim() || "").filter(Boolean)
1517
- );
1518
- const category = detectCategory(path);
1519
- return {
1520
- url: page.url(),
1521
- path,
1522
- title,
1523
- screenshot,
1524
- headings,
1525
- elements,
1526
- navItems,
1527
- category
1528
- };
1529
- }
1530
- function detectCategory(path) {
1531
- const p = path.toLowerCase();
1532
- if (p.includes("setting")) return "Settings";
1533
- if (p.includes("billing") || p.includes("plan") || p.includes("subscription")) return "Billing";
1534
- if (p.includes("team") || p.includes("member") || p.includes("invite")) return "Team Management";
1535
- if (p.includes("analytic") || p.includes("stat") || p.includes("report")) return "Analytics";
1536
- if (p.includes("content") || p.includes("article") || p.includes("doc")) return "Content Management";
1537
- if (p.includes("integrat") || p.includes("connect") || p.includes("source")) return "Integrations";
1538
- if (p.includes("widget") || p.includes("floatie") || p.includes("preview")) return "Widget";
1539
- if (p.includes("announce")) return "Announcements";
1540
- if (p.includes("help") || p.includes("center")) return "Help Center";
1541
- if (p.includes("login") || p.includes("signup") || p.includes("auth")) return "Authentication";
1542
- if (p === "/" || p.includes("dashboard") || p.includes("overview")) return "Getting Started";
1543
- return "Features";
1544
- }
1545
- function pathToFilename(path) {
1546
- return (path || "home").replace(/^\//, "").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "") || "home";
1547
- }
1548
-
1549
- // src/commands/screenshot.ts
1550
1420
  async function screenshotCommand(options) {
1551
1421
  console.log();
1552
1422
  console.log(chalk4.bold("\u{1F4F8} HelpShelf Screenshot"));
1553
1423
  console.log(chalk4.dim(` Target: ${options.url}`));
1554
1424
  console.log();
1555
1425
  let snapshot = null;
1556
- const snapshotPath = resolve5(options.snapshot || ".helpshelf/snapshot.json");
1557
- if (existsSync12(snapshotPath)) {
1558
- snapshot = JSON.parse(readFileSync9(snapshotPath, "utf-8"));
1426
+ const snapshotPath = resolve4(options.snapshot || ".helpshelf/snapshot.json");
1427
+ if (existsSync11(snapshotPath)) {
1428
+ snapshot = JSON.parse(readFileSync4(snapshotPath, "utf-8"));
1559
1429
  console.log(chalk4.dim(` Using scan data: ${snapshot.apiRoutes.length} routes`));
1560
1430
  console.log();
1561
1431
  } else {
1562
1432
  console.log(chalk4.dim(' No snapshot found \u2014 run "helpshelf scan" first for better route coverage'));
1563
1433
  console.log();
1564
1434
  }
1565
- const outputDir = resolve5(options.output || ".helpshelf/screenshots");
1566
- mkdirSync3(outputDir, { recursive: true });
1435
+ const outputDir = resolve4(options.output || ".helpshelf/screenshots");
1436
+ mkdirSync4(outputDir, { recursive: true });
1567
1437
  const maxPages = parseInt(options.maxPages || "20", 10);
1568
1438
  const spinner = ora3("Launching browser...").start();
1569
1439
  try {
@@ -1596,14 +1466,14 @@ async function screenshotCommand(options) {
1596
1466
  navigation: result.navigation
1597
1467
  };
1598
1468
  writeFileSync4(
1599
- join12(outputDir, "manifest.json"),
1469
+ join11(outputDir, "manifest.json"),
1600
1470
  JSON.stringify(manifest, null, 2)
1601
1471
  );
1602
1472
  console.log();
1603
1473
  console.log(chalk4.green.bold("\u2705 Screenshots captured!"));
1604
1474
  console.log();
1605
1475
  console.log(` ${chalk4.dim("Output:")} ${outputDir}`);
1606
- console.log(` ${chalk4.dim("Manifest:")} ${join12(outputDir, "manifest.json")}`);
1476
+ console.log(` ${chalk4.dim("Manifest:")} ${join11(outputDir, "manifest.json")}`);
1607
1477
  console.log();
1608
1478
  console.log(` ${chalk4.dim("Pages:")}`);
1609
1479
  for (const page of result.pages) {
@@ -1622,7 +1492,7 @@ async function screenshotCommand(options) {
1622
1492
  // src/index.ts
1623
1493
  var program = new Command();
1624
1494
  program.name("helpshelf").description("AI-agent toolkit for auto-generating support docs").version("0.1.0");
1625
- program.command("scan").description("Scan your codebase \u2192 snapshot.json + docs-template.json").option("-d, --dir <path>", "Project directory to scan", ".").option("-o, --output <path>", "Output file path", ".helpshelf/snapshot.json").option("--no-readme", "Skip README parsing").option("--no-deps", "Skip dependency analysis").option("--no-api", "Skip API route detection").option("--no-env", "Skip environment variable detection").option("--verbose", "Show detailed scan output").option("--quiet", "Suppress output (for programmatic use)").action(scanCommand);
1495
+ program.command("scan").description("Scan your codebase \u2192 snapshot.json + docs-template.json").option("-d, --dir <path>", "Project directory to scan", ".").option("-o, --output <path>", "Output file path", ".helpshelf/snapshot.json").option("--no-readme", "Skip README parsing").option("--no-deps", "Skip dependency analysis").option("--no-api", "Skip API route detection").option("--no-env", "Skip environment variable detection").option("--verbose", "Show detailed scan output").option("--quiet", "Suppress output (for programmatic use)").option("-u, --url <url>", "Also capture screenshots from a running app (e.g., http://localhost:3000)").option("-e, --email <email>", "Login email for screenshot auth").option("-p, --password <password>", "Login password for screenshot auth").option("-m, --max-pages <n>", "Max pages to screenshot", "20").action(scanCommand);
1626
1496
  program.command("screenshot").description("Capture screenshots of your running app's pages").requiredOption("-u, --url <url>", "URL of your running app (e.g., http://localhost:3000)").option("-e, --email <email>", "Login email for authenticated pages").option("-p, --password <password>", "Login password").option("-s, --snapshot <path>", "Path to scan snapshot (for route intelligence)", ".helpshelf/snapshot.json").option("-o, --output <path>", "Output directory for screenshots", ".helpshelf/screenshots").option("-m, --max-pages <n>", "Maximum pages to capture", "20").action(screenshotCommand);
1627
1497
  program.command("push").description("Push docs to HelpShelf (creates account if needed)").option("--email <email>", "HelpShelf account email").option("--domain <domain>", "Your app domain (for help center URL)").option("--docs-dir <path>", "Directory containing markdown docs", ".helpshelf/docs").option("--site <hash>", "Existing HelpShelf site hash").option("--api-key <key>", "Existing HelpShelf API key").option("--api-url <url>", "HelpShelf API URL", "https://app.helpshelf.com").option("--dry-run", "Preview what would be pushed without sending").action(pushCommand);
1628
1498
  program.command("init").description("Initialize HelpShelf in your project").action(initCommand);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helpshelf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scan your codebase and auto-generate support docs for HelpShelf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "scripts": {
13
- "build": "tsup src/index.ts --format esm --dts --clean --external playwright-core",
13
+ "build": "tsup src/index.ts --format esm --dts --clean --platform node --external playwright-core",
14
14
  "dev": "tsup src/index.ts --format esm --watch",
15
15
  "lint": "eslint src/",
16
16
  "type-check": "tsc --noEmit",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "chalk": "^5.3.0",
22
22
  "commander": "^12.1.0",
23
- "glob": "^11.0.0",
23
+ "glob": "10",
24
24
  "ignore": "^6.0.2",
25
25
  "ora": "^8.1.0",
26
26
  "playwright-core": "^1.58.1"