@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.
- package/README.md +100 -187
- package/package.json +3 -3
- package/src/api/gala.js +163 -0
- package/src/api/github.js +63 -0
- package/src/api/http.js +72 -0
- package/src/auth/gala.js +52 -0
- package/src/auth/github.js +78 -0
- package/src/auth/store.js +88 -0
- package/src/cli/args.js +56 -0
- package/src/cli/terminal.js +104 -0
- package/src/commands/auth.js +20 -0
- package/src/commands/doctor.js +98 -0
- package/src/commands/domain.js +124 -0
- package/src/commands/init.js +276 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/prism.js +360 -0
- package/src/commands/publish.js +57 -0
- package/src/commands/upgrade.js +173 -0
- package/src/commands-manifest.js +80 -0
- package/src/content.js +31 -0
- package/src/domain.js +33 -0
- package/src/git.js +160 -0
- package/src/index.js +44 -294
- package/src/publication.js +39 -0
- package/src/assign-content-ids.js +0 -1
- package/src/auth-command.js +0 -36
- package/src/configure-site.js +0 -102
- package/src/content-files.js +0 -1
- package/src/doctor-command.js +0 -214
- package/src/entitlement-client.js +0 -26
- package/src/entitlement-command.js +0 -74
- package/src/evaluation-date.js +0 -1
- package/src/gala-credential-health.js +0 -34
- package/src/gala-credential-store.js +0 -115
- package/src/gala-device-flow.js +0 -121
- package/src/git-credentials.js +0 -37
- package/src/github-auth-command.js +0 -50
- package/src/github-credential-store.js +0 -104
- package/src/github-device-flow.js +0 -153
- package/src/github-empty-repository.js +0 -89
- package/src/github-identity.js +0 -32
- package/src/github-pages-provisioning.js +0 -107
- package/src/github-repository-secret.js +0 -82
- package/src/github-repository-variable.js +0 -56
- package/src/github-template-repository.js +0 -171
- package/src/hook-command.js +0 -64
- package/src/http-failure.js +0 -55
- package/src/new-command.js +0 -54
- package/src/open-browser.js +0 -40
- package/src/preview-command.js +0 -60
- package/src/publication-creation-client.js +0 -155
- package/src/publication-state.js +0 -7
- package/src/publish-command.js +0 -37
- package/src/record-deployment-command.js +0 -147
- package/src/refresh-command.js +0 -104
- package/src/repository-limits.js +0 -94
- package/src/scaffold-git.js +0 -76
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -146
- package/src/scaffold-site.js +0 -185
- package/src/site-config-registration.js +0 -47
- package/src/site-registration-client.js +0 -138
- package/src/theme-package.js +0 -128
- package/src/topology-client.js +0 -43
- package/src/topology-command.js +0 -70
- package/src/upgrade-command.js +0 -81
- package/src/validate-command.js +0 -5
- package/src/workflow-command.js +0 -87
package/src/configure-site.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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 { parseDocument } from 'yaml';
|
|
5
|
-
|
|
6
|
-
import { scaffoldOptionNames } from './scaffold-options.js';
|
|
7
|
-
|
|
8
|
-
const IMPLEMENTED_DESIGN_VALUES = Object.freeze({
|
|
9
|
-
layout: Object.freeze(['article-first', 'portfolio']),
|
|
10
|
-
palette: Object.freeze(['default', 'ocean'])
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
function nonEmptyString(value, field) {
|
|
14
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
15
|
-
throw new TypeError(`${field} must be a non-empty string`);
|
|
16
|
-
}
|
|
17
|
-
return value.trim();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function configureSite(root, designOptions) {
|
|
21
|
-
const configPath = path.resolve(root, 'site.config.yml');
|
|
22
|
-
const relation = path.relative(path.resolve(root), configPath);
|
|
23
|
-
if (relation.startsWith('..') || path.isAbsolute(relation)) {
|
|
24
|
-
throw new TypeError('site.config.yml escapes the site root');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const metadata = await lstat(configPath);
|
|
28
|
-
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
29
|
-
throw new TypeError('site.config.yml must be a regular file');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let config;
|
|
33
|
-
let document;
|
|
34
|
-
try {
|
|
35
|
-
document = parseDocument(await readFile(configPath, 'utf8'));
|
|
36
|
-
if (document.errors.length > 0) throw document.errors[0];
|
|
37
|
-
config = document.toJS();
|
|
38
|
-
} catch (error) {
|
|
39
|
-
throw new TypeError(`Invalid site.config.yml: ${error.message}`);
|
|
40
|
-
}
|
|
41
|
-
if (config.schemaVersion !== 1 || config.design == null || Array.isArray(config.design)) {
|
|
42
|
-
throw new TypeError('Unsupported site configuration schema');
|
|
43
|
-
}
|
|
44
|
-
if (Object.keys(designOptions).length === 0) return config;
|
|
45
|
-
|
|
46
|
-
const siteOptions = Object.fromEntries(
|
|
47
|
-
Object.entries(designOptions).filter(([name]) => !scaffoldOptionNames.includes(name))
|
|
48
|
-
);
|
|
49
|
-
const normalizedSiteOptions = normalizeSiteConfigurationOptions(siteOptions);
|
|
50
|
-
|
|
51
|
-
for (const [name, value] of Object.entries(designOptions)) {
|
|
52
|
-
if (scaffoldOptionNames.includes(name)) {
|
|
53
|
-
config.design[name] = nonEmptyString(value, `Design option ${name}`);
|
|
54
|
-
if (IMPLEMENTED_DESIGN_VALUES[name]?.includes(config.design[name]) === false) {
|
|
55
|
-
throw new TypeError(`Unsupported design ${name}: ${config.design[name]}`);
|
|
56
|
-
}
|
|
57
|
-
document.setIn(['design', name], config.design[name]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (normalizedSiteOptions.siteName != null) {
|
|
61
|
-
config.site.name = normalizedSiteOptions.siteName;
|
|
62
|
-
document.setIn(['site', 'name'], config.site.name);
|
|
63
|
-
}
|
|
64
|
-
if (normalizedSiteOptions.siteAuthor != null) {
|
|
65
|
-
config.site.author = normalizedSiteOptions.siteAuthor;
|
|
66
|
-
document.setIn(['site', 'author'], config.site.author);
|
|
67
|
-
}
|
|
68
|
-
if (normalizedSiteOptions.defaultLanguage != null) {
|
|
69
|
-
config.site.defaultLanguage = normalizedSiteOptions.defaultLanguage;
|
|
70
|
-
document.setIn(['site', 'defaultLanguage'], config.site.defaultLanguage);
|
|
71
|
-
}
|
|
72
|
-
if (normalizedSiteOptions.timezone != null) {
|
|
73
|
-
config.site.timezone = normalizedSiteOptions.timezone;
|
|
74
|
-
document.setIn(['site', 'timezone'], config.site.timezone);
|
|
75
|
-
}
|
|
76
|
-
if (normalizedSiteOptions.shareTargets != null) {
|
|
77
|
-
config.sharing.targets = normalizedSiteOptions.shareTargets;
|
|
78
|
-
document.setIn(['sharing', 'targets'], config.sharing.targets);
|
|
79
|
-
}
|
|
80
|
-
if (normalizedSiteOptions.socialProfiles != null) {
|
|
81
|
-
config.sharing.socialProfiles = normalizedSiteOptions.socialProfiles;
|
|
82
|
-
document.setIn(['sharing', 'socialProfiles'], config.sharing.socialProfiles);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const temporary = `${configPath}.gala-config-${process.pid}`;
|
|
86
|
-
const backup = `${configPath}.gala-backup-${process.pid}`;
|
|
87
|
-
try {
|
|
88
|
-
await writeFile(temporary, String(document), { flag: 'wx' });
|
|
89
|
-
await rename(configPath, backup);
|
|
90
|
-
try {
|
|
91
|
-
await rename(temporary, configPath);
|
|
92
|
-
} catch (error) {
|
|
93
|
-
await rename(backup, configPath);
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
await rm(backup);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
await rm(temporary, { force: true });
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
return config;
|
|
102
|
-
}
|
package/src/content-files.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { markdownPostFiles } from '@rathnasgala/content-validation';
|
package/src/doctor-command.js
DELETED
|
@@ -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
|
-
}
|
package/src/evaluation-date.js
DELETED
|
@@ -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
|
-
}
|