@rathnasgala/cli 0.0.21 → 1.0.0
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 +66 -212
- package/package.json +3 -3
- package/src/api/gala.js +126 -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/init.js +197 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/publish.js +57 -0
- package/src/commands-manifest.js +58 -0
- package/src/content.js +31 -0
- package/src/git.js +143 -0
- package/src/index.js +44 -297
- package/src/publication.js +37 -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/github-auth-command.js +0 -29
- package/src/github-credential-store.js +0 -65
- package/src/github-device-flow.js +0 -130
- package/src/github-empty-repository.js +0 -76
- 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 -165
- 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 -144
- 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 -41
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -147
- package/src/scaffold-site.js +0 -162
- 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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { galaApi } from '../api/gala.js';
|
|
5
|
+
import { githubApi } from '../api/github.js';
|
|
6
|
+
import { galaCredential } from '../auth/gala.js';
|
|
7
|
+
import { githubCredential } from '../auth/github.js';
|
|
8
|
+
import { cloneRepository, createGit } from '../git.js';
|
|
9
|
+
import { UsageError } from '../cli/args.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a publication and leaves a working checkout behind.
|
|
13
|
+
*
|
|
14
|
+
* The shape of this command is the lesson of v0. Everything it used to do itself now belongs to
|
|
15
|
+
* whoever can do it correctly:
|
|
16
|
+
*
|
|
17
|
+
* - The **server** creates the repository, because it is the same call the browser editor makes:
|
|
18
|
+
* it falls back from template to empty-and-seed, waits for the App installation to actually
|
|
19
|
+
* reach the result, and reports the installation id. The CLI's own version had none of that,
|
|
20
|
+
* which is why repositories it created never appeared in the web UI.
|
|
21
|
+
* - The **server** writes `site.config.yml` and the publish workflow during registration. The CLI
|
|
22
|
+
* used to write its own versions afterwards and commit them, producing a second commit whose
|
|
23
|
+
* entire content was rewriting one line and stripping comments — and a second workflow run that
|
|
24
|
+
* collided with the first one's deployment record and failed.
|
|
25
|
+
* - **GitHub** turns on Pages by itself once publishing creates a `gh-pages` branch. The CLI used
|
|
26
|
+
* to poll ten minutes for a run it had caused, then call an API that changed nothing.
|
|
27
|
+
*
|
|
28
|
+
* What is left is genuinely the CLI's: asking what to call it, cloning, and reporting the address.
|
|
29
|
+
*/
|
|
30
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
31
|
+
|
|
32
|
+
export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
33
|
+
const explicitName = options.value('name');
|
|
34
|
+
const here = options.on('here');
|
|
35
|
+
const target = here ? cwd : undefined;
|
|
36
|
+
|
|
37
|
+
const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
38
|
+
const github = await githubCredential({ terminal });
|
|
39
|
+
|
|
40
|
+
const name = await publicationName({ terminal, explicitName, here, cwd });
|
|
41
|
+
const directory = path.resolve(cwd, target ?? name);
|
|
42
|
+
await refuseOccupied(directory, here);
|
|
43
|
+
|
|
44
|
+
const api = galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken });
|
|
45
|
+
const capability = await api.githubCapability(github.accessToken);
|
|
46
|
+
|
|
47
|
+
terminal.step(`Creating ${name}`);
|
|
48
|
+
const created = await createPublication({ terminal, api, capability, name, github });
|
|
49
|
+
|
|
50
|
+
terminal.step('Waiting for GitHub to copy the template');
|
|
51
|
+
await waitForContent(githubApi(github.accessToken), created.owner, created.name);
|
|
52
|
+
|
|
53
|
+
terminal.step('Cloning');
|
|
54
|
+
await cloneRepository({
|
|
55
|
+
url: `https://github.com/${created.owner}/${created.name}.git`,
|
|
56
|
+
target: directory,
|
|
57
|
+
token: github.accessToken
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
terminal.step('Registering the publication');
|
|
61
|
+
const git = createGit({ root: directory, token: github.accessToken });
|
|
62
|
+
const registration = await api.registerSite({
|
|
63
|
+
capability,
|
|
64
|
+
idempotencyKey: idempotencyKey(created.owner, created.name),
|
|
65
|
+
repositoryOwner: created.owner,
|
|
66
|
+
repositoryName: created.name,
|
|
67
|
+
topology: 'PROVIDER_DEFAULT',
|
|
68
|
+
canonicalBaseUrl: `https://${created.owner.toLowerCase()}.github.io`
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await githubApi(github.accessToken)
|
|
72
|
+
.setVariable(created.owner, created.name, 'GALA_API_BASE_URL', api.baseUrl);
|
|
73
|
+
|
|
74
|
+
// Registration wrote the managed files into the repository; bring them into the checkout so the
|
|
75
|
+
// writer's copy is the publication as it actually exists.
|
|
76
|
+
await git.takeRemote();
|
|
77
|
+
|
|
78
|
+
terminal.done(`Created ${created.owner}/${created.name}`);
|
|
79
|
+
terminal.result(publicationUrl(registration, created));
|
|
80
|
+
terminal.note(path.relative(cwd, directory) || '.');
|
|
81
|
+
terminal.blank();
|
|
82
|
+
terminal.note('gala new "Your first post"');
|
|
83
|
+
|
|
84
|
+
return { owner: created.owner, name: created.name, siteId: registration.siteId, root: directory };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creation is a conversation, not a single call.
|
|
89
|
+
*
|
|
90
|
+
* `NEEDS_SHARING` means the repository exists with the right content and the App installation
|
|
91
|
+
* simply cannot see it — an installation scoped to selected repositories, which is the right way to
|
|
92
|
+
* have it. That is one grant away from working, and GitHub offers no API to do it on the writer's
|
|
93
|
+
* behalf: adding a repository to an installation is documented as classic-PAT-only. So it is asked
|
|
94
|
+
* for, with a link to the one page that grants it.
|
|
95
|
+
*/
|
|
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 });
|
|
100
|
+
if (result?.status === 'READY') {
|
|
101
|
+
if (typeof result.owner !== 'string' || typeof result.name !== 'string') {
|
|
102
|
+
throw new Error('Gala created the publication but did not say where');
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
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) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`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.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
created = true;
|
|
117
|
+
|
|
118
|
+
const owner = result?.owner ?? '';
|
|
119
|
+
terminal.blank();
|
|
120
|
+
terminal.step(`${owner}/${result?.name ?? name} exists, but Gala cannot reach it yet`);
|
|
121
|
+
terminal.note('its installation covers only selected repositories — add this one');
|
|
122
|
+
terminal.openUrl(installationUrl(result?.installationId, owner, await viewerOf(github)));
|
|
123
|
+
if (!await terminal.waitForEnter('Once Gala can access it')) {
|
|
124
|
+
throw new Error(`Add ${owner}/${result?.name ?? name} to the Gala GitHub App, then run this again.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Gala still cannot reach the repository for ${name}.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let viewerCache;
|
|
131
|
+
async function viewerOf(github) {
|
|
132
|
+
viewerCache ??= await githubApi(github.accessToken).viewer();
|
|
133
|
+
return viewerCache;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** User and organisation installations live on different settings paths. */
|
|
137
|
+
export function installationUrl(installationId, owner, selfLogin) {
|
|
138
|
+
const id = Number(installationId);
|
|
139
|
+
if (!Number.isSafeInteger(id) || id <= 0) return 'https://github.com/settings/installations';
|
|
140
|
+
return owner && selfLogin && owner.toLowerCase() !== selfLogin.toLowerCase()
|
|
141
|
+
? `https://github.com/organizations/${encodeURIComponent(owner)}/settings/installations/${id}`
|
|
142
|
+
: `https://github.com/settings/installations/${id}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* GitHub answers the creation call before the template content lands. Cloning into that window
|
|
147
|
+
* gives an empty checkout and a missing site.config.yml — a confusing error about a file the
|
|
148
|
+
* template certainly contains.
|
|
149
|
+
*/
|
|
150
|
+
async function waitForContent(github, owner, name, { attempts = 30, intervalMs = 1000 } = {}) {
|
|
151
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
152
|
+
if (await github.hasContent(owner, name)) return;
|
|
153
|
+
await new Promise((resolve) => { setTimeout(resolve, intervalMs); });
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`${owner}/${name} was created but is still empty. Try again in a moment.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function publicationName({ terminal, explicitName, here, cwd }) {
|
|
159
|
+
const proposed = explicitName ?? (here ? path.basename(path.resolve(cwd)) : undefined);
|
|
160
|
+
const answer = proposed ?? await terminal.ask('What should this publication be called?');
|
|
161
|
+
const name = slugify(answer);
|
|
162
|
+
if (name == null) {
|
|
163
|
+
throw new UsageError(`"${answer}" cannot be a repository name — use letters, numbers and hyphens`);
|
|
164
|
+
}
|
|
165
|
+
return name;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function slugify(value) {
|
|
169
|
+
if (typeof value !== 'string') return null;
|
|
170
|
+
const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
171
|
+
return NAME.test(slug) ? slug : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function refuseOccupied(directory, here) {
|
|
175
|
+
const { readdir } = await import('node:fs/promises');
|
|
176
|
+
let entries;
|
|
177
|
+
try {
|
|
178
|
+
entries = await readdir(directory);
|
|
179
|
+
} catch (missing) {
|
|
180
|
+
if (missing?.code === 'ENOENT') return;
|
|
181
|
+
throw missing;
|
|
182
|
+
}
|
|
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.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function publicationUrl(registration, created) {
|
|
190
|
+
const base = registration?.canonicalBaseUrl ?? `https://${created.owner.toLowerCase()}.github.io`;
|
|
191
|
+
const prefix = registration?.pathPrefix ?? `/${created.name}`;
|
|
192
|
+
return `${base}${prefix === '/' ? '' : prefix}/`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function idempotencyKey(owner, name) {
|
|
196
|
+
return `init-${createHash('sha256').update(`${owner.toLowerCase()}/${name.toLowerCase()}`).digest('hex')}`;
|
|
197
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPostMetadata,
|
|
6
|
+
isContentId,
|
|
7
|
+
parseFrontmatter,
|
|
8
|
+
repositoryEvaluationDate,
|
|
9
|
+
slugifyTitle
|
|
10
|
+
} from '@rathnasgala/content-validation';
|
|
11
|
+
import { stringify } from 'yaml';
|
|
12
|
+
|
|
13
|
+
import { UsageError } from '../cli/args.js';
|
|
14
|
+
import { postUrl, readPublication } from '../publication.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Starts a post.
|
|
18
|
+
*
|
|
19
|
+
* The article id is the part worth being careful about: every language variant of one post shares
|
|
20
|
+
* it, and it is what lets a folder be renamed without the published URL moving. So an existing
|
|
21
|
+
* variant's id is adopted rather than a new one minted, and conflicting ids are refused instead of
|
|
22
|
+
* guessed at.
|
|
23
|
+
*/
|
|
24
|
+
export async function createPost({ terminal, options, cwd = process.cwd(), now = Date.now }) {
|
|
25
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
26
|
+
const title = options.positional[0] ?? await terminal.ask('What is this post called?');
|
|
27
|
+
if (typeof title !== 'string' || title.trim() === '') throw new UsageError('a post needs a title');
|
|
28
|
+
|
|
29
|
+
const timestamp = now();
|
|
30
|
+
const language = options.value('language') ?? 'en';
|
|
31
|
+
const publishAfterDate = options.value('today')
|
|
32
|
+
?? await repositoryEvaluationDate({ root, now: () => timestamp });
|
|
33
|
+
|
|
34
|
+
const metadata = createPostMetadata({ title, language, today: publishAfterDate, timestamp });
|
|
35
|
+
const directory = path.join(root, 'content', 'posts', slugifyTitle(title));
|
|
36
|
+
const file = path.join(directory, `index.${metadata.language}.md`);
|
|
37
|
+
|
|
38
|
+
await mkdir(directory, { recursive: true });
|
|
39
|
+
const existing = await siblingId(directory);
|
|
40
|
+
if (existing != null) metadata.id = existing;
|
|
41
|
+
await mkdir(path.join(directory, 'media'), { recursive: true });
|
|
42
|
+
|
|
43
|
+
// `wx` so an existing variant is never overwritten by a second run.
|
|
44
|
+
await writeFile(file, `---\n${stringify(metadata).trimEnd()}\n---\n\n# ${title}\n`, {
|
|
45
|
+
encoding: 'utf8', flag: 'wx'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
terminal.done('Post created');
|
|
49
|
+
terminal.result(path.relative(cwd, file));
|
|
50
|
+
|
|
51
|
+
// Where it will be, once published. The language segment is not obvious from anything the writer
|
|
52
|
+
// typed, and hunting for your own post is a poor first minute with a publishing tool.
|
|
53
|
+
const address = postUrl(await readPublication(root), path.basename(directory), metadata.language);
|
|
54
|
+
if (address != null) terminal.note(`will appear at ${address}`);
|
|
55
|
+
|
|
56
|
+
terminal.blank();
|
|
57
|
+
terminal.note('write below the second --- line, then: gala preview');
|
|
58
|
+
return { file, metadata };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The id shared by every language variant already in this folder. */
|
|
62
|
+
async function siblingId(directory) {
|
|
63
|
+
const variants = (await readdir(directory, { withFileTypes: true }))
|
|
64
|
+
.filter((entry) => entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name));
|
|
65
|
+
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
for (const variant of variants) {
|
|
68
|
+
const file = path.join(directory, variant.name);
|
|
69
|
+
const parsed = parseFrontmatter(await readFile(file, 'utf8'));
|
|
70
|
+
if (parsed.errors.length > 0) throw new Error(`${file} has invalid frontmatter`);
|
|
71
|
+
if (!isContentId(parsed.data.id)) throw new Error(`${file} is missing a valid article id`);
|
|
72
|
+
ids.add(parsed.data.id);
|
|
73
|
+
}
|
|
74
|
+
if (ids.size > 1) throw new Error(`${directory} has variants with conflicting article ids`);
|
|
75
|
+
return ids.size === 1 ? ids.values().next().value : null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { checkContent } from '../content.js';
|
|
6
|
+
import { readPublication } from '../publication.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds the site and serves it locally.
|
|
10
|
+
*
|
|
11
|
+
* Eleventy is run from the publication's own `node_modules`, so the preview uses the exact
|
|
12
|
+
* framework version the repository is pinned to — the same one the publish workflow will use. A
|
|
13
|
+
* preview that agrees with the local machine but not with production is worse than no preview.
|
|
14
|
+
*
|
|
15
|
+
* That pin is also why this installs dependencies when they are missing. A freshly cloned
|
|
16
|
+
* publication has no `node_modules`, and v0 spawned eleventy from it regardless: the writer's first
|
|
17
|
+
* preview died with a raw Node module-resolution stack, which says nothing about what to do. They
|
|
18
|
+
* should not need to know npm is involved at all.
|
|
19
|
+
*/
|
|
20
|
+
export async function preview({
|
|
21
|
+
terminal, options, cwd = process.cwd(), spawnProcess = spawn, regenerate
|
|
22
|
+
}) {
|
|
23
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
24
|
+
const today = options.value('today');
|
|
25
|
+
|
|
26
|
+
terminal.step('Checking content');
|
|
27
|
+
await checkContent({ terminal, root, today, ...(regenerate == null ? {} : { regenerate }) });
|
|
28
|
+
terminal.done('Content is valid');
|
|
29
|
+
|
|
30
|
+
const eleventy = path.join(root, 'node_modules', '@11ty', 'eleventy', 'cmd.cjs');
|
|
31
|
+
if (!await exists(eleventy)) {
|
|
32
|
+
terminal.step('Installing what this publication needs — first time only');
|
|
33
|
+
await install(root, spawnProcess);
|
|
34
|
+
if (!await exists(eleventy)) {
|
|
35
|
+
throw new Error('The preview tooling is still missing after installing. Check package.json.');
|
|
36
|
+
}
|
|
37
|
+
terminal.done('Installed');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const publication = await readPublication(root);
|
|
41
|
+
terminal.step('Starting the preview — stop it with Ctrl-C');
|
|
42
|
+
if (publication != null) terminal.note(`this is ${publication.name ?? 'your publication'} as it will look`);
|
|
43
|
+
terminal.blank();
|
|
44
|
+
|
|
45
|
+
const child = spawnProcess(process.execPath, [eleventy, '--serve', '--watch'], {
|
|
46
|
+
cwd: root,
|
|
47
|
+
env: { ...process.env, ...(today == null ? {} : { GALA_EVALUATION_DATE: today }) },
|
|
48
|
+
shell: false,
|
|
49
|
+
stdio: 'inherit'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
child.once('error', reject);
|
|
54
|
+
child.once('exit', (code, signal) => {
|
|
55
|
+
// Ctrl-C is how a writer stops a preview; it is not a failure to report.
|
|
56
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM' || code === 0 || code === 130) resolve();
|
|
57
|
+
else if (signal) reject(new Error(`Preview stopped by ${signal}`));
|
|
58
|
+
else reject(new Error(`Preview exited with ${code}`));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function exists(target) {
|
|
64
|
+
try {
|
|
65
|
+
await access(target);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Output is captured; it is npm's, not the writer's, unless something goes wrong. */
|
|
73
|
+
function install(root, spawnProcess) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawnProcess('npm', ['install', '--no-audit', '--no-fund'], {
|
|
76
|
+
cwd: root, shell: false, stdio: ['ignore', 'pipe', 'pipe']
|
|
77
|
+
});
|
|
78
|
+
let said = '';
|
|
79
|
+
child.stdout?.on('data', (chunk) => { said += chunk; });
|
|
80
|
+
child.stderr?.on('data', (chunk) => { said += chunk; });
|
|
81
|
+
child.once('error', reject);
|
|
82
|
+
child.once('exit', (code) => {
|
|
83
|
+
if (code === 0) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const failure = new Error('Installing the preview tooling failed');
|
|
88
|
+
failure.detail = said.trim();
|
|
89
|
+
reject(failure);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { checkContent } from '../content.js';
|
|
4
|
+
import { createGit } from '../git.js';
|
|
5
|
+
import { githubCredential } from '../auth/github.js';
|
|
6
|
+
import { readPublication } from '../publication.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates, records the writer's work, and sends it to GitHub.
|
|
10
|
+
*
|
|
11
|
+
* v0 only sent — the writer had to remember to record their changes first, and a run that appeared
|
|
12
|
+
* to succeed could ship nothing at all. It also used the machine's git credential rather than the
|
|
13
|
+
* one the CLI holds, which fails for anyone whose accounts differ.
|
|
14
|
+
*
|
|
15
|
+
* The order below is the whole of the difficulty. Every successful publish adds a deployment record
|
|
16
|
+
* to the branch from the workflow, so the checkout is behind by one before the writer has touched
|
|
17
|
+
* anything — catching up is the normal condition, not a race fix. It has to happen *before*
|
|
18
|
+
* validation, because validation assigns a content id to any post missing one and the workflow
|
|
19
|
+
* assigns ids remotely as well; the other order has both sides edit the same file with different
|
|
20
|
+
* ids, which git can only report as a conflict.
|
|
21
|
+
*
|
|
22
|
+
* Publishing itself happens on GitHub: the workflow in the repository builds and deploys. This
|
|
23
|
+
* command's job ends when the work is on the branch.
|
|
24
|
+
*/
|
|
25
|
+
export async function publish({ terminal, options, cwd = process.cwd(), regenerate }) {
|
|
26
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
27
|
+
const today = options.value('today');
|
|
28
|
+
|
|
29
|
+
const github = await githubCredential({ terminal });
|
|
30
|
+
const git = createGit({ root, token: github.accessToken });
|
|
31
|
+
|
|
32
|
+
terminal.step('Catching up with GitHub');
|
|
33
|
+
await git.takeRemote();
|
|
34
|
+
|
|
35
|
+
if (!options.on('skip-checks')) {
|
|
36
|
+
terminal.step('Checking content');
|
|
37
|
+
await checkContent({ terminal, root, today, ...(regenerate == null ? {} : { regenerate }) });
|
|
38
|
+
terminal.done('Content is valid');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const recorded = await git.record('Publish', ['.']);
|
|
42
|
+
if (!recorded) {
|
|
43
|
+
terminal.done('Nothing new to send');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
terminal.done('Changes recorded');
|
|
47
|
+
|
|
48
|
+
terminal.step('Sending to GitHub');
|
|
49
|
+
await git.send();
|
|
50
|
+
|
|
51
|
+
terminal.done('Sent');
|
|
52
|
+
|
|
53
|
+
const publication = await readPublication(root);
|
|
54
|
+
if (publication != null) terminal.result(publication.url);
|
|
55
|
+
terminal.blank();
|
|
56
|
+
terminal.note('GitHub is building it now; give it a minute or two.');
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { auth } from './commands/auth.js';
|
|
2
|
+
import { doctor } from './commands/doctor.js';
|
|
3
|
+
import { init } from './commands/init.js';
|
|
4
|
+
import { createPost } from './commands/new.js';
|
|
5
|
+
import { preview } from './commands/preview.js';
|
|
6
|
+
import { publish } from './commands/publish.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Six commands, in the order a writer meets them.
|
|
10
|
+
*
|
|
11
|
+
* v0 had fifteen, and the extra nine were the ones nobody could keep working: a `validate` a hook
|
|
12
|
+
* ran behind the writer's back, a `workflow` writer for a file the server owns, a
|
|
13
|
+
* `record-deployment` nothing called, a `configure` duplicating options another command took.
|
|
14
|
+
* Each was a surface to keep correct and a way to be wrong.
|
|
15
|
+
*
|
|
16
|
+
* This lives apart from the dispatcher so the README can be checked against it. v0's README
|
|
17
|
+
* outlived its commands by weeks — it taught a command that never existed — because nothing tied
|
|
18
|
+
* the two together.
|
|
19
|
+
*
|
|
20
|
+
* Each entry carries its own options. Nothing is parsed globally, so an option cannot mean two
|
|
21
|
+
* things in two places, which is how one ended up read twice with different defaults.
|
|
22
|
+
*/
|
|
23
|
+
export const COMMANDS = {
|
|
24
|
+
auth: {
|
|
25
|
+
summary: 'Sign in to Gala and GitHub',
|
|
26
|
+
flags: ['api-base-url'],
|
|
27
|
+
run: auth
|
|
28
|
+
},
|
|
29
|
+
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'],
|
|
34
|
+
run: init
|
|
35
|
+
},
|
|
36
|
+
new: {
|
|
37
|
+
summary: 'Start a post',
|
|
38
|
+
usage: 'gala new "A durable idea" [--language en]',
|
|
39
|
+
flags: ['root', 'language', 'today'],
|
|
40
|
+
run: createPost
|
|
41
|
+
},
|
|
42
|
+
preview: {
|
|
43
|
+
summary: 'Build and serve the publication locally',
|
|
44
|
+
flags: ['root', 'today'],
|
|
45
|
+
run: preview
|
|
46
|
+
},
|
|
47
|
+
publish: {
|
|
48
|
+
summary: 'Check, record and send your work to GitHub',
|
|
49
|
+
flags: ['root', 'today'],
|
|
50
|
+
switches: ['skip-checks'],
|
|
51
|
+
run: publish
|
|
52
|
+
},
|
|
53
|
+
doctor: {
|
|
54
|
+
summary: 'Check a publication and say what is wrong',
|
|
55
|
+
flags: ['root', 'api-base-url'],
|
|
56
|
+
run: doctor
|
|
57
|
+
}
|
|
58
|
+
};
|
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/git.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
}
|