sourcebook 0.5.0 → 0.5.1

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.
@@ -18,6 +18,10 @@ export declare function checkLicense(): Promise<LicenseInfo>;
18
18
  * Save a license key to disk.
19
19
  */
20
20
  export declare function saveLicenseKey(key: string): void;
21
+ /**
22
+ * Remove the license key from disk.
23
+ */
24
+ export declare function removeLicenseKey(): void;
21
25
  /**
22
26
  * Gate a feature behind Pro license.
23
27
  * Prints upgrade message and exits if not licensed.
@@ -44,12 +44,13 @@ export async function checkLicense() {
44
44
  catch {
45
45
  // Network error or timeout — fall back to cache or offline validation
46
46
  if (cached && cached.key === key) {
47
- return cached.info;
48
- }
49
- // Offline grace: if key looks valid (format check), allow Pro for 7 days
50
- if (isValidKeyFormat(key)) {
51
- return { valid: true, tier: "pro" };
47
+ // Only grant offline access if last validation was within 7 days
48
+ const OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
49
+ if (Date.now() - cached.timestamp <= OFFLINE_GRACE_MS) {
50
+ return cached.info;
51
+ }
52
52
  }
53
+ // No valid cached validation within 7 days — deny access
53
54
  }
54
55
  return { valid: false, tier: "free" };
55
56
  }
@@ -58,9 +59,22 @@ export async function checkLicense() {
58
59
  */
59
60
  export function saveLicenseKey(key) {
60
61
  if (!fs.existsSync(LICENSE_DIR)) {
61
- fs.mkdirSync(LICENSE_DIR, { recursive: true });
62
+ fs.mkdirSync(LICENSE_DIR, { recursive: true, mode: 0o700 });
63
+ }
64
+ fs.writeFileSync(LICENSE_FILE, key.trim(), { encoding: "utf-8", mode: 0o600 });
65
+ }
66
+ /**
67
+ * Remove the license key from disk.
68
+ */
69
+ export function removeLicenseKey() {
70
+ try {
71
+ if (fs.existsSync(LICENSE_FILE)) {
72
+ fs.unlinkSync(LICENSE_FILE);
73
+ }
74
+ }
75
+ catch {
76
+ // ignore cleanup errors
62
77
  }
63
- fs.writeFileSync(LICENSE_FILE, key.trim(), "utf-8");
64
78
  }
65
79
  /**
66
80
  * Read the license key from disk.
@@ -93,10 +107,10 @@ function readCache() {
93
107
  }
94
108
  function writeCache(key, info) {
95
109
  if (!fs.existsSync(LICENSE_DIR)) {
96
- fs.mkdirSync(LICENSE_DIR, { recursive: true });
110
+ fs.mkdirSync(LICENSE_DIR, { recursive: true, mode: 0o700 });
97
111
  }
98
112
  const entry = { key, info, timestamp: Date.now() };
99
- fs.writeFileSync(CACHE_FILE, JSON.stringify(entry), "utf-8");
113
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(entry), { encoding: "utf-8", mode: 0o600 });
100
114
  }
101
115
  function isCacheExpired(timestamp) {
102
116
  return Date.now() - timestamp > CACHE_TTL_MS;
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { saveLicenseKey, checkLicense } from "../auth/license.js";
2
+ import { saveLicenseKey, removeLicenseKey, checkLicense } from "../auth/license.js";
3
3
  export async function activate(key) {
4
4
  if (!key || key.trim().length === 0) {
5
5
  console.log(chalk.red("\nNo license key provided."));
@@ -9,9 +9,9 @@ export async function activate(key) {
9
9
  }
10
10
  console.log(chalk.bold("\nsourcebook activate"));
11
11
  console.log(chalk.dim("Validating license key...\n"));
12
- // Save key first
12
+ // Validate first, only save if valid
13
+ // Temporarily save so checkLicense can read it, then remove if invalid
13
14
  saveLicenseKey(key);
14
- // Validate it
15
15
  const license = await checkLicense();
16
16
  if (license.tier === "pro" || license.tier === "team") {
17
17
  console.log(chalk.green("✓") +
@@ -30,9 +30,11 @@ export async function activate(key) {
30
30
  console.log("");
31
31
  }
32
32
  else {
33
+ // Validation failed — remove the saved key to prevent offline bypass
34
+ removeLicenseKey();
33
35
  console.log(chalk.yellow("⚠") +
34
- " License key saved but could not be validated.");
35
- console.log(chalk.dim(" This may be a network issue. The key will be re-validated on next use."));
36
+ " License key could not be validated and was not saved.");
37
+ console.log(chalk.dim(" This may be a network issue. Please try again when you have an internet connection."));
36
38
  console.log(chalk.dim(" If the problem persists, contact roy@maroond.ai\n"));
37
39
  }
38
40
  }
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function safePath(dir, file) {
4
+ const resolved = path.resolve(path.join(dir, file));
5
+ if (!resolved.startsWith(path.resolve(dir) + path.sep) && resolved !== path.resolve(dir)) {
6
+ return null;
7
+ }
8
+ return resolved;
9
+ }
3
10
  export async function detectBuildCommands(dir) {
4
11
  const commands = {};
5
12
  // Check package.json scripts
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function safePath(dir, file) {
4
+ const resolved = path.resolve(path.join(dir, file));
5
+ if (!resolved.startsWith(path.resolve(dir) + path.sep) && resolved !== path.resolve(dir)) {
6
+ return null;
7
+ }
8
+ return resolved;
9
+ }
3
10
  export async function detectFrameworks(dir, files) {
4
11
  const detected = [];
5
12
  // Read all package.json files (root + workspaces/sub-packages)
@@ -8,7 +15,9 @@ export async function detectFrameworks(dir, files) {
8
15
  pkgFiles.push("package.json");
9
16
  const allDeps = {};
10
17
  for (const pkgFile of pkgFiles) {
11
- const pkgPath = path.join(dir, pkgFile);
18
+ const pkgPath = safePath(dir, pkgFile);
19
+ if (!pkgPath)
20
+ continue;
12
21
  if (fs.existsSync(pkgPath)) {
13
22
  try {
14
23
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -44,29 +53,31 @@ export async function detectFrameworks(dir, files) {
44
53
  // Check for next.config
45
54
  const nextConfig = files.find((f) => /^next\.config\.(js|mjs|ts)$/.test(f));
46
55
  if (nextConfig) {
47
- try {
48
- const configContent = fs.readFileSync(path.join(dir, nextConfig), "utf-8");
49
- if (configContent.includes("output:") && configContent.includes("standalone")) {
50
- findings.push({
51
- category: "Next.js deployment",
52
- description: "Standalone output mode is enabled. Build produces a self-contained server in .next/standalone.",
53
- confidence: "high",
54
- discoverable: false,
55
- });
56
+ const safeNextConfig = safePath(dir, nextConfig);
57
+ if (safeNextConfig)
58
+ try {
59
+ const configContent = fs.readFileSync(safeNextConfig, "utf-8");
60
+ if (configContent.includes("output:") && configContent.includes("standalone")) {
61
+ findings.push({
62
+ category: "Next.js deployment",
63
+ description: "Standalone output mode is enabled. Build produces a self-contained server in .next/standalone.",
64
+ confidence: "high",
65
+ discoverable: false,
66
+ });
67
+ }
68
+ if (configContent.includes("images") && configContent.includes("remotePatterns")) {
69
+ findings.push({
70
+ category: "Next.js images",
71
+ description: "Remote image patterns are configured. New image domains must be added to next.config before use.",
72
+ rationale: "Agents will try to use next/image with arbitrary URLs and get 400 errors without this config.",
73
+ confidence: "high",
74
+ discoverable: false,
75
+ });
76
+ }
56
77
  }
57
- if (configContent.includes("images") && configContent.includes("remotePatterns")) {
58
- findings.push({
59
- category: "Next.js images",
60
- description: "Remote image patterns are configured. New image domains must be added to next.config before use.",
61
- rationale: "Agents will try to use next/image with arbitrary URLs and get 400 errors without this config.",
62
- confidence: "high",
63
- discoverable: false,
64
- });
78
+ catch {
79
+ // can't read config
65
80
  }
66
- }
67
- catch {
68
- // can't read config
69
- }
70
81
  }
71
82
  detected.push({
72
83
  name: "Next.js",
@@ -157,7 +168,10 @@ export async function detectFrameworks(dir, files) {
157
168
  if (hasTwConfig) {
158
169
  try {
159
170
  const configPath = files.find((f) => /^tailwind\.config\.(js|ts|mjs|cjs)$/.test(f));
160
- const content = fs.readFileSync(path.join(dir, configPath), "utf-8");
171
+ const safeConfigPath = safePath(dir, configPath);
172
+ if (!safeConfigPath)
173
+ throw new Error("path escape");
174
+ const content = fs.readFileSync(safeConfigPath, "utf-8");
161
175
  if (content.includes("extend") && content.includes("colors")) {
162
176
  findings.push({
163
177
  category: "Tailwind",
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import path from "node:path";
3
3
  /**
4
4
  * Mine git history for non-obvious context:
@@ -33,7 +33,7 @@ export async function analyzeGitHistory(dir) {
33
33
  }
34
34
  function isGitRepo(dir) {
35
35
  try {
36
- execSync("git rev-parse --is-inside-work-tree", {
36
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
37
37
  cwd: dir,
38
38
  stdio: "pipe",
39
39
  });
@@ -45,7 +45,7 @@ function isGitRepo(dir) {
45
45
  }
46
46
  function git(dir, args) {
47
47
  try {
48
- return execSync(`git ${args}`, {
48
+ return execFileSync("git", args, {
49
49
  cwd: dir,
50
50
  stdio: "pipe",
51
51
  maxBuffer: 10 * 1024 * 1024,
@@ -60,7 +60,7 @@ function git(dir, args) {
60
60
  */
61
61
  function detectRevertedPatterns(dir, revertedPatterns) {
62
62
  const findings = [];
63
- const revertLog = git(dir, 'log --grep="^Revert" --oneline --since="1 year ago" -50');
63
+ const revertLog = git(dir, ["log", "--grep=^Revert", "--oneline", "--since=1 year ago", "-50"]);
64
64
  if (!revertLog.trim())
65
65
  return findings;
66
66
  const reverts = revertLog.trim().split("\n").filter(Boolean);
@@ -94,7 +94,7 @@ function detectRevertedPatterns(dir, revertedPatterns) {
94
94
  function detectAntiPatterns(dir) {
95
95
  const findings = [];
96
96
  // Extract detailed info from reverted commits
97
- const revertLog = git(dir, 'log --grep="^Revert" --format="%s" --since="1 year ago" -20');
97
+ const revertLog = git(dir, ["log", "--grep=^Revert", "--format=%s", "--since=1 year ago", "-20"]);
98
98
  if (revertLog.trim()) {
99
99
  const antiPatterns = [];
100
100
  for (const line of revertLog.trim().split("\n").filter(Boolean)) {
@@ -116,13 +116,13 @@ function detectAntiPatterns(dir) {
116
116
  }
117
117
  }
118
118
  // Detect files deleted in bulk (abandoned features/approaches)
119
- const deletedLog = git(dir, 'log --diff-filter=D --name-only --pretty=format:"COMMIT %s" --since="6 months ago" -50');
119
+ const deletedLog = git(dir, ["log", "--diff-filter=D", "--name-only", "--pretty=format:COMMIT %s", "--since=6 months ago", "-50"]);
120
120
  if (deletedLog.trim()) {
121
121
  const deletionBatches = [];
122
122
  let currentMessage = "";
123
123
  let currentFiles = [];
124
124
  for (const line of deletedLog.split("\n")) {
125
- const commitMatch = line.match(/^"?COMMIT (.+)"?$/);
125
+ const commitMatch = line.match(/^COMMIT (.+)$/);
126
126
  if (commitMatch) {
127
127
  if (currentFiles.length >= 3) {
128
128
  deletionBatches.push({ message: currentMessage, files: currentFiles });
@@ -159,7 +159,7 @@ function detectAntiPatterns(dir) {
159
159
  function detectActiveAreas(dir, activeAreas) {
160
160
  const findings = [];
161
161
  // Get files changed in the last 30 days, count changes per directory
162
- const recentChanges = git(dir, 'log --since="30 days ago" --name-only --pretty=format: --diff-filter=AMRC');
162
+ const recentChanges = git(dir, ["log", "--since=30 days ago", "--name-only", "--pretty=format:", "--diff-filter=AMRC"]);
163
163
  if (!recentChanges.trim())
164
164
  return findings;
165
165
  const dirCounts = new Map();
@@ -198,14 +198,14 @@ function detectActiveAreas(dir, activeAreas) {
198
198
  function detectCoChangeCoupling(dir, clusters) {
199
199
  const findings = [];
200
200
  // Get the last 200 commits with their changed files
201
- const log = git(dir, 'log --name-only --pretty=format:"COMMIT" --since="6 months ago" -200');
201
+ const log = git(dir, ["log", "--name-only", "--pretty=format:COMMIT", "--since=6 months ago", "-200"]);
202
202
  if (!log.trim())
203
203
  return findings;
204
204
  // Parse commits into file groups
205
205
  const commits = [];
206
206
  let current = [];
207
207
  for (const line of log.split("\n")) {
208
- if (line.trim() === '"COMMIT"' || line.trim() === "COMMIT") {
208
+ if (line.trim() === "COMMIT") {
209
209
  if (current.length > 0)
210
210
  commits.push(current);
211
211
  current = [];
@@ -293,7 +293,7 @@ function detectCoChangeCoupling(dir, clusters) {
293
293
  function detectRapidReEdits(dir) {
294
294
  const findings = [];
295
295
  // Get files with high commit frequency in short windows
296
- const log = git(dir, 'log --format="%H %aI" --name-only --since="3 months ago" -300');
296
+ const log = git(dir, ["log", "--format=%H %aI", "--name-only", "--since=3 months ago", "-300"]);
297
297
  if (!log.trim())
298
298
  return findings;
299
299
  // Track edits per file with timestamps
@@ -354,7 +354,7 @@ function detectRapidReEdits(dir) {
354
354
  */
355
355
  function detectCommitPatterns(dir) {
356
356
  const findings = [];
357
- const log = git(dir, 'log --oneline --since="6 months ago" -200');
357
+ const log = git(dir, ["log", "--oneline", "--since=6 months ago", "-200"]);
358
358
  if (!log.trim())
359
359
  return findings;
360
360
  const messages = log.trim().split("\n").filter(Boolean);
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function safePath(dir, file) {
4
+ const resolved = path.resolve(path.join(dir, file));
5
+ if (!resolved.startsWith(path.resolve(dir) + path.sep) && resolved !== path.resolve(dir)) {
6
+ return null;
7
+ }
8
+ return resolved;
9
+ }
3
10
  /**
4
11
  * Build an import/dependency graph and run PageRank to identify
5
12
  * the most structurally important files. Conventions found in
@@ -16,7 +23,9 @@ export async function analyzeImportGraph(dir, files) {
16
23
  const edges = [];
17
24
  const fileSet = new Set(sourceFiles);
18
25
  for (const file of sourceFiles) {
19
- const filePath = path.join(dir, file);
26
+ const filePath = safePath(dir, file);
27
+ if (!filePath)
28
+ continue;
20
29
  let content;
21
30
  try {
22
31
  content = fs.readFileSync(filePath, "utf-8");
@@ -28,6 +28,7 @@ export async function scanProject(dir) {
28
28
  nodir: true,
29
29
  ignore: IGNORE_PATTERNS,
30
30
  dot: true,
31
+ follow: false,
31
32
  });
32
33
  // Detect languages from file extensions
33
34
  const languages = detectLanguages(files);
@@ -1,5 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ function safePath(dir, file) {
4
+ const resolved = path.resolve(path.join(dir, file));
5
+ if (!resolved.startsWith(path.resolve(dir) + path.sep) && resolved !== path.resolve(dir)) {
6
+ return null;
7
+ }
8
+ return resolved;
9
+ }
3
10
  /**
4
11
  * Detect code patterns and conventions that are non-obvious.
5
12
  * This is the core intelligence layer -- finding things agents miss.
@@ -17,8 +24,11 @@ export async function detectPatterns(dir, files, frameworks) {
17
24
  const sampled = sampleFiles(sourceFiles, 50);
18
25
  const fileContents = new Map();
19
26
  for (const file of sampled) {
27
+ const safe = safePath(dir, file);
28
+ if (!safe)
29
+ continue;
20
30
  try {
21
- const content = fs.readFileSync(path.join(dir, file), "utf-8");
31
+ const content = fs.readFileSync(safe, "utf-8");
22
32
  fileContents.set(file, content);
23
33
  }
24
34
  catch {
@@ -275,8 +285,11 @@ function detectDominantPatterns(dir, files, contents, frameworks) {
275
285
  const allContents = new Map(contents);
276
286
  for (const file of extraSample) {
277
287
  if (!allContents.has(file)) {
288
+ const safe = safePath(dir, file);
289
+ if (!safe)
290
+ continue;
278
291
  try {
279
- const content = fs.readFileSync(path.join(dir, file), "utf-8");
292
+ const content = fs.readFileSync(safe, "utf-8");
280
293
  allContents.set(file, content);
281
294
  }
282
295
  catch { /* skip */ }
@@ -432,8 +445,11 @@ function detectDominantPatterns(dir, files, contents, frameworks) {
432
445
  .slice(0, 10);
433
446
  for (const file of testSampled) {
434
447
  if (!allContents.has(file)) {
448
+ const safe = safePath(dir, file);
449
+ if (!safe)
450
+ continue;
435
451
  try {
436
- const content = fs.readFileSync(path.join(dir, file), "utf-8");
452
+ const content = fs.readFileSync(safe, "utf-8");
437
453
  allContents.set(file, content);
438
454
  }
439
455
  catch { /* skip */ }
@@ -530,13 +546,15 @@ function detectDominantPatterns(dir, files, contents, frameworks) {
530
546
  if (primary.name === "Tailwind CSS") {
531
547
  const twConfig = files.find((f) => f.includes("tailwind.config"));
532
548
  if (twConfig) {
533
- try {
534
- const configContent = fs.readFileSync(path.join(dir, twConfig), "utf-8");
535
- if (configContent.includes("colors") || configContent.includes("extend")) {
536
- desc += ` Custom design tokens defined in ${twConfig} — use these instead of arbitrary values.`;
549
+ const safeTw = safePath(dir, twConfig);
550
+ if (safeTw)
551
+ try {
552
+ const configContent = fs.readFileSync(safeTw, "utf-8");
553
+ if (configContent.includes("colors") || configContent.includes("extend")) {
554
+ desc += ` Custom design tokens defined in ${twConfig} — use these instead of arbitrary values.`;
555
+ }
537
556
  }
538
- }
539
- catch { /* skip */ }
557
+ catch { /* skip */ }
540
558
  }
541
559
  }
542
560
  findings.push({
@@ -2,9 +2,13 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  export async function writeOutput(dir, filename, content) {
4
4
  const filePath = path.join(dir, filename);
5
- const parentDir = path.dirname(filePath);
5
+ const resolved = path.resolve(filePath);
6
+ if (!resolved.startsWith(path.resolve(dir) + path.sep)) {
7
+ throw new Error(`Output path escapes target directory: ${filename}`);
8
+ }
9
+ const parentDir = path.dirname(resolved);
6
10
  if (!fs.existsSync(parentDir)) {
7
11
  fs.mkdirSync(parentDir, { recursive: true });
8
12
  }
9
- fs.writeFileSync(filePath, content, "utf-8");
13
+ fs.writeFileSync(resolved, content, "utf-8");
10
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sourcebook",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Extract the conventions, constraints, and architectural truths your AI coding agents keep missing.",
5
5
  "type": "module",
6
6
  "bin": {