@rathnasgala/cli 0.0.1

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.
@@ -0,0 +1,65 @@
1
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export function githubCredentialPath({ platform = process.platform, environment = process.env, home = os.homedir() } = {}) {
6
+ if (platform === 'win32') {
7
+ if (!environment.APPDATA) throw new Error('APPDATA is required to store GitHub credentials on Windows');
8
+ return path.join(environment.APPDATA, 'Gala', 'github-credentials.json');
9
+ }
10
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Gala', 'github-credentials.json');
11
+ return path.join(environment.XDG_CONFIG_HOME || path.join(home, '.config'), 'gala', 'github-credentials.json');
12
+ }
13
+
14
+ async function regularOrMissing(target) {
15
+ try {
16
+ const metadata = await lstat(target);
17
+ if (metadata.isSymbolicLink() || !metadata.isFile()) throw new TypeError('GitHub credential must be a regular file');
18
+ return true;
19
+ } catch (error) {
20
+ if (error.code === 'ENOENT') return false;
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ export async function writeGithubCredential({ accessToken, scopes, target = githubCredentialPath() }) {
26
+ if (typeof accessToken !== 'string' || accessToken === '') throw new TypeError('accessToken is required');
27
+ if (!Array.isArray(scopes) || !scopes.includes('repo') || !scopes.includes('workflow')) {
28
+ throw new TypeError('GitHub credential requires repo and workflow scopes');
29
+ }
30
+ const directory = path.dirname(path.resolve(target));
31
+ await mkdir(directory, { recursive: true, mode: 0o700 });
32
+ const directoryMetadata = await lstat(directory);
33
+ if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
34
+ throw new TypeError('GitHub credential directory must be a real directory');
35
+ }
36
+ await chmod(directory, 0o700);
37
+ const exists = await regularOrMissing(target);
38
+ const temporary = `${target}.gala-${process.pid}`;
39
+ const backup = `${target}.gala-backup-${process.pid}`;
40
+ try {
41
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, accessToken, scopes })}\n`, {
42
+ flag: 'wx', mode: 0o600
43
+ });
44
+ await chmod(temporary, 0o600);
45
+ if (exists) await rename(target, backup);
46
+ try { await rename(temporary, target); }
47
+ catch (error) { if (exists) await rename(backup, target); throw error; }
48
+ await chmod(target, 0o600);
49
+ if (exists) await rm(backup);
50
+ return path.resolve(target);
51
+ } catch (error) {
52
+ await rm(temporary, { force: true });
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ export async function readGithubCredential({ target = githubCredentialPath() } = {}) {
58
+ if (!await regularOrMissing(target)) throw new Error('GitHub authentication is missing; run `gala auth github`');
59
+ const payload = JSON.parse(await readFile(target, 'utf8'));
60
+ if (payload?.schemaVersion !== 1 || typeof payload.accessToken !== 'string'
61
+ || !Array.isArray(payload.scopes) || !payload.scopes.includes('repo') || !payload.scopes.includes('workflow')) {
62
+ throw new TypeError('GitHub credential file has an unsupported schema or missing scopes');
63
+ }
64
+ return Object.freeze({ accessToken: payload.accessToken, scopes: [...payload.scopes] });
65
+ }
@@ -0,0 +1,130 @@
1
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
2
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
3
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
4
+
5
+ function requiredString(value, field) {
6
+ if (typeof value !== 'string' || value.trim() === '') {
7
+ throw new TypeError(`${field} is required`);
8
+ }
9
+ return value.trim();
10
+ }
11
+
12
+ function positiveInteger(value, field) {
13
+ if (!Number.isSafeInteger(value) || value <= 0) {
14
+ throw new TypeError(`${field} must be a positive integer`);
15
+ }
16
+ return value;
17
+ }
18
+
19
+ async function postForm(fetchImpl, url, fields) {
20
+ const response = await fetchImpl(url, {
21
+ method: 'POST',
22
+ headers: {
23
+ accept: 'application/json',
24
+ 'content-type': 'application/x-www-form-urlencoded'
25
+ },
26
+ body: new URLSearchParams(fields)
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`GitHub OAuth request failed with HTTP ${response.status}`);
30
+ }
31
+ const payload = await response.json();
32
+ if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
33
+ throw new TypeError('GitHub OAuth response must be a JSON object');
34
+ }
35
+ return payload;
36
+ }
37
+
38
+ export async function requestDeviceCode({ clientId, scopes, fetchImpl = fetch }) {
39
+ const normalizedClientId = requiredString(clientId, 'clientId');
40
+ if (!Array.isArray(scopes) || scopes.length === 0) {
41
+ throw new TypeError('scopes must be a non-empty list');
42
+ }
43
+ const normalizedScopes = scopes.map((scope) => requiredString(scope, 'scope'));
44
+ const payload = await postForm(fetchImpl, DEVICE_CODE_URL, {
45
+ client_id: normalizedClientId,
46
+ scope: normalizedScopes.join(' ')
47
+ });
48
+
49
+ return Object.freeze({
50
+ deviceCode: requiredString(payload.device_code, 'device_code'),
51
+ userCode: requiredString(payload.user_code, 'user_code'),
52
+ verificationUri: requiredString(payload.verification_uri, 'verification_uri'),
53
+ expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in'),
54
+ intervalSeconds: positiveInteger(payload.interval, 'interval')
55
+ });
56
+ }
57
+
58
+ export async function pollForAccessToken({
59
+ clientId,
60
+ deviceCode,
61
+ expiresInSeconds,
62
+ intervalSeconds,
63
+ requiredScopes = [],
64
+ fetchImpl = fetch,
65
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
66
+ now = Date.now
67
+ }) {
68
+ const normalizedClientId = requiredString(clientId, 'clientId');
69
+ const normalizedDeviceCode = requiredString(deviceCode, 'deviceCode');
70
+ if (!Array.isArray(requiredScopes)) throw new TypeError('requiredScopes must be a list');
71
+ const normalizedRequiredScopes = requiredScopes.map((scope) =>
72
+ requiredString(scope, 'scope').toLowerCase()
73
+ );
74
+ const lifetime = positiveInteger(expiresInSeconds, 'expiresInSeconds') * 1000;
75
+ let interval = positiveInteger(intervalSeconds, 'intervalSeconds');
76
+ const startedAt = now();
77
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
78
+ throw new TypeError('Clock must return epoch milliseconds');
79
+ }
80
+
81
+ while (true) {
82
+ await sleep(interval * 1000);
83
+ const currentTime = now();
84
+ if (typeof currentTime !== 'number' || !Number.isFinite(currentTime)) {
85
+ throw new TypeError('Clock must return epoch milliseconds');
86
+ }
87
+ if (currentTime - startedAt >= lifetime) {
88
+ throw new Error('GitHub device code expired before authorization completed');
89
+ }
90
+
91
+ const payload = await postForm(fetchImpl, ACCESS_TOKEN_URL, {
92
+ client_id: normalizedClientId,
93
+ device_code: normalizedDeviceCode,
94
+ grant_type: DEVICE_GRANT
95
+ });
96
+ if (typeof payload.access_token === 'string' && payload.access_token !== '') {
97
+ const tokenType = requiredString(payload.token_type, 'token_type');
98
+ if (tokenType.toLowerCase() !== 'bearer') {
99
+ throw new TypeError('GitHub OAuth token_type must be bearer');
100
+ }
101
+ const grantedScopes = typeof payload.scope === 'string'
102
+ ? payload.scope.split(/[,\s]+/).map((scope) => scope.trim().toLowerCase()).filter(Boolean)
103
+ : [];
104
+ const missingScopes = normalizedRequiredScopes.filter((scope) => !grantedScopes.includes(scope));
105
+ if (missingScopes.length > 0) {
106
+ throw new Error(`GitHub authorization omitted required scope(s): ${missingScopes.join(', ')}`);
107
+ }
108
+ return Object.freeze({
109
+ accessToken: requiredString(payload.access_token, 'access_token'),
110
+ tokenType: 'bearer',
111
+ scopes: grantedScopes
112
+ });
113
+ }
114
+
115
+ if (payload.error === 'authorization_pending') continue;
116
+ if (payload.error === 'slow_down') {
117
+ interval = Number.isSafeInteger(payload.interval) && payload.interval > interval
118
+ ? payload.interval
119
+ : interval + 5;
120
+ continue;
121
+ }
122
+ if (payload.error === 'expired_token' || payload.error === 'token_expired') {
123
+ throw new Error('GitHub device code expired before authorization completed');
124
+ }
125
+ if (typeof payload.error === 'string' && payload.error !== '') {
126
+ throw new Error(`GitHub device authorization failed: ${payload.error}`);
127
+ }
128
+ throw new TypeError('GitHub OAuth response contained neither a token nor an error');
129
+ }
130
+ }
@@ -0,0 +1,63 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const API_VERSION = '2026-03-10';
4
+
5
+ export async function verifyEmptyRepository({ owner, repository, accessToken, fetchImpl = fetch }) {
6
+ const repositoryUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`;
7
+ const headers = {
8
+ accept: 'application/vnd.github+json', authorization: `Bearer ${accessToken}`,
9
+ 'x-github-api-version': API_VERSION
10
+ };
11
+ const response = await fetchImpl(repositoryUrl, {
12
+ headers
13
+ });
14
+ if (!response.ok) throw new Error(`GitHub repository lookup failed with HTTP ${response.status}`);
15
+ const payload = await response.json();
16
+ if (payload.full_name?.toLowerCase() !== `${owner}/${repository}`.toLowerCase()) {
17
+ throw new TypeError('GitHub returned an unexpected repository');
18
+ }
19
+ const branchesResponse = await fetchImpl(`${repositoryUrl}/branches?per_page=1`, {
20
+ headers: {
21
+ ...headers
22
+ }
23
+ });
24
+ if (!branchesResponse.ok) throw new Error(`GitHub branch lookup failed with HTTP ${branchesResponse.status}`);
25
+ const branches = await branchesResponse.json();
26
+ if (payload.size !== 0 || !Array.isArray(branches) || branches.length !== 0) {
27
+ throw new Error('Existing repository is not empty; explicit non-empty adoption is not implemented');
28
+ }
29
+ }
30
+
31
+ export function setRepositoryOrigin({ root, owner, repository, spawnProcess = spawn }) {
32
+ const target = `https://github.com/${owner}/${repository}.git`;
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawnProcess('git', ['-C', root, 'remote', 'set-url', 'origin', target], {
35
+ cwd: root, shell: false, stdio: 'inherit'
36
+ });
37
+ child.once('error', reject);
38
+ child.once('exit', (code, signal) => {
39
+ if (signal) reject(new Error(`Git remote update terminated by signal ${signal}`));
40
+ else if (code !== 0) reject(new Error(`Git remote update exited with code ${code}`));
41
+ else resolve();
42
+ });
43
+ });
44
+ }
45
+
46
+ export function verifyRepositoryOrigin({ root, owner, repository, spawnProcess = spawn }) {
47
+ const expected = `https://github.com/${owner}/${repository}.git`;
48
+ return new Promise((resolve, reject) => {
49
+ const child = spawnProcess('git', ['-C', root, 'remote', 'get-url', 'origin'], {
50
+ cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
51
+ });
52
+ let output = '';
53
+ child.stdout?.setEncoding('utf8');
54
+ child.stdout?.on('data', (chunk) => { output += chunk; });
55
+ child.once('error', reject);
56
+ child.once('exit', (code, signal) => {
57
+ if (signal) reject(new Error(`Git origin verification terminated by signal ${signal}`));
58
+ else if (code !== 0) reject(new Error(`Git origin verification exited with code ${code}`));
59
+ else if (output.trim() !== expected) reject(new Error(`Existing checkout origin must be ${expected}`));
60
+ else resolve(root);
61
+ });
62
+ });
63
+ }
@@ -0,0 +1,83 @@
1
+ const GITHUB_API_VERSION = '2026-03-10';
2
+ const SEGMENT = /^[A-Za-z0-9_.-]+$/;
3
+ const SHA = /^[0-9a-f]{40}$/;
4
+
5
+ function required(value, field, pattern = null) {
6
+ if (typeof value !== 'string' || value.length === 0 || (pattern != null && !pattern.test(value))) {
7
+ throw new TypeError(`${field} is invalid`);
8
+ }
9
+ return value;
10
+ }
11
+
12
+ function headers(accessToken) {
13
+ return {
14
+ accept: 'application/vnd.github+json',
15
+ authorization: `Bearer ${accessToken}`,
16
+ 'content-type': 'application/json',
17
+ 'x-github-api-version': GITHUB_API_VERSION
18
+ };
19
+ }
20
+
21
+ async function json(response, operation) {
22
+ if (!response.ok) throw new Error(`GitHub ${operation} failed with HTTP ${response.status}`);
23
+ return response.json();
24
+ }
25
+
26
+ export async function provisionGithubPages({
27
+ owner, repository, accessToken, commitSha, fetchImpl = fetch,
28
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
29
+ pollIntervalMs = 5_000, maxPolls = 120
30
+ }) {
31
+ const normalizedOwner = required(owner, 'owner', SEGMENT);
32
+ const normalizedRepository = required(repository, 'repository', SEGMENT);
33
+ const token = required(accessToken, 'accessToken');
34
+ const sha = required(commitSha, 'commitSha', SHA);
35
+ if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 0) {
36
+ throw new TypeError('pollIntervalMs must be a non-negative safe integer');
37
+ }
38
+ if (!Number.isSafeInteger(maxPolls) || maxPolls <= 0) {
39
+ throw new TypeError('maxPolls must be a positive safe integer');
40
+ }
41
+ const requestHeaders = headers(token);
42
+ const repositoryUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}`;
43
+ const query = new URLSearchParams({ event: 'push', head_sha: sha, per_page: '10' });
44
+ let run = null;
45
+ for (let poll = 0; poll < maxPolls; poll += 1) {
46
+ const response = await fetchImpl(`${repositoryUrl}/actions/workflows/publish.yml/runs?${query}`, {
47
+ method: 'GET', headers: requestHeaders
48
+ });
49
+ const payload = await json(response, 'publish workflow runs request');
50
+ run = payload.workflow_runs?.find((candidate) => candidate.head_sha === sha) ?? null;
51
+ if (run?.status === 'completed') break;
52
+ if (poll + 1 < maxPolls) await sleep(pollIntervalMs);
53
+ }
54
+ if (run == null || run.status !== 'completed') {
55
+ throw new Error(`Timed out waiting for the publish workflow for ${sha}`);
56
+ }
57
+ if (run.conclusion !== 'success') {
58
+ throw new Error(`Initial publish workflow failed: ${run.html_url}`);
59
+ }
60
+ const branch = await fetchImpl(`${repositoryUrl}/branches/gh-pages`, {
61
+ method: 'GET', headers: requestHeaders
62
+ });
63
+ if (!branch.ok) {
64
+ throw new Error(`Successful publish run created no gh-pages branch: ${run.html_url}`);
65
+ }
66
+ const current = await fetchImpl(`${repositoryUrl}/pages`, { method: 'GET', headers: requestHeaders });
67
+ if (current.ok) {
68
+ const configuration = await current.json();
69
+ if (configuration.source?.branch !== 'gh-pages' || configuration.source?.path !== '/') {
70
+ throw new Error('Existing GitHub Pages configuration does not use gh-pages at /');
71
+ }
72
+ return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
73
+ }
74
+ if (current.status !== 404) {
75
+ throw new Error(`GitHub Pages configuration request failed with HTTP ${current.status}`);
76
+ }
77
+ const created = await fetchImpl(`${repositoryUrl}/pages`, {
78
+ method: 'POST', headers: requestHeaders,
79
+ body: JSON.stringify({ source: { branch: 'gh-pages', path: '/' } })
80
+ });
81
+ const configuration = await json(created, 'Pages activation');
82
+ return Object.freeze({ created: true, url: configuration.html_url, runUrl: run.html_url });
83
+ }
@@ -0,0 +1,82 @@
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
+ }
@@ -0,0 +1,55 @@
1
+ const GITHUB_API_VERSION = '2026-03-10';
2
+ const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
3
+ const VARIABLE_NAME = /^[A-Z_][A-Z0-9_]*$/;
4
+
5
+ function required(value, field, pattern) {
6
+ if (typeof value !== 'string' || !pattern.test(value)) {
7
+ throw new TypeError(`${field} is invalid`);
8
+ }
9
+ return value;
10
+ }
11
+
12
+ function requiredValue(value, field) {
13
+ if (typeof value !== 'string' || value.length === 0) {
14
+ throw new TypeError(`${field} must not be empty`);
15
+ }
16
+ return value;
17
+ }
18
+
19
+ function headers(accessToken) {
20
+ return {
21
+ accept: 'application/vnd.github+json',
22
+ authorization: `Bearer ${accessToken}`,
23
+ 'content-type': 'application/json',
24
+ 'x-github-api-version': GITHUB_API_VERSION
25
+ };
26
+ }
27
+
28
+ export async function installRepositoryVariable({
29
+ owner, repository, accessToken, variableName, variableValue, fetchImpl = fetch
30
+ }) {
31
+ const normalizedOwner = required(owner, 'owner', OWNER_OR_REPOSITORY);
32
+ const normalizedRepository = required(repository, 'repository', OWNER_OR_REPOSITORY);
33
+ const normalizedName = required(variableName, 'variableName', VARIABLE_NAME);
34
+ const token = requiredValue(accessToken, 'accessToken');
35
+ const value = requiredValue(variableValue, 'variableValue');
36
+ const baseUrl = `https://api.github.com/repos/${encodeURIComponent(normalizedOwner)}/${encodeURIComponent(normalizedRepository)}/actions/variables`;
37
+ const requestHeaders = headers(token);
38
+ const update = await fetchImpl(`${baseUrl}/${encodeURIComponent(normalizedName)}`, {
39
+ method: 'PATCH',
40
+ headers: requestHeaders,
41
+ body: JSON.stringify({ name: normalizedName, value })
42
+ });
43
+ if (update.ok) return;
44
+ if (update.status !== 404) {
45
+ throw new Error(`GitHub repository variable update failed with HTTP ${update.status}`);
46
+ }
47
+ const create = await fetchImpl(baseUrl, {
48
+ method: 'POST',
49
+ headers: requestHeaders,
50
+ body: JSON.stringify({ name: normalizedName, value })
51
+ });
52
+ if (!create.ok) {
53
+ throw new Error(`GitHub repository variable creation failed with HTTP ${create.status}`);
54
+ }
55
+ }
@@ -0,0 +1,117 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ const GITHUB_API_VERSION = '2026-03-10';
5
+ const REPOSITORY_IDENTITY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
6
+
7
+ function requiredString(value, field) {
8
+ if (typeof value !== 'string' || value.trim() === '') {
9
+ throw new TypeError(`${field} is required`);
10
+ }
11
+ return value.trim();
12
+ }
13
+
14
+ function repositorySegment(value, field) {
15
+ const segment = requiredString(value, field);
16
+ if (!/^[A-Za-z0-9_.-]+$/.test(segment)) {
17
+ throw new TypeError(`${field} contains unsupported characters`);
18
+ }
19
+ return segment;
20
+ }
21
+
22
+ export async function generateRepositoryFromTemplate({
23
+ accessToken,
24
+ templateOwner,
25
+ templateRepository,
26
+ owner,
27
+ repository,
28
+ description,
29
+ fetchImpl = fetch
30
+ }) {
31
+ const token = requiredString(accessToken, 'accessToken');
32
+ const sourceOwner = repositorySegment(templateOwner, 'templateOwner');
33
+ const sourceRepository = repositorySegment(templateRepository, 'templateRepository');
34
+ const targetOwner = repositorySegment(owner, 'owner');
35
+ const targetRepository = repositorySegment(repository, 'repository');
36
+ if (description != null && typeof description !== 'string') {
37
+ throw new TypeError('description must be a string');
38
+ }
39
+
40
+ const response = await fetchImpl(
41
+ `https://api.github.com/repos/${encodeURIComponent(sourceOwner)}/${encodeURIComponent(sourceRepository)}/generate`,
42
+ {
43
+ method: 'POST',
44
+ headers: {
45
+ accept: 'application/vnd.github+json',
46
+ authorization: `Bearer ${token}`,
47
+ 'content-type': 'application/json',
48
+ 'x-github-api-version': GITHUB_API_VERSION
49
+ },
50
+ body: JSON.stringify({
51
+ owner: targetOwner,
52
+ name: targetRepository,
53
+ description: description ?? '',
54
+ include_all_branches: false,
55
+ private: false
56
+ })
57
+ }
58
+ );
59
+ if (response.status !== 201) {
60
+ throw new Error(`GitHub template generation failed with HTTP ${response.status}`);
61
+ }
62
+ const payload = await response.json();
63
+ if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
64
+ throw new TypeError('GitHub repository response must be a JSON object');
65
+ }
66
+ const fullName = requiredString(payload.full_name, 'full_name');
67
+ if (!REPOSITORY_IDENTITY.test(fullName)) {
68
+ throw new TypeError('GitHub repository response contains an invalid full_name');
69
+ }
70
+ if (fullName.toLowerCase() !== `${targetOwner}/${targetRepository}`.toLowerCase()) {
71
+ throw new TypeError('GitHub generated an unexpected repository');
72
+ }
73
+
74
+ const cloneUrl = new URL(requiredString(payload.clone_url, 'clone_url'));
75
+ if (
76
+ cloneUrl.protocol !== 'https:'
77
+ || cloneUrl.hostname !== 'github.com'
78
+ || cloneUrl.username !== ''
79
+ || cloneUrl.password !== ''
80
+ || cloneUrl.search !== ''
81
+ || cloneUrl.hash !== ''
82
+ || cloneUrl.pathname.toLowerCase() !== `/${fullName}.git`.toLowerCase()
83
+ ) {
84
+ throw new TypeError('GitHub repository response contains an invalid clone_url');
85
+ }
86
+
87
+ return Object.freeze({ fullName, cloneUrl: cloneUrl.href });
88
+ }
89
+
90
+ export function cloneRepository({ cloneUrl, target, spawnProcess = spawn }) {
91
+ const source = new URL(requiredString(cloneUrl, 'cloneUrl'));
92
+ if (
93
+ source.protocol !== 'https:'
94
+ || source.hostname !== 'github.com'
95
+ || source.username !== ''
96
+ || source.password !== ''
97
+ || source.search !== ''
98
+ || source.hash !== ''
99
+ ) {
100
+ throw new TypeError('cloneUrl must be an uncredentialed GitHub HTTPS URL');
101
+ }
102
+ const resolvedTarget = path.resolve(requiredString(target, 'target'));
103
+
104
+ return new Promise((resolve, reject) => {
105
+ const child = spawnProcess('git', ['clone', source.href, resolvedTarget], {
106
+ cwd: path.dirname(resolvedTarget),
107
+ shell: false,
108
+ stdio: 'inherit'
109
+ });
110
+ child.once('error', reject);
111
+ child.once('exit', (code, signal) => {
112
+ if (signal) reject(new Error(`Git clone terminated by signal ${signal}`));
113
+ else if (code !== 0) reject(new Error(`Git clone exited with code ${code}`));
114
+ else resolve(resolvedTarget);
115
+ });
116
+ });
117
+ }
@@ -0,0 +1,64 @@
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
+ }