@shahmarasy/prodo 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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/bin/prodo.cjs +6 -0
  4. package/dist/agent-command-installer.d.ts +4 -0
  5. package/dist/agent-command-installer.js +158 -0
  6. package/dist/agents.d.ts +15 -0
  7. package/dist/agents.js +47 -0
  8. package/dist/artifact-registry.d.ts +11 -0
  9. package/dist/artifact-registry.js +49 -0
  10. package/dist/artifacts.d.ts +9 -0
  11. package/dist/artifacts.js +514 -0
  12. package/dist/cli.d.ts +9 -0
  13. package/dist/cli.js +305 -0
  14. package/dist/consistency.d.ts +8 -0
  15. package/dist/consistency.js +268 -0
  16. package/dist/constants.d.ts +7 -0
  17. package/dist/constants.js +64 -0
  18. package/dist/doctor.d.ts +1 -0
  19. package/dist/doctor.js +123 -0
  20. package/dist/errors.d.ts +3 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/hook-executor.d.ts +1 -0
  23. package/dist/hook-executor.js +175 -0
  24. package/dist/init-tui.d.ts +21 -0
  25. package/dist/init-tui.js +161 -0
  26. package/dist/init.d.ts +10 -0
  27. package/dist/init.js +307 -0
  28. package/dist/markdown.d.ts +11 -0
  29. package/dist/markdown.js +66 -0
  30. package/dist/normalize.d.ts +7 -0
  31. package/dist/normalize.js +73 -0
  32. package/dist/normalized-brief.d.ts +39 -0
  33. package/dist/normalized-brief.js +170 -0
  34. package/dist/output-index.d.ts +13 -0
  35. package/dist/output-index.js +55 -0
  36. package/dist/paths.d.ts +16 -0
  37. package/dist/paths.js +76 -0
  38. package/dist/preset-loader.d.ts +4 -0
  39. package/dist/preset-loader.js +210 -0
  40. package/dist/project-config.d.ts +14 -0
  41. package/dist/project-config.js +69 -0
  42. package/dist/providers/index.d.ts +2 -0
  43. package/dist/providers/index.js +12 -0
  44. package/dist/providers/mock-provider.d.ts +7 -0
  45. package/dist/providers/mock-provider.js +168 -0
  46. package/dist/providers/openai-provider.d.ts +11 -0
  47. package/dist/providers/openai-provider.js +69 -0
  48. package/dist/registry.d.ts +13 -0
  49. package/dist/registry.js +115 -0
  50. package/dist/settings.d.ts +6 -0
  51. package/dist/settings.js +34 -0
  52. package/dist/template-resolver.d.ts +11 -0
  53. package/dist/template-resolver.js +28 -0
  54. package/dist/templates.d.ts +33 -0
  55. package/dist/templates.js +428 -0
  56. package/dist/types.d.ts +35 -0
  57. package/dist/types.js +5 -0
  58. package/dist/utils.d.ts +6 -0
  59. package/dist/utils.js +53 -0
  60. package/dist/validate.d.ts +9 -0
  61. package/dist/validate.js +226 -0
  62. package/dist/validator.d.ts +5 -0
  63. package/dist/validator.js +80 -0
  64. package/dist/version.d.ts +1 -0
  65. package/dist/version.js +30 -0
  66. package/dist/workflow-commands.d.ts +7 -0
  67. package/dist/workflow-commands.js +28 -0
  68. package/package.json +45 -0
  69. package/presets/fintech/preset.json +1 -0
  70. package/presets/fintech/prompts/prd.md +3 -0
  71. package/presets/marketplace/preset.json +1 -0
  72. package/presets/marketplace/prompts/prd.md +3 -0
  73. package/presets/saas/preset.json +1 -0
  74. package/presets/saas/prompts/prd.md +3 -0
  75. package/src/agent-command-installer.ts +174 -0
  76. package/src/agents.ts +56 -0
  77. package/src/artifact-registry.ts +69 -0
  78. package/src/artifacts.ts +606 -0
  79. package/src/cli.ts +322 -0
  80. package/src/consistency.ts +303 -0
  81. package/src/constants.ts +72 -0
  82. package/src/doctor.ts +137 -0
  83. package/src/errors.ts +7 -0
  84. package/src/hook-executor.ts +196 -0
  85. package/src/init-tui.ts +193 -0
  86. package/src/init.ts +375 -0
  87. package/src/markdown.ts +73 -0
  88. package/src/normalize.ts +89 -0
  89. package/src/normalized-brief.ts +206 -0
  90. package/src/output-index.ts +59 -0
  91. package/src/paths.ts +72 -0
  92. package/src/preset-loader.ts +237 -0
  93. package/src/project-config.ts +78 -0
  94. package/src/providers/index.ts +12 -0
  95. package/src/providers/mock-provider.ts +188 -0
  96. package/src/providers/openai-provider.ts +87 -0
  97. package/src/registry.ts +119 -0
  98. package/src/settings.ts +34 -0
  99. package/src/template-resolver.ts +33 -0
  100. package/src/templates.ts +440 -0
  101. package/src/types.ts +46 -0
  102. package/src/utils.ts +50 -0
  103. package/src/validate.ts +246 -0
  104. package/src/validator.ts +96 -0
  105. package/src/version.ts +24 -0
  106. package/src/workflow-commands.ts +31 -0
  107. package/templates/artifacts/prd.md +219 -0
  108. package/templates/artifacts/stories.md +49 -0
  109. package/templates/artifacts/techspec.md +42 -0
  110. package/templates/artifacts/wireframe.html +260 -0
  111. package/templates/artifacts/wireframe.md +22 -0
  112. package/templates/artifacts/workflow.md +22 -0
  113. package/templates/artifacts/workflow.mmd +6 -0
  114. package/templates/commands/prodo-normalize.md +24 -0
  115. package/templates/commands/prodo-prd.md +24 -0
  116. package/templates/commands/prodo-stories.md +24 -0
  117. package/templates/commands/prodo-techspec.md +24 -0
  118. package/templates/commands/prodo-validate.md +24 -0
  119. package/templates/commands/prodo-wireframe.md +24 -0
  120. package/templates/commands/prodo-workflow.md +24 -0
@@ -0,0 +1,188 @@
1
+ import type { BriefContractItem } from "../normalized-brief";
2
+ import type { LLMProvider, ProviderSchemaHint } from "../types";
3
+
4
+ function asStringArray(value: unknown): string[] {
5
+ if (!Array.isArray(value)) return [];
6
+ return value.filter((item): item is string => typeof item === "string");
7
+ }
8
+
9
+ function asContracts(value: unknown): BriefContractItem[] {
10
+ if (!Array.isArray(value)) return [];
11
+ return value
12
+ .map((item) => {
13
+ if (!item || typeof item !== "object") return null;
14
+ const record = item as Record<string, unknown>;
15
+ if (typeof record.id !== "string" || typeof record.text !== "string") return null;
16
+ return { id: record.id, text: record.text };
17
+ })
18
+ .filter((item): item is BriefContractItem => item !== null);
19
+ }
20
+
21
+ function extractValue(markdown: string, heading: string): string[] {
22
+ const aliases = heading.split("|").map((item) => item.trim().toLowerCase());
23
+ const lines = markdown.split(/\r?\n/);
24
+ let collect = false;
25
+ const out: string[] = [];
26
+ for (const raw of lines) {
27
+ const headingMatch = raw.match(/^#{1,6}\s+(.+?)\s*$/);
28
+ if (headingMatch) {
29
+ const current = headingMatch[1].trim().toLowerCase();
30
+ if (collect && !aliases.includes(current)) break;
31
+ collect = aliases.includes(current);
32
+ continue;
33
+ }
34
+ if (!collect) continue;
35
+ const cleaned = raw.replace(/^\s*[-*]\s*/, "").trim();
36
+ if (cleaned.length > 0) out.push(cleaned);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function normalizeWithMock(inputContext: Record<string, unknown>): string {
42
+ const brief = typeof inputContext.briefMarkdown === "string" ? inputContext.briefMarkdown : "";
43
+ const product = extractValue(brief, "Product Name|Name")[0] ?? "";
44
+ const problem = extractValue(brief, "Problem|Problem Statement").join(" ");
45
+ const audience = extractValue(brief, "Audience|Users|Persona");
46
+ const goals = extractValue(brief, "Goals|Objectives");
47
+ const features = extractValue(brief, "Core Features|Features|Capabilities");
48
+ const constraints = extractValue(brief, "Constraints|Limitations|Restrictions");
49
+ const assumptions = extractValue(brief, "Assumptions|Open Questions");
50
+
51
+ const contracts = {
52
+ goals: goals.map((text, index) => ({ id: `G${index + 1}`, text })),
53
+ core_features: features.map((text, index) => ({ id: `F${index + 1}`, text })),
54
+ constraints: constraints.map((text, index) => ({ id: `C${index + 1}`, text }))
55
+ };
56
+
57
+ const confidence = {
58
+ product_name: product ? 0.95 : 0.25,
59
+ problem: problem ? 0.95 : 0.25,
60
+ audience: audience.length > 0 ? 0.95 : 0.25,
61
+ goals: goals.length > 0 ? 0.95 : 0.25,
62
+ core_features: features.length > 0 ? 0.95 : 0.25
63
+ };
64
+
65
+ return JSON.stringify(
66
+ {
67
+ schema_version: "1.0",
68
+ product_name: product,
69
+ problem,
70
+ audience,
71
+ goals,
72
+ core_features: features,
73
+ constraints,
74
+ assumptions,
75
+ contracts,
76
+ confidence
77
+ },
78
+ null,
79
+ 2
80
+ );
81
+ }
82
+
83
+ function normalizeSectionItems(inputContext: Record<string, unknown>): string[] {
84
+ const normalizedBrief = (inputContext.normalizedBrief ?? {}) as Record<string, unknown>;
85
+ const goals = asStringArray(normalizedBrief.goals);
86
+ const features = asStringArray(normalizedBrief.core_features);
87
+ const constraints = asStringArray(normalizedBrief.constraints);
88
+ return [...goals, ...features, ...constraints];
89
+ }
90
+
91
+ function coverageItems(
92
+ schemaHint: ProviderSchemaHint,
93
+ inputContext: Record<string, unknown>
94
+ ): Array<{ id: string; text: string }> {
95
+ const catalog = (inputContext.contractCatalog ?? {}) as Record<string, unknown>;
96
+ const values: Array<{ id: string; text: string }> = [];
97
+ if (schemaHint.requiredContracts.includes("goals")) values.push(...asContracts(catalog.goals));
98
+ if (schemaHint.requiredContracts.includes("core_features")) values.push(...asContracts(catalog.core_features));
99
+ if (schemaHint.requiredContracts.includes("constraints")) values.push(...asContracts(catalog.constraints));
100
+ return values;
101
+ }
102
+
103
+ function headingBlock(
104
+ heading: string,
105
+ items: string[],
106
+ fallbackText: string,
107
+ coverage: Array<{ id: string; text: string }>
108
+ ): string {
109
+ const selected = items.slice(0, 2);
110
+ const contractBullets = coverage.map((item) => `- [${item.id}] ${item.text}`);
111
+ const bullets = [...contractBullets, ...selected.map((item) => `- ${item}`)];
112
+ const finalBullets = bullets.length > 0 ? bullets.join("\n") : `- ${fallbackText}`;
113
+ return `${heading}\n${finalBullets}\n`;
114
+ }
115
+
116
+ function buildArtifactBody(schemaHint: ProviderSchemaHint, inputContext: Record<string, unknown>): string {
117
+ const normalizedBrief = (inputContext.normalizedBrief ?? {}) as Record<string, unknown>;
118
+ const productName =
119
+ typeof normalizedBrief.product_name === "string" ? normalizedBrief.product_name : "Product";
120
+ const lang = typeof inputContext.outputLanguage === "string" ? inputContext.outputLanguage.toLowerCase() : "en";
121
+ const items = normalizeSectionItems(inputContext);
122
+ const coverage = coverageItems(schemaHint, inputContext);
123
+ const fallback = lang === "tr" ? "Detay daha sonra netlestirilecek." : "To be refined.";
124
+ const sections = schemaHint.requiredHeadings.map((heading) => headingBlock(heading, items, fallback, coverage));
125
+ const title =
126
+ lang === "tr"
127
+ ? `# ${productName} icin ${schemaHint.artifactType.toUpperCase()}`
128
+ : `# ${schemaHint.artifactType.toUpperCase()} for ${productName}`;
129
+ if (schemaHint.artifactType === "workflow") {
130
+ return `${title}\n\n${sections.join("\n")}\n\n\`\`\`mermaid
131
+ flowchart TD
132
+ A[Start] --> B[[F1] User Action]
133
+ B --> C[System Step]
134
+ C --> D[Done]
135
+ \`\`\``.trim();
136
+ }
137
+ return `${title}\n\n${sections.join("\n")}\n\n${lang === "tr" ? "Not" : "Note"}: ${fallback}`.trim();
138
+ }
139
+
140
+ function semanticIssuesWithMock(inputContext: Record<string, unknown>): string {
141
+ const pair = inputContext.pair as Record<string, unknown>;
142
+ const leftBody = typeof pair?.left_body === "string" ? pair.left_body : "";
143
+ const rightBody = typeof pair?.right_body === "string" ? pair.right_body : "";
144
+ const leftFile = typeof pair?.left_file === "string" ? pair.left_file : "";
145
+ const rightFile = typeof pair?.right_file === "string" ? pair.right_file : "";
146
+ const issues: Array<Record<string, unknown>> = [];
147
+
148
+ const contradiction =
149
+ /guest checkout/i.test(leftBody) && /(auth required|requires auth|must login)/i.test(rightBody);
150
+ if (contradiction) {
151
+ issues.push({
152
+ level: "error",
153
+ code: "semantic_contradiction",
154
+ check: "semantic_consistency",
155
+ contract_id: "F1",
156
+ file: rightFile || leftFile,
157
+ message: "Workflow allows guest checkout but paired artifact requires authentication.",
158
+ suggestion: "Align auth behavior across both artifacts."
159
+ });
160
+ }
161
+
162
+ return JSON.stringify({ issues }, null, 2);
163
+ }
164
+
165
+ function contractRelevanceWithMock(inputContext: Record<string, unknown>): string {
166
+ const contractText = typeof inputContext.contract_text === "string" ? inputContext.contract_text.toLowerCase() : "";
167
+ const contextText = typeof inputContext.context_text === "string" ? inputContext.context_text.toLowerCase() : "";
168
+ const terms = contractText
169
+ .split(/\W+/)
170
+ .map((term) => term.trim())
171
+ .filter((term) => term.length > 3);
172
+ const overlap = terms.filter((term) => contextText.includes(term)).length;
173
+ const relevant = terms.length === 0 ? true : overlap / terms.length >= 0.25;
174
+ return JSON.stringify({ relevant, score: terms.length === 0 ? 1 : overlap / terms.length }, null, 2);
175
+ }
176
+
177
+ export class MockProvider implements LLMProvider {
178
+ async generate(
179
+ _prompt: string,
180
+ inputContext: Record<string, unknown>,
181
+ schemaHint: ProviderSchemaHint
182
+ ): Promise<{ body: string; frontmatter?: Record<string, unknown> }> {
183
+ if (schemaHint.artifactType === "normalize") return { body: normalizeWithMock(inputContext) };
184
+ if (schemaHint.artifactType === "semantic_consistency") return { body: semanticIssuesWithMock(inputContext) };
185
+ if (schemaHint.artifactType === "contract_relevance") return { body: contractRelevanceWithMock(inputContext) };
186
+ return { body: buildArtifactBody(schemaHint, inputContext) };
187
+ }
188
+ }
@@ -0,0 +1,87 @@
1
+ import type { LLMProvider, ProviderSchemaHint } from "../types";
2
+ import { UserError } from "../errors";
3
+
4
+ type ChatResponse = {
5
+ choices?: Array<{
6
+ message?: {
7
+ content?: string;
8
+ };
9
+ }>;
10
+ };
11
+
12
+ export class OpenAIProvider implements LLMProvider {
13
+ private readonly apiKey: string;
14
+ private readonly model: string;
15
+ private readonly baseUrl: string;
16
+
17
+ constructor() {
18
+ const key = process.env.OPENAI_API_KEY;
19
+ if (!key) {
20
+ throw new UserError("OPENAI_API_KEY missing. Set it or use PRODO_LLM_PROVIDER=mock.");
21
+ }
22
+ this.apiKey = key;
23
+ this.model = process.env.PRODO_OPENAI_MODEL ?? "gpt-4o-mini";
24
+ this.baseUrl = process.env.PRODO_OPENAI_BASE_URL ?? "https://api.openai.com/v1";
25
+ }
26
+
27
+ async generate(
28
+ prompt: string,
29
+ inputContext: Record<string, unknown>,
30
+ schemaHint: ProviderSchemaHint
31
+ ): Promise<{ body: string; frontmatter?: Record<string, unknown> }> {
32
+ const outputLanguage =
33
+ typeof inputContext.outputLanguage === "string" && inputContext.outputLanguage.trim()
34
+ ? inputContext.outputLanguage.trim()
35
+ : "en";
36
+ const mode = schemaHint.artifactType;
37
+ const system =
38
+ mode === "normalize"
39
+ ? `You normalize messy human product briefs into strict JSON.
40
+ Return valid JSON only, no markdown. Include confidence scores (0..1) for critical fields.`
41
+ : mode === "semantic_consistency"
42
+ ? `You detect semantic inconsistencies between paired artifacts.
43
+ Return valid JSON only: { "issues": [{level, code, check, contract_id, file, message, suggestion}] }.`
44
+ : mode === "contract_relevance"
45
+ ? `You verify whether tagged content actually matches the referenced contract text.
46
+ Return valid JSON only: { "relevant": boolean, "score": number, "reason": string }.`
47
+ : `You are a product-document generator.
48
+ Return only Markdown body content.
49
+ Headings required:
50
+ ${schemaHint.requiredHeadings.join("\n")}
51
+ Required contract tags:
52
+ ${schemaHint.requiredContracts.join(", ")}
53
+ Use tags like [G1], [F2], [C1] where relevant.
54
+ Output language: ${outputLanguage}
55
+ Do not translate required headings.`;
56
+ const user = `${prompt}\n\nContext JSON:\n${JSON.stringify(inputContext, null, 2)}`;
57
+
58
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ Authorization: `Bearer ${this.apiKey}`
63
+ },
64
+ body: JSON.stringify({
65
+ model: this.model,
66
+ messages: [
67
+ { role: "system", content: system },
68
+ { role: "user", content: user }
69
+ ],
70
+ temperature: 0.2
71
+ })
72
+ });
73
+
74
+ if (!response.ok) {
75
+ const text = await response.text();
76
+ throw new UserError(`OpenAI request failed (${response.status}): ${text}`);
77
+ }
78
+
79
+ const payload = (await response.json()) as ChatResponse;
80
+ const content = payload.choices?.[0]?.message?.content?.trim();
81
+ if (!content) {
82
+ throw new UserError("OpenAI provider returned an empty response.");
83
+ }
84
+
85
+ return { body: content };
86
+ }
87
+ }
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { registryPath } from "./paths";
5
+ import { ensureDir, fileExists } from "./utils";
6
+
7
+ export type OverrideRegistryEntry = {
8
+ artifact_type: string;
9
+ file: string;
10
+ sha256: string;
11
+ };
12
+
13
+ export type ProdoRegistry = {
14
+ schema_version: "1.0";
15
+ updated_at: string;
16
+ installed_presets: string[];
17
+ installed_overrides: OverrideRegistryEntry[];
18
+ };
19
+
20
+ const EMPTY_REGISTRY: ProdoRegistry = {
21
+ schema_version: "1.0",
22
+ updated_at: new Date(0).toISOString(),
23
+ installed_presets: [],
24
+ installed_overrides: []
25
+ };
26
+
27
+ async function sha256(filePath: string): Promise<string> {
28
+ const raw = await fs.readFile(filePath);
29
+ return createHash("sha256").update(raw).digest("hex");
30
+ }
31
+
32
+ function sanitizeRegistry(input: unknown): ProdoRegistry {
33
+ if (!input || typeof input !== "object") return { ...EMPTY_REGISTRY };
34
+ const raw = input as Record<string, unknown>;
35
+ const installedPresets = Array.isArray(raw.installed_presets)
36
+ ? raw.installed_presets
37
+ .filter((value): value is string => typeof value === "string")
38
+ .map((value) => value.trim())
39
+ .filter((value) => value.length > 0)
40
+ : [];
41
+ const installedOverrides = Array.isArray(raw.installed_overrides)
42
+ ? raw.installed_overrides
43
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
44
+ .map((item) => ({
45
+ artifact_type: typeof item.artifact_type === "string" ? item.artifact_type.trim() : "",
46
+ file: typeof item.file === "string" ? item.file.trim() : "",
47
+ sha256: typeof item.sha256 === "string" ? item.sha256.trim() : ""
48
+ }))
49
+ .filter((item) => item.artifact_type && item.file && item.sha256)
50
+ : [];
51
+ return {
52
+ schema_version: "1.0",
53
+ updated_at: typeof raw.updated_at === "string" && raw.updated_at.trim() ? raw.updated_at : EMPTY_REGISTRY.updated_at,
54
+ installed_presets: Array.from(new Set(installedPresets)),
55
+ installed_overrides: installedOverrides
56
+ };
57
+ }
58
+
59
+ export async function readRegistry(cwd: string): Promise<ProdoRegistry> {
60
+ const file = registryPath(cwd);
61
+ if (!(await fileExists(file))) return { ...EMPTY_REGISTRY };
62
+ try {
63
+ const parsed = JSON.parse(await fs.readFile(file, "utf8")) as unknown;
64
+ return sanitizeRegistry(parsed);
65
+ } catch {
66
+ return { ...EMPTY_REGISTRY };
67
+ }
68
+ }
69
+
70
+ async function readInstalledPresetsFromFile(cwd: string): Promise<string[]> {
71
+ const file = path.join(cwd, ".prodo", "presets", "installed.json");
72
+ if (!(await fileExists(file))) return [];
73
+ try {
74
+ const parsed = JSON.parse(await fs.readFile(file, "utf8")) as unknown;
75
+ if (!Array.isArray(parsed)) return [];
76
+ return parsed
77
+ .filter((value): value is string => typeof value === "string")
78
+ .map((value) => value.trim())
79
+ .filter((value) => value.length > 0);
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ async function discoverOverrides(cwd: string): Promise<OverrideRegistryEntry[]> {
86
+ const overridesDir = path.join(cwd, ".prodo", "templates", "overrides");
87
+ if (!(await fileExists(overridesDir))) return [];
88
+ const entries = await fs.readdir(overridesDir, { withFileTypes: true });
89
+ const out: OverrideRegistryEntry[] = [];
90
+ for (const entry of entries) {
91
+ if (!entry.isFile()) continue;
92
+ if (!entry.name.endsWith(".md")) continue;
93
+ const fullPath = path.join(overridesDir, entry.name);
94
+ out.push({
95
+ artifact_type: entry.name.replace(/\.md$/, ""),
96
+ file: fullPath,
97
+ sha256: await sha256(fullPath)
98
+ });
99
+ }
100
+ out.sort((a, b) => a.artifact_type.localeCompare(b.artifact_type));
101
+ return out;
102
+ }
103
+
104
+ export async function syncRegistry(cwd: string): Promise<ProdoRegistry> {
105
+ const existing = await readRegistry(cwd);
106
+ const discoveredPresets = await readInstalledPresetsFromFile(cwd);
107
+ const discoveredOverrides = await discoverOverrides(cwd);
108
+ const mergedPresets = Array.from(new Set([...existing.installed_presets, ...discoveredPresets])).sort();
109
+ const merged: ProdoRegistry = {
110
+ schema_version: "1.0",
111
+ updated_at: new Date().toISOString(),
112
+ installed_presets: mergedPresets,
113
+ installed_overrides: discoveredOverrides
114
+ };
115
+ const file = registryPath(cwd);
116
+ await ensureDir(path.dirname(file));
117
+ await fs.writeFile(file, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
118
+ return merged;
119
+ }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs/promises";
2
+ import { settingsPath } from "./paths";
3
+ import { fileExists } from "./utils";
4
+
5
+ export type ProdoSettings = {
6
+ lang: string;
7
+ ai?: string;
8
+ };
9
+
10
+ const DEFAULT_SETTINGS: ProdoSettings = {
11
+ lang: "en"
12
+ };
13
+
14
+ export async function readSettings(cwd: string): Promise<ProdoSettings> {
15
+ const path = settingsPath(cwd);
16
+ if (!(await fileExists(path))) return { ...DEFAULT_SETTINGS };
17
+ try {
18
+ const raw = await fs.readFile(path, "utf8");
19
+ const parsed = JSON.parse(raw) as Partial<ProdoSettings>;
20
+ return {
21
+ lang: typeof parsed.lang === "string" && parsed.lang.trim() ? parsed.lang.trim() : "en",
22
+ ai: typeof parsed.ai === "string" && parsed.ai.trim() ? parsed.ai.trim() : undefined
23
+ };
24
+ } catch {
25
+ return { ...DEFAULT_SETTINGS };
26
+ }
27
+ }
28
+
29
+ export async function writeSettings(cwd: string, settings: ProdoSettings): Promise<string> {
30
+ const path = settingsPath(cwd);
31
+ await fs.writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
32
+ return path;
33
+ }
34
+
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs/promises";
2
+ import {
3
+ overrideTemplateCandidatePaths,
4
+ templateCandidatePaths
5
+ } from "./paths";
6
+ import { extractRequiredHeadings } from "./markdown";
7
+ import type { ArtifactType } from "./types";
8
+ import { fileExists } from "./utils";
9
+
10
+ type ResolveOptions = {
11
+ cwd: string;
12
+ artifactType: ArtifactType;
13
+ };
14
+
15
+ export async function resolveTemplate(options: ResolveOptions): Promise<{ path: string; content: string } | null> {
16
+ const { cwd, artifactType } = options;
17
+ const candidates: string[] = [
18
+ ...overrideTemplateCandidatePaths(cwd, artifactType),
19
+ ...templateCandidatePaths(cwd, artifactType)
20
+ ];
21
+
22
+ for (const filePath of candidates) {
23
+ if (await fileExists(filePath)) {
24
+ const content = await fs.readFile(filePath, "utf8");
25
+ return { path: filePath, content };
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function extractRequiredHeadingsFromTemplate(content: string): string[] {
32
+ return extractRequiredHeadings(content);
33
+ }