@rathnasgala/cli 0.0.22 → 1.1.4

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 (69) hide show
  1. package/README.md +100 -187
  2. package/package.json +3 -3
  3. package/src/api/gala.js +163 -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/domain.js +124 -0
  14. package/src/commands/init.js +276 -0
  15. package/src/commands/new.js +76 -0
  16. package/src/commands/preview.js +92 -0
  17. package/src/commands/prism.js +360 -0
  18. package/src/commands/publish.js +57 -0
  19. package/src/commands/upgrade.js +173 -0
  20. package/src/commands-manifest.js +80 -0
  21. package/src/content.js +31 -0
  22. package/src/domain.js +33 -0
  23. package/src/git.js +160 -0
  24. package/src/index.js +44 -294
  25. package/src/publication.js +39 -0
  26. package/src/assign-content-ids.js +0 -1
  27. package/src/auth-command.js +0 -36
  28. package/src/configure-site.js +0 -102
  29. package/src/content-files.js +0 -1
  30. package/src/doctor-command.js +0 -214
  31. package/src/entitlement-client.js +0 -26
  32. package/src/entitlement-command.js +0 -74
  33. package/src/evaluation-date.js +0 -1
  34. package/src/gala-credential-health.js +0 -34
  35. package/src/gala-credential-store.js +0 -115
  36. package/src/gala-device-flow.js +0 -121
  37. package/src/git-credentials.js +0 -37
  38. package/src/github-auth-command.js +0 -50
  39. package/src/github-credential-store.js +0 -104
  40. package/src/github-device-flow.js +0 -153
  41. package/src/github-empty-repository.js +0 -89
  42. package/src/github-identity.js +0 -32
  43. package/src/github-pages-provisioning.js +0 -107
  44. package/src/github-repository-secret.js +0 -82
  45. package/src/github-repository-variable.js +0 -56
  46. package/src/github-template-repository.js +0 -171
  47. package/src/hook-command.js +0 -64
  48. package/src/http-failure.js +0 -55
  49. package/src/new-command.js +0 -54
  50. package/src/open-browser.js +0 -40
  51. package/src/preview-command.js +0 -60
  52. package/src/publication-creation-client.js +0 -155
  53. package/src/publication-state.js +0 -7
  54. package/src/publish-command.js +0 -37
  55. package/src/record-deployment-command.js +0 -147
  56. package/src/refresh-command.js +0 -104
  57. package/src/repository-limits.js +0 -94
  58. package/src/scaffold-git.js +0 -76
  59. package/src/scaffold-options.js +0 -58
  60. package/src/scaffold-preflight.js +0 -146
  61. package/src/scaffold-site.js +0 -185
  62. package/src/site-config-registration.js +0 -47
  63. package/src/site-registration-client.js +0 -138
  64. package/src/theme-package.js +0 -128
  65. package/src/topology-client.js +0 -43
  66. package/src/topology-command.js +0 -70
  67. package/src/upgrade-command.js +0 -81
  68. package/src/validate-command.js +0 -5
  69. package/src/workflow-command.js +0 -87
@@ -1,76 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
-
3
- import { gitCredentialArguments, gitEnvironment } from './git-credentials.js';
4
-
5
- function run(root, args, spawnProcess, acceptedExitCodes = [0], accessToken) {
6
- return new Promise((resolve, reject) => {
7
- const child = spawnProcess('git', ['-C', root, ...gitCredentialArguments(accessToken), ...args], {
8
- cwd: root, shell: false, stdio: 'inherit', env: gitEnvironment(accessToken)
9
- });
10
- child.once('error', reject);
11
- child.once('exit', (code, signal) => {
12
- if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
13
- else if (!acceptedExitCodes.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
14
- else resolve(code);
15
- });
16
- });
17
- }
18
-
19
- function capture(root, args, spawnProcess) {
20
- return new Promise((resolve, reject) => {
21
- const child = spawnProcess('git', ['-C', root, ...args], {
22
- cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
23
- });
24
- let stdout = '';
25
- child.stdout.on('data', (chunk) => { stdout += chunk; });
26
- child.once('error', reject);
27
- child.once('exit', (code, signal) => {
28
- if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
29
- else if (code !== 0) reject(new Error(`Git ${args[0]} exited with code ${code}`));
30
- else resolve(stdout.trim());
31
- });
32
- });
33
- }
34
-
35
- /**
36
- * Takes the commit registration just pushed, before anything local is written.
37
- *
38
- * Registering a site makes the server write `site.config.yml` and `.github/workflows/publish.yml`
39
- * into the repository. The checkout was taken before that, so the scaffold's own commit lands on a
40
- * parent the remote has moved past and the push is rejected:
41
- *
42
- * ! [rejected] HEAD -> main (fetch first)
43
- *
44
- * Integrating here rather than after committing is what keeps it simple: at this point the working
45
- * tree is untouched, so the rebase is trivial and cannot conflict with files this process is about
46
- * to write. A dirty tree — only reachable via --resume — fails loudly, which is the right answer
47
- * for work nobody asked this command to reconcile.
48
- */
49
- export async function syncScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
50
- const branch = await capture(root, ['rev-parse', '--abbrev-ref', 'HEAD'], spawnProcess);
51
- if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch === 'HEAD') {
52
- throw new Error('Git checkout is not on a named branch');
53
- }
54
- await run(root, ['fetch', 'origin', branch], spawnProcess, [0], accessToken);
55
- await run(root, ['rebase', `origin/${branch}`], spawnProcess);
56
- const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
57
- if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid head SHA');
58
- return commitSha;
59
- }
60
-
61
- export async function commitScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
62
- /*
63
- * Only the site configuration. The publish workflow is written by the server during registration
64
- * — the same path the browser editor uses — and staging a file that is not in the checkout yet
65
- * fails outright with a pathspec error.
66
- */
67
- await run(root, ['add', '--', 'site.config.yml'], spawnProcess);
68
- const unchanged = await run(root, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
69
- if (unchanged === 1) {
70
- await run(root, ['commit', '-m', 'chore(gala): configure site'], spawnProcess);
71
- }
72
- await run(root, ['push', 'origin', 'HEAD'], spawnProcess, [0], accessToken);
73
- const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
74
- if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid scaffold commit SHA');
75
- return commitSha;
76
- }
@@ -1,58 +0,0 @@
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
- }
@@ -1,146 +0,0 @@
1
- import path from 'node:path';
2
-
3
- import { authenticateGala } from './auth-command.js';
4
- import { authenticateGithub } from './github-auth-command.js';
5
- import { readGalaCredential } from './gala-credential-store.js';
6
- import { readGithubCredential } from './github-credential-store.js';
7
- import { resolveGithubLogin } from './github-identity.js';
8
- import { galaCredentialAccepted } from './gala-credential-health.js';
9
- import { openInBrowser } from './open-browser.js';
10
- import { forgetGalaCredential } from './gala-credential-store.js';
11
-
12
- export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
13
-
14
- const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
15
-
16
- /**
17
- * Everything `scaffold` needs, worked out rather than demanded.
18
- *
19
- * `scaffold` used to require four values up front — `--owner`, `--repository`, `--target` and
20
- * `--installation-id` — and it failed outright if `auth` or `auth github` had not been run first,
21
- * telling the writer to go and run them. Three of those four are derivable and the two sign-ins
22
- * can simply happen. Every one of them is still accepted as an explicit override; nothing that
23
- * worked before stops working.
24
- *
25
- * The steps are ordered so nothing is created until everything is known: the App installation is
26
- * confirmed before a repository exists, rather than after, so an interrupted run leaves no
27
- * half-connected repository behind.
28
- */
29
- export async function prepareScaffold({
30
- owner,
31
- repository,
32
- target,
33
- githubInstallationId,
34
- siteName,
35
- cwd = process.cwd(),
36
- notify = () => {},
37
- ask,
38
- openUrl = openInBrowser,
39
- readGala = readGalaCredential,
40
- readGithub = readGithubCredential,
41
- credentialAccepted = galaCredentialAccepted,
42
- forgetGala = forgetGalaCredential,
43
- signInGala = authenticateGala,
44
- signInGithub = authenticateGithub,
45
- resolveLogin = resolveGithubLogin,
46
- apiBaseUrl = DEFAULT_API_BASE_URL
47
- } = {}) {
48
- const gala = await ensureGala({
49
- apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
50
- });
51
- const github = await ensureGithub({ notify, readGithub, signInGithub, openUrl });
52
-
53
- const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
54
-
55
- const resolvedRepository = repository
56
- ?? (target == null ? null : path.basename(path.resolve(cwd, target)))
57
- ?? repositoryNameFrom(siteName)
58
- ?? await askForRepository(ask);
59
-
60
- // `--target ./` is the common case and means "here", so the repository takes its name from the
61
- // directory the writer is standing in. Everywhere else the repository names its own folder.
62
- const resolvedTarget = target ?? `./${resolvedRepository}`;
63
-
64
- /*
65
- * No installation lookup. The id is an internal GitHub identifier for an App the server owns, and
66
- * nothing here can discover it reliably: a GitHub App token could list installations and the CLI
67
- * cannot hold one, while the repository inventory only carries the id once a repository exists —
68
- * which, in the flow that creates the first repository, is never. The server resolves it during
69
- * registration. `--installation-id` still overrides, for an account with several.
70
- */
71
- return Object.freeze({
72
- owner: resolvedOwner,
73
- repository: resolvedRepository,
74
- target: resolvedTarget,
75
- githubInstallationId: githubInstallationId ?? null
76
- });
77
- }
78
-
79
- /**
80
- * A missing, expired or refused credential is a step to take, not an error to report.
81
- *
82
- * The stored file is checked against the server before anything depends on it, because a
83
- * credential that parses and has not expired can still be one the API refuses — and finding that
84
- * out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
85
- * "sign in again" turned into a stack trace.
86
- */
87
- async function ensureGala({
88
- apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
89
- }) {
90
- let stored = null;
91
- try {
92
- stored = await readGala();
93
- } catch {
94
- stored = null;
95
- }
96
-
97
- if (stored != null) {
98
- const base = stored.apiBaseUrl ?? apiBaseUrl;
99
- if (await credentialAccepted({ apiBaseUrl: base, accessToken: stored.accessToken })) {
100
- return stored;
101
- }
102
- // Leaving it on disk would make every later command repeat this discovery.
103
- await forgetGala();
104
- notify('Your Gala sign-in is no longer valid.');
105
- }
106
-
107
- notify('Signing in to Gala.');
108
- await signInGala({
109
- apiBaseUrl,
110
- showInstructions: ({ verificationUri, userCode }) =>
111
- notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
112
- });
113
- return readGala();
114
- }
115
-
116
- async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
117
- try {
118
- return await readGithub();
119
- } catch {
120
- notify('Signing in to GitHub.');
121
- await signInGithub({
122
- showInstructions: ({ verificationUri, userCode }) =>
123
- notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
124
- });
125
- return readGithub();
126
- }
127
- }
128
-
129
- /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
130
- export function repositoryNameFrom(siteName) {
131
- if (typeof siteName !== 'string') return null;
132
- const slug = siteName
133
- .trim().toLowerCase()
134
- .replace(/[^a-z0-9._-]+/g, '-')
135
- .replace(/^-+|-+$/g, '');
136
- return slug === '' ? null : slug;
137
- }
138
-
139
- async function askForRepository(ask) {
140
- if (typeof ask !== 'function') {
141
- throw new TypeError('repository is required; pass --repository or --site-name');
142
- }
143
- const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
144
- if (answer == null) throw new TypeError('A repository name is required');
145
- return answer;
146
- }
@@ -1,185 +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 { commitScaffold, syncScaffold } from './scaffold-git.js';
13
- import {
14
- setRepositoryOrigin, verifyEmptyRepository, verifyRepositoryOrigin
15
- } from './github-empty-repository.js';
16
-
17
- function segment(value, field) {
18
- if (typeof value !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(value)) throw new TypeError(`${field} is invalid`);
19
- return value;
20
- }
21
-
22
- function providerDefaultBase(owner) {
23
- return `https://${owner.toLowerCase()}.github.io`;
24
- }
25
-
26
- function registrationLocation(owner, topology, canonicalBaseUrl) {
27
- if (topology === 'provider-default') {
28
- if (canonicalBaseUrl != null) {
29
- throw new TypeError('--canonical-base-url is valid only with --topology custom-domain');
30
- }
31
- return { topology: 'PROVIDER_DEFAULT', canonicalBaseUrl: providerDefaultBase(owner) };
32
- }
33
- if (topology !== 'custom-domain') {
34
- throw new TypeError('topology must be provider-default or custom-domain');
35
- }
36
- if (typeof canonicalBaseUrl !== 'string') {
37
- throw new TypeError('--canonical-base-url is required with --topology custom-domain');
38
- }
39
- const canonical = new URL(canonicalBaseUrl);
40
- if (canonical.protocol !== 'https:' || canonical.username || canonical.password
41
- || canonical.port || canonical.search || canonical.hash || canonical.pathname !== '/') {
42
- throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin');
43
- }
44
- return { topology: 'CUSTOM_DOMAIN', canonicalBaseUrl: canonical.origin };
45
- }
46
-
47
- export async function scaffoldSite({
48
- owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
49
- notify = (message) => process.stdout.write(`${message}\n`), ask, openUrl,
50
- resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl,
51
- templateOwner = 'rathnasgala',
52
- templateRepository = 'site-template',
53
- readGithub = readGithubCredential, readGala = readGalaCredential,
54
- createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
55
- configure = configureSite, register = registerSite,
56
- installVariable = installRepositoryVariable,
57
- provisionPages = provisionGithubPages,
58
- commit = commitScaffold, sync = syncScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
59
- verifyCheckout = verifyRepositoryOrigin
60
- }) {
61
- const requestedOwner = segment(owner, 'owner');
62
- const requestedName = segment(repository, 'repository');
63
- // Validated now so a bad --topology/--canonical-base-url combination fails before a repository
64
- // exists. The value used later is recomputed once the server says who actually owns it.
65
- registrationLocation(requestedOwner, topology, canonicalBaseUrl);
66
- // Optional: the server resolves the installation from the owner when none is supplied. An
67
- // explicit value is still validated, because a wrong one fails much later and less clearly.
68
- if (githubInstallationId != null
69
- && (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
70
- throw new TypeError('githubInstallationId must be a positive integer');
71
- }
72
- if (target == null || path.resolve(target) === path.parse(path.resolve(target)).root) {
73
- throw new TypeError('target must be a non-root local path');
74
- }
75
- const [github, gala] = await Promise.all([readGithub(), readGala()]);
76
- // Creation reports which installation owns the new repository, so registration cannot disagree.
77
- let resolvedInstallationId = githubInstallationId ?? null;
78
- if (emptyExistingRepository && resumeExistingCheckout) {
79
- throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
80
- }
81
- /*
82
- * The server decides the owner, not this process. It creates under the account the Gala App
83
- * installation belongs to, which is not always the account behind the writer's OAuth token — an
84
- * installation on an organisation they belong to gives a different owner entirely. Deriving the
85
- * canonical URL, the idempotency key, the registration and the Pages target from the local guess
86
- * would register a publication against a repository that does not exist.
87
- */
88
- let repositoryOwner = requestedOwner;
89
- let repositoryName = requestedName;
90
- let generated;
91
- let root;
92
- if (resumeExistingCheckout) {
93
- root = await verifyCheckout({
94
- root: path.resolve(target), owner: repositoryOwner, repository: repositoryName
95
- });
96
- generated = { fullName: `${repositoryOwner}/${repositoryName}` };
97
- } else if (emptyExistingRepository) {
98
- await verifyEmpty({ owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken });
99
- generated = {
100
- fullName: `${repositoryOwner}/${repositoryName}`,
101
- cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
102
- };
103
- } else {
104
- /*
105
- * Created through the API, the same call the browser editor makes. Doing it here meant a second
106
- * implementation with no fallback and no wait for the App installation to reach the result,
107
- * which is why CLI-created repositories never appeared in the web UI.
108
- */
109
- const created = await createRepository({
110
- apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
111
- githubAccessToken: github.accessToken, name: repositoryName,
112
- notify, ask, openUrl, selfLogin: requestedOwner
113
- });
114
- repositoryOwner = segment(created.owner, 'owner');
115
- repositoryName = segment(created.repository, 'repository');
116
- resolvedInstallationId = created.installationId;
117
- generated = { fullName: created.fullName, cloneUrl: created.cloneUrl };
118
- // Creation is asynchronous: GitHub answers before the template content lands, and cloning into
119
- // that window produces an empty checkout and a missing site.config.yml.
120
- await awaitContent({
121
- accessToken: github.accessToken, owner: created.owner, repository: created.repository
122
- });
123
- }
124
- if (!resumeExistingCheckout) {
125
- root = await clone({ cloneUrl: generated.cloneUrl, target, accessToken: github.accessToken });
126
- if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
127
- }
128
- const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
129
- await configure(root, siteOptions ?? {});
130
-
131
- /*
132
- * The writer's design choices go up before registration, and nothing goes up after it.
133
- *
134
- * Registration makes the server write `site.config.yml` and `.github/workflows/publish.yml` into
135
- * the repository — the same code path the browser editor uses. The CLI used to write its own
136
- * versions of both files afterwards and commit them, which produced a second commit whose whole
137
- * content was rewriting `api-base-url` into a `vars` reference and stripping the template's
138
- * comments. That second commit triggered a second Publish run, which collided with the first
139
- * one's deployment record and failed:
140
- *
141
- * Assigned-ID source moved on the remote branch: content/posts/example/index.en.md
142
- *
143
- * So those two files have one owner now, and it is the server. What is left here is the design
144
- * configuration, which only the CLI receives — pushed first so the server provisions on top of
145
- * it rather than around it.
146
- */
147
- await commit(root, { accessToken: github.accessToken });
148
-
149
- const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
150
- const registration = await register({
151
- apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
152
- githubAccessToken: github.accessToken, idempotencyKey,
153
- githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
154
- topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
155
- });
156
-
157
- await installVariable({
158
- owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
159
- variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
160
- });
161
-
162
- // Brings the server's provisioning commits into the checkout, so the writer's working copy holds
163
- // the publication as it actually exists, and reports the commit publishing will run against.
164
- const commitSha = await sync(root, { accessToken: github.accessToken });
165
-
166
- /*
167
- * Pages is only touched for a custom domain.
168
- *
169
- * On the provider default it was never doing anything: publishing creates a `gh-pages` branch and
170
- * GitHub turns on classic Pages by itself — every scaffold produced a live site with
171
- * `build_type: legacy, source: gh-pages` before this step ran, including runs that failed before
172
- * reaching it. What the step did cost was up to ten minutes waiting on a workflow run, and a
173
- * reported failure for a publication that was already serving.
174
- *
175
- * A custom domain is different: classic Pages will not point itself at someone's own hostname, so
176
- * the API call is the thing that does it.
177
- */
178
- const pages = location.topology === 'CUSTOM_DOMAIN' ? await provisionPages({
179
- owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha,
180
- customDomain: new URL(location.canonicalBaseUrl).hostname
181
- }) : null;
182
- return Object.freeze({
183
- root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
184
- });
185
- }
@@ -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
- }