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 +27 -5
- package/package.json +2 -3
- package/src/cli/commands/build.ts +2 -1
- package/src/cli/commands/deploy.ts +208 -22
- package/src/cli/commands/dev.ts +2 -1
- package/src/cli/commands/init.ts +56 -4
- package/src/cli/commands/preview.ts +36 -3
- package/src/cli/main.ts +271 -19
- package/src/cli/prompt.ts +105 -0
- package/src/cli/provider-files.ts +225 -0
- package/src/cli/provider.ts +36 -0
- package/src/render/layout.tsx +28 -31
- package/src/render/right-rail.tsx +5 -3
- package/src/search/index.ts +19 -24
- package/src/search/page.tsx +13 -11
- package/src/server/headers.ts +23 -4
- package/src/server/user-routes.ts +1 -1
- package/src/server.ts +22 -2
- package/src/site/cache.ts +108 -0
- package/src/site/config.ts +46 -4
- package/templates/default/.github/workflows/ci.yml +0 -3
- package/templates/default/README.md +6 -5
- package/templates/default/package.json +0 -1
- package/templates/default/scripts/check.ts +0 -1
- package/templates/default/site.jsonc +10 -0
- package/templates/default/src/server.ts +1 -1
- package/templates/default/src/ui/layout.tsx +27 -26
- package/templates/default/src/ui/right-rail.tsx +6 -3
- package/templates/default/src/ui/search-page.tsx +13 -10
- package/templates/default/tsconfig.json +1 -1
- package/templates/default/scripts/smoke.ts +0 -223
- package/templates/default/vercel.json +0 -7
package/src/site/config.ts
CHANGED
|
@@ -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
|
|
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 = {
|
|
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 {
|
|
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
|
+
});
|
|
@@ -11,11 +11,10 @@ bun install
|
|
|
11
11
|
bun run dev
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Validation
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
bun run check
|
|
18
|
-
bun run smoke
|
|
19
18
|
```
|
|
20
19
|
|
|
21
20
|
## Layout
|
|
@@ -43,10 +42,12 @@ idcmd client update runtime --yes
|
|
|
43
42
|
These commands copy the latest baseline implementations from `idcmd` into `src/ui/` and `src/runtime/`.
|
|
44
43
|
Runtime files in `src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
|
|
45
44
|
|
|
46
|
-
## Deploy
|
|
45
|
+
## Deploy
|
|
47
46
|
|
|
48
47
|
```bash
|
|
49
|
-
|
|
48
|
+
idcmd deploy --vercel
|
|
49
|
+
idcmd deploy --fly
|
|
50
|
+
idcmd deploy --railway
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
Use one provider flag at a time to generate deployment files.
|
|
@@ -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": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Optional: this file is here as the obvious place to put server-side code.
|
|
2
|
-
//
|
|
2
|
+
// Runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
|
|
3
3
|
|
|
4
4
|
export const serverPlaceholder = true;
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
/* eslint-disable react/no-danger */
|
|
3
|
-
import type { JSX } from "preact";
|
|
1
|
+
/* eslint-disable react/jsx-key */
|
|
4
2
|
|
|
5
|
-
import {
|
|
3
|
+
import type { LayoutProps } from "idcmd/client";
|
|
6
4
|
|
|
7
5
|
import { RightRail } from "./right-rail";
|
|
8
6
|
|
|
9
7
|
type NavItem = LayoutProps["navigation"][number]["items"][number];
|
|
10
8
|
|
|
9
|
+
const escapeText = (value: string): string => Bun.escapeHTML(value);
|
|
10
|
+
|
|
11
11
|
const Icon = ({ svg }: { svg: string }): JSX.Element => (
|
|
12
|
-
<span
|
|
13
|
-
class="inline-flex h-[18px] w-[18px]"
|
|
14
|
-
dangerouslySetInnerHTML={{ __html: svg }}
|
|
15
|
-
/>
|
|
12
|
+
<span class="inline-flex h-[18px] w-[18px]">{svg}</span>
|
|
16
13
|
);
|
|
17
14
|
|
|
18
15
|
const isActiveLink = (item: NavItem, currentPath: string): boolean =>
|
|
@@ -36,19 +33,18 @@ const Sidebar = ({
|
|
|
36
33
|
data-prefetch="hover"
|
|
37
34
|
>
|
|
38
35
|
<span class="text-muted-foreground">~/</span>
|
|
39
|
-
{siteName}
|
|
36
|
+
{escapeText(siteName)}
|
|
40
37
|
</a>
|
|
41
38
|
</div>
|
|
42
39
|
<div class="sidebar-content">
|
|
43
40
|
{navigation.map((group) => (
|
|
44
|
-
<div
|
|
41
|
+
<div class="py-2">
|
|
45
42
|
<p class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
46
|
-
{group.label}
|
|
43
|
+
{escapeText(group.label)}
|
|
47
44
|
</p>
|
|
48
45
|
<nav class="space-y-1">
|
|
49
46
|
{group.items.map((item) => (
|
|
50
47
|
<a
|
|
51
|
-
key={item.href}
|
|
52
48
|
href={item.href}
|
|
53
49
|
data-prefetch="hover"
|
|
54
50
|
class={`flex items-center gap-3 px-3 py-1.5 text-sm transition-colors hover:text-sidebar-foreground ${
|
|
@@ -58,7 +54,7 @@ const Sidebar = ({
|
|
|
58
54
|
}`}
|
|
59
55
|
>
|
|
60
56
|
<Icon svg={item.iconSvg} />
|
|
61
|
-
<span>{item.title}</span>
|
|
57
|
+
<span>{escapeText(item.title)}</span>
|
|
62
58
|
</a>
|
|
63
59
|
))}
|
|
64
60
|
</nav>
|
|
@@ -74,19 +70,20 @@ const SearchForm = ({ query }: { query?: string }): JSX.Element => (
|
|
|
74
70
|
action="/search/"
|
|
75
71
|
class="flex w-full items-center"
|
|
76
72
|
role="search"
|
|
77
|
-
|
|
73
|
+
novalidate
|
|
78
74
|
>
|
|
79
|
-
|
|
75
|
+
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
|
76
|
+
<label for="site-search" class="sr-only">
|
|
80
77
|
Search pages
|
|
81
78
|
</label>
|
|
82
79
|
<input
|
|
83
80
|
id="site-search"
|
|
84
81
|
name="q"
|
|
85
82
|
type="search"
|
|
86
|
-
|
|
83
|
+
autocomplete="off"
|
|
87
84
|
spellcheck={false}
|
|
88
85
|
placeholder="Search..."
|
|
89
|
-
|
|
86
|
+
value={escapeText(query ?? "")}
|
|
90
87
|
class="w-full border-b border-input bg-transparent px-1 py-1.5 text-sm placeholder:text-muted-foreground focus:border-foreground focus:outline-none transition-colors"
|
|
91
88
|
/>
|
|
92
89
|
</form>
|
|
@@ -108,7 +105,7 @@ const TopNavbar = ({
|
|
|
108
105
|
data-prefetch="hover"
|
|
109
106
|
>
|
|
110
107
|
<span class="text-muted-foreground">~/</span>
|
|
111
|
-
{siteName}
|
|
108
|
+
{escapeText(siteName)}
|
|
112
109
|
</a>
|
|
113
110
|
<div class="not-prose ml-auto w-full max-w-xs">
|
|
114
111
|
<SearchForm query={query} />
|
|
@@ -170,14 +167,16 @@ const Layout = ({
|
|
|
170
167
|
<head>
|
|
171
168
|
<meta charset="utf-8" />
|
|
172
169
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
173
|
-
<title>{title}</title>
|
|
174
|
-
{description ?
|
|
170
|
+
<title>{escapeText(title)}</title>
|
|
171
|
+
{description ? (
|
|
172
|
+
<meta name="description" content={escapeText(description)} />
|
|
173
|
+
) : null}
|
|
175
174
|
{canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null}
|
|
176
175
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
177
176
|
<link
|
|
178
177
|
rel="preconnect"
|
|
179
178
|
href="https://fonts.gstatic.com"
|
|
180
|
-
|
|
179
|
+
crossorigin="anonymous"
|
|
181
180
|
/>
|
|
182
181
|
<link
|
|
183
182
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap"
|
|
@@ -208,8 +207,10 @@ const Layout = ({
|
|
|
208
207
|
class={`prose min-w-0 flex-1${
|
|
209
208
|
currentPath === "/" ? " prose-home" : ""
|
|
210
209
|
}`}
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
>
|
|
211
|
+
{/* content is pre-rendered markdown HTML */}
|
|
212
|
+
{content}
|
|
213
|
+
</article>
|
|
213
214
|
{shouldShowRightRail ? (
|
|
214
215
|
<RightRail
|
|
215
216
|
canonicalUrl={canonicalUrl}
|
|
@@ -221,12 +222,12 @@ const Layout = ({
|
|
|
221
222
|
</div>
|
|
222
223
|
</main>
|
|
223
224
|
<footer class="site-footer">
|
|
224
|
-
Built with
|
|
225
|
+
Built with idcmd SSR + Tailwind | Zero JavaScript on
|
|
225
226
|
content pages
|
|
226
227
|
</footer>
|
|
227
228
|
</div>
|
|
228
229
|
{scriptPaths.map((scriptPath) => (
|
|
229
|
-
<script
|
|
230
|
+
<script defer src={scriptPath} />
|
|
230
231
|
))}
|
|
231
232
|
</body>
|
|
232
233
|
</html>
|
|
@@ -234,4 +235,4 @@ const Layout = ({
|
|
|
234
235
|
};
|
|
235
236
|
|
|
236
237
|
export const renderLayout = (props: LayoutProps): string =>
|
|
237
|
-
`<!DOCTYPE html>${
|
|
238
|
+
`<!DOCTYPE html>${<Layout {...props} />}`;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
/* eslint-disable react/jsx-key */
|
|
2
|
+
|
|
1
3
|
import type { RightRailProps } from "idcmd/client";
|
|
2
|
-
|
|
4
|
+
|
|
5
|
+
const escapeText = (value: string): string => Bun.escapeHTML(value);
|
|
3
6
|
|
|
4
7
|
const CaretDownIcon = (): JSX.Element => (
|
|
5
8
|
<svg
|
|
@@ -173,13 +176,13 @@ const OnThisPage = ({
|
|
|
173
176
|
<div class="toc-scroll min-h-0 flex-1" data-toc-scroll-container="1">
|
|
174
177
|
<ul class="space-y-2 text-sm text-muted-foreground">
|
|
175
178
|
{items.map((item) => (
|
|
176
|
-
<li
|
|
179
|
+
<li class={item.level >= 3 ? "pl-3" : ""}>
|
|
177
180
|
<a
|
|
178
181
|
href={`#${encodeURIComponent(item.id)}`}
|
|
179
182
|
class="hover:text-foreground"
|
|
180
183
|
data-toc-link="1"
|
|
181
184
|
>
|
|
182
|
-
{item.text}
|
|
185
|
+
{escapeText(item.text)}
|
|
183
186
|
</a>
|
|
184
187
|
</li>
|
|
185
188
|
))}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
/* eslint-disable react/jsx-key */
|
|
2
|
+
|
|
1
3
|
import type { SearchPageProps } from "idcmd/client";
|
|
2
|
-
import type { JSX } from "preact";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const escapeText = (value: string): string => Bun.escapeHTML(value);
|
|
5
6
|
|
|
6
7
|
const ResultItem = ({
|
|
7
8
|
result,
|
|
@@ -13,9 +14,11 @@ const ResultItem = ({
|
|
|
13
14
|
href={result.slug}
|
|
14
15
|
class="font-medium underline decoration-border underline-offset-4"
|
|
15
16
|
>
|
|
16
|
-
{result.title}
|
|
17
|
+
{escapeText(result.title)}
|
|
17
18
|
</a>
|
|
18
|
-
<p class="mt-1 text-sm text-muted-foreground">
|
|
19
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
20
|
+
{escapeText(result.description)}
|
|
21
|
+
</p>
|
|
19
22
|
</li>
|
|
20
23
|
);
|
|
21
24
|
|
|
@@ -33,12 +36,12 @@ const EmptyState = ({
|
|
|
33
36
|
<p class="mt-4 font-medium text-foreground">Popular pages</p>
|
|
34
37
|
<ul class="mt-2 space-y-1">
|
|
35
38
|
{topPages.map((page) => (
|
|
36
|
-
<li
|
|
39
|
+
<li>
|
|
37
40
|
<a
|
|
38
41
|
href={page.href}
|
|
39
42
|
class="underline decoration-border underline-offset-4"
|
|
40
43
|
>
|
|
41
|
-
{page.title}
|
|
44
|
+
{escapeText(page.title)}
|
|
42
45
|
</a>
|
|
43
46
|
</li>
|
|
44
47
|
))}
|
|
@@ -63,8 +66,8 @@ const SearchPage = ({
|
|
|
63
66
|
{showResults ? (
|
|
64
67
|
<p class="mt-2 text-sm text-muted-foreground">
|
|
65
68
|
{results.length === 0
|
|
66
|
-
? `No matches for "${trimmed}".`
|
|
67
|
-
: `Found ${results.length} result(s) for "${trimmed}".`}
|
|
69
|
+
? `No matches for "${escapeText(trimmed)}".`
|
|
70
|
+
: `Found ${results.length} result(s) for "${escapeText(trimmed)}".`}
|
|
68
71
|
</p>
|
|
69
72
|
) : (
|
|
70
73
|
<div class="mt-2">
|
|
@@ -75,7 +78,7 @@ const SearchPage = ({
|
|
|
75
78
|
{showResults ? (
|
|
76
79
|
<ul class="mt-4 space-y-2">
|
|
77
80
|
{results.map((result) => (
|
|
78
|
-
<ResultItem
|
|
81
|
+
<ResultItem result={result} />
|
|
79
82
|
))}
|
|
80
83
|
</ul>
|
|
81
84
|
) : null}
|
|
@@ -84,4 +87,4 @@ const SearchPage = ({
|
|
|
84
87
|
};
|
|
85
88
|
|
|
86
89
|
export const renderSearchPageContent = (props: SearchPageProps): string =>
|
|
87
|
-
|
|
90
|
+
`${<SearchPage {...props} />}`;
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
interface CommandResult {
|
|
2
|
-
code: number;
|
|
3
|
-
stderr: string;
|
|
4
|
-
stdout: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
const BASE_URL = process.env.IDCMD_SMOKE_BASE_URL ?? "http://127.0.0.1:4000";
|
|
8
|
-
const CURL_MAX_TIME_SECONDS = "5";
|
|
9
|
-
const READY_TIMEOUT_MS = 60_000;
|
|
10
|
-
const READY_INTERVAL_MS = 500;
|
|
11
|
-
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
12
|
-
|
|
13
|
-
const delay = (ms: number): Promise<void> => Bun.sleep(ms);
|
|
14
|
-
|
|
15
|
-
const runCommand = async (command: string[]): Promise<CommandResult> => {
|
|
16
|
-
const proc = Bun.spawn(command, {
|
|
17
|
-
cwd: process.cwd(),
|
|
18
|
-
stderr: "pipe",
|
|
19
|
-
stdout: "pipe",
|
|
20
|
-
});
|
|
21
|
-
const [stdout, stderr, code] = await Promise.all([
|
|
22
|
-
new Response(proc.stdout).text(),
|
|
23
|
-
new Response(proc.stderr).text(),
|
|
24
|
-
proc.exited,
|
|
25
|
-
]);
|
|
26
|
-
return { code, stderr, stdout };
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const runCurl = (path: string): Promise<CommandResult> =>
|
|
30
|
-
runCommand([
|
|
31
|
-
"curl",
|
|
32
|
-
"-fsS",
|
|
33
|
-
"--max-time",
|
|
34
|
-
CURL_MAX_TIME_SECONDS,
|
|
35
|
-
`${BASE_URL}${path}`,
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
const assertCommandOk = (label: string, result: CommandResult): void => {
|
|
39
|
-
if (result.code === 0) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
throw new Error(
|
|
43
|
-
[
|
|
44
|
-
`${label} failed with exit code ${String(result.code)}.`,
|
|
45
|
-
"stdout:",
|
|
46
|
-
result.stdout.trim() || "(empty)",
|
|
47
|
-
"stderr:",
|
|
48
|
-
result.stderr.trim() || "(empty)",
|
|
49
|
-
].join("\n")
|
|
50
|
-
);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const expectIncludes = (args: {
|
|
54
|
-
haystack: string;
|
|
55
|
-
label: string;
|
|
56
|
-
needle: string;
|
|
57
|
-
}): void => {
|
|
58
|
-
if (args.haystack.includes(args.needle)) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
throw new Error(`Expected ${args.label} to include ${args.needle}.`);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const waitForReady = async (): Promise<void> => {
|
|
65
|
-
const startedAt = Date.now();
|
|
66
|
-
let lastFailure = "(no attempts yet)";
|
|
67
|
-
|
|
68
|
-
while (Date.now() - startedAt < READY_TIMEOUT_MS) {
|
|
69
|
-
const ready = await runCurl("/");
|
|
70
|
-
if (ready.code === 0) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
lastFailure = ready.stderr.trim() || ready.stdout.trim() || "curl failed";
|
|
74
|
-
await delay(READY_INTERVAL_MS);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
throw new Error(
|
|
78
|
-
`dev server did not become ready within ${String(
|
|
79
|
-
READY_TIMEOUT_MS
|
|
80
|
-
)}ms. Last curl failure: ${lastFailure}`
|
|
81
|
-
);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const shutdownDev = async (
|
|
85
|
-
proc: ReturnType<typeof Bun.spawn>
|
|
86
|
-
): Promise<void> => {
|
|
87
|
-
try {
|
|
88
|
-
proc.kill("SIGTERM");
|
|
89
|
-
} catch {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const didExit = await Promise.race([
|
|
94
|
-
proc.exited.then(() => true),
|
|
95
|
-
delay(SHUTDOWN_TIMEOUT_MS).then(() => false),
|
|
96
|
-
]);
|
|
97
|
-
if (!didExit) {
|
|
98
|
-
try {
|
|
99
|
-
proc.kill("SIGKILL");
|
|
100
|
-
} catch {
|
|
101
|
-
// ignore
|
|
102
|
-
}
|
|
103
|
-
await proc.exited;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const assertHomeResponse = async (): Promise<void> => {
|
|
108
|
-
const home = await runCurl("/");
|
|
109
|
-
assertCommandOk("curl /", home);
|
|
110
|
-
expectIncludes({ haystack: home.stdout, label: "/", needle: "<html" });
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const assertAboutResponse = async (): Promise<void> => {
|
|
114
|
-
const about = await runCurl("/about/");
|
|
115
|
-
assertCommandOk("curl /about/", about);
|
|
116
|
-
if (!about.stdout.includes("# About") && !about.stdout.includes(">About<")) {
|
|
117
|
-
throw new Error("Expected /about/ response to include About heading.");
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const assertLlmsResponse = async (): Promise<void> => {
|
|
122
|
-
const llms = await runCurl("/llms.txt");
|
|
123
|
-
assertCommandOk("curl /llms.txt", llms);
|
|
124
|
-
if (llms.stdout.trim().length === 0 || !llms.stdout.includes("about.md")) {
|
|
125
|
-
throw new Error("Expected /llms.txt to be non-empty and include about.md.");
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const assertApiResponse = async (): Promise<void> => {
|
|
130
|
-
const api = await runCurl("/api/hello");
|
|
131
|
-
assertCommandOk("curl /api/hello", api);
|
|
132
|
-
const payload = JSON.parse(api.stdout) as { message?: string; ok?: boolean };
|
|
133
|
-
if (payload.ok !== true || payload.message !== "Hello from idcmd route!") {
|
|
134
|
-
throw new Error("Expected /api/hello payload to match template route.");
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const runSmokeChecks = async (): Promise<void> => {
|
|
139
|
-
await assertHomeResponse();
|
|
140
|
-
await assertAboutResponse();
|
|
141
|
-
await assertLlmsResponse();
|
|
142
|
-
await assertApiResponse();
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const runProjectCheck = async (): Promise<void> => {
|
|
146
|
-
const check = await runCommand([process.execPath, "run", "check"]);
|
|
147
|
-
assertCommandOk("bun run check", check);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const startDev = (): {
|
|
151
|
-
devProc: ReturnType<typeof Bun.spawn>;
|
|
152
|
-
devStderr: Promise<string>;
|
|
153
|
-
devStdout: Promise<string>;
|
|
154
|
-
} => {
|
|
155
|
-
const devProc = Bun.spawn([process.execPath, "run", "dev"], {
|
|
156
|
-
cwd: process.cwd(),
|
|
157
|
-
stderr: "pipe",
|
|
158
|
-
stdout: "pipe",
|
|
159
|
-
});
|
|
160
|
-
return {
|
|
161
|
-
devProc,
|
|
162
|
-
devStderr: new Response(devProc.stderr).text(),
|
|
163
|
-
devStdout: new Response(devProc.stdout).text(),
|
|
164
|
-
};
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const logDevFailure = async (args: {
|
|
168
|
-
error: unknown;
|
|
169
|
-
stderr: Promise<string>;
|
|
170
|
-
stdout: Promise<string>;
|
|
171
|
-
}): Promise<void> => {
|
|
172
|
-
const [stdout, stderr] = await Promise.all([args.stdout, args.stderr]);
|
|
173
|
-
const message =
|
|
174
|
-
args.error instanceof Error ? args.error.message : String(args.error);
|
|
175
|
-
console.error(message);
|
|
176
|
-
console.error("dev stdout:");
|
|
177
|
-
console.error(stdout.trim() || "(empty)");
|
|
178
|
-
console.error("dev stderr:");
|
|
179
|
-
console.error(stderr.trim() || "(empty)");
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const toErrorMessage = (error: unknown): string => {
|
|
183
|
-
if (error instanceof Error) {
|
|
184
|
-
return error.message;
|
|
185
|
-
}
|
|
186
|
-
return String(error);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const runDevSmokeFlow = async (): Promise<number> => {
|
|
190
|
-
const { devProc, devStderr, devStdout } = startDev();
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
await waitForReady();
|
|
194
|
-
await runSmokeChecks();
|
|
195
|
-
} catch (error) {
|
|
196
|
-
await logDevFailure({ error, stderr: devStderr, stdout: devStdout });
|
|
197
|
-
return 1;
|
|
198
|
-
} finally {
|
|
199
|
-
await shutdownDev(devProc);
|
|
200
|
-
}
|
|
201
|
-
return 0;
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const runPostDevCheck = async (): Promise<number> => {
|
|
205
|
-
try {
|
|
206
|
-
await runProjectCheck();
|
|
207
|
-
return 0;
|
|
208
|
-
} catch (error) {
|
|
209
|
-
console.error(toErrorMessage(error));
|
|
210
|
-
return 1;
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const main = async (): Promise<number> => {
|
|
215
|
-
const smokeCode = await runDevSmokeFlow();
|
|
216
|
-
if (smokeCode !== 0) {
|
|
217
|
-
return smokeCode;
|
|
218
|
-
}
|
|
219
|
-
return runPostDevCheck();
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const code = await main();
|
|
223
|
-
process.exit(code);
|