@panomc/client-gen 0.0.0-stage → 1.0.0-dev.62

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/src/pull.js ADDED
@@ -0,0 +1,178 @@
1
+ // pull: core document + every active plugin's OpenAPI and controllers from a running Pano (doc 06 §2.4).
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { canonicalJson, shortHash } from './canonical.js'
6
+ import { ClientGenError } from './errors.js'
7
+ import { generateClient } from './generate.js'
8
+ import { getText, isSafeName, namespaceOf, parseJson, trimBase, unreachableMessage } from './http.js'
9
+
10
+ /**
11
+ * @typedef {Object} PullOptions
12
+ * @property {string} url
13
+ * @property {string} [out] default src/lib/pano
14
+ * @property {string[]} [plugins] extra plugin ids that have no UI package
15
+ * @property {string} [cwd]
16
+ * @property {typeof fetch} [fetch]
17
+ * @property {string | null} [installedCore] path of the installed @panomc/client core/openapi.json; default: resolved from cwd
18
+ * @property {(line: string) => void} [log]
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} PullReport
23
+ * @property {'written'|'skipped'} core
24
+ * @property {{ pluginId: string, ns: string, openapi: boolean, controllers: boolean }[]} plugins
25
+ * @property {string[]} warnings
26
+ */
27
+
28
+ /** @param {string} cwd */
29
+ function findInstalledCore(cwd) {
30
+ try {
31
+ return createRequire(join(cwd, 'package.json')).resolve('@panomc/client/core/openapi.json')
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ /** @param {string} dir @param {Record<string, string>} files */
38
+ export function writeFiles(dir, files) {
39
+ for (const [name, content] of Object.entries(files)) {
40
+ const target = join(dir, name)
41
+ mkdirSync(dirname(target), { recursive: true })
42
+ writeFileSync(target, content)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {PullOptions} options
48
+ * @returns {Promise<PullReport>}
49
+ */
50
+ export async function pull(options) {
51
+ if (!options.url) throw new ClientGenError('pull needs --url <pano>, e.g. --url http://localhost:8088')
52
+ const base = trimBase(options.url)
53
+ const cwd = resolve(options.cwd ?? process.cwd())
54
+ const out = resolve(cwd, options.out ?? 'src/lib/pano')
55
+ const log = options.log ?? (() => {})
56
+ const fetchOpts = { fetch: options.fetch, onUnreachable: () => unreachableMessage(base) }
57
+ /** @type {string[]} */
58
+ const warnings = []
59
+ const warn = (/** @type {string} */ m) => {
60
+ warnings.push(m)
61
+ log(`warning: ${m}`)
62
+ }
63
+
64
+ // ---- core
65
+ const coreRes = await getText(`${base}/api/v1/openapi.json`, fetchOpts)
66
+ if (!coreRes.ok) {
67
+ throw new ClientGenError(`Pano answered ${coreRes.status} at ${base}/api/v1/openapi.json. Is this a Pano with the /api/v1 API?`)
68
+ }
69
+ const coreDoc = parseJson(coreRes.text, `${base}/api/v1/openapi.json`)
70
+ const coreCanonical = canonicalJson(coreDoc)
71
+ const installedPath = options.installedCore === undefined ? findInstalledCore(cwd) : options.installedCore
72
+ let coreState = /** @type {'written'|'skipped'} */ ('written')
73
+ if (installedPath && existsSync(installedPath) && readFileSync(installedPath, 'utf8') === coreCanonical) {
74
+ coreState = 'skipped'
75
+ log('core: matches the installed @panomc/client, import from @panomc/client/core')
76
+ } else {
77
+ const gen = generateClient(coreDoc)
78
+ gen.warnings.forEach((w) => warn(`core: ${w}`))
79
+ writeFiles(join(out, 'core'), gen.files)
80
+ log(`core: ${gen.operations.length} operations written to ${join(out, 'core')}`)
81
+ }
82
+
83
+ // ---- plugin package list
84
+ /** @type {Map<string, any>} */
85
+ const packages = new Map()
86
+ const listRes = await getText(`${base}/api/v1/plugin-packages`, fetchOpts)
87
+ if (listRes.ok) {
88
+ const list = parseJson(listRes.text, `${base}/api/v1/plugin-packages`)
89
+ const plugins = list && typeof list.plugins === 'object' && list.plugins ? list.plugins : {}
90
+ for (const id of Object.keys(plugins).sort()) packages.set(id, plugins[id] ?? {})
91
+ } else {
92
+ warn(`${base}/api/v1/plugin-packages answered ${listRes.status}; only plugins named with --plugin are pulled`)
93
+ }
94
+ for (const id of options.plugins ?? []) if (!packages.has(id)) packages.set(id, null)
95
+
96
+ // ---- per plugin
97
+ /** @type {Record<string, { pluginId: string, version: string | null, operationsHash: string | null, controllersHash: string | null }>} */
98
+ const index = {}
99
+ /** @type {PullReport['plugins']} */
100
+ const reported = []
101
+ const indexPath = join(out, 'plugins', 'index.json')
102
+ /** @type {Record<string, any>} */
103
+ let previous = {}
104
+ try {
105
+ previous = JSON.parse(readFileSync(indexPath, 'utf8'))
106
+ } catch {}
107
+
108
+ for (const [pluginId, pkg] of packages) {
109
+ if (!isSafeName(pluginId)) {
110
+ warn(`plugin id "${pluginId}" is not a valid id, skipped`)
111
+ continue
112
+ }
113
+ const ns = pkg && typeof pkg.namespace === 'string' && pkg.namespace ? pkg.namespace : namespaceOf(pluginId)
114
+ if (!isSafeName(ns) || index[ns]) {
115
+ warn(`plugin ${pluginId}: namespace "${ns}" is invalid or already taken, skipped`)
116
+ continue
117
+ }
118
+ const id = encodeURIComponent(pluginId)
119
+ const pluginDir = join(out, 'plugins', ns)
120
+
121
+ // OpenAPI
122
+ let operationsHash = null
123
+ let openapi = false
124
+ const docRes = await getText(`${base}/api/v1/plugins/${id}/_/openapi.json`, fetchOpts)
125
+ if (docRes.ok) {
126
+ const doc = parseJson(docRes.text, `${base}/api/v1/plugins/${pluginId}/_/openapi.json`)
127
+ const gen = generateClient(doc)
128
+ gen.warnings.forEach((w) => warn(`${ns}: ${w}`))
129
+ writeFiles(pluginDir, gen.files)
130
+ operationsHash = shortHash(gen.files['openapi.json'])
131
+ openapi = true
132
+ log(`${ns}: ${gen.operations.length} operations written to ${pluginDir}`)
133
+ } else if (docRes.status === 404) {
134
+ warn(`plugin ${pluginId} ships no OpenAPI (no client generated)`)
135
+ } else {
136
+ warn(`plugin ${pluginId}: ${base}/api/v1/plugins/${pluginId}/_/openapi.json answered ${docRes.status}, no client generated`)
137
+ }
138
+
139
+ // controllers
140
+ let controllersHash = null
141
+ let controllers = false
142
+ if (pkg && pkg.controllers) {
143
+ const mjs = await getText(`${base}/api/v1/plugins/${id}/_/ui/controllers/controllers.mjs`, fetchOpts)
144
+ const types = await getText(`${base}/api/v1/plugins/${id}/_/ui/contract/controllers.types.js`, fetchOpts)
145
+ if (mjs.ok) {
146
+ /** @type {Record<string, string>} */
147
+ const files = { 'controllers.mjs': mjs.text }
148
+ if (types.ok) files['controllers.types.js'] = types.text
149
+ else warn(`plugin ${pluginId}: controllers.types.js answered ${types.status}, controllers are copied without types`)
150
+ writeFiles(join(pluginDir, 'controllers'), files)
151
+ controllersHash = shortHash(mjs.text + '\0' + (types.ok ? types.text : ''))
152
+ controllers = true
153
+ log(`${ns}: controllers copied`)
154
+ } else {
155
+ warn(`plugin ${pluginId}: controllers.mjs answered ${mjs.status}, no controllers copied`)
156
+ }
157
+ }
158
+
159
+ index[ns] = {
160
+ pluginId,
161
+ version: pkg && typeof pkg.version === 'string' ? pkg.version : null,
162
+ operationsHash,
163
+ controllersHash
164
+ }
165
+ reported.push({ pluginId, ns, openapi, controllers })
166
+ }
167
+
168
+ // generated folders of plugins that are gone (only those a previous pull recorded)
169
+ for (const ns of Object.keys(previous)) {
170
+ if (!index[ns] && isSafeName(ns)) rmSync(join(out, 'plugins', ns), { recursive: true, force: true })
171
+ }
172
+
173
+ const sorted = Object.fromEntries(Object.keys(index).sort().map((k) => [k, index[k]]))
174
+ writeFiles(join(out, 'plugins'), { 'index.json': JSON.stringify(sorted, null, 2) + '\n' })
175
+ log(`plugins: ${Object.keys(sorted).length} recorded in ${indexPath}`)
176
+
177
+ return { core: coreState, plugins: reported, warnings }
178
+ }
@@ -0,0 +1,301 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { canonicalJson, shortHash } from './canonical.js'
5
+ import { check } from './check.js'
6
+ import { run } from './cli.js'
7
+ import { ClientGenError } from './errors.js'
8
+ import { pull } from './pull.js'
9
+ import { FIXTURE, tmp } from './test-helpers.js'
10
+
11
+ const core = JSON.parse(readFileSync(FIXTURE, 'utf8'))
12
+
13
+ const marketDoc = {
14
+ openapi: '3.1.0',
15
+ info: { title: 'market', version: '1' },
16
+ servers: [{ url: '/api/plugins/pano-plugin-market' }],
17
+ paths: {
18
+ '/store/products': {
19
+ get: { operationId: 'ListProducts', responses: { 200: { content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Product' } } } } } } } } }
20
+ },
21
+ '/store/products/{id}': {
22
+ get: { operationId: 'GetProduct', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Product' } } } } } }
23
+ }
24
+ },
25
+ components: { schemas: { Product: { type: 'object', required: ['id'], properties: { id: { type: 'integer' }, name: { type: 'string' } } } } }
26
+ }
27
+
28
+ const extraDoc = {
29
+ openapi: '3.1.0',
30
+ servers: [{ url: '/api/plugins/pano-plugin-extra' }],
31
+ paths: { '/ping': { get: { operationId: 'Ping', responses: { 200: { description: 'pong' } } } } }
32
+ }
33
+
34
+ /** the stub's world; tests mutate it */
35
+ function freshWorld() {
36
+ return {
37
+ core: structuredClone(core),
38
+ packages: /** @type {Record<string, any>} */ ({
39
+ 'pano-plugin-market': { format: 1, pluginId: 'pano-plugin-market', namespace: 'market', version: '1.2.0', controllers: 'contract/controllers.json' },
40
+ 'pano-plugin-faq': { format: 1, pluginId: 'pano-plugin-faq', namespace: 'faq', version: '0.3.1' }
41
+ }),
42
+ docs: /** @type {Record<string, any>} */ ({ 'pano-plugin-market': structuredClone(marketDoc), 'pano-plugin-extra': extraDoc }),
43
+ controllers: 'export const controllers = { cart: {} }\n',
44
+ types: '/** @typedef {{ count: number }} CartState */\nexport {};\n',
45
+ requests: /** @type {string[]} */ ([]),
46
+ listBroken: false
47
+ }
48
+ }
49
+
50
+ /** @type {ReturnType<typeof freshWorld>} */
51
+ let world
52
+ /** @type {any} */
53
+ let server
54
+ let base = ''
55
+ const dirs = []
56
+
57
+ beforeAll(() => {
58
+ world = freshWorld()
59
+ server = Bun.serve({
60
+ port: 0,
61
+ hostname: '127.0.0.1',
62
+ fetch(req) {
63
+ const path = new URL(req.url).pathname
64
+ world.requests.push(path)
65
+ const json = (/** @type {any} */ v) => new Response(JSON.stringify(v), { headers: { 'content-type': 'application/json' } })
66
+ if (path === '/api/v1/openapi.json') return json(world.core)
67
+ if (path === '/api/v1/plugin-packages') return world.listBroken ? new Response('nope', { status: 404 }) : json({ plugins: world.packages })
68
+ const m = /^\/api\/v1\/plugins\/([^/]+)\/_\/(.+)$/.exec(path)
69
+ if (m) {
70
+ const [, id, rest] = m
71
+ if (rest === 'openapi.json') return world.docs[id] ? json(world.docs[id]) : new Response('{"error":{"code":"NOT_FOUND"}}', { status: 404 })
72
+ if (id === 'pano-plugin-market' && rest === 'ui/controllers/controllers.mjs') return new Response(world.controllers, { headers: { 'content-type': 'text/javascript' } })
73
+ if (id === 'pano-plugin-market' && rest === 'ui/contract/controllers.types.js') return new Response(world.types)
74
+ }
75
+ return new Response('{"error":{"code":"NOT_FOUND"}}', { status: 404 })
76
+ }
77
+ })
78
+ base = `http://127.0.0.1:${server.port}`
79
+ })
80
+ afterAll(() => {
81
+ server.stop(true)
82
+ for (const d of dirs) rmSync(d, { recursive: true, force: true })
83
+ })
84
+
85
+ const newDir = (/** @type {string} */ n) => {
86
+ const d = tmp(n)
87
+ dirs.push(d)
88
+ return d
89
+ }
90
+
91
+ describe('pull', () => {
92
+ test('core, both plugins, controllers and the index', async () => {
93
+ world = Object.assign(world, freshWorld(), { requests: [] })
94
+ const cwd = newDir('pull')
95
+ const logs = []
96
+ const report = await pull({ url: base + '/', cwd, installedCore: null, log: (l) => logs.push(l) })
97
+ const out = join(cwd, 'src/lib/pano')
98
+
99
+ expect(report.core).toBe('written')
100
+ for (const f of ['index.js', 'types.js', 'operations.js', 'openapi.json']) {
101
+ expect(existsSync(join(out, 'core', f))).toBe(true)
102
+ expect(existsSync(join(out, 'plugins/market', f))).toBe(true)
103
+ }
104
+ expect(readFileSync(join(out, 'core/openapi.json'), 'utf8')).toBe(canonicalJson(core))
105
+ expect(readFileSync(join(out, 'plugins/market/index.js'), 'utf8')).toContain('export const listProducts')
106
+ expect(readFileSync(join(out, 'plugins/market/controllers/controllers.mjs'), 'utf8')).toBe(world.controllers)
107
+ expect(readFileSync(join(out, 'plugins/market/controllers/controllers.types.js'), 'utf8')).toBe(world.types)
108
+
109
+ // faq ships no OpenAPI: no folder, a warning, but it is recorded
110
+ expect(existsSync(join(out, 'plugins/faq'))).toBe(false)
111
+ expect(report.warnings).toContain('plugin pano-plugin-faq ships no OpenAPI (no client generated)')
112
+ expect(logs).toContain('warning: plugin pano-plugin-faq ships no OpenAPI (no client generated)')
113
+
114
+ const index = JSON.parse(readFileSync(join(out, 'plugins/index.json'), 'utf8'))
115
+ expect(Object.keys(index)).toEqual(['faq', 'market'])
116
+ expect(index.faq).toEqual({ pluginId: 'pano-plugin-faq', version: '0.3.1', operationsHash: null, controllersHash: null })
117
+ expect(index.market).toEqual({
118
+ pluginId: 'pano-plugin-market',
119
+ version: '1.2.0',
120
+ operationsHash: shortHash(canonicalJson(marketDoc)),
121
+ controllersHash: shortHash(world.controllers + '\0' + world.types)
122
+ })
123
+ // the plugin documents were read from the plugin paths, the controllers from _/ui
124
+ expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/openapi.json')
125
+ expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/ui/controllers/controllers.mjs')
126
+ expect(world.requests).toContain('/api/v1/plugins/pano-plugin-market/_/ui/contract/controllers.types.js')
127
+ })
128
+
129
+ test('a second pull writes identical bytes', async () => {
130
+ world = Object.assign(world, freshWorld())
131
+ const cwd = newDir('pull2')
132
+ await pull({ url: base, cwd, installedCore: null })
133
+ const read = () => ['core/index.js', 'plugins/market/types.js', 'plugins/index.json'].map((f) => readFileSync(join(cwd, 'src/lib/pano', f), 'utf8'))
134
+ const first = read()
135
+ await pull({ url: base, cwd, installedCore: null })
136
+ expect(read()).toEqual(first)
137
+ })
138
+
139
+ test('core/ is skipped when the installed @panomc/client snapshot is byte-equal', async () => {
140
+ world = Object.assign(world, freshWorld())
141
+ const cwd = newDir('pull-skip')
142
+ const installed = join(cwd, 'installed-openapi.json')
143
+ writeFileSync(installed, canonicalJson(core))
144
+ const report = await pull({ url: base, cwd, installedCore: installed })
145
+ expect(report.core).toBe('skipped')
146
+ expect(existsSync(join(cwd, 'src/lib/pano/core'))).toBe(false)
147
+ expect(existsSync(join(cwd, 'src/lib/pano/plugins/market/index.js'))).toBe(true)
148
+
149
+ writeFileSync(installed, canonicalJson({ ...core, info: { title: 'older', version: '0' } }))
150
+ expect((await pull({ url: base, cwd, installedCore: installed })).core).toBe('written')
151
+ expect(existsSync(join(cwd, 'src/lib/pano/core/index.js'))).toBe(true)
152
+ })
153
+
154
+ test('--plugin adds a plugin that is not in the package list; --out is honoured', async () => {
155
+ world = Object.assign(world, freshWorld())
156
+ const cwd = newDir('pull-plugin')
157
+ const report = await pull({ url: base, cwd, out: 'gen', plugins: ['pano-plugin-extra', 'pano-plugin-market'], installedCore: null })
158
+ expect(report.plugins.map((p) => p.ns).sort()).toEqual(['extra', 'faq', 'market'])
159
+ expect(readFileSync(join(cwd, 'gen/plugins/extra/index.js'), 'utf8')).toContain('export const ping')
160
+ const index = JSON.parse(readFileSync(join(cwd, 'gen/plugins/index.json'), 'utf8'))
161
+ expect(index.extra).toEqual({ pluginId: 'pano-plugin-extra', version: null, operationsHash: shortHash(canonicalJson(extraDoc)), controllersHash: null })
162
+ })
163
+
164
+ test('an extra plugin without OpenAPI warns and a plugin that left is removed on the next pull', async () => {
165
+ world = Object.assign(world, freshWorld())
166
+ const cwd = newDir('pull-gone')
167
+ await pull({ url: base, cwd, installedCore: null })
168
+ expect(existsSync(join(cwd, 'src/lib/pano/plugins/market'))).toBe(true)
169
+ delete world.packages['pano-plugin-market']
170
+ const report = await pull({ url: base, cwd, installedCore: null, plugins: ['pano-plugin-ghost'] })
171
+ expect(report.warnings).toContain('plugin pano-plugin-ghost ships no OpenAPI (no client generated)')
172
+ expect(existsSync(join(cwd, 'src/lib/pano/plugins/market'))).toBe(false)
173
+ })
174
+
175
+ test('unsafe plugin ids are skipped', async () => {
176
+ world = Object.assign(world, freshWorld())
177
+ const cwd = newDir('pull-unsafe')
178
+ const report = await pull({ url: base, cwd, installedCore: null, plugins: ['../evil'] })
179
+ expect(report.warnings.some((w) => w.includes('"../evil" is not a valid id'))).toBe(true)
180
+ expect(existsSync(join(cwd, 'src/lib/evil'))).toBe(false)
181
+ })
182
+
183
+ test('a Pano without the package list still pulls core and named plugins', async () => {
184
+ world = Object.assign(world, freshWorld(), { listBroken: true })
185
+ const cwd = newDir('pull-nolist')
186
+ const report = await pull({ url: base, cwd, installedCore: null, plugins: ['pano-plugin-extra'] })
187
+ world.listBroken = false
188
+ expect(report.warnings.some((w) => w.includes('/api/v1/plugin-packages answered 404'))).toBe(true)
189
+ expect(report.plugins.map((p) => p.ns)).toEqual(['extra'])
190
+ })
191
+
192
+ test('unreachable Pano: the exact message', async () => {
193
+ const dead = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('x') })
194
+ const url = `http://127.0.0.1:${dead.port}`
195
+ dead.stop(true)
196
+ const cwd = newDir('pull-dead')
197
+ await expect(pull({ url, cwd, installedCore: null })).rejects.toThrow(
198
+ `Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`
199
+ )
200
+ const errs = []
201
+ const code = await run(['pull', '--url', url], { cwd, stderr: (s) => errs.push(s) })
202
+ expect(code).toBe(1)
203
+ expect(errs).toEqual([`Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`])
204
+ expect(existsSync(join(cwd, 'src'))).toBe(false)
205
+ })
206
+
207
+ test('missing --url', async () => {
208
+ await expect(pull({ url: '' })).rejects.toBeInstanceOf(ClientGenError)
209
+ })
210
+
211
+ test('cli: pull with --plugin twice', async () => {
212
+ world = Object.assign(world, freshWorld())
213
+ const cwd = newDir('pull-cli')
214
+ const outs = []
215
+ const code = await run(['pull', '--url', base, '--out', 'o', '--plugin', 'pano-plugin-extra', '--plugin', 'pano-plugin-ghost'], { cwd, stdout: (s) => outs.push(s), stderr: () => {} })
216
+ expect(code).toBe(0)
217
+ expect(existsSync(join(cwd, 'o/plugins/extra/index.js'))).toBe(true)
218
+ expect(outs.some((l) => l.includes('plugin pano-plugin-ghost ships no OpenAPI (no client generated)'))).toBe(true)
219
+ })
220
+ })
221
+
222
+ describe('check', () => {
223
+ /** pulls the fresh world into a new dir and returns { cwd } */
224
+ async function pulled() {
225
+ world = Object.assign(world, freshWorld())
226
+ const cwd = newDir('check')
227
+ await pull({ url: base, cwd, installedCore: null })
228
+ return cwd
229
+ }
230
+
231
+ test('unchanged Pano: ok, exit 0', async () => {
232
+ const cwd = await pulled()
233
+ const report = await check({ url: base, cwd, installedCore: null })
234
+ expect(report).toEqual({ ok: true, problems: [], notes: [] })
235
+ const outs = []
236
+ expect(await run(['check', '--url', base, '--dir', 'src/lib/pano'], { cwd, stdout: (s) => outs.push(s), stderr: () => {} })).toBe(0)
237
+ })
238
+
239
+ test('removed and changed operations, a new one is only a note', async () => {
240
+ const cwd = await pulled()
241
+ delete world.core.paths['/api/v1/posts/{url}'].delete
242
+ world.core.paths['/api/v1/posts'].get.parameters.push({ name: 'q', in: 'query', schema: { type: 'string' } })
243
+ world.core.components.schemas.Author.properties.nick = { type: 'string' } // changes GetPost / GetPosts / CreatePost responses
244
+ world.core.paths['/api/v1/hello'] = { get: { operationId: 'Hello', responses: { 200: { description: 'hi' } } } }
245
+ const report = await check({ url: base, cwd, installedCore: null })
246
+ expect(report.ok).toBe(false)
247
+ expect(report.problems).toEqual([
248
+ 'core: changed createPost (POST /api/v1/posts)',
249
+ 'core: removed deletePost (DELETE /api/v1/posts/{url})',
250
+ 'core: changed getPost (GET /api/v1/posts/{url})',
251
+ 'core: changed getPosts (GET /api/v1/posts)'
252
+ ])
253
+ expect(report.notes).toContain('core: new hello (GET /api/v1/hello)')
254
+
255
+ const errs = []
256
+ expect(await run(['check', '--url', base], { cwd, stdout: () => {}, stderr: (s) => errs.push(s) })).toBe(1)
257
+ expect(errs).toContain('core: removed deletePost (DELETE /api/v1/posts/{url})')
258
+ expect(errs.at(-1)).toBe('The generated client is out of date. Run pano-client pull.')
259
+ })
260
+
261
+ test('controllersHash differs -> run pano-client pull', async () => {
262
+ const cwd = await pulled()
263
+ world.controllers += '// new action\n'
264
+ const report = await check({ url: base, cwd, installedCore: null })
265
+ expect(report.ok).toBe(false)
266
+ expect(report.problems).toEqual(['market: controllers changed (run pano-client pull)'])
267
+ })
268
+
269
+ test('plugin operation removed, and a plugin that is gone', async () => {
270
+ const cwd = await pulled()
271
+ delete world.docs['pano-plugin-market'].paths['/store/products']
272
+ let report = await check({ url: base, cwd, installedCore: null })
273
+ expect(report.problems).toEqual(['market: removed listProducts (GET /api/plugins/pano-plugin-market/store/products)'])
274
+
275
+ delete world.packages['pano-plugin-market']
276
+ delete world.docs['pano-plugin-market']
277
+ report = await check({ url: base, cwd, installedCore: null })
278
+ expect(report.problems).toEqual(['market: plugin pano-plugin-market is no longer active on this Pano (run pano-client pull)'])
279
+ })
280
+
281
+ test('core snapshot comes from the installed package when core/ was skipped', async () => {
282
+ world = Object.assign(world, freshWorld())
283
+ const cwd = newDir('check-installed')
284
+ const installed = join(cwd, 'installed.json')
285
+ writeFileSync(installed, canonicalJson(core))
286
+ await pull({ url: base, cwd, installedCore: installed })
287
+ expect((await check({ url: base, cwd, installedCore: installed })).ok).toBe(true)
288
+ delete world.core.paths['/api/v1/posts/{url}'].delete
289
+ expect((await check({ url: base, cwd, installedCore: installed })).problems).toEqual(['core: removed deletePost (DELETE /api/v1/posts/{url})'])
290
+ })
291
+
292
+ test('nothing pulled yet, and an unreachable Pano', async () => {
293
+ const empty = newDir('check-empty')
294
+ await expect(check({ url: base, cwd: empty })).rejects.toThrow(`Nothing to check in ${join(empty, 'src/lib/pano')}. Run pano-client pull --url ${base} first.`)
295
+ const cwd = await pulled()
296
+ const dead = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('x') })
297
+ const url = `http://127.0.0.1:${dead.port}`
298
+ dead.stop(true)
299
+ await expect(check({ url, cwd, installedCore: null })).rejects.toThrow(`Pano did not answer at ${url}/api/v1/openapi.json. Is it running? Try --url http://localhost:8088`)
300
+ })
301
+ })
package/src/schema.js ADDED
@@ -0,0 +1,202 @@
1
+ // OpenAPI 3.1 schema -> JSDoc type string. A plain walk over the JSON, no dependency.
2
+
3
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
4
+
5
+ /** @param {string} s */
6
+ export const isIdent = (s) => IDENT.test(s)
7
+
8
+ /** Property key as it appears in an object type literal. @param {string} s */
9
+ export const propKey = (s) => (isIdent(s) ? s : quote(s))
10
+
11
+ /** @param {string} s */
12
+ export function quote(s) {
13
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n')}'`
14
+ }
15
+
16
+ /**
17
+ * Precedence of a rendered type: 0 union, 1 intersection, 2 atom (needs no parentheses anywhere).
18
+ * @typedef {{ s: string, p: number }} Rendered
19
+ */
20
+
21
+ /** @param {Rendered} r @param {number} min */
22
+ const at = (r, min) => (r.p < min ? `(${r.s})` : r.s)
23
+
24
+ /** @param {string} s @returns {Rendered} */
25
+ const atom = (s) => ({ s, p: 2 })
26
+
27
+ /** @param {Rendered[]} parts @returns {Rendered} */
28
+ function union(parts) {
29
+ const seen = new Set()
30
+ const out = []
31
+ for (const part of parts) {
32
+ if (seen.has(part.s)) continue
33
+ seen.add(part.s)
34
+ out.push(part)
35
+ }
36
+ if (out.length === 0) return atom('never')
37
+ if (out.length === 1) return out[0]
38
+ if (out.some((p) => p.s === 'any')) return atom('any')
39
+ return { s: out.map((p) => at(p, 0)).join(' | '), p: 0 }
40
+ }
41
+
42
+ /** @param {Rendered[]} parts @returns {Rendered} */
43
+ function intersection(parts) {
44
+ if (parts.length === 1) return parts[0]
45
+ if (parts.length === 0) return atom('any')
46
+ return { s: parts.map((p) => at(p, 1)).join(' & '), p: 1 }
47
+ }
48
+
49
+ /** @param {any} v */
50
+ function literal(v) {
51
+ if (typeof v === 'string') return quote(v)
52
+ if (v === null) return 'null'
53
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
54
+ return 'any'
55
+ }
56
+
57
+ /**
58
+ * @typedef {Object} TypeContext
59
+ * @property {(schemaName: string) => string} ref how a components.schemas entry is named at the call site
60
+ */
61
+
62
+ /**
63
+ * @param {any} schema
64
+ * @param {TypeContext} ctx
65
+ * @returns {string}
66
+ */
67
+ export function schemaType(schema, ctx) {
68
+ return render(schema, ctx).s
69
+ }
70
+
71
+ /**
72
+ * @param {any} schema
73
+ * @param {TypeContext} ctx
74
+ * @returns {Rendered}
75
+ */
76
+ function render(schema, ctx) {
77
+ if (schema === false) return atom('never')
78
+ if (schema === true || schema === null || typeof schema !== 'object') return atom('any')
79
+
80
+ if (typeof schema.$ref === 'string') {
81
+ const m = /^#\/components\/schemas\/(.+)$/.exec(schema.$ref)
82
+ if (!m) return atom('any')
83
+ const name = m[1].replace(/~1/g, '/').replace(/~0/g, '~')
84
+ const r = atom(ctx.ref(name))
85
+ return schema.nullable === true ? union([r, atom('null')]) : r
86
+ }
87
+
88
+ let out = renderBody(schema, ctx)
89
+ if (schema.nullable === true) out = union([out, atom('null')])
90
+ return out
91
+ }
92
+
93
+ /** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
94
+ function renderBody(schema, ctx) {
95
+ if ('const' in schema) return atom(literal(schema.const))
96
+ if (Array.isArray(schema.enum)) return union(schema.enum.map((v) => atom(literal(v))))
97
+
98
+ /** @type {Rendered[]} */
99
+ const parts = []
100
+ if (Array.isArray(schema.allOf) && schema.allOf.length) {
101
+ parts.push(intersection(schema.allOf.map((/** @type {any} */ s) => render(s, ctx))))
102
+ }
103
+ for (const key of ['oneOf', 'anyOf']) {
104
+ if (Array.isArray(schema[key]) && schema[key].length) {
105
+ parts.push(union(schema[key].map((/** @type {any} */ s) => render(s, ctx))))
106
+ }
107
+ }
108
+
109
+ const hasShape = schema.type !== undefined || schema.properties || schema.additionalProperties !== undefined || schema.items
110
+ if (hasShape || parts.length === 0) {
111
+ const own = renderTyped(schema, ctx)
112
+ // allOf/oneOf next to a bare `type: object` adds nothing; keep the composed part alone
113
+ if (!(parts.length && own.s === 'Record<string, any>' && !schema.properties)) parts.push(own)
114
+ }
115
+ return intersection(parts)
116
+ }
117
+
118
+ /** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
119
+ function renderTyped(schema, ctx) {
120
+ /** @type {string[]} */
121
+ let types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []
122
+ if (types.length === 0) {
123
+ if (schema.properties || schema.additionalProperties !== undefined) types = ['object']
124
+ else if (schema.items) types = ['array']
125
+ else return atom('any')
126
+ }
127
+ return union(types.map((t) => renderOne(t, schema, ctx)))
128
+ }
129
+
130
+ /** @param {string} type @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
131
+ function renderOne(type, schema, ctx) {
132
+ switch (type) {
133
+ case 'string':
134
+ return atom(schema.format === 'binary' ? 'Blob' : 'string')
135
+ case 'integer':
136
+ case 'number':
137
+ return atom('number')
138
+ case 'boolean':
139
+ return atom('boolean')
140
+ case 'null':
141
+ return atom('null')
142
+ case 'array':
143
+ return atom(`${at(schema.items === undefined ? atom('any') : render(schema.items, ctx), 2)}[]`)
144
+ case 'object':
145
+ return renderObject(schema, ctx)
146
+ default:
147
+ return atom('any')
148
+ }
149
+ }
150
+
151
+ /** @param {any} schema @param {TypeContext} ctx @returns {Rendered} */
152
+ function renderObject(schema, ctx) {
153
+ const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {}
154
+ const names = Object.keys(props)
155
+ const extra = schema.additionalProperties
156
+ const extraType =
157
+ extra === undefined || extra === false
158
+ ? null
159
+ : extra === true
160
+ ? atom('any')
161
+ : render(extra, ctx)
162
+
163
+ if (names.length === 0) return atom(`Record<string, ${extraType ? extraType.s : 'any'}>`)
164
+
165
+ const required = new Set(Array.isArray(schema.required) ? schema.required : [])
166
+ const body = names
167
+ .map((n) => `${propKey(n)}${required.has(n) ? '' : '?'}: ${render(props[n], ctx).s}`)
168
+ .join(', ')
169
+ const literalType = atom(`{ ${body} }`)
170
+ return extraType ? intersection([literalType, atom(`Record<string, ${extraType.s}>`)]) : literalType
171
+ }
172
+
173
+ /**
174
+ * Names, types and descriptions of an object schema's properties when every name is a plain identifier,
175
+ * so the typedef can use `@property`. Null otherwise (the caller falls back to the inline literal).
176
+ * @param {any} schema
177
+ * @param {TypeContext} ctx
178
+ * @returns {{ name: string, type: string, optional: boolean, description: string }[] | null}
179
+ */
180
+ export function objectProperties(schema, ctx) {
181
+ if (!schema || typeof schema !== 'object' || schema.$ref) return null
182
+ if (schema.allOf || schema.oneOf || schema.anyOf || schema.enum || 'const' in schema || schema.nullable === true) return null
183
+ if (schema.type !== undefined && schema.type !== 'object') return null
184
+ if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) return null
185
+ const props = schema.properties
186
+ if (!props || typeof props !== 'object') return null
187
+ const names = Object.keys(props)
188
+ if (names.length === 0 || !names.every(isIdent)) return null
189
+ const required = new Set(Array.isArray(schema.required) ? schema.required : [])
190
+ return names.map((name) => ({
191
+ name,
192
+ type: render(props[name], ctx).s,
193
+ optional: !required.has(name),
194
+ description: oneLine(props[name] && typeof props[name] === 'object' ? props[name].description : '')
195
+ }))
196
+ }
197
+
198
+ /** Collapse text to one comment-safe line. @param {any} text */
199
+ export function oneLine(text) {
200
+ if (typeof text !== 'string') return ''
201
+ return text.replace(/\*\//g, '*\\/').replace(/\s+/g, ' ').trim()
202
+ }