@rathnasgala/cli 0.0.16 → 0.0.18
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 +2 -1
- package/src/http-failure.js +55 -0
- package/src/scaffold-preflight.js +8 -55
- package/src/scaffold-site.js +4 -1
- package/src/site-registration-client.js +15 -4
- package/src/gala-installation-client.js +0 -105
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_.-]+$/;
|
|
@@ -57,7 +58,7 @@ export async function generateRepositoryFromTemplate({
|
|
|
57
58
|
}
|
|
58
59
|
);
|
|
59
60
|
if (response.status !== 201) {
|
|
60
|
-
throw new Error(
|
|
61
|
+
throw new Error(await describeHttpFailure(response, 'GitHub template generation'));
|
|
61
62
|
}
|
|
62
63
|
const payload = await response.json();
|
|
63
64
|
if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,6 @@ import { authenticateGithub } from './github-auth-command.js';
|
|
|
5
5
|
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
6
|
import { readGithubCredential } from './github-credential-store.js';
|
|
7
7
|
import { resolveGithubLogin } from './github-identity.js';
|
|
8
|
-
import { resolveInstallationId } from './gala-installation-client.js';
|
|
9
8
|
import { galaCredentialAccepted } from './gala-credential-health.js';
|
|
10
9
|
import { openInBrowser } from './open-browser.js';
|
|
11
10
|
import { forgetGalaCredential } from './gala-credential-store.js';
|
|
@@ -36,8 +35,6 @@ export async function prepareScaffold({
|
|
|
36
35
|
cwd = process.cwd(),
|
|
37
36
|
notify = () => {},
|
|
38
37
|
ask,
|
|
39
|
-
installUrl = GITHUB_APP_INSTALL_URL,
|
|
40
|
-
installAttempts = 3,
|
|
41
38
|
openUrl = openInBrowser,
|
|
42
39
|
readGala = readGalaCredential,
|
|
43
40
|
readGithub = readGithubCredential,
|
|
@@ -46,7 +43,6 @@ export async function prepareScaffold({
|
|
|
46
43
|
signInGala = authenticateGala,
|
|
47
44
|
signInGithub = authenticateGithub,
|
|
48
45
|
resolveLogin = resolveGithubLogin,
|
|
49
|
-
resolveInstallation = resolveInstallationId,
|
|
50
46
|
apiBaseUrl = DEFAULT_API_BASE_URL
|
|
51
47
|
} = {}) {
|
|
52
48
|
const gala = await ensureGala({
|
|
@@ -65,20 +61,18 @@ export async function prepareScaffold({
|
|
|
65
61
|
// directory the writer is standing in. Everywhere else the repository names its own folder.
|
|
66
62
|
const resolvedTarget = target ?? `./${resolvedRepository}`;
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
-
|
|
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
|
+
*/
|
|
77
71
|
return Object.freeze({
|
|
78
72
|
owner: resolvedOwner,
|
|
79
73
|
repository: resolvedRepository,
|
|
80
74
|
target: resolvedTarget,
|
|
81
|
-
githubInstallationId:
|
|
75
|
+
githubInstallationId: githubInstallationId ?? null
|
|
82
76
|
});
|
|
83
77
|
}
|
|
84
78
|
|
|
@@ -133,47 +127,6 @@ async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
|
|
|
133
127
|
}
|
|
134
128
|
}
|
|
135
129
|
|
|
136
|
-
/**
|
|
137
|
-
* Confirms the Gala GitHub App is installed, walking the writer through installing it if not.
|
|
138
|
-
*
|
|
139
|
-
* This is the step that used to be a manual detour through GitHub's settings to copy a number out
|
|
140
|
-
* of a redirect URL. The loop is what makes it a step rather than a failure: the writer installs
|
|
141
|
-
* the App in the browser, comes back, presses enter, and the run continues.
|
|
142
|
-
*/
|
|
143
|
-
async function ensureInstallation({
|
|
144
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, owner,
|
|
145
|
-
notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
|
|
146
|
-
}) {
|
|
147
|
-
for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
|
|
148
|
-
const installationId = await resolveInstallation({
|
|
149
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, owner
|
|
150
|
-
});
|
|
151
|
-
if (installationId != null) return installationId;
|
|
152
|
-
|
|
153
|
-
if (typeof ask !== 'function') {
|
|
154
|
-
throw new Error(
|
|
155
|
-
`The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run `
|
|
156
|
-
+ 'scaffold again, or pass --installation-id explicitly.'
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// An installation belongs to one account. Installing it on a personal account when the
|
|
161
|
-
// publication is meant for an organisation looks like it worked and changes nothing here, so
|
|
162
|
-
// the account being checked is named every time rather than assumed.
|
|
163
|
-
notify(attempt === 0
|
|
164
|
-
? `The Gala GitHub App is not installed on ${owner} yet.`
|
|
165
|
-
: `Still not seeing the App on ${owner}. Check that you installed it on ${owner} itself `
|
|
166
|
-
+ 'and not on another account or organisation you belong to.');
|
|
167
|
-
notify(`${openUrl(installUrl) ? 'Opened' : 'Open'} ${installUrl}`);
|
|
168
|
-
await ask('Press enter once the App is installed. ');
|
|
169
|
-
}
|
|
170
|
-
throw new Error(
|
|
171
|
-
`The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl} for ${owner} `
|
|
172
|
-
+ 'specifically, then run scaffold again. If the App is installed under a different account, '
|
|
173
|
-
+ 'pass --owner for that account, or --installation-id to name the installation directly.'
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
130
|
/** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
|
|
178
131
|
export function repositoryNameFrom(siteName) {
|
|
179
132
|
if (typeof siteName !== 'string') return null;
|
package/src/scaffold-site.js
CHANGED
|
@@ -62,7 +62,10 @@ export async function scaffoldSite({
|
|
|
62
62
|
const repositoryOwner = segment(owner, 'owner');
|
|
63
63
|
const repositoryName = segment(repository, 'repository');
|
|
64
64
|
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
65
|
-
|
|
65
|
+
// Optional: the server resolves the installation from the owner when none is supplied. An
|
|
66
|
+
// explicit value is still validated, because a wrong one fails much later and less clearly.
|
|
67
|
+
if (githubInstallationId != null
|
|
68
|
+
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
66
69
|
throw new TypeError('githubInstallationId must be a positive integer');
|
|
67
70
|
}
|
|
68
71
|
if (target == null || path.resolve(target) === path.parse(path.resolve(target)).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}$/;
|
|
@@ -34,7 +35,7 @@ async function authorizeGitHub({ apiBaseUrl, galaAccessToken, githubAccessToken,
|
|
|
34
35
|
throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
|
|
35
36
|
}
|
|
36
37
|
if (response.status !== 200) {
|
|
37
|
-
throw new Error(
|
|
38
|
+
throw new Error(await describeHttpFailure(response, 'GitHub repository authorization'));
|
|
38
39
|
}
|
|
39
40
|
const payload = await response.json();
|
|
40
41
|
return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
|
|
@@ -61,7 +62,8 @@ export async function registerSite({
|
|
|
61
62
|
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
62
63
|
required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
|
|
63
64
|
required(repositoryName, 'repositoryName', REPOSITORY_PART);
|
|
64
|
-
if (
|
|
65
|
+
if (githubInstallationId != null
|
|
66
|
+
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
65
67
|
throw new TypeError('githubInstallationId must be a positive integer');
|
|
66
68
|
}
|
|
67
69
|
if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
|
|
@@ -77,7 +79,8 @@ export async function registerSite({
|
|
|
77
79
|
'idempotency-key': idempotencyKey
|
|
78
80
|
},
|
|
79
81
|
body: JSON.stringify({
|
|
80
|
-
|
|
82
|
+
// Omitted rather than sent as null: the server resolves it from the owner.
|
|
83
|
+
...(githubInstallationId == null ? {} : { githubInstallationId }),
|
|
81
84
|
repositoryOwner,
|
|
82
85
|
repositoryName,
|
|
83
86
|
topology,
|
|
@@ -91,9 +94,17 @@ export async function registerSite({
|
|
|
91
94
|
throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
|
|
92
95
|
}
|
|
93
96
|
if (response.status === 409) {
|
|
97
|
+
const failure = await response.clone().json().catch(() => null);
|
|
98
|
+
if (failure?.code === 'GITHUB_APP_NOT_INSTALLED') {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`The Gala GitHub App is not installed on ${repositoryOwner}. Install it at `
|
|
101
|
+
+ 'https://github.com/apps/gala67-app/installations/new for that account, then run scaffold '
|
|
102
|
+
+ 'again.'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
94
105
|
throw new Error('Site registration conflicts with existing protected state; use the recovery command');
|
|
95
106
|
}
|
|
96
|
-
if (response.status !== 201) throw new Error(
|
|
107
|
+
if (response.status !== 201) throw new Error(await describeHttpFailure(response, 'Gala site registration'));
|
|
97
108
|
const payload = await response.json();
|
|
98
109
|
if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
|
|
99
110
|
throw new TypeError('Gala site registration response is invalid');
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which Gala GitHub App installation covers this writer's account.
|
|
3
|
-
*
|
|
4
|
-
* The installation ID is an internal GitHub identifier that `scaffold` has to send when it
|
|
5
|
-
* registers a site. Until now the writer supplied it by installing the App, watching GitHub
|
|
6
|
-
* redirect to `https://github.com/settings/installations/153144989`, and copying the number out of
|
|
7
|
-
* the address bar — an internal identifier, read out of a URL, by hand.
|
|
8
|
-
*
|
|
9
|
-
* The Gala API already knows it. `GET /v1/auth/github/repositories` answers with
|
|
10
|
-
* `{ installationId, owner, name, status }` per repository, so the CLI can ask the same service it
|
|
11
|
-
* is about to register with rather than guess. Reaching that endpoint needs the bounded capability
|
|
12
|
-
* from `POST /v1/auth/github/device-authorizations`, which is bound to the Gala user and takes the
|
|
13
|
-
* GitHub token the CLI already holds.
|
|
14
|
-
*/
|
|
15
|
-
function endpoint(apiBaseUrl, path) {
|
|
16
|
-
return `${String(apiBaseUrl).replace(/\/$/, '')}${path}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function exchangeGithubAuthorization({
|
|
20
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl = fetch
|
|
21
|
-
}) {
|
|
22
|
-
const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/device-authorizations'), {
|
|
23
|
-
method: 'POST',
|
|
24
|
-
headers: {
|
|
25
|
-
accept: 'application/json',
|
|
26
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
27
|
-
'content-type': 'application/json'
|
|
28
|
-
},
|
|
29
|
-
body: JSON.stringify({ accessToken: githubAccessToken })
|
|
30
|
-
});
|
|
31
|
-
if (response.status === 409) {
|
|
32
|
-
// The API distinguishes "the App is not installed on this account" from "your credential is
|
|
33
|
-
// finished". Only the first is something the writer can fix in a browser, so it is signalled
|
|
34
|
-
// rather than thrown: the caller offers the installation page and waits.
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
if (response.status === 401) {
|
|
38
|
-
// Either credential can be the one at fault and the caller cannot tell them apart, so say so
|
|
39
|
-
// rather than printing a status code the writer has no way to interpret.
|
|
40
|
-
throw new Error(
|
|
41
|
-
'Gala refused the GitHub authorization. Run `npx --yes @rathnasgala/cli@latest auth` and '
|
|
42
|
-
+ '`auth github` again, then retry.'
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
|
|
47
|
-
}
|
|
48
|
-
const payload = await response.json();
|
|
49
|
-
if (typeof payload?.authorization !== 'string') {
|
|
50
|
-
throw new TypeError('GitHub authorization exchange returned no capability');
|
|
51
|
-
}
|
|
52
|
-
return payload.authorization;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Both credentials are required, and they are not interchangeable.
|
|
57
|
-
*
|
|
58
|
-
* The endpoint is `.authenticated()` and its handler takes the Gala principal, so the bearer says
|
|
59
|
-
* who is asking; `GitHub-Authorization` is the short-lived capability that says what they may see.
|
|
60
|
-
* Sending only the capability gets a bare 401 from the security filter, before any handler runs —
|
|
61
|
-
* which reads as "your GitHub authorization was refused" and is nothing of the kind.
|
|
62
|
-
*/
|
|
63
|
-
export async function listAuthorizedRepositories({
|
|
64
|
-
apiBaseUrl, authorization, galaAccessToken, fetchImpl = fetch
|
|
65
|
-
}) {
|
|
66
|
-
const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
|
|
67
|
-
headers: {
|
|
68
|
-
accept: 'application/json',
|
|
69
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
70
|
-
'GitHub-Authorization': authorization
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
if (!response.ok) {
|
|
74
|
-
throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
|
|
75
|
-
}
|
|
76
|
-
const payload = await response.json();
|
|
77
|
-
if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
|
|
78
|
-
return payload;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Returns the installation covering `owner`, or null when the App is not installed there.
|
|
83
|
-
*
|
|
84
|
-
* An installation belongs to an account, not to one repository, so any repository the App can
|
|
85
|
-
* already see under that owner carries the id the new one will use. Null is an ordinary answer —
|
|
86
|
-
* it means "not installed yet" — and the caller turns it into an instruction, not an error.
|
|
87
|
-
*/
|
|
88
|
-
export async function resolveInstallationId({
|
|
89
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
|
|
90
|
-
exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
|
|
91
|
-
}) {
|
|
92
|
-
const authorization = await exchange({
|
|
93
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
94
|
-
});
|
|
95
|
-
// null means the App is not installed yet, which the caller turns into an instruction.
|
|
96
|
-
if (authorization == null) return null;
|
|
97
|
-
const repositories = await list({ apiBaseUrl, authorization, galaAccessToken, fetchImpl });
|
|
98
|
-
const wanted = String(owner).toLowerCase();
|
|
99
|
-
for (const repository of repositories) {
|
|
100
|
-
if (String(repository?.owner).toLowerCase() !== wanted) continue;
|
|
101
|
-
const installationId = Number(repository?.installationId);
|
|
102
|
-
if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
|
|
103
|
-
}
|
|
104
|
-
return null;
|
|
105
|
-
}
|