@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/install.d.ts
CHANGED
|
@@ -1,23 +1,40 @@
|
|
|
1
1
|
import { Project } from './project.js';
|
|
2
2
|
import { type ProjectConfig } from './model.js';
|
|
3
|
-
export declare function installSkills(project: Project): Promise<{
|
|
3
|
+
export declare function installSkills(project: Project, tools?: ProjectConfig['tools']): Promise<{
|
|
4
4
|
tools: ("claude" | "codex" | "cursor")[];
|
|
5
5
|
written: string[];
|
|
6
6
|
conflicts: string[];
|
|
7
|
+
unchanged: string[];
|
|
7
8
|
hints: {
|
|
8
9
|
codex: string;
|
|
9
10
|
claude: string;
|
|
10
11
|
cursor: string;
|
|
11
12
|
};
|
|
12
13
|
}>;
|
|
13
|
-
export declare function
|
|
14
|
+
export declare function updateProject(project: Project): Promise<{
|
|
15
|
+
tools: ("claude" | "codex" | "cursor")[];
|
|
16
|
+
written: string[];
|
|
17
|
+
conflicts: string[];
|
|
18
|
+
unchanged: string[];
|
|
19
|
+
hints: {
|
|
20
|
+
codex: string;
|
|
21
|
+
claude: string;
|
|
22
|
+
cursor: string;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
export declare function formatUpdate(result: Awaited<ReturnType<typeof installSkills>>): string;
|
|
26
|
+
export interface InitOptions {
|
|
14
27
|
product?: string;
|
|
15
28
|
tools?: ProjectConfig['tools'];
|
|
16
29
|
themes?: ProjectConfig['visuals']['themes'];
|
|
17
|
-
|
|
30
|
+
sourceLocale?: string;
|
|
31
|
+
locales?: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function initProject(project: Project, options: InitOptions): Promise<{
|
|
18
34
|
tools: ("claude" | "codex" | "cursor")[];
|
|
19
35
|
written: string[];
|
|
20
36
|
conflicts: string[];
|
|
37
|
+
unchanged: string[];
|
|
21
38
|
hints: {
|
|
22
39
|
codex: string;
|
|
23
40
|
claude: string;
|
package/dist/install.js
CHANGED
|
@@ -8,13 +8,14 @@ import { exists, within, write, writeYaml, digest } from './files.js';
|
|
|
8
8
|
const resources = fileURLToPath(new URL('../kit/', import.meta.url));
|
|
9
9
|
const names = ['releasekit-draft', 'releasekit-image', 'releasekit-finalize'];
|
|
10
10
|
const managedSchema = z.record(z.string(), z.string().regex(/^[a-f0-9]{64}$/));
|
|
11
|
-
export async function installSkills(project) {
|
|
11
|
+
export async function installSkills(project, tools) {
|
|
12
12
|
const config = await project.config();
|
|
13
|
+
const selected = tools ?? config.tools;
|
|
13
14
|
const marker = await project.content('managed-skills.json');
|
|
14
15
|
const managed = await exists(marker) ? managedSchema.parse(JSON.parse(await fs.readFile(marker, 'utf8'))) : {};
|
|
15
|
-
const roots = new Set(
|
|
16
|
+
const roots = new Set(selected.map(tool => tool === 'claude' ? '.claude/skills' : '.agents/skills'));
|
|
16
17
|
const references = (await fs.readdir(path.join(resources, 'references'))).filter(file => file.endsWith('.md')).sort();
|
|
17
|
-
const written = [], conflicts = [];
|
|
18
|
+
const written = [], conflicts = [], unchanged = [];
|
|
18
19
|
for (const root of roots) {
|
|
19
20
|
for (const name of names) {
|
|
20
21
|
const sourceFiles = [
|
|
@@ -29,6 +30,7 @@ export async function installSkills(project) {
|
|
|
29
30
|
const actual = digest(await fs.readFile(destination));
|
|
30
31
|
if (actual === expected) {
|
|
31
32
|
managed[item.destination] = expected;
|
|
33
|
+
unchanged.push(item.destination);
|
|
32
34
|
continue;
|
|
33
35
|
}
|
|
34
36
|
if (actual !== managed[item.destination]) {
|
|
@@ -43,12 +45,26 @@ export async function installSkills(project) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
await write(marker, JSON.stringify(managed, null, 2) + '\n');
|
|
46
|
-
return { tools:
|
|
48
|
+
return { tools: selected, written, conflicts, unchanged, hints: {
|
|
47
49
|
codex: 'Use $releasekit-draft, $releasekit-image, or $releasekit-finalize.',
|
|
48
50
|
claude: 'Use /releasekit-draft, /releasekit-image, or /releasekit-finalize.',
|
|
49
51
|
cursor: 'Use the installed releasekit-* skills from the agent skill picker or name them in your request.',
|
|
50
52
|
} };
|
|
51
53
|
}
|
|
54
|
+
export async function updateProject(project) {
|
|
55
|
+
return installSkills(project, ['codex', 'claude', 'cursor']);
|
|
56
|
+
}
|
|
57
|
+
export function formatUpdate(result) {
|
|
58
|
+
const lines = [result.conflicts.length ? 'Update needs attention.' : result.tools.length ? 'Skills are up to date.' : 'No agent tools selected.',
|
|
59
|
+
` Updated: ${result.written.length} files`, ` Already current: ${result.unchanged.length} files`, ` Modified files preserved: ${result.conflicts.length}`];
|
|
60
|
+
if (result.conflicts.length) {
|
|
61
|
+
lines.push('', ...result.conflicts.map(file => ` ! ${file}`), 'Compare these files with the installed package templates and merge the changes you want to keep.');
|
|
62
|
+
}
|
|
63
|
+
if (result.tools.length)
|
|
64
|
+
lines.push('', ...result.tools.map(tool => ` ${tool}: ${result.hints[tool]}`));
|
|
65
|
+
lines.push('', 'Next: releasekit status');
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
52
68
|
export async function initProject(project, options) {
|
|
53
69
|
const file = await project.content('config.yaml');
|
|
54
70
|
if (await exists(file))
|
|
@@ -58,6 +74,13 @@ export async function initProject(project, options) {
|
|
|
58
74
|
config.tools = [...new Set(options.tools)];
|
|
59
75
|
if (options.themes)
|
|
60
76
|
config.visuals.themes = options.themes;
|
|
61
|
-
|
|
77
|
+
if (options.sourceLocale !== undefined)
|
|
78
|
+
config.sourceLocale = options.sourceLocale;
|
|
79
|
+
config.locales = options.locales ?? [config.sourceLocale];
|
|
80
|
+
configSchema.parse(config);
|
|
81
|
+
if (!config.locales.includes(config.sourceLocale) || new Set(config.locales).size !== config.locales.length) {
|
|
82
|
+
throw new Error('Project locales must be unique and include the source locale.');
|
|
83
|
+
}
|
|
84
|
+
await writeYaml(file, config);
|
|
62
85
|
return { config: file, ...await installSkills(project) };
|
|
63
86
|
}
|
package/dist/model.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare const segment: z.ZodString;
|
|
3
|
+
export declare const channelName: z.ZodString;
|
|
4
|
+
export declare const channelRefSchema: z.ZodObject<{
|
|
5
|
+
channel: z.ZodString;
|
|
6
|
+
version: z.ZodString;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export type ReleaseRef = {
|
|
9
|
+
version: string;
|
|
10
|
+
channel?: string;
|
|
11
|
+
};
|
|
12
|
+
export type ReleaseId = string | ReleaseRef;
|
|
13
|
+
export declare const releasedAtSchema: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
|
|
3
14
|
export declare const sha: z.ZodString;
|
|
4
15
|
export declare const locale: z.ZodString;
|
|
5
16
|
export declare const theme: z.ZodEnum<{
|
|
@@ -66,13 +77,33 @@ export declare const historyStartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
66
77
|
version: z.ZodNull;
|
|
67
78
|
}, z.core.$strict>], "past">;
|
|
68
79
|
export type HistoryStart = z.infer<typeof historyStartSchema>;
|
|
80
|
+
export declare const channelsSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
81
|
+
include: z.ZodArray<z.ZodString>;
|
|
82
|
+
history: z.ZodOptional<z.ZodObject<{
|
|
83
|
+
start: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
84
|
+
ref: z.ZodString;
|
|
85
|
+
sha: z.ZodString;
|
|
86
|
+
past: z.ZodLiteral<"summary">;
|
|
87
|
+
version: z.ZodString;
|
|
88
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
89
|
+
ref: z.ZodString;
|
|
90
|
+
sha: z.ZodString;
|
|
91
|
+
past: z.ZodLiteral<"history">;
|
|
92
|
+
version: z.ZodString;
|
|
93
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
94
|
+
ref: z.ZodString;
|
|
95
|
+
sha: z.ZodString;
|
|
96
|
+
past: z.ZodLiteral<"skip">;
|
|
97
|
+
version: z.ZodNull;
|
|
98
|
+
}, z.core.$strict>], "past">>;
|
|
99
|
+
}, z.core.$strict>>;
|
|
100
|
+
}, z.core.$strict>>;
|
|
69
101
|
export declare const configSchema: z.ZodObject<{
|
|
70
102
|
schemaVersion: z.ZodLiteral<1>;
|
|
71
103
|
product: z.ZodString;
|
|
72
104
|
sourceLocale: z.ZodString;
|
|
73
105
|
locales: z.ZodArray<z.ZodString>;
|
|
74
|
-
history: z.ZodObject<{
|
|
75
|
-
limit: z.ZodNumber;
|
|
106
|
+
history: z.ZodOptional<z.ZodObject<{
|
|
76
107
|
start: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
77
108
|
ref: z.ZodString;
|
|
78
109
|
sha: z.ZodString;
|
|
@@ -89,7 +120,28 @@ export declare const configSchema: z.ZodObject<{
|
|
|
89
120
|
past: z.ZodLiteral<"skip">;
|
|
90
121
|
version: z.ZodNull;
|
|
91
122
|
}, z.core.$strict>], "past">>;
|
|
92
|
-
}, z.core.$strict
|
|
123
|
+
}, z.core.$strict>>;
|
|
124
|
+
channels: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
125
|
+
include: z.ZodArray<z.ZodString>;
|
|
126
|
+
history: z.ZodOptional<z.ZodObject<{
|
|
127
|
+
start: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
128
|
+
ref: z.ZodString;
|
|
129
|
+
sha: z.ZodString;
|
|
130
|
+
past: z.ZodLiteral<"summary">;
|
|
131
|
+
version: z.ZodString;
|
|
132
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
133
|
+
ref: z.ZodString;
|
|
134
|
+
sha: z.ZodString;
|
|
135
|
+
past: z.ZodLiteral<"history">;
|
|
136
|
+
version: z.ZodString;
|
|
137
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
138
|
+
ref: z.ZodString;
|
|
139
|
+
sha: z.ZodString;
|
|
140
|
+
past: z.ZodLiteral<"skip">;
|
|
141
|
+
version: z.ZodNull;
|
|
142
|
+
}, z.core.$strict>], "past">>;
|
|
143
|
+
}, z.core.$strict>>;
|
|
144
|
+
}, z.core.$strict>>]>>;
|
|
93
145
|
visuals: z.ZodObject<{
|
|
94
146
|
themes: z.ZodEnum<{
|
|
95
147
|
both: "both";
|
|
@@ -146,8 +198,12 @@ export type NoteMeta = z.infer<typeof noteMetaSchema>;
|
|
|
146
198
|
export declare const releaseSchema: z.ZodObject<{
|
|
147
199
|
schemaVersion: z.ZodLiteral<1>;
|
|
148
200
|
version: z.ZodString;
|
|
149
|
-
|
|
150
|
-
|
|
201
|
+
channel: z.ZodOptional<z.ZodString>;
|
|
202
|
+
releasedAt: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
|
|
203
|
+
previous: z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
204
|
+
channel: z.ZodString;
|
|
205
|
+
version: z.ZodString;
|
|
206
|
+
}, z.core.$strict>]>>;
|
|
151
207
|
status: z.ZodEnum<{
|
|
152
208
|
draft: "draft";
|
|
153
209
|
ready: "ready";
|
|
@@ -316,10 +372,16 @@ export declare const bundleSchema: z.ZodObject<{
|
|
|
316
372
|
schemaVersion: z.ZodLiteral<1>;
|
|
317
373
|
currentVersion: z.ZodString;
|
|
318
374
|
locale: z.ZodString;
|
|
375
|
+
viewChannel: z.ZodOptional<z.ZodString>;
|
|
376
|
+
currentChannel: z.ZodOptional<z.ZodString>;
|
|
319
377
|
releases: z.ZodArray<z.ZodObject<{
|
|
320
378
|
version: z.ZodString;
|
|
321
|
-
|
|
322
|
-
|
|
379
|
+
channel: z.ZodOptional<z.ZodString>;
|
|
380
|
+
releasedAt: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
|
|
381
|
+
previous: z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
382
|
+
channel: z.ZodString;
|
|
383
|
+
version: z.ZodString;
|
|
384
|
+
}, z.core.$strict>]>>;
|
|
323
385
|
notes: z.ZodArray<z.ZodObject<{
|
|
324
386
|
id: z.ZodString;
|
|
325
387
|
category: z.ZodEnum<{
|
package/dist/model.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const segment = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,95}$/);
|
|
3
|
+
export const channelName = z.string().regex(/^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$)[a-z][a-z0-9-]{0,62}$/);
|
|
4
|
+
export const channelRefSchema = z.strictObject({ channel: channelName, version: segment });
|
|
5
|
+
export const releasedAtSchema = z.union([z.iso.date(), z.iso.datetime({ offset: true }).regex(/T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/)]);
|
|
3
6
|
export const sha = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/);
|
|
4
7
|
export const locale = z.string().regex(/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/);
|
|
5
8
|
export const theme = z.enum(['dark', 'light']);
|
|
@@ -27,10 +30,25 @@ export const historyStartSchema = z.discriminatedUnion('past', [
|
|
|
27
30
|
z.strictObject({ ref: z.string().min(1), sha, past: z.literal('history'), version: segment }),
|
|
28
31
|
z.strictObject({ ref: z.string().min(1), sha, past: z.literal('skip'), version: z.null() }),
|
|
29
32
|
]);
|
|
33
|
+
const historySettings = z.strictObject({ start: historyStartSchema.optional() });
|
|
34
|
+
export const channelsSchema = z.record(channelName, z.strictObject({
|
|
35
|
+
include: z.array(channelName).min(1), history: historySettings.optional(),
|
|
36
|
+
})).superRefine((channels, ctx) => {
|
|
37
|
+
if (!Object.keys(channels).length)
|
|
38
|
+
ctx.addIssue({ code: 'custom', message: 'Use channels: false to disable channels.' });
|
|
39
|
+
for (const [name, settings] of Object.entries(channels)) {
|
|
40
|
+
if (new Set(settings.include).size !== settings.include.length)
|
|
41
|
+
ctx.addIssue({ code: 'custom', path: [name, 'include'], message: 'Included channels must be unique.' });
|
|
42
|
+
for (const included of settings.include)
|
|
43
|
+
if (!Object.hasOwn(channels, included))
|
|
44
|
+
ctx.addIssue({ code: 'custom', path: [name, 'include'], message: `Unknown included channel: ${included}` });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
30
47
|
export const configSchema = z.strictObject({
|
|
31
48
|
schemaVersion: z.literal(1), product: z.string().min(1),
|
|
32
49
|
sourceLocale: locale, locales: z.array(locale).min(1),
|
|
33
|
-
history:
|
|
50
|
+
history: historySettings.optional(),
|
|
51
|
+
channels: z.union([z.literal(false), channelsSchema]).optional(),
|
|
34
52
|
visuals: visualPolicySchema,
|
|
35
53
|
tools: z.array(z.enum(['codex', 'claude', 'cursor'])),
|
|
36
54
|
});
|
|
@@ -43,12 +61,16 @@ export const noteMetaSchema = z.strictObject({
|
|
|
43
61
|
commits: z.array(sha), paths: z.array(z.string()), image: z.boolean(),
|
|
44
62
|
});
|
|
45
63
|
export const releaseSchema = z.strictObject({
|
|
46
|
-
schemaVersion: z.literal(1), version: segment,
|
|
47
|
-
previous: segment.nullable(), status: z.enum(['draft', 'ready']),
|
|
64
|
+
schemaVersion: z.literal(1), version: segment, channel: channelName.optional(), releasedAt: releasedAtSchema,
|
|
65
|
+
previous: z.union([segment, channelRefSchema]).nullable(), status: z.enum(['draft', 'ready']),
|
|
48
66
|
source: sourceSchema, initialContent: z.enum(['summary', 'history']).optional(),
|
|
49
67
|
sourceLocale: locale, locales: z.array(locale).min(1),
|
|
50
68
|
visuals: visualPolicySchema, notes: z.array(noteMetaSchema),
|
|
51
69
|
emptyReason: z.string().nullable(), contentHash: z.string().nullable(),
|
|
70
|
+
}).superRefine((release, ctx) => {
|
|
71
|
+
if (release.previous !== null && (release.channel === undefined ? typeof release.previous !== 'string' : typeof release.previous === 'string')) {
|
|
72
|
+
ctx.addIssue({ code: 'custom', path: ['previous'], message: 'Channel releases require a channel/version predecessor; unchanneled releases require a version string.' });
|
|
73
|
+
}
|
|
52
74
|
});
|
|
53
75
|
export const noteTextSchema = z.strictObject({
|
|
54
76
|
title: z.string().min(1), alt: z.string(), sourceHash: z.string().nullable(),
|
|
@@ -80,8 +102,9 @@ const exportedImage = z.strictObject({
|
|
|
80
102
|
});
|
|
81
103
|
export const bundleSchema = z.strictObject({
|
|
82
104
|
schemaVersion: z.literal(1), currentVersion: segment, locale,
|
|
105
|
+
viewChannel: channelName.optional(), currentChannel: channelName.optional(),
|
|
83
106
|
releases: z.array(z.strictObject({
|
|
84
|
-
version: segment,
|
|
107
|
+
version: segment, channel: channelName.optional(), releasedAt: releasedAtSchema, previous: z.union([segment, channelRefSchema]).nullable(),
|
|
85
108
|
notes: z.array(z.strictObject({
|
|
86
109
|
id: segment, category: noteMetaSchema.shape.category, title: z.string(), bodyMarkdown: z.string(),
|
|
87
110
|
image: z.strictObject({
|
|
@@ -119,7 +142,7 @@ export function activeVariants(visual, policy) {
|
|
|
119
142
|
export function defaultConfig(product) {
|
|
120
143
|
return {
|
|
121
144
|
schemaVersion: 1, product, sourceLocale: 'en-US', locales: ['en-US'],
|
|
122
|
-
|
|
145
|
+
tools: ['codex', 'claude', 'cursor'],
|
|
123
146
|
visuals: {
|
|
124
147
|
themes: 'both', preset: 'quiet-product', width: 1280, height: 800, accent: '#4678ED',
|
|
125
148
|
dark: { canvas: '#242527', surface: '#18191B', raised: '#343638', primary: '#B9BBBE', secondary: '#777B80', divider: '#46494D' },
|
package/dist/move.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Project } from './project.js';
|
|
2
|
+
import { type ReleaseRef } from './model.js';
|
|
3
|
+
export interface MoveOptions {
|
|
4
|
+
fromChannel?: string;
|
|
5
|
+
toChannel?: string;
|
|
6
|
+
toUnchanneled?: boolean;
|
|
7
|
+
after?: string;
|
|
8
|
+
atStart?: boolean;
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function moveReleases(project: Project, versions: string[], options: MoveOptions): Promise<{
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
moved: {
|
|
14
|
+
from: ReleaseRef;
|
|
15
|
+
to: ReleaseRef;
|
|
16
|
+
status: "draft" | "ready";
|
|
17
|
+
}[];
|
|
18
|
+
updatedReleases: ReleaseRef[];
|
|
19
|
+
updatedFiles: string[];
|
|
20
|
+
paths: {
|
|
21
|
+
from: string;
|
|
22
|
+
to: string;
|
|
23
|
+
}[];
|
|
24
|
+
}>;
|
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,29 @@
|
|
|
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[];
|
|
2
4
|
export declare class Project {
|
|
3
5
|
readonly root: string;
|
|
4
6
|
constructor(root: string);
|
|
5
7
|
static find(cwd: string): Project;
|
|
6
8
|
content(relative: string): Promise<string>;
|
|
7
9
|
config(): Promise<ProjectConfig>;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
requireChannel(channel: string): Promise<void>;
|
|
11
|
+
releaseDir(value: ReleaseId): Promise<string>;
|
|
12
|
+
releaseFile(value: ReleaseId, relative: string): Promise<string>;
|
|
13
|
+
release(value: ReleaseId): Promise<Release>;
|
|
11
14
|
save(release: Release): Promise<void>;
|
|
15
|
+
allRefs(): Promise<ReleaseRef[]>;
|
|
12
16
|
versions(): Promise<string[]>;
|
|
17
|
+
channelHistory(): Promise<Release[]>;
|
|
13
18
|
latestVersion(): Promise<string>;
|
|
14
|
-
history(
|
|
19
|
+
history(value: ReleaseId, limit?: number): Promise<Release[]>;
|
|
15
20
|
}
|
|
16
21
|
export declare function editable(release: Release): void;
|
|
17
22
|
export interface StartOptions {
|
|
18
23
|
at: string;
|
|
19
24
|
past: HistoryStart['past'];
|
|
20
25
|
version?: string;
|
|
26
|
+
channel?: string;
|
|
21
27
|
}
|
|
22
28
|
export declare function startProject(project: Project, options: StartOptions): Promise<HistoryStart>;
|
|
23
29
|
export interface PrepareOptions {
|
|
@@ -27,5 +33,6 @@ export interface PrepareOptions {
|
|
|
27
33
|
fromRoot?: boolean;
|
|
28
34
|
firstRelease?: boolean;
|
|
29
35
|
date?: string;
|
|
36
|
+
channel?: string;
|
|
30
37
|
}
|
|
31
38
|
export declare function prepare(project: Project, version: string, options: PrepareOptions): Promise<Release>;
|