@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
@@ -0,0 +1,52 @@
1
+ import { galaApi, DEFAULT_API_BASE_URL } from '../api/gala.js';
2
+ import { credentialPath, forgetCredential, readCredential, writeCredential } from './store.js';
3
+
4
+ /**
5
+ * The Gala sign-in.
6
+ *
7
+ * A stored credential is checked against the server before anything depends on it, because one
8
+ * that parses and has not expired can still be one the API refuses — and discovering that four
9
+ * calls later, as an opaque 401 from whichever endpoint got there first, is how a "sign in again"
10
+ * became a stack trace.
11
+ */
12
+ export async function galaCredential({
13
+ terminal,
14
+ apiBaseUrl = DEFAULT_API_BASE_URL,
15
+ target = credentialPath('credentials'),
16
+ now = Date.now
17
+ }) {
18
+ const stored = await readCredential(target);
19
+ if (stored != null) {
20
+ const accepted = await galaApi({ baseUrl: stored.apiBaseUrl ?? apiBaseUrl, token: stored.accessToken }).accepted();
21
+ if (accepted) return stored;
22
+ // Leaving it on disk makes every later command rediscover that it is refused.
23
+ await forgetCredential(target);
24
+ terminal.step('Your Gala sign-in is no longer valid');
25
+ }
26
+
27
+ const api = galaApi({ baseUrl: apiBaseUrl });
28
+ const authorization = await api.startDeviceAuthorization();
29
+ terminal.step('Sign in to Gala');
30
+ terminal.openUrl(authorization.verification_uri);
31
+ terminal.note(`code ${authorization.user_code}`);
32
+
33
+ const token = await poll(api, authorization, now);
34
+ await writeCredential(target, {
35
+ accessToken: token.access_token,
36
+ apiBaseUrl,
37
+ expiresAt: new Date(now() + token.expires_in * 1000).toISOString()
38
+ });
39
+ terminal.done('Signed in to Gala');
40
+ return readCredential(target);
41
+ }
42
+
43
+ async function poll(api, authorization, now) {
44
+ const deadline = now() + authorization.expires_in * 1000;
45
+ const interval = Math.max(1, authorization.interval ?? 5) * 1000;
46
+ while (now() < deadline) {
47
+ await new Promise((resolve) => { setTimeout(resolve, interval); });
48
+ const token = await api.pollDeviceAuthorization(authorization.device_code);
49
+ if (token != null) return token;
50
+ }
51
+ throw new Error('Gala sign-in expired before it was authorized');
52
+ }
@@ -0,0 +1,78 @@
1
+ import { credentialPath, readCredential, writeCredential } from './store.js';
2
+
3
+ /**
4
+ * The GitHub sign-in, as the Gala App.
5
+ *
6
+ * v0 authenticated as a separate OAuth App while the browser editor used the GitHub App. They are
7
+ * two different identity systems and every difference fell on the CLI: an OAuth token cannot list
8
+ * App installations, organisations with OAuth App restrictions refuse it outright, and it inherits
9
+ * none of the App's repository grants. The editor hit none of that, which is why the two behaved
10
+ * so differently for so long.
11
+ *
12
+ * Client IDs are public; this is the value `GET /apps/gala67-app` publishes.
13
+ */
14
+ export const APP_CLIENT_ID = 'Iv23liIg7Hi1lMesiaon';
15
+
16
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
17
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
18
+
19
+ export async function githubCredential({
20
+ terminal,
21
+ clientId = APP_CLIENT_ID,
22
+ target = credentialPath('github-credentials'),
23
+ now = Date.now
24
+ }) {
25
+ const stored = await readCredential(target);
26
+ if (stored != null) return stored;
27
+
28
+ // No scopes. A GitHub App's permissions are fixed on the app and granted when the writer installs
29
+ // it, so there is nothing to negotiate — and nothing to warn them about, which is why the broad
30
+ // "read/write on every repository you can access" notice is gone.
31
+ const authorization = await post(DEVICE_CODE_URL, { client_id: clientId }, 'GitHub sign-in');
32
+ terminal.step('Sign in to GitHub');
33
+ terminal.openUrl(authorization.verification_uri);
34
+ terminal.note(`code ${authorization.user_code}`);
35
+
36
+ const token = await poll(clientId, authorization, now);
37
+ await writeCredential(target, {
38
+ accessToken: token.access_token,
39
+ // The app expires user tokens after eight hours and issues a refresh token with each. Exchanging
40
+ // one needs the app's client secret, which a published CLI cannot hold — so it is kept for the
41
+ // API-side refresh, and until that exists an expired credential asks for one sign-in rather
42
+ // than failing as an unexplained 401 somewhere deeper.
43
+ ...(token.expires_in ? { expiresAt: new Date(now() + token.expires_in * 1000).toISOString() } : {}),
44
+ ...(token.refresh_token ? { refreshToken: token.refresh_token } : {})
45
+ });
46
+ terminal.done('Signed in to GitHub');
47
+ return readCredential(target);
48
+ }
49
+
50
+ async function poll(clientId, authorization, now) {
51
+ const deadline = now() + authorization.expires_in * 1000;
52
+ let interval = Math.max(1, authorization.interval ?? 5) * 1000;
53
+ while (now() < deadline) {
54
+ await new Promise((resolve) => { setTimeout(resolve, interval); });
55
+ const payload = await post(ACCESS_TOKEN_URL, {
56
+ client_id: clientId,
57
+ device_code: authorization.device_code,
58
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
59
+ }, 'GitHub sign-in');
60
+ if (typeof payload.access_token === 'string' && payload.access_token !== '') return payload;
61
+ if (payload.error === 'slow_down') interval += 5000;
62
+ else if (payload.error !== 'authorization_pending') {
63
+ throw new Error(`GitHub sign-in failed: ${payload.error_description ?? payload.error}`);
64
+ }
65
+ }
66
+ throw new Error('GitHub sign-in expired before it was authorized');
67
+ }
68
+
69
+ async function post(url, form, action) {
70
+ const response = await fetch(url, {
71
+ method: 'POST',
72
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
73
+ body: new URLSearchParams(form).toString()
74
+ });
75
+ const payload = await response.json().catch(() => null);
76
+ if (payload == null) throw new Error(`${action} returned an unreadable response`);
77
+ return payload;
78
+ }
@@ -0,0 +1,88 @@
1
+ import { chmod, mkdir, lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Where credentials live, and the rules for reading them.
7
+ *
8
+ * Two rules matter more than the storage itself, both learned the hard way:
9
+ *
10
+ * A credential is only valid if the server still accepts it. v0 checked expiry and nothing else, so
11
+ * a token the API had already decided to refuse — it stopped issuing a claim these carried — was
12
+ * handed out for weeks, and every command failed as an unexplained 401 several calls deep.
13
+ *
14
+ * A credential whose shape has changed is not upgradable in place. Bumping `schemaVersion` and
15
+ * refusing the old one sends the writer through one sign-in, which is the only honest answer.
16
+ *
17
+ * Plaintext at 0600 protects against other users and stray backups, not against anything running
18
+ * as the writer. Moving to the OS keychain is tracked separately; the file stays as the fallback
19
+ * for platforms without one.
20
+ */
21
+ const SCHEMA_VERSION = 2;
22
+
23
+ export function credentialPath(name, { platform = process.platform, environment = process.env, home = os.homedir() } = {}) {
24
+ if (platform === 'win32') {
25
+ if (!environment.APPDATA) throw new Error('APPDATA is required to store credentials on Windows');
26
+ return path.join(environment.APPDATA, 'Gala', `${name}.json`);
27
+ }
28
+ if (platform === 'darwin') {
29
+ return path.join(home, 'Library', 'Application Support', 'Gala', `${name}.json`);
30
+ }
31
+ return path.join(environment.XDG_CONFIG_HOME || path.join(home, '.config'), 'gala', `${name}.json`);
32
+ }
33
+
34
+ export async function writeCredential(target, record) {
35
+ const directory = path.dirname(path.resolve(target));
36
+ await mkdir(directory, { recursive: true, mode: 0o700 });
37
+ await chmod(directory, 0o700);
38
+ await refuseSymbolicLink(target);
39
+
40
+ const temporary = `${target}.gala-${process.pid}`;
41
+ try {
42
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...record })}\n`, {
43
+ flag: 'wx', mode: 0o600
44
+ });
45
+ await chmod(temporary, 0o600);
46
+ // Renaming over the target is what makes a half-written credential impossible.
47
+ await rename(temporary, target);
48
+ return path.resolve(target);
49
+ } catch (failure) {
50
+ await rm(temporary, { force: true });
51
+ throw failure;
52
+ }
53
+ }
54
+
55
+ /** Returns null when there is nothing usable, so callers branch on presence rather than on errors. */
56
+ export async function readCredential(target, { now = new Date() } = {}) {
57
+ let payload;
58
+ try {
59
+ await refuseSymbolicLink(target);
60
+ payload = JSON.parse(await readFile(target, 'utf8'));
61
+ } catch (missing) {
62
+ if (missing?.code === 'ENOENT') return null;
63
+ if (missing instanceof SyntaxError) return null;
64
+ throw missing;
65
+ }
66
+ if (payload?.schemaVersion !== SCHEMA_VERSION) return null;
67
+ if (typeof payload.accessToken !== 'string' || payload.accessToken === '') return null;
68
+ if (typeof payload.expiresAt === 'string') {
69
+ const expiresAt = new Date(payload.expiresAt);
70
+ if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now) return null;
71
+ }
72
+ return payload;
73
+ }
74
+
75
+ export async function forgetCredential(target) {
76
+ await rm(target, { force: true });
77
+ }
78
+
79
+ async function refuseSymbolicLink(target) {
80
+ try {
81
+ const metadata = await lstat(target);
82
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
83
+ throw new TypeError('Credential must be a regular file');
84
+ }
85
+ } catch (missing) {
86
+ if (missing?.code !== 'ENOENT') throw missing;
87
+ }
88
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Argument parsing, deliberately small.
3
+ *
4
+ * v0 read arguments with `args.indexOf(name)` at each use site, so a mistyped flag was silently
5
+ * ignored and the same flag could be read twice with different defaults in two places. Parsing
6
+ * once and rejecting anything unrecognised is the difference between a typo that fails immediately
7
+ * and one that quietly changes what the command does.
8
+ */
9
+ export function parseArguments(argv, { flags = [], switches = [] } = {}) {
10
+ const values = new Map();
11
+ const positional = [];
12
+
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const token = argv[index];
15
+ if (!token.startsWith('--')) {
16
+ positional.push(token);
17
+ continue;
18
+ }
19
+
20
+ const [name, inline] = splitOnce(token.slice(2));
21
+ if (switches.includes(name)) {
22
+ if (inline != null) throw new UsageError(`--${name} takes no value`);
23
+ values.set(name, true);
24
+ continue;
25
+ }
26
+ if (!flags.includes(name)) throw new UsageError(`unknown option --${name}`);
27
+ if (values.has(name)) throw new UsageError(`--${name} given more than once`);
28
+
29
+ const value = inline ?? argv[index + 1];
30
+ if (value == null || (inline == null && value.startsWith('--'))) {
31
+ throw new UsageError(`--${name} needs a value`);
32
+ }
33
+ if (inline == null) index += 1;
34
+ values.set(name, value);
35
+ }
36
+
37
+ return {
38
+ positional,
39
+ value: (name) => (typeof values.get(name) === 'string' ? values.get(name) : undefined),
40
+ on: (name) => values.get(name) === true
41
+ };
42
+ }
43
+
44
+ /** Supports `--name value` and `--name=value`; only the first `=` separates. */
45
+ function splitOnce(token) {
46
+ const at = token.indexOf('=');
47
+ return at === -1 ? [token, undefined] : [token.slice(0, at), token.slice(at + 1)];
48
+ }
49
+
50
+ /** How the command was invoked is wrong, as opposed to something failing while it ran. */
51
+ export class UsageError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'UsageError';
55
+ }
56
+ }
@@ -0,0 +1,104 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { spawn } from 'node:child_process';
3
+
4
+ /**
5
+ * Everything the writer sees or is asked, in one place.
6
+ *
7
+ * v0 wrote to stdout from a dozen modules with no shared shape, so a scaffold emitted raw git
8
+ * output, bare status codes and half-sentences in whatever order they happened to occur. The
9
+ * failure mode that cost the most was subtler: prompts and browser launches were decided
10
+ * separately in each module, so behaviour with no terminal attached varied by code path — some
11
+ * hung, some crashed, some silently skipped.
12
+ *
13
+ * One object, created once, knows whether there is a terminal. Nothing else has to ask.
14
+ */
15
+ export function createTerminal({
16
+ input = process.stdin,
17
+ output = process.stdout,
18
+ errorOutput = process.stderr,
19
+ environment = process.env,
20
+ spawnProcess = spawn
21
+ } = {}) {
22
+ const interactive = input.isTTY === true && output.isTTY === true;
23
+ const plain = !interactive || environment.NO_COLOR != null || environment.TERM === 'dumb';
24
+ const paint = (code, text) => (plain ? text : `\u001b[${code}m${text}\u001b[0m`);
25
+
26
+ return {
27
+ interactive,
28
+
29
+ /** A step that is happening now. */
30
+ step: (message) => output.write(` ${paint('90', '·')} ${message}\n`),
31
+
32
+ /** A step that finished. */
33
+ done: (message) => output.write(` ${paint('32', '✓')} ${message}\n`),
34
+
35
+ /** The thing the writer wanted, at the end. */
36
+ result: (message) => output.write(`\n ${paint('1', message)}\n`),
37
+
38
+ note: (message) => output.write(` ${paint('90', message)}\n`),
39
+
40
+ blank: () => output.write('\n'),
41
+
42
+ fail: (message) => errorOutput.write(`\n ${paint('31', 'x')} ${message}\n\n`),
43
+
44
+ /**
45
+ * Asks a question, or refuses to.
46
+ *
47
+ * Returning a default without a terminal is what keeps CI honest: a prompt that silently
48
+ * resolves to a guess is worse than one that stops and names the flag to pass instead.
49
+ */
50
+ async ask(question, { fallback } = {}) {
51
+ if (!interactive) {
52
+ if (fallback !== undefined) return fallback;
53
+ throw new Error(`${question} — no terminal to ask; pass it as an option instead`);
54
+ }
55
+ const reader = createInterface({ input, output });
56
+ try {
57
+ const answer = await reader.question(` ${paint('36', '?')} ${question} `);
58
+ return answer.trim();
59
+ } finally {
60
+ reader.close();
61
+ }
62
+ },
63
+
64
+ /** Waits for the writer to finish something in a browser. */
65
+ async waitForEnter(message) {
66
+ if (!interactive) return false;
67
+ await this.ask(`${message} (press enter)`);
68
+ return true;
69
+ },
70
+
71
+ /**
72
+ * Opens a URL, best effort, and always prints it.
73
+ *
74
+ * Never attempted without a terminal: a CI job spawning a browser is a hang waiting to happen
75
+ * and there is nobody there to look at it. The URL is printed either way, because it is the
76
+ * thing a writer moves to another device.
77
+ */
78
+ openUrl(url) {
79
+ const opened = interactive
80
+ && environment.CI == null
81
+ && environment.NO_BROWSER == null
82
+ && environment.GALA_NO_BROWSER == null
83
+ && /^https:\/\//.test(url)
84
+ && launch(url, spawnProcess);
85
+ output.write(` ${paint('90', opened ? 'opened' : 'open')} ${paint('4', url)}\n`);
86
+ return opened;
87
+ }
88
+ };
89
+ }
90
+
91
+ function launch(url, spawnProcess) {
92
+ const [command, args] = process.platform === 'darwin' ? ['open', [url]]
93
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
94
+ : ['xdg-open', [url]];
95
+ try {
96
+ const child = spawnProcess(command, args, { stdio: 'ignore', detached: true, shell: false });
97
+ child.unref?.();
98
+ // A machine with no opener is an ordinary outcome, not something to report.
99
+ child.on?.('error', () => {});
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
@@ -0,0 +1,20 @@
1
+ import { galaCredential } from '../auth/gala.js';
2
+ import { githubCredential } from '../auth/github.js';
3
+
4
+ /**
5
+ * Signs in to both, and says so.
6
+ *
7
+ * Not a prerequisite the writer has to remember: every command that needs a credential obtains one.
8
+ * This exists for the times they want to do it deliberately — a new machine, a different account,
9
+ * or after a token has expired.
10
+ */
11
+ export async function auth({ terminal, options }) {
12
+ const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
13
+ const github = await githubCredential({ terminal });
14
+
15
+ terminal.blank();
16
+ terminal.done(`Gala — valid until ${new Date(gala.expiresAt).toLocaleString()}`);
17
+ terminal.done(github.expiresAt
18
+ ? `GitHub — valid until ${new Date(github.expiresAt).toLocaleString()}`
19
+ : 'GitHub — signed in');
20
+ }
@@ -0,0 +1,98 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { galaApi } from '../api/gala.js';
5
+ import { galaCredential } from '../auth/gala.js';
6
+ import { githubCredential } from '../auth/github.js';
7
+ import { createGit } from '../git.js';
8
+
9
+ /**
10
+ * Answers "why is this not working" without the writer having to know where to look.
11
+ *
12
+ * Every check reports one of three things and never guesses between them: it is fine, it is wrong
13
+ * and here is the fix, or it could not be determined. That third state is the one v0 kept
14
+ * collapsing into the second — an unreachable GitHub reported as "the App is not installed" sent
15
+ * writers to install something that was already installed, repeatedly.
16
+ */
17
+ export async function doctor({ terminal, options, cwd = process.cwd() }) {
18
+ const root = path.resolve(options.value('root') ?? cwd);
19
+ const checks = [];
20
+
21
+ checks.push(await checkCredential('Gala sign-in', async () => {
22
+ const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
23
+ const accepted = await galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken }).accepted();
24
+ return accepted
25
+ ? ok(`valid until ${new Date(gala.expiresAt).toLocaleString()}`)
26
+ : wrong('the API no longer accepts it', 'gala auth');
27
+ }));
28
+
29
+ checks.push(await checkCredential('GitHub sign-in', async () => {
30
+ const github = await githubCredential({ terminal });
31
+ return ok(github.expiresAt
32
+ ? `valid until ${new Date(github.expiresAt).toLocaleString()}`
33
+ : 'signed in');
34
+ }));
35
+
36
+ checks.push(await check('Publication folder', async () => {
37
+ const config = path.join(root, 'site.config.yml');
38
+ await stat(config);
39
+ const posts = await countPosts(root);
40
+ return ok(`${posts} post${posts === 1 ? '' : 's'}`);
41
+ }, 'run this inside a publication, or pass --root'));
42
+
43
+ checks.push(await check('Publishing workflow', async () => {
44
+ const workflow = path.join(root, '.github', 'workflows', 'publish.yml');
45
+ const source = await readFile(workflow, 'utf8');
46
+ const siteId = /site-id:\s*([0-9A-Z]{26})/.exec(source)?.[1];
47
+ return siteId == null
48
+ ? wrong('no site id — this publication may not be registered', 'gala init')
49
+ : ok(siteId);
50
+ }, 'the workflow is missing; registration writes it'));
51
+
52
+ checks.push(await check('Unsent work', async () => {
53
+ const git = createGit({ root });
54
+ const dirty = await git.run(['status', '--porcelain'], { capture: true });
55
+ const ahead = await git.run(['rev-list', '--count', '@{upstream}..HEAD'], { capture: true, allow: [0, 128] });
56
+ if (dirty !== '') return wrong(`${dirty.split('\n').length} file(s) not recorded`, 'gala publish');
57
+ if (ahead !== '' && ahead !== '0') return wrong(`${ahead} commit(s) not sent`, 'gala publish');
58
+ return ok('everything is on GitHub');
59
+ }, 'this folder is not a git checkout'));
60
+
61
+ terminal.blank();
62
+ for (const { name, state, detail, fix } of checks) {
63
+ if (state === 'ok') terminal.done(`${name} — ${detail}`);
64
+ else if (state === 'wrong') terminal.fail(`${name} — ${detail}`);
65
+ else terminal.step(`${name} — could not be determined: ${detail}`);
66
+ if (fix) terminal.note(fix);
67
+ }
68
+
69
+ const broken = checks.filter(({ state }) => state === 'wrong');
70
+ if (broken.length > 0) throw new Error(`${broken.length} problem(s) found`);
71
+ terminal.blank();
72
+ terminal.note('Nothing looks wrong.');
73
+ }
74
+
75
+ const ok = (detail) => ({ state: 'ok', detail });
76
+ const wrong = (detail, fix) => ({ state: 'wrong', detail, fix });
77
+
78
+ /** Distinguishes "this is wrong" from "I could not tell", which are different answers. */
79
+ async function check(name, run, unknownHint) {
80
+ try {
81
+ return { name, ...await run() };
82
+ } catch (failure) {
83
+ return { name, state: 'unknown', detail: failure.message, fix: unknownHint };
84
+ }
85
+ }
86
+
87
+ /** Credentials are obtained rather than merely inspected, so doctor can also repair a sign-in. */
88
+ const checkCredential = check;
89
+
90
+ async function countPosts(root) {
91
+ try {
92
+ const posts = path.join(root, 'content', 'posts');
93
+ const entries = await readdir(posts, { withFileTypes: true });
94
+ return entries.filter((entry) => entry.isDirectory()).length;
95
+ } catch {
96
+ return 0;
97
+ }
98
+ }