create-kelpie 0.2.0
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/LICENSE +661 -0
- package/README.md +72 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +41 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +172 -0
- package/dist/options.js.map +1 -0
- package/dist/scaffold.d.ts +36 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +106 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +53 -0
- package/src/index.ts +59 -0
- package/src/options.ts +229 -0
- package/src/scaffold.ts +146 -0
- package/templates/README.md +97 -0
- package/templates/docker-compose.yml +26 -0
- package/templates/env +27 -0
- package/templates/gitignore +6 -0
- package/templates/kelpie.config.ts +21 -0
- package/templates/kelpie.ui.config.ts +14 -0
- package/templates/package.json +35 -0
- package/templates/src/server.ts +118 -0
- package/templates/tsconfig.server.json +20 -0
- package/templates/tsconfig.web.json +21 -0
- package/templates/vite.config.ts +60 -0
- package/templates/web/index.html +12 -0
- package/templates/web/main.tsx +23 -0
package/src/options.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns argv and, where it has to, the operator, into `ScaffoldOptions`.
|
|
3
|
+
*
|
|
4
|
+
* Everything non-deterministic lives here: the generated key, the prompts, and
|
|
5
|
+
* reading this package's own version. `scaffold` itself is a pure function of
|
|
6
|
+
* what this produces, which is what makes it testable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { basename, resolve } from 'node:path'
|
|
12
|
+
import { createInterface } from 'node:readline/promises'
|
|
13
|
+
import { parseArgs } from 'node:util'
|
|
14
|
+
|
|
15
|
+
import { ScaffoldError } from './scaffold.ts'
|
|
16
|
+
import type { ScaffoldOptions } from './scaffold.ts'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_DIRECTORY = 'kelpie'
|
|
19
|
+
const DEFAULT_PORT = 3000
|
|
20
|
+
const DEFAULT_WEB_PORT = 5173
|
|
21
|
+
const DEFAULT_DATABASE_PORT = 5432
|
|
22
|
+
const DEFAULT_EMAIL_FROM = 'kelpie@example.com'
|
|
23
|
+
|
|
24
|
+
/** The service requires 32 bytes; anything shorter is rejected at boot. */
|
|
25
|
+
const SECRET_KEY_BYTES = 32
|
|
26
|
+
|
|
27
|
+
const HIGHEST_PORT = 65535
|
|
28
|
+
|
|
29
|
+
export const USAGE = `Scaffold a self-hosted Kelpie assembly.
|
|
30
|
+
|
|
31
|
+
npm create kelpie [directory] [options]
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--name <name> Package name. Defaults to the directory name
|
|
35
|
+
--database-url <url> postgres:// connection string
|
|
36
|
+
--port <port> API port (default ${DEFAULT_PORT})
|
|
37
|
+
--web-port <port> Dev server port (default ${DEFAULT_WEB_PORT})
|
|
38
|
+
--database-port <port> Host port for the bundled Postgres (default ${DEFAULT_DATABASE_PORT})
|
|
39
|
+
--email-from <address> Address transactional mail comes from
|
|
40
|
+
--docker, --no-docker Write a docker-compose.yml for Postgres (default yes)
|
|
41
|
+
--yes Take every default; never prompt
|
|
42
|
+
--help This message
|
|
43
|
+
|
|
44
|
+
With --yes and no --database-url, the connection string points at the bundled
|
|
45
|
+
Postgres. Without a terminal to prompt on, --yes is required.`
|
|
46
|
+
|
|
47
|
+
export interface ParsedArguments {
|
|
48
|
+
readonly directory: string | undefined
|
|
49
|
+
readonly name: string | undefined
|
|
50
|
+
readonly databaseUrl: string | undefined
|
|
51
|
+
readonly emailFrom: string | undefined
|
|
52
|
+
readonly port: number
|
|
53
|
+
readonly webPort: number
|
|
54
|
+
readonly databasePort: number
|
|
55
|
+
readonly docker: boolean | undefined
|
|
56
|
+
readonly yes: boolean
|
|
57
|
+
readonly help: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsePort(name: string, value: string | undefined, fallback: number): number {
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
return fallback
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const port = Number(value)
|
|
66
|
+
|
|
67
|
+
if (!Number.isInteger(port) || port < 1 || port > HIGHEST_PORT) {
|
|
68
|
+
throw new ScaffoldError(`${name} must be a port number between 1 and ${HIGHEST_PORT}. It is "${value}".`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return port
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* npm package names are lowercase, and a directory name often is not. Anything
|
|
76
|
+
* that cannot be salvaged falls back rather than failing: the name is cosmetic
|
|
77
|
+
* in a private package.
|
|
78
|
+
*/
|
|
79
|
+
export function toPackageName(directoryName: string): string {
|
|
80
|
+
const cleaned = directoryName
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
83
|
+
.replace(/^[._-]+/, '')
|
|
84
|
+
.replace(/-+$/, '')
|
|
85
|
+
|
|
86
|
+
return cleaned.length > 0 ? cleaned : DEFAULT_DIRECTORY
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseArguments(argv: readonly string[]): ParsedArguments {
|
|
90
|
+
const { values, positionals } = parseArgs({
|
|
91
|
+
args: [...argv],
|
|
92
|
+
allowPositionals: true,
|
|
93
|
+
options: {
|
|
94
|
+
name: { type: 'string' },
|
|
95
|
+
'database-url': { type: 'string' },
|
|
96
|
+
'email-from': { type: 'string' },
|
|
97
|
+
port: { type: 'string' },
|
|
98
|
+
'web-port': { type: 'string' },
|
|
99
|
+
'database-port': { type: 'string' },
|
|
100
|
+
docker: { type: 'boolean' },
|
|
101
|
+
// parseArgs has no native --no-x, so the negation is its own flag.
|
|
102
|
+
'no-docker': { type: 'boolean' },
|
|
103
|
+
yes: { type: 'boolean', short: 'y' },
|
|
104
|
+
help: { type: 'boolean', short: 'h' },
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (values.docker === true && values['no-docker'] === true) {
|
|
109
|
+
throw new ScaffoldError('--docker and --no-docker contradict each other.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const docker = values['no-docker'] === true ? false : values.docker === true ? true : undefined
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
directory: positionals[0],
|
|
116
|
+
name: values.name,
|
|
117
|
+
databaseUrl: values['database-url'],
|
|
118
|
+
emailFrom: values['email-from'],
|
|
119
|
+
port: parsePort('--port', values.port, DEFAULT_PORT),
|
|
120
|
+
webPort: parsePort('--web-port', values['web-port'], DEFAULT_WEB_PORT),
|
|
121
|
+
databasePort: parsePort('--database-port', values['database-port'], DEFAULT_DATABASE_PORT),
|
|
122
|
+
docker,
|
|
123
|
+
yes: values.yes === true,
|
|
124
|
+
help: values.help === true,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function generateSecretEncryptionKey(): string {
|
|
129
|
+
return randomBytes(SECRET_KEY_BYTES).toString('base64')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bundledDatabaseUrl(port: number): string {
|
|
133
|
+
return `postgres://kelpie:kelpie@localhost:${port}/kelpie`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** This package's own version, which is the core version a scaffold pins. */
|
|
137
|
+
export function readOwnVersion(): string {
|
|
138
|
+
const manifestUrl = new URL('../package.json', import.meta.url)
|
|
139
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestUrl, 'utf8'))
|
|
140
|
+
|
|
141
|
+
if (typeof parsed !== 'object' || parsed === null || !('version' in parsed)) {
|
|
142
|
+
throw new ScaffoldError('create-kelpie cannot read its own version.')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { version } = parsed as { version: unknown }
|
|
146
|
+
|
|
147
|
+
if (typeof version !== 'string') {
|
|
148
|
+
throw new ScaffoldError('create-kelpie has a non-string version in its manifest.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return version
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface Prompter {
|
|
155
|
+
ask(question: string, fallback: string): Promise<string>
|
|
156
|
+
confirm(question: string, fallback: boolean): Promise<boolean>
|
|
157
|
+
close(): void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function createPrompter(): Prompter {
|
|
161
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
async ask(question: string, fallback: string): Promise<string> {
|
|
165
|
+
const answer = (await rl.question(`${question} (${fallback}) `)).trim()
|
|
166
|
+
|
|
167
|
+
return answer.length > 0 ? answer : fallback
|
|
168
|
+
},
|
|
169
|
+
async confirm(question: string, fallback: boolean): Promise<boolean> {
|
|
170
|
+
const answer = (await rl.question(`${question} (${fallback ? 'Y/n' : 'y/N'}) `)).trim().toLowerCase()
|
|
171
|
+
|
|
172
|
+
if (answer.length === 0) {
|
|
173
|
+
return fallback
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return answer.startsWith('y')
|
|
177
|
+
},
|
|
178
|
+
close(): void {
|
|
179
|
+
rl.close()
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fills the gaps argv left, asking where there is a terminal to ask on.
|
|
186
|
+
*
|
|
187
|
+
* Without one, `--yes` is required rather than assumed. A scaffolder that
|
|
188
|
+
* silently invents a database URL in CI writes a project that fails at boot,
|
|
189
|
+
* some distance from the cause.
|
|
190
|
+
*/
|
|
191
|
+
export async function resolveOptions(parsed: ParsedArguments, interactive: boolean): Promise<ScaffoldOptions> {
|
|
192
|
+
if (!interactive && !parsed.yes) {
|
|
193
|
+
throw new ScaffoldError(
|
|
194
|
+
'There is no terminal to prompt on. Pass --yes to take the defaults, and --database-url to point at your database.',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const prompter = interactive && !parsed.yes ? createPrompter() : undefined
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const directoryName =
|
|
202
|
+
parsed.directory ?? (await prompter?.ask('Directory', DEFAULT_DIRECTORY)) ?? DEFAULT_DIRECTORY
|
|
203
|
+
const directory = resolve(process.cwd(), directoryName)
|
|
204
|
+
const projectName = parsed.name ?? toPackageName(basename(directory))
|
|
205
|
+
const docker = parsed.docker ?? (await prompter?.confirm('Write a docker-compose.yml for Postgres?', true)) ?? true
|
|
206
|
+
|
|
207
|
+
const defaultDatabaseUrl = bundledDatabaseUrl(parsed.databasePort)
|
|
208
|
+
const databaseUrl =
|
|
209
|
+
parsed.databaseUrl ?? (await prompter?.ask('DATABASE_URL', defaultDatabaseUrl)) ?? defaultDatabaseUrl
|
|
210
|
+
|
|
211
|
+
const emailFrom =
|
|
212
|
+
parsed.emailFrom ?? (await prompter?.ask('Send mail from', DEFAULT_EMAIL_FROM)) ?? DEFAULT_EMAIL_FROM
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
directory,
|
|
216
|
+
projectName,
|
|
217
|
+
databaseUrl,
|
|
218
|
+
emailFrom,
|
|
219
|
+
port: parsed.port,
|
|
220
|
+
webPort: parsed.webPort,
|
|
221
|
+
databasePort: parsed.databasePort,
|
|
222
|
+
docker,
|
|
223
|
+
secretEncryptionKey: generateSecretEncryptionKey(),
|
|
224
|
+
coreVersion: readOwnVersion(),
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
prompter?.close()
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a Kelpie assembly into a directory.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deterministic: the caller supplies the generated secret and
|
|
5
|
+
* every other value, so a test can assert on exact output. `resolveOptions` in
|
|
6
|
+
* `options.ts` is where the non-deterministic parts live.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
/** Resolves the same from `src/` and from `dist/`; both sit one level down. */
|
|
14
|
+
const templateDirectory = fileURLToPath(new URL('../templates/', import.meta.url))
|
|
15
|
+
|
|
16
|
+
export interface ScaffoldOptions {
|
|
17
|
+
/** Absolute path of the directory to write into. Created if missing. */
|
|
18
|
+
readonly directory: string
|
|
19
|
+
readonly projectName: string
|
|
20
|
+
readonly databaseUrl: string
|
|
21
|
+
readonly emailFrom: string
|
|
22
|
+
readonly port: number
|
|
23
|
+
readonly webPort: number
|
|
24
|
+
/** The host port `docker-compose.yml` publishes Postgres on. Ignored without `docker`. */
|
|
25
|
+
readonly databasePort: number
|
|
26
|
+
readonly docker: boolean
|
|
27
|
+
/** 32 bytes of base64. Generated per project by `resolveOptions`. */
|
|
28
|
+
readonly secretEncryptionKey: string
|
|
29
|
+
/** The `@kelpie/*` version range the generated manifest asks for. */
|
|
30
|
+
readonly coreVersion: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A precondition failed. Reported as a message rather than a stack. */
|
|
34
|
+
export class ScaffoldError extends Error {
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'ScaffoldError'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Template path to output path.
|
|
43
|
+
*
|
|
44
|
+
* `env` and `gitignore` are renamed rather than stored under their real names
|
|
45
|
+
* because npm silently drops a file called `.gitignore` from a published
|
|
46
|
+
* tarball, and a stray `.env` in the package directory is a trap regardless.
|
|
47
|
+
*/
|
|
48
|
+
const TEMPLATE_FILES: ReadonlyMap<string, string> = new Map([
|
|
49
|
+
['package.json', 'package.json'],
|
|
50
|
+
['kelpie.config.ts', 'kelpie.config.ts'],
|
|
51
|
+
['kelpie.ui.config.ts', 'kelpie.ui.config.ts'],
|
|
52
|
+
['src/server.ts', 'src/server.ts'],
|
|
53
|
+
['web/index.html', 'web/index.html'],
|
|
54
|
+
['web/main.tsx', 'web/main.tsx'],
|
|
55
|
+
['vite.config.ts', 'vite.config.ts'],
|
|
56
|
+
['tsconfig.server.json', 'tsconfig.server.json'],
|
|
57
|
+
['tsconfig.web.json', 'tsconfig.web.json'],
|
|
58
|
+
['README.md', 'README.md'],
|
|
59
|
+
['env', '.env'],
|
|
60
|
+
['gitignore', '.gitignore'],
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
/** Written only when the project takes the bundled Postgres. */
|
|
64
|
+
const DOCKER_TEMPLATE = 'docker-compose.yml'
|
|
65
|
+
|
|
66
|
+
/** Anything left matching this after substitution is a token nobody filled in. */
|
|
67
|
+
const UNRESOLVED_TOKEN = /__[A-Z][A-Z0-9_]*__/
|
|
68
|
+
|
|
69
|
+
function tokensFor(options: ScaffoldOptions): ReadonlyMap<string, string> {
|
|
70
|
+
return new Map([
|
|
71
|
+
['__PROJECT_NAME__', options.projectName],
|
|
72
|
+
['__CORE_VERSION__', options.coreVersion],
|
|
73
|
+
['__DATABASE_URL__', options.databaseUrl],
|
|
74
|
+
['__DATABASE_PORT__', String(options.databasePort)],
|
|
75
|
+
['__EMAIL_FROM__', options.emailFrom],
|
|
76
|
+
['__PORT__', String(options.port)],
|
|
77
|
+
['__WEB_PORT__', String(options.webPort)],
|
|
78
|
+
['__SECRET_ENCRYPTION_KEY__', options.secretEncryptionKey],
|
|
79
|
+
])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function render(template: string, tokens: ReadonlyMap<string, string>, source: string): string {
|
|
83
|
+
let rendered = template
|
|
84
|
+
|
|
85
|
+
for (const [token, value] of tokens) {
|
|
86
|
+
rendered = rendered.replaceAll(token, value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const leftover = UNRESOLVED_TOKEN.exec(rendered)
|
|
90
|
+
|
|
91
|
+
if (leftover !== null) {
|
|
92
|
+
throw new ScaffoldError(`The template ${source} still contains ${leftover[0]} after substitution.`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return rendered
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refuses a directory that already holds anything, so a mistyped path cannot
|
|
100
|
+
* overwrite a project. Dotfiles count; a directory with a `.git` in it is
|
|
101
|
+
* someone's repository.
|
|
102
|
+
*/
|
|
103
|
+
export function assertWritable(directory: string): void {
|
|
104
|
+
if (!existsSync(directory)) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const entries = readdirSync(directory)
|
|
109
|
+
|
|
110
|
+
if (entries.length > 0) {
|
|
111
|
+
throw new ScaffoldError(
|
|
112
|
+
`${directory} is not empty. It holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, ` +
|
|
113
|
+
'including ' +
|
|
114
|
+
entries
|
|
115
|
+
.slice(0, 3)
|
|
116
|
+
.map((entry) => `"${entry}"`)
|
|
117
|
+
.join(', ') +
|
|
118
|
+
'. Pick an empty directory, or a path that does not exist yet.',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Writes the assembly. Returns the output paths, relative to the directory, in write order. */
|
|
124
|
+
export function scaffold(options: ScaffoldOptions): readonly string[] {
|
|
125
|
+
assertWritable(options.directory)
|
|
126
|
+
|
|
127
|
+
const tokens = tokensFor(options)
|
|
128
|
+
const files = new Map(TEMPLATE_FILES)
|
|
129
|
+
|
|
130
|
+
if (options.docker) {
|
|
131
|
+
files.set(DOCKER_TEMPLATE, DOCKER_TEMPLATE)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const written: string[] = []
|
|
135
|
+
|
|
136
|
+
for (const [source, target] of files) {
|
|
137
|
+
const template = readFileSync(join(templateDirectory, source), 'utf8')
|
|
138
|
+
const destination = join(options.directory, target)
|
|
139
|
+
|
|
140
|
+
mkdirSync(dirname(destination), { recursive: true })
|
|
141
|
+
writeFileSync(destination, render(template, tokens, source))
|
|
142
|
+
written.push(target)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return written
|
|
146
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A self-hosted [Kelpie](https://github.com/velvet-tiger/kelpie) install.
|
|
4
|
+
|
|
5
|
+
These files are yours. Kelpie itself arrives as `@kelpie/server` and
|
|
6
|
+
`@kelpie/ui` in `node_modules`; what is checked in here is the assembly that
|
|
7
|
+
composes them: which modules are on, and how the service starts.
|
|
8
|
+
|
|
9
|
+
## Running it
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then start Postgres, if you took the `docker-compose.yml`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
docker compose up --detach --wait
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run dev
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The API listens on the `PORT` in `.env` and the UI on `WEB_PORT`. The UI proxies
|
|
28
|
+
API calls, so your browser only talks to one address.
|
|
29
|
+
|
|
30
|
+
Confirm it is up:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -s http://localhost:__WEB_PORT__/healthz
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You should see `{"status":"ok","database":"up"}`. A `"status":"degraded"`
|
|
37
|
+
response means Postgres is not reachable.
|
|
38
|
+
|
|
39
|
+
Then open <http://localhost:__WEB_PORT__/signup> and create an account.
|
|
40
|
+
Passwords need at least 12 characters. Signup names your workspace, invites your
|
|
41
|
+
team, and leaves you on People with a starter handbook in place.
|
|
42
|
+
|
|
43
|
+
Kelpie does not send email yet. Invitation and password-reset links print to the
|
|
44
|
+
API's log. Copy them from there to follow either flow.
|
|
45
|
+
|
|
46
|
+
## Adding a module
|
|
47
|
+
|
|
48
|
+
`kelpie.config.ts` is the server module list and `kelpie.ui.config.ts` is the UI
|
|
49
|
+
one. Install a module, add it to the relevant array, restart. Boot fails loudly
|
|
50
|
+
on an unknown id or an unmet dependency rather than starting without it.
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Everything below is required unless marked optional. A missing or invalid value
|
|
55
|
+
stops the service at boot and lists every problem at once.
|
|
56
|
+
|
|
57
|
+
| Variable | Values |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `NODE_ENV` | `development`, `test`, or `production` |
|
|
60
|
+
| `PORT` | The API's listen port. Bound exactly; the service fails if it is taken |
|
|
61
|
+
| `API_PORT` | The same number again, for the dev server's proxy |
|
|
62
|
+
| `WEB_PORT` | The Vite dev server's port. Development only |
|
|
63
|
+
| `DATABASE_URL` | A `postgres://` or `postgresql://` connection string |
|
|
64
|
+
| `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` |
|
|
65
|
+
| `EMAIL_PROVIDER` | `log`. Writes invitations and resets to the log instead of sending them |
|
|
66
|
+
| `EMAIL_FROM` | The address transactional mail comes from |
|
|
67
|
+
| `SECRET_ENCRYPTION_KEY` | 32 bytes of base64, generated for this project |
|
|
68
|
+
| `SECRET_ENCRYPTION_KEY_PREVIOUS` | Optional. Set only while rotating the key above |
|
|
69
|
+
| `WEBHOOK_DELIVERY_RETENTION_DAYS` | Optional, default 30 |
|
|
70
|
+
|
|
71
|
+
### Rotating the encryption key
|
|
72
|
+
|
|
73
|
+
Replacing `SECRET_ENCRYPTION_KEY` makes every secret sealed under the old one
|
|
74
|
+
unreadable, so rotate rather than replace:
|
|
75
|
+
|
|
76
|
+
1. Move the current key to `SECRET_ENCRYPTION_KEY_PREVIOUS` and put a new one in
|
|
77
|
+
`SECRET_ENCRYPTION_KEY`.
|
|
78
|
+
2. Deploy. New secrets seal under the new key; existing ones still read with the
|
|
79
|
+
previous one.
|
|
80
|
+
3. Re-encrypt everything still under the old key. Safe to run more than once.
|
|
81
|
+
4. Remove `SECRET_ENCRYPTION_KEY_PREVIOUS` and deploy again.
|
|
82
|
+
|
|
83
|
+
## Upgrading
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm update @kelpie/server @kelpie/ui
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Migrations apply at boot. Read the
|
|
90
|
+
[changelog](https://github.com/velvet-tiger/kelpie/blob/main/CHANGELOG.md)
|
|
91
|
+
first: while the major version is `0`, a minor bump may break the API.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
Kelpie is AGPL-3.0. Running a modified version as a network service obliges you
|
|
96
|
+
to offer its source to your users. These assembly files are yours; the
|
|
97
|
+
obligation attaches to Kelpie itself.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Postgres for this Kelpie install.
|
|
2
|
+
#
|
|
3
|
+
# The credentials are fixed here and repeated in .env. They are not secret and
|
|
4
|
+
# the container is not reachable from outside this machine. For anything other
|
|
5
|
+
# than local use, point DATABASE_URL at a real database and delete this file.
|
|
6
|
+
services:
|
|
7
|
+
db:
|
|
8
|
+
image: postgres:18-alpine
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: kelpie
|
|
11
|
+
POSTGRES_PASSWORD: kelpie
|
|
12
|
+
POSTGRES_DB: kelpie
|
|
13
|
+
ports:
|
|
14
|
+
- "__DATABASE_PORT__:5432"
|
|
15
|
+
# Postgres 18 images want the whole directory mounted, not the data
|
|
16
|
+
# subdirectory, so a later pg_upgrade can cross versions in place.
|
|
17
|
+
volumes:
|
|
18
|
+
- kelpie_db_data:/var/lib/postgresql
|
|
19
|
+
healthcheck:
|
|
20
|
+
test: ["CMD-SHELL", "pg_isready --username kelpie --dbname kelpie"]
|
|
21
|
+
interval: 5s
|
|
22
|
+
timeout: 5s
|
|
23
|
+
retries: 10
|
|
24
|
+
|
|
25
|
+
volumes:
|
|
26
|
+
kelpie_db_data:
|
package/templates/env
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Generated by `npm create kelpie`. Keep it out of version control.
|
|
2
|
+
|
|
3
|
+
NODE_ENV=development
|
|
4
|
+
LOG_LEVEL=info
|
|
5
|
+
|
|
6
|
+
# The API binds this exact port and fails if it is taken.
|
|
7
|
+
PORT=__PORT__
|
|
8
|
+
|
|
9
|
+
# The same number again, for the Vite dev server's proxy. It needs its own name
|
|
10
|
+
# because a process manager sets PORT to the port it wants Vite on, and Vite
|
|
11
|
+
# would then proxy to itself.
|
|
12
|
+
API_PORT=__PORT__
|
|
13
|
+
|
|
14
|
+
# The Vite dev server's own port.
|
|
15
|
+
WEB_PORT=__WEB_PORT__
|
|
16
|
+
|
|
17
|
+
DATABASE_URL=__DATABASE_URL__
|
|
18
|
+
|
|
19
|
+
# Seals secrets the service reads back, such as webhook signing secrets. This
|
|
20
|
+
# one was generated for this project. Losing it makes those secrets unreadable;
|
|
21
|
+
# changing it needs the rotation procedure in README.md.
|
|
22
|
+
SECRET_ENCRYPTION_KEY=__SECRET_ENCRYPTION_KEY__
|
|
23
|
+
|
|
24
|
+
# `log` writes invitations and password resets to the log instead of sending
|
|
25
|
+
# them. Real providers ship as modules.
|
|
26
|
+
EMAIL_PROVIDER=log
|
|
27
|
+
EMAIL_FROM=__EMAIL_FROM__
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { coreModules } from '@kelpie/server'
|
|
2
|
+
import type { KelpieModule } from '@kelpie/server'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The server module list, and the only place it is declared.
|
|
6
|
+
*
|
|
7
|
+
* Boot registers these in order, after resolving what each one requires. An
|
|
8
|
+
* unknown id, an unmet dependency, or invalid module configuration stops boot
|
|
9
|
+
* rather than starting a service that is missing a feature.
|
|
10
|
+
*
|
|
11
|
+
* Add a module by installing it and putting it in this array:
|
|
12
|
+
*
|
|
13
|
+
* import { smtpEmail } from '@kelpie/module-smtp-email'
|
|
14
|
+
*
|
|
15
|
+
* export const modules: readonly KelpieModule[] = [...coreModules, smtpEmail]
|
|
16
|
+
*
|
|
17
|
+
* Removing one from `coreModules` is possible too, but core modules depend on
|
|
18
|
+
* each other, so boot will tell you if you have taken out something another
|
|
19
|
+
* module needs.
|
|
20
|
+
*/
|
|
21
|
+
export const modules: readonly KelpieModule[] = [...coreModules]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { UiModule } from '@kelpie/ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The UI module list, and the only place it is declared.
|
|
5
|
+
*
|
|
6
|
+
* Separate from `kelpie.config.ts` because that one is imported by the Node
|
|
7
|
+
* entry point, and a UI module pulls React in with it. The two lists differ
|
|
8
|
+
* anyway: a module can contribute to one surface without the other, and most
|
|
9
|
+
* do.
|
|
10
|
+
*
|
|
11
|
+
* Empty is the supported state. Core pages look finished with every slot
|
|
12
|
+
* unfilled.
|
|
13
|
+
*/
|
|
14
|
+
export const uiModules: readonly UiModule[] = []
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "concurrently --names api,web --prefix-colors blue,magenta \"npm run dev:api\" \"npm run dev:web\"",
|
|
11
|
+
"dev:api": "node --watch --env-file-if-exists=.env src/server.ts",
|
|
12
|
+
"dev:web": "vite",
|
|
13
|
+
"start": "node --env-file-if-exists=.env src/server.ts",
|
|
14
|
+
"build": "vite build",
|
|
15
|
+
"typecheck": "tsc --project tsconfig.server.json && tsc --project tsconfig.web.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@hono/node-server": "^2.0.12",
|
|
19
|
+
"@kelpie/server": "^__CORE_VERSION__",
|
|
20
|
+
"@kelpie/ui": "^__CORE_VERSION__",
|
|
21
|
+
"react": "^19.2.8",
|
|
22
|
+
"react-dom": "^19.2.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
26
|
+
"@types/node": "^26.1.2",
|
|
27
|
+
"@types/react": "^19.2.18",
|
|
28
|
+
"@types/react-dom": "^19.2.4",
|
|
29
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
30
|
+
"concurrently": "^9.2.4",
|
|
31
|
+
"tailwindcss": "^4.3.3",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"vite": "^8.2.0"
|
|
34
|
+
}
|
|
35
|
+
}
|