bosia 0.6.3 → 0.6.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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/feat.ts CHANGED
@@ -28,6 +28,17 @@ interface FileEntry {
28
28
  target: string;
29
29
  strategy?: FileStrategy;
30
30
  marker?: string; // unique id within target (default = feature name)
31
+ when?: Record<string, string>; // install only if every option value matches
32
+ }
33
+
34
+ interface FeatureOption {
35
+ name: string; // option key, also used in FileEntry.when
36
+ flag?: string; // short flag, e.g. "-d"
37
+ long?: string; // long flag, e.g. "--dialect"
38
+ prompt?: string; // interactive prompt label
39
+ choices?: { value: string; label?: string; hint?: string }[]; // enum picker
40
+ default?: string; // fallback when -y is set or user accepts default
41
+ required?: boolean; // when true, missing value with no default errors out
31
42
  }
32
43
 
33
44
  interface FeatureMeta {
@@ -40,6 +51,7 @@ interface FeatureMeta {
40
51
  npmDevDeps?: Record<string, string>;
41
52
  scripts?: Record<string, string>; // package.json scripts to add
42
53
  envVars?: Record<string, string>; // env vars to append to .env if missing
54
+ options?: FeatureOption[]; // feature-specific CLI flags (e.g. file-upload's -d)
43
55
  }
44
56
 
45
57
  let registryRoot: string | null = null;
@@ -47,15 +59,18 @@ let registryRoot: string | null = null;
47
59
  // Track installed features to prevent circular dependencies
48
60
  const installedFeats = new Set<string>();
49
61
 
50
- export async function runFeat(name: string | undefined, flags: string[] = []) {
62
+ export async function runFeat(name: string | undefined, args: string[] = []) {
63
+ // Strip global flags (-y/--yes, --local); everything else is feature args.
64
+ const { autoYes, local, featureArgs } = splitGlobalFlags(args);
65
+
51
66
  if (!name) {
52
67
  console.error(
53
- "❌ Please provide a feature name.\n Usage: bun x bosia@latest feat <feature> [--local]",
68
+ "❌ Please provide a feature name.\n Usage: bun x bosia@latest feat [-y] [--local] <feature> [feature options...]",
54
69
  );
55
70
  process.exit(1);
56
71
  }
57
72
 
58
- if (flags.includes("--local")) {
73
+ if (local) {
59
74
  registryRoot = resolveLocalRegistryOrExit();
60
75
  console.log(`⬡ Using local registry: ${registryRoot}\n`);
61
76
  }
@@ -63,7 +78,119 @@ export async function runFeat(name: string | undefined, flags: string[] = []) {
63
78
  // Initialize add.ts registry context so addComponent resolves paths correctly
64
79
  await initAddRegistry(registryRoot);
65
80
 
66
- await installFeature(name, true);
81
+ await installFeature(name, true, { skipPrompts: autoYes, featureArgs });
82
+ }
83
+
84
+ function splitGlobalFlags(args: string[]): {
85
+ autoYes: boolean;
86
+ local: boolean;
87
+ featureArgs: string[];
88
+ } {
89
+ let autoYes = false;
90
+ let local = false;
91
+ const featureArgs: string[] = [];
92
+ for (const a of args) {
93
+ if (a === "-y" || a === "--yes") autoYes = true;
94
+ else if (a === "--local") local = true;
95
+ else featureArgs.push(a);
96
+ }
97
+ return { autoYes, local, featureArgs };
98
+ }
99
+
100
+ /**
101
+ * Parse `args` against `options` (the feature's declared schema). Unknown flags abort.
102
+ * Returns a `{name: value}` map; missing entries are filled by prompt or `default`.
103
+ */
104
+ async function resolveFeatureOptions(
105
+ featName: string,
106
+ options: FeatureOption[],
107
+ args: string[],
108
+ skipPrompts: boolean,
109
+ ): Promise<Record<string, string>> {
110
+ const values: Record<string, string> = {};
111
+ const byFlag = new Map<string, FeatureOption>();
112
+ for (const opt of options) {
113
+ if (opt.flag) byFlag.set(opt.flag, opt);
114
+ if (opt.long) byFlag.set(opt.long, opt);
115
+ }
116
+
117
+ for (let i = 0; i < args.length; i++) {
118
+ const tok = args[i];
119
+ const opt = byFlag.get(tok);
120
+ if (!opt) {
121
+ console.error(`❌ Unknown option "${tok}" for feature "${featName}".`);
122
+ if (options.length > 0) {
123
+ const valid = options
124
+ .map((o) => [o.flag, o.long].filter(Boolean).join("/"))
125
+ .filter(Boolean)
126
+ .join(", ");
127
+ console.error(` Valid options: ${valid}`);
128
+ }
129
+ process.exit(1);
130
+ }
131
+ const val = args[++i];
132
+ if (val === undefined) {
133
+ console.error(`❌ Option "${tok}" requires a value.`);
134
+ process.exit(1);
135
+ }
136
+ if (opt.choices && !opt.choices.some((c) => c.value === val)) {
137
+ console.error(
138
+ `❌ Invalid value "${val}" for "${tok}". Expected: ${opt.choices.map((c) => c.value).join(", ")}`,
139
+ );
140
+ process.exit(1);
141
+ }
142
+ values[opt.name] = val;
143
+ }
144
+
145
+ for (const opt of options) {
146
+ if (opt.name in values) continue;
147
+ if (skipPrompts) {
148
+ if (opt.default !== undefined) {
149
+ values[opt.name] = opt.default;
150
+ continue;
151
+ }
152
+ if (opt.required) {
153
+ console.error(
154
+ `❌ Feature "${featName}" requires "${opt.flag ?? opt.long ?? opt.name}".`,
155
+ );
156
+ process.exit(1);
157
+ }
158
+ continue;
159
+ }
160
+ values[opt.name] = await promptOption(featName, opt);
161
+ }
162
+
163
+ return values;
164
+ }
165
+
166
+ async function promptOption(featName: string, opt: FeatureOption): Promise<string> {
167
+ const message = opt.prompt ?? `Choose "${opt.name}" for "${featName}"`;
168
+ if (opt.choices && opt.choices.length > 0) {
169
+ const selected = await p.select({
170
+ message,
171
+ options: opt.choices.map((c) => ({
172
+ value: c.value,
173
+ label: c.label ?? c.value,
174
+ hint: c.hint,
175
+ })),
176
+ initialValue: opt.default ?? opt.choices[0].value,
177
+ });
178
+ if (p.isCancel(selected)) {
179
+ p.cancel("Operation cancelled.");
180
+ process.exit(0);
181
+ }
182
+ return selected as string;
183
+ }
184
+ const typed = await p.text({
185
+ message,
186
+ initialValue: opt.default,
187
+ validate: (v) => (opt.required && !v ? "Required" : undefined),
188
+ });
189
+ if (p.isCancel(typed)) {
190
+ p.cancel("Operation cancelled.");
191
+ process.exit(0);
192
+ }
193
+ return (typed as string) ?? "";
67
194
  }
68
195
 
69
196
  /** Set the registry root for feature resolution. Called by create.ts for template features. */
@@ -83,10 +210,39 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
83
210
 
84
211
  const meta = await readRegistryJSON<FeatureMeta>(registryRoot, "features", name, "meta.json");
85
212
 
213
+ // Resolve this feature's own options (from `featureArgs` if root, else from `featureOptions`).
214
+ const inheritedOptions = options?.featureOptions ?? {};
215
+ let myOptions: Record<string, string> = {};
216
+ if (meta.options && meta.options.length > 0) {
217
+ myOptions = isRoot
218
+ ? await resolveFeatureOptions(
219
+ name,
220
+ meta.options,
221
+ options?.featureArgs ?? [],
222
+ options?.skipPrompts ?? false,
223
+ )
224
+ : // Dependency features inherit any caller-provided values; prompt only for unresolved required opts.
225
+ await resolveFeatureOptions(name, meta.options, [], options?.skipPrompts ?? false);
226
+ for (const [k, v] of Object.entries(inheritedOptions)) {
227
+ const [feat, optName] = k.split(".");
228
+ if (feat === name && !(optName in myOptions)) myOptions[optName] = v;
229
+ }
230
+ }
231
+
232
+ // Merge into the namespaced map for downstream dependency features.
233
+ const featureOptions = { ...inheritedOptions };
234
+ for (const [k, v] of Object.entries(myOptions)) featureOptions[`${name}.${k}`] = v;
235
+
236
+ const nextOptions: InstallOptions = {
237
+ ...options,
238
+ featureOptions,
239
+ featureArgs: undefined, // already consumed by the root feature
240
+ };
241
+
86
242
  // Install required feature dependencies first (recursive)
87
243
  if (meta.features && meta.features.length > 0) {
88
244
  for (const feat of meta.features) {
89
- await installFeature(feat, false, options);
245
+ await installFeature(feat, false, nextOptions);
90
246
  }
91
247
  }
92
248
 
@@ -99,9 +255,10 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
99
255
  console.log("");
100
256
  }
101
257
 
102
- // Apply each file entry per its strategy
258
+ // Apply each file entry per its strategy. Skip entries whose `when` clause doesn't match.
103
259
  const createdDirs = new Set<string>();
104
260
  for (const entry of meta.files) {
261
+ if (entry.when && !whenMatches(entry.when, myOptions)) continue;
105
262
  const dest = join(cwd, entry.target);
106
263
  const strategy: FileStrategy = entry.strategy ?? "write";
107
264
  const dir = dirname(dest);
@@ -117,7 +274,7 @@ export async function installFeature(name: string, isRoot: boolean, options?: In
117
274
  strategy,
118
275
  feat: name,
119
276
  marker: entry.marker ?? name,
120
- skipPrompts: options?.skipPrompts ?? false,
277
+ skipPrompts: nextOptions.skipPrompts ?? false,
121
278
  });
122
279
  }
123
280
 
@@ -285,6 +442,13 @@ async function applyStrategy(args: StrategyArgs): Promise<void> {
285
442
  }
286
443
  }
287
444
 
445
+ function whenMatches(when: Record<string, string>, values: Record<string, string>): boolean {
446
+ for (const [k, expected] of Object.entries(when)) {
447
+ if (values[k] !== expected) return false;
448
+ }
449
+ return true;
450
+ }
451
+
288
452
  function blockDelim(ext: string): { start: string; end: string } {
289
453
  if (ext === ".html" || ext === ".svelte") return { start: "<!--", end: "-->" };
290
454
  if (ext === ".css") return { start: "/*", end: "*/" };
package/src/cli/index.ts CHANGED
@@ -59,9 +59,14 @@ async function main() {
59
59
  }
60
60
  case "feat": {
61
61
  const { runFeat } = await import("./feat.ts");
62
- const featName = args.find((a) => !a.startsWith("--"));
63
- const featFlags = args.filter((a) => a.startsWith("--"));
64
- await runFeat(featName, featFlags);
62
+ // First non-flag token is the feature name; everything else flows through to the
63
+ // feature's own option parser. Global flags (-y, --local) are also accepted here
64
+ // and get split out inside runFeat.
65
+ const nameIdx = args.findIndex((a) => !a.startsWith("-"));
66
+ const featName = nameIdx === -1 ? undefined : args[nameIdx];
67
+ const rest =
68
+ nameIdx === -1 ? args : [...args.slice(0, nameIdx), ...args.slice(nameIdx + 1)];
69
+ await runFeat(featName, rest);
65
70
  break;
66
71
  }
67
72
  default: {
@@ -81,7 +86,9 @@ Commands:
81
86
  add block <cat>/<name> Add a composed block from the registry
82
87
  add theme <name> Add a theme (tokens.css) from the registry
83
88
  add font <family> <url> Prepend an @import url(...) for a font family to src/app.css
84
- feat <feature> Add a feature scaffold from the registry [--local]
89
+ feat [-y] <feature> [feature options...] Add a feature scaffold from the registry [--local]
90
+ -y / --yes auto-confirms prompts and uses each feature's default option values
91
+ Feature-specific options (e.g. file-upload's -d) follow the feature name
85
92
 
86
93
  Examples:
87
94
  bun x bosia@latest create my-app
@@ -10,6 +10,10 @@ export interface InstallOptions {
10
10
  skipInstall?: boolean; // write deps to package.json instead of `bun add`
11
11
  skipPrompts?: boolean; // auto-overwrite, no interactive prompts
12
12
  cwd?: string; // override process.cwd() for file operations
13
+ /** Pre-resolved feature-specific option values, keyed by `featureName.optionName`. */
14
+ featureOptions?: Record<string, string>;
15
+ /** Remaining argv tokens to be parsed as the root feature's own options. */
16
+ featureArgs?: string[];
13
17
  }
14
18
 
15
19
  // ─── Local registry resolution ────────────────────────────
package/src/core/build.ts CHANGED
@@ -154,6 +154,7 @@ const clientPromise = Bun.build({
154
154
  entrypoints: [join(CORE_DIR, "client", "hydrate.ts")],
155
155
  outdir: `${OUT_DIR}/client`,
156
156
  target: "browser",
157
+ conditions: ["svelte"],
157
158
  splitting: true,
158
159
  naming: { chunk: "[name]-[hash].[ext]" },
159
160
  minify: isProduction,
@@ -169,6 +170,7 @@ const serverPromise = Bun.build({
169
170
  entrypoints: [join(CORE_DIR, "server.ts")],
170
171
  outdir: `${OUT_DIR}/server`,
171
172
  target: "bun",
173
+ conditions: ["svelte"],
172
174
  splitting: true,
173
175
  naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
174
176
  minify: isProduction,
@@ -3,7 +3,18 @@ import type { Cookies, CookieOptions } from "./hooks.ts";
3
3
  // ─── Cookie Validation (RFC 6265) ────────────────────────
4
4
  /** Rejects characters that could inject into Set-Cookie headers. */
5
5
  const UNSAFE_COOKIE_VALUE = /[;\r\n]/;
6
- const VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
6
+ /**
7
+ * Accept both casings (matches SvelteKit/Express convention) and write the
8
+ * canonical capitalized form into the Set-Cookie header.
9
+ */
10
+ const SAMESITE_NORMALIZE: Record<string, "Strict" | "Lax" | "None"> = {
11
+ strict: "Strict",
12
+ lax: "Lax",
13
+ none: "None",
14
+ Strict: "Strict",
15
+ Lax: "Lax",
16
+ None: "None",
17
+ };
7
18
 
8
19
  /**
9
20
  * RFC 6265 §4.1.1: cookie-name is an HTTP token (RFC 2616 §2.2).
@@ -41,15 +52,20 @@ function parseCookies(header: string): Record<string, string> {
41
52
  }
42
53
 
43
54
  export class CookieJar implements Cookies {
55
+ private static _warnedSecureOverHttp = false;
56
+
44
57
  private _incoming: Record<string, string>;
45
58
  private _outgoing: string[] = [];
46
59
  private _defaults: CookieOptions;
47
60
  private _accessed = false;
61
+ private _isHttps: boolean;
48
62
 
49
- constructor(cookieHeader: string, dev = false) {
63
+ constructor(cookieHeader: string, isHttps = false) {
50
64
  this._incoming = parseCookies(cookieHeader);
51
- // In dev mode, omit Secure — browsers reject Secure cookies over http://localhost
52
- this._defaults = dev ? { ...COOKIE_DEFAULTS, secure: false } : COOKIE_DEFAULTS;
65
+ this._isHttps = isHttps;
66
+ // Browsers drop Secure cookies sent over HTTP only default `secure` on
67
+ // when the current request actually arrived over HTTPS.
68
+ this._defaults = isHttps ? COOKIE_DEFAULTS : { ...COOKIE_DEFAULTS, secure: false };
53
69
  }
54
70
 
55
71
  get(name: string): string | undefined {
@@ -69,6 +85,17 @@ export class CookieJar implements Cookies {
69
85
  set(name: string, value: string, options?: CookieOptions): void {
70
86
  if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
71
87
  const opts = { ...this._defaults, ...options };
88
+ if (!this._isHttps && opts.secure) {
89
+ opts.secure = false;
90
+ if (!CookieJar._warnedSecureOverHttp) {
91
+ console.warn(
92
+ "[bosia] cookies.set passed secure:true over HTTP — downgrading. " +
93
+ "Browsers drop Secure cookies on non-HTTPS. " +
94
+ "Remove the `secure` option; Bosia auto-applies it when the request is HTTPS.",
95
+ );
96
+ CookieJar._warnedSecureOverHttp = true;
97
+ }
98
+ }
72
99
  let header = `${name}=${encodeURIComponent(value)}`;
73
100
  if (opts.path) {
74
101
  if (UNSAFE_COOKIE_VALUE.test(opts.path))
@@ -85,9 +112,9 @@ export class CookieJar implements Cookies {
85
112
  if (opts.httpOnly) header += "; HttpOnly";
86
113
  if (opts.secure) header += "; Secure";
87
114
  if (opts.sameSite) {
88
- if (!VALID_SAMESITE.has(opts.sameSite))
89
- throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
90
- header += `; SameSite=${opts.sameSite}`;
115
+ const canonical = SAMESITE_NORMALIZE[opts.sameSite as string];
116
+ if (!canonical) throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
117
+ header += `; SameSite=${canonical}`;
91
118
  }
92
119
  this._outgoing.push(header);
93
120
  }
package/src/core/hooks.ts CHANGED
@@ -15,7 +15,7 @@ export interface CookieOptions {
15
15
  expires?: Date;
16
16
  httpOnly?: boolean;
17
17
  secure?: boolean;
18
- sameSite?: "Strict" | "Lax" | "None";
18
+ sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none";
19
19
  }
20
20
 
21
21
  export interface Cookies {
@@ -711,6 +711,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
711
711
  // (preview/proxy hubs, design tools, etc.). Other security headers stay on.
712
712
  const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
713
713
 
714
+ // Trust `x-forwarded-proto` header behind a TLS-terminating proxy when computing
715
+ // per-request HTTPS-ness (drives `Secure` cookie flag). Off by default — the
716
+ // header is spoofable from any client that talks directly to the app.
717
+ const TRUST_PROXY = process.env.TRUST_PROXY === "true";
718
+
714
719
  const SECURITY_HEADERS: Record<string, string> = {
715
720
  "X-Content-Type-Options": "nosniff",
716
721
  ...(_xfoDisabled ? {} : { "X-Frame-Options": "SAMEORIGIN" }),
@@ -754,7 +759,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
754
759
  return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
755
760
  }
756
761
 
757
- const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
762
+ const isHttps =
763
+ (TRUST_PROXY && request.headers.get("x-forwarded-proto") === "https") ||
764
+ url.protocol === "https:";
765
+ const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
758
766
  const nonce = CSP_ENABLED ? generateNonce() : "";
759
767
  const event: RequestEvent = {
760
768
  request,