bosia 0.1.6 → 0.1.8
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 +1 -1
- package/src/cli/add.ts +29 -75
- package/src/cli/create.ts +32 -5
- package/src/cli/feat.ts +55 -93
- package/src/cli/registry.ts +168 -0
- package/src/core/cookies.ts +31 -15
- package/src/core/server.ts +1 -1
- package/templates/drizzle/package.json +3 -9
- package/templates/drizzle/template.json +3 -0
- package/templates/drizzle/drizzle.config.ts +0 -10
- package/templates/drizzle/src/features/drizzle/index.ts +0 -15
- package/templates/drizzle/src/features/drizzle/migrations/.gitkeep +0 -0
- package/templates/drizzle/src/features/drizzle/schemas.ts +0 -1
- package/templates/drizzle/src/features/drizzle/seeds/001_initial_todos.ts +0 -11
- package/templates/drizzle/src/features/drizzle/seeds/runner.ts +0 -80
- package/templates/drizzle/src/features/todo/index.ts +0 -3
- package/templates/drizzle/src/features/todo/queries.ts +0 -36
- package/templates/drizzle/src/features/todo/schemas/todo.table.ts +0 -9
- package/templates/drizzle/src/features/todo/types.ts +0 -5
- package/templates/drizzle/src/lib/components/todo/index.ts +0 -3
- package/templates/drizzle/src/lib/components/todo/todo-form.svelte +0 -23
- package/templates/drizzle/src/lib/components/todo/todo-item.svelte +0 -63
- package/templates/drizzle/src/lib/components/todo/todo-list.svelte +0 -21
- package/templates/drizzle/src/routes/api/todos/+server.ts +0 -18
- package/templates/drizzle/src/routes/api/todos/[id]/+server.ts +0 -42
- package/templates/drizzle/src/routes/todos/+page.server.ts +0 -52
- package/templates/drizzle/src/routes/todos/+page.svelte +0 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
package/src/cli/add.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { join, dirname } from "path";
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
|
|
3
|
-
import { spawn } from "bun";
|
|
4
3
|
import * as p from "@clack/prompts";
|
|
4
|
+
import {
|
|
5
|
+
type InstallOptions,
|
|
6
|
+
REGISTRY_URL,
|
|
7
|
+
resolveLocalRegistryOrExit,
|
|
8
|
+
readRegistryJSON,
|
|
9
|
+
readRegistryFile,
|
|
10
|
+
mergePkgJson,
|
|
11
|
+
bunAdd,
|
|
12
|
+
} from "./registry.ts";
|
|
5
13
|
|
|
6
14
|
// ─── bosia add <component> ────────────────────────────────
|
|
7
15
|
// Fetches a component from the GitHub registry (or local registry
|
|
@@ -11,8 +19,6 @@ import * as p from "@clack/prompts";
|
|
|
11
19
|
// bosia add button → src/lib/components/ui/button/
|
|
12
20
|
// bosia add shop/cart → src/lib/components/shop/cart/
|
|
13
21
|
|
|
14
|
-
const REMOTE_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
|
|
15
|
-
|
|
16
22
|
interface ComponentMeta {
|
|
17
23
|
name: string;
|
|
18
24
|
description: string;
|
|
@@ -46,8 +52,7 @@ export async function runAdd(name: string | undefined, flags: string[] = []) {
|
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
if (flags.includes("--local")) {
|
|
49
|
-
|
|
50
|
-
registryRoot = resolveLocalRegistry();
|
|
55
|
+
registryRoot = resolveLocalRegistryOrExit();
|
|
51
56
|
console.log(`⬡ Using local registry: ${registryRoot}\n`);
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -88,31 +93,35 @@ async function loadIndex(): Promise<RegistryIndex | null> {
|
|
|
88
93
|
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf-8"));
|
|
89
94
|
return null;
|
|
90
95
|
}
|
|
91
|
-
|
|
96
|
+
const res = await fetch(`${REGISTRY_URL}/index.json`);
|
|
97
|
+
if (!res.ok) return null;
|
|
98
|
+
return await res.json() as RegistryIndex;
|
|
92
99
|
} catch {
|
|
93
100
|
return null;
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
export async function addComponent(name: string, root = false) {
|
|
104
|
+
export async function addComponent(name: string, root = false, options?: InstallOptions) {
|
|
98
105
|
// Resolve the full path (e.g. "button" → "ui/button", "shop/cart" stays "shop/cart")
|
|
99
106
|
const fullPath = resolveDestPath(name);
|
|
100
107
|
|
|
101
108
|
if (installed.has(fullPath)) return;
|
|
102
109
|
installed.add(fullPath);
|
|
103
110
|
|
|
111
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
112
|
+
|
|
104
113
|
console.log(root ? `⬡ Installing component: ${name}\n` : ` 📦 Dependency: ${name}`);
|
|
105
114
|
|
|
106
|
-
const meta = await
|
|
115
|
+
const meta = await readRegistryJSON<ComponentMeta>(registryRoot, "components", fullPath, "meta.json");
|
|
107
116
|
|
|
108
117
|
// Install component dependencies first (recursive)
|
|
109
118
|
for (const dep of meta.dependencies) {
|
|
110
|
-
await addComponent(dep, false);
|
|
119
|
+
await addComponent(dep, false, options);
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
// Check if component already exists
|
|
114
|
-
const destDir = join(
|
|
115
|
-
if (existsSync(destDir)) {
|
|
122
|
+
// Check if component already exists (skip check entirely in non-interactive mode)
|
|
123
|
+
const destDir = join(cwd, "src", "lib", "components", fullPath);
|
|
124
|
+
if (!options?.skipPrompts && existsSync(destDir)) {
|
|
116
125
|
const replace = await p.confirm({
|
|
117
126
|
message: `Component "${name}" already exists at src/lib/components/${fullPath}/. Replace it?`,
|
|
118
127
|
});
|
|
@@ -126,25 +135,20 @@ export async function addComponent(name: string, root = false) {
|
|
|
126
135
|
mkdirSync(destDir, { recursive: true });
|
|
127
136
|
|
|
128
137
|
for (const file of meta.files) {
|
|
129
|
-
const content = await
|
|
138
|
+
const content = await readRegistryFile(registryRoot, "components", fullPath, file);
|
|
130
139
|
const dest = join(destDir, file);
|
|
131
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
140
|
+
if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
|
|
132
141
|
writeFileSync(dest, content, "utf-8");
|
|
133
142
|
console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
// Install npm dependencies
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
stderr: "inherit",
|
|
144
|
-
cwd: process.cwd(),
|
|
145
|
-
});
|
|
146
|
-
if ((await proc.exited) !== 0) {
|
|
147
|
-
console.warn(` ⚠️ bun add failed for: ${packages.join(", ")}`);
|
|
146
|
+
if (Object.keys(meta.npmDeps).length > 0) {
|
|
147
|
+
if (options?.skipInstall) {
|
|
148
|
+
const { addedDeps } = mergePkgJson(cwd, { deps: meta.npmDeps });
|
|
149
|
+
if (addedDeps.length > 0) console.log(` 📥 Added to package.json: ${addedDeps.join(", ")}`);
|
|
150
|
+
} else {
|
|
151
|
+
await bunAdd(cwd, meta.npmDeps);
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -169,53 +173,3 @@ function ensureUtils() {
|
|
|
169
173
|
console.log(" ✍️ src/lib/utils.ts (cn utility)\n");
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
// ─── Registry resolvers ──────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
function resolveLocalRegistry(): string {
|
|
176
|
-
// Walk up from this file's directory to find registry/
|
|
177
|
-
let dir = dirname(new URL(import.meta.url).pathname);
|
|
178
|
-
for (let i = 0; i < 10; i++) {
|
|
179
|
-
const candidate = join(dir, "registry");
|
|
180
|
-
if (existsSync(join(candidate, "index.json"))) return candidate;
|
|
181
|
-
const parent = dirname(dir);
|
|
182
|
-
if (parent === dir) break;
|
|
183
|
-
dir = parent;
|
|
184
|
-
}
|
|
185
|
-
console.error("❌ Could not find local registry/ directory.");
|
|
186
|
-
process.exit(1);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function readMeta(name: string): Promise<ComponentMeta> {
|
|
190
|
-
if (registryRoot) {
|
|
191
|
-
const path = join(registryRoot, "components", name, "meta.json");
|
|
192
|
-
if (!existsSync(path)) {
|
|
193
|
-
throw new Error(`Component "${name}" not found in local registry`);
|
|
194
|
-
}
|
|
195
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
196
|
-
}
|
|
197
|
-
return fetchJSON<ComponentMeta>(`${REMOTE_BASE}/components/${name}/meta.json`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function readFile(name: string, file: string): Promise<string> {
|
|
201
|
-
if (registryRoot) {
|
|
202
|
-
const path = join(registryRoot, "components", name, file);
|
|
203
|
-
if (!existsSync(path)) {
|
|
204
|
-
throw new Error(`File "${file}" not found for component "${name}" in local registry`);
|
|
205
|
-
}
|
|
206
|
-
return readFileSync(path, "utf-8");
|
|
207
|
-
}
|
|
208
|
-
return fetchText(`${REMOTE_BASE}/components/${name}/${file}`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function fetchJSON<T>(url: string): Promise<T> {
|
|
212
|
-
const res = await fetch(url);
|
|
213
|
-
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
214
|
-
return res.json() as Promise<T>;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function fetchText(url: string): Promise<string> {
|
|
218
|
-
const res = await fetch(url);
|
|
219
|
-
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
220
|
-
return res.text();
|
|
221
|
-
}
|
package/src/cli/create.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { resolve, join, basename, relative } from "path";
|
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import { spawn } from "bun";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
|
+
import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
|
|
6
|
+
import { initAddRegistry } from "./add.ts";
|
|
5
7
|
|
|
6
8
|
// ─── bosia create <name> [--template <name>] ──────────────
|
|
7
9
|
|
|
@@ -59,7 +61,32 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
|
59
61
|
writeFileSync(join(targetDir, ".env"), readFileSync(join(targetDir, ".env.example"), "utf-8"));
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
// Install template features from registry
|
|
65
|
+
const templateConfigPath = join(templateDir, "template.json");
|
|
66
|
+
if (existsSync(templateConfigPath)) {
|
|
67
|
+
const config = JSON.parse(readFileSync(templateConfigPath, "utf-8"));
|
|
68
|
+
if (config.features?.length) {
|
|
69
|
+
let localRegistry: string | null = null;
|
|
70
|
+
try {
|
|
71
|
+
localRegistry = resolveLocalRegistry();
|
|
72
|
+
} catch {
|
|
73
|
+
// Local registry not found — will use remote
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await initAddRegistry(localRegistry);
|
|
77
|
+
initFeatRegistry(localRegistry);
|
|
78
|
+
|
|
79
|
+
for (const feat of config.features) {
|
|
80
|
+
await installFeature(feat, true, {
|
|
81
|
+
skipInstall: true,
|
|
82
|
+
skipPrompts: true,
|
|
83
|
+
cwd: targetDir,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(`\n✅ Project created at ${targetDir}\n`);
|
|
63
90
|
|
|
64
91
|
console.log("Installing dependencies...");
|
|
65
92
|
const proc = spawn(["bun", "install"], {
|
|
@@ -72,7 +99,7 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
|
|
|
72
99
|
console.warn("⚠️ bun install failed — run it manually.");
|
|
73
100
|
} else {
|
|
74
101
|
console.log(`\n🎉 Ready!\n\ncd ${name}`);
|
|
75
|
-
|
|
102
|
+
|
|
76
103
|
const instPath = join(templateDir, "instructions.txt");
|
|
77
104
|
if (existsSync(instPath)) {
|
|
78
105
|
const instructions = readFileSync(instPath, "utf-8").trimEnd();
|
|
@@ -117,9 +144,9 @@ function copyDir(src: string, dest: string, projectName: string, isLocal: boolea
|
|
|
117
144
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
118
145
|
const srcPath = join(src, entry.name);
|
|
119
146
|
const destPath = join(dest, entry.name);
|
|
120
|
-
|
|
121
|
-
// Do not copy instructions.txt to the final project
|
|
122
|
-
if (entry.name === "instructions.txt") continue;
|
|
147
|
+
|
|
148
|
+
// Do not copy instructions.txt or template.json to the final project
|
|
149
|
+
if (entry.name === "instructions.txt" || entry.name === "template.json") continue;
|
|
123
150
|
|
|
124
151
|
if (entry.isDirectory()) {
|
|
125
152
|
copyDir(srcPath, destPath, projectName, isLocal);
|
package/src/cli/feat.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { join, dirname } from "path";
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
|
|
3
|
-
import { spawn } from "bun";
|
|
4
3
|
import * as p from "@clack/prompts";
|
|
5
4
|
import { addComponent, initAddRegistry } from "./add.ts";
|
|
5
|
+
import {
|
|
6
|
+
type InstallOptions,
|
|
7
|
+
resolveLocalRegistryOrExit,
|
|
8
|
+
readRegistryJSON,
|
|
9
|
+
readRegistryFile,
|
|
10
|
+
mergePkgJson,
|
|
11
|
+
bunAdd,
|
|
12
|
+
} from "./registry.ts";
|
|
6
13
|
|
|
7
14
|
// ─── bosia feat <feature> [--local] ──────────────────────
|
|
8
15
|
// Fetches a feature scaffold from the GitHub registry (or local
|
|
9
16
|
// registry with --local) and copies route/lib files, installs npm deps.
|
|
10
17
|
// Supports nested feature dependencies (e.g. todo → drizzle).
|
|
11
18
|
|
|
12
|
-
const REGISTRY_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
|
|
13
|
-
|
|
14
19
|
interface FeatureMeta {
|
|
15
20
|
name: string;
|
|
16
21
|
description: string;
|
|
@@ -19,6 +24,7 @@ interface FeatureMeta {
|
|
|
19
24
|
files: string[]; // source filenames in the registry feature dir
|
|
20
25
|
targets: string[]; // destination paths relative to project root
|
|
21
26
|
npmDeps: Record<string, string>;
|
|
27
|
+
npmDevDeps?: Record<string, string>;
|
|
22
28
|
scripts?: Record<string, string>; // package.json scripts to add
|
|
23
29
|
envVars?: Record<string, string>; // env vars to append to .env if missing
|
|
24
30
|
}
|
|
@@ -35,7 +41,7 @@ export async function runFeat(name: string | undefined, flags: string[] = []) {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
if (flags.includes("--local")) {
|
|
38
|
-
registryRoot =
|
|
44
|
+
registryRoot = resolveLocalRegistryOrExit();
|
|
39
45
|
console.log(`⬡ Using local registry: ${registryRoot}\n`);
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -45,18 +51,25 @@ export async function runFeat(name: string | undefined, flags: string[] = []) {
|
|
|
45
51
|
await installFeature(name, true);
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
/** Set the registry root for feature resolution. Called by create.ts for template features. */
|
|
55
|
+
export function initFeatRegistry(root: string | null) {
|
|
56
|
+
registryRoot = root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function installFeature(name: string, isRoot: boolean, options?: InstallOptions) {
|
|
49
60
|
if (installedFeats.has(name)) return;
|
|
50
61
|
installedFeats.add(name);
|
|
51
62
|
|
|
63
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
64
|
+
|
|
52
65
|
console.log(isRoot ? `⬡ Installing feature: ${name}\n` : `\n⬡ Installing dependency feature: ${name}\n`);
|
|
53
66
|
|
|
54
|
-
const meta = await
|
|
67
|
+
const meta = await readRegistryJSON<FeatureMeta>(registryRoot, "features", name, "meta.json");
|
|
55
68
|
|
|
56
69
|
// Install required feature dependencies first (recursive)
|
|
57
70
|
if (meta.features && meta.features.length > 0) {
|
|
58
71
|
for (const feat of meta.features) {
|
|
59
|
-
await installFeature(feat, false);
|
|
72
|
+
await installFeature(feat, false, options);
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
@@ -64,19 +77,20 @@ async function installFeature(name: string, isRoot: boolean) {
|
|
|
64
77
|
if (meta.components.length > 0) {
|
|
65
78
|
console.log("📦 Installing required components...");
|
|
66
79
|
for (const comp of meta.components) {
|
|
67
|
-
await addComponent(comp, false);
|
|
80
|
+
await addComponent(comp, false, options);
|
|
68
81
|
}
|
|
69
82
|
console.log("");
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
// Copy feature files to their target paths
|
|
86
|
+
const createdDirs = new Set<string>();
|
|
73
87
|
for (let i = 0; i < meta.files.length; i++) {
|
|
74
88
|
const file = meta.files[i]!;
|
|
75
89
|
const target = meta.targets[i] ?? file;
|
|
76
|
-
const dest = join(
|
|
90
|
+
const dest = join(cwd, target);
|
|
77
91
|
|
|
78
|
-
// Prompt before overwriting existing files
|
|
79
|
-
if (existsSync(dest)) {
|
|
92
|
+
// Prompt before overwriting existing files (skip check entirely in non-interactive mode)
|
|
93
|
+
if (!options?.skipPrompts && existsSync(dest)) {
|
|
80
94
|
const replace = await p.confirm({
|
|
81
95
|
message: `File "${target}" already exists. Replace it?`,
|
|
82
96
|
});
|
|
@@ -86,52 +100,46 @@ async function installFeature(name: string, isRoot: boolean) {
|
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
const content = await
|
|
90
|
-
|
|
103
|
+
const content = await readRegistryFile(registryRoot, "features", name, file);
|
|
104
|
+
const dir = dirname(dest);
|
|
105
|
+
if (!createdDirs.has(dir)) {
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
createdDirs.add(dir);
|
|
108
|
+
}
|
|
91
109
|
writeFileSync(dest, content, "utf-8");
|
|
92
110
|
console.log(` ✍️ ${target}`);
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
// Install npm dependencies
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (existsSync(pkgPath)) {
|
|
115
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
116
|
-
pkg.scripts = pkg.scripts ?? {};
|
|
117
|
-
const added: string[] = [];
|
|
118
|
-
for (const [key, val] of scriptEntries) {
|
|
119
|
-
if (!pkg.scripts[key]) {
|
|
120
|
-
pkg.scripts[key] = val;
|
|
121
|
-
added.push(key);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (added.length > 0) {
|
|
125
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
126
|
-
console.log(`\n📜 Added scripts: ${added.join(", ")}`);
|
|
114
|
+
const hasDeps = Object.keys(meta.npmDeps).length > 0;
|
|
115
|
+
const hasDevDeps = Object.keys(meta.npmDevDeps ?? {}).length > 0;
|
|
116
|
+
const hasScripts = Object.keys(meta.scripts ?? {}).length > 0;
|
|
117
|
+
|
|
118
|
+
if (hasDeps || hasDevDeps) {
|
|
119
|
+
if (options?.skipInstall) {
|
|
120
|
+
const { addedDeps, addedScripts } = mergePkgJson(cwd, {
|
|
121
|
+
deps: meta.npmDeps,
|
|
122
|
+
devDeps: meta.npmDevDeps,
|
|
123
|
+
scripts: meta.scripts,
|
|
124
|
+
});
|
|
125
|
+
if (addedDeps.length > 0) console.log(`\n📥 Added to package.json: ${addedDeps.join(", ")}`);
|
|
126
|
+
if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
|
|
127
|
+
} else {
|
|
128
|
+
await bunAdd(cwd, meta.npmDeps, meta.npmDevDeps);
|
|
129
|
+
if (hasScripts) {
|
|
130
|
+
const { addedScripts } = mergePkgJson(cwd, { scripts: meta.scripts });
|
|
131
|
+
if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
|
|
127
132
|
}
|
|
128
133
|
}
|
|
134
|
+
} else if (hasScripts) {
|
|
135
|
+
const { addedScripts } = mergePkgJson(cwd, { scripts: meta.scripts });
|
|
136
|
+
if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
// Append env vars to .env if missing
|
|
132
140
|
const envEntries = Object.entries(meta.envVars ?? {});
|
|
133
141
|
if (envEntries.length > 0) {
|
|
134
|
-
const envPath = join(
|
|
142
|
+
const envPath = join(cwd, ".env");
|
|
135
143
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
136
144
|
const toAdd: string[] = [];
|
|
137
145
|
for (const [key, val] of envEntries) {
|
|
@@ -154,51 +162,5 @@ async function installFeature(name: string, isRoot: boolean) {
|
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
function resolveLocalRegistry(): string {
|
|
160
|
-
let dir = dirname(new URL(import.meta.url).pathname);
|
|
161
|
-
for (let i = 0; i < 10; i++) {
|
|
162
|
-
const candidate = join(dir, "registry");
|
|
163
|
-
if (existsSync(join(candidate, "index.json"))) return candidate;
|
|
164
|
-
const parent = dirname(dir);
|
|
165
|
-
if (parent === dir) break;
|
|
166
|
-
dir = parent;
|
|
167
|
-
}
|
|
168
|
-
console.error("❌ Could not find local registry/ directory.");
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function readMeta(name: string): Promise<FeatureMeta> {
|
|
173
|
-
if (registryRoot) {
|
|
174
|
-
const path = join(registryRoot, "features", name, "meta.json");
|
|
175
|
-
if (!existsSync(path)) {
|
|
176
|
-
throw new Error(`Feature "${name}" not found in local registry`);
|
|
177
|
-
}
|
|
178
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
179
|
-
}
|
|
180
|
-
return fetchJSON<FeatureMeta>(`${REGISTRY_BASE}/features/${name}/meta.json`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function readFile(name: string, file: string): Promise<string> {
|
|
184
|
-
if (registryRoot) {
|
|
185
|
-
const path = join(registryRoot, "features", name, file);
|
|
186
|
-
if (!existsSync(path)) {
|
|
187
|
-
throw new Error(`File "${file}" not found for feature "${name}" in local registry`);
|
|
188
|
-
}
|
|
189
|
-
return readFileSync(path, "utf-8");
|
|
190
|
-
}
|
|
191
|
-
return fetchText(`${REGISTRY_BASE}/features/${name}/${file}`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function fetchJSON<T>(url: string): Promise<T> {
|
|
195
|
-
const res = await fetch(url);
|
|
196
|
-
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
197
|
-
return res.json() as Promise<T>;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function fetchText(url: string): Promise<string> {
|
|
201
|
-
const res = await fetch(url);
|
|
202
|
-
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
203
|
-
return res.text();
|
|
204
|
-
}
|
|
165
|
+
// Re-exports for create.ts
|
|
166
|
+
export { resolveLocalRegistry, type InstallOptions } from "./registry.ts";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { join, dirname } from "path";
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { spawn } from "bun";
|
|
4
|
+
|
|
5
|
+
// ─── Shared registry utilities for feat.ts and add.ts ─────
|
|
6
|
+
|
|
7
|
+
export const REGISTRY_URL = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
|
|
8
|
+
|
|
9
|
+
export interface InstallOptions {
|
|
10
|
+
skipInstall?: boolean; // write deps to package.json instead of `bun add`
|
|
11
|
+
skipPrompts?: boolean; // auto-overwrite, no interactive prompts
|
|
12
|
+
cwd?: string; // override process.cwd() for file operations
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Local registry resolution ────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function resolveLocalRegistry(): string {
|
|
18
|
+
let dir = dirname(new URL(import.meta.url).pathname);
|
|
19
|
+
for (let i = 0; i < 10; i++) {
|
|
20
|
+
const candidate = join(dir, "registry");
|
|
21
|
+
if (existsSync(join(candidate, "index.json"))) return candidate;
|
|
22
|
+
const parent = dirname(dir);
|
|
23
|
+
if (parent === dir) break;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
throw new Error("Could not find local registry/ directory.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolve local registry, exiting with error message on failure. For CLI entry points. */
|
|
30
|
+
export function resolveLocalRegistryOrExit(): string {
|
|
31
|
+
try {
|
|
32
|
+
return resolveLocalRegistry();
|
|
33
|
+
} catch {
|
|
34
|
+
console.error("❌ Could not find local registry/ directory.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Registry file readers ────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Read and parse a JSON file from the registry (local or remote). */
|
|
42
|
+
export async function readRegistryJSON<T>(
|
|
43
|
+
registryRoot: string | null,
|
|
44
|
+
category: string,
|
|
45
|
+
name: string,
|
|
46
|
+
file: string,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
if (registryRoot) {
|
|
49
|
+
const path = join(registryRoot, category, name, file);
|
|
50
|
+
if (!existsSync(path)) {
|
|
51
|
+
throw new Error(`"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
54
|
+
}
|
|
55
|
+
return fetchJSON<T>(`${REGISTRY_URL}/${category}/${name}/${file}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a text file from the registry (local or remote). */
|
|
59
|
+
export async function readRegistryFile(
|
|
60
|
+
registryRoot: string | null,
|
|
61
|
+
category: string,
|
|
62
|
+
name: string,
|
|
63
|
+
file: string,
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
if (registryRoot) {
|
|
66
|
+
const path = join(registryRoot, category, name, file);
|
|
67
|
+
if (!existsSync(path)) {
|
|
68
|
+
throw new Error(`File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
|
|
69
|
+
}
|
|
70
|
+
return readFileSync(path, "utf-8");
|
|
71
|
+
}
|
|
72
|
+
return fetchText(`${REGISTRY_URL}/${category}/${name}/${file}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── package.json helpers ─────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface PkgDeps {
|
|
78
|
+
deps?: Record<string, string>;
|
|
79
|
+
devDeps?: Record<string, string>;
|
|
80
|
+
scripts?: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Merge dependencies and scripts into package.json in a single read/write.
|
|
85
|
+
* Returns the list of added keys, or empty arrays if nothing changed.
|
|
86
|
+
*/
|
|
87
|
+
export function mergePkgJson(cwd: string, changes: PkgDeps): { addedDeps: string[]; addedScripts: string[] } {
|
|
88
|
+
const pkgPath = join(cwd, "package.json");
|
|
89
|
+
if (!existsSync(pkgPath)) return { addedDeps: [], addedScripts: [] };
|
|
90
|
+
|
|
91
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
92
|
+
let changed = false;
|
|
93
|
+
const addedDeps: string[] = [];
|
|
94
|
+
const addedScripts: string[] = [];
|
|
95
|
+
|
|
96
|
+
if (changes.deps && Object.keys(changes.deps).length > 0) {
|
|
97
|
+
pkg.dependencies = pkg.dependencies ?? {};
|
|
98
|
+
for (const [name, ver] of Object.entries(changes.deps)) {
|
|
99
|
+
if (!pkg.dependencies[name]) {
|
|
100
|
+
pkg.dependencies[name] = ver;
|
|
101
|
+
addedDeps.push(name);
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (changes.devDeps && Object.keys(changes.devDeps).length > 0) {
|
|
108
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
109
|
+
for (const [name, ver] of Object.entries(changes.devDeps)) {
|
|
110
|
+
if (!pkg.devDependencies[name]) {
|
|
111
|
+
pkg.devDependencies[name] = ver;
|
|
112
|
+
addedDeps.push(name);
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (changes.scripts && Object.keys(changes.scripts).length > 0) {
|
|
119
|
+
pkg.scripts = pkg.scripts ?? {};
|
|
120
|
+
for (const [key, val] of Object.entries(changes.scripts)) {
|
|
121
|
+
if (!pkg.scripts[key]) {
|
|
122
|
+
pkg.scripts[key] = val;
|
|
123
|
+
addedScripts.push(key);
|
|
124
|
+
changed = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (changed) {
|
|
130
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { addedDeps, addedScripts };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Run `bun add` for deps and optionally `bun add --dev` for devDeps. */
|
|
137
|
+
export async function bunAdd(cwd: string, deps: Record<string, string>, devDeps?: Record<string, string>): Promise<void> {
|
|
138
|
+
const packages = Object.entries(deps).map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
|
|
139
|
+
if (packages.length > 0) {
|
|
140
|
+
console.log(`\n📥 npm: ${packages.join(", ")}`);
|
|
141
|
+
const proc = spawn(["bun", "add", ...packages], { stdout: "inherit", stderr: "inherit", cwd });
|
|
142
|
+
if ((await proc.exited) !== 0) {
|
|
143
|
+
console.warn(`⚠️ bun add failed for: ${packages.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const devPackages = Object.entries(devDeps ?? {}).map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
|
|
147
|
+
if (devPackages.length > 0) {
|
|
148
|
+
console.log(`\n📥 npm (dev): ${devPackages.join(", ")}`);
|
|
149
|
+
const proc = spawn(["bun", "add", "--dev", ...devPackages], { stdout: "inherit", stderr: "inherit", cwd });
|
|
150
|
+
if ((await proc.exited) !== 0) {
|
|
151
|
+
console.warn(`⚠️ bun add --dev failed for: ${devPackages.join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── HTTP helpers ─────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async function fetchJSON<T>(url: string): Promise<T> {
|
|
159
|
+
const res = await fetch(url);
|
|
160
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
161
|
+
return res.json() as Promise<T>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function fetchText(url: string): Promise<string> {
|
|
165
|
+
const res = await fetch(url);
|
|
166
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
167
|
+
return res.text();
|
|
168
|
+
}
|
package/src/core/cookies.ts
CHANGED
|
@@ -11,6 +11,15 @@ const VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
|
11
11
|
*/
|
|
12
12
|
const VALID_COOKIE_NAME = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
13
13
|
|
|
14
|
+
// ─── Cookie Defaults ─────────────────────────────────────
|
|
15
|
+
/** Secure defaults matching SvelteKit conventions. */
|
|
16
|
+
const COOKIE_DEFAULTS: CookieOptions = {
|
|
17
|
+
path: "/",
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
secure: true,
|
|
20
|
+
sameSite: "Lax",
|
|
21
|
+
};
|
|
22
|
+
|
|
14
23
|
// ─── Cookie Helpers ──────────────────────────────────────
|
|
15
24
|
|
|
16
25
|
function parseCookies(header: string): Record<string, string> {
|
|
@@ -31,9 +40,14 @@ function parseCookies(header: string): Record<string, string> {
|
|
|
31
40
|
export class CookieJar implements Cookies {
|
|
32
41
|
private _incoming: Record<string, string>;
|
|
33
42
|
private _outgoing: string[] = [];
|
|
43
|
+
private _defaults: CookieOptions;
|
|
34
44
|
|
|
35
|
-
constructor(cookieHeader: string) {
|
|
45
|
+
constructor(cookieHeader: string, dev = false) {
|
|
36
46
|
this._incoming = parseCookies(cookieHeader);
|
|
47
|
+
// In dev mode, omit Secure — browsers reject Secure cookies over http://localhost
|
|
48
|
+
this._defaults = dev
|
|
49
|
+
? { ...COOKIE_DEFAULTS, secure: false }
|
|
50
|
+
: COOKIE_DEFAULTS;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
get(name: string): string | undefined {
|
|
@@ -46,27 +60,29 @@ export class CookieJar implements Cookies {
|
|
|
46
60
|
|
|
47
61
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
48
62
|
if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
|
63
|
+
const opts = { ...this._defaults, ...options };
|
|
49
64
|
let header = `${name}=${encodeURIComponent(value)}`;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
if (opts.path) {
|
|
66
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.path)) throw new Error(`Invalid cookie path: ${opts.path}`);
|
|
67
|
+
header += `; Path=${opts.path}`;
|
|
68
|
+
}
|
|
69
|
+
if (opts.domain) {
|
|
70
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.domain)) throw new Error(`Invalid cookie domain: ${opts.domain}`);
|
|
71
|
+
header += `; Domain=${opts.domain}`;
|
|
56
72
|
}
|
|
57
|
-
if (
|
|
58
|
-
if (
|
|
59
|
-
if (
|
|
60
|
-
if (
|
|
61
|
-
if (
|
|
62
|
-
if (!VALID_SAMESITE.has(
|
|
63
|
-
header += `; SameSite=${
|
|
73
|
+
if (opts.maxAge != null) header += `; Max-Age=${opts.maxAge}`;
|
|
74
|
+
if (opts.expires) header += `; Expires=${opts.expires.toUTCString()}`;
|
|
75
|
+
if (opts.httpOnly) header += "; HttpOnly";
|
|
76
|
+
if (opts.secure) header += "; Secure";
|
|
77
|
+
if (opts.sameSite) {
|
|
78
|
+
if (!VALID_SAMESITE.has(opts.sameSite)) throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
|
|
79
|
+
header += `; SameSite=${opts.sameSite}`;
|
|
64
80
|
}
|
|
65
81
|
this._outgoing.push(header);
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
delete(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
69
|
-
this.set(name, "", {
|
|
85
|
+
this.set(name, "", { ...options, maxAge: 0 });
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
get outgoing(): readonly string[] {
|
package/src/core/server.ts
CHANGED
|
@@ -328,7 +328,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
328
328
|
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "");
|
|
331
|
+
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
|
|
332
332
|
const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
|
|
333
333
|
const response = userHandle
|
|
334
334
|
? await userHandle({ event, resolve })
|
|
@@ -5,22 +5,16 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bosia dev",
|
|
7
7
|
"build": "bosia build",
|
|
8
|
-
"start": "bosia start"
|
|
9
|
-
"db:generate": "drizzle-kit generate",
|
|
10
|
-
"db:migrate": "drizzle-kit migrate",
|
|
11
|
-
"db:seed": "bun run src/features/drizzle/seeds/runner.ts"
|
|
8
|
+
"start": "bosia start"
|
|
12
9
|
},
|
|
13
10
|
"dependencies": {
|
|
14
11
|
"bosia": "^{{BOSIA_VERSION}}",
|
|
15
12
|
"svelte": "^5.20.0",
|
|
16
13
|
"clsx": "^2.1.1",
|
|
17
|
-
"tailwind-merge": "^3.5.0"
|
|
18
|
-
"drizzle-orm": "^0.44.0",
|
|
19
|
-
"postgres": "^3.4.5"
|
|
14
|
+
"tailwind-merge": "^3.5.0"
|
|
20
15
|
},
|
|
21
16
|
"devDependencies": {
|
|
22
17
|
"@types/bun": "latest",
|
|
23
|
-
"typescript": "^5"
|
|
24
|
-
"drizzle-kit": "^0.31.0"
|
|
18
|
+
"typescript": "^5"
|
|
25
19
|
}
|
|
26
20
|
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
-
import postgres from "postgres";
|
|
3
|
-
import * as schema from "./schemas";
|
|
4
|
-
|
|
5
|
-
const connectionString = process.env.DATABASE_URL;
|
|
6
|
-
|
|
7
|
-
if (!connectionString) {
|
|
8
|
-
console.warn("⚠️ DATABASE_URL is not set. Database queries will fail.");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const client = postgres(connectionString || "postgres://localhost/dummy");
|
|
12
|
-
|
|
13
|
-
export const db = drizzle(client, { schema });
|
|
14
|
-
|
|
15
|
-
export type Database = typeof db;
|
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "../todo/schemas/todo.table";
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { Database } from "../index";
|
|
2
|
-
import { todos } from "../../todo/schemas/todo.table";
|
|
3
|
-
|
|
4
|
-
export async function seed(db: Database) {
|
|
5
|
-
await db.insert(todos).values([
|
|
6
|
-
{ title: "Learn Bosia framework" },
|
|
7
|
-
{ title: "Set up PostgreSQL with Drizzle" },
|
|
8
|
-
{ title: "Build a full-stack app", completed: true },
|
|
9
|
-
{ title: "Deploy to production" },
|
|
10
|
-
]);
|
|
11
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { sql } from "drizzle-orm";
|
|
2
|
-
import { db } from "../index";
|
|
3
|
-
|
|
4
|
-
// Seed tracking table in the "drizzle" schema (same schema Drizzle Kit uses for migrations)
|
|
5
|
-
const SEEDS_TABLE = "__bosia_seeds";
|
|
6
|
-
|
|
7
|
-
interface SeedModule {
|
|
8
|
-
seed: (db: typeof import("../index").db) => Promise<void>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function ensureSeedsTable() {
|
|
12
|
-
await db.execute(sql`
|
|
13
|
-
CREATE SCHEMA IF NOT EXISTS drizzle
|
|
14
|
-
`);
|
|
15
|
-
await db.execute(sql`
|
|
16
|
-
CREATE TABLE IF NOT EXISTS drizzle.${sql.identifier(SEEDS_TABLE)} (
|
|
17
|
-
id SERIAL PRIMARY KEY,
|
|
18
|
-
name TEXT NOT NULL UNIQUE,
|
|
19
|
-
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
20
|
-
)
|
|
21
|
-
`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function getAppliedSeeds(): Promise<Set<string>> {
|
|
25
|
-
const rows = await db.execute<{ name: string }>(
|
|
26
|
-
sql`SELECT name FROM drizzle.${sql.identifier(SEEDS_TABLE)} ORDER BY id`
|
|
27
|
-
);
|
|
28
|
-
return new Set(rows.map((r) => r.name));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function markSeedApplied(name: string) {
|
|
32
|
-
await db.execute(
|
|
33
|
-
sql`INSERT INTO drizzle.${sql.identifier(SEEDS_TABLE)} (name) VALUES (${name})`
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function run() {
|
|
38
|
-
console.log("Running seeds...\n");
|
|
39
|
-
|
|
40
|
-
await ensureSeedsTable();
|
|
41
|
-
const applied = await getAppliedSeeds();
|
|
42
|
-
|
|
43
|
-
// Discover seed files (*.ts, excluding runner.ts)
|
|
44
|
-
const glob = new Bun.Glob("*.ts");
|
|
45
|
-
const seedDir = import.meta.dir;
|
|
46
|
-
const files = Array.from(glob.scanSync(seedDir))
|
|
47
|
-
.filter((f) => f !== "runner.ts")
|
|
48
|
-
.sort();
|
|
49
|
-
|
|
50
|
-
let count = 0;
|
|
51
|
-
|
|
52
|
-
for (const file of files) {
|
|
53
|
-
const name = file.replace(/\.ts$/, "");
|
|
54
|
-
|
|
55
|
-
if (applied.has(name)) {
|
|
56
|
-
console.log(` skip ${name} (already applied)`);
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log(` seed ${name}`);
|
|
61
|
-
const mod: SeedModule = await import(`./${file}`);
|
|
62
|
-
|
|
63
|
-
if (typeof mod.seed !== "function") {
|
|
64
|
-
console.error(` ERROR ${file} does not export a seed() function, skipping`);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
await mod.seed(db);
|
|
69
|
-
await markSeedApplied(name);
|
|
70
|
-
count++;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
console.log(`\nDone. Applied ${count} seed(s).`);
|
|
74
|
-
process.exit(0);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
run().catch((err) => {
|
|
78
|
-
console.error("Seed runner failed:", err);
|
|
79
|
-
process.exit(1);
|
|
80
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
2
|
-
import { db } from "../drizzle";
|
|
3
|
-
import { todos } from "./schemas/todo.table";
|
|
4
|
-
import type { NewTodo } from "./types";
|
|
5
|
-
|
|
6
|
-
export function getAll() {
|
|
7
|
-
return db.query.todos.findMany({
|
|
8
|
-
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
|
9
|
-
});
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function getById(id: string) {
|
|
13
|
-
return db.query.todos.findFirst({
|
|
14
|
-
where: eq(todos.id, id),
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function create(data: Pick<NewTodo, "title">) {
|
|
19
|
-
return db.insert(todos).values(data).returning();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function update(id: string, data: Partial<Pick<NewTodo, "title" | "completed">>) {
|
|
23
|
-
return db
|
|
24
|
-
.update(todos)
|
|
25
|
-
.set({ ...data, updatedAt: new Date() })
|
|
26
|
-
.where(eq(todos.id, id))
|
|
27
|
-
.returning();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function toggle(id: string, completed: boolean) {
|
|
31
|
-
return update(id, { completed });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function remove(id: string) {
|
|
35
|
-
return db.delete(todos).where(eq(todos.id, id)).returning();
|
|
36
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
-
|
|
3
|
-
export const todos = pgTable("todos", {
|
|
4
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
5
|
-
title: text("title").notNull(),
|
|
6
|
-
completed: boolean("completed").notNull().default(false),
|
|
7
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
8
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let { error }: { error?: string } = $props();
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<form method="POST" action="?/create" class="flex gap-2">
|
|
6
|
-
<input
|
|
7
|
-
name="title"
|
|
8
|
-
type="text"
|
|
9
|
-
placeholder="What needs to be done?"
|
|
10
|
-
required
|
|
11
|
-
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
12
|
-
/>
|
|
13
|
-
<button
|
|
14
|
-
type="submit"
|
|
15
|
-
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
16
|
-
>
|
|
17
|
-
Add
|
|
18
|
-
</button>
|
|
19
|
-
</form>
|
|
20
|
-
|
|
21
|
-
{#if error}
|
|
22
|
-
<p class="mt-2 text-sm text-destructive">{error}</p>
|
|
23
|
-
{/if}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Todo } from "../../../features/todo/types";
|
|
3
|
-
|
|
4
|
-
let { todo }: { todo: Todo } = $props();
|
|
5
|
-
let editing = $state(false);
|
|
6
|
-
let editTitle = $state(todo.title);
|
|
7
|
-
</script>
|
|
8
|
-
|
|
9
|
-
<li class="group flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-accent/50">
|
|
10
|
-
<!-- Toggle -->
|
|
11
|
-
<form method="POST" action="?/toggle">
|
|
12
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
13
|
-
<input type="hidden" name="completed" value={String(!todo.completed)} />
|
|
14
|
-
<button
|
|
15
|
-
type="submit"
|
|
16
|
-
class="flex size-5 items-center justify-center rounded border border-input transition-colors hover:border-primary {todo.completed ? 'bg-primary border-primary' : ''}"
|
|
17
|
-
aria-label={todo.completed ? 'Mark incomplete' : 'Mark complete'}
|
|
18
|
-
>
|
|
19
|
-
{#if todo.completed}
|
|
20
|
-
<svg class="size-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
|
21
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
22
|
-
</svg>
|
|
23
|
-
{/if}
|
|
24
|
-
</button>
|
|
25
|
-
</form>
|
|
26
|
-
|
|
27
|
-
<!-- Title / Edit -->
|
|
28
|
-
{#if editing}
|
|
29
|
-
<form method="POST" action="?/update" class="flex flex-1 gap-2" onsubmit={() => (editing = false)}>
|
|
30
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
31
|
-
<input
|
|
32
|
-
name="title"
|
|
33
|
-
type="text"
|
|
34
|
-
bind:value={editTitle}
|
|
35
|
-
required
|
|
36
|
-
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
37
|
-
/>
|
|
38
|
-
<button type="submit" class="text-sm text-primary hover:underline">Save</button>
|
|
39
|
-
<button type="button" class="text-sm text-muted-foreground hover:underline" onclick={() => { editing = false; editTitle = todo.title; }}>Cancel</button>
|
|
40
|
-
</form>
|
|
41
|
-
{:else}
|
|
42
|
-
<span
|
|
43
|
-
class="flex-1 text-sm {todo.completed ? 'line-through text-muted-foreground' : ''}"
|
|
44
|
-
ondblclick={() => (editing = true)}
|
|
45
|
-
>
|
|
46
|
-
{todo.title}
|
|
47
|
-
</span>
|
|
48
|
-
{/if}
|
|
49
|
-
|
|
50
|
-
<!-- Delete -->
|
|
51
|
-
<form method="POST" action="?/delete">
|
|
52
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
53
|
-
<button
|
|
54
|
-
type="submit"
|
|
55
|
-
class="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
|
|
56
|
-
aria-label="Delete todo"
|
|
57
|
-
>
|
|
58
|
-
<svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
59
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
60
|
-
</svg>
|
|
61
|
-
</button>
|
|
62
|
-
</form>
|
|
63
|
-
</li>
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Todo } from "../../../features/todo/types";
|
|
3
|
-
import TodoItem from "./todo-item.svelte";
|
|
4
|
-
|
|
5
|
-
let { todos }: { todos: Todo[] } = $props();
|
|
6
|
-
</script>
|
|
7
|
-
|
|
8
|
-
{#if todos.length === 0}
|
|
9
|
-
<div class="rounded-md border border-dashed border-border py-12 text-center">
|
|
10
|
-
<p class="text-muted-foreground">No todos yet. Add one above!</p>
|
|
11
|
-
</div>
|
|
12
|
-
{:else}
|
|
13
|
-
<ul class="space-y-2">
|
|
14
|
-
{#each todos as todo (todo.id)}
|
|
15
|
-
<TodoItem {todo} />
|
|
16
|
-
{/each}
|
|
17
|
-
</ul>
|
|
18
|
-
<p class="mt-3 text-xs text-muted-foreground">
|
|
19
|
-
{todos.filter(t => t.completed).length} of {todos.length} completed
|
|
20
|
-
</p>
|
|
21
|
-
{/if}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { RequestEvent } from "bosia";
|
|
2
|
-
import { todoQueries } from "../../../features/todo";
|
|
3
|
-
|
|
4
|
-
export async function GET() {
|
|
5
|
-
const todos = await todoQueries.getAll();
|
|
6
|
-
return Response.json(todos);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export async function POST({ request }: RequestEvent) {
|
|
10
|
-
const body = await request.json().catch(() => null);
|
|
11
|
-
|
|
12
|
-
if (!body?.title?.trim()) {
|
|
13
|
-
return Response.json({ error: "Title is required" }, { status: 400 });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const [todo] = await todoQueries.create({ title: body.title.trim() });
|
|
17
|
-
return Response.json(todo, { status: 201 });
|
|
18
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { RequestEvent } from "bosia";
|
|
2
|
-
import { todoQueries } from "../../../../features/todo";
|
|
3
|
-
|
|
4
|
-
export async function GET({ params }: RequestEvent) {
|
|
5
|
-
const todo = await todoQueries.getById(params.id);
|
|
6
|
-
|
|
7
|
-
if (!todo) {
|
|
8
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return Response.json(todo);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function PUT({ params, request }: RequestEvent) {
|
|
15
|
-
const body = await request.json().catch(() => null);
|
|
16
|
-
|
|
17
|
-
if (!body || (body.title !== undefined && !body.title?.trim())) {
|
|
18
|
-
return Response.json({ error: "Invalid body" }, { status: 400 });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const data: { title?: string; completed?: boolean } = {};
|
|
22
|
-
if (body.title !== undefined) data.title = body.title.trim();
|
|
23
|
-
if (body.completed !== undefined) data.completed = body.completed;
|
|
24
|
-
|
|
25
|
-
const [todo] = await todoQueries.update(params.id, data);
|
|
26
|
-
|
|
27
|
-
if (!todo) {
|
|
28
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return Response.json(todo);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function DELETE({ params }: RequestEvent) {
|
|
35
|
-
const [todo] = await todoQueries.remove(params.id);
|
|
36
|
-
|
|
37
|
-
if (!todo) {
|
|
38
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return Response.json(todo);
|
|
42
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { fail } from "bosia";
|
|
2
|
-
import type { RequestEvent } from "bosia";
|
|
3
|
-
import { todoQueries } from "../../features/todo";
|
|
4
|
-
|
|
5
|
-
export async function load() {
|
|
6
|
-
const todos = await todoQueries.getAll();
|
|
7
|
-
return { todos };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const actions = {
|
|
11
|
-
create: async ({ request }: RequestEvent) => {
|
|
12
|
-
const data = await request.formData();
|
|
13
|
-
const title = (data.get("title") as string)?.trim();
|
|
14
|
-
|
|
15
|
-
if (!title) {
|
|
16
|
-
return fail(400, { error: "Title is required" });
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
await todoQueries.create({ title });
|
|
20
|
-
return { success: true };
|
|
21
|
-
},
|
|
22
|
-
|
|
23
|
-
toggle: async ({ request }: RequestEvent) => {
|
|
24
|
-
const data = await request.formData();
|
|
25
|
-
const id = data.get("id") as string;
|
|
26
|
-
const completed = data.get("completed") === "true";
|
|
27
|
-
|
|
28
|
-
await todoQueries.toggle(id, completed);
|
|
29
|
-
return { success: true };
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
update: async ({ request }: RequestEvent) => {
|
|
33
|
-
const data = await request.formData();
|
|
34
|
-
const id = data.get("id") as string;
|
|
35
|
-
const title = (data.get("title") as string)?.trim();
|
|
36
|
-
|
|
37
|
-
if (!title) {
|
|
38
|
-
return fail(400, { error: "Title is required" });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
await todoQueries.update(id, { title });
|
|
42
|
-
return { success: true };
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
delete: async ({ request }: RequestEvent) => {
|
|
46
|
-
const data = await request.formData();
|
|
47
|
-
const id = data.get("id") as string;
|
|
48
|
-
|
|
49
|
-
await todoQueries.remove(id);
|
|
50
|
-
return { success: true };
|
|
51
|
-
},
|
|
52
|
-
};
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { PageData, ActionData } from './$types';
|
|
3
|
-
import { TodoForm, TodoList } from "$lib/components/todo";
|
|
4
|
-
|
|
5
|
-
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
6
|
-
</script>
|
|
7
|
-
|
|
8
|
-
<svelte:head>
|
|
9
|
-
<title>Todos</title>
|
|
10
|
-
</svelte:head>
|
|
11
|
-
|
|
12
|
-
<div class="flex min-h-screen flex-col bg-background text-foreground">
|
|
13
|
-
<header class="sticky top-0 z-10 border-b bg-background/80 backdrop-blur">
|
|
14
|
-
<nav class="mx-auto flex max-w-2xl items-center gap-6 px-4 py-3">
|
|
15
|
-
<a href="/" class="font-bold tracking-tight flex items-center gap-2">
|
|
16
|
-
<img src="/favicon.svg" alt="" class="size-5" />
|
|
17
|
-
Todos
|
|
18
|
-
</a>
|
|
19
|
-
<a href="/" class="text-sm text-muted-foreground hover:text-foreground transition-colors">Home</a>
|
|
20
|
-
<a href="/api/todos" target="_blank" class="text-sm text-muted-foreground hover:text-foreground transition-colors">API</a>
|
|
21
|
-
</nav>
|
|
22
|
-
</header>
|
|
23
|
-
|
|
24
|
-
<main class="mx-auto w-full max-w-2xl flex-1 px-4 py-8">
|
|
25
|
-
<div class="space-y-6">
|
|
26
|
-
<div>
|
|
27
|
-
<h1 class="text-2xl font-bold tracking-tight">Todos</h1>
|
|
28
|
-
<p class="mt-1 text-sm text-muted-foreground">A full-stack CRUD demo with Drizzle ORM</p>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<TodoForm error={form?.error} />
|
|
32
|
-
<TodoList todos={data.todos} />
|
|
33
|
-
</div>
|
|
34
|
-
</main>
|
|
35
|
-
|
|
36
|
-
<footer class="border-t py-4 text-center text-sm text-muted-foreground">
|
|
37
|
-
Powered by Bosia
|
|
38
|
-
</footer>
|
|
39
|
-
</div>
|