@rathnasgala/cli 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/github-empty-repository.js +3 -2
- package/src/github-identity.js +2 -1
- package/src/github-pages-provisioning.js +3 -2
- package/src/github-repository-variable.js +3 -2
- package/src/github-template-repository.js +50 -2
- package/src/http-failure.js +55 -0
- package/src/publication-creation-client.js +78 -0
- package/src/scaffold-site.js +38 -10
- package/src/site-registration-client.js +12 -4
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
2
3
|
|
|
3
4
|
const API_VERSION = '2026-03-10';
|
|
4
5
|
|
|
@@ -11,7 +12,7 @@ export async function verifyEmptyRepository({ owner, repository, accessToken, fe
|
|
|
11
12
|
const response = await fetchImpl(repositoryUrl, {
|
|
12
13
|
headers
|
|
13
14
|
});
|
|
14
|
-
if (!response.ok) throw new Error(
|
|
15
|
+
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub repository lookup'));
|
|
15
16
|
const payload = await response.json();
|
|
16
17
|
if (payload.full_name?.toLowerCase() !== `${owner}/${repository}`.toLowerCase()) {
|
|
17
18
|
throw new TypeError('GitHub returned an unexpected repository');
|
|
@@ -21,7 +22,7 @@ export async function verifyEmptyRepository({ owner, repository, accessToken, fe
|
|
|
21
22
|
...headers
|
|
22
23
|
}
|
|
23
24
|
});
|
|
24
|
-
if (!branchesResponse.ok) throw new Error(
|
|
25
|
+
if (!branchesResponse.ok) throw new Error(await describeHttpFailure(branchesResponse, 'GitHub branch lookup'));
|
|
25
26
|
const branches = await branchesResponse.json();
|
|
26
27
|
if (payload.size !== 0 || !Array.isArray(branches) || branches.length !== 0) {
|
|
27
28
|
throw new Error('Existing repository is not empty; explicit non-empty adoption is not implemented');
|
package/src/github-identity.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
1
2
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -19,7 +20,7 @@ export async function resolveGithubLogin({ accessToken, fetchImpl = fetch }) {
|
|
|
19
20
|
'x-github-api-version': GITHUB_API_VERSION
|
|
20
21
|
}
|
|
21
22
|
});
|
|
22
|
-
if (!response.ok) throw new Error(
|
|
23
|
+
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub account lookup'));
|
|
23
24
|
const payload = await response.json();
|
|
24
25
|
const login = payload?.login;
|
|
25
26
|
// The same shape `scaffold` demands of `--owner`. Refusing here beats a confusing failure four
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
1
2
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
2
3
|
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
3
4
|
const SHA = /^[0-9a-f]{40}$/;
|
|
@@ -19,7 +20,7 @@ function headers(accessToken) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
async function json(response, operation) {
|
|
22
|
-
if (!response.ok) throw new Error(`GitHub ${operation}
|
|
23
|
+
if (!response.ok) throw new Error(await describeHttpFailure(response, `GitHub ${operation}`));
|
|
23
24
|
return response.json();
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -86,7 +87,7 @@ export async function provisionGithubPages({
|
|
|
86
87
|
return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
|
|
87
88
|
}
|
|
88
89
|
if (current.status !== 404) {
|
|
89
|
-
throw new Error(
|
|
90
|
+
throw new Error(await describeHttpFailure(current, 'GitHub Pages configuration request'));
|
|
90
91
|
}
|
|
91
92
|
const created = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
92
93
|
method: 'POST', headers: requestHeaders,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
1
2
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
2
3
|
const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
|
|
3
4
|
const VARIABLE_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
@@ -42,7 +43,7 @@ export async function installRepositoryVariable({
|
|
|
42
43
|
});
|
|
43
44
|
if (update.ok) return;
|
|
44
45
|
if (update.status !== 404) {
|
|
45
|
-
throw new Error(
|
|
46
|
+
throw new Error(await describeHttpFailure(update, 'GitHub repository variable update'));
|
|
46
47
|
}
|
|
47
48
|
const create = await fetchImpl(baseUrl, {
|
|
48
49
|
method: 'POST',
|
|
@@ -50,6 +51,6 @@ export async function installRepositoryVariable({
|
|
|
50
51
|
body: JSON.stringify({ name: normalizedName, value })
|
|
51
52
|
});
|
|
52
53
|
if (!create.ok) {
|
|
53
|
-
throw new Error(
|
|
54
|
+
throw new Error(await describeHttpFailure(create, 'GitHub repository variable creation'));
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
3
4
|
|
|
4
5
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
5
6
|
const REPOSITORY_IDENTITY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
@@ -26,7 +27,10 @@ export async function generateRepositoryFromTemplate({
|
|
|
26
27
|
owner,
|
|
27
28
|
repository,
|
|
28
29
|
description,
|
|
29
|
-
fetchImpl = fetch
|
|
30
|
+
fetchImpl = fetch,
|
|
31
|
+
sleep,
|
|
32
|
+
readinessAttempts,
|
|
33
|
+
readinessIntervalMs
|
|
30
34
|
}) {
|
|
31
35
|
const token = requiredString(accessToken, 'accessToken');
|
|
32
36
|
const sourceOwner = repositorySegment(templateOwner, 'templateOwner');
|
|
@@ -57,7 +61,7 @@ export async function generateRepositoryFromTemplate({
|
|
|
57
61
|
}
|
|
58
62
|
);
|
|
59
63
|
if (response.status !== 201) {
|
|
60
|
-
throw new Error(
|
|
64
|
+
throw new Error(await describeHttpFailure(response, 'GitHub template generation'));
|
|
61
65
|
}
|
|
62
66
|
const payload = await response.json();
|
|
63
67
|
if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
|
|
@@ -84,9 +88,53 @@ export async function generateRepositoryFromTemplate({
|
|
|
84
88
|
throw new TypeError('GitHub repository response contains an invalid clone_url');
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
// Last, so a malformed response fails immediately instead of after the readiness wait.
|
|
92
|
+
await awaitRepositoryContent({
|
|
93
|
+
accessToken: token, owner: targetOwner, repository: targetRepository, fetchImpl,
|
|
94
|
+
...(sleep == null ? {} : { sleep }),
|
|
95
|
+
...(readinessAttempts == null ? {} : { attempts: readinessAttempts }),
|
|
96
|
+
...(readinessIntervalMs == null ? {} : { intervalMs: readinessIntervalMs })
|
|
97
|
+
});
|
|
98
|
+
|
|
87
99
|
return Object.freeze({ fullName, cloneUrl: cloneUrl.href });
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Waits for a generated repository to actually contain the template.
|
|
104
|
+
*
|
|
105
|
+
* Generating from a template is asynchronous: GitHub answers 201 with the repository's full name
|
|
106
|
+
* and clone URL, and copies the content in afterwards. Cloning on the 201 produced
|
|
107
|
+
* "warning: You appear to have cloned an empty repository", and scaffolding then failed on a
|
|
108
|
+
* missing site.config.yml — a confusing error about a file the template certainly contains.
|
|
109
|
+
*
|
|
110
|
+
* Readiness is the presence of a branch. `size` is not usable: GitHub still reported 0 for a
|
|
111
|
+
* repository that already had `main` and commits.
|
|
112
|
+
*/
|
|
113
|
+
export async function awaitRepositoryContent({
|
|
114
|
+
accessToken, owner, repository, fetchImpl = fetch,
|
|
115
|
+
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
116
|
+
attempts = 30, intervalMs = 1_000
|
|
117
|
+
}) {
|
|
118
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=1`;
|
|
119
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
120
|
+
if (attempt > 0) await sleep(intervalMs);
|
|
121
|
+
const response = await fetchImpl(url, {
|
|
122
|
+
headers: {
|
|
123
|
+
accept: 'application/vnd.github+json',
|
|
124
|
+
authorization: `Bearer ${accessToken}`,
|
|
125
|
+
'x-github-api-version': GITHUB_API_VERSION
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub branch lookup'));
|
|
129
|
+
const branches = await response.json();
|
|
130
|
+
if (Array.isArray(branches) && branches.length > 0) return;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(
|
|
133
|
+
`GitHub created ${owner}/${repository} from the template but it was still empty after `
|
|
134
|
+
+ `${Math.round((attempts * intervalMs) / 1000)}s. Re-run scaffold with --resume once it has content.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
export function cloneRepository({ cloneUrl, target, spawnProcess = spawn }) {
|
|
91
139
|
const source = new URL(requiredString(cloneUrl, 'cloneUrl'));
|
|
92
140
|
if (
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server actually said, instead of only what it scored.
|
|
3
|
+
*
|
|
4
|
+
* Every failure in this CLI reported `failed with HTTP 403` and discarded the response body. GitHub
|
|
5
|
+
* puts the reason there and nowhere else — an organisation's OAuth App restrictions, a missing
|
|
6
|
+
* scope, a rename, a rate limit all arrive as 403 with a sentence explaining which — so the one
|
|
7
|
+
* fact worth having was the one being thrown away. Diagnosing anything meant guessing between
|
|
8
|
+
* causes the server had already distinguished.
|
|
9
|
+
*
|
|
10
|
+
* Only the response is read. Request bodies, tokens and secrets never pass through here.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_DETAIL = 400;
|
|
13
|
+
|
|
14
|
+
export async function describeHttpFailure(response, action) {
|
|
15
|
+
const status = response?.status ?? 0;
|
|
16
|
+
let detail = '';
|
|
17
|
+
try {
|
|
18
|
+
// Cloned so a caller that also reads the body still can; a response whose body is already
|
|
19
|
+
// consumed simply yields no detail rather than a second failure on top of the first.
|
|
20
|
+
const text = await (typeof response.clone === 'function' ? response.clone() : response).text();
|
|
21
|
+
detail = extract(text);
|
|
22
|
+
} catch {
|
|
23
|
+
detail = '';
|
|
24
|
+
}
|
|
25
|
+
return `${action} failed with HTTP ${status}${detail === '' ? '' : `: ${detail}`}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extract(text) {
|
|
29
|
+
if (typeof text !== 'string' || text.trim() === '') return '';
|
|
30
|
+
let payload;
|
|
31
|
+
try {
|
|
32
|
+
payload = JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
return truncate(text);
|
|
35
|
+
}
|
|
36
|
+
if (payload == null || typeof payload !== 'object') return truncate(text);
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (typeof payload.message === 'string' && payload.message.trim() !== '') parts.push(payload.message.trim());
|
|
39
|
+
// GitHub's `errors` array carries the specific field or reason behind a generic message.
|
|
40
|
+
if (Array.isArray(payload.errors)) {
|
|
41
|
+
for (const error of payload.errors) {
|
|
42
|
+
const reason = typeof error === 'string' ? error : error?.message ?? error?.code;
|
|
43
|
+
if (typeof reason === 'string' && reason.trim() !== '') parts.push(reason.trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (typeof payload.error_description === 'string') parts.push(payload.error_description.trim());
|
|
47
|
+
if (typeof payload.code === 'string' && parts.length === 0) parts.push(payload.code);
|
|
48
|
+
if (typeof payload.documentation_url === 'string') parts.push(`See ${payload.documentation_url}`);
|
|
49
|
+
return truncate(parts.length === 0 ? text : parts.join(' — '));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncate(value) {
|
|
53
|
+
const flattened = value.replace(/\s+/g, ' ').trim();
|
|
54
|
+
return flattened.length > MAX_DETAIL ? `${flattened.slice(0, MAX_DETAIL)}…` : flattened;
|
|
55
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates the publication repository the way the browser editor does.
|
|
3
|
+
*
|
|
4
|
+
* The CLI used to call `POST /repos/{template}/generate` itself. That is a second, worse
|
|
5
|
+
* implementation of something the API already does and does properly:
|
|
6
|
+
*
|
|
7
|
+
* - it tries the template, then falls back to creating an empty repository and seeding it, then
|
|
8
|
+
* reports that the writer must do it by hand — the CLI's single attempt had no rung below it;
|
|
9
|
+
* - it waits for the Gala App installation to actually reach the new repository before calling it
|
|
10
|
+
* ready, which is why repositories the CLI created never appeared in the web UI;
|
|
11
|
+
* - it returns the installation id, so registration cannot disagree with creation about which
|
|
12
|
+
* installation owns the repository.
|
|
13
|
+
*
|
|
14
|
+
* One implementation, exercised by both clients, is the only way the two stay in step.
|
|
15
|
+
*/
|
|
16
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
17
|
+
import { exchangeGithubAuthorization } from './site-registration-client.js';
|
|
18
|
+
|
|
19
|
+
export async function createPublication({
|
|
20
|
+
apiBaseUrl = 'https://api.gala67.com',
|
|
21
|
+
galaAccessToken,
|
|
22
|
+
githubAccessToken,
|
|
23
|
+
name,
|
|
24
|
+
fetchImpl = fetch,
|
|
25
|
+
authorize = exchangeGithubAuthorization
|
|
26
|
+
}) {
|
|
27
|
+
const authorization = await authorize({
|
|
28
|
+
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
29
|
+
});
|
|
30
|
+
const response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}/v1/auth/github/publications`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
accept: 'application/json',
|
|
34
|
+
authorization: `Bearer ${galaAccessToken}`,
|
|
35
|
+
'content-type': 'application/json',
|
|
36
|
+
'GitHub-Authorization': authorization
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ name })
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) throw new Error(await describeHttpFailure(response, 'Gala publication creation'));
|
|
41
|
+
|
|
42
|
+
const payload = await response.json();
|
|
43
|
+
const status = payload?.status;
|
|
44
|
+
const owner = payload?.owner;
|
|
45
|
+
const repository = payload?.name;
|
|
46
|
+
|
|
47
|
+
if (status === 'NEEDS_SHARING') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${owner}/${repository} was created from the template, but the Gala GitHub App cannot reach `
|
|
50
|
+
+ 'it yet. Open https://github.com/settings/installations, give the App access to that '
|
|
51
|
+
+ 'repository, then run scaffold again with --resume.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (status !== 'READY') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Gala could not create the publication repository (${payload?.outcome ?? status}). `
|
|
57
|
+
+ 'Create it from https://github.com/rathnasgala/site-template yourself, then run scaffold '
|
|
58
|
+
+ 'with --empty-existing-repository.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (typeof owner !== 'string' || typeof repository !== 'string') {
|
|
62
|
+
throw new TypeError('Gala publication creation returned no repository identity');
|
|
63
|
+
}
|
|
64
|
+
const installationId = Number(payload?.installationId);
|
|
65
|
+
if (!Number.isSafeInteger(installationId) || installationId <= 0) {
|
|
66
|
+
throw new TypeError('Gala publication creation returned no installation');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
owner,
|
|
71
|
+
repository,
|
|
72
|
+
installationId,
|
|
73
|
+
outcome: payload?.outcome ?? null,
|
|
74
|
+
// The server names the repository it actually made, which may differ from what was asked for.
|
|
75
|
+
fullName: `${owner}/${repository}`,
|
|
76
|
+
cloneUrl: `https://github.com/${owner}/${repository}.git`
|
|
77
|
+
});
|
|
78
|
+
}
|
package/src/scaffold-site.js
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import { configureSite } from './configure-site.js';
|
|
5
5
|
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
6
|
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
-
import {
|
|
7
|
+
import { awaitRepositoryContent, cloneRepository } from './github-template-repository.js';
|
|
8
|
+
import { createPublication } from './publication-creation-client.js';
|
|
8
9
|
import { installRepositoryVariable } from './github-repository-variable.js';
|
|
9
10
|
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
10
11
|
import { registerSite } from './site-registration-client.js';
|
|
@@ -51,7 +52,7 @@ export async function scaffoldSite({
|
|
|
51
52
|
buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
|
|
52
53
|
templateRepository = 'site-template',
|
|
53
54
|
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
54
|
-
|
|
55
|
+
createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
|
|
55
56
|
configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
|
|
56
57
|
writeWorkflow = writePublishWorkflow,
|
|
57
58
|
installVariable = installRepositoryVariable,
|
|
@@ -59,9 +60,11 @@ export async function scaffoldSite({
|
|
|
59
60
|
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
60
61
|
verifyCheckout = verifyRepositoryOrigin
|
|
61
62
|
}) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const requestedOwner = segment(owner, 'owner');
|
|
64
|
+
const requestedName = segment(repository, 'repository');
|
|
65
|
+
// Validated now so a bad --topology/--canonical-base-url combination fails before a repository
|
|
66
|
+
// exists. The value used later is recomputed once the server says who actually owns it.
|
|
67
|
+
registrationLocation(requestedOwner, topology, canonicalBaseUrl);
|
|
65
68
|
// Optional: the server resolves the installation from the owner when none is supplied. An
|
|
66
69
|
// explicit value is still validated, because a wrong one fails much later and less clearly.
|
|
67
70
|
if (githubInstallationId != null
|
|
@@ -72,9 +75,20 @@ export async function scaffoldSite({
|
|
|
72
75
|
throw new TypeError('target must be a non-root local path');
|
|
73
76
|
}
|
|
74
77
|
const [github, gala] = await Promise.all([readGithub(), readGala()]);
|
|
78
|
+
// Creation reports which installation owns the new repository, so registration cannot disagree.
|
|
79
|
+
let resolvedInstallationId = githubInstallationId ?? null;
|
|
75
80
|
if (emptyExistingRepository && resumeExistingCheckout) {
|
|
76
81
|
throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
|
|
77
82
|
}
|
|
83
|
+
/*
|
|
84
|
+
* The server decides the owner, not this process. It creates under the account the Gala App
|
|
85
|
+
* installation belongs to, which is not always the account behind the writer's OAuth token — an
|
|
86
|
+
* installation on an organisation they belong to gives a different owner entirely. Deriving the
|
|
87
|
+
* canonical URL, the idempotency key, the registration and the Pages target from the local guess
|
|
88
|
+
* would register a publication against a repository that does not exist.
|
|
89
|
+
*/
|
|
90
|
+
let repositoryOwner = requestedOwner;
|
|
91
|
+
let repositoryName = requestedName;
|
|
78
92
|
let generated;
|
|
79
93
|
let root;
|
|
80
94
|
if (resumeExistingCheckout) {
|
|
@@ -89,22 +103,36 @@ export async function scaffoldSite({
|
|
|
89
103
|
cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
|
|
90
104
|
};
|
|
91
105
|
} else {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
/*
|
|
107
|
+
* Created through the API, the same call the browser editor makes. Doing it here meant a second
|
|
108
|
+
* implementation with no fallback and no wait for the App installation to reach the result,
|
|
109
|
+
* which is why CLI-created repositories never appeared in the web UI.
|
|
110
|
+
*/
|
|
111
|
+
const created = await createRepository({
|
|
112
|
+
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
113
|
+
githubAccessToken: github.accessToken, name: repositoryName
|
|
114
|
+
});
|
|
115
|
+
repositoryOwner = segment(created.owner, 'owner');
|
|
116
|
+
repositoryName = segment(created.repository, 'repository');
|
|
117
|
+
resolvedInstallationId = created.installationId;
|
|
118
|
+
generated = { fullName: created.fullName, cloneUrl: created.cloneUrl };
|
|
119
|
+
// Creation is asynchronous: GitHub answers before the template content lands, and cloning into
|
|
120
|
+
// that window produces an empty checkout and a missing site.config.yml.
|
|
121
|
+
await awaitContent({
|
|
122
|
+
accessToken: github.accessToken, owner: created.owner, repository: created.repository
|
|
96
123
|
});
|
|
97
124
|
}
|
|
98
125
|
if (!resumeExistingCheckout) {
|
|
99
126
|
root = await clone({ cloneUrl: generated.cloneUrl, target });
|
|
100
127
|
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
101
128
|
}
|
|
129
|
+
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
102
130
|
const configured = await configure(root, siteOptions ?? {});
|
|
103
131
|
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
104
132
|
const registration = await register({
|
|
105
133
|
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
106
134
|
githubAccessToken: github.accessToken, idempotencyKey,
|
|
107
|
-
githubInstallationId, repositoryOwner, repositoryName,
|
|
135
|
+
githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
|
|
108
136
|
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
109
137
|
});
|
|
110
138
|
await finalize(root, {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeHttpFailure } from './http-failure.js';
|
|
1
2
|
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
2
3
|
const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/;
|
|
3
4
|
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
@@ -17,7 +18,14 @@ function apiUrl(apiBaseUrl) {
|
|
|
17
18
|
return new URL('/v1/sites', base).href;
|
|
18
19
|
}
|
|
19
20
|
|
|
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
|
+
}) {
|
|
21
29
|
if (typeof githubAccessToken !== 'string' || githubAccessToken === '') {
|
|
22
30
|
throw new Error('GitHub authentication is missing; run `gala auth`');
|
|
23
31
|
}
|
|
@@ -34,7 +42,7 @@ async function authorizeGitHub({ apiBaseUrl, galaAccessToken, githubAccessToken,
|
|
|
34
42
|
throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
|
|
35
43
|
}
|
|
36
44
|
if (response.status !== 200) {
|
|
37
|
-
throw new Error(
|
|
45
|
+
throw new Error(await describeHttpFailure(response, 'GitHub repository authorization'));
|
|
38
46
|
}
|
|
39
47
|
const payload = await response.json();
|
|
40
48
|
return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
|
|
@@ -55,7 +63,7 @@ export async function registerSite({
|
|
|
55
63
|
if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
|
|
56
64
|
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
57
65
|
}
|
|
58
|
-
const githubAuthorization = await
|
|
66
|
+
const githubAuthorization = await exchangeGithubAuthorization({
|
|
59
67
|
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
60
68
|
});
|
|
61
69
|
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
@@ -103,7 +111,7 @@ export async function registerSite({
|
|
|
103
111
|
}
|
|
104
112
|
throw new Error('Site registration conflicts with existing protected state; use the recovery command');
|
|
105
113
|
}
|
|
106
|
-
if (response.status !== 201) throw new Error(
|
|
114
|
+
if (response.status !== 201) throw new Error(await describeHttpFailure(response, 'Gala site registration'));
|
|
107
115
|
const payload = await response.json();
|
|
108
116
|
if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
|
|
109
117
|
throw new TypeError('Gala site registration response is invalid');
|