@rathnasgala/cli 0.0.21 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +66 -212
  2. package/package.json +3 -3
  3. package/src/api/gala.js +126 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/init.js +197 -0
  14. package/src/commands/new.js +76 -0
  15. package/src/commands/preview.js +92 -0
  16. package/src/commands/publish.js +57 -0
  17. package/src/commands-manifest.js +58 -0
  18. package/src/content.js +31 -0
  19. package/src/git.js +143 -0
  20. package/src/index.js +44 -297
  21. package/src/publication.js +37 -0
  22. package/src/assign-content-ids.js +0 -1
  23. package/src/auth-command.js +0 -36
  24. package/src/configure-site.js +0 -102
  25. package/src/content-files.js +0 -1
  26. package/src/doctor-command.js +0 -214
  27. package/src/entitlement-client.js +0 -26
  28. package/src/entitlement-command.js +0 -74
  29. package/src/evaluation-date.js +0 -1
  30. package/src/gala-credential-health.js +0 -34
  31. package/src/gala-credential-store.js +0 -115
  32. package/src/gala-device-flow.js +0 -121
  33. package/src/github-auth-command.js +0 -29
  34. package/src/github-credential-store.js +0 -65
  35. package/src/github-device-flow.js +0 -130
  36. package/src/github-empty-repository.js +0 -76
  37. package/src/github-identity.js +0 -32
  38. package/src/github-pages-provisioning.js +0 -107
  39. package/src/github-repository-secret.js +0 -82
  40. package/src/github-repository-variable.js +0 -56
  41. package/src/github-template-repository.js +0 -165
  42. package/src/hook-command.js +0 -64
  43. package/src/http-failure.js +0 -55
  44. package/src/new-command.js +0 -54
  45. package/src/open-browser.js +0 -40
  46. package/src/preview-command.js +0 -60
  47. package/src/publication-creation-client.js +0 -144
  48. package/src/publication-state.js +0 -7
  49. package/src/publish-command.js +0 -37
  50. package/src/record-deployment-command.js +0 -147
  51. package/src/refresh-command.js +0 -104
  52. package/src/repository-limits.js +0 -94
  53. package/src/scaffold-git.js +0 -41
  54. package/src/scaffold-options.js +0 -58
  55. package/src/scaffold-preflight.js +0 -147
  56. package/src/scaffold-site.js +0 -162
  57. package/src/site-config-registration.js +0 -47
  58. package/src/site-registration-client.js +0 -138
  59. package/src/theme-package.js +0 -128
  60. package/src/topology-client.js +0 -43
  61. package/src/topology-command.js +0 -70
  62. package/src/upgrade-command.js +0 -81
  63. package/src/validate-command.js +0 -5
  64. package/src/workflow-command.js +0 -87
@@ -1,214 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
1
- const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
2
- const FIELDS = ['expiresAt', 'issuedAt', 'keyId', 'signature', 'siteId', 'tier'];
3
-
4
- export async function fetchAttributionEntitlement({ siteId, credential, fetchImpl = fetch }) {
5
- if (!ULID.test(siteId)) throw new TypeError('siteId must be a canonical ULID');
6
- const endpoint = new URL(`/v1/sites/${siteId}/attribution-entitlement`, credential.apiBaseUrl);
7
- const loopback = endpoint.protocol === 'http:'
8
- && ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
9
- if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
10
- throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
11
- }
12
- const response = await fetchImpl(endpoint, {
13
- headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
14
- });
15
- if (!response.ok) throw new Error(`Attribution entitlement retrieval failed with HTTP ${response.status}`);
16
- const artifact = await response.json();
17
- if (artifact == null || Array.isArray(artifact) || typeof artifact !== 'object'
18
- || Object.keys(artifact).sort().join('\0') !== FIELDS.join('\0')
19
- || artifact.siteId !== siteId || artifact.tier !== 'PAID'
20
- || !['issuedAt', 'expiresAt', 'keyId', 'signature'].every(
21
- (field) => typeof artifact[field] === 'string' && artifact[field].length > 0
22
- )) {
23
- throw new TypeError('Attribution entitlement response is invalid');
24
- }
25
- return artifact;
26
- }
@@ -1,74 +0,0 @@
1
- import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { parse } from 'yaml';
5
- import { readGalaCredential } from './gala-credential-store.js';
6
- import { fetchAttributionEntitlement } from './entitlement-client.js';
7
-
8
- const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
9
- const ARTIFACT = '.gala/entitlement.json';
10
-
11
- async function runGit(root, args) {
12
- return new Promise((resolve, reject) => {
13
- const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
14
- child.once('error', reject);
15
- child.once('exit', (code, signal) => {
16
- if (signal) reject(new Error(`git terminated by signal ${signal}`));
17
- else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
18
- else resolve();
19
- });
20
- });
21
- }
22
-
23
- async function commitArtifact(root) {
24
- await runGit(root, ['add', '--', ARTIFACT]);
25
- await runGit(root, ['commit', '--message', 'chore(gala): update attribution entitlement', '--', ARTIFACT]);
26
- await runGit(root, ['push']);
27
- }
28
-
29
- export async function acquireAttributionEntitlement({
30
- root = process.cwd(), readCredential = readGalaCredential,
31
- fetchEntitlement = fetchAttributionEntitlement, commit = commitArtifact
32
- } = {}) {
33
- const siteRoot = path.resolve(root);
34
- const configTarget = path.join(siteRoot, 'site.config.yml');
35
- const metadata = await lstat(configTarget);
36
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
37
- throw new TypeError('site.config.yml must be a regular file');
38
- }
39
- const config = parse(await readFile(configTarget, 'utf8'));
40
- const siteId = config?.site?.id;
41
- if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
42
- const artifact = await fetchEntitlement({ siteId, credential: await readCredential() });
43
- const directory = path.join(siteRoot, '.gala');
44
- await mkdir(directory, { recursive: true });
45
- const directoryMetadata = await lstat(directory);
46
- if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
47
- throw new TypeError('.gala must be a real directory');
48
- }
49
- const target = path.join(siteRoot, ARTIFACT);
50
- try {
51
- const current = await lstat(target);
52
- if (!current.isFile() || current.isSymbolicLink()) {
53
- throw new TypeError('Attribution entitlement must be a regular file');
54
- }
55
- } catch (error) {
56
- if (error.code !== 'ENOENT') throw error;
57
- }
58
- const serialized = `${JSON.stringify(artifact, null, 2)}\n`;
59
- try {
60
- if (await readFile(target, 'utf8') === serialized) return Object.freeze({ changed: false, siteId });
61
- } catch (error) {
62
- if (error.code !== 'ENOENT') throw error;
63
- }
64
- const temporary = `${target}.gala-${process.pid}`;
65
- try {
66
- await writeFile(temporary, serialized, { flag: 'wx' });
67
- await rename(temporary, target);
68
- } catch (error) {
69
- await rm(temporary, { force: true });
70
- throw error;
71
- }
72
- await commit(siteRoot);
73
- return Object.freeze({ changed: true, siteId });
74
- }
@@ -1 +0,0 @@
1
- export { repositoryEvaluationDate } from '@rathnasgala/content-validation';
@@ -1,34 +0,0 @@
1
- /**
2
- * Whether a stored Gala credential is one the server will still accept.
3
- *
4
- * `readGalaCredential` can only check what is written in the file — schema and expiry — and a
5
- * credential can satisfy both while the API refuses it outright. It happened: the API stopped
6
- * putting a `tenant` claim in its tokens and now rejects any token that still carries one
7
- * (Rs256JwtCodec: "Legacy tenant-bearing token requires reauthentication"). Tokens minted before
8
- * that change have a month-long expiry, so every command using one sent a bearer the server had
9
- * already decided to refuse, and reported it as whatever call happened to fail first.
10
- *
11
- * Expiry is not the only way a credential dies. It can be revoked, the signing key can rotate, the
12
- * claim set can change again. So this does not special-case the `tenant` claim: it asks the server,
13
- * once, and treats 401 as "this credential is finished" — which is true whatever the reason.
14
- */
15
- const PROBE_PATH = '/v1/me/sites';
16
-
17
- /**
18
- * Answers whether the API still accepts this credential.
19
- *
20
- * A network failure is deliberately not an answer: refusing to run because the machine is briefly
21
- * offline, or forcing a sign-in the writer does not need, are both worse than letting the real
22
- * call fail with its own error.
23
- */
24
- export async function galaCredentialAccepted({ apiBaseUrl, accessToken, fetchImpl = fetch }) {
25
- let response;
26
- try {
27
- response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}${PROBE_PATH}`, {
28
- headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
29
- });
30
- } catch {
31
- return true;
32
- }
33
- return response.status !== 401;
34
- }
@@ -1,115 +0,0 @@
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
- }
105
-
106
- /**
107
- * Removes a credential the server no longer accepts.
108
- *
109
- * Leaving a refused token on disk means every later command rediscovers that it is refused, and
110
- * `readGalaCredential` cannot tell the difference — the file is well-formed and unexpired. Deleting
111
- * it is what makes the next run ask for a sign-in instead of failing again.
112
- */
113
- export async function forgetGalaCredential({ target = galaCredentialPath() } = {}) {
114
- await rm(target, { force: true });
115
- }
@@ -1,121 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }