@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,764 @@
1
+ import { password, select } from "@inquirer/prompts";
2
+ import { resolve, relative } from "node:path";
3
+ import { mergeNotionConfig, readConfig, writeConfig, } from "../lib/config.js";
4
+ import { pathExists, readText, writeText } from "../lib/filesystem.js";
5
+ import { askInput, askYesNo } from "../lib/inquirer-helpers.js";
6
+ import { bold, cyan, dim, green, log, red, yellow } from "../lib/logger.js";
7
+ import { createNotionClient, notionApiError, } from "../lib/notion-client.js";
8
+ import { buildChoiceLabel, discoverInlineDatabasesFromPage, discoverNotionDatabases, NOTION_DISCOVERY_MAX, NOTION_PAGE_SCAN_MAX_BLOCKS, sortForKind, } from "../lib/notion-discovery.js";
9
+ import { inspectEnvFile, loadNotionEnv, maskToken, writeNotionTokenToEnvFile, } from "../lib/notion-env.js";
10
+ import { PROJECTS_DB_PROPERTIES, TASKS_DB_PROPERTIES, } from "../lib/notion-schema.js";
11
+ import { resolveNotionDataSourceTarget } from "../lib/notion-target.js";
12
+ import { projectPaths } from "../lib/paths.js";
13
+ const MANUAL_VALUE = "__manual__";
14
+ const SKIP_VALUE = "__skip__";
15
+ const NOTION_TOKEN_LINE = "NOTION_TOKEN=";
16
+ function relDisplay(root, abs) {
17
+ const r = relative(root, abs);
18
+ return r.length === 0 ? "." : r;
19
+ }
20
+ function renderRequiredProps(label, props) {
21
+ log.info(bold(label));
22
+ for (const p of props) {
23
+ const types = p.allowedTypes.join(" | ");
24
+ log.info(` · ${p.name} ${dim(`(${types})`)} ${dim("— " + p.description)}`);
25
+ }
26
+ }
27
+ export async function notionInitCommand(options = {}) {
28
+ const cwd = resolve(options.cwd ?? process.cwd());
29
+ const paths = projectPaths(cwd);
30
+ const dryRun = options.dryRun === true;
31
+ const explicitlyNonInteractive = options.nonInteractive === true;
32
+ const isTty = process.stdin.isTTY === true;
33
+ const interactive = !dryRun && !explicitlyNonInteractive && isTty;
34
+ log.info(bold("vibeops notion init"));
35
+ log.info(` ${dim("cwd")} ${cwd}`);
36
+ log.info(` ${dim("mode")} ${dryRun
37
+ ? "dry-run (no file writes)"
38
+ : interactive
39
+ ? "interactive (arrow keys + Enter, no y/n typing)"
40
+ : "non-interactive (flags only)"}`);
41
+ log.blank();
42
+ if (!(await pathExists(paths.config))) {
43
+ log.error(`.vibeops.json is missing. Run ${cyan("vibeops init")} first to install the VibeOps workflow files.`);
44
+ log.info(dim(` expected at: ${relDisplay(cwd, paths.config)}`));
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ const config = await readConfig(paths.root);
49
+ if (config === null) {
50
+ log.error(`Failed to read .vibeops.json (schema mismatch or invalid JSON). Inspect the file, or re-run ${cyan("vibeops init")} to recreate it.`);
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ const currentNotion = config.notion ?? {
55
+ enabled: false,
56
+ projectsTargetId: "",
57
+ tasksTargetId: "",
58
+ projectsDatabaseId: "",
59
+ tasksDatabaseId: "",
60
+ };
61
+ // Resolve patch values from the priority chain:
62
+ // explicit CLI flags → interactive answers → current config value
63
+ const patch = {};
64
+ let enabled = currentNotion.enabled;
65
+ if (options.enable === true) {
66
+ patch.enabled = true;
67
+ enabled = true;
68
+ }
69
+ let projectsDb = currentNotion.projectsDatabaseId;
70
+ let projectsTarget = currentNotion.projectsTargetId;
71
+ if (typeof options.projectsDb === "string" && options.projectsDb.length > 0) {
72
+ projectsDb = options.projectsDb.trim();
73
+ patch.projectsDatabaseId = projectsDb;
74
+ }
75
+ let tasksDb = currentNotion.tasksDatabaseId;
76
+ let tasksTarget = currentNotion.tasksTargetId;
77
+ if (typeof options.tasksDb === "string" && options.tasksDb.length > 0) {
78
+ tasksDb = options.tasksDb.trim();
79
+ patch.tasksDatabaseId = tasksDb;
80
+ }
81
+ let tokenToWrite = null;
82
+ let willOverwriteToken = false;
83
+ let envSnapshotKnown = false;
84
+ let envHadToken = false;
85
+ if (interactive) {
86
+ // ── Q1. Use Notion dashboard sync? ─────────────────────────────────────
87
+ if (options.enable !== true) {
88
+ enabled = await askYesNo({
89
+ message: "Use Notion dashboard sync? (Notion as a human dashboard — Git docs/tasks remains the source of truth)",
90
+ nonInteractive: false,
91
+ defaultValue: currentNotion.enabled,
92
+ });
93
+ patch.enabled = enabled;
94
+ }
95
+ else {
96
+ log.info(`${dim("·")} --enable provided → notion.enabled = true (skip question)`);
97
+ }
98
+ if (enabled) {
99
+ // ── Q2. Paste NOTION_TOKEN now? (moved up — token gates DB search) ──
100
+ log.blank();
101
+ log.info(dim(" NOTION_TOKEN is a Notion integration secret. VibeOps never prints the token value and only writes it to .vibeops.env (gitignored)."));
102
+ const envSnap = await inspectEnvFile(cwd);
103
+ envSnapshotKnown = true;
104
+ envHadToken =
105
+ envSnap.exists && envSnap.currentToken !== null && envSnap.currentToken.length > 0;
106
+ const pasteNow = await askYesNo({
107
+ message: envHadToken
108
+ ? "Paste NOTION_TOKEN now? (.vibeops.env already has a token — Yes will ask whether to overwrite next)"
109
+ : "Paste NOTION_TOKEN now? (Yes → save into .vibeops.env · No → edit the file manually later)",
110
+ nonInteractive: false,
111
+ defaultValue: false,
112
+ });
113
+ if (pasteNow) {
114
+ let go = true;
115
+ if (envHadToken) {
116
+ // ── Q3. Overwrite or update existing NOTION_TOKEN? ───────────────
117
+ const overwrite = await askYesNo({
118
+ message: `Overwrite existing NOTION_TOKEN? (current: ${maskToken(envSnap.currentToken)})`,
119
+ nonInteractive: false,
120
+ defaultValue: false,
121
+ });
122
+ willOverwriteToken = overwrite;
123
+ go = overwrite;
124
+ }
125
+ if (go) {
126
+ const entered = await password({
127
+ message: "Enter NOTION_TOKEN (input is hidden, starts with 'secret_…' or 'ntn_…')",
128
+ mask: "*",
129
+ validate: (v) => v.trim().length === 0
130
+ ? "Empty. Copy the secret from Notion → Settings → Integrations."
131
+ : true,
132
+ });
133
+ tokenToWrite = entered.trim();
134
+ }
135
+ }
136
+ log.blank();
137
+ // ── Search-driven DB selection ───────────────────────────────────────
138
+ //
139
+ // We resolve the "effective token" for THIS run (newly typed, or
140
+ // already present in .vibeops.env / process.env) so the user can use
141
+ // notion init purely to pick DBs without re-entering a token.
142
+ //
143
+ // We DO NOT prompt to search if the user already gave us both DBs via
144
+ // CLI flags — those wins are preserved.
145
+ const needProjects = projectsTarget.length === 0 &&
146
+ (typeof options.projectsDb !== "string" || options.projectsDb.length === 0) &&
147
+ patch.projectsTargetId === undefined &&
148
+ patch.projectsDatabaseId === undefined;
149
+ const needTasks = tasksTarget.length === 0 &&
150
+ (typeof options.tasksDb !== "string" || options.tasksDb.length === 0) &&
151
+ patch.tasksTargetId === undefined &&
152
+ patch.tasksDatabaseId === undefined;
153
+ const effectiveToken = await resolveEffectiveToken(cwd, tokenToWrite);
154
+ if ((needProjects || needTasks) && effectiveToken !== null) {
155
+ const wantSearch = await askYesNo({
156
+ message: "Search accessible Notion databases now? (Yes → call /v1/search and pick from the list · No → enter the 32-char id manually)",
157
+ nonInteractive: false,
158
+ defaultValue: true,
159
+ });
160
+ if (wantSearch) {
161
+ const picks = await pickDatabasesViaSearch({
162
+ token: effectiveToken,
163
+ needProjects,
164
+ needTasks,
165
+ currentProjects: projectsTarget.length > 0 ? projectsTarget : projectsDb,
166
+ currentTasks: tasksTarget.length > 0 ? tasksTarget : tasksDb,
167
+ });
168
+ if (picks.projectsTarget !== null) {
169
+ projectsTarget = picks.projectsTarget;
170
+ patch.projectsTargetId = projectsTarget;
171
+ }
172
+ if (picks.tasksTarget !== null) {
173
+ tasksTarget = picks.tasksTarget;
174
+ patch.tasksTargetId = tasksTarget;
175
+ }
176
+ if (picks.projectsDatabase !== null) {
177
+ projectsDb = picks.projectsDatabase;
178
+ patch.projectsDatabaseId = projectsDb;
179
+ }
180
+ if (picks.tasksDatabase !== null) {
181
+ tasksDb = picks.tasksDatabase;
182
+ patch.tasksDatabaseId = tasksDb;
183
+ }
184
+ }
185
+ }
186
+ else if ((needProjects || needTasks) && effectiveToken === null) {
187
+ log.info(dim(" No Notion token available — skipping DB search. Enter the 32-char id manually (or leave empty to fill in later)."));
188
+ }
189
+ // ── Manual fallback for any DB id still empty ────────────────────────
190
+ if (needProjects &&
191
+ (patch.projectsTargetId ?? projectsTarget).length === 0 &&
192
+ (patch.projectsDatabaseId ?? projectsDb).length === 0) {
193
+ const ans = await askInput({
194
+ message: "Projects data source ID (fallback: paste a data_source id, leave empty to fill later)",
195
+ nonInteractive: false,
196
+ default: projectsTarget.length > 0 ? projectsTarget : undefined,
197
+ });
198
+ if (ans.length > 0) {
199
+ projectsTarget = ans;
200
+ patch.projectsTargetId = projectsTarget;
201
+ }
202
+ }
203
+ if (needTasks &&
204
+ (patch.tasksTargetId ?? tasksTarget).length === 0 &&
205
+ (patch.tasksDatabaseId ?? tasksDb).length === 0) {
206
+ const ans = await askInput({
207
+ message: "Tasks data source ID (fallback: paste a data_source id, leave empty to fill later)",
208
+ nonInteractive: false,
209
+ default: tasksTarget.length > 0 ? tasksTarget : undefined,
210
+ });
211
+ if (ans.length > 0) {
212
+ tasksTarget = ans;
213
+ patch.tasksTargetId = tasksTarget;
214
+ }
215
+ }
216
+ // ── Q4. Continue without database IDs? (only if both empty) ──────────
217
+ if (projectsTarget.length === 0 &&
218
+ projectsDb.length === 0 &&
219
+ tasksTarget.length === 0 &&
220
+ tasksDb.length === 0) {
221
+ const proceed = await askYesNo({
222
+ message: "Continue without database IDs? (No → cancel, create the DBs, and re-run. Yes → enable now and fill in the IDs later)",
223
+ nonInteractive: false,
224
+ defaultValue: false,
225
+ });
226
+ if (!proceed) {
227
+ log.blank();
228
+ log.info(`${yellow("!")} Cancelled. Create the Projects/Tasks DBs in Notion, copy the 32-char ids, then re-run ${cyan("vibeops notion init")} or ${cyan("vibeops notion init --projects-db <id> --tasks-db <id>")}.`);
229
+ process.exitCode = 0;
230
+ return;
231
+ }
232
+ }
233
+ }
234
+ else {
235
+ log.info(dim(" notion.enabled = false — skipping DB id and NOTION_TOKEN prompts."));
236
+ }
237
+ log.blank();
238
+ }
239
+ const { merged, changed: notionChanged } = mergeNotionConfig(config, patch);
240
+ log.info(bold("Plan: .vibeops.json"));
241
+ if (config.notion === undefined) {
242
+ log.info(` ${green("+")} add ${cyan("notion")} section (enabled=${merged.notion.enabled})`);
243
+ }
244
+ diffNotionSection(config.notion, merged.notion);
245
+ log.blank();
246
+ log.info(bold("Plan: .vibeops.env.example"));
247
+ const envExampleAbs = paths.envExample;
248
+ const existingExample = (await pathExists(envExampleAbs))
249
+ ? await readText(envExampleAbs)
250
+ : null;
251
+ const exampleNeedsToken = !hasLine(existingExample, NOTION_TOKEN_LINE);
252
+ const nextExample = ensureEnvLine(existingExample, NOTION_TOKEN_LINE);
253
+ if (existingExample === null) {
254
+ log.info(` ${green("+")} create ${cyan(relDisplay(cwd, envExampleAbs))} with ${cyan(NOTION_TOKEN_LINE)}`);
255
+ }
256
+ else if (exampleNeedsToken) {
257
+ log.info(` ${green("+")} append ${cyan(NOTION_TOKEN_LINE)} to ${cyan(relDisplay(cwd, envExampleAbs))}`);
258
+ }
259
+ else {
260
+ log.info(` ${dim("·")} ${cyan(NOTION_TOKEN_LINE)} already present in ${cyan(relDisplay(cwd, envExampleAbs))}`);
261
+ }
262
+ log.blank();
263
+ if (interactive && tokenToWrite !== null) {
264
+ log.info(bold("Plan: .vibeops.env (local secret · NEVER COMMITTED)"));
265
+ if (envHadToken && willOverwriteToken) {
266
+ log.info(` ${green("~")} overwrite ${cyan("NOTION_TOKEN=")} (${maskToken(tokenToWrite)})`);
267
+ }
268
+ else if (envSnapshotKnown && (await pathExists(paths.envExample)) === false) {
269
+ log.info(` ${green("+")} create .vibeops.env with ${cyan("NOTION_TOKEN=")} (${maskToken(tokenToWrite)})`);
270
+ }
271
+ else {
272
+ log.info(` ${green("+")} write ${cyan("NOTION_TOKEN=")} line (${maskToken(tokenToWrite)})`);
273
+ }
274
+ log.blank();
275
+ }
276
+ log.info(bold("Required Notion DB schema (create manually in Notion)"));
277
+ log.info(dim(" VibeOps never creates Notion databases. Create the properties below by hand, then share the DBs with the integration."));
278
+ log.blank();
279
+ renderRequiredProps("Projects DB", PROJECTS_DB_PROPERTIES);
280
+ log.blank();
281
+ renderRequiredProps("Tasks DB", TASKS_DB_PROPERTIES);
282
+ log.blank();
283
+ log.info(bold("Security"));
284
+ log.info(` ${dim("·")} The raw ${cyan("NOTION_TOKEN")} value is never printed to stdout (interactive input is password-masked).`);
285
+ log.info(` ${dim("·")} ${cyan(".vibeops.env")} is gitignored — never commit it.`);
286
+ log.info(` ${dim("·")} ${cyan(".vibeops.env")} is only created when you answer ${cyan("Paste NOTION_TOKEN now? Yes")} in the interactive flow.`);
287
+ log.blank();
288
+ if (dryRun) {
289
+ log.info(dim("dry-run — no files were written."));
290
+ log.blank();
291
+ log.info(bold("Next steps"));
292
+ log.info(` 1) Create the Projects / Tasks DBs in Notion and share them with the integration.`);
293
+ log.info(` 2) Re-run ${cyan("vibeops notion init")} interactively, or supply ${cyan("--enable --projects-db <id> --tasks-db <id>")} in one line.`);
294
+ log.info(` 3) Validate with ${cyan("vibeops notion test")}.`);
295
+ return;
296
+ }
297
+ if (notionChanged) {
298
+ await writeConfig(paths.root, merged);
299
+ log.ok(`updated ${relDisplay(cwd, paths.config)}`);
300
+ }
301
+ else {
302
+ log.info(dim(`unchanged ${relDisplay(cwd, paths.config)}`));
303
+ }
304
+ if (existingExample === null || exampleNeedsToken) {
305
+ await writeText(envExampleAbs, nextExample);
306
+ log.ok(`updated ${relDisplay(cwd, envExampleAbs)}`);
307
+ }
308
+ else {
309
+ log.info(dim(`unchanged ${relDisplay(cwd, envExampleAbs)}`));
310
+ }
311
+ if (interactive && tokenToWrite !== null) {
312
+ const res = await writeNotionTokenToEnvFile(cwd, tokenToWrite);
313
+ if (res.created) {
314
+ log.ok(`created .vibeops.env ${dim("(NOTION_TOKEN saved · masked)")}`);
315
+ }
316
+ else if (res.replaced) {
317
+ log.ok(`updated .vibeops.env ${dim("(NOTION_TOKEN replaced · masked)")}`);
318
+ }
319
+ else {
320
+ log.ok(`appended .vibeops.env ${dim("(NOTION_TOKEN added · masked)")}`);
321
+ }
322
+ log.info(dim(" The token value is never displayed on stdout."));
323
+ }
324
+ log.blank();
325
+ log.info(bold("Next steps"));
326
+ log.info(` 1) Create the Projects / Tasks DB properties listed above in Notion.`);
327
+ log.info(` 2) Share the DBs with the integration via the page ⋯ menu → ${cyan("Connections")}.`);
328
+ if (!interactive || tokenToWrite === null) {
329
+ log.info(` 3) Create a local ${cyan(".vibeops.env")} with ${cyan("NOTION_TOKEN=secret_…")}.`);
330
+ log.info(` ${dim(".vibeops.env is gitignored — never commit it.")}`);
331
+ }
332
+ else {
333
+ log.info(` 3) ${dim(".vibeops.env is gitignored — never commit it.")}`);
334
+ }
335
+ if (!merged.notion.enabled) {
336
+ log.info(` 4) When ready, enable with ${cyan("vibeops notion init --enable")} and validate with ${cyan("vibeops notion test")}.`);
337
+ }
338
+ else {
339
+ log.info(` 4) Validate with ${cyan("vibeops notion test")}.`);
340
+ }
341
+ if (merged.notion.enabled &&
342
+ (effectiveProjectsTarget(merged.notion).length === 0 ||
343
+ effectiveTasksTarget(merged.notion).length === 0)) {
344
+ log.blank();
345
+ log.info(`${yellow("!")} notion.enabled = true but ${effectiveProjectsTarget(merged.notion).length === 0 ? "projectsTargetId/projectsDatabaseId " : ""}${effectiveTasksTarget(merged.notion).length === 0 ? "tasksTargetId/tasksDatabaseId " : ""}is empty. Fill it in with ${cyan("vibeops notion init --projects-db <id> --tasks-db <id>")}.`);
346
+ }
347
+ }
348
+ function effectiveProjectsTarget(notion) {
349
+ return notion.projectsTargetId.length > 0
350
+ ? notion.projectsTargetId
351
+ : notion.projectsDatabaseId;
352
+ }
353
+ function effectiveTasksTarget(notion) {
354
+ return notion.tasksTargetId.length > 0 ? notion.tasksTargetId : notion.tasksDatabaseId;
355
+ }
356
+ /**
357
+ * Pick the auth token to use for THIS interactive run, without writing it.
358
+ *
359
+ * 1. If the user just typed a new one in `Paste NOTION_TOKEN now?`, use it.
360
+ * 2. Otherwise load whatever is currently in `.vibeops.env` /
361
+ * `process.env.NOTION_TOKEN` so the user can re-run `notion init` purely
362
+ * to re-pick DBs.
363
+ *
364
+ * Returns `null` when no token can be resolved — callers MUST fall back to
365
+ * manual id input in that case.
366
+ */
367
+ async function resolveEffectiveToken(cwd, freshlyTyped) {
368
+ if (typeof freshlyTyped === "string" && freshlyTyped.length > 0)
369
+ return freshlyTyped;
370
+ const env = await loadNotionEnv(cwd);
371
+ return env.token;
372
+ }
373
+ async function pickDatabasesViaSearch(inputs) {
374
+ log.info(dim(" → Calling Notion /v1/search (read-only, 5s timeout, page_size ≤ 50)…"));
375
+ let client;
376
+ try {
377
+ client = await createNotionClient(inputs.token);
378
+ }
379
+ catch (err) {
380
+ const apiErr = sanitiseApiError(err);
381
+ log.warn(`Failed to load @notionhq/client — ${apiErr.message}`);
382
+ log.info(dim(" Skipping search. Enter the 32-char id manually (or fill it in later with `vibeops notion init --projects-db <id> --tasks-db <id>`)."));
383
+ return {
384
+ projectsTarget: null,
385
+ tasksTarget: null,
386
+ projectsDatabase: null,
387
+ tasksDatabase: null,
388
+ };
389
+ }
390
+ let dataSources = [];
391
+ let pages = [];
392
+ let dataSourcesTruncated = false;
393
+ let pagesTruncated = false;
394
+ try {
395
+ const combined = await discoverNotionDatabases(client);
396
+ dataSources = combined.dataSources;
397
+ pages = combined.pages;
398
+ dataSourcesTruncated = combined.dataSourcesTruncated;
399
+ pagesTruncated = combined.pagesTruncated;
400
+ if (combined.dataSourceErrored) {
401
+ log.warn("Notion rejected the \"data_source\" object filter — continuing in compatibility mode.");
402
+ log.info(dim(" (Internal: current Notion API expects search filter \"data_source\"; the @notionhq/client SDK may be outdated.)"));
403
+ }
404
+ for (const w of combined.warnings) {
405
+ log.info(dim(` · ${w}`));
406
+ }
407
+ }
408
+ catch (err) {
409
+ const apiErr = sanitiseApiError(err);
410
+ log.warn(`Notion search failed — ${explainSearchError(apiErr)}`);
411
+ log.info(dim(" Skipping search. Enter the 32-char id manually (or re-run later)."));
412
+ return {
413
+ projectsTarget: null,
414
+ tasksTarget: null,
415
+ projectsDatabase: null,
416
+ tasksDatabase: null,
417
+ };
418
+ }
419
+ // Candidate pool used by both the Projects-DB and Tasks-DB pickers.
420
+ let candidates = dataSources;
421
+ if (dataSources.length > 0) {
422
+ log.info(dim(` · ${dataSources.length} database${dataSources.length === 1 ? "" : "s"} accessible to this integration${dataSourcesTruncated ? ` (capped at ${NOTION_DISCOVERY_MAX} — Notion has more)` : ""}`));
423
+ }
424
+ else {
425
+ // No data sources surfaced. Show the corrected guidance (this is the
426
+ // common case for inline DBs that live inside a shared parent page).
427
+ log.info(yellow(" VibeOps can access pages, but no data sources were returned by Notion search.\n" +
428
+ " If your databases are inline, select the parent page so VibeOps can scan its child blocks.\n" +
429
+ " If they still do not appear, open each database as a page and add the VibeOps integration directly."));
430
+ if (pages.length === 0) {
431
+ log.info(dim(" · No accessible pages either — falling back to manual 32-char id input."));
432
+ return {
433
+ projectsTarget: null,
434
+ tasksTarget: null,
435
+ projectsDatabase: null,
436
+ tasksDatabase: null,
437
+ };
438
+ }
439
+ log.info(dim(` · ${pages.length} page${pages.length === 1 ? "" : "s"} accessible — pick a parent page to scan its 1-depth blocks (cap ${NOTION_PAGE_SCAN_MAX_BLOCKS} blocks)${pagesTruncated ? ` (capped at ${NOTION_DISCOVERY_MAX} pages — Notion has more)` : ""}`));
440
+ const inlineCandidates = await pickPageAndScanForInlineDatabases({
441
+ client,
442
+ pages,
443
+ });
444
+ if (inlineCandidates.length === 0) {
445
+ log.info(dim(" · No inline databases found in the selected page — falling back to manual 32-char id input."));
446
+ return {
447
+ projectsTarget: null,
448
+ tasksTarget: null,
449
+ projectsDatabase: null,
450
+ tasksDatabase: null,
451
+ };
452
+ }
453
+ candidates = inlineCandidates;
454
+ log.info(dim(` · Found ${inlineCandidates.length} inline database candidate${inlineCandidates.length === 1 ? "" : "s"}.`));
455
+ }
456
+ const picks = {
457
+ projectsTarget: null,
458
+ tasksTarget: null,
459
+ projectsDatabase: null,
460
+ tasksDatabase: null,
461
+ };
462
+ if (inputs.needProjects) {
463
+ const pick = await pickOneDatabase({
464
+ kind: "projects",
465
+ databases: candidates,
466
+ current: inputs.currentProjects,
467
+ client,
468
+ });
469
+ if (pick !== null) {
470
+ picks.projectsTarget = pick.targetId;
471
+ if (pick.databaseId !== null)
472
+ picks.projectsDatabase = pick.databaseId;
473
+ }
474
+ }
475
+ else {
476
+ log.info(dim(" · Projects DB is already configured — skipping selection."));
477
+ }
478
+ if (inputs.needTasks) {
479
+ const pick = await pickOneDatabase({
480
+ kind: "tasks",
481
+ databases: candidates,
482
+ current: inputs.currentTasks,
483
+ client,
484
+ });
485
+ if (pick !== null) {
486
+ picks.tasksTarget = pick.targetId;
487
+ if (pick.databaseId !== null)
488
+ picks.tasksDatabase = pick.databaseId;
489
+ }
490
+ }
491
+ else {
492
+ log.info(dim(" · Tasks DB is already configured — skipping selection."));
493
+ }
494
+ return picks;
495
+ }
496
+ const SCAN_SKIP_VALUE = "__skip_scan__";
497
+ /**
498
+ * Show a select prompt of accessible pages, scan the one the user picks for
499
+ * inline database / data_source child blocks, and normalize the results into
500
+ * `NotionDatabaseChoice[]` so the rest of the picker can reuse them.
501
+ *
502
+ * Returns `[]` if the user chooses to skip or no inline databases are found.
503
+ */
504
+ async function pickPageAndScanForInlineDatabases(inputs) {
505
+ const choices = inputs.pages.map((p) => ({
506
+ name: `${p.title} (${maskId(p.id)})`,
507
+ value: p.id,
508
+ }));
509
+ choices.push({
510
+ name: "Skip page scan — fall back to manual 32-char id input",
511
+ value: SCAN_SKIP_VALUE,
512
+ });
513
+ const picked = await select({
514
+ message: "Select a page to scan for inline databases",
515
+ choices,
516
+ default: inputs.pages[0]?.id ?? SCAN_SKIP_VALUE,
517
+ loop: false,
518
+ pageSize: 10,
519
+ });
520
+ if (picked === SCAN_SKIP_VALUE) {
521
+ log.info(dim(" · page scan skip"));
522
+ return [];
523
+ }
524
+ const page = inputs.pages.find((p) => p.id === picked);
525
+ const parentTitle = page?.title;
526
+ log.info(dim(` → blocks.children.list(${maskId(picked)}) — 1-depth scan (cap ${NOTION_PAGE_SCAN_MAX_BLOCKS} blocks, read-only)…`));
527
+ try {
528
+ const inline = await discoverInlineDatabasesFromPage(inputs.client, picked, parentTitle);
529
+ return inline;
530
+ }
531
+ catch (err) {
532
+ const apiErr = sanitiseApiError(err);
533
+ log.warn(`Page scan failed — ${explainSearchError(apiErr)}`);
534
+ return [];
535
+ }
536
+ }
537
+ async function pickOneDatabase(inputs) {
538
+ const { ordered, recommendedIds } = sortForKind(inputs.kind, inputs.databases);
539
+ const recommendedSet = new Set(recommendedIds);
540
+ const label = inputs.kind === "projects" ? "Projects DB" : "Tasks DB";
541
+ const choices = ordered.map((c) => ({
542
+ name: buildChoiceLabel({
543
+ kind: inputs.kind,
544
+ database: c,
545
+ isRecommended: recommendedSet.has(c.id),
546
+ }),
547
+ value: c.id,
548
+ }));
549
+ choices.push({ name: "Enter data source ID manually…", value: MANUAL_VALUE });
550
+ choices.push({ name: "Skip for now (use existing value or leave empty)", value: SKIP_VALUE });
551
+ const defaultValue = inputs.current.length > 0 && ordered.some((c) => c.id === inputs.current)
552
+ ? inputs.current
553
+ : recommendedIds[0] ?? ordered[0]?.id ?? MANUAL_VALUE;
554
+ const picked = await select({
555
+ message: `Select ${label} (arrow keys + Enter — recommended: ${recommendedIds.length})`,
556
+ choices,
557
+ default: defaultValue,
558
+ loop: false,
559
+ pageSize: 10,
560
+ });
561
+ if (picked === SKIP_VALUE) {
562
+ log.info(dim(` · ${label} skipped — keeping existing value (${inputs.current.length > 0 ? maskId(inputs.current) : "(empty)"})`));
563
+ return null;
564
+ }
565
+ if (picked === MANUAL_VALUE) {
566
+ const ans = await askInput({
567
+ message: `${label} data source ID (last fallback; Notion data_source id)`,
568
+ nonInteractive: false,
569
+ default: inputs.current.length > 0 ? inputs.current : undefined,
570
+ });
571
+ if (ans.length === 0) {
572
+ log.info(dim(` · ${label} empty input — keeping existing value`));
573
+ return null;
574
+ }
575
+ await softValidateSchema(inputs.client, ans, inputs.kind);
576
+ return { targetId: ans, databaseId: null };
577
+ }
578
+ const chosen = ordered.find((c) => c.id === picked) ?? null;
579
+ if (chosen !== null) {
580
+ const matched = inputs.kind === "projects" ? chosen.projectsScore : chosen.tasksScore;
581
+ renderImmediateSchemaCheck(inputs.kind, chosen, matched);
582
+ return {
583
+ targetId: chosen.dataSourceId ?? chosen.id,
584
+ databaseId: chosen.databaseId ?? null,
585
+ };
586
+ }
587
+ return { targetId: picked, databaseId: null };
588
+ }
589
+ function maskId(id) {
590
+ if (id.length <= 12)
591
+ return id;
592
+ return `${id.slice(0, 8)}…${id.slice(-4)}`;
593
+ }
594
+ function renderImmediateSchemaCheck(kind, db, score) {
595
+ if (score.total === 0) {
596
+ log.info(dim(` · No properties in the search response to verify ${kind} schema — re-validate with 'notion test'.`));
597
+ return;
598
+ }
599
+ if (score.matched === score.total) {
600
+ log.info(` ${green("✓")} ${kind} schema OK (${score.matched}/${score.total} matched)`);
601
+ return;
602
+ }
603
+ const required = kind === "projects" ? PROJECTS_DB_PROPERTIES : TASKS_DB_PROPERTIES;
604
+ const props = db.properties ?? {};
605
+ const missing = [];
606
+ const mismatched = [];
607
+ for (const req of required) {
608
+ const p = props[req.name];
609
+ if (p === undefined || p === null) {
610
+ missing.push(req.name);
611
+ continue;
612
+ }
613
+ if (!req.allowedTypes.includes((p.type ?? ""))) {
614
+ mismatched.push(`${req.name} (${p.type ?? "?"} ≠ ${req.allowedTypes.join("|")})`);
615
+ }
616
+ }
617
+ log.warn(`${kind} schema partial (${score.matched}/${score.total} matched, ${score.missing} missing, ${score.typeMismatch} mismatch) — 'notion test' will validate strictly. Saving anyway.`);
618
+ if (missing.length > 0) {
619
+ log.info(` ${dim("missing:")} ${missing.map((s) => red(s)).join(", ")}`);
620
+ }
621
+ if (mismatched.length > 0) {
622
+ log.info(` ${dim("type mismatch:")} ${mismatched.map((s) => red(s)).join(", ")}`);
623
+ }
624
+ }
625
+ /**
626
+ * Soft schema validation for the *manual* path — we don't have properties
627
+ * from search there, so we route through `resolveNotionDataSourceTarget`
628
+ * which knows how to follow `database → data_source` in the current Notion
629
+ * API. Failure is logged as a warning; init still saves the id so the user
630
+ * can fix Notion later.
631
+ */
632
+ async function softValidateSchema(client, id, kind) {
633
+ const resolved = await resolveNotionDataSourceTarget(client, id, kind);
634
+ if (!resolved.ok) {
635
+ log.warn(`${kind} DB inline validation failed — ${resolved.message}`);
636
+ log.info(dim(" Inspect with 'notion test'."));
637
+ return;
638
+ }
639
+ if (resolved.source === "database-default-data-source") {
640
+ log.info(dim(` resolved data_source id=${maskId(resolved.resolvedId)} (parent database=${maskId(resolved.parentDatabaseId ?? id)})`));
641
+ }
642
+ const required = kind === "projects" ? PROJECTS_DB_PROPERTIES : TASKS_DB_PROPERTIES;
643
+ let matched = 0;
644
+ const missing = [];
645
+ const mismatched = [];
646
+ for (const req of required) {
647
+ const p = resolved.properties[req.name];
648
+ if (p === undefined || p === null) {
649
+ missing.push(req.name);
650
+ continue;
651
+ }
652
+ if (!req.allowedTypes.includes((p.type ?? ""))) {
653
+ mismatched.push(`${req.name} (${p.type ?? "?"} ≠ ${req.allowedTypes.join("|")})`);
654
+ continue;
655
+ }
656
+ matched++;
657
+ }
658
+ if (matched === required.length) {
659
+ log.info(` ${green("✓")} ${kind} schema OK (${matched}/${required.length} matched)`);
660
+ return;
661
+ }
662
+ log.warn(`${kind} schema partial (${matched}/${required.length} matched, ${missing.length} missing, ${mismatched.length} mismatch) — validate precisely with 'notion test'.`);
663
+ if (missing.length > 0) {
664
+ log.info(` ${dim("missing:")} ${missing.map((s) => red(s)).join(", ")}`);
665
+ }
666
+ if (mismatched.length > 0) {
667
+ log.info(` ${dim("type mismatch:")} ${mismatched.map((s) => red(s)).join(", ")}`);
668
+ }
669
+ }
670
+ /**
671
+ * Wrap `notionApiError` to scrub any raw token text that might appear in the
672
+ * underlying error message (e.g. when Notion echoes the integration secret
673
+ * in a debug field). Defence-in-depth.
674
+ */
675
+ function sanitiseApiError(err) {
676
+ const apiErr = notionApiError(err);
677
+ const msg = apiErr.message ?? "";
678
+ const sanitised = msg
679
+ .replace(/(secret_[A-Za-z0-9]{20,}|ntn_[A-Za-z0-9_-]{20,})/g, "secret_***")
680
+ .replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, "$1***");
681
+ return { ...apiErr, message: sanitised };
682
+ }
683
+ function explainSearchError(err) {
684
+ const tail = err.status ? ` (HTTP ${err.status})` : "";
685
+ switch (err.code) {
686
+ case "unauthorized":
687
+ return `NOTION_TOKEN was rejected. Verify the integration secret.${tail}`;
688
+ case "restricted_resource":
689
+ return `The Notion DB is not shared with the integration — add it via DB → Connections.${tail}`;
690
+ case "object_not_found":
691
+ return `Notion resource not found. Verify the 32-char id.${tail}`;
692
+ case "validation_error": {
693
+ const msg = err.message ?? "";
694
+ if (/body\.filter\.value/i.test(msg) ||
695
+ /data_source/i.test(msg)) {
696
+ return (`Request rejected (validation_error)${tail}. ` +
697
+ "The current Notion API only accepts \"data_source\" as the search object filter. " +
698
+ "If VibeOps already sends that filter and Notion still rejects it, the @notionhq/client SDK may be outdated. " +
699
+ "Workaround: enter the DB id (32-char hex) manually to get the same behavior.");
700
+ }
701
+ return `Request rejected (validation_error): ${msg}${tail}`;
702
+ }
703
+ case "rate_limited":
704
+ return `Notion API rate limit — retry shortly.${tail}`;
705
+ case "request_timeout":
706
+ case "notionhq_client_request_timeout":
707
+ case "ETIMEDOUT":
708
+ return `Notion API 5s timeout. Check your network.${tail}`;
709
+ default:
710
+ return `${err.code}: ${err.message}${tail}`;
711
+ }
712
+ }
713
+ function diffNotionSection(prev, next) {
714
+ const prevVals = {
715
+ enabled: prev?.enabled ?? false,
716
+ projectsTargetId: prev?.projectsTargetId ?? "",
717
+ tasksTargetId: prev?.tasksTargetId ?? "",
718
+ projectsDatabaseId: prev?.projectsDatabaseId ?? "",
719
+ tasksDatabaseId: prev?.tasksDatabaseId ?? "",
720
+ };
721
+ const fields = [
722
+ "enabled",
723
+ "projectsTargetId",
724
+ "tasksTargetId",
725
+ "projectsDatabaseId",
726
+ "tasksDatabaseId",
727
+ ];
728
+ for (const f of fields) {
729
+ const before = prevVals[f];
730
+ const after = next[f];
731
+ if (before === after) {
732
+ log.info(` ${dim("·")} ${f} ${dim(`= ${display(after)}`)}`);
733
+ }
734
+ else {
735
+ log.info(` ${green("~")} ${f} ${dim(`${display(before)}`)} → ${cyan(display(after))}`);
736
+ }
737
+ }
738
+ }
739
+ function display(v) {
740
+ if (typeof v === "boolean")
741
+ return v ? "true" : "false";
742
+ if (v.length === 0)
743
+ return '""';
744
+ return v;
745
+ }
746
+ function hasLine(text, keyEq) {
747
+ if (text === null)
748
+ return false;
749
+ return text.split(/\r?\n/).some((l) => l.trimStart().startsWith(keyEq));
750
+ }
751
+ function ensureEnvLine(text, keyEq) {
752
+ if (text === null) {
753
+ return `# VibeOps · environment example
754
+ # Copy this file to .vibeops.env and fill in the values.
755
+ # Never commit .vibeops.env — it is added to .gitignore by \`vibeops init\`.
756
+
757
+ ${keyEq}
758
+ `;
759
+ }
760
+ if (hasLine(text, keyEq))
761
+ return text;
762
+ const needsTrailingNewline = !text.endsWith("\n");
763
+ return `${text}${needsTrailingNewline ? "\n" : ""}${keyEq}\n`;
764
+ }