@3sln/create-trove 0.0.2
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 +36 -0
- package/src/cli.js +115 -0
- package/src/plan.js +325 -0
- package/src/prompt.js +144 -0
- package/src/render.js +355 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@3sln/create-trove",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Scaffold a Trove deployment — Bun, Node, or Cloudflare Workers — with the bindings, environment and secrets each one needs.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/3sln/trove.git",
|
|
9
|
+
"directory": "packages/create-trove"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Ray Stubbs",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"trove",
|
|
15
|
+
"scaffold",
|
|
16
|
+
"create",
|
|
17
|
+
"cloudflare workers",
|
|
18
|
+
"self-hosted"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"create-trove": "./src/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
"./plan": "./src/plan.js",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// npm create @3sln/trove my-drive
|
|
4
|
+
//
|
|
5
|
+
// Asks where the drive will run, then writes a project that runs there.
|
|
6
|
+
//
|
|
7
|
+
// The version this pins @3sln/trove at is *this package's own*. The two are released
|
|
8
|
+
// together from one repository and one version number, so they are the same string by
|
|
9
|
+
// construction — which means a scaffolded project can never pair a server with a
|
|
10
|
+
// workbench from a different release, and there is no version to look up at runtime.
|
|
11
|
+
|
|
12
|
+
import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createPrompter } from './prompt.js';
|
|
16
|
+
import { askPlan, RUNTIMES } from './plan.js';
|
|
17
|
+
import { renderProject } from './render.js';
|
|
18
|
+
|
|
19
|
+
const USAGE = `Usage: npm create @3sln/trove <directory> [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--runtime <bun|node|workers> skip the first question
|
|
23
|
+
--yes take every default, ask nothing
|
|
24
|
+
--dry-run print what would be written, write nothing
|
|
25
|
+
--force write into a directory that is not empty
|
|
26
|
+
--help this
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
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];
|
|
33
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
34
|
+
else if (a === '--yes' || a === '-y') opts.yes = true;
|
|
35
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
36
|
+
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;
|
|
40
|
+
}
|
|
41
|
+
return opts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** npm package names: no leading dot/underscore, no uppercase, no spaces. */
|
|
45
|
+
const toPackageName = (dirName) =>
|
|
46
|
+
dirName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+/, '').replace(/-+$/, '') || 'trove-drive';
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
50
|
+
if (opts.help) {
|
|
51
|
+
process.stdout.write(USAGE);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (opts.runtime && !RUNTIMES.includes(opts.runtime)) {
|
|
55
|
+
process.stderr.write(`--runtime must be one of: ${RUNTIMES.join(', ')}\n`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
60
|
+
|
|
61
|
+
// A pipe rather than a terminal means nobody is there to answer, so take the defaults
|
|
62
|
+
// 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 });
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
process.stdout.write(`\ncreate-trove ${version}\n`);
|
|
68
|
+
|
|
69
|
+
const dir = opts.dir || (interactive
|
|
70
|
+
? await prompter.text('\nProject directory', { default: 'my-drive' })
|
|
71
|
+
: 'my-drive');
|
|
72
|
+
const target = path.resolve(dir);
|
|
73
|
+
const name = toPackageName(path.basename(target));
|
|
74
|
+
|
|
75
|
+
if (existsSync(target) && !opts.force) {
|
|
76
|
+
const entries = await readdir(target);
|
|
77
|
+
if (entries.length) {
|
|
78
|
+
process.stderr.write(`\n${target} is not empty. Use --force to write into it anyway.\n`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
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`);
|
|
88
|
+
|
|
89
|
+
if (!opts.dryRun) {
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
const dest = path.join(target, f.path);
|
|
92
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
93
|
+
await writeFile(dest, f.contents);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
process.stdout.write('\nNext:\n');
|
|
98
|
+
for (const s of steps) process.stdout.write(` ${s.cmd}${s.why ? ` # ${s.why}` : ''}\n`);
|
|
99
|
+
if (plan.skipped.length) {
|
|
100
|
+
process.stdout.write(`\nSkipped, and left commented in the config for you: ${plan.skipped.join(', ')}.\n`);
|
|
101
|
+
}
|
|
102
|
+
for (const w of plan.warnings) {
|
|
103
|
+
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');
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write('\n');
|
|
109
|
+
return 0;
|
|
110
|
+
} finally {
|
|
111
|
+
prompter.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.exitCode = await main();
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// The interview.
|
|
2
|
+
//
|
|
3
|
+
// Answers only — nothing here touches the filesystem, and nothing here knows what a
|
|
4
|
+
// wrangler.toml looks like. That split is what lets the whole wizard be tested from a
|
|
5
|
+
// transcript (see test/wizard.test.js): drive it with a scripted prompter, assert on the
|
|
6
|
+
// plan, and never mkdir anything.
|
|
7
|
+
//
|
|
8
|
+
// Two rules shape the questions:
|
|
9
|
+
//
|
|
10
|
+
// Skipping is an answer, not an escape. Every section can be declined, and declining
|
|
11
|
+
// still puts the section in the generated config — commented, with the keys that
|
|
12
|
+
// belong in it and a line saying what they are for. Someone who already knows their
|
|
13
|
+
// R2 credentials should not be interviewed about them, and someone who does not should
|
|
14
|
+
// not be blocked from getting a project on disk.
|
|
15
|
+
//
|
|
16
|
+
// Secrets are never values. A credential answered here becomes a `wrangler secret put`
|
|
17
|
+
// step on Workers and an untracked `.env` line elsewhere — it never lands in a file
|
|
18
|
+
// that belongs in version control. `secret: true` on an entry is what carries that.
|
|
19
|
+
|
|
20
|
+
export const RUNTIMES = ['bun', 'node', 'workers'];
|
|
21
|
+
|
|
22
|
+
/** An environment/config entry. `secret` keeps it out of anything committed. */
|
|
23
|
+
const entry = (key, value, { comment, secret = false, commented = false } = {}) =>
|
|
24
|
+
({ key, value, comment, secret, commented });
|
|
25
|
+
|
|
26
|
+
/** A declined section still emits its keys, commented, so the file documents itself. */
|
|
27
|
+
const placeholder = (key, comment) => entry(key, '', { comment, commented: true });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run the interview.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} prompter from ./prompt.js — real or scripted
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.name project directory name
|
|
35
|
+
* @param {string} opts.version the @3sln/trove version to pin (this package's own —
|
|
36
|
+
* the two are released together, so they are the same number by construction)
|
|
37
|
+
* @param {string} [opts.runtime] pre-answered by --runtime
|
|
38
|
+
* @returns {Promise<object>} the plan
|
|
39
|
+
*/
|
|
40
|
+
export async function askPlan(prompter, { name, version, runtime: preset }) {
|
|
41
|
+
const runtime = preset ?? await prompter.choice('Where will this run?', [
|
|
42
|
+
{ value: 'bun', label: 'Bun', hint: 'recommended for self-hosting' },
|
|
43
|
+
{ value: 'node', label: 'Node', hint: 'identical behaviour, a little slower' },
|
|
44
|
+
{ value: 'workers', label: 'Cloudflare Workers', hint: 'no disk — D1, Vectorize and R2 do the work' },
|
|
45
|
+
], { default: 'bun' });
|
|
46
|
+
|
|
47
|
+
const isWorkers = runtime === 'workers';
|
|
48
|
+
const plan = { name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [] };
|
|
49
|
+
|
|
50
|
+
const add = (title, entries, { skipped = false } = {}) => {
|
|
51
|
+
plan.sections.push({ title, entries, skipped });
|
|
52
|
+
if (skipped) plan.skipped.push(title);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// --- storage ---------------------------------------------------------------
|
|
56
|
+
// Workers has no disk, so `filesystem` is not offered there — and R2 is reached
|
|
57
|
+
// through the S3 API rather than a binding because that is what lets presigned
|
|
58
|
+
// uploads go straight to the bucket instead of through the Worker's CPU time.
|
|
59
|
+
if (await prompter.section('Object storage', {
|
|
60
|
+
blurb: isWorkers
|
|
61
|
+
? 'Where file bytes live. On Workers this is R2 through its S3-compatible API.'
|
|
62
|
+
: 'Where file bytes live.',
|
|
63
|
+
})) {
|
|
64
|
+
const driver = await prompter.choice(' Backend', isWorkers ? [
|
|
65
|
+
{ value: 's3', label: 'R2 / S3-compatible' },
|
|
66
|
+
{ value: 'memory', label: 'In memory', hint: 'lost when the isolate recycles — demos only' },
|
|
67
|
+
] : [
|
|
68
|
+
{ value: 'filesystem', label: 'Filesystem or NAS mount' },
|
|
69
|
+
{ value: 's3', label: 'S3-compatible', hint: 'AWS, R2, MinIO, B2' },
|
|
70
|
+
{ value: 'memory', label: 'In memory', hint: 'nothing is kept — demos only' },
|
|
71
|
+
], { default: isWorkers ? 's3' : 'filesystem' });
|
|
72
|
+
|
|
73
|
+
const entries = [entry('TROVE_STORAGE', driver)];
|
|
74
|
+
if (driver === 'filesystem') {
|
|
75
|
+
entries.push(entry('TROVE_FS_ROOT', await prompter.text(' Object root', { default: './data/objects' }),
|
|
76
|
+
{ comment: 'the backend creates objects/ under this, sharded two levels deep' }));
|
|
77
|
+
}
|
|
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' }),
|
|
81
|
+
{ comment: 'R2 uses "auto"' }));
|
|
82
|
+
entries.push(entry('TROVE_S3_ENDPOINT', await prompter.text(' Endpoint', {
|
|
83
|
+
default: isWorkers ? 'https://<account-id>.r2.cloudflarestorage.com' : '',
|
|
84
|
+
hint: 'leave blank for AWS S3',
|
|
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 })) {
|
|
89
|
+
entries.push(entry('TROVE_S3_PATH_STYLE', 'true', { comment: 'MinIO and most custom endpoints' }));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
add('Object storage', entries);
|
|
93
|
+
} else {
|
|
94
|
+
add('Object storage', [
|
|
95
|
+
placeholder('TROVE_STORAGE', `memory | ${isWorkers ? '' : 'filesystem | '}s3 — defaults to memory, which keeps nothing`),
|
|
96
|
+
...(isWorkers ? [] : [placeholder('TROVE_FS_ROOT', 'filesystem root, e.g. ./data/objects')]),
|
|
97
|
+
placeholder('TROVE_S3_BUCKET', 'for TROVE_STORAGE=s3'),
|
|
98
|
+
placeholder('TROVE_S3_REGION', '"auto" for R2'),
|
|
99
|
+
placeholder('TROVE_S3_ENDPOINT', 'e.g. https://<account-id>.r2.cloudflarestorage.com'),
|
|
100
|
+
], { skipped: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- metadata --------------------------------------------------------------
|
|
104
|
+
// On Workers this is D1, which is a binding rather than a variable, so the question
|
|
105
|
+
// moves to the bindings block below.
|
|
106
|
+
if (!isWorkers) {
|
|
107
|
+
if (await prompter.section('Metadata', { blurb: 'The file tree, collections, plugin installs and keyword index.' })) {
|
|
108
|
+
const driver = await prompter.choice(' Store', [
|
|
109
|
+
{ value: 'sqlite', label: 'SQLite file', hint: 'one file, backed up with a VACUUM INTO snapshot' },
|
|
110
|
+
{ value: 'memory', label: 'In memory', hint: 'lost on restart' },
|
|
111
|
+
], { default: 'sqlite' });
|
|
112
|
+
const entries = [entry('TROVE_METADATA', driver)];
|
|
113
|
+
if (driver === 'sqlite') {
|
|
114
|
+
entries.push(entry('TROVE_DB_PATH', await prompter.text(' Database path', { default: './data/trove.db' })));
|
|
115
|
+
}
|
|
116
|
+
add('Metadata', entries);
|
|
117
|
+
} else {
|
|
118
|
+
add('Metadata', [
|
|
119
|
+
placeholder('TROVE_METADATA', 'memory | sqlite — defaults to memory, which is lost on restart'),
|
|
120
|
+
placeholder('TROVE_DB_PATH', 'e.g. ./data/trove.db'),
|
|
121
|
+
], { skipped: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- search ----------------------------------------------------------------
|
|
126
|
+
if (await prompter.section('Semantic search', {
|
|
127
|
+
blurb: 'Embeddings turn text into vectors; the vector store holds them. Both have working defaults.',
|
|
128
|
+
})) {
|
|
129
|
+
const entries = [];
|
|
130
|
+
const embed = await prompter.choice(' Embeddings', [
|
|
131
|
+
{ value: 'builtin', label: 'Built-in hash embedding', hint: 'offline, no API key, weaker results' },
|
|
132
|
+
{ value: 'http', label: 'An HTTP embeddings API', hint: 'OpenAI-compatible' },
|
|
133
|
+
], { default: 'builtin' });
|
|
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' }),
|
|
139
|
+
{ comment: 'must match the model, and changing it means a reindex' }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (isWorkers) {
|
|
143
|
+
// sqlite-vec is a native artifact and cannot load on Workers, so there is no
|
|
144
|
+
// in-process option to fall back to — Vectorize is the only vector store here.
|
|
145
|
+
entries.push(entry('TROVE_VECTOR', 'vectorize', { comment: 'the VECTORIZE binding is picked up automatically' }));
|
|
146
|
+
} else {
|
|
147
|
+
const vector = await prompter.choice(' Vector store', [
|
|
148
|
+
{ value: 'memory', label: 'In process', hint: 'sqlite-vec if available, rebuilt on restart otherwise' },
|
|
149
|
+
{ value: 'qdrant', label: 'Qdrant' },
|
|
150
|
+
], { default: 'memory' });
|
|
151
|
+
entries.push(entry('TROVE_VECTOR', vector));
|
|
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 }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
add('Semantic search', entries);
|
|
159
|
+
} else {
|
|
160
|
+
add('Semantic search', [
|
|
161
|
+
placeholder('TROVE_EMBEDDINGS_URL', 'unset uses the built-in offline hash embedding'),
|
|
162
|
+
placeholder('TROVE_VECTOR', isWorkers ? 'vectorize — sqlite-vec cannot run on Workers' : 'memory | qdrant | vectorize'),
|
|
163
|
+
], { skipped: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- identity --------------------------------------------------------------
|
|
167
|
+
// The one section where declining is genuinely dangerous, so the warning is attached
|
|
168
|
+
// to the plan rather than left to the reader to infer.
|
|
169
|
+
if (await prompter.section('Identity', {
|
|
170
|
+
blurb: 'Trove ships no login — it verifies what an IdP or proxy already established.',
|
|
171
|
+
})) {
|
|
172
|
+
const driver = await prompter.choice(' Verify identity via', [
|
|
173
|
+
{ value: 'cloudflare-access', label: 'Cloudflare Access / Zero Trust' },
|
|
174
|
+
{ value: 'jwt', label: 'A JWT from any OIDC provider', hint: 'verified against a JWKS' },
|
|
175
|
+
{ value: 'header', label: 'A header set by a verifying proxy' },
|
|
176
|
+
{ value: 'anonymous', label: 'Nobody', hint: 'everyone is the same anonymous user' },
|
|
177
|
+
], { default: isWorkers ? 'cloudflare-access' : 'anonymous' });
|
|
178
|
+
|
|
179
|
+
const entries = [entry('TROVE_AUTH', driver)];
|
|
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: '' })));
|
|
183
|
+
// cloudflare-access is the one driver that requires auth unless told otherwise.
|
|
184
|
+
entries.push(entry('TROVE_AUTH_REQUIRED', 'true', { comment: 'the default for this driver; "false" falls back to anonymous' }));
|
|
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 }))));
|
|
190
|
+
} else if (driver === 'header') {
|
|
191
|
+
entries.push(entry('TROVE_AUTH_ID_HEADER', await prompter.text(' Identity header', { default: 'cf-access-authenticated-user-email' }),
|
|
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 }))));
|
|
194
|
+
}
|
|
195
|
+
if (driver === 'anonymous') plan.warnings.push('anonymous');
|
|
196
|
+
// Naming a driver whose settings were left blank is worse than naming none: the
|
|
197
|
+
// config looks configured. `cloudflare-access` in particular refuses every request
|
|
198
|
+
// it cannot verify, so a blank team is not an open door but a closed one — and
|
|
199
|
+
// either way the reason is a value nobody filled in.
|
|
200
|
+
const REQUIRED = {
|
|
201
|
+
'cloudflare-access': ['TROVE_CF_ACCESS_TEAM', 'TROVE_CF_ACCESS_AUD'],
|
|
202
|
+
jwt: ['TROVE_JWKS_URL', 'TROVE_JWT_ISSUER', 'TROVE_JWT_AUDIENCE'],
|
|
203
|
+
};
|
|
204
|
+
const missing = (REQUIRED[driver] ?? []).filter((k) => !entries.find((e) => e.key === k)?.value);
|
|
205
|
+
if (missing.length) plan.warnings.push({ kind: 'incomplete-identity', driver, missing });
|
|
206
|
+
add('Identity', entries);
|
|
207
|
+
} else {
|
|
208
|
+
plan.warnings.push('anonymous');
|
|
209
|
+
add('Identity', [
|
|
210
|
+
placeholder('TROVE_AUTH', 'anonymous | jwt | cloudflare-access | header — defaults to anonymous'),
|
|
211
|
+
placeholder('TROVE_AUTH_REQUIRED', 'true to reject unauthenticated requests'),
|
|
212
|
+
], { skipped: true });
|
|
213
|
+
}
|
|
214
|
+
|
|
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 });
|
|
219
|
+
add('Access control', [
|
|
220
|
+
entry('TROVE_ADMINS', admins),
|
|
221
|
+
entry('TROVE_DEFAULT_OPEN', String(open), { comment: 'false means the default collection is not world-writable' }),
|
|
222
|
+
]);
|
|
223
|
+
if (open) plan.warnings.push('default-open');
|
|
224
|
+
} else {
|
|
225
|
+
plan.warnings.push('default-open');
|
|
226
|
+
add('Access control', [
|
|
227
|
+
placeholder('TROVE_ADMINS', 'comma-separated principal ids with admin'),
|
|
228
|
+
placeholder('TROVE_DEFAULT_OPEN', 'false — otherwise everyone gets the default collection'),
|
|
229
|
+
], { skipped: true });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- branding --------------------------------------------------------------
|
|
233
|
+
// The manifest is generated from configuration rather than served from a file, so
|
|
234
|
+
// this is the one place a self-hoster gets to put their own name on the thing their
|
|
235
|
+
// users install. Off by default: it is the only optional section here, and a drive
|
|
236
|
+
// called "Trove" is a perfectly good drive.
|
|
237
|
+
if (await prompter.section('Installed app name', {
|
|
238
|
+
blurb: 'What the browser calls this when someone installs it. Defaults to Trove.',
|
|
239
|
+
default: false,
|
|
240
|
+
})) {
|
|
241
|
+
const appName = await prompter.text(' App name', { default: 'Trove' });
|
|
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' });
|
|
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' });
|
|
247
|
+
if (icon) {
|
|
248
|
+
entries.push(entry('TROVE_APP_ICON', icon));
|
|
249
|
+
entries.push(entry('TROVE_APP_ICON_SIZES', await prompter.text(' Icon size', {
|
|
250
|
+
default: icon.endsWith('.svg') ? 'any' : '512x512',
|
|
251
|
+
hint: 'a raster icon claiming "any" gets scaled badly',
|
|
252
|
+
})));
|
|
253
|
+
}
|
|
254
|
+
add('Installed app name', entries);
|
|
255
|
+
} else {
|
|
256
|
+
add('Installed app name', [
|
|
257
|
+
placeholder('TROVE_APP_NAME', 'what the installed app is called; defaults to Trove'),
|
|
258
|
+
placeholder('TROVE_APP_THEME_COLOR', 'defaults to #181a1f'),
|
|
259
|
+
placeholder('TROVE_APP_ICON', 'defaults to the built-in mark'),
|
|
260
|
+
], { skipped: true });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- runtime specifics -----------------------------------------------------
|
|
264
|
+
if (isWorkers) {
|
|
265
|
+
plan.workers = await askWorkers(prompter);
|
|
266
|
+
} else {
|
|
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' });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return plan;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The Workers bindings.
|
|
279
|
+
*
|
|
280
|
+
* These are not environment variables — they are resources that have to exist in the
|
|
281
|
+
* account before a deploy works, which is why every one of them also produces a command
|
|
282
|
+
* in the plan's steps. A wrangler.toml naming a D1 database that was never created is
|
|
283
|
+
* the single most common way a first Workers deploy fails, and it fails at request time
|
|
284
|
+
* rather than at deploy time.
|
|
285
|
+
*/
|
|
286
|
+
async function askWorkers(prompter) {
|
|
287
|
+
const w = {
|
|
288
|
+
d1: null, pluginDb: null, vectorize: null, ai: false, tasks: true,
|
|
289
|
+
compatibilityDate: '2024-09-23',
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (await prompter.section('D1 (metadata)', {
|
|
293
|
+
blurb: 'Bind DB or the drive runs entirely in memory — fine until the isolate recycles, then everything is gone.',
|
|
294
|
+
})) {
|
|
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' }),
|
|
298
|
+
};
|
|
299
|
+
if (await prompter.confirm(' Bind a second D1 for server-side plugin storage?', { default: false })) {
|
|
300
|
+
w.pluginDb = {
|
|
301
|
+
name: await prompter.text(' Plugin database name', { default: 'trove-plugins' }),
|
|
302
|
+
id: await prompter.text(' Plugin database id', { default: '' }),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (await prompter.section('Vectorize (semantic search)', {
|
|
308
|
+
blurb: 'sqlite-vec is a native artifact and cannot load here, so semantic search needs Vectorize.',
|
|
309
|
+
})) {
|
|
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' }),
|
|
313
|
+
metric: await prompter.choice(' Distance metric', [
|
|
314
|
+
{ value: 'cosine', label: 'cosine' },
|
|
315
|
+
{ value: 'euclidean', label: 'euclidean' },
|
|
316
|
+
{ value: 'dot-product', label: 'dot-product' },
|
|
317
|
+
], { default: 'cosine' }),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
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 });
|
|
323
|
+
|
|
324
|
+
return w;
|
|
325
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A prompt kit small enough to have no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// `npm create` runs this before anything is installed, so every dependency here is one
|
|
4
|
+
// the user waits on before they have answered a single question. That budget is better
|
|
5
|
+
// spent on the questions themselves, and what a wizard actually needs — a line of text,
|
|
6
|
+
// a pick from a list, a yes/no — is a few dozen lines over readline.
|
|
7
|
+
//
|
|
8
|
+
// Everything is injectable and `scripted()` implements the same interface, so the
|
|
9
|
+
// wizard can be driven by a test without a terminal. That is the reason the prompter is
|
|
10
|
+
// passed around rather than reached for: a wizard that reads process.stdin directly can
|
|
11
|
+
// only be tested by a human.
|
|
12
|
+
|
|
13
|
+
import readline from 'node:readline/promises';
|
|
14
|
+
|
|
15
|
+
const DIM = '[2m';
|
|
16
|
+
const BOLD = '[1m';
|
|
17
|
+
const RESET = '[0m';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A prompter backed by a real terminal.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {NodeJS.ReadableStream} [opts.input]
|
|
24
|
+
* @param {NodeJS.WritableStream} [opts.output]
|
|
25
|
+
* @param {boolean} [opts.assumeDefaults] answer every question with its default and
|
|
26
|
+
* never block — what `--yes` sets, and what a non-interactive stdin forces.
|
|
27
|
+
*/
|
|
28
|
+
export function createPrompter({ input = process.stdin, output = process.stdout, assumeDefaults = false } = {}) {
|
|
29
|
+
const colour = output.isTTY && !process.env.NO_COLOR;
|
|
30
|
+
const paint = (code, s) => (colour ? code + s + RESET : s);
|
|
31
|
+
let rl = null;
|
|
32
|
+
const lazy = () => (rl ??= readline.createInterface({ input, output }));
|
|
33
|
+
|
|
34
|
+
const write = (s) => output.write(s + '\n');
|
|
35
|
+
|
|
36
|
+
async function line(query) {
|
|
37
|
+
if (assumeDefaults) return '';
|
|
38
|
+
return (await lazy().question(query)).trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
close() { rl?.close(); rl = null; },
|
|
43
|
+
|
|
44
|
+
heading(text) {
|
|
45
|
+
write('');
|
|
46
|
+
write(paint(BOLD, text));
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
note(text) {
|
|
50
|
+
write(paint(DIM, ' ' + text));
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Free text. An empty answer takes the default, so Enter is always the safe key.
|
|
55
|
+
*/
|
|
56
|
+
async text(label, { default: def = '', hint } = {}) {
|
|
57
|
+
if (hint) this.note(hint);
|
|
58
|
+
const shown = def ? ` ${paint(DIM, `(${def})`)}` : '';
|
|
59
|
+
const answer = await line(`${label}${shown}: `);
|
|
60
|
+
return answer || def;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pick one of a list. Options are `{ value, label, hint }`.
|
|
65
|
+
*/
|
|
66
|
+
async choice(label, options, { default: def } = {}) {
|
|
67
|
+
const fallback = def ?? options[0].value;
|
|
68
|
+
if (assumeDefaults) return fallback;
|
|
69
|
+
write('');
|
|
70
|
+
write(label);
|
|
71
|
+
options.forEach((o, i) => {
|
|
72
|
+
const mark = o.value === fallback ? '>' : ' ';
|
|
73
|
+
write(` ${mark} ${i + 1}) ${o.label}${o.hint ? paint(DIM, ' — ' + o.hint) : ''}`);
|
|
74
|
+
});
|
|
75
|
+
for (;;) {
|
|
76
|
+
const raw = await line(` choice ${paint(DIM, `(${options.findIndex((o) => o.value === fallback) + 1})`)}: `);
|
|
77
|
+
if (!raw) return fallback;
|
|
78
|
+
const byIndex = options[Number(raw) - 1];
|
|
79
|
+
if (byIndex) return byIndex.value;
|
|
80
|
+
const byValue = options.find((o) => o.value === raw.toLowerCase());
|
|
81
|
+
if (byValue) return byValue.value;
|
|
82
|
+
write(paint(DIM, ' Not one of the options.'));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async confirm(label, { default: def = true } = {}) {
|
|
87
|
+
if (assumeDefaults) return def;
|
|
88
|
+
const shown = def ? 'Y/n' : 'y/N';
|
|
89
|
+
for (;;) {
|
|
90
|
+
const raw = (await line(`${label} ${paint(DIM, `(${shown})`)}: `)).toLowerCase();
|
|
91
|
+
if (!raw) return def;
|
|
92
|
+
if (['y', 'yes'].includes(raw)) return true;
|
|
93
|
+
if (['n', 'no'].includes(raw)) return false;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Offer a block of related questions, or let the user take it themselves.
|
|
99
|
+
*
|
|
100
|
+
* Skipping is a first-class answer rather than a way of bailing out: the generated
|
|
101
|
+
* files still carry the section, commented, with the variables that belong in it.
|
|
102
|
+
* Someone who already knows their S3 credentials does not want to be interviewed
|
|
103
|
+
* about them, and someone who does not should not be blocked from getting a
|
|
104
|
+
* project on disk.
|
|
105
|
+
*/
|
|
106
|
+
async section(title, { blurb, default: def = true } = {}) {
|
|
107
|
+
this.heading(title);
|
|
108
|
+
if (blurb) this.note(blurb);
|
|
109
|
+
return this.confirm(' Configure this now?', { default: def });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A prompter that reads from a list instead of a person.
|
|
116
|
+
*
|
|
117
|
+
* Answers are consumed in order and matched by the question's label, so a test reads as
|
|
118
|
+
* a transcript rather than as a queue of bare strings — and a wizard that grows a
|
|
119
|
+
* question in the middle fails loudly instead of silently shifting every later answer
|
|
120
|
+
* onto the wrong prompt.
|
|
121
|
+
*
|
|
122
|
+
* @param {Array<[string, any]>} script pairs of [label substring, answer]
|
|
123
|
+
*/
|
|
124
|
+
export function scripted(script) {
|
|
125
|
+
const remaining = [...script];
|
|
126
|
+
const take = (label, fallback) => {
|
|
127
|
+
const i = remaining.findIndex(([match]) => label.includes(match));
|
|
128
|
+
if (i === -1) {
|
|
129
|
+
if (fallback !== undefined) return fallback;
|
|
130
|
+
throw new Error(`No scripted answer for: ${label}\nRemaining: ${remaining.map(([m]) => m).join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
return remaining.splice(i, 1)[0][1];
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
close() {},
|
|
136
|
+
heading() {},
|
|
137
|
+
note() {},
|
|
138
|
+
unanswered: () => remaining.map(([m]) => m),
|
|
139
|
+
async text(label, { default: def = '' } = {}) { return take(label, def); },
|
|
140
|
+
async choice(label, options, { default: def } = {}) { return take(label, def ?? options[0].value); },
|
|
141
|
+
async confirm(label, { default: def = true } = {}) { return take(label, def); },
|
|
142
|
+
async section(title, { default: def = true } = {}) { return take(title, def); },
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Plan → files and commands.
|
|
2
|
+
//
|
|
3
|
+
// Pure: it takes the answers and returns `{ files, steps }`. Nothing is written here,
|
|
4
|
+
// which is what lets a test assert on a generated wrangler.toml without a temp
|
|
5
|
+
// directory, and what lets `--dry-run` be the same code path as a real run.
|
|
6
|
+
//
|
|
7
|
+
// The one rule worth stating out loud: a secret never becomes a file that belongs in
|
|
8
|
+
// version control. On Workers that means `[vars]` carries configuration and credentials
|
|
9
|
+
// become `wrangler secret put` steps plus a gitignored `.dev.vars` for local runs;
|
|
10
|
+
// everywhere else it means a gitignored `.env`. `secret: true` on an entry is the whole
|
|
11
|
+
// mechanism — there is no second list to keep in sync.
|
|
12
|
+
|
|
13
|
+
const RULE = '─'.repeat(58);
|
|
14
|
+
|
|
15
|
+
/** The exact version, not a range: the two packages are released together, and a drive
|
|
16
|
+
* whose server and workbench came from different releases is the failure this avoids. */
|
|
17
|
+
const pin = (version) => version;
|
|
18
|
+
|
|
19
|
+
const isSet = (e) => !e.commented && e.value !== '' && e.value != null;
|
|
20
|
+
|
|
21
|
+
export function renderProject(plan) {
|
|
22
|
+
const files = [];
|
|
23
|
+
const steps = [];
|
|
24
|
+
const secrets = plan.sections.flatMap((s) => s.entries.filter((e) => e.secret && isSet(e)));
|
|
25
|
+
|
|
26
|
+
if (plan.runtime === 'workers') renderWorkers(plan, files, steps, secrets);
|
|
27
|
+
else renderServer(plan, files, steps);
|
|
28
|
+
|
|
29
|
+
files.push({ path: 'README.md', contents: readme(plan, steps) });
|
|
30
|
+
return { files, steps };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- shared -----------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render sections as a dotenv file.
|
|
37
|
+
*
|
|
38
|
+
* @param {object[]} sections
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {boolean} [opts.secretsOnly] emit only the credentials — what .dev.vars wants
|
|
41
|
+
* @param {boolean} [opts.omitSecrets] emit everything but the credentials
|
|
42
|
+
*/
|
|
43
|
+
function renderEnv(sections, { secretsOnly = false, omitSecrets = false } = {}) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const section of sections) {
|
|
46
|
+
const entries = section.entries.filter((e) => (secretsOnly ? e.secret : omitSecrets ? !e.secret : true));
|
|
47
|
+
if (!entries.length) continue;
|
|
48
|
+
out.push(`# ${RULE}`);
|
|
49
|
+
out.push(`# ${section.title}${section.skipped ? ' (skipped — fill these in yourself)' : ''}`);
|
|
50
|
+
out.push(`# ${RULE}`);
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
const comment = e.comment ? ` # ${e.comment}` : '';
|
|
53
|
+
out.push(isSet(e) ? `${e.key}=${e.value}${comment}` : `# ${e.key}=${comment}`);
|
|
54
|
+
}
|
|
55
|
+
out.push('');
|
|
56
|
+
}
|
|
57
|
+
return out.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const gitignore = (extra = []) => ['node_modules/', '.env', '.dev.vars', ...extra, ''].join('\n');
|
|
61
|
+
|
|
62
|
+
// --- bun / node --------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function renderServer(plan, files, steps) {
|
|
65
|
+
const { runtime, version, name, server } = plan;
|
|
66
|
+
const exec = runtime === 'bun' ? 'bun' : 'node';
|
|
67
|
+
const adapter = runtime === 'bun' ? 'bun.js' : 'node.js';
|
|
68
|
+
|
|
69
|
+
// Bun reads .env on its own; Node needs telling, and --env-file is why this asks for
|
|
70
|
+
// Node 20.6 rather than the 20 the server itself needs.
|
|
71
|
+
const start = runtime === 'bun' ? 'bun server.js' : 'node --env-file=.env server.js';
|
|
72
|
+
|
|
73
|
+
files.push({
|
|
74
|
+
path: 'package.json',
|
|
75
|
+
contents: JSON.stringify({
|
|
76
|
+
name,
|
|
77
|
+
private: true,
|
|
78
|
+
type: 'module',
|
|
79
|
+
scripts: { start },
|
|
80
|
+
dependencies: { '@3sln/trove': pin(version) },
|
|
81
|
+
engines: runtime === 'bun' ? { bun: '>=1.1.0' } : { node: '>=20.6' },
|
|
82
|
+
}, null, 2) + '\n',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
files.push({
|
|
86
|
+
path: 'server.js',
|
|
87
|
+
contents: `// Trove, as this project runs it.
|
|
88
|
+
//
|
|
89
|
+
// The adapter starts listening when it is imported — it reads its configuration from
|
|
90
|
+
// the environment (see .env), so there is nothing to pass it. Importing it through the
|
|
91
|
+
// package's public export rather than a path into node_modules means this line keeps
|
|
92
|
+
// working whatever the installer does with the tree.
|
|
93
|
+
//
|
|
94
|
+
// To add your own openers or views, this is the file that grows: build a workbench with
|
|
95
|
+
// \`createWorkbench({ openers, views })\` from '@3sln/trove/web/workbench.js', bundle it,
|
|
96
|
+
// and point TROVE_WEB_DIST at the result.
|
|
97
|
+
import '@3sln/trove/server/adapters/${adapter}';
|
|
98
|
+
`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
files.push({ path: '.env', contents: envHeader(plan) + renderEnv(plan.sections) + serverVars(server) });
|
|
102
|
+
files.push({ path: '.gitignore', contents: gitignore(['data/']) });
|
|
103
|
+
|
|
104
|
+
steps.push({ cmd: `cd ${name} && npm install`, why: 'pulls @3sln/trove — the web app is already built inside it' });
|
|
105
|
+
steps.push({ cmd: `npm start`, why: `serves the API and the workbench on :${server?.port ?? '8787'}` });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const serverVars = (server) => server
|
|
109
|
+
? [`# ${RULE}`, '# Server', `# ${RULE}`, `PORT=${server.port}`, `HOST=${server.host}`, ''].join('\n')
|
|
110
|
+
: '';
|
|
111
|
+
|
|
112
|
+
const envHeader = (plan) => `# Generated by create-trove for a ${plan.runtime} deployment.
|
|
113
|
+
# Every value here is read at startup. Nothing in this file is committed — see .gitignore.
|
|
114
|
+
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
// --- workers ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
function renderWorkers(plan, files, steps, secrets) {
|
|
120
|
+
const { name, version, workers: w } = plan;
|
|
121
|
+
|
|
122
|
+
files.push({
|
|
123
|
+
path: 'package.json',
|
|
124
|
+
contents: JSON.stringify({
|
|
125
|
+
name,
|
|
126
|
+
private: true,
|
|
127
|
+
type: 'module',
|
|
128
|
+
scripts: { dev: 'wrangler dev', deploy: 'wrangler deploy' },
|
|
129
|
+
dependencies: { '@3sln/trove': pin(version) },
|
|
130
|
+
devDependencies: { wrangler: '^3.90.0' },
|
|
131
|
+
}, null, 2) + '\n',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
files.push({
|
|
135
|
+
path: 'src/worker.js',
|
|
136
|
+
contents: `// The Worker entry.
|
|
137
|
+
//
|
|
138
|
+
// Both exports matter: the default export is the fetch handler, and TroveTasks is the
|
|
139
|
+
// Durable Object class that owns scans and reindexes. Wrangler looks the DO class up by
|
|
140
|
+
// name in this module, so re-exporting it here is what makes the binding resolve —
|
|
141
|
+
// declaring it in wrangler.toml without this is a deploy-time error.
|
|
142
|
+
export { default, TroveTasks } from '@3sln/trove/server/adapters/worker.js';
|
|
143
|
+
`,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
files.push({ path: 'wrangler.toml', contents: wranglerToml(plan) });
|
|
147
|
+
files.push({ path: '.gitignore', contents: gitignore(['.wrangler/']) });
|
|
148
|
+
|
|
149
|
+
const devVars = renderEnv(plan.sections, { secretsOnly: true });
|
|
150
|
+
if (devVars.trim()) {
|
|
151
|
+
files.push({
|
|
152
|
+
path: '.dev.vars',
|
|
153
|
+
contents: `# Credentials for \`wrangler dev\`. Gitignored, and NOT uploaded by \`wrangler deploy\` —\n`
|
|
154
|
+
+ `# the deployed Worker reads these from secrets, which the README lists as commands.\n\n${devVars}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
steps.push({ cmd: `cd ${name} && npm install`, why: 'wrangler, and @3sln/trove for the app assets' });
|
|
159
|
+
if (w?.d1) {
|
|
160
|
+
steps.push({
|
|
161
|
+
cmd: `npx wrangler d1 create ${w.d1.name}`,
|
|
162
|
+
why: w.d1.id ? 'already have the id in wrangler.toml — skip if it exists' : 'then paste database_id into wrangler.toml',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (w?.pluginDb) {
|
|
166
|
+
steps.push({ cmd: `npx wrangler d1 create ${w.pluginDb.name}`, why: 'server-side plugin storage' });
|
|
167
|
+
}
|
|
168
|
+
if (w?.vectorize) {
|
|
169
|
+
steps.push({
|
|
170
|
+
cmd: `npx wrangler vectorize create ${w.vectorize.index} --dimensions=${w.vectorize.dimensions} --metric=${w.vectorize.metric}`,
|
|
171
|
+
why: 'semantic search — Vectorize is the only vector store that runs here',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const bucket = plan.sections.flatMap((s) => s.entries).find((e) => e.key === 'TROVE_S3_BUCKET' && isSet(e));
|
|
175
|
+
if (bucket) steps.push({ cmd: `npx wrangler r2 bucket create ${bucket.value}`, why: 'object bytes' });
|
|
176
|
+
for (const s of secrets) {
|
|
177
|
+
steps.push({ cmd: `npx wrangler secret put ${s.key}`, why: 'not in wrangler.toml — secrets never belong in a committed file' });
|
|
178
|
+
}
|
|
179
|
+
steps.push({ cmd: 'npx wrangler deploy', why: '' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function wranglerToml(plan) {
|
|
183
|
+
const { workers: w, sections } = plan;
|
|
184
|
+
const q = (v) => JSON.stringify(String(v));
|
|
185
|
+
const L = [];
|
|
186
|
+
|
|
187
|
+
L.push('# Generated by create-trove. Every binding below has a matching command in README.md —');
|
|
188
|
+
L.push('# a wrangler.toml naming a D1 database nobody created deploys fine and fails at request time.');
|
|
189
|
+
L.push('');
|
|
190
|
+
L.push('name = ' + q(plan.name));
|
|
191
|
+
L.push('main = "src/worker.js"');
|
|
192
|
+
L.push(`compatibility_date = ${q(w?.compatibilityDate ?? '2024-09-23')}`);
|
|
193
|
+
L.push('');
|
|
194
|
+
|
|
195
|
+
L.push('# The built web app, served straight from the installed package — no build step');
|
|
196
|
+
L.push('# here, because @3sln/trove ships its dist/ inside the tarball.');
|
|
197
|
+
L.push('[assets]');
|
|
198
|
+
L.push('directory = "node_modules/@3sln/trove/packages/web/dist"');
|
|
199
|
+
L.push('binding = "ASSETS"');
|
|
200
|
+
L.push('');
|
|
201
|
+
|
|
202
|
+
if (w?.d1) {
|
|
203
|
+
L.push('# Metadata, KV, plugin installs and keyword search. Without this the drive runs');
|
|
204
|
+
L.push('# entirely in memory and loses everything when the isolate recycles.');
|
|
205
|
+
L.push('[[d1_databases]]');
|
|
206
|
+
L.push('binding = "DB"');
|
|
207
|
+
L.push(`database_name = ${q(w.d1.name)}`);
|
|
208
|
+
L.push(`database_id = ${q(w.d1.id || '<run: wrangler d1 create ' + w.d1.name + '>')}`);
|
|
209
|
+
L.push('');
|
|
210
|
+
} else {
|
|
211
|
+
L.push('# [[d1_databases]] # REQUIRED for anything to persist');
|
|
212
|
+
L.push('# binding = "DB"');
|
|
213
|
+
L.push('# database_name = "trove"');
|
|
214
|
+
L.push('# database_id = "<wrangler d1 create trove>"');
|
|
215
|
+
L.push('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (w?.pluginDb) {
|
|
219
|
+
L.push('# Server-side plugin storage. One database holds every plugin scope: D1 cannot');
|
|
220
|
+
L.push('# create databases on demand, so per-scope databases are not expressible here.');
|
|
221
|
+
L.push('[[d1_databases]]');
|
|
222
|
+
L.push('binding = "PLUGIN_DB"');
|
|
223
|
+
L.push(`database_name = ${q(w.pluginDb.name)}`);
|
|
224
|
+
L.push(`database_id = ${q(w.pluginDb.id || '<run: wrangler d1 create ' + w.pluginDb.name + '>')}`);
|
|
225
|
+
L.push('');
|
|
226
|
+
} else {
|
|
227
|
+
L.push('# [[d1_databases]] # optional: server-side plugin storage');
|
|
228
|
+
L.push('# binding = "PLUGIN_DB" # without it that one feature reports a clear error');
|
|
229
|
+
L.push('');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (w?.vectorize) {
|
|
233
|
+
L.push('[[vectorize]]');
|
|
234
|
+
L.push('binding = "VECTORIZE"');
|
|
235
|
+
L.push(`index_name = ${q(w.vectorize.index)}`);
|
|
236
|
+
L.push('');
|
|
237
|
+
} else {
|
|
238
|
+
L.push('# [[vectorize]] # semantic search; sqlite-vec cannot load on Workers');
|
|
239
|
+
L.push('# binding = "VECTORIZE"');
|
|
240
|
+
L.push('# index_name = "trove"');
|
|
241
|
+
L.push('');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (w?.ai) {
|
|
245
|
+
L.push('# Turns natural language into semantic text + tag filters; falls back to the');
|
|
246
|
+
L.push('# #tag grammar on error.');
|
|
247
|
+
L.push('[ai]');
|
|
248
|
+
L.push('binding = "AI"');
|
|
249
|
+
L.push('');
|
|
250
|
+
} else {
|
|
251
|
+
L.push('# [ai] # optional: LLM query understanding');
|
|
252
|
+
L.push('# binding = "AI"');
|
|
253
|
+
L.push('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (w?.tasks) {
|
|
257
|
+
L.push('# Scans and reindexes live in one Durable Object, so a client polling /api/tasks');
|
|
258
|
+
L.push('# reaches the isolate that actually holds the task and Cancel reaches its');
|
|
259
|
+
L.push('# AbortController. Without it the work still runs, in the request isolate.');
|
|
260
|
+
L.push('[[durable_objects.bindings]]');
|
|
261
|
+
L.push('name = "TASKS"');
|
|
262
|
+
L.push('class_name = "TroveTasks"');
|
|
263
|
+
L.push('');
|
|
264
|
+
L.push('[[migrations]]');
|
|
265
|
+
L.push('tag = "v1"');
|
|
266
|
+
L.push('new_sqlite_classes = ["TroveTasks"]');
|
|
267
|
+
L.push('');
|
|
268
|
+
} else {
|
|
269
|
+
L.push('# [[durable_objects.bindings]] # optional: owns scans and reindexes');
|
|
270
|
+
L.push('# name = "TASKS"');
|
|
271
|
+
L.push('# class_name = "TroveTasks"');
|
|
272
|
+
L.push('# [[migrations]]');
|
|
273
|
+
L.push('# tag = "v1"');
|
|
274
|
+
L.push('# new_sqlite_classes = ["TroveTasks"]');
|
|
275
|
+
L.push('');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
L.push('[vars]');
|
|
279
|
+
L.push('# Configuration only. Credentials are secrets — see README.md.');
|
|
280
|
+
for (const section of sections) {
|
|
281
|
+
const shown = section.entries.filter((e) => !e.secret);
|
|
282
|
+
if (!shown.length) continue;
|
|
283
|
+
L.push(`# ${section.title}${section.skipped ? ' (skipped)' : ''}`);
|
|
284
|
+
for (const e of shown) {
|
|
285
|
+
const c = e.comment ? ` # ${e.comment}` : '';
|
|
286
|
+
L.push(isSet(e) ? `${e.key} = ${q(e.value)}${c}` : `# ${e.key} = ""${c}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
L.push('');
|
|
290
|
+
return L.join('\n');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- the generated README -----------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function readme(plan, steps) {
|
|
296
|
+
const L = [];
|
|
297
|
+
const runtimeName = { bun: 'Bun', node: 'Node', workers: 'Cloudflare Workers' }[plan.runtime];
|
|
298
|
+
|
|
299
|
+
L.push(`# ${plan.name}`);
|
|
300
|
+
L.push('');
|
|
301
|
+
L.push(`A [Trove](https://github.com/3sln/trove) drive on **${runtimeName}**, scaffolded with \`create-trove\`.`);
|
|
302
|
+
L.push('');
|
|
303
|
+
L.push(`The web app is not built here — \`@3sln/trove@${plan.version}\` ships it already built, so the`);
|
|
304
|
+
L.push('server and the workbench it serves are the same release by construction.');
|
|
305
|
+
L.push('');
|
|
306
|
+
|
|
307
|
+
L.push('## Getting it running');
|
|
308
|
+
L.push('');
|
|
309
|
+
L.push('```sh');
|
|
310
|
+
for (const s of steps) L.push(s.why ? `${s.cmd}${' '.repeat(Math.max(1, 44 - s.cmd.length))}# ${s.why}` : s.cmd);
|
|
311
|
+
L.push('```');
|
|
312
|
+
L.push('');
|
|
313
|
+
|
|
314
|
+
if (plan.warnings.length) {
|
|
315
|
+
const kind = (w) => (typeof w === 'string' ? w : w.kind);
|
|
316
|
+
const has = (k) => plan.warnings.some((w) => kind(w) === k);
|
|
317
|
+
const found = (k) => plan.warnings.find((w) => kind(w) === k);
|
|
318
|
+
|
|
319
|
+
L.push('## Before you expose this');
|
|
320
|
+
L.push('');
|
|
321
|
+
if (has('anonymous')) {
|
|
322
|
+
L.push('- **No identity is configured.** Everyone who can reach the port is the same');
|
|
323
|
+
L.push(' anonymous user. Set `TROVE_AUTH` and `TROVE_AUTH_REQUIRED=true`, and put it behind');
|
|
324
|
+
L.push(' an authenticating proxy over TLS.');
|
|
325
|
+
}
|
|
326
|
+
if (has('incomplete-identity')) {
|
|
327
|
+
const w = found('incomplete-identity');
|
|
328
|
+
L.push(`- **\`TROVE_AUTH=${w.driver}\` is set but incomplete** — ${w.missing.join(', ')} ${w.missing.length > 1 ? 'are' : 'is'} blank.`);
|
|
329
|
+
L.push(' The config reads as configured while the driver has nothing to verify against.');
|
|
330
|
+
}
|
|
331
|
+
if (has('default-open')) {
|
|
332
|
+
L.push('- **The default collection is open.** Every authenticated user gets full');
|
|
333
|
+
L.push(' read/write/delete on it. Set `TROVE_DEFAULT_OPEN=false`.');
|
|
334
|
+
}
|
|
335
|
+
L.push('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (plan.skipped.length) {
|
|
339
|
+
L.push('## Skipped');
|
|
340
|
+
L.push('');
|
|
341
|
+
L.push('Left for you to fill in. The keys are already in place, commented, in');
|
|
342
|
+
L.push(plan.runtime === 'workers' ? '`wrangler.toml`:' : '`.env`:');
|
|
343
|
+
L.push('');
|
|
344
|
+
for (const s of plan.skipped) L.push(`- ${s}`);
|
|
345
|
+
L.push('');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
L.push('## Configuration');
|
|
349
|
+
L.push('');
|
|
350
|
+
L.push(plan.runtime === 'workers'
|
|
351
|
+
? 'Non-secret settings live in `[vars]` in `wrangler.toml`; credentials are secrets, set with\n`wrangler secret put`, and mirrored into a gitignored `.dev.vars` for `wrangler dev`.'
|
|
352
|
+
: 'Everything is in `.env`, which is gitignored. The full set of variables, with defaults,\nis documented in the Trove README.');
|
|
353
|
+
L.push('');
|
|
354
|
+
return L.join('\n');
|
|
355
|
+
}
|