@pgpmjs/core 3.0.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.
- package/LICENSE +23 -0
- package/README.md +99 -0
- package/core/boilerplate-scanner.d.ts +41 -0
- package/core/boilerplate-scanner.js +106 -0
- package/core/boilerplate-types.d.ts +52 -0
- package/core/boilerplate-types.js +6 -0
- package/core/class/pgpm.d.ts +150 -0
- package/core/class/pgpm.js +1470 -0
- package/core/template-scaffold.d.ts +29 -0
- package/core/template-scaffold.js +168 -0
- package/esm/core/boilerplate-scanner.js +96 -0
- package/esm/core/boilerplate-types.js +5 -0
- package/esm/core/class/pgpm.js +1430 -0
- package/esm/core/template-scaffold.js +161 -0
- package/esm/export/export-meta.js +240 -0
- package/esm/export/export-migrations.js +180 -0
- package/esm/extensions/extensions.js +31 -0
- package/esm/files/extension/index.js +3 -0
- package/esm/files/extension/reader.js +79 -0
- package/esm/files/extension/writer.js +63 -0
- package/esm/files/index.js +6 -0
- package/esm/files/plan/generator.js +49 -0
- package/esm/files/plan/index.js +5 -0
- package/esm/files/plan/parser.js +296 -0
- package/esm/files/plan/validators.js +181 -0
- package/esm/files/plan/writer.js +114 -0
- package/esm/files/sql/index.js +1 -0
- package/esm/files/sql/writer.js +107 -0
- package/esm/files/sql-scripts/index.js +2 -0
- package/esm/files/sql-scripts/reader.js +19 -0
- package/esm/files/types/index.js +1 -0
- package/esm/files/types/package.js +1 -0
- package/esm/index.js +21 -0
- package/esm/init/client.js +144 -0
- package/esm/init/sql/bootstrap-roles.sql +55 -0
- package/esm/init/sql/bootstrap-test-roles.sql +72 -0
- package/esm/migrate/clean.js +23 -0
- package/esm/migrate/client.js +551 -0
- package/esm/migrate/index.js +5 -0
- package/esm/migrate/sql/procedures.sql +258 -0
- package/esm/migrate/sql/schema.sql +37 -0
- package/esm/migrate/types.js +1 -0
- package/esm/migrate/utils/event-logger.js +28 -0
- package/esm/migrate/utils/hash.js +27 -0
- package/esm/migrate/utils/transaction.js +125 -0
- package/esm/modules/modules.js +49 -0
- package/esm/packaging/package.js +96 -0
- package/esm/packaging/transform.js +70 -0
- package/esm/projects/deploy.js +123 -0
- package/esm/projects/revert.js +75 -0
- package/esm/projects/verify.js +61 -0
- package/esm/resolution/deps.js +526 -0
- package/esm/resolution/resolve.js +101 -0
- package/esm/utils/debug.js +147 -0
- package/esm/utils/target-utils.js +37 -0
- package/esm/workspace/paths.js +43 -0
- package/esm/workspace/utils.js +31 -0
- package/export/export-meta.d.ts +8 -0
- package/export/export-meta.js +244 -0
- package/export/export-migrations.d.ts +17 -0
- package/export/export-migrations.js +187 -0
- package/extensions/extensions.d.ts +5 -0
- package/extensions/extensions.js +35 -0
- package/files/extension/index.d.ts +2 -0
- package/files/extension/index.js +19 -0
- package/files/extension/reader.d.ts +24 -0
- package/files/extension/reader.js +86 -0
- package/files/extension/writer.d.ts +39 -0
- package/files/extension/writer.js +70 -0
- package/files/index.d.ts +5 -0
- package/files/index.js +22 -0
- package/files/plan/generator.d.ts +22 -0
- package/files/plan/generator.js +57 -0
- package/files/plan/index.d.ts +4 -0
- package/files/plan/index.js +21 -0
- package/files/plan/parser.d.ts +27 -0
- package/files/plan/parser.js +303 -0
- package/files/plan/validators.d.ts +52 -0
- package/files/plan/validators.js +187 -0
- package/files/plan/writer.d.ts +27 -0
- package/files/plan/writer.js +124 -0
- package/files/sql/index.d.ts +1 -0
- package/files/sql/index.js +17 -0
- package/files/sql/writer.d.ts +12 -0
- package/files/sql/writer.js +114 -0
- package/files/sql-scripts/index.d.ts +1 -0
- package/files/sql-scripts/index.js +18 -0
- package/files/sql-scripts/reader.d.ts +8 -0
- package/files/sql-scripts/reader.js +23 -0
- package/files/types/index.d.ts +46 -0
- package/files/types/index.js +17 -0
- package/files/types/package.d.ts +20 -0
- package/files/types/package.js +2 -0
- package/index.d.ts +21 -0
- package/index.js +45 -0
- package/init/client.d.ts +26 -0
- package/init/client.js +148 -0
- package/init/sql/bootstrap-roles.sql +55 -0
- package/init/sql/bootstrap-test-roles.sql +72 -0
- package/migrate/clean.d.ts +1 -0
- package/migrate/clean.js +27 -0
- package/migrate/client.d.ts +80 -0
- package/migrate/client.js +555 -0
- package/migrate/index.d.ts +5 -0
- package/migrate/index.js +21 -0
- package/migrate/sql/procedures.sql +258 -0
- package/migrate/sql/schema.sql +37 -0
- package/migrate/types.d.ts +67 -0
- package/migrate/types.js +2 -0
- package/migrate/utils/event-logger.d.ts +13 -0
- package/migrate/utils/event-logger.js +32 -0
- package/migrate/utils/hash.d.ts +12 -0
- package/migrate/utils/hash.js +32 -0
- package/migrate/utils/transaction.d.ts +27 -0
- package/migrate/utils/transaction.js +129 -0
- package/modules/modules.d.ts +31 -0
- package/modules/modules.js +56 -0
- package/package.json +70 -0
- package/packaging/package.d.ts +19 -0
- package/packaging/package.js +102 -0
- package/packaging/transform.d.ts +22 -0
- package/packaging/transform.js +75 -0
- package/projects/deploy.d.ts +8 -0
- package/projects/deploy.js +160 -0
- package/projects/revert.d.ts +15 -0
- package/projects/revert.js +112 -0
- package/projects/verify.d.ts +8 -0
- package/projects/verify.js +98 -0
- package/resolution/deps.d.ts +57 -0
- package/resolution/deps.js +531 -0
- package/resolution/resolve.d.ts +37 -0
- package/resolution/resolve.js +107 -0
- package/utils/debug.d.ts +21 -0
- package/utils/debug.js +153 -0
- package/utils/target-utils.d.ts +5 -0
- package/utils/target-utils.js +40 -0
- package/workspace/paths.d.ts +14 -0
- package/workspace/paths.js +50 -0
- package/workspace/utils.d.ts +8 -0
- package/workspace/utils.js +36 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { getExtensionInfo } from './reader';
|
|
3
|
+
/**
|
|
4
|
+
* Write the Makefile for the extension.
|
|
5
|
+
*/
|
|
6
|
+
export const writeExtensionMakefile = (outputPath, extname, version) => {
|
|
7
|
+
const content = `EXTENSION = ${extname}
|
|
8
|
+
DATA = sql/${extname}--${version}.sql
|
|
9
|
+
|
|
10
|
+
PG_CONFIG = pg_config
|
|
11
|
+
PGXS := $(shell $(PG_CONFIG) --pgxs)
|
|
12
|
+
include $(PGXS)
|
|
13
|
+
`;
|
|
14
|
+
writeFileSync(outputPath, content);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Generate content for a .control file
|
|
18
|
+
* https://www.postgresql.org/docs/current/extend-extensions.html
|
|
19
|
+
*/
|
|
20
|
+
export function generateControlFileContent(options) {
|
|
21
|
+
const { name, version, comment = `${name} extension`, requires = [], default_version = version, relocatable = false, superuser = false, schema, module_pathname } = options;
|
|
22
|
+
let content = `# ${name} extension
|
|
23
|
+
comment = '${comment}'
|
|
24
|
+
default_version = '${default_version}'
|
|
25
|
+
`;
|
|
26
|
+
if (module_pathname) {
|
|
27
|
+
content += `module_pathname = '${module_pathname}'\n`;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
content += `module_pathname = '$libdir/${name}'\n`;
|
|
31
|
+
}
|
|
32
|
+
if (requires.length > 0) {
|
|
33
|
+
content += `requires = '${requires.join(',')}'\n`;
|
|
34
|
+
}
|
|
35
|
+
content += `relocatable = ${relocatable}
|
|
36
|
+
superuser = ${superuser}\n`;
|
|
37
|
+
if (schema) {
|
|
38
|
+
content += `schema = ${schema}\n`;
|
|
39
|
+
}
|
|
40
|
+
return content;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Write the control file for the extension.
|
|
44
|
+
*/
|
|
45
|
+
export const writeExtensionControlFile = (outputPath, extname, extensions, version) => {
|
|
46
|
+
const content = generateControlFileContent({
|
|
47
|
+
name: extname,
|
|
48
|
+
version,
|
|
49
|
+
requires: extensions
|
|
50
|
+
});
|
|
51
|
+
writeFileSync(outputPath, content);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Write control and Makefile for the extension with given data.
|
|
55
|
+
* If extInfo is not provided, it will be generated from packageDir.
|
|
56
|
+
*/
|
|
57
|
+
export const writeExtensions = (packageDir, extensions, extInfo) => {
|
|
58
|
+
// If extInfo is not provided, get it from packageDir
|
|
59
|
+
const info = extInfo || getExtensionInfo(packageDir);
|
|
60
|
+
const { controlFile, Makefile, extname, version } = info;
|
|
61
|
+
writeExtensionControlFile(controlFile, extname, extensions, version);
|
|
62
|
+
writeExtensionMakefile(Makefile, extname, version);
|
|
63
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
/**
|
|
3
|
+
* Get a UTC timestamp string
|
|
4
|
+
*/
|
|
5
|
+
function getUTCTimestamp(d = new Date()) {
|
|
6
|
+
return (d.getUTCFullYear() +
|
|
7
|
+
'-' + String(d.getUTCMonth() + 1).padStart(2, '0') +
|
|
8
|
+
'-' + String(d.getUTCDate()).padStart(2, '0') +
|
|
9
|
+
'T' + String(d.getUTCHours()).padStart(2, '0') +
|
|
10
|
+
':' + String(d.getUTCMinutes()).padStart(2, '0') +
|
|
11
|
+
':' + String(d.getUTCSeconds()).padStart(2, '0') +
|
|
12
|
+
'Z');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Get a timestamp for the plan file
|
|
16
|
+
*/
|
|
17
|
+
export function getNow() {
|
|
18
|
+
return process.env.NODE_ENV === 'test'
|
|
19
|
+
? getUTCTimestamp(new Date('2017-08-11T08:11:51Z'))
|
|
20
|
+
: getUTCTimestamp(new Date());
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Generate a plan file content from structured data
|
|
24
|
+
*/
|
|
25
|
+
export function generatePlan(options) {
|
|
26
|
+
const { moduleName, uri, entries } = options;
|
|
27
|
+
const now = getNow();
|
|
28
|
+
const planfile = [
|
|
29
|
+
`%syntax-version=1.0.0`,
|
|
30
|
+
`%project=${moduleName}`,
|
|
31
|
+
`%uri=${uri || moduleName}`
|
|
32
|
+
];
|
|
33
|
+
// Generate the plan entries
|
|
34
|
+
entries.forEach(entry => {
|
|
35
|
+
if (entry.dependencies && entry.dependencies.length > 0) {
|
|
36
|
+
planfile.push(`${entry.change} [${entry.dependencies.join(' ')}] ${now} launchql <launchql@5b0c196eeb62>${entry.comment ? ` # ${entry.comment}` : ''}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
planfile.push(`${entry.change} ${now} launchql <launchql@5b0c196eeb62>${entry.comment ? ` # ${entry.comment}` : ''}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return planfile.join('\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Write a generated plan file to disk
|
|
46
|
+
*/
|
|
47
|
+
export function writePlan(planPath, plan) {
|
|
48
|
+
fs.writeFileSync(planPath, plan);
|
|
49
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { isValidChangeName, isValidDependency, isValidTagName, parseReference } from './validators';
|
|
3
|
+
import { errors } from '@pgpmjs/types';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a Sqitch plan file with full validation
|
|
6
|
+
* Supports both changes and tags
|
|
7
|
+
*/
|
|
8
|
+
export function parsePlanFile(planPath) {
|
|
9
|
+
const content = readFileSync(planPath, 'utf-8');
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
let project = '';
|
|
12
|
+
let uri = '';
|
|
13
|
+
const changes = [];
|
|
14
|
+
const tags = [];
|
|
15
|
+
const errors = [];
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
const line = lines[i];
|
|
18
|
+
const lineNum = i + 1;
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
// Skip empty lines
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
continue;
|
|
23
|
+
// Skip comments
|
|
24
|
+
if (trimmed.startsWith('#'))
|
|
25
|
+
continue;
|
|
26
|
+
// Parse package metadata
|
|
27
|
+
if (trimmed.startsWith('%project=')) {
|
|
28
|
+
project = trimmed.substring('%project='.length);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (trimmed.startsWith('%uri=')) {
|
|
32
|
+
uri = trimmed.substring('%uri='.length);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Skip other metadata lines
|
|
36
|
+
if (trimmed.startsWith('%')) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Parse tag lines
|
|
40
|
+
if (trimmed.startsWith('@')) {
|
|
41
|
+
const lastChangeName = changes.length > 0 ? changes[changes.length - 1].name : null;
|
|
42
|
+
const tag = parseTagLine(trimmed, lastChangeName);
|
|
43
|
+
if (tag) {
|
|
44
|
+
if (isValidTagName(tag.name)) {
|
|
45
|
+
tags.push(tag);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
errors.push({
|
|
49
|
+
line: lineNum,
|
|
50
|
+
message: `Invalid tag name: ${tag.name}`
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
errors.push({
|
|
56
|
+
line: lineNum,
|
|
57
|
+
message: 'Invalid tag line format'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Parse change lines
|
|
63
|
+
const change = parseChangeLine(trimmed);
|
|
64
|
+
if (change) {
|
|
65
|
+
// Validate change name
|
|
66
|
+
if (!isValidChangeName(change.name)) {
|
|
67
|
+
errors.push({
|
|
68
|
+
line: lineNum,
|
|
69
|
+
message: `Invalid change name: ${change.name}`
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Validate dependencies
|
|
74
|
+
for (const dep of change.dependencies) {
|
|
75
|
+
if (!isValidDependency(dep)) {
|
|
76
|
+
errors.push({
|
|
77
|
+
line: lineNum,
|
|
78
|
+
message: `Invalid dependency reference: ${dep}`
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
changes.push(change);
|
|
83
|
+
}
|
|
84
|
+
else if (trimmed) {
|
|
85
|
+
// Non-empty line that couldn't be parsed
|
|
86
|
+
errors.push({
|
|
87
|
+
line: lineNum,
|
|
88
|
+
message: 'Invalid line format'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
return { errors };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
data: { package: project, uri, changes, tags },
|
|
97
|
+
errors: []
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse a single change line from a plan file
|
|
102
|
+
* Format: change_name [deps] timestamp planner <email> # comment
|
|
103
|
+
*/
|
|
104
|
+
function parseChangeLine(line) {
|
|
105
|
+
// More flexible regex that handles various formats, including planner names with spaces
|
|
106
|
+
// Format: change_name [deps] timestamp planner <email> # comment
|
|
107
|
+
// The timestamp is required if planner/email/comment are present
|
|
108
|
+
const regex = /^(\S+)(?:\s+\[([^\]]*)\])?(?:\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)(?:\s+([^<]+?))?(?:\s+<([^>]+)>)?(?:\s+#\s+(.*))?)?$/;
|
|
109
|
+
const match = line.match(regex);
|
|
110
|
+
if (!match) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const [, name, depsStr, timestamp, planner, email, comment] = match;
|
|
114
|
+
// Parse dependencies
|
|
115
|
+
const dependencies = depsStr
|
|
116
|
+
? depsStr.split(/\s+/).filter(dep => dep.length > 0)
|
|
117
|
+
: [];
|
|
118
|
+
return {
|
|
119
|
+
name,
|
|
120
|
+
dependencies,
|
|
121
|
+
timestamp,
|
|
122
|
+
planner,
|
|
123
|
+
email,
|
|
124
|
+
comment
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parse a tag line from a plan file
|
|
129
|
+
* Format: @tag_name change_name timestamp planner <email> # comment
|
|
130
|
+
*/
|
|
131
|
+
function parseTagLine(line, lastChangeName) {
|
|
132
|
+
// Tag lines start with @
|
|
133
|
+
if (!line.startsWith('@')) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
// Remove the @ and parse
|
|
137
|
+
const tagContent = line.substring(1);
|
|
138
|
+
// Two possible formats:
|
|
139
|
+
// 1. tagname timestamp planner <email> # comment (tag for last change)
|
|
140
|
+
// 2. tagname changename timestamp planner <email> # comment (tag for specific change)
|
|
141
|
+
// First try to match with change name (format 2)
|
|
142
|
+
const regexWithChange = /^(\S+)\s+(\S+)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+(.+?)\s+<([^>]+)>(?:\s+#\s+(.*))?$/;
|
|
143
|
+
let match = tagContent.match(regexWithChange);
|
|
144
|
+
if (match) {
|
|
145
|
+
const [, name, secondToken, timestamp, planner, email, comment] = match;
|
|
146
|
+
// Check if the second token is a timestamp (format 1) or change name (format 2)
|
|
147
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(secondToken)) {
|
|
148
|
+
// Format 1: no change name specified
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
change: lastChangeName || '', // Tag is associated with the last change
|
|
152
|
+
timestamp: secondToken,
|
|
153
|
+
planner: timestamp, // What we thought was timestamp is actually planner
|
|
154
|
+
email: planner, // Shift everything
|
|
155
|
+
comment: email || comment
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Format 2: explicit change name
|
|
160
|
+
return {
|
|
161
|
+
name,
|
|
162
|
+
change: secondToken,
|
|
163
|
+
timestamp,
|
|
164
|
+
planner,
|
|
165
|
+
email,
|
|
166
|
+
comment
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Try simple format without change name
|
|
171
|
+
const regexSimple = /^(\S+)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+(.+?)\s+<([^>]+)>(?:\s+#\s+(.*))?$/;
|
|
172
|
+
match = tagContent.match(regexSimple);
|
|
173
|
+
if (!match) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const [, name, timestamp, planner, email, comment] = match;
|
|
177
|
+
return {
|
|
178
|
+
name,
|
|
179
|
+
change: lastChangeName || '', // Tag is associated with the last change
|
|
180
|
+
timestamp,
|
|
181
|
+
planner,
|
|
182
|
+
email,
|
|
183
|
+
comment
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve a reference within a plan file context
|
|
188
|
+
* Handles symbolic references (HEAD, ROOT) and relative references
|
|
189
|
+
*/
|
|
190
|
+
export function resolveReference(ref, plan, currentPackage) {
|
|
191
|
+
const parsed = parseReference(ref);
|
|
192
|
+
if (!parsed) {
|
|
193
|
+
return { error: `Invalid reference: ${ref}` };
|
|
194
|
+
}
|
|
195
|
+
// Handle package qualifier
|
|
196
|
+
const planPackage = currentPackage || plan.package;
|
|
197
|
+
if (parsed.package && parsed.package !== planPackage) {
|
|
198
|
+
// Cross-package reference - return as-is
|
|
199
|
+
return { change: ref };
|
|
200
|
+
}
|
|
201
|
+
// Handle SHA1 references
|
|
202
|
+
if (parsed.sha1) {
|
|
203
|
+
// Would need to look up in database
|
|
204
|
+
return { change: ref };
|
|
205
|
+
}
|
|
206
|
+
// Handle symbolic references
|
|
207
|
+
if (parsed.symbolic === 'HEAD' && plan.changes.length > 0) {
|
|
208
|
+
return { change: plan.changes[plan.changes.length - 1].name };
|
|
209
|
+
}
|
|
210
|
+
if (parsed.symbolic === 'ROOT' && plan.changes.length > 0) {
|
|
211
|
+
return { change: plan.changes[0].name };
|
|
212
|
+
}
|
|
213
|
+
// Handle relative references
|
|
214
|
+
if (parsed.relative) {
|
|
215
|
+
const baseResolved = resolveReference(parsed.relative.base, plan, currentPackage);
|
|
216
|
+
if (baseResolved.error) {
|
|
217
|
+
return baseResolved;
|
|
218
|
+
}
|
|
219
|
+
// Get the change name from the resolved reference
|
|
220
|
+
const baseChange = baseResolved.change;
|
|
221
|
+
if (!baseChange) {
|
|
222
|
+
return { error: `Cannot resolve base reference: ${parsed.relative.base}` };
|
|
223
|
+
}
|
|
224
|
+
const changeIndex = plan.changes.findIndex(c => c.name === baseChange);
|
|
225
|
+
if (changeIndex === -1) {
|
|
226
|
+
return { error: `Change not found: ${baseChange}` };
|
|
227
|
+
}
|
|
228
|
+
let targetIndex;
|
|
229
|
+
if (parsed.relative.direction === '^') {
|
|
230
|
+
// Go backwards
|
|
231
|
+
targetIndex = changeIndex - parsed.relative.count;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Go forwards
|
|
235
|
+
targetIndex = changeIndex + parsed.relative.count;
|
|
236
|
+
}
|
|
237
|
+
if (targetIndex < 0 || targetIndex >= plan.changes.length) {
|
|
238
|
+
return { error: `Relative reference out of bounds: ${ref}` };
|
|
239
|
+
}
|
|
240
|
+
return { change: plan.changes[targetIndex].name };
|
|
241
|
+
}
|
|
242
|
+
// Handle tag references
|
|
243
|
+
if (parsed.tag) {
|
|
244
|
+
const tag = plan.tags.find(t => t.name === parsed.tag);
|
|
245
|
+
if (!tag) {
|
|
246
|
+
return { error: `Tag not found: ${parsed.tag}` };
|
|
247
|
+
}
|
|
248
|
+
return { tag: parsed.tag, change: tag.change };
|
|
249
|
+
}
|
|
250
|
+
// Handle change@tag format
|
|
251
|
+
if (parsed.change && parsed.tag) {
|
|
252
|
+
return { change: parsed.change, tag: parsed.tag };
|
|
253
|
+
}
|
|
254
|
+
// Plain change reference
|
|
255
|
+
if (parsed.change) {
|
|
256
|
+
// Validate that the change exists in the plan
|
|
257
|
+
const changeExists = plan.changes.some(c => c.name === parsed.change);
|
|
258
|
+
if (!changeExists) {
|
|
259
|
+
return { error: `Change not found: ${parsed.change}` };
|
|
260
|
+
}
|
|
261
|
+
return { change: parsed.change };
|
|
262
|
+
}
|
|
263
|
+
return { error: `Cannot resolve reference: ${ref}` };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Simple plan file parser without validation (for backwards compatibility)
|
|
267
|
+
*/
|
|
268
|
+
export function parsePlanFileSimple(planPath) {
|
|
269
|
+
const result = parsePlanFile(planPath);
|
|
270
|
+
if (result.data) {
|
|
271
|
+
// Return without tags for simple format
|
|
272
|
+
const { tags, ...planWithoutTags } = result.data;
|
|
273
|
+
return planWithoutTags;
|
|
274
|
+
}
|
|
275
|
+
// If there are errors, throw with details
|
|
276
|
+
if (result.errors && result.errors.length > 0) {
|
|
277
|
+
const errorMessages = result.errors.map(e => `Line ${e.line}: ${e.message}`).join('\n');
|
|
278
|
+
throw errors.PLAN_PARSE_ERROR({ planPath, errors: errorMessages });
|
|
279
|
+
}
|
|
280
|
+
// Return empty plan if no data and no errors
|
|
281
|
+
return { package: '', uri: '', changes: [] };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Get all change names from a plan file
|
|
285
|
+
*/
|
|
286
|
+
export function getChanges(planPath) {
|
|
287
|
+
const plan = parsePlanFileSimple(planPath);
|
|
288
|
+
return plan.changes.map(change => change.name);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get the latest (last) change from a plan file
|
|
292
|
+
*/
|
|
293
|
+
export function getLatestChange(planPath) {
|
|
294
|
+
const changes = getChanges(planPath);
|
|
295
|
+
return changes[changes.length - 1] || '';
|
|
296
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators for Sqitch change and tag names according to the spec:
|
|
3
|
+
* https://sqitch.org/docs/manual/sqitchchanges/
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Check if a character is punctuation (excluding underscore)
|
|
7
|
+
*/
|
|
8
|
+
function isPunctuation(char) {
|
|
9
|
+
// Common punctuation marks, excluding underscore
|
|
10
|
+
const punctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^`{|}~]/;
|
|
11
|
+
return punctuation.test(char);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Validate a change name according to Sqitch rules:
|
|
15
|
+
* - ≥1 character
|
|
16
|
+
* - Cannot contain whitespace
|
|
17
|
+
* - First and last characters must not be punctuation (except _)
|
|
18
|
+
* - Must not end in ~, ^, /, = or % followed by digits
|
|
19
|
+
* - Disallowed characters anywhere: :, @, #, \
|
|
20
|
+
*/
|
|
21
|
+
export function isValidChangeName(name) {
|
|
22
|
+
if (!name || name.length === 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
// Check for whitespace
|
|
26
|
+
if (/\s/.test(name)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
// Check for disallowed characters anywhere
|
|
30
|
+
if (/[:@#\\]/.test(name)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Check first character - must not be punctuation except underscore
|
|
34
|
+
const firstChar = name[0];
|
|
35
|
+
if (firstChar !== '_' && isPunctuation(firstChar)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Check last character - must not be punctuation except underscore
|
|
39
|
+
const lastChar = name[name.length - 1];
|
|
40
|
+
if (lastChar !== '_' && isPunctuation(lastChar)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
// Check for endings like ~N, ^N, /N, =N, %N where N is digits
|
|
44
|
+
if (/[~^/=%]\d+$/.test(name)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate a tag name according to Sqitch rules:
|
|
51
|
+
* - Same as change name rules
|
|
52
|
+
* - Additionally, must not contain /
|
|
53
|
+
*/
|
|
54
|
+
export function isValidTagName(name) {
|
|
55
|
+
if (!isValidChangeName(name)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// Tags must not contain /
|
|
59
|
+
if (name.includes('/')) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if a string is a valid SHA1 hash (40 hex characters)
|
|
66
|
+
*/
|
|
67
|
+
function isSHA1(str) {
|
|
68
|
+
return /^[0-9a-f]{40}$/i.test(str);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse a reference string into its components
|
|
72
|
+
*/
|
|
73
|
+
export function parseReference(ref) {
|
|
74
|
+
if (!ref) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const result = {};
|
|
78
|
+
// Check for package qualifier
|
|
79
|
+
let workingRef = ref;
|
|
80
|
+
const colonIndex = ref.indexOf(':');
|
|
81
|
+
if (colonIndex > 0) {
|
|
82
|
+
const projectPart = ref.substring(0, colonIndex);
|
|
83
|
+
const remainingPart = ref.substring(colonIndex + 1);
|
|
84
|
+
// Check if this is actually a package qualifier or just a colon in the name
|
|
85
|
+
// Package names should be valid identifiers (alphanumeric, dash, underscore)
|
|
86
|
+
// Also check that the remaining part doesn't have more colons
|
|
87
|
+
if (/^[a-zA-Z0-9_-]+$/.test(projectPart) &&
|
|
88
|
+
remainingPart.length > 0 &&
|
|
89
|
+
remainingPart.indexOf(':') === -1) {
|
|
90
|
+
result.package = projectPart;
|
|
91
|
+
workingRef = remainingPart;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Not a valid package qualifier, treat the whole thing as a reference
|
|
95
|
+
workingRef = ref;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Check for symbolic references
|
|
99
|
+
if (workingRef === 'HEAD' || workingRef === '@HEAD') {
|
|
100
|
+
result.symbolic = 'HEAD';
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
if (workingRef === 'ROOT' || workingRef === '@ROOT') {
|
|
104
|
+
result.symbolic = 'ROOT';
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
// Check for relative references (e.g., HEAD^, @beta~2, HEAD^^)
|
|
108
|
+
// Handle multiple ^ characters
|
|
109
|
+
const multiCaretMatch = workingRef.match(/^(.+?)(\^+)$/);
|
|
110
|
+
if (multiCaretMatch) {
|
|
111
|
+
const [, base, carets] = multiCaretMatch;
|
|
112
|
+
result.relative = {
|
|
113
|
+
base: base, // Keep the original base including @ prefix
|
|
114
|
+
direction: '^',
|
|
115
|
+
count: carets.length
|
|
116
|
+
};
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
// Handle ^N or ~N format
|
|
120
|
+
const relativeMatch = workingRef.match(/^(.+?)([~^])(\d+)$/);
|
|
121
|
+
if (relativeMatch) {
|
|
122
|
+
const [, base, direction, countStr] = relativeMatch;
|
|
123
|
+
const count = parseInt(countStr, 10);
|
|
124
|
+
result.relative = {
|
|
125
|
+
base: base, // Keep the original base including @ prefix
|
|
126
|
+
direction: direction,
|
|
127
|
+
count
|
|
128
|
+
};
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
// Handle single ^ or ~ without number
|
|
132
|
+
const singleRelativeMatch = workingRef.match(/^(.+?)([~^])$/);
|
|
133
|
+
if (singleRelativeMatch) {
|
|
134
|
+
const [, base, direction] = singleRelativeMatch;
|
|
135
|
+
result.relative = {
|
|
136
|
+
base: base, // Keep the original base including @ prefix
|
|
137
|
+
direction: direction,
|
|
138
|
+
count: 1
|
|
139
|
+
};
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
// Check if it's a SHA1
|
|
143
|
+
if (isSHA1(workingRef)) {
|
|
144
|
+
result.sha1 = workingRef;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
// Check for tag reference (starts with @)
|
|
148
|
+
if (workingRef.startsWith('@')) {
|
|
149
|
+
const tagName = workingRef.substring(1);
|
|
150
|
+
if (isValidTagName(tagName)) {
|
|
151
|
+
result.tag = tagName;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
return null; // Invalid tag name
|
|
155
|
+
}
|
|
156
|
+
// Check for change@tag format
|
|
157
|
+
const atIndex = workingRef.indexOf('@');
|
|
158
|
+
if (atIndex > 0) {
|
|
159
|
+
const changeName = workingRef.substring(0, atIndex);
|
|
160
|
+
const tagName = workingRef.substring(atIndex + 1);
|
|
161
|
+
if (isValidChangeName(changeName) && isValidTagName(tagName)) {
|
|
162
|
+
result.change = changeName;
|
|
163
|
+
result.tag = tagName;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
return null; // Invalid change or tag name
|
|
167
|
+
}
|
|
168
|
+
// Must be a plain change name
|
|
169
|
+
if (isValidChangeName(workingRef)) {
|
|
170
|
+
result.change = workingRef;
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
return null; // Invalid reference
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Validate a dependency reference
|
|
177
|
+
* Dependencies can be any valid reference format
|
|
178
|
+
*/
|
|
179
|
+
export function isValidDependency(dep) {
|
|
180
|
+
return parseReference(dep) !== null;
|
|
181
|
+
}
|