@rathnasgala/cli 0.0.22 → 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.
Files changed (69) hide show
  1. package/README.md +100 -187
  2. package/package.json +3 -3
  3. package/src/api/gala.js +163 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/domain.js +124 -0
  14. package/src/commands/init.js +276 -0
  15. package/src/commands/new.js +76 -0
  16. package/src/commands/preview.js +92 -0
  17. package/src/commands/prism.js +360 -0
  18. package/src/commands/publish.js +57 -0
  19. package/src/commands/upgrade.js +173 -0
  20. package/src/commands-manifest.js +80 -0
  21. package/src/content.js +31 -0
  22. package/src/domain.js +33 -0
  23. package/src/git.js +160 -0
  24. package/src/index.js +44 -294
  25. package/src/publication.js +39 -0
  26. package/src/assign-content-ids.js +0 -1
  27. package/src/auth-command.js +0 -36
  28. package/src/configure-site.js +0 -102
  29. package/src/content-files.js +0 -1
  30. package/src/doctor-command.js +0 -214
  31. package/src/entitlement-client.js +0 -26
  32. package/src/entitlement-command.js +0 -74
  33. package/src/evaluation-date.js +0 -1
  34. package/src/gala-credential-health.js +0 -34
  35. package/src/gala-credential-store.js +0 -115
  36. package/src/gala-device-flow.js +0 -121
  37. package/src/git-credentials.js +0 -37
  38. package/src/github-auth-command.js +0 -50
  39. package/src/github-credential-store.js +0 -104
  40. package/src/github-device-flow.js +0 -153
  41. package/src/github-empty-repository.js +0 -89
  42. package/src/github-identity.js +0 -32
  43. package/src/github-pages-provisioning.js +0 -107
  44. package/src/github-repository-secret.js +0 -82
  45. package/src/github-repository-variable.js +0 -56
  46. package/src/github-template-repository.js +0 -171
  47. package/src/hook-command.js +0 -64
  48. package/src/http-failure.js +0 -55
  49. package/src/new-command.js +0 -54
  50. package/src/open-browser.js +0 -40
  51. package/src/preview-command.js +0 -60
  52. package/src/publication-creation-client.js +0 -155
  53. package/src/publication-state.js +0 -7
  54. package/src/publish-command.js +0 -37
  55. package/src/record-deployment-command.js +0 -147
  56. package/src/refresh-command.js +0 -104
  57. package/src/repository-limits.js +0 -94
  58. package/src/scaffold-git.js +0 -76
  59. package/src/scaffold-options.js +0 -58
  60. package/src/scaffold-preflight.js +0 -146
  61. package/src/scaffold-site.js +0 -185
  62. package/src/site-config-registration.js +0 -47
  63. package/src/site-registration-client.js +0 -138
  64. package/src/theme-package.js +0 -128
  65. package/src/topology-client.js +0 -43
  66. package/src/topology-command.js +0 -70
  67. package/src/upgrade-command.js +0 -81
  68. package/src/validate-command.js +0 -5
  69. package/src/workflow-command.js +0 -87
package/src/content.js ADDED
@@ -0,0 +1,31 @@
1
+ import { regenerateBuildManifest } from '@rathnasgala/content-validation';
2
+
3
+ /**
4
+ * Content validation, shared by preview and publish.
5
+ *
6
+ * 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.
8
+ * Validation is not a task; it is a precondition of showing or shipping. It runs where those happen
9
+ * and nowhere else.
10
+ *
11
+ * `regenerate` is injectable because the real validator compares the configuration against the
12
+ * theme package installed in the publication's node_modules. That belongs in a test of the
13
+ * validator, not of how this reports what it found.
14
+ */
15
+ export async function checkContent({ terminal, root, today, regenerate = regenerateBuildManifest }) {
16
+ const { results } = await regenerate({ root, today });
17
+ const failed = results.filter(({ errors }) => errors.length > 0);
18
+
19
+ for (const result of results) {
20
+ for (const warning of result.warnings ?? []) terminal.note(`${result.file}: ${warning}`);
21
+ }
22
+ if (failed.length === 0) return results;
23
+
24
+ // Every problem, in one pass. Stopping at the first means fixing one thing, running again, and
25
+ // only then learning about the next.
26
+ terminal.blank();
27
+ for (const result of failed) {
28
+ for (const error of result.errors) terminal.fail(`${result.file}: ${error}`);
29
+ }
30
+ throw new Error(`${failed.length} post${failed.length === 1 ? '' : 's'} cannot be published yet`);
31
+ }
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 ADDED
@@ -0,0 +1,160 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Git, authenticated as the writer's Gala credential rather than as the machine.
6
+ *
7
+ * v0 let every git call fall through to whatever credential helper the machine had configured,
8
+ * which is a different identity from the one the CLI just authenticated with. On the machine where
9
+ * this surfaced the token belonged to one account and git's stored credential to another, so a
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.
12
+ *
13
+ * The token travels in the environment, never in the argument list, because arguments are readable
14
+ * machine-wide through `ps`. Nothing is written to `.git/config`.
15
+ */
16
+ const TOKEN_VARIABLE = 'GALA_GIT_TOKEN';
17
+
18
+ function credentialArguments(token) {
19
+ if (typeof token !== 'string' || token === '') return [];
20
+ return [
21
+ // The empty helper first, or the machine's keychain answers before ours does.
22
+ '-c', 'credential.helper=',
23
+ '-c', `credential.helper=!f() { test "$1" = get && echo username=x-access-token && echo "password=$${TOKEN_VARIABLE}"; }; f`
24
+ ];
25
+ }
26
+
27
+ function environmentFor(token) {
28
+ if (typeof token !== 'string' || token === '') return process.env;
29
+ // Nothing on this path may block waiting for a username at a terminal.
30
+ return { ...process.env, [TOKEN_VARIABLE]: token, GIT_TERMINAL_PROMPT: '0' };
31
+ }
32
+
33
+ export function createGit({ root, token, spawnProcess = spawn } = {}) {
34
+ const cwd = root == null ? process.cwd() : path.resolve(root);
35
+
36
+ /*
37
+ * Git's own output is captured, not inherited.
38
+ *
39
+ * v0 let it through, so a writer's terminal filled with `Cloning into '/long/path'`, rebase
40
+ * plumbing and push refspecs interleaved with the CLI's own lines. None of it is addressed to
41
+ * them. On failure every captured line is emitted, because that is exactly when git's text is
42
+ * the most useful thing on screen.
43
+ */
44
+ const run = (args, { allow = [0], capture = false } = {}) => new Promise((resolve, reject) => {
45
+ const child = spawnProcess('git', ['-C', cwd, ...credentialArguments(token), ...args], {
46
+ cwd,
47
+ shell: false,
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ env: environmentFor(token)
50
+ });
51
+ let stdout = '';
52
+ let stderr = '';
53
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
54
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
55
+ child.once('error', reject);
56
+ child.once('exit', (code, signal) => {
57
+ if (signal || !allow.includes(code)) {
58
+ const said = `${stdout}${stderr}`.trim();
59
+ const failure = new Error(signal
60
+ ? `git ${args[0]} stopped by ${signal}`
61
+ : `git ${args[0]} exited with ${code}`);
62
+ failure.detail = said;
63
+ reject(failure);
64
+ return;
65
+ }
66
+ resolve(capture ? stdout.trim() : code);
67
+ });
68
+ });
69
+
70
+ const git = {
71
+ root: cwd,
72
+ run,
73
+
74
+ async branch() {
75
+ const name = await run(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true });
76
+ if (name === 'HEAD' || !/^[A-Za-z0-9._/-]+$/.test(name)) {
77
+ throw new Error('This checkout is not on a named branch');
78
+ }
79
+ return name;
80
+ },
81
+
82
+ async head() {
83
+ const sha = await run(['rev-parse', 'HEAD'], { capture: true });
84
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error('git returned an unusable commit id');
85
+ return sha;
86
+ },
87
+
88
+ /** True when something was recorded; false when the tree already matched. */
89
+ async record(message, paths) {
90
+ await run(['add', '--', ...paths]);
91
+ const unchanged = await run(['diff', '--cached', '--quiet', '--exit-code'], { allow: [0, 1] });
92
+ if (unchanged === 0) return false;
93
+ await run(['commit', '-m', message]);
94
+ return true;
95
+ },
96
+
97
+ send: () => run(['push', 'origin', 'HEAD']),
98
+
99
+ /**
100
+ * Brings the remote's commits in, over the top of anything uncommitted.
101
+ *
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.
108
+ */
109
+ async takeRemote() {
110
+ const branch = await git.branch();
111
+ await run(['fetch', 'origin', branch]);
112
+ await run(['rebase', '--autostash', `origin/${branch}`]);
113
+ return git.head();
114
+ }
115
+ };
116
+
117
+ return git;
118
+ }
119
+
120
+ export function cloneRepository({ url, target, token, spawnProcess = spawn }) {
121
+ const resolved = path.resolve(target);
122
+ return new Promise((resolve, reject) => {
123
+ const child = spawnProcess('git', [...credentialArguments(token), 'clone', url, resolved], {
124
+ cwd: path.dirname(resolved),
125
+ shell: false,
126
+ stdio: ['ignore', 'pipe', 'pipe'],
127
+ env: environmentFor(token)
128
+ });
129
+ let said = '';
130
+ child.stdout?.on('data', (chunk) => { said += chunk; });
131
+ child.stderr?.on('data', (chunk) => { said += chunk; });
132
+ child.once('error', reject);
133
+ child.once('exit', (code, signal) => {
134
+ if (signal || code !== 0) {
135
+ const failure = new Error(signal ? `clone stopped by ${signal}` : `clone exited with ${code}`);
136
+ failure.detail = said.trim();
137
+ reject(failure);
138
+ return;
139
+ }
140
+ resolve(resolved);
141
+ });
142
+ });
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
+ }
package/src/index.js CHANGED
@@ -1,310 +1,60 @@
1
1
  #!/usr/bin/env node
2
+ import { UsageError, parseArguments } from './cli/args.js';
3
+ import { createTerminal } from './cli/terminal.js';
4
+ import { COMMANDS } from './commands-manifest.js';
2
5
 
3
- import { parseScaffoldOptions } from './scaffold-options.js';
4
- import { regenerateBuildManifest } from './validate-command.js';
5
- import { createPost } from './new-command.js';
6
- import {
7
- diagnoseFramework,
8
- diagnosePublicationState,
9
- repairFramework
10
- } from './doctor-command.js';
11
- import { configureSite } from './configure-site.js';
12
- import { previewSite } from './preview-command.js';
13
- import { writePublishWorkflow } from './workflow-command.js';
14
- import { publishSite } from './publish-command.js';
15
- import { installPrePushHook } from './hook-command.js';
16
- import { reportRepositoryLimitWarnings } from './repository-limits.js';
17
- import { recordDeployment } from './record-deployment-command.js';
18
- import { authenticateGala } from './auth-command.js';
19
- import { createInterface } from 'node:readline/promises';
20
- import { upgradeTheme } from './upgrade-command.js';
21
- import { authenticateGithub } from './github-auth-command.js';
22
- import { scaffoldSite } from './scaffold-site.js';
23
- import { openInBrowser } from './open-browser.js';
24
- import { prepareScaffold } from './scaffold-preflight.js';
25
- import { refreshEngagementSnapshot } from './refresh-command.js';
26
- import { switchTopology } from './topology-command.js';
27
- import { acquireAttributionEntitlement } from './entitlement-command.js';
28
6
 
29
- /*
30
- * A failed command should say what went wrong and what to do about it. Node's default for a
31
- * rejected top-level await is a stack trace through node_modules, which tells a writer nothing and
32
- * buries the one line that matters. The stack is still available behind GALA_DEBUG for anyone
33
- * debugging the CLI itself.
34
- */
35
- process.on('uncaughtException', reportAndExit);
36
- process.on('unhandledRejection', reportAndExit);
7
+ const [name, ...argv] = process.argv.slice(2);
8
+ const terminal = createTerminal();
37
9
 
38
- function reportAndExit(failure) {
39
- if (process.env.GALA_DEBUG) {
40
- process.stderr.write(`${failure instanceof Error ? failure.stack : String(failure)}\n`);
41
- } else {
42
- const message = failure instanceof Error ? failure.message : String(failure);
43
- process.stderr.write(`${message}\n`);
44
- }
45
- process.exit(1);
10
+ if (name == null || name === 'help' || name === '--help' || name === '-h') {
11
+ usage();
12
+ process.exit(name == null ? 1 : 0);
46
13
  }
47
14
 
48
- /**
49
- * Opens the page and says so, or falls back to asking for it to be opened by hand. The URL is
50
- * printed either way — it is the thing the writer may need to move to another device.
51
- */
52
- function announce(verificationUri, userCode) {
53
- const opened = openInBrowser(verificationUri);
54
- return `${opened ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}\n`;
15
+ const command = COMMANDS[name];
16
+ if (command == null) {
17
+ terminal.fail(`there is no ${name} command`);
18
+ usage();
19
+ process.exit(1);
55
20
  }
56
21
 
57
- const [command, ...args] = process.argv.slice(2);
58
- const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
59
-
60
- if (command === 'help' || command === '--help' || command === '-h'
61
- || args.includes('--help') || args.includes('-h')) {
62
- process.stdout.write(`${usage}\n`);
22
+ if (argv.includes('--help') || argv.includes('-h')) {
23
+ process.stdout.write(`\n ${command.usage ?? `gala ${name}`}\n ${command.summary}\n\n`);
63
24
  process.exit(0);
64
25
  }
65
26
 
66
- const recognizedCommands = new Set([
67
- 'auth', 'configure', 'validate', 'new', 'doctor', 'preview',
68
- 'workflow', 'publish', 'record-deployment', 'refresh', 'hook', 'upgrade', 'topology', 'entitlement'
69
- ]);
70
- function commandRoot() {
71
- const rootIndex = args.indexOf('--root');
72
- if (rootIndex !== -1) return args[rootIndex + 1];
73
- if (command === 'doctor' || command === 'validate') {
74
- return args.find((argument, index) =>
75
- !argument.startsWith('--')
76
- && !['--today', '--source'].includes(args[index - 1])
77
- ) ?? process.cwd();
78
- }
79
- return process.cwd();
80
- }
81
- if (recognizedCommands.has(command)) {
82
- await reportRepositoryLimitWarnings(commandRoot());
27
+ try {
28
+ const options = parseArguments(argv, { flags: command.flags ?? [], switches: command.switches ?? [] });
29
+ await command.run({ terminal, options });
30
+ } catch (failure) {
31
+ report(failure);
32
+ process.exit(1);
83
33
  }
84
34
 
85
- if (command === 'auth') {
86
- if (args[0] === 'github') {
87
- await authenticateGithub({
88
- showInstructions: ({ verificationUri, userCode }) => {
89
- process.stdout.write(announce(verificationUri, userCode));
90
- }
91
- });
92
- process.stdout.write('GitHub authentication stored securely.\n');
93
- } else {
94
- const apiIndex = args.indexOf('--api-base-url');
95
- const apiBaseUrl = apiIndex === -1 ? 'https://api.gala67.com' : args[apiIndex + 1];
96
- const result = await authenticateGala({
97
- apiBaseUrl,
98
- showInstructions: ({ verificationUri, userCode }) => {
99
- process.stdout.write(announce(verificationUri, userCode));
100
- }
101
- });
102
- process.stdout.write(`Gala authentication stored securely until ${result.expiresAt.toISOString()}.\n`);
103
- }
104
- } else if (command === 'scaffold') {
105
- const valueFor = (name) => {
106
- const index = args.indexOf(name);
107
- return index === -1 ? undefined : args[index + 1];
108
- };
109
- const explicitInstallationId = valueFor('--installation-id');
110
- const topology = valueFor('--topology') ?? 'provider-default';
111
- const siteOptions = parseScaffoldOptions(args);
112
- // Prompting only makes sense at a terminal. In CI there is nobody to answer, so a missing value
113
- // has to stay a clear error rather than a process that hangs waiting for enter.
114
- const interactive = process.stdin.isTTY === true;
115
- const ask = interactive
116
- ? async (question) => {
117
- const terminal = createInterface({ input: process.stdin, output: process.stdout });
118
- try { return await terminal.question(question); } finally { terminal.close(); }
119
- }
120
- : undefined;
121
- const prepared = await prepareScaffold({
122
- owner: valueFor('--owner'),
123
- repository: valueFor('--repository'),
124
- target: valueFor('--target'),
125
- githubInstallationId: explicitInstallationId == null ? undefined : Number(explicitInstallationId),
126
- siteName: siteOptions.siteName,
127
- apiBaseUrl: valueFor('--api-base-url') ?? 'https://api.gala67.com',
128
- notify: (message) => process.stdout.write(`${message}\n`),
129
- ask
130
- });
131
- const result = await scaffoldSite({
132
- notify: (message) => process.stdout.write(`${message}\n`),
133
- ask,
134
- openUrl: openInBrowser,
135
- owner: prepared.owner,
136
- repository: prepared.repository,
137
- target: prepared.target,
138
- githubInstallationId: prepared.githubInstallationId,
139
- topology,
140
- canonicalBaseUrl: valueFor('--canonical-base-url'),
141
- siteOptions,
142
- emptyExistingRepository: args.includes('--empty-existing-repository'),
143
- resumeExistingCheckout: args.includes('--resume')
144
- });
145
- await reportRepositoryLimitWarnings(result.root);
146
- process.stdout.write(`Scaffolded ${result.fullName} as Gala site ${result.siteId} in ${result.root}.\n`);
147
- } else if (command === 'configure') {
148
- const rootIndex = args.indexOf('--root');
149
- const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
150
- const options = parseScaffoldOptions(args);
151
- const config = await configureSite(root, options);
152
- process.stdout.write(`${JSON.stringify(config.design, null, 2)}\n`);
153
- } else if (command === 'topology') {
154
- const valueFor = (name) => {
155
- const index = args.indexOf(name);
156
- return index === -1 ? undefined : args[index + 1];
157
- };
158
- const result = await switchTopology({
159
- root: valueFor('--root') ?? process.cwd(),
160
- owner: valueFor('--owner'),
161
- repository: valueFor('--repository'),
162
- canonicalBaseUrl: valueFor('--canonical-base-url'),
163
- pathPrefix: valueFor('--path-prefix') ?? '/'
164
- });
165
- process.stdout.write(`Committed topology ${result.changeId} at ${result.commitSha}.\n`);
166
- } else if (command === 'entitlement') {
167
- const rootIndex = args.indexOf('--root');
168
- const result = await acquireAttributionEntitlement({
169
- root: rootIndex === -1 ? process.cwd() : args[rootIndex + 1]
170
- });
171
- process.stdout.write(result.changed
172
- ? `Stored the signed attribution entitlement for ${result.siteId}.\n`
173
- : `Attribution entitlement for ${result.siteId} is current.\n`);
174
- } else if (command === 'validate') {
175
- const todayIndex = args.indexOf('--today');
176
- const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
177
- const root = args.find((argument) => !argument.startsWith('--') && argument !== today) ?? process.cwd();
178
- const { results } = await regenerateBuildManifest({ root, today });
179
- const failures = results.filter(({ errors }) => errors.length > 0);
180
-
181
- for (const result of failures) {
182
- for (const error of result.errors) process.stderr.write(`${result.file}: ${error}\n`);
183
- }
184
- for (const result of results) {
185
- for (const warning of result.warnings) process.stderr.write(`${result.file}: warning: ${warning}\n`);
35
+ /**
36
+ * One line the writer can act on.
37
+ *
38
+ * A rejected top-level await prints a stack through node_modules by default, which buries the only
39
+ * line that matters. The stack is still there behind GALA_DEBUG for anyone debugging the CLI
40
+ * itself, which is a different audience from anyone trying to publish.
41
+ */
42
+ function report(failure) {
43
+ if (process.env.GALA_DEBUG) {
44
+ process.stderr.write(`${failure instanceof Error ? failure.stack : String(failure)}\n`);
45
+ return;
186
46
  }
187
- process.stdout.write(`Validated ${results.length} post variant(s); ${failures.length} failed.\n`);
188
- if (failures.length > 0) process.exitCode = 1;
189
- } else if (command === 'new') {
190
- const valueFor = (name) => {
191
- const index = args.indexOf(name);
192
- return index === -1 ? undefined : args[index + 1];
193
- };
194
- const title = valueFor('--title');
195
- const language = valueFor('--language');
196
- const today = valueFor('--today');
197
- const root = valueFor('--root') ?? process.cwd();
198
- const result = await createPost({ root, title, language, today });
199
- process.stdout.write(`Created ${result.postPath}\n`);
200
- } else if (command === 'doctor') {
201
- const positional = args.filter((argument, index) =>
202
- !argument.startsWith('--') && args[index - 1] !== '--source'
203
- );
204
- const root = positional[0] ?? process.cwd();
205
- if (args.includes('--fix')) {
206
- const sourceIndex = args.indexOf('--source');
207
- const sourceRoot = sourceIndex === -1 ? undefined : args[sourceIndex + 1];
208
- if (!sourceRoot) throw new Error('doctor --fix requires --source <trusted-template-root>');
209
- const repaired = await repairFramework(root, sourceRoot);
210
- process.stdout.write(`Repaired ${repaired.length} managed file(s).\n`);
47
+ terminal.fail(failure instanceof Error ? failure.message : String(failure));
48
+ // Captured output from a subprocess is the most useful thing on screen at exactly this moment.
49
+ if (typeof failure?.detail === 'string' && failure.detail !== '') {
50
+ for (const line of failure.detail.split('\n')) terminal.note(line);
211
51
  }
212
- const findings = await diagnoseFramework(root);
213
- const drift = findings.filter(({ status }) => status !== 'intact');
214
- findings.forEach(({ path: file, status }) => process.stdout.write(`${status}\t${file}\n`));
215
- const publicationState = await diagnosePublicationState(root);
216
- process.stdout.write(`${publicationState.status}\t${publicationState.path}\n`);
217
- if (publicationState.status === 'invalid') process.exitCode = 1;
218
- if (drift.length > 0) process.exitCode = 1;
219
- } else if (command === 'preview') {
220
- const valueFor = (name) => {
221
- const index = args.indexOf(name);
222
- return index === -1 ? undefined : args[index + 1];
223
- };
224
- await previewSite({
225
- root: valueFor('--root') ?? process.cwd(),
226
- today: valueFor('--today')
227
- });
228
- } else if (command === 'workflow') {
229
- const valueFor = (name) => {
230
- const index = args.indexOf(name);
231
- return index === -1 ? undefined : args[index + 1];
232
- };
233
- const result = await writePublishWorkflow({
234
- root: valueFor('--root') ?? process.cwd(),
235
- siteId: valueFor('--site-id'),
236
- timezone: valueFor('--timezone'),
237
- actionRef: valueFor('--action-ref'),
238
- defaultBranch: valueFor('--default-branch') ?? 'main',
239
- buildMode: valueFor('--mode') ?? 'build-and-deploy'
240
- });
241
- process.stdout.write(`Wrote ${result.target} (${result.minute} ${result.hour} * * *)\n`);
242
- } else if (command === 'publish') {
243
- const valueFor = (name) => {
244
- const index = args.indexOf(name);
245
- return index === -1 ? undefined : args[index + 1];
246
- };
247
- await publishSite({
248
- root: valueFor('--root') ?? process.cwd(),
249
- today: valueFor('--today'),
250
- force: args.includes('--force')
251
- });
252
- } else if (command === 'record-deployment') {
253
- const valueFor = (name) => {
254
- const index = args.indexOf(name);
255
- return index === -1 ? undefined : args[index + 1];
256
- };
257
- const root = valueFor('--root') ?? process.cwd();
258
- const result = await recordDeployment({
259
- root,
260
- deployedOn: valueFor('--today'),
261
- deployedCommitSha: valueFor('--commit-sha')
262
- });
263
- process.stdout.write(
264
- `${result.pushed ? 'Pushed' : 'No change for'} successful deployment `
265
- + `of ${result.state.posts.length} article(s).\n`
266
- + `Recorded state SHA: ${result.recordedStateSha}\n`
267
- );
268
- } else if (command === 'refresh') {
269
- const rootIndex = args.indexOf('--root');
270
- const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
271
- const result = await refreshEngagementSnapshot({ root });
272
- process.stdout.write(result.changed
273
- ? 'Refreshed, committed, and pushed the engagement snapshot.\n'
274
- : 'Engagement snapshot is already current.\n');
275
- } else if (command === 'upgrade') {
276
- const valueFor = (name) => {
277
- const index = args.indexOf(name);
278
- return index === -1 ? undefined : args[index + 1];
279
- };
280
- const terminalConfirm = async ({ installed, version, channel }) => {
281
- if (args.includes('--yes')) return true;
282
- const terminal = createInterface({ input: process.stdin, output: process.stdout });
283
- try {
284
- const answer = await terminal.question(`Upgrade theme ${installed} -> ${version} (${channel})? [y/N] `);
285
- return /^(?:y|yes)$/i.test(answer.trim());
286
- } finally { terminal.close(); }
287
- };
288
- const result = await upgradeTheme({
289
- root: valueFor('--root') ?? process.cwd(),
290
- channel: valueFor('--channel'),
291
- confirm: terminalConfirm
292
- });
293
- process.stdout.write(result.cancelled ? 'Theme upgrade cancelled.\n'
294
- : result.changed ? `Upgraded theme to ${result.version}.\n`
295
- : `Theme ${result.version} is already installed.\n`);
296
- process.stdout.write(
297
- `Action major v${result.action.currentMajor}; `
298
- + (result.action.newerAvailable
299
- ? `v${result.action.latestMajor} is available.\n`
300
- : 'no newer major is available.\n')
301
- );
302
- } else if (command === 'hook' && args[0] === 'install') {
303
- const rootIndex = args.indexOf('--root');
304
- const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
305
- const result = await installPrePushHook(root);
306
- process.stdout.write(`${result.installed ? 'Installed' : 'Already installed'} ${result.target}\n`);
307
- } else {
308
- process.stderr.write(`${usage}\n`);
309
- process.exitCode = 1;
52
+ if (failure instanceof UsageError) usage();
53
+ }
54
+
55
+ function usage() {
56
+ const lines = Object.entries(COMMANDS)
57
+ .map(([key, { summary }]) => ` ${key.padEnd(9)} ${summary}`)
58
+ .join('\n');
59
+ process.stdout.write(`\n gala <command>\n\n${lines}\n\n Run any command with --help.\n\n`);
310
60
  }
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { parse } from 'yaml';
5
+
6
+ /**
7
+ * Where a publication and its posts actually live on the web.
8
+ *
9
+ * The CLI knew every part of this and never said it: `new` printed a file path, `publish` said the
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
13
+ * is a poor first minute with a publishing tool.
14
+ */
15
+ export async function readPublication(root) {
16
+ try {
17
+ const configuration = parse(await readFile(path.join(root, 'site.config.yml'), 'utf8'));
18
+ const base = configuration?.hosting?.canonicalBaseUrl;
19
+ if (typeof base !== 'string') return null;
20
+ const prefix = typeof configuration?.hosting?.pathPrefix === 'string'
21
+ ? configuration.hosting.pathPrefix
22
+ : '/';
23
+ return {
24
+ siteId: configuration?.site?.id,
25
+ repository: configuration?.site?.repository,
26
+ name: configuration?.site?.name,
27
+ defaultLanguage: configuration?.site?.defaultLanguage ?? 'en',
28
+ url: `${base.replace(/\/$/, '')}${prefix === '/' ? '' : prefix}/`
29
+ };
30
+ } catch {
31
+ // Not being able to read it is never worth failing a command over; the caller simply says less.
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function postUrl(publication, slug, language) {
37
+ if (publication == null) return null;
38
+ return `${publication.url}${language ?? publication.defaultLanguage}/${slug}/`;
39
+ }
@@ -1 +0,0 @@
1
- export { assignMissingContentIds } from '@rathnasgala/content-validation';
@@ -1,36 +0,0 @@
1
- import { pollForGalaToken, requestGalaDeviceCode } from './gala-device-flow.js';
2
- import { writeGalaCredential } from './gala-credential-store.js';
3
-
4
- export async function authenticateGala({
5
- apiBaseUrl = 'https://api.gala67.com',
6
- clientId = 'gala-cli',
7
- fetchImpl = fetch,
8
- sleep,
9
- now = Date.now,
10
- showInstructions,
11
- credentialTarget
12
- } = {}) {
13
- if (typeof showInstructions !== 'function') throw new TypeError('showInstructions is required');
14
- const authorization = await requestGalaDeviceCode({ apiBaseUrl, clientId, fetchImpl });
15
- showInstructions({
16
- verificationUri: authorization.verificationUri,
17
- verificationUriComplete: authorization.verificationUriComplete,
18
- userCode: authorization.userCode
19
- });
20
- const token = await pollForGalaToken({
21
- ...authorization,
22
- apiBaseUrl,
23
- clientId,
24
- fetchImpl,
25
- ...(sleep == null ? {} : { sleep }),
26
- now
27
- });
28
- const expiresAt = new Date(now() + token.expiresInSeconds * 1000);
29
- const target = await writeGalaCredential({
30
- accessToken: token.accessToken,
31
- expiresAt,
32
- apiBaseUrl,
33
- ...(credentialTarget == null ? {} : { target: credentialTarget })
34
- });
35
- return Object.freeze({ target, expiresAt });
36
- }