@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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { galaApi } from '../api/gala.js';
|
|
5
|
+
import { galaCredential } from '../auth/gala.js';
|
|
6
|
+
import { UsageError } from '../cli/args.js';
|
|
7
|
+
import { createGit } from '../git.js';
|
|
8
|
+
import { readPublication } from '../publication.js';
|
|
9
|
+
|
|
10
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
11
|
+
const MODES = new Map([
|
|
12
|
+
['off', 'OFF'], ['presentation-only', 'PRESENTATION_ONLY'], ['manual', 'MANUAL'],
|
|
13
|
+
['assisted', 'ASSISTED'],
|
|
14
|
+
]);
|
|
15
|
+
const POLICIES = new Map([['nofollow', 'NOFOLLOW'], ['follow', 'FOLLOW']]);
|
|
16
|
+
|
|
17
|
+
export async function prism({ terminal, options, cwd = process.cwd() }) {
|
|
18
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
19
|
+
const publication = await readPublication(root);
|
|
20
|
+
if (!publication || !ULID.test(publication.siteId ?? '')) {
|
|
21
|
+
throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
|
|
22
|
+
}
|
|
23
|
+
const credential = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
24
|
+
const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
|
|
25
|
+
const [action = 'status', ...args] = options.positional;
|
|
26
|
+
|
|
27
|
+
if (action === 'status') {
|
|
28
|
+
requireArgs(args, 0, 'gala prism status');
|
|
29
|
+
const state = await api.json(`/v1/sites/${publication.siteId}/prism`, { action: 'Prism status' });
|
|
30
|
+
terminal.result(`Prism ${state.publishedMode}`);
|
|
31
|
+
terminal.note(`Mode: requested ${state.requestedMode}; published ${state.publishedMode}`);
|
|
32
|
+
terminal.note(`Configuration links: requested ${state.requestedConfigurationLinkPolicy}; published ${state.publishedConfigurationLinkPolicy}`);
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const inventory = await api.json(`/v1/sites/${publication.siteId}/posts`, {
|
|
37
|
+
action: 'Published post inventory',
|
|
38
|
+
});
|
|
39
|
+
const expectedRepositoryHeadSha = inventory.headSha;
|
|
40
|
+
|
|
41
|
+
if (action === 'mode') {
|
|
42
|
+
requireArgs(args, 1, 'gala prism mode <off|presentation-only|manual|assisted>');
|
|
43
|
+
const mode = MODES.get(args[0]);
|
|
44
|
+
if (!mode) throw new UsageError('Prism mode must be off, presentation-only, manual, or assisted.');
|
|
45
|
+
if (mode === 'OFF' || mode === 'PRESENTATION_ONLY') await confirm(terminal, options, `Change Prism mode to ${args[0]}?`);
|
|
46
|
+
const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
|
|
47
|
+
mode, expectedRepositoryHeadSha,
|
|
48
|
+
}, 'Prism mode');
|
|
49
|
+
return settleMutation(api, terminal, publication, result);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'link-policy') {
|
|
53
|
+
const [scope, target, value] = args;
|
|
54
|
+
if (scope === 'site') {
|
|
55
|
+
requireArgs(args, 2, 'gala prism link-policy site <nofollow|follow>');
|
|
56
|
+
const policy = policyValue(target);
|
|
57
|
+
const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
|
|
58
|
+
configurationLinkPolicy: policy, expectedRepositoryHeadSha,
|
|
59
|
+
}, 'Prism link policy');
|
|
60
|
+
return settleMutation(api, terminal, publication, result);
|
|
61
|
+
}
|
|
62
|
+
if (scope === 'work') {
|
|
63
|
+
requireArgs(args, 3, 'gala prism link-policy work <slug> <inherit|nofollow|follow>');
|
|
64
|
+
const post = resolvePost(inventory, target, options.value('language'), publication.defaultLanguage);
|
|
65
|
+
if (value === 'inherit') {
|
|
66
|
+
const result = await mutate(api,
|
|
67
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/prism?fields=configurationLinkPolicy`,
|
|
68
|
+
'DELETE', undefined, 'Prism work link policy', {
|
|
69
|
+
'x-expected-repository-head': expectedRepositoryHeadSha,
|
|
70
|
+
});
|
|
71
|
+
return settleMutation(api, terminal, publication, result);
|
|
72
|
+
}
|
|
73
|
+
const result = await mutate(api,
|
|
74
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/prism`, 'PUT', {
|
|
75
|
+
configurationLinkPolicy: policyValue(value), expectedRepositoryHeadSha,
|
|
76
|
+
}, 'Prism work link policy');
|
|
77
|
+
return settleMutation(api, terminal, publication, result);
|
|
78
|
+
}
|
|
79
|
+
throw new UsageError('Use: gala prism link-policy site <nofollow|follow> or work <slug> <inherit|nofollow|follow>');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (action === 'list') {
|
|
83
|
+
requireArgs(args, 1, 'gala prism list <slug> [--language en]');
|
|
84
|
+
const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
|
|
85
|
+
const result = await configurations(api, publication.siteId, post);
|
|
86
|
+
terminal.result(`${result.configurations.length} configuration${result.configurations.length === 1 ? '' : 's'}`);
|
|
87
|
+
for (const item of result.configurations) {
|
|
88
|
+
terminal.note(`${item.configurationId} ${item.depth}/${item.intent}/${item.modality} ${item.lifecycle}/${item.deliveryState}`);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (action === 'create') {
|
|
94
|
+
requireArgs(args, 1, 'gala prism create <slug> --language en --depth brief --intent orientation');
|
|
95
|
+
const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
|
|
96
|
+
const current = await configurations(api, publication.siteId, post);
|
|
97
|
+
const result = await mutate(api,
|
|
98
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations`, 'POST', {
|
|
99
|
+
language: post.language,
|
|
100
|
+
depth: enumValue(options.value('depth') ?? 'brief', ['signal', 'brief', 'standard', 'complete', 'methods-references'], 'depth'),
|
|
101
|
+
intent: enumValue(options.value('intent') ?? 'orientation', ['orientation', 'story', 'proof', 'practice'], 'intent'),
|
|
102
|
+
modality: enumValue(options.value('modality') ?? 'text', ['text'], 'modality'),
|
|
103
|
+
expectedSourceContentHash: current.sourceRevisionHash,
|
|
104
|
+
hashContract: current.hashContract,
|
|
105
|
+
}, 'Create Prism configuration');
|
|
106
|
+
terminal.result(result.configurationId);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!['edit', 'generate', 'submit', 'approve', 'reject', 'revoke'].includes(action)) {
|
|
111
|
+
throw new UsageError('Unknown Prism action. Run gala prism --help.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const configurationId = args[0];
|
|
115
|
+
if (!ULID.test(configurationId ?? '')) throw new UsageError(`${action} needs a configuration ID.`);
|
|
116
|
+
const resolved = await findConfiguration(api, publication, inventory, configurationId,
|
|
117
|
+
options.value('language'));
|
|
118
|
+
const { post, collection, configuration } = resolved;
|
|
119
|
+
const base = `/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations/${configurationId}`;
|
|
120
|
+
|
|
121
|
+
if (action === 'edit') {
|
|
122
|
+
requireArgs(args, 1, 'gala prism edit <configuration-id> --file proposal.md');
|
|
123
|
+
const filename = options.value('file');
|
|
124
|
+
if (!filename) throw new UsageError('Prism edit needs --file proposal.md.');
|
|
125
|
+
const markdown = await readFile(path.resolve(root, filename), 'utf8');
|
|
126
|
+
const result = await mutate(api, `${base}/revisions`, 'POST', {
|
|
127
|
+
markdown, expectedSourceContentHash: collection.sourceRevisionHash,
|
|
128
|
+
hashContract: collection.hashContract,
|
|
129
|
+
}, 'Save Prism revision');
|
|
130
|
+
terminal.result(result.revisionId);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (action === 'generate') {
|
|
135
|
+
requireArgs(args, 1, 'gala prism generate <configuration-id>');
|
|
136
|
+
const result = await mutate(api, `${base}/generation-jobs`, 'POST', {
|
|
137
|
+
expectedSourceContentHash: collection.sourceRevisionHash,
|
|
138
|
+
hashContract: collection.hashContract,
|
|
139
|
+
}, 'Generate Prism proposal');
|
|
140
|
+
return settleGeneration(api, terminal, publication.siteId, post.articleId,
|
|
141
|
+
configurationId, result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const revisionId = args[1] ?? configuration.workingRevision?.revisionId;
|
|
145
|
+
if (action !== 'revoke' && !ULID.test(revisionId ?? '')) {
|
|
146
|
+
throw new UsageError(`${action} needs a revision ID when there is no working revision.`);
|
|
147
|
+
}
|
|
148
|
+
if (action === 'submit') {
|
|
149
|
+
if (args.length > 2) throw new UsageError('Use: gala prism submit <configuration-id> [revision-id]');
|
|
150
|
+
if (revisionId !== configuration.workingRevision?.revisionId) {
|
|
151
|
+
throw new UsageError('Only the current working revision can be submitted. Refresh the configuration and try again.');
|
|
152
|
+
}
|
|
153
|
+
const warnings = configuration.workingRevision.literalFindings
|
|
154
|
+
?.filter((item) => item.severity === 'WARNING') ?? [];
|
|
155
|
+
for (const warning of warnings) terminal.note(`Warning ${warning.id}: ${warning.message}`);
|
|
156
|
+
if (warnings.length > 0) {
|
|
157
|
+
await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
|
|
158
|
+
}
|
|
159
|
+
const result = await mutate(api, `${base}/submit`, 'POST', expectation(collection, {
|
|
160
|
+
revisionId, acknowledgedWarningIds: warnings.map((warning) => warning.id),
|
|
161
|
+
}), 'Submit Prism revision');
|
|
162
|
+
terminal.result(`${result.revisionId} ${result.reviewState}`);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const reason = options.value('reason');
|
|
167
|
+
if (action === 'approve') {
|
|
168
|
+
if (args.length > 2) throw new UsageError('Use: gala prism approve <configuration-id> [revision-id] [--yes]');
|
|
169
|
+
if (revisionId !== configuration.workingRevision?.revisionId) {
|
|
170
|
+
throw new UsageError('Only the current working revision can be approved. Refresh the configuration and try again.');
|
|
171
|
+
}
|
|
172
|
+
const changed = await createGit({ root }).run(
|
|
173
|
+
['status', '--porcelain', '--', `content/posts/${post.slug}`], { capture: true });
|
|
174
|
+
if (changed) throw new UsageError('The canonical post has uncommitted edits. Publish or revert them before approval.');
|
|
175
|
+
const warnings = configuration.workingRevision?.literalFindings
|
|
176
|
+
?.filter((item) => item.severity === 'WARNING').map((item) => item.id) ?? [];
|
|
177
|
+
for (const warning of configuration.workingRevision?.literalFindings
|
|
178
|
+
?.filter((item) => item.severity === 'WARNING') ?? []) {
|
|
179
|
+
terminal.note(`Warning ${warning.id}: ${warning.message}`);
|
|
180
|
+
}
|
|
181
|
+
if (warnings.length > 0) {
|
|
182
|
+
await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
|
|
183
|
+
}
|
|
184
|
+
await confirm(terminal, options, 'Approve this revision and publish it?');
|
|
185
|
+
const result = await mutate(api, `${base}/approve`, 'POST', expectation(collection, {
|
|
186
|
+
revisionId, expectedRepositoryHeadSha, acknowledgedWarningIds: warnings,
|
|
187
|
+
}), 'Approve Prism revision');
|
|
188
|
+
return settleMutation(api, terminal, publication, result);
|
|
189
|
+
}
|
|
190
|
+
if (!reason) throw new UsageError(`Prism ${action} needs --reason.`);
|
|
191
|
+
await confirm(terminal, options, `${action === 'reject' ? 'Reject this revision' : 'Revoke this configuration'}?`);
|
|
192
|
+
const suffix = action === 'reject' ? '/reject' : '/revoke';
|
|
193
|
+
const body = action === 'reject'
|
|
194
|
+
? expectation(collection, { revisionId, reason })
|
|
195
|
+
: expectation(collection, { expectedRepositoryHeadSha, reason });
|
|
196
|
+
const result = await mutate(api, `${base}${suffix}`, 'POST', body, `Prism ${action}`);
|
|
197
|
+
if (result.materialization) return settleMutation(api, terminal, publication, result);
|
|
198
|
+
terminal.result(result.reviewState ?? action);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function requireArgs(args, count, usage) {
|
|
203
|
+
if (args.length !== count) throw new UsageError(`Use: ${usage}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolvePost(inventory, slug, requestedLanguage, defaultLanguage) {
|
|
207
|
+
const variants = inventory.posts.filter((post) => post.slug === slug);
|
|
208
|
+
const post = variants.find((item) => item.language === requestedLanguage)
|
|
209
|
+
?? variants.find((item) => item.language === defaultLanguage)
|
|
210
|
+
?? variants[0];
|
|
211
|
+
if (!post) throw new UsageError(`No published work uses the slug ${slug}.`);
|
|
212
|
+
if (!post.articleId) throw new UsageError('Publish this work once before creating configurations.');
|
|
213
|
+
return post;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function findConfiguration(api, publication, inventory, id, language) {
|
|
217
|
+
for (const post of inventory.posts.filter((item) => item.articleId
|
|
218
|
+
&& (!language || item.language === language))) {
|
|
219
|
+
const collection = await configurations(api, publication.siteId, post);
|
|
220
|
+
const configuration = collection.configurations.find((item) => item.configurationId === id);
|
|
221
|
+
if (configuration) return { post, collection, configuration };
|
|
222
|
+
}
|
|
223
|
+
throw new UsageError(`Configuration ${id} is not part of this publication.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function configurations(api, siteId, post) {
|
|
227
|
+
return api.json(`/v1/sites/${siteId}/articles/${post.articleId}/configurations?language=${encodeURIComponent(post.language)}`, {
|
|
228
|
+
action: 'Prism configurations',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mutate(api, path, method, body, action, extraHeaders = {}) {
|
|
233
|
+
return api.json(path, {
|
|
234
|
+
action, method,
|
|
235
|
+
headers: {
|
|
236
|
+
'content-type': 'application/json', 'idempotency-key': crypto.randomUUID(), ...extraHeaders,
|
|
237
|
+
},
|
|
238
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function expectation(collection, extra) {
|
|
243
|
+
return {
|
|
244
|
+
...extra,
|
|
245
|
+
expectedSourceContentHash: collection.sourceRevisionHash,
|
|
246
|
+
hashContract: collection.hashContract,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function policyValue(value) {
|
|
251
|
+
const policy = POLICIES.get(value);
|
|
252
|
+
if (!policy) throw new UsageError('Link policy must be nofollow or follow.');
|
|
253
|
+
return policy;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function enumValue(value, allowed, name) {
|
|
257
|
+
if (!allowed.includes(value)) throw new UsageError(`${name} must be one of: ${allowed.join(', ')}.`);
|
|
258
|
+
return value.replaceAll('-', '_').toUpperCase();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function confirm(terminal, options, question) {
|
|
262
|
+
if (options.on('yes')) return;
|
|
263
|
+
const answer = await terminal.ask(`${question} Type yes to continue.`);
|
|
264
|
+
if (answer.toLowerCase() !== 'yes') throw new UsageError('Cancelled.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function showMutation(terminal, result) {
|
|
268
|
+
terminal.result(`Queued ${result.materialization?.materializationId ?? result.materializationId}`);
|
|
269
|
+
if (result.settings?.requestedEffectiveMode) {
|
|
270
|
+
terminal.note(`Effective when published: ${result.settings.requestedEffectiveMode} / ${result.settings.requestedEffectiveConfigurationLinkPolicy}`);
|
|
271
|
+
} else if (result.settings) {
|
|
272
|
+
terminal.note(`Requested: ${result.settings.requestedMode} / ${result.settings.requestedConfigurationLinkPolicy}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function settleMutation(api, terminal, publication, result) {
|
|
277
|
+
showMutation(terminal, result);
|
|
278
|
+
const initial = result.materialization;
|
|
279
|
+
if (!initial?.materializationId) return result;
|
|
280
|
+
|
|
281
|
+
let state = initial;
|
|
282
|
+
const deadline = Date.now() + 31 * 60_000;
|
|
283
|
+
while (!['COMMITTED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
|
|
284
|
+
await delay(state.status === 'RETRY_WAIT' ? 10_000 : 3_000);
|
|
285
|
+
state = await api.json(
|
|
286
|
+
`/v1/sites/${publication.siteId}/prism/materializations/${initial.materializationId}`,
|
|
287
|
+
{ action: 'Prism repository materialization' },
|
|
288
|
+
);
|
|
289
|
+
terminal.note(`Repository: ${state.status} (attempt ${state.attemptCount})`);
|
|
290
|
+
}
|
|
291
|
+
if (state.status === 'FAILED') {
|
|
292
|
+
throw new UsageError(`Repository update failed (${state.errorCode ?? 'unknown error'}). Run the command again after correcting the cause.`);
|
|
293
|
+
}
|
|
294
|
+
if (state.status !== 'COMMITTED') {
|
|
295
|
+
throw new UsageError('Repository update did not finish before the 31-minute tracking deadline. Check gala prism status before retrying.');
|
|
296
|
+
}
|
|
297
|
+
if (!state.publicationAttemptSha) {
|
|
298
|
+
terminal.result('Repository updated. No publication attempt was returned.');
|
|
299
|
+
return { ...result, materialization: state };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
terminal.note(`Repository committed at ${state.commitSha ?? state.publicationAttemptSha}. Verifying publication.`);
|
|
303
|
+
const publicationState = await waitForPublication(api, terminal, publication.siteId,
|
|
304
|
+
state.publicationAttemptSha, deadline);
|
|
305
|
+
if (publicationState.status === 'FAILED') {
|
|
306
|
+
throw new UsageError(`Publication failed (${publicationState.errors?.[0]?.message ?? 'unknown error'}).`);
|
|
307
|
+
}
|
|
308
|
+
if (publicationState.status !== 'PUBLISHED') {
|
|
309
|
+
throw new UsageError('Publication did not finish before the 31-minute tracking deadline.');
|
|
310
|
+
}
|
|
311
|
+
const live = publicationUrl(publication);
|
|
312
|
+
terminal.result(live ? `Published at ${live}` : 'Published.');
|
|
313
|
+
return { ...result, materialization: state, publicationAttempt: publicationState };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function waitForPublication(api, terminal, siteId, commitSha, deadline) {
|
|
317
|
+
let state;
|
|
318
|
+
do {
|
|
319
|
+
state = await api.json(`/v1/sites/${siteId}/publication-attempts/${commitSha}`, {
|
|
320
|
+
action: 'Prism publication attempt',
|
|
321
|
+
});
|
|
322
|
+
terminal.note(`Publication: ${state.status}`);
|
|
323
|
+
if (['PUBLISHED', 'FAILED'].includes(state.status)) return state;
|
|
324
|
+
await delay(5_000);
|
|
325
|
+
} while (Date.now() < deadline);
|
|
326
|
+
return state;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function publicationUrl(publication) {
|
|
330
|
+
if (publication.url) return publication.url.replace(/\/$/, '');
|
|
331
|
+
const base = publication.canonicalBaseUrl ?? publication.hosting?.canonicalBaseUrl;
|
|
332
|
+
const prefix = publication.pathPrefix ?? publication.hosting?.pathPrefix ?? '';
|
|
333
|
+
return base ? `${base.replace(/\/$/, '')}/${prefix.replace(/^\//, '').replace(/\/$/, '')}`.replace(/\/$/, '') : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function delay(milliseconds) {
|
|
337
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function settleGeneration(api, terminal, siteId, articleId, configurationId, initial) {
|
|
341
|
+
terminal.result(`${initial.jobId} ${initial.status}`);
|
|
342
|
+
let state = initial;
|
|
343
|
+
const deadline = Date.now() + 31 * 60_000;
|
|
344
|
+
while (!['SUCCEEDED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
|
|
345
|
+
await delay(3_000);
|
|
346
|
+
state = await api.json(
|
|
347
|
+
`/v1/sites/${siteId}/articles/${articleId}/configurations/${configurationId}/generation-jobs/${initial.jobId}`,
|
|
348
|
+
{ action: 'Prism generation job' },
|
|
349
|
+
);
|
|
350
|
+
terminal.note(`Generation: ${state.status} (attempt ${state.attemptCount})`);
|
|
351
|
+
}
|
|
352
|
+
if (state.status === 'FAILED') {
|
|
353
|
+
throw new UsageError(`Proposal generation failed (${state.errorCode ?? 'unknown error'}).`);
|
|
354
|
+
}
|
|
355
|
+
if (state.status !== 'SUCCEEDED') {
|
|
356
|
+
throw new UsageError('Proposal generation did not finish before the 31-minute tracking deadline.');
|
|
357
|
+
}
|
|
358
|
+
terminal.result(`Proposal revision ${state.revisionId} is ready for review.`);
|
|
359
|
+
return state;
|
|
360
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { checkContent } from '../content.js';
|
|
4
|
+
import { createGit } from '../git.js';
|
|
5
|
+
import { githubCredential } from '../auth/github.js';
|
|
6
|
+
import { readPublication } from '../publication.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates, records the writer's work, and sends it to GitHub.
|
|
10
|
+
*
|
|
11
|
+
* v0 only sent — the writer had to remember to record their changes first, and a run that appeared
|
|
12
|
+
* to succeed could ship nothing at all. It also used the machine's git credential rather than the
|
|
13
|
+
* one the CLI holds, which fails for anyone whose accounts differ.
|
|
14
|
+
*
|
|
15
|
+
* The order below is the whole of the difficulty. Every successful publish adds a deployment record
|
|
16
|
+
* to the branch from the workflow, so the checkout is behind by one before the writer has touched
|
|
17
|
+
* anything — catching up is the normal condition, not a race fix. It has to happen *before*
|
|
18
|
+
* validation, because validation assigns a content id to any post missing one and the workflow
|
|
19
|
+
* assigns ids remotely as well; the other order has both sides edit the same file with different
|
|
20
|
+
* ids, which git can only report as a conflict.
|
|
21
|
+
*
|
|
22
|
+
* Publishing itself happens on GitHub: the workflow in the repository builds and deploys. This
|
|
23
|
+
* command's job ends when the work is on the branch.
|
|
24
|
+
*/
|
|
25
|
+
export async function publish({ terminal, options, cwd = process.cwd(), regenerate }) {
|
|
26
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
27
|
+
const today = options.value('today');
|
|
28
|
+
|
|
29
|
+
const github = await githubCredential({ terminal });
|
|
30
|
+
const git = createGit({ root, token: github.accessToken });
|
|
31
|
+
|
|
32
|
+
terminal.step('Catching up with GitHub');
|
|
33
|
+
await git.takeRemote();
|
|
34
|
+
|
|
35
|
+
if (!options.on('skip-checks')) {
|
|
36
|
+
terminal.step('Checking content');
|
|
37
|
+
await checkContent({ terminal, root, today, ...(regenerate == null ? {} : { regenerate }) });
|
|
38
|
+
terminal.done('Content is valid');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const recorded = await git.record('Publish', ['.']);
|
|
42
|
+
if (!recorded) {
|
|
43
|
+
terminal.done('Nothing new to send');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
terminal.done('Changes recorded');
|
|
47
|
+
|
|
48
|
+
terminal.step('Sending to GitHub');
|
|
49
|
+
await git.send();
|
|
50
|
+
|
|
51
|
+
terminal.done('Sent');
|
|
52
|
+
|
|
53
|
+
const publication = await readPublication(root);
|
|
54
|
+
if (publication != null) terminal.result(publication.url);
|
|
55
|
+
terminal.blank();
|
|
56
|
+
terminal.note('GitHub is building it now; give it a minute or two.');
|
|
57
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, lstat, mkdtemp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { x as extractTar } from 'tar';
|
|
6
|
+
|
|
7
|
+
const PACKAGE = '@rathnasgala/theme';
|
|
8
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
9
|
+
const PROTECTED = ['.git/', 'content/', 'custom.css', 'site.config.yml'];
|
|
10
|
+
|
|
11
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const safeManagedPath = (value) => typeof value === 'string' && value !== ''
|
|
13
|
+
&& !path.isAbsolute(value) && !value.split('/').includes('..')
|
|
14
|
+
&& !PROTECTED.some((protectedPath) => value === protectedPath || value.startsWith(protectedPath));
|
|
15
|
+
|
|
16
|
+
async function exists(file) {
|
|
17
|
+
try { await stat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifyIntegrity(bytes, integrity) {
|
|
21
|
+
const [algorithm, expected] = String(integrity ?? '').split('-', 2);
|
|
22
|
+
if (!['sha512', 'sha256'].includes(algorithm) || !expected) {
|
|
23
|
+
throw new Error('The registry did not provide supported package integrity metadata');
|
|
24
|
+
}
|
|
25
|
+
const actual = createHash(algorithm).update(bytes).digest('base64');
|
|
26
|
+
if (actual !== expected) throw new Error('The downloaded theme failed registry integrity verification');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function registryRelease(channel, fetchImpl) {
|
|
30
|
+
const response = await fetchImpl(`${REGISTRY}/${encodeURIComponent(PACKAGE)}`, {
|
|
31
|
+
headers: { Accept: 'application/json' },
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) throw new Error(`Theme registry lookup failed with HTTP ${response.status}`);
|
|
34
|
+
const metadata = await response.json();
|
|
35
|
+
const version = metadata?.['dist-tags']?.[channel];
|
|
36
|
+
const release = metadata?.versions?.[version];
|
|
37
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version ?? '') || !release?.dist?.tarball) {
|
|
38
|
+
throw new Error(`Theme channel ${channel} has no valid release`);
|
|
39
|
+
}
|
|
40
|
+
if (release.scripts && Object.keys(release.scripts).length > 0) {
|
|
41
|
+
throw new Error('Theme release contains lifecycle scripts and was refused');
|
|
42
|
+
}
|
|
43
|
+
return { version, tarball: release.dist.tarball, integrity: release.dist.integrity };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function unpackRelease(release, fetchImpl) {
|
|
47
|
+
const response = await fetchImpl(release.tarball);
|
|
48
|
+
if (!response.ok) throw new Error(`Theme download failed with HTTP ${response.status}`);
|
|
49
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
50
|
+
verifyIntegrity(bytes, release.integrity);
|
|
51
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'gala-theme-upgrade-'));
|
|
52
|
+
const archive = path.join(temporary, 'theme.tgz');
|
|
53
|
+
const extracted = path.join(temporary, 'unpacked');
|
|
54
|
+
await mkdir(extracted);
|
|
55
|
+
await writeFile(archive, bytes);
|
|
56
|
+
await extractTar({ file: archive, cwd: extracted, strip: 1, strict: true });
|
|
57
|
+
return { temporary, payload: path.join(extracted, 'payload') };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readManifest(file) {
|
|
61
|
+
const manifest = JSON.parse(await readFile(file, 'utf8'));
|
|
62
|
+
if (manifest?.schemaVersion !== 1 || typeof manifest.files !== 'object') {
|
|
63
|
+
throw new Error('Theme managed-file manifest is invalid');
|
|
64
|
+
}
|
|
65
|
+
for (const managed of Object.keys(manifest.files)) {
|
|
66
|
+
if (!safeManagedPath(managed)) throw new Error(`Theme release contains protected path: ${managed}`);
|
|
67
|
+
}
|
|
68
|
+
return manifest;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function verifyPayload(payload, manifest) {
|
|
72
|
+
for (const [managed, expected] of Object.entries(manifest.files)) {
|
|
73
|
+
const artifact = manifest.artifactSources?.[managed] ?? managed;
|
|
74
|
+
if (!safeManagedPath(artifact)) throw new Error(`Theme artifact path is unsafe: ${artifact}`);
|
|
75
|
+
const bytes = await readFile(path.join(payload, artifact));
|
|
76
|
+
if (sha256(bytes) !== expected) throw new Error(`Theme payload hash mismatch: ${managed}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertNoManagedDrift(root, installed) {
|
|
81
|
+
for (const [managed, expected] of Object.entries(installed.files)) {
|
|
82
|
+
const file = path.join(root, managed);
|
|
83
|
+
if (!await exists(file) || (await lstat(file)).isSymbolicLink() || sha256(await readFile(file)) !== expected) {
|
|
84
|
+
throw new Error(`${managed} has local changes; restore it with gala doctor before upgrading`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function replaceFile(target, bytes) {
|
|
90
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
91
|
+
const temporary = `${target}.gala-upgrade-${process.pid}`;
|
|
92
|
+
await writeFile(temporary, bytes);
|
|
93
|
+
await rename(temporary, target);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function applyRelease(root, payload, installed, available) {
|
|
97
|
+
const configFile = path.join(root, 'site.config.yml');
|
|
98
|
+
const manifestFile = path.join(root, '.gala', 'managed-files.json');
|
|
99
|
+
const config = await readFile(configFile, 'utf8');
|
|
100
|
+
const updated = config.replace(
|
|
101
|
+
/(themePackage:\s*\n(?:\s+.*\n)*?\s+version:\s*)[^\s#]+/,
|
|
102
|
+
`$1${available.themePackage.version}`,
|
|
103
|
+
);
|
|
104
|
+
if (updated === config) throw new Error('site.config.yml has no framework.themePackage.version');
|
|
105
|
+
const backup = await mkdtemp(path.join(tmpdir(), 'gala-theme-rollback-'));
|
|
106
|
+
const previousFiles = Object.keys(installed.files);
|
|
107
|
+
try {
|
|
108
|
+
for (const managed of previousFiles) {
|
|
109
|
+
const target = path.join(root, managed);
|
|
110
|
+
const saved = path.join(backup, managed);
|
|
111
|
+
await mkdir(path.dirname(saved), { recursive: true });
|
|
112
|
+
await copyFile(target, saved);
|
|
113
|
+
}
|
|
114
|
+
await copyFile(configFile, path.join(backup, 'site.config.yml'));
|
|
115
|
+
await copyFile(manifestFile, path.join(backup, 'managed-files.json'));
|
|
116
|
+
for (const managed of previousFiles) {
|
|
117
|
+
if (available.files[managed] == null) await rm(path.join(root, managed));
|
|
118
|
+
}
|
|
119
|
+
for (const managed of Object.keys(available.files)) {
|
|
120
|
+
const artifact = available.artifactSources?.[managed] ?? managed;
|
|
121
|
+
await replaceFile(path.join(root, managed), await readFile(path.join(payload, artifact)));
|
|
122
|
+
}
|
|
123
|
+
await replaceFile(configFile, updated);
|
|
124
|
+
await replaceFile(manifestFile, `${JSON.stringify(available, null, 2)}\n`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
for (const managed of Object.keys(available.files)) {
|
|
127
|
+
if (installed.files[managed] == null) await rm(path.join(root, managed), { force: true });
|
|
128
|
+
}
|
|
129
|
+
for (const managed of previousFiles) {
|
|
130
|
+
await replaceFile(path.join(root, managed), await readFile(path.join(backup, managed)));
|
|
131
|
+
}
|
|
132
|
+
await replaceFile(configFile, await readFile(path.join(backup, 'site.config.yml')));
|
|
133
|
+
await replaceFile(manifestFile, await readFile(path.join(backup, 'managed-files.json')));
|
|
134
|
+
throw error;
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(backup, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function upgrade({ terminal, options, cwd = process.cwd(), fetchImpl = fetch }) {
|
|
141
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
142
|
+
const channel = options.value('channel') ?? 'latest';
|
|
143
|
+
if (!['latest', 'next'].includes(channel)) throw new Error('channel must be latest or next');
|
|
144
|
+
const installed = await readManifest(path.join(root, '.gala', 'managed-files.json'));
|
|
145
|
+
const release = await registryRelease(channel, fetchImpl);
|
|
146
|
+
terminal.result(`Theme ${installed.themePackage.version} → ${release.version} (${channel})`);
|
|
147
|
+
if (installed.themePackage.version === release.version) {
|
|
148
|
+
terminal.note('Already current.');
|
|
149
|
+
return { changed: false, version: release.version };
|
|
150
|
+
}
|
|
151
|
+
if (!options.on('yes')) {
|
|
152
|
+
const answer = await terminal.ask('Apply this managed theme upgrade? [y/N]', { fallback: 'no' });
|
|
153
|
+
if (!/^y(?:es)?$/i.test(answer)) {
|
|
154
|
+
terminal.note('Nothing changed.');
|
|
155
|
+
return { changed: false, version: release.version };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await assertNoManagedDrift(root, installed);
|
|
159
|
+
const unpacked = await unpackRelease(release, fetchImpl);
|
|
160
|
+
try {
|
|
161
|
+
const available = await readManifest(path.join(unpacked.payload, '.gala', 'managed-files.json'));
|
|
162
|
+
if (available.themePackage.version !== release.version) {
|
|
163
|
+
throw new Error('Theme package version does not match its managed manifest');
|
|
164
|
+
}
|
|
165
|
+
await verifyPayload(unpacked.payload, available);
|
|
166
|
+
await applyRelease(root, unpacked.payload, installed, available);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(unpacked.temporary, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
terminal.done(`Upgraded managed theme to ${release.version}`);
|
|
171
|
+
terminal.note('Run gala preview, then gala publish when the result is approved.');
|
|
172
|
+
return { changed: true, version: release.version };
|
|
173
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { auth } from './commands/auth.js';
|
|
2
|
+
import { doctor } from './commands/doctor.js';
|
|
3
|
+
import { domain } from './commands/domain.js';
|
|
4
|
+
import { init } from './commands/init.js';
|
|
5
|
+
import { createPost } from './commands/new.js';
|
|
6
|
+
import { preview } from './commands/preview.js';
|
|
7
|
+
import { publish } from './commands/publish.js';
|
|
8
|
+
import { prism } from './commands/prism.js';
|
|
9
|
+
import { upgrade } from './commands/upgrade.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Commands in the order a writer meets them.
|
|
13
|
+
*
|
|
14
|
+
* v0 had fifteen, and the extra nine were the ones nobody could keep working: a `validate` a hook
|
|
15
|
+
* ran behind the writer's back, a `workflow` writer for a file the server owns, a
|
|
16
|
+
* `record-deployment` nothing called, a `configure` duplicating options another command took.
|
|
17
|
+
* Each was a surface to keep correct and a way to be wrong.
|
|
18
|
+
*
|
|
19
|
+
* This lives apart from the dispatcher so the README can be checked against it. v0's README
|
|
20
|
+
* outlived its commands by weeks — it taught a command that never existed — because nothing tied
|
|
21
|
+
* the two together.
|
|
22
|
+
*
|
|
23
|
+
* Each entry carries its own options. Nothing is parsed globally, so an option cannot mean two
|
|
24
|
+
* things in two places, which is how one ended up read twice with different defaults.
|
|
25
|
+
*/
|
|
26
|
+
export const COMMANDS = {
|
|
27
|
+
auth: {
|
|
28
|
+
summary: 'Sign in to Gala and GitHub',
|
|
29
|
+
flags: ['api-base-url'],
|
|
30
|
+
run: auth
|
|
31
|
+
},
|
|
32
|
+
init: {
|
|
33
|
+
summary: 'Create a publication in an empty directory',
|
|
34
|
+
usage: 'gala init [directory] [--name my-notes] [--domain blog.example.com]',
|
|
35
|
+
flags: ['name', 'domain', 'api-base-url'],
|
|
36
|
+
run: init
|
|
37
|
+
},
|
|
38
|
+
domain: {
|
|
39
|
+
summary: 'Inspect or change this publication’s custom domain',
|
|
40
|
+
usage: 'gala domain [status|set <hostname>|check|cancel|remove] [--root path]',
|
|
41
|
+
flags: ['root', 'api-base-url'],
|
|
42
|
+
run: domain
|
|
43
|
+
},
|
|
44
|
+
new: {
|
|
45
|
+
summary: 'Start a post',
|
|
46
|
+
usage: 'gala new "A durable idea" [--language en]',
|
|
47
|
+
flags: ['root', 'language', 'today'],
|
|
48
|
+
run: createPost
|
|
49
|
+
},
|
|
50
|
+
preview: {
|
|
51
|
+
summary: 'Build and serve the publication locally',
|
|
52
|
+
flags: ['root', 'today'],
|
|
53
|
+
run: preview
|
|
54
|
+
},
|
|
55
|
+
publish: {
|
|
56
|
+
summary: 'Check, record and send your work to GitHub',
|
|
57
|
+
flags: ['root', 'today'],
|
|
58
|
+
switches: ['skip-checks'],
|
|
59
|
+
run: publish
|
|
60
|
+
},
|
|
61
|
+
prism: {
|
|
62
|
+
summary: 'Manage author-approved Prism configurations',
|
|
63
|
+
usage: 'gala prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]',
|
|
64
|
+
flags: ['root', 'api-base-url', 'language', 'depth', 'intent', 'modality', 'file', 'reason'],
|
|
65
|
+
switches: ['yes'],
|
|
66
|
+
run: prism
|
|
67
|
+
},
|
|
68
|
+
upgrade: {
|
|
69
|
+
summary: 'Inspect and apply a verified managed-theme update',
|
|
70
|
+
usage: 'gala upgrade [--channel latest|next] [--yes]',
|
|
71
|
+
flags: ['root', 'channel'],
|
|
72
|
+
switches: ['yes'],
|
|
73
|
+
run: upgrade
|
|
74
|
+
},
|
|
75
|
+
doctor: {
|
|
76
|
+
summary: 'Check a publication and say what is wrong',
|
|
77
|
+
flags: ['root', 'api-base-url'],
|
|
78
|
+
run: doctor
|
|
79
|
+
}
|
|
80
|
+
};
|