whale-igniter 1.5.1 → 1.5.3

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.
package/dist/index.js CHANGED
@@ -39,25 +39,31 @@ program
39
39
  .description("Bootstrap a Whale workspace with AI-readable context (opinionated by default).")
40
40
  .option("-i, --interactive", "Run an interactive wizard to configure stack, packs, AI targets")
41
41
  .option("-m, --minimal", "Minimal scaffold: folders + config + one validator pack")
42
+ .option("--force", "Proceed even when the target does not look like a project directory")
42
43
  .action((projectName, opts) => igniteCommand(projectName, opts));
43
44
  program
44
45
  .command("init [projectName]")
45
46
  .description("Create an empty Whale workspace (no packs, no AI context).")
46
- .action(async (projectName) => {
47
- await initCommand(projectName);
47
+ .option("--force", "Proceed even when the target does not look like a project directory")
48
+ .action(async (projectName, opts) => {
49
+ await initCommand(projectName, opts);
48
50
  });
49
51
  program
50
52
  .command("sync [target]")
51
- .description("Regenerate CLAUDE.md and the LLM Wiki from intelligence/*.json.")
52
- .action(syncCommand);
53
+ .description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
54
+ .option("--force", "Proceed even when the target does not look like a project directory")
55
+ .action((target, opts) => syncCommand(target, opts));
53
56
  program
54
57
  .command("remember [target]")
55
58
  .description("Update project memory for AI assistants (friendly alias for sync).")
56
- .action((target) => rememberCommand(target));
59
+ .option("--force", "Proceed even when the target does not look like a project directory")
60
+ .action((target, opts) => rememberCommand(target, opts));
57
61
  program
58
62
  .command("check [target]")
59
63
  .description("Check project health and recommendations (validate + insights).")
60
- .action((target) => checkCommand(target));
64
+ .option("--force", "Proceed even when the target does not look like a project directory")
65
+ .option("--full", "Show every validation issue instead of grouped summaries")
66
+ .action((target, opts) => checkCommand(target, opts));
61
67
  program
62
68
  .command("improve")
63
69
  .description("Ask Selene for project-wide improvement suggestions.")
@@ -73,7 +79,9 @@ program
73
79
  program
74
80
  .command("validate [target]")
75
81
  .description("Run operational validations (exits non-zero on errors).")
76
- .action(validateCommand);
82
+ .option("--force", "Proceed even when the target does not look like a project directory")
83
+ .option("--full", "Show every validation issue instead of grouped summaries")
84
+ .action((target, opts) => validateCommand(target, opts));
77
85
  program
78
86
  .command("docs [target]")
79
87
  .description("Generate validation report and run installed generator packs.")
@@ -81,7 +89,8 @@ program
81
89
  program
82
90
  .command("wiki [target]")
83
91
  .description("Regenerate AI context (alias of `sync`).")
84
- .action(wikiCommand);
92
+ .option("--force", "Proceed even when the target does not look like a project directory")
93
+ .action((target, opts) => wikiCommand(target, opts));
85
94
  program
86
95
  .command("refine <note>")
87
96
  .description("Record an operational refinement. Mention an issue type to suppress matching issues.")
@@ -157,12 +166,14 @@ const insightsCmd = program
157
166
  .option("--category <name>", "Filter by category: refinements | decisions | components | foundations | tokens | coverage")
158
167
  .option("--min-severity <level>", "Show only insights at this severity or higher (info | warning | critical)")
159
168
  .option("--skip-scan", "Skip the source scan (faster, but disables orphan/drift insights)")
169
+ .option("--force", "Proceed even when the target does not look like a project directory")
160
170
  .action((target, opts) => insightsCommand(target, opts));
161
171
  insightsCmd
162
172
  .command("drift <category>")
163
173
  .description("Show or review drift in spacing | color | radii.")
164
174
  .option("--review", "Enter interactive review loop: accept as refinement, flag for refactor, or skip")
165
175
  .option("--target <path>", "Workspace path (default: cwd)")
176
+ .option("--force", "Proceed even when the target does not look like a project directory")
166
177
  .action((category, opts) => insightsDriftReviewCommand(category, opts));
167
178
  const create = program
168
179
  .command("create")
@@ -240,6 +251,7 @@ const adopt = program
240
251
  .description("Scan an existing project and propose components, foundations and decisions.")
241
252
  .option("--pattern <glob>", "Glob pattern for source files (default: src/**/*.{tsx,jsx})")
242
253
  .option("--dry-run", "Run the scanner but don't write proposals to disk")
254
+ .option("--force", "Proceed even when the target does not look like a project directory")
243
255
  .action((target, opts) => adoptCommand(target, opts));
244
256
  adopt
245
257
  .command("review [target]")
@@ -38,7 +38,6 @@ import { collectReferencedFiles, normalizePath } from "../analyzer/imports.js";
38
38
  import { scanComponents } from "../scanner/componentScanner.js";
39
39
  import { aggregateTailwind } from "../scanner/tailwindScanner.js";
40
40
  import { validateCss } from "../validators/cssValidator.js";
41
- import { randomUUID } from "node:crypto";
42
41
  import { PACKAGE_VERSION } from "../version.js";
43
42
  function ok(text) {
44
43
  return { content: [{ type: "text", text }] };
@@ -354,12 +353,7 @@ export function buildMcpServer() {
354
353
  if (!check.ok)
355
354
  return err(check.reason);
356
355
  const scope = inferScope(note);
357
- await appendRefinement(ws, {
358
- id: randomUUID(),
359
- timestamp: new Date().toISOString(),
360
- note,
361
- scope
362
- });
356
+ await appendRefinement(ws, { note, scope });
363
357
  await generateWiki(ws);
364
358
  const scopeDesc = scope
365
359
  ? `Scope inferred: ${Object.entries(scope).map(([k, v]) => `${k}=${v}`).join(", ")}.`
@@ -368,8 +362,8 @@ export function buildMcpServer() {
368
362
  });
369
363
  server.registerTool("whale_sync", {
370
364
  title: "Regenerate CLAUDE.md and the LLM wiki",
371
- description: "Regenerates the AI-readable context files from intelligence/*.json. Call " +
372
- "this if you've manually edited the JSON stores and want the wiki to catch " +
365
+ description: "Regenerates the AI-readable context files from project intelligence. Call " +
366
+ "this if you've manually edited the source stores and want the wiki to catch " +
373
367
  "up. Whale auto-syncs on every recorded change, so manual calls are rarely needed.",
374
368
  inputSchema: { target: z.string().optional() }
375
369
  }, async ({ target }) => {
@@ -42,7 +42,7 @@ function renderProjectContext(ctx) {
42
42
  lines.push(`- Control radius: ${c.foundations?.radius?.control ?? 4}px (buttons, inputs)`);
43
43
  lines.push(`- Container radius: ${c.foundations?.radius?.container ?? 8}px (cards, modals)`);
44
44
  // Active decisions — show titles only to keep tokens down. Full text
45
- // is available in intelligence/decisions.json if the model needs it.
45
+ // is available in the linked intelligence/decisions Markdown files.
46
46
  const active = ctx.decisions.filter((d) => d.status === "active");
47
47
  if (active.length > 0) {
48
48
  lines.push("");
@@ -52,7 +52,7 @@ function renderProjectContext(ctx) {
52
52
  lines.push(`- [${d.category}] ${d.title}`);
53
53
  }
54
54
  if (active.length > recent.length) {
55
- lines.push(`- … and ${active.length - recent.length} more in intelligence/decisions.json`);
55
+ lines.push(`- … and ${active.length - recent.length} more in llm-wiki/DECISIONS.md`);
56
56
  }
57
57
  }
58
58
  if (ctx.refinements.length > 0) {
@@ -33,7 +33,7 @@ export function renderRootIndex(config, stats) {
33
33
  const stack = config.stack ?? "css";
34
34
  const packs = (config.packs ?? []).join(", ") || "(none)";
35
35
  const refsSection = renderReferencesSection(config.uiReferences ?? []);
36
- return `# AI Agent Context — ${name}
36
+ return `${stats.healthWarning ?? ""}# AI Agent Context — ${name}
37
37
 
38
38
  > This file is auto-generated by [Whale Igniter](https://github.com/whale-igniter).
39
39
  > Do not edit by hand. Run \`whale sync\` after changes to regenerate.
@@ -53,19 +53,27 @@ the design system, conventions and decisions without re-explaining them every se
53
53
 
54
54
  Whale stores structured context in two locations:
55
55
 
56
- 1. **\`intelligence/\`** — source of truth (JSON). Prefer these for precise data:
57
- - \`intelligence/refinements.json\` — approved validator overrides
58
- - \`intelligence/decisions.json\` — architectural and product decisions
56
+ 1. **\`intelligence/\`** — source of truth. Prefer these for precise data:
57
+ - \`intelligence/refinements/\` — approved validator overrides as Markdown
58
+ - \`intelligence/decisions/\` — architectural and product decisions as ADRs
59
59
  - \`intelligence/components.json\` — component catalog
60
60
 
61
- 2. **\`llm-wiki/\`** — human + AI readable markdown rendered from the JSON:
61
+ 2. **\`llm-wiki/\`** — compact human + AI readable markdown indexes:
62
62
  - \`llm-wiki/FOUNDATIONS.md\` — design tokens and grid rules
63
63
  - \`llm-wiki/CONVENTIONS.md\` — coding and design conventions
64
64
  - \`llm-wiki/DECISIONS.md\` — decision log in narrative form
65
65
  - \`llm-wiki/COMPONENTS.md\` — component catalog
66
66
  - \`llm-wiki/WORKFLOWS.md\` — how the team uses Whale day to day
67
67
 
68
- **Current status:** ${stats.errors} error(s) · ${stats.warnings} warning(s) · ${stats.decisionCount} decision(s) · ${stats.refinementCount} refinement(s) · ${stats.componentCount} component(s)
68
+ ## Current status
69
+
70
+ - Decisions: ${stats.decisionCount}
71
+ - Active refinements: ${stats.refinementCount}
72
+ - Components: ${stats.componentCount}
73
+
74
+ ### Validation
75
+
76
+ ${stats.validationSummary}
69
77
 
70
78
  ${refsSection}## How to work here
71
79
 
@@ -28,7 +28,8 @@ export function renderConventions(config, refinements) {
28
28
  if (r.scope?.file)
29
29
  scopeParts.push(`file=\`${r.scope.file}\``);
30
30
  const scope = scopeParts.length ? ` _(${scopeParts.join(", ")})_` : "";
31
- lines.push(`- **${r.timestamp.slice(0, 10)}** ${r.note}${scope}`);
31
+ const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
32
+ lines.push(`- **${ref}** · ${r.timestamp.slice(0, 10)} — ${r.note}${scope}`);
32
33
  }
33
34
  lines.push("");
34
35
  }
@@ -36,7 +37,8 @@ export function renderConventions(config, refinements) {
36
37
  lines.push("## Open questions", "");
37
38
  lines.push("These notes do not yet have a scope — the validator cannot act on them.", "Review and convert to scoped refinements or decisions.", "");
38
39
  for (const r of scopeless) {
39
- lines.push(`> ⚠️ Unresolved — **${r.timestamp.slice(0, 10)}**: ${r.note}`);
40
+ const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
41
+ lines.push(`> ⚠️ Unresolved — **${ref}** · ${r.timestamp.slice(0, 10)}: ${r.note}`);
40
42
  }
41
43
  lines.push("");
42
44
  }
@@ -1,12 +1,21 @@
1
- const MAX_DETAIL = 500;
2
- function truncate(text) {
3
- return text.length > MAX_DETAIL ? text.slice(0, MAX_DETAIL) + "…" : text;
1
+ const MAX_SUMMARY = 120;
2
+ function summarize(d) {
3
+ const text = (d.context ?? d.decision).replace(/\s+/g, " ").trim();
4
+ const oneLine = text.length > MAX_SUMMARY ? text.slice(0, MAX_SUMMARY) + "…" : text;
5
+ return oneLine.replace(/\|/g, "\\|");
6
+ }
7
+ function linkFor(d) {
8
+ // Wiki lives in llm-wiki/; decisions live in intelligence/decisions/.
9
+ if (!d.sourcePath)
10
+ return d.title;
11
+ return `[${d.title}](../${d.sourcePath})`;
4
12
  }
5
13
  export function renderDecisions(decisions) {
6
14
  const lines = [
7
15
  "# Decision Log",
8
16
  "",
9
- "Architectural, product and tooling decisions, most recent first.",
17
+ "Compact index of architectural, product and tooling decisions, most recent first.",
18
+ "Open the linked file for full Context / Decision / Consequences.",
10
19
  ""
11
20
  ];
12
21
  if (decisions.length === 0) {
@@ -14,20 +23,12 @@ export function renderDecisions(decisions) {
14
23
  }
15
24
  else {
16
25
  const sorted = [...decisions].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
26
+ lines.push("| ID | Decision | Category | Status | Summary |");
27
+ lines.push("| --- | --- | --- | --- | --- |");
17
28
  for (const d of sorted) {
18
- lines.push(`## ${d.title}`);
19
- lines.push("");
20
- lines.push(`_${d.timestamp.slice(0, 10)} · ${d.category} · ${d.status}_`);
21
- lines.push("");
22
- if (d.context) {
23
- lines.push("**Context**", "", truncate(d.context), "");
24
- }
25
- lines.push("**Decision**", "", truncate(d.decision));
26
- if (d.consequences) {
27
- lines.push("", "**Consequences**", "", truncate(d.consequences));
28
- }
29
- lines.push("", "---", "");
29
+ lines.push(`| ${d.id} | ${linkFor(d)} | ${d.category} | ${d.status} | ${summarize(d)} |`);
30
30
  }
31
+ lines.push("");
31
32
  }
32
33
  lines.push("_Generated by Whale Igniter._");
33
34
  return lines.join("\n");
@@ -1,5 +1,5 @@
1
- export function renderProject(config, errors, warnings, refinementCount, decisionCount) {
2
- return `# Project Context
1
+ export function renderProject(config, validationSummary, healthWarning, refinementCount, decisionCount) {
2
+ return `${healthWarning ?? ""}# Project Context
3
3
 
4
4
  - **Project name:** ${config.projectName ?? "(unset)"}
5
5
  - **Project type:** ${config.projectType ?? "unspecified"}
@@ -10,11 +10,13 @@ export function renderProject(config, errors, warnings, refinementCount, decisio
10
10
 
11
11
  ## Current status
12
12
 
13
- - Validation errors: ${errors}
14
- - Validation warnings: ${warnings}
15
13
  - Decisions logged: ${decisionCount}
16
14
  - Active refinements: ${refinementCount}
17
15
 
16
+ ## Validation
17
+
18
+ ${validationSummary}
19
+
18
20
  _Generated by Whale Igniter._
19
21
  `;
20
22
  }
@@ -1,9 +1,60 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { randomUUID } from "node:crypto";
4
- const STORE = "intelligence/decisions.json";
5
- export async function loadDecisions(target) {
6
- const file = path.join(target, STORE);
3
+ import matter from "gray-matter";
4
+ const DIR = "intelligence/decisions";
5
+ const LEGACY_STORE = "intelligence/decisions.json";
6
+ function slugify(title) {
7
+ return title
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, 60);
12
+ }
13
+ function section(body, heading) {
14
+ const re = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
15
+ const match = body.match(re);
16
+ const text = match?.[1]?.trim();
17
+ return text && text.length > 0 ? text : undefined;
18
+ }
19
+ function parseFile(raw) {
20
+ const { data, content } = matter(raw);
21
+ const decision = section(content, "Decision");
22
+ if (!data.id || !data.title || !decision)
23
+ return null;
24
+ return {
25
+ id: String(data.id),
26
+ timestamp: String(data.createdAt ?? data.timestamp ?? ""),
27
+ title: String(data.title),
28
+ category: (data.category ?? "architecture"),
29
+ context: section(content, "Context"),
30
+ decision,
31
+ consequences: section(content, "Consequences"),
32
+ status: (data.status ?? "active"),
33
+ supersedes: data.supersedes ? String(data.supersedes) : undefined
34
+ };
35
+ }
36
+ function serialize(d) {
37
+ const fm = matter.stringify([
38
+ "## Context",
39
+ d.context ?? "",
40
+ "",
41
+ "## Decision",
42
+ d.decision,
43
+ "",
44
+ "## Consequences",
45
+ d.consequences ?? ""
46
+ ].join("\n"), {
47
+ id: d.id,
48
+ title: d.title,
49
+ category: d.category,
50
+ status: d.status,
51
+ createdAt: d.timestamp,
52
+ supersedes: d.supersedes ?? null
53
+ });
54
+ return fm;
55
+ }
56
+ async function loadLegacy(target) {
57
+ const file = path.join(target, LEGACY_STORE);
7
58
  if (!(await fs.pathExists(file)))
8
59
  return [];
9
60
  try {
@@ -13,20 +64,49 @@ export async function loadDecisions(target) {
13
64
  return [];
14
65
  }
15
66
  }
16
- export async function saveDecisions(target, decisions) {
17
- const file = path.join(target, STORE);
18
- await fs.ensureDir(path.dirname(file));
19
- await fs.writeJson(file, decisions, { spaces: 2 });
67
+ export async function loadDecisions(target) {
68
+ const dir = path.join(target, DIR);
69
+ const fromFiles = [];
70
+ if (await fs.pathExists(dir)) {
71
+ const entries = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
72
+ for (const entry of entries) {
73
+ const raw = await fs.readFile(path.join(dir, entry), "utf8");
74
+ const parsed = parseFile(raw);
75
+ if (parsed) {
76
+ parsed.sourcePath = `${DIR}/${entry}`;
77
+ fromFiles.push(parsed);
78
+ }
79
+ }
80
+ }
81
+ // Dual read: surface legacy JSON entries that haven't been migrated yet.
82
+ const legacy = await loadLegacy(target);
83
+ const seen = new Set(fromFiles.map((d) => d.id));
84
+ for (const d of legacy) {
85
+ if (!seen.has(d.id))
86
+ fromFiles.push(d);
87
+ }
88
+ return fromFiles;
89
+ }
90
+ function nextId(existing) {
91
+ let max = 0;
92
+ for (const d of existing) {
93
+ const m = /^ADR-(\d+)$/.exec(d.id);
94
+ if (m)
95
+ max = Math.max(max, Number(m[1]));
96
+ }
97
+ return `ADR-${String(max + 1).padStart(4, "0")}`;
20
98
  }
21
99
  export async function appendDecision(target, input) {
22
- const decisions = await loadDecisions(target);
100
+ const existing = await loadDecisions(target);
23
101
  const decision = {
24
- id: randomUUID(),
25
- timestamp: new Date().toISOString(),
102
+ id: nextId(existing),
103
+ timestamp: new Date().toISOString().slice(0, 10),
26
104
  status: "active",
27
105
  ...input
28
106
  };
29
- decisions.push(decision);
30
- await saveDecisions(target, decisions);
107
+ const dir = path.join(target, DIR);
108
+ await fs.ensureDir(dir);
109
+ const file = path.join(dir, `${decision.id}-${slugify(decision.title)}.md`);
110
+ await fs.writeFile(file, serialize(decision));
31
111
  return decision;
32
112
  }
@@ -1,9 +1,49 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- const STORE_FILENAME = "refinements.json";
4
- const STORE_DIR = "intelligence";
5
- export async function loadRefinements(target) {
6
- const file = path.join(target, STORE_DIR, STORE_FILENAME);
3
+ import matter from "gray-matter";
4
+ const DIR = "intelligence/refinements";
5
+ const LEGACY_STORE = "intelligence/refinements.json";
6
+ function slugify(text) {
7
+ return text
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, 60);
12
+ }
13
+ function parseFile(raw) {
14
+ const { data, content } = matter(raw);
15
+ const note = content.trim();
16
+ if (!data.id || !note)
17
+ return null;
18
+ const scope = {};
19
+ if (data.issueType)
20
+ scope.issueType = String(data.issueType);
21
+ if (data.selector)
22
+ scope.selector = String(data.selector);
23
+ if (data.file)
24
+ scope.file = String(data.file);
25
+ return {
26
+ id: String(data.id),
27
+ timestamp: String(data.createdAt ?? data.timestamp ?? ""),
28
+ note,
29
+ scope: Object.keys(scope).length > 0 ? scope : undefined
30
+ };
31
+ }
32
+ function serialize(r) {
33
+ const fm = {
34
+ id: r.id,
35
+ createdAt: r.timestamp
36
+ };
37
+ if (r.scope?.issueType)
38
+ fm.issueType = r.scope.issueType;
39
+ if (r.scope?.selector)
40
+ fm.selector = r.scope.selector;
41
+ if (r.scope?.file)
42
+ fm.file = r.scope.file;
43
+ return matter.stringify(r.note, fm);
44
+ }
45
+ async function loadLegacy(target) {
46
+ const file = path.join(target, LEGACY_STORE);
7
47
  if (!(await fs.pathExists(file)))
8
48
  return [];
9
49
  try {
@@ -13,16 +53,49 @@ export async function loadRefinements(target) {
13
53
  return [];
14
54
  }
15
55
  }
16
- export async function saveRefinements(target, refinements) {
17
- const dir = path.join(target, STORE_DIR);
18
- await fs.ensureDir(dir);
19
- const file = path.join(dir, STORE_FILENAME);
20
- await fs.writeJson(file, refinements, { spaces: 2 });
56
+ export async function loadRefinements(target) {
57
+ const dir = path.join(target, DIR);
58
+ const fromFiles = [];
59
+ if (await fs.pathExists(dir)) {
60
+ const entries = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
61
+ for (const entry of entries) {
62
+ const raw = await fs.readFile(path.join(dir, entry), "utf8");
63
+ const parsed = parseFile(raw);
64
+ if (parsed)
65
+ fromFiles.push({ ...parsed, sourcePath: `${DIR}/${entry}` });
66
+ }
67
+ }
68
+ // Dual read: surface legacy JSON entries that haven't been migrated yet.
69
+ const legacy = await loadLegacy(target);
70
+ const seen = new Set(fromFiles.map((r) => r.id));
71
+ for (const r of legacy) {
72
+ if (!seen.has(r.id))
73
+ fromFiles.push(r);
74
+ }
75
+ return fromFiles;
21
76
  }
22
- export async function appendRefinement(target, refinement) {
23
- const current = await loadRefinements(target);
24
- current.push(refinement);
25
- await saveRefinements(target, current);
77
+ function nextId(existing) {
78
+ let max = 0;
79
+ for (const r of existing) {
80
+ const m = /^REF-(\d+)$/.exec(r.id);
81
+ if (m)
82
+ max = Math.max(max, Number(m[1]));
83
+ }
84
+ return `REF-${String(max + 1).padStart(4, "0")}`;
85
+ }
86
+ export async function appendRefinement(target, input) {
87
+ const existing = await loadRefinements(target);
88
+ const refinement = {
89
+ id: nextId(existing),
90
+ timestamp: new Date().toISOString().slice(0, 10),
91
+ note: input.note,
92
+ scope: input.scope
93
+ };
94
+ const dir = path.join(target, DIR);
95
+ await fs.ensureDir(dir);
96
+ const file = path.join(dir, `${refinement.id}-${slugify(refinement.note)}.md`);
97
+ await fs.writeFile(file, serialize(refinement));
98
+ return refinement;
26
99
  }
27
100
  // Lightweight natural-language extractor. Recognises hints like:
28
101
  // "Button radius should remain 2px for controls" -> issueType: radius-consistency
@@ -0,0 +1,77 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import { ui } from "../ui/index.js";
5
+ function isSamePath(a, b) {
6
+ return path.resolve(a) === path.resolve(b);
7
+ }
8
+ function isAncestorOf(ancestor, child) {
9
+ const relative = path.relative(ancestor, child);
10
+ return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
11
+ }
12
+ function projectMarkerMessage(target, command) {
13
+ const commandHint = command ? `whale ${command}` : "this command";
14
+ return [
15
+ `This does not look like a project directory: ${target}`,
16
+ "I could not find a .git directory or package.json there.",
17
+ `Run ${ui.code(commandHint)} from inside your project, pass an explicit project path, or add ${ui.code("--force")} if this really is the target you want.`
18
+ ].join("\n");
19
+ }
20
+ function protectedDirectoryMessage(target, command) {
21
+ const commandHint = command ? `whale ${command} path/to/project` : "pass an explicit project path";
22
+ return [
23
+ `I will not run in ${target}.`,
24
+ "That directory is your home folder, the filesystem root, or a parent of your home folder.",
25
+ `Please cd into a project directory first, or run ${ui.code(commandHint)}.`
26
+ ].join("\n");
27
+ }
28
+ export async function checkTargetDirectory(target, options = {}) {
29
+ const resolvedTarget = path.resolve(target);
30
+ const home = path.resolve(os.homedir());
31
+ const root = path.parse(resolvedTarget).root;
32
+ if (isSamePath(resolvedTarget, home) ||
33
+ isSamePath(resolvedTarget, root) ||
34
+ isAncestorOf(resolvedTarget, home)) {
35
+ return {
36
+ ok: false,
37
+ target: resolvedTarget,
38
+ reason: "protected-directory",
39
+ message: protectedDirectoryMessage(resolvedTarget, options.command)
40
+ };
41
+ }
42
+ const hasGit = await fs.pathExists(path.join(resolvedTarget, ".git"));
43
+ const hasPackageJson = await fs.pathExists(path.join(resolvedTarget, "package.json"));
44
+ if (!hasGit && !hasPackageJson) {
45
+ const message = projectMarkerMessage(resolvedTarget, options.command);
46
+ if (!options.force) {
47
+ return {
48
+ ok: false,
49
+ target: resolvedTarget,
50
+ reason: "missing-project-marker",
51
+ message
52
+ };
53
+ }
54
+ return {
55
+ ok: true,
56
+ target: resolvedTarget,
57
+ warning: message
58
+ };
59
+ }
60
+ return { ok: true, target: resolvedTarget };
61
+ }
62
+ export async function guardTargetDirectory(target, options = {}) {
63
+ const result = await checkTargetDirectory(target, options);
64
+ if (!result.ok) {
65
+ console.log();
66
+ console.log(ui.fail(result.message));
67
+ console.log();
68
+ process.exitCode = 1;
69
+ return false;
70
+ }
71
+ if (result.warning) {
72
+ console.log();
73
+ console.log(ui.warn(result.warning));
74
+ console.log();
75
+ }
76
+ return true;
77
+ }