@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/README.md
CHANGED
|
@@ -50,10 +50,20 @@ npm install -g @iodes/releasekit
|
|
|
50
50
|
Run inside your product's Git repository:
|
|
51
51
|
|
|
52
52
|
```sh
|
|
53
|
-
releasekit init
|
|
53
|
+
releasekit init
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Interactive setup asks for your product name, agent tools, original language, complete language set, and image themes. Press Enter to accept each default. Explicit options skip their corresponding questions. Settings are saved to `releasekit/config.yaml`; setup prints invocation hints only for the tools you selected. Ctrl+C cancels setup before configuration is written.
|
|
57
|
+
|
|
58
|
+
For scripts, CI, or a coding agent, pass options directly:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
releasekit init --no-interactive --tools codex,claude --source-locale en-US --locales en-US,ko-KR --themes both
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Prompts are disabled with `--json`, `--no-interactive`, or non-terminal input/output. Omitted settings then use the defaults: repository name, all supported tools, English only, and both themes. Use `--tools none` to install no agent skills. `--locales` must include the original language with no duplicates. You can also initialize another existing Git repository with `releasekit init ./your-product` (relative to `--cwd`, if supplied). Re-running init preserves existing configuration and directs you to `releasekit update` for managed skills.
|
|
65
|
+
|
|
66
|
+
Edit `releasekit/config.yaml` later to change defaults for new drafts. Existing releases retain their saved settings.
|
|
57
67
|
|
|
58
68
|
### 3. Ask your agent
|
|
59
69
|
|
|
@@ -64,8 +74,8 @@ You: Use releasekit-draft to draft 1.4.0.
|
|
|
64
74
|
AI: I'll use English for the original notes. Add Korean, your current
|
|
65
75
|
language, as a translation (recommended), or use English only?
|
|
66
76
|
You can also enter additional languages together.
|
|
67
|
-
You: Korean and Japanese translations.
|
|
68
|
-
AI: Saved these language defaults in releasekit/config.yaml.
|
|
77
|
+
You: Korean and Japanese translations, and a single release history.
|
|
78
|
+
AI: Saved these language defaults and channels: false in releasekit/config.yaml.
|
|
69
79
|
Found v1.4.0 and its preceding release tag v1.3.0 on this line.
|
|
70
80
|
Created releasekit/releases/1.4.0/
|
|
71
81
|
✓ Pinned v1.3.0 → v1.4.0 and collected change evidence
|
|
@@ -111,6 +121,24 @@ Language selection is a first-use decision. When it is still unresolved, the dra
|
|
|
111
121
|
|
|
112
122
|
Invoke the skill with `$releasekit-draft` in Codex, `/releasekit-draft` in Claude Code, or the skill picker in Cursor. The agent runs the CLI, generates flat explanations, and requests approved source images when the actual product or content must be shown.
|
|
113
123
|
|
|
124
|
+
## CLI progress and next steps
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
releasekit list # All releases, including channels
|
|
128
|
+
releasekit list --channel stable # One channel
|
|
129
|
+
releasekit status # Validate all releases and show next steps
|
|
130
|
+
releasekit status 1.4.0 # One unchanneled release
|
|
131
|
+
releasekit status 1.4.0 --channel stable
|
|
132
|
+
releasekit status --json # Structured progress for scripts
|
|
133
|
+
releasekit update # Refresh installed skills; preserve user edits
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`list` shows release identities, saved draft/ready state, note counts, and dates. `status` checks content with the same validation used by `validate`, displays errors and warnings, and suggests the next step. A ready label alone does not guarantee that files still pass validation. Status is read-only and exits successfully even when drafts have pending work; use `validate` for a failing exit code when content is invalid. Before initialization, `status` points to `init`.
|
|
137
|
+
|
|
138
|
+
Running `releasekit update` refreshes skills for all supported tools (Codex, Claude Code, and Cursor), regardless of the saved init selection. It installs missing skills, preserves project configuration and locally modified files, and never prompts for tool selection. The result shows the project, supported tools, updated files, already-current files, and preserved edits, followed by invocation hints and the next command. Conflicts produce exit code 1. `--json` returns only the structured result, including `written`, `unchanged`, and `conflicts` arrays. This command refreshes bundled project skills; it does not upgrade the CLI package itself.
|
|
139
|
+
|
|
140
|
+
Setup, update, prepare, list, status, and validate print readable summaries. `--json` preserves structured output for automation; command and option errors are JSON objects on stderr with a nonzero exit code. Help and version output remain plain text. Other content operations retain their detailed JSON results. Run `releasekit <command> --help` for options and `releasekit --help` for the workflow overview.
|
|
141
|
+
|
|
114
142
|
## Adopting ReleaseKit later
|
|
115
143
|
|
|
116
144
|
You can start after your product has already shipped. Ask the draft skill to set a starting point; it finds a relevant release tag or commit from the repository and asks how to handle the earlier period when that choice is unresolved. Regular notes begin **after** the selected baseline commit.
|
|
@@ -196,7 +224,7 @@ releasekit export --out ./release-output
|
|
|
196
224
|
- Notes include images by default. Use `note add --no-image` only for an explicit text-only choice. Adding a note clears any previous `emptyReason`.
|
|
197
225
|
- Use `releasekit note remove <version> <id>` to exclude a draft note. It removes the note folder, translations, visual brief, prompts, and unused managed images, including older imports. Images referenced by remaining visuals and source originals are preserved. The result lists removed paths and retained shared assets. Removing the last note leaves the draft pending until you add notes or supply a factual `emptyReason`.
|
|
198
226
|
- Add `--json` for structured results or `--cwd` to select a project directory.
|
|
199
|
-
- Only `--out` is required for export. Omit `--current` to select the release with no successor in the saved `previous` links; multiple release lines require an explicit `--current`. The selected release must be ready. Omit `--limit` to
|
|
227
|
+
- Only `--out` is required for export. Omit `--current` to select the release with no successor in the saved `previous` links; multiple release lines require an explicit `--current`. The selected release must be ready. Omit `--limit` to export the entire linked history; pass `--limit N` to select at most N releases.
|
|
200
228
|
- Omit `--locale` to export every locale saved in the current release as `release-notes.<locale>.json`, such as `release-notes.en-US.json` and `release-notes.ko-KR.json`. Add `--locale en-US` for only the English file. All files share the same `assets/` directory. Each selected version must contain the requested locales; missing or stale translations stop the export before output is created.
|
|
201
229
|
- Export to a new directory; an existing destination is never overwritten. The command result lists generated JSON paths in `files`, the number of version groups in `releases`, and the number of shared image files in `assets`.
|
|
202
230
|
|
|
@@ -213,6 +241,44 @@ Codex and Cursor share `.agents/skills` to avoid duplicate discovery. Claude Cod
|
|
|
213
241
|
|
|
214
242
|
</details>
|
|
215
243
|
|
|
244
|
+
## Release channels
|
|
245
|
+
|
|
246
|
+
Channel use is a first-draft choice, alongside unresolved language choices. The draft skill saves `channels: false` for a single history, or a user-selected channel map before preparing content. Initialization leaves the choice unset. Existing projects with releases and no channel setting continue their single history.
|
|
247
|
+
|
|
248
|
+
```yaml
|
|
249
|
+
channels:
|
|
250
|
+
prod:
|
|
251
|
+
include: [prod]
|
|
252
|
+
dev:
|
|
253
|
+
include: [dev, prod]
|
|
254
|
+
stage:
|
|
255
|
+
include: [prod]
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
For later new drafts, an explicit request such as “Draft 2.1 for dev” selects that channel. With several configured channels and no clear target, the skill asks which channel to write for. It reuses an existing draft's channel and never guesses from the previous release or branch name. A project with one configured channel needs no target question. The CLI itself does not prompt.
|
|
259
|
+
|
|
260
|
+
Channel releases live in `releasekit/releases/<channel>/<version>/`. The same version can exist in several channels. All channel releases share one newest-to-oldest `previous` chain using `{ channel, version }` references; an export follows that chain and skips drafts and excluded channels without sorting dates. Channel-free releases keep their separate existing history.
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
releasekit prepare 2.1 --channel dev --from <git-ref> --to <git-ref>
|
|
264
|
+
releasekit finalize 2.1 --channel dev
|
|
265
|
+
releasekit export --channel dev --out ./output/dev
|
|
266
|
+
releasekit export --channel prod --limit 3 --out ./output/prod
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
There is no default export count limit. `--limit N` caps the combined, filtered result at N releases. With the configuration above, dev sees dev and prod; stage sees prod only. The exported entries identify their channels, and the exported `previous` links connect only entries included in that output. Git analysis boundaries remain independent of display links.
|
|
270
|
+
|
|
271
|
+
Ask the draft skill to move one or several whole releases, for example “Move dev 2.0 and 2.1 to prod.” It previews and runs the deterministic move command, preserving ready status, content, images, Git boundaries, and release timestamps. Channel-to-channel moves retain their global position; moves to or from a channel-free history splice the selected releases between histories. Conflicts stop the whole move.
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
releasekit release move 2.0 2.1 --from-channel dev --to-channel prod --dry-run
|
|
275
|
+
releasekit release move 2.0 2.1 --from-channel dev --to-channel prod
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`releasedAt` accepts a date or a timestamp with `Z` or an explicit UTC offset. New drafts default to the current UTC timestamp, including milliseconds. Use `--date 2026-09-14T18:30:00+09:00` to supply one; existing date-only strings are preserved. Neither finalization nor channel moves replace the saved time.
|
|
279
|
+
|
|
280
|
+
See [channels and moves](kit/references/channels.md) for first-use decisions, identity, history, and insertion options.
|
|
281
|
+
|
|
216
282
|
## Image themes
|
|
217
283
|
|
|
218
284
|
Image work covers **every drafted note** by default, including grouped minor fixes and improvements. A group uses one visual brief and the configured image variants; individual bullets do not require separate images. Only an explicit text-only choice omits a note's image. Calling `releasekit-image` again fills missing images, including those for notes added later, and reuses existing valid images. Existing images that need corrections stay pending until the affected revision or replacement is requested.
|
|
@@ -297,7 +363,7 @@ Major capabilities and changes that warrant individual attention get standalone
|
|
|
297
363
|
|
|
298
364
|
Releases store the comparison start and end SHAs in `release.yaml`, with relevant paths or commits attached to individual notes. They do not save a full patch or a separate changed-file index. Draft validation reads the pinned Git range, or the baseline snapshot for a product introduction; finalized releases can be validated and exported without Git history.
|
|
299
365
|
|
|
300
|
-
Export follows explicit `previous` links, keeping each version's notes in a separate group.
|
|
366
|
+
Export follows explicit `previous` links, keeping each version's notes in a separate group. There is **no default count limit**; `--limit N` selects the first N releases after filtering. Similar notes in different versions remain separate.
|
|
301
367
|
|
|
302
368
|
The bundle contains display data and relative assets. Git evidence, prompts, and private source paths stay out of the export. Consumers safely render `bodyMarkdown` and select `image.variants[theme]`, falling back to `image.variants[image.fallbackTheme]` when needed. Text-only notes have `image: null`.
|
|
303
369
|
|
package/dist/assets.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type ReleaseId } from './model.js';
|
|
1
2
|
import { type AssetVariant, type Visual } from './model.js';
|
|
2
3
|
import { Project } from './project.js';
|
|
3
4
|
export declare function fileKey(file: string): Promise<string>;
|
|
4
|
-
export declare function visualFileReferences(project: Project, version:
|
|
5
|
+
export declare function visualFileReferences(project: Project, version: ReleaseId, file: string, selected?: Visual): Promise<Set<string>>;
|
|
5
6
|
export declare function retainedImageFiles(project: Project, replacement: {
|
|
6
|
-
version:
|
|
7
|
+
version: ReleaseId;
|
|
7
8
|
noteId: string;
|
|
8
9
|
visual: Visual | null;
|
|
9
10
|
}): Promise<Set<string>>;
|
|
10
|
-
export declare function managedAssetFiles(project: Project, version:
|
|
11
|
+
export declare function managedAssetFiles(project: Project, version: ReleaseId, noteId?: string, variant?: AssetVariant): Promise<string[]>;
|
package/dist/assets.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {} from './model.js';
|
|
2
|
+
import { refKey } from './refs.js';
|
|
1
3
|
import * as fs from 'node:fs/promises';
|
|
2
4
|
import { visualSchema } from './model.js';
|
|
3
5
|
import { Project } from './project.js';
|
|
@@ -23,14 +25,14 @@ export async function visualFileReferences(project, version, file, selected) {
|
|
|
23
25
|
}
|
|
24
26
|
export async function retainedImageFiles(project, replacement) {
|
|
25
27
|
const retained = new Set();
|
|
26
|
-
for (const version of await project.
|
|
28
|
+
for (const version of await project.allRefs()) {
|
|
27
29
|
const directory = await project.releaseFile(version, 'visuals');
|
|
28
30
|
if (!(await exists(directory)))
|
|
29
31
|
continue;
|
|
30
32
|
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
31
33
|
if (entry.isDirectory() || !entry.name.endsWith('.yaml'))
|
|
32
34
|
continue;
|
|
33
|
-
const replacing = version === replacement.version && entry.name === `${replacement.noteId}.yaml`;
|
|
35
|
+
const replacing = refKey(version) === refKey(replacement.version) && entry.name === `${replacement.noteId}.yaml`;
|
|
34
36
|
if (replacing && replacement.visual === null)
|
|
35
37
|
continue;
|
|
36
38
|
const file = await project.releaseFile(version, `visuals/${entry.name}`);
|
package/dist/cli.js
CHANGED
|
@@ -1,92 +1,173 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { Command, Option } from 'commander';
|
|
4
|
+
import { Command, CommanderError, Option } from 'commander';
|
|
5
5
|
import { Project, prepare, startProject } from './project.js';
|
|
6
|
-
import { initProject,
|
|
6
|
+
import { initProject, updateProject, formatUpdate } from './install.js';
|
|
7
|
+
import { commaList, parseTools, interactiveSetup } from './setup.js';
|
|
8
|
+
import { listReleases, projectStatus, formatStatus } from './status.js';
|
|
9
|
+
import { exists } from './files.js';
|
|
10
|
+
import { refKey } from './refs.js';
|
|
7
11
|
import { addNote, removeNote, markTranslation, syncImagePolicy } from './content.js';
|
|
8
12
|
import { planImages, importImage } from './images.js';
|
|
9
13
|
import { validate, finalize } from './validate.js';
|
|
14
|
+
import { moveReleases } from './move.js';
|
|
15
|
+
import { ref } from './refs.js';
|
|
10
16
|
import { exportBundle } from './export.js';
|
|
11
|
-
import {
|
|
17
|
+
import { noteMetaSchema, assetVariant } from './model.js';
|
|
12
18
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
19
|
const program = new Command();
|
|
14
20
|
program.name('releasekit').description('Git-based visual release content and agent skills').version(version)
|
|
15
21
|
.option('--cwd <directory>', 'project working directory', process.cwd())
|
|
16
|
-
.option('--json', 'print machine-readable results')
|
|
22
|
+
.option('--json', 'print machine-readable results')
|
|
23
|
+
.option('--no-interactive', 'disable setup prompts (also disabled for JSON and non-TTY input)')
|
|
24
|
+
.showSuggestionAfterError()
|
|
25
|
+
.showHelpAfterError('(Run releasekit --help for available commands.)')
|
|
26
|
+
.exitOverride();
|
|
27
|
+
program.configureOutput({ writeErr: message => {
|
|
28
|
+
if (!process.argv.includes('--json'))
|
|
29
|
+
process.stderr.write(message);
|
|
30
|
+
} });
|
|
31
|
+
program.addHelpText('after', '\nGetting started:\n releasekit init\n releasekit list\n releasekit status\n\nUse releasekit-draft with your agent to write a release. Run any command with --help for details.');
|
|
32
|
+
const target = (version, options) => ref({ version, channel: options.channel });
|
|
17
33
|
const project = () => Project.find(path.resolve(program.opts().cwd));
|
|
34
|
+
program.hook('preAction', async (_command, action) => {
|
|
35
|
+
const channel = action.opts().channel;
|
|
36
|
+
if (channel !== undefined)
|
|
37
|
+
await project().requireChannel(channel);
|
|
38
|
+
});
|
|
18
39
|
function emit(value, summary) {
|
|
19
40
|
console.log(program.opts().json || !summary ? JSON.stringify(value, null, 2) : summary);
|
|
20
41
|
}
|
|
21
|
-
program.command('init').description('Initialize content and install project skills')
|
|
42
|
+
program.command('init [directory]').description('Initialize content and install project skills')
|
|
22
43
|
.option('--product <name>', 'product name')
|
|
23
|
-
.option('--tools <tools>', 'comma-separated codex,claude,cursor')
|
|
44
|
+
.option('--tools <tools>', 'comma-separated codex,claude,cursor, or none')
|
|
24
45
|
.addOption(new Option('--themes <policy>', 'project image variants').choices(['both', 'dark', 'light']))
|
|
46
|
+
.option('--source-locale <locale>', 'original language, defaults to en-US')
|
|
47
|
+
.option('--locales <locales>', 'comma-separated languages including the original')
|
|
48
|
+
.action(async (directory, options) => {
|
|
49
|
+
const instance = directory === undefined ? project() : Project.find(path.resolve(program.opts().cwd, directory));
|
|
50
|
+
if (await exists(await instance.content('config.yaml')))
|
|
51
|
+
throw new Error('ReleaseKit is already initialized. Edit releasekit/config.yaml for settings or run releasekit update.');
|
|
52
|
+
let setup = { ...options, tools: options.tools === undefined ? undefined : parseTools(options.tools),
|
|
53
|
+
locales: options.locales === undefined ? undefined : commaList(options.locales) };
|
|
54
|
+
if (program.opts().interactive && !program.opts().json && process.stdin.isTTY && process.stdout.isTTY)
|
|
55
|
+
setup = await interactiveSetup(instance.root, setup);
|
|
56
|
+
const result = await initProject(instance, setup);
|
|
57
|
+
emit(result, [`Initialized ReleaseKit in ${instance.root}`, `Configuration: ${result.config}`,
|
|
58
|
+
`Installed skills for: ${result.tools.join(', ') || 'none'}`, ...result.tools.map(tool => result.hints[tool]),
|
|
59
|
+
...result.conflicts.map(file => `Preserved modified file: ${file}`),
|
|
60
|
+
'Next: ask your agent to use releasekit-draft with a release version.', 'Check progress: releasekit status'].join('\n'));
|
|
61
|
+
if (result.conflicts.length)
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
64
|
+
program.command('update').description('Refresh skills for all supported tools while preserving user edits')
|
|
65
|
+
.action(async () => {
|
|
66
|
+
const instance = project();
|
|
67
|
+
const config = await instance.config();
|
|
68
|
+
if (!program.opts().json)
|
|
69
|
+
console.log(`ReleaseKit skill update — ${config.product}\nProject: ${instance.root}\nTools: codex, claude, cursor\n`);
|
|
70
|
+
const result = await updateProject(instance);
|
|
71
|
+
emit(result, formatUpdate(result));
|
|
72
|
+
if (result.conflicts.length)
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
});
|
|
75
|
+
program.command('list').description('List releases across all channels')
|
|
76
|
+
.option('--channel <name>', 'show only this channel')
|
|
25
77
|
.action(async (options) => {
|
|
26
|
-
const
|
|
27
|
-
emit(
|
|
78
|
+
const releases = await listReleases(project(), options.channel);
|
|
79
|
+
emit(releases, releases.length ? ['Release Status Notes Date', ...releases.map(r => `${refKey(r)} ${r.status} ${r.notes} ${r.releasedAt}`),
|
|
80
|
+
'Next: releasekit status <version> (add --channel for a channel release)'].join('\n') : 'No releases yet. Use releasekit-draft with your agent to create the first draft.');
|
|
81
|
+
});
|
|
82
|
+
program.command('status [version]').description('Show validation issues and the next step; omit version for all releases')
|
|
83
|
+
.option('--channel <name>', 'show only this channel')
|
|
84
|
+
.action(async (version, options) => {
|
|
85
|
+
const result = await projectStatus(project(), version ? target(version, options) : undefined, options.channel);
|
|
86
|
+
emit(result, formatStatus(result));
|
|
28
87
|
});
|
|
29
|
-
program.command('update').description('Refresh managed skills while preserving user edits')
|
|
30
|
-
.action(async () => { const result = await installSkills(project()); emit(result); if (result.conflicts.length)
|
|
31
|
-
process.exitCode = 1; });
|
|
32
88
|
program.command('start').description('Save the first-use Git boundary and treatment of earlier history')
|
|
89
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
33
90
|
.requiredOption('--at <ref>', 'baseline commit or tag; subsequent notes begin after this commit')
|
|
34
91
|
.addOption(new Option('--past <mode>', 'summarize the baseline, analyze history, or skip earlier notes').choices(['summary', 'history', 'skip']).makeOptionMandatory())
|
|
35
92
|
.option('--baseline-version <version>', 'baseline release ID, required for summary or history')
|
|
36
|
-
.action(async (options) => emit(await startProject(project(), { at: options.at, past: options.past, version: options.baselineVersion })));
|
|
93
|
+
.action(async (options) => emit(await startProject(project(), { at: options.at, past: options.past, version: options.baselineVersion, channel: options.channel })));
|
|
37
94
|
program.command('prepare <version>').description('Create a draft from pinned Git commits')
|
|
95
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
38
96
|
.option('--from <ref>', 'comparison start commit or tag')
|
|
39
97
|
.option('--to <ref>', 'comparison end commit or tag, defaults to the saved baseline or HEAD')
|
|
40
|
-
.option('--previous <
|
|
98
|
+
.option('--previous <release>', 'previous version, or channel/version in channel history')
|
|
41
99
|
.option('--from-root', 'explicitly include the whole history')
|
|
42
100
|
.option('--first-release', 'start an independent release line')
|
|
43
|
-
.option('--date <
|
|
44
|
-
.action(async (version, options) =>
|
|
101
|
+
.option('--date <date-or-datetime>', 'release date or timestamp with timezone; defaults to the current UTC timestamp')
|
|
102
|
+
.action(async (version, options) => {
|
|
103
|
+
const instance = project();
|
|
104
|
+
const result = await prepare(instance, version, options);
|
|
105
|
+
emit(result, `Prepared draft ${refKey(result)}\nFiles: ${await instance.releaseDir(result)}\nNext: use releasekit-draft with your agent to write the notes, then run releasekit status ${version}${options.channel ? ` --channel ${options.channel}` : ''}.`);
|
|
106
|
+
});
|
|
45
107
|
const note = program.command('note').description('Manage individual release notes');
|
|
46
108
|
note.command('add <version> <id>').description('Scaffold a note and its locale files')
|
|
109
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
47
110
|
.addOption(new Option('--category <category>', 'note category').choices(['feature', 'improvement', 'fix', 'security']).default('feature'))
|
|
48
111
|
.option('--no-image', 'make this note intentionally text-only')
|
|
49
112
|
.action(async (version, id, options) => {
|
|
50
|
-
await addNote(project(), version, id, noteMetaSchema.shape.category.parse(options.category), options.image);
|
|
51
|
-
emit({ version, note: id, status: 'draft' });
|
|
113
|
+
await addNote(project(), target(version, options), id, noteMetaSchema.shape.category.parse(options.category), options.image);
|
|
114
|
+
emit({ ...target(version, options), note: id, status: 'draft' });
|
|
52
115
|
});
|
|
53
116
|
note.command('remove <version> <id>').description('Remove a draft note, its locale files, prompts, and unused managed images')
|
|
54
|
-
.
|
|
117
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
118
|
+
.action(async (version, id, options) => emit(await removeNote(project(), target(version, options), id)));
|
|
55
119
|
const images = program.command('image').description('Plan themed illustrations and register selected files');
|
|
56
120
|
images.command('plan <version>').description('Plan generation or supplied-image requests without calling a model')
|
|
121
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
57
122
|
.option('--sync-config', 'apply current project image settings to this draft')
|
|
58
123
|
.action(async (version, options) => {
|
|
59
124
|
const instance = project();
|
|
60
125
|
if (options.syncConfig)
|
|
61
|
-
await syncImagePolicy(instance, version);
|
|
62
|
-
emit(await planImages(instance, version));
|
|
126
|
+
await syncImagePolicy(instance, target(version, options));
|
|
127
|
+
emit(await planImages(instance, target(version, options)));
|
|
63
128
|
});
|
|
64
129
|
images.command('import <version> <note>').description('Import or replace an image, switch shared/themed usage, and remove unused note images')
|
|
130
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
65
131
|
.addOption(new Option('--theme <theme>', 'variant to register; shared replaces themed entries and a theme replaces shared').choices(['dark', 'light', 'shared']).makeOptionMandatory())
|
|
66
132
|
.addOption(new Option('--source <source>', 'save the media source with this import; otherwise keep the current source').choices(['generated', 'provided']))
|
|
67
133
|
.requiredOption('--file <file>', 'selected local PNG, JPEG, or WebP')
|
|
68
|
-
.action(async (version, id, options) => emit(await importImage(project(), version, id, assetVariant.parse(options.theme), path.resolve(program.opts().cwd, options.file), { source: options.source })));
|
|
134
|
+
.action(async (version, id, options) => emit(await importImage(project(), target(version, options), id, assetVariant.parse(options.theme), path.resolve(program.opts().cwd, options.file), { source: options.source })));
|
|
69
135
|
const translation = program.command('translation').description('Track source freshness for reviewed translations');
|
|
70
136
|
translation.command('mark <version> <note>').description('Mark an already reviewed translation current')
|
|
137
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
71
138
|
.requiredOption('--locale <locale>', 'translation language code')
|
|
72
|
-
.action(async (version, id, options) => emit({ sourceHash: await markTranslation(project(), version, id, options.locale) }));
|
|
139
|
+
.action(async (version, id, options) => emit({ sourceHash: await markTranslation(project(), target(version, options), id, options.locale) }));
|
|
73
140
|
program.command('validate [version]').description('Validate one release or all releases')
|
|
74
|
-
.
|
|
141
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
142
|
+
.action(async (version, options) => {
|
|
75
143
|
const instance = project();
|
|
76
|
-
|
|
144
|
+
if (options.channel !== undefined)
|
|
145
|
+
await instance.requireChannel(options.channel);
|
|
146
|
+
const versions = version ? [target(version, options)] : (await instance.allRefs()).filter(r => r.channel === options.channel);
|
|
77
147
|
const results = await Promise.all(versions.map(v => validate(instance, v)));
|
|
78
|
-
emit(results);
|
|
148
|
+
emit(results, results.length ? results.map(r => `${refKey(r)}: ${r.valid ? 'valid' : 'needs attention'}${[...r.errors.map(e => `\n - ${e}`), ...r.warnings.map(w => `\n Warning: ${w}`)].join('')}`).join('\n') : 'No releases to validate. Use releasekit-draft to create the first draft.');
|
|
79
149
|
if (results.some(r => !r.valid))
|
|
80
150
|
process.exitCode = 1;
|
|
81
151
|
});
|
|
82
152
|
program.command('finalize <version>').description('Validate and mark local release content ready')
|
|
83
|
-
.
|
|
153
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
154
|
+
.action(async (version, options) => emit(await finalize(project(), target(version, options))));
|
|
84
155
|
program.command('export').description('Export recent version groups and selected image variants')
|
|
156
|
+
.option('--channel <name>', 'release channel; omit for unchanneled releases')
|
|
85
157
|
.option('--current <version>', 'current release version, defaults to the release with no successor')
|
|
86
|
-
.option('--limit <count>', 'number of version groups,
|
|
158
|
+
.option('--limit <count>', 'number of version groups, after filtering; omit to export all releases', value => Number(value))
|
|
87
159
|
.option('--locale <locale>', 'output language, defaults to all locales saved in the current release')
|
|
88
160
|
.requiredOption('--out <directory>', 'new output directory')
|
|
89
161
|
.action(async (options) => emit(await exportBundle(project(), options.current, { ...options, out: path.resolve(program.opts().cwd, options.out) })));
|
|
162
|
+
const release = program.command('release').description('Manage whole releases');
|
|
163
|
+
release.command('move <versions...>').description('Move whole releases while preserving content and ready status')
|
|
164
|
+
.option('--from-channel <name>', 'source channel; omit for unchanneled releases')
|
|
165
|
+
.option('--to-channel <name>', 'destination channel')
|
|
166
|
+
.option('--to-unchanneled', 'move to the unchanneled history')
|
|
167
|
+
.option('--after <release>', 'insert after this version or channel/version when crossing histories')
|
|
168
|
+
.option('--at-start', 'insert at the oldest end when crossing histories')
|
|
169
|
+
.option('--dry-run', 'validate and report changes without writing files')
|
|
170
|
+
.action(async (versions, options) => emit(await moveReleases(project(), versions, options)));
|
|
90
171
|
async function main() {
|
|
91
172
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
92
173
|
if (major < 22 || major === 22 && minor < 12)
|
|
@@ -94,6 +175,14 @@ async function main() {
|
|
|
94
175
|
await program.parseAsync();
|
|
95
176
|
}
|
|
96
177
|
main().catch((error) => {
|
|
178
|
+
if (error instanceof CommanderError) {
|
|
179
|
+
if (error.exitCode === 0)
|
|
180
|
+
return;
|
|
181
|
+
if (process.argv.includes('--json'))
|
|
182
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
183
|
+
process.exitCode = error.exitCode;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
97
186
|
const message = error instanceof Error ? error.message : String(error);
|
|
98
187
|
console.error(program.opts().json ? JSON.stringify({ error: message }) : message);
|
|
99
188
|
process.exitCode = 1;
|
package/dist/content.d.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import { type ReleaseId } from './model.js';
|
|
1
2
|
import { type NoteMeta } from './model.js';
|
|
2
3
|
import { Project } from './project.js';
|
|
3
|
-
export declare function addNote(project: Project, version:
|
|
4
|
-
export declare function removeNote(project: Project, version:
|
|
4
|
+
export declare function addNote(project: Project, version: ReleaseId, id: string, category: NoteMeta['category'], image: boolean): Promise<void>;
|
|
5
|
+
export declare function removeNote(project: Project, version: ReleaseId, id: string): Promise<{
|
|
5
6
|
version: string;
|
|
7
|
+
channel?: string;
|
|
6
8
|
note: string;
|
|
7
9
|
status: "draft" | "ready";
|
|
8
10
|
removedPaths: string[];
|
|
9
11
|
retainedAssets: string[];
|
|
10
12
|
}>;
|
|
11
|
-
export declare function markTranslation(project: Project, version:
|
|
12
|
-
export declare function syncImagePolicy(project: Project, version:
|
|
13
|
-
export declare function readVisual(project: Project, version:
|
|
13
|
+
export declare function markTranslation(project: Project, version: ReleaseId, id: string, language: string): Promise<string>;
|
|
14
|
+
export declare function syncImagePolicy(project: Project, version: ReleaseId): Promise<void>;
|
|
15
|
+
export declare function readVisual(project: Project, version: ReleaseId, id: string): Promise<{
|
|
14
16
|
schemaVersion: 1;
|
|
15
17
|
scene: {
|
|
16
18
|
archetype: "data-view" | "device-view" | "editorial-scene" | "icon-tile" | "object-detail" | "spatial-view" | "symbol-pair" | "ui-detail";
|
package/dist/content.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {} from './model.js';
|
|
2
|
+
import { ref } from './refs.js';
|
|
1
3
|
import * as fs from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { fileKey, managedAssetFiles, retainedImageFiles, visualFileReferences } from './assets.js';
|
|
@@ -113,7 +115,7 @@ export async function removeNote(project, version, id) {
|
|
|
113
115
|
throw new Error(`Note ${id} was removed, but file cleanup is incomplete in ${checkedHolding}`, { cause: error });
|
|
114
116
|
}
|
|
115
117
|
const relative = (file) => path.relative(directory, file).split(path.sep).join('/');
|
|
116
|
-
return { version, note: id, status: release.status, removedPaths: targets.map(relative), retainedAssets: retainedAssets.map(relative) };
|
|
118
|
+
return { ...ref(version), note: id, status: release.status, removedPaths: targets.map(relative), retainedAssets: retainedAssets.map(relative) };
|
|
117
119
|
}
|
|
118
120
|
export async function markTranslation(project, version, id, language) {
|
|
119
121
|
const release = await project.release(version);
|
package/dist/export.d.ts
CHANGED
package/dist/export.js
CHANGED
|
@@ -1,44 +1,60 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { Project } from './project.js';
|
|
3
|
+
import { Project, checkLimit } from './project.js';
|
|
4
4
|
import { bundleSchema, activeVariants, locale } from './model.js';
|
|
5
|
-
import {
|
|
5
|
+
import { validateMany } from './validate.js';
|
|
6
6
|
import { exists, readNote, write } from './files.js';
|
|
7
|
+
import { ref, link, refKey } from './refs.js';
|
|
7
8
|
import { readVisual } from './content.js';
|
|
8
9
|
export async function exportBundle(project, current, options) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
checkLimit(options.limit);
|
|
11
|
+
await project.config();
|
|
12
|
+
if (options.channel !== undefined && current !== undefined)
|
|
13
|
+
throw new Error('--current is only supported for unchanneled exports.');
|
|
14
|
+
let history;
|
|
15
|
+
if (options.channel !== undefined) {
|
|
16
|
+
await project.requireChannel(options.channel);
|
|
17
|
+
const config = await project.config();
|
|
18
|
+
const include = config.channels && config.channels[options.channel].include;
|
|
19
|
+
history = (await project.channelHistory()).filter(r => r.status === 'ready' && include && include.includes(r.channel)).slice(0, options.limit);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
history = await project.history(current ?? await project.latestVersion(), options.limit);
|
|
23
|
+
}
|
|
24
|
+
if (!history.length)
|
|
25
|
+
throw new Error('No releases to export. Prepare and finalize a release first.');
|
|
26
|
+
const version = history[0].version;
|
|
12
27
|
const languages = options.locale === undefined ? history[0].locales : [locale.parse(options.locale)];
|
|
13
28
|
for (const release of history) {
|
|
14
29
|
if (release.status !== 'ready')
|
|
15
|
-
throw new Error(`Release ${release
|
|
30
|
+
throw new Error(`Release ${refKey(release)} is still a draft.`);
|
|
16
31
|
for (const language of languages) {
|
|
17
32
|
if (!release.locales.includes(language))
|
|
18
|
-
throw new Error(`Release ${release
|
|
33
|
+
throw new Error(`Release ${refKey(release)} has no ${language} locale.`);
|
|
19
34
|
}
|
|
20
|
-
const checked = await validate(project, release.version);
|
|
21
|
-
if (!checked.valid)
|
|
22
|
-
throw new Error(checked.errors.join('\n'));
|
|
23
35
|
}
|
|
36
|
+
const checked = await validateMany(project, history);
|
|
37
|
+
const errors = checked.flatMap(result => result.errors.map(error => `${refKey(result)}: ${error}`));
|
|
38
|
+
if (errors.length)
|
|
39
|
+
throw new Error(errors.join('\n'));
|
|
24
40
|
const bundles = [];
|
|
25
41
|
const copies = new Map();
|
|
26
42
|
for (const language of languages) {
|
|
27
|
-
const bundle = { schemaVersion: 1, currentVersion: version, locale: language, releases: [] };
|
|
28
|
-
for (const release of history) {
|
|
29
|
-
const entry = {
|
|
43
|
+
const bundle = { schemaVersion: 1, currentVersion: version, ...(options.channel === undefined ? {} : { viewChannel: options.channel, currentChannel: history[0].channel }), locale: language, releases: [] };
|
|
44
|
+
for (const [index, release] of history.entries()) {
|
|
45
|
+
const entry = { ...ref(release), releasedAt: release.releasedAt, previous: options.channel === undefined ? release.previous : link(history[index + 1]), notes: [] };
|
|
30
46
|
for (const note of release.notes) {
|
|
31
|
-
const text = await readNote(await project.releaseFile(release
|
|
47
|
+
const text = await readNote(await project.releaseFile(release, `notes/${note.id}/${language}.md`));
|
|
32
48
|
const exported = { id: note.id, category: note.category, title: text.title, bodyMarkdown: text.body, image: null };
|
|
33
49
|
if (note.image) {
|
|
34
|
-
const visual = await readVisual(project, release
|
|
50
|
+
const visual = await readVisual(project, release, note.id);
|
|
35
51
|
const variants = activeVariants(visual, release.visuals);
|
|
36
52
|
exported.image = { alt: text.alt, fallbackTheme: variants[0], variants: {} };
|
|
37
53
|
for (const variant of variants) {
|
|
38
54
|
const asset = visual.variants[variant];
|
|
39
|
-
const relative = `assets/${release.version}/${path.posix.basename(asset.file)}`;
|
|
55
|
+
const relative = `assets/${release.channel ? `${release.channel}/` : ''}${release.version}/${path.posix.basename(asset.file)}`;
|
|
40
56
|
exported.image.variants[variant] = { src: relative, width: asset.width, height: asset.height };
|
|
41
|
-
copies.set(relative, await project.releaseFile(release
|
|
57
|
+
copies.set(relative, await project.releaseFile(release, asset.file));
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
entry.notes.push(exported);
|
package/dist/files.js
CHANGED
|
@@ -72,8 +72,13 @@ export async function write(file, content) {
|
|
|
72
72
|
throw error;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
async function preserveLineEndings(file, text) {
|
|
76
|
+
if (await exists(file) && (await fs.readFile(file, 'utf8')).includes('\r\n'))
|
|
77
|
+
return text.replace(/\r?\n/g, '\r\n');
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
75
80
|
export async function writeYaml(file, value) {
|
|
76
|
-
await write(file, stringify(value, { lineWidth: 100 }));
|
|
81
|
+
await write(file, await preserveLineEndings(file, stringify(value, { lineWidth: 100 })));
|
|
77
82
|
}
|
|
78
83
|
export function parseYaml(text) {
|
|
79
84
|
const document = parseDocument(text, { uniqueKeys: true });
|
|
@@ -103,5 +108,5 @@ export function noteHash(note) {
|
|
|
103
108
|
}
|
|
104
109
|
export async function writeNote(file, note) {
|
|
105
110
|
const { body, ...metadata } = note;
|
|
106
|
-
await write(file, `---\n${stringify(metadata)}---\n\n${body.trim()}\n`);
|
|
111
|
+
await write(file, await preserveLineEndings(file, `---\n${stringify(metadata)}---\n\n${body.trim()}\n`));
|
|
107
112
|
}
|
package/dist/images.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReleaseId } from './model.js';
|
|
1
2
|
import { type AssetVariant, type Visual } from './model.js';
|
|
2
3
|
import { Project } from './project.js';
|
|
3
4
|
export declare function inspectImage(bytes: Buffer): Promise<{
|
|
@@ -20,8 +21,9 @@ export type ImageRequest = {
|
|
|
20
21
|
compositionReference: null;
|
|
21
22
|
instruction: string;
|
|
22
23
|
});
|
|
23
|
-
export declare function planImages(project: Project, version:
|
|
24
|
+
export declare function planImages(project: Project, version: ReleaseId): Promise<{
|
|
24
25
|
version: string;
|
|
26
|
+
channel?: string;
|
|
25
27
|
configuredThemes: ("dark" | "light")[];
|
|
26
28
|
requestedAssets: number;
|
|
27
29
|
readyAssets: number;
|
|
@@ -34,11 +36,11 @@ export declare function planImages(project: Project, version: string): Promise<{
|
|
|
34
36
|
export interface ImportImageOptions {
|
|
35
37
|
source?: 'generated' | 'provided';
|
|
36
38
|
}
|
|
37
|
-
export declare function importImage(project: Project, version:
|
|
39
|
+
export declare function importImage(project: Project, version: ReleaseId, noteId: string, variant: AssetVariant, source: string, options?: ImportImageOptions): Promise<{
|
|
38
40
|
file: string;
|
|
39
41
|
sha256: string;
|
|
40
42
|
sceneHash: string;
|
|
41
43
|
width: number;
|
|
42
44
|
height: number;
|
|
43
45
|
}>;
|
|
44
|
-
export declare function validateImages(project: Project, version:
|
|
46
|
+
export declare function validateImages(project: Project, version: ReleaseId, noteId: string, visual: Visual, errors: string[], warnings: string[]): Promise<void>;
|
package/dist/images.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {} from './model.js';
|
|
2
|
+
import { ref } from './refs.js';
|
|
1
3
|
import * as fs from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import sharp from 'sharp';
|
|
@@ -68,7 +70,7 @@ export async function planImages(project, version) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
return {
|
|
71
|
-
version, configuredThemes: themes(release.visuals), requestedAssets: ready + requests.length,
|
|
73
|
+
...ref(version), configuredThemes: themes(release.visuals), requestedAssets: ready + requests.length,
|
|
72
74
|
readyAssets: ready, pendingAssets: requests.length, requests,
|
|
73
75
|
generationRequests: requests.filter(request => request.action === 'generate').length,
|
|
74
76
|
providedRequests: requests.filter(request => request.action === 'provide').length,
|