@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.
Files changed (64) hide show
  1. package/README.md +66 -212
  2. package/package.json +3 -3
  3. package/src/api/gala.js +126 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/init.js +197 -0
  14. package/src/commands/new.js +76 -0
  15. package/src/commands/preview.js +92 -0
  16. package/src/commands/publish.js +57 -0
  17. package/src/commands-manifest.js +58 -0
  18. package/src/content.js +31 -0
  19. package/src/git.js +143 -0
  20. package/src/index.js +44 -297
  21. package/src/publication.js +37 -0
  22. package/src/assign-content-ids.js +0 -1
  23. package/src/auth-command.js +0 -36
  24. package/src/configure-site.js +0 -102
  25. package/src/content-files.js +0 -1
  26. package/src/doctor-command.js +0 -214
  27. package/src/entitlement-client.js +0 -26
  28. package/src/entitlement-command.js +0 -74
  29. package/src/evaluation-date.js +0 -1
  30. package/src/gala-credential-health.js +0 -34
  31. package/src/gala-credential-store.js +0 -115
  32. package/src/gala-device-flow.js +0 -121
  33. package/src/github-auth-command.js +0 -29
  34. package/src/github-credential-store.js +0 -65
  35. package/src/github-device-flow.js +0 -130
  36. package/src/github-empty-repository.js +0 -76
  37. package/src/github-identity.js +0 -32
  38. package/src/github-pages-provisioning.js +0 -107
  39. package/src/github-repository-secret.js +0 -82
  40. package/src/github-repository-variable.js +0 -56
  41. package/src/github-template-repository.js +0 -165
  42. package/src/hook-command.js +0 -64
  43. package/src/http-failure.js +0 -55
  44. package/src/new-command.js +0 -54
  45. package/src/open-browser.js +0 -40
  46. package/src/preview-command.js +0 -60
  47. package/src/publication-creation-client.js +0 -144
  48. package/src/publication-state.js +0 -7
  49. package/src/publish-command.js +0 -37
  50. package/src/record-deployment-command.js +0 -147
  51. package/src/refresh-command.js +0 -104
  52. package/src/repository-limits.js +0 -94
  53. package/src/scaffold-git.js +0 -41
  54. package/src/scaffold-options.js +0 -58
  55. package/src/scaffold-preflight.js +0 -147
  56. package/src/scaffold-site.js +0 -162
  57. package/src/site-config-registration.js +0 -47
  58. package/src/site-registration-client.js +0 -138
  59. package/src/theme-package.js +0 -128
  60. package/src/topology-client.js +0 -43
  61. package/src/topology-command.js +0 -70
  62. package/src/upgrade-command.js +0 -81
  63. package/src/validate-command.js +0 -5
  64. package/src/workflow-command.js +0 -87
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }