@memberjunction/open-app-engine 6.2.0-edge.4 → 6.2.0-edge.6
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/dist/github/github-client.d.ts +8 -5
- package/dist/github/github-client.d.ts.map +1 -1
- package/dist/github/github-client.js +405 -44
- package/dist/github/github-client.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/install/config-manager.d.ts.map +1 -1
- package/dist/install/config-manager.js +7 -0
- package/dist/install/config-manager.js.map +1 -1
- package/dist/install/install-orchestrator.d.ts.map +1 -1
- package/dist/install/install-orchestrator.js +20 -17
- package/dist/install/install-orchestrator.js.map +1 -1
- package/dist/install/package-manager.d.ts.map +1 -1
- package/dist/install/package-manager.js.map +1 -1
- package/dist/install/temp-dir.d.ts +2 -0
- package/dist/install/temp-dir.d.ts.map +1 -0
- package/dist/install/temp-dir.js +8 -0
- package/dist/install/temp-dir.js.map +1 -0
- package/dist/manifest/manifest-schema.d.ts +207 -64
- package/dist/manifest/manifest-schema.d.ts.map +1 -1
- package/dist/manifest/manifest-schema.js +20 -0
- package/dist/manifest/manifest-schema.js.map +1 -1
- package/dist/manifest/package-platform.d.ts +8 -0
- package/dist/manifest/package-platform.d.ts.map +1 -0
- package/dist/manifest/package-platform.js +14 -0
- package/dist/manifest/package-platform.js.map +1 -0
- package/dist/scaffold/app-scaffolder.d.ts +60 -0
- package/dist/scaffold/app-scaffolder.d.ts.map +1 -0
- package/dist/scaffold/app-scaffolder.js +786 -0
- package/dist/scaffold/app-scaffolder.js.map +1 -0
- package/package.json +6 -6
|
@@ -31,13 +31,16 @@ export declare class GitHubAccessError extends Error {
|
|
|
31
31
|
readonly Status: number;
|
|
32
32
|
constructor(status: number, message: string);
|
|
33
33
|
}
|
|
34
|
-
export declare function FetchManifestFromGitHub(repoUrl: string, version: string | undefined, options: GitHubClientOptions, subpath?: string): Promise<ManifestFetchResult>;
|
|
34
|
+
export declare function FetchManifestFromGitHub(repoUrl: string, version: string | undefined, options: GitHubClientOptions, subpath?: string, appName?: string): Promise<ManifestFetchResult>;
|
|
35
35
|
export declare function ListGitHubReleases(repoUrl: string, options: GitHubClientOptions): Promise<GitHubRelease[]>;
|
|
36
|
-
export declare function
|
|
37
|
-
export declare function
|
|
38
|
-
export declare function
|
|
36
|
+
export declare function ExtractSqlFilesFromTarGz(tarGzBuffer: Buffer, cleanPath: string, localDir: string): string[];
|
|
37
|
+
export declare function DownloadMigrations(repoUrl: string, version: string | undefined, migrationsPath: string, localDir: string, options: GitHubClientOptions, subpath?: string, appName?: string): Promise<MigrationDownloadResult>;
|
|
38
|
+
export declare function DescribeUnresolvedVersion(repoUrl: string, options: GitHubClientOptions, subpath?: string): Promise<string>;
|
|
39
|
+
export declare function GetLatestVersion(repoUrl: string, options: GitHubClientOptions, subpath?: string, appName?: string): Promise<string | null>;
|
|
40
|
+
export declare function FindVersionTagName(repoUrl: string, options: GitHubClientOptions, version: string, subpath?: string, appName?: string): Promise<string | null>;
|
|
41
|
+
export declare function ListGitHubTags(repoUrl: string, options: GitHubClientOptions, subpath?: string, appName?: string): Promise<string[]>;
|
|
39
42
|
export declare function ClearGitHubTagCache(): void;
|
|
40
|
-
export declare function ValidateGitHubTag(repoUrl: string, version: string, options: GitHubClientOptions, subpath?: string): Promise<{
|
|
43
|
+
export declare function ValidateGitHubTag(repoUrl: string, version: string, options: GitHubClientOptions, subpath?: string, appName?: string): Promise<{
|
|
41
44
|
Exists: boolean;
|
|
42
45
|
ErrorMessage?: string;
|
|
43
46
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"github-client.d.ts","sourceRoot":"","sources":["../../src/github/github-client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"github-client.d.ts","sourceRoot":"","sources":["../../src/github/github-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,29 +1,91 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
+
import { gunzipSync } from 'node:zlib';
|
|
3
4
|
import { Octokit } from '@octokit/rest';
|
|
4
5
|
import semver from 'semver';
|
|
6
|
+
function trimSlashes(value) {
|
|
7
|
+
let start = 0;
|
|
8
|
+
let end = value.length;
|
|
9
|
+
while (start < end && value.charCodeAt(start) === 47)
|
|
10
|
+
start++;
|
|
11
|
+
while (end > start && value.charCodeAt(end - 1) === 47)
|
|
12
|
+
end--;
|
|
13
|
+
return value.slice(start, end);
|
|
14
|
+
}
|
|
15
|
+
function SubpathOrUndefined(value) {
|
|
16
|
+
return value === undefined ? undefined : trimSlashes(value);
|
|
17
|
+
}
|
|
18
|
+
function TrimLeadingSlashes(value) {
|
|
19
|
+
let start = 0;
|
|
20
|
+
while (start < value.length && value.charCodeAt(start) === 47) {
|
|
21
|
+
start++;
|
|
22
|
+
}
|
|
23
|
+
return value.slice(start);
|
|
24
|
+
}
|
|
25
|
+
function TrimSlashes(value) {
|
|
26
|
+
let start = 0;
|
|
27
|
+
let end = value.length;
|
|
28
|
+
while (start < end && value.charCodeAt(start) === 47) {
|
|
29
|
+
start++;
|
|
30
|
+
}
|
|
31
|
+
while (end > start && value.charCodeAt(end - 1) === 47) {
|
|
32
|
+
end--;
|
|
33
|
+
}
|
|
34
|
+
return value.slice(start, end);
|
|
35
|
+
}
|
|
5
36
|
export function ParseGitHubUrl(repoUrl) {
|
|
6
|
-
const
|
|
7
|
-
if (
|
|
37
|
+
const hostIdx = repoUrl.indexOf('github.com/');
|
|
38
|
+
if (hostIdx < 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const afterHost = repoUrl.slice(hostIdx + 'github.com/'.length);
|
|
42
|
+
let endIdx = afterHost.length;
|
|
43
|
+
const qIdx = afterHost.indexOf('?');
|
|
44
|
+
if (qIdx >= 0 && qIdx < endIdx)
|
|
45
|
+
endIdx = qIdx;
|
|
46
|
+
const hIdx = afterHost.indexOf('#');
|
|
47
|
+
if (hIdx >= 0 && hIdx < endIdx)
|
|
48
|
+
endIdx = hIdx;
|
|
49
|
+
const pathPart = TrimSlashes(afterHost.slice(0, endIdx));
|
|
50
|
+
const firstSlash = pathPart.indexOf('/');
|
|
51
|
+
if (firstSlash <= 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const owner = pathPart.slice(0, firstSlash);
|
|
55
|
+
const remainder = pathPart.slice(firstSlash + 1);
|
|
56
|
+
const secondSlash = remainder.indexOf('/');
|
|
57
|
+
const rawRepo = secondSlash >= 0 ? remainder.slice(0, secondSlash) : remainder;
|
|
58
|
+
if (!rawRepo) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const repo = rawRepo.endsWith('.git') ? rawRepo.slice(0, -4) : rawRepo;
|
|
62
|
+
if (!repo) {
|
|
8
63
|
return null;
|
|
9
64
|
}
|
|
10
|
-
const
|
|
11
|
-
const repo = match[2].replace(/\.git$/, '');
|
|
12
|
-
const rawSubpath = (match[3] ?? '').replace(/^\/+|\/+$/g, '');
|
|
65
|
+
const rawSubpath = secondSlash >= 0 ? TrimSlashes(remainder.slice(secondSlash + 1)) : '';
|
|
13
66
|
const subpath = rawSubpath.length > 0 ? rawSubpath : undefined;
|
|
14
67
|
return { Owner: owner, Repo: repo, Subpath: subpath };
|
|
15
68
|
}
|
|
16
69
|
function ResolveToken(repoUrl, options) {
|
|
17
70
|
if (options.TokenMap) {
|
|
71
|
+
const entries = Object.entries(options.TokenMap);
|
|
18
72
|
const normalized = normalizeRepoUrl(repoUrl);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
73
|
+
const exact = entries.find(([mapUrl]) => normalizeRepoUrl(mapUrl) === normalized);
|
|
74
|
+
if (exact) {
|
|
75
|
+
return exact[1];
|
|
76
|
+
}
|
|
77
|
+
const repoKey = repositoryKey(repoUrl);
|
|
78
|
+
const sameRepo = repoKey ? entries.find(([mapUrl]) => repositoryKey(mapUrl) === repoKey) : undefined;
|
|
79
|
+
if (sameRepo) {
|
|
80
|
+
return sameRepo[1];
|
|
23
81
|
}
|
|
24
82
|
}
|
|
25
83
|
return options.Token;
|
|
26
84
|
}
|
|
85
|
+
function repositoryKey(url) {
|
|
86
|
+
const parsed = ParseGitHubUrl(url);
|
|
87
|
+
return parsed ? `${parsed.Owner}/${parsed.Repo}`.toLowerCase() : null;
|
|
88
|
+
}
|
|
27
89
|
function normalizeRepoUrl(url) {
|
|
28
90
|
return url.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase();
|
|
29
91
|
}
|
|
@@ -84,6 +146,38 @@ function ThrowIfRateLimitedOrForbidden(error, context) {
|
|
|
84
146
|
`This is NOT the same as "no versions found" — check your GitHub token and rate limit.`);
|
|
85
147
|
}
|
|
86
148
|
}
|
|
149
|
+
async function ProbeRepoVisibility(repoUrl, parsed, options) {
|
|
150
|
+
try {
|
|
151
|
+
await CreateOctokit(repoUrl, options).repos.get({ owner: parsed.Owner, repo: parsed.Repo });
|
|
152
|
+
return { State: 'Readable' };
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (OctokitStatus(error) === 404) {
|
|
156
|
+
return { State: 'NotReadable' };
|
|
157
|
+
}
|
|
158
|
+
return { State: 'Undetermined', Reason: error instanceof Error ? error.message : String(error) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const CONFIGURE_CREDENTIAL_REMEDY = 'set GITHUB_TOKEN in the environment, or add the repo to openApps.github.tokens (or set openApps.github.token) in mj.config.cjs';
|
|
162
|
+
function UnreadableRepoMessage(repoUrl, parsed, options) {
|
|
163
|
+
const target = `${parsed.Owner}/${parsed.Repo}`;
|
|
164
|
+
return ResolveToken(repoUrl, options)
|
|
165
|
+
? `Cannot read ${target}. The repository does not exist, or the GitHub credential supplied does not grant access to it — check the token is valid and carries 'repo' scope for ${target}.`
|
|
166
|
+
: `Cannot read ${target}. The repository is private or does not exist, and no GitHub credential was supplied — ${CONFIGURE_CREDENTIAL_REMEDY}, then retry.`;
|
|
167
|
+
}
|
|
168
|
+
async function DescribeNotFound(repoUrl, parsed, options, describeMissingTarget) {
|
|
169
|
+
const visibility = await ProbeRepoVisibility(repoUrl, parsed, options);
|
|
170
|
+
switch (visibility.State) {
|
|
171
|
+
case 'Readable':
|
|
172
|
+
return describeMissingTarget();
|
|
173
|
+
case 'NotReadable':
|
|
174
|
+
return UnreadableRepoMessage(repoUrl, parsed, options);
|
|
175
|
+
case 'Undetermined':
|
|
176
|
+
return `Could not confirm ${parsed.Owner}/${parsed.Repo} is readable: ${visibility.Reason}. `
|
|
177
|
+
+ `If it is private, a GitHub credential may be required — ${CONFIGURE_CREDENTIAL_REMEDY}. `
|
|
178
|
+
+ `If it is readable, then: ${describeMissingTarget()}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
87
181
|
async function FetchFileContent(octokit, owner, repo, path, ref) {
|
|
88
182
|
const { data } = await octokit.repos.getContent({ owner, repo, path, ref });
|
|
89
183
|
if (Array.isArray(data) || data.type !== 'file') {
|
|
@@ -103,9 +197,22 @@ async function ListDirectory(octokit, owner, repo, path, ref) {
|
|
|
103
197
|
return data.map(item => ({ name: item.name, path: item.path, type: item.type, sha: item.sha }));
|
|
104
198
|
}
|
|
105
199
|
function ScopedTagPrefix(subpath) {
|
|
106
|
-
const s = subpath
|
|
200
|
+
const s = SubpathOrUndefined(subpath);
|
|
107
201
|
return s ? s.replace(/\//g, '-') : undefined;
|
|
108
202
|
}
|
|
203
|
+
function ScopedTagCandidates(subpath, appName) {
|
|
204
|
+
const out = [];
|
|
205
|
+
const folder = ScopedTagPrefix(subpath);
|
|
206
|
+
if (folder)
|
|
207
|
+
out.push(folder);
|
|
208
|
+
const name = appName?.trim();
|
|
209
|
+
if (name && name !== folder)
|
|
210
|
+
out.push(name);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function EscapeForRegExp(v) {
|
|
214
|
+
return v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
215
|
+
}
|
|
109
216
|
function ResolveRef(version, subpath) {
|
|
110
217
|
if (!version)
|
|
111
218
|
return 'HEAD';
|
|
@@ -114,15 +221,18 @@ function ResolveRef(version, subpath) {
|
|
|
114
221
|
return prefix ? `${prefix}@${v}` : `v${v}`;
|
|
115
222
|
}
|
|
116
223
|
function ComposeRepoPath(effectiveSubpath, relativePath) {
|
|
117
|
-
return [effectiveSubpath, relativePath
|
|
224
|
+
return [effectiveSubpath, TrimSlashes(relativePath)].filter(Boolean).join('/');
|
|
118
225
|
}
|
|
119
|
-
export async function FetchManifestFromGitHub(repoUrl, version, options, subpath) {
|
|
226
|
+
export async function FetchManifestFromGitHub(repoUrl, version, options, subpath, appName) {
|
|
120
227
|
const parsed = ParseGitHubUrl(repoUrl);
|
|
121
228
|
if (!parsed) {
|
|
122
229
|
return { Success: false, ErrorMessage: `Invalid GitHub URL: ${repoUrl}` };
|
|
123
230
|
}
|
|
124
|
-
const effectiveSubpath = (subpath ?? parsed.Subpath)
|
|
125
|
-
const ref =
|
|
231
|
+
const effectiveSubpath = SubpathOrUndefined(subpath ?? parsed.Subpath);
|
|
232
|
+
const ref = version
|
|
233
|
+
? (await FindVersionTagName(repoUrl, options, version, effectiveSubpath, appName))
|
|
234
|
+
?? ResolveRef(version, effectiveSubpath)
|
|
235
|
+
: ResolveRef(version, effectiveSubpath);
|
|
126
236
|
const manifestPath = ComposeRepoPath(effectiveSubpath, 'mj-app.json');
|
|
127
237
|
try {
|
|
128
238
|
const content = await FetchFileContent(CreateOctokit(repoUrl, options), parsed.Owner, parsed.Repo, manifestPath, ref);
|
|
@@ -130,7 +240,10 @@ export async function FetchManifestFromGitHub(repoUrl, version, options, subpath
|
|
|
130
240
|
}
|
|
131
241
|
catch (error) {
|
|
132
242
|
if (OctokitStatus(error) === 404) {
|
|
133
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
Success: false,
|
|
245
|
+
ErrorMessage: await DescribeNotFound(repoUrl, parsed, options, () => `${manifestPath} not found in ${parsed.Owner}/${parsed.Repo} at ref ${ref}`),
|
|
246
|
+
};
|
|
134
247
|
}
|
|
135
248
|
const message = error instanceof Error ? error.message : String(error);
|
|
136
249
|
return { Success: false, ErrorMessage: `Failed to fetch manifest: ${message}` };
|
|
@@ -168,7 +281,7 @@ async function listSqlFilesRecursive(octokit, owner, repo, root, ref, currentDir
|
|
|
168
281
|
const out = [];
|
|
169
282
|
for (const item of items) {
|
|
170
283
|
if (item.type === 'file' && item.name.endsWith('.sql')) {
|
|
171
|
-
out.push({ path: item.path, relativePath: item.path.slice(root.length)
|
|
284
|
+
out.push({ path: item.path, relativePath: TrimLeadingSlashes(item.path.slice(root.length)) });
|
|
172
285
|
}
|
|
173
286
|
else if (item.type === 'dir') {
|
|
174
287
|
out.push(...await listSqlFilesRecursive(octokit, owner, repo, root, ref, item.path, depth + 1));
|
|
@@ -176,18 +289,207 @@ async function listSqlFilesRecursive(octokit, owner, repo, root, ref, currentDir
|
|
|
176
289
|
}
|
|
177
290
|
return out;
|
|
178
291
|
}
|
|
179
|
-
|
|
292
|
+
function IsZeroBlock(block) {
|
|
293
|
+
for (let i = 0; i < block.length; i++) {
|
|
294
|
+
if (block[i] !== 0)
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
function ReadNullTerminatedString(buf) {
|
|
300
|
+
const nulIdx = buf.indexOf(0);
|
|
301
|
+
return buf.subarray(0, nulIdx >= 0 ? nulIdx : buf.length).toString('utf-8');
|
|
302
|
+
}
|
|
303
|
+
function ReadUstarPath(header) {
|
|
304
|
+
const name = ReadNullTerminatedString(header.subarray(0, 100));
|
|
305
|
+
const prefix = ReadNullTerminatedString(header.subarray(345, 500));
|
|
306
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
307
|
+
}
|
|
308
|
+
function ParseTarSize(sizeField) {
|
|
309
|
+
if ((sizeField[0] & 0x80) !== 0) {
|
|
310
|
+
let value = 0;
|
|
311
|
+
for (let i = 1; i < sizeField.length; i++) {
|
|
312
|
+
value = value * 256 + sizeField[i];
|
|
313
|
+
}
|
|
314
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
315
|
+
throw new Error(`Corrupt tar archive: invalid binary entry size ${value}`);
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
const octal = ReadNullTerminatedString(sizeField).trim();
|
|
320
|
+
if (!octal)
|
|
321
|
+
return 0;
|
|
322
|
+
if (!/^[0-7]+$/.test(octal)) {
|
|
323
|
+
throw new Error(`Corrupt tar archive: invalid octal size '${octal}'`);
|
|
324
|
+
}
|
|
325
|
+
const parsed = parseInt(octal, 8);
|
|
326
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
327
|
+
throw new Error(`Corrupt tar archive: invalid entry size ${parsed}`);
|
|
328
|
+
}
|
|
329
|
+
return parsed;
|
|
330
|
+
}
|
|
331
|
+
function ParsePaxHeader(paxBuf) {
|
|
332
|
+
const result = {};
|
|
333
|
+
let pos = 0;
|
|
334
|
+
while (pos < paxBuf.length) {
|
|
335
|
+
const spaceIdx = paxBuf.indexOf(0x20, pos);
|
|
336
|
+
if (spaceIdx < 0)
|
|
337
|
+
break;
|
|
338
|
+
const recordLen = parseInt(paxBuf.subarray(pos, spaceIdx).toString('ascii'), 10);
|
|
339
|
+
if (!Number.isFinite(recordLen) || recordLen <= 0 || pos + recordLen > paxBuf.length)
|
|
340
|
+
break;
|
|
341
|
+
const body = paxBuf.subarray(spaceIdx + 1, pos + recordLen - 1).toString('utf-8');
|
|
342
|
+
const eqIdx = body.indexOf('=');
|
|
343
|
+
if (eqIdx > 0) {
|
|
344
|
+
const key = body.slice(0, eqIdx);
|
|
345
|
+
const val = body.slice(eqIdx + 1);
|
|
346
|
+
if (key === 'path') {
|
|
347
|
+
result.Path = val;
|
|
348
|
+
}
|
|
349
|
+
else if (key === 'size') {
|
|
350
|
+
const trimmedVal = val.trim();
|
|
351
|
+
if (!/^\d+$/.test(trimmedVal)) {
|
|
352
|
+
throw new Error(`Corrupt tar archive: invalid PAX size '${val}'`);
|
|
353
|
+
}
|
|
354
|
+
const parsedSize = parseInt(trimmedVal, 10);
|
|
355
|
+
if (!Number.isSafeInteger(parsedSize) || parsedSize < 0) {
|
|
356
|
+
throw new Error(`Corrupt tar archive: invalid PAX size ${parsedSize}`);
|
|
357
|
+
}
|
|
358
|
+
result.Size = parsedSize;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
pos += recordLen;
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
function ToTarballBuffer(data) {
|
|
366
|
+
if (Buffer.isBuffer(data))
|
|
367
|
+
return data;
|
|
368
|
+
if (data instanceof ArrayBuffer)
|
|
369
|
+
return Buffer.from(data);
|
|
370
|
+
if (ArrayBuffer.isView(data))
|
|
371
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
372
|
+
if (typeof data === 'string')
|
|
373
|
+
return Buffer.from(data, 'binary');
|
|
374
|
+
throw new Error('Unexpected tarball response data type from GitHub API');
|
|
375
|
+
}
|
|
376
|
+
export function ExtractSqlFilesFromTarGz(tarGzBuffer, cleanPath, localDir) {
|
|
377
|
+
const tarBuffer = gunzipSync(tarGzBuffer, { maxOutputLength: 512 * 1024 * 1024 });
|
|
378
|
+
const normalizedRoot = TrimSlashes(cleanPath);
|
|
379
|
+
const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : '';
|
|
380
|
+
const extractedFiles = [];
|
|
381
|
+
let offset = 0;
|
|
382
|
+
let paxPathOverride;
|
|
383
|
+
let paxSizeOverride;
|
|
384
|
+
let gnuLongPathOverride;
|
|
385
|
+
while (offset + 512 <= tarBuffer.length) {
|
|
386
|
+
const header = tarBuffer.subarray(offset, offset + 512);
|
|
387
|
+
offset += 512;
|
|
388
|
+
if (IsZeroBlock(header)) {
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
const typeFlagByte = header[156];
|
|
392
|
+
const typeFlag = typeFlagByte === 0 ? '0' : String.fromCharCode(typeFlagByte);
|
|
393
|
+
const entrySize = paxSizeOverride ?? ParseTarSize(header.subarray(124, 136));
|
|
394
|
+
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
|
|
395
|
+
throw new Error(`Corrupt tar archive: invalid entry size ${entrySize}`);
|
|
396
|
+
}
|
|
397
|
+
const dataOffset = offset;
|
|
398
|
+
const paddedSize = Math.ceil(entrySize / 512) * 512;
|
|
399
|
+
offset += paddedSize;
|
|
400
|
+
if (dataOffset + entrySize > tarBuffer.length) {
|
|
401
|
+
throw new Error('Corrupt tar archive: entry extends past end of buffer');
|
|
402
|
+
}
|
|
403
|
+
if (typeFlag === 'x') {
|
|
404
|
+
const parsedPax = ParsePaxHeader(tarBuffer.subarray(dataOffset, dataOffset + entrySize));
|
|
405
|
+
if (parsedPax.Path !== undefined)
|
|
406
|
+
paxPathOverride = parsedPax.Path;
|
|
407
|
+
if (parsedPax.Size !== undefined)
|
|
408
|
+
paxSizeOverride = parsedPax.Size;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (typeFlag === 'L') {
|
|
412
|
+
gnuLongPathOverride = ReadNullTerminatedString(tarBuffer.subarray(dataOffset, dataOffset + entrySize));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (typeFlag === 'g' || typeFlag === 'K') {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const rawEntryPath = paxPathOverride ?? gnuLongPathOverride ?? ReadUstarPath(header);
|
|
419
|
+
paxPathOverride = undefined;
|
|
420
|
+
paxSizeOverride = undefined;
|
|
421
|
+
gnuLongPathOverride = undefined;
|
|
422
|
+
if (typeFlag !== '0') {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const firstSlash = rawEntryPath.indexOf('/');
|
|
426
|
+
if (firstSlash < 0) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const repoRelativePath = rawEntryPath.slice(firstSlash + 1);
|
|
430
|
+
if (!repoRelativePath.startsWith(rootPrefix) || !repoRelativePath.endsWith('.sql')) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const relativePath = TrimLeadingSlashes(repoRelativePath.slice(rootPrefix.length));
|
|
434
|
+
if (!relativePath) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (relativePath.includes('\\')) {
|
|
438
|
+
throw new Error(`Refusing to extract migration path with backslash separator: '${relativePath}'`);
|
|
439
|
+
}
|
|
440
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
441
|
+
if (segments.some(seg => seg === '..' || seg === '.')) {
|
|
442
|
+
throw new Error(`Refusing to extract migration path with traversal segment: '${relativePath}'`);
|
|
443
|
+
}
|
|
444
|
+
const depth = segments.length - 1;
|
|
445
|
+
if (depth > MAX_MIGRATION_DIRECTORY_DEPTH) {
|
|
446
|
+
throw new Error(`Migration directory nesting exceeds ${MAX_MIGRATION_DIRECTORY_DEPTH} levels under '${normalizedRoot}' — `
|
|
447
|
+
+ `refusing to walk further. No real migration layout is this deep.`);
|
|
448
|
+
}
|
|
449
|
+
const target = join(localDir, ...segments);
|
|
450
|
+
const targetDir = dirname(target);
|
|
451
|
+
if (!existsSync(targetDir)) {
|
|
452
|
+
mkdirSync(targetDir, { recursive: true });
|
|
453
|
+
}
|
|
454
|
+
writeFileSync(target, tarBuffer.subarray(dataOffset, dataOffset + entrySize));
|
|
455
|
+
extractedFiles.push(segments.join('/'));
|
|
456
|
+
}
|
|
457
|
+
return extractedFiles;
|
|
458
|
+
}
|
|
459
|
+
export async function DownloadMigrations(repoUrl, version, migrationsPath, localDir, options, subpath, appName) {
|
|
180
460
|
const parsed = ParseGitHubUrl(repoUrl);
|
|
181
461
|
if (!parsed) {
|
|
182
462
|
return { Success: false, ErrorMessage: `Invalid GitHub URL: ${repoUrl}` };
|
|
183
463
|
}
|
|
184
|
-
const effectiveSubpath = (subpath ?? parsed.Subpath)
|
|
185
|
-
const ref =
|
|
464
|
+
const effectiveSubpath = SubpathOrUndefined(subpath ?? parsed.Subpath);
|
|
465
|
+
const ref = version
|
|
466
|
+
? (await FindVersionTagName(repoUrl, options, version, effectiveSubpath, appName))
|
|
467
|
+
?? ResolveRef(version, effectiveSubpath)
|
|
468
|
+
: ResolveRef(version, effectiveSubpath);
|
|
186
469
|
const cleanPath = ComposeRepoPath(effectiveSubpath, migrationsPath);
|
|
187
470
|
const octokit = CreateOctokit(repoUrl, options);
|
|
188
471
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
472
|
+
let downloadedFiles;
|
|
473
|
+
const canDownloadTarball = typeof octokit.repos.downloadTarballArchive === 'function';
|
|
474
|
+
if (canDownloadTarball) {
|
|
475
|
+
const tarGzBuffer = await FetchRepoTarball(octokit, repoUrl, parsed, ref, options);
|
|
476
|
+
downloadedFiles = ExtractSqlFilesFromTarGz(tarGzBuffer, cleanPath, localDir);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const sqlFiles = await listSqlFilesRecursive(octokit, parsed.Owner, parsed.Repo, cleanPath, ref);
|
|
480
|
+
downloadedFiles = [];
|
|
481
|
+
for (const file of sqlFiles) {
|
|
482
|
+
const content = await FetchFileContent(octokit, parsed.Owner, parsed.Repo, file.path, ref);
|
|
483
|
+
const target = join(localDir, file.relativePath);
|
|
484
|
+
const targetDir = dirname(target);
|
|
485
|
+
if (!existsSync(targetDir)) {
|
|
486
|
+
mkdirSync(targetDir, { recursive: true });
|
|
487
|
+
}
|
|
488
|
+
writeFileSync(target, content, 'utf-8');
|
|
489
|
+
downloadedFiles.push(file.relativePath);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (downloadedFiles.length === 0) {
|
|
191
493
|
return {
|
|
192
494
|
Success: false,
|
|
193
495
|
ErrorMessage: `No .sql files found under '${cleanPath}' (ref: ${ref ?? 'default branch'}). `
|
|
@@ -195,17 +497,6 @@ export async function DownloadMigrations(repoUrl, version, migrationsPath, local
|
|
|
195
497
|
+ `check 'migrations.directory' in mj-app.json and that the tag's tree contains it.`,
|
|
196
498
|
};
|
|
197
499
|
}
|
|
198
|
-
const downloadedFiles = [];
|
|
199
|
-
for (const file of sqlFiles) {
|
|
200
|
-
const content = await FetchFileContent(octokit, parsed.Owner, parsed.Repo, file.path, ref);
|
|
201
|
-
const target = join(localDir, file.relativePath);
|
|
202
|
-
const targetDir = dirname(target);
|
|
203
|
-
if (!existsSync(targetDir)) {
|
|
204
|
-
mkdirSync(targetDir, { recursive: true });
|
|
205
|
-
}
|
|
206
|
-
writeFileSync(target, content, 'utf-8');
|
|
207
|
-
downloadedFiles.push(file.relativePath);
|
|
208
|
-
}
|
|
209
500
|
return { Success: true, LocalPath: localDir, Files: downloadedFiles };
|
|
210
501
|
}
|
|
211
502
|
catch (error) {
|
|
@@ -213,8 +504,16 @@ export async function DownloadMigrations(repoUrl, version, migrationsPath, local
|
|
|
213
504
|
return { Success: false, ErrorMessage: `Failed to download migrations: ${message}` };
|
|
214
505
|
}
|
|
215
506
|
}
|
|
216
|
-
export async function
|
|
217
|
-
|
|
507
|
+
export async function DescribeUnresolvedVersion(repoUrl, options, subpath) {
|
|
508
|
+
const parsed = ParseGitHubUrl(repoUrl);
|
|
509
|
+
if (!parsed) {
|
|
510
|
+
return `Invalid GitHub URL: ${repoUrl}`;
|
|
511
|
+
}
|
|
512
|
+
const where = subpath ? `${parsed.Owner}/${parsed.Repo} (${subpath})` : `${parsed.Owner}/${parsed.Repo}`;
|
|
513
|
+
return DescribeNotFound(repoUrl, parsed, options, () => `Could not determine target version: no version tags or releases found in ${where}`);
|
|
514
|
+
}
|
|
515
|
+
export async function GetLatestVersion(repoUrl, options, subpath, appName) {
|
|
516
|
+
if (ScopedTagCandidates(subpath ?? ParseGitHubUrl(repoUrl)?.Subpath, appName).length === 0) {
|
|
218
517
|
const releases = await ListGitHubReleases(repoUrl, options);
|
|
219
518
|
const versioned = releases
|
|
220
519
|
.filter(r => !r.PreRelease && !r.Draft)
|
|
@@ -226,26 +525,52 @@ export async function GetLatestVersion(repoUrl, options, subpath) {
|
|
|
226
525
|
return candidates.sort(semver.rcompare)[0];
|
|
227
526
|
}
|
|
228
527
|
}
|
|
229
|
-
const tags = await ListGitHubTags(repoUrl, options, subpath);
|
|
528
|
+
const tags = await ListGitHubTags(repoUrl, options, subpath, appName);
|
|
230
529
|
if (tags.length > 0) {
|
|
231
530
|
const stableTag = tags.find(t => !IsPrereleaseVersion(t));
|
|
232
531
|
return (stableTag ?? tags[0]).replace(/^v/, '');
|
|
233
532
|
}
|
|
234
533
|
return null;
|
|
235
534
|
}
|
|
236
|
-
export async function
|
|
535
|
+
export async function FindVersionTagName(repoUrl, options, version, subpath, appName) {
|
|
536
|
+
const parsed = ParseGitHubUrl(repoUrl);
|
|
537
|
+
if (!parsed)
|
|
538
|
+
return null;
|
|
539
|
+
const candidates = ScopedTagCandidates(subpath ?? parsed.Subpath, appName);
|
|
540
|
+
if (candidates.length === 0)
|
|
541
|
+
return null;
|
|
542
|
+
const want = version.replace(/^v/, '');
|
|
543
|
+
const pattern = new RegExp(`^(?:@[^/]+/)?(?:${candidates.map(EscapeForRegExp).join('|')})@(.+)$`);
|
|
544
|
+
try {
|
|
545
|
+
for (const name of await FetchRepoTagNames(repoUrl, parsed, options)) {
|
|
546
|
+
const m = name.match(pattern);
|
|
547
|
+
if (m && m[1].replace(/^v/, '') === want)
|
|
548
|
+
return name;
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
ThrowIfRateLimitedOrForbidden(error, 'resolving version tag');
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
export async function ListGitHubTags(repoUrl, options, subpath, appName) {
|
|
237
558
|
const parsed = ParseGitHubUrl(repoUrl);
|
|
238
559
|
if (!parsed) {
|
|
239
560
|
return [];
|
|
240
561
|
}
|
|
241
|
-
const
|
|
562
|
+
const candidates = ScopedTagCandidates(subpath ?? parsed.Subpath, appName);
|
|
563
|
+
const prefix = candidates.length > 0 ? candidates[0] : undefined;
|
|
564
|
+
const isMultiAppRepo = ScopedTagPrefix(subpath ?? parsed.Subpath) !== undefined;
|
|
242
565
|
const SEMVER_PATTERN = '\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)*)?';
|
|
243
|
-
const
|
|
244
|
-
? new RegExp(
|
|
245
|
-
:
|
|
566
|
+
const scopedPattern = prefix
|
|
567
|
+
? new RegExp(`^(?:@[^/]+/)?(?:${candidates.map(EscapeForRegExp).join('|')})@(${SEMVER_PATTERN})$`)
|
|
568
|
+
: null;
|
|
569
|
+
const repoWidePattern = isMultiAppRepo ? null : new RegExp(`^(v?${SEMVER_PATTERN})$`);
|
|
570
|
+
const matchVersion = (name) => (scopedPattern ? name.match(scopedPattern)?.[1] : undefined) ?? (repoWidePattern ? name.match(repoWidePattern)?.[1] : undefined);
|
|
246
571
|
try {
|
|
247
572
|
return (await FetchRepoTagNames(repoUrl, parsed, options))
|
|
248
|
-
.map(name => name
|
|
573
|
+
.map(name => matchVersion(name))
|
|
249
574
|
.filter((v) => v != null)
|
|
250
575
|
.map(tag => ({ Tag: tag, Core: SemverCore(tag) }))
|
|
251
576
|
.filter((t) => t.Core !== null)
|
|
@@ -260,10 +585,42 @@ export async function ListGitHubTags(repoUrl, options, subpath) {
|
|
|
260
585
|
const TAG_CACHE_TTL_MS = 60_000;
|
|
261
586
|
const tagListCache = new Map();
|
|
262
587
|
const releaseListCache = new Map();
|
|
588
|
+
const tarballCache = new Map();
|
|
589
|
+
const TARBALL_CACHE_MAX_ENTRIES = 2;
|
|
263
590
|
const FETCH_CACHE_MAX_ENTRIES = 64;
|
|
264
591
|
export function ClearGitHubTagCache() {
|
|
265
592
|
tagListCache.clear();
|
|
266
593
|
releaseListCache.clear();
|
|
594
|
+
tarballCache.clear();
|
|
595
|
+
}
|
|
596
|
+
async function FetchRepoTarball(octokit, repoUrl, parsed, ref, options) {
|
|
597
|
+
const cacheKey = `${FetchCacheKey(repoUrl, parsed, options)}\u0000${ref}`;
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
const cached = tarballCache.get(cacheKey);
|
|
600
|
+
if (cached && cached.ExpiresAt > now) {
|
|
601
|
+
return await cached.Value;
|
|
602
|
+
}
|
|
603
|
+
for (const [key, entry] of tarballCache) {
|
|
604
|
+
if (entry.ExpiresAt <= now)
|
|
605
|
+
tarballCache.delete(key);
|
|
606
|
+
}
|
|
607
|
+
while (tarballCache.size >= TARBALL_CACHE_MAX_ENTRIES) {
|
|
608
|
+
const oldest = tarballCache.keys().next();
|
|
609
|
+
if (oldest.done)
|
|
610
|
+
break;
|
|
611
|
+
tarballCache.delete(oldest.value);
|
|
612
|
+
}
|
|
613
|
+
const inFlight = (async () => {
|
|
614
|
+
const response = await octokit.repos.downloadTarballArchive({
|
|
615
|
+
owner: parsed.Owner,
|
|
616
|
+
repo: parsed.Repo,
|
|
617
|
+
ref,
|
|
618
|
+
});
|
|
619
|
+
return ToTarballBuffer(response.data);
|
|
620
|
+
})();
|
|
621
|
+
tarballCache.set(cacheKey, { ExpiresAt: now + TAG_CACHE_TTL_MS, Value: inFlight });
|
|
622
|
+
inFlight.catch(() => { tarballCache.delete(cacheKey); });
|
|
623
|
+
return await inFlight;
|
|
267
624
|
}
|
|
268
625
|
async function MemoizedFetch(cache, cacheKey, fetcher) {
|
|
269
626
|
const now = Date.now();
|
|
@@ -296,19 +653,23 @@ async function FetchRepoTagNames(repoUrl, parsed, options) {
|
|
|
296
653
|
return data.map(t => t.name);
|
|
297
654
|
});
|
|
298
655
|
}
|
|
299
|
-
export async function ValidateGitHubTag(repoUrl, version, options, subpath) {
|
|
656
|
+
export async function ValidateGitHubTag(repoUrl, version, options, subpath, appName) {
|
|
300
657
|
const parsed = ParseGitHubUrl(repoUrl);
|
|
301
658
|
if (!parsed) {
|
|
302
659
|
return { Exists: false, ErrorMessage: `Invalid GitHub URL: ${repoUrl}` };
|
|
303
660
|
}
|
|
304
|
-
const tag =
|
|
661
|
+
const tag = (await FindVersionTagName(repoUrl, options, version, subpath ?? parsed.Subpath, appName))
|
|
662
|
+
?? ResolveRef(version, subpath ?? parsed.Subpath);
|
|
305
663
|
try {
|
|
306
664
|
await CreateOctokit(repoUrl, options).git.getRef({ owner: parsed.Owner, repo: parsed.Repo, ref: `tags/${tag}` });
|
|
307
665
|
return { Exists: true };
|
|
308
666
|
}
|
|
309
667
|
catch (error) {
|
|
310
668
|
if (OctokitStatus(error) === 404) {
|
|
311
|
-
return {
|
|
669
|
+
return {
|
|
670
|
+
Exists: false,
|
|
671
|
+
ErrorMessage: await DescribeNotFound(repoUrl, parsed, options, () => `Tag '${tag}' not found in ${parsed.Owner}/${parsed.Repo}. Available versions can be checked at https://github.com/${parsed.Owner}/${parsed.Repo}/tags`),
|
|
672
|
+
};
|
|
312
673
|
}
|
|
313
674
|
const message = error instanceof Error ? error.message : String(error);
|
|
314
675
|
return { Exists: false, ErrorMessage: `Failed to validate tag '${tag}': ${message}` };
|