@rathnasgala/cli 0.0.20 → 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 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 ./ --mode build-and-deploy
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, requesting `repo` to create the publication and install
42
- its Actions secret, and `workflow` for the initial scaffold. Ordinary publishing needs neither.
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`, `--mode` |
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 OAuth credentials are stored outside the repository.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- export const GITHUB_OAUTH_CLIENT_ID = 'Ov23ligTfectgl2FHJ6c';
5
- export const GITHUB_SCAFFOLD_SCOPES = Object.freeze(['repo', 'workflow']);
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 = GITHUB_OAUTH_CLIENT_ID, fetchImpl = fetch, sleep, now = Date.now,
9
- showScopeWarning, showInstructions, credentialTarget
21
+ clientId = GITHUB_APP_CLIENT_ID, fetchImpl = fetch, sleep, now = Date.now,
22
+ showInstructions, credentialTarget
10
23
  } = {}) {
11
- if (typeof showScopeWarning !== 'function' || typeof showInstructions !== 'function') {
12
- throw new TypeError('scope warning and device instructions are required');
24
+ if (typeof showInstructions !== 'function') {
25
+ throw new TypeError('device instructions are required');
13
26
  }
14
- showScopeWarning({
15
- scopes: GITHUB_SCAFFOLD_SCOPES,
16
- explanation: 'repo grants read/write access to every public and private repository you can access; workflow is used only for scaffold and explicit action-major migration.'
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, requiredScopes: GITHUB_SCAFFOLD_SCOPES, fetchImpl,
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, scopes: token.scopes,
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, scopes: token.scopes });
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
- export async function writeGithubCredential({ accessToken, scopes, target = githubCredentialPath() }) {
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 (!Array.isArray(scopes) || !scopes.includes('repo') || !scopes.includes('workflow')) {
28
- throw new TypeError('GitHub credential requires repo and workflow scopes');
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
- await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, accessToken, scopes })}\n`, {
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 !== 1 || typeof payload.accessToken !== 'string'
61
- || !Array.isArray(payload.scopes) || !payload.scopes.includes('repo') || !payload.scopes.includes('workflow')) {
62
- throw new TypeError('GitHub credential file has an unsupported schema or missing scopes');
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
- return Object.freeze({ accessToken: payload.accessToken, scopes: [...payload.scopes] });
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.map((scope) => requiredString(scope, 'scope'));
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
- const normalizedRequiredScopes = requiredScopes.map((scope) =>
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('git', ['clone', source.href, resolvedTarget], {
154
- cwd: path.dirname(resolvedTarget),
155
- shell: false,
156
- stdio: 'inherit'
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
- const result = await authenticateGithub({
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(`GitHub authentication stored securely with scopes: ${result.scopes.join(', ')}.\n`);
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
  });
@@ -27,9 +27,12 @@ export async function createPublication({
27
27
  ask,
28
28
  openUrl = () => false,
29
29
  shareAttempts = 3,
30
- installationsUrl = 'https://github.com/settings/installations'
30
+ selfLogin
31
31
  }) {
32
32
  let created = false;
33
+ let shareUrl = 'https://github.com/settings/installations';
34
+ let repositoryOwner = selfLogin ?? '';
35
+ let repositoryName = name;
33
36
  for (let attempt = 0; attempt < Math.max(1, shareAttempts); attempt += 1) {
34
37
  const result = await requestPublication({
35
38
  apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
@@ -47,20 +50,54 @@ export async function createPublication({
47
50
  * rather than NEEDS_SHARING — the same situation under a different name.
48
51
  */
49
52
  const shareable = result.status === 'NEEDS_SHARING' || created;
50
- created = created || result.status === 'NEEDS_SHARING';
51
- if (!shareable || typeof ask !== 'function') throw result.failure;
53
+ if (result.status === 'NEEDS_SHARING') {
54
+ created = true;
55
+ shareUrl = installationSettingsUrl(result.installationId, result.owner, selfLogin);
56
+ repositoryName = result.repository ?? repositoryName;
57
+ repositoryOwner = result.owner ?? repositoryOwner;
58
+ }
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
+ }
52
71
 
53
- notify(`${result.owner ?? ''}/${result.repository ?? name} exists, but the Gala GitHub App `
54
- + 'cannot reach it yet — its installation covers only selected repositories.');
55
- notify(`${openUrl(installationsUrl) ? 'Opened' : 'Open'} ${installationsUrl}`);
72
+ notify(`${repositoryOwner}/${repositoryName} was created, but the Gala GitHub App cannot reach `
73
+ + 'it yet — its installation covers only selected repositories, which is the right way to '
74
+ + 'have it. Add this one repository to the installation; nothing else needs granting.');
75
+ notify(`${openUrl(shareUrl) ? 'Opened' : 'Open'} ${shareUrl}`);
56
76
  await ask('Press enter once the App can access that repository. ');
57
77
  }
58
78
  throw new Error(
59
- `The Gala GitHub App still cannot reach the repository for ${name}. Give it access at `
60
- + `${installationsUrl}, then run scaffold again.`
79
+ `The Gala GitHub App still cannot reach ${repositoryOwner}/${repositoryName}. Add that `
80
+ + `repository to the installation at ${shareUrl}, then run scaffold again.`
61
81
  );
62
82
  }
63
83
 
84
+ /**
85
+ * The page that grants one repository, rather than the list of every app ever installed.
86
+ *
87
+ * GitHub keeps user and organisation installation settings on different paths, and only the caller
88
+ * knows which this is: the created owner differing from the token's own account means the
89
+ * installation lives on an organisation.
90
+ */
91
+ export function installationSettingsUrl(installationId, owner, selfLogin) {
92
+ const generic = 'https://github.com/settings/installations';
93
+ if (!Number.isSafeInteger(Number(installationId)) || Number(installationId) <= 0) return generic;
94
+ const isOrganization = typeof owner === 'string' && typeof selfLogin === 'string'
95
+ && owner.toLowerCase() !== selfLogin.toLowerCase();
96
+ return isOrganization
97
+ ? `https://github.com/organizations/${encodeURIComponent(owner)}/settings/installations/${installationId}`
98
+ : `https://github.com/settings/installations/${installationId}`;
99
+ }
100
+
64
101
  async function requestPublication({
65
102
  apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
66
103
  }) {
@@ -87,6 +124,9 @@ async function requestPublication({
87
124
  if (status !== 'READY') {
88
125
  return {
89
126
  ready: false, status, owner, repository,
127
+ // Carried even though the repository is not in the installation yet: it is what makes the
128
+ // grant a deep link rather than a hunt.
129
+ installationId: payload?.installationId,
90
130
  failure: new Error(
91
131
  `Gala could not create the publication repository (${payload?.outcome ?? status}). `
92
132
  + 'Give the Gala GitHub App access to it at https://github.com/settings/installations, or '
@@ -1,8 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
- function run(root, args, spawnProcess, acceptedExitCodes = [0]) {
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], { cwd: root, shell: false, stdio: 'inherit' });
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
- export async function commitScaffold(root, { spawnProcess = spawn } = {}) {
32
- await run(root, ['add', '--', 'site.config.yml', '.github/workflows/publish.yml'], spawnProcess);
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
  });
@@ -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 { writeRegisteredSiteConfiguration } from './site-config-registration.js';
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, actionRef,
53
- buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
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, finalize = writeRegisteredSiteConfiguration,
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');
@@ -112,7 +109,7 @@ export async function scaffoldSite({
112
109
  const created = await createRepository({
113
110
  apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
114
111
  githubAccessToken: github.accessToken, name: repositoryName,
115
- notify, ask, openUrl
112
+ notify, ask, openUrl, selfLogin: requestedOwner
116
113
  });
117
114
  repositoryOwner = segment(created.owner, 'owner');
118
115
  repositoryName = segment(created.repository, 'repository');
@@ -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
- const configured = await configure(root, siteOptions ?? {});
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
- await finalize(root, {
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
- const commitSha = await commit(root);
155
- const pages = buildMode === 'build-and-deploy' ? await provisionPages({
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: location.topology === 'CUSTOM_DOMAIN' ? new URL(location.canonicalBaseUrl).hostname : null
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