@nitra/ci-docs 0.0.2 → 1.0.1

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 CHANGED
@@ -4,9 +4,35 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
- ## [0.0.2] - 2026-05-12
7
+ ## [1.0.1] - 2026-05-13
8
+
9
+ ### Changed
10
+
11
+ - `writeGithubOutput()` тепер бере `GITHUB_OUTPUT` через `env` з `node:process` (вимога `n-js-run`).
12
+ - JSDoc-описи для helper-функцій у тестах (`setupTmpDocs`, `setupBareDocs`, `startMockGraphql`) — повний набір `@param`/`@returns`.
13
+ - `cli.mjs` рефакторено з `process.exit()` на `process.exitCode` (вимога `n-no-process-exit`).
14
+
15
+ ### Added
16
+
17
+ - `tsconfig.emit-types.json` для генерації `.d.mts` без штучного `src/index.js` (Variant B з `n-npm-module`).
18
+ - Локальні регулярки у тестах винесено в module-scope константи (вимога `e18e/prefer-static-regex`).
19
+
20
+ ## [1.0.0] - 2026-05-12
8
21
 
9
22
  ### Added
10
23
 
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`).
24
+ - CLI `ci-docs sync-schema` — інтроспектить **будь-який** GraphQL-ендпоінт, рахує SemVer-bump через `graphql-inspector`, оновлює CHANGELOG і пише SDL у `npm/schema/`.
25
+ - `--endpoint <url>` (обовʼязковий) GraphQL-ендпоінт.
26
+ - `--header "K: V"` (повторюваний) — будь-які HTTP-заголовки (Hasura admin secret, Bearer token тощо).
27
+ - `--docs <path>` (default `./docs`), `--schema-name <file>` (default `maya.graphql`), `--source-ref <text>` (default `unknown`).
28
+ - CLI `ci-docs commit-push` — git add/commit/push для перелічених файлів.
29
+ - `--repo <path>`, `--message <msg>`, `--file <path>` (повторюваний), `--author-name`, `--author-email` — обовʼязкові.
30
+ - `--branch <name>` (default `main`), `--remote <name>` (default `origin`) — опціональні.
31
+ - Якщо staged-зміни відсутні, ні коміту, ні push не буде.
32
+
33
+ ### Changed
34
+
35
+ - (BREAKING) Перейменовано CLI-аргументи `sync-schema`: `--hasura-url` → `--endpoint`, `--hasura-secret` → `--header`, `--db-sha` → `--source-ref`.
36
+ - (BREAKING) Текст CHANGELOG, що генерується, більше не згадує Hasura: "Оновлено GraphQL-схему (`<ref>`)." та "Початкове додавання GraphQL-схеми.".
37
+ - (BREAKING) `main()` та `formatChangelogBlock()` приймають `sourceRef` замість `dbSha`. `fetchSdl(endpoint, headers)` — headers як обʼєкт замість окремого `adminSecret`.
38
+ - Видалено CLI-аргумент `--new-schema` зі `sync-schema` (живий ендпоінт — єдина точка входу).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/ci-docs",
3
- "version": "0.0.2",
3
+ "version": "1.0.1",
4
4
  "description": "Nitra CI tooling: sync GraphQL schema to a docs npm package (and more to come).",
5
5
  "homepage": "https://github.com/nitra/ci-docs",
6
6
  "bugs": {
@@ -12,15 +12,18 @@
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/nitra/ci-docs.git"
14
14
  },
15
+ "bin": {
16
+ "ci-docs": "./src/cli.mjs"
17
+ },
15
18
  "files": [
16
19
  "src",
17
20
  "types",
18
21
  "CHANGELOG.md"
19
22
  ],
20
23
  "type": "module",
21
- "types": "./types/index.d.ts",
22
- "bin": {
23
- "ci-docs": "./src/cli.mjs"
24
+ "types": "./types/cli.d.mts",
25
+ "publishConfig": {
26
+ "access": "public"
24
27
  },
25
28
  "dependencies": {
26
29
  "@graphql-inspector/core": "^6.2.1",
@@ -29,11 +32,5 @@
29
32
  "engines": {
30
33
  "bun": ">=1.3",
31
34
  "node": ">=24"
32
- },
33
- "publishConfig": {
34
- "access": "public"
35
- },
36
- "devDependencies": {
37
- "@nitra/cursor": "^1.9.4"
38
35
  }
39
36
  }
package/src/cli.mjs CHANGED
@@ -5,11 +5,13 @@
5
5
  * Запуск: `npx -y @nitra/ci-docs <subcommand> [args...]`
6
6
  *
7
7
  * Доступні subcommands:
8
- * sync-schema — інтроспект Hasura → bump → CHANGELOG → копіювання SDL
8
+ * sync-schema — інтроспект GraphQL-ендпоінта → bump → CHANGELOG → запис SDL
9
+ * commit-push — git add/commit/push для оновлених файлів
9
10
  */
10
11
 
11
12
  const SUBCOMMANDS = {
12
- 'sync-schema': () => import('./sync-schema/main.mjs').then(m => m.cli)
13
+ 'sync-schema': () => import('./sync-schema/main.mjs').then(m => m.cli),
14
+ 'commit-push': () => import('./commit-push/main.mjs').then(m => m.cli)
13
15
  }
14
16
 
15
17
  const [subcommand, ...rest] = process.argv.slice(2)
@@ -21,15 +23,15 @@ Subcommands:
21
23
  ${Object.keys(SUBCOMMANDS).join('\n ')}
22
24
 
23
25
  Run \`ci-docs <subcommand> --help\` for subcommand-specific options.`)
24
- process.exit(subcommand ? 0 : 2)
26
+ process.exitCode = subcommand ? 0 : 2
27
+ } else {
28
+ const loader = SUBCOMMANDS[subcommand]
29
+ if (loader) {
30
+ const run = await loader()
31
+ await run(rest)
32
+ } else {
33
+ console.error(`Unknown subcommand: ${subcommand}`)
34
+ console.error(`Available: ${Object.keys(SUBCOMMANDS).join(', ')}`)
35
+ process.exitCode = 2
36
+ }
25
37
  }
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,100 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { parseArgs } from 'node:util'
3
+
4
+ /**
5
+ * Викликає `git` як зовнішній процес з фіксованим CWD.
6
+ * @param {string} cwd робоча директорія
7
+ * @param {string[]} args аргументи `git`
8
+ * @returns {string} stdout (UTF-8, без trailing newline)
9
+ */
10
+ function git(cwd, args) {
11
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8' }).trimEnd()
12
+ }
13
+
14
+ /**
15
+ * Перевіряє чи є хоч якісь стейджовані зміни.
16
+ * @param {string} repo шлях до робочого дерева git-репо
17
+ * @returns {boolean} true якщо є staged-зміни (diff --cached не порожній)
18
+ */
19
+ function hasStagedChanges(repo) {
20
+ try {
21
+ execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: repo, stdio: 'ignore' })
22
+ return false
23
+ } catch {
24
+ return true
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Стейджить файли, робить commit + push. Якщо файли не несуть змін — нічого не комітить і не пушить.
30
+ * @param {{repo: string, files: string[], message: string, authorName: string, authorEmail: string, branch?: string, remote?: string}} params параметри
31
+ * @returns {{committed: boolean, sha: string|null}} результат: чи був коміт і його SHA
32
+ */
33
+ export function main({ repo, files, message, authorName, authorEmail, branch = 'main', remote = 'origin' }) {
34
+ if (!files.length) throw new Error('At least one --file is required')
35
+
36
+ git(repo, ['config', 'user.name', authorName])
37
+ git(repo, ['config', 'user.email', authorEmail])
38
+
39
+ git(repo, ['add', '--', ...files])
40
+
41
+ if (!hasStagedChanges(repo)) {
42
+ return { committed: false, sha: null }
43
+ }
44
+
45
+ git(repo, ['commit', '-m', message])
46
+ const sha = git(repo, ['rev-parse', 'HEAD'])
47
+ git(repo, ['push', remote, `HEAD:${branch}`])
48
+
49
+ return { committed: true, sha }
50
+ }
51
+
52
+ /**
53
+ * CLI-обгортка для commit-push. Параметри як `--key value`.
54
+ *
55
+ * Обовʼязкові:
56
+ * --repo <path> шлях до git-репо
57
+ * --message <msg> повідомлення коміту
58
+ * --file <path> повторюваний; шлях файлу від кореня репо (мінімум один)
59
+ * --author-name <name> Git user.name
60
+ * --author-email <email> Git user.email
61
+ *
62
+ * Необовʼязкові:
63
+ * --branch <name> цільова гілка (default 'main')
64
+ * --remote <name> remote (default 'origin')
65
+ * @param {string[]} [argv] аргументи. Default — process.argv.slice(2).
66
+ * @returns {{committed: boolean, sha: string|null}} результат main()
67
+ */
68
+ export function cli(argv = process.argv.slice(2)) {
69
+ const { values } = parseArgs({
70
+ args: argv,
71
+ options: {
72
+ repo: { type: 'string' },
73
+ message: { type: 'string' },
74
+ file: { type: 'string', multiple: true, default: [] },
75
+ 'author-name': { type: 'string' },
76
+ 'author-email': { type: 'string' },
77
+ branch: { type: 'string', default: 'main' },
78
+ remote: { type: 'string', default: 'origin' }
79
+ },
80
+ allowPositionals: false,
81
+ strict: true
82
+ })
83
+
84
+ for (const key of ['repo', 'message', 'author-name', 'author-email']) {
85
+ if (!values[key]) throw new Error(`--${key} is required`)
86
+ }
87
+ if (values.file.length === 0) throw new Error('At least one --file is required')
88
+
89
+ const result = main({
90
+ repo: values.repo,
91
+ files: values.file,
92
+ message: values.message,
93
+ authorName: values['author-name'],
94
+ authorEmail: values['author-email'],
95
+ branch: values.branch,
96
+ remote: values.remote
97
+ })
98
+ console.log(JSON.stringify(result, null, 2))
99
+ return result
100
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
+ import { env } from 'node:process'
3
4
  import { parseArgs } from 'node:util'
4
5
  import { buildSchema, buildClientSchema, printSchema, getIntrospectionQuery } from 'graphql'
5
6
  import { diff } from '@graphql-inspector/core'
@@ -49,17 +50,17 @@ export function classifyChanges(changes) {
49
50
 
50
51
  /**
51
52
  * Будує markdown-блок changelog для версії.
52
- * @param {{version: string, date: string, dbSha: string, sections: {added: string[], removed: string[], changed: string[]}, first?: boolean}} params параметри блоку
53
+ * @param {{version: string, date: string, sourceRef: string, sections: {added: string[], removed: string[], changed: string[]}, first?: boolean}} params параметри блоку
53
54
  * @returns {string} markdown-блок з трейлінговим порожнім рядком
54
55
  */
55
- export function formatChangelogBlock({ version, date, dbSha, sections, first = false }) {
56
+ export function formatChangelogBlock({ version, date, sourceRef, sections, first = false }) {
56
57
  const lines = [`## [${version}] - ${date}`, '']
57
58
 
58
59
  const changed = [...sections.changed]
59
60
  if (first) {
60
- changed.unshift('Початкове додавання GraphQL-схеми з Hasura.')
61
+ changed.unshift('Початкове додавання GraphQL-схеми.')
61
62
  } else {
62
- changed.unshift(`Оновлено GraphQL-схему з Hasura (\`db@${dbSha}\`).`)
63
+ changed.unshift(`Оновлено GraphQL-схему (\`${sourceRef}\`).`)
63
64
  }
64
65
 
65
66
  if (sections.added.length > 0) {
@@ -178,25 +179,22 @@ export function bumpVersion(npmDir, kind) {
178
179
  * @returns {void}
179
180
  */
180
181
  export function writeGithubOutput(values) {
181
- const outPath = process.env.GITHUB_OUTPUT
182
+ const outPath = env.GITHUB_OUTPUT
182
183
  if (!outPath) return
183
184
  const lines = Object.entries(values).map(([k, v]) => `${k}=${v}`)
184
185
  appendFileSync(outPath, lines.join('\n') + '\n')
185
186
  }
186
187
 
187
188
  /**
188
- * Шле introspection-запит до Hasura і повертає SDL-рядок.
189
- * @param {string} hasuraUrl URL ендпоінта GraphQL
190
- * @param {string} [adminSecret] значення `X-Hasura-Admin-Secret` (опціонально для публічних ендпоінтів)
189
+ * Шле introspection-запит до GraphQL-ендпоінта і повертає SDL-рядок.
190
+ * @param {string} endpoint URL GraphQL-ендпоінта
191
+ * @param {Record<string, string>} [headers] додаткові HTTP-заголовки (наприклад `{ 'X-Hasura-Admin-Secret': '...' }`)
191
192
  * @returns {Promise<string>} SDL-схема у вигляді рядка
192
193
  */
193
- export async function fetchSdl(hasuraUrl, adminSecret) {
194
- const res = await fetch(hasuraUrl, {
194
+ export async function fetchSdl(endpoint, headers = {}) {
195
+ const res = await fetch(endpoint, {
195
196
  method: 'POST',
196
- headers: {
197
- 'Content-Type': 'application/json',
198
- ...(adminSecret ? { 'X-Hasura-Admin-Secret': adminSecret } : {})
199
- },
197
+ headers: { 'Content-Type': 'application/json', ...headers },
200
198
  body: JSON.stringify({ query: getIntrospectionQuery() })
201
199
  })
202
200
  if (!res.ok) throw new Error(`Introspection failed: ${res.status} ${res.statusText}`)
@@ -205,12 +203,23 @@ export async function fetchSdl(hasuraUrl, adminSecret) {
205
203
  return printSchema(buildClientSchema(data))
206
204
  }
207
205
 
206
+ /**
207
+ * Парсить рядок `Key: Value` у пару `[key, value]`. Помилка, якщо нема `:`.
208
+ * @param {string} raw сирий header
209
+ * @returns {[string, string]} key, value (обрізані з обох боків)
210
+ */
211
+ export function parseHeader(raw) {
212
+ const idx = raw.indexOf(':')
213
+ if (idx === -1) throw new Error(`Invalid --header value (expected "Key: Value"): ${raw}`)
214
+ return [raw.slice(0, idx).trim(), raw.slice(idx + 1).trim()]
215
+ }
216
+
208
217
  /**
209
218
  * Оркеструє весь flow: diff схем → bump → CHANGELOG → запис SDL.
210
- * @param {{newSdl: string, docsRoot: string, dbSha: string, date: string, schemaFilename?: string}} params параметри запуску
219
+ * @param {{newSdl: string, docsRoot: string, sourceRef: string, date: string, schemaFilename?: string}} params параметри запуску
211
220
  * @returns {Promise<{changed: boolean, bump: 'minor'|'patch'|null, version: string|null}>} результат
212
221
  */
213
- export async function main({ newSdl, docsRoot, dbSha, date, schemaFilename = 'maya.graphql' }) {
222
+ export async function main({ newSdl, docsRoot, sourceRef, date, schemaFilename = 'maya.graphql' }) {
214
223
  const oldSchemaPath = `${docsRoot}/npm/schema/${schemaFilename}`
215
224
  const oldSdl = readSdl(oldSchemaPath)
216
225
 
@@ -226,7 +235,7 @@ export async function main({ newSdl, docsRoot, dbSha, date, schemaFilename = 'ma
226
235
  const npmDir = `${docsRoot}/npm`
227
236
  const version = bumpVersion(npmDir, bump)
228
237
 
229
- const block = formatChangelogBlock({ version, date, dbSha: dbSha.slice(0, 7), sections, first })
238
+ const block = formatChangelogBlock({ version, date, sourceRef, sections, first })
230
239
  const changelogPath = `${npmDir}/CHANGELOG.md`
231
240
  const existingChangelog = readFileSync(changelogPath, 'utf8')
232
241
  writeFileSync(changelogPath, prependChangelog(existingChangelog, block))
@@ -241,14 +250,14 @@ export async function main({ newSdl, docsRoot, dbSha, date, schemaFilename = 'ma
241
250
  * CLI-обгортка для sync-schema. Приймає параметри як `--key value`.
242
251
  *
243
252
  * Обовʼязковий:
244
- * --hasura-url <url> URL GraphQL-ендпоінта (введроспект)
253
+ * --endpoint <url> URL GraphQL-ендпоінта для introspection
245
254
  *
246
255
  * Необовʼязкові:
247
- * --hasura-secret <s> значення `X-Hasura-Admin-Secret`
256
+ * --header "K: V" HTTP-заголовок (повторюваний; наприклад: `--header "X-Hasura-Admin-Secret: ..."`,
257
+ * `--header "Authorization: Bearer ..."`)
248
258
  * --docs <path> корінь docs-репо (default './docs')
249
259
  * --schema-name <file> назва файлу в `npm/schema/` (default 'maya.graphql')
250
- * --db-sha <sha> SHA коміту db (default 'unknown')
251
- *
260
+ * --source-ref <ref> текст, що йде у CHANGELOG як посилання на джерело (default 'unknown')
252
261
  * @param {string[]} [argv] аргументи (без 'node' та script path). Default — process.argv.slice(2).
253
262
  * @returns {Promise<{changed: boolean, bump: string|null, version: string|null}>} результат main()
254
263
  */
@@ -256,27 +265,28 @@ export async function cli(argv = process.argv.slice(2)) {
256
265
  const { values } = parseArgs({
257
266
  args: argv,
258
267
  options: {
259
- 'hasura-url': { type: 'string' },
260
- 'hasura-secret': { type: 'string' },
268
+ endpoint: { type: 'string' },
269
+ header: { type: 'string', multiple: true, default: [] },
261
270
  docs: { type: 'string', default: './docs' },
262
271
  'schema-name': { type: 'string', default: 'maya.graphql' },
263
- 'db-sha': { type: 'string', default: 'unknown' }
272
+ 'source-ref': { type: 'string', default: 'unknown' }
264
273
  },
265
274
  allowPositionals: false,
266
275
  strict: true
267
276
  })
268
277
 
269
- const hasuraUrl = values['hasura-url']
270
- if (!hasuraUrl) {
271
- throw new Error('--hasura-url is required')
278
+ const endpoint = values.endpoint
279
+ if (!endpoint) {
280
+ throw new Error('--endpoint is required')
272
281
  }
273
282
 
274
- const newSdl = await fetchSdl(hasuraUrl, values['hasura-secret'])
283
+ const headers = Object.fromEntries(values.header.map(h => parseHeader(h)))
284
+ const newSdl = await fetchSdl(endpoint, headers)
275
285
 
276
286
  const result = await main({
277
287
  newSdl,
278
288
  docsRoot: values.docs,
279
- dbSha: values['db-sha'],
289
+ sourceRef: values['source-ref'],
280
290
  date: new Date().toISOString().slice(0, 10),
281
291
  schemaFilename: values['schema-name']
282
292
  })
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Стейджить файли, робить commit + push. Якщо файли не несуть змін — нічого не комітить і не пушить.
3
+ * @param {{repo: string, files: string[], message: string, authorName: string, authorEmail: string, branch?: string, remote?: string}} params параметри
4
+ * @returns {{committed: boolean, sha: string|null}} результат: чи був коміт і його SHA
5
+ */
6
+ export function main({
7
+ repo,
8
+ files,
9
+ message,
10
+ authorName,
11
+ authorEmail,
12
+ branch,
13
+ remote
14
+ }: {
15
+ repo: string
16
+ files: string[]
17
+ message: string
18
+ authorName: string
19
+ authorEmail: string
20
+ branch?: string
21
+ remote?: string
22
+ }): {
23
+ committed: boolean
24
+ sha: string | null
25
+ }
26
+ /**
27
+ * CLI-обгортка для commit-push. Параметри як `--key value`.
28
+ *
29
+ * Обовʼязкові:
30
+ * --repo <path> шлях до git-репо
31
+ * --message <msg> повідомлення коміту
32
+ * --file <path> повторюваний; шлях файлу від кореня репо (мінімум один)
33
+ * --author-name <name> Git user.name
34
+ * --author-email <email> Git user.email
35
+ *
36
+ * Необовʼязкові:
37
+ * --branch <name> цільова гілка (default 'main')
38
+ * --remote <name> remote (default 'origin')
39
+ * @param {string[]} [argv] аргументи. Default — process.argv.slice(2).
40
+ * @returns {{committed: boolean, sha: string|null}} результат main()
41
+ */
42
+ export function cli(argv?: string[]): {
43
+ committed: boolean
44
+ sha: string | null
45
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Класифікує зміни схеми у секції changelog та визначає тип bump.
3
+ * @param {Array<{type?: string, message: string, criticality?: {level: string}}>} changes масив змін від graphql-inspector
4
+ * @returns {{bump: 'minor'|'patch'|null, sections: {added: string[], removed: string[], changed: string[]}}} тип bump та секції changelog
5
+ */
6
+ export function classifyChanges(
7
+ changes: Array<{
8
+ type?: string
9
+ message: string
10
+ criticality?: {
11
+ level: string
12
+ }
13
+ }>
14
+ ): {
15
+ bump: 'minor' | 'patch' | null
16
+ sections: {
17
+ added: string[]
18
+ removed: string[]
19
+ changed: string[]
20
+ }
21
+ }
22
+ /**
23
+ * Будує markdown-блок changelog для версії.
24
+ * @param {{version: string, date: string, sourceRef: string, sections: {added: string[], removed: string[], changed: string[]}, first?: boolean}} params параметри блоку
25
+ * @returns {string} markdown-блок з трейлінговим порожнім рядком
26
+ */
27
+ export function formatChangelogBlock({
28
+ version,
29
+ date,
30
+ sourceRef,
31
+ sections,
32
+ first
33
+ }: {
34
+ version: string
35
+ date: string
36
+ sourceRef: string
37
+ sections: {
38
+ added: string[]
39
+ removed: string[]
40
+ changed: string[]
41
+ }
42
+ first?: boolean
43
+ }): string
44
+ /**
45
+ * Вставляє новий блок changelog перед першим існуючим записом (або після хедера, якщо записів нема).
46
+ * @param {string} existing вміст CHANGELOG.md
47
+ * @param {string} newBlock новий блок версії
48
+ * @returns {string} оновлений вміст
49
+ */
50
+ export function prependChangelog(existing: string, newBlock: string): string
51
+ /**
52
+ * Запускає graphql-inspector diff між двома SDL.
53
+ * @param {string} oldSdl стара GraphQL-схема (SDL)
54
+ * @param {string} newSdl нова GraphQL-схема (SDL)
55
+ * @returns {Promise<Array<{type: string, message: string, criticality: {level: string}}>>} список змін
56
+ */
57
+ export function runInspector(
58
+ oldSdl: string,
59
+ newSdl: string
60
+ ): Promise<
61
+ Array<{
62
+ type: string
63
+ message: string
64
+ criticality: {
65
+ level: string
66
+ }
67
+ }>
68
+ >
69
+ /**
70
+ * Читає SDL з файлу або повертає null, якщо файл відсутній/порожній.
71
+ * @param {string} path шлях до SDL
72
+ * @returns {string|null} вміст або null
73
+ */
74
+ export function readSdl(path: string): string | null
75
+ /**
76
+ * Підіймає версію у package.json напряму (без виклику зовнішнього `npm`).
77
+ * @param {string} npmDir шлях до директорії пакета
78
+ * @param {'major'|'minor'|'patch'} kind тип bump
79
+ * @returns {string} нова версія
80
+ */
81
+ export function bumpVersion(npmDir: string, kind: 'major' | 'minor' | 'patch'): string
82
+ /**
83
+ * Пише пари key=value у файл, на який вказує `GITHUB_OUTPUT`.
84
+ * @param {Record<string, string>} values пари для запису
85
+ * @returns {void}
86
+ */
87
+ export function writeGithubOutput(values: Record<string, string>): void
88
+ /**
89
+ * Шле introspection-запит до GraphQL-ендпоінта і повертає SDL-рядок.
90
+ * @param {string} endpoint URL GraphQL-ендпоінта
91
+ * @param {Record<string, string>} [headers] додаткові HTTP-заголовки (наприклад `{ 'X-Hasura-Admin-Secret': '...' }`)
92
+ * @returns {Promise<string>} SDL-схема у вигляді рядка
93
+ */
94
+ export function fetchSdl(endpoint: string, headers?: Record<string, string>): Promise<string>
95
+ /**
96
+ * Парсить рядок `Key: Value` у пару `[key, value]`. Помилка, якщо нема `:`.
97
+ * @param {string} raw сирий header
98
+ * @returns {[string, string]} key, value (обрізані з обох боків)
99
+ */
100
+ export function parseHeader(raw: string): [string, string]
101
+ /**
102
+ * Оркеструє весь flow: diff схем → bump → CHANGELOG → запис SDL.
103
+ * @param {{newSdl: string, docsRoot: string, sourceRef: string, date: string, schemaFilename?: string}} params параметри запуску
104
+ * @returns {Promise<{changed: boolean, bump: 'minor'|'patch'|null, version: string|null}>} результат
105
+ */
106
+ export function main({
107
+ newSdl,
108
+ docsRoot,
109
+ sourceRef,
110
+ date,
111
+ schemaFilename
112
+ }: {
113
+ newSdl: string
114
+ docsRoot: string
115
+ sourceRef: string
116
+ date: string
117
+ schemaFilename?: string
118
+ }): Promise<{
119
+ changed: boolean
120
+ bump: 'minor' | 'patch' | null
121
+ version: string | null
122
+ }>
123
+ /**
124
+ * CLI-обгортка для sync-schema. Приймає параметри як `--key value`.
125
+ *
126
+ * Обовʼязковий:
127
+ * --endpoint <url> URL GraphQL-ендпоінта для introspection
128
+ *
129
+ * Необовʼязкові:
130
+ * --header "K: V" HTTP-заголовок (повторюваний; наприклад: `--header "X-Hasura-Admin-Secret: ..."`,
131
+ * `--header "Authorization: Bearer ..."`)
132
+ * --docs <path> корінь docs-репо (default './docs')
133
+ * --schema-name <file> назва файлу в `npm/schema/` (default 'maya.graphql')
134
+ * --source-ref <ref> текст, що йде у CHANGELOG як посилання на джерело (default 'unknown')
135
+ * @param {string[]} [argv] аргументи (без 'node' та script path). Default — process.argv.slice(2).
136
+ * @returns {Promise<{changed: boolean, bump: string|null, version: string|null}>} результат main()
137
+ */
138
+ export function cli(argv?: string[]): Promise<{
139
+ changed: boolean
140
+ bump: string | null
141
+ version: string | null
142
+ }>
@@ -1,10 +0,0 @@
1
- type Query {
2
- hello: String
3
- newField: Int
4
- }
5
-
6
- type User {
7
- id: ID!
8
- name: String
9
- email: String
10
- }
@@ -1,9 +0,0 @@
1
- type Query {
2
- hello: String
3
- deprecated_field: Int
4
- }
5
-
6
- type User {
7
- id: ID!
8
- name: String
9
- }
@@ -1,280 +0,0 @@
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
- })
package/types/index.d.ts DELETED
@@ -1 +0,0 @@
1
- export {}