@vendoai/vendo 0.4.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 (212) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +28 -0
  3. package/bin/vendo.mjs +4 -0
  4. package/dist/ai-sdk.d.ts +35 -0
  5. package/dist/ai-sdk.js +62 -0
  6. package/dist/auth-presets/auth-js.d.ts +14 -0
  7. package/dist/auth-presets/auth-js.js +91 -0
  8. package/dist/auth-presets/auth0.d.ts +23 -0
  9. package/dist/auth-presets/auth0.js +98 -0
  10. package/dist/auth-presets/clerk.d.ts +23 -0
  11. package/dist/auth-presets/clerk.js +65 -0
  12. package/dist/auth-presets/conformance.d.ts +42 -0
  13. package/dist/auth-presets/conformance.js +139 -0
  14. package/dist/auth-presets/identity.d.ts +56 -0
  15. package/dist/auth-presets/identity.js +152 -0
  16. package/dist/auth-presets/index.d.ts +14 -0
  17. package/dist/auth-presets/index.js +12 -0
  18. package/dist/auth-presets/jwt.d.ts +16 -0
  19. package/dist/auth-presets/jwt.js +54 -0
  20. package/dist/auth-presets/shared.d.ts +35 -0
  21. package/dist/auth-presets/shared.js +1 -0
  22. package/dist/auth-presets/supabase.d.ts +42 -0
  23. package/dist/auth-presets/supabase.js +229 -0
  24. package/dist/byo-approvals.d.ts +37 -0
  25. package/dist/byo-approvals.js +189 -0
  26. package/dist/capability-misses.d.ts +38 -0
  27. package/dist/capability-misses.js +172 -0
  28. package/dist/catalog.d.ts +19 -0
  29. package/dist/catalog.js +152 -0
  30. package/dist/cli/cloud/args.d.ts +3 -0
  31. package/dist/cli/cloud/args.js +32 -0
  32. package/dist/cli/cloud/auth.d.ts +17 -0
  33. package/dist/cli/cloud/auth.js +123 -0
  34. package/dist/cli/cloud/client.d.ts +28 -0
  35. package/dist/cli/cloud/client.js +133 -0
  36. package/dist/cli/cloud/command.d.ts +22 -0
  37. package/dist/cli/cloud/command.js +73 -0
  38. package/dist/cli/cloud/deploy.d.ts +26 -0
  39. package/dist/cli/cloud/deploy.js +187 -0
  40. package/dist/cli/cloud/device-login.d.ts +32 -0
  41. package/dist/cli/cloud/device-login.js +259 -0
  42. package/dist/cli/cloud/index.d.ts +5 -0
  43. package/dist/cli/cloud/index.js +59 -0
  44. package/dist/cli/cloud/keys.d.ts +2 -0
  45. package/dist/cli/cloud/keys.js +31 -0
  46. package/dist/cli/cloud/members.d.ts +3 -0
  47. package/dist/cli/cloud/members.js +25 -0
  48. package/dist/cli/cloud/output.d.ts +4 -0
  49. package/dist/cli/cloud/output.js +12 -0
  50. package/dist/cli/cloud/pending-claim.d.ts +29 -0
  51. package/dist/cli/cloud/pending-claim.js +38 -0
  52. package/dist/cli/cloud/read.d.ts +3 -0
  53. package/dist/cli/cloud/read.js +12 -0
  54. package/dist/cli/cloud/services.d.ts +2 -0
  55. package/dist/cli/cloud/services.js +9 -0
  56. package/dist/cli/cloud/session.d.ts +13 -0
  57. package/dist/cli/cloud/session.js +41 -0
  58. package/dist/cli/cloud-init.d.ts +60 -0
  59. package/dist/cli/cloud-init.js +149 -0
  60. package/dist/cli/dep-versions.d.ts +26 -0
  61. package/dist/cli/dep-versions.js +77 -0
  62. package/dist/cli/doctor-codes.d.ts +58 -0
  63. package/dist/cli/doctor-codes.js +60 -0
  64. package/dist/cli/doctor-live.d.ts +67 -0
  65. package/dist/cli/doctor-live.js +169 -0
  66. package/dist/cli/doctor.d.ts +52 -0
  67. package/dist/cli/doctor.js +467 -0
  68. package/dist/cli/eject.d.ts +29 -0
  69. package/dist/cli/eject.js +141 -0
  70. package/dist/cli/extract/apply.d.ts +26 -0
  71. package/dist/cli/extract/apply.js +123 -0
  72. package/dist/cli/extract/claude-cli-harness.d.ts +18 -0
  73. package/dist/cli/extract/claude-cli-harness.js +132 -0
  74. package/dist/cli/extract/claude-harness.d.ts +14 -0
  75. package/dist/cli/extract/claude-harness.js +127 -0
  76. package/dist/cli/extract/codex-cli-harness.d.ts +31 -0
  77. package/dist/cli/extract/codex-cli-harness.js +154 -0
  78. package/dist/cli/extract/delegate.d.ts +21 -0
  79. package/dist/cli/extract/delegate.js +77 -0
  80. package/dist/cli/extract/extraction.d.ts +65 -0
  81. package/dist/cli/extract/extraction.js +300 -0
  82. package/dist/cli/extract/gateway-fuel.d.ts +69 -0
  83. package/dist/cli/extract/gateway-fuel.js +69 -0
  84. package/dist/cli/extract/harness.d.ts +114 -0
  85. package/dist/cli/extract/harness.js +84 -0
  86. package/dist/cli/extract/index.d.ts +9 -0
  87. package/dist/cli/extract/index.js +9 -0
  88. package/dist/cli/extract/npx-engine-harness.d.ts +73 -0
  89. package/dist/cli/extract/npx-engine-harness.js +225 -0
  90. package/dist/cli/extract/stages.d.ts +149 -0
  91. package/dist/cli/extract/stages.js +398 -0
  92. package/dist/cli/framework.d.ts +8 -0
  93. package/dist/cli/framework.js +36 -0
  94. package/dist/cli/init-auth.d.ts +67 -0
  95. package/dist/cli/init-auth.js +142 -0
  96. package/dist/cli/init-scaffolds.d.ts +28 -0
  97. package/dist/cli/init-scaffolds.js +235 -0
  98. package/dist/cli/init.d.ts +115 -0
  99. package/dist/cli/init.js +1008 -0
  100. package/dist/cli/mcp/index.d.ts +7 -0
  101. package/dist/cli/mcp/index.js +59 -0
  102. package/dist/cli/mcp/registry.d.ts +9 -0
  103. package/dist/cli/mcp/registry.js +117 -0
  104. package/dist/cli/mcp/server-json.d.ts +12 -0
  105. package/dist/cli/mcp/server-json.js +65 -0
  106. package/dist/cli/mcp/server.schema.json +574 -0
  107. package/dist/cli/mcp/verify-domain.d.ts +11 -0
  108. package/dist/cli/mcp/verify-domain.js +48 -0
  109. package/dist/cli/playground/app/embed-entry.d.ts +20 -0
  110. package/dist/cli/playground/app/embed-entry.js +46 -0
  111. package/dist/cli/playground/app/fake-client.d.ts +3 -0
  112. package/dist/cli/playground/app/fake-client.js +215 -0
  113. package/dist/cli/playground/app/fixtures.d.ts +47 -0
  114. package/dist/cli/playground/app/fixtures.js +472 -0
  115. package/dist/cli/playground/app/main.d.ts +1 -0
  116. package/dist/cli/playground/app/main.js +108 -0
  117. package/dist/cli/playground/app/scenario-mount.d.ts +13 -0
  118. package/dist/cli/playground/app/scenario-mount.js +48 -0
  119. package/dist/cli/playground/app/scenarios.d.ts +25 -0
  120. package/dist/cli/playground/app/scenarios.js +152 -0
  121. package/dist/cli/playground/app/theme-editor.d.ts +13 -0
  122. package/dist/cli/playground/app/theme-editor.js +149 -0
  123. package/dist/cli/playground/app/theme-state.d.ts +29 -0
  124. package/dist/cli/playground/app/theme-state.js +151 -0
  125. package/dist/cli/playground/bundle.gen.d.ts +2 -0
  126. package/dist/cli/playground/bundle.gen.js +2 -0
  127. package/dist/cli/playground/embed-bundle.gen.d.ts +2 -0
  128. package/dist/cli/playground/embed-bundle.gen.js +2 -0
  129. package/dist/cli/playground.d.ts +33 -0
  130. package/dist/cli/playground.js +120 -0
  131. package/dist/cli/pretty.d.ts +48 -0
  132. package/dist/cli/pretty.js +330 -0
  133. package/dist/cli/refine.d.ts +43 -0
  134. package/dist/cli/refine.js +181 -0
  135. package/dist/cli/semantics.d.ts +31 -0
  136. package/dist/cli/semantics.js +122 -0
  137. package/dist/cli/shared.d.ts +68 -0
  138. package/dist/cli/shared.js +184 -0
  139. package/dist/cli/sync.d.ts +36 -0
  140. package/dist/cli/sync.js +202 -0
  141. package/dist/cli/theme/color.d.ts +15 -0
  142. package/dist/cli/theme/color.js +188 -0
  143. package/dist/cli/theme/css-vars.d.ts +13 -0
  144. package/dist/cli/theme/css-vars.js +40 -0
  145. package/dist/cli/theme/entry-candidates.d.ts +5 -0
  146. package/dist/cli/theme/entry-candidates.js +22 -0
  147. package/dist/cli/theme/extract-theme.d.ts +184 -0
  148. package/dist/cli/theme/extract-theme.js +358 -0
  149. package/dist/cli/theme/walk.d.ts +2 -0
  150. package/dist/cli/theme/walk.js +24 -0
  151. package/dist/cli.d.ts +2 -0
  152. package/dist/cli.js +333 -0
  153. package/dist/cloud-apps.d.ts +17 -0
  154. package/dist/cloud-apps.js +46 -0
  155. package/dist/cloud-console.d.ts +32 -0
  156. package/dist/cloud-console.js +72 -0
  157. package/dist/cloud-tools.d.ts +23 -0
  158. package/dist/cloud-tools.js +196 -0
  159. package/dist/connections.d.ts +71 -0
  160. package/dist/connections.js +192 -0
  161. package/dist/deployment-identity.d.ts +12 -0
  162. package/dist/deployment-identity.js +69 -0
  163. package/dist/dev-creds/model.d.ts +65 -0
  164. package/dist/dev-creds/model.js +159 -0
  165. package/dist/dev-creds/resolve.d.ts +36 -0
  166. package/dist/dev-creds/resolve.js +57 -0
  167. package/dist/hosted-store.d.ts +52 -0
  168. package/dist/hosted-store.js +322 -0
  169. package/dist/index.d.ts +13 -0
  170. package/dist/index.js +1 -0
  171. package/dist/mastra.d.ts +46 -0
  172. package/dist/mastra.js +88 -0
  173. package/dist/react.d.ts +10 -0
  174. package/dist/react.js +32 -0
  175. package/dist/refine.d.ts +244 -0
  176. package/dist/refine.js +599 -0
  177. package/dist/remixable.d.ts +18 -0
  178. package/dist/remixable.js +42 -0
  179. package/dist/runtime-capture.d.ts +18 -0
  180. package/dist/runtime-capture.js +111 -0
  181. package/dist/sandbox-wire.d.ts +101 -0
  182. package/dist/sandbox-wire.js +101 -0
  183. package/dist/sandbox.d.ts +38 -0
  184. package/dist/sandbox.js +389 -0
  185. package/dist/server.d.ts +216 -0
  186. package/dist/server.js +1320 -0
  187. package/dist/sync-impact.d.ts +14 -0
  188. package/dist/sync-impact.js +75 -0
  189. package/dist/turn-liveness.d.ts +33 -0
  190. package/dist/turn-liveness.js +105 -0
  191. package/dist/wire/approvals.d.ts +10 -0
  192. package/dist/wire/approvals.js +59 -0
  193. package/dist/wire/apps.d.ts +5 -0
  194. package/dist/wire/apps.js +155 -0
  195. package/dist/wire/automations.d.ts +5 -0
  196. package/dist/wire/automations.js +57 -0
  197. package/dist/wire/box.d.ts +3 -0
  198. package/dist/wire/box.js +268 -0
  199. package/dist/wire/connections.d.ts +5 -0
  200. package/dist/wire/connections.js +47 -0
  201. package/dist/wire/context.d.ts +22 -0
  202. package/dist/wire/context.js +190 -0
  203. package/dist/wire/doctor.d.ts +19 -0
  204. package/dist/wire/doctor.js +116 -0
  205. package/dist/wire/misc.d.ts +18 -0
  206. package/dist/wire/misc.js +227 -0
  207. package/dist/wire/shared.d.ts +158 -0
  208. package/dist/wire/shared.js +134 -0
  209. package/dist/wire/threads.d.ts +3 -0
  210. package/dist/wire/threads.js +68 -0
  211. package/package.json +151 -0
  212. package/skills/vendo-setup/SKILL.md +128 -0
@@ -0,0 +1,1008 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { mergeOverrides, vendoSync, } from "@vendoai/actions";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { stdin, stdout } from "node:process";
8
+ import { scrubErrorDetail } from "@vendoai/telemetry";
9
+ import { detectDepVersions, installedAiVersion } from "./dep-versions.js";
10
+ import { AUTH_MD_URL, runCloudStep, upsertEnvLocal } from "./cloud-init.js";
11
+ import { APPLY_COMMAND, composeDelegatedInstructions, EXTRACTION_DRAFT_JSON_SCHEMA } from "./extract/delegate.js";
12
+ import { askYesNo, runAiExtraction } from "./extract/extraction.js";
13
+ import { BRIEF_TEMPLATE } from "./extract/stages.js";
14
+ import { ENV_KEY_VARS, resolveDevCredential, describeDevCredential } from "../dev-creds/resolve.js";
15
+ import { detectFramework, detectVendoWiring } from "./framework.js";
16
+ import { resolveScaffoldAuth } from "./init-auth.js";
17
+ import { expressServerSource, registrySource, routeSource, serverActionsModuleSource, VENDO_ENV_EXAMPLE, wiringServerActions, } from "./init-scaffolds.js";
18
+ import { createPrettyOutput, plainSelect, usePrettyOutput } from "./pretty.js";
19
+ import { contrastingText } from "./theme/color.js";
20
+ import { applyThemeDraft, extractTheme as extractThemeSlots, validateSlotValue, } from "./theme/extract-theme.js";
21
+ import { cloudProjectProps, consoleOutput, envLocalValueSync, errorClass, exists, readOptional, toolingTelemetry, writeText, } from "./shared.js";
22
+ /**
23
+ * `vendo init` (install-dx v1, re-derived 2026-07-18): one command, zero
24
+ * questions on the happy path, no ceremony.
25
+ *
26
+ * scan → wire (the two-file surface — empty vendo/registry.tsx + the
27
+ * catch-all handler wired to it; a detected auth preset gets one
28
+ * consent-style confirm in interactive runs, --yes/non-interactive accept
29
+ * it silently — plus package.json hooks; never edits user-authored code)
30
+ * → key (env stated, else the cloud starter offer) → done summary (files
31
+ * changed, the VendoRoot line to paste, next steps).
32
+ *
33
+ * Removed by design: the interview, per-diff y/N approvals, the layout
34
+ * codemod, the lib/ai.ts scaffold (createVendo's `model` is optional now),
35
+ * remix offers, the encryption-key step, the refine offer, and the finale
36
+ * ceremony (doctor owns verification and the live turn).
37
+ */
38
+ const DEFAULT_RADIUS = { small: "4px", large: "12px" };
39
+ const BRIEF_PLACEHOLDER = `${BRIEF_TEMPLATE}\n`;
40
+ function toVendoTheme(slots) {
41
+ const deriveRadius = (factor, fallback) => {
42
+ const value = slots.radius.match(/^(\d+(?:\.\d+)?)px$/)?.[1];
43
+ return value === undefined ? fallback : `${Number(value) * factor}px`;
44
+ };
45
+ return {
46
+ colors: {
47
+ background: slots.background,
48
+ surface: slots.surface,
49
+ text: slots.text,
50
+ muted: slots.mutedText,
51
+ accent: slots.accent,
52
+ accentText: slots.accentText,
53
+ danger: slots.danger,
54
+ border: slots.border,
55
+ },
56
+ typography: {
57
+ fontFamily: slots.fontFamily,
58
+ headingFamily: slots.headingFamily,
59
+ baseSize: slots.baseSize,
60
+ },
61
+ radius: {
62
+ small: deriveRadius(0.5, DEFAULT_RADIUS.small),
63
+ medium: slots.radius,
64
+ large: deriveRadius(1.5, DEFAULT_RADIUS.large),
65
+ },
66
+ density: slots.density,
67
+ motion: slots.motion,
68
+ };
69
+ }
70
+ const THEME_PALETTE_SLOTS = ["accent", "background", "surface", "text", "mutedText", "border", "danger"];
71
+ /** ANSI truecolor swatch when interactive; plain hex otherwise. */
72
+ function swatch(hex) {
73
+ const match = /^#([0-9a-f]{6})$/i.exec(hex);
74
+ if (!match || !stdout.isTTY)
75
+ return "";
76
+ const [r, g, b] = [0, 2, 4].map((index) => parseInt(match[1].slice(index, index + 2), 16));
77
+ return `\u001b[48;2;${r};${g};${b}m \u001b[0m `;
78
+ }
79
+ /** One-glance confirm (§B2): the extracted palette, where each slot came
80
+ from is visible in defaulted/errors, and theme.json stays the editable
81
+ source of truth. */
82
+ function printThemeSummary(summary, output) {
83
+ const palette = THEME_PALETTE_SLOTS
84
+ .map((slot) => `${swatch(summary.slots[slot])}${slot} ${summary.slots[slot]}`)
85
+ .join(" · ");
86
+ output.log(`Theme: ${palette}`);
87
+ const headings = summary.slots.headingFamily === summary.slots.fontFamily
88
+ ? ""
89
+ : ` · headings ${summary.slots.headingFamily}`;
90
+ output.log(`Type: ${summary.slots.fontFamily}${headings} · radius ${summary.slots.radius}`);
91
+ const missing = summary.defaulted.filter((slot) => THEME_PALETTE_SLOTS.includes(slot) || slot === "fontFamily");
92
+ if (missing.length > 0) {
93
+ output.log(`No host evidence for ${missing.join(", ")} — neutral defaults used.`);
94
+ }
95
+ for (const error of summary.errors)
96
+ output.error(`warning: ${error}`);
97
+ output.log("Theme lives in .vendo/theme.json — edit it anytime; it is the source of truth.");
98
+ }
99
+ /** Interactive review of model-flagged uncertain slots (the ONLY theme question). */
100
+ async function defaultThemeReview(summary) {
101
+ if (!stdin.isTTY || !stdout.isTTY)
102
+ return {};
103
+ const prompt = createInterface({ input: stdin, output: stdout });
104
+ const overrides = {};
105
+ try {
106
+ for (const { slot, note } of summary.uncertain) {
107
+ const answer = (await prompt.question(`Theme ${slot} is uncertain (${note}); extracted ${summary.slots[slot]}. Replacement value, or Enter to keep: `)).trim();
108
+ if (answer !== "")
109
+ overrides[slot] = answer;
110
+ }
111
+ }
112
+ finally {
113
+ prompt.close();
114
+ }
115
+ return overrides;
116
+ }
117
+ async function appDirectory(root) {
118
+ if (await exists(join(root, "src", "app")))
119
+ return join(root, "src", "app");
120
+ return join(root, "app");
121
+ }
122
+ /** Telemetry `router` enum (init_completed): app | pages | none, from the
123
+ same directory evidence appDirectory rides. Express hosts are "none". */
124
+ async function detectRouter(root, framework) {
125
+ if (framework === "next") {
126
+ if (await exists(join(root, "src", "app")) || await exists(join(root, "app")))
127
+ return "app";
128
+ if (await exists(join(root, "src", "pages")) || await exists(join(root, "pages")))
129
+ return "pages";
130
+ }
131
+ return "none";
132
+ }
133
+ /** Relative, posix-style import specifier from the layout's directory to the
134
+ project-root `.vendo/theme.json` — printed for the user's paste, never
135
+ written by init. Returns null when the project EXPLICITLY disables
136
+ resolveJsonModule, so the printed snippet compiles. */
137
+ async function themeImportSpecifier(root, layoutDir) {
138
+ if (await resolveJsonModuleDisabled(root))
139
+ return null;
140
+ const themeJson = join(root, ".vendo", "theme.json");
141
+ return relative(layoutDir, themeJson).split(sep).join("/");
142
+ }
143
+ /** True only when tsconfig/jsconfig EXPLICITLY sets
144
+ `compilerOptions.resolveJsonModule === false` — the one case where importing
145
+ theme.json breaks the build. */
146
+ async function resolveJsonModuleDisabled(root) {
147
+ for (const file of ["tsconfig.json", "jsconfig.json"]) {
148
+ const raw = await readOptional(join(root, file));
149
+ if (raw === null)
150
+ continue;
151
+ try {
152
+ const config = JSON.parse(raw);
153
+ if (config.compilerOptions?.resolveJsonModule === false)
154
+ return true;
155
+ }
156
+ catch {
157
+ // Malformed config — assume the default (enabled).
158
+ }
159
+ }
160
+ return false;
161
+ }
162
+ function diff(path, before, after) {
163
+ const oldLines = before === null ? [] : before.trimEnd().split("\n");
164
+ const newLines = after.trimEnd().split("\n");
165
+ return [
166
+ `--- a/${path}`,
167
+ `+++ b/${path}`,
168
+ ...oldLines.map((line) => `-${line}`),
169
+ ...newLines.map((line) => `+${line}`),
170
+ ].join("\n");
171
+ }
172
+ /**
173
+ * Wire the server-action registration map into an EXISTING generated route
174
+ * (ENG-248 idempotency fix): a host that adds `"use server"` actions AFTER the
175
+ * initial init gets vendo-actions.ts generated, but its route.ts still calls
176
+ * `createVendo` without `serverActions` — so every server-action call fails
177
+ * closed at runtime. Best-effort: rewrites only the recognized
178
+ * `createVendo({ ... })` shape; returns null when already wired or the shape is
179
+ * unrecognized (never corrupts a hand-customized route). Idempotent: a route
180
+ * that already imports and passes serverActions yields null.
181
+ */
182
+ function wireRouteServerActions(source) {
183
+ const importsMap = /from\s+["']\.\/vendo-actions["']/.test(source);
184
+ const call = source.match(/createVendo\(\s*\{/);
185
+ if (!call)
186
+ return null; // unrecognized composition — leave it untouched
187
+ const callIndex = source.indexOf(call[0]);
188
+ const passesActions = /(^|[\s{,])serverActions\b/.test(source.slice(callIndex));
189
+ // Already wired, or hand-customized: a route that passes serverActions
190
+ // sourced from anywhere other than ./vendo-actions (a local map, an aliased
191
+ // import) would get a conflicting duplicate binding from our import — leave
192
+ // it untouched either way.
193
+ if (passesActions)
194
+ return null;
195
+ let next = source;
196
+ if (!importsMap) {
197
+ const importLine = `import { serverActions } from "./vendo-actions";`;
198
+ const serverImport = next.match(/^.*from\s+["']@vendoai\/vendo\/server["'];?[^\n]*$/m);
199
+ if (serverImport) {
200
+ const at = next.indexOf(serverImport[0]) + serverImport[0].length;
201
+ next = `${next.slice(0, at)}\n${importLine}${next.slice(at)}`;
202
+ }
203
+ else {
204
+ next = `${importLine}\n${next}`;
205
+ }
206
+ }
207
+ next = next.replace(/createVendo\(\s*\{/, (match) => `${match}\n serverActions,`);
208
+ return next === source ? null : next;
209
+ }
210
+ function packageWithSyncHooks(raw) {
211
+ const manifest = JSON.parse(raw);
212
+ const priorScripts = manifest["scripts"];
213
+ const scripts = typeof priorScripts === "object" && priorScripts !== null && !Array.isArray(priorScripts)
214
+ ? priorScripts
215
+ : {};
216
+ let changed = false;
217
+ const hook = (name, command) => {
218
+ const prior = scripts[name];
219
+ if (typeof prior !== "string") {
220
+ scripts[name] = command;
221
+ changed = true;
222
+ }
223
+ else if (!prior.includes(command)) {
224
+ scripts[name] = `${command} && ${prior}`;
225
+ changed = true;
226
+ }
227
+ };
228
+ hook("predev", "vendo sync");
229
+ hook("prebuild", "vendo sync --strict");
230
+ if (!changed)
231
+ return null;
232
+ manifest["scripts"] = scripts;
233
+ const detectedIndent = raw.match(/^[\t ]+(?=")/m)?.[0] ?? " ";
234
+ const trailingNewline = raw.endsWith("\r\n") ? "\r\n" : raw.endsWith("\n") ? "\n" : "";
235
+ return `${JSON.stringify(manifest, null, detectedIndent)}${trailingNewline}`;
236
+ }
237
+ /** Read-only extraction for the agent plan. vendoSync writes its artifacts, so
238
+ it runs against a throwaway out dir — the host tree stays untouched (the
239
+ --agent contract). Existing overrides ride along so the plan reflects prior
240
+ human risk decisions, mirroring vendoSync's own merge semantics. */
241
+ async function extractForPlan(root) {
242
+ const out = await mkdtemp(join(tmpdir(), "vendo-agent-plan-"));
243
+ try {
244
+ const overridesRaw = await readOptional(join(root, ".vendo", "overrides.json"));
245
+ if (overridesRaw !== null)
246
+ await writeText(join(out, "overrides.json"), overridesRaw);
247
+ const report = await vendoSync({ root, out });
248
+ const file = JSON.parse(await readFile(join(out, "tools.json"), "utf8"));
249
+ let overrides = null;
250
+ try {
251
+ overrides = overridesRaw === null ? null : JSON.parse(overridesRaw);
252
+ }
253
+ catch {
254
+ // vendoSync already validated the copy; an unreadable original merges as absent.
255
+ }
256
+ return { tools: mergeOverrides(file.tools ?? [], overrides), warnings: report.warnings };
257
+ }
258
+ catch (error) {
259
+ // The plan must always emit — extraction failures degrade to a warning.
260
+ return { tools: [], warnings: [`extraction failed: ${error instanceof Error ? error.message : "unknown error"}`] };
261
+ }
262
+ finally {
263
+ await rm(out, { recursive: true, force: true });
264
+ }
265
+ }
266
+ /** Project extraction results onto the small static-facts shape the
267
+ delegation instructions carry (route bindings surface method+path). */
268
+ function planStaticTools(tools) {
269
+ return tools.map((tool) => {
270
+ const binding = tool.binding;
271
+ return {
272
+ name: tool.name,
273
+ description: tool.description,
274
+ risk: tool.risk,
275
+ ...(tool.disabled === true ? { disabled: true } : {}),
276
+ ...(typeof binding.method === "string" ? { method: binding.method } : {}),
277
+ ...(typeof binding.path === "string" ? { path: binding.path } : {}),
278
+ };
279
+ });
280
+ }
281
+ /** 04-actions §1 risk ladder projected as advice: destructive asks first,
282
+ writes get reviewed, reads auto-run (no entry). */
283
+ function riskRecommendations(tools) {
284
+ return tools.flatMap((tool) => {
285
+ if (tool.disabled === true) {
286
+ return [{ tool: tool.name, risk: tool.risk, recommendation: "extracted disabled (unclassifiable); enable it deliberately in .vendo/overrides.json after review" }];
287
+ }
288
+ if (tool.critical === true) {
289
+ return [{ tool: tool.name, risk: tool.risk, recommendation: "already marked critical in .vendo/overrides.json; policy asks before running it" }];
290
+ }
291
+ if (tool.risk === "destructive") {
292
+ return [{ tool: tool.name, risk: tool.risk, recommendation: "irreversible; mark it critical in .vendo/overrides.json so policy asks first" }];
293
+ }
294
+ if (tool.risk === "write") {
295
+ return [{ tool: tool.name, risk: tool.risk, recommendation: "writes host data; review it and mark critical in .vendo/overrides.json when irreversible" }];
296
+ }
297
+ return [];
298
+ });
299
+ }
300
+ /** The packaged vendo-setup skill (shipped in the npm tarball next to dist/).
301
+ Resolved relative to this module so src (tests) and dist (published bin)
302
+ agree; a missing file degrades to not offering the skill. */
303
+ async function setupSkillSource() {
304
+ try {
305
+ return await readFile(new URL("../../skills/vendo-setup/SKILL.md", import.meta.url), "utf8");
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
311
+ /** The one line init never writes: the user pastes the VendoRoot wrap. When
312
+ the shared registry exists (scaffolded or host-authored) the wrap carries
313
+ `components={registry}` — the client half of the one-file/two-consumers
314
+ pattern; it stays ONE pasted line plus its imports. */
315
+ async function vendoRootPasteLines(root, framework, withRegistry) {
316
+ if (framework === "express") {
317
+ const wrap = withRegistry
318
+ ? `<VendoRoot components={registry} theme={theme}>…</VendoRoot>`
319
+ : `<VendoRoot theme={theme}>…</VendoRoot>`;
320
+ return [
321
+ `app.use("/api/vendo", mountVendo()); // in your server`,
322
+ `${wrap} // around your client root (see vendo/server for the imports)`,
323
+ ];
324
+ }
325
+ const app = await appDirectory(root);
326
+ const specifier = await themeImportSpecifier(root, app);
327
+ const layout = relative(root, join(app, "layout.tsx"));
328
+ const registrySpecifier = relative(app, join(dirname(app), "vendo", "registry")).split(sep).join("/");
329
+ const importLines = [
330
+ `import { VendoRoot } from "@vendoai/vendo/react";`,
331
+ ...(withRegistry ? [`import { registry } from ${JSON.stringify(registrySpecifier)};`] : []),
332
+ ...(specifier === null
333
+ ? []
334
+ : [
335
+ `import theme from ${JSON.stringify(specifier)};`,
336
+ `import type { VendoTheme } from "@vendoai/vendo";`,
337
+ ]),
338
+ ];
339
+ const props = [
340
+ ...(withRegistry ? ["components={registry}"] : []),
341
+ ...(specifier === null ? [] : ["theme={theme as VendoTheme}"]),
342
+ ];
343
+ const wrap = `<VendoRoot${props.length === 0 ? "" : ` ${props.join(" ")}`}>{children}</VendoRoot>`;
344
+ return [`In ${layout}:`, ...importLines.map((line) => ` ${line}`), ` … then wrap: ${wrap}`];
345
+ }
346
+ /** The repo-specific agent tail (agent-install-dx): a non-interactive
347
+ scaffold run is agent-driven, so the run ends with plain deterministic
348
+ pointers — the wired auth preset and what is still stubbed about it, the
349
+ exact files left to hand-edit (derived from what THIS run wrote, never
350
+ canned prose), and the one doctor command that gates "done". A pointer to
351
+ work, not documentation: the playbook carries the teaching. */
352
+ async function agentTailLines(args) {
353
+ const lines = [];
354
+ // Auth is a tail fact only when a composition was created this run — a
355
+ // re-run against an existing composition changed nothing about auth.
356
+ if (args.compositionPath !== null) {
357
+ if (args.authWired === null) {
358
+ lines.push("auth: none wired — sessions stay anonymous until a preset is added");
359
+ }
360
+ else if (args.authWired.source === "picked") {
361
+ lines.push(`auth: ${args.authWired.preset}() wired — stubbed: ${args.authWired.dependency} is not in package.json; install it before the first authenticated run`);
362
+ }
363
+ else {
364
+ lines.push(`auth: ${args.authWired.preset}() wired (detected ${args.authWired.dependency})`);
365
+ }
366
+ }
367
+ if (args.registryPath !== null) {
368
+ lines.push(`edit ${args.registryPath} — register the components the agent may render (generated empty)`);
369
+ }
370
+ if (args.compositionPath !== null && args.authWired === null) {
371
+ lines.push(`edit ${args.compositionPath} — add the auth preset named in the advisory above when the host has auth`);
372
+ }
373
+ if (args.framework === "express") {
374
+ // No exact entry file exists to name on Express — point at the printed
375
+ // wiring lines instead of guessing a path.
376
+ lines.push("edit your server and client entries — paste the mountVendo() and <VendoRoot> lines above");
377
+ }
378
+ else {
379
+ const layout = relative(args.root, join(await appDirectory(args.root), "layout.tsx"));
380
+ lines.push(`edit ${layout} — wrap the app in the <VendoRoot> lines above`);
381
+ }
382
+ if (await readOptional(join(args.root, ".vendo", "brief.md")) === BRIEF_PLACEHOLDER) {
383
+ lines.push(`edit ${join(".vendo", "brief.md")} — replace the placeholder with what this product does and for whom`);
384
+ }
385
+ if (args.cloudKeyMissing) {
386
+ lines.push(`cloud key: none — for Vendo Cloud, fetch ${AUTH_MD_URL} and run \`vendo login\` (your human approves a code in the browser; the key lands in .env.local), then re-run init or pass --cloud-key <key>; --byo with a provider key also works`);
387
+ }
388
+ lines.push("gate: run `vendo doctor --json` — done when every check reports green");
389
+ return lines;
390
+ }
391
+ const STAR_REPO = "runvendo/vendo";
392
+ // Tracked star link (vendo-web star-worker): captures star_link_clicked
393
+ // {src: cli} server-side, then redirects to the repo.
394
+ const STAR_LINK = "https://vendo.run/star?src=cli";
395
+ /** Star the repo via gh (agent-install-dx §CLI-5). Every failure mode — gh
396
+ not installed (spawn error), a non-zero exit, a throwing seam, or a gh
397
+ that hangs past `timeoutMs` — is plain `false`: the caller prints the
398
+ repo URL instead, one line, no error noise. Exported for the timeout's
399
+ direct unit test only. */
400
+ export function starViaGh(spawnStar, timeoutMs = 5_000) {
401
+ return new Promise((resolveStar) => {
402
+ let timer = null;
403
+ const settle = (starred) => {
404
+ if (timer !== null)
405
+ clearTimeout(timer);
406
+ resolveStar(starred);
407
+ };
408
+ let child;
409
+ try {
410
+ child = spawnStar("gh", ["api", "-X", "PUT", `user/starred/${STAR_REPO}`]);
411
+ }
412
+ catch {
413
+ settle(false);
414
+ return;
415
+ }
416
+ timer = setTimeout(() => settle(false), timeoutMs);
417
+ timer.unref?.();
418
+ child.on("error", () => settle(false));
419
+ child.on("exit", (code) => settle(code === 0));
420
+ });
421
+ }
422
+ async function buildPlan(options, confirmAuth, selectAuth) {
423
+ const root = resolve(options.targetDir);
424
+ const framework = options.framework ?? await detectFramework(root);
425
+ const changes = [];
426
+ let authAdvice = null;
427
+ let authWired = null;
428
+ let compositionPath = null;
429
+ let registryPath = null;
430
+ let withRegistry = false;
431
+ if (framework === "express") {
432
+ const wiring = await detectVendoWiring(root);
433
+ if (!wiring.server || !wiring.client) {
434
+ const typescript = await exists(join(root, "tsconfig.json"));
435
+ const server = join(root, "vendo", typescript ? "server.ts" : "server.mjs");
436
+ const registryFile = join(root, "vendo", typescript ? "registry.tsx" : "registry.mjs");
437
+ const registryBefore = await readOptional(registryFile);
438
+ const serverBefore = await readOptional(server);
439
+ // Init owns the composition only when it CREATES it: no generated
440
+ // server module yet AND no hand-wired createVendo anywhere else. A host
441
+ // that composed at its own path but hasn't pasted <VendoRoot> yet gets
442
+ // neither a duplicate server module nor an orphaned registry — the
443
+ // Express analog of the Next branch's routeBefore === null guard.
444
+ const scaffolding = serverBefore === null && !wiring.server;
445
+ // The registry regenerates only for a composition that uses it: the one
446
+ // being created now, or a previously generated server module whose
447
+ // ./registry import would otherwise dangle. Never clobbered.
448
+ const registryPlanned = registryBefore === null
449
+ && (scaffolding || serverBefore?.includes("./registry") === true);
450
+ if (registryPlanned) {
451
+ const path = relative(root, registryFile);
452
+ const registryAfter = registrySource(typescript ? "tsx" : "mjs");
453
+ changes.push({ absolute: registryFile, path, before: null, after: registryAfter, diff: diff(path, null, registryAfter) });
454
+ registryPath = path;
455
+ }
456
+ if (scaffolding) {
457
+ const path = relative(root, server);
458
+ // Detect + confirm happens only here — fresh composition creation —
459
+ // so a re-run before the manual <VendoRoot> paste neither asks nor
460
+ // re-fires the advisory after "Already wired".
461
+ const auth = await resolveScaffoldAuth(root, path, options.auth, confirmAuth, selectAuth);
462
+ const serverAfter = expressServerSource(typescript, auth.wired);
463
+ changes.push({ absolute: server, path, before: null, after: serverAfter, diff: diff(path, null, serverAfter) });
464
+ authAdvice = auth.advice;
465
+ authWired = auth.wired;
466
+ compositionPath = path;
467
+ }
468
+ withRegistry = registryBefore !== null || registryPlanned;
469
+ }
470
+ }
471
+ else {
472
+ const app = await appDirectory(root);
473
+ const route = join(app, "api", "vendo", "[...vendo]", "route.ts");
474
+ const actionsModule = join(app, "api", "vendo", "[...vendo]", "vendo-actions.ts");
475
+ const routeBefore = await readOptional(route);
476
+ const actionsBefore = await readOptional(actionsModule);
477
+ const registrations = await wiringServerActions(root);
478
+ // The shared registry mirrors the app dir (src/app → src/vendo): generated
479
+ // only while absent and only when the route uses it — a fresh scaffold, or
480
+ // a route that already imports vendo/registry. A hand-wired route that
481
+ // ignores the registry never grows an orphan file.
482
+ const registryFile = join(dirname(app), "vendo", "registry.tsx");
483
+ const registryBefore = await readOptional(registryFile);
484
+ const registryPlanned = registryBefore === null
485
+ && (routeBefore === null || routeBefore.includes("vendo/registry"));
486
+ if (registryPlanned) {
487
+ const path = relative(root, registryFile);
488
+ const registryAfter = registrySource("tsx");
489
+ changes.push({ absolute: registryFile, path, before: null, after: registryAfter, diff: diff(path, null, registryAfter) });
490
+ registryPath = path;
491
+ }
492
+ withRegistry = registryBefore !== null || registryPlanned;
493
+ // The registration map regenerates whenever the detected "use server"
494
+ // surface changes; an existing map is kept compiling (emptied, never
495
+ // deleted) when the last action disappears.
496
+ if (registrations.length > 0 || actionsBefore !== null) {
497
+ const actionsAfter = serverActionsModuleSource(root, dirname(actionsModule), registrations);
498
+ if (actionsAfter !== actionsBefore) {
499
+ const path = relative(root, actionsModule);
500
+ changes.push({ absolute: actionsModule, path, before: actionsBefore, after: actionsAfter, diff: diff(path, actionsBefore, actionsAfter) });
501
+ }
502
+ }
503
+ if (routeBefore === null) {
504
+ const path = relative(root, route);
505
+ // Detect + confirm happens only on fresh composition creation.
506
+ const auth = await resolveScaffoldAuth(root, path, options.auth, confirmAuth, selectAuth);
507
+ const registrySpecifier = relative(dirname(route), join(dirname(app), "vendo", "registry")).split(sep).join("/");
508
+ const routeAfter = routeSource({ serverActions: registrations.length > 0, auth: auth.wired, registrySpecifier });
509
+ changes.push({ absolute: route, path, before: routeBefore, after: routeAfter, diff: diff(path, routeBefore, routeAfter) });
510
+ authAdvice = auth.advice;
511
+ authWired = auth.wired;
512
+ compositionPath = path;
513
+ }
514
+ else if (registrations.length > 0) {
515
+ // The route already exists but server actions appeared since it was
516
+ // generated: wire the registration map into the existing createVendo so
517
+ // server-action execution doesn't fail closed (ENG-248 idempotency fix).
518
+ const wiredRoute = wireRouteServerActions(routeBefore);
519
+ if (wiredRoute !== null) {
520
+ const path = relative(root, route);
521
+ changes.push({ absolute: route, path, before: routeBefore, after: wiredRoute, diff: diff(path, routeBefore, wiredRoute) });
522
+ }
523
+ }
524
+ }
525
+ const packageJson = join(root, "package.json");
526
+ const packageBefore = await readOptional(packageJson);
527
+ if (packageBefore !== null) {
528
+ const packageAfter = packageWithSyncHooks(packageBefore);
529
+ if (packageAfter !== null) {
530
+ const path = relative(root, packageJson);
531
+ changes.push({
532
+ absolute: packageJson,
533
+ path,
534
+ before: packageBefore,
535
+ after: packageAfter,
536
+ diff: diff(path, packageBefore, packageAfter),
537
+ });
538
+ }
539
+ }
540
+ // Agent surface: a host that already uses skills (.claude/ exists) gets the
541
+ // packaged vendo-setup skill. Written only while missing — an edited copy is
542
+ // respected (never overwritten); a deleted copy returns on the next init,
543
+ // like any missing scaffold.
544
+ if (await exists(join(root, ".claude"))) {
545
+ const skillAbsolute = join(root, ".claude", "skills", "vendo-setup", "SKILL.md");
546
+ if (!(await exists(skillAbsolute))) {
547
+ const skillSource = await setupSkillSource();
548
+ if (skillSource !== null) {
549
+ const path = relative(root, skillAbsolute);
550
+ changes.push({ absolute: skillAbsolute, path, before: null, after: skillSource, diff: diff(path, null, skillSource) });
551
+ }
552
+ }
553
+ }
554
+ const writes = [
555
+ ".env.example",
556
+ ".vendo/tools.json",
557
+ ".vendo/overrides.json",
558
+ ".vendo/policy.json",
559
+ ".vendo/brief.md",
560
+ ".vendo/theme.json",
561
+ ".vendo/data/.gitignore",
562
+ ];
563
+ const manualSteps = await vendoRootPasteLines(root, framework, withRegistry);
564
+ return {
565
+ changes,
566
+ manualSteps,
567
+ authAdvice,
568
+ authWired,
569
+ compositionPath,
570
+ registryPath,
571
+ plan: {
572
+ framework,
573
+ root,
574
+ writes,
575
+ codeChanges: changes.map(({ path, diff: rendered }) => ({ path, diff: rendered })),
576
+ manualSteps,
577
+ },
578
+ };
579
+ }
580
+ async function writeIfMissing(path, content, force) {
581
+ if (!force && await exists(path))
582
+ return;
583
+ await writeText(path, content);
584
+ }
585
+ /** The value of one NAME=value line in .env.local (the cloud step's upsert
586
+ target) — the same-run pickup reads the freshly minted key back from disk.
587
+ One parser for the whole CLI: shared.ts's envLocalValueSync (telemetry's
588
+ cloud-key read uses the same one, so the two can never disagree). */
589
+ async function envLocalValue(root, name) {
590
+ return envLocalValueSync(root, name);
591
+ }
592
+ async function ensureVendoEnvExample(root) {
593
+ const path = join(root, ".env.example");
594
+ const current = await readOptional(path);
595
+ if (current === null) {
596
+ await writeText(path, VENDO_ENV_EXAMPLE);
597
+ return;
598
+ }
599
+ if (/^\s*VENDO_BASE_URL\s*=/m.test(current))
600
+ return;
601
+ const separator = current.length === 0 ? "" : current.endsWith("\n") ? "\n" : "\n\n";
602
+ await writeText(path, `${current}${separator}${VENDO_ENV_EXAMPLE}`);
603
+ }
604
+ /** root rides in as the client's cwd: projectIdHash/packageManager and the
605
+ .env.local cloud-key read attribute to the TARGET project, not the shell
606
+ cwd (`vendo init ../app` from elsewhere). Seams in options.telemetry win. */
607
+ function telemetryFor(options, output, root) {
608
+ return toolingTelemetry({ cwd: root, ...options.telemetry, log: (message) => output.log(message) });
609
+ }
610
+ /** 09-vendo §5 — idempotent, zero-question setup. */
611
+ export async function runInit(options) {
612
+ // The clack-style renderer rides the SAME Output seam: it restyles the
613
+ // exact plain messages below, and is selected only for a human terminal
614
+ // (TTY, no NO_COLOR/CI, never --agent, never an injected output). Every
615
+ // other run — tests, pipes, CI — keeps the plain strings byte-for-byte.
616
+ const pretty = options.output === undefined && options.agent !== true && usePrettyOutput()
617
+ ? createPrettyOutput()
618
+ : null;
619
+ const output = options.output ?? pretty ?? consoleOutput;
620
+ const started = Date.now();
621
+ const root = resolve(options.targetDir);
622
+ const env = options.env ?? process.env;
623
+ if (options.agent === true) {
624
+ // Extraction runs before the plan is emitted so the plan carries real tool
625
+ // names and risk advice; the throwaway out dir keeps --agent read-only.
626
+ const { plan } = await buildPlan(options);
627
+ const extraction = await extractForPlan(root);
628
+ let appName = "app";
629
+ try {
630
+ appName = JSON.parse((await readOptional(join(root, "package.json"))) ?? "{}").name ?? "app";
631
+ }
632
+ catch {
633
+ // package.json is optional context
634
+ }
635
+ output.log(JSON.stringify({
636
+ ...plan,
637
+ extraction,
638
+ riskRecommendations: riskRecommendations(extraction.tools),
639
+ // The delegation contract: the agent reading this plan can do the AI
640
+ // polish itself and land it through `vendo extract --apply` — the same
641
+ // deterministic guards as init's built-in pass.
642
+ aiPolish: {
643
+ instructions: composeDelegatedInstructions(planStaticTools(extraction.tools), appName),
644
+ draftSchema: EXTRACTION_DRAFT_JSON_SCHEMA,
645
+ apply: APPLY_COMMAND,
646
+ },
647
+ }, null, 2));
648
+ return 0;
649
+ }
650
+ // Detect + confirm (interactive runs only): --yes and non-interactive runs
651
+ // accept the detected default silently — the same interactivity posture as
652
+ // the AI-polish consent.
653
+ const interactive = options.interactive ?? (Boolean(stdin.isTTY) && Boolean(stdout.isTTY));
654
+ // An undetectable framework has NO safe default: a non-interactive run
655
+ // (agents) errors with the exact flag instead of guessing the Next layout
656
+ // into an unknown host. Interactive runs keep today's fall-through.
657
+ if (options.framework === undefined && (options.yes === true || !interactive)
658
+ && await detectFramework(root) === "unknown") {
659
+ output.error("Framework not detected (no next or express dependency in package.json) and this run cannot ask. " +
660
+ "Pass --framework. Example: vendo init --yes --framework next");
661
+ return 1;
662
+ }
663
+ // (No stdin-TTY guard on these defaults, unlike the star ask's: an unshown
664
+ // auth confirm resolving its default just wires the detected preset — the
665
+ // very accept the non-interactive path performs silently anyway.)
666
+ const confirmAuth = options.yes === true || !interactive
667
+ ? undefined
668
+ : (options.confirmAuth ?? (pretty === null ? askYesNo : pretty.confirm));
669
+ const selectAuth = options.yes === true || !interactive
670
+ ? undefined
671
+ : (options.selectAuth ?? (pretty === null ? plainSelect : pretty.select));
672
+ const detectStarted = Date.now();
673
+ const { plan, changes, manualSteps, authAdvice, authWired, compositionPath, registryPath } = await buildPlan(options, confirmAuth, selectAuth);
674
+ const detectMs = Date.now() - detectStarted;
675
+ let telemetry = telemetryFor(options, output, root);
676
+ await telemetry.track("init_started", { framework: plan.framework });
677
+ try {
678
+ // --cloud-key: the flag answer to the cloud-login offer — the supplied
679
+ // key lands exactly where the mint would (.env.local), so the merge
680
+ // below picks it up and the offer never fires.
681
+ if (options.cloudKey !== undefined) {
682
+ await upsertEnvLocal(root, "VENDO_API_KEY", options.cloudKey);
683
+ output.log("Wrote VENDO_API_KEY to .env.local (--cloud-key).");
684
+ }
685
+ // Key first (product order fix): the model-credential story — env keys,
686
+ // else the Vendo Cloud offer — runs BEFORE the AI-assisted passes, so a
687
+ // starter key minted here powers the SAME run's theme model pass and AI
688
+ // polish instead of those passes reporting "no model" while the offer
689
+ // waits below them. --yes / non-interactive semantics are unchanged.
690
+ // Dev keys may live in .env.local rather than this process's env — a
691
+ // PRIOR run's minted starter key, or hand-added provider keys. Merge
692
+ // them into the env every credential consumer reads (credential ladder,
693
+ // cloud step, theme model pass, AI polish); an explicit env value
694
+ // always wins over .env.local.
695
+ let effectiveEnv = env;
696
+ for (const name of [...ENV_KEY_VARS.map((entry) => entry.envVar), "VENDO_API_KEY"]) {
697
+ if ((env[name] ?? "").trim() !== "")
698
+ continue;
699
+ const stored = await envLocalValue(root, name);
700
+ if (stored !== null)
701
+ effectiveEnv = { ...effectiveEnv, [name]: stored };
702
+ }
703
+ let credential = await (options.resolveCredential ?? resolveDevCredential)({ env: effectiveEnv });
704
+ if (credential.rung === "env-key") {
705
+ output.log(`Model: ${describeDevCredential(credential)} — production uses this same key server-side.`);
706
+ }
707
+ const cloud = await runCloudStep({
708
+ root,
709
+ output,
710
+ // --byo answers the offer with "no" AND suppresses the agent-path
711
+ // auth.md pointer (an explicit BYO choice is final); --yes skips the
712
+ // prompt but still gets the pointer so an agent can mint in-band.
713
+ yes: options.yes === true,
714
+ byo: options.byo === true,
715
+ credential,
716
+ // The RUN's env, not process.env: a programmatic caller's key must be
717
+ // what the probe and the mint see (seams in options.cloud still win).
718
+ env: effectiveEnv,
719
+ // The step's own command_run row rides init's telemetry seams.
720
+ ...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }),
721
+ ...(pretty === null ? {} : { confirm: pretty.confirm }),
722
+ ...(options.cloud ?? {}),
723
+ });
724
+ // Same-run pickup: a starter key minted just now lands in .env.local —
725
+ // merge it the same way so THIS run's passes already benefit.
726
+ if (cloud.wroteEnvLocal) {
727
+ const minted = await envLocalValue(root, "VENDO_API_KEY");
728
+ if (minted !== null) {
729
+ effectiveEnv = { ...effectiveEnv, VENDO_API_KEY: minted };
730
+ credential = await (options.resolveCredential ?? resolveDevCredential)({ env: effectiveEnv });
731
+ }
732
+ }
733
+ // A key that landed in .env.local THIS run (--cloud-key upsert or the
734
+ // login ceremony) must activate the telemetry cloud lane for the rest of
735
+ // this run's events too — rebuild the client so it re-reads .env.local.
736
+ // A pre-existing key was already picked up at the first construction.
737
+ if (options.cloudKey !== undefined || cloud.wroteEnvLocal) {
738
+ telemetry = telemetryFor(options, output, root);
739
+ }
740
+ // Wire — apply the bounded change set and list it. No gates, no prompts.
741
+ // (Timed for the cloud lane's wiringMs; the static scan below adds on.)
742
+ const wiringStarted = Date.now();
743
+ for (const change of changes) {
744
+ await writeText(change.absolute, change.after);
745
+ }
746
+ // Scan — .vendo artifacts + static extraction (the hints layer for the AI
747
+ // extraction; interim tools.json source until it lands).
748
+ await ensureVendoEnvExample(root);
749
+ await mkdir(join(root, ".vendo"), { recursive: true });
750
+ await writeIfMissing(join(root, ".vendo", "overrides.json"), `${JSON.stringify({
751
+ format: "vendo/overrides@1",
752
+ tools: {},
753
+ remix: { ignoreSlots: [] },
754
+ }, null, 2)}\n`, options.force === true);
755
+ await writeIfMissing(join(root, ".vendo", "policy.json"), `${JSON.stringify({
756
+ format: "vendo/policy@1",
757
+ directions: [],
758
+ rules: [
759
+ { match: { risk: "destructive" }, action: "ask", note: "Review irreversible actions" },
760
+ { match: { risk: "read" }, action: "run" },
761
+ ],
762
+ }, null, 2)}\n`, options.force === true);
763
+ await writeIfMissing(join(root, ".vendo", "brief.md"), BRIEF_PLACEHOLDER, options.force === true);
764
+ // Theme (Task 2/4 re-derive): the exact-only allowlist pass runs and
765
+ // writes theme.json right away — never overwriting an existing one (it
766
+ // is the editable source of truth) unless --force. Whatever brand slots
767
+ // the allowlist left unfilled ride the consent-gated AI-polish pass
768
+ // below; the merge, --theme answers, the one-glance palette print, and
769
+ // the uncertain-slot review all happen AFTER that pass returns, further
770
+ // down this function — a pre-existing theme.json is never touched.
771
+ const themePath = join(root, ".vendo", "theme.json");
772
+ const themeCreatedThisRun = options.force === true || !(await exists(themePath));
773
+ let wiringMs = Date.now() - wiringStarted;
774
+ let themeMs;
775
+ let themeSummary = null;
776
+ if (themeCreatedThisRun) {
777
+ pretty?.spin("Capturing your theme");
778
+ const themeStarted = Date.now();
779
+ themeSummary = await extractThemeSlots(root);
780
+ themeMs = Date.now() - themeStarted;
781
+ pretty?.stopSpin();
782
+ await writeText(themePath, `${JSON.stringify(toVendoTheme(themeSummary.slots), null, 2)}\n`);
783
+ }
784
+ await writeIfMissing(join(root, ".vendo", "data", ".gitignore"), "*\n!.gitignore\n", options.force === true);
785
+ pretty?.spin("Learning your API surface");
786
+ const scanStarted = Date.now();
787
+ const report = await vendoSync({ root, out: join(root, ".vendo") });
788
+ wiringMs += Date.now() - scanStarted;
789
+ pretty?.stopSpin();
790
+ for (const warning of report.warnings)
791
+ output.error(`warning: ${warning}`);
792
+ let toolCount = 0;
793
+ let routeCount = 0;
794
+ try {
795
+ const tools = JSON.parse(await readFile(join(root, ".vendo", "tools.json"), "utf8"));
796
+ toolCount = tools.tools?.length ?? 0;
797
+ routeCount = tools.tools?.filter((tool) => tool.binding?.kind === "route").length ?? 0;
798
+ }
799
+ catch {
800
+ // Sync already reported any extraction warning; telemetry gets a count only.
801
+ }
802
+ // Summary — what changed, what was learned.
803
+ if (changes.length > 0) {
804
+ output.log(`\nWired (${changes.length} file${changes.length === 1 ? "" : "s"}):`);
805
+ for (const change of changes) {
806
+ output.log(` ${change.before === null ? "+" : "~"} ${change.path}`);
807
+ }
808
+ }
809
+ else {
810
+ output.log("\nAlready wired — nothing to change.");
811
+ }
812
+ // Detection-as-advice (zero-question contract): a wired preset stays
813
+ // silent — the comment in the scaffold cites the escape hatch; none or
814
+ // ambiguous gets exactly one calm line naming the line to add.
815
+ if (authAdvice !== null)
816
+ output.log(authAdvice);
817
+ output.log(`Learned: ${toolCount} tools · theme captured → .vendo/ (tools.json, theme.json, brief.md)`);
818
+ // AI extraction (install-dx, staged): a coding agent surveys the repo,
819
+ // drafts each surface in a focused pass, cross-checks the combined draft,
820
+ // and drafts the brief — all into the override channel; deterministic
821
+ // guards decide what applies. Consent-gated; skipped silently when
822
+ // non-interactive or credential-less. A successful pass re-syncs so
823
+ // tools.json reflects the polish immediately.
824
+ const engineStarted = Date.now();
825
+ const polish = await runAiExtraction({
826
+ root,
827
+ output,
828
+ env: effectiveEnv,
829
+ yes: options.yes === true,
830
+ // --ai-polish IS the consent: no prompt, and non-interactive runs
831
+ // stop skipping.
832
+ ...(options.aiPolish === true ? { consent: true } : {}),
833
+ ...(options.force === true ? { force: true } : {}),
834
+ ...(options.engine === undefined ? {} : { engine: options.engine }),
835
+ ...(pretty === null ? {} : { confirm: pretty.confirm, choose: pretty.select }),
836
+ ...(themeCreatedThisRun && themeSummary !== null ? {
837
+ theme: {
838
+ needed: themeSummary.needed,
839
+ alreadyExact: Object.fromEntries(Object.entries(themeSummary.matched)
840
+ .filter(([, provenance]) => provenance.startsWith("--"))
841
+ .map(([slot]) => [slot, String(themeSummary.slots[slot])])),
842
+ evidencePaths: themeSummary.evidencePaths,
843
+ },
844
+ } : {}),
845
+ ...(options.extract ?? {}),
846
+ });
847
+ const engineMs = Date.now() - engineStarted;
848
+ // Theme finalization (Task 4): merge whatever the AI pass filled — if
849
+ // consent was declined or unavailable, `polish.theme` is simply absent
850
+ // and the exact-only summary stands — then --theme answers (a human
851
+ // "(you)" wins over a model value), the one-glance palette print, and
852
+ // finally the uncertain-slot review. Skipped entirely when theme.json
853
+ // pre-existed this run (nothing above ran either).
854
+ if (themeCreatedThisRun && themeSummary !== null) {
855
+ const summary = polish.theme === undefined ? themeSummary : applyThemeDraft(themeSummary, polish.theme);
856
+ // --theme answers land first; the review prompt then covers only the
857
+ // uncertain slots the flags left unanswered (non-interactive runs keep
858
+ // the extracted/merged values for those, exactly as before).
859
+ const answers = { ...(options.themeAnswers ?? {}) };
860
+ const unanswered = summary.uncertain.filter((entry) => !Object.hasOwn(answers, entry.slot));
861
+ if (unanswered.length > 0 && options.yes !== true) {
862
+ const reviewed = await (options.themeReview ?? defaultThemeReview)(unanswered.length === summary.uncertain.length ? summary : { ...summary, uncertain: unanswered });
863
+ for (const [slot, raw] of Object.entries(reviewed)) {
864
+ if (!Object.hasOwn(answers, slot))
865
+ answers[slot] = raw;
866
+ }
867
+ }
868
+ if (Object.keys(answers).length > 0) {
869
+ for (const [slot, raw] of Object.entries(answers)) {
870
+ if (!Object.hasOwn(summary.slots, slot)) {
871
+ output.error(`ignored unknown theme slot ${JSON.stringify(slot)}`);
872
+ continue;
873
+ }
874
+ const value = validateSlotValue(slot, raw);
875
+ if (value === null) {
876
+ output.error(`ignored invalid theme ${slot} value ${JSON.stringify(raw)}`);
877
+ }
878
+ else {
879
+ summary.slots[slot] = value;
880
+ summary.matched[slot] = "(you)";
881
+ // The slot no longer defaulted — the human just set it.
882
+ summary.defaulted = summary.defaulted.filter((name) => name !== slot);
883
+ }
884
+ }
885
+ // A replaced accent invalidates an accentText nobody chose — one that
886
+ // was contrast-derived, or still the neutral default because the
887
+ // model omitted the accent too. Re-derive against the new accent; an
888
+ // explicit token or a direct human/model answer stays authoritative.
889
+ const accentTextUnchosen = summary.matched["accentText"] === "(contrast) accent"
890
+ || summary.defaulted.includes("accentText");
891
+ if (summary.matched["accent"] === "(you)" && accentTextUnchosen) {
892
+ summary.slots.accentText = contrastingText(summary.slots.accent);
893
+ summary.matched["accentText"] = "(contrast) accent";
894
+ summary.defaulted = summary.defaulted.filter((name) => name !== "accentText");
895
+ }
896
+ }
897
+ await writeText(themePath, `${JSON.stringify(toVendoTheme(summary.slots), null, 2)}\n`);
898
+ printThemeSummary(summary, output);
899
+ }
900
+ if (polish.ran) {
901
+ const resynced = await vendoSync({ root, out: join(root, ".vendo") });
902
+ for (const warning of resynced.warnings)
903
+ output.error(`warning: ${warning}`);
904
+ }
905
+ // Project-shape enrichment (posthog-analytics §3): bools, closed enums,
906
+ // counts, and bare dependency versions only — never names or content.
907
+ await telemetry.track("init_completed", {
908
+ framework: plan.framework,
909
+ command: "init",
910
+ toolCount,
911
+ durationMs: Date.now() - started,
912
+ typescript: await exists(join(root, "tsconfig.json")),
913
+ router: await detectRouter(root, plan.framework),
914
+ // The engine that actually ran the AI polish; "none" when it didn't run.
915
+ engine: polish.engine ?? "none",
916
+ // route-scan today; "zod" is reserved for a future oracle-backed detect
917
+ // (the zod collector currently enriches route-scan output invisibly).
918
+ apiDetectMethod: routeCount > 0 ? "route-scan" : "none",
919
+ routeCount,
920
+ themeExtracted: themeSummary !== null,
921
+ ...(await detectDepVersions(root, plan.framework)),
922
+ // Cloud-lane-only props, passed unconditionally — the client strips
923
+ // every one of them in the anonymous lane.
924
+ detectMs,
925
+ engineMs,
926
+ ...(themeMs === undefined ? {} : { themeMs }),
927
+ wiringMs,
928
+ ...(await cloudProjectProps(root)),
929
+ });
930
+ // The one short Cloud reminder in the end-of-run summary — ONLY while no
931
+ // key exists (the full emphasized block already ran up top; no repeat).
932
+ if (credential.rung === "none") {
933
+ output.log("No model key yet: set ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY in .env.local, or run `vendo login` for a free dev key.");
934
+ }
935
+ // #478 short-term — npm installs the ai@7 peer conflict without failing
936
+ // and every internal turn then throws AI_InvalidPromptError; warn in the
937
+ // end-of-run summary instead of waiting for doctor to fail (E-DEP-001).
938
+ const aiVersion = await installedAiVersion(root);
939
+ if (aiVersion !== null && Number.parseInt(aiVersion, 10) >= 7) {
940
+ output.error(`warning: installed ai@${aiVersion} is unsupported — Vendo supports ai@6; downgrade (npm install ai@^6 @ai-sdk/anthropic@^3 @ai-sdk/react@^3) or track github.com/runvendo/vendo/issues/478`);
941
+ }
942
+ // Done — the one paste that is the user's, then their own dev server.
943
+ output.log("\nLast steps are yours:");
944
+ for (const line of manualSteps)
945
+ output.log(` ${line}`);
946
+ output.log("\nThen start your dev server — the agent is live in your app.");
947
+ output.log("Verify everything: `npx vendo doctor` (it can start the server and run a live turn).");
948
+ // Agent tail (agent-install-dx): the --yes-or-non-TTY path is agent-driven
949
+ // — the run's FINAL block is the repo-specific pointers an agent parses.
950
+ // Interactive human runs keep the clack-style output untouched; --agent
951
+ // never reaches here (its read-only JSON plan returned above).
952
+ if (options.yes === true || !interactive) {
953
+ output.log("\nAgent tail:");
954
+ const tail = await agentTailLines({ root, framework: plan.framework, registryPath, compositionPath, authWired, cloudKeyMissing: credential.rung === "none" });
955
+ for (const line of tail)
956
+ output.log(` ${line}`);
957
+ }
958
+ else {
959
+ // Star ask (agent-install-dx §CLI-5): the interactive success screen
960
+ // ends with ONE consent question — never shown non-interactively (the
961
+ // playbook owns the agent-path ask; deterministic runs stay that way),
962
+ // and never fatal: nothing in this step can change init's exit code.
963
+ // Yes stars via gh; any failure degrades to the repo URL, one line.
964
+ // No does nothing — no guilt text.
965
+ try {
966
+ // Consent guard: an unshown prompt is NEVER a yes. On a non-TTY
967
+ // stdin (programmatic `interactive: true`, `init < file`) both real
968
+ // confirms would resolve the default — pretty.confirm returns it,
969
+ // askYesNo would block — and starring is an account action, so the
970
+ // answer without a real keyboard is false, regardless of path.
971
+ const confirmStar = options.confirmStar
972
+ ?? (async (question, defaultYes) => stdin.isTTY === true
973
+ ? (pretty === null ? askYesNo : pretty.confirm)(question, defaultYes)
974
+ : false);
975
+ if (await confirmStar(`Star ${STAR_REPO} to support the project?`, true)) {
976
+ const starred = await starViaGh(options.spawnStar ?? ((command, args) => spawn(command, args, { stdio: "ignore" })));
977
+ if (!starred)
978
+ output.log(`Star it anytime: ${STAR_LINK}`);
979
+ // Star attribution: `starred` is an exact star-from-the-CLI signal
980
+ // (closed outcome enum; counts-and-enums promise holds).
981
+ await telemetry.track("star_prompt", { outcome: starred ? "starred" : "star-failed" });
982
+ }
983
+ else {
984
+ await telemetry.track("star_prompt", { outcome: "declined" });
985
+ }
986
+ }
987
+ catch {
988
+ // The ask is best-effort by design; init already succeeded.
989
+ }
990
+ }
991
+ pretty?.done(Date.now() - started, true);
992
+ return 0;
993
+ }
994
+ catch (error) {
995
+ await telemetry.track("init_failed", {
996
+ framework: plan.framework,
997
+ failedStep: "wiring",
998
+ errorClass: errorClass(error),
999
+ // Cloud lane only (stripped anonymously); scrubbed at the call site and
1000
+ // re-scrubbed by the client as defense-in-depth.
1001
+ errorDetail: scrubErrorDetail(error instanceof Error ? error.message : String(error)),
1002
+ });
1003
+ await telemetry.track("error_class", { errorClass: errorClass(error) });
1004
+ output.error(error instanceof Error ? error.message : "vendo init failed");
1005
+ pretty?.done(Date.now() - started, false);
1006
+ return 1;
1007
+ }
1008
+ }