@rathnasgala/cli 0.0.1
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/package.json +32 -0
- package/src/assign-content-ids.js +1 -0
- package/src/auth-command.js +36 -0
- package/src/configure-site.js +77 -0
- package/src/content-files.js +1 -0
- package/src/doctor-command.js +214 -0
- package/src/evaluation-date.js +1 -0
- package/src/gala-credential-store.js +104 -0
- package/src/gala-device-flow.js +121 -0
- package/src/github-auth-command.js +29 -0
- package/src/github-credential-store.js +65 -0
- package/src/github-device-flow.js +130 -0
- package/src/github-empty-repository.js +63 -0
- package/src/github-pages-provisioning.js +83 -0
- package/src/github-repository-secret.js +82 -0
- package/src/github-repository-variable.js +55 -0
- package/src/github-template-repository.js +117 -0
- package/src/hook-command.js +64 -0
- package/src/index.js +224 -0
- package/src/new-command.js +54 -0
- package/src/preview-command.js +60 -0
- package/src/publication-state.js +7 -0
- package/src/publish-command.js +37 -0
- package/src/record-deployment-command.js +147 -0
- package/src/repository-limits.js +94 -0
- package/src/scaffold-git.js +41 -0
- package/src/scaffold-options.js +58 -0
- package/src/scaffold-site.js +110 -0
- package/src/site-config-registration.js +37 -0
- package/src/site-registration-client.js +87 -0
- package/src/theme-package.js +128 -0
- package/src/upgrade-command.js +73 -0
- package/src/validate-command.js +5 -0
- package/src/workflow-command.js +85 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const scaffoldOptionNames = Object.freeze([
|
|
2
|
+
'theme',
|
|
3
|
+
'layout',
|
|
4
|
+
'palette',
|
|
5
|
+
'typography',
|
|
6
|
+
'spacing',
|
|
7
|
+
'radius',
|
|
8
|
+
'density',
|
|
9
|
+
'motion',
|
|
10
|
+
'componentStyle'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const singleValueOptions = Object.freeze({
|
|
14
|
+
'site-name': 'siteName',
|
|
15
|
+
author: 'siteAuthor',
|
|
16
|
+
language: 'defaultLanguage',
|
|
17
|
+
timezone: 'timezone'
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function requireValue(args, index, name) {
|
|
21
|
+
const value = args[index + 1];
|
|
22
|
+
if (!value || value.startsWith('--')) {
|
|
23
|
+
throw new Error(`Missing value for --${name}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseScaffoldOptions(args) {
|
|
29
|
+
const values = {};
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
+
const argument = args[index];
|
|
33
|
+
if (!argument.startsWith('--')) continue;
|
|
34
|
+
|
|
35
|
+
const name = argument.slice(2);
|
|
36
|
+
if (name === 'share-target') {
|
|
37
|
+
const value = requireValue(args, index, name);
|
|
38
|
+
values.shareTargets = [...(values.shareTargets ?? []), value];
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (name === 'social-profile') {
|
|
43
|
+
const value = requireValue(args, index, name);
|
|
44
|
+
values.socialProfiles = [...(values.socialProfiles ?? []), value];
|
|
45
|
+
index += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const targetName = singleValueOptions[name] ?? name;
|
|
50
|
+
if (!scaffoldOptionNames.includes(name) && singleValueOptions[name] == null) continue;
|
|
51
|
+
|
|
52
|
+
const value = requireValue(args, index, name);
|
|
53
|
+
values[targetName] = value;
|
|
54
|
+
index += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return values;
|
|
58
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { configureSite } from './configure-site.js';
|
|
5
|
+
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
+
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
+
import { cloneRepository, generateRepositoryFromTemplate } from './github-template-repository.js';
|
|
8
|
+
import { installRepositorySecret } from './github-repository-secret.js';
|
|
9
|
+
import { installRepositoryVariable } from './github-repository-variable.js';
|
|
10
|
+
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
11
|
+
import { registerSite } from './site-registration-client.js';
|
|
12
|
+
import { writeRegisteredSiteConfiguration } from './site-config-registration.js';
|
|
13
|
+
import { writePublishWorkflow } from './workflow-command.js';
|
|
14
|
+
import { commitScaffold } from './scaffold-git.js';
|
|
15
|
+
import {
|
|
16
|
+
setRepositoryOrigin, verifyEmptyRepository, verifyRepositoryOrigin
|
|
17
|
+
} from './github-empty-repository.js';
|
|
18
|
+
|
|
19
|
+
function segment(value, field) {
|
|
20
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(value)) throw new TypeError(`${field} is invalid`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function providerDefaultBase(owner, repository) {
|
|
25
|
+
const rootRepository = repository.toLowerCase() === `${owner}.github.io`.toLowerCase();
|
|
26
|
+
return `https://${owner.toLowerCase()}.github.io${rootRepository ? '/' : `/${repository}/`}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function scaffoldSite({
|
|
30
|
+
owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
|
|
31
|
+
resumeExistingCheckout = false,
|
|
32
|
+
buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
|
|
33
|
+
templateRepository = 'site-template',
|
|
34
|
+
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
35
|
+
generate = generateRepositoryFromTemplate, clone = cloneRepository,
|
|
36
|
+
configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
|
|
37
|
+
writeWorkflow = writePublishWorkflow, installSecret = installRepositorySecret,
|
|
38
|
+
installVariable = installRepositoryVariable,
|
|
39
|
+
provisionPages = provisionGithubPages,
|
|
40
|
+
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
41
|
+
verifyCheckout = verifyRepositoryOrigin
|
|
42
|
+
}) {
|
|
43
|
+
const repositoryOwner = segment(owner, 'owner');
|
|
44
|
+
const repositoryName = segment(repository, 'repository');
|
|
45
|
+
if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
|
|
46
|
+
throw new TypeError('githubInstallationId must be a positive integer');
|
|
47
|
+
}
|
|
48
|
+
if (target == null || path.resolve(target) === path.parse(path.resolve(target)).root) {
|
|
49
|
+
throw new TypeError('target must be a non-root local path');
|
|
50
|
+
}
|
|
51
|
+
const [github, gala] = await Promise.all([readGithub(), readGala()]);
|
|
52
|
+
if (emptyExistingRepository && resumeExistingCheckout) {
|
|
53
|
+
throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
|
|
54
|
+
}
|
|
55
|
+
let generated;
|
|
56
|
+
let root;
|
|
57
|
+
if (resumeExistingCheckout) {
|
|
58
|
+
root = await verifyCheckout({
|
|
59
|
+
root: path.resolve(target), owner: repositoryOwner, repository: repositoryName
|
|
60
|
+
});
|
|
61
|
+
generated = { fullName: `${repositoryOwner}/${repositoryName}` };
|
|
62
|
+
} else if (emptyExistingRepository) {
|
|
63
|
+
await verifyEmpty({ owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken });
|
|
64
|
+
generated = {
|
|
65
|
+
fullName: `${repositoryOwner}/${repositoryName}`,
|
|
66
|
+
cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
generated = await generate({
|
|
70
|
+
accessToken: github.accessToken, templateOwner, templateRepository,
|
|
71
|
+
owner: repositoryOwner, repository: repositoryName,
|
|
72
|
+
description: siteOptions?.siteName ?? ''
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!resumeExistingCheckout) {
|
|
76
|
+
root = await clone({ cloneUrl: generated.cloneUrl, target });
|
|
77
|
+
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
78
|
+
}
|
|
79
|
+
const configured = await configure(root, siteOptions ?? {});
|
|
80
|
+
const canonicalBaseUrl = providerDefaultBase(repositoryOwner, repositoryName);
|
|
81
|
+
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
82
|
+
const registration = await register({
|
|
83
|
+
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken, idempotencyKey,
|
|
84
|
+
githubInstallationId, repositoryOwner, repositoryName,
|
|
85
|
+
topology: 'PROVIDER_DEFAULT', canonicalBaseUrl
|
|
86
|
+
});
|
|
87
|
+
await finalize(root, {
|
|
88
|
+
siteId: registration.siteId,
|
|
89
|
+
canonicalBaseUrl: registration.canonicalBaseUrl,
|
|
90
|
+
topology: 'provider-default'
|
|
91
|
+
});
|
|
92
|
+
await writeWorkflow({
|
|
93
|
+
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode
|
|
94
|
+
});
|
|
95
|
+
await installSecret({
|
|
96
|
+
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
97
|
+
secretName: 'GALA_SITE_SECRET', secretValue: registration.siteSecret
|
|
98
|
+
});
|
|
99
|
+
await installVariable({
|
|
100
|
+
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
101
|
+
variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
|
|
102
|
+
});
|
|
103
|
+
const commitSha = await commit(root);
|
|
104
|
+
const pages = buildMode === 'build-and-deploy' ? await provisionPages({
|
|
105
|
+
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha
|
|
106
|
+
}) : null;
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
|
|
5
|
+
export async function writeRegisteredSiteConfiguration(root, { siteId, canonicalBaseUrl, topology }) {
|
|
6
|
+
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('siteId is invalid');
|
|
7
|
+
if (topology !== 'provider-default') throw new TypeError('Only provider-default topology is implemented');
|
|
8
|
+
const canonical = new URL(canonicalBaseUrl);
|
|
9
|
+
if (canonical.protocol !== 'https:' || canonical.username || canonical.password || canonical.search || canonical.hash) {
|
|
10
|
+
throw new TypeError('canonicalBaseUrl must be credential-free HTTPS');
|
|
11
|
+
}
|
|
12
|
+
const target = path.resolve(root, 'site.config.yml');
|
|
13
|
+
const metadata = await lstat(target);
|
|
14
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new TypeError('site.config.yml must be a regular file');
|
|
15
|
+
const config = parse(await readFile(target, 'utf8'));
|
|
16
|
+
if (config?.schemaVersion !== 1 || config.site == null || config.hosting == null) {
|
|
17
|
+
throw new TypeError('Unsupported site configuration schema');
|
|
18
|
+
}
|
|
19
|
+
config.site.id = siteId;
|
|
20
|
+
config.hosting.provider = 'github-pages';
|
|
21
|
+
config.hosting.topology = topology;
|
|
22
|
+
config.hosting.canonicalBaseUrl = canonical.href.replace(/\/$/, '');
|
|
23
|
+
config.hosting.pathPrefix = canonical.pathname === '/' ? '/' : canonical.pathname.replace(/\/$/, '');
|
|
24
|
+
const temporary = `${target}.gala-register-${process.pid}`;
|
|
25
|
+
const backup = `${target}.gala-backup-${process.pid}`;
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(temporary, stringify(config), { flag: 'wx' });
|
|
28
|
+
await rename(target, backup);
|
|
29
|
+
try { await rename(temporary, target); }
|
|
30
|
+
catch (error) { await rename(backup, target); throw error; }
|
|
31
|
+
await rm(backup);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
await rm(temporary, { force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
return config;
|
|
37
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
2
|
+
const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/;
|
|
3
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
4
|
+
|
|
5
|
+
function required(value, field, pattern) {
|
|
6
|
+
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`${field} is invalid`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function apiUrl(apiBaseUrl) {
|
|
11
|
+
const base = new URL(apiBaseUrl);
|
|
12
|
+
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
13
|
+
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
14
|
+
|| base.username || base.password || base.search || base.hash) {
|
|
15
|
+
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
16
|
+
}
|
|
17
|
+
return new URL('/v1/sites', base).href;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function registerSite({
|
|
21
|
+
apiBaseUrl = 'https://api.gala67.com',
|
|
22
|
+
galaAccessToken,
|
|
23
|
+
idempotencyKey,
|
|
24
|
+
githubInstallationId,
|
|
25
|
+
repositoryOwner,
|
|
26
|
+
repositoryName,
|
|
27
|
+
topology,
|
|
28
|
+
canonicalBaseUrl,
|
|
29
|
+
fetchImpl = fetch
|
|
30
|
+
}) {
|
|
31
|
+
if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
|
|
32
|
+
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
33
|
+
}
|
|
34
|
+
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
35
|
+
required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
|
|
36
|
+
required(repositoryName, 'repositoryName', REPOSITORY_PART);
|
|
37
|
+
if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
|
|
38
|
+
throw new TypeError('githubInstallationId must be a positive integer');
|
|
39
|
+
}
|
|
40
|
+
if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
|
|
41
|
+
throw new TypeError('topology is invalid');
|
|
42
|
+
}
|
|
43
|
+
const response = await fetchImpl(apiUrl(apiBaseUrl), {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
accept: 'application/json',
|
|
47
|
+
authorization: `Bearer ${galaAccessToken}`,
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
'idempotency-key': idempotencyKey
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
githubInstallationId,
|
|
53
|
+
repositoryOwner,
|
|
54
|
+
repositoryName,
|
|
55
|
+
topology,
|
|
56
|
+
canonicalBaseUrl
|
|
57
|
+
})
|
|
58
|
+
});
|
|
59
|
+
if (response.status === 401) {
|
|
60
|
+
throw new Error('Gala authentication expired; run `gala auth` again');
|
|
61
|
+
}
|
|
62
|
+
if (response.status === 404) {
|
|
63
|
+
throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
|
|
64
|
+
}
|
|
65
|
+
if (response.status === 409) {
|
|
66
|
+
throw new Error('Site registration conflicts with existing protected state; use the recovery command');
|
|
67
|
+
}
|
|
68
|
+
if (response.status !== 201) throw new Error(`Gala site registration failed with HTTP ${response.status}`);
|
|
69
|
+
const payload = await response.json();
|
|
70
|
+
if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
|
|
71
|
+
throw new TypeError('Gala site registration response is invalid');
|
|
72
|
+
}
|
|
73
|
+
const canonical = new URL(payload.canonicalBaseUrl);
|
|
74
|
+
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
75
|
+
|| canonical.search || canonical.hash) {
|
|
76
|
+
throw new TypeError('Gala site registration returned an invalid canonicalBaseUrl');
|
|
77
|
+
}
|
|
78
|
+
const location = response.headers?.get?.('location');
|
|
79
|
+
if (location !== `/v1/sites/${payload.siteId}`) {
|
|
80
|
+
throw new TypeError('Gala site registration returned an invalid Location header');
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
siteId: payload.siteId,
|
|
84
|
+
siteSecret: payload.siteSecret,
|
|
85
|
+
canonicalBaseUrl: canonical.href
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import * as tar from 'tar';
|
|
7
|
+
|
|
8
|
+
const MAX_COMPRESSED_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
11
|
+
const MAX_ENTRIES = 2_048;
|
|
12
|
+
const EXACT_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
|
|
14
|
+
function registryUrl(name, version) {
|
|
15
|
+
if (name !== '@rathnasgala/theme' || !EXACT_VERSION.test(version)) {
|
|
16
|
+
throw new TypeError('theme package name and exact version are required');
|
|
17
|
+
}
|
|
18
|
+
return `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function boundedBody(response) {
|
|
22
|
+
const length = Number(response.headers.get('content-length'));
|
|
23
|
+
if (Number.isFinite(length) && length > MAX_COMPRESSED_BYTES) throw new Error('Theme archive exceeds compressed-size limit');
|
|
24
|
+
const reader = response.body.getReader();
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let size = 0;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
size += value.byteLength;
|
|
31
|
+
if (size > MAX_COMPRESSED_BYTES) {
|
|
32
|
+
await reader.cancel();
|
|
33
|
+
throw new Error('Theme archive exceeds compressed-size limit');
|
|
34
|
+
}
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(chunks, size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function verifyIntegrity(bytes, integrity) {
|
|
41
|
+
const match = /^(sha512|sha384|sha256)-([A-Za-z0-9+/=]+)$/.exec(integrity ?? '');
|
|
42
|
+
if (match == null) throw new Error('Theme package has no supported registry integrity value');
|
|
43
|
+
const actual = createHash(match[1]).update(bytes).digest('base64');
|
|
44
|
+
if (actual !== match[2]) throw new Error('Theme package integrity verification failed');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function validateArchive(archive) {
|
|
48
|
+
let entries = 0;
|
|
49
|
+
let total = 0;
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
const inspector = tar.t({
|
|
52
|
+
strict: true,
|
|
53
|
+
onReadEntry(entry) {
|
|
54
|
+
entries += 1;
|
|
55
|
+
total += entry.size;
|
|
56
|
+
const normalized = entry.path.replaceAll('\\', '/');
|
|
57
|
+
let error;
|
|
58
|
+
if (normalized !== 'package/' && !normalized.startsWith('package/')) error = 'Theme archive entry is outside package/';
|
|
59
|
+
else if (entry.type === 'SymbolicLink' || entry.type === 'Link') error = 'Theme archive links are forbidden';
|
|
60
|
+
else if (path.posix.isAbsolute(normalized) || normalized.split('/').includes('..')) error = 'Theme archive path is unsafe';
|
|
61
|
+
else if (entries > MAX_ENTRIES || entry.size > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES) {
|
|
62
|
+
error = 'Theme archive exceeds extraction limits';
|
|
63
|
+
}
|
|
64
|
+
entry.resume();
|
|
65
|
+
if (error) inspector.abort(new Error(error));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
inspector.once('error', reject);
|
|
69
|
+
inspector.once('close', resolve);
|
|
70
|
+
inspector.end(archive);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchVerifiedThemePackage({ name, version, fetchImpl = fetch }) {
|
|
75
|
+
const metadataResponse = await fetchImpl(registryUrl(name, version), { headers: { Accept: 'application/json' } });
|
|
76
|
+
if (!metadataResponse.ok) throw new Error(`Theme metadata request failed with HTTP ${metadataResponse.status}`);
|
|
77
|
+
const metadata = await metadataResponse.json();
|
|
78
|
+
if (metadata.name !== name || metadata.version !== version || typeof metadata.dist?.tarball !== 'string') {
|
|
79
|
+
throw new Error('Theme registry metadata does not match the requested package');
|
|
80
|
+
}
|
|
81
|
+
if (!metadata.dist.integrity) throw new Error('Theme package registry metadata has no integrity value');
|
|
82
|
+
const archiveResponse = await fetchImpl(metadata.dist.tarball);
|
|
83
|
+
if (!archiveResponse.ok || archiveResponse.body == null) {
|
|
84
|
+
throw new Error(`Theme archive request failed with HTTP ${archiveResponse.status}`);
|
|
85
|
+
}
|
|
86
|
+
const archive = await boundedBody(archiveResponse);
|
|
87
|
+
verifyIntegrity(archive, metadata.dist.integrity);
|
|
88
|
+
await validateArchive(archive);
|
|
89
|
+
|
|
90
|
+
const staging = await mkdtemp(path.join(tmpdir(), 'gala-theme-'));
|
|
91
|
+
try {
|
|
92
|
+
await mkdir(staging, { recursive: true });
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const extractor = tar.x({
|
|
95
|
+
cwd: staging,
|
|
96
|
+
strip: 1,
|
|
97
|
+
preservePaths: false,
|
|
98
|
+
strict: true
|
|
99
|
+
});
|
|
100
|
+
extractor.once('error', reject);
|
|
101
|
+
extractor.once('close', resolve);
|
|
102
|
+
extractor.end(archive);
|
|
103
|
+
});
|
|
104
|
+
const payloadRoot = path.join(staging, 'payload');
|
|
105
|
+
const manifest = JSON.parse(await readFile(path.join(payloadRoot, '.gala', 'managed-files.json'), 'utf8'));
|
|
106
|
+
if (manifest.themePackage?.name !== name || manifest.themePackage?.version !== version) {
|
|
107
|
+
throw new Error('Extracted theme manifest does not match registry identity');
|
|
108
|
+
}
|
|
109
|
+
for (const [relative, expected] of Object.entries(manifest.files ?? {})) {
|
|
110
|
+
const sourceRelative = manifest.artifactSources?.[relative] ?? relative;
|
|
111
|
+
const source = path.resolve(payloadRoot, sourceRelative);
|
|
112
|
+
const target = path.resolve(payloadRoot, relative);
|
|
113
|
+
if (path.relative(payloadRoot, source).startsWith('..') || path.relative(payloadRoot, target).startsWith('..')) {
|
|
114
|
+
throw new Error('Theme manifest path is unsafe');
|
|
115
|
+
}
|
|
116
|
+
const actual = createHash('sha256').update(await readFile(source)).digest('hex');
|
|
117
|
+
if (actual !== expected) throw new Error(`Theme file hash mismatch: ${relative}`);
|
|
118
|
+
if (source !== target) {
|
|
119
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
120
|
+
await copyFile(source, target);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { staging: payloadRoot, cleanupRoot: staging, manifest };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
await rm(staging, { recursive: true, force: true });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { lstat, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
|
|
5
|
+
import { repairFramework } from './doctor-command.js';
|
|
6
|
+
import { fetchVerifiedThemePackage } from './theme-package.js';
|
|
7
|
+
|
|
8
|
+
const NAME = '@rathnasgala/theme';
|
|
9
|
+
const ACTION_WORKFLOW = '.github/workflows/publish.yml';
|
|
10
|
+
const ACTION_REFERENCE = /rathnasgala\/publish\/\.github\/workflows\/publish\.yml@v([1-9][0-9]*)/g;
|
|
11
|
+
const ACTION_TAG = /^v([1-9][0-9]*)(?:\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/;
|
|
12
|
+
|
|
13
|
+
async function registryMetadata(fetchImpl) {
|
|
14
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(NAME)}`, {
|
|
15
|
+
headers: { Accept: 'application/json' }
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) throw new Error(`Theme registry request failed with HTTP ${response.status}`);
|
|
18
|
+
return response.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function inspectActionUpgrade({ root, fetchImpl = fetch }) {
|
|
22
|
+
const workflowPath = path.resolve(root, ACTION_WORKFLOW);
|
|
23
|
+
const metadata = await lstat(workflowPath);
|
|
24
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
25
|
+
throw new TypeError('Publish workflow must be a regular file');
|
|
26
|
+
}
|
|
27
|
+
const workflow = await readFile(workflowPath, 'utf8');
|
|
28
|
+
const majors = [...workflow.matchAll(ACTION_REFERENCE)].map((match) => Number(match[1]));
|
|
29
|
+
if (majors.length === 0 || new Set(majors).size !== 1) {
|
|
30
|
+
throw new Error('Publish workflow must reference exactly one Gala action major');
|
|
31
|
+
}
|
|
32
|
+
const response = await fetchImpl('https://api.github.com/repos/rathnasgala/publish/tags?per_page=100', {
|
|
33
|
+
headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10' }
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) throw new Error(`Action release lookup failed with HTTP ${response.status}`);
|
|
36
|
+
const tags = await response.json();
|
|
37
|
+
if (!Array.isArray(tags)) throw new TypeError('Action release response must be an array');
|
|
38
|
+
const releasedMajors = tags.map((tag) => ACTION_TAG.exec(tag?.name)?.[1])
|
|
39
|
+
.filter((major) => major != null).map(Number);
|
|
40
|
+
const currentMajor = majors[0];
|
|
41
|
+
const latestMajor = releasedMajors.length === 0 ? currentMajor : Math.max(...releasedMajors);
|
|
42
|
+
return Object.freeze({ currentMajor, latestMajor, newerAvailable: latestMajor > currentMajor });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function upgradeTheme({ root, channel, confirm, fetchImpl = fetch }) {
|
|
46
|
+
const configPath = path.resolve(root, 'site.config.yml');
|
|
47
|
+
const config = parse(await readFile(configPath, 'utf8'));
|
|
48
|
+
const installed = config?.framework?.themePackage?.version;
|
|
49
|
+
const [metadata, action] = await Promise.all([
|
|
50
|
+
registryMetadata(fetchImpl), inspectActionUpgrade({ root, fetchImpl })
|
|
51
|
+
]);
|
|
52
|
+
const selectedChannel = channel ?? (metadata['dist-tags']?.next === installed ? 'next' : 'latest');
|
|
53
|
+
if (!['latest', 'next'].includes(selectedChannel)) throw new TypeError('theme channel must be latest or next');
|
|
54
|
+
const version = metadata['dist-tags']?.[selectedChannel];
|
|
55
|
+
if (typeof version !== 'string') throw new Error(`Theme channel ${selectedChannel} has no resolved version`);
|
|
56
|
+
if (version === installed) return { changed: false, channel: selectedChannel, version, repaired: [], action };
|
|
57
|
+
if (typeof confirm !== 'function' || !await confirm({ name: NAME, installed, channel: selectedChannel, version })) {
|
|
58
|
+
return { changed: false, cancelled: true, channel: selectedChannel, version, repaired: [], action };
|
|
59
|
+
}
|
|
60
|
+
const downloaded = await fetchVerifiedThemePackage({ name: NAME, version, fetchImpl });
|
|
61
|
+
try {
|
|
62
|
+
if (!downloaded.manifest.themePackage.availableDesignThemes?.includes(config?.design?.theme)) {
|
|
63
|
+
throw new Error(`Visual theme ${String(config?.design?.theme)} is unavailable in ${NAME}@${version}`);
|
|
64
|
+
}
|
|
65
|
+
config.framework.themePackage = { name: NAME, version };
|
|
66
|
+
const repaired = await repairFramework(root, downloaded.staging, {
|
|
67
|
+
siteConfiguration: stringify(config)
|
|
68
|
+
});
|
|
69
|
+
return { changed: true, channel: selectedChannel, version, repaired, action };
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(downloaded.cleanupRoot, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ACTION_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml@v[1-9][0-9]*$/;
|
|
6
|
+
const BRANCH = /^(?![./])(?!.*\.\.)(?!.*[~^:?*\[\\])[A-Za-z0-9._/-]+(?<![/.])$/;
|
|
7
|
+
|
|
8
|
+
export function deriveNightlySchedule(siteId) {
|
|
9
|
+
if (typeof siteId !== 'string' || siteId.trim() === '') throw new TypeError('siteId is required');
|
|
10
|
+
const digest = createHash('sha256').update(siteId, 'utf8').digest();
|
|
11
|
+
return { minute: digest.readUInt16BE(0) % 60, hour: digest.readUInt16BE(2) % 24 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validateTimezone(timezone) {
|
|
15
|
+
try {
|
|
16
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(0);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new TypeError(`Invalid IANA timezone: ${timezone}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writePublishWorkflow({
|
|
23
|
+
root,
|
|
24
|
+
siteId,
|
|
25
|
+
timezone,
|
|
26
|
+
actionRef = 'rathnasgala/publish/.github/workflows/publish.yml@v1',
|
|
27
|
+
defaultBranch = 'main',
|
|
28
|
+
buildMode = 'build-and-deploy'
|
|
29
|
+
}) {
|
|
30
|
+
validateTimezone(timezone);
|
|
31
|
+
if (!ACTION_REF.test(actionRef)) throw new TypeError('actionRef must pin a reusable workflow to a major version');
|
|
32
|
+
if (!BRANCH.test(defaultBranch)) throw new TypeError('Invalid default branch');
|
|
33
|
+
if (!['build-only', 'build-and-deploy'].includes(buildMode)) throw new TypeError('Invalid build mode');
|
|
34
|
+
|
|
35
|
+
const resolvedRoot = path.resolve(root);
|
|
36
|
+
const templatePath = path.join(resolvedRoot, '.gala', 'publish.yml.template');
|
|
37
|
+
const target = path.join(resolvedRoot, '.github', 'workflows', 'publish.yml');
|
|
38
|
+
const templateMetadata = await lstat(templatePath);
|
|
39
|
+
if (!templateMetadata.isFile() || templateMetadata.isSymbolicLink()) {
|
|
40
|
+
throw new TypeError('Workflow template must be a regular file');
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const targetMetadata = await lstat(target);
|
|
44
|
+
if (targetMetadata.isSymbolicLink() || !targetMetadata.isFile()) {
|
|
45
|
+
throw new TypeError('Workflow target must be a regular file');
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code !== 'ENOENT') throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { minute, hour } = deriveNightlySchedule(siteId);
|
|
52
|
+
const workflow = (await readFile(templatePath, 'utf8'))
|
|
53
|
+
.replaceAll('__DEFAULT_BRANCH__', defaultBranch)
|
|
54
|
+
.replaceAll('__CRON__', `${minute} ${hour} * * *`)
|
|
55
|
+
.replaceAll('__TIMEZONE__', timezone)
|
|
56
|
+
.replaceAll('__SITE_ID__', siteId)
|
|
57
|
+
.replaceAll('__ACTION_REF__', actionRef)
|
|
58
|
+
.replaceAll('__BUILD_MODE__', buildMode);
|
|
59
|
+
if (/__[A-Z_]+__/.test(workflow)) throw new TypeError('Workflow template contains unresolved placeholders');
|
|
60
|
+
|
|
61
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
62
|
+
const temporary = `${target}.gala-workflow-${process.pid}`;
|
|
63
|
+
const backup = `${target}.gala-backup-${process.pid}`;
|
|
64
|
+
let backedUp = false;
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(temporary, workflow, { flag: 'wx' });
|
|
67
|
+
try {
|
|
68
|
+
await rename(target, backup);
|
|
69
|
+
backedUp = true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code !== 'ENOENT') throw error;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await rename(temporary, target);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (backedUp) await rename(backup, target);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (backedUp) await rm(backup);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await rm(temporary, { force: true });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return { target, minute, hour };
|
|
85
|
+
}
|