doctrina-cli 0.1.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,31 @@
1
+ // ANSI colour helpers. Falls back to plain text when:
2
+ // - stdout is not a TTY, OR
3
+ // - the NO_COLOR environment variable is set (any value), OR
4
+ // - the FORCE_COLOR variable is "0".
5
+ //
6
+ // Reference for NO_COLOR: https://no-color.org/
7
+
8
+ import process from "node:process";
9
+
10
+ const enabled = (() => {
11
+ if (process.env.NO_COLOR !== undefined) return false;
12
+ if (process.env.FORCE_COLOR === "0") return false;
13
+ if (process.env.FORCE_COLOR !== undefined) return true;
14
+ return Boolean(process.stdout.isTTY);
15
+ })();
16
+
17
+ function wrap(open, close) {
18
+ return (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
19
+ }
20
+
21
+ export const c = {
22
+ enabled,
23
+ bold: wrap(1, 22),
24
+ dim: wrap(2, 22),
25
+ red: wrap(31, 39),
26
+ green: wrap(32, 39),
27
+ yellow: wrap(33, 39),
28
+ blue: wrap(34, 39),
29
+ cyan: wrap(36, 39),
30
+ gray: wrap(90, 39),
31
+ };
@@ -0,0 +1,21 @@
1
+ // Date helpers. Single source so the CLI emits consistent YYYY-MM-DD strings.
2
+
3
+ export function today(now = new Date()) {
4
+ const y = now.getUTCFullYear();
5
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
6
+ const d = String(now.getUTCDate()).padStart(2, "0");
7
+ return `${y}-${m}-${d}`;
8
+ }
9
+
10
+ export function slugify(s) {
11
+ return String(s)
12
+ .normalize("NFKD")
13
+ .replace(/[̀-ͯ]/g, "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/^-+|-+$/g, "");
17
+ }
18
+
19
+ export function padNumber(n, width = 4) {
20
+ return String(n).padStart(width, "0");
21
+ }
@@ -0,0 +1,75 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function read(p) {
5
+ return readFileSync(p, "utf8");
6
+ }
7
+
8
+ export function write(p, content, { force = false } = {}) {
9
+ if (existsSync(p) && !force) {
10
+ throw new Error(`refusing to overwrite existing file: ${p} (pass --force to allow)`);
11
+ }
12
+ mkdirSync(path.dirname(p), { recursive: true });
13
+ writeFileSync(p, content);
14
+ }
15
+
16
+ export function exists(p) {
17
+ return existsSync(p);
18
+ }
19
+
20
+ export function isDir(p) {
21
+ try {
22
+ return statSync(p).isDirectory();
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export function isFile(p) {
29
+ try {
30
+ return statSync(p).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export function mkdirp(p) {
37
+ mkdirSync(p, { recursive: true });
38
+ }
39
+
40
+ export function move(from, to) {
41
+ mkdirSync(path.dirname(to), { recursive: true });
42
+ renameSync(from, to);
43
+ }
44
+
45
+ export function remove(p) {
46
+ if (!existsSync(p)) return;
47
+ if (isDir(p)) {
48
+ for (const entry of readdirSync(p)) remove(path.join(p, entry));
49
+ rmdirSync(p);
50
+ } else {
51
+ unlinkSync(p);
52
+ }
53
+ }
54
+
55
+ // Recursive file walk. Returns absolute paths of every regular file.
56
+ export function walk(root) {
57
+ const out = [];
58
+ if (!exists(root)) return out;
59
+ const stack = [root];
60
+ while (stack.length) {
61
+ const dir = stack.pop();
62
+ for (const entry of readdirSync(dir)) {
63
+ const full = path.join(dir, entry);
64
+ if (isDir(full)) stack.push(full);
65
+ else if (isFile(full)) out.push(full);
66
+ }
67
+ }
68
+ return out.sort();
69
+ }
70
+
71
+ export function lineCount(p) {
72
+ const text = read(p);
73
+ if (text.length === 0) return 0;
74
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
75
+ }
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import { read, write, exists } from "./fs-ops.js";
3
+
4
+ const SCHEMA_VERSION = "0.1.0";
5
+
6
+ export function indexPath(projectRoot) {
7
+ return path.join(projectRoot, ".doctrina", "index.json");
8
+ }
9
+
10
+ export function load(projectRoot) {
11
+ const p = indexPath(projectRoot);
12
+ if (!exists(p)) {
13
+ throw new Error(`.doctrina/index.json not found at ${p}`);
14
+ }
15
+ const raw = read(p);
16
+ try {
17
+ return JSON.parse(raw);
18
+ } catch (err) {
19
+ throw new Error(`.doctrina/index.json is not valid JSON: ${err.message}`);
20
+ }
21
+ }
22
+
23
+ export function save(projectRoot, index) {
24
+ const p = indexPath(projectRoot);
25
+ const text = JSON.stringify(index, null, 2) + "\n";
26
+ write(p, text, { force: true });
27
+ }
28
+
29
+ export function blank(projectName, date) {
30
+ return {
31
+ $schema_version: SCHEMA_VERSION,
32
+ project: projectName,
33
+ framework_version: "0.0.0",
34
+ last_updated: date,
35
+ artifacts: {
36
+ product: {
37
+ path: ".doctrina/product.md",
38
+ status: "active",
39
+ version: "0.1.0",
40
+ last_updated: date,
41
+ },
42
+ specs: [],
43
+ decisions: [],
44
+ changes: [],
45
+ changes_archive: [],
46
+ skills: [],
47
+ },
48
+ };
49
+ }
50
+
51
+ export function addSkill(index, entry) {
52
+ if (!index.artifacts.skills) index.artifacts.skills = [];
53
+ if (!index.artifacts.skills.some((s) => s.id === entry.id)) {
54
+ index.artifacts.skills.push(entry);
55
+ }
56
+ return index;
57
+ }
58
+
59
+ export function removeSkill(index, id) {
60
+ if (!index.artifacts.skills) return index;
61
+ index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
62
+ return index;
63
+ }
64
+
65
+ export function touch(index, date) {
66
+ index.last_updated = date;
67
+ return index;
68
+ }
69
+
70
+ export function addSpec(index, entry) {
71
+ if (!index.artifacts.specs.some((s) => s.id === entry.id)) {
72
+ index.artifacts.specs.push(entry);
73
+ }
74
+ return index;
75
+ }
76
+
77
+ export function removeSpec(index, id) {
78
+ index.artifacts.specs = index.artifacts.specs.filter((s) => s.id !== id);
79
+ return index;
80
+ }
81
+
82
+ export function addDecision(index, entry) {
83
+ if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
84
+ index.artifacts.decisions.push(entry);
85
+ }
86
+ return index;
87
+ }
88
+
89
+ export function updateDecision(index, id, mutator) {
90
+ const list = index.artifacts.decisions;
91
+ const i = list.findIndex((d) => d.id === id);
92
+ if (i >= 0) list[i] = { ...list[i], ...mutator(list[i]) };
93
+ return index;
94
+ }
95
+
96
+ export function addChange(index, entry) {
97
+ if (!index.artifacts.changes.some((c) => c.id === entry.id)) {
98
+ index.artifacts.changes.push(entry);
99
+ }
100
+ return index;
101
+ }
102
+
103
+ export function moveChangeToArchive(index, id, archiveEntry) {
104
+ index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
105
+ if (!index.artifacts.changes_archive.some((c) => c.id === id)) {
106
+ index.artifacts.changes_archive.push(archiveEntry);
107
+ }
108
+ return index;
109
+ }
@@ -0,0 +1,19 @@
1
+ import readline from "node:readline";
2
+
3
+ export async function ask(question, { defaultValue = "" } = {}) {
4
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5
+ const prompt = defaultValue ? `${question} [${defaultValue}] ` : `${question} `;
6
+ try {
7
+ const answer = await new Promise((resolve) => rl.question(prompt, resolve));
8
+ return answer.trim() || defaultValue;
9
+ } finally {
10
+ rl.close();
11
+ }
12
+ }
13
+
14
+ export async function confirm(question, { defaultYes = false } = {}) {
15
+ const suffix = defaultYes ? "Y/n" : "y/N";
16
+ const ans = await ask(`${question} (${suffix})`);
17
+ if (!ans) return defaultYes;
18
+ return /^y(es)?$/i.test(ans);
19
+ }
@@ -0,0 +1,44 @@
1
+ // Tiny Levenshtein distance + "did you mean" suggester. Zero deps.
2
+
3
+ export function distance(a, b) {
4
+ if (a === b) return 0;
5
+ if (!a) return b.length;
6
+ if (!b) return a.length;
7
+ const m = a.length;
8
+ const n = b.length;
9
+ let prev = new Array(n + 1);
10
+ let curr = new Array(n + 1);
11
+ for (let j = 0; j <= n; j++) prev[j] = j;
12
+ for (let i = 1; i <= m; i++) {
13
+ curr[0] = i;
14
+ for (let j = 1; j <= n; j++) {
15
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
16
+ curr[j] = Math.min(
17
+ curr[j - 1] + 1,
18
+ prev[j] + 1,
19
+ prev[j - 1] + cost,
20
+ );
21
+ }
22
+ [prev, curr] = [curr, prev];
23
+ }
24
+ return prev[n];
25
+ }
26
+
27
+ // Return the closest match from `candidates` to `input`, or null
28
+ // when no candidate is close enough.
29
+ export function suggest(input, candidates) {
30
+ if (!input || !candidates || candidates.length === 0) return null;
31
+ const inputLower = String(input).toLowerCase();
32
+ let best = null;
33
+ let bestD = Infinity;
34
+ for (const cand of candidates) {
35
+ const d = distance(inputLower, String(cand).toLowerCase());
36
+ if (d < bestD) {
37
+ bestD = d;
38
+ best = cand;
39
+ }
40
+ }
41
+ // Threshold: at most ceil(len/3), and never more than 3 absolute.
42
+ const threshold = Math.min(3, Math.ceil(inputLower.length / 3));
43
+ return bestD <= threshold ? best : null;
44
+ }
@@ -0,0 +1,78 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { read, walk, write } from "./fs-ops.js";
4
+
5
+ // Resolve the path to the .doctrina/templates/ directory shipped with this
6
+ // package. The package layout is:
7
+ // packages/doctrina-cli/src/lib/templates.js (this file)
8
+ // .doctrina/templates/ (when running from a source checkout)
9
+ //
10
+ // When installed via npm, the package will ship a copy of templates alongside
11
+ // src/. The resolver below tries the local source first, then a packaged copy.
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ export function locateTemplatesDir() {
16
+ const candidates = [
17
+ // running from source checkout: ../../../../.doctrina/templates
18
+ path.resolve(here, "..", "..", "..", "..", ".doctrina", "templates"),
19
+ // installed package layout: ../../templates
20
+ path.resolve(here, "..", "..", "templates"),
21
+ ];
22
+ for (const candidate of candidates) {
23
+ try {
24
+ // statSync via fs-ops without importing it twice
25
+ const test = path.join(candidate, "README.md");
26
+ // dynamic check using read (throws if missing)
27
+ read(test);
28
+ return candidate;
29
+ } catch {
30
+ // try next
31
+ }
32
+ }
33
+ throw new Error(
34
+ `cannot locate .doctrina/templates/. Tried: ${candidates.join(", ")}`,
35
+ );
36
+ }
37
+
38
+ // Single-pass {{TOKEN}} substitution. Tokens are uppercase, digits, or
39
+ // underscore inside double curly braces.
40
+ export function substitute(text, tokens) {
41
+ return text.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, name) => {
42
+ if (Object.prototype.hasOwnProperty.call(tokens, name)) {
43
+ return String(tokens[name]);
44
+ }
45
+ return match;
46
+ });
47
+ }
48
+
49
+ // Scan a template body for required tokens (every {{TOKEN}} occurrence).
50
+ export function discoverTokens(text) {
51
+ const set = new Set();
52
+ for (const m of text.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)) set.add(m[1]);
53
+ return [...set].sort();
54
+ }
55
+
56
+ // Walk a template subdirectory and return an array of
57
+ // { templatePath, relativePath, body } entries, suitable for substitution.
58
+ export function loadTemplateTree(templatesDir, subdir) {
59
+ const root = path.join(templatesDir, subdir);
60
+ const files = walk(root);
61
+ return files.map((templatePath) => {
62
+ const relativePath = path.relative(root, templatePath);
63
+ const body = templatePath.endsWith(".gitkeep") ? "" : read(templatePath);
64
+ return { templatePath, relativePath, body };
65
+ });
66
+ }
67
+
68
+ // Materialise a template entry into a destination directory. If the relative
69
+ // path ends in ".template", the suffix is stripped on write.
70
+ export function materialiseEntry(entry, destDir, tokens, opts = {}) {
71
+ const cleaned = entry.relativePath.replace(/\.template$/, "");
72
+ const destPath = path.join(destDir, cleaned);
73
+ const body = entry.templatePath.endsWith(".gitkeep")
74
+ ? ""
75
+ : substitute(entry.body, tokens);
76
+ write(destPath, body, opts);
77
+ return destPath;
78
+ }