@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.
Files changed (64) hide show
  1. package/README.md +66 -212
  2. package/package.json +3 -3
  3. package/src/api/gala.js +126 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/init.js +197 -0
  14. package/src/commands/new.js +76 -0
  15. package/src/commands/preview.js +92 -0
  16. package/src/commands/publish.js +57 -0
  17. package/src/commands-manifest.js +58 -0
  18. package/src/content.js +31 -0
  19. package/src/git.js +143 -0
  20. package/src/index.js +44 -297
  21. package/src/publication.js +37 -0
  22. package/src/assign-content-ids.js +0 -1
  23. package/src/auth-command.js +0 -36
  24. package/src/configure-site.js +0 -102
  25. package/src/content-files.js +0 -1
  26. package/src/doctor-command.js +0 -214
  27. package/src/entitlement-client.js +0 -26
  28. package/src/entitlement-command.js +0 -74
  29. package/src/evaluation-date.js +0 -1
  30. package/src/gala-credential-health.js +0 -34
  31. package/src/gala-credential-store.js +0 -115
  32. package/src/gala-device-flow.js +0 -121
  33. package/src/github-auth-command.js +0 -29
  34. package/src/github-credential-store.js +0 -65
  35. package/src/github-device-flow.js +0 -130
  36. package/src/github-empty-repository.js +0 -76
  37. package/src/github-identity.js +0 -32
  38. package/src/github-pages-provisioning.js +0 -107
  39. package/src/github-repository-secret.js +0 -82
  40. package/src/github-repository-variable.js +0 -56
  41. package/src/github-template-repository.js +0 -165
  42. package/src/hook-command.js +0 -64
  43. package/src/http-failure.js +0 -55
  44. package/src/new-command.js +0 -54
  45. package/src/open-browser.js +0 -40
  46. package/src/preview-command.js +0 -60
  47. package/src/publication-creation-client.js +0 -144
  48. package/src/publication-state.js +0 -7
  49. package/src/publish-command.js +0 -37
  50. package/src/record-deployment-command.js +0 -147
  51. package/src/refresh-command.js +0 -104
  52. package/src/repository-limits.js +0 -94
  53. package/src/scaffold-git.js +0 -41
  54. package/src/scaffold-options.js +0 -58
  55. package/src/scaffold-preflight.js +0 -147
  56. package/src/scaffold-site.js +0 -162
  57. package/src/site-config-registration.js +0 -47
  58. package/src/site-registration-client.js +0 -138
  59. package/src/theme-package.js +0 -128
  60. package/src/topology-client.js +0 -43
  61. package/src/topology-command.js +0 -70
  62. package/src/upgrade-command.js +0 -81
  63. package/src/validate-command.js +0 -5
  64. package/src/workflow-command.js +0 -87
@@ -1,147 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { lstat } from 'node:fs/promises';
3
- import { spawn } from 'node:child_process';
4
- import { createHash } from 'node:crypto';
5
- import path from 'node:path';
6
- import { parseFrontmatter } from '@rathnasgala/content-validation';
7
-
8
- import { repositoryEvaluationDate } from './evaluation-date.js';
9
- import { recordSuccessfulDeployment } from './publication-state.js';
10
- import { BUILD_MANIFEST_PATH } from './validate-command.js';
11
-
12
- function runGit(root, args, spawnProcess) {
13
- return new Promise((resolve, reject) => {
14
- const child = spawnProcess('git', ['-C', root, ...args], {
15
- cwd: root,
16
- shell: false,
17
- stdio: 'inherit'
18
- });
19
- child.once('error', reject);
20
- child.once('exit', (code, signal) => {
21
- if (signal) reject(new Error(`Git terminated by signal ${signal}`));
22
- else resolve(code);
23
- });
24
- });
25
- }
26
-
27
- function readHead(root, spawnProcess) {
28
- return new Promise((resolve, reject) => {
29
- const child = spawnProcess('git', ['-C', root, 'rev-parse', '--verify', 'HEAD'], {
30
- cwd: root,
31
- shell: false,
32
- stdio: ['ignore', 'pipe', 'inherit']
33
- });
34
- let output = '';
35
- child.stdout?.on('data', (chunk) => { output += chunk; });
36
- child.once('error', reject);
37
- child.once('exit', (code, signal) => {
38
- if (signal) reject(new Error(`Git terminated by signal ${signal}`));
39
- else if (code !== 0) reject(new Error(`Git rev-parse exited with code ${code}`));
40
- else {
41
- const sha = output.trim();
42
- if (!/^[0-9a-f]{40}$/.test(sha)) reject(new Error('Git returned an invalid HEAD SHA'));
43
- else resolve(sha);
44
- }
45
- });
46
- });
47
- }
48
-
49
- async function assignedContentPaths(root, manifest) {
50
- const assigned = manifest.assignedContentIds ?? [];
51
- if (!Array.isArray(assigned)) throw new TypeError('assignedContentIds must be a list');
52
- const sources = new Set();
53
- for (const item of assigned) {
54
- if (item == null
55
- || typeof item.source !== 'string'
56
- || !/^content\/posts\/[a-z0-9]+(?:-[a-z0-9]+)*\/index\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.md$/.test(item.source)
57
- || typeof item.id !== 'string'
58
- || !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(item.id)
59
- || typeof item.fileHash !== 'string'
60
- || !/^[a-f0-9]{64}$/.test(item.fileHash)
61
- || sources.has(item.source)) {
62
- throw new TypeError('assignedContentIds contains an invalid entry');
63
- }
64
- const file = path.resolve(root, item.source);
65
- const metadata = await lstat(file);
66
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
67
- throw new TypeError(`Assigned-ID source must be a regular file: ${item.source}`);
68
- }
69
- const bytes = await readFile(file);
70
- if (createHash('sha256').update(bytes).digest('hex') !== item.fileHash) {
71
- throw new Error(`Assigned-ID source changed after the deployed build: ${item.source}`);
72
- }
73
- const parsed = parseFrontmatter(bytes.toString('utf8'));
74
- if (parsed.errors.length > 0 || parsed.data.id !== item.id) {
75
- throw new Error(`Assigned-ID source no longer contains its deployed ULID: ${item.source}`);
76
- }
77
- sources.add(item.source);
78
- }
79
- return [...sources].sort();
80
- }
81
-
82
- export async function recordDeployment({
83
- root,
84
- deployedOn,
85
- now,
86
- deployedCommitSha,
87
- spawnProcess = spawn
88
- }) {
89
- const siteRoot = path.resolve(root);
90
- const manifestPath = path.join(siteRoot, BUILD_MANIFEST_PATH);
91
- let manifest;
92
- try {
93
- manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
94
- } catch (error) {
95
- if (error.code === 'ENOENT') {
96
- throw new Error('Current validated build manifest is missing; deployment cannot be recorded');
97
- }
98
- throw new TypeError(`Current validated build manifest is invalid: ${error.message}`);
99
- }
100
- const date = deployedOn ?? await repositoryEvaluationDate({ root: siteRoot, now });
101
- if (typeof deployedCommitSha !== 'string' || !/^[0-9a-f]{40}$/.test(deployedCommitSha)) {
102
- throw new TypeError('record-deployment requires --commit-sha <lowercase 40-character SHA>');
103
- }
104
- const head = await readHead(siteRoot, spawnProcess);
105
- if (deployedCommitSha !== head) {
106
- throw new Error(`Deployment SHA ${deployedCommitSha} does not match checkout HEAD ${head}`);
107
- }
108
- const state = await recordSuccessfulDeployment({
109
- root: siteRoot,
110
- manifest,
111
- deployedOn: date,
112
- deployedCommitSha
113
- });
114
- const statePath = '.gala/publication-state.yml';
115
- const contentPaths = await assignedContentPaths(siteRoot, manifest);
116
- const committedPaths = [statePath, ...contentPaths];
117
- const addCode = await runGit(siteRoot, ['add', '--', ...committedPaths], spawnProcess);
118
- if (addCode !== 0) throw new Error(`Git add exited with code ${addCode}`);
119
- const diffCode = await runGit(
120
- siteRoot,
121
- ['diff', '--cached', '--quiet', '--exit-code', '--', ...committedPaths],
122
- spawnProcess
123
- );
124
- if (diffCode === 0) return { state, pushed: false, recordedStateSha: head };
125
- if (diffCode !== 1) throw new Error(`Git diff exited with code ${diffCode}`);
126
- const assignmentTrailers = (manifest.assignedContentIds ?? []).map(
127
- ({ id, source }) => `Gala-Assigned-ID: ${id} ${source}`
128
- );
129
- const commitMessage = [
130
- 'chore(gala): record successful deployment [skip ci]',
131
- '',
132
- `Gala-Deployed-SHA: ${deployedCommitSha}`,
133
- ...assignmentTrailers
134
- ].join('\n');
135
- const commitCode = await runGit(siteRoot, [
136
- 'commit', '--only', '-m', commitMessage,
137
- '--', ...committedPaths
138
- ], spawnProcess);
139
- if (commitCode !== 0) throw new Error(`Git commit exited with code ${commitCode}`);
140
- const recordedStateSha = await readHead(siteRoot, spawnProcess);
141
- if (recordedStateSha === deployedCommitSha) {
142
- throw new Error('Git did not create a distinct recorded-state commit');
143
- }
144
- const pushCode = await runGit(siteRoot, ['push'], spawnProcess);
145
- if (pushCode !== 0) throw new Error(`Git push exited with code ${pushCode}`);
146
- return { state, pushed: true, recordedStateSha };
147
- }
@@ -1,104 +0,0 @@
1
- import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { parse } from 'yaml';
5
-
6
- import { readGalaCredential } from './gala-credential-store.js';
7
-
8
- const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
9
- const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
10
- const SNAPSHOT_PATH = '.engagement-snapshot.json';
11
-
12
- async function runGit(root, args) {
13
- return new Promise((resolve, reject) => {
14
- const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
15
- child.once('error', reject);
16
- child.once('exit', (code, signal) => {
17
- if (signal) reject(new Error(`git terminated by signal ${signal}`));
18
- else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
19
- else resolve();
20
- });
21
- });
22
- }
23
-
24
- async function commitRefreshedSnapshot(root, relativePath) {
25
- await runGit(root, [
26
- 'commit', '--only', '--message', 'chore(gala): refresh engagement snapshot', '--', relativePath
27
- ]);
28
- await runGit(root, ['push']);
29
- }
30
-
31
- function validateSnapshot(payload) {
32
- if (payload?.schemaVersion !== 1 || !UTC_INSTANT.test(payload.refreshedAt)
33
- || payload.articles == null || Array.isArray(payload.articles)
34
- || typeof payload.articles !== 'object') {
35
- throw new TypeError('Engagement snapshot response is invalid');
36
- }
37
- for (const [articleId, counts] of Object.entries(payload.articles)) {
38
- if (!ULID.test(articleId) || counts == null || Array.isArray(counts)
39
- || typeof counts !== 'object'
40
- || Object.keys(counts).sort().join(',') !== 'comments,reactions,views'
41
- || !['reactions', 'comments', 'views'].every(
42
- (field) => Number.isSafeInteger(counts[field]) && counts[field] >= 0
43
- )) {
44
- throw new TypeError('Engagement snapshot response is invalid');
45
- }
46
- }
47
- return payload;
48
- }
49
-
50
- async function requireRegularFile(target, label, { allowMissing = false } = {}) {
51
- try {
52
- const metadata = await lstat(target);
53
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
54
- throw new TypeError(`${label} must be a regular file`);
55
- }
56
- return true;
57
- } catch (error) {
58
- if (allowMissing && error.code === 'ENOENT') return false;
59
- throw error;
60
- }
61
- }
62
-
63
- export async function refreshEngagementSnapshot({
64
- root = process.cwd(),
65
- readCredential = readGalaCredential,
66
- fetchImpl = fetch,
67
- commitSnapshot = commitRefreshedSnapshot
68
- } = {}) {
69
- const siteRoot = path.resolve(root);
70
- const configPath = path.join(siteRoot, 'site.config.yml');
71
- await requireRegularFile(configPath, 'site.config.yml');
72
- const config = parse(await readFile(configPath, 'utf8'));
73
- const siteId = config?.site?.id;
74
- if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
75
-
76
- const credential = await readCredential();
77
- const endpoint = new URL(`/v1/sites/${siteId}/engagement-snapshot`, credential.apiBaseUrl);
78
- const loopback = endpoint.protocol === 'http:'
79
- && ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
80
- if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
81
- throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
82
- }
83
- const response = await fetchImpl(endpoint, {
84
- method: 'GET',
85
- headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
86
- });
87
- if (!response.ok) throw new Error(`Engagement snapshot refresh failed with HTTP ${response.status}`);
88
- const snapshot = validateSnapshot(await response.json());
89
- const next = `${JSON.stringify(snapshot, null, 2)}\n`;
90
- const target = path.join(siteRoot, SNAPSHOT_PATH);
91
- const exists = await requireRegularFile(target, 'Engagement snapshot', { allowMissing: true });
92
- if (exists && await readFile(target, 'utf8') === next) return Object.freeze({ changed: false });
93
-
94
- const temporary = `${target}.gala-${process.pid}`;
95
- try {
96
- await writeFile(temporary, next, { flag: 'wx' });
97
- await rename(temporary, target);
98
- } catch (error) {
99
- await rm(temporary, { force: true });
100
- throw error;
101
- }
102
- await commitSnapshot(siteRoot, SNAPSHOT_PATH);
103
- return Object.freeze({ changed: true });
104
- }
@@ -1,94 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { lstat, readdir } from 'node:fs/promises';
3
- import path from 'node:path';
4
-
5
- const MEBIBYTE = 1024 * 1024;
6
-
7
- function gitRepositoryBytes(root, spawnProcess) {
8
- return new Promise((resolve, reject) => {
9
- const child = spawnProcess('git', ['-C', root, 'count-objects', '-v'], {
10
- cwd: root,
11
- shell: false,
12
- stdio: ['ignore', 'pipe', 'pipe']
13
- });
14
- let output = '';
15
- let errors = '';
16
- child.stdout.on('data', (chunk) => { output += chunk; });
17
- child.stderr.on('data', (chunk) => { errors += chunk; });
18
- child.once('error', reject);
19
- child.once('exit', (code, signal) => {
20
- if (signal) return reject(new Error(`Repository inspection terminated by signal ${signal}`));
21
- if (code !== 0) return reject(new Error(`Repository inspection failed: ${errors.trim()}`));
22
- const values = Object.fromEntries(output.trim().split('\n').map((line) => {
23
- const separator = line.indexOf(':');
24
- return [line.slice(0, separator), Number(line.slice(separator + 1).trim())];
25
- }));
26
- if (!Number.isFinite(values.size) || !Number.isFinite(values['size-pack'])) {
27
- return reject(new Error('Git returned invalid repository size metadata'));
28
- }
29
- resolve((values.size + values['size-pack']) * 1024);
30
- });
31
- });
32
- }
33
-
34
- async function countPosts(directory) {
35
- const entries = await readdir(directory, { withFileTypes: true });
36
- const counts = await Promise.all(entries.map((entry) => {
37
- if (entry.isDirectory()) return countPosts(path.join(directory, entry.name));
38
- return Promise.resolve(entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name) ? 1 : 0);
39
- }));
40
- return counts.reduce((total, count) => total + count, 0);
41
- }
42
-
43
- export function repositoryLimitWarnings({ repositoryBytes, postCount, buildDurationMs }) {
44
- const warnings = [];
45
- if (repositoryBytes > 800 * MEBIBYTE) {
46
- warnings.push({ severity: 'critical', code: 'repository-size-800mb' });
47
- } else if (repositoryBytes > 500 * MEBIBYTE) {
48
- warnings.push({ severity: 'warning', code: 'repository-size-500mb' });
49
- }
50
- if (postCount > 1000) warnings.push({ severity: 'warning', code: 'post-count-1000' });
51
- if (buildDurationMs != null && buildDurationMs > 5 * 60 * 1000) {
52
- warnings.push({ severity: 'warning', code: 'build-duration-5m' });
53
- }
54
- return warnings;
55
- }
56
-
57
- export async function inspectRepositoryLimits(root, { spawnProcess = spawn, buildDurationMs } = {}) {
58
- const resolvedRoot = path.resolve(root);
59
- const [repositoryBytes, postCount] = await Promise.all([
60
- gitRepositoryBytes(resolvedRoot, spawnProcess),
61
- countPosts(path.join(resolvedRoot, 'content', 'posts'))
62
- ]);
63
- return {
64
- repositoryBytes,
65
- postCount,
66
- warnings: repositoryLimitWarnings({ repositoryBytes, postCount, buildDurationMs })
67
- };
68
- }
69
-
70
- export async function reportRepositoryLimitWarnings(
71
- root,
72
- { spawnProcess = spawn, output = process.stderr } = {}
73
- ) {
74
- const resolvedRoot = path.resolve(root);
75
- try {
76
- const [gitMetadata, config, posts] = await Promise.all([
77
- lstat(path.join(resolvedRoot, '.git')),
78
- lstat(path.join(resolvedRoot, 'site.config.yml')),
79
- lstat(path.join(resolvedRoot, 'content', 'posts'))
80
- ]);
81
- if ((!gitMetadata.isDirectory() && !gitMetadata.isFile())
82
- || !config.isFile() || config.isSymbolicLink()
83
- || !posts.isDirectory() || posts.isSymbolicLink()) {
84
- return [];
85
- }
86
- } catch (error) {
87
- if (error.code === 'ENOENT') return [];
88
- throw error;
89
- }
90
-
91
- const { warnings } = await inspectRepositoryLimits(resolvedRoot, { spawnProcess });
92
- for (const { severity, code } of warnings) output.write(`${severity}\t${code}\n`);
93
- return warnings;
94
- }
@@ -1,41 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
-
3
- function run(root, args, spawnProcess, acceptedExitCodes = [0]) {
4
- return new Promise((resolve, reject) => {
5
- const child = spawnProcess('git', ['-C', root, ...args], { cwd: root, shell: false, stdio: 'inherit' });
6
- child.once('error', reject);
7
- child.once('exit', (code, signal) => {
8
- if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
9
- else if (!acceptedExitCodes.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
10
- else resolve(code);
11
- });
12
- });
13
- }
14
-
15
- function capture(root, args, spawnProcess) {
16
- return new Promise((resolve, reject) => {
17
- const child = spawnProcess('git', ['-C', root, ...args], {
18
- cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
19
- });
20
- let stdout = '';
21
- child.stdout.on('data', (chunk) => { stdout += chunk; });
22
- child.once('error', reject);
23
- child.once('exit', (code, signal) => {
24
- if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
25
- else if (code !== 0) reject(new Error(`Git ${args[0]} exited with code ${code}`));
26
- else resolve(stdout.trim());
27
- });
28
- });
29
- }
30
-
31
- export async function commitScaffold(root, { spawnProcess = spawn } = {}) {
32
- await run(root, ['add', '--', 'site.config.yml', '.github/workflows/publish.yml'], spawnProcess);
33
- const unchanged = await run(root, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
34
- if (unchanged === 1) {
35
- await run(root, ['commit', '-m', 'chore(gala): configure site'], spawnProcess);
36
- }
37
- await run(root, ['push', 'origin', 'HEAD'], spawnProcess);
38
- const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
39
- if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid scaffold commit SHA');
40
- return commitSha;
41
- }
@@ -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,147 +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
- showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
123
- showInstructions: ({ verificationUri, userCode }) =>
124
- notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
125
- });
126
- return readGithub();
127
- }
128
- }
129
-
130
- /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
131
- export function repositoryNameFrom(siteName) {
132
- if (typeof siteName !== 'string') return null;
133
- const slug = siteName
134
- .trim().toLowerCase()
135
- .replace(/[^a-z0-9._-]+/g, '-')
136
- .replace(/^-+|-+$/g, '');
137
- return slug === '' ? null : slug;
138
- }
139
-
140
- async function askForRepository(ask) {
141
- if (typeof ask !== 'function') {
142
- throw new TypeError('repository is required; pass --repository or --site-name');
143
- }
144
- const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
145
- if (answer == null) throw new TypeError('A repository name is required');
146
- return answer;
147
- }