@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.
- package/README.md +100 -187
- package/package.json +3 -3
- package/src/api/gala.js +163 -0
- package/src/api/github.js +63 -0
- package/src/api/http.js +72 -0
- package/src/auth/gala.js +52 -0
- package/src/auth/github.js +78 -0
- package/src/auth/store.js +88 -0
- package/src/cli/args.js +56 -0
- package/src/cli/terminal.js +104 -0
- package/src/commands/auth.js +20 -0
- package/src/commands/doctor.js +98 -0
- package/src/commands/domain.js +124 -0
- package/src/commands/init.js +276 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/prism.js +360 -0
- package/src/commands/publish.js +57 -0
- package/src/commands/upgrade.js +173 -0
- package/src/commands-manifest.js +80 -0
- package/src/content.js +31 -0
- package/src/domain.js +33 -0
- package/src/git.js +160 -0
- package/src/index.js +44 -294
- package/src/publication.js +39 -0
- package/src/assign-content-ids.js +0 -1
- package/src/auth-command.js +0 -36
- package/src/configure-site.js +0 -102
- package/src/content-files.js +0 -1
- package/src/doctor-command.js +0 -214
- package/src/entitlement-client.js +0 -26
- package/src/entitlement-command.js +0 -74
- package/src/evaluation-date.js +0 -1
- package/src/gala-credential-health.js +0 -34
- package/src/gala-credential-store.js +0 -115
- package/src/gala-device-flow.js +0 -121
- package/src/git-credentials.js +0 -37
- package/src/github-auth-command.js +0 -50
- package/src/github-credential-store.js +0 -104
- package/src/github-device-flow.js +0 -153
- package/src/github-empty-repository.js +0 -89
- package/src/github-identity.js +0 -32
- package/src/github-pages-provisioning.js +0 -107
- package/src/github-repository-secret.js +0 -82
- package/src/github-repository-variable.js +0 -56
- package/src/github-template-repository.js +0 -171
- package/src/hook-command.js +0 -64
- package/src/http-failure.js +0 -55
- package/src/new-command.js +0 -54
- package/src/open-browser.js +0 -40
- package/src/preview-command.js +0 -60
- package/src/publication-creation-client.js +0 -155
- package/src/publication-state.js +0 -7
- package/src/publish-command.js +0 -37
- package/src/record-deployment-command.js +0 -147
- package/src/refresh-command.js +0 -104
- package/src/repository-limits.js +0 -94
- package/src/scaffold-git.js +0 -76
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -146
- package/src/scaffold-site.js +0 -185
- package/src/site-config-registration.js +0 -47
- package/src/site-registration-client.js +0 -138
- package/src/theme-package.js +0 -128
- package/src/topology-client.js +0 -43
- package/src/topology-command.js +0 -70
- package/src/upgrade-command.js +0 -81
- package/src/validate-command.js +0 -5
- 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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
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';
|
package/src/auth-command.js
DELETED
|
@@ -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
|
-
}
|