@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/create-trove",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
--
|
|
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 = {
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
38
|
-
else if (a.startsWith('--
|
|
39
|
-
else if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
141
|
+
say(`\ncreate-trove ${version}\n`);
|
|
68
142
|
|
|
69
143
|
const dir = opts.dir || (interactive
|
|
70
|
-
? await
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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')
|
|
105
|
-
if (kind === 'incomplete-identity')
|
|
106
|
-
if (kind === 'default-open')
|
|
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
|
-
|
|
216
|
+
say('\n');
|
|
109
217
|
return 0;
|
|
110
218
|
} finally {
|
|
111
|
-
|
|
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
|
+
}
|