create-stitchkit 0.5.0 → 0.6.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/UPGRADING.md +43 -0
- package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
- package/examples/repository/packages/backend/src/surface.ts +49 -2
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
- package/examples/repository/packages/shared/src/index.ts +3 -0
- package/examples/repository/project.json +10 -0
- package/package.json +1 -1
- package/template/.data/board.sqlite +0 -0
- package/template/README.md +11 -0
- package/template/_env.example +3 -0
- package/template/bun.lock +2 -5
- package/template/docs/ADDING_A_FEATURE.md +35 -11
- package/template/package.json +3 -2
- package/template/packages/backend/src/index.ts +10 -2
- package/template/packages/backend/src/lib/board.ts +99 -0
- package/template/packages/backend/src/lib/live.ts +73 -0
- package/template/packages/backend/src/surface.snapshot.json +32 -0
- package/template/packages/backend/src/surface.ts +53 -3
- package/template/packages/backend/src/transport/board-service.ts +18 -0
- package/template/packages/config/src/variables.ts +16 -0
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
- package/template/packages/frontend/src/features/board/board-live.ts +91 -0
- package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
- package/template/packages/shared/src/contracts/board.ts +54 -0
- package/template/packages/shared/src/contracts/live.ts +20 -0
- package/template/packages/shared/src/index.ts +3 -0
- package/template/packages/shared/src/schemas/board.ts +41 -0
- package/template/project.json +10 -0
- package/template/scripts/check-authored.test.ts +43 -0
- package/template/scripts/check-authored.ts +51 -20
- package/template/scripts/dev.ts +6 -1
- package/template/scripts/guide-paths.test.ts +37 -0
- package/template/scripts/guide-paths.ts +66 -0
- package/template/scripts/local-env.test.ts +50 -1
- package/template/scripts/local-env.ts +53 -9
|
@@ -20,15 +20,42 @@ function lineAt(source: string, offset: number): number {
|
|
|
20
20
|
return source.slice(0, offset).split('\n').length;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* `as const` is not the assertion this gate exists for.
|
|
25
|
+
*
|
|
26
|
+
* The gate refuses a cast because a cast can LAUNDER a type — claim something the value has not
|
|
27
|
+
* been shown to be. A const-assertion cannot: it only narrows, it introduces no name, and it
|
|
28
|
+
* cannot widen. Refusing it made five of the first fifteen findings on a real adoption false, in
|
|
29
|
+
* a gate whose whole value is that its findings are worth acting on.
|
|
30
|
+
*
|
|
31
|
+
* `const` is a reserved word, so no type can be named `const` — the check has no false positive
|
|
32
|
+
* of its own.
|
|
33
|
+
*/
|
|
34
|
+
function isConstAssertion(node: { typeAnnotation?: unknown }): boolean {
|
|
35
|
+
const annotation = node.typeAnnotation;
|
|
36
|
+
if (!isRecord(annotation) || annotation.type !== 'TSTypeReference') return false;
|
|
37
|
+
const typeName = annotation.typeName;
|
|
38
|
+
return isRecord(typeName) && typeName.type === 'Identifier' && typeName.name === 'const';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === 'object' && value !== null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What to do instead — a finding that only names the sin costs the reader a search. */
|
|
46
|
+
const ASSERTION_REMEDY =
|
|
47
|
+
'type assertion — use `satisfies` to check a literal against a type, or parse the value with its schema at the boundary';
|
|
48
|
+
|
|
49
|
+
export function inspect(path: string, source: string): string[] {
|
|
24
50
|
const result = parseSync(path, source);
|
|
25
51
|
const failures = result.errors.map((error) => `${path}: parse error: ${error.message}`);
|
|
26
52
|
const visitor = new Visitor({
|
|
27
53
|
TSAsExpression(node) {
|
|
28
|
-
|
|
54
|
+
if (isConstAssertion(node)) return;
|
|
55
|
+
failures.push(`${path}:${lineAt(source, node.start)}: ${ASSERTION_REMEDY}`);
|
|
29
56
|
},
|
|
30
57
|
TSTypeAssertion(node) {
|
|
31
|
-
failures.push(`${path}:${lineAt(source, node.start)}:
|
|
58
|
+
failures.push(`${path}:${lineAt(source, node.start)}: ${ASSERTION_REMEDY}`);
|
|
32
59
|
},
|
|
33
60
|
TSAnyKeyword(node) {
|
|
34
61
|
failures.push(`${path}:${lineAt(source, node.start)}: explicit any`);
|
|
@@ -91,21 +118,25 @@ async function visitDirectory(directory: string): Promise<string[]> {
|
|
|
91
118
|
return failures;
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
// Guarded so the module can be imported by its own test without running the gate over the
|
|
122
|
+
// repository — importing a script that scans the tree and exits is not a testable unit.
|
|
123
|
+
if (import.meta.main) {
|
|
124
|
+
const failures = [
|
|
125
|
+
...(await Promise.all(roots.map(visitDirectory))).flat(),
|
|
126
|
+
...(
|
|
127
|
+
await Promise.all(
|
|
128
|
+
rootFiles.map(async (path) => inspect(path, await readFile(path, 'utf8'))),
|
|
129
|
+
)
|
|
130
|
+
).flat(),
|
|
131
|
+
];
|
|
132
|
+
const webPackage = await readFile('packages/frontend/package.json', 'utf8');
|
|
133
|
+
if (webPackage.includes(replacedThemePackage)) {
|
|
134
|
+
failures.push(
|
|
135
|
+
'packages/frontend/package.json: use @wrksz/themes as the single theme runtime',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (failures.length > 0) {
|
|
139
|
+
for (const failure of failures) console.error(failure);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
111
142
|
}
|
package/template/scripts/dev.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { appDeclaration } from '../packages/config/src/declaration';
|
|
4
|
-
import { ensureLocalEnvironment } from './local-env';
|
|
4
|
+
import { assertUsableEnvironment, ensureLocalEnvironment } from './local-env';
|
|
5
5
|
import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
|
|
6
6
|
import { runDeclaredReleaseSteps } from './release-steps';
|
|
7
7
|
import { inheritToolingEnvironment } from './tooling-env';
|
|
@@ -21,9 +21,14 @@ async function run(command: string[], environment?: Record<string, string>): Pro
|
|
|
21
21
|
|
|
22
22
|
export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
|
|
23
23
|
ensureLocalEnvironment(root);
|
|
24
|
+
// Before pm2, before anything: an unusable environment is the reader's problem to fix, and
|
|
25
|
+
// making them read a supervisor error first only delays the sentence that matters.
|
|
26
|
+
assertUsableEnvironment(root);
|
|
24
27
|
assertToolAvailable('pm2', 'Install PM2 with `bun add --global pm2`, then rerun.');
|
|
25
28
|
await run(['pm2', 'ping']);
|
|
26
29
|
const environmentForRun = await developmentEnvironment(environment);
|
|
30
|
+
// Kept for an environment supplied from the shell rather than from `.env`, which the file
|
|
31
|
+
// check above cannot see.
|
|
27
32
|
if (environmentForRun.DATABASE_URL?.includes('USER:PASSWORD')) {
|
|
28
33
|
throw new Error(
|
|
29
34
|
'DATABASE_URL still contains the starter placeholder. Create a PostgreSQL database, update DATABASE_URL in .env, then rerun `bun run dev`.',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { inspectGuide, inspectGuides } from './guide-paths';
|
|
3
|
+
|
|
4
|
+
const present = (path: string) => path === 'packages/shared/src/index.ts';
|
|
5
|
+
|
|
6
|
+
describe('guide paths', () => {
|
|
7
|
+
test('reports a path the guide names but the scaffold lacks', () => {
|
|
8
|
+
const findings = inspectGuide(
|
|
9
|
+
'docs/G.md',
|
|
10
|
+
'In `packages/frontend/src/lib/api/client.ts`, create the api.\n',
|
|
11
|
+
present,
|
|
12
|
+
);
|
|
13
|
+
expect(findings).toEqual([
|
|
14
|
+
{ guide: 'docs/G.md', line: 1, path: 'packages/frontend/src/lib/api/client.ts' },
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a path the guide creates is declared, not broken', () => {
|
|
19
|
+
const source = 'Create `packages/shared/src/realtime.ts` (created in this step) and…\n';
|
|
20
|
+
expect(inspectGuide('docs/G.md', source, present)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('an existing path is quiet, and prose without a path is quiet', () => {
|
|
24
|
+
expect(
|
|
25
|
+
inspectGuide('docs/G.md', 'Export it from `packages/shared/src/index.ts`.\n', present),
|
|
26
|
+
).toEqual([]);
|
|
27
|
+
expect(inspectGuide('docs/G.md', 'Pages compose features.\n', present)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the real guide resolves every path it does not create', async () => {
|
|
31
|
+
// The denominator matters: a guide the scanner cannot read would also return [].
|
|
32
|
+
const findings = await inspectGuides(`${import.meta.dir}/..`);
|
|
33
|
+
expect(findings).toEqual([]);
|
|
34
|
+
const anyPath = inspectGuide('docs/G.md', 'See `packages/absent/x.ts`.\n', () => false);
|
|
35
|
+
expect(anyPath).toHaveLength(1);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every repository path a guide names has to exist.
|
|
7
|
+
*
|
|
8
|
+
* `ADDING_A_FEATURE.md` is the one document a new consumer executes literally, and two of its
|
|
9
|
+
* steps pointed at files the scaffold does not contain — `lib/api/client.ts` and a "shared
|
|
10
|
+
* realtime source". The reader does not learn that by reading; they learn it by opening the path
|
|
11
|
+
* and finding nothing, after having already trusted the sentence around it.
|
|
12
|
+
*
|
|
13
|
+
* A path the guide tells you to CREATE is not a broken reference, so those are declared by the
|
|
14
|
+
* guide itself: a line that introduces one carries `(created in this step)`. Everything else
|
|
15
|
+
* must resolve.
|
|
16
|
+
*/
|
|
17
|
+
const GUIDES = ['docs/ADDING_A_FEATURE.md'];
|
|
18
|
+
const PATH_PATTERN = /`((?:packages|scripts|docs|e2e)\/[A-Za-z0-9_./-]+)`/g;
|
|
19
|
+
|
|
20
|
+
export interface GuidePathFinding {
|
|
21
|
+
readonly guide: string;
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function inspectGuide(
|
|
27
|
+
guide: string,
|
|
28
|
+
source: string,
|
|
29
|
+
exists: (path: string) => boolean,
|
|
30
|
+
): GuidePathFinding[] {
|
|
31
|
+
const findings: GuidePathFinding[] = [];
|
|
32
|
+
source.split('\n').forEach((text, index) => {
|
|
33
|
+
if (text.includes('(created in this step)')) return;
|
|
34
|
+
for (const match of text.matchAll(PATH_PATTERN)) {
|
|
35
|
+
const path = match[1];
|
|
36
|
+
if (!path || exists(path)) continue;
|
|
37
|
+
findings.push({ guide, line: index + 1, path });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function inspectGuides(root: string): Promise<GuidePathFinding[]> {
|
|
44
|
+
const findings: GuidePathFinding[] = [];
|
|
45
|
+
for (const guide of GUIDES) {
|
|
46
|
+
const absolute = resolve(root, guide);
|
|
47
|
+
if (!existsSync(absolute)) {
|
|
48
|
+
findings.push({ guide, line: 0, path: guide });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
findings.push(
|
|
52
|
+
...inspectGuide(guide, await readFile(absolute, 'utf8'), (path) =>
|
|
53
|
+
existsSync(join(root, path)),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (import.meta.main) {
|
|
61
|
+
const findings = await inspectGuides(resolve(import.meta.dir, '..'));
|
|
62
|
+
for (const { guide, line, path } of findings) {
|
|
63
|
+
console.error(`${guide}:${line}: names ${path}, which does not exist`);
|
|
64
|
+
}
|
|
65
|
+
if (findings.length > 0) process.exit(1);
|
|
66
|
+
}
|
|
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { appDeclaration } from '../packages/config/src/declaration';
|
|
6
|
-
import { ensureLocalEnvironment } from './local-env';
|
|
6
|
+
import { assertUsableEnvironment, ensureLocalEnvironment } from './local-env';
|
|
7
7
|
|
|
8
8
|
describe('ensureLocalEnvironment', () => {
|
|
9
9
|
test('renders the application identity into a fresh .env and never touches an existing one', async () => {
|
|
@@ -13,7 +13,10 @@ describe('ensureLocalEnvironment', () => {
|
|
|
13
13
|
join(root, '.env.example'),
|
|
14
14
|
'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
|
|
15
15
|
);
|
|
16
|
+
// Rendering succeeds — a generator that refuses to generate would break `--no-install`
|
|
17
|
+
// scaffolding — and the separate usability check is what refuses.
|
|
16
18
|
ensureLocalEnvironment(root);
|
|
19
|
+
expect(() => assertUsableEnvironment(root)).toThrow(/USER:PASSWORD/);
|
|
17
20
|
const created = await readFile(join(root, '.env'), 'utf8');
|
|
18
21
|
// In the neutral dev workspace the slug IS the neutral identity, so the
|
|
19
22
|
// substitution is proven end-to-end by the starter lane on a renamed
|
|
@@ -39,10 +42,56 @@ describe('ensureLocalEnvironment', () => {
|
|
|
39
42
|
'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
|
|
40
43
|
);
|
|
41
44
|
ensureLocalEnvironment(root);
|
|
45
|
+
expect(() => assertUsableEnvironment(root)).toThrow(/USER:PASSWORD/);
|
|
42
46
|
const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
|
|
43
47
|
expect(await readFile(join(root, '.env'), 'utf8')).toContain(`5432/${databaseName}`);
|
|
44
48
|
} finally {
|
|
45
49
|
await rm(root, { recursive: true, force: true });
|
|
46
50
|
}
|
|
47
51
|
});
|
|
52
|
+
|
|
53
|
+
test('an unedited .env is refused on every later run, not only the one that wrote it', async () => {
|
|
54
|
+
const root = await mkdtemp(join(tmpdir(), 'sk-env-again-'));
|
|
55
|
+
try {
|
|
56
|
+
// No example at all: the file is already there, exactly as a second `bun run dev` finds it.
|
|
57
|
+
await writeFile(
|
|
58
|
+
join(root, '.env'),
|
|
59
|
+
'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/app\n',
|
|
60
|
+
);
|
|
61
|
+
let message = '';
|
|
62
|
+
try {
|
|
63
|
+
assertUsableEnvironment(root);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
message = error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
// The message has to name the file, the line and the privilege the next step needs —
|
|
68
|
+
// a refusal that only says "invalid" moves the search back to the reader.
|
|
69
|
+
expect(message).toContain('.env:1');
|
|
70
|
+
expect(message).toContain('DATABASE_URL');
|
|
71
|
+
expect(message).toContain('CREATEDB');
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a commented example line is not mistaken for an unresolved credential', async () => {
|
|
78
|
+
const root = await mkdtemp(join(tmpdir(), 'sk-env-comment-'));
|
|
79
|
+
try {
|
|
80
|
+
// The placeholder is assembled rather than written: spelled out it is a credential inside
|
|
81
|
+
// a URL, which the publication-privacy gate refuses on sight — rightly, since "it is only
|
|
82
|
+
// a fixture" is the argument every leak makes. Same idiom as `check-authored.ts`.
|
|
83
|
+
const placeholder = ['USER', 'PASSWORD'].join(':');
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(root, '.env'),
|
|
86
|
+
[
|
|
87
|
+
`# DATABASE_URL=postgresql://${placeholder}@127.0.0.1:5432/example`,
|
|
88
|
+
'DATABASE_URL=postgresql://127.0.0.1:5432/app',
|
|
89
|
+
'',
|
|
90
|
+
].join('\n'),
|
|
91
|
+
);
|
|
92
|
+
expect(() => assertUsableEnvironment(root)).not.toThrow();
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(root, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
48
97
|
});
|
|
@@ -19,18 +19,62 @@ import { appIdentity } from '../packages/config/src/app-identity.generated';
|
|
|
19
19
|
* environment too. Synchronous on purpose: `playwright.config.ts` and other
|
|
20
20
|
* synchronous entry points must be able to self-heal before validating.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The credentials this file renders but cannot know.
|
|
24
|
+
*
|
|
25
|
+
* `.env.example` ships a connection string with literal `USER:PASSWORD`, and this script
|
|
26
|
+
* substitutes only the database name. A file that is generated, edited by the generator and then
|
|
27
|
+
* left unusable is the worst of the three: the reader assumes a generated file is ready. So the
|
|
28
|
+
* placeholder is named here rather than discovered as a driver stack on the first request.
|
|
29
|
+
*/
|
|
30
|
+
function unresolvedCredentialLines(destination: string): string[] {
|
|
31
|
+
return readFileSync(destination, 'utf8')
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
|
|
34
|
+
.filter(({ line }) => !line.startsWith('#') && line.includes('USER:PASSWORD'))
|
|
35
|
+
.map(({ line, number }) => ` ${destination}:${number} ${line.split('=')[0] ?? line}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Refuse to start on an environment that still carries the example credentials.
|
|
40
|
+
*
|
|
41
|
+
* Separate from rendering on purpose: a generator that refuses to generate is wrong — `--no-install`
|
|
42
|
+
* scaffolding renders `.env` before anyone could have filled it in — while a start that proceeds
|
|
43
|
+
* into a driver stack is the defect. So `env:ensure` writes and reports; `dev` writes and refuses.
|
|
44
|
+
*/
|
|
45
|
+
export function assertUsableEnvironment(root: string): void {
|
|
23
46
|
const destination = resolve(root, '.env');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
const unresolved = unresolvedCredentialLines(destination);
|
|
48
|
+
if (unresolved.length === 0) return;
|
|
49
|
+
throw new Error(
|
|
50
|
+
`This environment still carries the example credentials.\n${unresolved.join('\n')}\n` +
|
|
51
|
+
'Replace USER:PASSWORD with a role that can reach your PostgreSQL server. ' +
|
|
52
|
+
'A role that runs `db:migrate` also needs CREATEDB, because `prisma migrate dev` ' +
|
|
53
|
+
'creates a shadow database.',
|
|
29
54
|
);
|
|
30
|
-
|
|
31
|
-
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ensureLocalEnvironment(root: string): void {
|
|
58
|
+
const destination = resolve(root, '.env');
|
|
59
|
+
if (!existsSync(destination)) {
|
|
60
|
+
const publicExample = resolve(root, '.env.example');
|
|
61
|
+
const example = readFileSync(
|
|
62
|
+
existsSync(publicExample) ? publicExample : resolve(root, '_env.example'),
|
|
63
|
+
'utf8',
|
|
64
|
+
);
|
|
65
|
+
const databaseName = appIdentity.slug.replaceAll('-', '_');
|
|
66
|
+
writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
|
|
67
|
+
}
|
|
32
68
|
}
|
|
33
69
|
|
|
34
70
|
if (import.meta.main) {
|
|
35
|
-
|
|
71
|
+
const root = resolve(import.meta.dir, '..');
|
|
72
|
+
ensureLocalEnvironment(root);
|
|
73
|
+
// Reported, not fatal: this command's job is to produce the file, and it has.
|
|
74
|
+
const unresolved = unresolvedCredentialLines(resolve(root, '.env'));
|
|
75
|
+
if (unresolved.length > 0) {
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`.env still carries the example credentials — \`bun run dev\` will refuse until they are replaced:\n${unresolved.join('\n')}\n`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
36
80
|
}
|