@rathnasgala/cli 0.0.12 → 0.0.14

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
@@ -9,7 +9,7 @@ The quick start below begins with the required accounts and tools and does not a
9
9
  - [Git](https://git-scm.com/downloads)
10
10
  - [Node.js 24](https://nodejs.org/en/download) recommended; the CLI package supports Node.js 18 or newer
11
11
  - A [GitHub account](https://github.com/signup)
12
- - The [Gala GitHub App](https://github.com/apps/gala67-app/installations/new) installed for the account that will own the publication
12
+ - The [Gala GitHub App](https://github.com/apps/gala67-app/installations/new) `scaffold` walks you through installing it if it is not already
13
13
 
14
14
  Check your local tools:
15
15
 
@@ -28,42 +28,47 @@ git version 2.50.1 (Apple Git-155)
28
28
 
29
29
  ## Quick start
30
30
 
31
- ### 1. Authenticate with Gala
31
+ One command, run inside an empty folder named after the publication you want:
32
32
 
33
33
  ```console
34
- npx --yes @rathnasgala/cli@latest auth
35
- ```
36
-
37
- The CLI displays a short code and opens the platform authorization page. The resulting Gala token is stored in your operating system's application-config directory, never in the publication repository.
38
-
39
- ### 2. Authenticate with GitHub
40
-
41
- ```console
42
- npx --yes @rathnasgala/cli@latest auth github
34
+ mkdir field-notes && cd field-notes
35
+ npx --yes @rathnasgala/cli@latest scaffold --target ./ --mode build-and-deploy
43
36
  ```
44
37
 
45
- GitHub OAuth Apps cannot restrict `repo` access to one repository. The CLI therefore requests:
38
+ That single command does all of the following, and asks only for what it cannot work out:
46
39
 
47
- - `repo` to create the publication repository and install its Actions secret
48
- - `workflow` for initial scaffolding and explicit Action-major migrations
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.
43
+ 3. **Reads your GitHub account** from that token, so there is no username to type.
44
+ 4. **Finds the Gala GitHub App installation** for your account. If the App is not installed yet it
45
+ prints the installation page, waits while you install it, and carries on — the installation ID
46
+ is never something you have to read out of a URL.
47
+ 5. **Names the publication** after the folder you are standing in.
48
+ 6. **Creates the repository** from the site template, registers it, installs its one-time secret,
49
+ writes the publication workflow, commits, and enables GitHub Pages.
49
50
 
50
- The GitHub token is stored outside the repository with private file permissions. Gala does not require `workflow` for ordinary publishing or patch upgrades.
51
+ Both sign-ins are skipped when a valid credential is already stored, so re-running is cheap.
51
52
 
52
- ### 3. Install the GitHub App
53
+ After scaffolding succeeds, open [GitHub App settings](https://github.com/settings/installations)
54
+ and restrict the App to the publication repository if you installed it against all of them.
53
55
 
54
- Open the [Gala GitHub App installation page](https://github.com/apps/gala67-app/installations/new). For a new publication, select **All repositories** temporarily because the target repository does not exist yet.
56
+ ### Write, preview, and publish
55
57
 
56
- After installation, GitHub redirects to a URL ending in a number, for example:
57
-
58
- ```text
59
- https://github.com/settings/installations/153144989
58
+ ```console
59
+ npx --yes @rathnasgala/cli@latest new --title "My first post" --language en
60
+ npx --yes @rathnasgala/cli@latest preview
61
+ npx --yes @rathnasgala/cli@latest publish
60
62
  ```
61
63
 
62
- That final number is the installation ID required by `scaffold`.
64
+ `new` prints the Markdown file it created. Write below the second `---` line, save the file,
65
+ preview it locally, then publish it through GitHub.
63
66
 
64
- ### 4. Scaffold the publication
67
+ ### Overriding what scaffold works out
65
68
 
66
- Replace every capitalized placeholder:
69
+ Every derived value is still an explicit flag, for the cases where the default is wrong — a
70
+ publication owned by an organisation, a folder named differently from the repository, or more than
71
+ one App installation on the account:
67
72
 
68
73
  ```console
69
74
  npx --yes @rathnasgala/cli@latest scaffold \
@@ -74,20 +79,9 @@ npx --yes @rathnasgala/cli@latest scaffold \
74
79
  --mode build-and-deploy
75
80
  ```
76
81
 
77
- Scaffolding creates a public repository from `rathnasgala/site-template`, registers the site, installs the one-time site secret as a GitHub Actions secret, writes the publication workflow, commits the generated configuration, and enables GitHub Pages.
78
-
79
- After scaffolding succeeds, open [GitHub App settings](https://github.com/settings/installations) and restrict the App to the publication repository.
80
-
81
- ### 5. Write, preview, and publish
82
-
83
- ```console
84
- cd YOUR_REPOSITORY_NAME
85
- npx --yes @rathnasgala/cli@latest new --title "My first post" --language en
86
- npx --yes @rathnasgala/cli@latest preview
87
- npx --yes @rathnasgala/cli@latest publish
88
- ```
89
-
90
- `new` prints the Markdown file it created. Write below the second `---` line, save the file, preview it locally, then publish it through GitHub.
82
+ `--repository` is otherwise taken from `--target`, then from `--site-name`, and only then asked
83
+ for. Outside a terminal — in CI — nothing is ever prompted for: a value that cannot be derived is
84
+ an error, so an automated run fails fast instead of waiting for an answer that will not come.
91
85
 
92
86
  ## Command reference
93
87
 
@@ -103,7 +97,7 @@ Inside the table below, `gala` is shorthand for that prefix.
103
97
  | --- | --- | --- |
104
98
  | `gala auth` | Authenticate the author with Gala | `--api-base-url URL` for a non-production API |
105
99
  | `gala auth github` | Authenticate the CLI with GitHub | Browser device flow; requests `repo workflow` |
106
- | `gala scaffold` | Create and register a publication | `--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`, `--mode` |
107
101
  | `gala configure` | Update author-owned site and design settings | `--root`, plus the configuration options below |
108
102
  | `gala new` | Create a Markdown post variant | `--root`, `--title`, `--language`, `--today` |
109
103
  | `gala validate` | Validate repository content without publishing | optional root path, `--today` |
@@ -148,10 +142,7 @@ Use this only when the exact GitHub repository already exists and has no branche
148
142
 
149
143
  ```console
150
144
  npx --yes @rathnasgala/cli@latest scaffold \
151
- --owner YOUR_GITHUB_USERNAME \
152
145
  --repository YOUR_REPOSITORY_NAME \
153
- --target ./YOUR_REPOSITORY_NAME \
154
- --installation-id YOUR_INSTALLATION_ID \
155
146
  --empty-existing-repository
156
147
  ```
157
148
 
@@ -161,10 +152,8 @@ The target must already be a checkout whose HTTPS origin exactly matches the req
161
152
 
162
153
  ```console
163
154
  npx --yes @rathnasgala/cli@latest scaffold \
164
- --owner YOUR_GITHUB_USERNAME \
165
155
  --repository YOUR_REPOSITORY_NAME \
166
156
  --target ./YOUR_REPOSITORY_NAME \
167
- --installation-id YOUR_INSTALLATION_ID \
168
157
  --resume
169
158
  ```
170
159
 
@@ -237,9 +226,12 @@ Gala author tokens expire and do not use a refresh token. Run:
237
226
  npx --yes @rathnasgala/cli@latest auth
238
227
  ```
239
228
 
240
- ### `githubInstallationId must be a positive integer`
229
+ ### `The Gala GitHub App is not installed on YOUR_ACCOUNT`
241
230
 
242
- Open [GitHub App settings](https://github.com/settings/installations), select Gala, and copy the number at the end of the browser URL.
231
+ `scaffold` could not find an installation covering that account. At a terminal it prints the
232
+ installation page and waits; in CI it stops, because there is nobody to install it. Install the App
233
+ at [the installation page](https://github.com/apps/gala67-app/installations/new) and run `scaffold`
234
+ again, or pass `--installation-id` explicitly.
243
235
 
244
236
  ### The App cannot access the new repository
245
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Whether a stored Gala credential is one the server will still accept.
3
+ *
4
+ * `readGalaCredential` can only check what is written in the file — schema and expiry — and a
5
+ * credential can satisfy both while the API refuses it outright. It happened: the API stopped
6
+ * putting a `tenant` claim in its tokens and now rejects any token that still carries one
7
+ * (Rs256JwtCodec: "Legacy tenant-bearing token requires reauthentication"). Tokens minted before
8
+ * that change have a month-long expiry, so every command using one sent a bearer the server had
9
+ * already decided to refuse, and reported it as whatever call happened to fail first.
10
+ *
11
+ * Expiry is not the only way a credential dies. It can be revoked, the signing key can rotate, the
12
+ * claim set can change again. So this does not special-case the `tenant` claim: it asks the server,
13
+ * once, and treats 401 as "this credential is finished" — which is true whatever the reason.
14
+ */
15
+ const PROBE_PATH = '/v1/me/sites';
16
+
17
+ /**
18
+ * Answers whether the API still accepts this credential.
19
+ *
20
+ * A network failure is deliberately not an answer: refusing to run because the machine is briefly
21
+ * offline, or forcing a sign-in the writer does not need, are both worse than letting the real
22
+ * call fail with its own error.
23
+ */
24
+ export async function galaCredentialAccepted({ apiBaseUrl, accessToken, fetchImpl = fetch }) {
25
+ let response;
26
+ try {
27
+ response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}${PROBE_PATH}`, {
28
+ headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
29
+ });
30
+ } catch {
31
+ return true;
32
+ }
33
+ return response.status !== 401;
34
+ }
@@ -102,3 +102,14 @@ export async function readGalaCredential({ target = galaCredentialPath(), now =
102
102
  expiresAt
103
103
  });
104
104
  }
105
+
106
+ /**
107
+ * Removes a credential the server no longer accepts.
108
+ *
109
+ * Leaving a refused token on disk means every later command rediscovers that it is refused, and
110
+ * `readGalaCredential` cannot tell the difference — the file is well-formed and unexpired. Deleting
111
+ * it is what makes the next run ask for a sign-in instead of failing again.
112
+ */
113
+ export async function forgetGalaCredential({ target = galaCredentialPath() } = {}) {
114
+ await rm(target, { force: true });
115
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Which Gala GitHub App installation covers this writer's account.
3
+ *
4
+ * The installation ID is an internal GitHub identifier that `scaffold` has to send when it
5
+ * registers a site. Until now the writer supplied it by installing the App, watching GitHub
6
+ * redirect to `https://github.com/settings/installations/153144989`, and copying the number out of
7
+ * the address bar — an internal identifier, read out of a URL, by hand.
8
+ *
9
+ * The Gala API already knows it. `GET /v1/auth/github/repositories` answers with
10
+ * `{ installationId, owner, name, status }` per repository, so the CLI can ask the same service it
11
+ * is about to register with rather than guess. Reaching that endpoint needs the bounded capability
12
+ * from `POST /v1/auth/github/device-authorizations`, which is bound to the Gala user and takes the
13
+ * GitHub token the CLI already holds.
14
+ */
15
+ function endpoint(apiBaseUrl, path) {
16
+ return `${String(apiBaseUrl).replace(/\/$/, '')}${path}`;
17
+ }
18
+
19
+ export async function exchangeGithubAuthorization({
20
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl = fetch
21
+ }) {
22
+ const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/device-authorizations'), {
23
+ method: 'POST',
24
+ headers: {
25
+ accept: 'application/json',
26
+ authorization: `Bearer ${galaAccessToken}`,
27
+ 'content-type': 'application/json'
28
+ },
29
+ body: JSON.stringify({ accessToken: githubAccessToken })
30
+ });
31
+ if (response.status === 409) {
32
+ // The API distinguishes "the App is not installed on this account" from "your credential is
33
+ // finished". Only the first is something the writer can fix in a browser, so it is signalled
34
+ // rather than thrown: the caller offers the installation page and waits.
35
+ return null;
36
+ }
37
+ if (response.status === 401) {
38
+ // Either credential can be the one at fault and the caller cannot tell them apart, so say so
39
+ // rather than printing a status code the writer has no way to interpret.
40
+ throw new Error(
41
+ 'Gala refused the GitHub authorization. Run `npx --yes @rathnasgala/cli@latest auth` and '
42
+ + '`auth github` again, then retry.'
43
+ );
44
+ }
45
+ if (!response.ok) {
46
+ throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
47
+ }
48
+ const payload = await response.json();
49
+ if (typeof payload?.authorization !== 'string') {
50
+ throw new TypeError('GitHub authorization exchange returned no capability');
51
+ }
52
+ return payload.authorization;
53
+ }
54
+
55
+ export async function listAuthorizedRepositories({ apiBaseUrl, authorization, fetchImpl = fetch }) {
56
+ const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
57
+ headers: { accept: 'application/json', 'GitHub-Authorization': authorization }
58
+ });
59
+ if (!response.ok) {
60
+ throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
61
+ }
62
+ const payload = await response.json();
63
+ if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
64
+ return payload;
65
+ }
66
+
67
+ /**
68
+ * Returns the installation covering `owner`, or null when the App is not installed there.
69
+ *
70
+ * An installation belongs to an account, not to one repository, so any repository the App can
71
+ * already see under that owner carries the id the new one will use. Null is an ordinary answer —
72
+ * it means "not installed yet" — and the caller turns it into an instruction, not an error.
73
+ */
74
+ export async function resolveInstallationId({
75
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
76
+ exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
77
+ }) {
78
+ const authorization = await exchange({
79
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
80
+ });
81
+ // null means the App is not installed yet, which the caller turns into an instruction.
82
+ if (authorization == null) return null;
83
+ const repositories = await list({ apiBaseUrl, authorization, fetchImpl });
84
+ const wanted = String(owner).toLowerCase();
85
+ for (const repository of repositories) {
86
+ if (String(repository?.owner).toLowerCase() !== wanted) continue;
87
+ const installationId = Number(repository?.installationId);
88
+ if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
89
+ }
90
+ return null;
91
+ }
@@ -0,0 +1,31 @@
1
+ const GITHUB_API_VERSION = '2026-03-10';
2
+
3
+ /**
4
+ * The GitHub account the stored credential belongs to.
5
+ *
6
+ * `scaffold` used to make the writer pass `--owner`, which is a value the token already knows and
7
+ * they can only get wrong. The credential file holds the token and its scopes and nothing else, so
8
+ * this is a live lookup rather than something cached at `auth github` time — a login can be
9
+ * changed, and a stale one would create the repository under a name that no longer exists.
10
+ */
11
+ export async function resolveGithubLogin({ accessToken, fetchImpl = fetch }) {
12
+ if (typeof accessToken !== 'string' || accessToken === '') {
13
+ throw new TypeError('accessToken is required');
14
+ }
15
+ const response = await fetchImpl('https://api.github.com/user', {
16
+ headers: {
17
+ accept: 'application/vnd.github+json',
18
+ authorization: `Bearer ${accessToken}`,
19
+ 'x-github-api-version': GITHUB_API_VERSION
20
+ }
21
+ });
22
+ if (!response.ok) throw new Error(`GitHub account lookup failed with HTTP ${response.status}`);
23
+ const payload = await response.json();
24
+ const login = payload?.login;
25
+ // The same shape `scaffold` demands of `--owner`. Refusing here beats a confusing failure four
26
+ // API calls later.
27
+ if (typeof login !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) {
28
+ throw new TypeError('GitHub returned an unusable account login');
29
+ }
30
+ return login;
31
+ }
package/src/index.js CHANGED
@@ -20,10 +20,30 @@ import { createInterface } from 'node:readline/promises';
20
20
  import { upgradeTheme } from './upgrade-command.js';
21
21
  import { authenticateGithub } from './github-auth-command.js';
22
22
  import { scaffoldSite } from './scaffold-site.js';
23
+ import { prepareScaffold } from './scaffold-preflight.js';
23
24
  import { refreshEngagementSnapshot } from './refresh-command.js';
24
25
  import { switchTopology } from './topology-command.js';
25
26
  import { acquireAttributionEntitlement } from './entitlement-command.js';
26
27
 
28
+ /*
29
+ * A failed command should say what went wrong and what to do about it. Node's default for a
30
+ * rejected top-level await is a stack trace through node_modules, which tells a writer nothing and
31
+ * buries the one line that matters. The stack is still available behind GALA_DEBUG for anyone
32
+ * debugging the CLI itself.
33
+ */
34
+ process.on('uncaughtException', reportAndExit);
35
+ process.on('unhandledRejection', reportAndExit);
36
+
37
+ function reportAndExit(failure) {
38
+ if (process.env.GALA_DEBUG) {
39
+ process.stderr.write(`${failure instanceof Error ? failure.stack : String(failure)}\n`);
40
+ } else {
41
+ const message = failure instanceof Error ? failure.message : String(failure);
42
+ process.stderr.write(`${message}\n`);
43
+ }
44
+ process.exit(1);
45
+ }
46
+
27
47
  const [command, ...args] = process.argv.slice(2);
28
48
  const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
29
49
 
@@ -77,17 +97,37 @@ if (command === 'auth') {
77
97
  const index = args.indexOf(name);
78
98
  return index === -1 ? undefined : args[index + 1];
79
99
  };
80
- const installationId = Number(valueFor('--installation-id'));
100
+ const explicitInstallationId = valueFor('--installation-id');
81
101
  const topology = valueFor('--topology') ?? 'provider-default';
82
- const result = await scaffoldSite({
102
+ const siteOptions = parseScaffoldOptions(args);
103
+ // Prompting only makes sense at a terminal. In CI there is nobody to answer, so a missing value
104
+ // has to stay a clear error rather than a process that hangs waiting for enter.
105
+ const interactive = process.stdin.isTTY === true;
106
+ const ask = interactive
107
+ ? async (question) => {
108
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
109
+ try { return await terminal.question(question); } finally { terminal.close(); }
110
+ }
111
+ : undefined;
112
+ const prepared = await prepareScaffold({
83
113
  owner: valueFor('--owner'),
84
114
  repository: valueFor('--repository'),
85
115
  target: valueFor('--target'),
86
- githubInstallationId: installationId,
116
+ githubInstallationId: explicitInstallationId == null ? undefined : Number(explicitInstallationId),
117
+ siteName: siteOptions.siteName,
118
+ apiBaseUrl: valueFor('--api-base-url') ?? 'https://api.gala67.com',
119
+ notify: (message) => process.stdout.write(`${message}\n`),
120
+ ask
121
+ });
122
+ const result = await scaffoldSite({
123
+ owner: prepared.owner,
124
+ repository: prepared.repository,
125
+ target: prepared.target,
126
+ githubInstallationId: prepared.githubInstallationId,
87
127
  topology,
88
128
  canonicalBaseUrl: valueFor('--canonical-base-url'),
89
129
  actionRef: valueFor('--action-ref'),
90
- siteOptions: parseScaffoldOptions(args),
130
+ siteOptions,
91
131
  buildMode: valueFor('--mode') ?? 'build-and-deploy',
92
132
  emptyExistingRepository: args.includes('--empty-existing-repository'),
93
133
  resumeExistingCheckout: args.includes('--resume')
@@ -0,0 +1,180 @@
1
+ import path from 'node:path';
2
+
3
+ import { authenticateGala } from './auth-command.js';
4
+ import { authenticateGithub } from './github-auth-command.js';
5
+ import { readGalaCredential } from './gala-credential-store.js';
6
+ import { readGithubCredential } from './github-credential-store.js';
7
+ import { resolveGithubLogin } from './github-identity.js';
8
+ import { resolveInstallationId } from './gala-installation-client.js';
9
+ import { galaCredentialAccepted } from './gala-credential-health.js';
10
+ import { forgetGalaCredential } from './gala-credential-store.js';
11
+
12
+ export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
13
+
14
+ const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
15
+
16
+ /**
17
+ * Everything `scaffold` needs, worked out rather than demanded.
18
+ *
19
+ * `scaffold` used to require four values up front — `--owner`, `--repository`, `--target` and
20
+ * `--installation-id` — and it failed outright if `auth` or `auth github` had not been run first,
21
+ * telling the writer to go and run them. Three of those four are derivable and the two sign-ins
22
+ * can simply happen. Every one of them is still accepted as an explicit override; nothing that
23
+ * worked before stops working.
24
+ *
25
+ * The steps are ordered so nothing is created until everything is known: the App installation is
26
+ * confirmed before a repository exists, rather than after, so an interrupted run leaves no
27
+ * half-connected repository behind.
28
+ */
29
+ export async function prepareScaffold({
30
+ owner,
31
+ repository,
32
+ target,
33
+ githubInstallationId,
34
+ siteName,
35
+ cwd = process.cwd(),
36
+ notify = () => {},
37
+ ask,
38
+ installUrl = GITHUB_APP_INSTALL_URL,
39
+ installAttempts = 3,
40
+ readGala = readGalaCredential,
41
+ readGithub = readGithubCredential,
42
+ credentialAccepted = galaCredentialAccepted,
43
+ forgetGala = forgetGalaCredential,
44
+ signInGala = authenticateGala,
45
+ signInGithub = authenticateGithub,
46
+ resolveLogin = resolveGithubLogin,
47
+ resolveInstallation = resolveInstallationId,
48
+ apiBaseUrl = DEFAULT_API_BASE_URL
49
+ } = {}) {
50
+ const gala = await ensureGala({
51
+ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala
52
+ });
53
+ const github = await ensureGithub({ notify, readGithub, signInGithub });
54
+
55
+ const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
56
+
57
+ const resolvedRepository = repository
58
+ ?? (target == null ? null : path.basename(path.resolve(cwd, target)))
59
+ ?? repositoryNameFrom(siteName)
60
+ ?? await askForRepository(ask);
61
+
62
+ // `--target ./` is the common case and means "here", so the repository takes its name from the
63
+ // directory the writer is standing in. Everywhere else the repository names its own folder.
64
+ const resolvedTarget = target ?? `./${resolvedRepository}`;
65
+
66
+ const resolvedInstallation = githubInstallationId
67
+ ?? await ensureInstallation({
68
+ apiBaseUrl: gala.apiBaseUrl ?? apiBaseUrl,
69
+ galaAccessToken: gala.accessToken,
70
+ githubAccessToken: github.accessToken,
71
+ owner: resolvedOwner,
72
+ notify, ask, installUrl, installAttempts, resolveInstallation
73
+ });
74
+
75
+ return Object.freeze({
76
+ owner: resolvedOwner,
77
+ repository: resolvedRepository,
78
+ target: resolvedTarget,
79
+ githubInstallationId: resolvedInstallation
80
+ });
81
+ }
82
+
83
+ /**
84
+ * A missing, expired or refused credential is a step to take, not an error to report.
85
+ *
86
+ * The stored file is checked against the server before anything depends on it, because a
87
+ * credential that parses and has not expired can still be one the API refuses — and finding that
88
+ * out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
89
+ * "sign in again" turned into a stack trace.
90
+ */
91
+ async function ensureGala({ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala }) {
92
+ let stored = null;
93
+ try {
94
+ stored = await readGala();
95
+ } catch {
96
+ stored = null;
97
+ }
98
+
99
+ if (stored != null) {
100
+ const base = stored.apiBaseUrl ?? apiBaseUrl;
101
+ if (await credentialAccepted({ apiBaseUrl: base, accessToken: stored.accessToken })) {
102
+ return stored;
103
+ }
104
+ // Leaving it on disk would make every later command repeat this discovery.
105
+ await forgetGala();
106
+ notify('Your Gala sign-in is no longer valid.');
107
+ }
108
+
109
+ notify('Signing in to Gala.');
110
+ await signInGala({
111
+ apiBaseUrl,
112
+ showInstructions: ({ verificationUri, userCode }) =>
113
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
114
+ });
115
+ return readGala();
116
+ }
117
+
118
+ async function ensureGithub({ notify, readGithub, signInGithub }) {
119
+ try {
120
+ return await readGithub();
121
+ } catch {
122
+ notify('Signing in to GitHub.');
123
+ await signInGithub({
124
+ showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
125
+ showInstructions: ({ verificationUri, userCode }) =>
126
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
127
+ });
128
+ return readGithub();
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Confirms the Gala GitHub App is installed, walking the writer through installing it if not.
134
+ *
135
+ * This is the step that used to be a manual detour through GitHub's settings to copy a number out
136
+ * of a redirect URL. The loop is what makes it a step rather than a failure: the writer installs
137
+ * the App in the browser, comes back, presses enter, and the run continues.
138
+ */
139
+ async function ensureInstallation({
140
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner,
141
+ notify, ask, installUrl, installAttempts, resolveInstallation
142
+ }) {
143
+ for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
144
+ const installationId = await resolveInstallation({
145
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner
146
+ });
147
+ if (installationId != null) return installationId;
148
+
149
+ if (typeof ask !== 'function') {
150
+ throw new Error(
151
+ `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run scaffold again, `
152
+ + 'or pass --installation-id explicitly.'
153
+ );
154
+ }
155
+ notify(`The Gala GitHub App is not installed on ${owner} yet.\nOpen ${installUrl}`);
156
+ await ask('Press enter once the App is installed. ');
157
+ }
158
+ throw new Error(
159
+ `The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl}, then run scaffold again.`
160
+ );
161
+ }
162
+
163
+ /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
164
+ export function repositoryNameFrom(siteName) {
165
+ if (typeof siteName !== 'string') return null;
166
+ const slug = siteName
167
+ .trim().toLowerCase()
168
+ .replace(/[^a-z0-9._-]+/g, '-')
169
+ .replace(/^-+|-+$/g, '');
170
+ return slug === '' ? null : slug;
171
+ }
172
+
173
+ async function askForRepository(ask) {
174
+ if (typeof ask !== 'function') {
175
+ throw new TypeError('repository is required; pass --repository or --site-name');
176
+ }
177
+ const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
178
+ if (answer == null) throw new TypeError('A repository name is required');
179
+ return answer;
180
+ }