idcmd 0.0.9 → 0.0.11

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/README.md CHANGED
@@ -18,10 +18,24 @@ idcmd init [dir] # scaffold a new site
18
18
  idcmd dev # tailwind watch + SSR dev server
19
19
  idcmd build # static public/
20
20
  idcmd preview # serve public/ locally
21
- idcmd deploy # build + validate Vercel static deploy config
21
+ idcmd deploy # build + generate deploy files (Vercel/Fly/Railway)
22
22
  idcmd client ... # add/update local src implementations
23
23
  ```
24
24
 
25
+ ### Deploy targets
26
+
27
+ ```bash
28
+ idcmd init my-docs --fly
29
+ idcmd init my-docs --railway
30
+ idcmd init my-docs --vercel
31
+
32
+ idcmd deploy --fly
33
+ idcmd deploy --railway
34
+ idcmd deploy --vercel
35
+ ```
36
+
37
+ `idcmd init --yes` is provider-neutral by default (no provider files generated).
38
+
25
39
  ## Layout (V1)
26
40
 
27
41
  - `content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idcmd",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/rustydotwtf/idcmd"
@@ -1,5 +1,16 @@
1
+ import { resolveCachePolicy } from "../../site/cache";
2
+ import { loadSiteConfig } from "../../site/config";
3
+ import { basename } from "../path";
4
+ import { isDeployProvider, resolveProviderFromFlags } from "../provider";
5
+ import { generateProviderFiles, providerConfigPaths } from "../provider-files";
1
6
  import { buildCommand } from "./build";
2
7
 
8
+ export interface DeployFlags {
9
+ fly?: boolean;
10
+ railway?: boolean;
11
+ vercel?: boolean;
12
+ }
13
+
3
14
  const readJsonFile = async (path: string): Promise<unknown> => {
4
15
  const file = Bun.file(path);
5
16
  if (!(await file.exists())) {
@@ -9,22 +20,81 @@ const readJsonFile = async (path: string): Promise<unknown> => {
9
20
  return JSON.parse(text) as unknown;
10
21
  };
11
22
 
23
+ const readPackageName = async (): Promise<string> => {
24
+ const raw = await readJsonFile("package.json");
25
+ if (raw && typeof raw === "object") {
26
+ const pkg = raw as { name?: unknown };
27
+ if (typeof pkg.name === "string" && pkg.name.length > 0) {
28
+ return pkg.name;
29
+ }
30
+ }
31
+ return basename(process.cwd());
32
+ };
33
+
34
+ const warn = (message: string): void => {
35
+ // eslint-disable-next-line no-console
36
+ console.warn(`Warning: ${message}`);
37
+ };
38
+
39
+ const warnIfFileMissing = async (
40
+ path: string,
41
+ context: string
42
+ ): Promise<void> => {
43
+ if (await Bun.file(path).exists()) {
44
+ return;
45
+ }
46
+ warn(`${path} not found (${context}).`);
47
+ };
48
+
12
49
  const warnIfVercelMisconfigured = async (): Promise<void> => {
13
- const raw = await readJsonFile("vercel.json");
50
+ const raw = await readJsonFile(providerConfigPaths.vercelJson);
14
51
  if (!raw) {
15
- // eslint-disable-next-line no-console
16
- console.warn(
17
- "Warning: vercel.json not found. Vercel static deploy expects public/ output."
18
- );
52
+ warn("vercel.json not found. Run `idcmd deploy --vercel` to generate it.");
19
53
  return;
20
54
  }
21
55
 
22
56
  const record = raw as Record<string, unknown>;
23
57
  const out = record.outputDirectory;
24
58
  if (out !== "public") {
25
- // eslint-disable-next-line no-console
26
- console.warn(
27
- `Warning: vercel.json outputDirectory is not "public" (got ${JSON.stringify(out)}).`
59
+ warn(
60
+ `vercel.json outputDirectory is not "public" (got ${JSON.stringify(out)}).`
61
+ );
62
+ }
63
+ };
64
+
65
+ const warnIfFlyMisconfigured = async (): Promise<void> => {
66
+ await warnIfFileMissing(
67
+ providerConfigPaths.flyToml,
68
+ "required for Fly.io deploy"
69
+ );
70
+ await warnIfFileMissing(
71
+ providerConfigPaths.dockerfile,
72
+ "required for Fly.io deploy"
73
+ );
74
+ };
75
+
76
+ const warnIfRailwayMisconfigured = async (): Promise<void> => {
77
+ await warnIfFileMissing(
78
+ providerConfigPaths.railwayJson,
79
+ "required for Railway deploy"
80
+ );
81
+ await warnIfFileMissing(
82
+ providerConfigPaths.dockerfile,
83
+ "required for Railway deploy"
84
+ );
85
+
86
+ const raw = await readJsonFile(providerConfigPaths.railwayJson);
87
+ if (!raw || typeof raw !== "object") {
88
+ return;
89
+ }
90
+
91
+ const config = raw as {
92
+ build?: { builder?: unknown };
93
+ };
94
+ const builder = config.build?.builder;
95
+ if (builder !== undefined && builder !== "DOCKERFILE") {
96
+ warn(
97
+ `railway.json build.builder is ${JSON.stringify(builder)}; expected "DOCKERFILE".`
28
98
  );
29
99
  }
30
100
  };
@@ -40,33 +110,78 @@ const warnIfBaseUrlMissing = async (): Promise<void> => {
40
110
  baseUrl?: unknown;
41
111
  };
42
112
  if (!cfg.baseUrl) {
43
- // eslint-disable-next-line no-console
44
- console.warn(
45
- "Warning: site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
113
+ warn(
114
+ "site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
46
115
  );
47
116
  }
48
117
  } catch (error) {
49
118
  const message = error instanceof Error ? error.message : String(error);
50
- // eslint-disable-next-line no-console
51
- console.warn(`Warning: Failed to parse site.jsonc: ${message}`);
119
+ warn(`Failed to parse site.jsonc: ${message}`);
52
120
  }
53
121
  };
54
122
 
55
- export const deployCommand = async (): Promise<number> => {
56
- const code = await buildCommand();
57
- if (code !== 0) {
58
- return code;
123
+ const warnIfProviderMisconfigured = async (
124
+ provider: "vercel" | "fly" | "railway"
125
+ ): Promise<void> => {
126
+ if (provider === "vercel") {
127
+ await warnIfVercelMisconfigured();
128
+ return;
59
129
  }
130
+ if (provider === "fly") {
131
+ await warnIfFlyMisconfigured();
132
+ return;
133
+ }
134
+ await warnIfRailwayMisconfigured();
135
+ };
60
136
 
61
- await warnIfVercelMisconfigured();
62
- await warnIfBaseUrlMissing();
137
+ const printGeneratedFiles = (files: readonly string[]): void => {
138
+ if (files.length === 0) {
139
+ return;
140
+ }
141
+ // eslint-disable-next-line no-console
142
+ console.log("");
143
+ // eslint-disable-next-line no-console
144
+ console.log("Generated files:");
145
+ for (const path of files) {
146
+ // eslint-disable-next-line no-console
147
+ console.log(` - ${path}`);
148
+ }
149
+ };
63
150
 
64
- printDeployInstructions();
151
+ const generateProviderFilesForProject = async (
152
+ provider: "vercel" | "fly" | "railway"
153
+ ): Promise<void> => {
154
+ const siteConfig = await loadSiteConfig();
155
+ const packageName = await readPackageName();
156
+ const files = await generateProviderFiles({
157
+ cachePolicy: resolveCachePolicy(siteConfig.cache),
158
+ packageName,
159
+ provider,
160
+ targetDir: process.cwd(),
161
+ });
162
+ printGeneratedFiles(files);
163
+ };
65
164
 
66
- return 0;
165
+ const printNeutralDeployInstructions = (): void => {
166
+ // eslint-disable-next-line no-console
167
+ console.log("");
168
+ // eslint-disable-next-line no-console
169
+ console.log("Deploy:");
170
+ // eslint-disable-next-line no-console
171
+ console.log(" 1. Choose a provider");
172
+ // eslint-disable-next-line no-console
173
+ console.log(" 2. Generate provider files:");
174
+ // eslint-disable-next-line no-console
175
+ console.log(" idcmd deploy --vercel");
176
+ // eslint-disable-next-line no-console
177
+ console.log(" idcmd deploy --fly");
178
+ // eslint-disable-next-line no-console
179
+ console.log(" idcmd deploy --railway");
180
+ // eslint-disable-next-line no-console
181
+ console.log("");
67
182
  };
68
183
 
69
- const printDeployInstructions = (): void => {
184
+ const printVercelInstructions = (): void => {
70
185
  // eslint-disable-next-line no-console
71
186
  console.log("");
72
187
  // eslint-disable-next-line no-console
@@ -80,3 +195,74 @@ const printDeployInstructions = (): void => {
80
195
  // eslint-disable-next-line no-console
81
196
  console.log("");
82
197
  };
198
+
199
+ const printFlyInstructions = (): void => {
200
+ // eslint-disable-next-line no-console
201
+ console.log("");
202
+ // eslint-disable-next-line no-console
203
+ console.log("Deploy (Fly.io):");
204
+ // eslint-disable-next-line no-console
205
+ console.log(" 1. Install flyctl and run `fly auth login`");
206
+ // eslint-disable-next-line no-console
207
+ console.log(" 2. Set `app` in fly.toml to your Fly app name");
208
+ // eslint-disable-next-line no-console
209
+ console.log(" 3. Run `fly deploy`");
210
+ // eslint-disable-next-line no-console
211
+ console.log("");
212
+ };
213
+
214
+ const printRailwayInstructions = (): void => {
215
+ // eslint-disable-next-line no-console
216
+ console.log("");
217
+ // eslint-disable-next-line no-console
218
+ console.log("Deploy (Railway):");
219
+ // eslint-disable-next-line no-console
220
+ console.log(" 1. Connect this repo in Railway (or use `railway up`)");
221
+ // eslint-disable-next-line no-console
222
+ console.log(" 2. Railway will build from Dockerfile");
223
+ // eslint-disable-next-line no-console
224
+ console.log(
225
+ " 3. Ensure required env vars are set (for example SITE_BASE_URL)"
226
+ );
227
+ // eslint-disable-next-line no-console
228
+ console.log("");
229
+ };
230
+
231
+ const printDeployInstructions = (
232
+ provider: "none" | "vercel" | "fly" | "railway"
233
+ ): void => {
234
+ if (provider === "none") {
235
+ printNeutralDeployInstructions();
236
+ return;
237
+ }
238
+ if (provider === "vercel") {
239
+ printVercelInstructions();
240
+ return;
241
+ }
242
+ if (provider === "fly") {
243
+ printFlyInstructions();
244
+ return;
245
+ }
246
+ printRailwayInstructions();
247
+ };
248
+
249
+ export const deployCommand = async (
250
+ flags: DeployFlags = {}
251
+ ): Promise<number> => {
252
+ const provider = resolveProviderFromFlags(flags);
253
+ const code = await buildCommand();
254
+ if (code !== 0) {
255
+ return code;
256
+ }
257
+
258
+ await warnIfBaseUrlMissing();
259
+
260
+ if (isDeployProvider(provider)) {
261
+ await generateProviderFilesForProject(provider);
262
+ await warnIfProviderMisconfigured(provider);
263
+ }
264
+
265
+ printDeployInstructions(provider);
266
+
267
+ return 0;
268
+ };
@@ -1,3 +1,4 @@
1
+ import { resolveCachePolicy } from "../../site/cache";
1
2
  import { copyDir, ensureDir, isDirEmpty, replaceInFile } from "../fs";
2
3
  import {
3
4
  normalizeOptionalString,
@@ -5,7 +6,9 @@ import {
5
6
  toPackageName,
6
7
  } from "../normalize";
7
8
  import { basename, dirname, joinPath } from "../path";
8
- import { promptOptionalText, promptText } from "../prompt";
9
+ import { promptOptionalText, promptSelect, promptText } from "../prompt";
10
+ import { isDeployProvider, resolveProviderFromFlags } from "../provider";
11
+ import { generateProviderFiles } from "../provider-files";
9
12
  import { run } from "../run";
10
13
  import { readPackageVersion } from "../version";
11
14
 
@@ -14,9 +17,12 @@ const DEFAULT_PORT = 4000;
14
17
  export interface InitFlags {
15
18
  "base-url"?: string;
16
19
  description?: string;
20
+ fly?: boolean;
17
21
  git?: boolean;
18
22
  name?: string;
19
23
  port?: string;
24
+ railway?: boolean;
25
+ vercel?: boolean;
20
26
  yes?: boolean;
21
27
  }
22
28
 
@@ -125,14 +131,41 @@ interface InitInputs {
125
131
  baseUrl: string | null;
126
132
  description: string;
127
133
  port: number;
134
+ provider: "none" | "vercel" | "fly" | "railway";
128
135
  siteName: string;
129
136
  }
130
137
 
138
+ const promptProviderChoice = (): Promise<
139
+ "none" | "vercel" | "fly" | "railway"
140
+ > =>
141
+ promptSelect(
142
+ "Deploy provider (optional)",
143
+ [
144
+ { label: "None (self-host / decide later)", value: "none" },
145
+ { label: "Vercel", value: "vercel" },
146
+ { label: "Fly.io", value: "fly" },
147
+ { label: "Railway", value: "railway" },
148
+ ],
149
+ "none"
150
+ );
151
+
152
+ const resolveInitProvider = (
153
+ flags: InitFlags,
154
+ yes: boolean
155
+ ): Promise<"none" | "vercel" | "fly" | "railway"> => {
156
+ const provider = resolveProviderFromFlags(flags);
157
+ if (provider !== "none" || yes) {
158
+ return Promise.resolve(provider);
159
+ }
160
+ return promptProviderChoice();
161
+ };
162
+
131
163
  const readInitInputs = async (
132
164
  flags: InitFlags,
133
165
  defaults: InitDefaults
134
166
  ): Promise<InitInputs> => {
135
167
  const yes = flags.yes === true;
168
+ const provider = await resolveInitProvider(flags, yes);
136
169
 
137
170
  const siteName = yes
138
171
  ? (flags.name ?? defaults.defaultSiteName)
@@ -157,6 +190,7 @@ const readInitInputs = async (
157
190
  baseUrl: normalizeOptionalString(baseUrlRaw),
158
191
  description,
159
192
  port,
193
+ provider,
160
194
  siteName,
161
195
  };
162
196
  };
@@ -280,6 +314,15 @@ const scaffoldAndConfigure = async (args: {
280
314
  siteName: args.inputs.siteName,
281
315
  targetDir: args.targetDir,
282
316
  });
317
+
318
+ if (isDeployProvider(args.inputs.provider)) {
319
+ await generateProviderFiles({
320
+ cachePolicy: resolveCachePolicy(),
321
+ packageName: args.defaults.packageName,
322
+ provider: args.inputs.provider,
323
+ targetDir: args.targetDir,
324
+ });
325
+ }
283
326
  };
284
327
 
285
328
  export const initCommand = async (
package/src/cli/main.ts CHANGED
@@ -19,11 +19,11 @@ const usage = (): string =>
19
19
  "idcmd",
20
20
  "",
21
21
  "Usage:",
22
- " idcmd init [dir] [--yes] [--name <name>] [--description <text>] [--base-url <url>] [--port <port>] [--no-git]",
22
+ " idcmd init [dir] [--yes] [--name <name>] [--description <text>] [--base-url <url>] [--port <port>] [--no-git] [--vercel|--fly|--railway]",
23
23
  " idcmd dev [--port <port>]",
24
24
  " idcmd build",
25
25
  " idcmd preview [--port <port>]",
26
- " idcmd deploy",
26
+ " idcmd deploy [--vercel|--fly|--railway]",
27
27
  " idcmd client <add|update> <layout|right-rail|search-page|runtime|all> [--dry-run] [--yes]",
28
28
  "",
29
29
  ].join("\n");
@@ -34,9 +34,26 @@ const asStringFlag = (value: unknown): string | undefined =>
34
34
  const asBooleanFlag = (value: unknown): boolean =>
35
35
  value === true || value === "true";
36
36
 
37
+ const asOptionalBooleanFlag = (value: unknown): boolean | undefined => {
38
+ if (value === false || value === "false") {
39
+ return false;
40
+ }
41
+ return asBooleanFlag(value) ? true : undefined;
42
+ };
43
+
37
44
  const handleInit = (parsed: ParsedArgs): Promise<number> => {
38
45
  const [dir] = parsed.positionals;
39
- return initCommand(dir, parsed.flags);
46
+ return initCommand(dir, {
47
+ "base-url": asStringFlag(parsed.flags["base-url"]),
48
+ description: asStringFlag(parsed.flags.description),
49
+ fly: asBooleanFlag(parsed.flags.fly),
50
+ git: asOptionalBooleanFlag(parsed.flags.git),
51
+ name: asStringFlag(parsed.flags.name),
52
+ port: asStringFlag(parsed.flags.port),
53
+ railway: asBooleanFlag(parsed.flags.railway),
54
+ vercel: asBooleanFlag(parsed.flags.vercel),
55
+ yes: asBooleanFlag(parsed.flags.yes),
56
+ });
40
57
  };
41
58
 
42
59
  const handleDev = (parsed: ParsedArgs): Promise<number> =>
@@ -49,7 +66,12 @@ const handlePreview = (parsed: ParsedArgs): Promise<number> => {
49
66
  return previewCommand(port);
50
67
  };
51
68
 
52
- const handleDeploy = (): Promise<number> => deployCommand();
69
+ const handleDeploy = (parsed: ParsedArgs): Promise<number> =>
70
+ deployCommand({
71
+ fly: asBooleanFlag(parsed.flags.fly),
72
+ railway: asBooleanFlag(parsed.flags.railway),
73
+ vercel: asBooleanFlag(parsed.flags.vercel),
74
+ });
53
75
 
54
76
  const handleClient = (parsed: ParsedArgs): Promise<number> =>
55
77
  clientCommand(parsed.positionals, {
@@ -60,7 +82,7 @@ const handleClient = (parsed: ParsedArgs): Promise<number> =>
60
82
  const handlers: Record<string, (parsed: ParsedArgs) => Promise<number>> = {
61
83
  build: () => handleBuild(),
62
84
  client: (parsed) => handleClient(parsed),
63
- deploy: () => handleDeploy(),
85
+ deploy: (parsed) => handleDeploy(parsed),
64
86
  dev: (parsed) => handleDev(parsed),
65
87
  init: (parsed) => handleInit(parsed),
66
88
  preview: (parsed) => handlePreview(parsed),
package/src/cli/prompt.ts CHANGED
@@ -72,3 +72,108 @@ export const promptOptionalText = async (
72
72
  const trimmed = raw.trim();
73
73
  return trimmed.length > 0 ? trimmed : defaultValue;
74
74
  };
75
+
76
+ export interface PromptSelectOption<T extends string> {
77
+ label: string;
78
+ value: T;
79
+ }
80
+
81
+ const parseNumericChoice = (
82
+ raw: string,
83
+ optionsLength: number
84
+ ): number | null => {
85
+ const parsed = Number(raw);
86
+ if (!Number.isInteger(parsed)) {
87
+ return null;
88
+ }
89
+ const index = parsed - 1;
90
+ if (index < 0 || index >= optionsLength) {
91
+ return null;
92
+ }
93
+ return index;
94
+ };
95
+
96
+ const findOptionByValue = <T extends string>(
97
+ options: readonly PromptSelectOption<T>[],
98
+ raw: string
99
+ ): PromptSelectOption<T> | null => {
100
+ const direct = options.find((option) => option.value === raw);
101
+ return direct ?? null;
102
+ };
103
+
104
+ const getDefaultOptionIndex = <T extends string>(
105
+ options: readonly PromptSelectOption<T>[],
106
+ defaultValue: T
107
+ ): number => {
108
+ if (options.length === 0) {
109
+ throw new Error("promptSelect requires at least one option.");
110
+ }
111
+ const defaultIndex = options.findIndex(
112
+ (option) => option.value === defaultValue
113
+ );
114
+ if (defaultIndex === -1) {
115
+ throw new Error(
116
+ `promptSelect default value "${defaultValue}" is not in options.`
117
+ );
118
+ }
119
+ return defaultIndex;
120
+ };
121
+
122
+ const printSelectPrompt = <T extends string>(args: {
123
+ defaultIndex: number;
124
+ options: readonly PromptSelectOption<T>[];
125
+ question: string;
126
+ }): void => {
127
+ process.stdout.write(`${args.question}\n`);
128
+ for (const [index, option] of args.options.entries()) {
129
+ const suffix = index === args.defaultIndex ? " (default)" : "";
130
+ process.stdout.write(` ${String(index + 1)}. ${option.label}${suffix}\n`);
131
+ }
132
+ };
133
+
134
+ const resolveSelectedValue = <T extends string>(args: {
135
+ defaultValue: T;
136
+ input: string;
137
+ options: readonly PromptSelectOption<T>[];
138
+ }): T | null => {
139
+ if (args.input.length === 0) {
140
+ return args.defaultValue;
141
+ }
142
+ const numericChoice = parseNumericChoice(args.input, args.options.length);
143
+ if (numericChoice !== null) {
144
+ return args.options[numericChoice]?.value ?? args.defaultValue;
145
+ }
146
+ const valueChoice = findOptionByValue(args.options, args.input);
147
+ return valueChoice?.value ?? null;
148
+ };
149
+
150
+ const readSelection = async <T extends string>(args: {
151
+ defaultIndex: number;
152
+ defaultValue: T;
153
+ options: readonly PromptSelectOption<T>[];
154
+ }): Promise<T> => {
155
+ while (true) {
156
+ const input = await readLine(
157
+ `Select [1-${String(args.options.length)}] (${String(args.defaultIndex + 1)}): `
158
+ );
159
+ const value = resolveSelectedValue({
160
+ defaultValue: args.defaultValue,
161
+ input: input.trim(),
162
+ options: args.options,
163
+ });
164
+ if (value !== null) {
165
+ return value;
166
+ }
167
+ process.stdout.write("Invalid selection. Enter a number from the list.\n");
168
+ }
169
+ };
170
+
171
+ export const promptSelect = <T extends string>(
172
+ question: string,
173
+ options: readonly PromptSelectOption<T>[],
174
+ defaultValue: T
175
+ ): Promise<T> => {
176
+ const defaultIndex = getDefaultOptionIndex(options, defaultValue);
177
+ printSelectPrompt({ defaultIndex, options, question });
178
+ return readSelection({ defaultIndex, defaultValue, options });
179
+ };
@@ -0,0 +1,225 @@
1
+ import type { ResolvedCachePolicy } from "../site/cache";
2
+ import type { DeployProvider } from "./provider";
3
+
4
+ import { ensureDir } from "./fs";
5
+ import { dirname, joinPath } from "./path";
6
+
7
+ interface ProviderFileOptions {
8
+ cachePolicy: ResolvedCachePolicy;
9
+ packageName: string;
10
+ targetDir: string;
11
+ }
12
+
13
+ const DOCKERFILE_PATH = "Dockerfile";
14
+ const DOCKERIGNORE_PATH = ".dockerignore";
15
+ const FLY_CONFIG_PATH = "fly.toml";
16
+ const RAILWAY_CONFIG_PATH = "railway.json";
17
+ const VERCEL_CONFIG_PATH = "vercel.json";
18
+
19
+ const DOCKERFILE = `FROM oven/bun:1
20
+
21
+ WORKDIR /app
22
+
23
+ COPY package.json bun.lock* ./
24
+ RUN bun install --frozen-lockfile
25
+
26
+ COPY . .
27
+
28
+ RUN bun run build
29
+
30
+ ENV NODE_ENV=production
31
+ ENV PORT=8080
32
+
33
+ EXPOSE 8080
34
+
35
+ CMD ["bun", "src/server.ts"]
36
+ `;
37
+
38
+ const DOCKERIGNORE = `.git
39
+ .github
40
+ .gitignore
41
+ node_modules
42
+ public
43
+ .env
44
+ .env.*
45
+ `;
46
+
47
+ const sanitizeFlyAppName = (value: string): string => {
48
+ const normalized = value
49
+ .toLowerCase()
50
+ .replaceAll(/[^a-z0-9-]/g, "-")
51
+ .replaceAll(/-+/g, "-")
52
+ .replaceAll(/^-+|-+$/g, "");
53
+ if (normalized.length === 0) {
54
+ return "idcmd-app";
55
+ }
56
+ return normalized.slice(0, 63);
57
+ };
58
+
59
+ const buildCacheControl = (args: {
60
+ browserCacheControl: string;
61
+ edgeCacheControl: string | null;
62
+ }): string =>
63
+ args.edgeCacheControl
64
+ ? `${args.browserCacheControl}, ${args.edgeCacheControl}`
65
+ : args.browserCacheControl;
66
+
67
+ const createVercelHeaders = (
68
+ cachePolicy: ResolvedCachePolicy
69
+ ): {
70
+ headers: { key: string; value: string }[];
71
+ source: string;
72
+ }[] => {
73
+ const htmlHeaders = [
74
+ {
75
+ key: "Cache-Control",
76
+ value: buildCacheControl({
77
+ browserCacheControl: cachePolicy.html.browserCacheControl,
78
+ edgeCacheControl: null,
79
+ }),
80
+ },
81
+ ];
82
+ if (cachePolicy.html.edgeCacheControl) {
83
+ htmlHeaders.push({
84
+ key: "Vercel-CDN-Cache-Control",
85
+ value: cachePolicy.html.edgeCacheControl,
86
+ });
87
+ }
88
+
89
+ return [
90
+ { headers: htmlHeaders, source: "/(.*)" },
91
+ {
92
+ headers: [
93
+ { key: "Cache-Control", value: cachePolicy.static.cacheControl },
94
+ ],
95
+ source: "/(.*\\..*)",
96
+ },
97
+ ];
98
+ };
99
+
100
+ const createVercelConfig = (cachePolicy: ResolvedCachePolicy): string =>
101
+ `${JSON.stringify(
102
+ {
103
+ $schema: "https://openapi.vercel.sh/vercel.json",
104
+ buildCommand: "bun run build",
105
+ bunVersion: "1.x",
106
+ headers: createVercelHeaders(cachePolicy),
107
+ installCommand: "bun install",
108
+ outputDirectory: "public",
109
+ },
110
+ null,
111
+ 2
112
+ )}\n`;
113
+
114
+ const createFlyToml = (packageName: string): string =>
115
+ `app = "${sanitizeFlyAppName(packageName)}"
116
+ primary_region = "iad"
117
+
118
+ [env]
119
+ NODE_ENV = "production"
120
+ PORT = "8080"
121
+
122
+ [http_service]
123
+ internal_port = 8080
124
+ force_https = true
125
+ auto_stop_machines = "stop"
126
+ auto_start_machines = true
127
+ min_machines_running = 0
128
+ processes = ["app"]
129
+
130
+ [[http_service.checks]]
131
+ grace_period = "10s"
132
+ interval = "30s"
133
+ method = "GET"
134
+ timeout = "5s"
135
+ path = "/health"
136
+ `;
137
+
138
+ const createRailwayConfig = (): string =>
139
+ `${JSON.stringify(
140
+ {
141
+ $schema: "https://railway.com/railway.schema.json",
142
+ build: {
143
+ builder: "DOCKERFILE",
144
+ },
145
+ deploy: {
146
+ healthcheckPath: "/health",
147
+ healthcheckTimeout: 120,
148
+ restartPolicyMaxRetries: 10,
149
+ restartPolicyType: "ON_FAILURE",
150
+ startCommand: "bun src/server.ts",
151
+ },
152
+ },
153
+ null,
154
+ 2
155
+ )}\n`;
156
+
157
+ const writeFile = async (
158
+ targetDir: string,
159
+ relativePath: string,
160
+ text: string
161
+ ): Promise<void> => {
162
+ const path = joinPath(targetDir, relativePath);
163
+ await ensureDir(dirname(path));
164
+ await Bun.write(path, text);
165
+ };
166
+
167
+ const writeVercelFiles = async (
168
+ args: ProviderFileOptions
169
+ ): Promise<string[]> => {
170
+ await writeFile(
171
+ args.targetDir,
172
+ VERCEL_CONFIG_PATH,
173
+ createVercelConfig(args.cachePolicy)
174
+ );
175
+ return [VERCEL_CONFIG_PATH];
176
+ };
177
+
178
+ const writeFlyFiles = async (args: ProviderFileOptions): Promise<string[]> => {
179
+ await writeFile(args.targetDir, DOCKERFILE_PATH, DOCKERFILE);
180
+ await writeFile(args.targetDir, DOCKERIGNORE_PATH, DOCKERIGNORE);
181
+ await writeFile(
182
+ args.targetDir,
183
+ FLY_CONFIG_PATH,
184
+ createFlyToml(args.packageName)
185
+ );
186
+ return [DOCKERFILE_PATH, DOCKERIGNORE_PATH, FLY_CONFIG_PATH];
187
+ };
188
+
189
+ const writeRailwayFiles = async (
190
+ args: ProviderFileOptions
191
+ ): Promise<string[]> => {
192
+ await writeFile(args.targetDir, DOCKERFILE_PATH, DOCKERFILE);
193
+ await writeFile(args.targetDir, DOCKERIGNORE_PATH, DOCKERIGNORE);
194
+ await writeFile(args.targetDir, RAILWAY_CONFIG_PATH, createRailwayConfig());
195
+ return [DOCKERFILE_PATH, DOCKERIGNORE_PATH, RAILWAY_CONFIG_PATH];
196
+ };
197
+
198
+ export const generateProviderFiles = (args: {
199
+ cachePolicy: ResolvedCachePolicy;
200
+ packageName: string;
201
+ provider: DeployProvider;
202
+ targetDir: string;
203
+ }): Promise<string[]> => {
204
+ const options = {
205
+ cachePolicy: args.cachePolicy,
206
+ packageName: args.packageName,
207
+ targetDir: args.targetDir,
208
+ };
209
+
210
+ if (args.provider === "vercel") {
211
+ return writeVercelFiles(options);
212
+ }
213
+ if (args.provider === "fly") {
214
+ return writeFlyFiles(options);
215
+ }
216
+ return writeRailwayFiles(options);
217
+ };
218
+
219
+ export const providerConfigPaths = {
220
+ dockerfile: DOCKERFILE_PATH,
221
+ dockerignore: DOCKERIGNORE_PATH,
222
+ flyToml: FLY_CONFIG_PATH,
223
+ railwayJson: RAILWAY_CONFIG_PATH,
224
+ vercelJson: VERCEL_CONFIG_PATH,
225
+ } as const;
@@ -0,0 +1,36 @@
1
+ export type Provider = "none" | "vercel" | "fly" | "railway";
2
+ export type DeployProvider = Exclude<Provider, "none">;
3
+
4
+ export interface ProviderFlags {
5
+ fly?: boolean;
6
+ railway?: boolean;
7
+ vercel?: boolean;
8
+ }
9
+
10
+ const DEPLOY_PROVIDERS: readonly DeployProvider[] = [
11
+ "vercel",
12
+ "fly",
13
+ "railway",
14
+ ];
15
+
16
+ const isFlagEnabled = (value: boolean | undefined): boolean => value === true;
17
+
18
+ const selectedProviders = (flags: ProviderFlags): DeployProvider[] =>
19
+ DEPLOY_PROVIDERS.filter((provider) => isFlagEnabled(flags[provider]));
20
+
21
+ const formatProviderFlags = (providers: readonly DeployProvider[]): string =>
22
+ providers.map((provider) => `--${provider}`).join(" ");
23
+
24
+ export const resolveProviderFromFlags = (flags: ProviderFlags): Provider => {
25
+ const selected = selectedProviders(flags);
26
+ if (selected.length > 1) {
27
+ throw new Error(
28
+ `Choose exactly one provider. Received ${formatProviderFlags(selected)}.`
29
+ );
30
+ }
31
+ return selected[0] ?? "none";
32
+ };
33
+
34
+ export const isDeployProvider = (
35
+ provider: Provider
36
+ ): provider is DeployProvider => provider !== "none";
@@ -151,7 +151,7 @@ const TopNavbar = ({
151
151
  <div class="flex items-center gap-4">
152
152
  <a
153
153
  href="/"
154
- class="text-sm font-medium tracking-tight font-mono md:hidden"
154
+ class="text-sm font-medium tracking-tight font-mono lg:hidden"
155
155
  data-prefetch="hover"
156
156
  >
157
157
  <span class="text-muted-foreground">~/</span>
@@ -1,10 +1,29 @@
1
- export const createHtmlCacheHeaders = (isDev: boolean): HeadersInit => ({
1
+ import type { ResolvedCachePolicy } from "../site/cache";
2
+
3
+ const combineCacheControl = (args: {
4
+ browserCacheControl: string;
5
+ edgeCacheControl: string | null;
6
+ }): string =>
7
+ args.edgeCacheControl
8
+ ? `${args.browserCacheControl}, ${args.edgeCacheControl}`
9
+ : args.browserCacheControl;
10
+
11
+ export const createHtmlCacheHeaders = (
12
+ isDev: boolean,
13
+ cachePolicy: ResolvedCachePolicy
14
+ ): HeadersInit => ({
2
15
  "Cache-Control": isDev
3
16
  ? "no-cache"
4
- : "s-maxage=60, stale-while-revalidate=3600",
17
+ : combineCacheControl({
18
+ browserCacheControl: cachePolicy.html.browserCacheControl,
19
+ edgeCacheControl: cachePolicy.html.edgeCacheControl,
20
+ }),
5
21
  "Content-Type": "text/html; charset=utf-8",
6
22
  });
7
23
 
8
- export const createStaticCacheHeaders = (isDev: boolean): HeadersInit => ({
9
- "Cache-Control": isDev ? "no-cache" : "public, max-age=31536000, immutable",
24
+ export const createStaticCacheHeaders = (
25
+ isDev: boolean,
26
+ cachePolicy: ResolvedCachePolicy
27
+ ): HeadersInit => ({
28
+ "Cache-Control": isDev ? "no-cache" : cachePolicy.static.cacheControl,
10
29
  });
package/src/server.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  import { createLiveReload } from "./server/live-reload";
16
16
  import { serveStaticFile } from "./server/static";
17
17
  import { handleUserRouteRequest } from "./server/user-routes";
18
+ import { resolveCachePolicy } from "./site/cache";
19
+ import { loadSiteConfig } from "./site/config";
18
20
  import {
19
21
  getRedirectForCanonicalHtmlPath,
20
22
  isFileLikePathname,
@@ -30,9 +32,12 @@ const isDev = process.env.NODE_ENV !== "production";
30
32
  const LIVE_RELOAD_POLL_MS = 250;
31
33
  const MIN_SEARCH_QUERY_LENGTH = 2;
32
34
  const MAX_SEARCH_RESULTS = 50;
35
+ const HEALTHCHECK_PATH = "/health";
33
36
 
34
- const cacheHeaders = createHtmlCacheHeaders(isDev);
35
- const staticCacheHeaders = createStaticCacheHeaders(isDev);
37
+ const siteConfig = await loadSiteConfig();
38
+ const cachePolicy = resolveCachePolicy(siteConfig.cache);
39
+ const cacheHeaders = createHtmlCacheHeaders(isDev, cachePolicy);
40
+ const staticCacheHeaders = createStaticCacheHeaders(isDev, cachePolicy);
36
41
 
37
42
  const withQueryString = (pathname: string, search: string): string =>
38
43
  search ? `${pathname}${search}` : pathname;
@@ -74,6 +79,20 @@ const handleLlmsTxt = async (path: string): Promise<Response | undefined> => {
74
79
  });
75
80
  };
76
81
 
82
+ const handleHealthRequest = (path: string): Response | undefined => {
83
+ if (path !== HEALTHCHECK_PATH) {
84
+ return undefined;
85
+ }
86
+
87
+ return new Response("ok", {
88
+ headers: {
89
+ "Cache-Control": "no-cache",
90
+ "Content-Type": "text/plain; charset=utf-8",
91
+ },
92
+ status: 200,
93
+ });
94
+ };
95
+
77
96
  const handleMarkdownRequest = async (
78
97
  path: string
79
98
  ): Promise<Response | undefined> => {
@@ -202,6 +221,7 @@ const handleRequest = async (
202
221
 
203
222
  return (
204
223
  liveReloadUpgrade ??
224
+ handleHealthRequest(path) ??
205
225
  (await handleLlmsTxt(path)) ??
206
226
  (await handleRobotsTxt(url, seoEnv)) ??
207
227
  (await handleSitemapXml(url, seoEnv)) ??
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+
3
+ const MAX_EDGE_CACHE_SECONDS = 7 * 24 * 60 * 60;
4
+ const MAX_STALE_SECONDS = 30 * 24 * 60 * 60;
5
+
6
+ const HTML_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalidate";
7
+ const STATIC_REVALIDATE_CACHE_CONTROL = "public, max-age=0, must-revalidate";
8
+ const STATIC_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
9
+ const NO_STORE_CACHE_CONTROL = "no-store";
10
+
11
+ export const CachePresetSchema = z.enum(["fresh", "balanced", "static"]);
12
+ export type CachePreset = z.infer<typeof CachePresetSchema>;
13
+
14
+ export const CacheHtmlConfigSchema = z
15
+ .object({
16
+ sMaxAgeSeconds: z
17
+ .number()
18
+ .int()
19
+ .min(0)
20
+ .max(MAX_EDGE_CACHE_SECONDS)
21
+ .optional(),
22
+ staleWhileRevalidateSeconds: z
23
+ .number()
24
+ .int()
25
+ .min(0)
26
+ .max(MAX_STALE_SECONDS)
27
+ .optional(),
28
+ })
29
+ .strict();
30
+ export type CacheHtmlConfig = z.infer<typeof CacheHtmlConfigSchema>;
31
+
32
+ export const CacheConfigSchema = z
33
+ .object({
34
+ html: CacheHtmlConfigSchema.optional(),
35
+ preset: CachePresetSchema.optional(),
36
+ })
37
+ .strict();
38
+ export type CacheConfig = z.infer<typeof CacheConfigSchema>;
39
+
40
+ export interface ResolvedCachePolicy {
41
+ html: {
42
+ browserCacheControl: string;
43
+ edgeCacheControl: string | null;
44
+ };
45
+ preset: CachePreset;
46
+ static: {
47
+ cacheControl: string;
48
+ };
49
+ }
50
+
51
+ interface HtmlEdgePolicy {
52
+ sMaxAgeSeconds: number;
53
+ staleWhileRevalidateSeconds: number;
54
+ }
55
+
56
+ const DEFAULT_HTML_EDGE_POLICY: HtmlEdgePolicy = {
57
+ sMaxAgeSeconds: 60,
58
+ staleWhileRevalidateSeconds: 3600,
59
+ };
60
+ const DEFAULT_PRESET: CachePreset = "static";
61
+
62
+ const formatEdgeCacheControl = (policy: HtmlEdgePolicy): string =>
63
+ `s-maxage=${String(policy.sMaxAgeSeconds)}, stale-while-revalidate=${String(policy.staleWhileRevalidateSeconds)}`;
64
+
65
+ const resolveHtmlEdgePolicy = (
66
+ config: CacheConfig | undefined
67
+ ): HtmlEdgePolicy => ({
68
+ sMaxAgeSeconds:
69
+ config?.html?.sMaxAgeSeconds ?? DEFAULT_HTML_EDGE_POLICY.sMaxAgeSeconds,
70
+ staleWhileRevalidateSeconds:
71
+ config?.html?.staleWhileRevalidateSeconds ??
72
+ DEFAULT_HTML_EDGE_POLICY.staleWhileRevalidateSeconds,
73
+ });
74
+
75
+ const resolvePreset = (config: CacheConfig | undefined): CachePreset =>
76
+ config?.preset ?? DEFAULT_PRESET;
77
+
78
+ export const resolveCachePolicy = (
79
+ config?: CacheConfig
80
+ ): ResolvedCachePolicy => {
81
+ const preset = resolvePreset(config);
82
+
83
+ if (preset === "fresh") {
84
+ return {
85
+ html: {
86
+ browserCacheControl: NO_STORE_CACHE_CONTROL,
87
+ edgeCacheControl: null,
88
+ },
89
+ preset,
90
+ static: { cacheControl: NO_STORE_CACHE_CONTROL },
91
+ };
92
+ }
93
+
94
+ const edgePolicy = resolveHtmlEdgePolicy(config);
95
+ return {
96
+ html: {
97
+ browserCacheControl: HTML_REVALIDATE_CACHE_CONTROL,
98
+ edgeCacheControl: formatEdgeCacheControl(edgePolicy),
99
+ },
100
+ preset,
101
+ static: {
102
+ cacheControl:
103
+ preset === "static"
104
+ ? STATIC_IMMUTABLE_CACHE_CONTROL
105
+ : STATIC_REVALIDATE_CACHE_CONTROL,
106
+ },
107
+ };
108
+ };
@@ -1,5 +1,9 @@
1
1
  import { ZodError, z } from "zod";
2
2
 
3
+ import type { CacheConfig } from "./cache";
4
+
5
+ import { CacheConfigSchema } from "./cache";
6
+
3
7
  export const SearchScopeSchema = z.enum([
4
8
  "full",
5
9
  "title",
@@ -64,6 +68,7 @@ export type GroupConfig = z.infer<typeof GroupConfigSchema>;
64
68
  export const SiteConfigSchema = z
65
69
  .object({
66
70
  baseUrl: z.string().url().optional(),
71
+ cache: CacheConfigSchema.optional(),
67
72
  description: z.string(),
68
73
  groups: z.array(GroupConfigSchema).optional(),
69
74
  name: z.string().min(1),
@@ -146,12 +151,31 @@ const normalizeBaseUrl = (baseUrl: string): string | undefined => {
146
151
  }
147
152
  };
148
153
 
149
- const resolveBaseUrlFromEnv = (): string | undefined => {
154
+ const resolveExplicitBaseUrlFromEnv = (): string | undefined => {
150
155
  const explicit = process.env.SITE_BASE_URL;
151
156
  if (explicit) {
152
157
  return normalizeBaseUrl(explicit);
153
158
  }
159
+ return undefined;
160
+ };
161
+
162
+ const resolveRailwayBaseUrlFromEnv = (): string | undefined => {
163
+ const railwayDomain = process.env.RAILWAY_PUBLIC_DOMAIN;
164
+ if (railwayDomain) {
165
+ return normalizeBaseUrl(`https://${railwayDomain}`);
166
+ }
167
+ return undefined;
168
+ };
169
+
170
+ const resolveFlyBaseUrlFromEnv = (): string | undefined => {
171
+ const flyApp = process.env.FLY_APP_NAME;
172
+ if (flyApp) {
173
+ return normalizeBaseUrl(`https://${flyApp}.fly.dev`);
174
+ }
175
+ return undefined;
176
+ };
154
177
 
178
+ const resolveVercelBaseUrlFromEnv = (): string | undefined => {
155
179
  // Vercel provides hostnames without protocol.
156
180
  const vercelUrl =
157
181
  process.env.VERCEL_URL ??
@@ -161,10 +185,15 @@ const resolveBaseUrlFromEnv = (): string | undefined => {
161
185
  if (vercelUrl) {
162
186
  return normalizeBaseUrl(`https://${vercelUrl}`);
163
187
  }
164
-
165
188
  return undefined;
166
189
  };
167
190
 
191
+ const resolveBaseUrlFromEnv = (): string | undefined =>
192
+ resolveExplicitBaseUrlFromEnv() ??
193
+ resolveRailwayBaseUrlFromEnv() ??
194
+ resolveFlyBaseUrlFromEnv() ??
195
+ resolveVercelBaseUrlFromEnv();
196
+
168
197
  const resolveBaseUrl = (baseUrl: string | undefined): string | undefined => {
169
198
  const normalizedConfigUrl = baseUrl ? normalizeBaseUrl(baseUrl) : undefined;
170
199
  const envUrl = resolveBaseUrlFromEnv();
@@ -190,7 +219,11 @@ const formatSiteConfigZodError = (
190
219
  return `${configPath} validation failed:\n${lines.join("\n")}`;
191
220
  };
192
221
 
193
- const DEFAULT_SITE_CONFIG: SiteConfig = { description: "", name: "idcmd" };
222
+ const DEFAULT_SITE_CONFIG: SiteConfig = {
223
+ cache: { preset: "static" },
224
+ description: "",
225
+ name: "idcmd",
226
+ };
194
227
 
195
228
  const parseSiteConfigJsonc = (configPath: string, text: string): unknown => {
196
229
  try {
@@ -229,8 +262,17 @@ export const loadSiteConfig = async (): Promise<SiteConfig> => {
229
262
  const text = await file.text();
230
263
  const raw = parseSiteConfigJsonc(configPath, text);
231
264
  const parsed = parseSiteConfigUnknown(configPath, raw);
232
- return { ...parsed, baseUrl: resolveBaseUrl(parsed.baseUrl) };
265
+ return {
266
+ ...parsed,
267
+ baseUrl: resolveBaseUrl(parsed.baseUrl),
268
+ cache: resolveCacheConfig(parsed.cache),
269
+ };
233
270
  };
234
271
 
235
272
  export const getSearchScope = (siteConfig: SiteConfig): SearchScope =>
236
273
  siteConfig.search?.scope ?? "full";
274
+
275
+ const resolveCacheConfig = (cache: CacheConfig | undefined): CacheConfig => ({
276
+ html: cache?.html,
277
+ preset: cache?.preset ?? "static",
278
+ });
@@ -43,10 +43,12 @@ idcmd client update runtime --yes
43
43
  These commands copy the latest baseline implementations from `idcmd` into `src/ui/` and `src/runtime/`.
44
44
  Runtime files in `src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
45
45
 
46
- ## Deploy (Vercel static)
46
+ ## Deploy
47
47
 
48
48
  ```bash
49
- bun run build
49
+ idcmd deploy --vercel
50
+ idcmd deploy --fly
51
+ idcmd deploy --railway
50
52
  ```
51
53
 
52
- This produces a static `public/` directory for Vercel.
54
+ Use one provider flag at a time to generate deployment files.
@@ -32,7 +32,6 @@ const LINT_TARGETS = [
32
32
  "README.md",
33
33
  "package.json",
34
34
  "tsconfig.json",
35
- "vercel.json",
36
35
  ".oxlintrc.json",
37
36
  ".oxfmtrc.jsonc",
38
37
  "scripts",
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "__IDCMD_SITE_NAME__",
3
3
  "description": "__IDCMD_SITE_DESCRIPTION__",
4
+ "cache": {
5
+ // "fresh" | "balanced" | "static"
6
+ "preset": "static",
7
+
8
+ // Optional HTML edge cache overrides
9
+ // "html": {
10
+ // "sMaxAgeSeconds": 60,
11
+ // "staleWhileRevalidateSeconds": 3600,
12
+ // },
13
+ },
4
14
  "baseUrl": "__IDCMD_SITE_BASE_URL__",
5
15
  "groups": [{ "id": "main", "label": "Navigation", "order": 1 }],
6
16
  "search": {
@@ -104,7 +104,7 @@ const TopNavbar = ({
104
104
  <div class="flex items-center gap-4">
105
105
  <a
106
106
  href="/"
107
- class="text-sm font-mono font-medium tracking-tight md:hidden"
107
+ class="text-sm font-mono font-medium tracking-tight lg:hidden"
108
108
  data-prefetch="hover"
109
109
  >
110
110
  <span class="text-muted-foreground">~/</span>
@@ -151,7 +151,7 @@ header {
151
151
  display: none;
152
152
  }
153
153
 
154
- @media (min-width: 768px) {
154
+ @media (min-width: 1024px) {
155
155
  .sidebar {
156
156
  position: fixed;
157
157
  top: 0;
@@ -185,7 +185,7 @@ header {
185
185
  flex-direction: column;
186
186
  }
187
187
 
188
- @media (min-width: 768px) {
188
+ @media (min-width: 1024px) {
189
189
  .main-wrapper {
190
190
  margin-left: var(--sidebar-width);
191
191
  }
@@ -1,7 +0,0 @@
1
- {
2
- "$schema": "https://openapi.vercel.sh/vercel.json",
3
- "buildCommand": "bun run build",
4
- "outputDirectory": "public",
5
- "installCommand": "bun install",
6
- "bunVersion": "1.x"
7
- }