@goodtek/vibeops 0.2.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 (93) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/agent/loader.js +71 -0
  5. package/dist/agent/prompt.js +66 -0
  6. package/dist/bootstrap/installer.js +149 -0
  7. package/dist/bootstrap/manifest.js +15 -0
  8. package/dist/bootstrap/substitute.js +35 -0
  9. package/dist/cli.js +241 -0
  10. package/dist/commands/agent-list.js +32 -0
  11. package/dist/commands/agent-prompt.js +59 -0
  12. package/dist/commands/agent-show.js +26 -0
  13. package/dist/commands/github-init.js +554 -0
  14. package/dist/commands/github-status.js +164 -0
  15. package/dist/commands/init.js +179 -0
  16. package/dist/commands/notion-init.js +764 -0
  17. package/dist/commands/notion-sync.js +405 -0
  18. package/dist/commands/notion-test.js +595 -0
  19. package/dist/commands/plan.js +114 -0
  20. package/dist/commands/status.js +17 -0
  21. package/dist/commands/task-check.js +155 -0
  22. package/dist/commands/task-done.js +98 -0
  23. package/dist/commands/task-generate.js +206 -0
  24. package/dist/commands/task-pull.js +277 -0
  25. package/dist/commands/task-rollback.js +174 -0
  26. package/dist/commands/task-start.js +90 -0
  27. package/dist/lib/brief.js +349 -0
  28. package/dist/lib/config.js +158 -0
  29. package/dist/lib/filesystem.js +67 -0
  30. package/dist/lib/git.js +237 -0
  31. package/dist/lib/github-cli.js +247 -0
  32. package/dist/lib/inquirer-helpers.js +111 -0
  33. package/dist/lib/logger.js +42 -0
  34. package/dist/lib/notion-client.js +459 -0
  35. package/dist/lib/notion-discovery.js +671 -0
  36. package/dist/lib/notion-env.js +140 -0
  37. package/dist/lib/notion-mappers.js +148 -0
  38. package/dist/lib/notion-schema.js +272 -0
  39. package/dist/lib/notion-sync.js +337 -0
  40. package/dist/lib/notion-target.js +247 -0
  41. package/dist/lib/package-json.js +133 -0
  42. package/dist/lib/paths.js +26 -0
  43. package/dist/lib/project-docs.js +95 -0
  44. package/dist/lib/prompt-builder.js +125 -0
  45. package/dist/lib/task-generator.js +183 -0
  46. package/dist/lib/task-prompt.js +23 -0
  47. package/dist/lib/task-pull.js +354 -0
  48. package/dist/lib/task-scaffold.js +128 -0
  49. package/dist/lib/task-summary.js +276 -0
  50. package/dist/lib/task.js +364 -0
  51. package/dist/status/collector.js +103 -0
  52. package/dist/status/format.js +177 -0
  53. package/dist/types/brief.js +126 -0
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/task.js +1 -0
  56. package/dist/version.js +8 -0
  57. package/package.json +61 -0
  58. package/templates/.cursor/rules/00-project-governance.mdc +28 -0
  59. package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
  60. package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
  61. package/templates/.cursor/rules/03-git-safety.mdc +30 -0
  62. package/templates/.cursor/rules/04-docs-update.mdc +22 -0
  63. package/templates/.vibeops/agents/architect.md +47 -0
  64. package/templates/.vibeops/agents/builder.md +38 -0
  65. package/templates/.vibeops/agents/docs.md +54 -0
  66. package/templates/.vibeops/agents/orchestrator.md +40 -0
  67. package/templates/.vibeops/agents/planner.md +60 -0
  68. package/templates/.vibeops/agents/recovery.md +49 -0
  69. package/templates/.vibeops/agents/reviewer.md +47 -0
  70. package/templates/.vibeops/agents/tester.md +43 -0
  71. package/templates/.vibeops/prompts/create-plan.md +33 -0
  72. package/templates/.vibeops/prompts/generate-tasks.md +41 -0
  73. package/templates/.vibeops/prompts/implement-task.md +39 -0
  74. package/templates/.vibeops/prompts/review-task.md +34 -0
  75. package/templates/.vibeops/prompts/rollback.md +32 -0
  76. package/templates/.vibeops/prompts/start-project.md +39 -0
  77. package/templates/.vibeops/workflows/notion-sync.md +53 -0
  78. package/templates/.vibeops/workflows/project-start.md +73 -0
  79. package/templates/.vibeops/workflows/rollback.md +45 -0
  80. package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
  81. package/templates/AGENTS.md +98 -0
  82. package/templates/docs/logs/README.md +38 -0
  83. package/templates/docs/project/00-overview.md +27 -0
  84. package/templates/docs/project/01-requirements.md +30 -0
  85. package/templates/docs/project/02-mvp-scope.md +36 -0
  86. package/templates/docs/project/03-architecture.md +34 -0
  87. package/templates/docs/project/04-tech-stack.md +29 -0
  88. package/templates/docs/project/05-current-state.md +35 -0
  89. package/templates/docs/project/06-decisions.md +20 -0
  90. package/templates/docs/project/07-backlog.md +23 -0
  91. package/templates/docs/project/08-env.md +29 -0
  92. package/templates/docs/project/09-deployment.md +28 -0
  93. package/templates/docs/tasks/TASK-000-template.md +72 -0
@@ -0,0 +1,349 @@
1
+ import { basename } from "node:path";
2
+ import { askCheckbox, askConfirm, askInput, askSelect, } from "./inquirer-helpers.js";
3
+ import { VERSION } from "../version.js";
4
+ import { AGENT_WORKFLOW_CHOICES, AUTH_REQUIREMENT_CHOICES, BACKEND_CHOICES, DATABASE_CHOICES, DB_LAYER_CHOICES, DEPLOYMENT_TARGET_CHOICES, FRONTEND_CHOICES, INTEGRATION_CHOICES, MVP_FEATURE_CHOICES, OUT_OF_SCOPE_CHOICES, PACKAGE_MANAGER_CHOICES, PROJECT_BRIEF_SCHEMA_VERSION, PROJECT_TYPE_CHOICES, RISK_AREA_CHOICES, TARGET_USER_CHOICES, } from "../types/brief.js";
5
+ const PLACEHOLDER_PROJECT_NAME = "Unnamed Project";
6
+ const PLACEHOLDER_IDEA = "(no idea provided — Planner Agent must fill this in)";
7
+ const PLACEHOLDER_PROBLEM = "(no core problem provided — Planner Agent must fill this in)";
8
+ const PLACEHOLDER_SUCCESS = "(no success criteria provided — Planner Agent must fill this in)";
9
+ export function parseIdea(idea) {
10
+ if (typeof idea !== "string")
11
+ return {};
12
+ const trimmed = idea.trim();
13
+ if (trimmed.length === 0)
14
+ return {};
15
+ const colonIdx = trimmed.indexOf(":");
16
+ if (colonIdx > 0 && colonIdx < 40) {
17
+ const head = trimmed.slice(0, colonIdx).trim();
18
+ const tail = trimmed.slice(colonIdx + 1).trim();
19
+ if (head.length > 0 && !head.includes(" ") && tail.length > 0) {
20
+ return { projectName: head, oneLineIdea: tail };
21
+ }
22
+ }
23
+ return { oneLineIdea: trimmed };
24
+ }
25
+ function deriveProjectTypeDefault(idea) {
26
+ if (typeof idea === "string" && /browser/i.test(idea)) {
27
+ return "Browser Automation";
28
+ }
29
+ return "SaaS";
30
+ }
31
+ export async function gatherBrief(inputs) {
32
+ const assumptions = [];
33
+ const seed = inputs.seed ?? {};
34
+ const ideaParsed = parseIdea(inputs.idea);
35
+ const defaultName = seed.projectName ?? ideaParsed.projectName ?? basename(inputs.cwd) ?? PLACEHOLDER_PROJECT_NAME;
36
+ const projectName = await askInput({
37
+ message: "1/20 · Project name",
38
+ nonInteractive: inputs.nonInteractive,
39
+ default: defaultName,
40
+ required: !inputs.nonInteractive,
41
+ fallback: PLACEHOLDER_PROJECT_NAME,
42
+ });
43
+ const finalProjectName = projectName.length > 0 ? projectName : PLACEHOLDER_PROJECT_NAME;
44
+ if (finalProjectName === PLACEHOLDER_PROJECT_NAME) {
45
+ assumptions.push('projectName: directory name was empty too — defaulted to "Unnamed Project"');
46
+ }
47
+ const oneLineIdea = await askInput({
48
+ message: "2/20 · One-line idea",
49
+ nonInteractive: inputs.nonInteractive,
50
+ default: seed.oneLineIdea ?? ideaParsed.oneLineIdea,
51
+ required: !inputs.nonInteractive,
52
+ fallback: PLACEHOLDER_IDEA,
53
+ });
54
+ const finalIdea = oneLineIdea.length > 0 ? oneLineIdea : PLACEHOLDER_IDEA;
55
+ if (finalIdea === PLACEHOLDER_IDEA) {
56
+ assumptions.push("oneLineIdea: not provided — placeholder for Planner Agent to fill");
57
+ }
58
+ const projectType = await askSelect({
59
+ message: "3/20 · Project type",
60
+ nonInteractive: inputs.nonInteractive,
61
+ choices: PROJECT_TYPE_CHOICES,
62
+ default: seed.projectType ?? deriveProjectTypeDefault(inputs.idea ?? seed.oneLineIdea),
63
+ });
64
+ const targetUsers = await askCheckbox({
65
+ message: "4/20 · Target users (Space to toggle, Enter to confirm)",
66
+ nonInteractive: inputs.nonInteractive,
67
+ choices: TARGET_USER_CHOICES,
68
+ default: seed.targetUsers,
69
+ });
70
+ const coreProblem = await askInput({
71
+ message: "5/20 · Core problem",
72
+ nonInteractive: inputs.nonInteractive,
73
+ default: seed.coreProblem,
74
+ fallback: PLACEHOLDER_PROBLEM,
75
+ });
76
+ const finalCoreProblem = coreProblem.length > 0 ? coreProblem : PLACEHOLDER_PROBLEM;
77
+ if (finalCoreProblem === PLACEHOLDER_PROBLEM) {
78
+ assumptions.push("coreProblem: not provided — placeholder for Planner Agent to fill");
79
+ }
80
+ const mvpFeatures = await askCheckbox({
81
+ message: "6/20 · MVP must-have features",
82
+ nonInteractive: inputs.nonInteractive,
83
+ choices: MVP_FEATURE_CHOICES,
84
+ default: seed.mvpFeatures,
85
+ });
86
+ const outOfScope = await askCheckbox({
87
+ message: "7/20 · Out of scope for MVP",
88
+ nonInteractive: inputs.nonInteractive,
89
+ choices: OUT_OF_SCOPE_CHOICES,
90
+ default: seed.outOfScope,
91
+ });
92
+ const frontend = await askSelect({
93
+ message: "8/20 · Preferred frontend",
94
+ nonInteractive: inputs.nonInteractive,
95
+ choices: FRONTEND_CHOICES,
96
+ default: seed.frontend ?? "Next.js",
97
+ });
98
+ const backend = await askSelect({
99
+ message: "9/20 · Preferred backend",
100
+ nonInteractive: inputs.nonInteractive,
101
+ choices: BACKEND_CHOICES,
102
+ default: seed.backend ?? "NestJS",
103
+ });
104
+ const database = await askSelect({
105
+ message: "10/20 · Database",
106
+ nonInteractive: inputs.nonInteractive,
107
+ choices: DATABASE_CHOICES,
108
+ default: seed.database ?? "PostgreSQL",
109
+ });
110
+ const dbLayer = await askSelect({
111
+ message: "11/20 · ORM / DB layer",
112
+ nonInteractive: inputs.nonInteractive,
113
+ choices: DB_LAYER_CHOICES,
114
+ default: seed.dbLayer ?? "Drizzle",
115
+ });
116
+ const packageManager = await askSelect({
117
+ message: "12/20 · Package manager",
118
+ nonInteractive: inputs.nonInteractive,
119
+ choices: PACKAGE_MANAGER_CHOICES,
120
+ default: seed.packageManager ?? "pnpm",
121
+ });
122
+ const deploymentTargets = await askCheckbox({
123
+ message: "13/20 · Deployment target",
124
+ nonInteractive: inputs.nonInteractive,
125
+ choices: DEPLOYMENT_TARGET_CHOICES,
126
+ default: seed.deploymentTargets,
127
+ });
128
+ const authRequirements = await askCheckbox({
129
+ message: "14/20 · Auth requirement",
130
+ nonInteractive: inputs.nonInteractive,
131
+ choices: AUTH_REQUIREMENT_CHOICES,
132
+ default: seed.authRequirements,
133
+ });
134
+ const integrations = await askCheckbox({
135
+ message: "15/20 · External integrations",
136
+ nonInteractive: inputs.nonInteractive,
137
+ choices: INTEGRATION_CHOICES,
138
+ default: seed.integrations,
139
+ });
140
+ const useNotion = await askConfirm({
141
+ message: "16/20 · Use Notion dashboard sync?",
142
+ nonInteractive: inputs.nonInteractive,
143
+ default: seed.useNotion ?? true,
144
+ });
145
+ const useGitWorkflow = await askConfirm({
146
+ message: "17/20 · Use Git task branch workflow?",
147
+ nonInteractive: inputs.nonInteractive,
148
+ default: seed.useGitWorkflow ?? true,
149
+ });
150
+ const agentWorkflowLevel = await askSelect({
151
+ message: "18/20 · Agent workflow level",
152
+ nonInteractive: inputs.nonInteractive,
153
+ choices: AGENT_WORKFLOW_CHOICES,
154
+ default: seed.agentWorkflowLevel ??
155
+ "Advanced: Orchestrator + Planner + Architect + Builder + Tester + Reviewer + Docs + Recovery",
156
+ });
157
+ const risks = await askCheckbox({
158
+ message: "19/20 · Risk areas",
159
+ nonInteractive: inputs.nonInteractive,
160
+ choices: RISK_AREA_CHOICES,
161
+ default: seed.risks,
162
+ });
163
+ const successCriteria = await askInput({
164
+ message: "20/20 · Success criteria",
165
+ nonInteractive: inputs.nonInteractive,
166
+ default: seed.successCriteria,
167
+ fallback: PLACEHOLDER_SUCCESS,
168
+ });
169
+ const finalSuccess = successCriteria.length > 0 ? successCriteria : PLACEHOLDER_SUCCESS;
170
+ if (finalSuccess === PLACEHOLDER_SUCCESS) {
171
+ assumptions.push("successCriteria: not provided — placeholder for Planner Agent to fill");
172
+ }
173
+ const brief = {
174
+ projectName: finalProjectName,
175
+ oneLineIdea: finalIdea,
176
+ projectType,
177
+ targetUsers,
178
+ coreProblem: finalCoreProblem,
179
+ mvpFeatures,
180
+ outOfScope,
181
+ frontend,
182
+ backend,
183
+ database,
184
+ dbLayer,
185
+ packageManager,
186
+ deploymentTargets,
187
+ authRequirements,
188
+ integrations,
189
+ useNotion,
190
+ useGitWorkflow,
191
+ agentWorkflowLevel,
192
+ risks,
193
+ successCriteria: finalSuccess,
194
+ };
195
+ const meta = {
196
+ vibeopsVersion: VERSION,
197
+ generatedAt: new Date().toISOString(),
198
+ source: inputs.nonInteractive ? "non-interactive" : "interactive",
199
+ schemaVersion: PROJECT_BRIEF_SCHEMA_VERSION,
200
+ assumptions,
201
+ };
202
+ return { brief, meta };
203
+ }
204
+ const SECTIONS = [
205
+ { num: 1, title: "Project name", field: "projectName" },
206
+ { num: 2, title: "One-line idea", field: "oneLineIdea" },
207
+ { num: 3, title: "Project type", field: "projectType" },
208
+ { num: 4, title: "Target users", field: "targetUsers" },
209
+ { num: 5, title: "Core problem", field: "coreProblem" },
210
+ { num: 6, title: "MVP must-have features", field: "mvpFeatures" },
211
+ { num: 7, title: "Out of scope for MVP", field: "outOfScope" },
212
+ { num: 8, title: "Preferred frontend", field: "frontend" },
213
+ { num: 9, title: "Preferred backend", field: "backend" },
214
+ { num: 10, title: "Database", field: "database" },
215
+ { num: 11, title: "ORM / DB layer", field: "dbLayer" },
216
+ { num: 12, title: "Package manager", field: "packageManager" },
217
+ { num: 13, title: "Deployment target", field: "deploymentTargets" },
218
+ { num: 14, title: "Auth requirement", field: "authRequirements" },
219
+ { num: 15, title: "External integrations", field: "integrations" },
220
+ { num: 16, title: "Use Notion dashboard sync?", field: "useNotion" },
221
+ { num: 17, title: "Use Git task branch workflow?", field: "useGitWorkflow" },
222
+ { num: 18, title: "Agent workflow level", field: "agentWorkflowLevel" },
223
+ { num: 19, title: "Risk areas", field: "risks" },
224
+ { num: 20, title: "Success criteria", field: "successCriteria" },
225
+ ];
226
+ function renderList(values) {
227
+ if (values.length === 0)
228
+ return "_(none)_";
229
+ return values.map((v) => `- ${v}`).join("\n");
230
+ }
231
+ function renderScalar(value) {
232
+ return value.length > 0 ? value : "_(empty)_";
233
+ }
234
+ function renderBool(value) {
235
+ return value ? "yes" : "no";
236
+ }
237
+ export function briefToMarkdown(brief, meta) {
238
+ const lines = [];
239
+ lines.push(`# Project Brief — ${brief.projectName}`);
240
+ lines.push("");
241
+ lines.push(`> Generated: ${meta.generatedAt} · VibeOps ${meta.vibeopsVersion} · Source: ${meta.source} · schemaVersion: ${meta.schemaVersion}`);
242
+ lines.push("");
243
+ lines.push("This brief is the input for the Cursor **Planner Agent**. VibeOps does not call LLMs directly.");
244
+ lines.push("The Planner Agent reads this brief, fills in `docs/project/*`, and creates the initial backlog.");
245
+ lines.push("");
246
+ for (const sec of SECTIONS) {
247
+ lines.push(`## ${sec.num}. ${sec.title}`);
248
+ lines.push("");
249
+ const value = brief[sec.field];
250
+ if (typeof value === "boolean") {
251
+ lines.push(renderBool(value));
252
+ }
253
+ else if (Array.isArray(value)) {
254
+ lines.push(renderList(value));
255
+ }
256
+ else {
257
+ lines.push(renderScalar(value));
258
+ }
259
+ lines.push("");
260
+ }
261
+ lines.push("## Assumptions");
262
+ lines.push("");
263
+ if (meta.assumptions.length === 0) {
264
+ lines.push("_(none)_");
265
+ }
266
+ else {
267
+ for (const a of meta.assumptions)
268
+ lines.push(`- ${a}`);
269
+ }
270
+ lines.push("");
271
+ return lines.join("\n");
272
+ }
273
+ function extractSection(md, num) {
274
+ const re = new RegExp(String.raw `^##\s+${num}\.\s+[^\n]*\n([\s\S]*?)(?=^##\s+|\Z)`, "m");
275
+ const match = md.match(re);
276
+ return match ? (match[1] ?? "").trim() : "";
277
+ }
278
+ function parseList(text) {
279
+ if (text.length === 0 || text === "_(none)_")
280
+ return [];
281
+ const items = [];
282
+ for (const raw of text.split("\n")) {
283
+ const line = raw.trim();
284
+ if (line.startsWith("- ")) {
285
+ const v = line.slice(2).trim();
286
+ if (v.length > 0)
287
+ items.push(v);
288
+ }
289
+ }
290
+ return items;
291
+ }
292
+ function parseScalar(text) {
293
+ if (text === "_(empty)_")
294
+ return "";
295
+ return text.trim();
296
+ }
297
+ function parseBool(text) {
298
+ const t = text.trim().toLowerCase();
299
+ return t === "yes" || t === "true" || t === "y";
300
+ }
301
+ export function parseBriefFromMarkdown(md) {
302
+ const brief = {
303
+ projectName: parseScalar(extractSection(md, 1)),
304
+ oneLineIdea: parseScalar(extractSection(md, 2)),
305
+ projectType: parseScalar(extractSection(md, 3)),
306
+ targetUsers: parseList(extractSection(md, 4)),
307
+ coreProblem: parseScalar(extractSection(md, 5)),
308
+ mvpFeatures: parseList(extractSection(md, 6)),
309
+ outOfScope: parseList(extractSection(md, 7)),
310
+ frontend: parseScalar(extractSection(md, 8)),
311
+ backend: parseScalar(extractSection(md, 9)),
312
+ database: parseScalar(extractSection(md, 10)),
313
+ dbLayer: parseScalar(extractSection(md, 11)),
314
+ packageManager: parseScalar(extractSection(md, 12)),
315
+ deploymentTargets: parseList(extractSection(md, 13)),
316
+ authRequirements: parseList(extractSection(md, 14)),
317
+ integrations: parseList(extractSection(md, 15)),
318
+ useNotion: parseBool(extractSection(md, 16)),
319
+ useGitWorkflow: parseBool(extractSection(md, 17)),
320
+ agentWorkflowLevel: parseScalar(extractSection(md, 18)),
321
+ risks: parseList(extractSection(md, 19)),
322
+ successCriteria: parseScalar(extractSection(md, 20)),
323
+ };
324
+ const assumptionMatch = md.match(/^##\s+Assumptions\s*\n([\s\S]*?)(?=^##\s+|\Z)/m);
325
+ const assumptions = assumptionMatch && assumptionMatch[1] ? parseList(assumptionMatch[1].trim()) : [];
326
+ const headerMatch = md.match(/^>\s+Generated:\s+([^\s]+)\s+·\s+VibeOps\s+([^\s]+)\s+·\s+Source:\s+([^\s·]+)/m);
327
+ const generatedAt = headerMatch?.[1] ?? new Date().toISOString();
328
+ const recordedVersion = headerMatch?.[2] ?? VERSION;
329
+ const recordedSource = headerMatch?.[3] ?? "from-file";
330
+ const meta = {
331
+ vibeopsVersion: recordedVersion,
332
+ generatedAt,
333
+ source: recordedSource === "from-file" ? "from-file" : recordedSource,
334
+ schemaVersion: PROJECT_BRIEF_SCHEMA_VERSION,
335
+ assumptions,
336
+ };
337
+ return { brief, meta };
338
+ }
339
+ export function findMissingRequired(brief) {
340
+ const missing = [];
341
+ if (brief.projectName.length === 0 || brief.projectName === PLACEHOLDER_PROJECT_NAME) {
342
+ missing.push("projectName");
343
+ }
344
+ if (brief.oneLineIdea.length === 0 ||
345
+ brief.oneLineIdea === PLACEHOLDER_IDEA) {
346
+ missing.push("oneLineIdea");
347
+ }
348
+ return missing;
349
+ }
@@ -0,0 +1,158 @@
1
+ import { join } from "node:path";
2
+ import { readTextOrNull, writeText } from "./filesystem.js";
3
+ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
+ import { VERSION } from "../version.js";
5
+ import { DEFAULT_GITHUB_CONFIG, VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
6
+ function parseNotionSection(raw) {
7
+ if (raw === null || typeof raw !== "object")
8
+ return undefined;
9
+ const r = raw;
10
+ const out = {
11
+ enabled: typeof r.enabled === "boolean" ? r.enabled : false,
12
+ projectsTargetId: typeof r.projectsTargetId === "string" ? r.projectsTargetId : "",
13
+ tasksTargetId: typeof r.tasksTargetId === "string" ? r.tasksTargetId : "",
14
+ projectsDatabaseId: typeof r.projectsDatabaseId === "string" ? r.projectsDatabaseId : "",
15
+ tasksDatabaseId: typeof r.tasksDatabaseId === "string" ? r.tasksDatabaseId : "",
16
+ };
17
+ return out;
18
+ }
19
+ function parseGithubVisibility(raw) {
20
+ if (raw === "public" || raw === "private")
21
+ return raw;
22
+ return "";
23
+ }
24
+ function parseGithubSection(raw) {
25
+ if (raw === null || typeof raw !== "object")
26
+ return undefined;
27
+ const r = raw;
28
+ return {
29
+ enabled: typeof r.enabled === "boolean" ? r.enabled : false,
30
+ mode: r.mode === "gh-cli" ? "gh-cli" : "gh-cli",
31
+ owner: typeof r.owner === "string" ? r.owner : "",
32
+ repo: typeof r.repo === "string" ? r.repo : "",
33
+ remote: typeof r.remote === "string" && r.remote.length > 0 ? r.remote : "origin",
34
+ visibility: parseGithubVisibility(r.visibility),
35
+ url: typeof r.url === "string" ? r.url : "",
36
+ };
37
+ }
38
+ export async function readConfig(root) {
39
+ const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
40
+ if (text === null)
41
+ return null;
42
+ try {
43
+ const parsed = JSON.parse(text);
44
+ if (typeof parsed.name === "string" &&
45
+ typeof parsed.vibeopsVersion === "string" &&
46
+ typeof parsed.createdAt === "string" &&
47
+ parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
48
+ const notion = parseNotionSection(parsed.notion);
49
+ const github = parseGithubSection(parsed.github);
50
+ const config = {
51
+ name: parsed.name,
52
+ vibeopsVersion: parsed.vibeopsVersion,
53
+ schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
54
+ createdAt: parsed.createdAt,
55
+ };
56
+ if (notion)
57
+ config.notion = notion;
58
+ if (github)
59
+ config.github = github;
60
+ return config;
61
+ }
62
+ return null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ export function buildConfig(name, nowIso) {
69
+ return {
70
+ name,
71
+ vibeopsVersion: VERSION,
72
+ schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
73
+ createdAt: nowIso ?? new Date().toISOString(),
74
+ };
75
+ }
76
+ export function configToJson(config) {
77
+ return `${JSON.stringify(config, null, 2)}\n`;
78
+ }
79
+ export async function writeConfig(root, config) {
80
+ await writeText(join(root, VIBEOPS_CONFIG_FILE), configToJson(config));
81
+ }
82
+ /** Merge a partial notion section into an existing config without touching other fields. */
83
+ export function mergeNotionConfig(base, patch) {
84
+ const current = base.notion ?? {
85
+ enabled: false,
86
+ projectsTargetId: "",
87
+ tasksTargetId: "",
88
+ projectsDatabaseId: "",
89
+ tasksDatabaseId: "",
90
+ };
91
+ const next = {
92
+ enabled: patch.enabled ?? current.enabled,
93
+ projectsTargetId: patch.projectsTargetId !== undefined && patch.projectsTargetId.length > 0
94
+ ? patch.projectsTargetId
95
+ : current.projectsTargetId,
96
+ tasksTargetId: patch.tasksTargetId !== undefined && patch.tasksTargetId.length > 0
97
+ ? patch.tasksTargetId
98
+ : current.tasksTargetId,
99
+ projectsDatabaseId: patch.projectsDatabaseId !== undefined && patch.projectsDatabaseId.length > 0
100
+ ? patch.projectsDatabaseId
101
+ : current.projectsDatabaseId,
102
+ tasksDatabaseId: patch.tasksDatabaseId !== undefined && patch.tasksDatabaseId.length > 0
103
+ ? patch.tasksDatabaseId
104
+ : current.tasksDatabaseId,
105
+ };
106
+ const changed = base.notion === undefined ||
107
+ next.enabled !== current.enabled ||
108
+ next.projectsTargetId !== current.projectsTargetId ||
109
+ next.tasksTargetId !== current.tasksTargetId ||
110
+ next.projectsDatabaseId !== current.projectsDatabaseId ||
111
+ next.tasksDatabaseId !== current.tasksDatabaseId;
112
+ return { merged: { ...base, notion: next }, changed };
113
+ }
114
+ /** Preferred Projects target id for API calls: data_source target first, legacy DB fallback. */
115
+ export function notionProjectsTargetId(notion) {
116
+ return notion.projectsTargetId.length > 0
117
+ ? notion.projectsTargetId
118
+ : notion.projectsDatabaseId;
119
+ }
120
+ /** Preferred Tasks target id for API calls: data_source target first, legacy DB fallback. */
121
+ export function notionTasksTargetId(notion) {
122
+ return notion.tasksTargetId.length > 0 ? notion.tasksTargetId : notion.tasksDatabaseId;
123
+ }
124
+ /**
125
+ * Merge a partial github section into an existing config without touching
126
+ * other fields (notion, name, etc.). Empty strings in the patch are treated
127
+ * as "no value provided" — the existing value is kept. Pass through
128
+ * `enabled` / `mode` always (booleans / enums always overwrite).
129
+ */
130
+ export function mergeGithubConfig(base, patch) {
131
+ const current = base.github ?? DEFAULT_GITHUB_CONFIG;
132
+ const next = {
133
+ enabled: patch.enabled ?? current.enabled,
134
+ mode: patch.mode ?? current.mode,
135
+ owner: patch.owner !== undefined && patch.owner.length > 0
136
+ ? patch.owner
137
+ : current.owner,
138
+ repo: patch.repo !== undefined && patch.repo.length > 0
139
+ ? patch.repo
140
+ : current.repo,
141
+ remote: patch.remote !== undefined && patch.remote.length > 0
142
+ ? patch.remote
143
+ : current.remote,
144
+ visibility: patch.visibility !== undefined && patch.visibility.length > 0
145
+ ? patch.visibility
146
+ : current.visibility,
147
+ url: patch.url !== undefined && patch.url.length > 0 ? patch.url : current.url,
148
+ };
149
+ const changed = base.github === undefined ||
150
+ next.enabled !== current.enabled ||
151
+ next.mode !== current.mode ||
152
+ next.owner !== current.owner ||
153
+ next.repo !== current.repo ||
154
+ next.remote !== current.remote ||
155
+ next.visibility !== current.visibility ||
156
+ next.url !== current.url;
157
+ return { merged: { ...base, github: next }, changed };
158
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, relative } from "node:path";
3
+ export async function pathExists(path) {
4
+ try {
5
+ await stat(path);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export async function isFile(path) {
13
+ try {
14
+ const s = await stat(path);
15
+ return s.isFile();
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export async function isDirectory(path) {
22
+ try {
23
+ const s = await stat(path);
24
+ return s.isDirectory();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ export async function readText(path) {
31
+ return readFile(path, "utf-8");
32
+ }
33
+ export async function readTextOrNull(path) {
34
+ try {
35
+ return await readFile(path, "utf-8");
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ export async function ensureDir(path) {
42
+ await mkdir(path, { recursive: true });
43
+ }
44
+ export async function writeText(path, contents) {
45
+ await ensureDir(dirname(path));
46
+ await writeFile(path, contents, "utf-8");
47
+ }
48
+ export async function walk(root) {
49
+ const out = [];
50
+ async function visit(dir) {
51
+ const entries = await readdir(dir, { withFileTypes: true });
52
+ for (const entry of entries) {
53
+ const abs = `${dir}/${entry.name}`;
54
+ if (entry.isDirectory()) {
55
+ await visit(abs);
56
+ }
57
+ else if (entry.isFile()) {
58
+ out.push(abs);
59
+ }
60
+ }
61
+ }
62
+ await visit(root);
63
+ return out;
64
+ }
65
+ export function relativeFrom(root, target) {
66
+ return relative(root, target);
67
+ }