create-stitchkit 0.1.1 → 0.3.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/CHANGELOG.md +83 -0
- package/README.md +11 -4
- package/dist/cli.js +93 -15
- package/examples/repository/_env.example.append +4 -0
- package/examples/repository/e2e/repository.spec.ts +69 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +16 -0
- package/examples/repository/packages/config/src/features.ts +7 -0
- package/examples/repository/packages/db/schema.prisma +31 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
- package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +5 -1
- package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
- package/examples/repository/packages/shared/src/index.ts +5 -0
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +169 -0
- package/package.json +10 -1
- package/template/AGENTS.md +50 -0
- package/template/README.md +17 -8
- package/template/_env.example +1 -4
- package/template/app.config.json +9 -0
- package/template/bun.lock +6 -4
- package/template/docs/ADDING_A_FEATURE.md +102 -0
- package/template/e2e/starter.spec.ts +34 -22
- package/template/ecosystem.config.cjs +3 -2
- package/template/ecosystem.dev.config.cjs +7 -4
- package/template/package.json +6 -2
- package/template/packages/backend/src/cli.ts +2 -1
- package/template/packages/backend/src/index.ts +4 -3
- package/template/packages/backend/src/surface-manifest.test.ts +237 -0
- package/template/packages/backend/src/surface-manifest.ts +220 -0
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +4 -9
- package/template/packages/backend/src/tools.ts +4 -1
- package/template/packages/backend/src/transport/errors.ts +1 -0
- package/template/packages/backend/src/transport/system-service.ts +8 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/features.ts +1 -0
- package/template/packages/config/src/identity.ts +18 -0
- package/template/packages/config/src/server.ts +10 -3
- package/template/packages/db/schema.prisma +0 -23
- package/template/packages/frontend/messages/en.json +0 -1
- package/template/packages/frontend/messages/ru.json +0 -1
- package/template/packages/frontend/package.json +1 -1
- package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
- package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
- package/template/packages/frontend/src/lib/query-client.ts +10 -2
- package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
- package/template/packages/frontend/src/providers/index.tsx +1 -4
- package/template/packages/frontend/src/theme/config.ts +2 -1
- package/template/packages/frontend/tsconfig.json +7 -1
- package/template/packages/shared/package.json +0 -1
- package/template/packages/shared/src/contracts/system.ts +19 -0
- package/template/packages/shared/src/index.ts +2 -3
- package/template/packages/shared/src/schemas/system.ts +4 -0
- package/template/playwright.config.ts +2 -1
- package/template/scripts/check-authored.ts +20 -6
- package/template/scripts/dev.ts +46 -7
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +22 -61
- package/template/scripts/surface-conformance.ts +166 -0
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/tsconfig.json +3 -1
- package/template/_env +0 -11
- package/template/packages/shared/src/events/repository.ts +0 -9
- /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
- /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
- /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
- /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,89 @@ is declared in the template root catalog.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] — 2026-08-10
|
|
10
|
+
|
|
11
|
+
### ⚠️ Breaking changes
|
|
12
|
+
|
|
13
|
+
- **Generated projects ship no `.env`.** The single environment source is
|
|
14
|
+
`.env.example`; `bun run env:ensure` (and every tooling entry point,
|
|
15
|
+
self-healing before validation) renders `.env` with the application-derived
|
|
16
|
+
database name. A clone and a post-generation rename now produce the same
|
|
17
|
+
database, and the second-developer path (`runtime:smoke`, `e2e`) works
|
|
18
|
+
unaided.
|
|
19
|
+
`// before: scaffold writes .env with a neutral database name` →
|
|
20
|
+
`// after: .env is rendered on first run from .env.example`
|
|
21
|
+
|
|
22
|
+
- **Starter-owned LAN HTTPS is removed.** Generated applications no longer ship
|
|
23
|
+
`dev:lan`, certificate generation, onboarding routes or `DEV_HTTPS_*` settings.
|
|
24
|
+
Applications that need a trusted device-testing origin should own certificate
|
|
25
|
+
creation and pass TLS files through Stitchkit's documented `bun.tls` boundary.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Surface conformance is anchored and total.** The declared surface is
|
|
30
|
+
compared against a committed `surface.snapshot.json` (with schema-shape
|
|
31
|
+
digests, regenerated deliberately via `bun run surface:snapshot`), the CLI is
|
|
32
|
+
observed by spawning the real process, missing `x-stitchkit-*` metadata is an
|
|
33
|
+
error unless a standard-document mode is declared explicitly, and the starter
|
|
34
|
+
lane sweeps generated trees for neutral-identity leaks against a committed
|
|
35
|
+
allowlist.
|
|
36
|
+
- **The template targets Stitchkit 0.46.** The error-code map covers
|
|
37
|
+
`REALTIME_CONTRACT_VIOLATION` and conformance validates the operation
|
|
38
|
+
metadata 0.46 emits.
|
|
39
|
+
- **Scaffold identity is rendered from one config.** The generated
|
|
40
|
+
`app.config.json` drives runtime identity and database naming; only the root
|
|
41
|
+
package manifest is structurally projected, with no global text search or
|
|
42
|
+
inert lockfile rewrite.
|
|
43
|
+
- **Development is explicit and portable.** `bun run dev` validates PM2 and
|
|
44
|
+
external PostgreSQL requirements before side effects. The removed LAN HTTPS
|
|
45
|
+
mode is documented as application-owned framework configuration instead of a
|
|
46
|
+
starter subsystem.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Fresh clones bootstrap in the right order.** The starter materializes its
|
|
51
|
+
local environment before Prisma/type gates and reports a missing or placeholder
|
|
52
|
+
`DATABASE_URL` directly.
|
|
53
|
+
- **Every executable source is checked.** Root TypeScript coverage includes
|
|
54
|
+
scripts and browser E2E; the authored-source guard scans CJS and reports the
|
|
55
|
+
real offending line.
|
|
56
|
+
- **Runtime and browser gates prove the backend.** Surface conformance compares
|
|
57
|
+
HTTP/OpenAPI, MCP, Agent and CLI identities and schemas; browser E2E performs
|
|
58
|
+
a contract call and realtime cache update.
|
|
59
|
+
- **Repository example uses the canonical realtime contract.** Shared Zod event
|
|
60
|
+
definitions drive backend emission, browser subscriptions and cache bridging
|
|
61
|
+
without handwritten event maps.
|
|
62
|
+
|
|
63
|
+
### Removed
|
|
64
|
+
|
|
65
|
+
- **Starter-owned LAN HTTPS.** `dev:lan`, certificate generation, onboarding
|
|
66
|
+
transport and `DEV_HTTPS_*` settings are gone; trusted local TLS remains an
|
|
67
|
+
application-owned adapter choice documented by Stitchkit.
|
|
68
|
+
|
|
69
|
+
## [0.2.0] — 2026-08-10
|
|
70
|
+
|
|
71
|
+
### ⚠️ Breaking changes
|
|
72
|
+
|
|
73
|
+
- **The default scaffold is now domain-free.** The repository example is explicit
|
|
74
|
+
so a new product does not need to dismantle demo code.
|
|
75
|
+
`bun create stitchkit my-app` now creates the blank base; use
|
|
76
|
+
`bun create stitchkit my-app --example repository` for the previous runnable example.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **One generated application identity.** A validated `app.config.json` now drives
|
|
81
|
+
package, PM2, MCP, OpenAPI, CLI, SEO and theme identity from one edit point.
|
|
82
|
+
- **Contract-derived surface conformance.** Generated runtime smoke compares the
|
|
83
|
+
registered HTTP and tool manifest with live OpenAPI and MCP discovery, while
|
|
84
|
+
explicit typed probes remain application-owned.
|
|
85
|
+
- **Optional trusted LAN HTTPS.** `bun run dev:lan` uses mkcert, exact-origin CORS
|
|
86
|
+
and a development-only onboarding route for secure-context testing on physical
|
|
87
|
+
devices without changing ordinary development or production behavior.
|
|
88
|
+
- **Agent-first extension guide.** Generated applications include an application-
|
|
89
|
+
scoped `AGENTS.md` and a complete schema-to-runtime feature workflow in
|
|
90
|
+
`docs/ADDING_A_FEATURE.md`.
|
|
91
|
+
|
|
9
92
|
## [0.1.1] — 2026-08-10
|
|
10
93
|
|
|
11
94
|
### Changed
|
package/README.md
CHANGED
|
@@ -14,11 +14,18 @@ API, Prisma/PostgreSQL, typed shared contracts, Socket.IO, MCP, CLI tools and a
|
|
|
14
14
|
complete production UI system.
|
|
15
15
|
|
|
16
16
|
It uses one conventional `packages/*` namespace: `backend`, `frontend`,
|
|
17
|
-
`config`, `db` and `shared`. The
|
|
18
|
-
|
|
17
|
+
`config`, `db` and `shared`. The destination name becomes the generated slug;
|
|
18
|
+
`--display-name` sets the human title. Both are recorded once in
|
|
19
|
+
`app.config.json` and drive package, process, transport, UI and SEO identity.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
The default scaffold is domain-free. To add the runnable repository example:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun create stitchkit my-app --example repository
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The optional example uses one configurable GitHub repository persisted through a
|
|
28
|
+
server-side PostgreSQL cache, including a Prisma-backed
|
|
22
29
|
visibility enum that flows through the shared schema and typed client. `/en/ui`
|
|
23
30
|
presents every reusable primitive and composition. Every public page ships with
|
|
24
31
|
localized metadata, canonical and language alternatives, sitemap coverage and a
|
package/dist/cli.js
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
|
-
import { basename as
|
|
5
|
+
import { basename as basename3, resolve as resolve2 } from "path";
|
|
6
6
|
var {spawn } = globalThis.Bun;
|
|
7
7
|
|
|
8
8
|
// src/options.ts
|
|
9
9
|
var HELP = `Create a production-shaped Stitchkit application.
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
|
-
bun create stitchkit <directory> [--no-install]
|
|
12
|
+
bun create stitchkit <directory> [--display-name "Product Name"] [--example repository] [--no-install]
|
|
13
13
|
|
|
14
14
|
Options:
|
|
15
15
|
--no-install Generate files without installing dependencies
|
|
16
|
+
--example Add an isolated runnable example (supported: repository)
|
|
17
|
+
--display-name Set the initial public application name
|
|
16
18
|
--help Show this help
|
|
17
19
|
`;
|
|
18
20
|
function helpText() {
|
|
@@ -21,11 +23,26 @@ function helpText() {
|
|
|
21
23
|
function parseOptions(args) {
|
|
22
24
|
if (args.includes("--help") || args.includes("-h"))
|
|
23
25
|
return "help";
|
|
24
|
-
const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install");
|
|
26
|
+
const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install" && arg !== "--example" && arg !== "--display-name");
|
|
25
27
|
if (unknown.length > 0) {
|
|
26
28
|
throw new Error(`Unknown option: ${unknown[0]}`);
|
|
27
29
|
}
|
|
28
|
-
const
|
|
30
|
+
const exampleFlagIndex = args.indexOf("--example");
|
|
31
|
+
const example = exampleFlagIndex === -1 ? undefined : args[exampleFlagIndex + 1];
|
|
32
|
+
if (exampleFlagIndex !== -1 && example === undefined) {
|
|
33
|
+
throw new Error("--example requires a value");
|
|
34
|
+
}
|
|
35
|
+
if (example !== undefined && example !== "repository") {
|
|
36
|
+
throw new Error(`Unknown example: ${example}`);
|
|
37
|
+
}
|
|
38
|
+
const displayNameFlagIndex = args.indexOf("--display-name");
|
|
39
|
+
const displayName = displayNameFlagIndex === -1 ? undefined : args[displayNameFlagIndex + 1];
|
|
40
|
+
if (displayNameFlagIndex !== -1 && displayName === undefined) {
|
|
41
|
+
throw new Error("--display-name requires a value");
|
|
42
|
+
}
|
|
43
|
+
const consumedExampleIndex = exampleFlagIndex === -1 ? -1 : exampleFlagIndex + 1;
|
|
44
|
+
const consumedDisplayNameIndex = displayNameFlagIndex === -1 ? -1 : displayNameFlagIndex + 1;
|
|
45
|
+
const positionals = args.filter((arg, index) => !arg.startsWith("-") && index !== consumedExampleIndex && index !== consumedDisplayNameIndex);
|
|
29
46
|
if (positionals.length !== 1) {
|
|
30
47
|
throw new Error("Exactly one destination directory is required");
|
|
31
48
|
}
|
|
@@ -34,14 +51,49 @@ function parseOptions(args) {
|
|
|
34
51
|
throw new Error("Destination directory is required");
|
|
35
52
|
return {
|
|
36
53
|
destination,
|
|
37
|
-
install: !args.includes("--no-install")
|
|
54
|
+
install: !args.includes("--no-install"),
|
|
55
|
+
...example && { example },
|
|
56
|
+
...displayName && { displayName }
|
|
38
57
|
};
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
// src/scaffold.ts
|
|
42
61
|
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
43
62
|
import { homedir } from "os";
|
|
44
|
-
import { basename, dirname, join, parse, relative, resolve, sep } from "path";
|
|
63
|
+
import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
|
|
64
|
+
import { z as z2 } from "zod";
|
|
65
|
+
|
|
66
|
+
// src/identity.ts
|
|
67
|
+
import { basename } from "path";
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
var ApplicationSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
|
|
70
|
+
var ApplicationIdentitySchema = z.object({
|
|
71
|
+
slug: ApplicationSlugSchema,
|
|
72
|
+
name: z.string().trim().min(1).max(80),
|
|
73
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
|
|
74
|
+
description: z.object({
|
|
75
|
+
en: z.string().trim().min(1),
|
|
76
|
+
ru: z.string().trim().min(1)
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
function displayNameFromSlug(slug) {
|
|
80
|
+
return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
|
|
81
|
+
}
|
|
82
|
+
function createApplicationIdentity(destination, displayName) {
|
|
83
|
+
const slug = ApplicationSlugSchema.parse(basename(destination));
|
|
84
|
+
const name = displayName?.trim() || displayNameFromSlug(slug);
|
|
85
|
+
return ApplicationIdentitySchema.parse({
|
|
86
|
+
slug,
|
|
87
|
+
name,
|
|
88
|
+
version: "0.1.0",
|
|
89
|
+
description: {
|
|
90
|
+
en: `${name} is a production application built with Stitchkit.`,
|
|
91
|
+
ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/scaffold.ts
|
|
45
97
|
var TEXT_EXTENSIONS = new Set([
|
|
46
98
|
".cjs",
|
|
47
99
|
".css",
|
|
@@ -61,10 +113,11 @@ var TEXT_EXTENSIONS = new Set([
|
|
|
61
113
|
".yml"
|
|
62
114
|
]);
|
|
63
115
|
var TEMPLATE_RENAMES = new Map([
|
|
64
|
-
["_env", ".env"],
|
|
65
116
|
["_env.example", ".env.example"],
|
|
117
|
+
["_env.example.append", ".env.example"],
|
|
66
118
|
["_gitignore", ".gitignore"]
|
|
67
119
|
]);
|
|
120
|
+
var RootManifestSchema = z2.looseObject({ name: z2.string().min(1) });
|
|
68
121
|
var IGNORED_DIRECTORIES = new Set([
|
|
69
122
|
".next",
|
|
70
123
|
"coverage",
|
|
@@ -82,7 +135,7 @@ function isTemplateSourcePathIncluded(sourcePath) {
|
|
|
82
135
|
return false;
|
|
83
136
|
if (normalized === "packages/db/src/generated" || normalized.startsWith("packages/db/src/generated/"))
|
|
84
137
|
return false;
|
|
85
|
-
const name =
|
|
138
|
+
const name = basename2(normalized);
|
|
86
139
|
return name !== ".env" && name !== "next-env.d.ts" && !name.endsWith(".log") && !name.endsWith(".tsbuildinfo");
|
|
87
140
|
}
|
|
88
141
|
function shouldIncludeTemplatePath(templateDirectory, sourcePath) {
|
|
@@ -136,13 +189,14 @@ async function collectMaterialisedFiles(templateDirectory, directory, files) {
|
|
|
136
189
|
const targetName = TEMPLATE_RENAMES.get(entry.name);
|
|
137
190
|
const sourceRelativePath = relative(templateDirectory, sourcePath);
|
|
138
191
|
const outputRelativePath = targetName ? join(dirname(sourceRelativePath), targetName) : sourceRelativePath;
|
|
139
|
-
const
|
|
140
|
-
const fileExtension =
|
|
192
|
+
const materialisedName = outputRelativePath.endsWith(".append") ? outputRelativePath.slice(0, -".append".length) : outputRelativePath;
|
|
193
|
+
const fileExtension = extname(materialisedName);
|
|
141
194
|
const content = TEXT_EXTENSIONS.has(fileExtension) || targetName ? await readFile(sourcePath, "utf8") : await readFile(sourcePath);
|
|
142
195
|
files.push({
|
|
143
196
|
sourcePath: portablePath(sourceRelativePath),
|
|
144
197
|
outputPath: portablePath(outputRelativePath),
|
|
145
|
-
content
|
|
198
|
+
content,
|
|
199
|
+
append: entry.name.endsWith(".append")
|
|
146
200
|
});
|
|
147
201
|
}
|
|
148
202
|
}
|
|
@@ -155,15 +209,34 @@ async function writeMaterialisedFiles(destination, files) {
|
|
|
155
209
|
for (const file of files) {
|
|
156
210
|
const targetPath = join(destination, file.outputPath);
|
|
157
211
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
158
|
-
|
|
212
|
+
if (file.append) {
|
|
213
|
+
if (typeof file.content !== "string") {
|
|
214
|
+
throw new Error(`Append template entries must contain text: ${file.sourcePath}`);
|
|
215
|
+
}
|
|
216
|
+
const existing = await readFile(targetPath, "utf8");
|
|
217
|
+
await writeFile(targetPath, `${existing.trimEnd()}
|
|
218
|
+
${file.content.trimStart()}`);
|
|
219
|
+
} else {
|
|
220
|
+
await writeFile(targetPath, file.content);
|
|
221
|
+
}
|
|
159
222
|
}
|
|
160
223
|
}
|
|
161
|
-
async function scaffoldProject(templateDirectory, destination) {
|
|
224
|
+
async function scaffoldProject(templateDirectory, destination, options = {}) {
|
|
162
225
|
const resolvedDestination = resolve(destination);
|
|
226
|
+
const identity = createApplicationIdentity(resolvedDestination, options.displayName);
|
|
163
227
|
const destinationExisted = await assertDestinationAvailable(resolvedDestination);
|
|
164
228
|
await mkdir(resolvedDestination, { recursive: true });
|
|
165
229
|
try {
|
|
166
230
|
await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(templateDirectory));
|
|
231
|
+
if (options.overlayDirectory) {
|
|
232
|
+
await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
|
|
233
|
+
}
|
|
234
|
+
await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
|
|
235
|
+
`);
|
|
236
|
+
const manifestPath = join(resolvedDestination, "package.json");
|
|
237
|
+
const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
|
|
238
|
+
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
|
|
239
|
+
`);
|
|
167
240
|
} catch (error) {
|
|
168
241
|
if (!destinationExisted) {
|
|
169
242
|
await rm(resolvedDestination, { recursive: true, force: true });
|
|
@@ -185,7 +258,11 @@ async function run(args) {
|
|
|
185
258
|
}
|
|
186
259
|
const destination = resolve2(options.destination);
|
|
187
260
|
const templateDirectory = resolve2(import.meta.dir, "../template");
|
|
188
|
-
|
|
261
|
+
const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
|
|
262
|
+
await scaffoldProject(templateDirectory, destination, {
|
|
263
|
+
...overlayDirectory && { overlayDirectory },
|
|
264
|
+
...options.displayName && { displayName: options.displayName }
|
|
265
|
+
});
|
|
189
266
|
if (options.install) {
|
|
190
267
|
const install = spawn(["bun", "install"], {
|
|
191
268
|
cwd: destination,
|
|
@@ -197,8 +274,9 @@ async function run(args) {
|
|
|
197
274
|
if (exitCode !== 0)
|
|
198
275
|
throw new Error(`bun install failed with exit code ${exitCode}`);
|
|
199
276
|
}
|
|
277
|
+
const mode = options.example ? ` with the ${options.example} example` : "";
|
|
200
278
|
process.stdout.write(`
|
|
201
|
-
Created
|
|
279
|
+
Created ${options.displayName ?? basename3(destination)}${mode}
|
|
202
280
|
|
|
203
281
|
`);
|
|
204
282
|
process.stdout.write(` cd ${options.destination}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
test('prefetched data hydrates without a loading flash or a client refetch', async ({
|
|
4
|
+
page,
|
|
5
|
+
request,
|
|
6
|
+
}) => {
|
|
7
|
+
// The SSR document itself carries the prefetched repository data — the
|
|
8
|
+
// dehydration envelope is not empty.
|
|
9
|
+
const document = await request.get('/en');
|
|
10
|
+
expect(await document.text()).toContain('max-listov/stitchkit');
|
|
11
|
+
|
|
12
|
+
// Block the browser-side read entirely: the page must still render the data
|
|
13
|
+
// from the hydration envelope — never refetching, never flashing a loader.
|
|
14
|
+
let clientReads = 0;
|
|
15
|
+
await page.route('**/api/repository', async (route) => {
|
|
16
|
+
if (route.request().method() === 'GET') {
|
|
17
|
+
clientReads += 1;
|
|
18
|
+
await route.abort();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await route.continue();
|
|
22
|
+
});
|
|
23
|
+
await page.goto('/en');
|
|
24
|
+
await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
|
|
25
|
+
expect(clientReads).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a server realtime event updates the TanStack cache without a client refetch', async ({
|
|
29
|
+
page,
|
|
30
|
+
}) => {
|
|
31
|
+
await page.goto('/en');
|
|
32
|
+
const summary = page.getByTestId('repository-summary');
|
|
33
|
+
await expect(summary).toBeVisible();
|
|
34
|
+
const before = await summary.getAttribute('data-fetched-at');
|
|
35
|
+
expect(before).not.toBeNull();
|
|
36
|
+
|
|
37
|
+
// Sever the refetch path completely: the refresh mutation invalidates the
|
|
38
|
+
// query, but its refetch is aborted here — so the ONLY way the summary can
|
|
39
|
+
// carry a new snapshot is the Socket.IO event through the cache bridge.
|
|
40
|
+
await page.route('**/api/repository', async (route) => {
|
|
41
|
+
if (route.request().method() === 'GET') {
|
|
42
|
+
await route.abort();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await route.continue();
|
|
46
|
+
});
|
|
47
|
+
await page.getByRole('button', { name: 'Refresh repository data' }).click();
|
|
48
|
+
await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
|
|
49
|
+
timeout: 10_000,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('renders and refreshes the repository example', async ({ page }) => {
|
|
54
|
+
await page.route('**/api/repository/refresh', async (route) => {
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
56
|
+
await route.continue();
|
|
57
|
+
});
|
|
58
|
+
await page.goto('/en');
|
|
59
|
+
await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
|
|
60
|
+
|
|
61
|
+
const refresh = page.getByRole('button', { name: 'Refresh repository data' });
|
|
62
|
+
await expect(refresh).toHaveCSS('height', '32px');
|
|
63
|
+
await expect(refresh).toHaveCSS('width', '32px');
|
|
64
|
+
await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
|
|
65
|
+
await refresh.click();
|
|
66
|
+
await expect(refresh).toHaveAttribute('aria-busy', 'true');
|
|
67
|
+
await expect(refresh.locator('svg')).toHaveCount(1);
|
|
68
|
+
await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
|
|
69
|
+
});
|
package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts
RENAMED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { RepositoryVisibility } from '@app/db';
|
|
3
|
+
import { RepositoryVisibilitySchema } from '@app/shared';
|
|
2
4
|
import {
|
|
3
5
|
GitHubRepositoryCache,
|
|
4
6
|
type RepositorySnapshotStore,
|
|
@@ -39,6 +41,12 @@ function commitsResponse(): Response {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
describe('GitHubRepositoryCache', () => {
|
|
44
|
+
test('keeps the shared wire enum aligned with the database enum', () => {
|
|
45
|
+
expect([...RepositoryVisibilitySchema.options].sort()).toEqual(
|
|
46
|
+
Object.values(RepositoryVisibility).sort(),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
42
50
|
test('deduplicates refreshes and persists an exact repository snapshot', async () => {
|
|
43
51
|
const requests: URL[] = [];
|
|
44
52
|
const fetcher = async (url: URL) => {
|
package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
|
+
import { appIdentity } from '@app/config/identity';
|
|
2
3
|
import { RepositoryVisibility } from '@app/db';
|
|
3
4
|
import type { RepositorySnapshot } from '@app/shared';
|
|
4
5
|
import { z } from 'zod';
|
|
@@ -80,7 +81,7 @@ const snapshotStore: RepositorySnapshotStore = {
|
|
|
80
81
|
function githubHeaders(): Headers {
|
|
81
82
|
const headers = new Headers({
|
|
82
83
|
Accept: 'application/vnd.github+json',
|
|
83
|
-
'User-Agent':
|
|
84
|
+
'User-Agent': appIdentity.slug,
|
|
84
85
|
'X-GitHub-Api-Version': '2026-03-10',
|
|
85
86
|
});
|
|
86
87
|
if (env.GITHUB_TOKEN) headers.set('Authorization', `Bearer ${env.GITHUB_TOKEN}`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"service": "repository",
|
|
4
|
+
"action": "read",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "49c1f81a53ca82ad",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/repository"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {
|
|
17
|
+
"MCP": "repository_read",
|
|
18
|
+
"AGENT": "repository_read",
|
|
19
|
+
"CLI": "repository_read"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"service": "repository",
|
|
24
|
+
"action": "refresh",
|
|
25
|
+
"scope": "public",
|
|
26
|
+
"hasInput": false,
|
|
27
|
+
"hasOutput": true,
|
|
28
|
+
"inputShape": null,
|
|
29
|
+
"outputShape": "49c1f81a53ca82ad",
|
|
30
|
+
"http": [
|
|
31
|
+
{
|
|
32
|
+
"method": "POST",
|
|
33
|
+
"path": "/api/repository/refresh"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"tools": {
|
|
37
|
+
"MCP": "repository_refresh",
|
|
38
|
+
"AGENT": "repository_refresh",
|
|
39
|
+
"CLI": "repository_refresh"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"service": "system",
|
|
44
|
+
"action": "status",
|
|
45
|
+
"scope": "public",
|
|
46
|
+
"hasInput": false,
|
|
47
|
+
"hasOutput": true,
|
|
48
|
+
"inputShape": null,
|
|
49
|
+
"outputShape": "58b078ade3ee6167",
|
|
50
|
+
"http": [
|
|
51
|
+
{
|
|
52
|
+
"method": "GET",
|
|
53
|
+
"path": "/api/system/status"
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"tools": {}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { env } from '@app/config';
|
|
2
|
+
import { repositoryRealtimeContract } from '@app/shared';
|
|
3
|
+
import { bindRealtimeServer, createSocketIOServer } from 'stitchkit/server';
|
|
4
|
+
import { createRepositoryService } from './transport/repository-service';
|
|
5
|
+
import { createSystemService } from './transport/system-service';
|
|
6
|
+
|
|
7
|
+
export async function createSurface() {
|
|
8
|
+
const socket = await createSocketIOServer({
|
|
9
|
+
cors: { origin: env.CORS_ORIGIN },
|
|
10
|
+
});
|
|
11
|
+
const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
|
|
12
|
+
const repositoryService = createRepositoryService((snapshot) =>
|
|
13
|
+
realtime.emit('repository:refreshed', snapshot),
|
|
14
|
+
);
|
|
15
|
+
return { socket, services: [createSystemService(), repositoryService] };
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const featureServerSchema = {
|
|
4
|
+
GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
|
|
5
|
+
GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
|
6
|
+
GITHUB_TOKEN: z.string().min(1).optional(),
|
|
7
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client"
|
|
3
|
+
output = "./src/generated"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
datasource db {
|
|
7
|
+
provider = "postgresql"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
enum RepositoryVisibility {
|
|
11
|
+
PUBLIC
|
|
12
|
+
PRIVATE
|
|
13
|
+
INTERNAL
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
model RepositorySnapshot {
|
|
17
|
+
fullName String @id
|
|
18
|
+
description String?
|
|
19
|
+
htmlUrl String
|
|
20
|
+
language String?
|
|
21
|
+
visibility RepositoryVisibility @default(PUBLIC)
|
|
22
|
+
stars Int
|
|
23
|
+
forks Int
|
|
24
|
+
openIssues Int
|
|
25
|
+
commitCount Int
|
|
26
|
+
latestCommitSha String?
|
|
27
|
+
latestCommitMessage String?
|
|
28
|
+
latestCommittedAt DateTime?
|
|
29
|
+
fetchedAt DateTime
|
|
30
|
+
expiresAt DateTime
|
|
31
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { appIdentity } from '@app/config/identity';
|
|
2
|
+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
|
3
|
+
import type { Metadata } from 'next';
|
|
4
|
+
import { getTranslations } from 'next-intl/server';
|
|
5
|
+
import { LocaleSchema } from '@/i18n/locales';
|
|
6
|
+
import { createServerRepositoryApi } from '@/lib/api/client';
|
|
7
|
+
import { useRepository } from '@/lib/api/queries';
|
|
8
|
+
import { getQueryClient } from '@/lib/query-client';
|
|
9
|
+
import { createPageMetadata } from '@/lib/seo/metadata';
|
|
10
|
+
import { StarterPage } from './starter-page';
|
|
11
|
+
|
|
12
|
+
export const dynamic = 'force-dynamic';
|
|
13
|
+
|
|
14
|
+
export async function generateMetadata({
|
|
15
|
+
params,
|
|
16
|
+
}: {
|
|
17
|
+
params: Promise<{ locale: string }>;
|
|
18
|
+
}): Promise<Metadata> {
|
|
19
|
+
const { locale } = await params;
|
|
20
|
+
return createPageMetadata('home', LocaleSchema.parse(locale));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
|
24
|
+
const { locale } = await params;
|
|
25
|
+
const appLocale = LocaleSchema.parse(locale);
|
|
26
|
+
const queryClient = getQueryClient();
|
|
27
|
+
const api = createServerRepositoryApi();
|
|
28
|
+
const t = await getTranslations('App');
|
|
29
|
+
await queryClient.prefetchQuery({
|
|
30
|
+
queryKey: useRepository.getKey(),
|
|
31
|
+
queryFn: () => api.read(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<HydrationBoundary state={dehydrate(queryClient)}>
|
|
36
|
+
<StarterPage
|
|
37
|
+
applicationName={appIdentity.name}
|
|
38
|
+
applicationDescription={appIdentity.description[appLocale]}
|
|
39
|
+
heroTitle={t('heroTitle')}
|
|
40
|
+
catalogueLabel={t('ui')}
|
|
41
|
+
locale={appLocale}
|
|
42
|
+
/>
|
|
43
|
+
</HydrationBoundary>
|
|
44
|
+
);
|
|
45
|
+
}
|