@iodes/releasekit 0.1.6 → 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.
Files changed (76) hide show
  1. package/README.md +74 -8
  2. package/dist/assets.d.ts +4 -3
  3. package/dist/assets.js +4 -2
  4. package/dist/cli.js +116 -27
  5. package/dist/content.d.ts +7 -5
  6. package/dist/content.js +3 -1
  7. package/dist/export.d.ts +1 -0
  8. package/dist/export.js +33 -17
  9. package/dist/files.js +7 -2
  10. package/dist/images.d.ts +5 -3
  11. package/dist/images.js +3 -1
  12. package/dist/install.d.ts +20 -3
  13. package/dist/install.js +28 -5
  14. package/dist/model.d.ts +69 -7
  15. package/dist/model.js +32 -9
  16. package/dist/move.d.ts +24 -0
  17. package/dist/move.js +292 -0
  18. package/dist/project.d.ts +12 -5
  19. package/dist/project.js +180 -29
  20. package/dist/prompts.js +18 -16
  21. package/dist/refs.d.ts +6 -0
  22. package/dist/refs.js +29 -0
  23. package/dist/setup.d.ts +7 -0
  24. package/dist/setup.js +64 -0
  25. package/dist/status.d.ts +31 -0
  26. package/dist/status.js +52 -0
  27. package/dist/validate.d.ts +5 -2
  28. package/dist/validate.js +31 -17
  29. package/examples/README.md +4 -4
  30. package/examples/backup-encryption/dark.prompt.md +15 -6
  31. package/examples/backup-encryption/light.prompt.md +15 -6
  32. package/examples/connected-route/dark.prompt.md +13 -4
  33. package/examples/connected-route/light.prompt.md +13 -4
  34. package/examples/location-preferences/README.md +6 -4
  35. package/examples/location-preferences/dark-refined.png +0 -0
  36. package/examples/location-preferences/dark-refinement.prompt.md +70 -0
  37. package/examples/location-preferences/dark-size-correction.prompt.md +5 -0
  38. package/examples/location-preferences/dark-weight-correction.prompt.md +5 -0
  39. package/examples/location-preferences/dark.prompt.md +15 -13
  40. package/examples/location-preferences/light-refined.png +0 -0
  41. package/examples/location-preferences/light-refinement.prompt.md +70 -0
  42. package/examples/location-preferences/light.prompt.md +15 -14
  43. package/examples/location-preferences/pair-review.md +17 -10
  44. package/examples/location-preferences/scene.yaml +16 -9
  45. package/examples/queue-action/README.md +3 -3
  46. package/examples/queue-action/dark-accent-edit.prompt.md +5 -0
  47. package/examples/queue-action/dark-accent.png +0 -0
  48. package/examples/queue-action/dark-refined.png +0 -0
  49. package/examples/queue-action/dark-refinement.prompt.md +74 -0
  50. package/examples/queue-action/dark-size-correction.prompt.md +5 -0
  51. package/examples/queue-action/dark.prompt.md +15 -13
  52. package/examples/queue-action/light-accent-edit.prompt.md +5 -0
  53. package/examples/queue-action/light-accent.png +0 -0
  54. package/examples/queue-action/light-refined.png +0 -0
  55. package/examples/queue-action/light-refinement.prompt.md +74 -0
  56. package/examples/queue-action/light.prompt.md +14 -13
  57. package/examples/queue-action/pair-review.md +11 -5
  58. package/examples/queue-action/scene.yaml +13 -9
  59. package/examples/storage-breakdown/dark.prompt.md +15 -6
  60. package/examples/storage-breakdown/light.prompt.md +15 -6
  61. package/examples/tablet-reading/dark.prompt.md +14 -5
  62. package/examples/tablet-reading/light.prompt.md +14 -5
  63. package/kit/references/adoption.md +3 -1
  64. package/kit/references/channels.md +88 -0
  65. package/kit/references/composition-recipes.md +5 -5
  66. package/kit/references/format.md +16 -6
  67. package/kit/references/theme-pairing.md +6 -6
  68. package/kit/references/visual-language.md +17 -17
  69. package/kit/references/workflow.md +4 -4
  70. package/kit/skills/releasekit-draft/SKILL.md +3 -1
  71. package/kit/skills/releasekit-finalize/SKILL.md +4 -2
  72. package/kit/skills/releasekit-image/SKILL.md +4 -2
  73. package/package.json +1 -1
  74. package/schemas/bundle.schema.json +54 -5
  75. package/schemas/config.schema.json +133 -17
  76. package/schemas/release.schema.json +54 -13
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 initProject(project: Project, options: {
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
- }): Promise<{
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(config.tools.map(tool => tool === 'claude' ? '.claude/skills' : '.agents/skills'));
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: config.tools, written, conflicts, hints: {
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
- await writeYaml(file, configSchema.parse(config));
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
- releasedAt: z.ZodISODate;
150
- previous: z.ZodNullable<z.ZodString>;
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
- releasedAt: z.ZodISODate;
322
- previous: z.ZodNullable<z.ZodString>;
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']);
@@ -7,10 +10,10 @@ export const assetVariant = z.enum(['dark', 'light', 'shared']);
7
10
  const color = z.string().regex(/^#[a-fA-F0-9]{6}$/);
8
11
  export const paletteSchema = z.strictObject({
9
12
  canvas: color.describe('Uniform illustration background.'),
10
- surface: color.describe('Base interface panels and resting rows.'),
11
- raised: color.describe('Quiet icon tiles, inset areas, and abstract thumbnail fills.'),
12
- primary: color.describe('Main neutral glyphs and feature-defining marks; mid-gray in the default light theme.'),
13
- secondary: color.describe('Incidental label bars and supporting schematic details.'),
13
+ surface: color.describe('Base or recessed interface panels.'),
14
+ raised: color.describe('Foreground panels, controls, and quiet tile fills.'),
15
+ primary: color.describe('Main neutral glyphs, focal controls, and feature-defining marks; mid-gray in the default light theme.'),
16
+ secondary: color.describe('Supporting glyphs, incidental label bars, and abstract content.'),
14
17
  divider: color.describe('Thin separators and necessary surface boundaries.'),
15
18
  });
16
19
  export const visualPolicySchema = z.strictObject({
@@ -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: z.strictObject({ limit: z.number().int().min(1).max(100), start: historyStartSchema.optional() }),
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, releasedAt: z.iso.date(),
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, releasedAt: z.iso.date(), previous: segment.nullable(),
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
- history: { limit: 3 }, tools: ['codex', 'claude', 'cursor'],
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
+ }