@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/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
- releaseDir(version: string): Promise<string>;
9
- releaseFile(version: string, relative: string): Promise<string>;
10
- release(version: string): Promise<Release>;
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(version: string, limit: number): Promise<Release[]>;
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 config = await readYaml(await this.content('config.yaml'), configSchema);
13
- if (!config.locales.includes(config.sourceLocale) || new Set(config.locales).size !== config.locales.length) {
14
- throw new Error('Project locales must be unique and include the source locale.');
15
- }
16
- return config;
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(version) {
19
- return this.content(`releases/${identifier(version)}`);
77
+ async releaseDir(value) {
78
+ return this.content(`releases/${refKey(value)}`);
20
79
  }
21
- async releaseFile(version, relative) {
22
- return within(await this.releaseDir(version), relative);
80
+ async releaseFile(value, relative) {
81
+ return within(await this.releaseDir(value), relative);
23
82
  }
24
- async release(version) {
25
- const release = await readYaml(await this.releaseFile(version, 'release.yaml'), releaseSchema);
26
- if (release.version !== version)
27
- throw new Error(`Release directory and version disagree: ${version}`);
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.version, 'release.yaml'), releaseSchema.parse(release));
90
+ await writeYaml(await this.releaseFile(release, 'release.yaml'), releaseSchema.parse(release));
32
91
  }
33
- async versions() {
92
+ async allRefs() {
34
93
  const folder = await this.content('releases');
35
94
  if (!(await exists(folder)))
36
95
  return [];
37
- const entries = await fs.readdir(folder, { withFileTypes: true });
38
- return entries.filter(e => e.isDirectory()).map(e => e.name).sort();
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(version, limit) {
54
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
55
- throw new Error('History limit must be an integer from 1 to 100.');
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 = version;
59
- // Validate the entire linked lineage, including links beyond the requested display window.
150
+ let cursor = ref(value);
60
151
  while (cursor !== null) {
61
- if (visited.has(cursor))
62
- throw new Error(`Cycle in previous-release links at ${cursor}`);
63
- visited.add(cursor);
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.previous;
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 (config.history.start)
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.versions()).length)
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
- config.history.start = start;
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
- const start = config.history.start;
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().slice(0, 10),
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
+ }