create-soda 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 falsea3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # create-soda
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Creates a [Soda](https://github.com/getsodajs/soda) project.
4
+
5
+ ```bash
6
+ bun create soda my-app
7
+ ```
8
+
9
+ or `npm create soda@latest my-app`. Then:
10
+
11
+ ```bash
12
+ cd my-app
13
+ bun install
14
+ bun dev
15
+ ```
16
+
17
+ The project has a users module (service, controller, test), a guard passing the current user to handlers, CORS and graceful shutdown.
18
+
19
+ ## Options
20
+
21
+ | Option | |
22
+ |---|---|
23
+ | `[directory]` | where to create the project; asked in a terminal, `soda-app` otherwise |
24
+ | `--overwrite` | empty a non-empty directory first (`.git` is kept) |
25
+ | `--no-interactive` | never ask, for scripts and CI |
26
+ | `--core-version <range>` | version of `@getsoda/core` to depend on |
27
+
28
+ ## License
29
+
30
+ [MIT](./LICENSE)
@@ -0,0 +1 @@
1
+ {"version":"0.1.1"}
package/dist/index.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @module
4
+ * `create-soda`: creates a Soda project.
5
+ *
6
+ * ```bash
7
+ * bun create soda my-app
8
+ * npm create soda@latest my-app
9
+ * ```
10
+ *
11
+ * Interactive in a terminal, flag-driven otherwise (CI, scripts). Uses only
12
+ * Node built-ins, so it runs the same under `npm create` (Node) and `bun create`.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { basename, relative, resolve } from 'node:path';
16
+ import { stdin, stdout } from 'node:process';
17
+ import { createInterface } from 'node:readline/promises';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createProject, detectPackageManager, emptyDir, formatTargetDir, isEmptyDir, isValidPackageName, toValidPackageName, } from './project.js';
20
+ const DEFAULT_DIR = 'soda-app';
21
+ const HELP = `Usage: create-soda [directory] [options]
22
+
23
+ Creates a Soda project. Interactive in a terminal.
24
+
25
+ Options:
26
+ --overwrite empty the directory if it is not empty
27
+ --interactive, --no-interactive
28
+ force or disable questions
29
+ --core-version <range> version of @getsoda/core to depend on
30
+ -h, --help show this help
31
+ `;
32
+ const parseArguments = (args) => {
33
+ let dir;
34
+ let overwrite = false;
35
+ let interactive;
36
+ let coreVersion;
37
+ let help = false;
38
+ for (let i = 0; i < args.length; i++) {
39
+ const arg = args[i];
40
+ if (arg === '--overwrite') {
41
+ overwrite = true;
42
+ }
43
+ else if (arg === '--interactive' || arg === '--no-interactive') {
44
+ interactive = arg === '--interactive';
45
+ }
46
+ else if (arg === '--core-version') {
47
+ coreVersion = args[++i];
48
+ }
49
+ else if (arg === '-h' || arg === '--help') {
50
+ help = true;
51
+ }
52
+ else if (arg.startsWith('-')) {
53
+ throw new Error(`Unknown option ${arg}\n\n${HELP}`);
54
+ }
55
+ else {
56
+ dir ??= arg;
57
+ }
58
+ }
59
+ return { dir, overwrite, interactive, coreVersion, help };
60
+ };
61
+ /** Version range of `@getsoda/core`: written next to the build, or read from the monorepo when run from sources. */
62
+ const readCoreVersion = () => {
63
+ const built = fileURLToPath(new URL('../core-version.json', import.meta.url));
64
+ if (existsSync(built)) {
65
+ return `^${JSON.parse(readFileSync(built, 'utf8')).version}`;
66
+ }
67
+ const workspace = fileURLToPath(new URL('../../core/package.json', import.meta.url));
68
+ return `^${JSON.parse(readFileSync(workspace, 'utf8')).version}`;
69
+ };
70
+ const main = async () => {
71
+ const args = parseArguments(process.argv.slice(2));
72
+ if (args.help) {
73
+ stdout.write(HELP);
74
+ return;
75
+ }
76
+ const interactive = args.interactive ?? stdin.isTTY === true;
77
+ const prompt = interactive ? createInterface({ input: stdin, output: stdout }) : undefined;
78
+ try {
79
+ let dir = args.dir === undefined ? '' : formatTargetDir(args.dir);
80
+ if (dir === '' && prompt !== undefined) {
81
+ dir = formatTargetDir((await prompt.question(`Project directory (${DEFAULT_DIR}): `)) || DEFAULT_DIR);
82
+ }
83
+ dir ||= DEFAULT_DIR;
84
+ if (!(await isEmptyDir(dir))) {
85
+ const overwrite = args.overwrite ||
86
+ (prompt !== undefined &&
87
+ /^y(?:es)?$/i.test(await prompt.question(`${dir} is not empty. Remove its files and continue? (y/N) `)));
88
+ if (!overwrite) {
89
+ throw new Error(`${dir} is not empty; choose another directory or pass --overwrite`);
90
+ }
91
+ await emptyDir(dir);
92
+ }
93
+ let name = toValidPackageName(basename(resolve(dir)));
94
+ if (prompt !== undefined) {
95
+ const answer = (await prompt.question(`Package name (${name}): `)).trim();
96
+ if (answer !== '' && !isValidPackageName(answer)) {
97
+ throw new Error(`"${answer}" is not a valid npm package name`);
98
+ }
99
+ name = answer || name;
100
+ }
101
+ await createProject({
102
+ dir,
103
+ name,
104
+ coreVersion: args.coreVersion ?? readCoreVersion(),
105
+ template: fileURLToPath(new URL('../template', import.meta.url)),
106
+ });
107
+ const manager = detectPackageManager(process.env.npm_config_user_agent);
108
+ const cd = relative(process.cwd(), resolve(dir));
109
+ stdout.write(`\nCreated ${name} in ${resolve(dir)}\n\nNext steps:\n`);
110
+ if (cd !== '') {
111
+ stdout.write(` cd ${cd.includes(' ') ? JSON.stringify(cd) : cd}\n`);
112
+ }
113
+ stdout.write(` ${manager} install\n ${manager === 'npm' ? 'npm run' : manager} dev\n`);
114
+ }
115
+ finally {
116
+ prompt?.close();
117
+ }
118
+ };
119
+ main().catch((error) => {
120
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
121
+ process.exit(1);
122
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @module
3
+ * Creating a project from the template: names, target directory, files.
4
+ *
5
+ * Rules from `create-vite`: the target directory is cleaned of characters
6
+ * file systems refuse, a directory holding only `.git` counts as empty, the
7
+ * package name is made valid for npm, and `.gitignore` is stored as
8
+ * `_gitignore` because npm drops `.gitignore` files from published packages.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { cp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
12
+ import { basename, join, resolve } from 'node:path';
13
+ /** Template files renamed when copied. */
14
+ const RENAMED = { _gitignore: '.gitignore' };
15
+ /** `' my-app/ '` → `'my-app'`. */
16
+ export const formatTargetDir = (dir) => dir
17
+ .trim()
18
+ .replace(/[<>:"\\|?*]/g, '')
19
+ .replace(/\/+$/, '');
20
+ export const isValidPackageName = (name) => /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(name);
21
+ /** `'My App'` → `'my-app'`. */
22
+ export const toValidPackageName = (name) => name
23
+ .trim()
24
+ .toLowerCase()
25
+ .replace(/\s+/g, '-')
26
+ .replace(/^[._]/, '')
27
+ .replace(/[^a-z\d\-~]+/g, '-');
28
+ /** `true` for a missing directory, an empty one, or one holding only `.git`. */
29
+ export const isEmptyDir = async (dir) => {
30
+ if (!existsSync(dir)) {
31
+ return true;
32
+ }
33
+ const entries = await readdir(dir);
34
+ return entries.length === 0 || (entries.length === 1 && entries[0] === '.git');
35
+ };
36
+ /** Removes everything in `dir` but `.git`. */
37
+ export const emptyDir = async (dir) => {
38
+ for (const entry of await readdir(dir)) {
39
+ if (entry !== '.git') {
40
+ await rm(join(dir, entry), { recursive: true, force: true });
41
+ }
42
+ }
43
+ };
44
+ /**
45
+ * Copies the template into `dir`, renames `_gitignore`, sets the package name and the core version.
46
+ */
47
+ export const createProject = async (options) => {
48
+ const root = resolve(options.dir);
49
+ await cp(options.template, root, { recursive: true });
50
+ for (const [from, to] of Object.entries(RENAMED)) {
51
+ if (existsSync(join(root, from))) {
52
+ await rename(join(root, from), join(root, to));
53
+ }
54
+ }
55
+ const manifestPath = join(root, 'package.json');
56
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
57
+ manifest.name = options.name ?? toValidPackageName(basename(root));
58
+ manifest.dependencies['@getsoda/core'] = options.coreVersion;
59
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
60
+ };
61
+ /**
62
+ * Package manager the command was run with, from `npm_config_user_agent`
63
+ * (`bun/1.4.3 npm/? node/v24…` → `bun`). Defaults to `bun`.
64
+ */
65
+ export const detectPackageManager = (userAgent) => {
66
+ const name = userAgent?.split(' ')[0]?.split('/')[0];
67
+ return name === undefined || name === '' ? 'bun' : name;
68
+ };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "create-soda",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Create a Soda project: bun create soda my-app",
5
+ "keywords": [
6
+ "soda",
7
+ "bun",
8
+ "create",
9
+ "scaffold",
10
+ "template"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/getsodajs/soda.git",
16
+ "directory": "packages/create-soda"
17
+ },
18
+ "homepage": "https://github.com/getsodajs/soda/tree/main/packages/create-soda#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/getsodajs/soda/issues"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "create-soda": "dist/index.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "template",
29
+ "core-version.json"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && bun scripts/write-core-version.ts"
36
+ }
37
+ }
@@ -0,0 +1,21 @@
1
+ # Soda app
2
+
3
+ Created with `create-soda`. Requires [Bun](https://bun.sh).
4
+
5
+ ```bash
6
+ bun install
7
+ bun dev # bun --hot: reloads on change
8
+ bun test
9
+ bun typecheck
10
+ ```
11
+
12
+ - `src/main.ts`: creates the application, CORS, graceful shutdown.
13
+ - `src/users`: a module with a service, a controller and a test.
14
+ - `src/auth/auth.guard.ts`: a guard passing the current user to handlers.
15
+
16
+ Try it:
17
+
18
+ ```bash
19
+ curl -X POST localhost:3000/users -H 'authorization: Bearer ann' -H 'content-type: application/json' -d '{"name":"Bo"}'
20
+ curl localhost:3000/users/1
21
+ ```
@@ -0,0 +1,11 @@
1
+ node_modules
2
+ dist
3
+ *.tgz
4
+ coverage
5
+
6
+ .env
7
+ .env.local
8
+ .env.*.local
9
+
10
+ .DS_Store
11
+ .idea
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "soda-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "bun --hot src/main.ts",
8
+ "start": "bun src/main.ts",
9
+ "test": "bun test",
10
+ "typecheck": "bun check"
11
+ },
12
+ "dependencies": {
13
+ "@getsoda/core": "workspace",
14
+ "zod": "^4.6.7"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "latest",
18
+ "typescript": "^7.0.2"
19
+ }
20
+ }
@@ -0,0 +1,5 @@
1
+ import { Module } from '@getsoda/core'
2
+ import { UsersModule } from './users/users.module.ts'
3
+
4
+ @Module({ imports: [UsersModule] })
5
+ export class AppModule {}
@@ -0,0 +1,26 @@
1
+ import { ContextKey } from '@getsoda/core'
2
+ import type { CanActivate, Context } from '@getsoda/core'
3
+
4
+ export interface CurrentUser {
5
+ readonly name: string
6
+ }
7
+
8
+ /** The user the guard found; handlers read it with `ctx.get(CURRENT_USER)` or `value(CURRENT_USER)`. */
9
+ export const CURRENT_USER = new ContextKey<CurrentUser>('currentUser')
10
+
11
+ /**
12
+ * Accepts `Authorization: Bearer <name>`. A placeholder: verify real tokens here.
13
+ */
14
+ export class AuthGuard implements CanActivate {
15
+ canActivate(ctx: Context): boolean {
16
+ const header = ctx.header('authorization')
17
+
18
+ if (header === undefined || !header.startsWith('Bearer ')) {
19
+ return false
20
+ }
21
+
22
+ ctx.set(CURRENT_USER, { name: header.slice('Bearer '.length) })
23
+
24
+ return true
25
+ }
26
+ }
@@ -0,0 +1,9 @@
1
+ import { cors, createApp } from '@getsoda/core'
2
+ import { AppModule } from './app.module.ts'
3
+
4
+ const app = await createApp(AppModule, {
5
+ middleware: [cors({ origin: process.env.CORS_ORIGIN ?? '*' })],
6
+ })
7
+
8
+ app.enableShutdownSignals()
9
+ await app.listen(Number(process.env.PORT ?? 3000))
@@ -0,0 +1,35 @@
1
+ import { body, Controller, Get, inject, int, NotFoundException, param, Post, UseGuards, value } from '@getsoda/core'
2
+ import { z } from 'zod'
3
+ import { AuthGuard, CURRENT_USER } from '../auth/auth.guard.ts'
4
+ import type { CurrentUser } from '../auth/auth.guard.ts'
5
+ import { UsersService } from './users.service.ts'
6
+ import type { User } from './users.service.ts'
7
+
8
+ const CreateUser = z.object({ name: z.string().trim().min(1) })
9
+
10
+ @Controller('users')
11
+ export class UsersController {
12
+ readonly #users = inject(UsersService)
13
+
14
+ @Get()
15
+ list(): User[] {
16
+ return this.#users.list()
17
+ }
18
+
19
+ @Get(':id', [param('id', int())])
20
+ findOne(id: number): User {
21
+ const user = this.#users.find(id)
22
+
23
+ if (user === undefined) {
24
+ throw new NotFoundException(`User ${id} not found`)
25
+ }
26
+
27
+ return user
28
+ }
29
+
30
+ @Post([body(CreateUser), value(CURRENT_USER)])
31
+ @UseGuards(AuthGuard)
32
+ create(input: z.infer<typeof CreateUser>, currentUser: CurrentUser): User {
33
+ return this.#users.create(input.name, currentUser.name)
34
+ }
35
+ }
@@ -0,0 +1,6 @@
1
+ import { Module } from '@getsoda/core'
2
+ import { UsersController } from './users.controller.ts'
3
+ import { UsersService } from './users.service.ts'
4
+
5
+ @Module({ providers: [UsersService], controllers: [UsersController] })
6
+ export class UsersModule {}
@@ -0,0 +1,29 @@
1
+ import { inject, Logger } from '@getsoda/core'
2
+
3
+ export interface User {
4
+ readonly id: number
5
+ readonly name: string
6
+ readonly createdBy: string
7
+ }
8
+
9
+ export class UsersService {
10
+ readonly #logger = inject(Logger).child('UsersService')
11
+ readonly #users = new Map<number, User>()
12
+
13
+ list(): User[] {
14
+ return [...this.#users.values()]
15
+ }
16
+
17
+ find(id: number): User | undefined {
18
+ return this.#users.get(id)
19
+ }
20
+
21
+ create(name: string, createdBy: string): User {
22
+ const user = { id: this.#users.size + 1, name, createdBy }
23
+
24
+ this.#users.set(user.id, user)
25
+ this.#logger.log('User created', { id: user.id })
26
+
27
+ return user
28
+ }
29
+ }
@@ -0,0 +1,34 @@
1
+ import { afterAll, expect, test } from 'bun:test'
2
+ import { createApp } from '@getsoda/core'
3
+ import { AppModule } from '../app.module.ts'
4
+
5
+ const app = await createApp(AppModule, { logger: false })
6
+ const server = await app.listen(0)
7
+ const url = (path: string) => new URL(path, server.url)
8
+
9
+ afterAll(() => app.close())
10
+
11
+ test('creates a user when authenticated', async () => {
12
+ const response = await fetch(url('/users'), {
13
+ method: 'POST',
14
+ headers: { authorization: 'Bearer ann', 'content-type': 'application/json' },
15
+ body: JSON.stringify({ name: 'Bo' }),
16
+ })
17
+
18
+ expect(response.status).toBe(201)
19
+ expect(await response.json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
20
+ expect(await (await fetch(url('/users/1'))).json()).toEqual({ id: 1, name: 'Bo', createdBy: 'ann' })
21
+ })
22
+
23
+ test('refuses anonymous writes, invalid bodies and unknown users', async () => {
24
+ const anonymous = await fetch(url('/users'), { method: 'POST', body: '{}' })
25
+ const invalid = await fetch(url('/users'), {
26
+ method: 'POST',
27
+ headers: { authorization: 'Bearer ann', 'content-type': 'application/json' },
28
+ body: JSON.stringify({ name: '' }),
29
+ })
30
+
31
+ expect(anonymous.status).toBe(403)
32
+ expect(invalid.status).toBe(400)
33
+ expect((await fetch(url('/users/999'))).status).toBe(404)
34
+ })
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "moduleResolution": "bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "verbatimModuleSyntax": true,
10
+ "noEmit": true,
11
+ "types": ["bun"],
12
+
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noImplicitOverride": true
18
+ }
19
+ }