@rathnasgala/cli 0.0.12 → 0.0.13

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.13",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,75 @@
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.ok) {
32
+ throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
33
+ }
34
+ const payload = await response.json();
35
+ if (typeof payload?.authorization !== 'string') {
36
+ throw new TypeError('GitHub authorization exchange returned no capability');
37
+ }
38
+ return payload.authorization;
39
+ }
40
+
41
+ export async function listAuthorizedRepositories({ apiBaseUrl, authorization, fetchImpl = fetch }) {
42
+ const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
43
+ headers: { accept: 'application/json', 'GitHub-Authorization': authorization }
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
47
+ }
48
+ const payload = await response.json();
49
+ if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
50
+ return payload;
51
+ }
52
+
53
+ /**
54
+ * Returns the installation covering `owner`, or null when the App is not installed there.
55
+ *
56
+ * An installation belongs to an account, not to one repository, so any repository the App can
57
+ * already see under that owner carries the id the new one will use. Null is an ordinary answer —
58
+ * it means "not installed yet" — and the caller turns it into an instruction, not an error.
59
+ */
60
+ export async function resolveInstallationId({
61
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
62
+ exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
63
+ }) {
64
+ const authorization = await exchange({
65
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
66
+ });
67
+ const repositories = await list({ apiBaseUrl, authorization, fetchImpl });
68
+ const wanted = String(owner).toLowerCase();
69
+ for (const repository of repositories) {
70
+ if (String(repository?.owner).toLowerCase() !== wanted) continue;
71
+ const installationId = Number(repository?.installationId);
72
+ if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
73
+ }
74
+ return null;
75
+ }
@@ -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,6 +20,7 @@ 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';
@@ -77,17 +78,37 @@ if (command === 'auth') {
77
78
  const index = args.indexOf(name);
78
79
  return index === -1 ? undefined : args[index + 1];
79
80
  };
80
- const installationId = Number(valueFor('--installation-id'));
81
+ const explicitInstallationId = valueFor('--installation-id');
81
82
  const topology = valueFor('--topology') ?? 'provider-default';
82
- const result = await scaffoldSite({
83
+ const siteOptions = parseScaffoldOptions(args);
84
+ // Prompting only makes sense at a terminal. In CI there is nobody to answer, so a missing value
85
+ // has to stay a clear error rather than a process that hangs waiting for enter.
86
+ const interactive = process.stdin.isTTY === true;
87
+ const ask = interactive
88
+ ? async (question) => {
89
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
90
+ try { return await terminal.question(question); } finally { terminal.close(); }
91
+ }
92
+ : undefined;
93
+ const prepared = await prepareScaffold({
83
94
  owner: valueFor('--owner'),
84
95
  repository: valueFor('--repository'),
85
96
  target: valueFor('--target'),
86
- githubInstallationId: installationId,
97
+ githubInstallationId: explicitInstallationId == null ? undefined : Number(explicitInstallationId),
98
+ siteName: siteOptions.siteName,
99
+ apiBaseUrl: valueFor('--api-base-url') ?? 'https://api.gala67.com',
100
+ notify: (message) => process.stdout.write(`${message}\n`),
101
+ ask
102
+ });
103
+ const result = await scaffoldSite({
104
+ owner: prepared.owner,
105
+ repository: prepared.repository,
106
+ target: prepared.target,
107
+ githubInstallationId: prepared.githubInstallationId,
87
108
  topology,
88
109
  canonicalBaseUrl: valueFor('--canonical-base-url'),
89
110
  actionRef: valueFor('--action-ref'),
90
- siteOptions: parseScaffoldOptions(args),
111
+ siteOptions,
91
112
  buildMode: valueFor('--mode') ?? 'build-and-deploy',
92
113
  emptyExistingRepository: args.includes('--empty-existing-repository'),
93
114
  resumeExistingCheckout: args.includes('--resume')
@@ -0,0 +1,154 @@
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
+
10
+ export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
11
+
12
+ const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
13
+
14
+ /**
15
+ * Everything `scaffold` needs, worked out rather than demanded.
16
+ *
17
+ * `scaffold` used to require four values up front — `--owner`, `--repository`, `--target` and
18
+ * `--installation-id` — and it failed outright if `auth` or `auth github` had not been run first,
19
+ * telling the writer to go and run them. Three of those four are derivable and the two sign-ins
20
+ * can simply happen. Every one of them is still accepted as an explicit override; nothing that
21
+ * worked before stops working.
22
+ *
23
+ * The steps are ordered so nothing is created until everything is known: the App installation is
24
+ * confirmed before a repository exists, rather than after, so an interrupted run leaves no
25
+ * half-connected repository behind.
26
+ */
27
+ export async function prepareScaffold({
28
+ owner,
29
+ repository,
30
+ target,
31
+ githubInstallationId,
32
+ siteName,
33
+ cwd = process.cwd(),
34
+ notify = () => {},
35
+ ask,
36
+ installUrl = GITHUB_APP_INSTALL_URL,
37
+ installAttempts = 3,
38
+ readGala = readGalaCredential,
39
+ readGithub = readGithubCredential,
40
+ signInGala = authenticateGala,
41
+ signInGithub = authenticateGithub,
42
+ resolveLogin = resolveGithubLogin,
43
+ resolveInstallation = resolveInstallationId,
44
+ apiBaseUrl = DEFAULT_API_BASE_URL
45
+ } = {}) {
46
+ const gala = await ensureGala({ apiBaseUrl, notify, readGala, signInGala });
47
+ const github = await ensureGithub({ notify, readGithub, signInGithub });
48
+
49
+ const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
50
+
51
+ const resolvedRepository = repository
52
+ ?? (target == null ? null : path.basename(path.resolve(cwd, target)))
53
+ ?? repositoryNameFrom(siteName)
54
+ ?? await askForRepository(ask);
55
+
56
+ // `--target ./` is the common case and means "here", so the repository takes its name from the
57
+ // directory the writer is standing in. Everywhere else the repository names its own folder.
58
+ const resolvedTarget = target ?? `./${resolvedRepository}`;
59
+
60
+ const resolvedInstallation = githubInstallationId
61
+ ?? await ensureInstallation({
62
+ apiBaseUrl: gala.apiBaseUrl ?? apiBaseUrl,
63
+ galaAccessToken: gala.accessToken,
64
+ githubAccessToken: github.accessToken,
65
+ owner: resolvedOwner,
66
+ notify, ask, installUrl, installAttempts, resolveInstallation
67
+ });
68
+
69
+ return Object.freeze({
70
+ owner: resolvedOwner,
71
+ repository: resolvedRepository,
72
+ target: resolvedTarget,
73
+ githubInstallationId: resolvedInstallation
74
+ });
75
+ }
76
+
77
+ /** A missing or expired credential is a step to take, not an error to report. */
78
+ async function ensureGala({ apiBaseUrl, notify, readGala, signInGala }) {
79
+ try {
80
+ return await readGala();
81
+ } catch {
82
+ notify('Signing in to Gala.');
83
+ await signInGala({
84
+ apiBaseUrl,
85
+ showInstructions: ({ verificationUri, userCode }) =>
86
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
87
+ });
88
+ return readGala();
89
+ }
90
+ }
91
+
92
+ async function ensureGithub({ notify, readGithub, signInGithub }) {
93
+ try {
94
+ return await readGithub();
95
+ } catch {
96
+ notify('Signing in to GitHub.');
97
+ await signInGithub({
98
+ showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
99
+ showInstructions: ({ verificationUri, userCode }) =>
100
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
101
+ });
102
+ return readGithub();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Confirms the Gala GitHub App is installed, walking the writer through installing it if not.
108
+ *
109
+ * This is the step that used to be a manual detour through GitHub's settings to copy a number out
110
+ * of a redirect URL. The loop is what makes it a step rather than a failure: the writer installs
111
+ * the App in the browser, comes back, presses enter, and the run continues.
112
+ */
113
+ async function ensureInstallation({
114
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner,
115
+ notify, ask, installUrl, installAttempts, resolveInstallation
116
+ }) {
117
+ for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
118
+ const installationId = await resolveInstallation({
119
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner
120
+ });
121
+ if (installationId != null) return installationId;
122
+
123
+ if (typeof ask !== 'function') {
124
+ throw new Error(
125
+ `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run scaffold again, `
126
+ + 'or pass --installation-id explicitly.'
127
+ );
128
+ }
129
+ notify(`The Gala GitHub App is not installed on ${owner} yet.\nOpen ${installUrl}`);
130
+ await ask('Press enter once the App is installed. ');
131
+ }
132
+ throw new Error(
133
+ `The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl}, then run scaffold again.`
134
+ );
135
+ }
136
+
137
+ /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
138
+ export function repositoryNameFrom(siteName) {
139
+ if (typeof siteName !== 'string') return null;
140
+ const slug = siteName
141
+ .trim().toLowerCase()
142
+ .replace(/[^a-z0-9._-]+/g, '-')
143
+ .replace(/^-+|-+$/g, '');
144
+ return slug === '' ? null : slug;
145
+ }
146
+
147
+ async function askForRepository(ask) {
148
+ if (typeof ask !== 'function') {
149
+ throw new TypeError('repository is required; pass --repository or --site-name');
150
+ }
151
+ const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
152
+ if (answer == null) throw new TypeError('A repository name is required');
153
+ return answer;
154
+ }