@checkstack/dev-server 1.0.2 → 2.1.0

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": "@checkstack/dev-server",
3
- "version": "1.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Local Checkstack dev server for plugin authors. Spawns the platform backend with the cwd plugin loaded, runs Vite for the frontend, and reloads on save. Used as a devDependency in plugin repos (run via `bun run dev` → `checkstack-dev`).",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -24,9 +24,9 @@
24
24
  "lint:code": "eslint . --max-warnings 0"
25
25
  },
26
26
  "dependencies": {
27
- "@checkstack/common": "0.10.0",
28
- "@vitejs/plugin-react": "^6.0.1",
29
- "vite": "^8.0.12"
27
+ "@checkstack/common": "0.12.0",
28
+ "@vitejs/plugin-react": "^6.0.2",
29
+ "vite": "^8.0.16"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@checkstack/backend": "*",
@@ -42,9 +42,9 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@checkstack/tsconfig": "0.0.7",
45
- "@checkstack/scripts": "0.3.2",
46
- "@checkstack/backend": "0.10.2",
47
- "@checkstack/frontend": "0.6.3",
45
+ "@checkstack/scripts": "0.3.4",
46
+ "@checkstack/backend": "0.15.0",
47
+ "@checkstack/frontend": "0.6.7",
48
48
  "typescript": "^5.0.0"
49
49
  }
50
50
  }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import path from "node:path";
3
+ import { buildDevTailwindContent } from "./dev-frontend-css";
4
+
5
+ const FRONTEND = "/nm/@checkstack/frontend";
6
+ const SRC_GLOB = "**/*.{js,ts,jsx,tsx}";
7
+
8
+ describe("buildDevTailwindContent", () => {
9
+ it("always covers the dev shell (frontend index.html + src)", () => {
10
+ const globs = buildDevTailwindContent({
11
+ frontendDir: FRONTEND,
12
+ pluginCwd: "/ws/packages/widget-backend",
13
+ pluginEntry: "/ws/packages/widget-frontend/src/index.tsx",
14
+ });
15
+ expect(globs).toContain(path.join(FRONTEND, "index.html"));
16
+ expect(globs).toContain(path.join(FRONTEND, "src", SRC_GLOB));
17
+ });
18
+
19
+ it("includes the plugin's own sources (so custom classes compile)", () => {
20
+ const pluginCwd = "/ws/packages/widget-backend";
21
+ const pluginEntry = "/ws/packages/widget-frontend/src/index.tsx";
22
+ const globs = buildDevTailwindContent({
23
+ frontendDir: FRONTEND,
24
+ pluginCwd,
25
+ pluginEntry,
26
+ });
27
+ // The directory the entry lives in (a -frontend sibling) is scanned, so
28
+ // an author's custom utility classes in those .tsx files compile.
29
+ expect(globs).toContain(
30
+ path.join("/ws/packages/widget-frontend/src", SRC_GLOB),
31
+ );
32
+ // The dev cwd's own src is also scanned (covers the type === "frontend"
33
+ // case where cwd IS the frontend package).
34
+ expect(globs).toContain(path.join(pluginCwd, "src", SRC_GLOB));
35
+ });
36
+
37
+ it("scans the cwd's own src when the plugin IS a -frontend package", () => {
38
+ const cwd = "/ws/packages/widget-frontend";
39
+ const globs = buildDevTailwindContent({
40
+ frontendDir: FRONTEND,
41
+ pluginCwd: cwd,
42
+ pluginEntry: path.join(cwd, "src/index.tsx"),
43
+ });
44
+ expect(globs).toContain(path.join(cwd, "src", SRC_GLOB));
45
+ });
46
+
47
+ it("appends extra source dirs (e.g. @checkstack/ui) when provided", () => {
48
+ const globs = buildDevTailwindContent({
49
+ frontendDir: FRONTEND,
50
+ pluginCwd: "/ws/p/widget-backend",
51
+ pluginEntry: "/ws/p/widget-frontend/src/index.tsx",
52
+ extraSourceDirs: ["/nm/@checkstack/ui/src", "/nm/@checkstack/foo/src"],
53
+ });
54
+ expect(globs).toContain(path.join("/nm/@checkstack/ui/src", SRC_GLOB));
55
+ expect(globs).toContain(path.join("/nm/@checkstack/foo/src", SRC_GLOB));
56
+ });
57
+
58
+ it("de-duplicates overlapping globs (cwd === entry package)", () => {
59
+ const cwd = "/ws/packages/widget-frontend";
60
+ const globs = buildDevTailwindContent({
61
+ frontendDir: FRONTEND,
62
+ pluginCwd: cwd,
63
+ // entry directly under cwd/src → entry dir scan and cwd/src scan coincide
64
+ pluginEntry: path.join(cwd, "src/index.tsx"),
65
+ });
66
+ const cwdSrcGlob = path.join(cwd, "src", SRC_GLOB);
67
+ expect(globs.filter((g) => g === cwdSrcGlob)).toHaveLength(1);
68
+ // No duplicates anywhere.
69
+ expect(new Set(globs).size).toBe(globs.length);
70
+ });
71
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tailwind content-glob assembly for the standalone dev server's inline
3
+ * Vite/PostCSS pipeline.
4
+ *
5
+ * The dev server roots Vite at the installed `@checkstack/frontend`
6
+ * directory and applies the shared `@checkstack/frontend/tailwind-preset`
7
+ * (theme + `tailwindcss-animate`). The preset carries NO `content`, so we
8
+ * supply an explicit, ABSOLUTE content-glob set here. The load-bearing
9
+ * part is that it reaches the plugin under development's OWN source tree —
10
+ * otherwise a plugin author's custom Tailwind utility classes (anything
11
+ * outside the precompiled `@checkstack/ui` components) never compile into
12
+ * the dev CSS. We also cover the dev shell and any extra source dirs (e.g.
13
+ * the shared UI library) so their classes generate too.
14
+ *
15
+ * Pure: takes only resolved directory inputs and returns globs. No FS, no
16
+ * Tailwind, no Vite — so the unit suite can assert the glob shape without
17
+ * touching disk.
18
+ */
19
+ import path from "node:path";
20
+
21
+ export interface DevTailwindContentInput {
22
+ /** Installed `@checkstack/frontend` root (Vite `root`). */
23
+ frontendDir: string;
24
+ /** The plugin author's package directory (the dev `cwd`). */
25
+ pluginCwd: string;
26
+ /**
27
+ * Absolute path to the plugin's resolved frontend entry file (from
28
+ * `pickFrontendEntry`). Its package directory is scanned so the author's
29
+ * classes are picked up even when the entry lives in a `-frontend`
30
+ * sibling rather than `pluginCwd` itself.
31
+ */
32
+ pluginEntry: string;
33
+ /**
34
+ * Resolved `src` directories of additional frontend packages whose
35
+ * components the dev shell renders (e.g. `@checkstack/ui`). Best-effort:
36
+ * callers pass whatever resolved cleanly; missing ones are omitted.
37
+ */
38
+ extraSourceDirs?: string[];
39
+ }
40
+
41
+ const SOURCE_GLOB = "**/*.{js,ts,jsx,tsx}";
42
+
43
+ /**
44
+ * Build the absolute Tailwind `content` glob list for the dev pipeline.
45
+ * De-duplicated; order is dev-shell first, then extras, then the plugin's
46
+ * own sources (which are the whole point of this function).
47
+ */
48
+ export function buildDevTailwindContent({
49
+ frontendDir,
50
+ pluginCwd,
51
+ pluginEntry,
52
+ extraSourceDirs = [],
53
+ }: DevTailwindContentInput): string[] {
54
+ const globs: string[] = [
55
+ // Dev shell.
56
+ path.join(frontendDir, "index.html"),
57
+ path.join(frontendDir, "src", SOURCE_GLOB),
58
+ // Extra source dirs (e.g. @checkstack/ui).
59
+ ...extraSourceDirs.map((dir) => path.join(dir, SOURCE_GLOB)),
60
+ // The plugin's own sources — the load-bearing part. Cover the directory
61
+ // the entry file lives in (the `-frontend` package, possibly a sibling:
62
+ // e.g. `packages/widget-frontend/src/index.tsx` → scan that whole `src`
63
+ // tree) and the dev `cwd`'s own `src` (they differ for a bundle
64
+ // primary, and we want the author's classes whichever one holds the
65
+ // components).
66
+ path.join(path.dirname(pluginEntry), SOURCE_GLOB),
67
+ path.join(pluginCwd, "src", SOURCE_GLOB),
68
+ ];
69
+
70
+ // De-dupe while preserving order.
71
+ return [...new Set(globs)];
72
+ }
@@ -1,6 +1,10 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import path from "node:path";
3
- import { pickFrontendEntry } from "./dev-frontend";
3
+ import {
4
+ pickFrontendEntry,
5
+ resolveFromCandidates,
6
+ findSiblingPackageDir,
7
+ } from "./dev-frontend";
4
8
 
5
9
  const ROOT = "/plugin-author/repo";
6
10
 
@@ -145,4 +149,125 @@ describe("pickFrontendEntry", () => {
145
149
  });
146
150
  expect(entry).toBe("/nm/@my-org/widget-frontend/src/index.tsx");
147
151
  });
152
+
153
+ it("falls back to a filesystem-sibling scan when node_modules resolution misses (standalone workspace)", () => {
154
+ // Mirrors the standalone scaffold: backend at packages/widget-backend,
155
+ // frontend at packages/widget-frontend — the latter only referenced via
156
+ // checkstack.bundle, never installed as a dependency, so require.resolve
157
+ // can never find it. The dev server must still pick it up.
158
+ const pluginCwd = "/ws/packages/widget-backend";
159
+ const entry = pickFrontendEntry({
160
+ pluginCwd,
161
+ pluginPkg: {
162
+ main: "src/index.ts",
163
+ checkstack: {
164
+ type: "backend",
165
+ bundle: ["@scope/widget-common", "@scope/widget-frontend"],
166
+ },
167
+ },
168
+ // node_modules resolution always misses for the workspace sibling.
169
+ resolveFrom: () => undefined,
170
+ findSiblingDir: (name) =>
171
+ name === "@scope/widget-frontend"
172
+ ? "/ws/packages/widget-frontend/package.json"
173
+ : undefined,
174
+ readFile: () => JSON.stringify({ main: "src/index.tsx" }),
175
+ });
176
+ expect(entry).toBe("/ws/packages/widget-frontend/src/index.tsx");
177
+ });
178
+
179
+ it("prefers node_modules resolution over the sibling-dir scan", () => {
180
+ let scanned = false;
181
+ const entry = pickFrontendEntry({
182
+ pluginCwd: ROOT,
183
+ pluginPkg: {
184
+ checkstack: { type: "backend", bundle: ["@my-org/widget-frontend"] },
185
+ },
186
+ resolveFrom: () => "/nm/@my-org/widget-frontend/package.json",
187
+ findSiblingDir: () => {
188
+ scanned = true;
189
+ return undefined;
190
+ },
191
+ readFile: () => JSON.stringify({ main: "src/index.tsx" }),
192
+ });
193
+ expect(entry).toBe("/nm/@my-org/widget-frontend/src/index.tsx");
194
+ expect(scanned).toBe(false);
195
+ });
196
+ });
197
+
198
+ describe("resolveFromCandidates", () => {
199
+ it("returns undefined for an unresolvable request without throwing", () => {
200
+ // The load-bearing behaviour for #251 bug 2: a missing module must NOT
201
+ // surface a (non-Error) throw to the caller. A real bun/node resolve of
202
+ // a guaranteed-absent specifier exercises the actual throw path.
203
+ const result = resolveFromCandidates({
204
+ request: "@checkstack/definitely-not-a-real-package-251/package.json",
205
+ candidateBasePaths: ["/no/such/base", process.cwd()],
206
+ });
207
+ expect(result).toBeUndefined();
208
+ });
209
+
210
+ it("resolves a real package from the cwd candidate", () => {
211
+ const result = resolveFromCandidates({
212
+ request: "vite/package.json",
213
+ candidateBasePaths: [import.meta.dir],
214
+ });
215
+ expect(result).toBeDefined();
216
+ expect(result).toContain("vite");
217
+ });
218
+ });
219
+
220
+ describe("findSiblingPackageDir", () => {
221
+ const PLUGIN = "/ws/packages/widget-backend";
222
+
223
+ it("finds the sibling package.json by matching its name", () => {
224
+ const found = findSiblingPackageDir({
225
+ pluginCwd: PLUGIN,
226
+ siblingName: "@scope/widget-frontend",
227
+ listDir: (dir) => {
228
+ expect(dir).toBe("/ws/packages");
229
+ return ["widget-backend", "widget-common", "widget-frontend"];
230
+ },
231
+ readFile: (p) =>
232
+ JSON.stringify({
233
+ name: `@scope/${path.basename(path.dirname(p))}`,
234
+ }),
235
+ });
236
+ expect(found).toBe("/ws/packages/widget-frontend/package.json");
237
+ });
238
+
239
+ it("returns undefined when no sibling matches", () => {
240
+ const found = findSiblingPackageDir({
241
+ pluginCwd: PLUGIN,
242
+ siblingName: "@scope/widget-frontend",
243
+ listDir: () => ["widget-backend", "widget-common"],
244
+ readFile: (p) =>
245
+ JSON.stringify({ name: `@scope/${path.basename(path.dirname(p))}` }),
246
+ });
247
+ expect(found).toBeUndefined();
248
+ });
249
+
250
+ it("skips entries with unreadable or malformed package.json", () => {
251
+ const found = findSiblingPackageDir({
252
+ pluginCwd: PLUGIN,
253
+ siblingName: "@scope/widget-frontend",
254
+ listDir: () => ["broken", "widget-frontend"],
255
+ readFile: (p) => {
256
+ if (p.includes("broken")) throw new Error("ENOENT");
257
+ return JSON.stringify({ name: "@scope/widget-frontend" });
258
+ },
259
+ });
260
+ expect(found).toBe("/ws/packages/widget-frontend/package.json");
261
+ });
262
+
263
+ it("returns undefined when the parent directory cannot be listed", () => {
264
+ const found = findSiblingPackageDir({
265
+ pluginCwd: PLUGIN,
266
+ siblingName: "@scope/widget-frontend",
267
+ listDir: () => {
268
+ throw new Error("ENOENT");
269
+ },
270
+ });
271
+ expect(found).toBeUndefined();
272
+ });
148
273
  });
@@ -10,8 +10,50 @@
10
10
  */
11
11
  import path from "node:path";
12
12
  import fs from "node:fs";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
14
  import { createRequire } from "node:module";
14
15
  import type { ViteDevServer } from "vite";
16
+ import { buildDevTailwindContent } from "./dev-frontend-css";
17
+
18
+ /**
19
+ * Directory of THIS module (`@checkstack/dev-server`'s installed location).
20
+ * `@checkstack/frontend`, `vite`, and `@vitejs/plugin-react` are
21
+ * dev-server's own (peer/runtime) dependencies, so under a published
22
+ * `bun install` layout they are reachable from here even though they are
23
+ * NOT reachable from the plugin author's package — the plugin only
24
+ * declares `@checkstack/dev-server`, never `@checkstack/frontend` or Vite
25
+ * directly. See {@link resolveFromCandidates}.
26
+ */
27
+ const DEV_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ /**
30
+ * Resolve a package request from the FIRST base path under which it is
31
+ * reachable, returning the resolved path or `undefined`.
32
+ *
33
+ * Bun's `createRequire(...).resolve()` throws a value that is NOT an
34
+ * `instanceof Error` for an unresolvable specifier (a `ResolveError`-like
35
+ * object), so a bare `try/catch` that rethrows leaks a non-Error up the
36
+ * stack — which is exactly why the caller's `extractErrorMessage` used to
37
+ * print the useless `"An error occurred"` fallback. We swallow the throw
38
+ * per-candidate here and let the caller decide what to do with a clean
39
+ * `undefined`.
40
+ */
41
+ export function resolveFromCandidates({
42
+ request,
43
+ candidateBasePaths,
44
+ }: {
45
+ request: string;
46
+ candidateBasePaths: string[];
47
+ }): string | undefined {
48
+ for (const base of candidateBasePaths) {
49
+ try {
50
+ return createRequire(path.join(base, "package.json")).resolve(request);
51
+ } catch {
52
+ // not reachable from this base — try the next
53
+ }
54
+ }
55
+ return undefined;
56
+ }
15
57
 
16
58
  // `vite` and `@vitejs/plugin-react` are lazily imported inside
17
59
  // `startFrontendDevServer` so that consumers of this module which only
@@ -44,11 +86,24 @@ export async function startFrontendDevServer({
44
86
  ]);
45
87
  const react = reactModule.default;
46
88
 
47
- // Resolve the @checkstack/frontend package from the plugin author's
48
- // node_modules so paths line up with what `bun install` produced. Same
49
- // strategy the backend dev command uses for @checkstack/backend.
50
- const req = createRequire(path.join(pluginCwd, "package.json"));
51
- const frontendPkgJsonPath = req.resolve("@checkstack/frontend/package.json");
89
+ // Resolve the @checkstack/frontend package. In the monorepo it is
90
+ // reachable from the plugin's own package; under a PUBLISHED
91
+ // `bun install`, the plugin only depends on @checkstack/dev-server, so
92
+ // @checkstack/frontend (dev-server's peer dep) is reachable from THIS
93
+ // module's install location, not the plugin's. Try the plugin first so a
94
+ // plugin that pins its own @checkstack/frontend wins, then fall back to
95
+ // dev-server's location. (Before this fallback, the resolve threw a
96
+ // non-Error under Bun and the dev server logged "An error occurred" and
97
+ // ran backend-only — #251 bug 2.)
98
+ const frontendPkgJsonPath = resolveFromCandidates({
99
+ request: "@checkstack/frontend/package.json",
100
+ candidateBasePaths: [pluginCwd, DEV_SERVER_MODULE_DIR],
101
+ });
102
+ if (!frontendPkgJsonPath) {
103
+ throw new Error(
104
+ "Could not resolve @checkstack/frontend. It ships as a dependency of @checkstack/dev-server; ensure your plugin's dev dependencies are installed (`bun install`).",
105
+ );
106
+ }
52
107
  const frontendDir = path.dirname(frontendPkgJsonPath);
53
108
  const indexHtmlPath = path.join(frontendDir, "index.html");
54
109
  if (!fs.existsSync(indexHtmlPath)) {
@@ -59,23 +114,30 @@ export async function startFrontendDevServer({
59
114
 
60
115
  // Resolve the plugin under dev's main entry — Vite's alias maps the
61
116
  // virtual import `virtual:checkstack-dev-plugin` to this file.
117
+ // Cast: `JSON.parse` is typed `any`; we read only these optional fields
118
+ // and every consumer treats them as optional, so an unexpected shape
119
+ // narrows to `undefined` rather than crashing.
62
120
  const pluginPkg = JSON.parse(
63
121
  fs.readFileSync(path.join(pluginCwd, "package.json"), "utf8"),
64
122
  ) as { main?: string; checkstack?: { type?: string; bundle?: string[] } };
65
123
  // Bundle primaries point at their own backend main, but the frontend
66
124
  // entry lives in a sibling. Best-effort: if the cwd's checkstack.type
67
125
  // is "frontend", use cwd; else look for a sibling -frontend package
68
- // listed in `checkstack.bundle` and resolve through node_modules.
126
+ // listed in `checkstack.bundle`. We resolve the sibling from the plugin
127
+ // first, then from dev-server's location (published layout), and finally
128
+ // fall back to scanning sibling directories — a standalone workspace's
129
+ // own `-frontend` package lives at `packages/<base>-frontend` and is NOT
130
+ // a node_modules entry (nothing depends on it as an npm package; it is
131
+ // only referenced via `checkstack.bundle`), so `require.resolve` can
132
+ // never find it. See {@link findSiblingPackageDir}.
69
133
  const pluginEntry = pickFrontendEntry({
70
134
  pluginCwd,
71
135
  pluginPkg,
72
- resolveFrom: (request) => {
73
- try {
74
- return req.resolve(request);
75
- } catch {
76
- return;
77
- }
78
- },
136
+ resolveFrom: (request) =>
137
+ resolveFromCandidates({
138
+ request,
139
+ candidateBasePaths: [pluginCwd, DEV_SERVER_MODULE_DIR],
140
+ }),
79
141
  });
80
142
  if (!pluginEntry) {
81
143
  throw new Error(
@@ -83,6 +145,19 @@ export async function startFrontendDevServer({
83
145
  );
84
146
  }
85
147
 
148
+ // Build the Tailwind/PostCSS pipeline for the dev shell. `@checkstack/
149
+ // frontend` now ships the Tailwind toolchain as RUNTIME deps and exports
150
+ // a `tailwind-preset` subpath, so we can assemble the pipeline from a
151
+ // published install. The key win over the frontend's own config: we
152
+ // inject the plugin under development's source globs into `content`, so a
153
+ // plugin author's custom utility classes compile in dev. Returns
154
+ // `undefined` (→ Vite default PostCSS) if the toolchain can't be loaded.
155
+ const postcssPlugins = await loadDevPostcssPlugins({
156
+ frontendDir,
157
+ pluginCwd,
158
+ pluginEntry,
159
+ });
160
+
86
161
  const server = await createViteServer({
87
162
  root: frontendDir,
88
163
  configFile: false, // we control the config inline
@@ -98,6 +173,11 @@ export async function startFrontendDevServer({
98
173
  "/assets/plugins": { target: backendUrl, changeOrigin: true },
99
174
  },
100
175
  },
176
+ // Explicit PostCSS chain (Tailwind preset + plugin globs + autoprefixer)
177
+ // when it could be assembled — overrides Vite's auto-discovery of the
178
+ // frontend's own `postcss.config.js`, whose Tailwind `content` globs are
179
+ // relative to the dev cwd and never reach the plugin under development.
180
+ ...(postcssPlugins ? { css: { postcss: { plugins: postcssPlugins } } } : {}),
101
181
  // Replace the production `main.tsx` entry with our `dev-main.tsx`
102
182
  // shell. Vite will pick this up via `index.html`'s `<script>` tag,
103
183
  // because dev-main.tsx imports `virtual:checkstack-dev-plugin`,
@@ -134,13 +214,163 @@ export async function startFrontendDevServer({
134
214
  return server;
135
215
  }
136
216
 
217
+ /**
218
+ * The Vite config types `css.postcss` as a `string | (ProcessOptions & {
219
+ * plugins?: PostCSS.AcceptedPlugin[] })`. We pull the plugin element type
220
+ * straight out of that shape so the helper stays free of `any` without
221
+ * depending on the `postcss` types package directly (we only depend on
222
+ * `vite`, which re-exposes them).
223
+ */
224
+ type ViteCss = NonNullable<import("vite").UserConfig["css"]>;
225
+ type ViteCssPostcssObject = Extract<ViteCss["postcss"], { plugins?: unknown }>;
226
+ type PostcssPlugin = NonNullable<ViteCssPostcssObject["plugins"]>[number];
227
+
228
+ /** The fields we read off `@checkstack/frontend`'s tailwind preset. */
229
+ interface TailwindPreset {
230
+ [key: string]: unknown;
231
+ }
232
+
233
+ /**
234
+ * Assemble the dev PostCSS plugin chain — the shared `@checkstack/frontend`
235
+ * Tailwind preset (theme + `tailwindcss-animate`) with a `content` glob set
236
+ * that reaches the plugin author's OWN sources, plus autoprefixer.
237
+ *
238
+ * Returns `undefined` when the chain cannot be built (e.g. an older
239
+ * `@checkstack/frontend` without the preset/toolchain, or `tailwindcss` not
240
+ * resolvable). In that case the caller falls back to Vite's default PostCSS
241
+ * auto-discovery — the dev server still starts. This mirrors the rest of
242
+ * this module: a CSS-pipeline hiccup must never take the dev server down.
243
+ */
244
+ async function loadDevPostcssPlugins({
245
+ frontendDir,
246
+ pluginCwd,
247
+ pluginEntry,
248
+ }: {
249
+ frontendDir: string;
250
+ pluginCwd: string;
251
+ pluginEntry: string;
252
+ }): Promise<PostcssPlugin[] | undefined> {
253
+ try {
254
+ // `tailwindcss` + `autoprefixer` ship as `@checkstack/frontend`'s
255
+ // RUNTIME deps, so they are reachable from the frontend root in any
256
+ // layout (monorepo or published install).
257
+ const frontendRequire = createRequire(
258
+ path.join(frontendDir, "package.json"),
259
+ );
260
+ const tailwindModule = await import(
261
+ pathToFileURL(frontendRequire.resolve("tailwindcss")).href
262
+ );
263
+ const autoprefixerModule = await import(
264
+ pathToFileURL(frontendRequire.resolve("autoprefixer")).href
265
+ );
266
+ // Cast: dynamic `import()` of CJS/ESM packages is typed `any` for the
267
+ // namespace, so `.default` is untyped. `tailwindcss` and `autoprefixer`
268
+ // are the canonical PostCSS-plugin factories; we assert the call
269
+ // signatures we actually use. A wrong-shaped module is caught by the
270
+ // surrounding try/catch (falls back to Vite's default PostCSS).
271
+ const tailwindcss = tailwindModule.default as (
272
+ config: TailwindPreset & { content: string[] },
273
+ ) => PostcssPlugin;
274
+ const autoprefixer = autoprefixerModule.default as () => PostcssPlugin;
275
+
276
+ // The shared theme + tailwindcss-animate, exported as a public subpath
277
+ // so we don't reach into frontend internals. It carries no `content`.
278
+ const presetPath = frontendRequire.resolve(
279
+ "@checkstack/frontend/tailwind-preset",
280
+ );
281
+ const presetModule = await import(pathToFileURL(presetPath).href);
282
+ // Cast: the preset is a JS module typed `any`; we spread it into the
283
+ // tailwind config and only add `content`, so the loose shape suffices.
284
+ const preset = (presetModule.default ?? presetModule) as TailwindPreset;
285
+
286
+ // Best-effort: include the shared UI library so its source classes (the
287
+ // dev shell renders `@checkstack/ui` components) compile too. Its
288
+ // resolved package dir is `<pkg>/src`.
289
+ const extraSourceDirs: string[] = [];
290
+ const uiPkgJson = resolveFromCandidates({
291
+ request: "@checkstack/ui/package.json",
292
+ candidateBasePaths: [frontendDir, pluginCwd, DEV_SERVER_MODULE_DIR],
293
+ });
294
+ if (uiPkgJson) {
295
+ extraSourceDirs.push(path.join(path.dirname(uiPkgJson), "src"));
296
+ }
297
+
298
+ const content = buildDevTailwindContent({
299
+ frontendDir,
300
+ pluginCwd,
301
+ pluginEntry,
302
+ extraSourceDirs,
303
+ });
304
+
305
+ return [tailwindcss({ ...preset, content }), autoprefixer()];
306
+ } catch {
307
+ // Any failure → fall back to Vite's default PostCSS discovery.
308
+ return undefined;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Find the `package.json` of a bundle sibling that lives as a FILESYSTEM
314
+ * sibling of the plugin (not a node_modules entry).
315
+ *
316
+ * A standalone scaffold lays the trio out as `packages/<base>-common`,
317
+ * `packages/<base>-backend`, `packages/<base>-frontend`. The `-frontend`
318
+ * package is referenced only via `checkstack.bundle` — nothing `depends`
319
+ * on it as an npm package — so bun never links it into any
320
+ * `node_modules`, and `require.resolve("@scope/<base>-frontend")` cannot
321
+ * find it from anywhere. We instead scan the directories next to the
322
+ * plugin's own directory for one whose `package.json#name` matches.
323
+ *
324
+ * Pure: every FS touch is injected so the unit suite can drive it without
325
+ * disk.
326
+ */
327
+ export function findSiblingPackageDir({
328
+ pluginCwd,
329
+ siblingName,
330
+ listDir = (dir) =>
331
+ fs
332
+ .readdirSync(dir, { withFileTypes: true })
333
+ .filter((e) => e.isDirectory())
334
+ .map((e) => e.name),
335
+ readFile = (p) => fs.readFileSync(p, "utf8"),
336
+ }: {
337
+ pluginCwd: string;
338
+ siblingName: string;
339
+ /** List the immediate subdirectory names of a directory. */
340
+ listDir?: (dir: string) => string[];
341
+ readFile?: (p: string) => string;
342
+ }): string | undefined {
343
+ const parent = path.dirname(pluginCwd);
344
+ let entries: string[];
345
+ try {
346
+ entries = listDir(parent);
347
+ } catch {
348
+ return undefined;
349
+ }
350
+ for (const name of entries) {
351
+ const candidate = path.join(parent, name, "package.json");
352
+ try {
353
+ // Cast: `JSON.parse` is typed `any`; we only compare the optional
354
+ // `name` string. A malformed/unexpected shape simply won't match.
355
+ const pkg = JSON.parse(readFile(candidate)) as { name?: string };
356
+ if (pkg.name === siblingName) return candidate;
357
+ } catch {
358
+ // not a package dir, unreadable, or malformed — skip
359
+ }
360
+ }
361
+ return undefined;
362
+ }
363
+
137
364
  /**
138
365
  * Resolve the entry file Vite should load for the plugin's frontend.
139
366
  *
140
367
  * - For a `-frontend` plugin: the cwd's own `main` field.
141
368
  * - For a bundle primary (e.g. `-backend` with `checkstack.bundle`
142
- * listing a `-frontend` sibling): the sibling's `main`, resolved
143
- * through node_modules.
369
+ * listing a `-frontend` sibling): the sibling's `main`. Resolved
370
+ * through node_modules when the sibling is an installed package, or —
371
+ * for a standalone workspace where the sibling is only a filesystem
372
+ * neighbour — by scanning sibling directories (see
373
+ * {@link findSiblingPackageDir}).
144
374
  *
145
375
  * Pure: all FS lookups go through injected hooks so the test suite can
146
376
  * drive every branch without touching disk.
@@ -149,6 +379,7 @@ export function pickFrontendEntry({
149
379
  pluginCwd,
150
380
  pluginPkg,
151
381
  resolveFrom,
382
+ findSiblingDir,
152
383
  readFile = (p) => fs.readFileSync(p, "utf8"),
153
384
  }: {
154
385
  pluginCwd: string;
@@ -162,6 +393,12 @@ export function pickFrontendEntry({
162
393
  * runtime.
163
394
  */
164
395
  resolveFrom?: (request: string) => string | undefined;
396
+ /**
397
+ * Optional injection: locate a bundle sibling that lives as a filesystem
398
+ * neighbour rather than a node_modules entry. Defaults to
399
+ * {@link findSiblingPackageDir} rooted at the plugin's directory.
400
+ */
401
+ findSiblingDir?: (siblingName: string) => string | undefined;
165
402
  readFile?: (p: string) => string;
166
403
  }): string | undefined {
167
404
  if (pluginPkg.checkstack?.type === "frontend") {
@@ -179,12 +416,22 @@ export function pickFrontendEntry({
179
416
  return undefined;
180
417
  }
181
418
  });
419
+ const siblingDirFinder =
420
+ findSiblingDir ??
421
+ ((siblingName: string): string | undefined =>
422
+ findSiblingPackageDir({ pluginCwd, siblingName, readFile }));
182
423
  const siblings = pluginPkg.checkstack?.bundle ?? [];
183
424
  for (const sibling of siblings) {
184
425
  if (!sibling.endsWith("-frontend")) continue;
185
- const pkgJsonPath = resolver(`${sibling}/package.json`);
426
+ // node_modules resolution first (monorepo / installed sibling), then a
427
+ // filesystem-neighbour scan (standalone workspace whose -frontend is a
428
+ // sibling package, not a dependency).
429
+ const pkgJsonPath =
430
+ resolver(`${sibling}/package.json`) ?? siblingDirFinder(sibling);
186
431
  if (!pkgJsonPath) continue;
187
432
  try {
433
+ // Cast: `JSON.parse` is typed `any`; we read only the optional
434
+ // `main` field and default it when absent.
188
435
  const pkg = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
189
436
  return path.resolve(
190
437
  path.dirname(pkgJsonPath),
package/src/dev-server.ts CHANGED
@@ -165,7 +165,7 @@ export async function runDevServer(rawArgs: string[]): Promise<number> {
165
165
  }
166
166
 
167
167
  function printHelp(): void {
168
- console.log(String.raw`Usage: checkstack-scripts dev [options]
168
+ console.log(String.raw`Usage: checkstack-dev [options]
169
169
 
170
170
  Spins up a local Checkstack dev server with the current directory's
171
171
  plugin loaded. Same boot code as production — the only differences are:
@@ -173,6 +173,10 @@ plugin loaded. Same boot code as production — the only differences are:
173
173
  * a synthetic dev auth grants every access rule (no login)
174
174
  * the backend restarts on changes under ./src
175
175
 
176
+ This binary ships in the @checkstack/dev-server package. Add it as a
177
+ devDependency and wire "dev": "checkstack-dev" in your package.json, or
178
+ run it directly via: bunx @checkstack/dev-server
179
+
176
180
  Options:
177
181
  --cwd <dir> Plugin directory (default: process.cwd())
178
182
  --port <num> Backend HTTP port (default: 3000 or $PORT)