@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.
- package/package.json +32 -0
- package/src/assign-content-ids.js +1 -0
- package/src/auth-command.js +36 -0
- package/src/configure-site.js +77 -0
- package/src/content-files.js +1 -0
- package/src/doctor-command.js +214 -0
- package/src/evaluation-date.js +1 -0
- package/src/gala-credential-store.js +104 -0
- package/src/gala-device-flow.js +121 -0
- package/src/github-auth-command.js +29 -0
- package/src/github-credential-store.js +65 -0
- package/src/github-device-flow.js +130 -0
- package/src/github-empty-repository.js +63 -0
- package/src/github-pages-provisioning.js +83 -0
- package/src/github-repository-secret.js +82 -0
- package/src/github-repository-variable.js +55 -0
- package/src/github-template-repository.js +117 -0
- package/src/hook-command.js +64 -0
- package/src/index.js +224 -0
- package/src/new-command.js +54 -0
- package/src/preview-command.js +60 -0
- package/src/publication-state.js +7 -0
- package/src/publish-command.js +37 -0
- package/src/record-deployment-command.js +147 -0
- package/src/repository-limits.js +94 -0
- package/src/scaffold-git.js +41 -0
- package/src/scaffold-options.js +58 -0
- package/src/scaffold-site.js +110 -0
- package/src/site-config-registration.js +37 -0
- package/src/site-registration-client.js +87 -0
- package/src/theme-package.js +128 -0
- package/src/upgrade-command.js +73 -0
- package/src/validate-command.js +5 -0
- package/src/workflow-command.js +85 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rathnasgala/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"provenance": true
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"gala": "src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"lint": "node scripts/lint.js"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/rathnasgala/cli.git"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@rathnasgala/content-validation": "0.0.1",
|
|
28
|
+
"libsodium-wrappers": "0.8.4",
|
|
29
|
+
"tar": "7.5.22",
|
|
30
|
+
"yaml": "2.9.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { assignMissingContentIds } from '@rathnasgala/content-validation';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { pollForGalaToken, requestGalaDeviceCode } from './gala-device-flow.js';
|
|
2
|
+
import { writeGalaCredential } from './gala-credential-store.js';
|
|
3
|
+
|
|
4
|
+
export async function authenticateGala({
|
|
5
|
+
apiBaseUrl = 'https://api.gala67.com',
|
|
6
|
+
clientId = 'gala-cli',
|
|
7
|
+
fetchImpl = fetch,
|
|
8
|
+
sleep,
|
|
9
|
+
now = Date.now,
|
|
10
|
+
showInstructions,
|
|
11
|
+
credentialTarget
|
|
12
|
+
} = {}) {
|
|
13
|
+
if (typeof showInstructions !== 'function') throw new TypeError('showInstructions is required');
|
|
14
|
+
const authorization = await requestGalaDeviceCode({ apiBaseUrl, clientId, fetchImpl });
|
|
15
|
+
showInstructions({
|
|
16
|
+
verificationUri: authorization.verificationUri,
|
|
17
|
+
verificationUriComplete: authorization.verificationUriComplete,
|
|
18
|
+
userCode: authorization.userCode
|
|
19
|
+
});
|
|
20
|
+
const token = await pollForGalaToken({
|
|
21
|
+
...authorization,
|
|
22
|
+
apiBaseUrl,
|
|
23
|
+
clientId,
|
|
24
|
+
fetchImpl,
|
|
25
|
+
...(sleep == null ? {} : { sleep }),
|
|
26
|
+
now
|
|
27
|
+
});
|
|
28
|
+
const expiresAt = new Date(now() + token.expiresInSeconds * 1000);
|
|
29
|
+
const target = await writeGalaCredential({
|
|
30
|
+
accessToken: token.accessToken,
|
|
31
|
+
expiresAt,
|
|
32
|
+
apiBaseUrl,
|
|
33
|
+
...(credentialTarget == null ? {} : { target: credentialTarget })
|
|
34
|
+
});
|
|
35
|
+
return Object.freeze({ target, expiresAt });
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeSiteConfigurationOptions } from '@rathnasgala/content-validation';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
|
+
|
|
6
|
+
import { scaffoldOptionNames } from './scaffold-options.js';
|
|
7
|
+
|
|
8
|
+
function nonEmptyString(value, field) {
|
|
9
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
10
|
+
throw new TypeError(`${field} must be a non-empty string`);
|
|
11
|
+
}
|
|
12
|
+
return value.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function configureSite(root, designOptions) {
|
|
16
|
+
const configPath = path.resolve(root, 'site.config.yml');
|
|
17
|
+
const relation = path.relative(path.resolve(root), configPath);
|
|
18
|
+
if (relation.startsWith('..') || path.isAbsolute(relation)) {
|
|
19
|
+
throw new TypeError('site.config.yml escapes the site root');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const metadata = await lstat(configPath);
|
|
23
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
24
|
+
throw new TypeError('site.config.yml must be a regular file');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let config;
|
|
28
|
+
try {
|
|
29
|
+
config = parse(await readFile(configPath, 'utf8'));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new TypeError(`Invalid site.config.yml: ${error.message}`);
|
|
32
|
+
}
|
|
33
|
+
if (config.schemaVersion !== 1 || config.design == null || Array.isArray(config.design)) {
|
|
34
|
+
throw new TypeError('Unsupported site configuration schema');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const siteOptions = Object.fromEntries(
|
|
38
|
+
Object.entries(designOptions).filter(([name]) => !scaffoldOptionNames.includes(name))
|
|
39
|
+
);
|
|
40
|
+
const normalizedSiteOptions = normalizeSiteConfigurationOptions(siteOptions);
|
|
41
|
+
|
|
42
|
+
for (const [name, value] of Object.entries(designOptions)) {
|
|
43
|
+
if (scaffoldOptionNames.includes(name)) {
|
|
44
|
+
config.design[name] = nonEmptyString(value, `Design option ${name}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (normalizedSiteOptions.siteName != null) config.site.name = normalizedSiteOptions.siteName;
|
|
48
|
+
if (normalizedSiteOptions.siteAuthor != null) config.site.author = normalizedSiteOptions.siteAuthor;
|
|
49
|
+
if (normalizedSiteOptions.defaultLanguage != null) {
|
|
50
|
+
config.site.defaultLanguage = normalizedSiteOptions.defaultLanguage;
|
|
51
|
+
}
|
|
52
|
+
if (normalizedSiteOptions.timezone != null) config.site.timezone = normalizedSiteOptions.timezone;
|
|
53
|
+
if (normalizedSiteOptions.shareTargets != null) {
|
|
54
|
+
config.sharing.targets = normalizedSiteOptions.shareTargets;
|
|
55
|
+
}
|
|
56
|
+
if (normalizedSiteOptions.socialProfiles != null) {
|
|
57
|
+
config.sharing.socialProfiles = normalizedSiteOptions.socialProfiles;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const temporary = `${configPath}.gala-config-${process.pid}`;
|
|
61
|
+
const backup = `${configPath}.gala-backup-${process.pid}`;
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(temporary, stringify(config), { flag: 'wx' });
|
|
64
|
+
await rename(configPath, backup);
|
|
65
|
+
try {
|
|
66
|
+
await rename(temporary, configPath);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
await rename(backup, configPath);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
await rm(backup);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await rm(temporary, { force: true });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { markdownPostFiles } from '@rathnasgala/content-validation';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readPublicationState, PUBLICATION_STATE_PATH } from './publication-state.js';
|
|
5
|
+
|
|
6
|
+
async function sha256(file) {
|
|
7
|
+
return createHash('sha256').update(await readFile(file)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function diagnoseFramework(root) {
|
|
11
|
+
const manifestPath = managedPath(root, '.gala/managed-files.json');
|
|
12
|
+
let manifest;
|
|
13
|
+
try {
|
|
14
|
+
await assertSafeAncestors(root, manifestPath);
|
|
15
|
+
await assertNotSymbolicLink(manifestPath, false);
|
|
16
|
+
manifest = parseManifest(await readFile(manifestPath, 'utf8'));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') {
|
|
19
|
+
return [{ path: '.gala/managed-files.json', status: 'missing-manifest' }];
|
|
20
|
+
}
|
|
21
|
+
throw new TypeError(`Invalid managed-file manifest: ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return diagnoseAgainstManifest(root, manifest);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function diagnosePublicationState(root) {
|
|
28
|
+
try {
|
|
29
|
+
await readPublicationState(root);
|
|
30
|
+
return { path: PUBLICATION_STATE_PATH.split(path.sep).join('/'), status: 'valid' };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT') {
|
|
33
|
+
return { path: PUBLICATION_STATE_PATH.split(path.sep).join('/'), status: 'missing' };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
path: PUBLICATION_STATE_PATH.split(path.sep).join('/'),
|
|
37
|
+
status: 'invalid',
|
|
38
|
+
detail: error.message
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function diagnoseAgainstManifest(root, manifest) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
for (const [relativePath, expectedHash] of Object.entries(manifest.files)) {
|
|
46
|
+
if (relativePath === '.gala/managed-files.json') {
|
|
47
|
+
throw new TypeError('Managed-file manifest cannot manage itself');
|
|
48
|
+
}
|
|
49
|
+
const file = managedPath(root, relativePath);
|
|
50
|
+
try {
|
|
51
|
+
await assertSafeAncestors(root, file);
|
|
52
|
+
await assertNotSymbolicLink(file, false);
|
|
53
|
+
const actualHash = await sha256(file);
|
|
54
|
+
findings.push({
|
|
55
|
+
path: relativePath,
|
|
56
|
+
status: actualHash === expectedHash ? 'intact' : 'modified'
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code !== 'ENOENT') throw error;
|
|
60
|
+
findings.push({ path: relativePath, status: 'missing' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseManifest(source) {
|
|
67
|
+
const manifest = JSON.parse(source);
|
|
68
|
+
if (manifest.schemaVersion !== 1 || manifest.files == null || Array.isArray(manifest.files)) {
|
|
69
|
+
throw new TypeError('Unsupported managed-file manifest schema');
|
|
70
|
+
}
|
|
71
|
+
return manifest;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function managedPath(root, relativePath) {
|
|
75
|
+
if (path.isAbsolute(relativePath)) throw new TypeError(`Managed path must be relative: ${relativePath}`);
|
|
76
|
+
const resolvedRoot = path.resolve(root);
|
|
77
|
+
const resolved = path.resolve(resolvedRoot, ...relativePath.split('/'));
|
|
78
|
+
const relation = path.relative(resolvedRoot, resolved);
|
|
79
|
+
if (relation.startsWith('..') || path.isAbsolute(relation)) {
|
|
80
|
+
throw new TypeError(`Managed path escapes the site root: ${relativePath}`);
|
|
81
|
+
}
|
|
82
|
+
const [firstSegment] = relation.split(path.sep);
|
|
83
|
+
const protectedFile = new Set([
|
|
84
|
+
'.engagement-snapshot.json',
|
|
85
|
+
'.gala/publication-state.yml',
|
|
86
|
+
'.github/workflows/publish.yml',
|
|
87
|
+
'CNAME',
|
|
88
|
+
'custom.css',
|
|
89
|
+
'site.config.yml'
|
|
90
|
+
]).has(relation);
|
|
91
|
+
if (
|
|
92
|
+
protectedFile
|
|
93
|
+
|| firstSegment === '.git'
|
|
94
|
+
|| firstSegment === 'content'
|
|
95
|
+
|| relation === '.env'
|
|
96
|
+
|| (relation.startsWith('.env.') && relation !== '.env.example')
|
|
97
|
+
) {
|
|
98
|
+
throw new TypeError(`Author-owned path cannot be managed: ${relativePath}`);
|
|
99
|
+
}
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function assertNotSymbolicLink(file, allowMissing) {
|
|
104
|
+
try {
|
|
105
|
+
const metadata = await lstat(file);
|
|
106
|
+
if (metadata.isSymbolicLink()) throw new TypeError(`Refusing symbolic link: ${file}`);
|
|
107
|
+
return metadata;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (allowMissing && error.code === 'ENOENT') return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function assertSafeAncestors(root, file) {
|
|
115
|
+
const resolvedRoot = path.resolve(root);
|
|
116
|
+
const segments = path.relative(resolvedRoot, path.dirname(file)).split(path.sep).filter(Boolean);
|
|
117
|
+
let cursor = resolvedRoot;
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
cursor = path.join(cursor, segment);
|
|
120
|
+
const metadata = await assertNotSymbolicLink(cursor, true);
|
|
121
|
+
if (metadata == null) return;
|
|
122
|
+
if (!metadata.isDirectory()) throw new TypeError(`Managed path ancestor is not a directory: ${cursor}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function repairFramework(root, sourceRoot, { renameImpl = rename, siteConfiguration } = {}) {
|
|
127
|
+
const manifestPath = path.resolve(root, '.gala', 'managed-files.json');
|
|
128
|
+
const sourceManifestPath = path.resolve(sourceRoot, '.gala', 'managed-files.json');
|
|
129
|
+
await assertSafeAncestors(sourceRoot, sourceManifestPath);
|
|
130
|
+
const sourceManifestMetadata = await assertNotSymbolicLink(sourceManifestPath, false);
|
|
131
|
+
if (!sourceManifestMetadata.isFile()) throw new TypeError('Trusted manifest must be a regular file');
|
|
132
|
+
const sourceManifest = await readFile(sourceManifestPath);
|
|
133
|
+
const manifest = parseManifest(sourceManifest.toString('utf8'));
|
|
134
|
+
await assertSafeAncestors(root, manifestPath);
|
|
135
|
+
await assertNotSymbolicLink(manifestPath, true);
|
|
136
|
+
|
|
137
|
+
const findings = await diagnoseAgainstManifest(root, manifest);
|
|
138
|
+
const repairTargets = findings.filter(({ status }) => status === 'missing' || status === 'modified');
|
|
139
|
+
const validated = [];
|
|
140
|
+
|
|
141
|
+
// Validate the entire repair set before replacing the first target.
|
|
142
|
+
for (const finding of repairTargets) {
|
|
143
|
+
const expectedHash = manifest.files[finding.path];
|
|
144
|
+
const source = managedPath(sourceRoot, finding.path);
|
|
145
|
+
const target = managedPath(root, finding.path);
|
|
146
|
+
await assertSafeAncestors(sourceRoot, source);
|
|
147
|
+
await assertSafeAncestors(root, target);
|
|
148
|
+
const sourceMetadata = await assertNotSymbolicLink(source, false);
|
|
149
|
+
if (!sourceMetadata.isFile()) throw new TypeError(`Repair source is not a file: ${finding.path}`);
|
|
150
|
+
const targetMetadata = await assertNotSymbolicLink(target, true);
|
|
151
|
+
if (await sha256(source) !== expectedHash) {
|
|
152
|
+
throw new TypeError(`Repair source hash mismatch: ${finding.path}`);
|
|
153
|
+
}
|
|
154
|
+
validated.push({ ...finding, source, target, targetExists: targetMetadata != null });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let existingManifest = null;
|
|
158
|
+
try { existingManifest = await readFile(manifestPath); } catch (error) {
|
|
159
|
+
if (error.code !== 'ENOENT') throw error;
|
|
160
|
+
}
|
|
161
|
+
if (!existingManifest?.equals(sourceManifest)) {
|
|
162
|
+
validated.push({
|
|
163
|
+
path: '.gala/managed-files.json',
|
|
164
|
+
target: manifestPath,
|
|
165
|
+
bytes: sourceManifest,
|
|
166
|
+
targetExists: existingManifest != null
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (siteConfiguration != null) {
|
|
170
|
+
const configTarget = path.resolve(root, 'site.config.yml');
|
|
171
|
+
await assertSafeAncestors(root, configTarget);
|
|
172
|
+
const configMetadata = await assertNotSymbolicLink(configTarget, false);
|
|
173
|
+
if (!configMetadata.isFile()) throw new TypeError('site.config.yml must be a regular file');
|
|
174
|
+
validated.push({
|
|
175
|
+
path: 'site.config.yml', target: configTarget,
|
|
176
|
+
bytes: Buffer.from(siteConfiguration), targetExists: true
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const transaction = `${process.pid}-${Date.now()}`;
|
|
181
|
+
const prepared = [];
|
|
182
|
+
try {
|
|
183
|
+
for (const item of validated) {
|
|
184
|
+
await mkdir(path.dirname(item.target), { recursive: true });
|
|
185
|
+
const temporary = `${item.target}.gala-repair-${transaction}`;
|
|
186
|
+
const backup = `${item.target}.gala-backup-${transaction}`;
|
|
187
|
+
await writeFile(temporary, item.bytes ?? await readFile(item.source), { flag: 'wx' });
|
|
188
|
+
prepared.push({ ...item, temporary, backup, backedUp: false, installed: false });
|
|
189
|
+
}
|
|
190
|
+
for (const item of prepared) {
|
|
191
|
+
if (item.targetExists) {
|
|
192
|
+
await renameImpl(item.target, item.backup);
|
|
193
|
+
item.backedUp = true;
|
|
194
|
+
}
|
|
195
|
+
await renameImpl(item.temporary, item.target);
|
|
196
|
+
item.installed = true;
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const rollbackErrors = [];
|
|
200
|
+
for (const item of [...prepared].reverse()) {
|
|
201
|
+
try {
|
|
202
|
+
if (item.installed) await rm(item.target, { force: true });
|
|
203
|
+
if (item.backedUp) await renameImpl(item.backup, item.target);
|
|
204
|
+
await rm(item.temporary, { force: true });
|
|
205
|
+
} catch (rollbackError) { rollbackErrors.push(rollbackError); }
|
|
206
|
+
}
|
|
207
|
+
if (rollbackErrors.length > 0) {
|
|
208
|
+
throw new AggregateError([error, ...rollbackErrors], 'Framework repair and rollback failed');
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
for (const item of prepared) if (item.backedUp) await rm(item.backup);
|
|
213
|
+
return prepared.map(({ path: repairedPath }) => repairedPath);
|
|
214
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { repositoryEvaluationDate } from '@rathnasgala/content-validation';
|
|
@@ -0,0 +1,104 @@
|
|
|
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 galaCredentialPath({
|
|
6
|
+
platform = process.platform,
|
|
7
|
+
environment = process.env,
|
|
8
|
+
home = os.homedir()
|
|
9
|
+
} = {}) {
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
const root = environment.APPDATA;
|
|
12
|
+
if (!root) throw new Error('APPDATA is required to store Gala credentials on Windows');
|
|
13
|
+
return path.join(root, 'Gala', 'credentials.json');
|
|
14
|
+
}
|
|
15
|
+
if (platform === 'darwin') {
|
|
16
|
+
return path.join(home, 'Library', 'Application Support', 'Gala', 'credentials.json');
|
|
17
|
+
}
|
|
18
|
+
const root = environment.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
19
|
+
return path.join(root, 'gala', 'credentials.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function regularOrMissing(target, label) {
|
|
23
|
+
try {
|
|
24
|
+
const metadata = await lstat(target);
|
|
25
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
26
|
+
throw new TypeError(`${label} must be a regular file`);
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT') return false;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeGalaCredential({
|
|
36
|
+
accessToken,
|
|
37
|
+
expiresAt,
|
|
38
|
+
apiBaseUrl,
|
|
39
|
+
target = galaCredentialPath()
|
|
40
|
+
}) {
|
|
41
|
+
if (typeof accessToken !== 'string' || accessToken === '') throw new TypeError('accessToken is required');
|
|
42
|
+
if (!(expiresAt instanceof Date) || Number.isNaN(expiresAt.getTime())) {
|
|
43
|
+
throw new TypeError('expiresAt must be a valid Date');
|
|
44
|
+
}
|
|
45
|
+
const base = new URL(apiBaseUrl);
|
|
46
|
+
const loopback = base.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(base.hostname);
|
|
47
|
+
if ((base.protocol !== 'https:' && !loopback) || base.username || base.password
|
|
48
|
+
|| base.search || base.hash) {
|
|
49
|
+
throw new TypeError('apiBaseUrl must be credential-free HTTPS (or HTTP loopback) without query or fragment');
|
|
50
|
+
}
|
|
51
|
+
const directory = path.dirname(path.resolve(target));
|
|
52
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
53
|
+
const directoryMetadata = await lstat(directory);
|
|
54
|
+
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
55
|
+
throw new TypeError('Gala credential directory must be a real directory');
|
|
56
|
+
}
|
|
57
|
+
await chmod(directory, 0o700);
|
|
58
|
+
const exists = await regularOrMissing(target, 'Gala credential file');
|
|
59
|
+
const temporary = `${target}.gala-${process.pid}`;
|
|
60
|
+
const backup = `${target}.gala-backup-${process.pid}`;
|
|
61
|
+
const content = `${JSON.stringify({
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
apiBaseUrl: base.href,
|
|
64
|
+
accessToken,
|
|
65
|
+
expiresAt: expiresAt.toISOString()
|
|
66
|
+
})}\n`;
|
|
67
|
+
try {
|
|
68
|
+
await writeFile(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
69
|
+
await chmod(temporary, 0o600);
|
|
70
|
+
if (exists) await rename(target, backup);
|
|
71
|
+
try {
|
|
72
|
+
await rename(temporary, target);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (exists) await rename(backup, target);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
await chmod(target, 0o600);
|
|
78
|
+
if (exists) await rm(backup);
|
|
79
|
+
return path.resolve(target);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await rm(temporary, { force: true });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readGalaCredential({ target = galaCredentialPath(), now = new Date() } = {}) {
|
|
87
|
+
if (!await regularOrMissing(target, 'Gala credential file')) {
|
|
88
|
+
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
89
|
+
}
|
|
90
|
+
const payload = JSON.parse(await readFile(target, 'utf8'));
|
|
91
|
+
if (payload?.schemaVersion !== 1 || typeof payload.accessToken !== 'string'
|
|
92
|
+
|| typeof payload.apiBaseUrl !== 'string' || typeof payload.expiresAt !== 'string') {
|
|
93
|
+
throw new TypeError('Gala credential file has an unsupported schema');
|
|
94
|
+
}
|
|
95
|
+
const expiresAt = new Date(payload.expiresAt);
|
|
96
|
+
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now) {
|
|
97
|
+
throw new Error('Gala authentication expired; run `gala auth` again');
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
accessToken: payload.accessToken,
|
|
101
|
+
apiBaseUrl: payload.apiBaseUrl,
|
|
102
|
+
expiresAt
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
2
|
+
|
|
3
|
+
function requiredString(value, field) {
|
|
4
|
+
if (typeof value !== 'string' || value.trim() === '') throw new TypeError(`${field} is required`);
|
|
5
|
+
return value.trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function positiveInteger(value, field) {
|
|
9
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${field} must be positive`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function apiUrl(apiBaseUrl, path) {
|
|
14
|
+
const base = new URL(requiredString(apiBaseUrl, 'apiBaseUrl'));
|
|
15
|
+
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
16
|
+
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
17
|
+
|| base.username || base.password || base.search || base.hash) {
|
|
18
|
+
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
19
|
+
}
|
|
20
|
+
return new URL(path, base).href;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function postForm(fetchImpl, url, fields) {
|
|
24
|
+
const response = await fetchImpl(url, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
accept: 'application/json',
|
|
28
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
29
|
+
},
|
|
30
|
+
body: new URLSearchParams(fields)
|
|
31
|
+
});
|
|
32
|
+
let payload;
|
|
33
|
+
try {
|
|
34
|
+
payload = await response.json();
|
|
35
|
+
} catch {
|
|
36
|
+
const status = Number.isInteger(response?.status) ? ` (HTTP ${response.status})` : '';
|
|
37
|
+
throw new TypeError(`Gala device authorization returned invalid JSON${status}`);
|
|
38
|
+
}
|
|
39
|
+
if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
|
|
40
|
+
throw new TypeError('Gala device authorization response must be a JSON object');
|
|
41
|
+
}
|
|
42
|
+
return { response, payload };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function requestGalaDeviceCode({
|
|
46
|
+
apiBaseUrl = 'https://api.gala67.com',
|
|
47
|
+
clientId = 'gala-cli',
|
|
48
|
+
fetchImpl = fetch
|
|
49
|
+
} = {}) {
|
|
50
|
+
const { response, payload } = await postForm(
|
|
51
|
+
fetchImpl,
|
|
52
|
+
apiUrl(apiBaseUrl, '/v1/auth/device/code'),
|
|
53
|
+
{ client_id: requiredString(clientId, 'clientId') }
|
|
54
|
+
);
|
|
55
|
+
if (!response.ok) throw new Error(`Gala device authorization failed with HTTP ${response.status}`);
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
deviceCode: requiredString(payload.device_code, 'device_code'),
|
|
58
|
+
userCode: requiredString(payload.user_code, 'user_code'),
|
|
59
|
+
verificationUri: requiredString(payload.verification_uri, 'verification_uri'),
|
|
60
|
+
verificationUriComplete: requiredString(
|
|
61
|
+
payload.verification_uri_complete,
|
|
62
|
+
'verification_uri_complete'
|
|
63
|
+
),
|
|
64
|
+
expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in'),
|
|
65
|
+
intervalSeconds: positiveInteger(payload.interval ?? 5, 'interval')
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function pollForGalaToken({
|
|
70
|
+
deviceCode,
|
|
71
|
+
expiresInSeconds,
|
|
72
|
+
intervalSeconds,
|
|
73
|
+
apiBaseUrl = 'https://api.gala67.com',
|
|
74
|
+
clientId = 'gala-cli',
|
|
75
|
+
fetchImpl = fetch,
|
|
76
|
+
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
77
|
+
now = Date.now
|
|
78
|
+
}) {
|
|
79
|
+
const code = requiredString(deviceCode, 'deviceCode');
|
|
80
|
+
const lifetime = positiveInteger(expiresInSeconds, 'expiresInSeconds') * 1000;
|
|
81
|
+
let interval = positiveInteger(intervalSeconds, 'intervalSeconds');
|
|
82
|
+
const startedAt = now();
|
|
83
|
+
if (!Number.isFinite(startedAt)) throw new TypeError('Clock must return epoch milliseconds');
|
|
84
|
+
|
|
85
|
+
while (true) {
|
|
86
|
+
await sleep(interval * 1000);
|
|
87
|
+
const currentTime = now();
|
|
88
|
+
if (!Number.isFinite(currentTime)) throw new TypeError('Clock must return epoch milliseconds');
|
|
89
|
+
if (currentTime - startedAt >= lifetime) {
|
|
90
|
+
throw new Error('Gala device authorization expired; run `gala auth` again');
|
|
91
|
+
}
|
|
92
|
+
const { response, payload } = await postForm(
|
|
93
|
+
fetchImpl,
|
|
94
|
+
apiUrl(apiBaseUrl, '/v1/auth/device/token'),
|
|
95
|
+
{
|
|
96
|
+
grant_type: DEVICE_GRANT,
|
|
97
|
+
device_code: code,
|
|
98
|
+
client_id: requiredString(clientId, 'clientId')
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
if (requiredString(payload.token_type, 'token_type').toLowerCase() !== 'bearer') {
|
|
103
|
+
throw new TypeError('Gala token_type must be bearer');
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
accessToken: requiredString(payload.access_token, 'access_token'),
|
|
107
|
+
expiresInSeconds: positiveInteger(payload.expires_in, 'expires_in')
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (payload.error === 'authorization_pending') continue;
|
|
111
|
+
if (payload.error === 'slow_down') {
|
|
112
|
+
interval += 5;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (payload.error === 'expired_token') {
|
|
116
|
+
throw new Error('Gala device authorization expired; run `gala auth` again');
|
|
117
|
+
}
|
|
118
|
+
if (payload.error === 'access_denied') throw new Error('Gala device authorization was denied');
|
|
119
|
+
throw new Error(`Gala device authorization failed: ${requiredString(payload.error, 'error')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { pollForAccessToken, requestDeviceCode } from './github-device-flow.js';
|
|
2
|
+
import { writeGithubCredential } from './github-credential-store.js';
|
|
3
|
+
|
|
4
|
+
export const GITHUB_OAUTH_CLIENT_ID = 'Ov23ligTfectgl2FHJ6c';
|
|
5
|
+
export const GITHUB_SCAFFOLD_SCOPES = Object.freeze(['repo', 'workflow']);
|
|
6
|
+
|
|
7
|
+
export async function authenticateGithub({
|
|
8
|
+
clientId = GITHUB_OAUTH_CLIENT_ID, fetchImpl = fetch, sleep, now = Date.now,
|
|
9
|
+
showScopeWarning, showInstructions, credentialTarget
|
|
10
|
+
} = {}) {
|
|
11
|
+
if (typeof showScopeWarning !== 'function' || typeof showInstructions !== 'function') {
|
|
12
|
+
throw new TypeError('scope warning and device instructions are required');
|
|
13
|
+
}
|
|
14
|
+
showScopeWarning({
|
|
15
|
+
scopes: GITHUB_SCAFFOLD_SCOPES,
|
|
16
|
+
explanation: 'repo grants read/write access to every public and private repository you can access; workflow is used only for scaffold and explicit action-major migration.'
|
|
17
|
+
});
|
|
18
|
+
const authorization = await requestDeviceCode({ clientId, scopes: GITHUB_SCAFFOLD_SCOPES, fetchImpl });
|
|
19
|
+
showInstructions(authorization);
|
|
20
|
+
const token = await pollForAccessToken({
|
|
21
|
+
...authorization, clientId, requiredScopes: GITHUB_SCAFFOLD_SCOPES, fetchImpl,
|
|
22
|
+
...(sleep == null ? {} : { sleep }), now
|
|
23
|
+
});
|
|
24
|
+
const target = await writeGithubCredential({
|
|
25
|
+
accessToken: token.accessToken, scopes: token.scopes,
|
|
26
|
+
...(credentialTarget == null ? {} : { target: credentialTarget })
|
|
27
|
+
});
|
|
28
|
+
return Object.freeze({ target, scopes: token.scopes });
|
|
29
|
+
}
|