@rathnasgala/cli 0.0.21 → 0.0.22
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 +7 -15
- package/package.json +1 -1
- package/src/git-credentials.js +37 -0
- package/src/github-auth-command.js +35 -14
- package/src/github-credential-store.js +48 -9
- package/src/github-device-flow.js +28 -5
- package/src/github-empty-repository.js +13 -0
- package/src/github-template-repository.js +12 -6
- package/src/index.js +2 -5
- package/src/publication-creation-client.js +12 -1
- package/src/scaffold-git.js +40 -5
- package/src/scaffold-preflight.js +0 -1
- package/src/scaffold-site.js +46 -23
package/README.md
CHANGED
|
@@ -32,14 +32,15 @@ One command, run inside an empty folder named after the publication you want:
|
|
|
32
32
|
|
|
33
33
|
```console
|
|
34
34
|
mkdir field-notes && cd field-notes
|
|
35
|
-
npx --yes @rathnasgala/cli@latest scaffold --target ./
|
|
35
|
+
npx --yes @rathnasgala/cli@latest scaffold --target ./
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
That single command does all of the following, and asks only for what it cannot work out:
|
|
39
39
|
|
|
40
40
|
1. **Signs you in to Gala** if no valid token is stored, showing a code to enter in the browser.
|
|
41
|
-
2. **Signs you in to GitHub** the same way,
|
|
42
|
-
|
|
41
|
+
2. **Signs you in to GitHub** the same way, as the Gala GitHub App. It requests no scopes: a GitHub
|
|
42
|
+
App's permissions are fixed on the app and granted when you install it, so Gala reaches only the
|
|
43
|
+
repositories you have shared with it — never every repository you can access.
|
|
43
44
|
3. **Reads your GitHub account** from that token, so there is no username to type.
|
|
44
45
|
4. **Finds the Gala GitHub App installation** for your account. If the App is not installed yet it
|
|
45
46
|
prints the installation page, waits while you install it, and carries on — the installation ID
|
|
@@ -75,8 +76,7 @@ npx --yes @rathnasgala/cli@latest scaffold \
|
|
|
75
76
|
--owner YOUR_GITHUB_USERNAME \
|
|
76
77
|
--repository YOUR_REPOSITORY_NAME \
|
|
77
78
|
--target ./YOUR_REPOSITORY_NAME \
|
|
78
|
-
--installation-id YOUR_INSTALLATION_ID
|
|
79
|
-
--mode build-and-deploy
|
|
79
|
+
--installation-id YOUR_INSTALLATION_ID
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
`--repository` is otherwise taken from `--target`, then from `--site-name`, and only then asked
|
|
@@ -97,7 +97,7 @@ Inside the table below, `gala` is shorthand for that prefix.
|
|
|
97
97
|
| --- | --- | --- |
|
|
98
98
|
| `gala auth` | Authenticate the author with Gala | `--api-base-url URL` for a non-production API |
|
|
99
99
|
| `gala auth github` | Authenticate the CLI with GitHub | Browser device flow; requests `repo workflow` |
|
|
100
|
-
| `gala scaffold` | Sign in if needed, then create and register a publication | All derived; override with `--owner`, `--repository`, `--target`, `--installation-id
|
|
100
|
+
| `gala scaffold` | Sign in if needed, then create and register a publication | All derived; override with `--owner`, `--repository`, `--target`, `--installation-id` |
|
|
101
101
|
| `gala configure` | Update author-owned site and design settings | `--root`, plus the configuration options below |
|
|
102
102
|
| `gala new` | Create a Markdown post variant | `--root`, `--title`, `--language`, `--today` |
|
|
103
103
|
| `gala validate` | Validate repository content without publishing | optional root path, `--today` |
|
|
@@ -159,14 +159,6 @@ npx --yes @rathnasgala/cli@latest scaffold \
|
|
|
159
159
|
|
|
160
160
|
Scaffolding is designed to converge after partial failure. It will not adopt a non-empty unrelated repository.
|
|
161
161
|
|
|
162
|
-
### Build without deploying
|
|
163
|
-
|
|
164
|
-
```console
|
|
165
|
-
npx --yes @rathnasgala/cli@latest scaffold ... --mode build-only
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
`build-only` writes and validates the site but does not provision GitHub Pages. `build-and-deploy` is the default.
|
|
169
|
-
|
|
170
162
|
## Everyday workflow
|
|
171
163
|
|
|
172
164
|
Create another post:
|
|
@@ -202,7 +194,7 @@ npx --yes @rathnasgala/cli@latest doctor
|
|
|
202
194
|
## Security and ownership
|
|
203
195
|
|
|
204
196
|
- Your repository remains the canonical source for publication content and configuration.
|
|
205
|
-
- Gala credentials and GitHub
|
|
197
|
+
- Gala credentials and GitHub App credentials are stored outside the repository.
|
|
206
198
|
- Credential directories are created with private permissions; credential files use mode `0600` on operating systems that support POSIX modes.
|
|
207
199
|
- The site signing secret is returned once by the API and sealed directly into GitHub Actions secrets.
|
|
208
200
|
- Do not copy credential files into the repository, dotfiles, cloud-sync folders, or `/tmp`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
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,29 +1,50 @@
|
|
|
1
1
|
import { pollForAccessToken, requestDeviceCode } from './github-device-flow.js';
|
|
2
2
|
import { writeGithubCredential } from './github-credential-store.js';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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';
|
|
6
19
|
|
|
7
20
|
export async function authenticateGithub({
|
|
8
|
-
clientId =
|
|
9
|
-
|
|
21
|
+
clientId = GITHUB_APP_CLIENT_ID, fetchImpl = fetch, sleep, now = Date.now,
|
|
22
|
+
showInstructions, credentialTarget
|
|
10
23
|
} = {}) {
|
|
11
|
-
if (typeof
|
|
12
|
-
throw new TypeError('
|
|
24
|
+
if (typeof showInstructions !== 'function') {
|
|
25
|
+
throw new TypeError('device instructions are required');
|
|
13
26
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
18
|
-
const authorization = await requestDeviceCode({ clientId, scopes: GITHUB_SCAFFOLD_SCOPES, fetchImpl });
|
|
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 });
|
|
19
31
|
showInstructions(authorization);
|
|
20
32
|
const token = await pollForAccessToken({
|
|
21
|
-
...authorization, clientId,
|
|
33
|
+
...authorization, clientId, fetchImpl,
|
|
22
34
|
...(sleep == null ? {} : { sleep }), now
|
|
23
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
|
+
*/
|
|
24
43
|
const target = await writeGithubCredential({
|
|
25
|
-
accessToken: token.accessToken,
|
|
44
|
+
accessToken: token.accessToken,
|
|
45
|
+
...(token.expiresAt == null ? {} : { expiresAt: token.expiresAt }),
|
|
46
|
+
...(token.refreshToken == null ? {} : { refreshToken: token.refreshToken }),
|
|
26
47
|
...(credentialTarget == null ? {} : { target: credentialTarget })
|
|
27
48
|
});
|
|
28
|
-
return Object.freeze({ target,
|
|
49
|
+
return Object.freeze({ target, expiresAt: token.expiresAt ?? null });
|
|
29
50
|
}
|
|
@@ -22,10 +22,21 @@ async function regularOrMissing(target) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
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
|
+
}) {
|
|
26
37
|
if (typeof accessToken !== 'string' || accessToken === '') throw new TypeError('accessToken is required');
|
|
27
|
-
if (
|
|
28
|
-
throw new TypeError('
|
|
38
|
+
if (expiresAt != null && Number.isNaN(new Date(expiresAt).getTime())) {
|
|
39
|
+
throw new TypeError('expiresAt must be a date');
|
|
29
40
|
}
|
|
30
41
|
const directory = path.dirname(path.resolve(target));
|
|
31
42
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -38,7 +49,13 @@ export async function writeGithubCredential({ accessToken, scopes, target = gith
|
|
|
38
49
|
const temporary = `${target}.gala-${process.pid}`;
|
|
39
50
|
const backup = `${target}.gala-backup-${process.pid}`;
|
|
40
51
|
try {
|
|
41
|
-
|
|
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`, {
|
|
42
59
|
flag: 'wx', mode: 0o600
|
|
43
60
|
});
|
|
44
61
|
await chmod(temporary, 0o600);
|
|
@@ -54,12 +71,34 @@ export async function writeGithubCredential({ accessToken, scopes, target = gith
|
|
|
54
71
|
}
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
export async function readGithubCredential({ target = githubCredentialPath() } = {}) {
|
|
74
|
+
export async function readGithubCredential({ target = githubCredentialPath(), now = new Date() } = {}) {
|
|
58
75
|
if (!await regularOrMissing(target)) throw new Error('GitHub authentication is missing; run `gala auth github`');
|
|
59
76
|
const payload = JSON.parse(await readFile(target, 'utf8'));
|
|
60
|
-
if (payload?.schemaVersion
|
|
61
|
-
|
|
62
|
-
|
|
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');
|
|
63
81
|
}
|
|
64
|
-
|
|
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 });
|
|
65
104
|
}
|
|
@@ -35,15 +35,24 @@ async function postForm(fetchImpl, url, fields) {
|
|
|
35
35
|
return payload;
|
|
36
36
|
}
|
|
37
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
|
+
*/
|
|
38
45
|
export async function requestDeviceCode({ clientId, scopes, fetchImpl = fetch }) {
|
|
39
46
|
const normalizedClientId = requiredString(clientId, 'clientId');
|
|
40
|
-
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
41
|
-
throw new TypeError('scopes must be a non-empty list');
|
|
47
|
+
if (scopes != null && (!Array.isArray(scopes) || scopes.length === 0)) {
|
|
48
|
+
throw new TypeError('scopes must be a non-empty list when supplied');
|
|
42
49
|
}
|
|
43
|
-
const normalizedScopes = scopes
|
|
50
|
+
const normalizedScopes = scopes == null
|
|
51
|
+
? null
|
|
52
|
+
: scopes.map((scope) => requiredString(scope, 'scope'));
|
|
44
53
|
const payload = await postForm(fetchImpl, DEVICE_CODE_URL, {
|
|
45
54
|
client_id: normalizedClientId,
|
|
46
|
-
scope: normalizedScopes.join(' ')
|
|
55
|
+
...(normalizedScopes == null ? {} : { scope: normalizedScopes.join(' ') })
|
|
47
56
|
});
|
|
48
57
|
|
|
49
58
|
return Object.freeze({
|
|
@@ -68,7 +77,8 @@ export async function pollForAccessToken({
|
|
|
68
77
|
const normalizedClientId = requiredString(clientId, 'clientId');
|
|
69
78
|
const normalizedDeviceCode = requiredString(deviceCode, 'deviceCode');
|
|
70
79
|
if (!Array.isArray(requiredScopes)) throw new TypeError('requiredScopes must be a list');
|
|
71
|
-
|
|
80
|
+
// A GitHub App answers with no `scope` field at all; there is nothing to require of it.
|
|
81
|
+
const normalizedRequiredScopes = (requiredScopes ?? []).map((scope) =>
|
|
72
82
|
requiredString(scope, 'scope').toLowerCase()
|
|
73
83
|
);
|
|
74
84
|
const lifetime = positiveInteger(expiresInSeconds, 'expiresInSeconds') * 1000;
|
|
@@ -105,8 +115,21 @@ export async function pollForAccessToken({
|
|
|
105
115
|
if (missingScopes.length > 0) {
|
|
106
116
|
throw new Error(`GitHub authorization omitted required scope(s): ${missingScopes.join(', ')}`);
|
|
107
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);
|
|
108
125
|
return Object.freeze({
|
|
109
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
|
+
: {}),
|
|
110
133
|
tokenType: 'bearer',
|
|
111
134
|
scopes: grantedScopes
|
|
112
135
|
});
|
|
@@ -12,6 +12,19 @@ export async function verifyEmptyRepository({ owner, repository, accessToken, fe
|
|
|
12
12
|
const response = await fetchImpl(repositoryUrl, {
|
|
13
13
|
headers
|
|
14
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
|
+
}
|
|
15
28
|
if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub repository lookup'));
|
|
16
29
|
const payload = await response.json();
|
|
17
30
|
if (payload.full_name?.toLowerCase() !== `${owner}/${repository}`.toLowerCase()) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { describeHttpFailure } from './http-failure.js';
|
|
4
|
+
import { gitCredentialArguments, gitEnvironment } from './git-credentials.js';
|
|
4
5
|
|
|
5
6
|
const GITHUB_API_VERSION = '2026-03-10';
|
|
6
7
|
const REPOSITORY_IDENTITY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
@@ -135,7 +136,7 @@ export async function awaitRepositoryContent({
|
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export function cloneRepository({ cloneUrl, target, spawnProcess = spawn }) {
|
|
139
|
+
export function cloneRepository({ cloneUrl, target, spawnProcess = spawn, accessToken }) {
|
|
139
140
|
const source = new URL(requiredString(cloneUrl, 'cloneUrl'));
|
|
140
141
|
if (
|
|
141
142
|
source.protocol !== 'https:'
|
|
@@ -150,11 +151,16 @@ export function cloneRepository({ cloneUrl, target, spawnProcess = spawn }) {
|
|
|
150
151
|
const resolvedTarget = path.resolve(requiredString(target, 'target'));
|
|
151
152
|
|
|
152
153
|
return new Promise((resolve, reject) => {
|
|
153
|
-
const child = spawnProcess(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
const child = spawnProcess(
|
|
155
|
+
'git',
|
|
156
|
+
[...gitCredentialArguments(accessToken), 'clone', source.href, resolvedTarget],
|
|
157
|
+
{
|
|
158
|
+
cwd: path.dirname(resolvedTarget),
|
|
159
|
+
shell: false,
|
|
160
|
+
stdio: 'inherit',
|
|
161
|
+
env: gitEnvironment(accessToken)
|
|
162
|
+
}
|
|
163
|
+
);
|
|
158
164
|
child.once('error', reject);
|
|
159
165
|
child.once('exit', (code, signal) => {
|
|
160
166
|
if (signal) reject(new Error(`Git clone terminated by signal ${signal}`));
|
package/src/index.js
CHANGED
|
@@ -84,13 +84,12 @@ if (recognizedCommands.has(command)) {
|
|
|
84
84
|
|
|
85
85
|
if (command === 'auth') {
|
|
86
86
|
if (args[0] === 'github') {
|
|
87
|
-
|
|
88
|
-
showScopeWarning: ({ explanation }) => process.stdout.write(`GitHub authorization: ${explanation}\n`),
|
|
87
|
+
await authenticateGithub({
|
|
89
88
|
showInstructions: ({ verificationUri, userCode }) => {
|
|
90
89
|
process.stdout.write(announce(verificationUri, userCode));
|
|
91
90
|
}
|
|
92
91
|
});
|
|
93
|
-
process.stdout.write(
|
|
92
|
+
process.stdout.write('GitHub authentication stored securely.\n');
|
|
94
93
|
} else {
|
|
95
94
|
const apiIndex = args.indexOf('--api-base-url');
|
|
96
95
|
const apiBaseUrl = apiIndex === -1 ? 'https://api.gala67.com' : args[apiIndex + 1];
|
|
@@ -139,9 +138,7 @@ if (command === 'auth') {
|
|
|
139
138
|
githubInstallationId: prepared.githubInstallationId,
|
|
140
139
|
topology,
|
|
141
140
|
canonicalBaseUrl: valueFor('--canonical-base-url'),
|
|
142
|
-
actionRef: valueFor('--action-ref'),
|
|
143
141
|
siteOptions,
|
|
144
|
-
buildMode: valueFor('--mode') ?? 'build-and-deploy',
|
|
145
142
|
emptyExistingRepository: args.includes('--empty-existing-repository'),
|
|
146
143
|
resumeExistingCheckout: args.includes('--resume')
|
|
147
144
|
});
|
|
@@ -56,7 +56,18 @@ export async function createPublication({
|
|
|
56
56
|
repositoryName = result.repository ?? repositoryName;
|
|
57
57
|
repositoryOwner = result.owner ?? repositoryOwner;
|
|
58
58
|
}
|
|
59
|
-
if (!shareable
|
|
59
|
+
if (!shareable) throw result.failure;
|
|
60
|
+
if (typeof ask !== 'function') {
|
|
61
|
+
/*
|
|
62
|
+
* No terminal to prompt at — CI, or a piped run. The repository exists and is one grant from
|
|
63
|
+
* working, so the failure has to carry everything needed to finish it by hand. Falling back
|
|
64
|
+
* to the generic message here threw away the deep link that had just been computed.
|
|
65
|
+
*/
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${repositoryOwner}/${repositoryName} was created, but the Gala GitHub App cannot reach it `
|
|
68
|
+
+ `yet. Add that one repository to the installation at ${shareUrl}, then run scaffold again.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
60
71
|
|
|
61
72
|
notify(`${repositoryOwner}/${repositoryName} was created, but the Gala GitHub App cannot reach `
|
|
62
73
|
+ 'it yet — its installation covers only selected repositories, which is the right way to '
|
package/src/scaffold-git.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import { gitCredentialArguments, gitEnvironment } from './git-credentials.js';
|
|
4
|
+
|
|
5
|
+
function run(root, args, spawnProcess, acceptedExitCodes = [0], accessToken) {
|
|
4
6
|
return new Promise((resolve, reject) => {
|
|
5
|
-
const child = spawnProcess('git', ['-C', root, ...args], {
|
|
7
|
+
const child = spawnProcess('git', ['-C', root, ...gitCredentialArguments(accessToken), ...args], {
|
|
8
|
+
cwd: root, shell: false, stdio: 'inherit', env: gitEnvironment(accessToken)
|
|
9
|
+
});
|
|
6
10
|
child.once('error', reject);
|
|
7
11
|
child.once('exit', (code, signal) => {
|
|
8
12
|
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
@@ -28,13 +32,44 @@ function capture(root, args, spawnProcess) {
|
|
|
28
32
|
});
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Takes the commit registration just pushed, before anything local is written.
|
|
37
|
+
*
|
|
38
|
+
* Registering a site makes the server write `site.config.yml` and `.github/workflows/publish.yml`
|
|
39
|
+
* into the repository. The checkout was taken before that, so the scaffold's own commit lands on a
|
|
40
|
+
* parent the remote has moved past and the push is rejected:
|
|
41
|
+
*
|
|
42
|
+
* ! [rejected] HEAD -> main (fetch first)
|
|
43
|
+
*
|
|
44
|
+
* Integrating here rather than after committing is what keeps it simple: at this point the working
|
|
45
|
+
* tree is untouched, so the rebase is trivial and cannot conflict with files this process is about
|
|
46
|
+
* to write. A dirty tree — only reachable via --resume — fails loudly, which is the right answer
|
|
47
|
+
* for work nobody asked this command to reconcile.
|
|
48
|
+
*/
|
|
49
|
+
export async function syncScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
|
|
50
|
+
const branch = await capture(root, ['rev-parse', '--abbrev-ref', 'HEAD'], spawnProcess);
|
|
51
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch === 'HEAD') {
|
|
52
|
+
throw new Error('Git checkout is not on a named branch');
|
|
53
|
+
}
|
|
54
|
+
await run(root, ['fetch', 'origin', branch], spawnProcess, [0], accessToken);
|
|
55
|
+
await run(root, ['rebase', `origin/${branch}`], spawnProcess);
|
|
56
|
+
const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
|
|
57
|
+
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid head SHA');
|
|
58
|
+
return commitSha;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function commitScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
|
|
62
|
+
/*
|
|
63
|
+
* Only the site configuration. The publish workflow is written by the server during registration
|
|
64
|
+
* — the same path the browser editor uses — and staging a file that is not in the checkout yet
|
|
65
|
+
* fails outright with a pathspec error.
|
|
66
|
+
*/
|
|
67
|
+
await run(root, ['add', '--', 'site.config.yml'], spawnProcess);
|
|
33
68
|
const unchanged = await run(root, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
|
|
34
69
|
if (unchanged === 1) {
|
|
35
70
|
await run(root, ['commit', '-m', 'chore(gala): configure site'], spawnProcess);
|
|
36
71
|
}
|
|
37
|
-
await run(root, ['push', 'origin', 'HEAD'], spawnProcess);
|
|
72
|
+
await run(root, ['push', 'origin', 'HEAD'], spawnProcess, [0], accessToken);
|
|
38
73
|
const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
|
|
39
74
|
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid scaffold commit SHA');
|
|
40
75
|
return commitSha;
|
|
@@ -119,7 +119,6 @@ async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
|
|
|
119
119
|
} catch {
|
|
120
120
|
notify('Signing in to GitHub.');
|
|
121
121
|
await signInGithub({
|
|
122
|
-
showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
|
|
123
122
|
showInstructions: ({ verificationUri, userCode }) =>
|
|
124
123
|
notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
|
|
125
124
|
});
|
package/src/scaffold-site.js
CHANGED
|
@@ -9,9 +9,7 @@ import { createPublication } from './publication-creation-client.js';
|
|
|
9
9
|
import { installRepositoryVariable } from './github-repository-variable.js';
|
|
10
10
|
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
11
11
|
import { registerSite } from './site-registration-client.js';
|
|
12
|
-
import {
|
|
13
|
-
import { writePublishWorkflow } from './workflow-command.js';
|
|
14
|
-
import { commitScaffold } from './scaffold-git.js';
|
|
12
|
+
import { commitScaffold, syncScaffold } from './scaffold-git.js';
|
|
15
13
|
import {
|
|
16
14
|
setRepositoryOrigin, verifyEmptyRepository, verifyRepositoryOrigin
|
|
17
15
|
} from './github-empty-repository.js';
|
|
@@ -49,16 +47,15 @@ function registrationLocation(owner, topology, canonicalBaseUrl) {
|
|
|
49
47
|
export async function scaffoldSite({
|
|
50
48
|
owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
|
|
51
49
|
notify = (message) => process.stdout.write(`${message}\n`), ask, openUrl,
|
|
52
|
-
resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl,
|
|
53
|
-
|
|
50
|
+
resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl,
|
|
51
|
+
templateOwner = 'rathnasgala',
|
|
54
52
|
templateRepository = 'site-template',
|
|
55
53
|
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
56
54
|
createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
|
|
57
|
-
configure = configureSite, register = registerSite,
|
|
58
|
-
writeWorkflow = writePublishWorkflow,
|
|
55
|
+
configure = configureSite, register = registerSite,
|
|
59
56
|
installVariable = installRepositoryVariable,
|
|
60
57
|
provisionPages = provisionGithubPages,
|
|
61
|
-
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
58
|
+
commit = commitScaffold, sync = syncScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
62
59
|
verifyCheckout = verifyRepositoryOrigin
|
|
63
60
|
}) {
|
|
64
61
|
const requestedOwner = segment(owner, 'owner');
|
|
@@ -125,11 +122,30 @@ export async function scaffoldSite({
|
|
|
125
122
|
});
|
|
126
123
|
}
|
|
127
124
|
if (!resumeExistingCheckout) {
|
|
128
|
-
root = await clone({ cloneUrl: generated.cloneUrl, target });
|
|
125
|
+
root = await clone({ cloneUrl: generated.cloneUrl, target, accessToken: github.accessToken });
|
|
129
126
|
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
130
127
|
}
|
|
131
128
|
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
132
|
-
|
|
129
|
+
await configure(root, siteOptions ?? {});
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
* The writer's design choices go up before registration, and nothing goes up after it.
|
|
133
|
+
*
|
|
134
|
+
* Registration makes the server write `site.config.yml` and `.github/workflows/publish.yml` into
|
|
135
|
+
* the repository — the same code path the browser editor uses. The CLI used to write its own
|
|
136
|
+
* versions of both files afterwards and commit them, which produced a second commit whose whole
|
|
137
|
+
* content was rewriting `api-base-url` into a `vars` reference and stripping the template's
|
|
138
|
+
* comments. That second commit triggered a second Publish run, which collided with the first
|
|
139
|
+
* one's deployment record and failed:
|
|
140
|
+
*
|
|
141
|
+
* Assigned-ID source moved on the remote branch: content/posts/example/index.en.md
|
|
142
|
+
*
|
|
143
|
+
* So those two files have one owner now, and it is the server. What is left here is the design
|
|
144
|
+
* configuration, which only the CLI receives — pushed first so the server provisions on top of
|
|
145
|
+
* it rather than around it.
|
|
146
|
+
*/
|
|
147
|
+
await commit(root, { accessToken: github.accessToken });
|
|
148
|
+
|
|
133
149
|
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
134
150
|
const registration = await register({
|
|
135
151
|
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
@@ -137,24 +153,31 @@ export async function scaffoldSite({
|
|
|
137
153
|
githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
|
|
138
154
|
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
139
155
|
});
|
|
140
|
-
|
|
141
|
-
siteId: registration.siteId,
|
|
142
|
-
canonicalBaseUrl: registration.canonicalBaseUrl,
|
|
143
|
-
pathPrefix: registration.pathPrefix,
|
|
144
|
-
topology
|
|
145
|
-
});
|
|
146
|
-
await writeWorkflow({
|
|
147
|
-
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode,
|
|
148
|
-
...(actionRef == null ? {} : { actionRef })
|
|
149
|
-
});
|
|
156
|
+
|
|
150
157
|
await installVariable({
|
|
151
158
|
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
152
159
|
variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
|
|
153
160
|
});
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
|
|
162
|
+
// Brings the server's provisioning commits into the checkout, so the writer's working copy holds
|
|
163
|
+
// the publication as it actually exists, and reports the commit publishing will run against.
|
|
164
|
+
const commitSha = await sync(root, { accessToken: github.accessToken });
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* Pages is only touched for a custom domain.
|
|
168
|
+
*
|
|
169
|
+
* On the provider default it was never doing anything: publishing creates a `gh-pages` branch and
|
|
170
|
+
* GitHub turns on classic Pages by itself — every scaffold produced a live site with
|
|
171
|
+
* `build_type: legacy, source: gh-pages` before this step ran, including runs that failed before
|
|
172
|
+
* reaching it. What the step did cost was up to ten minutes waiting on a workflow run, and a
|
|
173
|
+
* reported failure for a publication that was already serving.
|
|
174
|
+
*
|
|
175
|
+
* A custom domain is different: classic Pages will not point itself at someone's own hostname, so
|
|
176
|
+
* the API call is the thing that does it.
|
|
177
|
+
*/
|
|
178
|
+
const pages = location.topology === 'CUSTOM_DOMAIN' ? await provisionPages({
|
|
156
179
|
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha,
|
|
157
|
-
customDomain:
|
|
180
|
+
customDomain: new URL(location.canonicalBaseUrl).hostname
|
|
158
181
|
}) : null;
|
|
159
182
|
return Object.freeze({
|
|
160
183
|
root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
|