@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.
Files changed (69) hide show
  1. package/README.md +100 -187
  2. package/package.json +3 -3
  3. package/src/api/gala.js +163 -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/domain.js +124 -0
  14. package/src/commands/init.js +276 -0
  15. package/src/commands/new.js +76 -0
  16. package/src/commands/preview.js +92 -0
  17. package/src/commands/prism.js +360 -0
  18. package/src/commands/publish.js +57 -0
  19. package/src/commands/upgrade.js +173 -0
  20. package/src/commands-manifest.js +80 -0
  21. package/src/content.js +31 -0
  22. package/src/domain.js +33 -0
  23. package/src/git.js +160 -0
  24. package/src/index.js +44 -294
  25. package/src/publication.js +39 -0
  26. package/src/assign-content-ids.js +0 -1
  27. package/src/auth-command.js +0 -36
  28. package/src/configure-site.js +0 -102
  29. package/src/content-files.js +0 -1
  30. package/src/doctor-command.js +0 -214
  31. package/src/entitlement-client.js +0 -26
  32. package/src/entitlement-command.js +0 -74
  33. package/src/evaluation-date.js +0 -1
  34. package/src/gala-credential-health.js +0 -34
  35. package/src/gala-credential-store.js +0 -115
  36. package/src/gala-device-flow.js +0 -121
  37. package/src/git-credentials.js +0 -37
  38. package/src/github-auth-command.js +0 -50
  39. package/src/github-credential-store.js +0 -104
  40. package/src/github-device-flow.js +0 -153
  41. package/src/github-empty-repository.js +0 -89
  42. package/src/github-identity.js +0 -32
  43. package/src/github-pages-provisioning.js +0 -107
  44. package/src/github-repository-secret.js +0 -82
  45. package/src/github-repository-variable.js +0 -56
  46. package/src/github-template-repository.js +0 -171
  47. package/src/hook-command.js +0 -64
  48. package/src/http-failure.js +0 -55
  49. package/src/new-command.js +0 -54
  50. package/src/open-browser.js +0 -40
  51. package/src/preview-command.js +0 -60
  52. package/src/publication-creation-client.js +0 -155
  53. package/src/publication-state.js +0 -7
  54. package/src/publish-command.js +0 -37
  55. package/src/record-deployment-command.js +0 -147
  56. package/src/refresh-command.js +0 -104
  57. package/src/repository-limits.js +0 -94
  58. package/src/scaffold-git.js +0 -76
  59. package/src/scaffold-options.js +0 -58
  60. package/src/scaffold-preflight.js +0 -146
  61. package/src/scaffold-site.js +0 -185
  62. package/src/site-config-registration.js +0 -47
  63. package/src/site-registration-client.js +0 -138
  64. package/src/theme-package.js +0 -128
  65. package/src/topology-client.js +0 -43
  66. package/src/topology-command.js +0 -70
  67. package/src/upgrade-command.js +0 -81
  68. package/src/validate-command.js +0 -5
  69. package/src/workflow-command.js +0 -87
@@ -1,107 +0,0 @@
1
- import { describeHttpFailure } from './http-failure.js';
2
- const GITHUB_API_VERSION = '2026-03-10';
3
- const SEGMENT = /^[A-Za-z0-9_.-]+$/;
4
- const SHA = /^[0-9a-f]{40}$/;
5
-
6
- function required(value, field, pattern = null) {
7
- if (typeof value !== 'string' || value.length === 0 || (pattern != null && !pattern.test(value))) {
8
- throw new TypeError(`${field} is invalid`);
9
- }
10
- return value;
11
- }
12
-
13
- function headers(accessToken) {
14
- return {
15
- accept: 'application/vnd.github+json',
16
- authorization: `Bearer ${accessToken}`,
17
- 'content-type': 'application/json',
18
- 'x-github-api-version': GITHUB_API_VERSION
19
- };
20
- }
21
-
22
- async function json(response, operation) {
23
- if (!response.ok) throw new Error(await describeHttpFailure(response, `GitHub ${operation}`));
24
- return response.json();
25
- }
26
-
27
- export async function provisionGithubPages({
28
- owner, repository, accessToken, commitSha, fetchImpl = fetch,
29
- customDomain = null,
30
- sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
31
- pollIntervalMs = 5_000, maxPolls = 120
32
- }) {
33
- const normalizedOwner = required(owner, 'owner', SEGMENT);
34
- const normalizedRepository = required(repository, 'repository', SEGMENT);
35
- const token = required(accessToken, 'accessToken');
36
- const sha = required(commitSha, 'commitSha', SHA);
37
- if (customDomain != null && (typeof customDomain !== 'string'
38
- || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(customDomain))) {
39
- throw new TypeError('customDomain is invalid');
40
- }
41
- if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 0) {
42
- throw new TypeError('pollIntervalMs must be a non-negative safe integer');
43
- }
44
- if (!Number.isSafeInteger(maxPolls) || maxPolls <= 0) {
45
- throw new TypeError('maxPolls must be a positive safe integer');
46
- }
47
- const requestHeaders = headers(token);
48
- const repositoryUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}`;
49
- const query = new URLSearchParams({ event: 'push', head_sha: sha, per_page: '10' });
50
- let run = null;
51
- for (let poll = 0; poll < maxPolls; poll += 1) {
52
- const response = await fetchImpl(`${repositoryUrl}/actions/workflows/publish.yml/runs?${query}`, {
53
- method: 'GET', headers: requestHeaders
54
- });
55
- const payload = await json(response, 'publish workflow runs request');
56
- run = payload.workflow_runs?.find((candidate) => candidate.head_sha === sha) ?? null;
57
- if (run?.status === 'completed') break;
58
- if (poll + 1 < maxPolls) await sleep(pollIntervalMs);
59
- }
60
- if (run == null || run.status !== 'completed') {
61
- throw new Error(`Timed out waiting for the publish workflow for ${sha}`);
62
- }
63
- if (run.conclusion !== 'success') {
64
- throw new Error(`Initial publish workflow failed: ${run.html_url}`);
65
- }
66
- const branch = await fetchImpl(`${repositoryUrl}/branches/gh-pages`, {
67
- method: 'GET', headers: requestHeaders
68
- });
69
- if (!branch.ok) {
70
- throw new Error(`Successful publish run created no gh-pages branch: ${run.html_url}`);
71
- }
72
- const current = await fetchImpl(`${repositoryUrl}/pages`, { method: 'GET', headers: requestHeaders });
73
- if (current.ok) {
74
- const configuration = await current.json();
75
- if (configuration.source?.branch !== 'gh-pages' || configuration.source?.path !== '/') {
76
- throw new Error('Existing GitHub Pages configuration does not use gh-pages at /');
77
- }
78
- if ((configuration.cname ?? null) !== customDomain) {
79
- const updated = await fetchImpl(`${repositoryUrl}/pages`, {
80
- method: 'PUT', headers: requestHeaders,
81
- body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
82
- });
83
- if (updated.status !== 204) {
84
- throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
85
- }
86
- }
87
- return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
88
- }
89
- if (current.status !== 404) {
90
- throw new Error(await describeHttpFailure(current, 'GitHub Pages configuration request'));
91
- }
92
- const created = await fetchImpl(`${repositoryUrl}/pages`, {
93
- method: 'POST', headers: requestHeaders,
94
- body: JSON.stringify({ source: { branch: 'gh-pages', path: '/' } })
95
- });
96
- const configuration = await json(created, 'Pages activation');
97
- if (customDomain != null) {
98
- const updated = await fetchImpl(`${repositoryUrl}/pages`, {
99
- method: 'PUT', headers: requestHeaders,
100
- body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
101
- });
102
- if (updated.status !== 204) {
103
- throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
104
- }
105
- }
106
- return Object.freeze({ created: true, url: configuration.html_url, runUrl: run.html_url });
107
- }
@@ -1,82 +0,0 @@
1
- import sodium from 'libsodium-wrappers';
2
-
3
- const GITHUB_API_VERSION = '2026-03-10';
4
- const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
5
- const SECRET_NAME = /^[A-Z_][A-Z0-9_]*$/;
6
-
7
- function required(value, field, pattern) {
8
- if (typeof value !== 'string' || !pattern.test(value)) {
9
- throw new TypeError(`${field} is invalid`);
10
- }
11
- return value;
12
- }
13
-
14
- function requiredSecret(value, field) {
15
- if (typeof value !== 'string' || value.length === 0) {
16
- throw new TypeError(`${field} must not be empty`);
17
- }
18
- return value;
19
- }
20
-
21
- function headers(accessToken) {
22
- return {
23
- accept: 'application/vnd.github+json',
24
- authorization: `Bearer ${accessToken}`,
25
- 'content-type': 'application/json',
26
- 'x-github-api-version': GITHUB_API_VERSION
27
- };
28
- }
29
-
30
- async function requireSuccess(response, operation) {
31
- if (!response?.ok) {
32
- const status = Number.isInteger(response?.status) ? response.status : 'unknown';
33
- throw new Error(`GitHub ${operation} failed with HTTP ${status}`);
34
- }
35
- }
36
-
37
- export async function installRepositorySecret({
38
- owner,
39
- repository,
40
- accessToken,
41
- secretName,
42
- secretValue,
43
- fetchImpl = fetch,
44
- sodiumImpl = sodium
45
- }) {
46
- const normalizedOwner = required(owner, 'owner', OWNER_OR_REPOSITORY);
47
- const normalizedRepository = required(repository, 'repository', OWNER_OR_REPOSITORY);
48
- const normalizedSecretName = required(secretName, 'secretName', SECRET_NAME);
49
- const token = requiredSecret(accessToken, 'accessToken');
50
- const plaintext = requiredSecret(secretValue, 'secretValue');
51
-
52
- await sodiumImpl.ready;
53
-
54
- const baseUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}/actions/secrets`;
55
- const publicKeyResponse = await fetchImpl(`${baseUrl}/public-key`, {
56
- method: 'GET',
57
- headers: headers(token)
58
- });
59
- await requireSuccess(publicKeyResponse, 'repository public-key request');
60
- const publicKeyPayload = await publicKeyResponse.json();
61
- const keyId = requiredSecret(publicKeyPayload?.key_id, 'GitHub key_id');
62
- const publicKey = requiredSecret(publicKeyPayload?.key, 'GitHub public key');
63
-
64
- const ciphertext = sodiumImpl.crypto_box_seal(
65
- sodiumImpl.from_string(plaintext),
66
- sodiumImpl.from_base64(publicKey, sodiumImpl.base64_variants.ORIGINAL)
67
- );
68
- const encryptedValue = sodiumImpl.to_base64(
69
- ciphertext,
70
- sodiumImpl.base64_variants.ORIGINAL
71
- );
72
-
73
- const uploadResponse = await fetchImpl(
74
- `${baseUrl}/${encodeURIComponent(normalizedSecretName)}`,
75
- {
76
- method: 'PUT',
77
- headers: headers(token),
78
- body: JSON.stringify({ encrypted_value: encryptedValue, key_id: keyId })
79
- }
80
- );
81
- await requireSuccess(uploadResponse, 'repository secret upload');
82
- }
@@ -1,56 +0,0 @@
1
- import { describeHttpFailure } from './http-failure.js';
2
- const GITHUB_API_VERSION = '2026-03-10';
3
- const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
4
- const VARIABLE_NAME = /^[A-Z_][A-Z0-9_]*$/;
5
-
6
- function required(value, field, pattern) {
7
- if (typeof value !== 'string' || !pattern.test(value)) {
8
- throw new TypeError(`${field} is invalid`);
9
- }
10
- return value;
11
- }
12
-
13
- function requiredValue(value, field) {
14
- if (typeof value !== 'string' || value.length === 0) {
15
- throw new TypeError(`${field} must not be empty`);
16
- }
17
- return value;
18
- }
19
-
20
- function headers(accessToken) {
21
- return {
22
- accept: 'application/vnd.github+json',
23
- authorization: `Bearer ${accessToken}`,
24
- 'content-type': 'application/json',
25
- 'x-github-api-version': GITHUB_API_VERSION
26
- };
27
- }
28
-
29
- export async function installRepositoryVariable({
30
- owner, repository, accessToken, variableName, variableValue, fetchImpl = fetch
31
- }) {
32
- const normalizedOwner = required(owner, 'owner', OWNER_OR_REPOSITORY);
33
- const normalizedRepository = required(repository, 'repository', OWNER_OR_REPOSITORY);
34
- const normalizedName = required(variableName, 'variableName', VARIABLE_NAME);
35
- const token = requiredValue(accessToken, 'accessToken');
36
- const value = requiredValue(variableValue, 'variableValue');
37
- const baseUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}/actions/variables`;
38
- const requestHeaders = headers(token);
39
- const update = await fetchImpl(`${baseUrl}/${encodeURIComponent(normalizedName)}`, {
40
- method: 'PATCH',
41
- headers: requestHeaders,
42
- body: JSON.stringify({ name: normalizedName, value })
43
- });
44
- if (update.ok) return;
45
- if (update.status !== 404) {
46
- throw new Error(await describeHttpFailure(update, 'GitHub repository variable update'));
47
- }
48
- const create = await fetchImpl(baseUrl, {
49
- method: 'POST',
50
- headers: requestHeaders,
51
- body: JSON.stringify({ name: normalizedName, value })
52
- });
53
- if (!create.ok) {
54
- throw new Error(await describeHttpFailure(create, 'GitHub repository variable creation'));
55
- }
56
- }
@@ -1,171 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import path from 'node:path';
3
- import { describeHttpFailure } from './http-failure.js';
4
- import { gitCredentialArguments, gitEnvironment } from './git-credentials.js';
5
-
6
- const GITHUB_API_VERSION = '2026-03-10';
7
- const REPOSITORY_IDENTITY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8
-
9
- function requiredString(value, field) {
10
- if (typeof value !== 'string' || value.trim() === '') {
11
- throw new TypeError(`${field} is required`);
12
- }
13
- return value.trim();
14
- }
15
-
16
- function repositorySegment(value, field) {
17
- const segment = requiredString(value, field);
18
- if (!/^[A-Za-z0-9_.-]+$/.test(segment)) {
19
- throw new TypeError(`${field} contains unsupported characters`);
20
- }
21
- return segment;
22
- }
23
-
24
- export async function generateRepositoryFromTemplate({
25
- accessToken,
26
- templateOwner,
27
- templateRepository,
28
- owner,
29
- repository,
30
- description,
31
- fetchImpl = fetch,
32
- sleep,
33
- readinessAttempts,
34
- readinessIntervalMs
35
- }) {
36
- const token = requiredString(accessToken, 'accessToken');
37
- const sourceOwner = repositorySegment(templateOwner, 'templateOwner');
38
- const sourceRepository = repositorySegment(templateRepository, 'templateRepository');
39
- const targetOwner = repositorySegment(owner, 'owner');
40
- const targetRepository = repositorySegment(repository, 'repository');
41
- if (description != null && typeof description !== 'string') {
42
- throw new TypeError('description must be a string');
43
- }
44
-
45
- const response = await fetchImpl(
46
- `https://api.github.com/repos/${encodeURIComponent(sourceOwner)}/${encodeURIComponent(sourceRepository)}/generate`,
47
- {
48
- method: 'POST',
49
- headers: {
50
- accept: 'application/vnd.github+json',
51
- authorization: `Bearer ${token}`,
52
- 'content-type': 'application/json',
53
- 'x-github-api-version': GITHUB_API_VERSION
54
- },
55
- body: JSON.stringify({
56
- owner: targetOwner,
57
- name: targetRepository,
58
- description: description ?? '',
59
- include_all_branches: false,
60
- private: false
61
- })
62
- }
63
- );
64
- if (response.status !== 201) {
65
- throw new Error(await describeHttpFailure(response, 'GitHub template generation'));
66
- }
67
- const payload = await response.json();
68
- if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
69
- throw new TypeError('GitHub repository response must be a JSON object');
70
- }
71
- const fullName = requiredString(payload.full_name, 'full_name');
72
- if (!REPOSITORY_IDENTITY.test(fullName)) {
73
- throw new TypeError('GitHub repository response contains an invalid full_name');
74
- }
75
- if (fullName.toLowerCase() !== `${targetOwner}/${targetRepository}`.toLowerCase()) {
76
- throw new TypeError('GitHub generated an unexpected repository');
77
- }
78
-
79
- const cloneUrl = new URL(requiredString(payload.clone_url, 'clone_url'));
80
- if (
81
- cloneUrl.protocol !== 'https:'
82
- || cloneUrl.hostname !== 'github.com'
83
- || cloneUrl.username !== ''
84
- || cloneUrl.password !== ''
85
- || cloneUrl.search !== ''
86
- || cloneUrl.hash !== ''
87
- || cloneUrl.pathname.toLowerCase() !== `/${fullName}.git`.toLowerCase()
88
- ) {
89
- throw new TypeError('GitHub repository response contains an invalid clone_url');
90
- }
91
-
92
- // Last, so a malformed response fails immediately instead of after the readiness wait.
93
- await awaitRepositoryContent({
94
- accessToken: token, owner: targetOwner, repository: targetRepository, fetchImpl,
95
- ...(sleep == null ? {} : { sleep }),
96
- ...(readinessAttempts == null ? {} : { attempts: readinessAttempts }),
97
- ...(readinessIntervalMs == null ? {} : { intervalMs: readinessIntervalMs })
98
- });
99
-
100
- return Object.freeze({ fullName, cloneUrl: cloneUrl.href });
101
- }
102
-
103
- /**
104
- * Waits for a generated repository to actually contain the template.
105
- *
106
- * Generating from a template is asynchronous: GitHub answers 201 with the repository's full name
107
- * and clone URL, and copies the content in afterwards. Cloning on the 201 produced
108
- * "warning: You appear to have cloned an empty repository", and scaffolding then failed on a
109
- * missing site.config.yml — a confusing error about a file the template certainly contains.
110
- *
111
- * Readiness is the presence of a branch. `size` is not usable: GitHub still reported 0 for a
112
- * repository that already had `main` and commits.
113
- */
114
- export async function awaitRepositoryContent({
115
- accessToken, owner, repository, fetchImpl = fetch,
116
- sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
117
- attempts = 30, intervalMs = 1_000
118
- }) {
119
- const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=1`;
120
- for (let attempt = 0; attempt < attempts; attempt += 1) {
121
- if (attempt > 0) await sleep(intervalMs);
122
- const response = await fetchImpl(url, {
123
- headers: {
124
- accept: 'application/vnd.github+json',
125
- authorization: `Bearer ${accessToken}`,
126
- 'x-github-api-version': GITHUB_API_VERSION
127
- }
128
- });
129
- if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub branch lookup'));
130
- const branches = await response.json();
131
- if (Array.isArray(branches) && branches.length > 0) return;
132
- }
133
- throw new Error(
134
- `GitHub created ${owner}/${repository} from the template but it was still empty after `
135
- + `${Math.round((attempts * intervalMs) / 1000)}s. Re-run scaffold with --resume once it has content.`
136
- );
137
- }
138
-
139
- export function cloneRepository({ cloneUrl, target, spawnProcess = spawn, accessToken }) {
140
- const source = new URL(requiredString(cloneUrl, 'cloneUrl'));
141
- if (
142
- source.protocol !== 'https:'
143
- || source.hostname !== 'github.com'
144
- || source.username !== ''
145
- || source.password !== ''
146
- || source.search !== ''
147
- || source.hash !== ''
148
- ) {
149
- throw new TypeError('cloneUrl must be an uncredentialed GitHub HTTPS URL');
150
- }
151
- const resolvedTarget = path.resolve(requiredString(target, 'target'));
152
-
153
- return new Promise((resolve, reject) => {
154
- const child = spawnProcess(
155
- 'git',
156
- [...gitCredentialArguments(accessToken), 'clone', source.href, resolvedTarget],
157
- {
158
- cwd: path.dirname(resolvedTarget),
159
- shell: false,
160
- stdio: 'inherit',
161
- env: gitEnvironment(accessToken)
162
- }
163
- );
164
- child.once('error', reject);
165
- child.once('exit', (code, signal) => {
166
- if (signal) reject(new Error(`Git clone terminated by signal ${signal}`));
167
- else if (code !== 0) reject(new Error(`Git clone exited with code ${code}`));
168
- else resolve(resolvedTarget);
169
- });
170
- });
171
- }
@@ -1,64 +0,0 @@
1
- import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- const MARKER = '// Managed by @rathnasgala/cli. Do not edit.\n';
5
- const HOOK = `#!/usr/bin/env node
6
- ${MARKER}const { spawnSync } = require('node:child_process');
7
- const path = require('node:path');
8
-
9
- const root = process.cwd();
10
- const executable = path.join(
11
- root,
12
- 'node_modules',
13
- '.bin',
14
- process.platform === 'win32' ? 'gala.cmd' : 'gala'
15
- );
16
- const result = spawnSync(executable, ['validate', '--root', root], {
17
- cwd: root,
18
- shell: false,
19
- stdio: 'inherit'
20
- });
21
- if (result.error) {
22
- console.error('Gala validation hook failed to start:', result.error.message);
23
- process.exit(1);
24
- }
25
- process.exit(result.status ?? 1);
26
- `;
27
-
28
- async function metadata(file, allowMissing = false) {
29
- try {
30
- return await lstat(file);
31
- } catch (error) {
32
- if (allowMissing && error.code === 'ENOENT') return null;
33
- throw error;
34
- }
35
- }
36
-
37
- export async function installPrePushHook(root) {
38
- const resolvedRoot = path.resolve(root);
39
- const gitDirectory = path.join(resolvedRoot, '.git');
40
- const gitMetadata = await metadata(gitDirectory);
41
- if (!gitMetadata.isDirectory() || gitMetadata.isSymbolicLink()) {
42
- throw new TypeError('.git must be a real directory');
43
- }
44
-
45
- const hooksDirectory = path.join(gitDirectory, 'hooks');
46
- const hooksMetadata = await metadata(hooksDirectory, true);
47
- if (hooksMetadata?.isSymbolicLink() || (hooksMetadata && !hooksMetadata.isDirectory())) {
48
- throw new TypeError('.git/hooks must be a real directory');
49
- }
50
- if (!hooksMetadata) await mkdir(hooksDirectory);
51
-
52
- const target = path.join(hooksDirectory, 'pre-push');
53
- const existing = await metadata(target, true);
54
- if (existing) {
55
- if (!existing.isFile() || existing.isSymbolicLink()) {
56
- throw new TypeError('Existing pre-push hook must be a regular file');
57
- }
58
- if (await readFile(target, 'utf8') === HOOK) return { target, installed: false };
59
- throw new Error('Refusing to overwrite an existing pre-push hook');
60
- }
61
-
62
- await writeFile(target, HOOK, { encoding: 'utf8', flag: 'wx', mode: 0o755 });
63
- return { target, installed: true };
64
- }
@@ -1,55 +0,0 @@
1
- /**
2
- * What the server actually said, instead of only what it scored.
3
- *
4
- * Every failure in this CLI reported `failed with HTTP 403` and discarded the response body. GitHub
5
- * puts the reason there and nowhere else — an organisation's OAuth App restrictions, a missing
6
- * scope, a rename, a rate limit all arrive as 403 with a sentence explaining which — so the one
7
- * fact worth having was the one being thrown away. Diagnosing anything meant guessing between
8
- * causes the server had already distinguished.
9
- *
10
- * Only the response is read. Request bodies, tokens and secrets never pass through here.
11
- */
12
- const MAX_DETAIL = 400;
13
-
14
- export async function describeHttpFailure(response, action) {
15
- const status = response?.status ?? 0;
16
- let detail = '';
17
- try {
18
- // Cloned so a caller that also reads the body still can; a response whose body is already
19
- // consumed simply yields no detail rather than a second failure on top of the first.
20
- const text = await (typeof response.clone === 'function' ? response.clone() : response).text();
21
- detail = extract(text);
22
- } catch {
23
- detail = '';
24
- }
25
- return `${action} failed with HTTP ${status}${detail === '' ? '' : `: ${detail}`}`;
26
- }
27
-
28
- function extract(text) {
29
- if (typeof text !== 'string' || text.trim() === '') return '';
30
- let payload;
31
- try {
32
- payload = JSON.parse(text);
33
- } catch {
34
- return truncate(text);
35
- }
36
- if (payload == null || typeof payload !== 'object') return truncate(text);
37
- const parts = [];
38
- if (typeof payload.message === 'string' && payload.message.trim() !== '') parts.push(payload.message.trim());
39
- // GitHub's `errors` array carries the specific field or reason behind a generic message.
40
- if (Array.isArray(payload.errors)) {
41
- for (const error of payload.errors) {
42
- const reason = typeof error === 'string' ? error : error?.message ?? error?.code;
43
- if (typeof reason === 'string' && reason.trim() !== '') parts.push(reason.trim());
44
- }
45
- }
46
- if (typeof payload.error_description === 'string') parts.push(payload.error_description.trim());
47
- if (typeof payload.code === 'string' && parts.length === 0) parts.push(payload.code);
48
- if (typeof payload.documentation_url === 'string') parts.push(`See ${payload.documentation_url}`);
49
- return truncate(parts.length === 0 ? text : parts.join(' — '));
50
- }
51
-
52
- function truncate(value) {
53
- const flattened = value.replace(/\s+/g, ' ').trim();
54
- return flattened.length > MAX_DETAIL ? `${flattened.slice(0, MAX_DETAIL)}…` : flattened;
55
- }
@@ -1,54 +0,0 @@
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
- slugifyTitle
9
- } from '@rathnasgala/content-validation';
10
- import { stringify } from 'yaml';
11
- import { repositoryEvaluationDate } from './evaluation-date.js';
12
-
13
- export async function createPost({ root, title, language, today, now = Date.now }) {
14
- const siteRoot = path.resolve(root);
15
- const creationTimestamp = now();
16
- const publishAfterDate = today ?? await repositoryEvaluationDate({
17
- root: siteRoot,
18
- now: () => creationTimestamp
19
- });
20
- const metadata = createPostMetadata({
21
- title,
22
- language,
23
- today: publishAfterDate,
24
- timestamp: creationTimestamp
25
- });
26
- const postDirectory = path.join(siteRoot, 'content', 'posts', slugifyTitle(title));
27
- const mediaDirectory = path.join(postDirectory, 'media');
28
- const postPath = path.join(postDirectory, `index.${metadata.language}.md`);
29
-
30
- await mkdir(postDirectory, { recursive: true });
31
- const variants = (await readdir(postDirectory, { withFileTypes: true }))
32
- .filter((entry) => entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name));
33
- const existingIds = new Set();
34
- for (const variant of variants) {
35
- const variantPath = path.join(postDirectory, variant.name);
36
- const parsed = parseFrontmatter(await readFile(variantPath, 'utf8'));
37
- if (parsed.errors.length > 0) {
38
- throw new Error(`Existing variant has invalid frontmatter: ${variantPath}`);
39
- }
40
- if (!isContentId(parsed.data.id)) {
41
- throw new Error(`Existing variant is missing a valid article id: ${variantPath}`);
42
- }
43
- existingIds.add(parsed.data.id);
44
- }
45
- if (existingIds.size > 1) {
46
- throw new Error(`Existing variants have conflicting article ids: ${postDirectory}`);
47
- }
48
- if (existingIds.size === 1) metadata.id = existingIds.values().next().value;
49
-
50
- await mkdir(mediaDirectory, { recursive: true });
51
- const source = `---\n${stringify(metadata).trimEnd()}\n---\n\n# ${title}\n`;
52
- await writeFile(postPath, source, { encoding: 'utf8', flag: 'wx' });
53
- return { metadata, postPath, mediaDirectory };
54
- }
@@ -1,40 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
-
3
- /**
4
- * Opens a URL in the writer's browser, best effort.
5
- *
6
- * Printing "Open https://…" and a code asks someone to copy a URL out of a terminal by hand, three
7
- * times over in one scaffold. The URL is still always printed — this only saves the copying, and it
8
- * has to keep working when it cannot: over SSH, in a container, in CI, on a machine with no browser
9
- * at all. So nothing here is allowed to fail the command.
10
- *
11
- * Deliberately not attempted when there is no terminal. A CI job that silently spawns a browser
12
- * process is a hang waiting to happen, and there is nobody there to look at it.
13
- */
14
- export function openInBrowser(url, {
15
- platform = process.platform,
16
- environment = process.env,
17
- interactive = process.stdin.isTTY === true,
18
- spawnProcess = spawn
19
- } = {}) {
20
- if (!interactive) return false;
21
- // Respected by convention across CLI tooling, and the escape hatch for anyone who does not want
22
- // their browser taken over.
23
- if (environment.GALA_NO_BROWSER || environment.CI || environment.NO_BROWSER) return false;
24
- if (!/^https:\/\//.test(url)) return false;
25
-
26
- const [command, args] = platform === 'darwin' ? ['open', [url]]
27
- : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
28
- : ['xdg-open', [url]];
29
-
30
- try {
31
- const child = spawnProcess(command, args, { stdio: 'ignore', detached: true, shell: false });
32
- // Without this the CLI waits for the browser to exit before it can finish.
33
- child.unref?.();
34
- // A missing opener is an ordinary outcome on a headless box, not something to report.
35
- child.on?.('error', () => {});
36
- return true;
37
- } catch {
38
- return false;
39
- }
40
- }