@3sln/create-trove 0.0.2 → 0.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/create-trove",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Trove deployment — Bun, Node, or Cloudflare Workers — with the bindings, environment and secrets each one needs.",
6
6
  "repository": {
@@ -24,7 +24,10 @@
24
24
  "create-trove": "./src/cli.js"
25
25
  },
26
26
  "exports": {
27
+ ".": "./src/index.js",
27
28
  "./plan": "./src/plan.js",
29
+ "./render": "./src/render.js",
30
+ "./prompt": "./src/prompt.js",
28
31
  "./package.json": "./package.json"
29
32
  },
30
33
  "files": [
package/src/cli.js CHANGED
@@ -8,35 +8,63 @@
8
8
  // together from one repository and one version number, so they are the same string by
9
9
  // construction — which means a scaffolded project can never pair a server with a
10
10
  // workbench from a different release, and there is no version to look up at runtime.
11
+ //
12
+ // It is also usable by something that cannot read a prompt. `--set key=value` supplies
13
+ // answers up front, `--json` puts a machine-readable result on stdout with every human
14
+ // word on stderr, and `--describe` lists the keys rather than making a caller read this
15
+ // file to find them. Keys are a flat namespace (`storage.bucket`) rather than the text
16
+ // of a question, because the wording of a question is not an interface and rewording a
17
+ // hint should not break a caller.
11
18
 
12
19
  import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises';
13
20
  import { existsSync } from 'node:fs';
14
21
  import path from 'node:path';
15
- import { createPrompter } from './prompt.js';
22
+ import { createPrompter, presetPrompter } from './prompt.js';
16
23
  import { askPlan, RUNTIMES } from './plan.js';
17
24
  import { renderProject } from './render.js';
25
+ import { describeQuestions } from './index.js';
18
26
 
19
27
  const USAGE = `Usage: npm create @3sln/trove <directory> [options]
20
28
 
21
29
  Options:
22
30
  --runtime <bun|node|workers> skip the first question
31
+ --set <key=value> answer one question; repeatable (see --describe)
32
+ --config <file.json> answer many, as a flat { "key": value } object
23
33
  --yes take every default, ask nothing
24
- --dry-run print what would be written, write nothing
34
+ --json machine-readable result on stdout, prose on stderr
35
+ --describe list every question key and exit
36
+ --dry-run report what would be written, write nothing
25
37
  --force write into a directory that is not empty
26
38
  --help this
39
+
40
+ Answers given by --set and --config are taken as-is; anything left over is asked
41
+ interactively, or defaulted when there is no terminal. A key that is never asked for
42
+ is an error, since it is either a typo or a setting the other answers ruled out.
27
43
  `;
28
44
 
29
45
  function parseArgs(argv) {
30
- const opts = { dir: null, runtime: null, yes: false, dryRun: false, force: false, help: false };
31
- for (let i = 0; i < argv.length; i++) {
32
- const a = argv[i];
46
+ const opts = {
47
+ dir: null, runtime: null, yes: false, dryRun: false, force: false,
48
+ help: false, json: false, describe: false, config: null, set: {},
49
+ };
50
+ const value = (a, i, flag) => (a.startsWith(`${flag}=`) ? a.slice(flag.length + 1) : argv[++i.v]);
51
+ for (const i = { v: 0 }; i.v < argv.length; i.v++) {
52
+ const a = argv[i.v];
33
53
  if (a === '--help' || a === '-h') opts.help = true;
34
54
  else if (a === '--yes' || a === '-y') opts.yes = true;
55
+ else if (a === '--json') opts.json = true;
56
+ else if (a === '--describe') opts.describe = true;
35
57
  else if (a === '--dry-run') opts.dryRun = true;
36
58
  else if (a === '--force') opts.force = true;
37
- else if (a === '--runtime') opts.runtime = argv[++i];
38
- else if (a.startsWith('--runtime=')) opts.runtime = a.slice('--runtime='.length);
39
- else if (!a.startsWith('-') && !opts.dir) opts.dir = a;
59
+ else if (a.startsWith('--runtime')) opts.runtime = value(a, i, '--runtime');
60
+ else if (a.startsWith('--config')) opts.config = value(a, i, '--config');
61
+ else if (a.startsWith('--set')) {
62
+ const raw = value(a, i, '--set');
63
+ const eq = String(raw ?? '').indexOf('=');
64
+ if (eq < 1) throw new Error(`--set wants key=value, got "${raw}"`);
65
+ opts.set[raw.slice(0, eq)] = raw.slice(eq + 1);
66
+ } else if (a.startsWith('-')) throw new Error(`Unknown option "${a}"`);
67
+ else if (!opts.dir) opts.dir = a;
40
68
  }
41
69
  return opts;
42
70
  }
@@ -46,28 +74,74 @@ const toPackageName = (dirName) =>
46
74
  dirName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '').replace(/-+$/, '') || 'trove-drive';
47
75
 
48
76
  async function main() {
49
- const opts = parseArgs(process.argv.slice(2));
77
+ let opts;
78
+ try {
79
+ opts = parseArgs(process.argv.slice(2));
80
+ } catch (err) {
81
+ process.stderr.write(`${err.message}\n\n${USAGE}`);
82
+ return 2;
83
+ }
50
84
  if (opts.help) {
51
85
  process.stdout.write(USAGE);
52
86
  return 0;
53
87
  }
54
88
  if (opts.runtime && !RUNTIMES.includes(opts.runtime)) {
55
89
  process.stderr.write(`--runtime must be one of: ${RUNTIMES.join(', ')}\n`);
56
- return 1;
90
+ return 2;
57
91
  }
58
92
 
59
93
  const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
60
94
 
95
+ // Prose goes to stderr under --json, so stdout is one parseable document and nothing
96
+ // else. Without that split a caller has to strip the banner before it can parse, which
97
+ // is the sort of thing that works until someone adds a line to the banner.
98
+ const say = opts.json ? (s) => process.stderr.write(s) : (s) => process.stdout.write(s);
99
+ const emit = (obj) => process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
100
+
101
+ if (opts.describe) {
102
+ const questions = await describeQuestions({ version });
103
+ if (opts.json) emit({ version, questions });
104
+ else {
105
+ process.stdout.write(`\nEvery question, by key. Use --set key=value.\n\n`);
106
+ for (const q of questions) {
107
+ const opt = q.options ? ` (${q.options.map((o) => o.value).join(' | ')})` : '';
108
+ process.stdout.write(` ${q.key.padEnd(30)} ${q.kind}${opt}\n`);
109
+ process.stdout.write(` ${''.padEnd(30)} ${q.label} — default ${JSON.stringify(q.default)}\n`);
110
+ if (q.runtimes.length < RUNTIMES.length) {
111
+ process.stdout.write(` ${''.padEnd(30)} only for: ${q.runtimes.join(', ')}\n`);
112
+ }
113
+ process.stdout.write('\n');
114
+ }
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ let answers = { ...opts.set };
120
+ if (opts.config) {
121
+ try {
122
+ const parsed = JSON.parse(await readFile(path.resolve(opts.config), 'utf8'));
123
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
124
+ throw new Error('expected a flat JSON object of key/value pairs');
125
+ }
126
+ // --set wins, so a config file can be a base that one flag overrides.
127
+ answers = { ...parsed, ...opts.set };
128
+ } catch (err) {
129
+ process.stderr.write(`Could not read ${opts.config}: ${err.message}\n`);
130
+ return 2;
131
+ }
132
+ }
133
+
61
134
  // A pipe rather than a terminal means nobody is there to answer, so take the defaults
62
135
  // instead of blocking forever on a read that will never return.
63
- const interactive = process.stdin.isTTY && !opts.yes;
64
- const prompter = createPrompter({ assumeDefaults: !interactive });
136
+ const interactive = process.stdin.isTTY && !opts.yes && !opts.json;
137
+ const base = createPrompter({ assumeDefaults: !interactive, output: opts.json ? process.stderr : process.stdout });
138
+ const prompter = presetPrompter(answers, base);
65
139
 
66
140
  try {
67
- process.stdout.write(`\ncreate-trove ${version}\n`);
141
+ say(`\ncreate-trove ${version}\n`);
68
142
 
69
143
  const dir = opts.dir || (interactive
70
- ? await prompter.text('\nProject directory', { default: 'my-drive' })
144
+ ? await base.text('\nProject directory', { default: 'my-drive' })
71
145
  : 'my-drive');
72
146
  const target = path.resolve(dir);
73
147
  const name = toPackageName(path.basename(target));
@@ -80,11 +154,28 @@ async function main() {
80
154
  }
81
155
  }
82
156
 
83
- const plan = await askPlan(prompter, { name, version, runtime: opts.runtime });
84
- const { files, steps } = renderProject(plan);
85
-
86
- process.stdout.write(`\n${opts.dryRun ? 'Would write' : 'Writing'} ${files.length} files to ${target}\n`);
87
- for (const f of files) process.stdout.write(` ${f.path}\n`);
157
+ let plan;
158
+ let rendered;
159
+ try {
160
+ plan = await askPlan(prompter, { name, version, runtime: opts.runtime });
161
+ rendered = renderProject(plan);
162
+ } catch (err) {
163
+ // A bad --set value: name the key rather than making someone map a stack trace
164
+ // back to a flag they typed.
165
+ process.stderr.write(`\n${err.message}\n`);
166
+ return 2;
167
+ }
168
+ const { files, steps } = rendered;
169
+
170
+ // Either it was a typo or the other answers ruled the question out. Both are worth
171
+ // refusing over: an agent that thinks it set a bucket should not get a drive with
172
+ // no bucket and a zero exit code.
173
+ const unused = prompter.unused();
174
+ if (unused.length) {
175
+ process.stderr.write(`\nThese answers were never asked for: ${unused.join(', ')}\n`);
176
+ process.stderr.write('Either the key is wrong (see --describe) or another answer ruled the question out.\n');
177
+ return 2;
178
+ }
88
179
 
89
180
  if (!opts.dryRun) {
90
181
  for (const f of files) {
@@ -94,21 +185,37 @@ async function main() {
94
185
  }
95
186
  }
96
187
 
97
- process.stdout.write('\nNext:\n');
98
- for (const s of steps) process.stdout.write(` ${s.cmd}${s.why ? ` # ${s.why}` : ''}\n`);
188
+ if (opts.json) {
189
+ emit({
190
+ version,
191
+ directory: target,
192
+ written: !opts.dryRun,
193
+ runtime: plan.runtime,
194
+ files: files.map((f) => f.path),
195
+ steps: steps.map((s) => ({ command: s.cmd, why: s.why || undefined })),
196
+ skipped: plan.skipped,
197
+ warnings: plan.warnings.map((w) => (typeof w === 'string' ? { kind: w } : w)),
198
+ });
199
+ return 0;
200
+ }
201
+
202
+ say(`\n${opts.dryRun ? 'Would write' : 'Writing'} ${files.length} files to ${target}\n`);
203
+ for (const f of files) say(` ${f.path}\n`);
204
+ say('\nNext:\n');
205
+ for (const s of steps) say(` ${s.cmd}${s.why ? ` # ${s.why}` : ''}\n`);
99
206
  if (plan.skipped.length) {
100
- process.stdout.write(`\nSkipped, and left commented in the config for you: ${plan.skipped.join(', ')}.\n`);
207
+ say(`\nSkipped, and left commented in the config for you: ${plan.skipped.join(', ')}.\n`);
101
208
  }
102
209
  for (const w of plan.warnings) {
103
210
  const kind = typeof w === 'string' ? w : w.kind;
104
- if (kind === 'anonymous') process.stdout.write('\nNo identity configured — anyone who can reach this has full access.\n');
105
- if (kind === 'incomplete-identity') process.stdout.write(`\nTROVE_AUTH=${w.driver} is set but ${w.missing.join(' and ')} left blank.\n`);
106
- if (kind === 'default-open') process.stdout.write('The default collection is open to every user.\n');
211
+ if (kind === 'anonymous') say('\nNo identity configured — anyone who can reach this has full access.\n');
212
+ if (kind === 'incomplete-identity') say(`\nTROVE_AUTH=${w.driver} is set but ${w.missing.join(' and ')} left blank.\n`);
213
+ if (kind === 'default-open') say('The default collection is open to every user.\n');
107
214
  }
108
- process.stdout.write('\n');
215
+ say('\n');
109
216
  return 0;
110
217
  } finally {
111
- prompter.close();
218
+ base.close();
112
219
  }
113
220
  }
114
221
 
package/src/index.js ADDED
@@ -0,0 +1,129 @@
1
+ // The programmatic surface.
2
+ //
3
+ // The CLI is one caller of this, not the only way in. `exports` previously named
4
+ // `./plan` alone, which was not usable: `askPlan` needs a prompter, and neither the
5
+ // prompter nor the renderer was exported. So the package advertised a library entry
6
+ // that could not build anything.
7
+ //
8
+ // Everything here is non-interactive by construction — no terminal is touched unless a
9
+ // caller passes a prompter that wants one. That is the shape an agent needs: supply the
10
+ // answers it knows, take defaults for the rest, and get files back as data rather than
11
+ // as a directory it then has to go and read.
12
+
13
+ import { presetPrompter, recordingPrompter, createPrompter } from './prompt.js';
14
+ import { askPlan, RUNTIMES } from './plan.js';
15
+ import { renderProject } from './render.js';
16
+
17
+ export { askPlan, renderProject, RUNTIMES };
18
+ export { createPrompter, presetPrompter, recordingPrompter, scripted } from './prompt.js';
19
+
20
+ /**
21
+ * Plan and render a project without asking anybody anything.
22
+ *
23
+ * @param {object} opts
24
+ * @param {string} opts.name project (and package) name
25
+ * @param {string} opts.version the @3sln/trove version to pin
26
+ * @param {string} [opts.runtime] bun | node | workers; also settable as answers.runtime
27
+ * @param {Record<string, string|boolean|number>} [opts.answers] keyed answers — see
28
+ * `describeQuestions()` for what the keys are
29
+ * @param {object} [opts.prompter] asked about anything `answers` does not cover;
30
+ * defaults to one that takes every default and never blocks
31
+ * @returns {Promise<{plan: object, files: Array, steps: Array, unused: string[]}>}
32
+ * `unused` is the answers that were never asked for — a typo, or a setting the other
33
+ * answers made unreachable. Both are worth surfacing rather than swallowing.
34
+ */
35
+ /** Somewhere for headings to go that is not a caller's stdout. */
36
+ const SILENT = { write() {}, isTTY: false };
37
+
38
+ export async function createProject({ name, version, runtime, answers = {}, prompter } = {}) {
39
+ // Silent unless the caller hands over a prompter that wants to talk. A library that
40
+ // prints section headings to stdout cannot be used inside anything that emits
41
+ // structured output — which is most of what would want to call this.
42
+ const base = prompter ?? createPrompter({ assumeDefaults: true, output: SILENT });
43
+ const preset = presetPrompter(answers, base);
44
+ const plan = await askPlan(preset, { name, version, runtime: runtime ?? undefined });
45
+ const { files, steps } = renderProject(plan);
46
+ return { plan, files, steps, unused: preset.unused() };
47
+ }
48
+
49
+ /**
50
+ * Every question the wizard can ask, by key.
51
+ *
52
+ * Generated by running the interview rather than kept by hand, because the interview
53
+ * branches on its own answers and a hand-written schema would drift from it the first
54
+ * time a question moved. Running it once per runtime with a recorder covers the three
55
+ * shapes that actually differ; a question only reachable under a non-default answer
56
+ * (Qdrant's URL, say) is reported against the answer that reveals it via `revealedBy`.
57
+ *
58
+ * @param {object} [opts]
59
+ * @param {string} [opts.version] only affects the plan, not the questions
60
+ */
61
+ export async function describeQuestions({ version = '0.0.0' } = {}) {
62
+ const byKey = new Map();
63
+
64
+ const run = async (runtime, answers) => {
65
+ const rec = recordingPrompter();
66
+ try {
67
+ // `runtime: undefined` lets the runtime question itself be asked and recorded;
68
+ // passing it skips the question, which is how it went undocumented at first.
69
+ await askPlan(presetPrompter(answers, rec), { name: 'example', version, runtime });
70
+ } catch {
71
+ // The combination does not exist — `storage.driver=filesystem` under Workers,
72
+ // which has no disk and so does not offer it. Not every pairing of answers is a
73
+ // reachable state, and probing for that is the point.
74
+ return false;
75
+ }
76
+ let discovered = false;
77
+ for (const q of rec.questions()) {
78
+ const seen = byKey.get(q.key);
79
+ if (!seen) {
80
+ byKey.set(q.key, { ...q, runtimes: [runtime ?? 'bun'] });
81
+ discovered = true;
82
+ } else {
83
+ if (runtime && !seen.runtimes.includes(runtime)) seen.runtimes.push(runtime);
84
+ // A choice can offer different options per runtime, so the description is the
85
+ // union — otherwise `--describe` would reject a value the wizard accepts.
86
+ for (const o of q.options ?? []) {
87
+ if (!seen.options.some((x) => x.value === o.value)) seen.options.push(o);
88
+ }
89
+ }
90
+ }
91
+ return discovered;
92
+ };
93
+
94
+ // The interview branches on its own answers, so one pass describes only the path the
95
+ // defaults take. Walking it to a fixed point — turn every section on, then try each
96
+ // alternative of every choice, then give every empty text field a value — reaches the
97
+ // questions that only exist under some other answer: Qdrant's URL, the JWT fields,
98
+ // the icon size that appears once an icon is named. It is bounded by the question set
99
+ // rather than by a guess, and it cannot drift from the code the way a hand-kept
100
+ // schema would.
101
+ await run(undefined, {});
102
+ for (const runtime of RUNTIMES) await run(runtime, {});
103
+
104
+ for (let round = 0; round < 4; round++) {
105
+ const on = Object.fromEntries([...byKey.keys()].filter((k) => k.endsWith('.enabled')).map((k) => [k, true]));
106
+ let discovered = false;
107
+
108
+ for (const runtime of RUNTIMES) {
109
+ if (await run(runtime, on)) discovered = true;
110
+
111
+ for (const q of [...byKey.values()]) {
112
+ // Each alternative of a choice, one at a time, so a reveal is attributable.
113
+ if (q.kind === 'choice') {
114
+ for (const opt of q.options) {
115
+ if (await run(runtime, { ...on, [q.key]: opt.value })) discovered = true;
116
+ }
117
+ }
118
+ // A field left blank by default usually gates something (an icon URL gates its
119
+ // size). Anything non-empty will do; the value is never used.
120
+ if (q.kind === 'text' && q.default === '') {
121
+ if (await run(runtime, { ...on, [q.key]: 'x' })) discovered = true;
122
+ }
123
+ }
124
+ }
125
+ if (!discovered) break;
126
+ }
127
+
128
+ return [...byKey.values()];
129
+ }
package/src/plan.js CHANGED
@@ -42,7 +42,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
42
42
  { value: 'bun', label: 'Bun', hint: 'recommended for self-hosting' },
43
43
  { value: 'node', label: 'Node', hint: 'identical behaviour, a little slower' },
44
44
  { value: 'workers', label: 'Cloudflare Workers', hint: 'no disk — D1, Vectorize and R2 do the work' },
45
- ], { default: 'bun' });
45
+ ], { key: 'runtime', default: 'bun' });
46
46
 
47
47
  const isWorkers = runtime === 'workers';
48
48
  const plan = { name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [] };
@@ -56,7 +56,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
56
56
  // Workers has no disk, so `filesystem` is not offered there — and R2 is reached
57
57
  // through the S3 API rather than a binding because that is what lets presigned
58
58
  // uploads go straight to the bucket instead of through the Worker's CPU time.
59
- if (await prompter.section('Object storage', {
59
+ if (await prompter.section('Object storage', { key: 'storage.enabled',
60
60
  blurb: isWorkers
61
61
  ? 'Where file bytes live. On Workers this is R2 through its S3-compatible API.'
62
62
  : 'Where file bytes live.',
@@ -68,24 +68,24 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
68
68
  { value: 'filesystem', label: 'Filesystem or NAS mount' },
69
69
  { value: 's3', label: 'S3-compatible', hint: 'AWS, R2, MinIO, B2' },
70
70
  { value: 'memory', label: 'In memory', hint: 'nothing is kept — demos only' },
71
- ], { default: isWorkers ? 's3' : 'filesystem' });
71
+ ], { key: 'storage.driver', default: isWorkers ? 's3' : 'filesystem' });
72
72
 
73
73
  const entries = [entry('TROVE_STORAGE', driver)];
74
74
  if (driver === 'filesystem') {
75
- entries.push(entry('TROVE_FS_ROOT', await prompter.text(' Object root', { default: './data/objects' }),
75
+ entries.push(entry('TROVE_FS_ROOT', await prompter.text(' Object root', { key: 'storage.root', default: './data/objects' }),
76
76
  { comment: 'the backend creates objects/ under this, sharded two levels deep' }));
77
77
  }
78
78
  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' }),
79
+ entries.push(entry('TROVE_S3_BUCKET', await prompter.text(' Bucket', { key: 'storage.bucket', default: 'trove' })));
80
+ entries.push(entry('TROVE_S3_REGION', await prompter.text(' Region', { key: 'storage.region', default: isWorkers ? 'auto' : 'us-east-1' }),
81
81
  { comment: 'R2 uses "auto"' }));
82
- entries.push(entry('TROVE_S3_ENDPOINT', await prompter.text(' Endpoint', {
82
+ entries.push(entry('TROVE_S3_ENDPOINT', await prompter.text(' Endpoint', { key: 'storage.endpoint',
83
83
  default: isWorkers ? 'https://<account-id>.r2.cloudflarestorage.com' : '',
84
84
  hint: 'leave blank for AWS S3',
85
85
  }), { 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 })) {
86
+ entries.push(entry('TROVE_S3_ACCESS_KEY_ID', await prompter.text(' Access key id', { key: 'storage.accessKeyId', default: '' }), { secret: true }));
87
+ entries.push(entry('TROVE_S3_SECRET_ACCESS_KEY', await prompter.text(' Secret access key', { key: 'storage.secretAccessKey', default: '' }), { secret: true }));
88
+ if (!isWorkers && await prompter.confirm(' Path-style addressing?', { key: 'storage.pathStyle', default: false })) {
89
89
  entries.push(entry('TROVE_S3_PATH_STYLE', 'true', { comment: 'MinIO and most custom endpoints' }));
90
90
  }
91
91
  }
@@ -104,14 +104,14 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
104
104
  // On Workers this is D1, which is a binding rather than a variable, so the question
105
105
  // moves to the bindings block below.
106
106
  if (!isWorkers) {
107
- if (await prompter.section('Metadata', { blurb: 'The file tree, collections, plugin installs and keyword index.' })) {
107
+ if (await prompter.section('Metadata', { key: 'metadata.enabled', blurb: 'The file tree, collections, plugin installs and keyword index.' })) {
108
108
  const driver = await prompter.choice(' Store', [
109
109
  { value: 'sqlite', label: 'SQLite file', hint: 'one file, backed up with a VACUUM INTO snapshot' },
110
110
  { value: 'memory', label: 'In memory', hint: 'lost on restart' },
111
- ], { default: 'sqlite' });
111
+ ], { key: 'metadata.driver', default: 'sqlite' });
112
112
  const entries = [entry('TROVE_METADATA', driver)];
113
113
  if (driver === 'sqlite') {
114
- entries.push(entry('TROVE_DB_PATH', await prompter.text(' Database path', { default: './data/trove.db' })));
114
+ entries.push(entry('TROVE_DB_PATH', await prompter.text(' Database path', { key: 'metadata.path', default: './data/trove.db' })));
115
115
  }
116
116
  add('Metadata', entries);
117
117
  } else {
@@ -123,19 +123,19 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
123
123
  }
124
124
 
125
125
  // --- search ----------------------------------------------------------------
126
- if (await prompter.section('Semantic search', {
126
+ if (await prompter.section('Semantic search', { key: 'search.enabled',
127
127
  blurb: 'Embeddings turn text into vectors; the vector store holds them. Both have working defaults.',
128
128
  })) {
129
129
  const entries = [];
130
130
  const embed = await prompter.choice(' Embeddings', [
131
131
  { value: 'builtin', label: 'Built-in hash embedding', hint: 'offline, no API key, weaker results' },
132
132
  { value: 'http', label: 'An HTTP embeddings API', hint: 'OpenAI-compatible' },
133
- ], { default: 'builtin' });
133
+ ], { key: 'search.embeddings', default: 'builtin' });
134
134
  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' }),
135
+ entries.push(entry('TROVE_EMBEDDINGS_URL', await prompter.text(' Embeddings URL', { key: 'search.embeddingsUrl', default: 'https://api.openai.com/v1/embeddings' })));
136
+ entries.push(entry('TROVE_EMBEDDINGS_API_KEY', await prompter.text(' API key', { key: 'search.embeddingsApiKey', default: '' }), { secret: true }));
137
+ entries.push(entry('TROVE_EMBEDDINGS_MODEL', await prompter.text(' Model', { key: 'search.embeddingsModel', default: 'text-embedding-3-small' })));
138
+ entries.push(entry('TROVE_EMBEDDINGS_DIM', await prompter.text(' Dimensions', { key: 'search.embeddingsDim', default: '1536' }),
139
139
  { comment: 'must match the model, and changing it means a reindex' }));
140
140
  }
141
141
 
@@ -147,12 +147,12 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
147
147
  const vector = await prompter.choice(' Vector store', [
148
148
  { value: 'memory', label: 'In process', hint: 'sqlite-vec if available, rebuilt on restart otherwise' },
149
149
  { value: 'qdrant', label: 'Qdrant' },
150
- ], { default: 'memory' });
150
+ ], { key: 'search.vector', default: 'memory' });
151
151
  entries.push(entry('TROVE_VECTOR', vector));
152
152
  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 }));
153
+ entries.push(entry('TROVE_QDRANT_URL', await prompter.text(' Qdrant URL', { key: 'search.qdrantUrl', default: 'http://localhost:6333' })));
154
+ entries.push(entry('TROVE_QDRANT_COLLECTION', await prompter.text(' Collection', { key: 'search.qdrantCollection', default: 'trove' })));
155
+ entries.push(entry('TROVE_QDRANT_API_KEY', await prompter.text(' API key', { key: 'search.qdrantApiKey', default: '' }), { secret: true }));
156
156
  }
157
157
  }
158
158
  add('Semantic search', entries);
@@ -166,7 +166,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
166
166
  // --- identity --------------------------------------------------------------
167
167
  // The one section where declining is genuinely dangerous, so the warning is attached
168
168
  // to the plan rather than left to the reader to infer.
169
- if (await prompter.section('Identity', {
169
+ if (await prompter.section('Identity', { key: 'identity.enabled',
170
170
  blurb: 'Trove ships no login — it verifies what an IdP or proxy already established.',
171
171
  })) {
172
172
  const driver = await prompter.choice(' Verify identity via', [
@@ -174,23 +174,23 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
174
174
  { value: 'jwt', label: 'A JWT from any OIDC provider', hint: 'verified against a JWKS' },
175
175
  { value: 'header', label: 'A header set by a verifying proxy' },
176
176
  { value: 'anonymous', label: 'Nobody', hint: 'everyone is the same anonymous user' },
177
- ], { default: isWorkers ? 'cloudflare-access' : 'anonymous' });
177
+ ], { key: 'identity.driver', default: isWorkers ? 'cloudflare-access' : 'anonymous' });
178
178
 
179
179
  const entries = [entry('TROVE_AUTH', driver)];
180
180
  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: '' })));
181
+ entries.push(entry('TROVE_CF_ACCESS_TEAM', await prompter.text(' Access team name', { key: 'identity.team', default: '', hint: 'the <team> in <team>.cloudflareaccess.com' })));
182
+ entries.push(entry('TROVE_CF_ACCESS_AUD', await prompter.text(' Application AUD tag', { key: 'identity.aud', default: '' })));
183
183
  // cloudflare-access is the one driver that requires auth unless told otherwise.
184
184
  entries.push(entry('TROVE_AUTH_REQUIRED', 'true', { comment: 'the default for this driver; "false" falls back to anonymous' }));
185
185
  } 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 }))));
186
+ entries.push(entry('TROVE_JWKS_URL', await prompter.text(' JWKS URL', { key: 'identity.jwksUrl', default: '' })));
187
+ entries.push(entry('TROVE_JWT_ISSUER', await prompter.text(' Issuer', { key: 'identity.issuer', default: '' })));
188
+ entries.push(entry('TROVE_JWT_AUDIENCE', await prompter.text(' Audience', { key: 'identity.audience', default: '' })));
189
+ entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { key: 'identity.required', default: true }))));
190
190
  } else if (driver === 'header') {
191
- entries.push(entry('TROVE_AUTH_ID_HEADER', await prompter.text(' Identity header', { default: 'cf-access-authenticated-user-email' }),
191
+ entries.push(entry('TROVE_AUTH_ID_HEADER', await prompter.text(' Identity header', { key: 'identity.header', default: 'cf-access-authenticated-user-email' }),
192
192
  { 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 }))));
193
+ entries.push(entry('TROVE_AUTH_REQUIRED', String(await prompter.confirm(' Reject unauthenticated requests?', { key: 'identity.required', default: true }))));
194
194
  }
195
195
  if (driver === 'anonymous') plan.warnings.push('anonymous');
196
196
  // Naming a driver whose settings were left blank is worse than naming none: the
@@ -213,9 +213,9 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
213
213
  }
214
214
 
215
215
  // --- 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 });
216
+ if (await prompter.section('Access control', { key: 'access.enabled', blurb: 'Who is an admin, and whether the default collection is open to everyone.' })) {
217
+ const admins = await prompter.text(' Admin principal ids', { key: 'access.admins', default: '', hint: 'comma-separated, usually email addresses' });
218
+ const open = await prompter.confirm(' Give everyone full access to the default collection?', { key: 'access.defaultOpen', default: false });
219
219
  add('Access control', [
220
220
  entry('TROVE_ADMINS', admins),
221
221
  entry('TROVE_DEFAULT_OPEN', String(open), { comment: 'false means the default collection is not world-writable' }),
@@ -234,19 +234,19 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
234
234
  // this is the one place a self-hoster gets to put their own name on the thing their
235
235
  // users install. Off by default: it is the only optional section here, and a drive
236
236
  // called "Trove" is a perfectly good drive.
237
- if (await prompter.section('Installed app name', {
237
+ if (await prompter.section('Installed app name', { key: 'app.enabled',
238
238
  blurb: 'What the browser calls this when someone installs it. Defaults to Trove.',
239
239
  default: false,
240
240
  })) {
241
- const appName = await prompter.text(' App name', { default: 'Trove' });
241
+ const appName = await prompter.text(' App name', { key: 'app.name', default: 'Trove' });
242
242
  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' });
243
+ const short = await prompter.text(' Short name', { key: 'app.shortName', default: '', hint: 'for a home-screen label; defaults to the app name' });
244
244
  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' });
245
+ entries.push(entry('TROVE_APP_THEME_COLOR', await prompter.text(' Theme colour', { key: 'app.themeColor', default: '#181a1f' })));
246
+ const icon = await prompter.text(' Icon URL', { key: 'app.icon', default: '', hint: 'leave blank for the built-in mark' });
247
247
  if (icon) {
248
248
  entries.push(entry('TROVE_APP_ICON', icon));
249
- entries.push(entry('TROVE_APP_ICON_SIZES', await prompter.text(' Icon size', {
249
+ entries.push(entry('TROVE_APP_ICON_SIZES', await prompter.text(' Icon size', { key: 'app.iconSizes',
250
250
  default: icon.endsWith('.svg') ? 'any' : '512x512',
251
251
  hint: 'a raster icon claiming "any" gets scaled badly',
252
252
  })));
@@ -265,9 +265,9 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
265
265
  plan.workers = await askWorkers(prompter);
266
266
  } else {
267
267
  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' });
268
+ if (await prompter.section('Server', { key: 'server.enabled', blurb: 'Port and bind address.', default: false })) {
269
+ plan.server.port = await prompter.text(' Port', { key: 'server.port', default: '8787' });
270
+ plan.server.host = await prompter.text(' Host', { key: 'server.host', default: '0.0.0.0' });
271
271
  }
272
272
  }
273
273
 
@@ -289,37 +289,37 @@ async function askWorkers(prompter) {
289
289
  compatibilityDate: '2024-09-23',
290
290
  };
291
291
 
292
- if (await prompter.section('D1 (metadata)', {
292
+ if (await prompter.section('D1 (metadata)', { key: 'workers.d1.enabled',
293
293
  blurb: 'Bind DB or the drive runs entirely in memory — fine until the isolate recycles, then everything is gone.',
294
294
  })) {
295
295
  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' }),
296
+ name: await prompter.text(' Database name', { key: 'workers.d1.name', default: 'trove' }),
297
+ id: await prompter.text(' Database id', { key: 'workers.d1.id', default: '', hint: 'from `wrangler d1 create` — leave blank to fill in after' }),
298
298
  };
299
- if (await prompter.confirm(' Bind a second D1 for server-side plugin storage?', { default: false })) {
299
+ if (await prompter.confirm(' Bind a second D1 for server-side plugin storage?', { key: 'workers.pluginDb.enabled', default: false })) {
300
300
  w.pluginDb = {
301
- name: await prompter.text(' Plugin database name', { default: 'trove-plugins' }),
302
- id: await prompter.text(' Plugin database id', { default: '' }),
301
+ name: await prompter.text(' Plugin database name', { key: 'workers.pluginDb.name', default: 'trove-plugins' }),
302
+ id: await prompter.text(' Plugin database id', { key: 'workers.pluginDb.id', default: '' }),
303
303
  };
304
304
  }
305
305
  }
306
306
 
307
- if (await prompter.section('Vectorize (semantic search)', {
307
+ if (await prompter.section('Vectorize (semantic search)', { key: 'workers.vectorize.enabled',
308
308
  blurb: 'sqlite-vec is a native artifact and cannot load here, so semantic search needs Vectorize.',
309
309
  })) {
310
310
  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' }),
311
+ index: await prompter.text(' Index name', { key: 'workers.vectorize.index', default: 'trove' }),
312
+ dimensions: await prompter.text(' Dimensions', { key: 'workers.vectorize.dimensions', default: '1536', hint: 'must match your embedding model' }),
313
313
  metric: await prompter.choice(' Distance metric', [
314
314
  { value: 'cosine', label: 'cosine' },
315
315
  { value: 'euclidean', label: 'euclidean' },
316
316
  { value: 'dot-product', label: 'dot-product' },
317
- ], { default: 'cosine' }),
317
+ ], { key: 'workers.vectorize.metric', default: 'cosine' }),
318
318
  };
319
319
  }
320
320
 
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 });
321
+ w.ai = await prompter.confirm('\nBind Workers AI for natural-language search queries?', { key: 'workers.ai', default: false });
322
+ w.tasks = await prompter.confirm('Bind the TroveTasks Durable Object for scans and reindexes?', { key: 'workers.tasks', default: true });
323
323
 
324
324
  return w;
325
325
  }
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
  *