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/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
+ }
@@ -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,6 @@
1
+ node_modules/
2
+ dist/
3
+
4
+ # Holds SECRET_ENCRYPTION_KEY and your database credentials.
5
+ .env
6
+ .env.local
@@ -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
+ }