@rathnasgala/cli 0.0.21 → 1.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/README.md +66 -212
- package/package.json +3 -3
- package/src/api/gala.js +126 -0
- package/src/api/github.js +63 -0
- package/src/api/http.js +72 -0
- package/src/auth/gala.js +52 -0
- package/src/auth/github.js +78 -0
- package/src/auth/store.js +88 -0
- package/src/cli/args.js +56 -0
- package/src/cli/terminal.js +104 -0
- package/src/commands/auth.js +20 -0
- package/src/commands/doctor.js +98 -0
- package/src/commands/init.js +197 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/publish.js +57 -0
- package/src/commands-manifest.js +58 -0
- package/src/content.js +31 -0
- package/src/git.js +143 -0
- package/src/index.js +44 -297
- package/src/publication.js +37 -0
- package/src/assign-content-ids.js +0 -1
- package/src/auth-command.js +0 -36
- package/src/configure-site.js +0 -102
- package/src/content-files.js +0 -1
- package/src/doctor-command.js +0 -214
- package/src/entitlement-client.js +0 -26
- package/src/entitlement-command.js +0 -74
- package/src/evaluation-date.js +0 -1
- package/src/gala-credential-health.js +0 -34
- package/src/gala-credential-store.js +0 -115
- package/src/gala-device-flow.js +0 -121
- package/src/github-auth-command.js +0 -29
- package/src/github-credential-store.js +0 -65
- package/src/github-device-flow.js +0 -130
- package/src/github-empty-repository.js +0 -76
- package/src/github-identity.js +0 -32
- package/src/github-pages-provisioning.js +0 -107
- package/src/github-repository-secret.js +0 -82
- package/src/github-repository-variable.js +0 -56
- package/src/github-template-repository.js +0 -165
- package/src/hook-command.js +0 -64
- package/src/http-failure.js +0 -55
- package/src/new-command.js +0 -54
- package/src/open-browser.js +0 -40
- package/src/preview-command.js +0 -60
- package/src/publication-creation-client.js +0 -144
- package/src/publication-state.js +0 -7
- package/src/publish-command.js +0 -37
- package/src/record-deployment-command.js +0 -147
- package/src/refresh-command.js +0 -104
- package/src/repository-limits.js +0 -94
- package/src/scaffold-git.js +0 -41
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -147
- package/src/scaffold-site.js +0 -162
- package/src/site-config-registration.js +0 -47
- package/src/site-registration-client.js +0 -138
- package/src/theme-package.js +0 -128
- package/src/topology-client.js +0 -43
- package/src/topology-command.js +0 -70
- package/src/upgrade-command.js +0 -81
- package/src/validate-command.js +0 -5
- package/src/workflow-command.js +0 -87
package/src/scaffold-site.js
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
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 { awaitRepositoryContent, cloneRepository } from './github-template-repository.js';
|
|
8
|
-
import { createPublication } from './publication-creation-client.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) {
|
|
25
|
-
return `https://${owner.toLowerCase()}.github.io`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function registrationLocation(owner, topology, canonicalBaseUrl) {
|
|
29
|
-
if (topology === 'provider-default') {
|
|
30
|
-
if (canonicalBaseUrl != null) {
|
|
31
|
-
throw new TypeError('--canonical-base-url is valid only with --topology custom-domain');
|
|
32
|
-
}
|
|
33
|
-
return { topology: 'PROVIDER_DEFAULT', canonicalBaseUrl: providerDefaultBase(owner) };
|
|
34
|
-
}
|
|
35
|
-
if (topology !== 'custom-domain') {
|
|
36
|
-
throw new TypeError('topology must be provider-default or custom-domain');
|
|
37
|
-
}
|
|
38
|
-
if (typeof canonicalBaseUrl !== 'string') {
|
|
39
|
-
throw new TypeError('--canonical-base-url is required with --topology custom-domain');
|
|
40
|
-
}
|
|
41
|
-
const canonical = new URL(canonicalBaseUrl);
|
|
42
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
43
|
-
|| canonical.port || canonical.search || canonical.hash || canonical.pathname !== '/') {
|
|
44
|
-
throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin');
|
|
45
|
-
}
|
|
46
|
-
return { topology: 'CUSTOM_DOMAIN', canonicalBaseUrl: canonical.origin };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function scaffoldSite({
|
|
50
|
-
owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
|
|
51
|
-
notify = (message) => process.stdout.write(`${message}\n`), ask, openUrl,
|
|
52
|
-
resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl, actionRef,
|
|
53
|
-
buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
|
|
54
|
-
templateRepository = 'site-template',
|
|
55
|
-
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
56
|
-
createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
|
|
57
|
-
configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
|
|
58
|
-
writeWorkflow = writePublishWorkflow,
|
|
59
|
-
installVariable = installRepositoryVariable,
|
|
60
|
-
provisionPages = provisionGithubPages,
|
|
61
|
-
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
62
|
-
verifyCheckout = verifyRepositoryOrigin
|
|
63
|
-
}) {
|
|
64
|
-
const requestedOwner = segment(owner, 'owner');
|
|
65
|
-
const requestedName = segment(repository, 'repository');
|
|
66
|
-
// Validated now so a bad --topology/--canonical-base-url combination fails before a repository
|
|
67
|
-
// exists. The value used later is recomputed once the server says who actually owns it.
|
|
68
|
-
registrationLocation(requestedOwner, topology, canonicalBaseUrl);
|
|
69
|
-
// Optional: the server resolves the installation from the owner when none is supplied. An
|
|
70
|
-
// explicit value is still validated, because a wrong one fails much later and less clearly.
|
|
71
|
-
if (githubInstallationId != null
|
|
72
|
-
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
73
|
-
throw new TypeError('githubInstallationId must be a positive integer');
|
|
74
|
-
}
|
|
75
|
-
if (target == null || path.resolve(target) === path.parse(path.resolve(target)).root) {
|
|
76
|
-
throw new TypeError('target must be a non-root local path');
|
|
77
|
-
}
|
|
78
|
-
const [github, gala] = await Promise.all([readGithub(), readGala()]);
|
|
79
|
-
// Creation reports which installation owns the new repository, so registration cannot disagree.
|
|
80
|
-
let resolvedInstallationId = githubInstallationId ?? null;
|
|
81
|
-
if (emptyExistingRepository && resumeExistingCheckout) {
|
|
82
|
-
throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
|
|
83
|
-
}
|
|
84
|
-
/*
|
|
85
|
-
* The server decides the owner, not this process. It creates under the account the Gala App
|
|
86
|
-
* installation belongs to, which is not always the account behind the writer's OAuth token — an
|
|
87
|
-
* installation on an organisation they belong to gives a different owner entirely. Deriving the
|
|
88
|
-
* canonical URL, the idempotency key, the registration and the Pages target from the local guess
|
|
89
|
-
* would register a publication against a repository that does not exist.
|
|
90
|
-
*/
|
|
91
|
-
let repositoryOwner = requestedOwner;
|
|
92
|
-
let repositoryName = requestedName;
|
|
93
|
-
let generated;
|
|
94
|
-
let root;
|
|
95
|
-
if (resumeExistingCheckout) {
|
|
96
|
-
root = await verifyCheckout({
|
|
97
|
-
root: path.resolve(target), owner: repositoryOwner, repository: repositoryName
|
|
98
|
-
});
|
|
99
|
-
generated = { fullName: `${repositoryOwner}/${repositoryName}` };
|
|
100
|
-
} else if (emptyExistingRepository) {
|
|
101
|
-
await verifyEmpty({ owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken });
|
|
102
|
-
generated = {
|
|
103
|
-
fullName: `${repositoryOwner}/${repositoryName}`,
|
|
104
|
-
cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
|
|
105
|
-
};
|
|
106
|
-
} else {
|
|
107
|
-
/*
|
|
108
|
-
* Created through the API, the same call the browser editor makes. Doing it here meant a second
|
|
109
|
-
* implementation with no fallback and no wait for the App installation to reach the result,
|
|
110
|
-
* which is why CLI-created repositories never appeared in the web UI.
|
|
111
|
-
*/
|
|
112
|
-
const created = await createRepository({
|
|
113
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
114
|
-
githubAccessToken: github.accessToken, name: repositoryName,
|
|
115
|
-
notify, ask, openUrl, selfLogin: requestedOwner
|
|
116
|
-
});
|
|
117
|
-
repositoryOwner = segment(created.owner, 'owner');
|
|
118
|
-
repositoryName = segment(created.repository, 'repository');
|
|
119
|
-
resolvedInstallationId = created.installationId;
|
|
120
|
-
generated = { fullName: created.fullName, cloneUrl: created.cloneUrl };
|
|
121
|
-
// Creation is asynchronous: GitHub answers before the template content lands, and cloning into
|
|
122
|
-
// that window produces an empty checkout and a missing site.config.yml.
|
|
123
|
-
await awaitContent({
|
|
124
|
-
accessToken: github.accessToken, owner: created.owner, repository: created.repository
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
if (!resumeExistingCheckout) {
|
|
128
|
-
root = await clone({ cloneUrl: generated.cloneUrl, target });
|
|
129
|
-
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
130
|
-
}
|
|
131
|
-
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
132
|
-
const configured = await configure(root, siteOptions ?? {});
|
|
133
|
-
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
134
|
-
const registration = await register({
|
|
135
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
136
|
-
githubAccessToken: github.accessToken, idempotencyKey,
|
|
137
|
-
githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
|
|
138
|
-
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
139
|
-
});
|
|
140
|
-
await finalize(root, {
|
|
141
|
-
siteId: registration.siteId,
|
|
142
|
-
canonicalBaseUrl: registration.canonicalBaseUrl,
|
|
143
|
-
pathPrefix: registration.pathPrefix,
|
|
144
|
-
topology
|
|
145
|
-
});
|
|
146
|
-
await writeWorkflow({
|
|
147
|
-
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode,
|
|
148
|
-
...(actionRef == null ? {} : { actionRef })
|
|
149
|
-
});
|
|
150
|
-
await installVariable({
|
|
151
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
152
|
-
variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
|
|
153
|
-
});
|
|
154
|
-
const commitSha = await commit(root);
|
|
155
|
-
const pages = buildMode === 'build-and-deploy' ? await provisionPages({
|
|
156
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha,
|
|
157
|
-
customDomain: location.topology === 'CUSTOM_DOMAIN' ? new URL(location.canonicalBaseUrl).hostname : null
|
|
158
|
-
}) : null;
|
|
159
|
-
return Object.freeze({
|
|
160
|
-
root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
|
|
161
|
-
});
|
|
162
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
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, {
|
|
6
|
-
siteId, canonicalBaseUrl, pathPrefix, topology
|
|
7
|
-
}) {
|
|
8
|
-
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('siteId is invalid');
|
|
9
|
-
if (!['provider-default', 'custom-domain', 'domain-root', 'domain-subpath'].includes(topology)) {
|
|
10
|
-
throw new TypeError('topology is invalid');
|
|
11
|
-
}
|
|
12
|
-
const canonical = new URL(canonicalBaseUrl);
|
|
13
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password || canonical.search
|
|
14
|
-
|| canonical.hash || canonical.pathname !== '/') {
|
|
15
|
-
throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin; put the URL path in pathPrefix');
|
|
16
|
-
}
|
|
17
|
-
const normalizedPrefix = pathPrefix === '' ? '/' : pathPrefix;
|
|
18
|
-
if (typeof normalizedPrefix !== 'string'
|
|
19
|
-
|| !/^\/(?:[^/?#]+(?:\/[^/?#]+)*)?$/.test(normalizedPrefix)) {
|
|
20
|
-
throw new TypeError('pathPrefix must be a normalized URL path');
|
|
21
|
-
}
|
|
22
|
-
const target = path.resolve(root, 'site.config.yml');
|
|
23
|
-
const metadata = await lstat(target);
|
|
24
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new TypeError('site.config.yml must be a regular file');
|
|
25
|
-
const config = parse(await readFile(target, 'utf8'));
|
|
26
|
-
if (config?.schemaVersion !== 1 || config.site == null || config.hosting == null) {
|
|
27
|
-
throw new TypeError('Unsupported site configuration schema');
|
|
28
|
-
}
|
|
29
|
-
config.site.id = siteId;
|
|
30
|
-
config.hosting.provider = 'github-pages';
|
|
31
|
-
config.hosting.topology = topology;
|
|
32
|
-
config.hosting.canonicalBaseUrl = canonical.origin;
|
|
33
|
-
config.hosting.pathPrefix = normalizedPrefix;
|
|
34
|
-
const temporary = `${target}.gala-register-${process.pid}`;
|
|
35
|
-
const backup = `${target}.gala-backup-${process.pid}`;
|
|
36
|
-
try {
|
|
37
|
-
await writeFile(temporary, stringify(config), { flag: 'wx' });
|
|
38
|
-
await rename(target, backup);
|
|
39
|
-
try { await rename(temporary, target); }
|
|
40
|
-
catch (error) { await rename(backup, target); throw error; }
|
|
41
|
-
await rm(backup);
|
|
42
|
-
} catch (error) {
|
|
43
|
-
await rm(temporary, { force: true });
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
return config;
|
|
47
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
2
|
-
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
3
|
-
const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/;
|
|
4
|
-
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
5
|
-
|
|
6
|
-
function required(value, field, pattern) {
|
|
7
|
-
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`${field} is invalid`);
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function apiUrl(apiBaseUrl) {
|
|
12
|
-
const base = new URL(apiBaseUrl);
|
|
13
|
-
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
14
|
-
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
15
|
-
|| base.username || base.password || base.search || base.hash) {
|
|
16
|
-
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
17
|
-
}
|
|
18
|
-
return new URL('/v1/sites', base).href;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Exchanges the stored GitHub token for the short-lived capability the API's GitHub-scoped
|
|
23
|
-
* endpoints require. Exported because publication creation needs the same capability, and two
|
|
24
|
-
* copies of an auth exchange is how they drift.
|
|
25
|
-
*/
|
|
26
|
-
export async function exchangeGithubAuthorization({
|
|
27
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
28
|
-
}) {
|
|
29
|
-
if (typeof githubAccessToken !== 'string' || githubAccessToken === '') {
|
|
30
|
-
throw new Error('GitHub authentication is missing; run `gala auth`');
|
|
31
|
-
}
|
|
32
|
-
const response = await fetchImpl(new URL('/v1/auth/github/device-authorizations', apiBaseUrl), {
|
|
33
|
-
method: 'POST',
|
|
34
|
-
headers: {
|
|
35
|
-
accept: 'application/json',
|
|
36
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
37
|
-
'content-type': 'application/json'
|
|
38
|
-
},
|
|
39
|
-
body: JSON.stringify({ accessToken: githubAccessToken })
|
|
40
|
-
});
|
|
41
|
-
if (response.status === 401) {
|
|
42
|
-
throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
|
|
43
|
-
}
|
|
44
|
-
if (response.status !== 200) {
|
|
45
|
-
throw new Error(await describeHttpFailure(response, 'GitHub repository authorization'));
|
|
46
|
-
}
|
|
47
|
-
const payload = await response.json();
|
|
48
|
-
return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function registerSite({
|
|
52
|
-
apiBaseUrl = 'https://api.gala67.com',
|
|
53
|
-
galaAccessToken,
|
|
54
|
-
githubAccessToken,
|
|
55
|
-
idempotencyKey,
|
|
56
|
-
githubInstallationId,
|
|
57
|
-
repositoryOwner,
|
|
58
|
-
repositoryName,
|
|
59
|
-
topology,
|
|
60
|
-
canonicalBaseUrl,
|
|
61
|
-
fetchImpl = fetch
|
|
62
|
-
}) {
|
|
63
|
-
if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
|
|
64
|
-
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
65
|
-
}
|
|
66
|
-
const githubAuthorization = await exchangeGithubAuthorization({
|
|
67
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
68
|
-
});
|
|
69
|
-
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
70
|
-
required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
|
|
71
|
-
required(repositoryName, 'repositoryName', REPOSITORY_PART);
|
|
72
|
-
if (githubInstallationId != null
|
|
73
|
-
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
74
|
-
throw new TypeError('githubInstallationId must be a positive integer');
|
|
75
|
-
}
|
|
76
|
-
if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
|
|
77
|
-
throw new TypeError('topology is invalid');
|
|
78
|
-
}
|
|
79
|
-
const response = await fetchImpl(apiUrl(apiBaseUrl), {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: {
|
|
82
|
-
accept: 'application/json',
|
|
83
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
84
|
-
'content-type': 'application/json',
|
|
85
|
-
'github-authorization': githubAuthorization,
|
|
86
|
-
'idempotency-key': idempotencyKey
|
|
87
|
-
},
|
|
88
|
-
body: JSON.stringify({
|
|
89
|
-
// Omitted rather than sent as null: the server resolves it from the owner.
|
|
90
|
-
...(githubInstallationId == null ? {} : { githubInstallationId }),
|
|
91
|
-
repositoryOwner,
|
|
92
|
-
repositoryName,
|
|
93
|
-
topology,
|
|
94
|
-
canonicalBaseUrl
|
|
95
|
-
})
|
|
96
|
-
});
|
|
97
|
-
if (response.status === 401) {
|
|
98
|
-
throw new Error('Gala authentication expired; run `gala auth` again');
|
|
99
|
-
}
|
|
100
|
-
if (response.status === 404) {
|
|
101
|
-
throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
|
|
102
|
-
}
|
|
103
|
-
if (response.status === 409) {
|
|
104
|
-
const failure = await response.clone().json().catch(() => null);
|
|
105
|
-
if (failure?.code === 'GITHUB_APP_NOT_INSTALLED') {
|
|
106
|
-
throw new Error(
|
|
107
|
-
`The Gala GitHub App is not installed on ${repositoryOwner}. Install it at `
|
|
108
|
-
+ 'https://github.com/apps/gala67-app/installations/new for that account, then run scaffold '
|
|
109
|
-
+ 'again.'
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
throw new Error('Site registration conflicts with existing protected state; use the recovery command');
|
|
113
|
-
}
|
|
114
|
-
if (response.status !== 201) throw new Error(await describeHttpFailure(response, 'Gala site registration'));
|
|
115
|
-
const payload = await response.json();
|
|
116
|
-
if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
|
|
117
|
-
throw new TypeError('Gala site registration response is invalid');
|
|
118
|
-
}
|
|
119
|
-
const canonical = new URL(payload.canonicalBaseUrl);
|
|
120
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
121
|
-
|| canonical.search || canonical.hash || canonical.pathname !== '/') {
|
|
122
|
-
throw new TypeError('Gala site registration returned an invalid canonicalBaseUrl');
|
|
123
|
-
}
|
|
124
|
-
if (typeof payload.pathPrefix !== 'string'
|
|
125
|
-
|| !/^\/(?:[^/?#]+(?:\/[^/?#]+)*)?$/.test(payload.pathPrefix)) {
|
|
126
|
-
throw new TypeError('Gala site registration returned an invalid pathPrefix');
|
|
127
|
-
}
|
|
128
|
-
const location = response.headers?.get?.('location');
|
|
129
|
-
if (location !== `/v1/sites/${payload.siteId}`) {
|
|
130
|
-
throw new TypeError('Gala site registration returned an invalid Location header');
|
|
131
|
-
}
|
|
132
|
-
return Object.freeze({
|
|
133
|
-
siteId: payload.siteId,
|
|
134
|
-
siteSecret: payload.siteSecret,
|
|
135
|
-
canonicalBaseUrl: canonical.origin,
|
|
136
|
-
pathPrefix: payload.pathPrefix === '' ? '/' : payload.pathPrefix
|
|
137
|
-
});
|
|
138
|
-
}
|
package/src/theme-package.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
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
|
-
}
|
package/src/topology-client.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
2
|
-
|
|
3
|
-
function endpoint(apiBaseUrl, siteId, suffix) {
|
|
4
|
-
if (!ULID.test(siteId)) throw new TypeError('siteId is invalid');
|
|
5
|
-
const base = new URL(apiBaseUrl);
|
|
6
|
-
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
7
|
-
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
8
|
-
|| base.username || base.password || base.search || base.hash) {
|
|
9
|
-
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
10
|
-
}
|
|
11
|
-
return new URL(`/v1/sites/${siteId}/topology-changes/${suffix}`, base).href;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function response(response, operation) {
|
|
15
|
-
if (response.status === 401) throw new Error('Gala authentication expired; run `gala auth` again');
|
|
16
|
-
if (response.status === 404) throw new Error('Site is unavailable');
|
|
17
|
-
if (response.status === 409) throw new Error(`Topology ${operation} conflicts with protected state`);
|
|
18
|
-
if (!response.ok) throw new Error(`Topology ${operation} failed with HTTP ${response.status}`);
|
|
19
|
-
const payload = await response.json();
|
|
20
|
-
if (!ULID.test(payload?.changeId)) throw new TypeError('Topology response is invalid');
|
|
21
|
-
return Object.freeze(payload);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function prepareTopologyChange({
|
|
25
|
-
apiBaseUrl, accessToken, siteId, canonicalBaseUrl, pathPrefix, fetchImpl = fetch
|
|
26
|
-
}) {
|
|
27
|
-
const result = await fetchImpl(endpoint(apiBaseUrl, siteId, 'prepare'), {
|
|
28
|
-
method: 'POST',
|
|
29
|
-
headers: { accept: 'application/json', authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
|
|
30
|
-
body: JSON.stringify({ canonicalBaseUrl, pathPrefix })
|
|
31
|
-
});
|
|
32
|
-
return response(result, 'prepare');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function commitTopologyChange({
|
|
36
|
-
apiBaseUrl, accessToken, siteId, changeId, fetchImpl = fetch
|
|
37
|
-
}) {
|
|
38
|
-
if (!ULID.test(changeId)) throw new TypeError('changeId is invalid');
|
|
39
|
-
const result = await fetchImpl(endpoint(apiBaseUrl, siteId, `${changeId}/commit`), {
|
|
40
|
-
method: 'POST', headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
|
|
41
|
-
});
|
|
42
|
-
return response(result, 'commit');
|
|
43
|
-
}
|
package/src/topology-command.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { parse } from 'yaml';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
-
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
-
import { writeRegisteredSiteConfiguration } from './site-config-registration.js';
|
|
8
|
-
import { prepareTopologyChange, commitTopologyChange } from './topology-client.js';
|
|
9
|
-
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
10
|
-
|
|
11
|
-
function run(root, args, spawnProcess, accepted = [0]) {
|
|
12
|
-
return new Promise((resolve, reject) => {
|
|
13
|
-
const child = spawnProcess('git', ['-C', root, ...args], { cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
14
|
-
let output = '';
|
|
15
|
-
child.stdout?.on('data', (chunk) => { output += chunk; });
|
|
16
|
-
child.once('error', reject);
|
|
17
|
-
child.once('exit', (code, signal) => {
|
|
18
|
-
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
19
|
-
else if (!accepted.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
20
|
-
else resolve({ code, output: output.trim() });
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function switchTopology({
|
|
26
|
-
root, owner, repository, canonicalBaseUrl, pathPrefix = '/',
|
|
27
|
-
readGala = readGalaCredential, readGithub = readGithubCredential,
|
|
28
|
-
prepare = prepareTopologyChange, commit = commitTopologyChange,
|
|
29
|
-
provisionPages = provisionGithubPages, spawnProcess = spawn
|
|
30
|
-
}) {
|
|
31
|
-
if (typeof owner !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(owner)
|
|
32
|
-
|| typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(repository)) {
|
|
33
|
-
throw new TypeError('owner and repository are required GitHub path segments');
|
|
34
|
-
}
|
|
35
|
-
const siteRoot = path.resolve(root);
|
|
36
|
-
const config = parse(await readFile(path.join(siteRoot, 'site.config.yml'), 'utf8'));
|
|
37
|
-
const siteId = config?.site?.id;
|
|
38
|
-
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('site.config.yml has no valid site id');
|
|
39
|
-
const [gala, github] = await Promise.all([readGala(), readGithub()]);
|
|
40
|
-
const pending = await prepare({
|
|
41
|
-
apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken, siteId, canonicalBaseUrl, pathPrefix
|
|
42
|
-
});
|
|
43
|
-
// A site served under a path holds no domain of its own — GitHub lends it the one on the
|
|
44
|
-
// owner's main site — so the absence of a cname no longer means the provider address.
|
|
45
|
-
const topology = pending.canonicalBaseUrl === `https://${owner.toLowerCase()}.github.io`
|
|
46
|
-
? 'provider-default' : (pending.pathPrefix === '/' ? 'domain-root' : 'domain-subpath');
|
|
47
|
-
await writeRegisteredSiteConfiguration(siteRoot, {
|
|
48
|
-
siteId, canonicalBaseUrl: pending.canonicalBaseUrl,
|
|
49
|
-
pathPrefix: pending.pathPrefix, topology
|
|
50
|
-
});
|
|
51
|
-
const cnamePath = path.join(siteRoot, 'CNAME');
|
|
52
|
-
if (pending.cname == null) await rm(cnamePath, { force: true });
|
|
53
|
-
else await writeFile(cnamePath, `${pending.cname}\n`, { encoding: 'utf8' });
|
|
54
|
-
await run(siteRoot, ['add', '-A', '--', 'site.config.yml', 'CNAME'], spawnProcess);
|
|
55
|
-
const unchanged = await run(siteRoot, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
|
|
56
|
-
if (unchanged.code === 1) {
|
|
57
|
-
await run(siteRoot, ['commit', '-m', `chore(gala): switch topology to ${topology}`], spawnProcess);
|
|
58
|
-
}
|
|
59
|
-
await run(siteRoot, ['push', 'origin', 'HEAD'], spawnProcess);
|
|
60
|
-
const { output: commitSha } = await run(siteRoot, ['rev-parse', 'HEAD'], spawnProcess);
|
|
61
|
-
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid topology commit SHA');
|
|
62
|
-
await provisionPages({
|
|
63
|
-
owner, repository, accessToken: github.accessToken, commitSha, customDomain: pending.cname
|
|
64
|
-
});
|
|
65
|
-
const committed = await commit({
|
|
66
|
-
apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken,
|
|
67
|
-
siteId, changeId: pending.changeId
|
|
68
|
-
});
|
|
69
|
-
return Object.freeze({ ...committed, commitSha });
|
|
70
|
-
}
|