@3sln/create-trove 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan.js CHANGED
@@ -17,8 +17,18 @@
17
17
  // step on Workers and an untracked `.env` line elsewhere — it never lands in a file
18
18
  // that belongs in version control. `secret: true` on an entry is what carries that.
19
19
 
20
+ import { generateVapidKeys } from './vapid.js';
21
+
20
22
  export const RUNTIMES = ['bun', 'node', 'workers'];
21
23
 
24
+ // LocalHashEmbedding's dimension (see core/src/search/embeddings.js). Not a default
25
+ // anyone should be asked to confirm: with the built-in embedding this IS the number, and
26
+ // a Vectorize index created at any other size accepts the deploy and then rejects every
27
+ // vector write — so search returns nothing, forever, without a single error anywhere a
28
+ // user would look. The wizard knows which embedding was chosen, so it derives this
29
+ // rather than asking a question whose wrong answer is invisible.
30
+ export const BUILTIN_EMBEDDING_DIM = '256';
31
+
22
32
  /** An environment/config entry. `secret` keeps it out of anything committed. */
23
33
  const entry = (key, value, { comment, secret = false, commented = false } = {}) =>
24
34
  ({ key, value, comment, secret, commented });
@@ -35,17 +45,25 @@ const placeholder = (key, comment) => entry(key, '', { comment, commented: true
35
45
  * @param {string} opts.version the @3sln/trove version to pin (this package's own —
36
46
  * the two are released together, so they are the same number by construction)
37
47
  * @param {string} [opts.runtime] pre-answered by --runtime
48
+ * @param {() => Promise<{publicKey: string, privateKey: string}>} [opts.generateKeys]
49
+ * how a local VAPID pair is minted. Injected so this stays the pure, transcript-driven
50
+ * function it is elsewhere — a real key pair is random, and a test asserting on the
51
+ * plan cannot assert on randomness.
38
52
  * @returns {Promise<object>} the plan
39
53
  */
40
- export async function askPlan(prompter, { name, version, runtime: preset }) {
54
+ export async function askPlan(prompter, { name, version, runtime: preset, generateKeys = generateVapidKeys }) {
41
55
  const runtime = preset ?? await prompter.choice('Where will this run?', [
42
56
  { value: 'bun', label: 'Bun', hint: 'recommended for self-hosting' },
43
57
  { value: 'node', label: 'Node', hint: 'identical behaviour, a little slower' },
44
58
  { value: 'workers', label: 'Cloudflare Workers', hint: 'no disk — D1, Vectorize and R2 do the work' },
45
- ], { default: 'bun' });
59
+ ], { key: 'runtime', default: 'bun' });
46
60
 
47
61
  const isWorkers = runtime === 'workers';
48
- const plan = { name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [] };
62
+ const plan = {
63
+ name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [],
64
+ // Overwritten only if an HTTP embedding names its own size.
65
+ embeddingDim: BUILTIN_EMBEDDING_DIM,
66
+ };
49
67
 
50
68
  const add = (title, entries, { skipped = false } = {}) => {
51
69
  plan.sections.push({ title, entries, skipped });
@@ -56,7 +74,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
56
74
  // Workers has no disk, so `filesystem` is not offered there — and R2 is reached
57
75
  // through the S3 API rather than a binding because that is what lets presigned
58
76
  // uploads go straight to the bucket instead of through the Worker's CPU time.
59
- if (await prompter.section('Object storage', {
77
+ if (await prompter.section('Object storage', { key: 'storage.enabled',
60
78
  blurb: isWorkers
61
79
  ? 'Where file bytes live. On Workers this is R2 through its S3-compatible API.'
62
80
  : 'Where file bytes live.',
@@ -68,24 +86,24 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
68
86
  { value: 'filesystem', label: 'Filesystem or NAS mount' },
69
87
  { value: 's3', label: 'S3-compatible', hint: 'AWS, R2, MinIO, B2' },
70
88
  { value: 'memory', label: 'In memory', hint: 'nothing is kept — demos only' },
71
- ], { default: isWorkers ? 's3' : 'filesystem' });
89
+ ], { key: 'storage.driver', default: isWorkers ? 's3' : 'filesystem' });
72
90
 
73
91
  const entries = [entry('TROVE_STORAGE', driver)];
74
92
  if (driver === 'filesystem') {
75
- entries.push(entry('TROVE_FS_ROOT', await prompter.text(' Object root', { default: './data/objects' }),
93
+ entries.push(entry('TROVE_FS_ROOT', await prompter.text(' Object root', { key: 'storage.root', default: './data/objects' }),
76
94
  { comment: 'the backend creates objects/ under this, sharded two levels deep' }));
77
95
  }
78
96
  if (driver === 's3') {
79
- entries.push(entry('TROVE_S3_BUCKET', await prompter.text(' Bucket', { default: 'trove' })));
80
- entries.push(entry('TROVE_S3_REGION', await prompter.text(' Region', { default: isWorkers ? 'auto' : 'us-east-1' }),
97
+ entries.push(entry('TROVE_S3_BUCKET', await prompter.text(' Bucket', { key: 'storage.bucket', default: 'trove' })));
98
+ entries.push(entry('TROVE_S3_REGION', await prompter.text(' Region', { key: 'storage.region', default: isWorkers ? 'auto' : 'us-east-1' }),
81
99
  { comment: 'R2 uses "auto"' }));
82
- entries.push(entry('TROVE_S3_ENDPOINT', await prompter.text(' Endpoint', {
100
+ entries.push(entry('TROVE_S3_ENDPOINT', await prompter.text(' Endpoint', { key: 'storage.endpoint',
83
101
  default: isWorkers ? 'https://<account-id>.r2.cloudflarestorage.com' : '',
84
102
  hint: 'leave blank for AWS S3',
85
103
  }), { comment: 'omit for AWS' }));
86
- entries.push(entry('TROVE_S3_ACCESS_KEY_ID', await prompter.text(' Access key id', { default: '' }), { secret: true }));
87
- entries.push(entry('TROVE_S3_SECRET_ACCESS_KEY', await prompter.text(' Secret access key', { default: '' }), { secret: true }));
88
- if (!isWorkers && await prompter.confirm(' Path-style addressing?', { default: false })) {
104
+ entries.push(entry('TROVE_S3_ACCESS_KEY_ID', await prompter.text(' Access key id', { key: 'storage.accessKeyId', default: '' }), { secret: true }));
105
+ entries.push(entry('TROVE_S3_SECRET_ACCESS_KEY', await prompter.text(' Secret access key', { key: 'storage.secretAccessKey', default: '' }), { secret: true }));
106
+ if (!isWorkers && await prompter.confirm(' Path-style addressing?', { key: 'storage.pathStyle', default: false })) {
89
107
  entries.push(entry('TROVE_S3_PATH_STYLE', 'true', { comment: 'MinIO and most custom endpoints' }));
90
108
  }
91
109
  }
@@ -104,14 +122,14 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
104
122
  // On Workers this is D1, which is a binding rather than a variable, so the question
105
123
  // moves to the bindings block below.
106
124
  if (!isWorkers) {
107
- if (await prompter.section('Metadata', { blurb: 'The file tree, collections, plugin installs and keyword index.' })) {
125
+ if (await prompter.section('Metadata', { key: 'metadata.enabled', blurb: 'The file tree, collections, plugin installs and keyword index.' })) {
108
126
  const driver = await prompter.choice(' Store', [
109
127
  { value: 'sqlite', label: 'SQLite file', hint: 'one file, backed up with a VACUUM INTO snapshot' },
110
128
  { value: 'memory', label: 'In memory', hint: 'lost on restart' },
111
- ], { default: 'sqlite' });
129
+ ], { key: 'metadata.driver', default: 'sqlite' });
112
130
  const entries = [entry('TROVE_METADATA', driver)];
113
131
  if (driver === 'sqlite') {
114
- entries.push(entry('TROVE_DB_PATH', await prompter.text(' Database path', { default: './data/trove.db' })));
132
+ entries.push(entry('TROVE_DB_PATH', await prompter.text(' Database path', { key: 'metadata.path', default: './data/trove.db' })));
115
133
  }
116
134
  add('Metadata', entries);
117
135
  } else {
@@ -123,19 +141,20 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
123
141
  }
124
142
 
125
143
  // --- search ----------------------------------------------------------------
126
- if (await prompter.section('Semantic search', {
144
+ if (await prompter.section('Semantic search', { key: 'search.enabled',
127
145
  blurb: 'Embeddings turn text into vectors; the vector store holds them. Both have working defaults.',
128
146
  })) {
129
147
  const entries = [];
130
148
  const embed = await prompter.choice(' Embeddings', [
131
149
  { value: 'builtin', label: 'Built-in hash embedding', hint: 'offline, no API key, weaker results' },
132
150
  { value: 'http', label: 'An HTTP embeddings API', hint: 'OpenAI-compatible' },
133
- ], { default: 'builtin' });
151
+ ], { key: 'search.embeddings', default: 'builtin' });
134
152
  if (embed === 'http') {
135
- entries.push(entry('TROVE_EMBEDDINGS_URL', await prompter.text(' Embeddings URL', { default: 'https://api.openai.com/v1/embeddings' })));
136
- entries.push(entry('TROVE_EMBEDDINGS_API_KEY', await prompter.text(' API key', { default: '' }), { secret: true }));
137
- entries.push(entry('TROVE_EMBEDDINGS_MODEL', await prompter.text(' Model', { default: 'text-embedding-3-small' })));
138
- entries.push(entry('TROVE_EMBEDDINGS_DIM', await prompter.text(' Dimensions', { default: '1536' }),
153
+ entries.push(entry('TROVE_EMBEDDINGS_URL', await prompter.text(' Embeddings URL', { key: 'search.embeddingsUrl', default: 'https://api.openai.com/v1/embeddings' })));
154
+ entries.push(entry('TROVE_EMBEDDINGS_API_KEY', await prompter.text(' API key', { key: 'search.embeddingsApiKey', default: '' }), { secret: true }));
155
+ entries.push(entry('TROVE_EMBEDDINGS_MODEL', await prompter.text(' Model', { key: 'search.embeddingsModel', default: 'text-embedding-3-small' })));
156
+ plan.embeddingDim = await prompter.text(' Dimensions', { key: 'search.embeddingsDim', default: '1536' });
157
+ entries.push(entry('TROVE_EMBEDDINGS_DIM', plan.embeddingDim,
139
158
  { comment: 'must match the model, and changing it means a reindex' }));
140
159
  }
141
160
 
@@ -147,12 +166,12 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
147
166
  const vector = await prompter.choice(' Vector store', [
148
167
  { value: 'memory', label: 'In process', hint: 'sqlite-vec if available, rebuilt on restart otherwise' },
149
168
  { value: 'qdrant', label: 'Qdrant' },
150
- ], { default: 'memory' });
169
+ ], { key: 'search.vector', default: 'memory' });
151
170
  entries.push(entry('TROVE_VECTOR', vector));
152
171
  if (vector === 'qdrant') {
153
- entries.push(entry('TROVE_QDRANT_URL', await prompter.text(' Qdrant URL', { default: 'http://localhost:6333' })));
154
- entries.push(entry('TROVE_QDRANT_COLLECTION', await prompter.text(' Collection', { default: 'trove' })));
155
- entries.push(entry('TROVE_QDRANT_API_KEY', await prompter.text(' API key', { default: '' }), { secret: true }));
172
+ entries.push(entry('TROVE_QDRANT_URL', await prompter.text(' Qdrant URL', { key: 'search.qdrantUrl', default: 'http://localhost:6333' })));
173
+ entries.push(entry('TROVE_QDRANT_COLLECTION', await prompter.text(' Collection', { key: 'search.qdrantCollection', default: 'trove' })));
174
+ entries.push(entry('TROVE_QDRANT_API_KEY', await prompter.text(' API key', { key: 'search.qdrantApiKey', default: '' }), { secret: true }));
156
175
  }
157
176
  }
158
177
  add('Semantic search', entries);
@@ -166,7 +185,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
166
185
  // --- identity --------------------------------------------------------------
167
186
  // The one section where declining is genuinely dangerous, so the warning is attached
168
187
  // to the plan rather than left to the reader to infer.
169
- if (await prompter.section('Identity', {
188
+ if (await prompter.section('Identity', { key: 'identity.enabled',
170
189
  blurb: 'Trove ships no login — it verifies what an IdP or proxy already established.',
171
190
  })) {
172
191
  const driver = await prompter.choice(' Verify identity via', [
@@ -174,23 +193,23 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
174
193
  { value: 'jwt', label: 'A JWT from any OIDC provider', hint: 'verified against a JWKS' },
175
194
  { value: 'header', label: 'A header set by a verifying proxy' },
176
195
  { value: 'anonymous', label: 'Nobody', hint: 'everyone is the same anonymous user' },
177
- ], { default: isWorkers ? 'cloudflare-access' : 'anonymous' });
196
+ ], { key: 'identity.driver', default: isWorkers ? 'cloudflare-access' : 'anonymous' });
178
197
 
179
198
  const entries = [entry('TROVE_AUTH', driver)];
180
199
  if (driver === 'cloudflare-access') {
181
- entries.push(entry('TROVE_CF_ACCESS_TEAM', await prompter.text(' Access team name', { default: '', hint: 'the <team> in <team>.cloudflareaccess.com' })));
182
- entries.push(entry('TROVE_CF_ACCESS_AUD', await prompter.text(' Application AUD tag', { default: '' })));
200
+ entries.push(entry('TROVE_CF_ACCESS_TEAM', await prompter.text(' Access team name', { key: 'identity.team', default: '', hint: 'the <team> in <team>.cloudflareaccess.com' })));
201
+ entries.push(entry('TROVE_CF_ACCESS_AUD', await prompter.text(' Application AUD tag', { key: 'identity.aud', default: '' })));
183
202
  // cloudflare-access is the one driver that requires auth unless told otherwise.
184
203
  entries.push(entry('TROVE_AUTH_REQUIRED', 'true', { comment: 'the default for this driver; "false" falls back to anonymous' }));
185
204
  } else if (driver === 'jwt') {
186
- entries.push(entry('TROVE_JWKS_URL', await prompter.text(' JWKS URL', { default: '' })));
187
- entries.push(entry('TROVE_JWT_ISSUER', await prompter.text(' Issuer', { default: '' })));
188
- entries.push(entry('TROVE_JWT_AUDIENCE', await prompter.text(' Audience', { default: '' })));
189
- entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { default: true }))));
205
+ entries.push(entry('TROVE_JWKS_URL', await prompter.text(' JWKS URL', { key: 'identity.jwksUrl', default: '' })));
206
+ entries.push(entry('TROVE_JWT_ISSUER', await prompter.text(' Issuer', { key: 'identity.issuer', default: '' })));
207
+ entries.push(entry('TROVE_JWT_AUDIENCE', await prompter.text(' Audience', { key: 'identity.audience', default: '' })));
208
+ entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { key: 'identity.required', default: true }))));
190
209
  } else if (driver === 'header') {
191
- entries.push(entry('TROVE_AUTH_ID_HEADER', await prompter.text(' Identity header', { default: 'cf-access-authenticated-user-email' }),
210
+ entries.push(entry('TROVE_AUTH_ID_HEADER', await prompter.text(' Identity header', { key: 'identity.header', default: 'cf-access-authenticated-user-email' }),
192
211
  { comment: 'only safe behind a proxy that sets this and strips it from client requests' }));
193
- entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { default: true }))));
212
+ entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { key: 'identity.required', default: true }))));
194
213
  }
195
214
  if (driver === 'anonymous') plan.warnings.push('anonymous');
196
215
  // Naming a driver whose settings were left blank is worse than naming none: the
@@ -213,9 +232,9 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
213
232
  }
214
233
 
215
234
  // --- access control --------------------------------------------------------
216
- if (await prompter.section('Access control', { blurb: 'Who is an admin, and whether the default collection is open to everyone.' })) {
217
- const admins = await prompter.text(' Admin principal ids', { default: '', hint: 'comma-separated, usually email addresses' });
218
- const open = await prompter.confirm(' Give everyone full access to the default collection?', { default: false });
235
+ if (await prompter.section('Access control', { key: 'access.enabled', blurb: 'Who is an admin, and whether the default collection is open to everyone.' })) {
236
+ const admins = await prompter.text(' Admin principal ids', { key: 'access.admins', default: '', hint: 'comma-separated, usually email addresses' });
237
+ const open = await prompter.confirm(' Give everyone full access to the default collection?', { key: 'access.defaultOpen', default: false });
219
238
  add('Access control', [
220
239
  entry('TROVE_ADMINS', admins),
221
240
  entry('TROVE_DEFAULT_OPEN', String(open), { comment: 'false means the default collection is not world-writable' }),
@@ -229,24 +248,68 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
229
248
  ], { skipped: true });
230
249
  }
231
250
 
251
+ // --- notifications ---------------------------------------------------------
252
+ // Off by default, and harmless to decline: mentions reach the in-app inbox either
253
+ // way. What VAPID adds is the ping — a browser waking a service worker while the
254
+ // drive is closed. The push carries no text (the worker fetches the inbox over its
255
+ // own authenticated connection), so declining costs a banner and nothing else.
256
+ if (await prompter.section('Push notifications', { key: 'notify.enabled',
257
+ blurb: 'Web push when someone @mentions you. The in-app inbox works without it.',
258
+ default: false,
259
+ })) {
260
+ // A LOCAL pair, minted here, written only to the gitignored .dev.vars. VAPID keys
261
+ // are self-issued — no account, no network — so unlike an R2 credential there is
262
+ // nothing to go and fetch, and making the developer find a way to produce a P-256
263
+ // point before they can try the feature is friction with nothing on the other side
264
+ // of it.
265
+ //
266
+ // Separate from production on purpose. A key identifies an application server, and
267
+ // these are two different servers; keeping them apart also means the value sitting
268
+ // on a laptop is worth nothing if it leaks.
269
+ plan.devVapid = await generateKeys();
270
+
271
+ // The production PUBLIC key only. The private half is never asked for: the answer
272
+ // would be written to disk by a program whose whole job is writing files, and a
273
+ // production signing key has no business in a scaffolder's output. It goes straight
274
+ // from `npm run vapid` into `wrangler secret put`, and the step for that is emitted
275
+ // whether or not this is filled in — see the blank-secret entry below.
276
+ const publicKey = await prompter.text(' Production public key', { key: 'notify.publicKey', default: '',
277
+ hint: 'leave blank — `npm run vapid` prints a pair once the project is installed',
278
+ });
279
+ add('Push notifications', [
280
+ entry('TROVE_VAPID_PUBLIC_KEY', publicKey,
281
+ { comment: 'must be the pair of the TROVE_VAPID_PRIVATE_KEY secret' }),
282
+ entry('TROVE_VAPID_PRIVATE_KEY', '', { secret: true }),
283
+ entry('TROVE_VAPID_SUBJECT', await prompter.text(' Contact subject', { key: 'notify.subject', default: 'mailto:admin@example.com',
284
+ hint: 'mailto: or https URL — how a push service reaches you about your own traffic',
285
+ })),
286
+ ]);
287
+ } else {
288
+ add('Push notifications', [
289
+ placeholder('TROVE_VAPID_PUBLIC_KEY', 'both keys set enables web push; the inbox works either way'),
290
+ placeholder('TROVE_VAPID_PRIVATE_KEY', 'a credential — set it with `wrangler secret put`, not here'),
291
+ placeholder('TROVE_VAPID_SUBJECT', 'mailto: or https URL; defaults to mailto:admin@example.com'),
292
+ ], { skipped: true });
293
+ }
294
+
232
295
  // --- branding --------------------------------------------------------------
233
296
  // The manifest is generated from configuration rather than served from a file, so
234
297
  // this is the one place a self-hoster gets to put their own name on the thing their
235
298
  // users install. Off by default: it is the only optional section here, and a drive
236
299
  // called "Trove" is a perfectly good drive.
237
- if (await prompter.section('Installed app name', {
300
+ if (await prompter.section('Installed app name', { key: 'app.enabled',
238
301
  blurb: 'What the browser calls this when someone installs it. Defaults to Trove.',
239
302
  default: false,
240
303
  })) {
241
- const appName = await prompter.text(' App name', { default: 'Trove' });
304
+ const appName = await prompter.text(' App name', { key: 'app.name', default: 'Trove' });
242
305
  const entries = [entry('TROVE_APP_NAME', appName)];
243
- const short = await prompter.text(' Short name', { default: '', hint: 'for a home-screen label; defaults to the app name' });
306
+ const short = await prompter.text(' Short name', { key: 'app.shortName', default: '', hint: 'for a home-screen label; defaults to the app name' });
244
307
  if (short) entries.push(entry('TROVE_APP_SHORT_NAME', short));
245
- entries.push(entry('TROVE_APP_THEME_COLOR', await prompter.text(' Theme colour', { default: '#181a1f' })));
246
- const icon = await prompter.text(' Icon URL', { default: '', hint: 'leave blank for the built-in mark' });
308
+ entries.push(entry('TROVE_APP_THEME_COLOR', await prompter.text(' Theme colour', { key: 'app.themeColor', default: '#181a1f' })));
309
+ const icon = await prompter.text(' Icon URL', { key: 'app.icon', default: '', hint: 'leave blank for the built-in mark' });
247
310
  if (icon) {
248
311
  entries.push(entry('TROVE_APP_ICON', icon));
249
- entries.push(entry('TROVE_APP_ICON_SIZES', await prompter.text(' Icon size', {
312
+ entries.push(entry('TROVE_APP_ICON_SIZES', await prompter.text(' Icon size', { key: 'app.iconSizes',
250
313
  default: icon.endsWith('.svg') ? 'any' : '512x512',
251
314
  hint: 'a raster icon claiming "any" gets scaled badly',
252
315
  })));
@@ -262,12 +325,12 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
262
325
 
263
326
  // --- runtime specifics -----------------------------------------------------
264
327
  if (isWorkers) {
265
- plan.workers = await askWorkers(prompter);
328
+ plan.workers = await askWorkers(prompter, { embeddingDim: plan.embeddingDim });
266
329
  } else {
267
330
  plan.server = { port: '8787', host: '0.0.0.0' };
268
- if (await prompter.section('Server', { blurb: 'Port and bind address.', default: false })) {
269
- plan.server.port = await prompter.text(' Port', { default: '8787' });
270
- plan.server.host = await prompter.text(' Host', { default: '0.0.0.0' });
331
+ if (await prompter.section('Server', { key: 'server.enabled', blurb: 'Port and bind address.', default: false })) {
332
+ plan.server.port = await prompter.text(' Port', { key: 'server.port', default: '8787' });
333
+ plan.server.host = await prompter.text(' Host', { key: 'server.host', default: '0.0.0.0' });
271
334
  }
272
335
  }
273
336
 
@@ -283,43 +346,47 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
283
346
  * the single most common way a first Workers deploy fails, and it fails at request time
284
347
  * rather than at deploy time.
285
348
  */
286
- async function askWorkers(prompter) {
349
+ async function askWorkers(prompter, { embeddingDim }) {
287
350
  const w = {
288
351
  d1: null, pluginDb: null, vectorize: null, ai: false, tasks: true,
289
- compatibilityDate: '2024-09-23',
352
+ // nodejs_compat v2 needs 2024-09-23 or later; that exact floor was also the hardcoded
353
+ // value, which made it two years stale on the day it shipped.
354
+ compatibilityDate: '2026-07-01',
290
355
  };
291
356
 
292
- if (await prompter.section('D1 (metadata)', {
357
+ if (await prompter.section('D1 (metadata)', { key: 'workers.d1.enabled',
293
358
  blurb: 'Bind DB or the drive runs entirely in memory — fine until the isolate recycles, then everything is gone.',
294
359
  })) {
295
360
  w.d1 = {
296
- name: await prompter.text(' Database name', { default: 'trove' }),
297
- id: await prompter.text(' Database id', { default: '', hint: 'from `wrangler d1 create` — leave blank to fill in after' }),
361
+ name: await prompter.text(' Database name', { key: 'workers.d1.name', default: 'trove' }),
362
+ id: await prompter.text(' Database id', { key: 'workers.d1.id', default: '', hint: 'from `wrangler d1 create` — leave blank to fill in after' }),
298
363
  };
299
- if (await prompter.confirm(' Bind a second D1 for server-side plugin storage?', { default: false })) {
364
+ if (await prompter.confirm(' Bind a second D1 for server-side plugin storage?', { key: 'workers.pluginDb.enabled', default: false })) {
300
365
  w.pluginDb = {
301
- name: await prompter.text(' Plugin database name', { default: 'trove-plugins' }),
302
- id: await prompter.text(' Plugin database id', { default: '' }),
366
+ name: await prompter.text(' Plugin database name', { key: 'workers.pluginDb.name', default: 'trove-plugins' }),
367
+ id: await prompter.text(' Plugin database id', { key: 'workers.pluginDb.id', default: '' }),
303
368
  };
304
369
  }
305
370
  }
306
371
 
307
- if (await prompter.section('Vectorize (semantic search)', {
372
+ if (await prompter.section('Vectorize (semantic search)', { key: 'workers.vectorize.enabled',
308
373
  blurb: 'sqlite-vec is a native artifact and cannot load here, so semantic search needs Vectorize.',
309
374
  })) {
310
375
  w.vectorize = {
311
- index: await prompter.text(' Index name', { default: 'trove' }),
312
- dimensions: await prompter.text(' Dimensions', { default: '1536', hint: 'must match your embedding model' }),
376
+ index: await prompter.text(' Index name', { key: 'workers.vectorize.index', default: 'trove' }),
377
+ // Derived, not asked. It has exactly one correct value the dimension of the
378
+ // embedding chosen above — and getting it wrong fails silently.
379
+ dimensions: embeddingDim,
313
380
  metric: await prompter.choice(' Distance metric', [
314
381
  { value: 'cosine', label: 'cosine' },
315
382
  { value: 'euclidean', label: 'euclidean' },
316
383
  { value: 'dot-product', label: 'dot-product' },
317
- ], { default: 'cosine' }),
384
+ ], { key: 'workers.vectorize.metric', default: 'cosine' }),
318
385
  };
319
386
  }
320
387
 
321
- w.ai = await prompter.confirm('\nBind Workers AI for natural-language search queries?', { default: false });
322
- w.tasks = await prompter.confirm('Bind the TroveTasks Durable Object for scans and reindexes?', { default: true });
388
+ w.ai = await prompter.confirm('\nBind Workers AI for natural-language search queries?', { key: 'workers.ai', default: false });
389
+ w.tasks = await prompter.confirm('Bind the TroveTasks Durable Object for scans and reindexes?', { key: 'workers.tasks', default: true });
323
390
 
324
391
  return w;
325
392
  }
package/src/prompt.js CHANGED
@@ -111,6 +111,105 @@ export function createPrompter({ input = process.stdin, output = process.stdout,
111
111
  };
112
112
  }
113
113
 
114
+ // --- non-interactive drivers -------------------------------------------------
115
+ //
116
+ // Everything below is the same interface, which is the point: the wizard does not know
117
+ // whether a person, a test transcript, a `--set` flag or nobody at all is answering it.
118
+ //
119
+ // These two are what make the tool usable by something that is not a human. An agent
120
+ // cannot read a blurb and type a bucket name, so it supplies answers up front by key —
121
+ // `storage.bucket`, not " Bucket". Keys are stable; the wording of a question is not,
122
+ // and pinning an interface to prose means rewording a hint breaks callers.
123
+
124
+ const TRUE = new Set(['true', 'yes', 'y', '1', 'on']);
125
+ const FALSE = new Set(['false', 'no', 'n', '0', 'off']);
126
+
127
+ function toBool(raw, key) {
128
+ const v = String(raw).trim().toLowerCase();
129
+ if (TRUE.has(v)) return true;
130
+ if (FALSE.has(v)) return false;
131
+ throw new Error(`${key}: expected a boolean, got "${raw}"`);
132
+ }
133
+
134
+ /**
135
+ * Answer from a map of keys, and ask `inner` about anything not supplied.
136
+ *
137
+ * Unused keys are an error rather than a shrug — see `unused()`. A key that was never
138
+ * consumed is either a typo or a setting that the other answers made unreachable
139
+ * (`storage.bucket` when the backend is `filesystem`), and both are things the caller
140
+ * wants told to them rather than silently dropped.
141
+ *
142
+ * @param {Record<string, string|number|boolean>} answers
143
+ * @param {object} inner the prompter to fall back to
144
+ */
145
+ export function presetPrompter(answers, inner) {
146
+ const supplied = new Map(Object.entries(answers ?? {}).map(([k, v]) => [k, v]));
147
+ const used = new Set();
148
+
149
+ const take = (key) => {
150
+ if (key === undefined || !supplied.has(key)) return undefined;
151
+ used.add(key);
152
+ return supplied.get(key);
153
+ };
154
+
155
+ return {
156
+ close: () => inner.close(),
157
+ heading: (t) => inner.heading(t),
158
+ note: (t) => inner.note(t),
159
+ /** Keys that were given but never asked for. */
160
+ unused: () => [...supplied.keys()].filter((k) => !used.has(k)),
161
+
162
+ async text(label, opts = {}) {
163
+ const v = take(opts.key);
164
+ return v === undefined ? inner.text(label, opts) : String(v);
165
+ },
166
+ async choice(label, options, opts = {}) {
167
+ const v = take(opts.key);
168
+ if (v === undefined) return inner.choice(label, options, opts);
169
+ const wanted = String(v);
170
+ if (!options.some((o) => o.value === wanted)) {
171
+ throw new Error(`${opts.key}: "${wanted}" is not one of ${options.map((o) => o.value).join(', ')}`);
172
+ }
173
+ return wanted;
174
+ },
175
+ async confirm(label, opts = {}) {
176
+ const v = take(opts.key);
177
+ return v === undefined ? inner.confirm(label, opts) : toBool(v, opts.key);
178
+ },
179
+ async section(title, opts = {}) {
180
+ const v = take(opts.key);
181
+ return v === undefined ? inner.section(title, opts) : toBool(v, opts.key);
182
+ },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Ask nothing, answer with defaults, and write down every question it was asked.
188
+ *
189
+ * This is how `--describe` works. The interview branches on its own answers, so there is
190
+ * no static schema to print — but running it with a recorder produces the questions that
191
+ * are actually reachable, which is the honest version of the same thing and cannot drift
192
+ * from the code the way a hand-kept list would.
193
+ */
194
+ export function recordingPrompter() {
195
+ const seen = [];
196
+ const record = (kind, label, opts, value, options) => {
197
+ if (opts.key) seen.push({ key: opts.key, kind, label: label.trim(), default: value, ...(options ? { options } : {}) });
198
+ return value;
199
+ };
200
+ return {
201
+ close() {}, heading() {}, note() {},
202
+ questions: () => seen,
203
+ async text(label, opts = {}) { return record('text', label, opts, opts.default ?? ''); },
204
+ async choice(label, options, opts = {}) {
205
+ return record('choice', label, opts, opts.default ?? options[0].value,
206
+ options.map((o) => ({ value: o.value, label: o.label })));
207
+ },
208
+ async confirm(label, opts = {}) { return record('boolean', label, opts, opts.default ?? true); },
209
+ async section(title, opts = {}) { return record('boolean', title, opts, opts.default ?? true); },
210
+ };
211
+ }
212
+
114
213
  /**
115
214
  * A prompter that reads from a list instead of a person.
116
215
  *