@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.
- package/README.md +100 -187
- package/package.json +3 -3
- package/src/api/gala.js +163 -0
- package/src/api/github.js +63 -0
- package/src/api/http.js +72 -0
- package/src/auth/gala.js +52 -0
- package/src/auth/github.js +78 -0
- package/src/auth/store.js +88 -0
- package/src/cli/args.js +56 -0
- package/src/cli/terminal.js +104 -0
- package/src/commands/auth.js +20 -0
- package/src/commands/doctor.js +98 -0
- package/src/commands/domain.js +124 -0
- package/src/commands/init.js +276 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/prism.js +360 -0
- package/src/commands/publish.js +57 -0
- package/src/commands/upgrade.js +173 -0
- package/src/commands-manifest.js +80 -0
- package/src/content.js +31 -0
- package/src/domain.js +33 -0
- package/src/git.js +160 -0
- package/src/index.js +44 -294
- package/src/publication.js +39 -0
- package/src/assign-content-ids.js +0 -1
- package/src/auth-command.js +0 -36
- package/src/configure-site.js +0 -102
- package/src/content-files.js +0 -1
- package/src/doctor-command.js +0 -214
- package/src/entitlement-client.js +0 -26
- package/src/entitlement-command.js +0 -74
- package/src/evaluation-date.js +0 -1
- package/src/gala-credential-health.js +0 -34
- package/src/gala-credential-store.js +0 -115
- package/src/gala-device-flow.js +0 -121
- package/src/git-credentials.js +0 -37
- package/src/github-auth-command.js +0 -50
- package/src/github-credential-store.js +0 -104
- package/src/github-device-flow.js +0 -153
- package/src/github-empty-repository.js +0 -89
- package/src/github-identity.js +0 -32
- package/src/github-pages-provisioning.js +0 -107
- package/src/github-repository-secret.js +0 -82
- package/src/github-repository-variable.js +0 -56
- package/src/github-template-repository.js +0 -171
- package/src/hook-command.js +0 -64
- package/src/http-failure.js +0 -55
- package/src/new-command.js +0 -54
- package/src/open-browser.js +0 -40
- package/src/preview-command.js +0 -60
- package/src/publication-creation-client.js +0 -155
- package/src/publication-state.js +0 -7
- package/src/publish-command.js +0 -37
- package/src/record-deployment-command.js +0 -147
- package/src/refresh-command.js +0 -104
- package/src/repository-limits.js +0 -94
- package/src/scaffold-git.js +0 -76
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -146
- package/src/scaffold-site.js +0 -185
- package/src/site-config-registration.js +0 -47
- package/src/site-registration-client.js +0 -138
- package/src/theme-package.js +0 -128
- package/src/topology-client.js +0 -43
- package/src/topology-command.js +0 -70
- package/src/upgrade-command.js +0 -81
- package/src/validate-command.js +0 -5
- package/src/workflow-command.js +0 -87
package/src/gala-device-flow.js
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
2
|
-
|
|
3
|
-
function requiredString(value, field) {
|
|
4
|
-
if (typeof value !== 'string' || value.trim() === '') throw new TypeError(`${field} is required`);
|
|
5
|
-
return value.trim();
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function positiveInteger(value, field) {
|
|
9
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${field} must be positive`);
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function apiUrl(apiBaseUrl, path) {
|
|
14
|
-
const base = new URL(requiredString(apiBaseUrl, 'apiBaseUrl'));
|
|
15
|
-
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
16
|
-
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
17
|
-
|| base.username || base.password || base.search || base.hash) {
|
|
18
|
-
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
19
|
-
}
|
|
20
|
-
return new URL(path, base).href;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function postForm(fetchImpl, url, fields) {
|
|
24
|
-
const response = await fetchImpl(url, {
|
|
25
|
-
method: 'POST',
|
|
26
|
-
headers: {
|
|
27
|
-
accept: 'application/json',
|
|
28
|
-
'content-type': 'application/x-www-form-urlencoded'
|
|
29
|
-
},
|
|
30
|
-
body: new URLSearchParams(fields)
|
|
31
|
-
});
|
|
32
|
-
let payload;
|
|
33
|
-
try {
|
|
34
|
-
payload = await response.json();
|
|
35
|
-
} catch {
|
|
36
|
-
const status = Number.isInteger(response?.status) ? ` (HTTP ${response.status})` : '';
|
|
37
|
-
throw new TypeError(`Gala device authorization returned invalid JSON${status}`);
|
|
38
|
-
}
|
|
39
|
-
if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
|
|
40
|
-
throw new TypeError('Gala device authorization response must be a JSON object');
|
|
41
|
-
}
|
|
42
|
-
return { response, payload };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function requestGalaDeviceCode({
|
|
46
|
-
apiBaseUrl = 'https://api.gala67.com',
|
|
47
|
-
clientId = 'gala-cli',
|
|
48
|
-
fetchImpl = fetch
|
|
49
|
-
} = {}) {
|
|
50
|
-
const { response, payload } = await postForm(
|
|
51
|
-
fetchImpl,
|
|
52
|
-
apiUrl(apiBaseUrl, '/v1/auth/device/code'),
|
|
53
|
-
{ client_id: requiredString(clientId, 'clientId') }
|
|
54
|
-
);
|
|
55
|
-
if (!response.ok) throw new Error(`Gala device authorization failed with HTTP ${response.status}`);
|
|
56
|
-
return Object.freeze({
|
|
57
|
-
deviceCode: requiredString(payload.device_code, 'device_code'),
|
|
58
|
-
userCode: requiredString(payload.user_code, 'user_code'),
|
|
59
|
-
verificationUri: requiredString(payload.verification_uri, 'verification_uri'),
|
|
60
|
-
verificationUriComplete: requiredString(
|
|
61
|
-
payload.verification_uri_complete,
|
|
62
|
-
'verification_uri_complete'
|
|
63
|
-
),
|
|
64
|
-
expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in'),
|
|
65
|
-
intervalSeconds: positiveInteger(payload.interval ?? 5, 'interval')
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function pollForGalaToken({
|
|
70
|
-
deviceCode,
|
|
71
|
-
expiresInSeconds,
|
|
72
|
-
intervalSeconds,
|
|
73
|
-
apiBaseUrl = 'https://api.gala67.com',
|
|
74
|
-
clientId = 'gala-cli',
|
|
75
|
-
fetchImpl = fetch,
|
|
76
|
-
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
77
|
-
now = Date.now
|
|
78
|
-
}) {
|
|
79
|
-
const code = requiredString(deviceCode, 'deviceCode');
|
|
80
|
-
const lifetime = positiveInteger(expiresInSeconds, 'expiresInSeconds') * 1000;
|
|
81
|
-
let interval = positiveInteger(intervalSeconds, 'intervalSeconds');
|
|
82
|
-
const startedAt = now();
|
|
83
|
-
if (!Number.isFinite(startedAt)) throw new TypeError('Clock must return epoch milliseconds');
|
|
84
|
-
|
|
85
|
-
while (true) {
|
|
86
|
-
await sleep(interval * 1000);
|
|
87
|
-
const currentTime = now();
|
|
88
|
-
if (!Number.isFinite(currentTime)) throw new TypeError('Clock must return epoch milliseconds');
|
|
89
|
-
if (currentTime - startedAt >= lifetime) {
|
|
90
|
-
throw new Error('Gala device authorization expired; run `gala auth` again');
|
|
91
|
-
}
|
|
92
|
-
const { response, payload } = await postForm(
|
|
93
|
-
fetchImpl,
|
|
94
|
-
apiUrl(apiBaseUrl, '/v1/auth/device/token'),
|
|
95
|
-
{
|
|
96
|
-
grant_type: DEVICE_GRANT,
|
|
97
|
-
device_code: code,
|
|
98
|
-
client_id: requiredString(clientId, 'clientId')
|
|
99
|
-
}
|
|
100
|
-
);
|
|
101
|
-
if (response.ok) {
|
|
102
|
-
if (requiredString(payload.token_type, 'token_type').toLowerCase() !== 'bearer') {
|
|
103
|
-
throw new TypeError('Gala token_type must be bearer');
|
|
104
|
-
}
|
|
105
|
-
return Object.freeze({
|
|
106
|
-
accessToken: requiredString(payload.access_token, 'access_token'),
|
|
107
|
-
expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in')
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
if (payload.error === 'authorization_pending') continue;
|
|
111
|
-
if (payload.error === 'slow_down') {
|
|
112
|
-
interval += 5;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (payload.error === 'expired_token') {
|
|
116
|
-
throw new Error('Gala device authorization expired; run `gala auth` again');
|
|
117
|
-
}
|
|
118
|
-
if (payload.error === 'access_denied') throw new Error('Gala device authorization was denied');
|
|
119
|
-
throw new Error(`Gala device authorization failed: ${requiredString(payload.error, 'error')}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
package/src/git-credentials.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git operations authenticated as the writer's Gala credential, not as the machine.
|
|
3
|
-
*
|
|
4
|
-
* Every git call used to fall through to whatever credential helper the machine had configured,
|
|
5
|
-
* which is a different identity from the one the CLI just authenticated with. On this machine the
|
|
6
|
-
* token belonged to `rfai8me` and git's stored credential to `anandrathnas`, so a scaffold created
|
|
7
|
-
* the repository through the API and was then refused its own push:
|
|
8
|
-
*
|
|
9
|
-
* remote: Permission to rfai8me/pub-231254.git denied to anandrathnas
|
|
10
|
-
*
|
|
11
|
-
* The OAuth App hid this because both identities were usually the same person. A writer with none
|
|
12
|
-
* configured at all — a fresh machine, or someone who only uses SSH — had no chance.
|
|
13
|
-
*
|
|
14
|
-
* The token is passed through the environment rather than the argument list, because arguments are
|
|
15
|
-
* visible to every process on the machine via `ps`, and written nowhere: an ephemeral `-c` helper
|
|
16
|
-
* leaves no trace in `.git/config`.
|
|
17
|
-
*/
|
|
18
|
-
export const GIT_TOKEN_VARIABLE = 'GALA_GIT_TOKEN';
|
|
19
|
-
|
|
20
|
-
/** Clears inherited helpers first, or the machine's keychain answers before ours does. */
|
|
21
|
-
export function gitCredentialArguments(accessToken) {
|
|
22
|
-
if (typeof accessToken !== 'string' || accessToken === '') return [];
|
|
23
|
-
return [
|
|
24
|
-
'-c', 'credential.helper=',
|
|
25
|
-
'-c', `credential.helper=!f() { test "$1" = get && echo username=x-access-token && echo "password=$${GIT_TOKEN_VARIABLE}"; }; f`
|
|
26
|
-
];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function gitEnvironment(accessToken, environment = process.env) {
|
|
30
|
-
if (typeof accessToken !== 'string' || accessToken === '') return environment;
|
|
31
|
-
return {
|
|
32
|
-
...environment,
|
|
33
|
-
[GIT_TOKEN_VARIABLE]: accessToken,
|
|
34
|
-
// Nothing on this path may block waiting for a username at a terminal.
|
|
35
|
-
GIT_TERMINAL_PROMPT: '0'
|
|
36
|
-
};
|
|
37
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { pollForAccessToken, requestDeviceCode } from './github-device-flow.js';
|
|
2
|
-
import { writeGithubCredential } from './github-credential-store.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The Gala GitHub App, not an OAuth App.
|
|
6
|
-
*
|
|
7
|
-
* The CLI used to authenticate as a separate OAuth App (`Ov23ligTfectgl2FHJ6c`) while the browser
|
|
8
|
-
* editor used the GitHub App. They are two different identity systems, and every difference fell on
|
|
9
|
-
* the CLI: an OAuth token cannot list App installations, is blocked by organisation OAuth App
|
|
10
|
-
* restrictions, and inherits none of the App's repository grants. The editor hit none of that.
|
|
11
|
-
*
|
|
12
|
-
* The one thing that forced an OAuth App — the CLI creating repositories before any installation
|
|
13
|
-
* covered them — no longer applies: creation moved to the API. So both clients are now the same
|
|
14
|
-
* GitHub App, and the differences disappear rather than being worked around.
|
|
15
|
-
*
|
|
16
|
-
* Client IDs are public; this is the value `GET /apps/gala67-app` publishes.
|
|
17
|
-
*/
|
|
18
|
-
export const GITHUB_APP_CLIENT_ID = 'Iv23liIg7Hi1lMesiaon';
|
|
19
|
-
|
|
20
|
-
export async function authenticateGithub({
|
|
21
|
-
clientId = GITHUB_APP_CLIENT_ID, fetchImpl = fetch, sleep, now = Date.now,
|
|
22
|
-
showInstructions, credentialTarget
|
|
23
|
-
} = {}) {
|
|
24
|
-
if (typeof showInstructions !== 'function') {
|
|
25
|
-
throw new TypeError('device instructions are required');
|
|
26
|
-
}
|
|
27
|
-
// No scopes: a GitHub App's permissions are fixed on the app and granted at installation, so
|
|
28
|
-
// there is nothing to negotiate and nothing to warn about. The broad `repo` scope the OAuth App
|
|
29
|
-
// had to request — read/write on every repository the writer could reach — is gone with it.
|
|
30
|
-
const authorization = await requestDeviceCode({ clientId, fetchImpl });
|
|
31
|
-
showInstructions(authorization);
|
|
32
|
-
const token = await pollForAccessToken({
|
|
33
|
-
...authorization, clientId, fetchImpl,
|
|
34
|
-
...(sleep == null ? {} : { sleep }), now
|
|
35
|
-
});
|
|
36
|
-
/*
|
|
37
|
-
* The app expires user tokens after eight hours and issues a refresh token with each one.
|
|
38
|
-
* Exchanging that refresh token requires the app's client secret, which a published CLI cannot
|
|
39
|
-
* hold — so it is stored for the API-side refresh that will do the exchange, and until that
|
|
40
|
-
* exists an expired credential asks for one sign-in rather than failing somewhere further down
|
|
41
|
-
* as an unexplained 401.
|
|
42
|
-
*/
|
|
43
|
-
const target = await writeGithubCredential({
|
|
44
|
-
accessToken: token.accessToken,
|
|
45
|
-
...(token.expiresAt == null ? {} : { expiresAt: token.expiresAt }),
|
|
46
|
-
...(token.refreshToken == null ? {} : { refreshToken: token.refreshToken }),
|
|
47
|
-
...(credentialTarget == null ? {} : { target: credentialTarget })
|
|
48
|
-
});
|
|
49
|
-
return Object.freeze({ target, expiresAt: token.expiresAt ?? null });
|
|
50
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
export function githubCredentialPath({ platform = process.platform, environment = process.env, home = os.homedir() } = {}) {
|
|
6
|
-
if (platform === 'win32') {
|
|
7
|
-
if (!environment.APPDATA) throw new Error('APPDATA is required to store GitHub credentials on Windows');
|
|
8
|
-
return path.join(environment.APPDATA, 'Gala', 'github-credentials.json');
|
|
9
|
-
}
|
|
10
|
-
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Gala', 'github-credentials.json');
|
|
11
|
-
return path.join(environment.XDG_CONFIG_HOME || path.join(home, '.config'), 'gala', 'github-credentials.json');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function regularOrMissing(target) {
|
|
15
|
-
try {
|
|
16
|
-
const metadata = await lstat(target);
|
|
17
|
-
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new TypeError('GitHub credential must be a regular file');
|
|
18
|
-
return true;
|
|
19
|
-
} catch (error) {
|
|
20
|
-
if (error.code === 'ENOENT') return false;
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Schema 2 stores a GitHub App user token, which has no scopes.
|
|
27
|
-
*
|
|
28
|
-
* Schema 1 held an OAuth App token and recorded the `repo` and `workflow` scopes it had negotiated.
|
|
29
|
-
* A GitHub App has neither: its permissions are fixed on the app and granted at installation. The
|
|
30
|
-
* version bump is what makes the difference visible — a schema-1 file is rejected on read, so a
|
|
31
|
-
* writer carrying an OAuth token is sent through `auth github` once rather than presenting a
|
|
32
|
-
* credential the API will refuse in a less obvious way later.
|
|
33
|
-
*/
|
|
34
|
-
export async function writeGithubCredential({
|
|
35
|
-
accessToken, expiresAt, refreshToken, target = githubCredentialPath()
|
|
36
|
-
}) {
|
|
37
|
-
if (typeof accessToken !== 'string' || accessToken === '') throw new TypeError('accessToken is required');
|
|
38
|
-
if (expiresAt != null && Number.isNaN(new Date(expiresAt).getTime())) {
|
|
39
|
-
throw new TypeError('expiresAt must be a date');
|
|
40
|
-
}
|
|
41
|
-
const directory = path.dirname(path.resolve(target));
|
|
42
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
43
|
-
const directoryMetadata = await lstat(directory);
|
|
44
|
-
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
45
|
-
throw new TypeError('GitHub credential directory must be a real directory');
|
|
46
|
-
}
|
|
47
|
-
await chmod(directory, 0o700);
|
|
48
|
-
const exists = await regularOrMissing(target);
|
|
49
|
-
const temporary = `${target}.gala-${process.pid}`;
|
|
50
|
-
const backup = `${target}.gala-backup-${process.pid}`;
|
|
51
|
-
try {
|
|
52
|
-
const record = {
|
|
53
|
-
schemaVersion: 2,
|
|
54
|
-
accessToken,
|
|
55
|
-
...(expiresAt == null ? {} : { expiresAt: new Date(expiresAt).toISOString() }),
|
|
56
|
-
...(refreshToken == null ? {} : { refreshToken })
|
|
57
|
-
};
|
|
58
|
-
await writeFile(temporary, `${JSON.stringify(record)}\n`, {
|
|
59
|
-
flag: 'wx', mode: 0o600
|
|
60
|
-
});
|
|
61
|
-
await chmod(temporary, 0o600);
|
|
62
|
-
if (exists) await rename(target, backup);
|
|
63
|
-
try { await rename(temporary, target); }
|
|
64
|
-
catch (error) { if (exists) await rename(backup, target); throw error; }
|
|
65
|
-
await chmod(target, 0o600);
|
|
66
|
-
if (exists) await rm(backup);
|
|
67
|
-
return path.resolve(target);
|
|
68
|
-
} catch (error) {
|
|
69
|
-
await rm(temporary, { force: true });
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function readGithubCredential({ target = githubCredentialPath(), now = new Date() } = {}) {
|
|
75
|
-
if (!await regularOrMissing(target)) throw new Error('GitHub authentication is missing; run `gala auth github`');
|
|
76
|
-
const payload = JSON.parse(await readFile(target, 'utf8'));
|
|
77
|
-
if (payload?.schemaVersion === 1) {
|
|
78
|
-
// An OAuth App token. It cannot list installations and organisations may refuse it outright, so
|
|
79
|
-
// it is not usable — and saying that here beats a confusing 403 four calls later.
|
|
80
|
-
throw new Error('GitHub authentication is out of date; run `gala auth github` again');
|
|
81
|
-
}
|
|
82
|
-
if (payload?.schemaVersion !== 2 || typeof payload.accessToken !== 'string'
|
|
83
|
-
|| payload.accessToken === '') {
|
|
84
|
-
throw new TypeError('GitHub credential file has an unsupported schema');
|
|
85
|
-
}
|
|
86
|
-
/*
|
|
87
|
-
* The app expires user tokens after eight hours. Refreshing one needs the app's client secret,
|
|
88
|
-
* which lives on the server, so until that exchange exists the honest answer is to ask for a
|
|
89
|
-
* sign-in here — rather than hand out a token that fails as a 401 several calls deeper, which is
|
|
90
|
-
* exactly how the legacy Gala credential wasted a week.
|
|
91
|
-
*/
|
|
92
|
-
if (typeof payload.expiresAt === 'string') {
|
|
93
|
-
const expiresAt = new Date(payload.expiresAt);
|
|
94
|
-
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now) {
|
|
95
|
-
throw new Error('GitHub authentication expired; run `gala auth github` again');
|
|
96
|
-
}
|
|
97
|
-
return Object.freeze({
|
|
98
|
-
accessToken: payload.accessToken,
|
|
99
|
-
expiresAt,
|
|
100
|
-
...(typeof payload.refreshToken === 'string' ? { refreshToken: payload.refreshToken } : {})
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return Object.freeze({ accessToken: payload.accessToken });
|
|
104
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
2
|
-
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
3
|
-
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
4
|
-
|
|
5
|
-
function requiredString(value, field) {
|
|
6
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
7
|
-
throw new TypeError(`${field} is required`);
|
|
8
|
-
}
|
|
9
|
-
return value.trim();
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function positiveInteger(value, field) {
|
|
13
|
-
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
14
|
-
throw new TypeError(`${field} must be a positive integer`);
|
|
15
|
-
}
|
|
16
|
-
return value;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function postForm(fetchImpl, url, fields) {
|
|
20
|
-
const response = await fetchImpl(url, {
|
|
21
|
-
method: 'POST',
|
|
22
|
-
headers: {
|
|
23
|
-
accept: 'application/json',
|
|
24
|
-
'content-type': 'application/x-www-form-urlencoded'
|
|
25
|
-
},
|
|
26
|
-
body: new URLSearchParams(fields)
|
|
27
|
-
});
|
|
28
|
-
if (!response.ok) {
|
|
29
|
-
throw new Error(`GitHub OAuth request failed with HTTP ${response.status}`);
|
|
30
|
-
}
|
|
31
|
-
const payload = await response.json();
|
|
32
|
-
if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
|
|
33
|
-
throw new TypeError('GitHub OAuth response must be a JSON object');
|
|
34
|
-
}
|
|
35
|
-
return payload;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* `scopes` is optional and must be omitted for a GitHub App.
|
|
40
|
-
*
|
|
41
|
-
* OAuth Apps negotiate scopes per authorization; GitHub Apps do not — their permissions are fixed
|
|
42
|
-
* on the app and granted at installation. Sending a `scope` parameter to an App's device flow asks
|
|
43
|
-
* for something the grant cannot express.
|
|
44
|
-
*/
|
|
45
|
-
export async function requestDeviceCode({ clientId, scopes, fetchImpl = fetch }) {
|
|
46
|
-
const normalizedClientId = requiredString(clientId, 'clientId');
|
|
47
|
-
if (scopes != null && (!Array.isArray(scopes) || scopes.length === 0)) {
|
|
48
|
-
throw new TypeError('scopes must be a non-empty list when supplied');
|
|
49
|
-
}
|
|
50
|
-
const normalizedScopes = scopes == null
|
|
51
|
-
? null
|
|
52
|
-
: scopes.map((scope) => requiredString(scope, 'scope'));
|
|
53
|
-
const payload = await postForm(fetchImpl, DEVICE_CODE_URL, {
|
|
54
|
-
client_id: normalizedClientId,
|
|
55
|
-
...(normalizedScopes == null ? {} : { scope: normalizedScopes.join(' ') })
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
return Object.freeze({
|
|
59
|
-
deviceCode: requiredString(payload.device_code, 'device_code'),
|
|
60
|
-
userCode: requiredString(payload.user_code, 'user_code'),
|
|
61
|
-
verificationUri: requiredString(payload.verification_uri, 'verification_uri'),
|
|
62
|
-
expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in'),
|
|
63
|
-
intervalSeconds: positiveInteger(payload.interval, 'interval')
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function pollForAccessToken({
|
|
68
|
-
clientId,
|
|
69
|
-
deviceCode,
|
|
70
|
-
expiresInSeconds,
|
|
71
|
-
intervalSeconds,
|
|
72
|
-
requiredScopes = [],
|
|
73
|
-
fetchImpl = fetch,
|
|
74
|
-
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
75
|
-
now = Date.now
|
|
76
|
-
}) {
|
|
77
|
-
const normalizedClientId = requiredString(clientId, 'clientId');
|
|
78
|
-
const normalizedDeviceCode = requiredString(deviceCode, 'deviceCode');
|
|
79
|
-
if (!Array.isArray(requiredScopes)) throw new TypeError('requiredScopes must be a list');
|
|
80
|
-
// A GitHub App answers with no `scope` field at all; there is nothing to require of it.
|
|
81
|
-
const normalizedRequiredScopes = (requiredScopes ?? []).map((scope) =>
|
|
82
|
-
requiredString(scope, 'scope').toLowerCase()
|
|
83
|
-
);
|
|
84
|
-
const lifetime = positiveInteger(expiresInSeconds, 'expiresInSeconds') * 1000;
|
|
85
|
-
let interval = positiveInteger(intervalSeconds, 'intervalSeconds');
|
|
86
|
-
const startedAt = now();
|
|
87
|
-
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
|
|
88
|
-
throw new TypeError('Clock must return epoch milliseconds');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
while (true) {
|
|
92
|
-
await sleep(interval * 1000);
|
|
93
|
-
const currentTime = now();
|
|
94
|
-
if (typeof currentTime !== 'number' || !Number.isFinite(currentTime)) {
|
|
95
|
-
throw new TypeError('Clock must return epoch milliseconds');
|
|
96
|
-
}
|
|
97
|
-
if (currentTime - startedAt >= lifetime) {
|
|
98
|
-
throw new Error('GitHub device code expired before authorization completed');
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const payload = await postForm(fetchImpl, ACCESS_TOKEN_URL, {
|
|
102
|
-
client_id: normalizedClientId,
|
|
103
|
-
device_code: normalizedDeviceCode,
|
|
104
|
-
grant_type: DEVICE_GRANT
|
|
105
|
-
});
|
|
106
|
-
if (typeof payload.access_token === 'string' && payload.access_token !== '') {
|
|
107
|
-
const tokenType = requiredString(payload.token_type, 'token_type');
|
|
108
|
-
if (tokenType.toLowerCase() !== 'bearer') {
|
|
109
|
-
throw new TypeError('GitHub OAuth token_type must be bearer');
|
|
110
|
-
}
|
|
111
|
-
const grantedScopes = typeof payload.scope === 'string'
|
|
112
|
-
? payload.scope.split(/[,\s]+/).map((scope) => scope.trim().toLowerCase()).filter(Boolean)
|
|
113
|
-
: [];
|
|
114
|
-
const missingScopes = normalizedRequiredScopes.filter((scope) => !grantedScopes.includes(scope));
|
|
115
|
-
if (missingScopes.length > 0) {
|
|
116
|
-
throw new Error(`GitHub authorization omitted required scope(s): ${missingScopes.join(', ')}`);
|
|
117
|
-
}
|
|
118
|
-
/*
|
|
119
|
-
* A GitHub App may be set to expire user tokens after eight hours, in which case GitHub
|
|
120
|
-
* returns `expires_in` and a refresh token. Refreshing one requires the app's client secret,
|
|
121
|
-
* which a published CLI cannot hold — so the expiry is reported rather than dropped, and the
|
|
122
|
-
* caller decides what to do about a credential it has no way to renew.
|
|
123
|
-
*/
|
|
124
|
-
const expiresInSeconds = Number(payload.expires_in);
|
|
125
|
-
return Object.freeze({
|
|
126
|
-
accessToken: requiredString(payload.access_token, 'access_token'),
|
|
127
|
-
...(Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
|
128
|
-
? { expiresAt: new Date(now() + expiresInSeconds * 1000) }
|
|
129
|
-
: {}),
|
|
130
|
-
...(typeof payload.refresh_token === 'string' && payload.refresh_token !== ''
|
|
131
|
-
? { refreshToken: payload.refresh_token }
|
|
132
|
-
: {}),
|
|
133
|
-
tokenType: 'bearer',
|
|
134
|
-
scopes: grantedScopes
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (payload.error === 'authorization_pending') continue;
|
|
139
|
-
if (payload.error === 'slow_down') {
|
|
140
|
-
interval = Number.isSafeInteger(payload.interval) && payload.interval > interval
|
|
141
|
-
? payload.interval
|
|
142
|
-
: interval + 5;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (payload.error === 'expired_token' || payload.error === 'token_expired') {
|
|
146
|
-
throw new Error('GitHub device code expired before authorization completed');
|
|
147
|
-
}
|
|
148
|
-
if (typeof payload.error === 'string' && payload.error !== '') {
|
|
149
|
-
throw new Error(`GitHub device authorization failed: ${payload.error}`);
|
|
150
|
-
}
|
|
151
|
-
throw new TypeError('GitHub OAuth response contained neither a token nor an error');
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
3
|
-
|
|
4
|
-
const API_VERSION = '2026-03-10';
|
|
5
|
-
|
|
6
|
-
export async function verifyEmptyRepository({ owner, repository, accessToken, fetchImpl = fetch }) {
|
|
7
|
-
const repositoryUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`;
|
|
8
|
-
const headers = {
|
|
9
|
-
accept: 'application/vnd.github+json', authorization: `Bearer ${accessToken}`,
|
|
10
|
-
'x-github-api-version': API_VERSION
|
|
11
|
-
};
|
|
12
|
-
const response = await fetchImpl(repositoryUrl, {
|
|
13
|
-
headers
|
|
14
|
-
});
|
|
15
|
-
if (response.status === 404) {
|
|
16
|
-
/*
|
|
17
|
-
* A GitHub App user token only sees repositories the App is installed on, so "not found" here
|
|
18
|
-
* usually means "not shared with Gala" rather than "does not exist". The OAuth token this
|
|
19
|
-
* replaced held `repo` and could see everything, which is exactly the access we stopped asking
|
|
20
|
-
* for — so the cost is that this needs saying out loud.
|
|
21
|
-
*/
|
|
22
|
-
throw new Error(
|
|
23
|
-
`${owner}/${repository} is not visible to Gala. Either it does not exist, or the Gala GitHub `
|
|
24
|
-
+ 'App has not been given access to it — install or share it at '
|
|
25
|
-
+ 'https://github.com/settings/installations, then run scaffold again.'
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub repository lookup'));
|
|
29
|
-
const payload = await response.json();
|
|
30
|
-
if (payload.full_name?.toLowerCase() !== `${owner}/${repository}`.toLowerCase()) {
|
|
31
|
-
throw new TypeError('GitHub returned an unexpected repository');
|
|
32
|
-
}
|
|
33
|
-
const branchesResponse = await fetchImpl(`${repositoryUrl}/branches?per_page=1`, {
|
|
34
|
-
headers: {
|
|
35
|
-
...headers
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
if (!branchesResponse.ok) throw new Error(await describeHttpFailure(branchesResponse, 'GitHub branch lookup'));
|
|
39
|
-
const branches = await branchesResponse.json();
|
|
40
|
-
if (payload.size !== 0 || !Array.isArray(branches) || branches.length !== 0) {
|
|
41
|
-
throw new Error('Existing repository is not empty; explicit non-empty adoption is not implemented');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function setRepositoryOrigin({ root, owner, repository, spawnProcess = spawn }) {
|
|
46
|
-
const target = `https://github.com/${owner}/${repository}.git`;
|
|
47
|
-
return new Promise((resolve, reject) => {
|
|
48
|
-
const child = spawnProcess('git', ['-C', root, 'remote', 'set-url', 'origin', target], {
|
|
49
|
-
cwd: root, shell: false, stdio: 'inherit'
|
|
50
|
-
});
|
|
51
|
-
child.once('error', reject);
|
|
52
|
-
child.once('exit', (code, signal) => {
|
|
53
|
-
if (signal) reject(new Error(`Git remote update terminated by signal ${signal}`));
|
|
54
|
-
else if (code !== 0) reject(new Error(`Git remote update exited with code ${code}`));
|
|
55
|
-
else resolve();
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function verifyRepositoryOrigin({ root, owner, repository, spawnProcess = spawn }) {
|
|
61
|
-
const expected = `https://github.com/${owner}/${repository}.git`;
|
|
62
|
-
const expectedPath = `/${owner}/${repository}.git`;
|
|
63
|
-
return new Promise((resolve, reject) => {
|
|
64
|
-
const child = spawnProcess('git', ['-C', root, 'remote', 'get-url', 'origin'], {
|
|
65
|
-
cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
|
|
66
|
-
});
|
|
67
|
-
let output = '';
|
|
68
|
-
child.stdout?.setEncoding('utf8');
|
|
69
|
-
child.stdout?.on('data', (chunk) => { output += chunk; });
|
|
70
|
-
child.once('error', reject);
|
|
71
|
-
child.once('exit', (code, signal) => {
|
|
72
|
-
if (signal) reject(new Error(`Git origin verification terminated by signal ${signal}`));
|
|
73
|
-
else if (code !== 0) reject(new Error(`Git origin verification exited with code ${code}`));
|
|
74
|
-
else {
|
|
75
|
-
let origin;
|
|
76
|
-
try {
|
|
77
|
-
origin = new URL(output.trim());
|
|
78
|
-
} catch {
|
|
79
|
-
reject(new Error(`Existing checkout origin must be ${expected}`));
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (origin.protocol !== 'https:' || origin.hostname !== 'github.com' || origin.port !== ''
|
|
83
|
-
|| origin.pathname !== expectedPath || origin.search !== '' || origin.hash !== '') {
|
|
84
|
-
reject(new Error(`Existing checkout origin must be ${expected}`));
|
|
85
|
-
} else resolve(root);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
package/src/github-identity.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
2
|
-
const GITHUB_API_VERSION = '2026-03-10';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The GitHub account the stored credential belongs to.
|
|
6
|
-
*
|
|
7
|
-
* `scaffold` used to make the writer pass `--owner`, which is a value the token already knows and
|
|
8
|
-
* they can only get wrong. The credential file holds the token and its scopes and nothing else, so
|
|
9
|
-
* this is a live lookup rather than something cached at `auth github` time — a login can be
|
|
10
|
-
* changed, and a stale one would create the repository under a name that no longer exists.
|
|
11
|
-
*/
|
|
12
|
-
export async function resolveGithubLogin({ accessToken, fetchImpl = fetch }) {
|
|
13
|
-
if (typeof accessToken !== 'string' || accessToken === '') {
|
|
14
|
-
throw new TypeError('accessToken is required');
|
|
15
|
-
}
|
|
16
|
-
const response = await fetchImpl('https://api.github.com/user', {
|
|
17
|
-
headers: {
|
|
18
|
-
accept: 'application/vnd.github+json',
|
|
19
|
-
authorization: `Bearer ${accessToken}`,
|
|
20
|
-
'x-github-api-version': GITHUB_API_VERSION
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub account lookup'));
|
|
24
|
-
const payload = await response.json();
|
|
25
|
-
const login = payload?.login;
|
|
26
|
-
// The same shape `scaffold` demands of `--owner`. Refusing here beats a confusing failure four
|
|
27
|
-
// API calls later.
|
|
28
|
-
if (typeof login !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) {
|
|
29
|
-
throw new TypeError('GitHub returned an unusable account login');
|
|
30
|
-
}
|
|
31
|
-
return login;
|
|
32
|
-
}
|