@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/create-trove",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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,29 @@ 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
+ plan.inPlace = target === process.cwd();
162
+ rendered = renderProject(plan);
163
+ } catch (err) {
164
+ // A bad --set value: name the key rather than making someone map a stack trace
165
+ // back to a flag they typed.
166
+ process.stderr.write(`\n${err.message}\n`);
167
+ return 2;
168
+ }
169
+ const { files, steps } = rendered;
170
+
171
+ // Either it was a typo or the other answers ruled the question out. Both are worth
172
+ // refusing over: an agent that thinks it set a bucket should not get a drive with
173
+ // no bucket and a zero exit code.
174
+ const unused = prompter.unused();
175
+ if (unused.length) {
176
+ process.stderr.write(`\nThese answers were never asked for: ${unused.join(', ')}\n`);
177
+ process.stderr.write('Either the key is wrong (see --describe) or another answer ruled the question out.\n');
178
+ return 2;
179
+ }
88
180
 
89
181
  if (!opts.dryRun) {
90
182
  for (const f of files) {
@@ -94,21 +186,37 @@ async function main() {
94
186
  }
95
187
  }
96
188
 
97
- process.stdout.write('\nNext:\n');
98
- for (const s of steps) process.stdout.write(` ${s.cmd}${s.why ? ` # ${s.why}` : ''}\n`);
189
+ if (opts.json) {
190
+ emit({
191
+ version,
192
+ directory: target,
193
+ written: !opts.dryRun,
194
+ runtime: plan.runtime,
195
+ files: files.map((f) => f.path),
196
+ steps: steps.map((s) => ({ command: s.cmd, why: s.why || undefined })),
197
+ skipped: plan.skipped,
198
+ warnings: plan.warnings.map((w) => (typeof w === 'string' ? { kind: w } : w)),
199
+ });
200
+ return 0;
201
+ }
202
+
203
+ say(`\n${opts.dryRun ? 'Would write' : 'Writing'} ${files.length} files to ${target}\n`);
204
+ for (const f of files) say(` ${f.path}\n`);
205
+ say('\nNext:\n');
206
+ for (const s of steps) say(` ${s.cmd}${s.why ? ` # ${s.why}` : ''}\n`);
99
207
  if (plan.skipped.length) {
100
- process.stdout.write(`\nSkipped, and left commented in the config for you: ${plan.skipped.join(', ')}.\n`);
208
+ say(`\nSkipped, and left commented in the config for you: ${plan.skipped.join(', ')}.\n`);
101
209
  }
102
210
  for (const w of plan.warnings) {
103
211
  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');
212
+ if (kind === 'anonymous') say('\nNo identity configured — anyone who can reach this has full access.\n');
213
+ if (kind === 'incomplete-identity') say(`\nTROVE_AUTH=${w.driver} is set but ${w.missing.join(' and ')} left blank.\n`);
214
+ if (kind === 'default-open') say('The default collection is open to every user.\n');
107
215
  }
108
- process.stdout.write('\n');
216
+ say('\n');
109
217
  return 0;
110
218
  } finally {
111
- prompter.close();
219
+ base.close();
112
220
  }
113
221
  }
114
222
 
package/src/index.js ADDED
@@ -0,0 +1,130 @@
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, inPlace = false } = {}) {
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
+ plan.inPlace = inPlace;
46
+ const { files, steps } = renderProject(plan);
47
+ return { plan, files, steps, unused: preset.unused() };
48
+ }
49
+
50
+ /**
51
+ * Every question the wizard can ask, by key.
52
+ *
53
+ * Generated by running the interview rather than kept by hand, because the interview
54
+ * branches on its own answers and a hand-written schema would drift from it the first
55
+ * time a question moved. Running it once per runtime with a recorder covers the three
56
+ * shapes that actually differ; a question only reachable under a non-default answer
57
+ * (Qdrant's URL, say) is reported against the answer that reveals it via `revealedBy`.
58
+ *
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.version] only affects the plan, not the questions
61
+ */
62
+ export async function describeQuestions({ version = '0.0.0' } = {}) {
63
+ const byKey = new Map();
64
+
65
+ const run = async (runtime, answers) => {
66
+ const rec = recordingPrompter();
67
+ try {
68
+ // `runtime: undefined` lets the runtime question itself be asked and recorded;
69
+ // passing it skips the question, which is how it went undocumented at first.
70
+ await askPlan(presetPrompter(answers, rec), { name: 'example', version, runtime });
71
+ } catch {
72
+ // The combination does not exist — `storage.driver=filesystem` under Workers,
73
+ // which has no disk and so does not offer it. Not every pairing of answers is a
74
+ // reachable state, and probing for that is the point.
75
+ return false;
76
+ }
77
+ let discovered = false;
78
+ for (const q of rec.questions()) {
79
+ const seen = byKey.get(q.key);
80
+ if (!seen) {
81
+ byKey.set(q.key, { ...q, runtimes: [runtime ?? 'bun'] });
82
+ discovered = true;
83
+ } else {
84
+ if (runtime && !seen.runtimes.includes(runtime)) seen.runtimes.push(runtime);
85
+ // A choice can offer different options per runtime, so the description is the
86
+ // union — otherwise `--describe` would reject a value the wizard accepts.
87
+ for (const o of q.options ?? []) {
88
+ if (!seen.options.some((x) => x.value === o.value)) seen.options.push(o);
89
+ }
90
+ }
91
+ }
92
+ return discovered;
93
+ };
94
+
95
+ // The interview branches on its own answers, so one pass describes only the path the
96
+ // defaults take. Walking it to a fixed point — turn every section on, then try each
97
+ // alternative of every choice, then give every empty text field a value — reaches the
98
+ // questions that only exist under some other answer: Qdrant's URL, the JWT fields,
99
+ // the icon size that appears once an icon is named. It is bounded by the question set
100
+ // rather than by a guess, and it cannot drift from the code the way a hand-kept
101
+ // schema would.
102
+ await run(undefined, {});
103
+ for (const runtime of RUNTIMES) await run(runtime, {});
104
+
105
+ for (let round = 0; round < 4; round++) {
106
+ const on = Object.fromEntries([...byKey.keys()].filter((k) => k.endsWith('.enabled')).map((k) => [k, true]));
107
+ let discovered = false;
108
+
109
+ for (const runtime of RUNTIMES) {
110
+ if (await run(runtime, on)) discovered = true;
111
+
112
+ for (const q of [...byKey.values()]) {
113
+ // Each alternative of a choice, one at a time, so a reveal is attributable.
114
+ if (q.kind === 'choice') {
115
+ for (const opt of q.options) {
116
+ if (await run(runtime, { ...on, [q.key]: opt.value })) discovered = true;
117
+ }
118
+ }
119
+ // A field left blank by default usually gates something (an icon URL gates its
120
+ // size). Anything non-empty will do; the value is never used.
121
+ if (q.kind === 'text' && q.default === '') {
122
+ if (await run(runtime, { ...on, [q.key]: 'x' })) discovered = true;
123
+ }
124
+ }
125
+ }
126
+ if (!discovered) break;
127
+ }
128
+
129
+ return [...byKey.values()];
130
+ }