@rathnasgala/cli 0.0.22 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +100 -187
  2. package/package.json +3 -3
  3. package/src/api/gala.js +163 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/domain.js +124 -0
  14. package/src/commands/init.js +276 -0
  15. package/src/commands/new.js +76 -0
  16. package/src/commands/preview.js +92 -0
  17. package/src/commands/prism.js +360 -0
  18. package/src/commands/publish.js +57 -0
  19. package/src/commands/upgrade.js +173 -0
  20. package/src/commands-manifest.js +80 -0
  21. package/src/content.js +31 -0
  22. package/src/domain.js +33 -0
  23. package/src/git.js +160 -0
  24. package/src/index.js +44 -294
  25. package/src/publication.js +39 -0
  26. package/src/assign-content-ids.js +0 -1
  27. package/src/auth-command.js +0 -36
  28. package/src/configure-site.js +0 -102
  29. package/src/content-files.js +0 -1
  30. package/src/doctor-command.js +0 -214
  31. package/src/entitlement-client.js +0 -26
  32. package/src/entitlement-command.js +0 -74
  33. package/src/evaluation-date.js +0 -1
  34. package/src/gala-credential-health.js +0 -34
  35. package/src/gala-credential-store.js +0 -115
  36. package/src/gala-device-flow.js +0 -121
  37. package/src/git-credentials.js +0 -37
  38. package/src/github-auth-command.js +0 -50
  39. package/src/github-credential-store.js +0 -104
  40. package/src/github-device-flow.js +0 -153
  41. package/src/github-empty-repository.js +0 -89
  42. package/src/github-identity.js +0 -32
  43. package/src/github-pages-provisioning.js +0 -107
  44. package/src/github-repository-secret.js +0 -82
  45. package/src/github-repository-variable.js +0 -56
  46. package/src/github-template-repository.js +0 -171
  47. package/src/hook-command.js +0 -64
  48. package/src/http-failure.js +0 -55
  49. package/src/new-command.js +0 -54
  50. package/src/open-browser.js +0 -40
  51. package/src/preview-command.js +0 -60
  52. package/src/publication-creation-client.js +0 -155
  53. package/src/publication-state.js +0 -7
  54. package/src/publish-command.js +0 -37
  55. package/src/record-deployment-command.js +0 -147
  56. package/src/refresh-command.js +0 -104
  57. package/src/repository-limits.js +0 -94
  58. package/src/scaffold-git.js +0 -76
  59. package/src/scaffold-options.js +0 -58
  60. package/src/scaffold-preflight.js +0 -146
  61. package/src/scaffold-site.js +0 -185
  62. package/src/site-config-registration.js +0 -47
  63. package/src/site-registration-client.js +0 -138
  64. package/src/theme-package.js +0 -128
  65. package/src/topology-client.js +0 -43
  66. package/src/topology-command.js +0 -70
  67. package/src/upgrade-command.js +0 -81
  68. package/src/validate-command.js +0 -5
  69. package/src/workflow-command.js +0 -87
@@ -1,128 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import path from 'node:path';
5
-
6
- import * as tar from 'tar';
7
-
8
- const MAX_COMPRESSED_BYTES = 10 * 1024 * 1024;
9
- const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
10
- const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
11
- const MAX_ENTRIES = 2_048;
12
- const EXACT_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
13
-
14
- function registryUrl(name, version) {
15
- if (name !== '@rathnasgala/theme' || !EXACT_VERSION.test(version)) {
16
- throw new TypeError('theme package name and exact version are required');
17
- }
18
- return `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`;
19
- }
20
-
21
- async function boundedBody(response) {
22
- const length = Number(response.headers.get('content-length'));
23
- if (Number.isFinite(length) && length > MAX_COMPRESSED_BYTES) throw new Error('Theme archive exceeds compressed-size limit');
24
- const reader = response.body.getReader();
25
- const chunks = [];
26
- let size = 0;
27
- for (;;) {
28
- const { done, value } = await reader.read();
29
- if (done) break;
30
- size += value.byteLength;
31
- if (size > MAX_COMPRESSED_BYTES) {
32
- await reader.cancel();
33
- throw new Error('Theme archive exceeds compressed-size limit');
34
- }
35
- chunks.push(value);
36
- }
37
- return Buffer.concat(chunks, size);
38
- }
39
-
40
- function verifyIntegrity(bytes, integrity) {
41
- const match = /^(sha512|sha384|sha256)-([A-Za-z0-9+/=]+)$/.exec(integrity ?? '');
42
- if (match == null) throw new Error('Theme package has no supported registry integrity value');
43
- const actual = createHash(match[1]).update(bytes).digest('base64');
44
- if (actual !== match[2]) throw new Error('Theme package integrity verification failed');
45
- }
46
-
47
- async function validateArchive(archive) {
48
- let entries = 0;
49
- let total = 0;
50
- await new Promise((resolve, reject) => {
51
- const inspector = tar.t({
52
- strict: true,
53
- onReadEntry(entry) {
54
- entries += 1;
55
- total += entry.size;
56
- const normalized = entry.path.replaceAll('\\', '/');
57
- let error;
58
- if (normalized !== 'package/' && !normalized.startsWith('package/')) error = 'Theme archive entry is outside package/';
59
- else if (entry.type === 'SymbolicLink' || entry.type === 'Link') error = 'Theme archive links are forbidden';
60
- else if (path.posix.isAbsolute(normalized) || normalized.split('/').includes('..')) error = 'Theme archive path is unsafe';
61
- else if (entries > MAX_ENTRIES || entry.size > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES) {
62
- error = 'Theme archive exceeds extraction limits';
63
- }
64
- entry.resume();
65
- if (error) inspector.abort(new Error(error));
66
- }
67
- });
68
- inspector.once('error', reject);
69
- inspector.once('close', resolve);
70
- inspector.end(archive);
71
- });
72
- }
73
-
74
- export async function fetchVerifiedThemePackage({ name, version, fetchImpl = fetch }) {
75
- const metadataResponse = await fetchImpl(registryUrl(name, version), { headers: { Accept: 'application/json' } });
76
- if (!metadataResponse.ok) throw new Error(`Theme metadata request failed with HTTP ${metadataResponse.status}`);
77
- const metadata = await metadataResponse.json();
78
- if (metadata.name !== name || metadata.version !== version || typeof metadata.dist?.tarball !== 'string') {
79
- throw new Error('Theme registry metadata does not match the requested package');
80
- }
81
- if (!metadata.dist.integrity) throw new Error('Theme package registry metadata has no integrity value');
82
- const archiveResponse = await fetchImpl(metadata.dist.tarball);
83
- if (!archiveResponse.ok || archiveResponse.body == null) {
84
- throw new Error(`Theme archive request failed with HTTP ${archiveResponse.status}`);
85
- }
86
- const archive = await boundedBody(archiveResponse);
87
- verifyIntegrity(archive, metadata.dist.integrity);
88
- await validateArchive(archive);
89
-
90
- const staging = await mkdtemp(path.join(tmpdir(), 'gala-theme-'));
91
- try {
92
- await mkdir(staging, { recursive: true });
93
- await new Promise((resolve, reject) => {
94
- const extractor = tar.x({
95
- cwd: staging,
96
- strip: 1,
97
- preservePaths: false,
98
- strict: true
99
- });
100
- extractor.once('error', reject);
101
- extractor.once('close', resolve);
102
- extractor.end(archive);
103
- });
104
- const payloadRoot = path.join(staging, 'payload');
105
- const manifest = JSON.parse(await readFile(path.join(payloadRoot, '.gala', 'managed-files.json'), 'utf8'));
106
- if (manifest.themePackage?.name !== name || manifest.themePackage?.version !== version) {
107
- throw new Error('Extracted theme manifest does not match registry identity');
108
- }
109
- for (const [relative, expected] of Object.entries(manifest.files ?? {})) {
110
- const sourceRelative = manifest.artifactSources?.[relative] ?? relative;
111
- const source = path.resolve(payloadRoot, sourceRelative);
112
- const target = path.resolve(payloadRoot, relative);
113
- if (path.relative(payloadRoot, source).startsWith('..') || path.relative(payloadRoot, target).startsWith('..')) {
114
- throw new Error('Theme manifest path is unsafe');
115
- }
116
- const actual = createHash('sha256').update(await readFile(source)).digest('hex');
117
- if (actual !== expected) throw new Error(`Theme file hash mismatch: ${relative}`);
118
- if (source !== target) {
119
- await mkdir(path.dirname(target), { recursive: true });
120
- await copyFile(source, target);
121
- }
122
- }
123
- return { staging: payloadRoot, cleanupRoot: staging, manifest };
124
- } catch (error) {
125
- await rm(staging, { recursive: true, force: true });
126
- throw error;
127
- }
128
- }
@@ -1,43 +0,0 @@
1
- const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
2
-
3
- function endpoint(apiBaseUrl, siteId, suffix) {
4
- if (!ULID.test(siteId)) throw new TypeError('siteId is invalid');
5
- const base = new URL(apiBaseUrl);
6
- const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
7
- if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
8
- || base.username || base.password || base.search || base.hash) {
9
- throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
10
- }
11
- return new URL(`/v1/sites/${siteId}/topology-changes/${suffix}`, base).href;
12
- }
13
-
14
- async function response(response, operation) {
15
- if (response.status === 401) throw new Error('Gala authentication expired; run `gala auth` again');
16
- if (response.status === 404) throw new Error('Site is unavailable');
17
- if (response.status === 409) throw new Error(`Topology ${operation} conflicts with protected state`);
18
- if (!response.ok) throw new Error(`Topology ${operation} failed with HTTP ${response.status}`);
19
- const payload = await response.json();
20
- if (!ULID.test(payload?.changeId)) throw new TypeError('Topology response is invalid');
21
- return Object.freeze(payload);
22
- }
23
-
24
- export async function prepareTopologyChange({
25
- apiBaseUrl, accessToken, siteId, canonicalBaseUrl, pathPrefix, fetchImpl = fetch
26
- }) {
27
- const result = await fetchImpl(endpoint(apiBaseUrl, siteId, 'prepare'), {
28
- method: 'POST',
29
- headers: { accept: 'application/json', authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
30
- body: JSON.stringify({ canonicalBaseUrl, pathPrefix })
31
- });
32
- return response(result, 'prepare');
33
- }
34
-
35
- export async function commitTopologyChange({
36
- apiBaseUrl, accessToken, siteId, changeId, fetchImpl = fetch
37
- }) {
38
- if (!ULID.test(changeId)) throw new TypeError('changeId is invalid');
39
- const result = await fetchImpl(endpoint(apiBaseUrl, siteId, `${changeId}/commit`), {
40
- method: 'POST', headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
41
- });
42
- return response(result, 'commit');
43
- }
@@ -1,70 +0,0 @@
1
- import { readFile, rm, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { parse } from 'yaml';
4
- import { spawn } from 'node:child_process';
5
- import { readGalaCredential } from './gala-credential-store.js';
6
- import { readGithubCredential } from './github-credential-store.js';
7
- import { writeRegisteredSiteConfiguration } from './site-config-registration.js';
8
- import { prepareTopologyChange, commitTopologyChange } from './topology-client.js';
9
- import { provisionGithubPages } from './github-pages-provisioning.js';
10
-
11
- function run(root, args, spawnProcess, accepted = [0]) {
12
- return new Promise((resolve, reject) => {
13
- const child = spawnProcess('git', ['-C', root, ...args], { cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit'] });
14
- let output = '';
15
- child.stdout?.on('data', (chunk) => { output += chunk; });
16
- child.once('error', reject);
17
- child.once('exit', (code, signal) => {
18
- if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
19
- else if (!accepted.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
20
- else resolve({ code, output: output.trim() });
21
- });
22
- });
23
- }
24
-
25
- export async function switchTopology({
26
- root, owner, repository, canonicalBaseUrl, pathPrefix = '/',
27
- readGala = readGalaCredential, readGithub = readGithubCredential,
28
- prepare = prepareTopologyChange, commit = commitTopologyChange,
29
- provisionPages = provisionGithubPages, spawnProcess = spawn
30
- }) {
31
- if (typeof owner !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(owner)
32
- || typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(repository)) {
33
- throw new TypeError('owner and repository are required GitHub path segments');
34
- }
35
- const siteRoot = path.resolve(root);
36
- const config = parse(await readFile(path.join(siteRoot, 'site.config.yml'), 'utf8'));
37
- const siteId = config?.site?.id;
38
- if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('site.config.yml has no valid site id');
39
- const [gala, github] = await Promise.all([readGala(), readGithub()]);
40
- const pending = await prepare({
41
- apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken, siteId, canonicalBaseUrl, pathPrefix
42
- });
43
- // A site served under a path holds no domain of its own — GitHub lends it the one on the
44
- // owner's main site — so the absence of a cname no longer means the provider address.
45
- const topology = pending.canonicalBaseUrl === `https://${owner.toLowerCase()}.github.io`
46
- ? 'provider-default' : (pending.pathPrefix === '/' ? 'domain-root' : 'domain-subpath');
47
- await writeRegisteredSiteConfiguration(siteRoot, {
48
- siteId, canonicalBaseUrl: pending.canonicalBaseUrl,
49
- pathPrefix: pending.pathPrefix, topology
50
- });
51
- const cnamePath = path.join(siteRoot, 'CNAME');
52
- if (pending.cname == null) await rm(cnamePath, { force: true });
53
- else await writeFile(cnamePath, `${pending.cname}\n`, { encoding: 'utf8' });
54
- await run(siteRoot, ['add', '-A', '--', 'site.config.yml', 'CNAME'], spawnProcess);
55
- const unchanged = await run(siteRoot, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
56
- if (unchanged.code === 1) {
57
- await run(siteRoot, ['commit', '-m', `chore(gala): switch topology to ${topology}`], spawnProcess);
58
- }
59
- await run(siteRoot, ['push', 'origin', 'HEAD'], spawnProcess);
60
- const { output: commitSha } = await run(siteRoot, ['rev-parse', 'HEAD'], spawnProcess);
61
- if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid topology commit SHA');
62
- await provisionPages({
63
- owner, repository, accessToken: github.accessToken, commitSha, customDomain: pending.cname
64
- });
65
- const committed = await commit({
66
- apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken,
67
- siteId, changeId: pending.changeId
68
- });
69
- return Object.freeze({ ...committed, commitSha });
70
- }
@@ -1,81 +0,0 @@
1
- import { lstat, readFile, rm } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { parse, stringify } from 'yaml';
4
-
5
- import { repairFramework } from './doctor-command.js';
6
- import { fetchVerifiedThemePackage } from './theme-package.js';
7
-
8
- const NAME = '@rathnasgala/theme';
9
- const ACTION_WORKFLOW = '.github/workflows/publish.yml';
10
- const ACTION_REFERENCE = /rathnasgala\/publish\/\.github\/workflows\/publish\.yml@v([1-9][0-9]*)/g;
11
- const ACTION_TAG = /^v([1-9][0-9]*)(?:\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/;
12
-
13
- async function registryMetadata(fetchImpl) {
14
- const response = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(NAME)}`, {
15
- headers: { Accept: 'application/json' }
16
- });
17
- if (!response.ok) throw new Error(`Theme registry request failed with HTTP ${response.status}`);
18
- return response.json();
19
- }
20
-
21
- export async function inspectActionUpgrade({ root, fetchImpl = fetch }) {
22
- const workflowPath = path.resolve(root, ACTION_WORKFLOW);
23
- const metadata = await lstat(workflowPath);
24
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
25
- throw new TypeError('Publish workflow must be a regular file');
26
- }
27
- const workflow = await readFile(workflowPath, 'utf8');
28
- const majors = [...workflow.matchAll(ACTION_REFERENCE)].map((match) => Number(match[1]));
29
- if (majors.length === 0 || new Set(majors).size !== 1) {
30
- throw new Error('Publish workflow must reference exactly one Gala action major');
31
- }
32
- const response = await fetchImpl('https://api.github.com/repos/rathnasgala/publish/tags?per_page=100', {
33
- headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10' }
34
- });
35
- if (!response.ok) throw new Error(`Action release lookup failed with HTTP ${response.status}`);
36
- const tags = await response.json();
37
- if (!Array.isArray(tags)) throw new TypeError('Action release response must be an array');
38
- const releasedMajors = tags.map((tag) => ACTION_TAG.exec(tag?.name)?.[1])
39
- .filter((major) => major != null).map(Number);
40
- const currentMajor = majors[0];
41
- const latestMajor = releasedMajors.length === 0 ? currentMajor : Math.max(...releasedMajors);
42
- return Object.freeze({ currentMajor, latestMajor, newerAvailable: latestMajor > currentMajor });
43
- }
44
-
45
- export async function upgradeTheme({ root, channel, confirm, fetchImpl = fetch }) {
46
- const configPath = path.resolve(root, 'site.config.yml');
47
- const config = parse(await readFile(configPath, 'utf8'));
48
- if (config?.canonicalPolicy != null) {
49
- if (config.canonicalPolicy !== 'self' || config.hosting == null || Array.isArray(config.hosting)
50
- || (config.hosting.canonicalPolicy != null && config.hosting.canonicalPolicy !== 'self')) {
51
- throw new TypeError('Legacy canonicalPolicy cannot be migrated safely');
52
- }
53
- config.hosting.canonicalPolicy = 'self';
54
- delete config.canonicalPolicy;
55
- }
56
- const installed = config?.framework?.themePackage?.version;
57
- const [metadata, action] = await Promise.all([
58
- registryMetadata(fetchImpl), inspectActionUpgrade({ root, fetchImpl })
59
- ]);
60
- const selectedChannel = channel ?? (metadata['dist-tags']?.next === installed ? 'next' : 'latest');
61
- if (!['latest', 'next'].includes(selectedChannel)) throw new TypeError('theme channel must be latest or next');
62
- const version = metadata['dist-tags']?.[selectedChannel];
63
- if (typeof version !== 'string') throw new Error(`Theme channel ${selectedChannel} has no resolved version`);
64
- if (version === installed) return { changed: false, channel: selectedChannel, version, repaired: [], action };
65
- if (typeof confirm !== 'function' || !await confirm({ name: NAME, installed, channel: selectedChannel, version })) {
66
- return { changed: false, cancelled: true, channel: selectedChannel, version, repaired: [], action };
67
- }
68
- const downloaded = await fetchVerifiedThemePackage({ name: NAME, version, fetchImpl });
69
- try {
70
- if (!downloaded.manifest.themePackage.availableDesignThemes?.includes(config?.design?.theme)) {
71
- throw new Error(`Visual theme ${String(config?.design?.theme)} is unavailable in ${NAME}@${version}`);
72
- }
73
- config.framework.themePackage = { name: NAME, version };
74
- const repaired = await repairFramework(root, downloaded.staging, {
75
- siteConfiguration: stringify(config)
76
- });
77
- return { changed: true, channel: selectedChannel, version, repaired, action };
78
- } finally {
79
- await rm(downloaded.cleanupRoot, { recursive: true, force: true });
80
- }
81
- }
@@ -1,5 +0,0 @@
1
- export {
2
- BUILD_MANIFEST_PATH,
3
- regenerateBuildManifest,
4
- validateContent
5
- } from '@rathnasgala/content-validation';
@@ -1,87 +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
-
5
- const ACTION_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml@v(?:[1-9][0-9]*|[0-9]+\.[0-9]+\.[0-9]+)$/;
6
- const BRANCH = /^(?![./])(?!.*\.\.)(?!.*[~^:?*\[\\])[A-Za-z0-9._/-]+(?<![/.])$/;
7
-
8
- export function deriveNightlySchedule(siteId) {
9
- if (typeof siteId !== 'string' || siteId.trim() === '') throw new TypeError('siteId is required');
10
- const digest = createHash('sha256').update(siteId, 'utf8').digest();
11
- return { minute: digest.readUInt16BE(0) % 60, hour: digest.readUInt16BE(2) % 24 };
12
- }
13
-
14
- function validateTimezone(timezone) {
15
- try {
16
- new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(0);
17
- } catch {
18
- throw new TypeError(`Invalid IANA timezone: ${timezone}`);
19
- }
20
- }
21
-
22
- export async function writePublishWorkflow({
23
- root,
24
- siteId,
25
- timezone,
26
- actionRef = 'rathnasgala/publish/.github/workflows/publish.yml@v1',
27
- defaultBranch = 'main',
28
- buildMode = 'build-and-deploy'
29
- }) {
30
- validateTimezone(timezone);
31
- if (!ACTION_REF.test(actionRef)) {
32
- throw new TypeError('actionRef must pin a reusable workflow to a major or immutable semver tag');
33
- }
34
- if (!BRANCH.test(defaultBranch)) throw new TypeError('Invalid default branch');
35
- if (!['build-only', 'build-and-deploy'].includes(buildMode)) throw new TypeError('Invalid build mode');
36
-
37
- const resolvedRoot = path.resolve(root);
38
- const templatePath = path.join(resolvedRoot, '.gala', 'publish.yml.template');
39
- const target = path.join(resolvedRoot, '.github', 'workflows', 'publish.yml');
40
- const templateMetadata = await lstat(templatePath);
41
- if (!templateMetadata.isFile() || templateMetadata.isSymbolicLink()) {
42
- throw new TypeError('Workflow template must be a regular file');
43
- }
44
- try {
45
- const targetMetadata = await lstat(target);
46
- if (targetMetadata.isSymbolicLink() || !targetMetadata.isFile()) {
47
- throw new TypeError('Workflow target must be a regular file');
48
- }
49
- } catch (error) {
50
- if (error.code !== 'ENOENT') throw error;
51
- }
52
-
53
- const { minute, hour } = deriveNightlySchedule(siteId);
54
- const workflow = (await readFile(templatePath, 'utf8'))
55
- .replaceAll('__DEFAULT_BRANCH__', defaultBranch)
56
- .replaceAll('__CRON__', `${minute} ${hour} * * *`)
57
- .replaceAll('__TIMEZONE__', timezone)
58
- .replaceAll('__SITE_ID__', siteId)
59
- .replaceAll('__ACTION_REF__', actionRef)
60
- .replaceAll('__BUILD_MODE__', buildMode);
61
- if (/__[A-Z_]+__/.test(workflow)) throw new TypeError('Workflow template contains unresolved placeholders');
62
-
63
- await mkdir(path.dirname(target), { recursive: true });
64
- const temporary = `${target}.gala-workflow-${process.pid}`;
65
- const backup = `${target}.gala-backup-${process.pid}`;
66
- let backedUp = false;
67
- try {
68
- await writeFile(temporary, workflow, { flag: 'wx' });
69
- try {
70
- await rename(target, backup);
71
- backedUp = true;
72
- } catch (error) {
73
- if (error.code !== 'ENOENT') throw error;
74
- }
75
- try {
76
- await rename(temporary, target);
77
- } catch (error) {
78
- if (backedUp) await rename(backup, target);
79
- throw error;
80
- }
81
- if (backedUp) await rm(backup);
82
- } catch (error) {
83
- await rm(temporary, { force: true });
84
- throw error;
85
- }
86
- return { target, minute, hour };
87
- }