@rathnasgala/cli 1.1.8 → 1.1.10

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
@@ -1,6 +1,6 @@
1
1
  # Gala CLI
2
2
 
3
- Create a publication, write posts, preview them, and publish from your terminal.
3
+ Create a publication, write posts, preview them, and publish - from your terminal.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -39,7 +39,7 @@ On the first run, it opens GitHub's Gala App installation page. Choose the accou
39
39
  finish the installation, and return to the terminal; the same command resumes automatically.
40
40
 
41
41
  If the Gala GitHub App has not been given access to the new repository, it says so and links to the
42
- one page that grants it GitHub has no way for an app to grant itself access, so that click is
42
+ one page that grants it - GitHub has no way for an app to grant itself access, so that click is
43
43
  unavoidable. Everything else is automatic.
44
44
 
45
45
  ## Write
@@ -63,7 +63,7 @@ npx --yes @rathnasgala/cli@latest preview
63
63
  ```
64
64
 
65
65
  Builds the publication and serves it locally, using the exact framework version the repository is
66
- pinned to so what you see is what gets published. The first run installs that tooling, which takes
66
+ pinned to - so what you see is what gets published. The first run installs that tooling, which takes
67
67
  a moment. Stop it with Ctrl-C.
68
68
 
69
69
  ```console
@@ -118,7 +118,7 @@ npx --yes @rathnasgala/cli@latest upgrade
118
118
 
119
119
  Reports on your sign-ins, the publication folder, the publishing workflow, and anything you have
120
120
  written but not sent. Each check either passes, names what is wrong and how to fix it, or says it
121
- could not be determined never one of those disguised as another.
121
+ could not be determined - never one of those disguised as another.
122
122
 
123
123
  ## Commands
124
124
 
@@ -134,13 +134,13 @@ Run any command with `--help`.
134
134
  | `prism` | Manage author-approved reading configurations | `--root`, `--language`, `--depth`, `--intent`, `--modality`, `--file`, `--reason`, `--yes` |
135
135
  | `upgrade` | Inspect and apply a verified managed-theme update | `--root`, `--channel`, `--yes` |
136
136
  | `doctor` | Check a publication and say what is wrong | `--root` |
137
- | `auth` | Sign in to Gala and GitHub | |
137
+ | `auth` | Sign in to Gala and GitHub | - |
138
138
 
139
139
  `auth` is never a prerequisite you have to remember: any command that needs a credential obtains
140
- one. It exists for when you want to do it deliberately a new machine, or a different account.
140
+ one. It exists for when you want to do it deliberately - a new machine, or a different account.
141
141
 
142
142
  Every command prompts for what it needs when run in a terminal, and every prompt has an option that
143
- supplies it instead. With no terminal attached in CI nothing is ever prompted for: a value that
143
+ supplies it instead. With no terminal attached - in CI - nothing is ever prompted for: a value that
144
144
  cannot be worked out is an error naming the option, so an automated run fails immediately rather
145
145
  than waiting for an answer that will not come.
146
146
 
@@ -155,7 +155,7 @@ directory, with private file permissions. Your GitHub sign-in expires after eigh
155
155
  asks you to sign in again rather than quietly using a credential the server will refuse.
156
156
 
157
157
  Git operations use that same sign-in, not whatever credential your machine happens to have
158
- configured so publishing works on a machine where those differ, or where none is configured.
158
+ configured - so publishing works on a machine where those differ, or where none is configured.
159
159
 
160
160
  ## Troubleshooting
161
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
package/src/api/gala.js CHANGED
@@ -3,8 +3,8 @@ import { request, requestJson } from './http.js';
3
3
  /**
4
4
  * The Gala API.
5
5
  *
6
- * Only the calls the six commands make. v0 carried a generated client covering the whole surface
7
- * comments, reactions, admin, moderation none of which a CLI ever touches.
6
+ * Only the calls the six commands make. v0 carried a generated client covering the whole surface -
7
+ * comments, reactions, admin, moderation - none of which a CLI ever touches.
8
8
  */
9
9
  export const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
10
10
 
package/src/api/github.js CHANGED
@@ -5,7 +5,7 @@ import { requestJson } from './http.js';
5
5
  *
6
6
  * The CLI holds a GitHub App user token, not an OAuth App token. That single difference is what
7
7
  * separates this from v0: an App token can list installations, is not blocked by an organisation's
8
- * OAuth App restrictions, and reaches only repositories the App has been given rather than every
8
+ * OAuth App restrictions, and reaches only repositories the App has been given - rather than every
9
9
  * repository the writer can see, which is what `repo` scope meant.
10
10
  */
11
11
  const API = 'https://api.github.com';
@@ -33,7 +33,7 @@ export function githubApi(token) {
33
33
  * Whether a repository has content yet.
34
34
  *
35
35
  * Creating a repository is asynchronous: GitHub answers with a clone URL before the template
36
- * lands. Cloning into that window produces an empty checkout and a missing site.config.yml
36
+ * lands. Cloning into that window produces an empty checkout and a missing site.config.yml -
37
37
  * a confusing error about a file the template certainly contains. `size` is not usable as the
38
38
  * signal; GitHub still reported 0 for a repository that already had commits.
39
39
  */
package/src/api/http.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Every HTTP call the CLI makes, and every failure it reports.
3
3
  *
4
- * v0 threw away the response body at all twenty failure sites `failed with HTTP 403` and nothing
4
+ * v0 threw away the response body at all twenty failure sites - `failed with HTTP 403` and nothing
5
5
  * else. GitHub explains which 403 it is in that body and nowhere else: an organisation's OAuth App
6
6
  * restrictions, a missing permission, a rename and a rate limit all arrive as 403 with a sentence
7
7
  * telling them apart. Diagnosing anything meant guessing between causes the server had already
@@ -67,6 +67,6 @@ function describe(body) {
67
67
  if (parts.length === 0 && typeof body.raw === 'string') parts.push(body.raw);
68
68
  if (typeof body.documentation_url === 'string') parts.push(`See ${body.documentation_url}`);
69
69
 
70
- const detail = parts.join(' ').replace(/\s+/g, ' ').trim();
70
+ const detail = parts.join(' - ').replace(/\s+/g, ' ').trim();
71
71
  return detail.length > MAX_DETAIL ? `${detail.slice(0, MAX_DETAIL)}…` : detail;
72
72
  }
package/src/auth/gala.js CHANGED
@@ -5,7 +5,7 @@ import { credentialPath, forgetCredential, readCredential, writeCredential } fro
5
5
  * The Gala sign-in.
6
6
  *
7
7
  * A stored credential is checked against the server before anything depends on it, because one
8
- * that parses and has not expired can still be one the API refuses and discovering that four
8
+ * that parses and has not expired can still be one the API refuses - and discovering that four
9
9
  * calls later, as an opaque 401 from whichever endpoint got there first, is how a "sign in again"
10
10
  * became a stack trace.
11
11
  */
@@ -26,7 +26,7 @@ export async function githubCredential({
26
26
  if (stored != null) return stored;
27
27
 
28
28
  // No scopes. A GitHub App's permissions are fixed on the app and granted when the writer installs
29
- // it, so there is nothing to negotiate and nothing to warn them about, which is why the broad
29
+ // it, so there is nothing to negotiate - and nothing to warn them about, which is why the broad
30
30
  // "read/write on every repository you can access" notice is gone.
31
31
  const authorization = await post(DEVICE_CODE_URL, { client_id: clientId }, 'GitHub sign-in');
32
32
  terminal.step('Sign in to GitHub');
@@ -37,7 +37,7 @@ export async function githubCredential({
37
37
  await writeCredential(target, {
38
38
  accessToken: token.access_token,
39
39
  // The app expires user tokens after eight hours and issues a refresh token with each. Exchanging
40
- // one needs the app's client secret, which a published CLI cannot hold so it is kept for the
40
+ // one needs the app's client secret, which a published CLI cannot hold - so it is kept for the
41
41
  // API-side refresh, and until that exists an expired credential asks for one sign-in rather
42
42
  // than failing as an unexplained 401 somewhere deeper.
43
43
  ...(token.expires_in ? { expiresAt: new Date(now() + token.expires_in * 1000).toISOString() } : {}),
package/src/auth/store.js CHANGED
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  * Two rules matter more than the storage itself, both learned the hard way:
9
9
  *
10
10
  * A credential is only valid if the server still accepts it. v0 checked expiry and nothing else, so
11
- * a token the API had already decided to refuse it stopped issuing a claim these carried was
11
+ * a token the API had already decided to refuse - it stopped issuing a claim these carried - was
12
12
  * handed out for weeks, and every command failed as an unexplained 401 several calls deep.
13
13
  *
14
14
  * A credential whose shape has changed is not upgradable in place. Bumping `schemaVersion` and
@@ -0,0 +1,9 @@
1
+ export const CLI_INVOCATION = 'npx --yes @rathnasgala/cli@latest';
2
+
3
+ export function cliCommand(argumentsText = '') {
4
+ return argumentsText === '' ? CLI_INVOCATION : `${CLI_INVOCATION} ${argumentsText}`;
5
+ }
6
+
7
+ export function shellArgument(value) {
8
+ return /^[A-Za-z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
9
+ }
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  * v0 wrote to stdout from a dozen modules with no shared shape, so a scaffold emitted raw git
8
8
  * output, bare status codes and half-sentences in whatever order they happened to occur. The
9
9
  * failure mode that cost the most was subtler: prompts and browser launches were decided
10
- * separately in each module, so behaviour with no terminal attached varied by code path some
10
+ * separately in each module, so behaviour with no terminal attached varied by code path - some
11
11
  * hung, some crashed, some silently skipped.
12
12
  *
13
13
  * One object, created once, knows whether there is a terminal. Nothing else has to ask.
@@ -50,7 +50,7 @@ export function createTerminal({
50
50
  async ask(question, { fallback } = {}) {
51
51
  if (!interactive) {
52
52
  if (fallback !== undefined) return fallback;
53
- throw new Error(`${question} no terminal to ask; pass it as an option instead`);
53
+ throw new Error(`${question} - no terminal to ask; pass it as an option instead`);
54
54
  }
55
55
  const reader = createInterface({ input, output });
56
56
  try {
@@ -5,7 +5,7 @@ import { githubCredential } from '../auth/github.js';
5
5
  * Signs in to both, and says so.
6
6
  *
7
7
  * Not a prerequisite the writer has to remember: every command that needs a credential obtains one.
8
- * This exists for the times they want to do it deliberately a new machine, a different account,
8
+ * This exists for the times they want to do it deliberately - a new machine, a different account,
9
9
  * or after a token has expired.
10
10
  */
11
11
  export async function auth({ terminal, options }) {
@@ -13,8 +13,8 @@ export async function auth({ terminal, options }) {
13
13
  const github = await githubCredential({ terminal });
14
14
 
15
15
  terminal.blank();
16
- terminal.done(`Gala valid until ${new Date(gala.expiresAt).toLocaleString()}`);
16
+ terminal.done(`Gala - valid until ${new Date(gala.expiresAt).toLocaleString()}`);
17
17
  terminal.done(github.expiresAt
18
- ? `GitHub valid until ${new Date(github.expiresAt).toLocaleString()}`
19
- : 'GitHub signed in');
18
+ ? `GitHub - valid until ${new Date(github.expiresAt).toLocaleString()}`
19
+ : 'GitHub - signed in');
20
20
  }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { galaApi } from '../api/gala.js';
5
5
  import { galaCredential } from '../auth/gala.js';
6
6
  import { githubCredential } from '../auth/github.js';
7
+ import { cliCommand } from '../cli/invocation.js';
7
8
  import { createGit } from '../git.js';
8
9
 
9
10
  /**
@@ -11,7 +12,7 @@ import { createGit } from '../git.js';
11
12
  *
12
13
  * Every check reports one of three things and never guesses between them: it is fine, it is wrong
13
14
  * and here is the fix, or it could not be determined. That third state is the one v0 kept
14
- * collapsing into the second an unreachable GitHub reported as "the App is not installed" sent
15
+ * collapsing into the second - an unreachable GitHub reported as "the App is not installed" sent
15
16
  * writers to install something that was already installed, repeatedly.
16
17
  */
17
18
  export async function doctor({ terminal, options, cwd = process.cwd() }) {
@@ -23,7 +24,7 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
23
24
  const accepted = await galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken }).accepted();
24
25
  return accepted
25
26
  ? ok(`valid until ${new Date(gala.expiresAt).toLocaleString()}`)
26
- : wrong('the API no longer accepts it', 'gala auth');
27
+ : wrong('the API no longer accepts it', cliCommand('auth'));
27
28
  }));
28
29
 
29
30
  checks.push(await checkCredential('GitHub sign-in', async () => {
@@ -45,7 +46,7 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
45
46
  const source = await readFile(workflow, 'utf8');
46
47
  const siteId = /site-id:\s*([0-9A-Z]{26})/.exec(source)?.[1];
47
48
  return siteId == null
48
- ? wrong('no site id this publication may not be registered', 'gala init')
49
+ ? wrong('no site id - this publication may not be registered', cliCommand('init'))
49
50
  : ok(siteId);
50
51
  }, 'the workflow is missing; registration writes it'));
51
52
 
@@ -53,16 +54,16 @@ export async function doctor({ terminal, options, cwd = process.cwd() }) {
53
54
  const git = createGit({ root });
54
55
  const dirty = await git.run(['status', '--porcelain'], { capture: true });
55
56
  const ahead = await git.run(['rev-list', '--count', '@{upstream}..HEAD'], { capture: true, allow: [0, 128] });
56
- if (dirty !== '') return wrong(`${dirty.split('\n').length} file(s) not recorded`, 'gala publish');
57
- if (ahead !== '' && ahead !== '0') return wrong(`${ahead} commit(s) not sent`, 'gala publish');
57
+ if (dirty !== '') return wrong(`${dirty.split('\n').length} file(s) not recorded`, cliCommand('publish'));
58
+ if (ahead !== '' && ahead !== '0') return wrong(`${ahead} commit(s) not sent`, cliCommand('publish'));
58
59
  return ok('everything is on GitHub');
59
60
  }, 'this folder is not a git checkout'));
60
61
 
61
62
  terminal.blank();
62
63
  for (const { name, state, detail, fix } of checks) {
63
- if (state === 'ok') terminal.done(`${name} ${detail}`);
64
- else if (state === 'wrong') terminal.fail(`${name} ${detail}`);
65
- else terminal.step(`${name} could not be determined: ${detail}`);
64
+ if (state === 'ok') terminal.done(`${name} - ${detail}`);
65
+ else if (state === 'wrong') terminal.fail(`${name} - ${detail}`);
66
+ else terminal.step(`${name} - could not be determined: ${detail}`);
66
67
  if (fix) terminal.note(fix);
67
68
  }
68
69
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { galaApi } from '../api/gala.js';
4
4
  import { galaCredential } from '../auth/gala.js';
5
5
  import { UsageError } from '../cli/args.js';
6
+ import { cliCommand } from '../cli/invocation.js';
6
7
  import { customDomain } from '../domain.js';
7
8
  import { readPublication } from '../publication.js';
8
9
 
@@ -16,7 +17,7 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
16
17
  }
17
18
  const [action = 'status', value, ...extra] = options.positional;
18
19
  if (extra.length > 0 || !['status', 'set', 'check', 'cancel', 'remove'].includes(action)) {
19
- throw new UsageError('Use: gala domain [status|set <hostname>|check|cancel|remove]');
20
+ throw new UsageError(`Use: ${cliCommand('domain [status|set <hostname>|check|cancel|remove]')}`);
20
21
  }
21
22
  if (action === 'set' && value == null) throw new UsageError('domain set needs a hostname');
22
23
  if (action !== 'set' && value != null) throw new UsageError(`domain ${action} takes no hostname`);
@@ -45,7 +46,7 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
45
46
  pathPrefix: '/',
46
47
  });
47
48
  terminal.done(`Reserved ${checked.host}`);
48
- terminal.note('Verify it in the repository owner’s GitHub account, then run: gala domain check');
49
+ terminal.note(`Verify it in the repository owner’s GitHub account, then run: ${cliCommand('domain check')}`);
49
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');
50
51
  return change;
51
52
  }
@@ -83,7 +84,7 @@ export async function domain({ terminal, options, cwd = process.cwd() }) {
83
84
  const configured = await api.configureTopologyChange(publication.siteId, pending.changeId);
84
85
  terminal.done(`GitHub verified ${configured.cname}`);
85
86
  terminal.note(dnsInstruction(configured.cname, await providerHost(api, publication.siteId)));
86
- terminal.note('After DNS propagates, run: gala domain check');
87
+ terminal.note(`After DNS propagates, run: ${cliCommand('domain check')}`);
87
88
  return configured;
88
89
  }
89
90
  const committed = await api.commitTopologyChange(publication.siteId, pending.changeId);
@@ -119,6 +120,6 @@ function dnsInstruction(host, target) {
119
120
  function showPending(terminal, pending) {
120
121
  terminal.result(`${pending.canonicalBaseUrl}${pending.pathPrefix}`);
121
122
  terminal.note(`State: ${pending.state}`);
122
- if (pending.state === 'PREPARED') terminal.note('Next: verify ownership, then run gala domain check.');
123
- else terminal.note('Next: configure DNS, then run gala domain check.');
123
+ if (pending.state === 'PREPARED') terminal.note(`Next: verify ownership, then run ${cliCommand('domain check')}.`);
124
+ else terminal.note(`Next: configure DNS, then run ${cliCommand('domain check')}.`);
124
125
  }
@@ -7,6 +7,7 @@ import { galaCredential } from '../auth/gala.js';
7
7
  import { githubCredential } from '../auth/github.js';
8
8
  import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
9
9
  import { UsageError } from '../cli/args.js';
10
+ import { CLI_INVOCATION, shellArgument } from '../cli/invocation.js';
10
11
  import { customDomain } from '../domain.js';
11
12
 
12
13
  /**
@@ -21,7 +22,7 @@ import { customDomain } from '../domain.js';
21
22
  * which is why repositories it created never appeared in the web UI.
22
23
  * - The **server** writes `site.config.yml` and the publish workflow during registration. The CLI
23
24
  * used to write its own versions afterwards and commit them, producing a second commit whose
24
- * entire content was rewriting one line and stripping comments and a second workflow run that
25
+ * entire content was rewriting one line and stripping comments - and a second workflow run that
25
26
  * collided with the first one's deployment record and failed.
26
27
  * - **GitHub** turns on Pages by itself once publishing creates a `gh-pages` branch. The CLI used
27
28
  * to poll ten minutes for a run it had caused, then call an API that changed nothing.
@@ -119,7 +120,7 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
119
120
  * Creation is a conversation, not a single call.
120
121
  *
121
122
  * `NEEDS_SHARING` means the repository exists with the right content and the App installation
122
- * simply cannot see it an installation scoped to selected repositories, which is the right way to
123
+ * simply cannot see it - an installation scoped to selected repositories, which is the right way to
123
124
  * have it. That is one grant away from working, and GitHub offers no API to do it on the writer's
124
125
  * behalf: adding a repository to an installation is documented as classic-PAT-only. So it is asked
125
126
  * for, with a link to the one page that grants it.
@@ -151,7 +152,7 @@ async function createPublication({ terminal, api, capability, name, github, inst
151
152
  const owner = result?.owner ?? '';
152
153
  terminal.blank();
153
154
  terminal.step(`${owner}/${result?.name ?? name} exists, but Gala cannot reach it yet`);
154
- terminal.note('its installation covers only selected repositories add this one');
155
+ terminal.note('its installation covers only selected repositories - add this one');
155
156
  terminal.openUrl(result?.recoveryUrl
156
157
  ?? installationUrl(result?.installationId, owner, await viewerOf(github)));
157
158
  if (!await terminal.waitForEnter('Once Gala can access it')) {
@@ -217,7 +218,7 @@ export function installationUrl(installationId, owner, selfLogin) {
217
218
 
218
219
  /**
219
220
  * GitHub answers the creation call before the template content lands. Cloning into that window
220
- * gives an empty checkout and a missing site.config.yml a confusing error about a file the
221
+ * gives an empty checkout and a missing site.config.yml - a confusing error about a file the
221
222
  * template certainly contains.
222
223
  */
223
224
  async function waitForContent(github, owner, name, { attempts = 30, intervalMs = 1000 } = {}) {
@@ -233,7 +234,7 @@ async function publicationName({ terminal, explicitName, directory }) {
233
234
  const answer = proposed ?? await terminal.ask('What should this publication be called?');
234
235
  const name = slugify(answer);
235
236
  if (name == null) {
236
- throw new UsageError(`"${answer}" cannot be a repository name use letters, numbers and hyphens`);
237
+ throw new UsageError(`"${answer}" cannot be a repository name - use letters, numbers and hyphens`);
237
238
  }
238
239
  return name;
239
240
  }
@@ -291,7 +292,7 @@ export function reportCreatedPublication({
291
292
  directoryLabel,
292
293
  hasDomainChange = false
293
294
  }) {
294
- const run = 'npx --yes @rathnasgala/cli@latest';
295
+ const run = CLI_INVOCATION;
295
296
  terminal.done(`Created ${owner}/${name}`);
296
297
  terminal.note('The first deployment is running in GitHub Actions. The public site is not live yet.');
297
298
  terminal.note(`track it at https://github.com/${owner}/${name}/actions`);
@@ -313,10 +314,6 @@ export function reportCreatedPublication({
313
314
  terminal.note('lists every available command');
314
315
  }
315
316
 
316
- function shellArgument(value) {
317
- return /^[A-Za-z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
318
- }
319
-
320
317
  function idempotencyKey(owner, name) {
321
318
  return `init-${createHash('sha256').update(`${owner.toLowerCase()}/${name.toLowerCase()}`).digest('hex')}`;
322
319
  }
@@ -11,6 +11,7 @@ import {
11
11
  import { stringify } from 'yaml';
12
12
 
13
13
  import { UsageError } from '../cli/args.js';
14
+ import { cliCommand } from '../cli/invocation.js';
14
15
  import { postUrl, readPublication } from '../publication.js';
15
16
 
16
17
  /**
@@ -54,7 +55,7 @@ export async function createPost({ terminal, options, cwd = process.cwd(), now =
54
55
  if (address != null) terminal.note(`will appear at ${address}`);
55
56
 
56
57
  terminal.blank();
57
- terminal.note('write below the second --- line, then: npx --yes @rathnasgala/cli@latest preview');
58
+ terminal.note(`write below the second --- line, then: ${cliCommand('preview')}`);
58
59
  return { file, metadata };
59
60
  }
60
61
 
@@ -9,7 +9,7 @@ import { readPublication } from '../publication.js';
9
9
  * Builds the site and serves it locally.
10
10
  *
11
11
  * Eleventy is run from the publication's own `node_modules`, so the preview uses the exact
12
- * framework version the repository is pinned to the same one the publish workflow will use. A
12
+ * framework version the repository is pinned to - the same one the publish workflow will use. A
13
13
  * preview that agrees with the local machine but not with production is worse than no preview.
14
14
  *
15
15
  * That pin is also why this installs dependencies when they are missing. A freshly cloned
@@ -24,21 +24,23 @@ export async function preview({
24
24
  const today = options.value('today');
25
25
 
26
26
  terminal.step('Checking content');
27
- await checkContent({ terminal, root, today, ...(regenerate == null ? {} : { regenerate }) });
27
+ await checkContent({
28
+ terminal, root, today, preview: true, ...(regenerate == null ? {} : { regenerate })
29
+ });
28
30
  terminal.done('Content is valid');
29
31
 
30
32
  const eleventy = path.join(root, 'node_modules', '@11ty', 'eleventy', 'cmd.cjs');
31
33
  if (!await exists(eleventy)) {
32
- terminal.step('Installing what this publication needs first time only');
34
+ terminal.step('Installing what this publication needs - first time only');
33
35
  await install(root, spawnProcess);
34
36
  if (!await exists(eleventy)) {
35
- throw new Error('The preview tooling is still missing after installing. Check package.json.');
37
+ throw new Error('Gala installed the publication dependencies, but the preview tool is still missing. Restore the managed package.json and package-lock.json files, then run preview again.');
36
38
  }
37
39
  terminal.done('Installed');
38
40
  }
39
41
 
40
42
  const publication = await readPublication(root);
41
- terminal.step('Starting the preview stop it with Ctrl-C');
43
+ terminal.step('Starting the preview - stop it with Ctrl-C');
42
44
  if (publication != null) terminal.note(`this is ${publication.name ?? 'your publication'} as it will look`);
43
45
  terminal.blank();
44
46
 
@@ -50,12 +52,17 @@ export async function preview({
50
52
  });
51
53
 
52
54
  return new Promise((resolve, reject) => {
53
- child.once('error', reject);
55
+ child.once('error', (error) => {
56
+ reject(new Error(`The preview could not start: ${error.message}`));
57
+ });
54
58
  child.once('exit', (code, signal) => {
55
59
  // Ctrl-C is how a writer stops a preview; it is not a failure to report.
56
60
  if (signal === 'SIGINT' || signal === 'SIGTERM' || code === 0 || code === 130) resolve();
57
- else if (signal) reject(new Error(`Preview stopped by ${signal}`));
58
- else reject(new Error(`Preview exited with ${code}`));
61
+ else if (signal) {
62
+ reject(new Error(`The preview process was stopped by ${signal}. Review the build output above, correct the reported problem, and run preview again.`));
63
+ } else {
64
+ reject(new Error(`The preview build failed with exit code ${code}. Review the build output above, correct the reported problem, and run preview again.`));
65
+ }
59
66
  });
60
67
  });
61
68
  }
@@ -84,7 +91,7 @@ function install(root, spawnProcess) {
84
91
  resolve();
85
92
  return;
86
93
  }
87
- const failure = new Error('Installing the preview tooling failed');
94
+ const failure = new Error('Gala could not install the preview dependencies. The package-manager output below contains the cause.');
88
95
  failure.detail = said.trim();
89
96
  reject(failure);
90
97
  });
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { galaApi } from '../api/gala.js';
5
5
  import { galaCredential } from '../auth/gala.js';
6
6
  import { UsageError } from '../cli/args.js';
7
+ import { cliCommand } from '../cli/invocation.js';
7
8
  import { createGit } from '../git.js';
8
9
  import { readPublication } from '../publication.js';
9
10
 
@@ -25,7 +26,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
25
26
  const [action = 'status', ...args] = options.positional;
26
27
 
27
28
  if (action === 'status') {
28
- requireArgs(args, 0, 'gala prism status');
29
+ requireArgs(args, 0, cliCommand('prism status'));
29
30
  const state = await api.json(`/v1/sites/${publication.siteId}/prism`, { action: 'Prism status' });
30
31
  terminal.result(`Prism ${state.publishedMode}`);
31
32
  terminal.note(`Mode: requested ${state.requestedMode}; published ${state.publishedMode}`);
@@ -39,7 +40,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
39
40
  const expectedRepositoryHeadSha = inventory.headSha;
40
41
 
41
42
  if (action === 'mode') {
42
- requireArgs(args, 1, 'gala prism mode <off|presentation-only|manual|assisted>');
43
+ requireArgs(args, 1, cliCommand('prism mode <off|presentation-only|manual|assisted>'));
43
44
  const mode = MODES.get(args[0]);
44
45
  if (!mode) throw new UsageError('Prism mode must be off, presentation-only, manual, or assisted.');
45
46
  if (mode === 'OFF' || mode === 'PRESENTATION_ONLY') await confirm(terminal, options, `Change Prism mode to ${args[0]}?`);
@@ -52,7 +53,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
52
53
  if (action === 'link-policy') {
53
54
  const [scope, target, value] = args;
54
55
  if (scope === 'site') {
55
- requireArgs(args, 2, 'gala prism link-policy site <nofollow|follow>');
56
+ requireArgs(args, 2, cliCommand('prism link-policy site <nofollow|follow>'));
56
57
  const policy = policyValue(target);
57
58
  const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
58
59
  configurationLinkPolicy: policy, expectedRepositoryHeadSha,
@@ -60,7 +61,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
60
61
  return settleMutation(api, terminal, publication, result);
61
62
  }
62
63
  if (scope === 'work') {
63
- requireArgs(args, 3, 'gala prism link-policy work <slug> <inherit|nofollow|follow>');
64
+ requireArgs(args, 3, cliCommand('prism link-policy work <slug> <inherit|nofollow|follow>'));
64
65
  const post = resolvePost(inventory, target, options.value('language'), publication.defaultLanguage);
65
66
  if (value === 'inherit') {
66
67
  const result = await mutate(api,
@@ -76,11 +77,14 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
76
77
  }, 'Prism work link policy');
77
78
  return settleMutation(api, terminal, publication, result);
78
79
  }
79
- throw new UsageError('Use: gala prism link-policy site <nofollow|follow> or work <slug> <inherit|nofollow|follow>');
80
+ throw new UsageError(
81
+ `Use: ${cliCommand('prism link-policy site <nofollow|follow>')} or `
82
+ + cliCommand('prism link-policy work <slug> <inherit|nofollow|follow>')
83
+ );
80
84
  }
81
85
 
82
86
  if (action === 'list') {
83
- requireArgs(args, 1, 'gala prism list <slug> [--language en]');
87
+ requireArgs(args, 1, cliCommand('prism list <slug> [--language en]'));
84
88
  const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
85
89
  const result = await configurations(api, publication.siteId, post);
86
90
  terminal.result(`${result.configurations.length} configuration${result.configurations.length === 1 ? '' : 's'}`);
@@ -91,7 +95,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
91
95
  }
92
96
 
93
97
  if (action === 'create') {
94
- requireArgs(args, 1, 'gala prism create <slug> --language en --depth brief --intent orientation');
98
+ requireArgs(args, 1, cliCommand('prism create <slug> --language en --depth brief --intent orientation'));
95
99
  const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
96
100
  const current = await configurations(api, publication.siteId, post);
97
101
  const result = await mutate(api,
@@ -108,7 +112,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
108
112
  }
109
113
 
110
114
  if (!['edit', 'generate', 'submit', 'approve', 'reject', 'revoke'].includes(action)) {
111
- throw new UsageError('Unknown Prism action. Run gala prism --help.');
115
+ throw new UsageError(`Unknown Prism action. Run ${cliCommand('prism --help')}.`);
112
116
  }
113
117
 
114
118
  const configurationId = args[0];
@@ -119,7 +123,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
119
123
  const base = `/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations/${configurationId}`;
120
124
 
121
125
  if (action === 'edit') {
122
- requireArgs(args, 1, 'gala prism edit <configuration-id> --file proposal.md');
126
+ requireArgs(args, 1, cliCommand('prism edit <configuration-id> --file proposal.md'));
123
127
  const filename = options.value('file');
124
128
  if (!filename) throw new UsageError('Prism edit needs --file proposal.md.');
125
129
  const markdown = await readFile(path.resolve(root, filename), 'utf8');
@@ -132,7 +136,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
132
136
  }
133
137
 
134
138
  if (action === 'generate') {
135
- requireArgs(args, 1, 'gala prism generate <configuration-id>');
139
+ requireArgs(args, 1, cliCommand('prism generate <configuration-id>'));
136
140
  const result = await mutate(api, `${base}/generation-jobs`, 'POST', {
137
141
  expectedSourceContentHash: collection.sourceRevisionHash,
138
142
  hashContract: collection.hashContract,
@@ -146,7 +150,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
146
150
  throw new UsageError(`${action} needs a revision ID when there is no working revision.`);
147
151
  }
148
152
  if (action === 'submit') {
149
- if (args.length > 2) throw new UsageError('Use: gala prism submit <configuration-id> [revision-id]');
153
+ if (args.length > 2) throw new UsageError(`Use: ${cliCommand('prism submit <configuration-id> [revision-id]')}`);
150
154
  if (revisionId !== configuration.workingRevision?.revisionId) {
151
155
  throw new UsageError('Only the current working revision can be submitted. Refresh the configuration and try again.');
152
156
  }
@@ -165,7 +169,7 @@ export async function prism({ terminal, options, cwd = process.cwd() }) {
165
169
 
166
170
  const reason = options.value('reason');
167
171
  if (action === 'approve') {
168
- if (args.length > 2) throw new UsageError('Use: gala prism approve <configuration-id> [revision-id] [--yes]');
172
+ if (args.length > 2) throw new UsageError(`Use: ${cliCommand('prism approve <configuration-id> [revision-id] [--yes]')}`);
169
173
  if (revisionId !== configuration.workingRevision?.revisionId) {
170
174
  throw new UsageError('Only the current working revision can be approved. Refresh the configuration and try again.');
171
175
  }
@@ -292,7 +296,7 @@ async function settleMutation(api, terminal, publication, result) {
292
296
  throw new UsageError(`Repository update failed (${state.errorCode ?? 'unknown error'}). Run the command again after correcting the cause.`);
293
297
  }
294
298
  if (state.status !== 'COMMITTED') {
295
- throw new UsageError('Repository update did not finish before the 31-minute tracking deadline. Check gala prism status before retrying.');
299
+ throw new UsageError(`Repository update did not finish before the 31-minute tracking deadline. Check ${cliCommand('prism status')} before retrying.`);
296
300
  }
297
301
  if (!state.publicationAttemptSha) {
298
302
  terminal.result('Repository updated. No publication attempt was returned.');
@@ -8,16 +8,15 @@ import { readPublication } from '../publication.js';
8
8
  /**
9
9
  * Validates, records the writer's work, and sends it to GitHub.
10
10
  *
11
- * v0 only sent the writer had to remember to record their changes first, and a run that appeared
11
+ * v0 only sent - the writer had to remember to record their changes first, and a run that appeared
12
12
  * to succeed could ship nothing at all. It also used the machine's git credential rather than the
13
13
  * one the CLI holds, which fails for anyone whose accounts differ.
14
14
  *
15
15
  * The order below is the whole of the difficulty. Every successful publish adds a deployment record
16
16
  * to the branch from the workflow, so the checkout is behind by one before the writer has touched
17
- * anything catching up is the normal condition, not a race fix. It has to happen *before*
18
- * validation, because validation assigns a content id to any post missing one and the workflow
19
- * assigns ids remotely as well; the other order has both sides edit the same file with different
20
- * ids, which git can only report as a conflict.
17
+ * anything - catching up is the normal condition, not a race fix. It has to happen *before*
18
+ * validation, because publish validation assigns a content id to any post still missing one. Local
19
+ * preview is deliberately read-only and can therefore never create a competing ID before this sync.
21
20
  *
22
21
  * Publishing itself happens on GitHub: the workflow in the repository builds and deploys. This
23
22
  * command's job ends when the work is on the branch.
@@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { x as extractTar } from 'tar';
6
6
 
7
+ import { cliCommand } from '../cli/invocation.js';
8
+
7
9
  const PACKAGE = '@rathnasgala/theme';
8
10
  const REGISTRY = 'https://registry.npmjs.org';
9
11
  const PROTECTED = ['.git/', 'content/', 'custom.css', 'site.config.yml'];
@@ -81,7 +83,7 @@ async function assertNoManagedDrift(root, installed) {
81
83
  for (const [managed, expected] of Object.entries(installed.files)) {
82
84
  const file = path.join(root, managed);
83
85
  if (!await exists(file) || (await lstat(file)).isSymbolicLink() || sha256(await readFile(file)) !== expected) {
84
- throw new Error(`${managed} has local changes; restore it with gala doctor before upgrading`);
86
+ throw new Error(`${managed} has local changes; restore it with ${cliCommand('doctor')} before upgrading`);
85
87
  }
86
88
  }
87
89
  }
@@ -168,6 +170,6 @@ export async function upgrade({ terminal, options, cwd = process.cwd(), fetchImp
168
170
  await rm(unpacked.temporary, { recursive: true, force: true });
169
171
  }
170
172
  terminal.done(`Upgraded managed theme to ${release.version}`);
171
- terminal.note('Run gala preview, then gala publish when the result is approved.');
173
+ terminal.note(`Run ${cliCommand('preview')}, then ${cliCommand('publish')} when the result is approved.`);
172
174
  return { changed: true, version: release.version };
173
175
  }
@@ -7,6 +7,7 @@ import { preview } from './commands/preview.js';
7
7
  import { publish } from './commands/publish.js';
8
8
  import { prism } from './commands/prism.js';
9
9
  import { upgrade } from './commands/upgrade.js';
10
+ import { cliCommand } from './cli/invocation.js';
10
11
 
11
12
  /**
12
13
  * Commands in the order a writer meets them.
@@ -17,7 +18,7 @@ import { upgrade } from './commands/upgrade.js';
17
18
  * Each was a surface to keep correct and a way to be wrong.
18
19
  *
19
20
  * This lives apart from the dispatcher so the README can be checked against it. v0's README
20
- * outlived its commands by weeks it taught a command that never existed because nothing tied
21
+ * outlived its commands by weeks - it taught a command that never existed - because nothing tied
21
22
  * the two together.
22
23
  *
23
24
  * Each entry carries its own options. Nothing is parsed globally, so an option cannot mean two
@@ -31,19 +32,19 @@ export const COMMANDS = {
31
32
  },
32
33
  init: {
33
34
  summary: 'Create a publication in an empty directory',
34
- usage: 'gala init [directory] [--name my-notes] [--domain blog.example.com]',
35
+ usage: cliCommand('init [directory] [--name my-notes] [--domain blog.example.com]'),
35
36
  flags: ['name', 'domain', 'api-base-url'],
36
37
  run: init
37
38
  },
38
39
  domain: {
39
40
  summary: 'Inspect or change this publication’s custom domain',
40
- usage: 'gala domain [status|set <hostname>|check|cancel|remove] [--root path]',
41
+ usage: cliCommand('domain [status|set <hostname>|check|cancel|remove] [--root path]'),
41
42
  flags: ['root', 'api-base-url'],
42
43
  run: domain
43
44
  },
44
45
  new: {
45
46
  summary: 'Start a post',
46
- usage: 'gala new "A durable idea" [--language en]',
47
+ usage: cliCommand('new "A durable idea" [--language en]'),
47
48
  flags: ['root', 'language', 'today'],
48
49
  run: createPost
49
50
  },
@@ -60,14 +61,14 @@ export const COMMANDS = {
60
61
  },
61
62
  prism: {
62
63
  summary: 'Manage author-approved Prism configurations',
63
- usage: 'gala prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]',
64
+ usage: cliCommand('prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]'),
64
65
  flags: ['root', 'api-base-url', 'language', 'depth', 'intent', 'modality', 'file', 'reason'],
65
66
  switches: ['yes'],
66
67
  run: prism
67
68
  },
68
69
  upgrade: {
69
70
  summary: 'Inspect and apply a verified managed-theme update',
70
- usage: 'gala upgrade [--channel latest|next] [--yes]',
71
+ usage: cliCommand('upgrade [--channel latest|next] [--yes]'),
71
72
  flags: ['root', 'channel'],
72
73
  switches: ['yes'],
73
74
  run: upgrade
package/src/content.js CHANGED
@@ -1,10 +1,20 @@
1
+ import path from 'node:path';
2
+
1
3
  import { regenerateBuildManifest } from '@rathnasgala/content-validation';
2
4
 
5
+ function shownPath(root, file) {
6
+ if (!path.isAbsolute(file)) return file;
7
+ const relative = path.relative(root, file);
8
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
9
+ ? relative
10
+ : file;
11
+ }
12
+
3
13
  /**
4
14
  * Content validation, shared by preview and publish.
5
15
  *
6
16
  * v0 exposed this as its own `validate` command and then called it from two others, so a writer had
7
- * three ways to learn the same thing and one of them the pre-push hook ran it behind their back.
17
+ * three ways to learn the same thing and one of them - the pre-push hook - ran it behind their back.
8
18
  * Validation is not a task; it is a precondition of showing or shipping. It runs where those happen
9
19
  * and nowhere else.
10
20
  *
@@ -12,20 +22,32 @@ import { regenerateBuildManifest } from '@rathnasgala/content-validation';
12
22
  * theme package installed in the publication's node_modules. That belongs in a test of the
13
23
  * validator, not of how this reports what it found.
14
24
  */
15
- export async function checkContent({ terminal, root, today, regenerate = regenerateBuildManifest }) {
16
- const { results } = await regenerate({ root, today });
25
+ export async function checkContent({
26
+ terminal, root, today, preview = false, regenerate = regenerateBuildManifest
27
+ }) {
28
+ const { results } = await regenerate({ root, today, preview });
17
29
  const failed = results.filter(({ errors }) => errors.length > 0);
18
30
 
19
31
  for (const result of results) {
20
- for (const warning of result.warnings ?? []) terminal.note(`${result.file}: ${warning}`);
32
+ for (const warning of result.warnings ?? []) {
33
+ terminal.note(`${shownPath(root, result.file)} - ${warning}`);
34
+ }
21
35
  }
22
36
  if (failed.length === 0) return results;
23
37
 
24
38
  // Every problem, in one pass. Stopping at the first means fixing one thing, running again, and
25
39
  // only then learning about the next.
26
- terminal.blank();
40
+ const details = [];
27
41
  for (const result of failed) {
28
- for (const error of result.errors) terminal.fail(`${result.file}: ${error}`);
42
+ details.push(shownPath(root, result.file));
43
+ for (const error of result.errors) details.push(` - ${error}`);
29
44
  }
30
- throw new Error(`${failed.length} post${failed.length === 1 ? '' : 's'} cannot be published yet`);
45
+ const problemCount = failed.reduce((count, result) => count + result.errors.length, 0);
46
+ details.push(`Fix ${problemCount === 1 ? 'this problem' : 'these problems'}, then run the command again.`);
47
+ const failure = new Error(
48
+ `Content check failed: ${problemCount} problem${problemCount === 1 ? '' : 's'} `
49
+ + `in ${failed.length} post${failed.length === 1 ? '' : 's'}`
50
+ );
51
+ failure.detail = details.join('\n');
52
+ throw failure;
31
53
  }
package/src/git.js CHANGED
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  * which is a different identity from the one the CLI just authenticated with. On the machine where
9
9
  * this surfaced the token belonged to one account and git's stored credential to another, so a
10
10
  * scaffold created the repository through the API and was then refused when it tried to write to
11
- * it. Anyone with no credential configured at all a fresh machine, or SSH-only had no chance.
11
+ * it. Anyone with no credential configured at all - a fresh machine, or SSH-only - had no chance.
12
12
  *
13
13
  * The token travels in the environment, never in the argument list, because arguments are readable
14
14
  * machine-wide through `ps`. Nothing is written to `.git/config`.
@@ -100,11 +100,9 @@ export function createGit({ root, token, spawnProcess = spawn } = {}) {
100
100
  * Brings the remote's commits in, over the top of anything uncommitted.
101
101
  *
102
102
  * `--autostash` matters: this runs before the writer's work is recorded, and a rebase refuses a
103
- * dirty tree. Doing it the other way round record first, then rebase is what produced
104
- * conflicts in `content/posts/*`: validation assigns a content id to any post missing one, and
105
- * the publish workflow assigns one remotely too. Both sides edit the same file, pick different
106
- * ids, and git can only call that a conflict. Taking the remote first means the local pass sees
107
- * ids that already exist and changes nothing.
103
+ * dirty tree. Doing it the other way round - record first, then rebase - lets the publication
104
+ * workflow and the local publish independently update the same file. Taking the remote first
105
+ * makes the post-publish validation pass the sole local writer of any missing content ID.
108
106
  */
109
107
  async takeRemote() {
110
108
  const branch = await git.branch();
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { UsageError, parseArguments } from './cli/args.js';
3
+ import { CLI_INVOCATION, cliCommand } from './cli/invocation.js';
3
4
  import { createTerminal } from './cli/terminal.js';
4
5
  import { COMMANDS } from './commands-manifest.js';
5
6
 
@@ -20,7 +21,7 @@ if (command == null) {
20
21
  }
21
22
 
22
23
  if (argv.includes('--help') || argv.includes('-h')) {
23
- process.stdout.write(`\n ${command.usage ?? `gala ${name}`}\n ${command.summary}\n\n`);
24
+ process.stdout.write(`\n ${command.usage ?? cliCommand(name)}\n ${command.summary}\n\n`);
24
25
  process.exit(0);
25
26
  }
26
27
 
@@ -56,5 +57,6 @@ function usage() {
56
57
  const lines = Object.entries(COMMANDS)
57
58
  .map(([key, { summary }]) => ` ${key.padEnd(9)} ${summary}`)
58
59
  .join('\n');
59
- process.stdout.write(`\n gala <command>\n\n${lines}\n\n Run any command with --help.\n\n`);
60
+ process.stdout.write(`\n ${CLI_INVOCATION} <command>\n\n${lines}\n\n`
61
+ + ` Run ${CLI_INVOCATION} <command> --help for details.\n\n`);
60
62
  }
@@ -8,8 +8,8 @@ import { parse } from 'yaml';
8
8
  *
9
9
  * The CLI knew every part of this and never said it: `new` printed a file path, `publish` said the
10
10
  * site would appear shortly, and the writer was left to assemble
11
- * `{canonicalBaseUrl}{pathPrefix}/{language}/{slug}/` themselves. That is easy to get wrong the
12
- * language segment is not obvious from anything they typed and being unable to find your own post
11
+ * `{canonicalBaseUrl}{pathPrefix}/{language}/{slug}/` themselves. That is easy to get wrong - the
12
+ * language segment is not obvious from anything they typed - and being unable to find your own post
13
13
  * is a poor first minute with a publishing tool.
14
14
  */
15
15
  export async function readPublication(root) {