ebely 0.0.1 → 0.0.2
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/bin/ebely.mjs +70 -0
- package/dist/index.d.ts +273 -0
- package/dist/index.js +722 -0
- package/dist/index.mjs +691 -0
- package/package.json +26 -7
- package/skills/ebely-setup/SKILL.md +29 -0
- package/skills/ebely-write-tests/SKILL.md +28 -0
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.changeset/fast-goats-doubt.md +0 -5
- package/.github/workflows/main.yml +0 -21
- package/.github/workflows/publish.yml +0 -36
- package/index.ts +0 -3
- package/tsconfig.json +0 -21
package/bin/ebely.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, mkdir, readdir, stat } from 'node:fs/promises'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const pkgRoot = resolve(__dirname, '..')
|
|
9
|
+
const skillsSrc = join(pkgRoot, 'skills')
|
|
10
|
+
|
|
11
|
+
async function listSkills() {
|
|
12
|
+
const entries = await readdir(skillsSrc, { withFileTypes: true })
|
|
13
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function installSkills({ toUser }) {
|
|
17
|
+
const base = toUser
|
|
18
|
+
? join(process.env.HOME ?? process.cwd(), '.claude', 'skills')
|
|
19
|
+
: join(process.cwd(), '.claude', 'skills')
|
|
20
|
+
|
|
21
|
+
if (!existsSync(skillsSrc)) {
|
|
22
|
+
console.error(`ebely: skills directory not found at ${skillsSrc}`)
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await mkdir(base, { recursive: true })
|
|
27
|
+
const names = await listSkills()
|
|
28
|
+
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const dest = join(base, name)
|
|
31
|
+
await cp(join(skillsSrc, name), dest, { recursive: true })
|
|
32
|
+
console.log(` ✓ ${name} → ${dest}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(
|
|
36
|
+
`\nГотово. Установлено скиллов: ${names.length}.` +
|
|
37
|
+
`\nОткрой Claude Code в этом проекте и вызови /ebely-setup для первичной настройки.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function help() {
|
|
42
|
+
console.log(`ebely — CLI
|
|
43
|
+
|
|
44
|
+
Использование:
|
|
45
|
+
npx ebely skills Установить скиллы в .claude/skills проекта (рекомендуется)
|
|
46
|
+
npx ebely skills --user Установить скиллы глобально в ~/.claude/skills
|
|
47
|
+
npx ebely help Показать эту справку
|
|
48
|
+
|
|
49
|
+
Скиллы:
|
|
50
|
+
/ebely-setup первичная настройка библиотеки в проекте
|
|
51
|
+
/ebely-write-tests написание тестов через ebely`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
55
|
+
|
|
56
|
+
switch (cmd) {
|
|
57
|
+
case 'skills':
|
|
58
|
+
await installSkills({ toUser: rest.includes('--user') })
|
|
59
|
+
break
|
|
60
|
+
case undefined:
|
|
61
|
+
case 'help':
|
|
62
|
+
case '--help':
|
|
63
|
+
case '-h':
|
|
64
|
+
help()
|
|
65
|
+
break
|
|
66
|
+
default:
|
|
67
|
+
console.error(`ebely: неизвестная команда «${cmd}»\n`)
|
|
68
|
+
help()
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
declare class BaseStore<Vars extends Record<string, unknown> = Record<string, never>, Api = unknown> {
|
|
2
|
+
private store;
|
|
3
|
+
/**
|
|
4
|
+
* Типизированный доступ к эндпоинтам ИЗНУТРИ методов-сценариев
|
|
5
|
+
* («actions»): `this.api.<группа>.<метод>(...)`. Само значение
|
|
6
|
+
* подставляет генерируемый `World` (для user-store — клиент этого
|
|
7
|
+
* пользователя, для world-store — анонимный клиент), поэтому здесь это
|
|
8
|
+
* объявление без инициализатора. Тип `Api` пользователь задаёт вторым
|
|
9
|
+
* дженериком, передавая сгенерированный `WorldApi`:
|
|
10
|
+
*
|
|
11
|
+
* import type { WorldApi } from './generated'
|
|
12
|
+
* class UserStore extends BaseStore<Vars, WorldApi> {
|
|
13
|
+
* async fullRegister(args: { email: string; password: string }) {
|
|
14
|
+
* await this.api.auth.register({ body: args })
|
|
15
|
+
* await this.api.auth.confirm({ body: { code: '0000' } })
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* (Тип импортируется как `import type` → рантайм-цикла нет, ровно как
|
|
20
|
+
* у `hooks.ts`; см. ARCHITECTURE.md §7–§8.)
|
|
21
|
+
*/
|
|
22
|
+
protected api: Api;
|
|
23
|
+
/** Сохранить внутреннюю переменную пользователя. */
|
|
24
|
+
set<K extends keyof Vars>(args: {
|
|
25
|
+
key: K;
|
|
26
|
+
value: Vars[K];
|
|
27
|
+
}): void;
|
|
28
|
+
/** Прочитать внутреннюю переменную пользователя (undefined, если не задана). */
|
|
29
|
+
get<K extends keyof Vars>(args: {
|
|
30
|
+
key: K;
|
|
31
|
+
}): Vars[K] | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Глубоко-частичный тип: на каждом уровне все поля необязательны. */
|
|
35
|
+
type DeepPartial<T> = T extends (infer U)[] ? DeepPartial<U>[] : T extends object ? {
|
|
36
|
+
[K in keyof T]?: DeepPartial<T[K]>;
|
|
37
|
+
} : T;
|
|
38
|
+
/** Ошибка проваленной проверки `res.assert(...)`. */
|
|
39
|
+
declare class EbelyAssertionError extends Error {
|
|
40
|
+
constructor(message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Ответ эндпоинта в режиме `'test'`. Дженерик `M` — карта «статус → тело»,
|
|
44
|
+
* собранная генератором по swagger.
|
|
45
|
+
*
|
|
46
|
+
* - `status` / `body` — фактические значения ответа (не-2xx тоже доступен);
|
|
47
|
+
* - `assert(status, body?)` — проверка; статус подсказывается интеллисенсом.
|
|
48
|
+
*/
|
|
49
|
+
declare class ApiResponse<M extends Record<number, unknown>> {
|
|
50
|
+
/** Фактический HTTP-статус ответа. */
|
|
51
|
+
readonly status: number;
|
|
52
|
+
/** Распарсенное тело ответа. */
|
|
53
|
+
readonly body: M[keyof M];
|
|
54
|
+
constructor(args: {
|
|
55
|
+
status: number;
|
|
56
|
+
body: M[keyof M];
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Проверяет статус и (опционально) часть тела ответа.
|
|
60
|
+
*
|
|
61
|
+
* @param status Ожидаемый статус. Подсказывается интеллисенсом из
|
|
62
|
+
* swagger; незадекларированный литерал — ошибка типов. Чтобы
|
|
63
|
+
* проверить статус, которого нет в схеме (например `400`),
|
|
64
|
+
* используйте `res.assert(400 as any, ...)`.
|
|
65
|
+
* @param expectedBody Необязательная часть тела: проверяются только
|
|
66
|
+
* переданные поля (глубоко-частично), остальные игнорируются.
|
|
67
|
+
* @returns тот же объект ответа — удобно читать `.body` после проверки.
|
|
68
|
+
*/
|
|
69
|
+
assert<S extends keyof M>(status: S, expectedBody?: DeepPartial<M[S]>): this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Изменяемое описание исходящего запроса, доступное хукам. `before`-хук
|
|
74
|
+
* МОЖЕТ его править (`headers` / `query` / `body` / `pathParams`) —
|
|
75
|
+
* изменения уедут в реальный fetch. `path` — это ШАБЛОН из swagger
|
|
76
|
+
* (`/posts/{id}`); подстановка `pathParams` происходит уже после хуков.
|
|
77
|
+
*/
|
|
78
|
+
type HookRequest = {
|
|
79
|
+
method: string;
|
|
80
|
+
path: string;
|
|
81
|
+
pathParams: Record<string, string>;
|
|
82
|
+
query: Record<string, string | number | boolean | undefined>;
|
|
83
|
+
body: unknown;
|
|
84
|
+
headers: Record<string, string>;
|
|
85
|
+
};
|
|
86
|
+
/** Ответ эндпоинта в том виде, в каком его видит `after`-хук (read-only). */
|
|
87
|
+
type HookResponse = {
|
|
88
|
+
status: number;
|
|
89
|
+
body: unknown;
|
|
90
|
+
};
|
|
91
|
+
/** Аргумент `before`-хука. `Body` уточняется генератором на каждую операцию. */
|
|
92
|
+
type BeforeHookArgs<Ctx, Body = unknown> = {
|
|
93
|
+
request: HookRequest & {
|
|
94
|
+
body: Body;
|
|
95
|
+
};
|
|
96
|
+
ctx: Ctx;
|
|
97
|
+
};
|
|
98
|
+
/** Аргумент `after`-хука. `Body` / `ResBody` уточняются генератором. */
|
|
99
|
+
type AfterHookArgs<Ctx, Body = unknown, ResBody = unknown> = {
|
|
100
|
+
request: HookRequest & {
|
|
101
|
+
body: Body;
|
|
102
|
+
};
|
|
103
|
+
response: {
|
|
104
|
+
status: number;
|
|
105
|
+
body: ResBody;
|
|
106
|
+
};
|
|
107
|
+
ctx: Ctx;
|
|
108
|
+
};
|
|
109
|
+
/** `before`-хук: вызывается ДО запроса; может мутировать `request`. */
|
|
110
|
+
type BeforeHook<Ctx, Body = unknown> = (args: BeforeHookArgs<Ctx, Body>) => void | Promise<void>;
|
|
111
|
+
/** `after`-хук: вызывается ПОСЛЕ ответа; `response` — только на чтение. */
|
|
112
|
+
type AfterHook<Ctx, Body = unknown, ResBody = unknown> = (args: AfterHookArgs<Ctx, Body, ResBody>) => void | Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Регистратор хуков, который пользователь передаёт в конфиг одной
|
|
115
|
+
* переменной (`EbelyConfig.hooks`). Точную типизированную форму аргумента
|
|
116
|
+
* (`h.<группа>.<метод>.before/after`) задаёт СГЕНЕРИРОВАННЫЙ клиент —
|
|
117
|
+
* он экспортирует конкретный тип `Hooks`. В библиотеке тип намеренно
|
|
118
|
+
* широкий (`any`): здесь ещё не известны ни операции, ни класс store.
|
|
119
|
+
*/
|
|
120
|
+
type HooksRegistrar = (registrar: any) => void;
|
|
121
|
+
/**
|
|
122
|
+
* Реестр хуков. Ключ — `"<группа>.<метод>"` (как `operationId`, напр.
|
|
123
|
+
* `posts.create`). Несколько хуков на один ключ выполняются ПО ОЧЕРЕДИ в
|
|
124
|
+
* порядке регистрации.
|
|
125
|
+
*
|
|
126
|
+
* Реестр ОБЩИЙ (живёт на экземпляре `World`), но `ctx` подставляется не
|
|
127
|
+
* при регистрации, а в момент запроса — это store КОНКРЕТНОГО
|
|
128
|
+
* пользователя, сделавшего вызов. Поэтому запись хуком во внутренние
|
|
129
|
+
* переменные не «течёт» между пользователями: изоляция получается
|
|
130
|
+
* бесплатно из того, что `ctx` приходит из инстанса в момент вызова.
|
|
131
|
+
*
|
|
132
|
+
* Реестр НЕ знает ни про сеть, ни про то, ЧЕЙ это store — это и есть
|
|
133
|
+
* архитектурный шов: добавить позже второй уровень контекста (общий
|
|
134
|
+
* account/session для «один юзер с двух устройств») можно аддитивно,
|
|
135
|
+
* не меняя этот класс.
|
|
136
|
+
*/
|
|
137
|
+
declare class HookRegistry {
|
|
138
|
+
private beforeMap;
|
|
139
|
+
private afterMap;
|
|
140
|
+
/** Зарегистрировать `before`-хук на ключ `"<группа>.<метод>"`. */
|
|
141
|
+
before(args: {
|
|
142
|
+
key: string;
|
|
143
|
+
fn: BeforeHook<any>;
|
|
144
|
+
}): void;
|
|
145
|
+
/** Зарегистрировать `after`-хук на ключ `"<группа>.<метод>"`. */
|
|
146
|
+
after(args: {
|
|
147
|
+
key: string;
|
|
148
|
+
fn: AfterHook<any>;
|
|
149
|
+
}): void;
|
|
150
|
+
/** Прогнать все `before`-хуки ключа по очереди (await на async). */
|
|
151
|
+
runBefore(args: {
|
|
152
|
+
key: string;
|
|
153
|
+
request: HookRequest;
|
|
154
|
+
ctx: unknown;
|
|
155
|
+
}): Promise<void>;
|
|
156
|
+
/** Прогнать все `after`-хуки ключа по очереди (await на async). */
|
|
157
|
+
runAfter(args: {
|
|
158
|
+
key: string;
|
|
159
|
+
request: HookRequest;
|
|
160
|
+
response: HookResponse;
|
|
161
|
+
ctx: unknown;
|
|
162
|
+
}): Promise<void>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Источник swagger/OpenAPI-схемы. Указывается ровно одно из полей:
|
|
167
|
+
* либо `pathToFile` (локальный файл), либо `url` (HTTP-эндпоинт).
|
|
168
|
+
*/
|
|
169
|
+
type SwaggerSource = {
|
|
170
|
+
/** Путь к файлу схемы (резолвится от process.cwd()). */
|
|
171
|
+
pathToFile: `${string}.json`;
|
|
172
|
+
url?: never;
|
|
173
|
+
} | {
|
|
174
|
+
/** URL, по которому отдаётся swagger.json. */
|
|
175
|
+
url: `http${string}.json`;
|
|
176
|
+
pathToFile?: never;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Режим генерируемого клиента (решается на этапе генерации, влияет на
|
|
181
|
+
* форму сгенерированного файла):
|
|
182
|
+
*
|
|
183
|
+
* - `'test'` — методы возвращают объект-ответ с `.status` / `.body` и
|
|
184
|
+
* методом `.assert(status, body?)`. Не-2xx НЕ бросает исключение —
|
|
185
|
+
* проверка делается явным `res.assert(...)`.
|
|
186
|
+
* - `'frontend'` — методы возвращают тело ответа напрямую; не-2xx
|
|
187
|
+
* бросает ошибку; метода `.assert` нет. Подходит для использования
|
|
188
|
+
* клиента из приложения (фронтенд/сервис), а не только в тестах.
|
|
189
|
+
*/
|
|
190
|
+
type ClientMode = 'test' | 'frontend';
|
|
191
|
+
/** Конфиг ebely, который пользователь объявляет в своём `ebely.ts`. */
|
|
192
|
+
type EbelyConfig = {
|
|
193
|
+
/** URL бэкенда, который нужно тестировать. */
|
|
194
|
+
url: string;
|
|
195
|
+
/**
|
|
196
|
+
* Класс-хранилище внутренних переменных ОДНОГО пользователя — наследник
|
|
197
|
+
* `BaseStore`. Передаётся сам класс (конструктор), не его экземпляр.
|
|
198
|
+
* Может содержать методы-сценарии (`async fullRegister(...)`), которые
|
|
199
|
+
* через `this.api.<группа>.<метод>` дёргают эндпоинты от лица этого
|
|
200
|
+
* пользователя (его заголовки, его переменные, его `ctx` в хуках).
|
|
201
|
+
*/
|
|
202
|
+
userStore: new () => BaseStore<any, any>;
|
|
203
|
+
/**
|
|
204
|
+
* Класс-хранилище переменных/сценариев УРОВНЯ WORLD (необязательно).
|
|
205
|
+
* Тот же `BaseStore`, но его экземпляр — не «один пользователь», а
|
|
206
|
+
* весь мир: сюда кладут глобальные сценарии подготовки/очистки
|
|
207
|
+
* (`clearDatabase`, `seed`), доступные как `world.<метод>()`. Внутри
|
|
208
|
+
* `this.api` — анонимный клиент (без per-user заголовков).
|
|
209
|
+
* @default undefined — у `World` нет доп. методов
|
|
210
|
+
*/
|
|
211
|
+
worldStore?: new () => BaseStore<any, any>;
|
|
212
|
+
/** Откуда брать swagger-схему: из файла (`pathToFile`) или по `url`. */
|
|
213
|
+
swagger: SwaggerSource;
|
|
214
|
+
/** Путь, куда писать сгенерированный клиент (резолвится от process.cwd()). */
|
|
215
|
+
generateClientTo: `${string}.ts`;
|
|
216
|
+
/**
|
|
217
|
+
* Режим генерируемого клиента — см. {@link ClientMode}.
|
|
218
|
+
* @default 'test'
|
|
219
|
+
*/
|
|
220
|
+
mode?: ClientMode;
|
|
221
|
+
/**
|
|
222
|
+
* Регистратор хуков `before` / `after`. Объявляется в ОТДЕЛЬНОМ
|
|
223
|
+
* типизированном файле (тип `Hooks` экспортирует сгенерированный
|
|
224
|
+
* клиент) и передаётся сюда одной переменной:
|
|
225
|
+
*
|
|
226
|
+
* ```ts
|
|
227
|
+
* // ebely/hooks.ts
|
|
228
|
+
* import type { Hooks } from './generated'
|
|
229
|
+
* export const hooks: Hooks = (h) => {
|
|
230
|
+
* h.posts.create.after(({ response, ctx }) => {
|
|
231
|
+
* ctx.set({ key: 'lastPostId', value: response.body.id })
|
|
232
|
+
* })
|
|
233
|
+
* }
|
|
234
|
+
* // ebely/ebely.ts
|
|
235
|
+
* import { hooks } from './hooks'
|
|
236
|
+
* export const ebely = { …, hooks } satisfies EbelyConfig
|
|
237
|
+
* ```
|
|
238
|
+
*
|
|
239
|
+
* Регистратор вызывается ОДИН раз при `new World()`. Внутри хука `ctx`
|
|
240
|
+
* — это store КОНКРЕТНОГО пользователя, сделавшего запрос, поэтому
|
|
241
|
+
* запись в переменные не «течёт» между пользователями.
|
|
242
|
+
* @default undefined
|
|
243
|
+
*/
|
|
244
|
+
hooks?: HooksRegistrar;
|
|
245
|
+
/**
|
|
246
|
+
* Из какого модуля СГЕНЕРИРОВАННЫЙ файл импортирует `BaseStore`.
|
|
247
|
+
* По умолчанию `'ebely'` — имя npm-пакета библиотеки. Менять нужно
|
|
248
|
+
* только в нестандартной раскладке (монорепо без публикации,
|
|
249
|
+
* импорт по относительному пути или по alias из tsconfig).
|
|
250
|
+
* @default 'ebely'
|
|
251
|
+
*/
|
|
252
|
+
userStoreImport?: string;
|
|
253
|
+
/**
|
|
254
|
+
* Из какого модуля СГЕНЕРИРОВАННЫЙ файл импортирует `ebely`-конфиг
|
|
255
|
+
* (нужен ему для значений по умолчанию: `ebely.url`, `ebely.userStore`).
|
|
256
|
+
* Это путь ОТ сгенерированного файла К этому конфигу. По умолчанию
|
|
257
|
+
* `'./ebely'` — т.е. конфиг лежит рядом с генерируемым файлом.
|
|
258
|
+
* @default './ebely'
|
|
259
|
+
*/
|
|
260
|
+
configImport?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Читает swagger-схему и пишет типизированный клиент `World` в файл.
|
|
265
|
+
* Это публичная точка входа библиотеки: пользователь вызывает её из
|
|
266
|
+
* своего проекта, передавая собственный ebely-конфиг.
|
|
267
|
+
*/
|
|
268
|
+
declare function generateClient(args: EbelyConfig): Promise<{
|
|
269
|
+
outPath: string;
|
|
270
|
+
operations: number;
|
|
271
|
+
}>;
|
|
272
|
+
|
|
273
|
+
export { AfterHook, AfterHookArgs, ApiResponse, BaseStore, BeforeHook, BeforeHookArgs, ClientMode, DeepPartial, EbelyAssertionError, EbelyConfig, HookRegistry, HookRequest, HookResponse, HooksRegistrar, SwaggerSource, generateClient };
|