@shanvit7/poiesis 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.
package/src/utils.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { execSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+ import type { ChapterState, ChapterStep } from "./types.ts"
5
+
6
+ export const slugify = (s: string): string =>
7
+ s
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, 40)
12
+
13
+ export const expandHome = (p: string): string => p.replace(/^~/, process.env.HOME ?? "")
14
+
15
+ /**
16
+ * Scan $HOME for directories that actually exist — used to build ask_user_question options
17
+ * for the project location picker. Returns at most 4 (the tool's max per question).
18
+ */
19
+ export const scanHomeDirs = (): { label: string; path: string }[] => {
20
+ const home = process.env.HOME ?? ""
21
+ const candidates = ["Desktop", "projects", "dev", "code", "Documents", "workspace", "src"]
22
+ return candidates
23
+ .filter((name) => existsSync(`${home}/${name}`))
24
+ .slice(0, 4)
25
+ .map((name) => ({ label: name, path: `${home}/${name}` }))
26
+ }
27
+
28
+ export const readJson = <T>(path: string): T => {
29
+ const full = expandHome(path)
30
+ return JSON.parse(readFileSync(full, "utf8")) as T
31
+ }
32
+
33
+ export const writeJson = (path: string, data: unknown): void => {
34
+ const full = expandHome(path)
35
+ mkdirSync(dirname(full), { recursive: true })
36
+ writeFileSync(full, JSON.stringify(data, null, 2))
37
+ }
38
+
39
+ export const exists = (path: string): boolean => existsSync(expandHome(path))
40
+
41
+ export const run = (cmd: string, cwd?: string): string => {
42
+ try {
43
+ return execSync(cmd, {
44
+ cwd: cwd ? expandHome(cwd) : undefined,
45
+ encoding: "utf8",
46
+ stdio: ["inherit", "pipe", "pipe"],
47
+ }).trim()
48
+ } catch (e: unknown) {
49
+ const err = e as { stderr?: Buffer; message?: string }
50
+ throw new Error(`Command failed: ${cmd}\n${err.stderr?.toString() ?? err.message ?? ""}`)
51
+ }
52
+ }
53
+
54
+ export const ensureDir = (path: string): void => {
55
+ mkdirSync(expandHome(path), { recursive: true })
56
+ }
57
+
58
+ // ── Chapter step state ──────────────────────────────────────────────────────────────────
59
+
60
+ export const chapterStatePath = (projectDir: string, n: number): string =>
61
+ join(expandHome(projectDir), ".poiesis", "chapters", `chapter-${n}.state.json`)
62
+
63
+ export const readChapterState = (projectDir: string, n: number): ChapterState | null => {
64
+ const p = chapterStatePath(projectDir, n)
65
+ if (!existsSync(p)) return null
66
+ return readJson<ChapterState>(p)
67
+ }
68
+
69
+ /** Deep-merges patch into existing state, or creates from defaults. */
70
+ export const writeChapterState = (
71
+ projectDir: string,
72
+ n: number,
73
+ patch: Partial<ChapterState>
74
+ ): void => {
75
+ const existing = readChapterState(projectDir, n) ?? {
76
+ step: "prereq" as ChapterStep,
77
+ prereqResult: null,
78
+ testsFile: null,
79
+ testsPlan: [],
80
+ testsPass: false,
81
+ startedAt: new Date().toISOString(),
82
+ }
83
+ writeJson(chapterStatePath(projectDir, n), { ...existing, ...patch })
84
+ }
85
+
86
+ /**
87
+ * Find a poiesis project directory that has an active .progress.json.
88
+ *
89
+ * Two-pass:
90
+ * 1. cwd itself is the project root (user is already inside it)
91
+ * 2. scan immediate children of cwd (user is in the parent)
92
+ */
93
+ export const findActiveProject = (cwd: string): string | null => {
94
+ const base = expandHome(cwd)
95
+
96
+ // Pass 1: cwd is the project itself
97
+ if (existsSync(join(base, ".poiesis", "chapters", ".progress.json"))) return base
98
+
99
+ // Pass 2: scan one level down
100
+ let entries: import("node:fs").Dirent[]
101
+ try {
102
+ entries = readdirSync(base, { withFileTypes: true })
103
+ } catch {
104
+ return null
105
+ }
106
+ for (const e of entries) {
107
+ if (!e.isDirectory()) continue
108
+ if (existsSync(join(base, e.name, ".poiesis", "chapters", ".progress.json")))
109
+ return join(base, e.name)
110
+ }
111
+ return null
112
+ }
113
+
114
+ // ponytail: self-check
115
+ if (process.argv[1]?.endsWith("utils.ts")) {
116
+ import("node:os").then(({ tmpdir }) => {
117
+ import("node:fs").then(({ mkdirSync, writeFileSync, rmSync }) => {
118
+ const s = slugify("Build a Rust HTTP Server from Scratch!!")
119
+ console.assert(s === "build-a-rust-http-server-from-scratch", `slugify failed: ${s}`)
120
+
121
+ // Pass 2: cwd is the parent, project is a child dir
122
+ const tmp = join(tmpdir(), `poiesis-utils-${Date.now()}`)
123
+ const projDir = join(tmp, "my-project", ".poiesis", "chapters")
124
+ mkdirSync(projDir, { recursive: true })
125
+ writeFileSync(join(projDir, ".progress.json"), "{}")
126
+ const found = findActiveProject(tmp)
127
+ console.assert(found === join(tmp, "my-project"), `pass-2 failed: ${found}`)
128
+
129
+ // Pass 1: cwd IS the project (user ran /poiesis from inside it)
130
+ const insideFound = findActiveProject(join(tmp, "my-project"))
131
+ console.assert(insideFound === join(tmp, "my-project"), `pass-1 failed: ${insideFound}`)
132
+
133
+ // null when nothing found
134
+ const empty = join(tmpdir(), `poiesis-empty-${Date.now()}`)
135
+ mkdirSync(empty)
136
+ console.assert(findActiveProject(empty) === null, "should return null for empty dir")
137
+
138
+ // chapterState round-trip
139
+ writeChapterState(join(tmp, "my-project"), 1, { step: "theory", prereqResult: "primed" })
140
+ const st = readChapterState(join(tmp, "my-project"), 1)
141
+ console.assert(st?.step === "theory", `state step not persisted: ${st?.step}`)
142
+ console.assert(
143
+ st?.prereqResult === "primed",
144
+ `state prereqResult not persisted: ${st?.prereqResult}`
145
+ )
146
+ console.assert(
147
+ st?.testsFile === null,
148
+ `state testsFile should default null: ${st?.testsFile}`
149
+ )
150
+ // patch merges, does not clobber existing fields
151
+ writeChapterState(join(tmp, "my-project"), 1, { step: "plan" })
152
+ const st2 = readChapterState(join(tmp, "my-project"), 1)
153
+ console.assert(st2?.step === "plan", `patched step failed: ${st2?.step}`)
154
+ console.assert(
155
+ st2?.prereqResult === "primed",
156
+ `prereqResult should survive patch: ${st2?.prereqResult}`
157
+ )
158
+
159
+ rmSync(tmp, { recursive: true })
160
+ rmSync(empty, { recursive: true })
161
+ console.log("utils.ts: ok")
162
+ })
163
+ })
164
+ }