@iodes/releasekit 0.1.7 → 0.2.0
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 +72 -6
- package/dist/assets.d.ts +4 -3
- package/dist/assets.js +4 -2
- package/dist/cli.js +116 -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 +20 -3
- package/dist/install.js +28 -5
- 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 +12 -5
- package/dist/project.js +180 -29
- package/dist/refs.d.ts +6 -0
- package/dist/refs.js +29 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +64 -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 +1 -1
- package/schemas/bundle.schema.json +54 -5
- package/schemas/config.schema.json +125 -9
- package/schemas/release.schema.json +46 -5
package/dist/project.js
CHANGED
|
@@ -1,41 +1,124 @@
|
|
|
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
|
+
}
|
|
6
49
|
export class Project {
|
|
7
50
|
root;
|
|
8
51
|
constructor(root) { this.root = path.resolve(root); }
|
|
9
52
|
static find(cwd) { return new Project(repoRoot(cwd)); }
|
|
10
53
|
async content(relative) { return within(this.root, `${KIT_DIR}/${relative}`); }
|
|
11
54
|
async config() {
|
|
12
|
-
const
|
|
55
|
+
const file = await this.content('config.yaml');
|
|
56
|
+
if (!(await exists(file)))
|
|
57
|
+
throw new Error('ReleaseKit is not initialized. Run releasekit init first.');
|
|
58
|
+
const raw = parseYaml(await fs.readFile(file, 'utf8'));
|
|
59
|
+
if (raw && typeof raw === 'object' && 'history' in raw && raw.history && typeof raw.history === 'object' && 'limit' in raw.history) {
|
|
60
|
+
throw new Error('Remove history.limit from config.yaml; use export --limit instead. Omit --limit to export all releases.');
|
|
61
|
+
}
|
|
62
|
+
const config = configSchema.parse(raw);
|
|
13
63
|
if (!config.locales.includes(config.sourceLocale) || new Set(config.locales).size !== config.locales.length) {
|
|
14
64
|
throw new Error('Project locales must be unique and include the source locale.');
|
|
15
65
|
}
|
|
16
66
|
return config;
|
|
17
67
|
}
|
|
18
|
-
async
|
|
19
|
-
|
|
68
|
+
async requireChannel(channel) {
|
|
69
|
+
ref({ channel, version: 'check' });
|
|
70
|
+
const config = await this.config();
|
|
71
|
+
if (!config.channels || !Object.hasOwn(config.channels, channel))
|
|
72
|
+
throw new Error(`Unknown channel: ${channel}. Configure it in config.yaml first.`);
|
|
20
73
|
}
|
|
21
|
-
async
|
|
22
|
-
return
|
|
74
|
+
async releaseDir(value) {
|
|
75
|
+
return this.content(`releases/${refKey(value)}`);
|
|
23
76
|
}
|
|
24
|
-
async
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
77
|
+
async releaseFile(value, relative) {
|
|
78
|
+
return within(await this.releaseDir(value), relative);
|
|
79
|
+
}
|
|
80
|
+
async release(value) {
|
|
81
|
+
const release = await readYaml(await this.releaseFile(value, 'release.yaml'), releaseSchema);
|
|
82
|
+
if (refKey(release) !== refKey(value))
|
|
83
|
+
throw new Error(`Release directory and identity disagree: ${refKey(value)}`);
|
|
28
84
|
return release;
|
|
29
85
|
}
|
|
30
86
|
async save(release) {
|
|
31
|
-
await writeYaml(await this.releaseFile(release
|
|
87
|
+
await writeYaml(await this.releaseFile(release, 'release.yaml'), releaseSchema.parse(release));
|
|
32
88
|
}
|
|
33
|
-
async
|
|
89
|
+
async allRefs() {
|
|
34
90
|
const folder = await this.content('releases');
|
|
35
91
|
if (!(await exists(folder)))
|
|
36
92
|
return [];
|
|
37
|
-
const
|
|
38
|
-
|
|
93
|
+
const result = [];
|
|
94
|
+
for (const entry of await fs.readdir(folder, { withFileTypes: true })) {
|
|
95
|
+
if (!entry.isDirectory())
|
|
96
|
+
continue;
|
|
97
|
+
const directory = await this.content(`releases/${identifier(entry.name)}`);
|
|
98
|
+
if (await exists(path.join(directory, 'release.yaml')))
|
|
99
|
+
result.push(ref(entry.name));
|
|
100
|
+
// An unchanneled version may also happen to be a channel name.
|
|
101
|
+
for (const child of await fs.readdir(directory, { withFileTypes: true })) {
|
|
102
|
+
if (child.isDirectory() && await exists(path.join(directory, child.name, 'release.yaml'))) {
|
|
103
|
+
result.push(ref({ channel: entry.name, version: child.name }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const keys = result.map(r => refKey(r).toLowerCase());
|
|
108
|
+
if (new Set(keys).size !== keys.length)
|
|
109
|
+
throw new Error('Release identities collide on a case-insensitive filesystem.');
|
|
110
|
+
return result.sort((a, b) => refKey(a) < refKey(b) ? -1 : 1);
|
|
111
|
+
}
|
|
112
|
+
async versions() {
|
|
113
|
+
return (await this.allRefs()).filter(r => r.channel === undefined).map(r => r.version);
|
|
114
|
+
}
|
|
115
|
+
async channelHistory() {
|
|
116
|
+
const refs = (await this.allRefs()).filter(r => r.channel !== undefined);
|
|
117
|
+
const config = await this.config();
|
|
118
|
+
for (const value of refs)
|
|
119
|
+
if (!config.channels || !Object.hasOwn(config.channels, value.channel))
|
|
120
|
+
throw new Error(`Unknown channel: ${value.channel}`);
|
|
121
|
+
return linearHistory(await Promise.all(refs.map(r => this.release(r))));
|
|
39
122
|
}
|
|
40
123
|
async latestVersion() {
|
|
41
124
|
const versions = await this.versions();
|
|
@@ -50,21 +133,27 @@ export class Project {
|
|
|
50
133
|
throw new Error(`Multiple latest releases found: ${latest.map(release => release.version).join(', ')}. Specify --current <version>.`);
|
|
51
134
|
return latest[0].version;
|
|
52
135
|
}
|
|
53
|
-
async history(
|
|
54
|
-
|
|
55
|
-
|
|
136
|
+
async history(value, limit) {
|
|
137
|
+
checkLimit(limit);
|
|
138
|
+
if (ref(value).channel !== undefined) {
|
|
139
|
+
const chain = await this.channelHistory();
|
|
140
|
+
const index = chain.findIndex(r => refKey(r) === refKey(value));
|
|
141
|
+
if (index < 0)
|
|
142
|
+
throw new Error(`Missing release: ${refKey(value)}`);
|
|
143
|
+
return chain.slice(index, limit === undefined ? undefined : index + limit);
|
|
144
|
+
}
|
|
56
145
|
const chain = [];
|
|
57
146
|
const visited = new Set();
|
|
58
|
-
let cursor =
|
|
59
|
-
// Validate the entire linked lineage, including links beyond the requested display window.
|
|
147
|
+
let cursor = ref(value);
|
|
60
148
|
while (cursor !== null) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
149
|
+
const key = refKey(cursor);
|
|
150
|
+
if (visited.has(key))
|
|
151
|
+
throw new Error(`Cycle in previous-release links at ${key}`);
|
|
152
|
+
visited.add(key);
|
|
64
153
|
const release = await this.release(cursor);
|
|
65
|
-
if (chain.length < limit)
|
|
154
|
+
if (limit === undefined || chain.length < limit)
|
|
66
155
|
chain.push(release);
|
|
67
|
-
cursor = release
|
|
156
|
+
cursor = previousRef(release);
|
|
68
157
|
}
|
|
69
158
|
return chain;
|
|
70
159
|
}
|
|
@@ -75,9 +164,12 @@ export function editable(release) {
|
|
|
75
164
|
}
|
|
76
165
|
export async function startProject(project, options) {
|
|
77
166
|
const config = await project.config();
|
|
78
|
-
if (
|
|
167
|
+
if (options.channel !== undefined)
|
|
168
|
+
await project.requireChannel(options.channel);
|
|
169
|
+
const settings = options.channel === undefined ? (config.history ??= {}) : (config.channels[options.channel].history ??= {});
|
|
170
|
+
if (settings.start)
|
|
79
171
|
throw new Error('A history start is already configured. Reuse the saved choice; it was not overwritten.');
|
|
80
|
-
if ((await project.
|
|
172
|
+
if ((await project.allRefs()).some(r => r.channel === options.channel))
|
|
81
173
|
throw new Error('History setup requires a project without releases. Continue an existing release line with --previous.');
|
|
82
174
|
if (options.past === 'skip' && options.version !== undefined)
|
|
83
175
|
throw new Error('--baseline-version is not used when --past is skip.');
|
|
@@ -87,12 +179,14 @@ export async function startProject(project, options) {
|
|
|
87
179
|
identifier(options.version);
|
|
88
180
|
const source = resolveRange(project.root, null, options.at);
|
|
89
181
|
const start = historyStartSchema.parse({ ref: options.at, sha: source.toSha, past: options.past, version: options.version ?? null });
|
|
90
|
-
|
|
182
|
+
settings.start = start;
|
|
91
183
|
await writeYaml(await project.content('config.yaml'), config);
|
|
92
184
|
return start;
|
|
93
185
|
}
|
|
94
186
|
export async function prepare(project, version, options) {
|
|
95
187
|
identifier(version);
|
|
188
|
+
if (options.channel !== undefined)
|
|
189
|
+
return prepareChannel(project, version, options);
|
|
96
190
|
const directory = await project.releaseDir(version);
|
|
97
191
|
if (await exists(directory))
|
|
98
192
|
throw new Error(`Release ${version} already exists; edit it in place instead of overwriting it.`);
|
|
@@ -102,7 +196,9 @@ export async function prepare(project, version, options) {
|
|
|
102
196
|
if (options.firstRelease && options.previous)
|
|
103
197
|
throw new Error('--first-release cannot be combined with --previous.');
|
|
104
198
|
const versions = await project.versions();
|
|
105
|
-
|
|
199
|
+
if (versions.some(v => v.toLowerCase() === version.toLowerCase()))
|
|
200
|
+
throw new Error(`Release ${version} already exists on a case-insensitive filesystem.`);
|
|
201
|
+
const start = config.history?.start;
|
|
106
202
|
let previous = options.previous ? await project.release(options.previous) : undefined;
|
|
107
203
|
let from = options.fromRoot ? null : options.from ?? previous?.source.toSha;
|
|
108
204
|
let to = options.to ?? 'HEAD';
|
|
@@ -151,7 +247,7 @@ export async function prepare(project, version, options) {
|
|
|
151
247
|
await project.history(previous.version, 1);
|
|
152
248
|
}
|
|
153
249
|
const release = releaseSchema.parse({
|
|
154
|
-
schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString()
|
|
250
|
+
schemaVersion: 1, version, releasedAt: options.date ?? new Date().toISOString(),
|
|
155
251
|
previous: previous?.version ?? null, status: 'draft', source,
|
|
156
252
|
...(initialContent ? { initialContent } : {}),
|
|
157
253
|
sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
|
|
@@ -160,3 +256,58 @@ export async function prepare(project, version, options) {
|
|
|
160
256
|
await project.save(release);
|
|
161
257
|
return release;
|
|
162
258
|
}
|
|
259
|
+
async function prepareChannel(project, version, options) {
|
|
260
|
+
const channel = options.channel;
|
|
261
|
+
await project.requireChannel(channel);
|
|
262
|
+
const identity = ref({ channel, version });
|
|
263
|
+
if (await exists(await project.releaseDir(identity)))
|
|
264
|
+
throw new Error(`Release ${refKey(identity)} already exists; edit it in place.`);
|
|
265
|
+
const config = await project.config();
|
|
266
|
+
const chain = await project.channelHistory();
|
|
267
|
+
if (chain.some(r => refKey(r).toLowerCase() === refKey(identity).toLowerCase()))
|
|
268
|
+
throw new Error(`Release ${refKey(identity)} already exists on a case-insensitive filesystem.`);
|
|
269
|
+
const head = chain[0];
|
|
270
|
+
const sameChannel = chain.filter(r => r.channel === channel);
|
|
271
|
+
if (options.previous !== undefined && (!head || refKey(parseRef(options.previous)) !== refKey(head)))
|
|
272
|
+
throw new Error('--previous must identify the latest release in the whole channel history.');
|
|
273
|
+
if (options.firstRelease && head)
|
|
274
|
+
throw new Error('Channel history already exists; a second independent history is not allowed.');
|
|
275
|
+
if (options.fromRoot && options.from !== undefined)
|
|
276
|
+
throw new Error('--from-root cannot be combined with --from.');
|
|
277
|
+
const start = config.channels && config.channels[channel].history?.start;
|
|
278
|
+
let from = options.fromRoot ? null : options.from ?? sameChannel[0]?.source.toSha;
|
|
279
|
+
let to = options.to ?? 'HEAD';
|
|
280
|
+
let initialContent;
|
|
281
|
+
let savedStart = false;
|
|
282
|
+
if (start && start.past !== 'skip' && version === start.version) {
|
|
283
|
+
if (sameChannel.length || options.from !== undefined)
|
|
284
|
+
throw new Error('Prepare the configured baseline before other releases in its channel.');
|
|
285
|
+
if (options.to !== undefined && resolveCommit(project.root, options.to) !== start.sha)
|
|
286
|
+
throw new Error('The requested end differs from the pinned history start.');
|
|
287
|
+
from = null;
|
|
288
|
+
to = start.sha;
|
|
289
|
+
initialContent = start.past;
|
|
290
|
+
}
|
|
291
|
+
else if (start && from === undefined) {
|
|
292
|
+
if (start.past !== 'skip' && !sameChannel.length)
|
|
293
|
+
throw new Error(`Prepare the configured baseline ${channel}/${start.version} first.`);
|
|
294
|
+
from = start.sha;
|
|
295
|
+
savedStart = true;
|
|
296
|
+
}
|
|
297
|
+
if (from === undefined)
|
|
298
|
+
throw new Error(`Specify --from or --from-root, or configure a history start for ${channel}.`);
|
|
299
|
+
const source = resolveRange(project.root, from, to);
|
|
300
|
+
if (initialContent && start)
|
|
301
|
+
source.toRef = start.ref;
|
|
302
|
+
if (savedStart && start)
|
|
303
|
+
source.fromRef = start.ref;
|
|
304
|
+
const release = releaseSchema.parse({
|
|
305
|
+
schemaVersion: 1, ...identity, previous: link(head), status: 'draft', source,
|
|
306
|
+
releasedAt: options.date ?? new Date().toISOString(),
|
|
307
|
+
...(initialContent ? { initialContent } : {}),
|
|
308
|
+
sourceLocale: config.sourceLocale, locales: config.locales, visuals: config.visuals,
|
|
309
|
+
notes: [], emptyReason: null, contentHash: null,
|
|
310
|
+
});
|
|
311
|
+
await project.save(release);
|
|
312
|
+
return release;
|
|
313
|
+
}
|
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
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { InitOptions } from './install.js';
|
|
2
|
+
export declare const commaList: (value: string) => string[];
|
|
3
|
+
export declare function parseTools(value: string): ("claude" | "codex" | "cursor")[];
|
|
4
|
+
type Ask = (question: string) => Promise<string>;
|
|
5
|
+
export declare function collectSetup(root: string, options: InitOptions, ask: Ask, report: (message: string) => void): Promise<InitOptions>;
|
|
6
|
+
export declare function interactiveSetup(root: string, options: InitOptions): Promise<InitOptions>;
|
|
7
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { configSchema, defaultConfig } from './model.js';
|
|
5
|
+
export const commaList = (value) => value.split(',').map(s => s.trim()).filter(Boolean);
|
|
6
|
+
export function parseTools(value) {
|
|
7
|
+
if (value === 'none')
|
|
8
|
+
return [];
|
|
9
|
+
const values = commaList(value);
|
|
10
|
+
if (!values.length)
|
|
11
|
+
throw new Error('Choose codex, claude, cursor, or none.');
|
|
12
|
+
return configSchema.shape.tools.parse(values);
|
|
13
|
+
}
|
|
14
|
+
// Collect and validate everything before initProject writes any files.
|
|
15
|
+
export async function collectSetup(root, options, ask, report) {
|
|
16
|
+
const defaults = defaultConfig(path.basename(root));
|
|
17
|
+
async function field(label, fallback, parse) {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const answer = (await ask(`${label} [${fallback}]: `)).trim() || fallback;
|
|
20
|
+
try {
|
|
21
|
+
return parse(answer);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
report(error instanceof Error ? error.message : String(error));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const product = options.product ?? await field('Product name', defaults.product, value => configSchema.shape.product.parse(value));
|
|
29
|
+
const tools = options.tools ?? await field('Agent tools (codex, claude, cursor; comma-separated, or none)', defaults.tools.join(','), parseTools);
|
|
30
|
+
const sourceLocale = options.sourceLocale ?? await field('Original language (locale code)', defaults.sourceLocale, value => configSchema.shape.sourceLocale.parse(value));
|
|
31
|
+
const locales = options.locales ?? await field('All languages, including the original (comma-separated locale codes)', sourceLocale, value => {
|
|
32
|
+
const values = configSchema.shape.locales.parse(commaList(value));
|
|
33
|
+
if (!values.includes(sourceLocale) || new Set(values).size !== values.length)
|
|
34
|
+
throw new Error(`Languages must be unique and include ${sourceLocale}.`);
|
|
35
|
+
return values;
|
|
36
|
+
});
|
|
37
|
+
const themes = options.themes ?? await field('Image themes (both, dark, light)', defaults.visuals.themes, value => configSchema.shape.visuals.shape.themes.parse(value));
|
|
38
|
+
return { product, tools, sourceLocale, locales, themes };
|
|
39
|
+
}
|
|
40
|
+
export function interactiveSetup(root, options) {
|
|
41
|
+
return withTerminal((ask, report) => collectSetup(root, options, ask, report), 'Setup cancelled. No configuration was written.');
|
|
42
|
+
}
|
|
43
|
+
async function withTerminal(collect, cancelled) {
|
|
44
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
45
|
+
const abort = new AbortController();
|
|
46
|
+
const cancel = () => abort.abort();
|
|
47
|
+
terminal.on('SIGINT', cancel);
|
|
48
|
+
terminal.on('close', cancel);
|
|
49
|
+
try {
|
|
50
|
+
return await collect(question => {
|
|
51
|
+
if (abort.signal.aborted)
|
|
52
|
+
throw new Error(cancelled);
|
|
53
|
+
return terminal.question(question, { signal: abort.signal });
|
|
54
|
+
}, message => stdout.write(`${message}\n`));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (abort.signal.aborted)
|
|
58
|
+
throw new Error(cancelled);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
terminal.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Project } from './project.js';
|
|
2
|
+
import type { ReleaseRef } from './model.js';
|
|
3
|
+
export declare function listReleases(project: Project, channel?: string): Promise<{
|
|
4
|
+
version: string;
|
|
5
|
+
channel?: string;
|
|
6
|
+
status: "draft" | "ready";
|
|
7
|
+
releasedAt: string;
|
|
8
|
+
notes: number;
|
|
9
|
+
locales: string[];
|
|
10
|
+
}[]>;
|
|
11
|
+
export declare function projectStatus(project: Project, selected?: ReleaseRef, channel?: string): Promise<{
|
|
12
|
+
initialized: boolean;
|
|
13
|
+
releases: never[];
|
|
14
|
+
next: string[];
|
|
15
|
+
product?: undefined;
|
|
16
|
+
} | {
|
|
17
|
+
initialized: boolean;
|
|
18
|
+
product: string;
|
|
19
|
+
releases: {
|
|
20
|
+
version: string;
|
|
21
|
+
channel?: string | undefined;
|
|
22
|
+
status: "draft" | "ready";
|
|
23
|
+
notes: number;
|
|
24
|
+
valid: boolean;
|
|
25
|
+
errors: string[];
|
|
26
|
+
warnings: string[];
|
|
27
|
+
next: string;
|
|
28
|
+
}[];
|
|
29
|
+
next: string[];
|
|
30
|
+
}>;
|
|
31
|
+
export declare function formatStatus(result: Awaited<ReturnType<typeof projectStatus>>): string;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Project } from './project.js';
|
|
2
|
+
import { exists } from './files.js';
|
|
3
|
+
import { refKey } from './refs.js';
|
|
4
|
+
import { validate } from './validate.js';
|
|
5
|
+
export async function listReleases(project, channel) {
|
|
6
|
+
await project.config();
|
|
7
|
+
if (channel !== undefined)
|
|
8
|
+
await project.requireChannel(channel);
|
|
9
|
+
const refs = (await project.allRefs()).filter(value => channel === undefined || value.channel === channel);
|
|
10
|
+
return Promise.all(refs.map(async (value) => {
|
|
11
|
+
const release = await project.release(value);
|
|
12
|
+
return { ...value, status: release.status, releasedAt: release.releasedAt, notes: release.notes.length, locales: release.locales };
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
export async function projectStatus(project, selected, channel) {
|
|
16
|
+
if (!(await exists(await project.content('config.yaml')))) {
|
|
17
|
+
return { initialized: false, releases: [], next: ['releasekit init'] };
|
|
18
|
+
}
|
|
19
|
+
const config = await project.config();
|
|
20
|
+
const releases = selected ? [await project.release(selected)] : await listReleases(project, channel);
|
|
21
|
+
const results = await Promise.all(releases.map(async (release) => {
|
|
22
|
+
const validation = await validate(project, release);
|
|
23
|
+
const identity = refKey(release);
|
|
24
|
+
const args = `${release.version}${release.channel === undefined ? '' : ` --channel ${release.channel}`}`;
|
|
25
|
+
let next;
|
|
26
|
+
if (!validation.valid)
|
|
27
|
+
next = `Use releasekit-draft or releasekit-image for ${identity} to resolve the reported issues, then run releasekit validate ${args}.`;
|
|
28
|
+
else if (release.status === 'ready')
|
|
29
|
+
next = `releasekit export --current ${args} --out <new-directory>`;
|
|
30
|
+
else {
|
|
31
|
+
const predecessors = (await project.history(release)).slice(1).filter(item => item.status !== 'ready');
|
|
32
|
+
next = release.channel === undefined && predecessors.length
|
|
33
|
+
? `Finalize earlier releases first: ${predecessors.reverse().map(refKey).join(', ')}.`
|
|
34
|
+
: `releasekit finalize ${args}`;
|
|
35
|
+
}
|
|
36
|
+
return { version: release.version, ...(release.channel === undefined ? {} : { channel: release.channel }), status: release.status,
|
|
37
|
+
notes: typeof release.notes === 'number' ? release.notes : release.notes.length,
|
|
38
|
+
valid: validation.valid, errors: validation.errors, warnings: validation.warnings, next };
|
|
39
|
+
}));
|
|
40
|
+
return { initialized: true, product: config.product, releases: results,
|
|
41
|
+
next: results.length ? [] : ['Use releasekit-draft with a version to select the Git range and write the first draft.'] };
|
|
42
|
+
}
|
|
43
|
+
export function formatStatus(result) {
|
|
44
|
+
const lines = [result.initialized ? `${result.product} — release status` : 'ReleaseKit is not initialized.'];
|
|
45
|
+
for (const release of result.releases) {
|
|
46
|
+
lines.push(`\n${refKey(release)} ${release.status} ${release.notes} notes ${release.valid ? 'validation passed' : 'needs attention'}`);
|
|
47
|
+
lines.push(...release.errors.map(error => ` - ${error}`), ...release.warnings.map(warning => ` Warning: ${warning}`));
|
|
48
|
+
lines.push(` Next: ${release.next}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push(...result.next.map(next => `Next: ${next}`));
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { type ReleaseId } from './model.js';
|
|
1
2
|
import { type Release } from './model.js';
|
|
2
3
|
import { Project } from './project.js';
|
|
3
4
|
export interface Validation {
|
|
4
5
|
version: string;
|
|
6
|
+
channel?: string;
|
|
5
7
|
valid: boolean;
|
|
6
8
|
errors: string[];
|
|
7
9
|
warnings: string[];
|
|
8
10
|
contentHash: string | null;
|
|
9
11
|
}
|
|
10
12
|
export declare function contentHash(project: Project, release: Release): Promise<string>;
|
|
11
|
-
export declare function validate(project: Project, version:
|
|
12
|
-
export declare function
|
|
13
|
+
export declare function validate(project: Project, version: ReleaseId): Promise<Validation>;
|
|
14
|
+
export declare function validateMany(project: Project, versions: ReleaseId[]): Promise<Validation[]>;
|
|
15
|
+
export declare function finalize(project: Project, version: ReleaseId): Promise<Validation>;
|
package/dist/validate.js
CHANGED
|
@@ -1,27 +1,47 @@
|
|
|
1
|
+
import {} from './model.js';
|
|
2
|
+
import { ref, refKey } from './refs.js';
|
|
1
3
|
import { imageSource } from './model.js';
|
|
2
4
|
import { Project, editable } from './project.js';
|
|
3
5
|
import { canonical, digest, readNote, noteHash, identifier } from './files.js';
|
|
4
6
|
import { readVisual, checkReferenceFiles } from './content.js';
|
|
5
7
|
import { validateImages } from './images.js';
|
|
6
|
-
import {
|
|
8
|
+
import { collect, collectSnapshot } from './git.js';
|
|
7
9
|
export async function contentHash(project, release) {
|
|
8
10
|
const { status: _status, contentHash: _hash, ...metadata } = release;
|
|
9
11
|
const parts = [metadata];
|
|
10
12
|
for (const note of release.notes) {
|
|
11
13
|
for (const language of release.locales)
|
|
12
|
-
parts.push(await readNote(await project.releaseFile(release
|
|
14
|
+
parts.push(await readNote(await project.releaseFile(release, `notes/${note.id}/${language}.md`)));
|
|
13
15
|
if (note.image)
|
|
14
|
-
parts.push(await readVisual(project, release
|
|
16
|
+
parts.push(await readVisual(project, release, note.id));
|
|
15
17
|
}
|
|
16
18
|
return digest(canonical(parts));
|
|
17
19
|
}
|
|
18
20
|
export async function validate(project, version) {
|
|
21
|
+
return validateOne(project, version);
|
|
22
|
+
}
|
|
23
|
+
// Reuse structural validation within an export or move, without caching across mutations.
|
|
24
|
+
export async function validateMany(project, versions) {
|
|
25
|
+
const checkedHistory = new Set();
|
|
26
|
+
if (versions.some(v => ref(v).channel !== undefined)) {
|
|
27
|
+
for (const release of await project.channelHistory())
|
|
28
|
+
checkedHistory.add(refKey(release));
|
|
29
|
+
}
|
|
30
|
+
for (const version of versions) {
|
|
31
|
+
if (!checkedHistory.has(refKey(version))) {
|
|
32
|
+
for (const release of await project.history(version))
|
|
33
|
+
checkedHistory.add(refKey(release));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Promise.all(versions.map(version => validateOne(project, version, checkedHistory)));
|
|
37
|
+
}
|
|
38
|
+
async function validateOne(project, version, checkedHistory) {
|
|
19
39
|
const errors = [], warnings = [];
|
|
20
40
|
let hash = null;
|
|
21
41
|
try {
|
|
22
42
|
const release = await project.release(version);
|
|
23
|
-
if (release.initialContent && (release.source.fromSha !== null || release.source.fromRef !== null
|
|
24
|
-
errors.push('Initial content requires a root baseline
|
|
43
|
+
if (release.initialContent && (release.source.fromSha !== null || release.source.fromRef !== null)) {
|
|
44
|
+
errors.push('Initial content requires a root baseline Git range.');
|
|
25
45
|
}
|
|
26
46
|
// Summaries inspect only the baseline snapshot; ready releases use their finalized fingerprint.
|
|
27
47
|
const summary = release.initialContent === 'summary';
|
|
@@ -74,19 +94,13 @@ export async function validate(project, version) {
|
|
|
74
94
|
}
|
|
75
95
|
}
|
|
76
96
|
try {
|
|
77
|
-
|
|
97
|
+
if (!checkedHistory?.has(refKey(version)))
|
|
98
|
+
await project.history(version, 1);
|
|
78
99
|
}
|
|
79
100
|
catch (error) {
|
|
80
101
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
81
102
|
}
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
checkPrevious(project.root, await project.release(release.previous), release.source);
|
|
85
|
-
}
|
|
86
|
-
catch (error) {
|
|
87
|
-
errors.push(error instanceof Error ? error.message : String(error));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
103
|
+
// Git scope was pinned independently; display links can change during moves.
|
|
90
104
|
if (!errors.length)
|
|
91
105
|
hash = await contentHash(project, release);
|
|
92
106
|
if (release.status === 'ready' && release.contentHash !== hash && !errors.length)
|
|
@@ -95,7 +109,7 @@ export async function validate(project, version) {
|
|
|
95
109
|
catch (error) {
|
|
96
110
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
97
111
|
}
|
|
98
|
-
return { version, valid: errors.length === 0, errors, warnings, contentHash: hash };
|
|
112
|
+
return { ...ref(version), valid: errors.length === 0, errors, warnings, contentHash: hash };
|
|
99
113
|
}
|
|
100
114
|
export async function finalize(project, version) {
|
|
101
115
|
const release = await project.release(version);
|
|
@@ -103,8 +117,8 @@ export async function finalize(project, version) {
|
|
|
103
117
|
const result = await validate(project, version);
|
|
104
118
|
if (!result.valid || !result.contentHash)
|
|
105
119
|
throw new Error(result.errors.join('\n'));
|
|
106
|
-
const chain = await project.history(version
|
|
107
|
-
if (chain.slice(1).some(r => r.status !== 'ready'))
|
|
120
|
+
const chain = await project.history(version);
|
|
121
|
+
if (release.channel === undefined && chain.slice(1).some(r => r.status !== 'ready'))
|
|
108
122
|
throw new Error('Finalize the previous releases before finalizing this release.');
|
|
109
123
|
release.status = 'ready';
|
|
110
124
|
release.contentHash = result.contentHash;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Read this when a product has no ReleaseKit releases and the author is choosing where to start. Resolve the likely baseline through [repository inspection](workflow.md#resolve-release-scope-from-the-repository) before asking; a clear inferred tag interval does not require confirmation. Setup has two separate decisions: the baseline commit, and how to present the product's earlier history. A baseline is the end of the earlier period; regular change notes begin **after** it. Choosing a tag does not mean starting at the commit that created that tag's features.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
For channel-aware first drafts, choose use/non-use and the target channel through [the channel workflow](channels.md#first-draft-and-later-drafts) before applying this guide. Use `--channel` on start and prepare, scope existing-release discovery to that channel, and read its `channels.<name>.history.start`. Its display predecessor can be another channel; Git boundaries remain separate.
|
|
6
|
+
|
|
7
|
+
For an unchanneled history, read existing releases and `history.start` in the project config first. Reuse a saved selection when continuing setup. Existing releases use their explicit `previous` links. An explicitly limited Git interval or a requested full-history release already establishes the work's scope; do not add retrospective work or repeat that choice. For first-use requests with unresolved scope, gather the missing decisions using the workflow's [native question UI](workflow.md#ask-with-the-native-question-ui). Follow [the answer-waiting procedure](workflow.md#wait-for-the-users-answer) after asking. Independent inspection may continue while answers are pending, but do not save an unconfirmed choice or draft dependent copy.
|
|
6
8
|
|
|
7
9
|
## Choose the baseline
|
|
8
10
|
|