@iodes/releasekit 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -5
- package/dist/assets.d.ts +4 -3
- package/dist/assets.js +4 -2
- package/dist/cli.js +115 -27
- package/dist/content.d.ts +7 -5
- package/dist/content.js +3 -1
- package/dist/export.d.ts +1 -0
- package/dist/export.js +33 -17
- package/dist/files.js +7 -2
- package/dist/images.d.ts +5 -3
- package/dist/images.js +3 -1
- package/dist/install.d.ts +36 -3
- package/dist/install.js +33 -5
- package/dist/migrate-config.d.ts +10 -0
- package/dist/migrate-config.js +44 -0
- package/dist/model.d.ts +69 -7
- package/dist/model.js +28 -5
- package/dist/move.d.ts +24 -0
- package/dist/move.js +292 -0
- package/dist/project.d.ts +13 -5
- package/dist/project.js +187 -33
- package/dist/refs.d.ts +6 -0
- package/dist/refs.js +29 -0
- package/dist/setup.d.ts +18 -0
- package/dist/setup.js +55 -0
- package/dist/status.d.ts +31 -0
- package/dist/status.js +52 -0
- package/dist/validate.d.ts +5 -2
- package/dist/validate.js +31 -17
- package/kit/references/adoption.md +3 -1
- package/kit/references/channels.md +88 -0
- package/kit/references/format.md +15 -5
- package/kit/references/workflow.md +4 -4
- package/kit/skills/releasekit-draft/SKILL.md +3 -1
- package/kit/skills/releasekit-finalize/SKILL.md +4 -2
- package/kit/skills/releasekit-image/SKILL.md +2 -0
- package/package.json +2 -1
- package/schemas/bundle.schema.json +54 -5
- package/schemas/config.schema.json +125 -9
- package/schemas/release.schema.json +46 -5
package/dist/move.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { Project, linearHistory } from './project.js';
|
|
5
|
+
import { visualSchema } from './model.js';
|
|
6
|
+
import { ref, refKey, parseRef, link } from './refs.js';
|
|
7
|
+
import { canonical, digest, exists, parseYaml, readNote, within, write } from './files.js';
|
|
8
|
+
import { validate } from './validate.js';
|
|
9
|
+
import { imagePrompt, sceneHash } from './prompts.js';
|
|
10
|
+
const inside = (root, file) => {
|
|
11
|
+
const relative = path.relative(root, file);
|
|
12
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
13
|
+
};
|
|
14
|
+
function yamlBytes(value, original) {
|
|
15
|
+
const text = stringify(value, { lineWidth: 100 });
|
|
16
|
+
return Buffer.from(original.includes(Buffer.from('\r\n')) ? text.replace(/\n/g, '\r\n') : text);
|
|
17
|
+
}
|
|
18
|
+
function historySettings(config, channel) {
|
|
19
|
+
if (channel === undefined)
|
|
20
|
+
return config.history ??= {};
|
|
21
|
+
if (!config.channels || !config.channels[channel])
|
|
22
|
+
throw new Error(`Unknown channel: ${channel}`);
|
|
23
|
+
return config.channels[channel].history ??= {};
|
|
24
|
+
}
|
|
25
|
+
export async function moveReleases(project, versions, options) {
|
|
26
|
+
if ((options.toChannel !== undefined) === !!options.toUnchanneled)
|
|
27
|
+
throw new Error('Specify exactly one of --to-channel or --to-unchanneled.');
|
|
28
|
+
if (!versions.length || new Set(versions).size !== versions.length)
|
|
29
|
+
throw new Error('Choose one or more unique release versions.');
|
|
30
|
+
if (options.after !== undefined && options.atStart)
|
|
31
|
+
throw new Error('--after and --at-start cannot be combined.');
|
|
32
|
+
if (options.fromChannel !== undefined)
|
|
33
|
+
await project.requireChannel(options.fromChannel);
|
|
34
|
+
if (options.toChannel !== undefined)
|
|
35
|
+
await project.requireChannel(options.toChannel);
|
|
36
|
+
const destinationChannel = options.toChannel;
|
|
37
|
+
if (options.fromChannel === destinationChannel)
|
|
38
|
+
throw new Error('The source and destination are the same.');
|
|
39
|
+
const crossing = (options.fromChannel === undefined) !== (destinationChannel === undefined);
|
|
40
|
+
if (!crossing && (options.after !== undefined || options.atStart))
|
|
41
|
+
throw new Error('Channel-to-channel moves preserve position; insertion options only apply when crossing histories.');
|
|
42
|
+
const config = await project.config();
|
|
43
|
+
const updatedConfig = structuredClone(config);
|
|
44
|
+
const all = await Promise.all((await project.allRefs()).map(r => project.release(r)));
|
|
45
|
+
const entries = all.map(r => ({ before: r, after: structuredClone(r) }));
|
|
46
|
+
const byOldKey = new Map(entries.map(e => [refKey(e.before), e]));
|
|
47
|
+
const selected = versions.map(version => {
|
|
48
|
+
const identity = ref({ channel: options.fromChannel, version });
|
|
49
|
+
const entry = byOldKey.get(refKey(identity));
|
|
50
|
+
if (!entry)
|
|
51
|
+
throw new Error(`Missing release: ${refKey(identity)}`);
|
|
52
|
+
return entry;
|
|
53
|
+
});
|
|
54
|
+
const selectedKeys = new Set(selected.map(e => refKey(e.before)));
|
|
55
|
+
const channelChain = linearHistory(all.filter(r => r.channel !== undefined));
|
|
56
|
+
// Existing unchanneled projects may have multiple lines; a crossing move requires one unambiguous line.
|
|
57
|
+
const singleChain = crossing ? linearHistory(all.filter(r => r.channel === undefined)) : [];
|
|
58
|
+
const sourceChain = options.fromChannel === undefined ? singleChain : channelChain;
|
|
59
|
+
const ordered = sourceChain.filter(r => selectedKeys.has(refKey(r))).reverse();
|
|
60
|
+
const relocations = [];
|
|
61
|
+
for (const entry of selected) {
|
|
62
|
+
const destination = ref({ version: entry.before.version, channel: destinationChannel });
|
|
63
|
+
const from = await project.releaseDir(entry.before);
|
|
64
|
+
const to = await project.releaseDir(destination);
|
|
65
|
+
if (entries.some(e => refKey(e.before).toLowerCase() === refKey(destination).toLowerCase()))
|
|
66
|
+
throw new Error(`Destination already exists: ${refKey(destination)}`);
|
|
67
|
+
if (await exists(to))
|
|
68
|
+
throw new Error(`Destination already exists: ${refKey(destination)}`);
|
|
69
|
+
for (const other of entries) {
|
|
70
|
+
if (other === entry)
|
|
71
|
+
continue;
|
|
72
|
+
const directory = await project.releaseDir(other.before);
|
|
73
|
+
if (inside(from, directory) || inside(directory, from) || inside(to, directory) || inside(directory, to)) {
|
|
74
|
+
throw new Error('Release directories overlap; resolve the directory collision before moving.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const prior of relocations)
|
|
78
|
+
if (inside(prior.to, to) || inside(to, prior.to))
|
|
79
|
+
throw new Error('Move destinations overlap.');
|
|
80
|
+
delete entry.after.channel;
|
|
81
|
+
Object.assign(entry.after, destination);
|
|
82
|
+
relocations.push({ from, realFrom: await fs.realpath(from), to, identity: ref(entry.before), destination });
|
|
83
|
+
}
|
|
84
|
+
const relink = (chain) => {
|
|
85
|
+
// chain is oldest first and refers to original identities.
|
|
86
|
+
for (const [index, old] of chain.entries()) {
|
|
87
|
+
byOldKey.get(refKey(old)).after.previous = link(index ? byOldKey.get(refKey(chain[index - 1])).after : undefined);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
if (!crossing) {
|
|
91
|
+
relink([...channelChain].reverse());
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const targetChain = [...(destinationChannel === undefined ? singleChain : channelChain)].reverse();
|
|
95
|
+
let insertion = targetChain.length;
|
|
96
|
+
if (options.atStart)
|
|
97
|
+
insertion = 0;
|
|
98
|
+
if (options.after !== undefined) {
|
|
99
|
+
const after = parseRef(options.after);
|
|
100
|
+
const index = targetChain.findIndex(r => refKey(r) === refKey(after));
|
|
101
|
+
if (index < 0)
|
|
102
|
+
throw new Error(`Insertion point is not in the target history: ${options.after}`);
|
|
103
|
+
insertion = index + 1;
|
|
104
|
+
}
|
|
105
|
+
targetChain.splice(insertion, 0, ...ordered);
|
|
106
|
+
relink([...sourceChain].reverse().filter(r => !selectedKeys.has(refKey(r))));
|
|
107
|
+
relink(targetChain);
|
|
108
|
+
}
|
|
109
|
+
linearHistory(entries.filter(e => e.after.channel !== undefined).map(e => e.after));
|
|
110
|
+
if (crossing)
|
|
111
|
+
linearHistory(entries.filter(e => e.after.channel === undefined).map(e => e.after));
|
|
112
|
+
const sourceSettings = options.fromChannel === undefined ? config.history : (config.channels ? config.channels[options.fromChannel]?.history : undefined);
|
|
113
|
+
if (sourceSettings?.start && sourceSettings.start.past !== 'skip' && versions.includes(sourceSettings.start.version)) {
|
|
114
|
+
const targetSettings = historySettings(updatedConfig, destinationChannel);
|
|
115
|
+
if (targetSettings.start)
|
|
116
|
+
throw new Error('The destination already has a history start; resolve the settings conflict first.');
|
|
117
|
+
targetSettings.start = structuredClone(sourceSettings.start);
|
|
118
|
+
delete historySettings(updatedConfig, options.fromChannel).start;
|
|
119
|
+
}
|
|
120
|
+
const relocated = (file) => {
|
|
121
|
+
const relocation = relocations.find(r => inside(r.from, file));
|
|
122
|
+
return relocation ? path.join(relocation.to, path.relative(relocation.from, file)) : file;
|
|
123
|
+
};
|
|
124
|
+
const originals = new Map();
|
|
125
|
+
const writes = new Map();
|
|
126
|
+
const setWrite = async (file, contents) => {
|
|
127
|
+
const original = await fs.readFile(file);
|
|
128
|
+
if (!original.equals(contents)) {
|
|
129
|
+
originals.set(file, original);
|
|
130
|
+
writes.set(file, contents);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const visuals = new Map();
|
|
134
|
+
const affected = new Set(entries.filter(e => canonical(e.before) !== canonical(e.after)));
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const directory = await project.releaseFile(entry.before, 'visuals');
|
|
137
|
+
if (!(await exists(directory)))
|
|
138
|
+
continue;
|
|
139
|
+
for (const fileName of await fs.readdir(directory)) {
|
|
140
|
+
if (!fileName.endsWith('.yaml'))
|
|
141
|
+
continue;
|
|
142
|
+
const file = await project.releaseFile(entry.before, `visuals/${fileName}`);
|
|
143
|
+
const original = await fs.readFile(file);
|
|
144
|
+
const raw = parseYaml(original.toString());
|
|
145
|
+
const references = raw?.scene?.references;
|
|
146
|
+
if (!Array.isArray(references) || !references.every(r => typeof r === 'string'))
|
|
147
|
+
throw new Error(`Invalid visual references: ${file}`);
|
|
148
|
+
const replacements = [];
|
|
149
|
+
for (const reference of references) {
|
|
150
|
+
const absolute = await within(project.root, reference);
|
|
151
|
+
const real = await exists(absolute) ? await fs.realpath(absolute) : absolute;
|
|
152
|
+
const relocation = relocations.find(r => inside(r.realFrom, real));
|
|
153
|
+
const next = relocation ? path.join(relocation.to, path.relative(relocation.realFrom, real)) : relocated(absolute);
|
|
154
|
+
replacements.push(next === absolute ? reference : path.relative(project.root, next).split(path.sep).join('/'));
|
|
155
|
+
}
|
|
156
|
+
if (canonical(references) === canonical(replacements))
|
|
157
|
+
continue;
|
|
158
|
+
const oldVisual = visualSchema.safeParse(raw);
|
|
159
|
+
raw.scene.references = replacements;
|
|
160
|
+
const parsed = visualSchema.safeParse(raw);
|
|
161
|
+
if (oldVisual.success && parsed.success) {
|
|
162
|
+
for (const variant of ['dark', 'light', 'shared']) {
|
|
163
|
+
const asset = raw.variants?.[variant];
|
|
164
|
+
if (asset && asset.sceneHash === sceneHash(oldVisual.data.scene, entry.before.visuals, variant)) {
|
|
165
|
+
asset.sceneHash = sceneHash(parsed.data.scene, entry.after.visuals, variant);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const theme of ['dark', 'light']) {
|
|
169
|
+
const prompt = await project.releaseFile(entry.before, `prompts/${fileName.slice(0, -5)}.${theme}.md`);
|
|
170
|
+
if (await exists(prompt) && parsed.data.scene.source !== 'provided' && !['object-detail', 'editorial-scene'].includes(parsed.data.scene.archetype)) {
|
|
171
|
+
const bytes = await fs.readFile(prompt);
|
|
172
|
+
let generated = imagePrompt(parsed.data.scene, entry.after.visuals, theme);
|
|
173
|
+
if (bytes.includes(Buffer.from('\r\n')))
|
|
174
|
+
generated = generated.replace(/\n/g, '\r\n');
|
|
175
|
+
await setWrite(prompt, Buffer.from(generated));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
visuals.set(file, raw);
|
|
180
|
+
await setWrite(file, yamlBytes(raw, original));
|
|
181
|
+
affected.add(entry);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const entry of affected) {
|
|
185
|
+
if (entry.before.status === 'ready') {
|
|
186
|
+
const checked = await validate(project, entry.before);
|
|
187
|
+
if (!checked.valid)
|
|
188
|
+
throw new Error(`Cannot move changed ready content ${refKey(entry.before)}: ${checked.errors.join('\n')}`);
|
|
189
|
+
const { status: _status, contentHash: _hash, ...metadata } = entry.after;
|
|
190
|
+
const parts = [metadata];
|
|
191
|
+
for (const note of entry.after.notes) {
|
|
192
|
+
for (const locale of entry.after.locales)
|
|
193
|
+
parts.push(await readNote(await project.releaseFile(entry.before, `notes/${note.id}/${locale}.md`)));
|
|
194
|
+
if (note.image) {
|
|
195
|
+
const file = await project.releaseFile(entry.before, `visuals/${note.id}.yaml`);
|
|
196
|
+
parts.push(visualSchema.parse(visuals.get(file) ?? parseYaml(await fs.readFile(file, 'utf8'))));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
entry.after.contentHash = digest(canonical(parts));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
entry.after.contentHash = null;
|
|
203
|
+
}
|
|
204
|
+
const file = await project.releaseFile(entry.before, 'release.yaml');
|
|
205
|
+
await setWrite(file, yamlBytes(entry.after, await fs.readFile(file)));
|
|
206
|
+
}
|
|
207
|
+
if (canonical(config) !== canonical(updatedConfig)) {
|
|
208
|
+
const file = await project.content('config.yaml');
|
|
209
|
+
await setWrite(file, yamlBytes(updatedConfig, await fs.readFile(file)));
|
|
210
|
+
}
|
|
211
|
+
const result = {
|
|
212
|
+
dryRun: !!options.dryRun,
|
|
213
|
+
moved: selected.map(e => ({ from: ref(e.before), to: ref(e.after), status: e.after.status })),
|
|
214
|
+
updatedReleases: [...affected].map(e => ref(e.after)),
|
|
215
|
+
updatedFiles: [...writes.keys()].map(file => path.relative(project.root, relocated(file)).split(path.sep).join('/')),
|
|
216
|
+
paths: relocations.map(r => ({ from: r.from, to: r.to })),
|
|
217
|
+
};
|
|
218
|
+
if (options.dryRun)
|
|
219
|
+
return result;
|
|
220
|
+
// Detect edits made during preflight before any mutation.
|
|
221
|
+
for (const [file, original] of originals)
|
|
222
|
+
if (!(await fs.readFile(file)).equals(original))
|
|
223
|
+
throw new Error(`File changed during move preparation: ${file}`);
|
|
224
|
+
const holding = await fs.mkdtemp(await project.content('.move-'));
|
|
225
|
+
const locations = relocations.map(r => r.from);
|
|
226
|
+
const written = [];
|
|
227
|
+
const createdParents = [];
|
|
228
|
+
const recoveryFiles = [];
|
|
229
|
+
const clearHolding = async () => {
|
|
230
|
+
for (const file of recoveryFiles)
|
|
231
|
+
await fs.rm(file, { force: true });
|
|
232
|
+
await fs.rmdir(holding);
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
// Durable originals and path mapping remain available if rollback itself fails.
|
|
236
|
+
const backups = [];
|
|
237
|
+
for (const [file, original] of originals) {
|
|
238
|
+
const backup = path.join(holding, `original-${backups.length}`);
|
|
239
|
+
recoveryFiles.push(backup);
|
|
240
|
+
await fs.writeFile(backup, original, { flag: 'wx' });
|
|
241
|
+
backups.push({ from: file, to: relocated(file), backup });
|
|
242
|
+
}
|
|
243
|
+
const manifest = path.join(holding, 'recovery.json');
|
|
244
|
+
recoveryFiles.push(manifest);
|
|
245
|
+
await fs.writeFile(manifest, JSON.stringify({ backups, relocations: relocations.map((r, index) => ({ from: r.from, to: r.to, holding: path.join(holding, String(index)) })) }, null, 2), { flag: 'wx' });
|
|
246
|
+
for (const [index, relocation] of relocations.entries()) {
|
|
247
|
+
const temporary = path.join(holding, String(index));
|
|
248
|
+
await fs.rename(relocation.from, temporary);
|
|
249
|
+
locations[index] = temporary;
|
|
250
|
+
}
|
|
251
|
+
for (const [index, relocation] of relocations.entries()) {
|
|
252
|
+
const parent = path.dirname(relocation.to);
|
|
253
|
+
if (!(await exists(parent))) {
|
|
254
|
+
await fs.mkdir(parent, { recursive: true });
|
|
255
|
+
createdParents.push(parent);
|
|
256
|
+
}
|
|
257
|
+
if (await exists(relocation.to))
|
|
258
|
+
throw new Error(`Destination appeared during move: ${relocation.to}`);
|
|
259
|
+
await fs.rename(locations[index], relocation.to);
|
|
260
|
+
locations[index] = relocation.to;
|
|
261
|
+
}
|
|
262
|
+
for (const [file, contents] of writes) {
|
|
263
|
+
// Include the attempted file so even an error after replacement is recoverable.
|
|
264
|
+
written.push(file);
|
|
265
|
+
await write(relocated(file), contents);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
try {
|
|
270
|
+
for (const file of written.reverse())
|
|
271
|
+
await fs.writeFile(relocated(file), originals.get(file));
|
|
272
|
+
for (let index = relocations.length - 1; index >= 0; index--) {
|
|
273
|
+
if (locations[index] !== relocations[index].from)
|
|
274
|
+
await fs.rename(locations[index], relocations[index].from);
|
|
275
|
+
}
|
|
276
|
+
for (const directory of createdParents.reverse())
|
|
277
|
+
await fs.rmdir(directory);
|
|
278
|
+
await clearHolding();
|
|
279
|
+
}
|
|
280
|
+
catch (rollbackError) {
|
|
281
|
+
throw new AggregateError([error, rollbackError], `Move failed and rollback needs recovery. Preserved holding directory: ${holding}`);
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
await clearHolding();
|
|
286
|
+
// Only remove now-empty channel containers. Never recursively remove a source path.
|
|
287
|
+
for (const directory of new Set(relocations.filter(r => r.identity.channel !== undefined).map(r => path.dirname(r.from)))) {
|
|
288
|
+
if (await exists(directory) && !(await fs.readdir(directory)).length)
|
|
289
|
+
await fs.rmdir(directory);
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
}
|
package/dist/project.d.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import { type HistoryStart, type ProjectConfig, type Release } from './model.js';
|
|
1
|
+
import { type HistoryStart, type ProjectConfig, type Release, type ReleaseId, type ReleaseRef } from './model.js';
|
|
2
|
+
export declare function checkLimit(limit?: number): void;
|
|
3
|
+
export declare function linearHistory(releases: Release[]): Release[];
|
|
4
|
+
export declare function parseProjectConfig(raw: unknown): ProjectConfig;
|
|
2
5
|
export declare class Project {
|
|
3
6
|
readonly root: string;
|
|
4
7
|
constructor(root: string);
|
|
5
8
|
static find(cwd: string): Project;
|
|
6
9
|
content(relative: string): Promise<string>;
|
|
7
10
|
config(): Promise<ProjectConfig>;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
requireChannel(channel: string): Promise<void>;
|
|
12
|
+
releaseDir(value: ReleaseId): Promise<string>;
|
|
13
|
+
releaseFile(value: ReleaseId, relative: string): Promise<string>;
|
|
14
|
+
release(value: ReleaseId): Promise<Release>;
|
|
11
15
|
save(release: Release): Promise<void>;
|
|
16
|
+
allRefs(): Promise<ReleaseRef[]>;
|
|
12
17
|
versions(): Promise<string[]>;
|
|
18
|
+
channelHistory(): Promise<Release[]>;
|
|
13
19
|
latestVersion(): Promise<string>;
|
|
14
|
-
history(
|
|
20
|
+
history(value: ReleaseId, limit?: number): Promise<Release[]>;
|
|
15
21
|
}
|
|
16
22
|
export declare function editable(release: Release): void;
|
|
17
23
|
export interface StartOptions {
|
|
18
24
|
at: string;
|
|
19
25
|
past: HistoryStart['past'];
|
|
20
26
|
version?: string;
|
|
27
|
+
channel?: string;
|
|
21
28
|
}
|
|
22
29
|
export declare function startProject(project: Project, options: StartOptions): Promise<HistoryStart>;
|
|
23
30
|
export interface PrepareOptions {
|
|
@@ -27,5 +34,6 @@ export interface PrepareOptions {
|
|
|
27
34
|
fromRoot?: boolean;
|
|
28
35
|
firstRelease?: boolean;
|
|
29
36
|
date?: string;
|
|
37
|
+
channel?: string;
|
|
30
38
|
}
|
|
31
39
|
export declare function prepare(project: Project, version: string, options: PrepareOptions): Promise<Release>;
|
package/dist/project.js
CHANGED
|
@@ -1,41 +1,127 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { configSchema, historyStartSchema, releaseSchema } from './model.js';
|
|
4
|
-
import { exists, identifier, readYaml, within, writeYaml, KIT_DIR } from './files.js';
|
|
4
|
+
import { exists, identifier, readYaml, within, writeYaml, parseYaml, KIT_DIR } from './files.js';
|
|
5
5
|
import { repoRoot, resolveCommit, resolveRange, checkPrevious } from './git.js';
|
|
6
|
+
import { ref, refKey, parseRef, previousRef, link } from './refs.js';
|
|
7
|
+
export function checkLimit(limit) {
|
|
8
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
9
|
+
throw new Error('History limit must be a positive safe integer.');
|
|
10
|
+
}
|
|
11
|
+
// Newest first. Validate every member, not just the displayed window.
|
|
12
|
+
export function linearHistory(releases) {
|
|
13
|
+
if (!releases.length)
|
|
14
|
+
return [];
|
|
15
|
+
const items = new Map(releases.map(r => [refKey(r), r]));
|
|
16
|
+
if (items.size !== releases.length)
|
|
17
|
+
throw new Error('Duplicate release identity.');
|
|
18
|
+
const successors = new Set();
|
|
19
|
+
for (const release of releases) {
|
|
20
|
+
const previous = previousRef(release);
|
|
21
|
+
if (!previous)
|
|
22
|
+
continue;
|
|
23
|
+
const key = refKey(previous);
|
|
24
|
+
if (!items.has(key))
|
|
25
|
+
throw new Error(`Missing previous release: ${key}`);
|
|
26
|
+
if (successors.has(key))
|
|
27
|
+
throw new Error(`Branch in release history at ${key}`);
|
|
28
|
+
successors.add(key);
|
|
29
|
+
}
|
|
30
|
+
const heads = releases.filter(r => !successors.has(refKey(r)));
|
|
31
|
+
if (heads.length !== 1)
|
|
32
|
+
throw new Error('Release history must have one endpoint; disconnected history or cycle found.');
|
|
33
|
+
const chain = [];
|
|
34
|
+
const visited = new Set();
|
|
35
|
+
let cursor = heads[0];
|
|
36
|
+
while (cursor) {
|
|
37
|
+
const key = refKey(cursor);
|
|
38
|
+
if (visited.has(key))
|
|
39
|
+
throw new Error(`Cycle in release history at ${key}`);
|
|
40
|
+
visited.add(key);
|
|
41
|
+
chain.push(cursor);
|
|
42
|
+
const previous = previousRef(cursor);
|
|
43
|
+
cursor = previous ? items.get(refKey(previous)) : undefined;
|
|
44
|
+
}
|
|
45
|
+
if (chain.length !== releases.length)
|
|
46
|
+
throw new Error('Disconnected release history or cycle found.');
|
|
47
|
+
return chain;
|
|
48
|
+
}
|
|
49
|
+
export function parseProjectConfig(raw) {
|
|
50
|
+
if (raw && typeof raw === 'object' && 'history' in raw && raw.history && typeof raw.history === 'object' && 'limit' in raw.history) {
|
|
51
|
+
throw new Error('Run releasekit update to migrate this configuration automatically. Remove history.limit from config.yaml; use export --limit instead. Omit --limit to export all releases.');
|
|
52
|
+
}
|
|
53
|
+
const config = configSchema.parse(raw);
|
|
54
|
+
if (!config.locales.includes(config.sourceLocale) || new Set(config.locales).size !== config.locales.length) {
|
|
55
|
+
throw new Error('Project locales must be unique and include the source locale.');
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
6
59
|
export class Project {
|
|
7
60
|
root;
|
|
8
61
|
constructor(root) { this.root = path.resolve(root); }
|
|
9
62
|
static find(cwd) { return new Project(repoRoot(cwd)); }
|
|
10
63
|
async content(relative) { return within(this.root, `${KIT_DIR}/${relative}`); }
|
|
11
64
|
async config() {
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
throw new Error('
|
|
15
|
-
|
|
16
|
-
return
|
|
65
|
+
const file = await this.content('config.yaml');
|
|
66
|
+
if (!(await exists(file)))
|
|
67
|
+
throw new Error('ReleaseKit is not initialized. Run releasekit init first.');
|
|
68
|
+
const raw = parseYaml(await fs.readFile(file, 'utf8'));
|
|
69
|
+
return parseProjectConfig(raw);
|
|
70
|
+
}
|
|
71
|
+
async requireChannel(channel) {
|
|
72
|
+
ref({ channel, version: 'check' });
|
|
73
|
+
const config = await this.config();
|
|
74
|
+
if (!config.channels || !Object.hasOwn(config.channels, channel))
|
|
75
|
+
throw new Error(`Unknown channel: ${channel}. Configure it in config.yaml first.`);
|
|
17
76
|
}
|
|
18
|
-
async releaseDir(
|
|
19
|
-
return this.content(`releases/${
|
|
77
|
+
async releaseDir(value) {
|
|
78
|
+
return this.content(`releases/${refKey(value)}`);
|
|
20
79
|
}
|
|
21
|
-
async releaseFile(
|
|
22
|
-
return within(await this.releaseDir(
|
|
80
|
+
async releaseFile(value, relative) {
|
|
81
|
+
return within(await this.releaseDir(value), relative);
|
|
23
82
|
}
|
|
24
|
-
async release(
|
|
25
|
-
const release = await readYaml(await this.releaseFile(
|
|
26
|
-
if (release
|
|
27
|
-
throw new Error(`Release directory and
|
|
83
|
+
async release(value) {
|
|
84
|
+
const release = await readYaml(await this.releaseFile(value, 'release.yaml'), releaseSchema);
|
|
85
|
+
if (refKey(release) !== refKey(value))
|
|
86
|
+
throw new Error(`Release directory and identity disagree: ${refKey(value)}`);
|
|
28
87
|
return release;
|
|
29
88
|
}
|
|
30
89
|
async save(release) {
|
|
31
|
-
await writeYaml(await this.releaseFile(release
|
|
90
|
+
await writeYaml(await this.releaseFile(release, 'release.yaml'), releaseSchema.parse(release));
|
|
32
91
|
}
|
|
33
|
-
async
|
|
92
|
+
async allRefs() {
|
|
34
93
|
const folder = await this.content('releases');
|
|
35
94
|
if (!(await exists(folder)))
|
|
36
95
|
return [];
|
|
37
|
-
const
|
|
38
|
-
|
|
96
|
+
const result = [];
|
|
97
|
+
for (const entry of await fs.readdir(folder, { withFileTypes: true })) {
|
|
98
|
+
if (!entry.isDirectory())
|
|
99
|
+
continue;
|
|
100
|
+
const directory = await this.content(`releases/${identifier(entry.name)}`);
|
|
101
|
+
if (await exists(path.join(directory, 'release.yaml')))
|
|
102
|
+
result.push(ref(entry.name));
|
|
103
|
+
// An unchanneled version may also happen to be a channel name.
|
|
104
|
+
for (const child of await fs.readdir(directory, { withFileTypes: true })) {
|
|
105
|
+
if (child.isDirectory() && await exists(path.join(directory, child.name, 'release.yaml'))) {
|
|
106
|
+
result.push(ref({ channel: entry.name, version: child.name }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const keys = result.map(r => refKey(r).toLowerCase());
|
|
111
|
+
if (new Set(keys).size !== keys.length)
|
|
112
|
+
throw new Error('Release identities collide on a case-insensitive filesystem.');
|
|
113
|
+
return result.sort((a, b) => refKey(a) < refKey(b) ? -1 : 1);
|
|
114
|
+
}
|
|
115
|
+
async versions() {
|
|
116
|
+
return (await this.allRefs()).filter(r => r.channel === undefined).map(r => r.version);
|
|
117
|
+
}
|
|
118
|
+
async channelHistory() {
|
|
119
|
+
const refs = (await this.allRefs()).filter(r => r.channel !== undefined);
|
|
120
|
+
const config = await this.config();
|
|
121
|
+
for (const value of refs)
|
|
122
|
+
if (!config.channels || !Object.hasOwn(config.channels, value.channel))
|
|
123
|
+
throw new Error(`Unknown channel: ${value.channel}`);
|
|
124
|
+
return linearHistory(await Promise.all(refs.map(r => this.release(r))));
|
|
39
125
|
}
|
|
40
126
|
async latestVersion() {
|
|
41
127
|
const versions = await this.versions();
|
|
@@ -50,21 +136,27 @@ export class Project {
|
|
|
50
136
|
throw new Error(`Multiple latest releases found: ${latest.map(release => release.version).join(', ')}. Specify --current <version>.`);
|
|
51
137
|
return latest[0].version;
|
|
52
138
|
}
|
|
53
|
-
async history(
|
|
54
|
-
|
|
55
|
-
|
|
139
|
+
async history(value, limit) {
|
|
140
|
+
checkLimit(limit);
|
|
141
|
+
if (ref(value).channel !== undefined) {
|
|
142
|
+
const chain = await this.channelHistory();
|
|
143
|
+
const index = chain.findIndex(r => refKey(r) === refKey(value));
|
|
144
|
+
if (index < 0)
|
|
145
|
+
throw new Error(`Missing release: ${refKey(value)}`);
|
|
146
|
+
return chain.slice(index, limit === undefined ? undefined : index + limit);
|
|
147
|
+
}
|
|
56
148
|
const chain = [];
|
|
57
149
|
const visited = new Set();
|
|
58
|
-
let cursor =
|
|
59
|
-
// Validate the entire linked lineage, including links beyond the requested display window.
|
|
150
|
+
let cursor = ref(value);
|
|
60
151
|
while (cursor !== null) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
152
|
+
const key = refKey(cursor);
|
|
153
|
+
if (visited.has(key))
|
|
154
|
+
throw new Error(`Cycle in previous-release links at ${key}`);
|
|
155
|
+
visited.add(key);
|
|
64
156
|
const release = await this.release(cursor);
|
|
65
|
-
if (chain.length < limit)
|
|
157
|
+
if (limit === undefined || chain.length < limit)
|
|
66
158
|
chain.push(release);
|
|
67
|
-
cursor = release
|
|
159
|
+
cursor = previousRef(release);
|
|
68
160
|
}
|
|
69
161
|
return chain;
|
|
70
162
|
}
|
|
@@ -75,9 +167,12 @@ export function editable(release) {
|
|
|
75
167
|
}
|
|
76
168
|
export async function startProject(project, options) {
|
|
77
169
|
const config = await project.config();
|
|
78
|
-
if (
|
|
170
|
+
if (options.channel !== undefined)
|
|
171
|
+
await project.requireChannel(options.channel);
|
|
172
|
+
const settings = options.channel === undefined ? (config.history ??= {}) : (config.channels[options.channel].history ??= {});
|
|
173
|
+
if (settings.start)
|
|
79
174
|
throw new Error('A history start is already configured. Reuse the saved choice; it was not overwritten.');
|
|
80
|
-
if ((await project.
|
|
175
|
+
if ((await project.allRefs()).some(r => r.channel === options.channel))
|
|
81
176
|
throw new Error('History setup requires a project without releases. Continue an existing release line with --previous.');
|
|
82
177
|
if (options.past === 'skip' && options.version !== undefined)
|
|
83
178
|
throw new Error('--baseline-version is not used when --past is skip.');
|
|
@@ -87,12 +182,14 @@ export async function startProject(project, options) {
|
|
|
87
182
|
identifier(options.version);
|
|
88
183
|
const source = resolveRange(project.root, null, options.at);
|
|
89
184
|
const start = historyStartSchema.parse({ ref: options.at, sha: source.toSha, past: options.past, version: options.version ?? null });
|
|
90
|
-
|
|
185
|
+
settings.start = start;
|
|
91
186
|
await writeYaml(await project.content('config.yaml'), config);
|
|
92
187
|
return start;
|
|
93
188
|
}
|
|
94
189
|
export async function prepare(project, version, options) {
|
|
95
190
|
identifier(version);
|
|
191
|
+
if (options.channel !== undefined)
|
|
192
|
+
return prepareChannel(project, version, options);
|
|
96
193
|
const directory = await project.releaseDir(version);
|
|
97
194
|
if (await exists(directory))
|
|
98
195
|
throw new Error(`Release ${version} already exists; edit it in place instead of overwriting it.`);
|
|
@@ -102,7 +199,9 @@ export async function prepare(project, version, options) {
|
|
|
102
199
|
if (options.firstRelease && options.previous)
|
|
103
200
|
throw new Error('--first-release cannot be combined with --previous.');
|
|
104
201
|
const versions = await project.versions();
|
|
105
|
-
|
|
202
|
+
if (versions.some(v => v.toLowerCase() === version.toLowerCase()))
|
|
203
|
+
throw new Error(`Release ${version} already exists on a case-insensitive filesystem.`);
|
|
204
|
+
const start = config.history?.start;
|
|
106
205
|
let previous = options.previous ? await project.release(options.previous) : undefined;
|
|
107
206
|
let from = options.fromRoot ? null : options.from ?? previous?.source.toSha;
|
|
108
207
|
let to = options.to ?? 'HEAD';
|
|
@@ -151,7 +250,7 @@ export async function prepare(project, version, options) {
|
|
|
151
250
|
await project.history(previous.version, 1);
|
|
152
251
|
}
|
|
153
252
|
const release = releaseSchema.parse({
|
|
154
|
-
schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString()
|
|
253
|
+
schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString(),
|
|
155
254
|
previous: previous?.version ?? null, status: 'draft', source,
|
|
156
255
|
...(initialContent ? { initialContent } : {}),
|
|
157
256
|
sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
|
|
@@ -160,3 +259,58 @@ export async function prepare(project, version, options) {
|
|
|
160
259
|
await project.save(release);
|
|
161
260
|
return release;
|
|
162
261
|
}
|
|
262
|
+
async function prepareChannel(project, version, options) {
|
|
263
|
+
const channel = options.channel;
|
|
264
|
+
await project.requireChannel(channel);
|
|
265
|
+
const identity = ref({ channel, version });
|
|
266
|
+
if (await exists(await project.releaseDir(identity)))
|
|
267
|
+
throw new Error(`Release ${refKey(identity)} already exists; edit it in place.`);
|
|
268
|
+
const config = await project.config();
|
|
269
|
+
const chain = await project.channelHistory();
|
|
270
|
+
if (chain.some(r => refKey(r).toLowerCase() === refKey(identity).toLowerCase()))
|
|
271
|
+
throw new Error(`Release ${refKey(identity)} already exists on a case-insensitive filesystem.`);
|
|
272
|
+
const head = chain[0];
|
|
273
|
+
const sameChannel = chain.filter(r => r.channel === channel);
|
|
274
|
+
if (options.previous !== undefined && (!head || refKey(parseRef(options.previous)) !== refKey(head)))
|
|
275
|
+
throw new Error('--previous must identify the latest release in the whole channel history.');
|
|
276
|
+
if (options.firstRelease && head)
|
|
277
|
+
throw new Error('Channel history already exists; a second independent history is not allowed.');
|
|
278
|
+
if (options.fromRoot && options.from !== undefined)
|
|
279
|
+
throw new Error('--from-root cannot be combined with --from.');
|
|
280
|
+
const start = config.channels && config.channels[channel].history?.start;
|
|
281
|
+
let from = options.fromRoot ? null : options.from ?? sameChannel[0]?.source.toSha;
|
|
282
|
+
let to = options.to ?? 'HEAD';
|
|
283
|
+
let initialContent;
|
|
284
|
+
let savedStart = false;
|
|
285
|
+
if (start && start.past !== 'skip' && version === start.version) {
|
|
286
|
+
if (sameChannel.length || options.from !== undefined)
|
|
287
|
+
throw new Error('Prepare the configured baseline before other releases in its channel.');
|
|
288
|
+
if (options.to !== undefined && resolveCommit(project.root, options.to) !== start.sha)
|
|
289
|
+
throw new Error('The requested end differs from the pinned history start.');
|
|
290
|
+
from = null;
|
|
291
|
+
to = start.sha;
|
|
292
|
+
initialContent = start.past;
|
|
293
|
+
}
|
|
294
|
+
else if (start && from === undefined) {
|
|
295
|
+
if (start.past !== 'skip' && !sameChannel.length)
|
|
296
|
+
throw new Error(`Prepare the configured baseline ${channel}/${start.version} first.`);
|
|
297
|
+
from = start.sha;
|
|
298
|
+
savedStart = true;
|
|
299
|
+
}
|
|
300
|
+
if (from === undefined)
|
|
301
|
+
throw new Error(`Specify --from or --from-root, or configure a history start for ${channel}.`);
|
|
302
|
+
const source = resolveRange(project.root, from, to);
|
|
303
|
+
if (initialContent && start)
|
|
304
|
+
source.toRef = start.ref;
|
|
305
|
+
if (savedStart && start)
|
|
306
|
+
source.fromRef = start.ref;
|
|
307
|
+
const release = releaseSchema.parse({
|
|
308
|
+
schemaVersion: 1, ...identity, previous: link(head), status: 'draft', source,
|
|
309
|
+
releasedAt: options.date ?? new Date().toISOString(),
|
|
310
|
+
...(initialContent ? { initialContent } : {}),
|
|
311
|
+
sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
|
|
312
|
+
notes: [], emptyReason: null, contentHash: null,
|
|
313
|
+
});
|
|
314
|
+
await project.save(release);
|
|
315
|
+
return release;
|
|
316
|
+
}
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Release, type ReleaseId, type ReleaseRef } from './model.js';
|
|
2
|
+
export declare function ref(value: ReleaseId): ReleaseRef;
|
|
3
|
+
export declare function refKey(value: ReleaseId): string;
|
|
4
|
+
export declare function parseRef(value: string): ReleaseRef;
|
|
5
|
+
export declare function previousRef(release: Release): ReleaseRef | null;
|
|
6
|
+
export declare function link(value: ReleaseRef | undefined): Release['previous'];
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { channelRefSchema } from './model.js';
|
|
2
|
+
import { identifier } from './files.js';
|
|
3
|
+
export function ref(value) {
|
|
4
|
+
const result = typeof value === 'string' ? { version: value } : value;
|
|
5
|
+
identifier(result.version);
|
|
6
|
+
if (result.channel !== undefined) {
|
|
7
|
+
channelRefSchema.parse({ channel: result.channel, version: result.version });
|
|
8
|
+
identifier(result.channel);
|
|
9
|
+
}
|
|
10
|
+
return result.channel === undefined ? { version: result.version } : { channel: result.channel, version: result.version };
|
|
11
|
+
}
|
|
12
|
+
export function refKey(value) {
|
|
13
|
+
const item = ref(value);
|
|
14
|
+
return item.channel === undefined ? item.version : `${item.channel}/${item.version}`;
|
|
15
|
+
}
|
|
16
|
+
export function parseRef(value) {
|
|
17
|
+
const parts = value.split('/');
|
|
18
|
+
if (parts.length === 1)
|
|
19
|
+
return ref(value);
|
|
20
|
+
if (parts.length !== 2)
|
|
21
|
+
throw new Error(`Expected channel/version: ${value}`);
|
|
22
|
+
return ref({ channel: parts[0], version: parts[1] });
|
|
23
|
+
}
|
|
24
|
+
export function previousRef(release) {
|
|
25
|
+
return release.previous === null ? null : ref(release.previous);
|
|
26
|
+
}
|
|
27
|
+
export function link(value) {
|
|
28
|
+
return value === undefined ? null : value.channel === undefined ? value.version : ref(value);
|
|
29
|
+
}
|