@rathnasgala/cli 1.0.0 → 1.1.4

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
@@ -12,13 +12,24 @@ Nothing to install. Every command runs through `npx`.
12
12
 
13
13
  ## Start a publication
14
14
 
15
- Make a folder named after the publication you want, and run one command inside it:
15
+ Create it in the current empty folder:
16
16
 
17
17
  ```console
18
18
  mkdir field-notes && cd field-notes
19
- npx --yes @rathnasgala/cli@latest init --here
19
+ npx --yes @rathnasgala/cli@latest init
20
20
  ```
21
21
 
22
+ Or name a new destination directly:
23
+
24
+ ```console
25
+ npx --yes @rathnasgala/cli@latest init field-notes
26
+ ```
27
+
28
+ The destination must be empty. An initialized Git repository with no commits and no files is also
29
+ accepted and its `.git` directory is preserved. To reserve a custom domain during setup, add
30
+ `--domain blog.example.com`; Gala still requires GitHub ownership verification and healthy DNS
31
+ before activating it.
32
+
22
33
  It signs you in to Gala and to GitHub if you are not already, creates the repository, registers the
23
34
  publication, and leaves a working checkout in the folder. When it finishes it prints the address
24
35
  your publication will live at.
@@ -51,12 +62,49 @@ npx --yes @rathnasgala/cli@latest publish
51
62
  Checks your content, records it, and sends it to GitHub. GitHub builds and deploys from there; the
52
63
  site updates a minute or two later.
53
64
 
65
+ ## Prism configurations
66
+
67
+ Prism keeps one canonical work while letting you explicitly approve alternate reading depths and
68
+ intents. The CLI uses Gala's public lifecycle API; it never writes approval artifacts itself.
69
+
70
+ ```console
71
+ npx --yes @rathnasgala/cli@latest prism status
72
+ npx --yes @rathnasgala/cli@latest prism create my-post --language en --depth brief --intent orientation
73
+ npx --yes @rathnasgala/cli@latest prism list my-post --language en
74
+ ```
75
+
76
+ Use `prism edit`, `submit`, `approve`, `reject`, and `revoke` to advance a configuration. Approval,
77
+ rejection, revocation, and reducing the publication mode require terminal confirmation or `--yes`.
78
+ Configuration links default to `nofollow`; change the publication or one work with
79
+ `prism link-policy` when ordinary followed links are intentional. Commands that change repository
80
+ artifacts stay attached through Gala's materialization and GitHub Pages publication states, then
81
+ print the live publication address or a concrete terminal failure. Proposal generation likewise
82
+ waits until its revision is ready for review or generation fails.
83
+
84
+ ## Custom domain
85
+
86
+ Reserve a domain after setup, then advance the verified GitHub Pages flow as DNS propagates:
87
+
88
+ ```console
89
+ npx --yes @rathnasgala/cli@latest domain set blog.example.com
90
+ npx --yes @rathnasgala/cli@latest domain check
91
+ ```
92
+
93
+ `domain status` resumes an interrupted change, `domain cancel` abandons it, and `domain remove`
94
+ returns the publication to its GitHub Pages address. After removal, delete the old DNS records.
95
+
54
96
  ## When something is wrong
55
97
 
56
98
  ```console
57
99
  npx --yes @rathnasgala/cli@latest doctor
58
100
  ```
59
101
 
102
+ Inspect a verified managed-theme release without changing anything unless you confirm it:
103
+
104
+ ```console
105
+ npx --yes @rathnasgala/cli@latest upgrade
106
+ ```
107
+
60
108
  Reports on your sign-ins, the publication folder, the publishing workflow, and anything you have
61
109
  written but not sent. Each check either passes, names what is wrong and how to fix it, or says it
62
110
  could not be determined — never one of those disguised as another.
@@ -67,10 +115,13 @@ Run any command with `--help`.
67
115
 
68
116
  | Command | What it does | Options |
69
117
  | --- | --- | --- |
70
- | `init` | Create a publication and clone it here | `--name`, `--here` |
118
+ | `init` | Create a publication in the current or named empty directory | `--name`, `--domain` |
119
+ | `domain` | Inspect or change the custom domain | `--root` |
71
120
  | `new` | Start a post | `--language`, `--root`, `--today` |
72
121
  | `preview` | Build and serve the publication locally | `--root`, `--today` |
73
122
  | `publish` | Check, record and send your work to GitHub | `--root`, `--today`, `--skip-checks` |
123
+ | `prism` | Manage author-approved reading configurations | `--root`, `--language`, `--depth`, `--intent`, `--modality`, `--file`, `--reason`, `--yes` |
124
+ | `upgrade` | Inspect and apply a verified managed-theme update | `--root`, `--channel`, `--yes` |
74
125
  | `doctor` | Check a publication and say what is wrong | `--root` |
75
126
  | `auth` | Sign in to Gala and GitHub | — |
76
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
package/src/api/gala.js CHANGED
@@ -59,12 +59,19 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
59
59
  * wait for the App installation to reach the result, which is why repositories the CLI created
60
60
  * never appeared in the web UI.
61
61
  */
62
- createPublication({ capability, name }) {
62
+ createPublication({ capability, name, installationId }) {
63
63
  return requestJson(`${root}/v1/auth/github/publications`,
64
64
  authorized('Publication creation', {
65
65
  method: 'POST',
66
66
  headers: { 'content-type': 'application/json', 'GitHub-Authorization': capability },
67
- body: JSON.stringify({ name })
67
+ body: JSON.stringify({ name, installationId })
68
+ }));
69
+ },
70
+
71
+ githubInstallationAccounts({ capability }) {
72
+ return requestJson(`${root}/v1/auth/github/accounts`,
73
+ authorized('GitHub installation accounts', {
74
+ headers: { 'GitHub-Authorization': capability }
68
75
  }));
69
76
  },
70
77
 
@@ -85,6 +92,35 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
85
92
  return requestJson(`${root}/v1/me/sites`, authorized('Publication list'));
86
93
  },
87
94
 
95
+ prepareTopologyChange(siteId, body) {
96
+ return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/prepare`,
97
+ authorized('Custom domain reservation', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify(body)
101
+ }));
102
+ },
103
+
104
+ pendingTopologyChange(siteId) {
105
+ return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/pending`,
106
+ authorized('Custom domain status'));
107
+ },
108
+
109
+ configureTopologyChange(siteId, changeId) {
110
+ return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}/configure`,
111
+ authorized('GitHub Pages domain verification', { method: 'POST' }));
112
+ },
113
+
114
+ commitTopologyChange(siteId, changeId) {
115
+ return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}/commit`,
116
+ authorized('Custom domain activation', { method: 'POST' }));
117
+ },
118
+
119
+ discardTopologyChange(siteId, changeId) {
120
+ return request(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}`,
121
+ authorized('Custom domain cancellation', { method: 'DELETE' }));
122
+ },
123
+
88
124
  /** Not in the OpenAPI document, though the endpoint exists and is public. */
89
125
  async signInConfiguration() {
90
126
  return requestJson(`${root}/v1/auth/configuration`, { action: 'Sign-in configuration' });
@@ -121,6 +157,7 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
121
157
  throw new Error(`Gala sign-in failed: ${body?.error_description ?? body?.error ?? response.status}`);
122
158
  },
123
159
 
124
- request: (path, options) => request(`${root}${path}`, authorized(options?.action ?? path, options))
160
+ request: (path, options) => request(`${root}${path}`, authorized(options?.action ?? path, options)),
161
+ json: (path, options) => requestJson(`${root}${path}`, authorized(options?.action ?? path, options))
125
162
  };
126
163
  }
@@ -0,0 +1,124 @@
1
+ import path from 'node:path';
2
+
3
+ import { galaApi } from '../api/gala.js';
4
+ import { galaCredential } from '../auth/gala.js';
5
+ import { UsageError } from '../cli/args.js';
6
+ import { customDomain } from '../domain.js';
7
+ import { readPublication } from '../publication.js';
8
+
9
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
10
+
11
+ export async function domain({ terminal, options, cwd = process.cwd() }) {
12
+ const root = path.resolve(options.value('root') ?? cwd);
13
+ const publication = await readPublication(root);
14
+ if (!publication || !ULID.test(publication.siteId ?? '')) {
15
+ throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
16
+ }
17
+ const [action = 'status', value, ...extra] = options.positional;
18
+ 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
+ }
21
+ if (action === 'set' && value == null) throw new UsageError('domain set needs a hostname');
22
+ if (action !== 'set' && value != null) throw new UsageError(`domain ${action} takes no hostname`);
23
+
24
+ const credential = await galaCredential({
25
+ terminal, apiBaseUrl: options.value('api-base-url'),
26
+ });
27
+ const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
28
+
29
+ if (action === 'status') {
30
+ const pending = await api.pendingTopologyChange(publication.siteId);
31
+ if (!pending) {
32
+ terminal.result((await ownedSite(api, publication.siteId)).publicationUrl);
33
+ terminal.note('No domain change is pending.');
34
+ return null;
35
+ }
36
+ showPending(terminal, pending);
37
+ return pending;
38
+ }
39
+
40
+ if (action === 'set') {
41
+ const checked = customDomain(value);
42
+ if (checked.error) throw new UsageError(checked.error);
43
+ const change = await api.prepareTopologyChange(publication.siteId, {
44
+ canonicalBaseUrl: `https://${checked.host}`,
45
+ pathPrefix: '/',
46
+ });
47
+ terminal.done(`Reserved ${checked.host}`);
48
+ terminal.note('Verify it in the repository owner’s GitHub account, then run: gala domain check');
49
+ 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
+ return change;
51
+ }
52
+
53
+ if (action === 'remove') {
54
+ const existing = await api.pendingTopologyChange(publication.siteId);
55
+ if (existing) {
56
+ throw new UsageError('No second domain change can start while another one is pending; cancel it first.');
57
+ }
58
+ const site = await ownedSite(api, publication.siteId);
59
+ const [owner, repository] = site.repository.split('/');
60
+ const host = `${owner.toLowerCase()}.github.io`;
61
+ const prefix = repository.toLowerCase() === host ? '/' : `/${repository}`;
62
+ const change = await api.prepareTopologyChange(publication.siteId, {
63
+ canonicalBaseUrl: `https://${host}`,
64
+ pathPrefix: prefix,
65
+ });
66
+ const committed = await api.commitTopologyChange(publication.siteId, change.changeId);
67
+ terminal.done(`Returned to ${committed.canonicalBaseUrl}${prefix === '/' ? '/' : `${prefix}/`}`);
68
+ terminal.note('Remove the old custom-domain records from your DNS provider.');
69
+ return committed;
70
+ }
71
+
72
+ const pending = await api.pendingTopologyChange(publication.siteId);
73
+ if (!pending) throw new UsageError('No domain change is pending.');
74
+
75
+ if (action === 'cancel') {
76
+ await api.discardTopologyChange(publication.siteId, pending.changeId);
77
+ terminal.done('Cancelled the pending domain change');
78
+ return null;
79
+ }
80
+
81
+ if (action === 'check') {
82
+ if (pending.cname && pending.state === 'PREPARED') {
83
+ const configured = await api.configureTopologyChange(publication.siteId, pending.changeId);
84
+ terminal.done(`GitHub verified ${configured.cname}`);
85
+ terminal.note(dnsInstruction(configured.cname, await providerHost(api, publication.siteId)));
86
+ terminal.note('After DNS propagates, run: gala domain check');
87
+ return configured;
88
+ }
89
+ const committed = await api.commitTopologyChange(publication.siteId, pending.changeId);
90
+ terminal.done(committed.cname
91
+ ? `${committed.cname} is live with enforced HTTPS`
92
+ : 'The GitHub Pages address is live again');
93
+ if (!committed.cname) {
94
+ terminal.note('Remove the old custom-domain records from your DNS provider.');
95
+ }
96
+ return committed;
97
+ }
98
+
99
+ throw new UsageError(`Unsupported domain action: ${action}`);
100
+ }
101
+
102
+ async function ownedSite(api, siteId) {
103
+ const sites = await api.listPublications();
104
+ const site = Array.isArray(sites) ? sites.find((candidate) => candidate.siteId === siteId) : null;
105
+ if (!site || typeof site.repository !== 'string' || typeof site.publicationUrl !== 'string') {
106
+ throw new Error('Publication is not available');
107
+ }
108
+ return site;
109
+ }
110
+
111
+ async function providerHost(api, siteId) {
112
+ return (await ownedSite(api, siteId)).repository.split('/')[0].toLowerCase() + '.github.io';
113
+ }
114
+
115
+ function dnsInstruction(host, target) {
116
+ return `For a subdomain: CNAME ${host} → ${target}. For an apex domain, use GitHub’s documented A/AAAA records.`;
117
+ }
118
+
119
+ function showPending(terminal, pending) {
120
+ terminal.result(`${pending.canonicalBaseUrl}${pending.pathPrefix}`);
121
+ 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.');
124
+ }
@@ -5,8 +5,9 @@ import { galaApi } from '../api/gala.js';
5
5
  import { githubApi } from '../api/github.js';
6
6
  import { galaCredential } from '../auth/gala.js';
7
7
  import { githubCredential } from '../auth/github.js';
8
- import { cloneRepository, createGit } from '../git.js';
8
+ import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
9
9
  import { UsageError } from '../cli/args.js';
10
+ import { customDomain } from '../domain.js';
10
11
 
11
12
  /**
12
13
  * Creates a publication and leaves a working checkout behind.
@@ -28,34 +29,45 @@ import { UsageError } from '../cli/args.js';
28
29
  * What is left is genuinely the CLI's: asking what to call it, cloning, and reporting the address.
29
30
  */
30
31
  const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
32
+ const GITHUB_APP_INSTALLATION_URL = 'https://github.com/apps/gala67-app/installations/new';
31
33
 
32
34
  export async function init({ terminal, options, cwd = process.cwd() }) {
33
35
  const explicitName = options.value('name');
34
- const here = options.on('here');
35
- const target = here ? cwd : undefined;
36
+ if (options.positional.length > 1) {
37
+ throw new UsageError('init accepts at most one destination directory');
38
+ }
39
+ const directory = path.resolve(cwd, options.positional[0] ?? '.');
40
+ const requestedDomain = options.value('domain');
41
+ const checkedDomain = requestedDomain == null ? null : customDomain(requestedDomain);
42
+ if (checkedDomain?.error) throw new UsageError(checkedDomain.error);
43
+
44
+ const destination = await inspectDestination(directory);
36
45
 
37
46
  const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
38
47
  const github = await githubCredential({ terminal });
39
48
 
40
- const name = await publicationName({ terminal, explicitName, here, cwd });
41
- const directory = path.resolve(cwd, target ?? name);
42
- await refuseOccupied(directory, here);
49
+ const name = await publicationName({ terminal, explicitName, directory });
43
50
 
44
51
  const api = galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken });
45
52
  const capability = await api.githubCapability(github.accessToken);
53
+ const installation = await publicationAccount({ terminal, api, capability });
46
54
 
47
55
  terminal.step(`Creating ${name}`);
48
- const created = await createPublication({ terminal, api, capability, name, github });
56
+ const created = await createPublication({
57
+ terminal, api, capability, name, github, installationId: installation.installationId
58
+ });
49
59
 
50
60
  terminal.step('Waiting for GitHub to copy the template');
51
61
  await waitForContent(githubApi(github.accessToken), created.owner, created.name);
52
62
 
53
63
  terminal.step('Cloning');
54
- await cloneRepository({
64
+ const checkout = {
55
65
  url: `https://github.com/${created.owner}/${created.name}.git`,
56
66
  target: directory,
57
67
  token: github.accessToken
58
- });
68
+ };
69
+ if (destination === 'empty-git') await populateEmptyRepository(checkout);
70
+ else await cloneRepository(checkout);
59
71
 
60
72
  terminal.step('Registering the publication');
61
73
  const git = createGit({ root: directory, token: github.accessToken });
@@ -78,8 +90,26 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
78
90
  terminal.done(`Created ${created.owner}/${created.name}`);
79
91
  terminal.result(publicationUrl(registration, created));
80
92
  terminal.note(path.relative(cwd, directory) || '.');
93
+
94
+ let domainChange;
95
+ if (checkedDomain?.host) {
96
+ terminal.step(`Reserving ${checkedDomain.host}`);
97
+ try {
98
+ domainChange = await api.prepareTopologyChange(registration.siteId, {
99
+ canonicalBaseUrl: `https://${checkedDomain.host}`,
100
+ pathPrefix: '/'
101
+ });
102
+ } catch (failure) {
103
+ throw new Error(
104
+ `${created.owner}/${created.name} was created, but ${checkedDomain.host} was not reserved: `
105
+ + `${failure instanceof Error ? failure.message : 'unknown error'}`
106
+ );
107
+ }
108
+ }
109
+
81
110
  terminal.blank();
82
111
  terminal.note('gala new "Your first post"');
112
+ if (domainChange) terminal.note('gala domain check');
83
113
 
84
114
  return { owner: created.owner, name: created.name, siteId: registration.siteId, root: directory };
85
115
  }
@@ -93,10 +123,9 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
93
123
  * behalf: adding a repository to an installation is documented as classic-PAT-only. So it is asked
94
124
  * for, with a link to the one page that grants it.
95
125
  */
96
- async function createPublication({ terminal, api, capability, name, github }) {
97
- let created = false;
98
- for (let attempt = 0; attempt < 3; attempt += 1) {
99
- const result = await api.createPublication({ capability, name });
126
+ async function createPublication({ terminal, api, capability, name, github, installationId }) {
127
+ for (let attempt = 0; attempt < 5; attempt += 1) {
128
+ const result = await api.createPublication({ capability, name, installationId });
100
129
  if (result?.status === 'READY') {
101
130
  if (typeof result.owner !== 'string' || typeof result.name !== 'string') {
102
131
  throw new Error('Gala created the publication but did not say where');
@@ -104,22 +133,26 @@ async function createPublication({ terminal, api, capability, name, github }) {
104
133
  return result;
105
134
  }
106
135
 
107
- // Once the repository exists a further refusal reports UNSUPPORTED rather than NEEDS_SHARING:
108
- // the same situation under a different name.
109
- const shareable = result?.status === 'NEEDS_SHARING' || created;
110
- if (!shareable) {
136
+ if (result?.status === 'SETUP_PENDING') {
137
+ terminal.note(`${result.owner ?? ''}/${result.name ?? name} exists; GitHub is still copying it`);
138
+ await new Promise((resolve) => { setTimeout(resolve, 1000); });
139
+ continue;
140
+ }
141
+
142
+ if (result?.status !== 'NEEDS_SHARING') {
111
143
  throw new Error(
112
144
  `Gala could not create the publication (${result?.outcome ?? result?.status}). `
113
- + 'Install the Gala GitHub App at https://github.com/apps/gala67-app and try again.'
145
+ + `Continue at ${result?.recoveryUrl ?? GITHUB_APP_INSTALLATION_URL} `
146
+ + 'and try again.'
114
147
  );
115
148
  }
116
- created = true;
117
149
 
118
150
  const owner = result?.owner ?? '';
119
151
  terminal.blank();
120
152
  terminal.step(`${owner}/${result?.name ?? name} exists, but Gala cannot reach it yet`);
121
153
  terminal.note('its installation covers only selected repositories — add this one');
122
- terminal.openUrl(installationUrl(result?.installationId, owner, await viewerOf(github)));
154
+ terminal.openUrl(result?.recoveryUrl
155
+ ?? installationUrl(result?.installationId, owner, await viewerOf(github)));
123
156
  if (!await terminal.waitForEnter('Once Gala can access it')) {
124
157
  throw new Error(`Add ${owner}/${result?.name ?? name} to the Gala GitHub App, then run this again.`);
125
158
  }
@@ -127,6 +160,27 @@ async function createPublication({ terminal, api, capability, name, github }) {
127
160
  throw new Error(`Gala still cannot reach the repository for ${name}.`);
128
161
  }
129
162
 
163
+ export async function publicationAccount({ terminal, api, capability }) {
164
+ const state = await api.githubInstallationAccounts({ capability });
165
+ const accounts = Array.isArray(state?.accounts) ? state.accounts : [];
166
+ if (accounts.length === 0) {
167
+ throw new Error(
168
+ `Install or request the Gala GitHub App at ${state?.installationUrl
169
+ ?? GITHUB_APP_INSTALLATION_URL} and try again.`
170
+ );
171
+ }
172
+ const personal = accounts.find((account) => account?.organization === false);
173
+ if (personal) return personal;
174
+ if (accounts.length === 1) return accounts[0];
175
+
176
+ const choices = accounts.map((account) => account.login).join(', ');
177
+ const selected = (await terminal.ask(`Which GitHub account should own it? (${choices})`)).trim();
178
+ const account = accounts.find((candidate) =>
179
+ candidate.login.toLowerCase() === selected.toLowerCase());
180
+ if (!account) throw new UsageError(`Choose one of these GitHub accounts: ${choices}`);
181
+ return account;
182
+ }
183
+
130
184
  let viewerCache;
131
185
  async function viewerOf(github) {
132
186
  viewerCache ??= await githubApi(github.accessToken).viewer();
@@ -155,8 +209,8 @@ async function waitForContent(github, owner, name, { attempts = 30, intervalMs =
155
209
  throw new Error(`${owner}/${name} was created but is still empty. Try again in a moment.`);
156
210
  }
157
211
 
158
- async function publicationName({ terminal, explicitName, here, cwd }) {
159
- const proposed = explicitName ?? (here ? path.basename(path.resolve(cwd)) : undefined);
212
+ async function publicationName({ terminal, explicitName, directory }) {
213
+ const proposed = explicitName ?? path.basename(directory);
160
214
  const answer = proposed ?? await terminal.ask('What should this publication be called?');
161
215
  const name = slugify(answer);
162
216
  if (name == null) {
@@ -171,19 +225,44 @@ export function slugify(value) {
171
225
  return NAME.test(slug) ? slug : null;
172
226
  }
173
227
 
174
- async function refuseOccupied(directory, here) {
175
- const { readdir } = await import('node:fs/promises');
228
+ export async function inspectDestination(directory) {
229
+ const { readFile, readdir, stat } = await import('node:fs/promises');
176
230
  let entries;
177
231
  try {
178
232
  entries = await readdir(directory);
179
233
  } catch (missing) {
180
- if (missing?.code === 'ENOENT') return;
234
+ if (missing?.code === 'ENOENT') return 'missing';
181
235
  throw missing;
182
236
  }
183
- if (entries.length === 0) return;
184
- throw new UsageError(here
185
- ? 'This folder is not empty. Run it in an empty folder, or without --here.'
186
- : `${path.basename(directory)} already exists and is not empty.`);
237
+ if (entries.length === 0) return 'empty';
238
+ if (entries.length === 1 && entries[0] === '.git') {
239
+ const gitDirectory = path.join(directory, '.git');
240
+ if (!(await stat(gitDirectory)).isDirectory()) {
241
+ throw new UsageError('The destination has a linked .git file; use a new empty directory.');
242
+ }
243
+ const head = (await readFile(path.join(gitDirectory, 'HEAD'), 'utf8')).trim();
244
+ const branch = /^ref: (refs\/heads\/.+)$/.exec(head)?.[1];
245
+ if (branch) {
246
+ const packed = await readFile(path.join(gitDirectory, 'packed-refs'), 'utf8')
247
+ .then((source) => source.split('\n').some((line) => line && !line.startsWith('#')),
248
+ (failure) => failure?.code === 'ENOENT' ? false : Promise.reject(failure));
249
+ if (!await hasReference(path.join(gitDirectory, 'refs')) && !packed) return 'empty-git';
250
+ }
251
+ }
252
+ throw new UsageError('Gala needs an empty destination directory (an empty git repository is allowed).');
253
+ }
254
+
255
+ async function hasReference(directory) {
256
+ const { readdir } = await import('node:fs/promises');
257
+ const entries = await readdir(directory, { withFileTypes: true }).catch((failure) => {
258
+ if (failure?.code === 'ENOENT') return [];
259
+ throw failure;
260
+ });
261
+ for (const entry of entries) {
262
+ if (!entry.isDirectory()) return true;
263
+ if (await hasReference(path.join(directory, entry.name))) return true;
264
+ }
265
+ return false;
187
266
  }
188
267
 
189
268
  function publicationUrl(registration, created) {
@@ -0,0 +1,360 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { galaApi } from '../api/gala.js';
5
+ import { galaCredential } from '../auth/gala.js';
6
+ import { UsageError } from '../cli/args.js';
7
+ import { createGit } from '../git.js';
8
+ import { readPublication } from '../publication.js';
9
+
10
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
11
+ const MODES = new Map([
12
+ ['off', 'OFF'], ['presentation-only', 'PRESENTATION_ONLY'], ['manual', 'MANUAL'],
13
+ ['assisted', 'ASSISTED'],
14
+ ]);
15
+ const POLICIES = new Map([['nofollow', 'NOFOLLOW'], ['follow', 'FOLLOW']]);
16
+
17
+ export async function prism({ terminal, options, cwd = process.cwd() }) {
18
+ const root = path.resolve(options.value('root') ?? cwd);
19
+ const publication = await readPublication(root);
20
+ if (!publication || !ULID.test(publication.siteId ?? '')) {
21
+ throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
22
+ }
23
+ const credential = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
24
+ const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
25
+ const [action = 'status', ...args] = options.positional;
26
+
27
+ if (action === 'status') {
28
+ requireArgs(args, 0, 'gala prism status');
29
+ const state = await api.json(`/v1/sites/${publication.siteId}/prism`, { action: 'Prism status' });
30
+ terminal.result(`Prism ${state.publishedMode}`);
31
+ terminal.note(`Mode: requested ${state.requestedMode}; published ${state.publishedMode}`);
32
+ terminal.note(`Configuration links: requested ${state.requestedConfigurationLinkPolicy}; published ${state.publishedConfigurationLinkPolicy}`);
33
+ return state;
34
+ }
35
+
36
+ const inventory = await api.json(`/v1/sites/${publication.siteId}/posts`, {
37
+ action: 'Published post inventory',
38
+ });
39
+ const expectedRepositoryHeadSha = inventory.headSha;
40
+
41
+ if (action === 'mode') {
42
+ requireArgs(args, 1, 'gala prism mode <off|presentation-only|manual|assisted>');
43
+ const mode = MODES.get(args[0]);
44
+ if (!mode) throw new UsageError('Prism mode must be off, presentation-only, manual, or assisted.');
45
+ if (mode === 'OFF' || mode === 'PRESENTATION_ONLY') await confirm(terminal, options, `Change Prism mode to ${args[0]}?`);
46
+ const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
47
+ mode, expectedRepositoryHeadSha,
48
+ }, 'Prism mode');
49
+ return settleMutation(api, terminal, publication, result);
50
+ }
51
+
52
+ if (action === 'link-policy') {
53
+ const [scope, target, value] = args;
54
+ if (scope === 'site') {
55
+ requireArgs(args, 2, 'gala prism link-policy site <nofollow|follow>');
56
+ const policy = policyValue(target);
57
+ const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
58
+ configurationLinkPolicy: policy, expectedRepositoryHeadSha,
59
+ }, 'Prism link policy');
60
+ return settleMutation(api, terminal, publication, result);
61
+ }
62
+ if (scope === 'work') {
63
+ requireArgs(args, 3, 'gala prism link-policy work <slug> <inherit|nofollow|follow>');
64
+ const post = resolvePost(inventory, target, options.value('language'), publication.defaultLanguage);
65
+ if (value === 'inherit') {
66
+ const result = await mutate(api,
67
+ `/v1/sites/${publication.siteId}/articles/${post.articleId}/prism?fields=configurationLinkPolicy`,
68
+ 'DELETE', undefined, 'Prism work link policy', {
69
+ 'x-expected-repository-head': expectedRepositoryHeadSha,
70
+ });
71
+ return settleMutation(api, terminal, publication, result);
72
+ }
73
+ const result = await mutate(api,
74
+ `/v1/sites/${publication.siteId}/articles/${post.articleId}/prism`, 'PUT', {
75
+ configurationLinkPolicy: policyValue(value), expectedRepositoryHeadSha,
76
+ }, 'Prism work link policy');
77
+ return settleMutation(api, terminal, publication, result);
78
+ }
79
+ throw new UsageError('Use: gala prism link-policy site <nofollow|follow> or work <slug> <inherit|nofollow|follow>');
80
+ }
81
+
82
+ if (action === 'list') {
83
+ requireArgs(args, 1, 'gala prism list <slug> [--language en]');
84
+ const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
85
+ const result = await configurations(api, publication.siteId, post);
86
+ terminal.result(`${result.configurations.length} configuration${result.configurations.length === 1 ? '' : 's'}`);
87
+ for (const item of result.configurations) {
88
+ terminal.note(`${item.configurationId} ${item.depth}/${item.intent}/${item.modality} ${item.lifecycle}/${item.deliveryState}`);
89
+ }
90
+ return result;
91
+ }
92
+
93
+ if (action === 'create') {
94
+ requireArgs(args, 1, 'gala prism create <slug> --language en --depth brief --intent orientation');
95
+ const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
96
+ const current = await configurations(api, publication.siteId, post);
97
+ const result = await mutate(api,
98
+ `/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations`, 'POST', {
99
+ language: post.language,
100
+ depth: enumValue(options.value('depth') ?? 'brief', ['signal', 'brief', 'standard', 'complete', 'methods-references'], 'depth'),
101
+ intent: enumValue(options.value('intent') ?? 'orientation', ['orientation', 'story', 'proof', 'practice'], 'intent'),
102
+ modality: enumValue(options.value('modality') ?? 'text', ['text'], 'modality'),
103
+ expectedSourceContentHash: current.sourceRevisionHash,
104
+ hashContract: current.hashContract,
105
+ }, 'Create Prism configuration');
106
+ terminal.result(result.configurationId);
107
+ return result;
108
+ }
109
+
110
+ if (!['edit', 'generate', 'submit', 'approve', 'reject', 'revoke'].includes(action)) {
111
+ throw new UsageError('Unknown Prism action. Run gala prism --help.');
112
+ }
113
+
114
+ const configurationId = args[0];
115
+ if (!ULID.test(configurationId ?? '')) throw new UsageError(`${action} needs a configuration ID.`);
116
+ const resolved = await findConfiguration(api, publication, inventory, configurationId,
117
+ options.value('language'));
118
+ const { post, collection, configuration } = resolved;
119
+ const base = `/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations/${configurationId}`;
120
+
121
+ if (action === 'edit') {
122
+ requireArgs(args, 1, 'gala prism edit <configuration-id> --file proposal.md');
123
+ const filename = options.value('file');
124
+ if (!filename) throw new UsageError('Prism edit needs --file proposal.md.');
125
+ const markdown = await readFile(path.resolve(root, filename), 'utf8');
126
+ const result = await mutate(api, `${base}/revisions`, 'POST', {
127
+ markdown, expectedSourceContentHash: collection.sourceRevisionHash,
128
+ hashContract: collection.hashContract,
129
+ }, 'Save Prism revision');
130
+ terminal.result(result.revisionId);
131
+ return result;
132
+ }
133
+
134
+ if (action === 'generate') {
135
+ requireArgs(args, 1, 'gala prism generate <configuration-id>');
136
+ const result = await mutate(api, `${base}/generation-jobs`, 'POST', {
137
+ expectedSourceContentHash: collection.sourceRevisionHash,
138
+ hashContract: collection.hashContract,
139
+ }, 'Generate Prism proposal');
140
+ return settleGeneration(api, terminal, publication.siteId, post.articleId,
141
+ configurationId, result);
142
+ }
143
+
144
+ const revisionId = args[1] ?? configuration.workingRevision?.revisionId;
145
+ if (action !== 'revoke' && !ULID.test(revisionId ?? '')) {
146
+ throw new UsageError(`${action} needs a revision ID when there is no working revision.`);
147
+ }
148
+ if (action === 'submit') {
149
+ if (args.length > 2) throw new UsageError('Use: gala prism submit <configuration-id> [revision-id]');
150
+ if (revisionId !== configuration.workingRevision?.revisionId) {
151
+ throw new UsageError('Only the current working revision can be submitted. Refresh the configuration and try again.');
152
+ }
153
+ const warnings = configuration.workingRevision.literalFindings
154
+ ?.filter((item) => item.severity === 'WARNING') ?? [];
155
+ for (const warning of warnings) terminal.note(`Warning ${warning.id}: ${warning.message}`);
156
+ if (warnings.length > 0) {
157
+ await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
158
+ }
159
+ const result = await mutate(api, `${base}/submit`, 'POST', expectation(collection, {
160
+ revisionId, acknowledgedWarningIds: warnings.map((warning) => warning.id),
161
+ }), 'Submit Prism revision');
162
+ terminal.result(`${result.revisionId} ${result.reviewState}`);
163
+ return result;
164
+ }
165
+
166
+ const reason = options.value('reason');
167
+ if (action === 'approve') {
168
+ if (args.length > 2) throw new UsageError('Use: gala prism approve <configuration-id> [revision-id] [--yes]');
169
+ if (revisionId !== configuration.workingRevision?.revisionId) {
170
+ throw new UsageError('Only the current working revision can be approved. Refresh the configuration and try again.');
171
+ }
172
+ const changed = await createGit({ root }).run(
173
+ ['status', '--porcelain', '--', `content/posts/${post.slug}`], { capture: true });
174
+ if (changed) throw new UsageError('The canonical post has uncommitted edits. Publish or revert them before approval.');
175
+ const warnings = configuration.workingRevision?.literalFindings
176
+ ?.filter((item) => item.severity === 'WARNING').map((item) => item.id) ?? [];
177
+ for (const warning of configuration.workingRevision?.literalFindings
178
+ ?.filter((item) => item.severity === 'WARNING') ?? []) {
179
+ terminal.note(`Warning ${warning.id}: ${warning.message}`);
180
+ }
181
+ if (warnings.length > 0) {
182
+ await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
183
+ }
184
+ await confirm(terminal, options, 'Approve this revision and publish it?');
185
+ const result = await mutate(api, `${base}/approve`, 'POST', expectation(collection, {
186
+ revisionId, expectedRepositoryHeadSha, acknowledgedWarningIds: warnings,
187
+ }), 'Approve Prism revision');
188
+ return settleMutation(api, terminal, publication, result);
189
+ }
190
+ if (!reason) throw new UsageError(`Prism ${action} needs --reason.`);
191
+ await confirm(terminal, options, `${action === 'reject' ? 'Reject this revision' : 'Revoke this configuration'}?`);
192
+ const suffix = action === 'reject' ? '/reject' : '/revoke';
193
+ const body = action === 'reject'
194
+ ? expectation(collection, { revisionId, reason })
195
+ : expectation(collection, { expectedRepositoryHeadSha, reason });
196
+ const result = await mutate(api, `${base}${suffix}`, 'POST', body, `Prism ${action}`);
197
+ if (result.materialization) return settleMutation(api, terminal, publication, result);
198
+ terminal.result(result.reviewState ?? action);
199
+ return result;
200
+ }
201
+
202
+ function requireArgs(args, count, usage) {
203
+ if (args.length !== count) throw new UsageError(`Use: ${usage}`);
204
+ }
205
+
206
+ function resolvePost(inventory, slug, requestedLanguage, defaultLanguage) {
207
+ const variants = inventory.posts.filter((post) => post.slug === slug);
208
+ const post = variants.find((item) => item.language === requestedLanguage)
209
+ ?? variants.find((item) => item.language === defaultLanguage)
210
+ ?? variants[0];
211
+ if (!post) throw new UsageError(`No published work uses the slug ${slug}.`);
212
+ if (!post.articleId) throw new UsageError('Publish this work once before creating configurations.');
213
+ return post;
214
+ }
215
+
216
+ async function findConfiguration(api, publication, inventory, id, language) {
217
+ for (const post of inventory.posts.filter((item) => item.articleId
218
+ && (!language || item.language === language))) {
219
+ const collection = await configurations(api, publication.siteId, post);
220
+ const configuration = collection.configurations.find((item) => item.configurationId === id);
221
+ if (configuration) return { post, collection, configuration };
222
+ }
223
+ throw new UsageError(`Configuration ${id} is not part of this publication.`);
224
+ }
225
+
226
+ function configurations(api, siteId, post) {
227
+ return api.json(`/v1/sites/${siteId}/articles/${post.articleId}/configurations?language=${encodeURIComponent(post.language)}`, {
228
+ action: 'Prism configurations',
229
+ });
230
+ }
231
+
232
+ function mutate(api, path, method, body, action, extraHeaders = {}) {
233
+ return api.json(path, {
234
+ action, method,
235
+ headers: {
236
+ 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID(), ...extraHeaders,
237
+ },
238
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
239
+ });
240
+ }
241
+
242
+ function expectation(collection, extra) {
243
+ return {
244
+ ...extra,
245
+ expectedSourceContentHash: collection.sourceRevisionHash,
246
+ hashContract: collection.hashContract,
247
+ };
248
+ }
249
+
250
+ function policyValue(value) {
251
+ const policy = POLICIES.get(value);
252
+ if (!policy) throw new UsageError('Link policy must be nofollow or follow.');
253
+ return policy;
254
+ }
255
+
256
+ function enumValue(value, allowed, name) {
257
+ if (!allowed.includes(value)) throw new UsageError(`${name} must be one of: ${allowed.join(', ')}.`);
258
+ return value.replaceAll('-', '_').toUpperCase();
259
+ }
260
+
261
+ async function confirm(terminal, options, question) {
262
+ if (options.on('yes')) return;
263
+ const answer = await terminal.ask(`${question} Type yes to continue.`);
264
+ if (answer.toLowerCase() !== 'yes') throw new UsageError('Cancelled.');
265
+ }
266
+
267
+ function showMutation(terminal, result) {
268
+ terminal.result(`Queued ${result.materialization?.materializationId ?? result.materializationId}`);
269
+ if (result.settings?.requestedEffectiveMode) {
270
+ terminal.note(`Effective when published: ${result.settings.requestedEffectiveMode} / ${result.settings.requestedEffectiveConfigurationLinkPolicy}`);
271
+ } else if (result.settings) {
272
+ terminal.note(`Requested: ${result.settings.requestedMode} / ${result.settings.requestedConfigurationLinkPolicy}`);
273
+ }
274
+ }
275
+
276
+ async function settleMutation(api, terminal, publication, result) {
277
+ showMutation(terminal, result);
278
+ const initial = result.materialization;
279
+ if (!initial?.materializationId) return result;
280
+
281
+ let state = initial;
282
+ const deadline = Date.now() + 31 * 60_000;
283
+ while (!['COMMITTED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
284
+ await delay(state.status === 'RETRY_WAIT' ? 10_000 : 3_000);
285
+ state = await api.json(
286
+ `/v1/sites/${publication.siteId}/prism/materializations/${initial.materializationId}`,
287
+ { action: 'Prism repository materialization' },
288
+ );
289
+ terminal.note(`Repository: ${state.status} (attempt ${state.attemptCount})`);
290
+ }
291
+ if (state.status === 'FAILED') {
292
+ throw new UsageError(`Repository update failed (${state.errorCode ?? 'unknown error'}). Run the command again after correcting the cause.`);
293
+ }
294
+ 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.');
296
+ }
297
+ if (!state.publicationAttemptSha) {
298
+ terminal.result('Repository updated. No publication attempt was returned.');
299
+ return { ...result, materialization: state };
300
+ }
301
+
302
+ terminal.note(`Repository committed at ${state.commitSha ?? state.publicationAttemptSha}. Verifying publication.`);
303
+ const publicationState = await waitForPublication(api, terminal, publication.siteId,
304
+ state.publicationAttemptSha, deadline);
305
+ if (publicationState.status === 'FAILED') {
306
+ throw new UsageError(`Publication failed (${publicationState.errors?.[0]?.message ?? 'unknown error'}).`);
307
+ }
308
+ if (publicationState.status !== 'PUBLISHED') {
309
+ throw new UsageError('Publication did not finish before the 31-minute tracking deadline.');
310
+ }
311
+ const live = publicationUrl(publication);
312
+ terminal.result(live ? `Published at ${live}` : 'Published.');
313
+ return { ...result, materialization: state, publicationAttempt: publicationState };
314
+ }
315
+
316
+ async function waitForPublication(api, terminal, siteId, commitSha, deadline) {
317
+ let state;
318
+ do {
319
+ state = await api.json(`/v1/sites/${siteId}/publication-attempts/${commitSha}`, {
320
+ action: 'Prism publication attempt',
321
+ });
322
+ terminal.note(`Publication: ${state.status}`);
323
+ if (['PUBLISHED', 'FAILED'].includes(state.status)) return state;
324
+ await delay(5_000);
325
+ } while (Date.now() < deadline);
326
+ return state;
327
+ }
328
+
329
+ function publicationUrl(publication) {
330
+ if (publication.url) return publication.url.replace(/\/$/, '');
331
+ const base = publication.canonicalBaseUrl ?? publication.hosting?.canonicalBaseUrl;
332
+ const prefix = publication.pathPrefix ?? publication.hosting?.pathPrefix ?? '';
333
+ return base ? `${base.replace(/\/$/, '')}/${prefix.replace(/^\//, '').replace(/\/$/, '')}`.replace(/\/$/, '') : undefined;
334
+ }
335
+
336
+ function delay(milliseconds) {
337
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
338
+ }
339
+
340
+ async function settleGeneration(api, terminal, siteId, articleId, configurationId, initial) {
341
+ terminal.result(`${initial.jobId} ${initial.status}`);
342
+ let state = initial;
343
+ const deadline = Date.now() + 31 * 60_000;
344
+ while (!['SUCCEEDED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
345
+ await delay(3_000);
346
+ state = await api.json(
347
+ `/v1/sites/${siteId}/articles/${articleId}/configurations/${configurationId}/generation-jobs/${initial.jobId}`,
348
+ { action: 'Prism generation job' },
349
+ );
350
+ terminal.note(`Generation: ${state.status} (attempt ${state.attemptCount})`);
351
+ }
352
+ if (state.status === 'FAILED') {
353
+ throw new UsageError(`Proposal generation failed (${state.errorCode ?? 'unknown error'}).`);
354
+ }
355
+ if (state.status !== 'SUCCEEDED') {
356
+ throw new UsageError('Proposal generation did not finish before the 31-minute tracking deadline.');
357
+ }
358
+ terminal.result(`Proposal revision ${state.revisionId} is ready for review.`);
359
+ return state;
360
+ }
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFile, lstat, mkdtemp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { x as extractTar } from 'tar';
6
+
7
+ const PACKAGE = '@rathnasgala/theme';
8
+ const REGISTRY = 'https://registry.npmjs.org';
9
+ const PROTECTED = ['.git/', 'content/', 'custom.css', 'site.config.yml'];
10
+
11
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
12
+ const safeManagedPath = (value) => typeof value === 'string' && value !== ''
13
+ && !path.isAbsolute(value) && !value.split('/').includes('..')
14
+ && !PROTECTED.some((protectedPath) => value === protectedPath || value.startsWith(protectedPath));
15
+
16
+ async function exists(file) {
17
+ try { await stat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
18
+ }
19
+
20
+ function verifyIntegrity(bytes, integrity) {
21
+ const [algorithm, expected] = String(integrity ?? '').split('-', 2);
22
+ if (!['sha512', 'sha256'].includes(algorithm) || !expected) {
23
+ throw new Error('The registry did not provide supported package integrity metadata');
24
+ }
25
+ const actual = createHash(algorithm).update(bytes).digest('base64');
26
+ if (actual !== expected) throw new Error('The downloaded theme failed registry integrity verification');
27
+ }
28
+
29
+ async function registryRelease(channel, fetchImpl) {
30
+ const response = await fetchImpl(`${REGISTRY}/${encodeURIComponent(PACKAGE)}`, {
31
+ headers: { Accept: 'application/json' },
32
+ });
33
+ if (!response.ok) throw new Error(`Theme registry lookup failed with HTTP ${response.status}`);
34
+ const metadata = await response.json();
35
+ const version = metadata?.['dist-tags']?.[channel];
36
+ const release = metadata?.versions?.[version];
37
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version ?? '') || !release?.dist?.tarball) {
38
+ throw new Error(`Theme channel ${channel} has no valid release`);
39
+ }
40
+ if (release.scripts && Object.keys(release.scripts).length > 0) {
41
+ throw new Error('Theme release contains lifecycle scripts and was refused');
42
+ }
43
+ return { version, tarball: release.dist.tarball, integrity: release.dist.integrity };
44
+ }
45
+
46
+ async function unpackRelease(release, fetchImpl) {
47
+ const response = await fetchImpl(release.tarball);
48
+ if (!response.ok) throw new Error(`Theme download failed with HTTP ${response.status}`);
49
+ const bytes = Buffer.from(await response.arrayBuffer());
50
+ verifyIntegrity(bytes, release.integrity);
51
+ const temporary = await mkdtemp(path.join(tmpdir(), 'gala-theme-upgrade-'));
52
+ const archive = path.join(temporary, 'theme.tgz');
53
+ const extracted = path.join(temporary, 'unpacked');
54
+ await mkdir(extracted);
55
+ await writeFile(archive, bytes);
56
+ await extractTar({ file: archive, cwd: extracted, strip: 1, strict: true });
57
+ return { temporary, payload: path.join(extracted, 'payload') };
58
+ }
59
+
60
+ async function readManifest(file) {
61
+ const manifest = JSON.parse(await readFile(file, 'utf8'));
62
+ if (manifest?.schemaVersion !== 1 || typeof manifest.files !== 'object') {
63
+ throw new Error('Theme managed-file manifest is invalid');
64
+ }
65
+ for (const managed of Object.keys(manifest.files)) {
66
+ if (!safeManagedPath(managed)) throw new Error(`Theme release contains protected path: ${managed}`);
67
+ }
68
+ return manifest;
69
+ }
70
+
71
+ async function verifyPayload(payload, manifest) {
72
+ for (const [managed, expected] of Object.entries(manifest.files)) {
73
+ const artifact = manifest.artifactSources?.[managed] ?? managed;
74
+ if (!safeManagedPath(artifact)) throw new Error(`Theme artifact path is unsafe: ${artifact}`);
75
+ const bytes = await readFile(path.join(payload, artifact));
76
+ if (sha256(bytes) !== expected) throw new Error(`Theme payload hash mismatch: ${managed}`);
77
+ }
78
+ }
79
+
80
+ async function assertNoManagedDrift(root, installed) {
81
+ for (const [managed, expected] of Object.entries(installed.files)) {
82
+ const file = path.join(root, managed);
83
+ 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`);
85
+ }
86
+ }
87
+ }
88
+
89
+ async function replaceFile(target, bytes) {
90
+ await mkdir(path.dirname(target), { recursive: true });
91
+ const temporary = `${target}.gala-upgrade-${process.pid}`;
92
+ await writeFile(temporary, bytes);
93
+ await rename(temporary, target);
94
+ }
95
+
96
+ async function applyRelease(root, payload, installed, available) {
97
+ const configFile = path.join(root, 'site.config.yml');
98
+ const manifestFile = path.join(root, '.gala', 'managed-files.json');
99
+ const config = await readFile(configFile, 'utf8');
100
+ const updated = config.replace(
101
+ /(themePackage:\s*\n(?:\s+.*\n)*?\s+version:\s*)[^\s#]+/,
102
+ `$1${available.themePackage.version}`,
103
+ );
104
+ if (updated === config) throw new Error('site.config.yml has no framework.themePackage.version');
105
+ const backup = await mkdtemp(path.join(tmpdir(), 'gala-theme-rollback-'));
106
+ const previousFiles = Object.keys(installed.files);
107
+ try {
108
+ for (const managed of previousFiles) {
109
+ const target = path.join(root, managed);
110
+ const saved = path.join(backup, managed);
111
+ await mkdir(path.dirname(saved), { recursive: true });
112
+ await copyFile(target, saved);
113
+ }
114
+ await copyFile(configFile, path.join(backup, 'site.config.yml'));
115
+ await copyFile(manifestFile, path.join(backup, 'managed-files.json'));
116
+ for (const managed of previousFiles) {
117
+ if (available.files[managed] == null) await rm(path.join(root, managed));
118
+ }
119
+ for (const managed of Object.keys(available.files)) {
120
+ const artifact = available.artifactSources?.[managed] ?? managed;
121
+ await replaceFile(path.join(root, managed), await readFile(path.join(payload, artifact)));
122
+ }
123
+ await replaceFile(configFile, updated);
124
+ await replaceFile(manifestFile, `${JSON.stringify(available, null, 2)}\n`);
125
+ } catch (error) {
126
+ for (const managed of Object.keys(available.files)) {
127
+ if (installed.files[managed] == null) await rm(path.join(root, managed), { force: true });
128
+ }
129
+ for (const managed of previousFiles) {
130
+ await replaceFile(path.join(root, managed), await readFile(path.join(backup, managed)));
131
+ }
132
+ await replaceFile(configFile, await readFile(path.join(backup, 'site.config.yml')));
133
+ await replaceFile(manifestFile, await readFile(path.join(backup, 'managed-files.json')));
134
+ throw error;
135
+ } finally {
136
+ await rm(backup, { recursive: true, force: true });
137
+ }
138
+ }
139
+
140
+ export async function upgrade({ terminal, options, cwd = process.cwd(), fetchImpl = fetch }) {
141
+ const root = path.resolve(options.value('root') ?? cwd);
142
+ const channel = options.value('channel') ?? 'latest';
143
+ if (!['latest', 'next'].includes(channel)) throw new Error('channel must be latest or next');
144
+ const installed = await readManifest(path.join(root, '.gala', 'managed-files.json'));
145
+ const release = await registryRelease(channel, fetchImpl);
146
+ terminal.result(`Theme ${installed.themePackage.version} → ${release.version} (${channel})`);
147
+ if (installed.themePackage.version === release.version) {
148
+ terminal.note('Already current.');
149
+ return { changed: false, version: release.version };
150
+ }
151
+ if (!options.on('yes')) {
152
+ const answer = await terminal.ask('Apply this managed theme upgrade? [y/N]', { fallback: 'no' });
153
+ if (!/^y(?:es)?$/i.test(answer)) {
154
+ terminal.note('Nothing changed.');
155
+ return { changed: false, version: release.version };
156
+ }
157
+ }
158
+ await assertNoManagedDrift(root, installed);
159
+ const unpacked = await unpackRelease(release, fetchImpl);
160
+ try {
161
+ const available = await readManifest(path.join(unpacked.payload, '.gala', 'managed-files.json'));
162
+ if (available.themePackage.version !== release.version) {
163
+ throw new Error('Theme package version does not match its managed manifest');
164
+ }
165
+ await verifyPayload(unpacked.payload, available);
166
+ await applyRelease(root, unpacked.payload, installed, available);
167
+ } finally {
168
+ await rm(unpacked.temporary, { recursive: true, force: true });
169
+ }
170
+ terminal.done(`Upgraded managed theme to ${release.version}`);
171
+ terminal.note('Run gala preview, then gala publish when the result is approved.');
172
+ return { changed: true, version: release.version };
173
+ }
@@ -1,12 +1,15 @@
1
1
  import { auth } from './commands/auth.js';
2
2
  import { doctor } from './commands/doctor.js';
3
+ import { domain } from './commands/domain.js';
3
4
  import { init } from './commands/init.js';
4
5
  import { createPost } from './commands/new.js';
5
6
  import { preview } from './commands/preview.js';
6
7
  import { publish } from './commands/publish.js';
8
+ import { prism } from './commands/prism.js';
9
+ import { upgrade } from './commands/upgrade.js';
7
10
 
8
11
  /**
9
- * Six commands, in the order a writer meets them.
12
+ * Commands in the order a writer meets them.
10
13
  *
11
14
  * v0 had fifteen, and the extra nine were the ones nobody could keep working: a `validate` a hook
12
15
  * ran behind the writer's back, a `workflow` writer for a file the server owns, a
@@ -27,12 +30,17 @@ export const COMMANDS = {
27
30
  run: auth
28
31
  },
29
32
  init: {
30
- summary: 'Create a publication and clone it here',
31
- usage: 'gala init [--name my-notes] [--here]',
32
- flags: ['name', 'api-base-url'],
33
- switches: ['here'],
33
+ summary: 'Create a publication in an empty directory',
34
+ usage: 'gala init [directory] [--name my-notes] [--domain blog.example.com]',
35
+ flags: ['name', 'domain', 'api-base-url'],
34
36
  run: init
35
37
  },
38
+ domain: {
39
+ summary: 'Inspect or change this publication’s custom domain',
40
+ usage: 'gala domain [status|set <hostname>|check|cancel|remove] [--root path]',
41
+ flags: ['root', 'api-base-url'],
42
+ run: domain
43
+ },
36
44
  new: {
37
45
  summary: 'Start a post',
38
46
  usage: 'gala new "A durable idea" [--language en]',
@@ -50,6 +58,20 @@ export const COMMANDS = {
50
58
  switches: ['skip-checks'],
51
59
  run: publish
52
60
  },
61
+ prism: {
62
+ summary: 'Manage author-approved Prism configurations',
63
+ usage: 'gala prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]',
64
+ flags: ['root', 'api-base-url', 'language', 'depth', 'intent', 'modality', 'file', 'reason'],
65
+ switches: ['yes'],
66
+ run: prism
67
+ },
68
+ upgrade: {
69
+ summary: 'Inspect and apply a verified managed-theme update',
70
+ usage: 'gala upgrade [--channel latest|next] [--yes]',
71
+ flags: ['root', 'channel'],
72
+ switches: ['yes'],
73
+ run: upgrade
74
+ },
53
75
  doctor: {
54
76
  summary: 'Check a publication and say what is wrong',
55
77
  flags: ['root', 'api-base-url'],
package/src/domain.js ADDED
@@ -0,0 +1,33 @@
1
+ // Generated by scripts/generate-custom-domain.mjs. Do not edit.
2
+ const HOST = new RegExp("^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$");
3
+ const MAXIMUM_HOSTNAME_LENGTH = 63;
4
+ const FORBIDDEN_PREFIXES = ["www.www."];
5
+ const FORBIDDEN_SUFFIXES = [".github.io"];
6
+ const MESSAGE = {
7
+ "empty": "Enter a domain such as blog.example.com.",
8
+ "components": "Enter only the hostname, without a protocol, path, port or credentials.",
9
+ "policy": "Enter a valid custom hostname shorter than 64 characters and outside github.io.",
10
+ "labels": "Enter a complete domain with valid DNS labels.",
11
+ "invalid": "Enter a valid domain such as blog.example.com."
12
+ };
13
+
14
+ export function customDomain(value) {
15
+ const source = typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : '';
16
+ if (!source) return { error: MESSAGE.empty };
17
+ try {
18
+ const parsed = new URL(`https://${source}`);
19
+ if (parsed.username || parsed.password || parsed.port || parsed.pathname !== '/'
20
+ || parsed.search || parsed.hash || parsed.hostname !== source) {
21
+ return { error: MESSAGE.components };
22
+ }
23
+ if (source.length > MAXIMUM_HOSTNAME_LENGTH
24
+ || FORBIDDEN_PREFIXES.some((prefix) => source.startsWith(prefix))
25
+ || FORBIDDEN_SUFFIXES.some((suffix) => source.endsWith(suffix))) {
26
+ return { error: MESSAGE.policy };
27
+ }
28
+ if (!HOST.test(source)) return { error: MESSAGE.labels };
29
+ return { host: source };
30
+ } catch {
31
+ return { error: MESSAGE.invalid };
32
+ }
33
+ }
package/src/git.js CHANGED
@@ -141,3 +141,20 @@ export function cloneRepository({ url, target, token, spawnProcess = spawn }) {
141
141
  });
142
142
  });
143
143
  }
144
+
145
+ /** Populate a deliberately empty git repository without replacing its .git directory. */
146
+ export async function populateEmptyRepository({ url, target, token, spawnProcess = spawn }) {
147
+ const git = createGit({ root: target, token, spawnProcess });
148
+ const hasOrigin = await git.run(['remote', 'get-url', 'origin'], { allow: [0, 2] });
149
+ await git.run(hasOrigin === 0
150
+ ? ['remote', 'set-url', 'origin', url]
151
+ : ['remote', 'add', 'origin', url]);
152
+ await git.run(['fetch', 'origin']);
153
+ const remoteHead = await git.run(['ls-remote', '--symref', 'origin', 'HEAD'], { capture: true });
154
+ const branch = /^ref: refs\/heads\/([^\s]+)\s+HEAD$/m.exec(remoteHead)?.[1];
155
+ if (!branch || !/^[A-Za-z0-9._/-]+$/.test(branch)) {
156
+ throw new Error('GitHub did not report a usable default branch');
157
+ }
158
+ await git.run(['checkout', '-B', branch, '--track', `origin/${branch}`]);
159
+ return path.resolve(target);
160
+ }
@@ -21,6 +21,8 @@ export async function readPublication(root) {
21
21
  ? configuration.hosting.pathPrefix
22
22
  : '/';
23
23
  return {
24
+ siteId: configuration?.site?.id,
25
+ repository: configuration?.site?.repository,
24
26
  name: configuration?.site?.name,
25
27
  defaultLanguage: configuration?.site?.defaultLanguage ?? 'en',
26
28
  url: `${base.replace(/\/$/, '')}${prefix === '/' ? '' : prefix}/`