@jwilleke/ngdpbase-types 5.25.0 → 5.27.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.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/lib/packages/ngdpbase-types/index.d.ts +9 -2
  3. package/lib/packages/ngdpbase-types/testing.d.ts +7 -3
  4. package/lib/packages/ngdpbase-types/testing.js +7 -3
  5. package/lib/src/converters/DocxConverter.d.ts +30 -0
  6. package/lib/src/converters/MarkdownConverter.d.ts +31 -0
  7. package/lib/src/export/ChromeServicePdfProvider.d.ts +52 -0
  8. package/lib/src/export/ChromeServicePdfProvider.js +153 -0
  9. package/lib/src/export/LocalChromePdfProvider.d.ts +25 -0
  10. package/lib/src/export/LocalChromePdfProvider.js +33 -0
  11. package/lib/src/export/PdfProvider.d.ts +51 -0
  12. package/lib/src/export/PdfProvider.js +37 -0
  13. package/lib/src/export/pdfEngine.d.ts +113 -0
  14. package/lib/src/export/pdfEngine.js +195 -0
  15. package/lib/src/http/egressPolicy.js +61 -0
  16. package/lib/src/http/guardedElasticsearch.d.ts +27 -0
  17. package/lib/src/http/guardedElasticsearch.js +99 -0
  18. package/lib/src/ice/converterShim.d.ts +5 -0
  19. package/lib/src/ice/fileHandling.d.ts +30 -0
  20. package/lib/src/ice/ledger.d.ts +37 -0
  21. package/lib/src/ice/runImport.d.ts +51 -0
  22. package/lib/src/ice/types.d.ts +262 -0
  23. package/lib/src/mail/MailProvider.d.ts +15 -0
  24. package/lib/src/mail/NodemailerMailProvider.d.ts +1 -1
  25. package/lib/src/managers/AddonsManager.d.ts +55 -3
  26. package/lib/src/managers/AttachmentManager.d.ts +4 -5
  27. package/lib/src/managers/AuthManager.d.ts +150 -32
  28. package/lib/src/managers/BackgroundJobManager.d.ts +22 -12
  29. package/lib/src/managers/BackupManager.d.ts +779 -0
  30. package/lib/src/managers/BackupManager.js +2042 -0
  31. package/lib/src/managers/BaseManager.d.ts +156 -6
  32. package/lib/src/managers/BaseManager.js +64 -2
  33. package/lib/src/managers/CommentManager.d.ts +6 -6
  34. package/lib/src/managers/ConsentManager.d.ts +130 -0
  35. package/lib/src/managers/DatabaseManager.d.ts +6 -0
  36. package/lib/src/managers/DatabaseManager.js +25 -15
  37. package/lib/src/managers/EmailManager.d.ts +32 -5
  38. package/lib/src/managers/ExportManager.d.ts +62 -0
  39. package/lib/src/managers/ExportManager.js +147 -2
  40. package/lib/src/managers/FootnoteManager.d.ts +6 -6
  41. package/lib/src/managers/ImportManager.d.ts +590 -0
  42. package/lib/src/managers/OrganizationManager.d.ts +8 -6
  43. package/lib/src/managers/PageManager.d.ts +61 -1
  44. package/lib/src/managers/PersonManager.d.ts +8 -6
  45. package/lib/src/managers/RoleManager.d.ts +6 -8
  46. package/lib/src/providers/BaseAttachmentProvider.d.ts +4 -4
  47. package/lib/src/providers/BaseBackupProvider.d.ts +77 -0
  48. package/lib/src/providers/BaseBackupProvider.js +52 -0
  49. package/lib/src/providers/BasicAttachmentProvider.d.ts +4 -4
  50. package/lib/src/providers/FileBackupProvider.d.ts +34 -0
  51. package/lib/src/providers/FileBackupProvider.js +116 -0
  52. package/lib/src/providers/FileConsentProvider.d.ts +18 -0
  53. package/lib/src/providers/SqliteDatabaseProvider.d.ts +2 -2
  54. package/lib/src/providers/SqliteDatabaseProvider.js +12 -11
  55. package/lib/src/providers/attachmentFilesBackup.d.ts +5 -4
  56. package/lib/src/providers/databaseMigrations.js +45 -0
  57. package/lib/src/security/PolicyDecisionPoint.d.ts +14 -0
  58. package/lib/src/security/PolicyInformationPoint.d.ts +25 -2
  59. package/lib/src/types/Config.d.ts +2 -0
  60. package/lib/src/types/Consent.d.ts +90 -0
  61. package/lib/src/types/express.d.ts +6 -0
  62. package/lib/src/utils/addonHost.d.ts +45 -2
  63. package/lib/src/utils/addonHost.js +39 -3
  64. package/lib/src/utils/auditEventNames.generated.d.ts +20 -0
  65. package/lib/src/utils/auditEventNames.generated.js +20 -0
  66. package/lib/src/utils/backupCrypto.js +275 -0
  67. package/lib/src/utils/backupDocument.d.ts +36 -0
  68. package/lib/src/utils/backupDocument.js +99 -0
  69. package/lib/src/utils/browserDescription.d.ts +11 -0
  70. package/lib/src/utils/directoryBackup.d.ts +48 -48
  71. package/lib/src/utils/directoryBackup.js +291 -0
  72. package/lib/src/utils/frontmatterAccess.d.ts +54 -11
  73. package/lib/src/utils/importRunSummary.d.ts +64 -0
  74. package/lib/src/utils/instanceEnvBackup.d.ts +46 -0
  75. package/lib/src/utils/instanceEnvBackup.js +120 -0
  76. package/lib/src/utils/instanceEnvSecret.js +79 -0
  77. package/lib/src/utils/ipLocation.d.ts +11 -0
  78. package/lib/src/utils/ncmNotify.d.ts +30 -0
  79. package/lib/src/utils/privateStoreImport.d.ts +58 -0
  80. package/lib/src/utils/schedule.js +171 -0
  81. package/lib/src/utils/secretFileMode.js +78 -0
  82. package/lib/src/utils/siteRestore.d.ts +13 -16
  83. package/lib/src/utils/siteRestore.js +33 -6
  84. package/lib/src/utils/treeArchive.d.ts +2 -0
  85. package/lib/src/utils/treeArchive.js +242 -0
  86. package/lib/src/utils/version.js +408 -0
  87. package/lib/src/utils/zipArchive.d.ts +71 -0
  88. package/package.json +6 -1
  89. package/lib/src/utils/administrators.d.ts +0 -21
@@ -0,0 +1,242 @@
1
+ /**
2
+ * A folder of a site's files as one `.tar` beside a backup document, and back
3
+ * (#1876).
4
+ *
5
+ * The backup document is JSON built in memory, so file contents never go in
6
+ * it: a manager whose data is files (attachments, role and person records,
7
+ * footnotes, comments) writes them here instead, as `<stem>.<part>.tar`
8
+ * beside the document, the way DatabaseManager writes `<stem>.<name>.db`.
9
+ * BackupManager prunes and downloads these with the document.
10
+ *
11
+ * - __Streamed__ both ways: each file is read from disk as the archive is
12
+ * written, and written to disk as it is read, so a file's size never
13
+ * becomes memory.
14
+ * - __Bytes as stored__: nothing is decoded, so a sealed vault file stays the
15
+ * sealed bytes it is on disk.
16
+ * - __Only files and folders__: a symbolic link or any other kind of entry is
17
+ * left out of a backup, and named; one in an archive refuses the extract.
18
+ * - __Paths are data__: an extract refuses an entry that is absolute or
19
+ * climbs out of its folder (`strict`), never writing outside `dest`.
20
+ * - __Encrypted under the backup key__ when one is given (#1561, #1876): the
21
+ * archive is streamed through `backupCrypto.ts` as it is written, and an
22
+ * extract or a listing given the key decrypts it as it reads. One that is
23
+ * encrypted is never read without the key, one made under another key is
24
+ * refused by its fingerprint, and given a key a plain archive is refused:
25
+ * it is not the one the backup wrote.
26
+ */
27
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
+ import path from 'node:path';
29
+ import { Readable } from 'node:stream';
30
+ import { pipeline } from 'node:stream/promises';
31
+ import { create as createTar, extract as extractTar, list as listTar } from 'tar';
32
+ import { decryptBuffer, decryptStream, encryptBuffer, encryptStream, fileIsEncrypted } from './backupCrypto.js';
33
+ import { BACKUP_KEY_ENV } from './backupKey.js';
34
+ /**
35
+ * Whether a backup's read failed because the entry it was reading was
36
+ * deleted meanwhile (#1563): a page, a version folder or a file removed
37
+ * while the backup walked past it. That entry is skipped and counted, never
38
+ * a failed backup — the site keeps running while it is backed up (#1213).
39
+ * Every other error (EACCES, EIO, …) is a real failure and still fails.
40
+ */
41
+ export function vanishedDuringBackup(err) {
42
+ return err?.code === 'ENOENT';
43
+ }
44
+ /** The log's note of entries deleted while they were backed up (#1563): skipped, not a failure. */
45
+ export function vanishedNote(vanished) {
46
+ return vanished.length > 0 ? `; ${vanished.length} entr${vanished.length === 1 ? 'y' : 'ies'} deleted during the backup skipped (${vanished.slice(0, 5).join(', ')})` : '';
47
+ }
48
+ /** Two 512-byte zero blocks: a tar with no entries. */
49
+ const EMPTY_TAR = Buffer.alloc(1024);
50
+ /**
51
+ * Write `entries` (paths relative to `root`; default: everything in it) into
52
+ * `target`, streamed. `exclude` leaves out a path relative to `root`, in
53
+ * posix form. A missing `root` writes an empty archive. Never overwrites.
54
+ *
55
+ * An entry deleted while the archive is written (#1563) — the tar stream
56
+ * stops at the first file it can no longer find — is not a failure: the
57
+ * archive is written again from a fresh listing, without it, and the entry
58
+ * is named in `vanished`. It is written again only while each attempt finds
59
+ * another entry gone, so it ends once the deleting stops; the same entry
60
+ * missing twice, or one still there, is a real failure.
61
+ */
62
+ export async function writeTreeArchive(target, root, options = {}) {
63
+ if (existsSync(target))
64
+ throw new Error(`${path.basename(target)} already exists; a backup never overwrites one`);
65
+ const vanished = new Set();
66
+ for (;;) {
67
+ try {
68
+ const outcome = await writeTreeArchiveOnce(target, root, options);
69
+ return { ...outcome, vanished: [...vanished].map((p) => toPosix(path.relative(root, p))).sort() };
70
+ }
71
+ catch (err) {
72
+ const gone = err.path;
73
+ if (!vanishedDuringBackup(err) || !gone || vanished.has(gone) || existsSync(gone))
74
+ throw err;
75
+ vanished.add(gone);
76
+ }
77
+ }
78
+ }
79
+ async function writeTreeArchiveOnce(target, root, options) {
80
+ const outcome = { files: 0, bytes: 0, skipped: [] };
81
+ const entries = (options.entries ?? (existsSync(root) ? readdirSync(root) : []))
82
+ .filter((e) => existsSync(path.join(root, e)))
83
+ .filter((e) => !options.exclude?.(toPosix(e)))
84
+ .sort();
85
+ if (entries.length === 0) {
86
+ writeFileSync(target, options.backupKey ? await encryptBuffer(EMPTY_TAR, options.backupKey) : EMPTY_TAR, { mode: 0o600, flag: 'wx' });
87
+ return outcome;
88
+ }
89
+ try {
90
+ const tarOptions = {
91
+ cwd: root,
92
+ portable: true,
93
+ filter: (p, stat) => {
94
+ const relative = toPosix(path.normalize(p));
95
+ if (options.exclude?.(relative))
96
+ return false;
97
+ const s = stat;
98
+ if (s.isDirectory())
99
+ return true;
100
+ if (!s.isFile()) {
101
+ outcome.skipped.push(relative);
102
+ return false;
103
+ }
104
+ outcome.files++;
105
+ outcome.bytes += s.size;
106
+ return true;
107
+ }
108
+ };
109
+ if (options.backupKey) {
110
+ const pack = createTar(tarOptions, entries);
111
+ await pipeline(pack, encryptStream(options.backupKey), createWriteStream(target, { flags: 'wx', mode: 0o600 }));
112
+ }
113
+ else {
114
+ await createTar({ ...tarOptions, file: target, mode: 0o600 }, entries);
115
+ }
116
+ }
117
+ catch (err) {
118
+ rmSync(target, { force: true });
119
+ throw err;
120
+ }
121
+ return outcome;
122
+ }
123
+ /**
124
+ * Extract `archive` into `dest`, which is emptied first, streamed.
125
+ * `select(relative)` picks the entries to extract (default: all). Refuses —
126
+ * removing `dest` — an entry that is not a file or a folder, an absolute
127
+ * path, or one that climbs out (`..`).
128
+ */
129
+ export async function extractTreeArchive(archive, dest, options = {}) {
130
+ if (!existsSync(archive) || !statSync(archive).isFile())
131
+ throw new Error(`${path.basename(archive)} is missing`);
132
+ const source = await openArchive(archive, options.backupKey);
133
+ rmSync(dest, { recursive: true, force: true });
134
+ mkdirSync(dest, { recursive: true });
135
+ // An archive of an empty folder is only its end blocks, which a strict read refuses.
136
+ if (source.empty)
137
+ return { files: 0, bytes: 0 };
138
+ const refused = [];
139
+ let files = 0;
140
+ let bytes = 0;
141
+ try {
142
+ const unpackOptions = {
143
+ cwd: dest,
144
+ strict: true,
145
+ preservePaths: false,
146
+ preserveOwner: false,
147
+ ...(options.strip ? { strip: options.strip } : {}),
148
+ filter: (p, entry) => {
149
+ const e = entry;
150
+ const relative = toPosix(p).replace(/^\.\//, '');
151
+ if (options.select && !options.select(relative))
152
+ return false;
153
+ if (!['File', 'OldFile', 'ContiguousFile', 'Directory'].includes(e.type)) {
154
+ refused.push(`${relative} (${e.type})`);
155
+ return false;
156
+ }
157
+ if (e.type !== 'Directory') {
158
+ files++;
159
+ bytes += e.size ?? 0;
160
+ }
161
+ return true;
162
+ }
163
+ };
164
+ if (source.into)
165
+ await source.into(extractTar(unpackOptions));
166
+ else
167
+ await extractTar({ ...unpackOptions, file: archive });
168
+ }
169
+ catch (err) {
170
+ rmSync(dest, { recursive: true, force: true });
171
+ throw new Error(`${path.basename(archive)} could not be read: ${err.message}`, { cause: err });
172
+ }
173
+ if (refused.length > 0) {
174
+ rmSync(dest, { recursive: true, force: true });
175
+ throw new Error(`${path.basename(archive)} holds entries that are not files or folders: ${refused.slice(0, 5).join(', ')}`);
176
+ }
177
+ return { files, bytes };
178
+ }
179
+ /** An encrypted archive this small is decrypted whole, to recognise an empty one; anything larger streams. */
180
+ const SMALL_ENCRYPTED = 64 * 1024;
181
+ /**
182
+ * How to read an archive: plain (`stream` absent, tar reads the file), or
183
+ * decrypted as a stream. `empty` when it holds no entry at all. Throws for an
184
+ * encrypted archive with no key or another key, before anything is written.
185
+ */
186
+ async function openArchive(archive, backupKey) {
187
+ const name = path.basename(archive);
188
+ if (!(await fileIsEncrypted(archive))) {
189
+ // A backup that says its archive is encrypted never takes a plain one in its place.
190
+ if (backupKey)
191
+ throw new Error(`${name} is not encrypted, but the backup says it is: it was replaced or damaged`);
192
+ const empty = statSync(archive).size === EMPTY_TAR.length && (await readHead(archive, EMPTY_TAR.length)).equals(EMPTY_TAR);
193
+ return { empty };
194
+ }
195
+ if (!backupKey)
196
+ throw new Error(`${name} is encrypted under a backup key, and none was given to read it (${BACKUP_KEY_ENV} is not set, or the backup does not say the archive is encrypted)`);
197
+ if (statSync(archive).size <= SMALL_ENCRYPTED) {
198
+ let plain;
199
+ try {
200
+ plain = await decryptBuffer(await readHead(archive, SMALL_ENCRYPTED), backupKey);
201
+ }
202
+ catch (err) {
203
+ throw new Error(`${name} could not be read: ${err.message}`, { cause: err });
204
+ }
205
+ if (plain.length === EMPTY_TAR.length && plain.equals(EMPTY_TAR))
206
+ return { empty: true };
207
+ return { empty: false, into: (sink) => pipeline(Readable.from([plain]), sink) };
208
+ }
209
+ // The decrypting stream checks the header (a wrong key is refused) before it passes on any byte.
210
+ return { empty: false, into: (sink) => pipeline(createReadStream(archive), decryptStream(backupKey), sink) };
211
+ }
212
+ /**
213
+ * Every entry in an archive, plain or encrypted, read as a stream: what a
214
+ * caller checks before extracting. `onEntry` gets each entry's path and type.
215
+ */
216
+ export async function listTreeArchive(archive, onEntry, options = {}) {
217
+ if (!existsSync(archive) || !statSync(archive).isFile())
218
+ throw new Error(`${path.basename(archive)} is missing`);
219
+ const source = await openArchive(archive, options.backupKey);
220
+ if (source.empty)
221
+ return;
222
+ const listOptions = { onReadEntry: (e) => onEntry({ path: e.path, type: e.type }) };
223
+ if (source.into)
224
+ await source.into(listTar(listOptions));
225
+ else
226
+ await listTar({ ...listOptions, file: archive });
227
+ }
228
+ /** The first `n` bytes of a file, read without reading the rest. */
229
+ export async function readHead(file, n) {
230
+ const chunks = [];
231
+ let total = 0;
232
+ if (n <= 0)
233
+ return Buffer.alloc(0);
234
+ for await (const chunk of createReadStream(file, { start: 0, end: n - 1 })) {
235
+ chunks.push(chunk);
236
+ total += chunk.length;
237
+ }
238
+ return Buffer.concat(chunks, total);
239
+ }
240
+ function toPosix(p) {
241
+ return p.split(path.sep).join('/');
242
+ }
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Version Management Script for ngdpbase
4
+ *
5
+ * Helps manage semantic versioning for the project.
6
+ *
7
+ * Usage:
8
+ * node version.js - Show current version
9
+ * node version.js patch - Increment patch version (bug fixes)
10
+ * node version.js minor - Increment minor version (new features)
11
+ * node version.js major - Increment major version (breaking changes)
12
+ * node version.js set <version> - Set specific version
13
+ */
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { execSync } from 'child_process';
17
+ import { fileURLToPath } from 'url';
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+ // Find project root by walking up from __dirname until we find package.json
21
+ function findProjectRoot() {
22
+ let dir = __dirname;
23
+ while (dir !== path.dirname(dir)) {
24
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
25
+ return dir;
26
+ }
27
+ dir = path.dirname(dir);
28
+ }
29
+ throw new Error('Could not find project root (no package.json found)');
30
+ }
31
+ const PROJECT_ROOT = findProjectRoot();
32
+ const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, 'package.json');
33
+ const PACKAGE_LOCK_PATH = path.join(PROJECT_ROOT, 'package-lock.json');
34
+ const CHANGELOG_PATH = path.join(PROJECT_ROOT, 'CHANGELOG.md');
35
+ const APP_CONFIG_PATH = path.join(PROJECT_ROOT, 'config/app-default-config.json');
36
+ /**
37
+ * Read package.json
38
+ *
39
+ * @returns {PackageJson} Parsed package.json
40
+ */
41
+ function readPackageJson() {
42
+ try {
43
+ const content = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
44
+ return JSON.parse(content);
45
+ }
46
+ catch (error) {
47
+ console.error('Error reading package.json:', error.message);
48
+ process.exit(1);
49
+ }
50
+ }
51
+ /**
52
+ * Write package.json
53
+ *
54
+ * @param {PackageJson} packageData - Package data to write
55
+ */
56
+ function writePackageJson(packageData) {
57
+ try {
58
+ const content = JSON.stringify(packageData, null, 2) + '\n';
59
+ fs.writeFileSync(PACKAGE_JSON_PATH, content);
60
+ }
61
+ catch (error) {
62
+ console.error('Error writing package.json:', error.message);
63
+ process.exit(1);
64
+ }
65
+ }
66
+ /**
67
+ * Update version in app-default-config.json
68
+ *
69
+ * @param {string} newVersion - New version string
70
+ */
71
+ function updateAppConfig(newVersion) {
72
+ try {
73
+ const content = fs.readFileSync(APP_CONFIG_PATH, 'utf8');
74
+ const config = JSON.parse(content);
75
+ config['ngdpbase.version'] = newVersion;
76
+ fs.writeFileSync(APP_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
77
+ console.log(`Updated app-default-config.json with version ${newVersion}`);
78
+ }
79
+ catch (error) {
80
+ console.warn('Warning: Could not update app-default-config.json:', error.message);
81
+ }
82
+ }
83
+ /**
84
+ * Update the version fields in package-lock.json.
85
+ *
86
+ * The lockfile mirrors the project version in two places — the top-level
87
+ * `version` and the root package entry `packages[""].version` — and npm rewrites
88
+ * both on the next `npm install`. Leaving them behind meant every satellite
89
+ * checkout showed a two-line `package-lock.json` diff after any build, which had
90
+ * to be inspected and discarded by hand at every release. Keeping them in
91
+ * lockstep here removes the drift at the source.
92
+ *
93
+ * A parse → mutate → `JSON.stringify(…, null, 2)` round-trip is byte-identical
94
+ * to npm's own formatting for this lockfile, so this rewrites exactly the two
95
+ * version lines and nothing else. Dependency resolution is untouched: only the
96
+ * project's own version is edited, never any package entry.
97
+ *
98
+ * Warns rather than fails — a missing or unparseable lockfile must not abort a
99
+ * release, matching how a missing app-default-config.json is handled.
100
+ *
101
+ * @param {string} newVersion - New version string
102
+ */
103
+ function updatePackageLock(newVersion) {
104
+ try {
105
+ if (!fs.existsSync(PACKAGE_LOCK_PATH)) {
106
+ console.warn('Warning: package-lock.json not found, skipping');
107
+ return;
108
+ }
109
+ const content = fs.readFileSync(PACKAGE_LOCK_PATH, 'utf8');
110
+ const lock = applyVersionToLock(JSON.parse(content), newVersion);
111
+ fs.writeFileSync(PACKAGE_LOCK_PATH, JSON.stringify(lock, null, 2) + '\n');
112
+ console.log(`Updated package-lock.json with version ${newVersion}`);
113
+ }
114
+ catch (error) {
115
+ console.warn('Warning: Could not update package-lock.json:', error.message);
116
+ }
117
+ }
118
+ /**
119
+ * Set the project's own version in a parsed lockfile — the pure half of
120
+ * {@link updatePackageLock}, split out so the mapping is testable without
121
+ * touching the filesystem.
122
+ *
123
+ * Writes exactly two fields: the top-level `version` and the root package entry
124
+ * `packages[""].version`. Dependency entries are never touched — only npm may
125
+ * change those, and rewriting one here would corrupt resolution.
126
+ *
127
+ * @param {PackageLock} lock - Parsed lockfile (mutated in place and returned)
128
+ * @param {string} newVersion - New version string
129
+ * @returns {PackageLock} The same object, for convenient chaining
130
+ */
131
+ export function applyVersionToLock(lock, newVersion) {
132
+ lock.version = newVersion;
133
+ // lockfileVersion 1 has no `packages` map — guard rather than assume v2/v3.
134
+ const rootEntry = lock.packages?.[''];
135
+ if (rootEntry)
136
+ rootEntry.version = newVersion;
137
+ return lock;
138
+ }
139
+ /**
140
+ * Parse a semantic version string
141
+ *
142
+ * @param {string} version - Version string (e.g., "1.2.3")
143
+ * @returns {VersionComponents} Parsed version components
144
+ * @throws {Error} If version format is invalid
145
+ */
146
+ export function parseVersion(version) {
147
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
148
+ if (!match) {
149
+ throw new Error(`Invalid version format: ${version}`);
150
+ }
151
+ return {
152
+ major: parseInt(match[1], 10),
153
+ minor: parseInt(match[2], 10),
154
+ patch: parseInt(match[3], 10)
155
+ };
156
+ }
157
+ /**
158
+ * Whether `version` is `minimum` or later (both `x.y.z`). An add-on states the
159
+ * core release it needs this way (#1757: `ngdpbase.minVersion`).
160
+ *
161
+ * @throws {Error} If either version format is invalid
162
+ */
163
+ export function isAtLeast(version, minimum) {
164
+ const v = parseVersion(version);
165
+ const m = parseVersion(minimum);
166
+ if (v.major !== m.major)
167
+ return v.major > m.major;
168
+ if (v.minor !== m.minor)
169
+ return v.minor > m.minor;
170
+ return v.patch >= m.patch;
171
+ }
172
+ /**
173
+ * Format version components into string
174
+ *
175
+ * @param {number} major - Major version
176
+ * @param {number} minor - Minor version
177
+ * @param {number} patch - Patch version
178
+ * @returns {string} Formatted version string
179
+ */
180
+ export function formatVersion(major, minor, patch) {
181
+ return `${major}.${minor}.${patch}`;
182
+ }
183
+ /**
184
+ * Increment version based on type
185
+ *
186
+ * @param {string} currentVersion - Current version string
187
+ * @param {VersionIncrementType} type - Type of increment
188
+ * @returns {string} New version string
189
+ * @throws {Error} If increment type is invalid
190
+ */
191
+ export function incrementVersion(currentVersion, type) {
192
+ const { major, minor, patch } = parseVersion(currentVersion);
193
+ switch (type) {
194
+ case 'patch':
195
+ return formatVersion(major, minor, patch + 1);
196
+ case 'minor':
197
+ return formatVersion(major, minor + 1, 0);
198
+ case 'major':
199
+ return formatVersion(major + 1, 0, 0);
200
+ default:
201
+ throw new Error(`Invalid increment type: ${String(type)}`);
202
+ }
203
+ }
204
+ /**
205
+ * Get current date in ISO format (YYYY-MM-DD)
206
+ *
207
+ * @returns {string} Current date
208
+ */
209
+ function getCurrentDate() {
210
+ return new Date().toISOString().split('T')[0];
211
+ }
212
+ /**
213
+ * Update CHANGELOG.md for a new release
214
+ *
215
+ * @param {string} newVersion - New version number
216
+ */
217
+ function updateChangelogForRelease(newVersion) {
218
+ try {
219
+ let changelog = fs.readFileSync(CHANGELOG_PATH, 'utf8');
220
+ const newHeader = `## [${newVersion}] - ${getCurrentDate()}`;
221
+ if (/## \[Unreleased\]/.test(changelog)) {
222
+ // Replace [Unreleased] heading, preserving a fresh one above the new entry
223
+ const replacement = `## [Unreleased]\n\n### Planned\n\n- Future enhancements\n\n${newHeader}`;
224
+ changelog = changelog.replace(/## \[Unreleased\]/, replacement);
225
+ }
226
+ else {
227
+ // No [Unreleased] section — insert new version after the header block (first ---)
228
+ const insertAfter = /^---\s*\n/m;
229
+ if (insertAfter.test(changelog)) {
230
+ changelog = changelog.replace(insertAfter, `---\n\n${newHeader}\n\n`);
231
+ }
232
+ else {
233
+ // Fallback: prepend to the first ## heading
234
+ changelog = changelog.replace(/^(## )/m, `${newHeader}\n\n$1`);
235
+ }
236
+ }
237
+ fs.writeFileSync(CHANGELOG_PATH, changelog);
238
+ console.log(`Updated CHANGELOG.md with version ${newVersion}`);
239
+ }
240
+ catch (error) {
241
+ console.warn('Warning: Could not update CHANGELOG.md:', error.message);
242
+ }
243
+ }
244
+ /**
245
+ * Extract the release notes for a specific version from CHANGELOG.md.
246
+ * Returns the content between `## [version]` and the next `## [` heading.
247
+ */
248
+ function extractChangelogNotes(version) {
249
+ try {
250
+ const changelog = fs.readFileSync(CHANGELOG_PATH, 'utf8');
251
+ const escaped = version.replace(/\./g, '\\.');
252
+ const pattern = new RegExp(`## \\[${escaped}\\][^\\n]*\\n([\\s\\S]*?)(?=\\n## \\[|$)`);
253
+ const match = changelog.match(pattern);
254
+ if (match?.[1])
255
+ return match[1].trim();
256
+ }
257
+ catch {
258
+ // ignore — caller handles empty notes
259
+ }
260
+ return '';
261
+ }
262
+ /**
263
+ * Create a git tag and push it to origin.
264
+ */
265
+ function createGitTag(version) {
266
+ const tag = `v${version}`;
267
+ try {
268
+ execSync(`git tag ${tag}`, { stdio: 'inherit' });
269
+ execSync(`git push origin ${tag}`, { stdio: 'inherit' });
270
+ console.log(`✅ Git tag ${tag} created and pushed`);
271
+ }
272
+ catch (error) {
273
+ console.error('Error creating git tag:', error.message);
274
+ process.exit(1);
275
+ }
276
+ }
277
+ /**
278
+ * Create a GitHub release using the `gh` CLI.
279
+ * Release notes are written to a temp file to avoid shell-escaping issues.
280
+ */
281
+ function createGitHubRelease(version, notes) {
282
+ const tag = `v${version}`;
283
+ const tmpFile = path.join(PROJECT_ROOT, '.release-notes.tmp');
284
+ try {
285
+ fs.writeFileSync(tmpFile, notes || `Release ${tag}`);
286
+ execSync(`gh release create ${tag} --title "${tag}" --notes-file "${tmpFile}"`, { stdio: 'inherit' });
287
+ fs.unlinkSync(tmpFile);
288
+ console.log(`✅ GitHub release ${tag} created`);
289
+ }
290
+ catch (error) {
291
+ console.error('Error creating GitHub release:', error.message);
292
+ try {
293
+ fs.unlinkSync(tmpFile);
294
+ }
295
+ catch { /* ignore */ }
296
+ process.exit(1);
297
+ }
298
+ }
299
+ /**
300
+ * Show help message
301
+ */
302
+ function showHelp() {
303
+ console.log(`
304
+ ngdpbase Version Management
305
+
306
+ Usage:
307
+ node version.js - Show current version and info
308
+ node version.js patch - Increment patch (bug fixes: 1.2.0 → 1.2.1)
309
+ node version.js minor - Increment minor (new features: 1.2.0 → 1.3.0)
310
+ node version.js major - Increment major (breaking changes: 1.2.0 → 2.0.0)
311
+ node version.js set <version> - Set specific version (e.g., 1.2.3)
312
+ node version.js patch --tag-only - Bump + create/push git tag (no GH release)
313
+ node version.js minor --release - Bump + create/push tag + GitHub release
314
+ node version.js help - Show this help
315
+
316
+ Semantic Versioning:
317
+ MAJOR.MINOR.PATCH
318
+ - MAJOR: Incompatible API changes
319
+ - MINOR: Backward-compatible functionality additions
320
+ - PATCH: Backward-compatible bug fixes
321
+ `);
322
+ }
323
+ /**
324
+ * Main CLI function
325
+ */
326
+ function main() {
327
+ const args = process.argv.slice(2);
328
+ const command = args[0];
329
+ const doRelease = args.includes('--release');
330
+ const doTagOnly = args.includes('--tag-only');
331
+ const packageData = readPackageJson();
332
+ const currentVersion = packageData.version;
333
+ if (!command || command === 'help') {
334
+ if (!command) {
335
+ console.log(`Current version: ${currentVersion}`);
336
+ console.log(`Project: ${packageData.name}`);
337
+ console.log(`Description: ${packageData.description || 'No description'}`);
338
+ console.log('\nRun "node version.js help" for usage information.');
339
+ }
340
+ else {
341
+ showHelp();
342
+ }
343
+ return;
344
+ }
345
+ try {
346
+ let newVersion;
347
+ switch (command) {
348
+ case 'patch':
349
+ case 'minor':
350
+ case 'major':
351
+ newVersion = incrementVersion(currentVersion, command);
352
+ break;
353
+ case 'set':
354
+ newVersion = args[1];
355
+ if (!newVersion) {
356
+ console.error('Error: Please specify a version to set');
357
+ process.exit(1);
358
+ }
359
+ // Validate version format
360
+ parseVersion(newVersion);
361
+ break;
362
+ default:
363
+ console.error(`Error: Unknown command "${command}"`);
364
+ console.log('Run "node version.js help" for usage information.');
365
+ process.exit(1);
366
+ }
367
+ // Update package.json
368
+ packageData.version = newVersion;
369
+ writePackageJson(packageData);
370
+ // Update package-lock.json (its two version fields mirror package.json)
371
+ updatePackageLock(newVersion);
372
+ // Update app-default-config.json
373
+ updateAppConfig(newVersion);
374
+ // Update changelog if incrementing
375
+ if (['patch', 'minor', 'major'].includes(command)) {
376
+ updateChangelogForRelease(newVersion);
377
+ }
378
+ console.log(`Version updated: ${currentVersion} → ${newVersion}`);
379
+ console.log(`Type: ${command.toUpperCase()}`);
380
+ if (command === 'major') {
381
+ console.log('\n⚠️ MAJOR version bump - ensure breaking changes are documented!');
382
+ }
383
+ else if (command === 'minor') {
384
+ console.log('\n✨ MINOR version bump - new features added');
385
+ }
386
+ else if (command === 'patch') {
387
+ console.log('\n🐛 PATCH version bump - bug fixes');
388
+ }
389
+ // Tag and/or release if requested
390
+ if (doRelease || doTagOnly) {
391
+ createGitTag(newVersion);
392
+ }
393
+ if (doRelease) {
394
+ const notes = extractChangelogNotes(newVersion);
395
+ createGitHubRelease(newVersion, notes);
396
+ }
397
+ }
398
+ catch (error) {
399
+ console.error('Error:', error.message);
400
+ process.exit(1);
401
+ }
402
+ }
403
+ // Run main only when executed directly (ESM-safe).
404
+ // `require.main === module` would crash under "type": "module".
405
+ const argvPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
406
+ if (argvPath === __filename) {
407
+ main();
408
+ }