@nitra/ci-docs 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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ Усі помітні зміни цього пакета документуються тут.
4
+
5
+ Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
+
7
+ ## [0.0.2] - 2026-05-12
8
+
9
+ ### Added
10
+
11
+ - CLI `ci-docs` з subcommand `sync-schema` — синкає GraphQL-схему в споживацький `npm/`-пакет (bump + CHANGELOG + копіювання SDL).
12
+ - Аргументи (тільки `--key value`, без env-vars): `--new-schema <path>` (обовʼязковий), `--docs <path>` (default `./docs`), `--schema-name <file>` (default `maya.graphql`), `--db-sha <sha>` (default `unknown`).
package/README.md ADDED
@@ -0,0 +1 @@
1
+ #
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@nitra/ci-docs",
3
+ "version": "0.0.2",
4
+ "description": "Nitra CI tooling: sync GraphQL schema to a docs npm package (and more to come).",
5
+ "homepage": "https://github.com/nitra/ci-docs",
6
+ "bugs": {
7
+ "url": "https://github.com/nitra/ci-docs/issues"
8
+ },
9
+ "license": "ISC",
10
+ "author": "v@nitra.ai",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/nitra/ci-docs.git"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "types",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "type": "module",
21
+ "types": "./types/index.d.ts",
22
+ "bin": {
23
+ "ci-docs": "./src/cli.mjs"
24
+ },
25
+ "dependencies": {
26
+ "@graphql-inspector/core": "^6.2.1",
27
+ "graphql": "^16.9.0"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.3",
31
+ "node": ">=24"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@nitra/cursor": "^1.9.4"
38
+ }
39
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@nitra/ci-docs` — CLI-диспатчер.
4
+ *
5
+ * Запуск: `npx -y @nitra/ci-docs <subcommand> [args...]`
6
+ *
7
+ * Доступні subcommands:
8
+ * sync-schema — інтроспект Hasura → bump → CHANGELOG → копіювання SDL
9
+ */
10
+
11
+ const SUBCOMMANDS = {
12
+ 'sync-schema': () => import('./sync-schema/main.mjs').then(m => m.cli)
13
+ }
14
+
15
+ const [subcommand, ...rest] = process.argv.slice(2)
16
+
17
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
18
+ console.log(`Usage: ci-docs <subcommand> [args...]
19
+
20
+ Subcommands:
21
+ ${Object.keys(SUBCOMMANDS).join('\n ')}
22
+
23
+ Run \`ci-docs <subcommand> --help\` for subcommand-specific options.`)
24
+ process.exit(subcommand ? 0 : 2)
25
+ }
26
+
27
+ const loader = SUBCOMMANDS[subcommand]
28
+ if (!loader) {
29
+ console.error(`Unknown subcommand: ${subcommand}`)
30
+ console.error(`Available: ${Object.keys(SUBCOMMANDS).join(', ')}`)
31
+ process.exit(2)
32
+ }
33
+
34
+ const run = await loader()
35
+ await run(rest)
@@ -0,0 +1,10 @@
1
+ type Query {
2
+ hello: String
3
+ newField: Int
4
+ }
5
+
6
+ type User {
7
+ id: ID!
8
+ name: String
9
+ email: String
10
+ }
@@ -0,0 +1,9 @@
1
+ type Query {
2
+ hello: String
3
+ deprecated_field: Int
4
+ }
5
+
6
+ type User {
7
+ id: ID!
8
+ name: String
9
+ }
@@ -0,0 +1,290 @@
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { buildSchema, buildClientSchema, printSchema, getIntrospectionQuery } from 'graphql'
5
+ import { diff } from '@graphql-inspector/core'
6
+
7
+ const REMOVAL_TYPES = new Set([
8
+ 'FIELD_REMOVED',
9
+ 'TYPE_REMOVED',
10
+ 'ENUM_VALUE_REMOVED',
11
+ 'INPUT_FIELD_REMOVED',
12
+ 'DIRECTIVE_REMOVED',
13
+ 'DIRECTIVE_ARGUMENT_REMOVED',
14
+ 'UNION_MEMBER_REMOVED',
15
+ 'OBJECT_TYPE_INTERFACE_REMOVED',
16
+ 'FIELD_ARGUMENT_REMOVED'
17
+ ])
18
+
19
+ const ALLOWED_BUMP_KINDS = new Set(['major', 'minor', 'patch'])
20
+
21
+ /**
22
+ * Класифікує зміни схеми у секції changelog та визначає тип bump.
23
+ * @param {Array<{type?: string, message: string, criticality?: {level: string}}>} changes масив змін від graphql-inspector
24
+ * @returns {{bump: 'minor'|'patch'|null, sections: {added: string[], removed: string[], changed: string[]}}} тип bump та секції changelog
25
+ */
26
+ export function classifyChanges(changes) {
27
+ const sections = { added: [], removed: [], changed: [] }
28
+ let hasBreaking = false
29
+ let hasAnyChange = false
30
+
31
+ for (const change of changes) {
32
+ hasAnyChange = true
33
+ const level = change.criticality?.level
34
+ if (level === 'BREAKING') hasBreaking = true
35
+
36
+ if (level === 'BREAKING' && REMOVAL_TYPES.has(change.type)) {
37
+ sections.removed.push(change.message)
38
+ } else if (change.type?.endsWith('_ADDED')) {
39
+ sections.added.push(change.message)
40
+ } else {
41
+ sections.changed.push(change.message)
42
+ }
43
+ }
44
+
45
+ let bump = null
46
+ if (hasAnyChange) bump = hasBreaking ? 'minor' : 'patch'
47
+ return { bump, sections }
48
+ }
49
+
50
+ /**
51
+ * Будує markdown-блок changelog для версії.
52
+ * @param {{version: string, date: string, dbSha: string, sections: {added: string[], removed: string[], changed: string[]}, first?: boolean}} params параметри блоку
53
+ * @returns {string} markdown-блок з трейлінговим порожнім рядком
54
+ */
55
+ export function formatChangelogBlock({ version, date, dbSha, sections, first = false }) {
56
+ const lines = [`## [${version}] - ${date}`, '']
57
+
58
+ const changed = [...sections.changed]
59
+ if (first) {
60
+ changed.unshift('Початкове додавання GraphQL-схеми з Hasura.')
61
+ } else {
62
+ changed.unshift(`Оновлено GraphQL-схему з Hasura (\`db@${dbSha}\`).`)
63
+ }
64
+
65
+ if (sections.added.length > 0) {
66
+ lines.push('### Added', '')
67
+ for (const item of sections.added) lines.push(`- ${item}`)
68
+ lines.push('')
69
+ }
70
+
71
+ lines.push('### Changed', '')
72
+ for (const item of changed) lines.push(`- ${item}`)
73
+ lines.push('')
74
+
75
+ if (sections.removed.length > 0) {
76
+ lines.push('### Removed', '')
77
+ for (const item of sections.removed) lines.push(`- ${item}`)
78
+ lines.push('')
79
+ }
80
+
81
+ return lines.join('\n') + '\n'
82
+ }
83
+
84
+ /**
85
+ * Обчислює розділювач між існуючим текстом і блоком, що додається.
86
+ * @param {string} existing існуючий вміст
87
+ * @returns {string} '', '\n' або '\n\n' — щоб новий блок починався з відступу одного порожнього рядка
88
+ */
89
+ function separatorFor(existing) {
90
+ if (existing.endsWith('\n\n')) return ''
91
+ if (existing.endsWith('\n')) return '\n'
92
+ return '\n\n'
93
+ }
94
+
95
+ /**
96
+ * Вставляє новий блок changelog перед першим існуючим записом (або після хедера, якщо записів нема).
97
+ * @param {string} existing вміст CHANGELOG.md
98
+ * @param {string} newBlock новий блок версії
99
+ * @returns {string} оновлений вміст
100
+ */
101
+ export function prependChangelog(existing, newBlock) {
102
+ const firstEntryIdx = existing.indexOf('\n## [')
103
+ if (firstEntryIdx === -1) {
104
+ return existing + separatorFor(existing) + newBlock
105
+ }
106
+ const insertAt = firstEntryIdx + 1
107
+ return existing.slice(0, insertAt) + newBlock + existing.slice(insertAt)
108
+ }
109
+
110
+ /**
111
+ * Запускає graphql-inspector diff між двома SDL.
112
+ * @param {string} oldSdl стара GraphQL-схема (SDL)
113
+ * @param {string} newSdl нова GraphQL-схема (SDL)
114
+ * @returns {Promise<Array<{type: string, message: string, criticality: {level: string}}>>} список змін
115
+ */
116
+ export async function runInspector(oldSdl, newSdl) {
117
+ const oldSchema = buildSchema(oldSdl)
118
+ const newSchema = buildSchema(newSdl)
119
+ return await diff(oldSchema, newSchema)
120
+ }
121
+
122
+ /**
123
+ * Читає SDL з файлу або повертає null, якщо файл відсутній/порожній.
124
+ * @param {string} path шлях до SDL
125
+ * @returns {string|null} вміст або null
126
+ */
127
+ export function readSdl(path) {
128
+ if (!existsSync(path)) return null
129
+ const content = readFileSync(path, 'utf8')
130
+ return content.trim().length === 0 ? null : content
131
+ }
132
+
133
+ /**
134
+ * Збільшує semver-версію за типом bump.
135
+ * @param {string} version поточна semver-версія (наприклад '0.1.2')
136
+ * @param {'major'|'minor'|'patch'} kind тип bump
137
+ * @returns {string} нова версія
138
+ */
139
+ function incrementSemver(version, kind) {
140
+ const parts = version.split('.').map(s => Number.parseInt(s, 10))
141
+ if (parts.length !== 3 || parts.some(n => Number.isNaN(n))) {
142
+ throw new Error(`Invalid semver: ${version}`)
143
+ }
144
+ let [major, minor, patch] = parts
145
+ if (kind === 'major') {
146
+ major += 1
147
+ minor = 0
148
+ patch = 0
149
+ } else if (kind === 'minor') {
150
+ minor += 1
151
+ patch = 0
152
+ } else {
153
+ patch += 1
154
+ }
155
+ return `${major}.${minor}.${patch}`
156
+ }
157
+
158
+ /**
159
+ * Підіймає версію у package.json напряму (без виклику зовнішнього `npm`).
160
+ * @param {string} npmDir шлях до директорії пакета
161
+ * @param {'major'|'minor'|'patch'} kind тип bump
162
+ * @returns {string} нова версія
163
+ */
164
+ export function bumpVersion(npmDir, kind) {
165
+ if (!ALLOWED_BUMP_KINDS.has(kind)) {
166
+ throw new Error(`Unsupported bump kind: ${kind}`)
167
+ }
168
+ const pkgPath = `${npmDir}/package.json`
169
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
170
+ pkg.version = incrementSemver(pkg.version, kind)
171
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
172
+ return pkg.version
173
+ }
174
+
175
+ /**
176
+ * Пише пари key=value у файл, на який вказує `GITHUB_OUTPUT`.
177
+ * @param {Record<string, string>} values пари для запису
178
+ * @returns {void}
179
+ */
180
+ export function writeGithubOutput(values) {
181
+ const outPath = process.env.GITHUB_OUTPUT
182
+ if (!outPath) return
183
+ const lines = Object.entries(values).map(([k, v]) => `${k}=${v}`)
184
+ appendFileSync(outPath, lines.join('\n') + '\n')
185
+ }
186
+
187
+ /**
188
+ * Шле introspection-запит до Hasura і повертає SDL-рядок.
189
+ * @param {string} hasuraUrl URL ендпоінта GraphQL
190
+ * @param {string} [adminSecret] значення `X-Hasura-Admin-Secret` (опціонально для публічних ендпоінтів)
191
+ * @returns {Promise<string>} SDL-схема у вигляді рядка
192
+ */
193
+ export async function fetchSdl(hasuraUrl, adminSecret) {
194
+ const res = await fetch(hasuraUrl, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'Content-Type': 'application/json',
198
+ ...(adminSecret ? { 'X-Hasura-Admin-Secret': adminSecret } : {})
199
+ },
200
+ body: JSON.stringify({ query: getIntrospectionQuery() })
201
+ })
202
+ if (!res.ok) throw new Error(`Introspection failed: ${res.status} ${res.statusText}`)
203
+ const { data, errors } = await res.json()
204
+ if (errors?.length) throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)
205
+ return printSchema(buildClientSchema(data))
206
+ }
207
+
208
+ /**
209
+ * Оркеструє весь flow: diff схем → bump → CHANGELOG → запис SDL.
210
+ * @param {{newSdl: string, docsRoot: string, dbSha: string, date: string, schemaFilename?: string}} params параметри запуску
211
+ * @returns {Promise<{changed: boolean, bump: 'minor'|'patch'|null, version: string|null}>} результат
212
+ */
213
+ export async function main({ newSdl, docsRoot, dbSha, date, schemaFilename = 'maya.graphql' }) {
214
+ const oldSchemaPath = `${docsRoot}/npm/schema/${schemaFilename}`
215
+ const oldSdl = readSdl(oldSchemaPath)
216
+
217
+ const first = oldSdl === null
218
+ const changes = first ? [] : await runInspector(oldSdl, newSdl)
219
+ const { bump: classifiedBump, sections } = classifyChanges(changes)
220
+ const bump = first ? 'patch' : classifiedBump
221
+
222
+ if (!first && bump === null) {
223
+ return { changed: false, bump: null, version: null }
224
+ }
225
+
226
+ const npmDir = `${docsRoot}/npm`
227
+ const version = bumpVersion(npmDir, bump)
228
+
229
+ const block = formatChangelogBlock({ version, date, dbSha: dbSha.slice(0, 7), sections, first })
230
+ const changelogPath = `${npmDir}/CHANGELOG.md`
231
+ const existingChangelog = readFileSync(changelogPath, 'utf8')
232
+ writeFileSync(changelogPath, prependChangelog(existingChangelog, block))
233
+
234
+ mkdirSync(dirname(oldSchemaPath), { recursive: true })
235
+ writeFileSync(oldSchemaPath, newSdl)
236
+
237
+ return { changed: true, bump, version }
238
+ }
239
+
240
+ /**
241
+ * CLI-обгортка для sync-schema. Приймає параметри як `--key value`.
242
+ *
243
+ * Обовʼязковий:
244
+ * --hasura-url <url> URL GraphQL-ендпоінта (введроспект)
245
+ *
246
+ * Необовʼязкові:
247
+ * --hasura-secret <s> значення `X-Hasura-Admin-Secret`
248
+ * --docs <path> корінь docs-репо (default './docs')
249
+ * --schema-name <file> назва файлу в `npm/schema/` (default 'maya.graphql')
250
+ * --db-sha <sha> SHA коміту db (default 'unknown')
251
+ *
252
+ * @param {string[]} [argv] аргументи (без 'node' та script path). Default — process.argv.slice(2).
253
+ * @returns {Promise<{changed: boolean, bump: string|null, version: string|null}>} результат main()
254
+ */
255
+ export async function cli(argv = process.argv.slice(2)) {
256
+ const { values } = parseArgs({
257
+ args: argv,
258
+ options: {
259
+ 'hasura-url': { type: 'string' },
260
+ 'hasura-secret': { type: 'string' },
261
+ docs: { type: 'string', default: './docs' },
262
+ 'schema-name': { type: 'string', default: 'maya.graphql' },
263
+ 'db-sha': { type: 'string', default: 'unknown' }
264
+ },
265
+ allowPositionals: false,
266
+ strict: true
267
+ })
268
+
269
+ const hasuraUrl = values['hasura-url']
270
+ if (!hasuraUrl) {
271
+ throw new Error('--hasura-url is required')
272
+ }
273
+
274
+ const newSdl = await fetchSdl(hasuraUrl, values['hasura-secret'])
275
+
276
+ const result = await main({
277
+ newSdl,
278
+ docsRoot: values.docs,
279
+ dbSha: values['db-sha'],
280
+ date: new Date().toISOString().slice(0, 10),
281
+ schemaFilename: values['schema-name']
282
+ })
283
+ console.log(JSON.stringify(result, null, 2))
284
+ writeGithubOutput({
285
+ changed: String(result.changed),
286
+ bump: result.bump ?? '',
287
+ version: result.version ?? ''
288
+ })
289
+ return result
290
+ }
@@ -0,0 +1,280 @@
1
+ import { describe, it, expect, afterEach } from 'bun:test'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { buildSchema, graphqlSync } from 'graphql'
6
+ import { classifyChanges, cli, formatChangelogBlock, main, prependChangelog, runInspector } from './main.mjs'
7
+
8
+ const HEADER = `# Changelog\n\nУсі помітні зміни цього пакета документуються тут.\n\nФормат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).\n\n`
9
+ const OLD_SDL = readFileSync(join(import.meta.dir, '__fixtures__/old-schema.graphql'), 'utf8')
10
+ const NEW_SDL = readFileSync(join(import.meta.dir, '__fixtures__/new-schema.graphql'), 'utf8')
11
+
12
+ describe('classifyChanges', () => {
13
+ it('повертає bump=null коли немає змін', () => {
14
+ expect(classifyChanges([])).toEqual({ bump: null, sections: { added: [], removed: [], changed: [] } })
15
+ })
16
+
17
+ it('повертає bump=patch для NON_BREAKING додавань', () => {
18
+ const result = classifyChanges([
19
+ { type: 'FIELD_ADDED', criticality: { level: 'NON_BREAKING' }, message: "Field 'foo' was added to type 'Bar'" }
20
+ ])
21
+ expect(result.bump).toBe('patch')
22
+ expect(result.sections.added).toEqual(["Field 'foo' was added to type 'Bar'"])
23
+ })
24
+
25
+ it('повертає bump=minor для будь-якого BREAKING', () => {
26
+ const result = classifyChanges([
27
+ { type: 'FIELD_REMOVED', criticality: { level: 'BREAKING' }, message: "Field 'old' was removed from type 'User'" },
28
+ { type: 'FIELD_ADDED', criticality: { level: 'NON_BREAKING' }, message: "Field 'new' was added to type 'User'" }
29
+ ])
30
+ expect(result.bump).toBe('minor')
31
+ expect(result.sections.removed).toEqual(["Field 'old' was removed from type 'User'"])
32
+ expect(result.sections.added).toEqual(["Field 'new' was added to type 'User'"])
33
+ })
34
+
35
+ it('повертає bump=patch для DANGEROUS', () => {
36
+ const result = classifyChanges([
37
+ { type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', criticality: { level: 'DANGEROUS' }, message: "Default for arg 'limit' changed" }
38
+ ])
39
+ expect(result.bump).toBe('patch')
40
+ expect(result.sections.changed).toEqual(["Default for arg 'limit' changed"])
41
+ })
42
+
43
+ it('класифікує BREAKING модифікації у `changed`', () => {
44
+ const result = classifyChanges([
45
+ { type: 'TYPE_KIND_CHANGED', criticality: { level: 'BREAKING' }, message: "Type 'Foo' changed kind" }
46
+ ])
47
+ expect(result.sections.changed).toEqual(["Type 'Foo' changed kind"])
48
+ expect(result.sections.removed).toEqual([])
49
+ })
50
+ })
51
+
52
+ describe('formatChangelogBlock', () => {
53
+ const baseInput = { version: '0.1.0', date: '2026-05-11', dbSha: 'a1b2c3d', sections: { added: [], removed: [], changed: [] } }
54
+
55
+ it('завжди містить "Changed" з посиланням на db-SHA', () => {
56
+ const out = formatChangelogBlock(baseInput)
57
+ expect(out).toContain('## [0.1.0] - 2026-05-11')
58
+ expect(out).toContain('### Changed')
59
+ expect(out).toContain('Оновлено GraphQL-схему з Hasura (`db@a1b2c3d`)')
60
+ })
61
+
62
+ it('додає секцію Removed для breaking-видалень', () => {
63
+ const out = formatChangelogBlock({ ...baseInput, sections: { added: [], removed: ["Field 'old' removed"], changed: [] } })
64
+ expect(out).toContain('### Removed')
65
+ expect(out).toContain("- Field 'old' removed")
66
+ })
67
+
68
+ it('додає секцію Added для non-breaking додавань', () => {
69
+ const out = formatChangelogBlock({ ...baseInput, sections: { added: ["Field 'new' added"], removed: [], changed: [] } })
70
+ expect(out).toContain('### Added')
71
+ expect(out).toContain("- Field 'new' added")
72
+ })
73
+
74
+ it('не друкує порожні секції', () => {
75
+ const out = formatChangelogBlock(baseInput)
76
+ expect(out).not.toContain('### Added')
77
+ expect(out).not.toContain('### Removed')
78
+ })
79
+
80
+ it('first-run шаблон, коли first=true', () => {
81
+ const out = formatChangelogBlock({ ...baseInput, first: true })
82
+ expect(out).toContain('Початкове додавання GraphQL-схеми')
83
+ expect(out).not.toContain('Оновлено GraphQL-схему')
84
+ })
85
+
86
+ it('закінчується одним порожнім рядком (для коректного prepend)', () => {
87
+ const out = formatChangelogBlock(baseInput)
88
+ expect(out.endsWith('\n\n')).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe('runInspector', () => {
93
+ it('повертає порожній масив для ідентичних схем', async () => {
94
+ const sdl = `type Query { hello: String }`
95
+ const changes = await runInspector(sdl, sdl)
96
+ expect(changes).toEqual([])
97
+ })
98
+
99
+ it('детектить BREAKING при видаленні поля', async () => {
100
+ const oldSdl = `type Query { hello: String, bye: String }`
101
+ const newSdl = `type Query { hello: String }`
102
+ const changes = await runInspector(oldSdl, newSdl)
103
+ const breaking = changes.find(c => c.criticality.level === 'BREAKING')
104
+ expect(breaking).toBeDefined()
105
+ expect(breaking.type).toBe('FIELD_REMOVED')
106
+ })
107
+
108
+ it('детектить NON_BREAKING при додаванні поля', async () => {
109
+ const oldSdl = `type Query { hello: String }`
110
+ const newSdl = `type Query { hello: String, newField: Int }`
111
+ const changes = await runInspector(oldSdl, newSdl)
112
+ const nb = changes.find(c => c.type === 'FIELD_ADDED')
113
+ expect(nb).toBeDefined()
114
+ expect(nb.criticality.level).toBe('NON_BREAKING')
115
+ })
116
+ })
117
+
118
+ describe('prependChangelog', () => {
119
+ it('вставляє новий блок між хедером і першим існуючим записом', () => {
120
+ const existing = HEADER + '## [0.0.2] - 2026-05-11\n\n### Added\n\n- щось.\n'
121
+ const block = '## [0.0.3] - 2026-05-12\n\n### Changed\n\n- нове.\n\n'
122
+ const out = prependChangelog(existing, block)
123
+ expect(out.indexOf('## [0.0.3]')).toBeLessThan(out.indexOf('## [0.0.2]'))
124
+ expect(out.startsWith('# Changelog')).toBe(true)
125
+ })
126
+
127
+ it('додає блок після хедера, якщо записів ще нема', () => {
128
+ const block = '## [0.0.1] - 2026-05-11\n\n### Added\n\n- перший запис.\n\n'
129
+ const out = prependChangelog(HEADER, block)
130
+ expect(out).toBe(HEADER + block)
131
+ })
132
+ })
133
+
134
+ describe('main (e2e via fixtures)', () => {
135
+ let tmp
136
+
137
+ function setupTmpDocs({ withOldSchema = true, schemaFilename = 'maya.graphql' } = {}) {
138
+ tmp = mkdtempSync(join(tmpdir(), 'sync-schema-'))
139
+ const npmDir = join(tmp, 'npm')
140
+ mkdirSync(npmDir, { recursive: true })
141
+ writeFileSync(join(npmDir, 'package.json'), JSON.stringify({ name: '@nitra/efes-docs', version: '0.0.2' }, null, 2))
142
+ writeFileSync(join(npmDir, 'CHANGELOG.md'), HEADER + '## [0.0.2] - 2026-05-10\n\n### Added\n\n- Базовий каркас.\n')
143
+ if (withOldSchema) {
144
+ mkdirSync(join(npmDir, 'schema'), { recursive: true })
145
+ writeFileSync(join(npmDir, 'schema', schemaFilename), OLD_SDL)
146
+ }
147
+ return { docsRoot: tmp, npmDir }
148
+ }
149
+
150
+ afterEach(() => {
151
+ if (tmp && existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
152
+ })
153
+
154
+ it('NON_BREAKING зміна → bump patch, CHANGELOG.Added', async () => {
155
+ const { docsRoot, npmDir } = setupTmpDocs()
156
+ const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
157
+ console.log(JSON.stringify(result, null, 2))
158
+
159
+ expect(result.changed).toBe(true)
160
+ expect(result.version).toBe('0.0.3')
161
+
162
+ const changelog = readFileSync(join(npmDir, 'CHANGELOG.md'), 'utf8')
163
+ expect(changelog).toContain('## [0.0.3] - 2026-05-11')
164
+ expect(changelog.indexOf('## [0.0.3]')).toBeLessThan(changelog.indexOf('## [0.0.2]'))
165
+
166
+ const schema = readFileSync(join(npmDir, 'schema/maya.graphql'), 'utf8')
167
+ expect(schema).toBe(NEW_SDL)
168
+ })
169
+
170
+ it('однакові схеми → changed=false, нічого не записує', async () => {
171
+ const { docsRoot, npmDir } = setupTmpDocs()
172
+ const result = await main({ newSdl: OLD_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
173
+ console.log(JSON.stringify(result, null, 2))
174
+
175
+ expect(result.changed).toBe(false)
176
+ expect(result.bump).toBeNull()
177
+
178
+ const pkg = JSON.parse(readFileSync(join(npmDir, 'package.json'), 'utf8'))
179
+ expect(pkg.version).toBe('0.0.2')
180
+ })
181
+
182
+ it('first-run (нема старої схеми) → bump patch, CHANGELOG з "Початкове додавання"', async () => {
183
+ const { docsRoot, npmDir } = setupTmpDocs({ withOldSchema: false })
184
+ const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11' })
185
+ console.log(JSON.stringify(result, null, 2))
186
+
187
+ expect(result.changed).toBe(true)
188
+ expect(result.bump).toBe('patch')
189
+ expect(result.version).toBe('0.0.3')
190
+
191
+ const changelog = readFileSync(join(npmDir, 'CHANGELOG.md'), 'utf8')
192
+ expect(changelog).toContain('Початкове додавання GraphQL-схеми')
193
+ expect(existsSync(join(npmDir, 'schema/maya.graphql'))).toBe(true)
194
+ })
195
+
196
+ it('кастомний schemaFilename (smart.graphql) — записує саме його', async () => {
197
+ const { docsRoot, npmDir } = setupTmpDocs({ schemaFilename: 'smart.graphql' })
198
+ const result = await main({ newSdl: NEW_SDL, docsRoot, dbSha: 'abc1234567', date: '2026-05-11', schemaFilename: 'smart.graphql' })
199
+
200
+ expect(result.changed).toBe(true)
201
+ expect(existsSync(join(npmDir, 'schema/smart.graphql'))).toBe(true)
202
+ expect(existsSync(join(npmDir, 'schema/maya.graphql'))).toBe(false)
203
+ })
204
+ })
205
+
206
+ describe('cli (тільки args)', () => {
207
+ let tmp
208
+ let server
209
+ let receivedHeaders
210
+
211
+ function setupBareDocs() {
212
+ tmp = mkdtempSync(join(tmpdir(), 'sync-schema-cli-'))
213
+ const npmDir = join(tmp, 'npm')
214
+ mkdirSync(npmDir, { recursive: true })
215
+ writeFileSync(join(npmDir, 'package.json'), JSON.stringify({ name: '@nitra/x-docs', version: '0.0.2' }, null, 2))
216
+ writeFileSync(join(npmDir, 'CHANGELOG.md'), HEADER + '## [0.0.2] - 2026-05-10\n\n### Added\n\n- щось.\n')
217
+ return tmp
218
+ }
219
+
220
+ function startMockHasura(sdl) {
221
+ const schema = buildSchema(sdl)
222
+ server = Bun.serve({
223
+ port: 0,
224
+ async fetch(req) {
225
+ receivedHeaders = req.headers
226
+ const { query } = await req.json()
227
+ const result = graphqlSync({ schema, source: query })
228
+ return Response.json(result)
229
+ }
230
+ })
231
+ return `http://localhost:${server.port}/v1/graphql`
232
+ }
233
+
234
+ afterEach(() => {
235
+ if (server) {
236
+ server.stop()
237
+ server = undefined
238
+ }
239
+ receivedHeaders = undefined
240
+ if (tmp && existsSync(tmp)) rmSync(tmp, { recursive: true, force: true })
241
+ })
242
+
243
+ it('інтроспектить Hasura через --hasura-url і синкає схему', async () => {
244
+ const docsRoot = setupBareDocs()
245
+ const url = startMockHasura(NEW_SDL)
246
+ const result = await cli(['--hasura-url', url, '--docs', docsRoot, '--schema-name', 'test.graphql', '--db-sha', 'abcdef1234'])
247
+
248
+ expect(result.changed).toBe(true)
249
+ expect(result.bump).toBe('patch')
250
+ expect(existsSync(join(docsRoot, 'npm', 'schema', 'test.graphql'))).toBe(true)
251
+ })
252
+
253
+ it('передає --hasura-secret як X-Hasura-Admin-Secret заголовок', async () => {
254
+ const docsRoot = setupBareDocs()
255
+ const url = startMockHasura(NEW_SDL)
256
+ await cli(['--hasura-url', url, '--hasura-secret', 'super-secret', '--docs', docsRoot])
257
+
258
+ expect(receivedHeaders.get('x-hasura-admin-secret')).toBe('super-secret')
259
+ })
260
+
261
+ it('без --hasura-secret заголовок не надсилається', async () => {
262
+ const docsRoot = setupBareDocs()
263
+ const url = startMockHasura(NEW_SDL)
264
+ await cli(['--hasura-url', url, '--docs', docsRoot])
265
+
266
+ expect(receivedHeaders.get('x-hasura-admin-secret')).toBeNull()
267
+ })
268
+
269
+ it('схема за замовчуванням — maya.graphql', async () => {
270
+ const docsRoot = setupBareDocs()
271
+ const url = startMockHasura(NEW_SDL)
272
+ await cli(['--hasura-url', url, '--docs', docsRoot])
273
+
274
+ expect(existsSync(join(docsRoot, 'npm', 'schema', 'maya.graphql'))).toBe(true)
275
+ })
276
+
277
+ it('викидає якщо не передано --hasura-url', () => {
278
+ expect(cli(['--docs', '/tmp/nowhere'])).rejects.toThrow('--hasura-url is required')
279
+ })
280
+ })
@@ -0,0 +1 @@
1
+ export {}