idcmd 0.0.10 → 0.0.12

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,11 +18,33 @@ 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
- ## Layout (V1)
25
+ For full command docs (flags, examples, side effects), use:
26
+
27
+ ```bash
28
+ idcmd --help
29
+ idcmd init --help
30
+ idcmd deploy --help
31
+ ```
32
+
33
+ ### Deploy targets
34
+
35
+ ```bash
36
+ idcmd init my-docs --fly
37
+ idcmd init my-docs --railway
38
+ idcmd init my-docs --vercel
39
+
40
+ idcmd deploy --fly
41
+ idcmd deploy --railway
42
+ idcmd deploy --vercel
43
+ ```
44
+
45
+ `idcmd init --yes` is provider-neutral by default (no provider files generated).
46
+
47
+ ## Layout
26
48
 
27
49
  - `content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
28
50
  - `src/ui/*` is local UI source code (you own and edit these files)
@@ -66,7 +88,7 @@ This is a new page.
66
88
 
67
89
  It renders at `/hello/`.
68
90
 
69
- ## Custom Server Routes (V1)
91
+ ## Custom Server Routes
70
92
 
71
93
  Add `src/routes/api/hello.ts`:
72
94
 
@@ -76,9 +98,9 @@ export const GET = (): Response => Response.json({ ok: true });
76
98
 
77
99
  It responds at `/api/hello`.
78
100
 
79
- ## V1 Definition Of Done
101
+ ## Definition Of Done
80
102
 
81
- `tickets/ROADMAP.md` is the source of truth. For V1, we explicitly target:
103
+ We explicitly target:
82
104
 
83
105
  - Content routes ship `0` bytes of JavaScript by default (both SSR output and built HTML).
84
106
  - Content routes ship a small, opinionated JavaScript runtime by default (prefetch + optional right-rail behavior).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idcmd",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/rustydotwtf/idcmd"
@@ -35,8 +35,7 @@
35
35
  "prepare": "lefthook install"
36
36
  },
37
37
  "dependencies": {
38
- "preact": "^10.28.3",
39
- "preact-render-to-string": "^6.6.5",
38
+ "@kitajs/html": "^4.2.13",
40
39
  "shiki": "^3.22.0",
41
40
  "zod": "^3.24.0"
42
41
  },
@@ -25,7 +25,8 @@ export const buildCommand = async (): Promise<number> => {
25
25
  const cssProc = Bun.spawn(
26
26
  [
27
27
  "bunx",
28
- "@tailwindcss/cli",
28
+ "--no-install",
29
+ "tailwindcss",
29
30
  "-i",
30
31
  tailwindInput,
31
32
  "-o",
@@ -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
+ };
@@ -38,7 +38,8 @@ const spawnCssProcess = (
38
38
  Bun.spawn(
39
39
  [
40
40
  "bunx",
41
- "@tailwindcss/cli",
41
+ "--no-install",
42
+ "tailwindcss",
42
43
  "-i",
43
44
  tailwindInput,
44
45
  "-o",
@@ -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
 
@@ -110,7 +116,7 @@ const fillPackageJson = (args: {
110
116
  .replaceAll("__IDCMD_IDCMD_VERSION__", `^${args.idcmdVersion}`)
111
117
  .replaceAll("__IDCMD_DEV_PORT__", String(args.port));
112
118
 
113
- const fillReadme = (args: { siteName: string; text: string }): string =>
119
+ const fillSiteNameTokens = (args: { siteName: string; text: string }): string =>
114
120
  args.text
115
121
  .replaceAll("__IDCMD_SITE_NAME__", args.siteName)
116
122
  .replaceAll("IDCMD_SITE_NAME", args.siteName);
@@ -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
  };
@@ -219,7 +253,14 @@ const applySubstitutions = async (args: {
219
253
  );
220
254
 
221
255
  await replaceInFile(joinPath(args.targetDir, "README.md"), (text) =>
222
- fillReadme({
256
+ fillSiteNameTokens({
257
+ siteName: args.siteName,
258
+ text,
259
+ })
260
+ );
261
+
262
+ await replaceInFile(joinPath(args.targetDir, "content", "index.md"), (text) =>
263
+ fillSiteNameTokens({
223
264
  siteName: args.siteName,
224
265
  text,
225
266
  })
@@ -248,7 +289,9 @@ const printNextSteps = (dir: string): void => {
248
289
  const assertEmptyTargetDir = async (targetDir: string): Promise<void> => {
249
290
  const empty = await isDirEmpty(targetDir);
250
291
  if (!empty) {
251
- throw new Error(`Target directory is not empty: ${targetDir}`);
292
+ throw new Error(
293
+ `Target directory is not empty: ${targetDir}\nUse an empty directory or run \`idcmd init <new-directory>\`.`
294
+ );
252
295
  }
253
296
  };
254
297
 
@@ -280,6 +323,15 @@ const scaffoldAndConfigure = async (args: {
280
323
  siteName: args.inputs.siteName,
281
324
  targetDir: args.targetDir,
282
325
  });
326
+
327
+ if (isDeployProvider(args.inputs.provider)) {
328
+ await generateProviderFiles({
329
+ cachePolicy: resolveCachePolicy(),
330
+ packageName: args.defaults.packageName,
331
+ provider: args.inputs.provider,
332
+ targetDir: args.targetDir,
333
+ });
334
+ }
283
335
  };
284
336
 
285
337
  export const initCommand = async (
@@ -6,12 +6,15 @@ import {
6
6
  const stripLeadingSlash = (pathname: string): string =>
7
7
  pathname.startsWith("/") ? pathname.slice(1) : pathname;
8
8
 
9
- const tryServeFile = async (relativePath: string): Promise<Response | null> => {
9
+ const tryServeFile = async (
10
+ relativePath: string,
11
+ status = 200
12
+ ): Promise<Response | null> => {
10
13
  const file = Bun.file(`public/${stripLeadingSlash(relativePath)}`);
11
14
  if (!(await file.exists())) {
12
15
  return null;
13
16
  }
14
- return new Response(file);
17
+ return new Response(file, { status });
15
18
  };
16
19
 
17
20
  const toHtmlEntryPath = (canonicalPathname: string): string =>
@@ -32,10 +35,36 @@ const serveHtml = async (pathname: string): Promise<Response> => {
32
35
  return served;
33
36
  }
34
37
 
35
- const notFound = await tryServeFile("404.html");
38
+ const notFound = await tryServeFile("404.html", 404);
36
39
  return notFound ?? new Response("Not Found", { status: 404 });
37
40
  };
38
41
 
42
+ type ShutdownSignal = "SIGINT" | "SIGTERM";
43
+
44
+ const waitForShutdownSignal = (): Promise<ShutdownSignal> => {
45
+ const { promise, resolve } = Promise.withResolvers<ShutdownSignal>();
46
+
47
+ const handleSigInt = (): void => {
48
+ cleanup();
49
+ resolve("SIGINT");
50
+ };
51
+
52
+ const handleSigTerm = (): void => {
53
+ cleanup();
54
+ resolve("SIGTERM");
55
+ };
56
+
57
+ const cleanup = (): void => {
58
+ process.off("SIGINT", handleSigInt);
59
+ process.off("SIGTERM", handleSigTerm);
60
+ };
61
+
62
+ process.on("SIGINT", handleSigInt);
63
+ process.on("SIGTERM", handleSigTerm);
64
+
65
+ return promise;
66
+ };
67
+
39
68
  export const previewCommand = async (port: number): Promise<number> => {
40
69
  const exists = await Bun.file("public/index.html").exists();
41
70
  if (!exists) {
@@ -56,5 +85,9 @@ export const previewCommand = async (port: number): Promise<number> => {
56
85
 
57
86
  // eslint-disable-next-line no-console
58
87
  console.log(`Preview running at http://localhost:${server.port}`);
88
+
89
+ await waitForShutdownSignal();
90
+ server.stop(true);
91
+
59
92
  return 0;
60
93
  };