@rathnasgala/cli 1.1.15 → 1.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "1.1.15",
3
+ "version": "1.1.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -122,6 +122,26 @@ export async function selectedProfile({ name, root = credentialDirectory() } = {
122
122
  return readProfile(selected, { root });
123
123
  }
124
124
 
125
+ export async function authenticatedProfile({
126
+ name,
127
+ terminal,
128
+ root = credentialDirectory(),
129
+ reauthenticate = addProfile,
130
+ } = {}) {
131
+ try {
132
+ return await selectedProfile({ name, root });
133
+ } catch (failure) {
134
+ if (!(failure instanceof ExpiredProfileError) || !terminal?.interactive) throw failure;
135
+ terminal.step(`Account profile ${failure.profileName} expired; signing in again`);
136
+ const refreshed = await reauthenticate({ terminal, root });
137
+ if (refreshed.metadata.name !== failure.profileName) {
138
+ throw new UsageError(`This repository uses account profile ${failure.profileName}, but you signed in as ${refreshed.metadata.name}. Sign in again with the matching account.`);
139
+ }
140
+ terminal.done(`Signed in again as ${failure.profileName}`);
141
+ return refreshed;
142
+ }
143
+ }
144
+
125
145
  export async function activeProfile({ root = credentialDirectory() } = {}) {
126
146
  try {
127
147
  const value = (await readFile(path.join(root, 'active-profile'), 'utf8')).trim();
@@ -145,13 +165,18 @@ async function readProfile(name, { root }) {
145
165
  }
146
166
  const [gala, github] = await Promise.all([readCredential(paths.gala), readCredential(paths.github)]);
147
167
  if (gala == null || github == null) {
148
- throw new UsageError(
149
- `Account profile ${name} has expired; run \`npx --yes @rathnasgala/cli@latest auth add\` to sign in again.`
150
- );
168
+ throw new ExpiredProfileError(name);
151
169
  }
152
170
  return { metadata, gala, github };
153
171
  }
154
172
 
173
+ class ExpiredProfileError extends UsageError {
174
+ constructor(profileName) {
175
+ super(`Account profile ${profileName} has expired; run \`npx --yes @rathnasgala/cli@latest auth add\` to sign in again.`);
176
+ this.profileName = profileName;
177
+ }
178
+ }
179
+
155
180
  async function setActiveProfile(name, { root }) {
156
181
  await mkdir(root, { recursive: true, mode: 0o700 });
157
182
  await atomicText(path.join(root, 'active-profile'), `${requireProfileName(name)}\n`);
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { galaApi } from '../api/gala.js';
5
5
  import { accountForCommand } from '../auth/checkout-profile.js';
6
- import { selectedProfile } from '../auth/profiles.js';
6
+ import { authenticatedProfile } from '../auth/profiles.js';
7
7
  import { cliCommand } from '../cli/invocation.js';
8
8
  import { createGit } from '../git.js';
9
9
 
@@ -22,7 +22,7 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
22
22
  let selected;
23
23
  checks.push(await checkCredential('Account profile', async () => {
24
24
  const account = await accountForCommand(options, root, { terminal });
25
- selected = await selectedProfile({ name: account });
25
+ selected = await authenticatedProfile({ name: account, terminal });
26
26
  return ok(`${account}: Gala ${selected.metadata.gala.email} + GitHub @${selected.metadata.githubLogin}`);
27
27
  }));
28
28
 
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  import { galaApi } from '../api/gala.js';
4
+ import { HttpError } from '../api/http.js';
4
5
  import { accountForCommand } from '../auth/checkout-profile.js';
5
- import { selectedProfile } from '../auth/profiles.js';
6
+ import { authenticatedProfile } from '../auth/profiles.js';
6
7
  import { UsageError } from '../cli/args.js';
7
8
  import { cliCommand } from '../cli/invocation.js';
8
9
  import { customDomain } from '../domain.js';
@@ -24,7 +25,8 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
24
25
  if (action !== 'set' && value != null) throw new UsageError(`domain ${action} takes no hostname`);
25
26
 
26
27
  const account = await accountForCommand(options, root, { terminal });
27
- const credential = (await selectedProfile({ name: account })).gala;
28
+ const profile = await authenticatedProfile({ name: account, terminal });
29
+ const credential = profile.gala;
28
30
  const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
29
31
 
30
32
  if (action === 'status') {
@@ -41,13 +43,14 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
41
43
  if (action === 'set') {
42
44
  const checked = customDomain(value);
43
45
  if (checked.error) throw new UsageError(checked.error);
46
+ const site = await ownedSite(api, publication.siteId);
47
+ const owner = site.repository.split('/')[0];
44
48
  const change = await api.prepareTopologyChange(publication.siteId, {
45
49
  canonicalBaseUrl: `https://${checked.host}`,
46
50
  pathPrefix: '/',
47
51
  });
48
52
  terminal.done(`Reserved ${checked.host}`);
49
- terminal.note(`Verify it in the repository owner’s GitHub account, then run: ${cliCommand('domain check')}`);
50
- terminal.openUrl('https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages');
53
+ showVerificationSteps(terminal, checked.host, owner, profile.metadata.githubLogin);
51
54
  return change;
52
55
  }
53
56
 
@@ -81,7 +84,17 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
81
84
 
82
85
  if (action === 'check') {
83
86
  if (pending.cname && pending.state === 'PREPARED') {
84
- const configured = await api.configureTopologyChange(publication.siteId, pending.changeId);
87
+ let configured;
88
+ try {
89
+ configured = await api.configureTopologyChange(publication.siteId, pending.changeId);
90
+ } catch (failure) {
91
+ if (!(failure instanceof HttpError)
92
+ || failure.code !== 'GITHUB_PAGES_DOMAIN_VERIFICATION_REQUIRED') throw failure;
93
+ const site = await ownedSite(api, publication.siteId);
94
+ const owner = site.repository.split('/')[0];
95
+ showVerificationSteps(terminal, pending.cname, owner, profile.metadata.githubLogin);
96
+ throw new Error(`GitHub has not verified ${pending.cname} for @${owner} yet. Complete the steps above, then retry.`);
97
+ }
85
98
  terminal.done(`GitHub verified ${configured.cname}`);
86
99
  terminal.note(dnsInstruction(configured.cname, await providerHost(api, publication.siteId)));
87
100
  terminal.note(`After DNS propagates, run: ${cliCommand('domain check')}`);
@@ -100,6 +113,16 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
100
113
  throw new UsageError(`Unsupported domain action: ${action}`);
101
114
  }
102
115
 
116
+ function showVerificationSteps(terminal, host, owner, login) {
117
+ terminal.note(`GitHub owner @${owner} must verify ${host} once under Settings → Pages → Verified domains.`);
118
+ terminal.openUrl(owner.toLowerCase() === login.toLowerCase()
119
+ ? 'https://github.com/settings/pages'
120
+ : `https://github.com/organizations/${encodeURIComponent(owner)}/settings/pages`);
121
+ terminal.note(`Choose “Add a domain”, enter ${host}, and add GitHub’s TXT record at your DNS provider.`);
122
+ terminal.note(`The TXT record name will be _github-pages-challenge-${owner}.${host}; GitHub supplies its value.`);
123
+ terminal.note(`When GitHub shows “Verified”, run: ${cliCommand('domain check')}`);
124
+ }
125
+
103
126
  async function ownedSite(api, siteId) {
104
127
  const sites = await api.listPublications();
105
128
  const site = Array.isArray(sites) ? sites.find((candidate) => candidate.siteId === siteId) : null;
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { galaApi } from '../api/gala.js';
5
5
  import { githubApi } from '../api/github.js';
6
6
  import { accountForCommand, bindCheckoutProfile } from '../auth/checkout-profile.js';
7
- import { selectedProfile } from '../auth/profiles.js';
7
+ import { authenticatedProfile } from '../auth/profiles.js';
8
8
  import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
9
9
  import { UsageError } from '../cli/args.js';
10
10
  import { CLI_INVOCATION, shellArgument } from '../cli/invocation.js';
@@ -45,7 +45,7 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
45
45
 
46
46
  const destination = await inspectDestination(directory);
47
47
 
48
- const profile = await selectedProfile({ name: options.value('account') });
48
+ const profile = await authenticatedProfile({ name: options.value('account'), terminal });
49
49
  const { gala, github, metadata } = profile;
50
50
  const account = metadata.name;
51
51
  terminal.step(`Account ${account}: Gala ${metadata.gala.email} + GitHub @${metadata.githubLogin}`);
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { galaApi } from '../api/gala.js';
5
5
  import { accountForCommand } from '../auth/checkout-profile.js';
6
- import { selectedProfile } from '../auth/profiles.js';
6
+ import { authenticatedProfile } from '../auth/profiles.js';
7
7
  import { UsageError } from '../cli/args.js';
8
8
  import { cliCommand } from '../cli/invocation.js';
9
9
  import { createGit } from '../git.js';
@@ -23,7 +23,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
23
23
  throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
24
24
  }
25
25
  const account = await accountForCommand(options, root, { terminal });
26
- const credential = (await selectedProfile({ name: account })).gala;
26
+ const credential = (await authenticatedProfile({ name: account, terminal })).gala;
27
27
  const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
28
28
  const [action = 'status', ...args] = options.positional;
29
29
 
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { checkContent } from '../content.js';
4
4
  import { createGit } from '../git.js';
5
5
  import { accountForCommand } from '../auth/checkout-profile.js';
6
- import { selectedProfile } from '../auth/profiles.js';
6
+ import { authenticatedProfile } from '../auth/profiles.js';
7
7
  import { readPublication } from '../publication.js';
8
8
 
9
9
  /**
@@ -27,7 +27,7 @@ export async function publish({ terminal, options, cwd = process.cwd(), regenera
27
27
  const today = options.value('today');
28
28
 
29
29
  const account = await accountForCommand(options, root, { terminal });
30
- const github = (await selectedProfile({ name: account })).github;
30
+ const github = (await authenticatedProfile({ name: account, terminal })).github;
31
31
  const git = createGit({ root, token: github.accessToken });
32
32
 
33
33
  terminal.step('Catching up with GitHub');
package/src/git.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile, writeFile } from 'node:fs/promises';
2
4
  import path from 'node:path';
5
+ import { parse } from 'yaml';
3
6
 
4
7
  /**
5
8
  * Git, authenticated as the writer's Gala credential rather than as the machine.
@@ -41,21 +44,21 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
41
44
  * them. On failure every captured line is emitted, because that is exactly when git's text is
42
45
  * the most useful thing on screen.
43
46
  */
44
- const run = (args, { allow = [0], capture = false } = {}) => new Promise((resolve, reject) => {
47
+ const run = (args, { allow = [0], capture = false, binary = false } = {}) => new Promise((resolve, reject) => {
45
48
  const child = spawnProcess('git', ['-C', cwd, ...credentialArguments(token), ...args], {
46
49
  cwd,
47
50
  shell: false,
48
51
  stdio: ['ignore', 'pipe', 'pipe'],
49
52
  env: environmentFor(token)
50
53
  });
51
- let stdout = '';
54
+ const stdout = [];
52
55
  let stderr = '';
53
- child.stdout?.on('data', (chunk) => { stdout += chunk; });
56
+ child.stdout?.on('data', (chunk) => { stdout.push(Buffer.from(chunk)); });
54
57
  child.stderr?.on('data', (chunk) => { stderr += chunk; });
55
58
  child.once('error', reject);
56
59
  child.once('exit', (code, signal) => {
57
60
  if (signal || !allow.includes(code)) {
58
- const said = `${stdout}${stderr}`.trim();
61
+ const said = `${Buffer.concat(stdout).toString('utf8')}${stderr}`.trim();
59
62
  const failure = new Error(signal
60
63
  ? `git ${args[0]} stopped by ${signal}`
61
64
  : `git ${args[0]} exited with ${code}`);
@@ -63,7 +66,8 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
63
66
  reject(failure);
64
67
  return;
65
68
  }
66
- resolve(capture ? stdout.trim() : code);
69
+ const output = Buffer.concat(stdout);
70
+ resolve(capture ? (binary ? output : output.toString('utf8').trim()) : code);
67
71
  });
68
72
  });
69
73
 
@@ -105,9 +109,11 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
105
109
  * makes the post-publish validation pass the sole local writer of any missing content ID.
106
110
  */
107
111
  async takeRemote() {
108
- const unmerged = await run(['diff', '--name-only', '--diff-filter=U', '-z'], { capture: true });
109
- const conflictedPaths = unmerged.split('\0').filter(Boolean);
112
+ const conflictedPaths = await unmergedPaths(run);
110
113
  if (conflictedPaths.length > 0) {
114
+ if (await reconcileManagedThemeConflict(run, cwd, conflictedPaths).catch(() => false)) {
115
+ return git.takeRemote();
116
+ }
111
117
  const failure = new Error('Git has unresolved conflicts. Gala left them untouched. Run git '
112
118
  + 'status, resolve or abort the operation it reports, then publish again.');
113
119
  failure.detail = `Conflicted files:\n${conflictedPaths.join('\n')}`;
@@ -116,6 +122,17 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
116
122
  const branch = await git.branch();
117
123
  await run(['fetch', 'origin', branch]);
118
124
  await run(['rebase', '--autostash', `origin/${branch}`]);
125
+ const reappliedConflicts = await unmergedPaths(run);
126
+ if (reappliedConflicts.length > 0) {
127
+ if (await reconcileManagedThemeConflict(run, cwd, reappliedConflicts).catch(() => false)) {
128
+ return git.head();
129
+ }
130
+ const failure = new Error('Git updated from GitHub, but could not reapply your local work '
131
+ + 'without conflicts. Gala left every file untouched. Run git status, resolve the named '
132
+ + 'files, git add them, then publish again.');
133
+ failure.detail = `Conflicted files:\n${reappliedConflicts.join('\n')}`;
134
+ throw failure;
135
+ }
119
136
  return git.head();
120
137
  }
121
138
  };
@@ -123,6 +140,57 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
123
140
  return git;
124
141
  }
125
142
 
143
+ async function reconcileManagedThemeConflict(run, root, conflictedPaths) {
144
+ const manifestPath = '.gala/managed-files.json';
145
+ if (!conflictedPaths.includes(manifestPath)) return false;
146
+ let manifest;
147
+ try {
148
+ manifest = JSON.parse((await run(
149
+ ['show', `:3:${manifestPath}`], { capture: true, binary: true }
150
+ )).toString('utf8'));
151
+ } catch {
152
+ return false;
153
+ }
154
+ if (manifest?.schemaVersion !== 1 || typeof manifest.files !== 'object') return false;
155
+ const managedConflicts = conflictedPaths.filter((entry) => entry !== manifestPath
156
+ && entry !== 'site.config.yml');
157
+ if (managedConflicts.length + 2 !== conflictedPaths.length) return false;
158
+ for (const managed of managedConflicts) {
159
+ const expected = manifest.files[managed];
160
+ if (!/^[0-9a-f]{64}$/.test(expected ?? '')) return false;
161
+ const local = await run(['show', `:3:${managed}`], { capture: true, binary: true });
162
+ if (createHash('sha256').update(local).digest('hex') !== expected) return false;
163
+ }
164
+ const worktreeConfig = await readFile(path.join(root, 'site.config.yml'), 'utf8');
165
+ const resolvedConfig = selectStashedConflictSections(worktreeConfig);
166
+ let configuration;
167
+ try {
168
+ configuration = parse(resolvedConfig);
169
+ } catch {
170
+ return false;
171
+ }
172
+ if (configuration?.framework?.themePackage?.name !== manifest.themePackage?.name
173
+ || configuration?.framework?.themePackage?.version !== manifest.themePackage?.version) return false;
174
+ await run(['checkout', '--theirs', '--', manifestPath, ...managedConflicts]);
175
+ await writeFile(path.join(root, 'site.config.yml'), resolvedConfig);
176
+ await run(['add', '--', ...conflictedPaths]);
177
+ return (await unmergedPaths(run)).length === 0;
178
+ }
179
+
180
+ function selectStashedConflictSections(value) {
181
+ return value.replace(
182
+ /^<<<<<<< Updated upstream\n[\s\S]*?^=======\n([\s\S]*?)^>>>>>>> Stashed changes\n/gm,
183
+ '$1'
184
+ );
185
+ }
186
+
187
+ async function unmergedPaths(run) {
188
+ const unmerged = await run(
189
+ ['diff', '--name-only', '--diff-filter=U', '-z'], { capture: true }
190
+ );
191
+ return unmerged.split('\0').filter(Boolean);
192
+ }
193
+
126
194
  export function cloneRepository({ url, target, token, spawnProcess = spawn }) {
127
195
  const resolved = path.resolve(target);
128
196
  return new Promise((resolve, reject) => {