@nan0web/payload-release 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,308 @@
1
+ import { ReleaseConfigModel } from '../domain/ReleaseConfigModel.js'
2
+
3
+ /**
4
+ * Creates dynamic tab label with count badge
5
+ * @param {string} baseLabel
6
+ * @param {string} arrayFieldName
7
+ * @returns {import('payload').LabelFunction}
8
+ */
9
+ function createTabBadgeLabel(baseLabel, arrayFieldName) {
10
+ return ({ data }) => {
11
+ const arr = data?.[arrayFieldName]
12
+ if (Array.isArray(arr) && arr.length > 0) {
13
+ return baseLabel.replace('{count}', String(arr.length))
14
+ }
15
+ // Return clean label without ' (0)' placeholder if no items or data not loaded yet
16
+ return baseLabel.replace(' ({count})', '').replace('{count}', '')
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Builds the Payload CMS Collection configuration for Releases.
22
+ *
23
+ * @param {Object} [options]
24
+ * @param {boolean} [options.allowPreview=true]
25
+ * @returns {import('payload').CollectionConfig}
26
+ */
27
+ export function buildReleasesCollection(options = {}) {
28
+ const allowPreview = options.allowPreview !== false
29
+ const fileEndpointHandler = options.fileEndpointHandler
30
+
31
+ return {
32
+ slug: 'releases',
33
+ labels: {
34
+ singular: ReleaseConfigModel.UI.singular,
35
+ plural: ReleaseConfigModel.UI.plural,
36
+ },
37
+ endpoints: fileEndpointHandler
38
+ ? [
39
+ {
40
+ path: '/:id/file',
41
+ method: 'get',
42
+ handler: fileEndpointHandler,
43
+ },
44
+ {
45
+ path: '/view/file',
46
+ method: 'get',
47
+ handler: fileEndpointHandler,
48
+ },
49
+ ]
50
+ : [],
51
+ access: {
52
+ read: () => true,
53
+ create: () => false,
54
+ update: () => false,
55
+ delete: () => false,
56
+ },
57
+ admin: {
58
+ useAsTitle: 'version',
59
+ defaultColumns: ['version', 'command', 'status', 'durationMs', 'createdAt'],
60
+ group: ReleaseConfigModel.UI.group,
61
+ hideAPIURL: false,
62
+ components: {
63
+ beforeList: [],
64
+ views: {
65
+ edit: {
66
+ default: {
67
+ Component: '@nan0web/payload-release/admin#ReleaseDocumentView',
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ fields: [
74
+ {
75
+ type: 'tabs',
76
+ tabs: [
77
+ {
78
+ label: ReleaseConfigModel.UI.tabGeneral,
79
+ fields: [
80
+ {
81
+ type: 'row',
82
+ fields: [
83
+ {
84
+ name: 'version',
85
+ type: 'text',
86
+ required: true,
87
+ unique: true,
88
+ index: true,
89
+ admin: { width: '50%' },
90
+ },
91
+ {
92
+ name: 'status',
93
+ type: 'select',
94
+ defaultValue: 'ok',
95
+ options: [
96
+ { label: 'OK', value: 'ok' },
97
+ { label: 'Failed', value: 'failed' },
98
+ ],
99
+ admin: { width: '50%' },
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ type: 'row',
105
+ fields: [
106
+ {
107
+ name: 'command',
108
+ type: 'select',
109
+ options: [
110
+ { label: 'Seed', value: 'seed' },
111
+ { label: 'Fresh', value: 'fresh' },
112
+ { label: 'Migrate', value: 'migrate' },
113
+ ],
114
+ admin: { width: '50%' },
115
+ },
116
+ {
117
+ name: 'durationMs',
118
+ type: 'number',
119
+ admin: { width: '50%' },
120
+ },
121
+ ],
122
+ },
123
+ {
124
+ type: 'row',
125
+ fields: [
126
+ {
127
+ name: 'gitSha',
128
+ type: 'text',
129
+ admin: { width: '50%' },
130
+ },
131
+ {
132
+ name: 'gitDirty',
133
+ type: 'checkbox',
134
+ admin: { width: '50%' },
135
+ },
136
+ ],
137
+ },
138
+ {
139
+ type: 'row',
140
+ fields: [
141
+ {
142
+ name: 'nodeVersion',
143
+ type: 'text',
144
+ admin: { width: '50%' },
145
+ },
146
+ {
147
+ name: 'pnpmVersion',
148
+ type: 'text',
149
+ admin: { width: '50%' },
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ {
156
+ label: createTabBadgeLabel(ReleaseConfigModel.UI.tabSteps, 'steps'),
157
+ fields: [
158
+ {
159
+ name: 'steps',
160
+ type: 'array',
161
+ admin: {
162
+ components: {
163
+ Field: '@nan0web/payload-release/admin#ReleaseStepsField',
164
+ },
165
+ },
166
+ fields: [
167
+ {
168
+ type: 'row',
169
+ fields: [
170
+ { name: 'name', type: 'text', required: true, admin: { width: '40%' } },
171
+ {
172
+ name: 'status',
173
+ type: 'select',
174
+ defaultValue: 'ok',
175
+ options: [
176
+ { label: 'OK', value: 'ok' },
177
+ { label: 'Error', value: 'error' },
178
+ ],
179
+ admin: { width: '20%' },
180
+ },
181
+ { name: 'startedAt', type: 'date', admin: { width: '20%' } },
182
+ { name: 'durationMs', type: 'number', admin: { width: '20%' } },
183
+ ],
184
+ },
185
+ {
186
+ name: 'output',
187
+ type: 'textarea',
188
+ admin: { rows: 3 },
189
+ },
190
+ ],
191
+ },
192
+ ],
193
+ },
194
+ {
195
+ label: createTabBadgeLabel(ReleaseConfigModel.UI.tabParity, 'parity'),
196
+ fields: [
197
+ {
198
+ name: 'parity',
199
+ type: 'array',
200
+ admin: {
201
+ components: {
202
+ Field: '@nan0web/payload-release/admin#ReleaseParityField',
203
+ },
204
+ },
205
+ fields: [
206
+ {
207
+ type: 'row',
208
+ fields: [
209
+ { name: 'collection', type: 'text', required: true, admin: { width: '30%' } },
210
+ { name: 'csvRows', type: 'number', admin: { width: '25%' } },
211
+ { name: 'dbRows', type: 'number', admin: { width: '25%' } },
212
+ {
213
+ name: 'synced',
214
+ type: 'checkbox',
215
+ defaultValue: true,
216
+ admin: { width: '20%' },
217
+ },
218
+ ],
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ },
224
+ {
225
+ label: createTabBadgeLabel(ReleaseConfigModel.UI.tabIssues, 'issues'),
226
+ fields: [
227
+ {
228
+ name: 'issues',
229
+ type: 'array',
230
+ admin: {
231
+ components: {
232
+ Field: '@nan0web/payload-release/admin#ReleaseIssuesField',
233
+ },
234
+ },
235
+ fields: [
236
+ {
237
+ type: 'row',
238
+ fields: [
239
+ {
240
+ name: 'level',
241
+ type: 'select',
242
+ defaultValue: 'warning',
243
+ options: [
244
+ { label: 'Info', value: 'info' },
245
+ { label: 'Warning', value: 'warning' },
246
+ { label: 'Error', value: 'error' },
247
+ ],
248
+ admin: { width: '25%' },
249
+ },
250
+ { name: 'code', type: 'text', admin: { width: '25%' } },
251
+ { name: 'source', type: 'text', admin: { width: '25%' } },
252
+ { name: 'file', type: 'text', admin: { width: '25%' } },
253
+ ],
254
+ },
255
+ {
256
+ name: 'message',
257
+ type: 'textarea',
258
+ required: true,
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ },
264
+ {
265
+ label: createTabBadgeLabel(ReleaseConfigModel.UI.tabFiles, 'files'),
266
+ fields: [
267
+ {
268
+ name: 'files',
269
+ type: 'array',
270
+ admin: {
271
+ components: {
272
+ Field: '@nan0web/payload-release/admin#ReleaseFilesField',
273
+ },
274
+ },
275
+ fields: [
276
+ {
277
+ type: 'row',
278
+ fields: [
279
+ { name: 'path', type: 'text', required: true, admin: { width: '40%' } },
280
+ { name: 'size', type: 'number', admin: { width: '20%' } },
281
+ { name: 'sha256', type: 'text', admin: { width: '30%' } },
282
+ ...(allowPreview
283
+ ? [
284
+ {
285
+ name: 'preview',
286
+ type: 'ui',
287
+ admin: {
288
+ width: '10%',
289
+ components: {
290
+ Field: '@nan0web/payload-release/admin#FilePreviewButton',
291
+ },
292
+ },
293
+ },
294
+ ]
295
+ : []),
296
+ ],
297
+ },
298
+ ],
299
+ },
300
+ ],
301
+ },
302
+ ],
303
+ },
304
+ ],
305
+ }
306
+ }
307
+
308
+ export const ReleasesCollection = buildReleasesCollection()
@@ -0,0 +1,56 @@
1
+ import { Model } from '@nan0web/types'
2
+
3
+ /**
4
+ * Configuration schema for @nan0web/payload-release plugin.
5
+ * Implements Model-as-Schema pattern.
6
+ */
7
+ export class ReleaseConfigModel extends Model {
8
+ /**
9
+ * Path to releases directory relative to project root.
10
+ */
11
+ static releasesDir = {
12
+ type: 'string',
13
+ default: 'releases',
14
+ help: 'Path to releases directory relative to project root',
15
+ validate: (v) =>
16
+ (typeof v === 'string' && v.trim().length > 0) ||
17
+ 'releasesDir must be a non-empty string',
18
+ }
19
+
20
+ /**
21
+ * Whether Markdown file preview is enabled in Admin UI.
22
+ */
23
+ static allowPreview = {
24
+ type: 'boolean',
25
+ default: true,
26
+ help: 'Allow modal preview of release Markdown documents',
27
+ }
28
+
29
+ /**
30
+ * UI labels and translation tokens.
31
+ */
32
+ static UI = {
33
+ singular: 'Release',
34
+ plural: 'Releases',
35
+ group: 'System',
36
+ tabGeneral: '📋 General',
37
+ tabSteps: '⚡ Steps ({count})',
38
+ tabParity: '⚖️ Parity ({count})',
39
+ tabIssues: '⚠️ Issues ({count})',
40
+ tabFiles: '📦 Files ({count})',
41
+ previewButton: 'Preview',
42
+ modalClose: 'Close',
43
+ loading: 'Loading content...',
44
+ errorNotFound: 'Document not found or inaccessible',
45
+ }
46
+
47
+ /**
48
+ * @param {Partial<ReleaseConfigModel>} [data]
49
+ * @param {import('@nan0web/types').ModelOptions} [options]
50
+ */
51
+ constructor(data = {}, options = {}) {
52
+ super(data, options)
53
+ /** @type {string} */ this.releasesDir
54
+ /** @type {boolean} */ this.allowPreview
55
+ }
56
+ }
package/src/index.js ADDED
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { ReleaseConfigModel } from './domain/ReleaseConfigModel.js'
4
+ import { buildReleasesCollection, ReleasesCollection } from './collections/Releases.js'
5
+ import { createReleaseFileEndpoint } from './server/endpoint.js'
6
+ import { renderMarkdown, stripFrontmatter } from './server/markdown.js'
7
+ import {
8
+ discoverReleases,
9
+ parseMarkdownCheckboxes,
10
+ getGitInfo,
11
+ getRuntimeInfo,
12
+ getPackageRootDir,
13
+ } from './server/discovery.js'
14
+
15
+ export {
16
+ ReleaseConfigModel,
17
+ ReleasesCollection,
18
+ buildReleasesCollection,
19
+ createReleaseFileEndpoint,
20
+ renderMarkdown,
21
+ stripFrontmatter,
22
+ discoverReleases,
23
+ parseMarkdownCheckboxes,
24
+ getGitInfo,
25
+ getRuntimeInfo,
26
+ getPackageRootDir,
27
+ }
28
+
29
+ /**
30
+ * Configure payload-release plugin for Payload CMS.
31
+ *
32
+ * @param {Object} [options]
33
+ * @param {string} [options.releasesDir='releases']
34
+ * @param {boolean} [options.allowPreview=true]
35
+ * @param {boolean} [options.autoDiscover=true]
36
+ * @returns {(config: any) => any}
37
+ */
38
+ export function payloadRelease(options = {}) {
39
+ const model = new ReleaseConfigModel(options)
40
+ const { releasesDir, allowPreview } = model
41
+ const autoDiscover = options.autoDiscover !== false
42
+
43
+ return (config) => {
44
+ const packageRoot = getPackageRootDir()
45
+
46
+ // Safe DB-FS or filesystem file reader
47
+ const readReleaseFile = async (relPath) => {
48
+ const projectRoot = process.cwd()
49
+ const cleanPath = String(relPath || '')
50
+ .replace(/^@releases\/?/, '')
51
+ .replace(/^\.\//, '')
52
+
53
+ const candidatePaths = [
54
+ path.resolve(projectRoot, cleanPath),
55
+ path.resolve(projectRoot, releasesDir, cleanPath),
56
+ path.resolve(packageRoot, cleanPath),
57
+ path.resolve(packageRoot, 'releases', cleanPath),
58
+ path.resolve(packageRoot, cleanPath.replace(/^releases\/?/, 'releases/')),
59
+ ]
60
+
61
+ const allowedBases = [
62
+ path.resolve(projectRoot),
63
+ path.resolve(packageRoot),
64
+ path.resolve(packageRoot, 'releases'),
65
+ ]
66
+
67
+ for (const fullPath of candidatePaths) {
68
+ const isUnderAllowedBase = allowedBases.some(
69
+ (base) => fullPath === base || fullPath.startsWith(`${base}${path.sep}`)
70
+ )
71
+ if (!isUnderAllowedBase) continue
72
+
73
+ try {
74
+ return await fs.readFile(fullPath, 'utf8')
75
+ } catch {}
76
+ }
77
+
78
+ return null
79
+ }
80
+
81
+ const fileEndpointHandler = createReleaseFileEndpoint({
82
+ releasesDir,
83
+ readReleaseFile,
84
+ })
85
+
86
+ const collection = buildReleasesCollection({ allowPreview, fileEndpointHandler })
87
+
88
+
89
+ const endpoints = [
90
+ ...(Array.isArray(config.endpoints) ? config.endpoints : []),
91
+ {
92
+ path: '/releases/:id/file',
93
+ method: 'get',
94
+ handler: fileEndpointHandler,
95
+ },
96
+ {
97
+ path: '/releases/view/file',
98
+ method: 'get',
99
+ handler: fileEndpointHandler,
100
+ },
101
+ ]
102
+
103
+ const existingOnInit = config.onInit
104
+ const wrappedOnInit = async (payload) => {
105
+ if (existingOnInit) {
106
+ await existingOnInit(payload)
107
+ }
108
+
109
+ if (autoDiscover && payload?.find && payload?.create) {
110
+ try {
111
+ const discovered = await discoverReleases({
112
+ projectRoot: process.cwd(),
113
+ releasesDir,
114
+ })
115
+
116
+ for (const release of discovered) {
117
+ const existing = await payload.find({
118
+ collection: 'releases',
119
+ where: {
120
+ version: { equals: release.version },
121
+ },
122
+ limit: 1,
123
+ depth: 0,
124
+ })
125
+
126
+ if (!existing?.docs?.length) {
127
+ await payload.create({
128
+ collection: 'releases',
129
+ data: release,
130
+ })
131
+ } else {
132
+ const doc = existing.docs[0]
133
+ // If DB record lacks parity data or has fewer steps, sync it
134
+ if (!Array.isArray(doc.parity) || doc.parity.length === 0) {
135
+ await payload.update({
136
+ collection: 'releases',
137
+ id: doc.id,
138
+ data: {
139
+ parity: release.parity,
140
+ steps: release.steps,
141
+ },
142
+ })
143
+ }
144
+ }
145
+ }
146
+ } catch (err) {
147
+ payload?.logger?.warn?.(
148
+ `[payload-release] Auto-discovery note: ${/** @type {Error} */ (err).message}`
149
+ )
150
+ }
151
+ }
152
+ }
153
+
154
+ return {
155
+ ...config,
156
+ collections: [
157
+ ...(Array.isArray(config.collections) ? config.collections : []),
158
+ collection,
159
+ ],
160
+ endpoints,
161
+ onInit: wrappedOnInit,
162
+ }
163
+ }
164
+ }
165
+
166
+ export default payloadRelease