create-stitchkit 0.6.6 → 0.6.8
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 +37 -0
- package/package.json +2 -2
- package/template/bun.lock +2 -2
- package/template/e2e/starter.spec.ts +2 -1
- package/template/package.json +1 -1
- package/template/packages/frontend/src/features/board/board-panel.tsx +10 -1
- package/templates/telegram-bot/README.md +4 -3
- package/templates/telegram-bot/src/application.ts +19 -16
- package/templates/telegram-bot/tests/application.test.ts +52 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,43 @@ step is overwritten by the next release.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.8] — 2026-09-28
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Generated projects track Stitchkit `^0.100.1`. The catalog stayed on
|
|
20
|
+
`^0.96.1` while 0.97.0–0.100.1 shipped, and on 0.x a caret never crosses a
|
|
21
|
+
minor, so a new project started four minors behind. The generated code needs
|
|
22
|
+
no change; what a project that grows past the new defaults must know — the
|
|
23
|
+
finite watch capacities of 0.100.0 among them — is in Stitchkit's
|
|
24
|
+
`docs/guide/upgrading.md`.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- The board keeps a note typed before the page hydrated. The text reached the
|
|
29
|
+
input but not React's state, so Post stayed disabled over a filled field — a
|
|
30
|
+
slow browser's end-to-end run timed out on it, and a person typing early saw
|
|
31
|
+
the same.
|
|
32
|
+
|
|
33
|
+
## [0.6.7] — 2026-09-24
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **A bot from `--template telegram-bot` starts with an operators' chat.** With
|
|
38
|
+
`OPERATOR_CHAT_ID` set, the optional `operator-channel` was one of the bot's
|
|
39
|
+
dependencies, and the kernel refuses a required resource that depends on an
|
|
40
|
+
optional one: the bot started without the chat and not with it. The channel
|
|
41
|
+
now stands beside the bot and still drains on the way down; the template's
|
|
42
|
+
test starts the graph with the chat and with local Bot API files. A bot
|
|
43
|
+
generated from 0.6.5 or 0.6.6 moves the `operator-channel` resource out of the
|
|
44
|
+
`dependsOn` it passes to `grammyBotResources` and into the application's
|
|
45
|
+
`resources`.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- Generated projects track Stitchkit `^0.96.1`, whose operator channel masks
|
|
50
|
+
a bot token before a message leaves.
|
|
51
|
+
|
|
15
52
|
## [0.6.6] — 2026-09-24
|
|
16
53
|
|
|
17
54
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-stitchkit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Create a production-shaped Stitchkit application",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Listov <maxlistov@gmail.com>",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@types/react": "^19.3.0",
|
|
85
85
|
"ai": "^7.0.111",
|
|
86
86
|
"react": "^19.3.0",
|
|
87
|
-
"stitchkit": "0.
|
|
87
|
+
"stitchkit": "0.100.1",
|
|
88
88
|
"typescript": "^7.0.2"
|
|
89
89
|
},
|
|
90
90
|
"engines": {
|
package/template/bun.lock
CHANGED
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"zod": "4.6.5",
|
|
146
146
|
},
|
|
147
147
|
"catalog": {
|
|
148
|
-
"stitchkit": "^0.
|
|
148
|
+
"stitchkit": "^0.100.1",
|
|
149
149
|
},
|
|
150
150
|
"packages": {
|
|
151
151
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.89", "", { "dependencies": { "@ai-sdk/provider": "4.0.17", "@ai-sdk/provider-utils": "5.0.45", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-n0Q88UUASYQBGK3Ogs9pCe8h3ZXeTHFSuGOLBJAyH73MVGNKUv9w8EJX2f340WUF3eDWWOV305gO7YBpSCPblA=="],
|
|
@@ -1128,7 +1128,7 @@
|
|
|
1128
1128
|
|
|
1129
1129
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1130
1130
|
|
|
1131
|
-
"stitchkit": ["stitchkit@0.
|
|
1131
|
+
"stitchkit": ["stitchkit@0.100.1", "", { "dependencies": { "ky": "^2.1.0" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2 || ^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.1.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.2", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.4.2", "ai": "^7.0.107", "google-auth-library": "^11.1.0", "grammy": "^1.46.0", "maxmind": "^5.0.7", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5 || ^1.0.5", "zod": "^4.6.5" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "google-auth-library", "grammy", "maxmind", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"], "bin": { "stitchkit": "dist/entrypoints/bin/upgrade-cli.js" } }, "sha512-zpJEfRl2xe/2JzxyIuxuZrFBe/i2mtEZTa3p+wZNbN6L3wFlP7GfK2ak6MLwikuLYtbmB2N4qGKEcA7qTEMA/Q=="],
|
|
1132
1132
|
|
|
1133
1133
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
1134
1134
|
|
|
@@ -45,7 +45,8 @@ test('a note posted in one tab reaches another through the web origin and the so
|
|
|
45
45
|
const writer = await browser.newPage();
|
|
46
46
|
const reader = await browser.newPage();
|
|
47
47
|
await Promise.all([writer.goto('/en'), reader.goto('/en')]);
|
|
48
|
-
|
|
48
|
+
// Browser projects share the backend and can post in the same millisecond.
|
|
49
|
+
const note = `note ${crypto.randomUUID()}`;
|
|
49
50
|
await writer.getByRole('textbox', { name: 'Note' }).fill(note);
|
|
50
51
|
await writer.getByRole('button', { name: 'Post' }).click();
|
|
51
52
|
await expect(writer.getByText(note)).toBeVisible();
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { Board } from '@app/shared';
|
|
4
|
-
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
5
|
import type { WatchStateFrame } from 'stitchkit/live';
|
|
6
6
|
import {
|
|
7
7
|
Button,
|
|
@@ -27,6 +27,14 @@ export function BoardPanel({ realtimeOrigin }: { realtimeOrigin?: string }) {
|
|
|
27
27
|
const [state, setState] = useState<WatchStateFrame>();
|
|
28
28
|
const [draft, setDraft] = useState('');
|
|
29
29
|
const [posting, setPosting] = useState(false);
|
|
30
|
+
const draftInput = useRef<HTMLInputElement>(null);
|
|
31
|
+
|
|
32
|
+
// Text typed before hydration is in the input but not in state, which would
|
|
33
|
+
// leave Post disabled over a filled field. Take it once, on mount.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const typed = draftInput.current?.value;
|
|
36
|
+
if (typed) setDraft(typed);
|
|
37
|
+
}, []);
|
|
30
38
|
|
|
31
39
|
useEffect(() => {
|
|
32
40
|
const watch = watchBoard(setBoard, setState, realtimeOrigin);
|
|
@@ -60,6 +68,7 @@ export function BoardPanel({ realtimeOrigin }: { realtimeOrigin?: string }) {
|
|
|
60
68
|
maxLength={140}
|
|
61
69
|
onChange={(event) => setDraft(event.target.value)}
|
|
62
70
|
placeholder='Say something, then open a second tab'
|
|
71
|
+
ref={draftInput}
|
|
63
72
|
value={draft}
|
|
64
73
|
/>
|
|
65
74
|
<Button disabled={posting || draft.trim().length === 0} type='submit'>
|
|
@@ -16,9 +16,10 @@ bun run dev
|
|
|
16
16
|
|
|
17
17
|
- `src/handlers.ts` — the product: commands, messages, what goes to the operators' chat.
|
|
18
18
|
- `src/application.ts` — the resource graph: `database` → (`telegram-files`) →
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
`telegram-configuration` → `telegram-polling`, with the optional
|
|
20
|
+
`operator-channel` beside the bot rather than under it. Required resources the
|
|
21
|
+
handlers need — queues, an HTTP server for payment webhooks — join the bot's
|
|
22
|
+
dependencies; optional ones — the operators' chat, metrics — stand beside it.
|
|
22
23
|
- `src/index.ts` — the entry point: environment, journal, signals, and the one
|
|
23
24
|
policy for a poller that ended on its own (shut down, exit 1, let the supervisor
|
|
24
25
|
restart).
|
|
@@ -27,11 +27,14 @@ export interface BotApplicationConfig {
|
|
|
27
27
|
/**
|
|
28
28
|
* The graph, in start order:
|
|
29
29
|
*
|
|
30
|
-
* database → [telegram-files] →
|
|
30
|
+
* database → [telegram-files] → telegram-configuration → telegram-polling
|
|
31
|
+
* [operator-channel] beside the bot, not under it
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* The bot depends only on what it cannot answer without — required resources.
|
|
34
|
+
* An optional one (the operators' chat, metrics) stands beside the bot: a
|
|
35
|
+
* required resource may not depend on an optional one, and the bot must keep
|
|
36
|
+
* answering when the chat is unreachable. Queues and an HTTP server for
|
|
37
|
+
* payment webhooks are required and join the bot's `dependsOn`.
|
|
35
38
|
*/
|
|
36
39
|
export function createBotApplication(config: BotApplicationConfig): ApplicationHandle {
|
|
37
40
|
const database = createDatabase(config.databasePath);
|
|
@@ -59,17 +62,17 @@ export function createBotApplication(config: BotApplicationConfig): ApplicationH
|
|
|
59
62
|
);
|
|
60
63
|
}
|
|
61
64
|
const operators = config.operators;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
const beside: ManagedResource[] = operators
|
|
66
|
+
? [
|
|
67
|
+
defineManagedResource({
|
|
68
|
+
id: 'operator-channel',
|
|
69
|
+
required: false,
|
|
70
|
+
start() {},
|
|
71
|
+
drain: (context) => operators.drain(context.signal),
|
|
72
|
+
close: () => operators.close(),
|
|
73
|
+
}),
|
|
74
|
+
]
|
|
75
|
+
: [];
|
|
73
76
|
|
|
74
77
|
const telegram = grammyBotResources({
|
|
75
78
|
bot: config.bot,
|
|
@@ -84,7 +87,7 @@ export function createBotApplication(config: BotApplicationConfig): ApplicationH
|
|
|
84
87
|
|
|
85
88
|
return createApplication({
|
|
86
89
|
id: 'telegram-bot',
|
|
87
|
-
resources: [...botDependencies, ...telegram.resources],
|
|
90
|
+
resources: [...botDependencies, ...beside, ...telegram.resources],
|
|
88
91
|
shutdown: config.shutdown ?? { gracePeriodMs: 30_000, forceTimeoutMs: 5_000 },
|
|
89
92
|
onResourceFailure: ({ resourceId, phase, error }) =>
|
|
90
93
|
config.log.error('Resource failed', { resourceId, phase, error }),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import { Bot } from 'grammy';
|
|
6
|
+
import { createTelegramLocalFiles, createTelegramOperatorChannel } from 'stitchkit/telegram';
|
|
3
7
|
import { z } from 'zod';
|
|
4
8
|
import { createBotApplication } from '../src/application';
|
|
5
9
|
import { createLog } from '../src/log';
|
|
@@ -73,8 +77,12 @@ function fakeTelegram() {
|
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
const servers: { stop(): void }[] = [];
|
|
76
|
-
|
|
80
|
+
const directories: string[] = [];
|
|
81
|
+
afterEach(async () => {
|
|
77
82
|
for (const server of servers.splice(0)) server.stop();
|
|
83
|
+
await Promise.all(
|
|
84
|
+
directories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
|
85
|
+
);
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
async function until(check: () => boolean): Promise<void> {
|
|
@@ -117,4 +125,47 @@ describe('the bot application', () => {
|
|
|
117
125
|
expect(Math.max(...telegram.offsets)).toBe(6);
|
|
118
126
|
expect(ended).toEqual([]);
|
|
119
127
|
});
|
|
128
|
+
|
|
129
|
+
test('starts with an operators chat and local Bot API files, and the channel drains on the way down', async () => {
|
|
130
|
+
const telegram = fakeTelegram();
|
|
131
|
+
servers.push(telegram);
|
|
132
|
+
const root = await mkdtemp(join(tmpdir(), 'template-bot-files-'));
|
|
133
|
+
directories.push(root);
|
|
134
|
+
await mkdir(join(root, '1:template'));
|
|
135
|
+
const bot = new Bot('1:template', { client: { apiRoot: telegram.origin } });
|
|
136
|
+
const posted: string[] = [];
|
|
137
|
+
const operators = createTelegramOperatorChannel<'users' | 'errors'>({
|
|
138
|
+
chatId: -100,
|
|
139
|
+
minIntervalMs: 1,
|
|
140
|
+
send: async (message) => void posted.push(message.text),
|
|
141
|
+
});
|
|
142
|
+
const app = createBotApplication({
|
|
143
|
+
bot,
|
|
144
|
+
databasePath: ':memory:',
|
|
145
|
+
log: createLog({ LOG_LEVEL: 'info' }),
|
|
146
|
+
operators,
|
|
147
|
+
files: createTelegramLocalFiles({ root, token: '1:template' }),
|
|
148
|
+
onPollingEnded: () => undefined,
|
|
149
|
+
shutdown: { gracePeriodMs: 2_000, forceTimeoutMs: 200 },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const snapshot = await app.start();
|
|
153
|
+
expect(snapshot.ready).toBe(true);
|
|
154
|
+
expect(snapshot.resources.map((resource) => resource.id)).toEqual([
|
|
155
|
+
'database',
|
|
156
|
+
'operator-channel',
|
|
157
|
+
'telegram-files',
|
|
158
|
+
'telegram-configuration',
|
|
159
|
+
'telegram-polling',
|
|
160
|
+
]);
|
|
161
|
+
// The bot waits for what it cannot answer without, never for the chat.
|
|
162
|
+
expect(
|
|
163
|
+
snapshot.resources.find((resource) => resource.id === 'telegram-configuration')
|
|
164
|
+
?.dependsOn,
|
|
165
|
+
).toEqual(['database', 'telegram-files']);
|
|
166
|
+
telegram.push(8);
|
|
167
|
+
await until(() => posted.length > 0);
|
|
168
|
+
expect(posted).toEqual(['New user 7']);
|
|
169
|
+
expect((await app.shutdown()).outcome).toBe('clean');
|
|
170
|
+
});
|
|
120
171
|
});
|