@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/scaffold-git.js
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import { gitCredentialArguments, gitEnvironment } from './git-credentials.js';
|
|
4
|
-
|
|
5
|
-
function run(root, args, spawnProcess, acceptedExitCodes = [0], accessToken) {
|
|
6
|
-
return new Promise((resolve, reject) => {
|
|
7
|
-
const child = spawnProcess('git', ['-C', root, ...gitCredentialArguments(accessToken), ...args], {
|
|
8
|
-
cwd: root, shell: false, stdio: 'inherit', env: gitEnvironment(accessToken)
|
|
9
|
-
});
|
|
10
|
-
child.once('error', reject);
|
|
11
|
-
child.once('exit', (code, signal) => {
|
|
12
|
-
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
13
|
-
else if (!acceptedExitCodes.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
14
|
-
else resolve(code);
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function capture(root, args, spawnProcess) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const child = spawnProcess('git', ['-C', root, ...args], {
|
|
22
|
-
cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
|
|
23
|
-
});
|
|
24
|
-
let stdout = '';
|
|
25
|
-
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
26
|
-
child.once('error', reject);
|
|
27
|
-
child.once('exit', (code, signal) => {
|
|
28
|
-
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
29
|
-
else if (code !== 0) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
30
|
-
else resolve(stdout.trim());
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Takes the commit registration just pushed, before anything local is written.
|
|
37
|
-
*
|
|
38
|
-
* Registering a site makes the server write `site.config.yml` and `.github/workflows/publish.yml`
|
|
39
|
-
* into the repository. The checkout was taken before that, so the scaffold's own commit lands on a
|
|
40
|
-
* parent the remote has moved past and the push is rejected:
|
|
41
|
-
*
|
|
42
|
-
* ! [rejected] HEAD -> main (fetch first)
|
|
43
|
-
*
|
|
44
|
-
* Integrating here rather than after committing is what keeps it simple: at this point the working
|
|
45
|
-
* tree is untouched, so the rebase is trivial and cannot conflict with files this process is about
|
|
46
|
-
* to write. A dirty tree — only reachable via --resume — fails loudly, which is the right answer
|
|
47
|
-
* for work nobody asked this command to reconcile.
|
|
48
|
-
*/
|
|
49
|
-
export async function syncScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
|
|
50
|
-
const branch = await capture(root, ['rev-parse', '--abbrev-ref', 'HEAD'], spawnProcess);
|
|
51
|
-
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch === 'HEAD') {
|
|
52
|
-
throw new Error('Git checkout is not on a named branch');
|
|
53
|
-
}
|
|
54
|
-
await run(root, ['fetch', 'origin', branch], spawnProcess, [0], accessToken);
|
|
55
|
-
await run(root, ['rebase', `origin/${branch}`], spawnProcess);
|
|
56
|
-
const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
|
|
57
|
-
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid head SHA');
|
|
58
|
-
return commitSha;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function commitScaffold(root, { spawnProcess = spawn, accessToken } = {}) {
|
|
62
|
-
/*
|
|
63
|
-
* Only the site configuration. The publish workflow is written by the server during registration
|
|
64
|
-
* — the same path the browser editor uses — and staging a file that is not in the checkout yet
|
|
65
|
-
* fails outright with a pathspec error.
|
|
66
|
-
*/
|
|
67
|
-
await run(root, ['add', '--', 'site.config.yml'], spawnProcess);
|
|
68
|
-
const unchanged = await run(root, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
|
|
69
|
-
if (unchanged === 1) {
|
|
70
|
-
await run(root, ['commit', '-m', 'chore(gala): configure site'], spawnProcess);
|
|
71
|
-
}
|
|
72
|
-
await run(root, ['push', 'origin', 'HEAD'], spawnProcess, [0], accessToken);
|
|
73
|
-
const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
|
|
74
|
-
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid scaffold commit SHA');
|
|
75
|
-
return commitSha;
|
|
76
|
-
}
|
package/src/scaffold-options.js
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
export const scaffoldOptionNames = Object.freeze([
|
|
2
|
-
'theme',
|
|
3
|
-
'layout',
|
|
4
|
-
'palette',
|
|
5
|
-
'typography',
|
|
6
|
-
'spacing',
|
|
7
|
-
'radius',
|
|
8
|
-
'density',
|
|
9
|
-
'motion',
|
|
10
|
-
'componentStyle'
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
|
-
const singleValueOptions = Object.freeze({
|
|
14
|
-
'site-name': 'siteName',
|
|
15
|
-
author: 'siteAuthor',
|
|
16
|
-
language: 'defaultLanguage',
|
|
17
|
-
timezone: 'timezone'
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
function requireValue(args, index, name) {
|
|
21
|
-
const value = args[index + 1];
|
|
22
|
-
if (!value || value.startsWith('--')) {
|
|
23
|
-
throw new Error(`Missing value for --${name}`);
|
|
24
|
-
}
|
|
25
|
-
return value;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function parseScaffoldOptions(args) {
|
|
29
|
-
const values = {};
|
|
30
|
-
|
|
31
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
-
const argument = args[index];
|
|
33
|
-
if (!argument.startsWith('--')) continue;
|
|
34
|
-
|
|
35
|
-
const name = argument.slice(2);
|
|
36
|
-
if (name === 'share-target') {
|
|
37
|
-
const value = requireValue(args, index, name);
|
|
38
|
-
values.shareTargets = [...(values.shareTargets ?? []), value];
|
|
39
|
-
index += 1;
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (name === 'social-profile') {
|
|
43
|
-
const value = requireValue(args, index, name);
|
|
44
|
-
values.socialProfiles = [...(values.socialProfiles ?? []), value];
|
|
45
|
-
index += 1;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const targetName = singleValueOptions[name] ?? name;
|
|
50
|
-
if (!scaffoldOptionNames.includes(name) && singleValueOptions[name] == null) continue;
|
|
51
|
-
|
|
52
|
-
const value = requireValue(args, index, name);
|
|
53
|
-
values[targetName] = value;
|
|
54
|
-
index += 1;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return values;
|
|
58
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
|
|
3
|
-
import { authenticateGala } from './auth-command.js';
|
|
4
|
-
import { authenticateGithub } from './github-auth-command.js';
|
|
5
|
-
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
-
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
-
import { resolveGithubLogin } from './github-identity.js';
|
|
8
|
-
import { galaCredentialAccepted } from './gala-credential-health.js';
|
|
9
|
-
import { openInBrowser } from './open-browser.js';
|
|
10
|
-
import { forgetGalaCredential } from './gala-credential-store.js';
|
|
11
|
-
|
|
12
|
-
export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
|
|
13
|
-
|
|
14
|
-
const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Everything `scaffold` needs, worked out rather than demanded.
|
|
18
|
-
*
|
|
19
|
-
* `scaffold` used to require four values up front — `--owner`, `--repository`, `--target` and
|
|
20
|
-
* `--installation-id` — and it failed outright if `auth` or `auth github` had not been run first,
|
|
21
|
-
* telling the writer to go and run them. Three of those four are derivable and the two sign-ins
|
|
22
|
-
* can simply happen. Every one of them is still accepted as an explicit override; nothing that
|
|
23
|
-
* worked before stops working.
|
|
24
|
-
*
|
|
25
|
-
* The steps are ordered so nothing is created until everything is known: the App installation is
|
|
26
|
-
* confirmed before a repository exists, rather than after, so an interrupted run leaves no
|
|
27
|
-
* half-connected repository behind.
|
|
28
|
-
*/
|
|
29
|
-
export async function prepareScaffold({
|
|
30
|
-
owner,
|
|
31
|
-
repository,
|
|
32
|
-
target,
|
|
33
|
-
githubInstallationId,
|
|
34
|
-
siteName,
|
|
35
|
-
cwd = process.cwd(),
|
|
36
|
-
notify = () => {},
|
|
37
|
-
ask,
|
|
38
|
-
openUrl = openInBrowser,
|
|
39
|
-
readGala = readGalaCredential,
|
|
40
|
-
readGithub = readGithubCredential,
|
|
41
|
-
credentialAccepted = galaCredentialAccepted,
|
|
42
|
-
forgetGala = forgetGalaCredential,
|
|
43
|
-
signInGala = authenticateGala,
|
|
44
|
-
signInGithub = authenticateGithub,
|
|
45
|
-
resolveLogin = resolveGithubLogin,
|
|
46
|
-
apiBaseUrl = DEFAULT_API_BASE_URL
|
|
47
|
-
} = {}) {
|
|
48
|
-
const gala = await ensureGala({
|
|
49
|
-
apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
|
|
50
|
-
});
|
|
51
|
-
const github = await ensureGithub({ notify, readGithub, signInGithub, openUrl });
|
|
52
|
-
|
|
53
|
-
const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
|
|
54
|
-
|
|
55
|
-
const resolvedRepository = repository
|
|
56
|
-
?? (target == null ? null : path.basename(path.resolve(cwd, target)))
|
|
57
|
-
?? repositoryNameFrom(siteName)
|
|
58
|
-
?? await askForRepository(ask);
|
|
59
|
-
|
|
60
|
-
// `--target ./` is the common case and means "here", so the repository takes its name from the
|
|
61
|
-
// directory the writer is standing in. Everywhere else the repository names its own folder.
|
|
62
|
-
const resolvedTarget = target ?? `./${resolvedRepository}`;
|
|
63
|
-
|
|
64
|
-
/*
|
|
65
|
-
* No installation lookup. The id is an internal GitHub identifier for an App the server owns, and
|
|
66
|
-
* nothing here can discover it reliably: a GitHub App token could list installations and the CLI
|
|
67
|
-
* cannot hold one, while the repository inventory only carries the id once a repository exists —
|
|
68
|
-
* which, in the flow that creates the first repository, is never. The server resolves it during
|
|
69
|
-
* registration. `--installation-id` still overrides, for an account with several.
|
|
70
|
-
*/
|
|
71
|
-
return Object.freeze({
|
|
72
|
-
owner: resolvedOwner,
|
|
73
|
-
repository: resolvedRepository,
|
|
74
|
-
target: resolvedTarget,
|
|
75
|
-
githubInstallationId: githubInstallationId ?? null
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A missing, expired or refused credential is a step to take, not an error to report.
|
|
81
|
-
*
|
|
82
|
-
* The stored file is checked against the server before anything depends on it, because a
|
|
83
|
-
* credential that parses and has not expired can still be one the API refuses — and finding that
|
|
84
|
-
* out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
|
|
85
|
-
* "sign in again" turned into a stack trace.
|
|
86
|
-
*/
|
|
87
|
-
async function ensureGala({
|
|
88
|
-
apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
|
|
89
|
-
}) {
|
|
90
|
-
let stored = null;
|
|
91
|
-
try {
|
|
92
|
-
stored = await readGala();
|
|
93
|
-
} catch {
|
|
94
|
-
stored = null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (stored != null) {
|
|
98
|
-
const base = stored.apiBaseUrl ?? apiBaseUrl;
|
|
99
|
-
if (await credentialAccepted({ apiBaseUrl: base, accessToken: stored.accessToken })) {
|
|
100
|
-
return stored;
|
|
101
|
-
}
|
|
102
|
-
// Leaving it on disk would make every later command repeat this discovery.
|
|
103
|
-
await forgetGala();
|
|
104
|
-
notify('Your Gala sign-in is no longer valid.');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
notify('Signing in to Gala.');
|
|
108
|
-
await signInGala({
|
|
109
|
-
apiBaseUrl,
|
|
110
|
-
showInstructions: ({ verificationUri, userCode }) =>
|
|
111
|
-
notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
|
|
112
|
-
});
|
|
113
|
-
return readGala();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
|
|
117
|
-
try {
|
|
118
|
-
return await readGithub();
|
|
119
|
-
} catch {
|
|
120
|
-
notify('Signing in to GitHub.');
|
|
121
|
-
await signInGithub({
|
|
122
|
-
showInstructions: ({ verificationUri, userCode }) =>
|
|
123
|
-
notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
|
|
124
|
-
});
|
|
125
|
-
return readGithub();
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
|
|
130
|
-
export function repositoryNameFrom(siteName) {
|
|
131
|
-
if (typeof siteName !== 'string') return null;
|
|
132
|
-
const slug = siteName
|
|
133
|
-
.trim().toLowerCase()
|
|
134
|
-
.replace(/[^a-z0-9._-]+/g, '-')
|
|
135
|
-
.replace(/^-+|-+$/g, '');
|
|
136
|
-
return slug === '' ? null : slug;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function askForRepository(ask) {
|
|
140
|
-
if (typeof ask !== 'function') {
|
|
141
|
-
throw new TypeError('repository is required; pass --repository or --site-name');
|
|
142
|
-
}
|
|
143
|
-
const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
|
|
144
|
-
if (answer == null) throw new TypeError('A repository name is required');
|
|
145
|
-
return answer;
|
|
146
|
-
}
|
package/src/scaffold-site.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
import { configureSite } from './configure-site.js';
|
|
5
|
-
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
-
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
-
import { awaitRepositoryContent, cloneRepository } from './github-template-repository.js';
|
|
8
|
-
import { createPublication } from './publication-creation-client.js';
|
|
9
|
-
import { installRepositoryVariable } from './github-repository-variable.js';
|
|
10
|
-
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
11
|
-
import { registerSite } from './site-registration-client.js';
|
|
12
|
-
import { commitScaffold, syncScaffold } from './scaffold-git.js';
|
|
13
|
-
import {
|
|
14
|
-
setRepositoryOrigin, verifyEmptyRepository, verifyRepositoryOrigin
|
|
15
|
-
} from './github-empty-repository.js';
|
|
16
|
-
|
|
17
|
-
function segment(value, field) {
|
|
18
|
-
if (typeof value !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(value)) throw new TypeError(`${field} is invalid`);
|
|
19
|
-
return value;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function providerDefaultBase(owner) {
|
|
23
|
-
return `https://${owner.toLowerCase()}.github.io`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function registrationLocation(owner, topology, canonicalBaseUrl) {
|
|
27
|
-
if (topology === 'provider-default') {
|
|
28
|
-
if (canonicalBaseUrl != null) {
|
|
29
|
-
throw new TypeError('--canonical-base-url is valid only with --topology custom-domain');
|
|
30
|
-
}
|
|
31
|
-
return { topology: 'PROVIDER_DEFAULT', canonicalBaseUrl: providerDefaultBase(owner) };
|
|
32
|
-
}
|
|
33
|
-
if (topology !== 'custom-domain') {
|
|
34
|
-
throw new TypeError('topology must be provider-default or custom-domain');
|
|
35
|
-
}
|
|
36
|
-
if (typeof canonicalBaseUrl !== 'string') {
|
|
37
|
-
throw new TypeError('--canonical-base-url is required with --topology custom-domain');
|
|
38
|
-
}
|
|
39
|
-
const canonical = new URL(canonicalBaseUrl);
|
|
40
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
41
|
-
|| canonical.port || canonical.search || canonical.hash || canonical.pathname !== '/') {
|
|
42
|
-
throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin');
|
|
43
|
-
}
|
|
44
|
-
return { topology: 'CUSTOM_DOMAIN', canonicalBaseUrl: canonical.origin };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function scaffoldSite({
|
|
48
|
-
owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
|
|
49
|
-
notify = (message) => process.stdout.write(`${message}\n`), ask, openUrl,
|
|
50
|
-
resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl,
|
|
51
|
-
templateOwner = 'rathnasgala',
|
|
52
|
-
templateRepository = 'site-template',
|
|
53
|
-
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
54
|
-
createRepository = createPublication, awaitContent = awaitRepositoryContent, clone = cloneRepository,
|
|
55
|
-
configure = configureSite, register = registerSite,
|
|
56
|
-
installVariable = installRepositoryVariable,
|
|
57
|
-
provisionPages = provisionGithubPages,
|
|
58
|
-
commit = commitScaffold, sync = syncScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
59
|
-
verifyCheckout = verifyRepositoryOrigin
|
|
60
|
-
}) {
|
|
61
|
-
const requestedOwner = segment(owner, 'owner');
|
|
62
|
-
const requestedName = segment(repository, 'repository');
|
|
63
|
-
// Validated now so a bad --topology/--canonical-base-url combination fails before a repository
|
|
64
|
-
// exists. The value used later is recomputed once the server says who actually owns it.
|
|
65
|
-
registrationLocation(requestedOwner, topology, canonicalBaseUrl);
|
|
66
|
-
// Optional: the server resolves the installation from the owner when none is supplied. An
|
|
67
|
-
// explicit value is still validated, because a wrong one fails much later and less clearly.
|
|
68
|
-
if (githubInstallationId != null
|
|
69
|
-
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
70
|
-
throw new TypeError('githubInstallationId must be a positive integer');
|
|
71
|
-
}
|
|
72
|
-
if (target == null || path.resolve(target) === path.parse(path.resolve(target)).root) {
|
|
73
|
-
throw new TypeError('target must be a non-root local path');
|
|
74
|
-
}
|
|
75
|
-
const [github, gala] = await Promise.all([readGithub(), readGala()]);
|
|
76
|
-
// Creation reports which installation owns the new repository, so registration cannot disagree.
|
|
77
|
-
let resolvedInstallationId = githubInstallationId ?? null;
|
|
78
|
-
if (emptyExistingRepository && resumeExistingCheckout) {
|
|
79
|
-
throw new TypeError('emptyExistingRepository and resumeExistingCheckout are mutually exclusive');
|
|
80
|
-
}
|
|
81
|
-
/*
|
|
82
|
-
* The server decides the owner, not this process. It creates under the account the Gala App
|
|
83
|
-
* installation belongs to, which is not always the account behind the writer's OAuth token — an
|
|
84
|
-
* installation on an organisation they belong to gives a different owner entirely. Deriving the
|
|
85
|
-
* canonical URL, the idempotency key, the registration and the Pages target from the local guess
|
|
86
|
-
* would register a publication against a repository that does not exist.
|
|
87
|
-
*/
|
|
88
|
-
let repositoryOwner = requestedOwner;
|
|
89
|
-
let repositoryName = requestedName;
|
|
90
|
-
let generated;
|
|
91
|
-
let root;
|
|
92
|
-
if (resumeExistingCheckout) {
|
|
93
|
-
root = await verifyCheckout({
|
|
94
|
-
root: path.resolve(target), owner: repositoryOwner, repository: repositoryName
|
|
95
|
-
});
|
|
96
|
-
generated = { fullName: `${repositoryOwner}/${repositoryName}` };
|
|
97
|
-
} else if (emptyExistingRepository) {
|
|
98
|
-
await verifyEmpty({ owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken });
|
|
99
|
-
generated = {
|
|
100
|
-
fullName: `${repositoryOwner}/${repositoryName}`,
|
|
101
|
-
cloneUrl: `https://github.com/${templateOwner}/${templateRepository}.git`
|
|
102
|
-
};
|
|
103
|
-
} else {
|
|
104
|
-
/*
|
|
105
|
-
* Created through the API, the same call the browser editor makes. Doing it here meant a second
|
|
106
|
-
* implementation with no fallback and no wait for the App installation to reach the result,
|
|
107
|
-
* which is why CLI-created repositories never appeared in the web UI.
|
|
108
|
-
*/
|
|
109
|
-
const created = await createRepository({
|
|
110
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
111
|
-
githubAccessToken: github.accessToken, name: repositoryName,
|
|
112
|
-
notify, ask, openUrl, selfLogin: requestedOwner
|
|
113
|
-
});
|
|
114
|
-
repositoryOwner = segment(created.owner, 'owner');
|
|
115
|
-
repositoryName = segment(created.repository, 'repository');
|
|
116
|
-
resolvedInstallationId = created.installationId;
|
|
117
|
-
generated = { fullName: created.fullName, cloneUrl: created.cloneUrl };
|
|
118
|
-
// Creation is asynchronous: GitHub answers before the template content lands, and cloning into
|
|
119
|
-
// that window produces an empty checkout and a missing site.config.yml.
|
|
120
|
-
await awaitContent({
|
|
121
|
-
accessToken: github.accessToken, owner: created.owner, repository: created.repository
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
if (!resumeExistingCheckout) {
|
|
125
|
-
root = await clone({ cloneUrl: generated.cloneUrl, target, accessToken: github.accessToken });
|
|
126
|
-
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
127
|
-
}
|
|
128
|
-
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
129
|
-
await configure(root, siteOptions ?? {});
|
|
130
|
-
|
|
131
|
-
/*
|
|
132
|
-
* The writer's design choices go up before registration, and nothing goes up after it.
|
|
133
|
-
*
|
|
134
|
-
* Registration makes the server write `site.config.yml` and `.github/workflows/publish.yml` into
|
|
135
|
-
* the repository — the same code path the browser editor uses. The CLI used to write its own
|
|
136
|
-
* versions of both files afterwards and commit them, which produced a second commit whose whole
|
|
137
|
-
* content was rewriting `api-base-url` into a `vars` reference and stripping the template's
|
|
138
|
-
* comments. That second commit triggered a second Publish run, which collided with the first
|
|
139
|
-
* one's deployment record and failed:
|
|
140
|
-
*
|
|
141
|
-
* Assigned-ID source moved on the remote branch: content/posts/example/index.en.md
|
|
142
|
-
*
|
|
143
|
-
* So those two files have one owner now, and it is the server. What is left here is the design
|
|
144
|
-
* configuration, which only the CLI receives — pushed first so the server provisions on top of
|
|
145
|
-
* it rather than around it.
|
|
146
|
-
*/
|
|
147
|
-
await commit(root, { accessToken: github.accessToken });
|
|
148
|
-
|
|
149
|
-
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
150
|
-
const registration = await register({
|
|
151
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
152
|
-
githubAccessToken: github.accessToken, idempotencyKey,
|
|
153
|
-
githubInstallationId: resolvedInstallationId, repositoryOwner, repositoryName,
|
|
154
|
-
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
await installVariable({
|
|
158
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
159
|
-
variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// Brings the server's provisioning commits into the checkout, so the writer's working copy holds
|
|
163
|
-
// the publication as it actually exists, and reports the commit publishing will run against.
|
|
164
|
-
const commitSha = await sync(root, { accessToken: github.accessToken });
|
|
165
|
-
|
|
166
|
-
/*
|
|
167
|
-
* Pages is only touched for a custom domain.
|
|
168
|
-
*
|
|
169
|
-
* On the provider default it was never doing anything: publishing creates a `gh-pages` branch and
|
|
170
|
-
* GitHub turns on classic Pages by itself — every scaffold produced a live site with
|
|
171
|
-
* `build_type: legacy, source: gh-pages` before this step ran, including runs that failed before
|
|
172
|
-
* reaching it. What the step did cost was up to ten minutes waiting on a workflow run, and a
|
|
173
|
-
* reported failure for a publication that was already serving.
|
|
174
|
-
*
|
|
175
|
-
* A custom domain is different: classic Pages will not point itself at someone's own hostname, so
|
|
176
|
-
* the API call is the thing that does it.
|
|
177
|
-
*/
|
|
178
|
-
const pages = location.topology === 'CUSTOM_DOMAIN' ? await provisionPages({
|
|
179
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha,
|
|
180
|
-
customDomain: new URL(location.canonicalBaseUrl).hostname
|
|
181
|
-
}) : null;
|
|
182
|
-
return Object.freeze({
|
|
183
|
-
root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
|
|
184
|
-
});
|
|
185
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { parse, stringify } from 'yaml';
|
|
4
|
-
|
|
5
|
-
export async function writeRegisteredSiteConfiguration(root, {
|
|
6
|
-
siteId, canonicalBaseUrl, pathPrefix, topology
|
|
7
|
-
}) {
|
|
8
|
-
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('siteId is invalid');
|
|
9
|
-
if (!['provider-default', 'custom-domain', 'domain-root', 'domain-subpath'].includes(topology)) {
|
|
10
|
-
throw new TypeError('topology is invalid');
|
|
11
|
-
}
|
|
12
|
-
const canonical = new URL(canonicalBaseUrl);
|
|
13
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password || canonical.search
|
|
14
|
-
|| canonical.hash || canonical.pathname !== '/') {
|
|
15
|
-
throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin; put the URL path in pathPrefix');
|
|
16
|
-
}
|
|
17
|
-
const normalizedPrefix = pathPrefix === '' ? '/' : pathPrefix;
|
|
18
|
-
if (typeof normalizedPrefix !== 'string'
|
|
19
|
-
|| !/^\/(?:[^/?#]+(?:\/[^/?#]+)*)?$/.test(normalizedPrefix)) {
|
|
20
|
-
throw new TypeError('pathPrefix must be a normalized URL path');
|
|
21
|
-
}
|
|
22
|
-
const target = path.resolve(root, 'site.config.yml');
|
|
23
|
-
const metadata = await lstat(target);
|
|
24
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new TypeError('site.config.yml must be a regular file');
|
|
25
|
-
const config = parse(await readFile(target, 'utf8'));
|
|
26
|
-
if (config?.schemaVersion !== 1 || config.site == null || config.hosting == null) {
|
|
27
|
-
throw new TypeError('Unsupported site configuration schema');
|
|
28
|
-
}
|
|
29
|
-
config.site.id = siteId;
|
|
30
|
-
config.hosting.provider = 'github-pages';
|
|
31
|
-
config.hosting.topology = topology;
|
|
32
|
-
config.hosting.canonicalBaseUrl = canonical.origin;
|
|
33
|
-
config.hosting.pathPrefix = normalizedPrefix;
|
|
34
|
-
const temporary = `${target}.gala-register-${process.pid}`;
|
|
35
|
-
const backup = `${target}.gala-backup-${process.pid}`;
|
|
36
|
-
try {
|
|
37
|
-
await writeFile(temporary, stringify(config), { flag: 'wx' });
|
|
38
|
-
await rename(target, backup);
|
|
39
|
-
try { await rename(temporary, target); }
|
|
40
|
-
catch (error) { await rename(backup, target); throw error; }
|
|
41
|
-
await rm(backup);
|
|
42
|
-
} catch (error) {
|
|
43
|
-
await rm(temporary, { force: true });
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
return config;
|
|
47
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { describeHttpFailure } from './http-failure.js';
|
|
2
|
-
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
3
|
-
const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/;
|
|
4
|
-
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
5
|
-
|
|
6
|
-
function required(value, field, pattern) {
|
|
7
|
-
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`${field} is invalid`);
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function apiUrl(apiBaseUrl) {
|
|
12
|
-
const base = new URL(apiBaseUrl);
|
|
13
|
-
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
14
|
-
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
15
|
-
|| base.username || base.password || base.search || base.hash) {
|
|
16
|
-
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
17
|
-
}
|
|
18
|
-
return new URL('/v1/sites', base).href;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Exchanges the stored GitHub token for the short-lived capability the API's GitHub-scoped
|
|
23
|
-
* endpoints require. Exported because publication creation needs the same capability, and two
|
|
24
|
-
* copies of an auth exchange is how they drift.
|
|
25
|
-
*/
|
|
26
|
-
export async function exchangeGithubAuthorization({
|
|
27
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
28
|
-
}) {
|
|
29
|
-
if (typeof githubAccessToken !== 'string' || githubAccessToken === '') {
|
|
30
|
-
throw new Error('GitHub authentication is missing; run `gala auth`');
|
|
31
|
-
}
|
|
32
|
-
const response = await fetchImpl(new URL('/v1/auth/github/device-authorizations', apiBaseUrl), {
|
|
33
|
-
method: 'POST',
|
|
34
|
-
headers: {
|
|
35
|
-
accept: 'application/json',
|
|
36
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
37
|
-
'content-type': 'application/json'
|
|
38
|
-
},
|
|
39
|
-
body: JSON.stringify({ accessToken: githubAccessToken })
|
|
40
|
-
});
|
|
41
|
-
if (response.status === 401) {
|
|
42
|
-
throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
|
|
43
|
-
}
|
|
44
|
-
if (response.status !== 200) {
|
|
45
|
-
throw new Error(await describeHttpFailure(response, 'GitHub repository authorization'));
|
|
46
|
-
}
|
|
47
|
-
const payload = await response.json();
|
|
48
|
-
return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function registerSite({
|
|
52
|
-
apiBaseUrl = 'https://api.gala67.com',
|
|
53
|
-
galaAccessToken,
|
|
54
|
-
githubAccessToken,
|
|
55
|
-
idempotencyKey,
|
|
56
|
-
githubInstallationId,
|
|
57
|
-
repositoryOwner,
|
|
58
|
-
repositoryName,
|
|
59
|
-
topology,
|
|
60
|
-
canonicalBaseUrl,
|
|
61
|
-
fetchImpl = fetch
|
|
62
|
-
}) {
|
|
63
|
-
if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
|
|
64
|
-
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
65
|
-
}
|
|
66
|
-
const githubAuthorization = await exchangeGithubAuthorization({
|
|
67
|
-
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
68
|
-
});
|
|
69
|
-
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
70
|
-
required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
|
|
71
|
-
required(repositoryName, 'repositoryName', REPOSITORY_PART);
|
|
72
|
-
if (githubInstallationId != null
|
|
73
|
-
&& (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
|
|
74
|
-
throw new TypeError('githubInstallationId must be a positive integer');
|
|
75
|
-
}
|
|
76
|
-
if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
|
|
77
|
-
throw new TypeError('topology is invalid');
|
|
78
|
-
}
|
|
79
|
-
const response = await fetchImpl(apiUrl(apiBaseUrl), {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: {
|
|
82
|
-
accept: 'application/json',
|
|
83
|
-
authorization: `Bearer ${galaAccessToken}`,
|
|
84
|
-
'content-type': 'application/json',
|
|
85
|
-
'github-authorization': githubAuthorization,
|
|
86
|
-
'idempotency-key': idempotencyKey
|
|
87
|
-
},
|
|
88
|
-
body: JSON.stringify({
|
|
89
|
-
// Omitted rather than sent as null: the server resolves it from the owner.
|
|
90
|
-
...(githubInstallationId == null ? {} : { githubInstallationId }),
|
|
91
|
-
repositoryOwner,
|
|
92
|
-
repositoryName,
|
|
93
|
-
topology,
|
|
94
|
-
canonicalBaseUrl
|
|
95
|
-
})
|
|
96
|
-
});
|
|
97
|
-
if (response.status === 401) {
|
|
98
|
-
throw new Error('Gala authentication expired; run `gala auth` again');
|
|
99
|
-
}
|
|
100
|
-
if (response.status === 404) {
|
|
101
|
-
throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
|
|
102
|
-
}
|
|
103
|
-
if (response.status === 409) {
|
|
104
|
-
const failure = await response.clone().json().catch(() => null);
|
|
105
|
-
if (failure?.code === 'GITHUB_APP_NOT_INSTALLED') {
|
|
106
|
-
throw new Error(
|
|
107
|
-
`The Gala GitHub App is not installed on ${repositoryOwner}. Install it at `
|
|
108
|
-
+ 'https://github.com/apps/gala67-app/installations/new for that account, then run scaffold '
|
|
109
|
-
+ 'again.'
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
throw new Error('Site registration conflicts with existing protected state; use the recovery command');
|
|
113
|
-
}
|
|
114
|
-
if (response.status !== 201) throw new Error(await describeHttpFailure(response, 'Gala site registration'));
|
|
115
|
-
const payload = await response.json();
|
|
116
|
-
if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
|
|
117
|
-
throw new TypeError('Gala site registration response is invalid');
|
|
118
|
-
}
|
|
119
|
-
const canonical = new URL(payload.canonicalBaseUrl);
|
|
120
|
-
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
121
|
-
|| canonical.search || canonical.hash || canonical.pathname !== '/') {
|
|
122
|
-
throw new TypeError('Gala site registration returned an invalid canonicalBaseUrl');
|
|
123
|
-
}
|
|
124
|
-
if (typeof payload.pathPrefix !== 'string'
|
|
125
|
-
|| !/^\/(?:[^/?#]+(?:\/[^/?#]+)*)?$/.test(payload.pathPrefix)) {
|
|
126
|
-
throw new TypeError('Gala site registration returned an invalid pathPrefix');
|
|
127
|
-
}
|
|
128
|
-
const location = response.headers?.get?.('location');
|
|
129
|
-
if (location !== `/v1/sites/${payload.siteId}`) {
|
|
130
|
-
throw new TypeError('Gala site registration returned an invalid Location header');
|
|
131
|
-
}
|
|
132
|
-
return Object.freeze({
|
|
133
|
-
siteId: payload.siteId,
|
|
134
|
-
siteSecret: payload.siteSecret,
|
|
135
|
-
canonicalBaseUrl: canonical.origin,
|
|
136
|
-
pathPrefix: payload.pathPrefix === '' ? '/' : payload.pathPrefix
|
|
137
|
-
});
|
|
138
|
-
}
|