@rathnasgala/cli 0.0.22 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -204
- package/package.json +3 -3
- package/src/api/gala.js +126 -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/init.js +197 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/publish.js +57 -0
- package/src/commands-manifest.js +58 -0
- package/src/content.js +31 -0
- package/src/git.js +143 -0
- package/src/index.js +44 -294
- package/src/publication.js +37 -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
|
@@ -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
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
2
|
-
const GITHUB_API_VERSION = '2026-03-10';
|
|
3
|
-
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
4
|
-
const SHA = /^[0-9a-f]{40}$/;
|
|
5
|
-
|
|
6
|
-
function required(value, field, pattern = null) {
|
|
7
|
-
if (typeof value !== 'string' || value.length === 0 || (pattern != null && !pattern.test(value))) {
|
|
8
|
-
throw new TypeError(`${field} is invalid`);
|
|
9
|
-
}
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function headers(accessToken) {
|
|
14
|
-
return {
|
|
15
|
-
accept: 'application/vnd.github+json',
|
|
16
|
-
authorization: `Bearer ${accessToken}`,
|
|
17
|
-
'content-type': 'application/json',
|
|
18
|
-
'x-github-api-version': GITHUB_API_VERSION
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function json(response, operation) {
|
|
23
|
-
if (!response.ok) throw new Error(await describeHttpFailure(response, `GitHub ${operation}`));
|
|
24
|
-
return response.json();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function provisionGithubPages({
|
|
28
|
-
owner, repository, accessToken, commitSha, fetchImpl = fetch,
|
|
29
|
-
customDomain = null,
|
|
30
|
-
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
31
|
-
pollIntervalMs = 5_000, maxPolls = 120
|
|
32
|
-
}) {
|
|
33
|
-
const normalizedOwner = required(owner, 'owner', SEGMENT);
|
|
34
|
-
const normalizedRepository = required(repository, 'repository', SEGMENT);
|
|
35
|
-
const token = required(accessToken, 'accessToken');
|
|
36
|
-
const sha = required(commitSha, 'commitSha', SHA);
|
|
37
|
-
if (customDomain != null && (typeof customDomain !== 'string'
|
|
38
|
-
|| !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(customDomain))) {
|
|
39
|
-
throw new TypeError('customDomain is invalid');
|
|
40
|
-
}
|
|
41
|
-
if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 0) {
|
|
42
|
-
throw new TypeError('pollIntervalMs must be a non-negative safe integer');
|
|
43
|
-
}
|
|
44
|
-
if (!Number.isSafeInteger(maxPolls) || maxPolls <= 0) {
|
|
45
|
-
throw new TypeError('maxPolls must be a positive safe integer');
|
|
46
|
-
}
|
|
47
|
-
const requestHeaders = headers(token);
|
|
48
|
-
const repositoryUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}`;
|
|
49
|
-
const query = new URLSearchParams({ event: 'push', head_sha: sha, per_page: '10' });
|
|
50
|
-
let run = null;
|
|
51
|
-
for (let poll = 0; poll < maxPolls; poll += 1) {
|
|
52
|
-
const response = await fetchImpl(`${repositoryUrl}/actions/workflows/publish.yml/runs?${query}`, {
|
|
53
|
-
method: 'GET', headers: requestHeaders
|
|
54
|
-
});
|
|
55
|
-
const payload = await json(response, 'publish workflow runs request');
|
|
56
|
-
run = payload.workflow_runs?.find((candidate) => candidate.head_sha === sha) ?? null;
|
|
57
|
-
if (run?.status === 'completed') break;
|
|
58
|
-
if (poll + 1 < maxPolls) await sleep(pollIntervalMs);
|
|
59
|
-
}
|
|
60
|
-
if (run == null || run.status !== 'completed') {
|
|
61
|
-
throw new Error(`Timed out waiting for the publish workflow for ${sha}`);
|
|
62
|
-
}
|
|
63
|
-
if (run.conclusion !== 'success') {
|
|
64
|
-
throw new Error(`Initial publish workflow failed: ${run.html_url}`);
|
|
65
|
-
}
|
|
66
|
-
const branch = await fetchImpl(`${repositoryUrl}/branches/gh-pages`, {
|
|
67
|
-
method: 'GET', headers: requestHeaders
|
|
68
|
-
});
|
|
69
|
-
if (!branch.ok) {
|
|
70
|
-
throw new Error(`Successful publish run created no gh-pages branch: ${run.html_url}`);
|
|
71
|
-
}
|
|
72
|
-
const current = await fetchImpl(`${repositoryUrl}/pages`, { method: 'GET', headers: requestHeaders });
|
|
73
|
-
if (current.ok) {
|
|
74
|
-
const configuration = await current.json();
|
|
75
|
-
if (configuration.source?.branch !== 'gh-pages' || configuration.source?.path !== '/') {
|
|
76
|
-
throw new Error('Existing GitHub Pages configuration does not use gh-pages at /');
|
|
77
|
-
}
|
|
78
|
-
if ((configuration.cname ?? null) !== customDomain) {
|
|
79
|
-
const updated = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
80
|
-
method: 'PUT', headers: requestHeaders,
|
|
81
|
-
body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
|
|
82
|
-
});
|
|
83
|
-
if (updated.status !== 204) {
|
|
84
|
-
throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
|
|
88
|
-
}
|
|
89
|
-
if (current.status !== 404) {
|
|
90
|
-
throw new Error(await describeHttpFailure(current, 'GitHub Pages configuration request'));
|
|
91
|
-
}
|
|
92
|
-
const created = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
93
|
-
method: 'POST', headers: requestHeaders,
|
|
94
|
-
body: JSON.stringify({ source: { branch: 'gh-pages', path: '/' } })
|
|
95
|
-
});
|
|
96
|
-
const configuration = await json(created, 'Pages activation');
|
|
97
|
-
if (customDomain != null) {
|
|
98
|
-
const updated = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
99
|
-
method: 'PUT', headers: requestHeaders,
|
|
100
|
-
body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
|
|
101
|
-
});
|
|
102
|
-
if (updated.status !== 204) {
|
|
103
|
-
throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return Object.freeze({ created: true, url: configuration.html_url, runUrl: run.html_url });
|
|
107
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import sodium from 'libsodium-wrappers';
|
|
2
|
-
|
|
3
|
-
const GITHUB_API_VERSION = '2026-03-10';
|
|
4
|
-
const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
|
|
5
|
-
const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
6
|
-
|
|
7
|
-
function required(value, field, pattern) {
|
|
8
|
-
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
9
|
-
throw new TypeError(`${field} is invalid`);
|
|
10
|
-
}
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function requiredSecret(value, field) {
|
|
15
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
16
|
-
throw new TypeError(`${field} must not be empty`);
|
|
17
|
-
}
|
|
18
|
-
return value;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function headers(accessToken) {
|
|
22
|
-
return {
|
|
23
|
-
accept: 'application/vnd.github+json',
|
|
24
|
-
authorization: `Bearer ${accessToken}`,
|
|
25
|
-
'content-type': 'application/json',
|
|
26
|
-
'x-github-api-version': GITHUB_API_VERSION
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function requireSuccess(response, operation) {
|
|
31
|
-
if (!response?.ok) {
|
|
32
|
-
const status = Number.isInteger(response?.status) ? response.status : 'unknown';
|
|
33
|
-
throw new Error(`GitHub ${operation} failed with HTTP ${status}`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function installRepositorySecret({
|
|
38
|
-
owner,
|
|
39
|
-
repository,
|
|
40
|
-
accessToken,
|
|
41
|
-
secretName,
|
|
42
|
-
secretValue,
|
|
43
|
-
fetchImpl = fetch,
|
|
44
|
-
sodiumImpl = sodium
|
|
45
|
-
}) {
|
|
46
|
-
const normalizedOwner = required(owner, 'owner', OWNER_OR_REPOSITORY);
|
|
47
|
-
const normalizedRepository = required(repository, 'repository', OWNER_OR_REPOSITORY);
|
|
48
|
-
const normalizedSecretName = required(secretName, 'secretName', SECRET_NAME);
|
|
49
|
-
const token = requiredSecret(accessToken, 'accessToken');
|
|
50
|
-
const plaintext = requiredSecret(secretValue, 'secretValue');
|
|
51
|
-
|
|
52
|
-
await sodiumImpl.ready;
|
|
53
|
-
|
|
54
|
-
const baseUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}/actions/secrets`;
|
|
55
|
-
const publicKeyResponse = await fetchImpl(`${baseUrl}/public-key`, {
|
|
56
|
-
method: 'GET',
|
|
57
|
-
headers: headers(token)
|
|
58
|
-
});
|
|
59
|
-
await requireSuccess(publicKeyResponse, 'repository public-key request');
|
|
60
|
-
const publicKeyPayload = await publicKeyResponse.json();
|
|
61
|
-
const keyId = requiredSecret(publicKeyPayload?.key_id, 'GitHub key_id');
|
|
62
|
-
const publicKey = requiredSecret(publicKeyPayload?.key, 'GitHub public key');
|
|
63
|
-
|
|
64
|
-
const ciphertext = sodiumImpl.crypto_box_seal(
|
|
65
|
-
sodiumImpl.from_string(plaintext),
|
|
66
|
-
sodiumImpl.from_base64(publicKey, sodiumImpl.base64_variants.ORIGINAL)
|
|
67
|
-
);
|
|
68
|
-
const encryptedValue = sodiumImpl.to_base64(
|
|
69
|
-
ciphertext,
|
|
70
|
-
sodiumImpl.base64_variants.ORIGINAL
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const uploadResponse = await fetchImpl(
|
|
74
|
-
`${baseUrl}/${encodeURIComponent(normalizedSecretName)}`,
|
|
75
|
-
{
|
|
76
|
-
method: 'PUT',
|
|
77
|
-
headers: headers(token),
|
|
78
|
-
body: JSON.stringify({ encrypted_value: encryptedValue, key_id: keyId })
|
|
79
|
-
}
|
|
80
|
-
);
|
|
81
|
-
await requireSuccess(uploadResponse, 'repository secret upload');
|
|
82
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
2
|
-
const GITHUB_API_VERSION = '2026-03-10';
|
|
3
|
-
const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
|
|
4
|
-
const VARIABLE_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
5
|
-
|
|
6
|
-
function required(value, field, pattern) {
|
|
7
|
-
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
8
|
-
throw new TypeError(`${field} is invalid`);
|
|
9
|
-
}
|
|
10
|
-
return value;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function requiredValue(value, field) {
|
|
14
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
15
|
-
throw new TypeError(`${field} must not be empty`);
|
|
16
|
-
}
|
|
17
|
-
return value;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function headers(accessToken) {
|
|
21
|
-
return {
|
|
22
|
-
accept: 'application/vnd.github+json',
|
|
23
|
-
authorization: `Bearer ${accessToken}`,
|
|
24
|
-
'content-type': 'application/json',
|
|
25
|
-
'x-github-api-version': GITHUB_API_VERSION
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function installRepositoryVariable({
|
|
30
|
-
owner, repository, accessToken, variableName, variableValue, fetchImpl = fetch
|
|
31
|
-
}) {
|
|
32
|
-
const normalizedOwner = required(owner, 'owner', OWNER_OR_REPOSITORY);
|
|
33
|
-
const normalizedRepository = required(repository, 'repository', OWNER_OR_REPOSITORY);
|
|
34
|
-
const normalizedName = required(variableName, 'variableName', VARIABLE_NAME);
|
|
35
|
-
const token = requiredValue(accessToken, 'accessToken');
|
|
36
|
-
const value = requiredValue(variableValue, 'variableValue');
|
|
37
|
-
const baseUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}/actions/variables`;
|
|
38
|
-
const requestHeaders = headers(token);
|
|
39
|
-
const update = await fetchImpl(`${baseUrl}/${encodeURIComponent(normalizedName)}`, {
|
|
40
|
-
method: 'PATCH',
|
|
41
|
-
headers: requestHeaders,
|
|
42
|
-
body: JSON.stringify({ name: normalizedName, value })
|
|
43
|
-
});
|
|
44
|
-
if (update.ok) return;
|
|
45
|
-
if (update.status !== 404) {
|
|
46
|
-
throw new Error(await describeHttpFailure(update, 'GitHub repository variable update'));
|
|
47
|
-
}
|
|
48
|
-
const create = await fetchImpl(baseUrl, {
|
|
49
|
-
method: 'POST',
|
|
50
|
-
headers: requestHeaders,
|
|
51
|
-
body: JSON.stringify({ name: normalizedName, value })
|
|
52
|
-
});
|
|
53
|
-
if (!create.ok) {
|
|
54
|
-
throw new Error(await describeHttpFailure(create, 'GitHub repository variable creation'));
|
|
55
|
-
}
|
|
56
|
-
}
|