create-stitchkit 0.3.3 → 0.4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +225 -0
  4. package/dist/cli.js +233 -41
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +25 -5
  23. package/package.json +9 -1
  24. package/template/AGENTS.md +12 -2
  25. package/template/README.md +43 -6
  26. package/template/_env.example +8 -4
  27. package/template/biome.json +5 -1
  28. package/template/bun.lock +2 -2
  29. package/template/e2e/starter.spec.ts +5 -7
  30. package/template/ecosystem.config.cjs +42 -19
  31. package/template/ecosystem.dev.config.cjs +41 -21
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/src/cli.ts +6 -2
  34. package/template/packages/backend/src/index.ts +21 -5
  35. package/template/packages/backend/src/surface.ts +6 -1
  36. package/template/packages/backend/src/transport/errors.ts +4 -2
  37. package/template/packages/config/package.json +3 -1
  38. package/template/packages/config/src/app-identity.generated.ts +20 -0
  39. package/template/packages/config/src/declaration.ts +30 -0
  40. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  41. package/template/packages/config/src/server.ts +8 -17
  42. package/template/packages/config/src/variables.ts +89 -0
  43. package/template/packages/frontend/next.config.ts +3 -2
  44. package/template/packages/frontend/package.json +2 -2
  45. package/template/packages/frontend/scripts/serve.ts +70 -0
  46. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  47. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  48. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  49. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  50. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  51. package/template/packages/frontend/src/app/robots.ts +4 -2
  52. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  53. package/template/packages/frontend/src/env.ts +27 -8
  54. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  55. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  56. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  57. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  58. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  59. package/template/packages/frontend/src/theme/config.ts +1 -1
  60. package/template/packages/frontend/tsconfig.json +10 -3
  61. package/template/playwright.config.ts +1 -1
  62. package/template/project.json +169 -0
  63. package/template/scripts/build-inputs.test.ts +69 -0
  64. package/template/scripts/build-inputs.ts +57 -0
  65. package/template/scripts/check-authored.ts +18 -2
  66. package/template/scripts/declaration.test.ts +206 -0
  67. package/template/scripts/declaration.ts +268 -0
  68. package/template/scripts/dev.ts +41 -20
  69. package/template/scripts/local-env.test.ts +2 -2
  70. package/template/scripts/local-env.ts +3 -3
  71. package/template/scripts/release-steps.test.ts +87 -0
  72. package/template/scripts/release-steps.ts +108 -0
  73. package/template/scripts/release.ts +30 -0
  74. package/template/scripts/runtime-smoke.ts +7 -4
  75. package/template/scripts/serve-mode.test.ts +36 -0
  76. package/template/scripts/supervision-signal.test.ts +94 -0
  77. package/template/scripts/tooling-env.ts +5 -2
  78. package/template/scripts/web-surface-smoke.ts +70 -0
  79. package/template/app.config.json +0 -9
  80. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,36 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The web role refuses a mode it does not understand.
6
+ *
7
+ * `argv[2] === 'development' ? 'dev' : 'start'` meant a typo, an empty string
8
+ * and a missing argument all became production in silence — the one decision
9
+ * that changes whether the role serves a build or compiles on demand. Run as a
10
+ * real process, because the failure has to happen before Next is spawned.
11
+ */
12
+ const serve = join(import.meta.dir, '../packages/frontend/scripts/serve.ts');
13
+
14
+ async function refusal(argv: string[]): Promise<{ code: number; stderr: string }> {
15
+ const child = Bun.spawn(['bun', serve, ...argv], {
16
+ cwd: join(import.meta.dir, '../packages/frontend'),
17
+ env: { ...Bun.env, WEB_PORT: '3210', BIND_HOST: '127.0.0.1' },
18
+ stdout: 'ignore',
19
+ stderr: 'pipe',
20
+ });
21
+ const [stderr, code] = await Promise.all([new Response(child.stderr).text(), child.exited]);
22
+ return { code, stderr };
23
+ }
24
+
25
+ test('a misspelled mode is refused, not treated as production', async () => {
26
+ const { code, stderr } = await refusal(['produciton']);
27
+ expect(code).not.toBe(0);
28
+ expect(stderr).toContain('Run mode must be "development" or "production"');
29
+ expect(stderr).toContain('produciton');
30
+ }, 30_000);
31
+
32
+ test('a missing mode is refused', async () => {
33
+ const { code, stderr } = await refusal([]);
34
+ expect(code).not.toBe(0);
35
+ expect(stderr).toContain('received nothing');
36
+ }, 30_000);
@@ -0,0 +1,94 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Why the supervision files run each role from its OWN directory.
8
+ *
9
+ * A supervisor that reaches into a workspace from the root — `bun run --filter
10
+ * @app/backend start` — puts a launcher process between itself and the role.
11
+ * `SIGTERM` sent to that launcher does not arrive at the role: the launcher
12
+ * dies and the role is torn down without ever running its shutdown. Every
13
+ * drain, every `kill_timeout`, every grace period is then decoration.
14
+ *
15
+ * This test spawns both shapes and compares what the role actually receives, so
16
+ * the choice in `scripts/declaration.ts` is pinned by behaviour rather than by
17
+ * a comment that a later refactor can talk itself out of.
18
+ */
19
+ async function roleReceivesSignal(from: 'role directory' | 'workspace root'): Promise<boolean> {
20
+ const root = await mkdtemp(join(tmpdir(), 'starter-signal-'));
21
+ try {
22
+ const rolePath = join(root, 'role');
23
+ await Bun.$`mkdir -p ${rolePath}`.quiet();
24
+ await writeFile(
25
+ join(rolePath, 'role.ts'),
26
+ [
27
+ "process.on('SIGTERM', () => { console.log('DRAINED'); process.exit(0); });",
28
+ "console.log('UP');",
29
+ 'setInterval(() => {}, 1000);',
30
+ ].join('\n'),
31
+ );
32
+ await writeFile(
33
+ join(rolePath, 'package.json'),
34
+ JSON.stringify({ name: '@t/role', scripts: { start: 'bun role.ts' } }),
35
+ );
36
+ await writeFile(
37
+ join(root, 'package.json'),
38
+ JSON.stringify({ name: 'root', private: true, workspaces: ['role'] }),
39
+ );
40
+
41
+ // `detached` makes the child a process-group LEADER, which is what lets the
42
+ // negative-pid signal below reach the role behind the launcher. The
43
+ // NEGATIVE case is the point: there the role never sees the signal, so
44
+ // killing the launcher alone would leave it running for the life of the
45
+ // machine — this test used to leak one role per run.
46
+ const child =
47
+ from === 'role directory'
48
+ ? Bun.spawn(['bun', 'run', 'start'], {
49
+ cwd: rolePath,
50
+ detached: true,
51
+ stdout: 'pipe',
52
+ stderr: 'ignore',
53
+ })
54
+ : Bun.spawn(['bun', 'run', '--filter', '@t/role', 'start'], {
55
+ cwd: root,
56
+ detached: true,
57
+ stdout: 'pipe',
58
+ stderr: 'ignore',
59
+ });
60
+
61
+ const output = new Response(child.stdout).text();
62
+ await Bun.sleep(1500);
63
+ // The role first, exactly as a supervisor would; then the whole group, so
64
+ // nothing that ignored the request outlives the test.
65
+ child.kill('SIGTERM');
66
+ await child.exited;
67
+ const printed = await output;
68
+ try {
69
+ process.kill(-child.pid, 'SIGKILL');
70
+ } catch {
71
+ // The group is already gone, which is the outcome this wants.
72
+ }
73
+ // Without this the negative case passes when the spawn simply failed —
74
+ // proving nothing about signal delivery, which is the only thing it exists
75
+ // to prove.
76
+ if (!printed.includes('UP')) {
77
+ throw new Error(`The role never started when launched from the ${from}: ${printed}`);
78
+ }
79
+ return printed.includes('DRAINED');
80
+ } finally {
81
+ await rm(root, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ test('a role started in its own directory receives the shutdown signal', async () => {
86
+ expect(await roleReceivesSignal('role directory')).toBe(true);
87
+ }, 20_000);
88
+
89
+ test('a role started through a workspace filter never receives it', async () => {
90
+ // The reason `cwd` is per role in the generated supervision files. If this
91
+ // ever starts passing, the generator may be simplified — until then it may
92
+ // not.
93
+ expect(await roleReceivesSignal('workspace root')).toBe(false);
94
+ }, 20_000);
@@ -5,9 +5,12 @@ import { z } from 'zod';
5
5
  import { ensureLocalEnvironment } from './local-env';
6
6
 
7
7
  const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
8
+ // Tooling addresses are LEGITIMATELY bound to a place — they name the deployment a
9
+ // check is dialling. They must not be named `NEXT_PUBLIC_*`, because that
10
+ // prefix makes Next substitute the value into the build output.
8
11
  const ToolingEnvSchema = z.object({
9
- NEXT_PUBLIC_API_URL: z.url(),
10
- NEXT_PUBLIC_WEB_URL: z.url(),
12
+ SMOKE_API_ORIGIN: z.url(),
13
+ SMOKE_WEB_ORIGIN: z.url(),
11
14
  PLAYWRIGHT_BASE_URL: z.url().optional(),
12
15
  });
13
16
 
@@ -26,3 +26,73 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
26
26
  throw new Error(`GET ${SITEMAP_PATH} omitted the localized theme-system URL`);
27
27
  }
28
28
  }
29
+
30
+ /**
31
+ * One artifact, many external addresses.
32
+ *
33
+ * The single check that catches a regression back into a build-time address: if
34
+ * anything is baked in again — a `NEXT_PUBLIC_` variable, a prerendered
35
+ * `sitemap.xml` — the two answers below stop differing, because the build would
36
+ * be answering with the address it was built with instead of the one it was
37
+ * asked on.
38
+ */
39
+ export async function assertArtifactIsPlacementFree(webOrigin: string): Promise<void> {
40
+ const addresses = [
41
+ { host: 'alpha.example', proto: 'https' },
42
+ { host: 'beta.example:8443', proto: 'http' },
43
+ ];
44
+
45
+ for (const { host, proto } of addresses) {
46
+ const expected = `${proto}://${host}`;
47
+ const headers = { 'x-forwarded-host': host, 'x-forwarded-proto': proto };
48
+
49
+ const sitemap = await fetch(publicUrl(webOrigin, SITEMAP_PATH), { headers });
50
+ if (sitemap.status !== 200) {
51
+ throw new Error(`GET ${SITEMAP_PATH} as ${expected} returned ${sitemap.status}`);
52
+ }
53
+ const body = await sitemap.text();
54
+ if (!body.includes(`${expected}/en`)) {
55
+ throw new Error(`GET ${SITEMAP_PATH} as ${expected} did not answer with that address`);
56
+ }
57
+ if (body.includes(new URL(webOrigin).host)) {
58
+ throw new Error(
59
+ `GET ${SITEMAP_PATH} as ${expected} leaked the address the build was made on — something is baked into the artifact`,
60
+ );
61
+ }
62
+
63
+ const robots = await fetch(publicUrl(webOrigin, '/robots.txt'), { headers });
64
+ if (!(await robots.text()).includes(`Sitemap: ${expected}/sitemap.xml`)) {
65
+ throw new Error(`GET /robots.txt as ${expected} did not answer with that address`);
66
+ }
67
+ }
68
+
69
+ // Portable is not the same as forgeable. A host the deployment never claimed
70
+ // must be refused, or the same mechanism that lets one artifact serve many
71
+ // addresses lets a caller choose the canonical URL, the sitemap and the OG
72
+ // metadata for everyone else.
73
+ const forged = await fetch(publicUrl(webOrigin, SITEMAP_PATH), {
74
+ headers: { 'x-forwarded-host': 'attacker.example', 'x-forwarded-proto': 'https' },
75
+ });
76
+ if (forged.status < 400) {
77
+ throw new Error(
78
+ `GET ${SITEMAP_PATH} answered for an unclaimed host with ${forged.status} — the forwarded host is trusted without a policy`,
79
+ );
80
+ }
81
+
82
+ // Same rule, one layer down. A forwarded protocol outside `http | https` is a
83
+ // misconfigured proxy or a forgery, and both have to be visible: mapping
84
+ // every unknown value to `http` produced a plausible canonical origin from
85
+ // `ftp`, `javascript` and a truncated header alike.
86
+ for (const proto of ['ftp', 'javascript', 'HTTPS://', '']) {
87
+ const answered = await fetch(publicUrl(webOrigin, SITEMAP_PATH), {
88
+ headers: { 'x-forwarded-host': 'alpha.example', 'x-forwarded-proto': proto },
89
+ });
90
+ // An empty header carries no claim at all and is treated as absent.
91
+ const acceptable = proto === '' ? answered.status === 200 : answered.status >= 400;
92
+ if (!acceptable) {
93
+ throw new Error(
94
+ `GET ${SITEMAP_PATH} with x-forwarded-proto "${proto}" returned ${answered.status} — an unknown protocol is normalised instead of refused`,
95
+ );
96
+ }
97
+ }
98
+ }
@@ -1,9 +0,0 @@
1
- {
2
- "slug": "stitchkit-starter",
3
- "name": "Stitchkit Starter",
4
- "version": "0.1.0",
5
- "description": {
6
- "en": "Stitchkit Starter is a production application built with Stitchkit.",
7
- "ru": "Stitchkit Starter — production-приложение на Stitchkit."
8
- }
9
- }
@@ -1,18 +0,0 @@
1
- import { z } from 'zod';
2
- import source from '../../../app.config.json' with { type: 'json' };
3
-
4
- export const ApplicationIdentitySchema = z.object({
5
- slug: z
6
- .string()
7
- .min(1)
8
- .max(64)
9
- .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
10
- name: z.string().trim().min(1).max(80),
11
- version: z.string().regex(/^\d+\.\d+\.\d+$/),
12
- description: z.object({
13
- en: z.string().trim().min(1),
14
- ru: z.string().trim().min(1),
15
- }),
16
- });
17
-
18
- export const appIdentity = ApplicationIdentitySchema.parse(source);