@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/package.json +4 -1
- package/src/cli.js +135 -27
- package/src/index.js +130 -0
- package/src/plan.js +129 -62
- package/src/prompt.js +99 -0
- package/src/render.js +258 -13
- package/src/templates/localS3.js +259 -0
- package/src/templates/vapid.js +48 -0
- package/src/vapid.js +49 -0
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|