@iodes/releasekit 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +90 -0
- package/dist/content.d.ts +38 -0
- package/dist/content.js +64 -0
- package/dist/export.d.ts +10 -0
- package/dist/export.js +57 -0
- package/dist/files.d.ts +15 -0
- package/dist/files.js +107 -0
- package/dist/git.d.ts +10 -0
- package/dist/git.js +68 -0
- package/dist/images.d.ts +32 -0
- package/dist/images.js +123 -0
- package/dist/install.d.ts +27 -0
- package/dist/install.js +63 -0
- package/dist/model.d.ts +318 -0
- package/dist/model.js +99 -0
- package/dist/project.d.ts +25 -0
- package/dist/project.js +103 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +72 -0
- package/dist/schema-export.d.ts +1 -0
- package/dist/schema-export.js +10 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.js +109 -0
- package/examples/README.md +14 -0
- package/examples/feature-briefs.yaml +89 -0
- package/examples/queue-action/README.md +15 -0
- package/examples/queue-action/alignment-edit.prompt.md +8 -0
- package/examples/queue-action/dark.png +0 -0
- package/examples/queue-action/dark.prompt.md +58 -0
- package/examples/queue-action/light.png +0 -0
- package/examples/queue-action/light.prompt.md +58 -0
- package/examples/queue-action/pair-review.md +33 -0
- package/examples/queue-action/scene.yaml +41 -0
- package/examples/release-notes.en-US.json +56 -0
- package/examples/release-notes.ko-KR.json +56 -0
- package/kit/references/composition-recipes.md +73 -0
- package/kit/references/format.md +24 -0
- package/kit/references/theme-pairing.md +53 -0
- package/kit/references/visual-language.md +69 -0
- package/kit/references/workflow.md +24 -0
- package/kit/references/writing.md +27 -0
- package/kit/skills/releasekit-draft/SKILL.md +10 -0
- package/kit/skills/releasekit-image/SKILL.md +16 -0
- package/kit/skills/releasekit-review/SKILL.md +12 -0
- package/kit/skills/releasekit-translate/SKILL.md +10 -0
- package/package.json +52 -0
- package/schemas/bundle.schema.json +178 -0
- package/schemas/config.schema.json +179 -0
- package/schemas/evidence.schema.json +96 -0
- package/schemas/note.schema.json +26 -0
- package/schemas/release.schema.json +275 -0
- package/schemas/visual.schema.json +174 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SO, HYEONSEOP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# ReleaseKit
|
|
2
|
+
|
|
3
|
+
Git-based visual release notes, written with your coding agent and stored with your product.
|
|
4
|
+
|
|
5
|
+
ReleaseKit provides a deterministic CLI, portable agent skills, an original visual language, and versioned content files. Your agent writes the copy and uses its available image tools. The CLI prepares Git evidence, compiles image prompts, validates artifacts, and exports JSON with local assets.
|
|
6
|
+
|
|
7
|
+
## Project image policy
|
|
8
|
+
|
|
9
|
+
Dark **and** light illustrations are recommended by default. Choose one theme when generation cost matters more than providing a dedicated variant for both viewer themes:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
# releasekit/config.yaml
|
|
13
|
+
visuals:
|
|
14
|
+
themes: both # both | dark | light
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This edits one field in the complete generated config; keep the other visual settings. You can also select the policy at setup with `releasekit init --themes light`.
|
|
18
|
+
|
|
19
|
+
One scene brief drives both variants. Object positions, scale, crop, interaction state, and semantic colors stay consistent while presentation surfaces and lighting adapt. Single-theme exports contain one real asset and an explicit fallback, without inventing a second variant. Images are shared across locales.
|
|
20
|
+
|
|
21
|
+
`releasekit image plan <version>` reports pending and reusable asset counts before any image generation. It never calls a model API. Previously imported current assets are reused. If image generation is unavailable, the prompts remain available for an external tool.
|
|
22
|
+
|
|
23
|
+
## Local setup
|
|
24
|
+
|
|
25
|
+
Requires Git and Node.js 22.12 or later. Node.js 24 is recommended for development.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
npm pack
|
|
31
|
+
npm install -g ./iodes-releasekit-0.1.0.tgz
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The package can be built and installed locally; no public registry publication is required. In the product's Git repository:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
releasekit init --tools codex,claude,cursor --themes both
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Codex and Cursor share a single project skill tree to avoid duplicate discovery. Claude Code receives its project skill tree. Installation prints invocation hints. `releasekit update` refreshes owned files, preserves user edits, and reports conflicts.
|
|
41
|
+
|
|
42
|
+
## Agent workflow
|
|
43
|
+
|
|
44
|
+
Ask your agent to use `releasekit-draft` to create notes between two tags or commits. Include the desired release version. For example:
|
|
45
|
+
|
|
46
|
+
> Prepare release 1.4.0 from v1.3.0 to v1.4.0. Write Korean and English notes, create the configured image variants, and export the latest three releases.
|
|
47
|
+
|
|
48
|
+
The installed skills are `releasekit-draft`, `releasekit-image`, `releasekit-translate`, and `releasekit-review`. They use the CLI rather than implementing Git parsing or asset bookkeeping again.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
releasekit prepare 1.4.0 --from v1.3.0 --to v1.4.0 --previous 1.3.0
|
|
52
|
+
releasekit note add 1.4.0 queue-action
|
|
53
|
+
# Fill the note files, evidence references, and shared visual brief.
|
|
54
|
+
releasekit image plan 1.4.0
|
|
55
|
+
# Generate externally or through the agent's image tool, then select the files.
|
|
56
|
+
releasekit image import 1.4.0 queue-action --theme dark --file ./selected-dark.png
|
|
57
|
+
releasekit image import 1.4.0 queue-action --theme light --file ./selected-light.png
|
|
58
|
+
# Review the completed translation before marking it current.
|
|
59
|
+
releasekit translation mark 1.4.0 queue-action --locale en-US
|
|
60
|
+
releasekit validate 1.4.0
|
|
61
|
+
releasekit finalize 1.4.0
|
|
62
|
+
releasekit export --current 1.4.0 --limit 3 --locale ko-KR --out ./release-output
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use `--from-root` for an explicitly requested full-history first release. `--to` defaults to `HEAD`, and `--previous` can supply the default comparison start. Existing drafts are edited in place; `prepare` does not overwrite them. `--json` provides structured results, and `--cwd` selects a project working directory.
|
|
66
|
+
|
|
67
|
+
There is no built-in model API, viewer, hosted database, automatic Git commit, or publishing step.
|
|
68
|
+
|
|
69
|
+
## Visual guidance
|
|
70
|
+
|
|
71
|
+
The built-in guidance goes beyond a style adjective. It includes eight composition recipes, feature-to-image selection, a scene contract, semantic palette roles, theme-pair invariants, text rules, cost-aware reuse, external generation handoff, and visual acceptance checks.
|
|
72
|
+
|
|
73
|
+
For each note, the agent derives the visual message from its evidence, selects the appropriate archetype, and writes feature-specific relationships and correctness constraints. The CLI combines that scene with common presentation rules, only the selected recipe, and the requested theme. Review first checks what the image claims, then visual clarity, then theme correspondence. Worked examples do not set the layout for other notes.
|
|
74
|
+
|
|
75
|
+
- [Visual language](kit/references/visual-language.md)
|
|
76
|
+
- [Composition recipes](kit/references/composition-recipes.md)
|
|
77
|
+
- [Theme pairs and cost](kit/references/theme-pairing.md)
|
|
78
|
+
- [Writing and translation](kit/references/writing.md)
|
|
79
|
+
- [Original dark/light example and three-version bundles](examples/README.md)
|
|
80
|
+
|
|
81
|
+
All shipped guidance uses independent, brand-neutral descriptions. Product-specific imagery should depict the user's actual feature. Reference-company identities, attributed style labels, copied artwork, and unrelated product silhouettes do not belong in briefs or generated output.
|
|
82
|
+
|
|
83
|
+
## Version and file contract
|
|
84
|
+
|
|
85
|
+
Each release stores its own changes and an explicit `previous` release link. Export follows that chain, keeping the current version and the configured number of preceding versions in separate groups. The default count is three including current. Version strings are not sorted to infer ancestry, and similar notes in separate versions are retained.
|
|
86
|
+
|
|
87
|
+
Content uses YAML metadata, Markdown locale files, image briefs and prompts, and local raster assets. Git boundaries are pinned to immutable commits. Notes carry evidence, and translations carry source fingerprints. A ready release has a content fingerprint so later edits are detected.
|
|
88
|
+
|
|
89
|
+
Project visual defaults are captured when a release is prepared. To apply changed project settings to an existing draft:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
releasekit image plan 1.4.0 --sync-config
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This preserves selected files and schedules only the newly required or stale variants. Ready content must first be reopened with `status: draft` and `contentHash: null`.
|
|
96
|
+
|
|
97
|
+
See [the file contract](kit/references/format.md) and the generated [JSON schemas](schemas). Export contains display data and relative assets, excluding Git evidence, prompts, and private source paths. Consumers select `image.variants[theme]` or `image.variants[image.fallbackTheme]` and safely render `bodyMarkdown`.
|
|
98
|
+
|
|
99
|
+
## Development checks
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
npm run check
|
|
103
|
+
npm test
|
|
104
|
+
npm run build
|
|
105
|
+
npm pack --dry-run
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Tests cover pinned Git ranges, reverted changes, branched release history, theme scheduling, asset integrity, single-theme fallback, stale translations, finalized content edits, installation conflicts, and command-line behavior. CI runs on Windows and Linux with Node.js 22 and 24.
|
|
109
|
+
|
|
110
|
+
MIT licensed.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Command, Option } from 'commander';
|
|
4
|
+
import { Project, prepare } from './project.js';
|
|
5
|
+
import { initProject, installSkills } from './install.js';
|
|
6
|
+
import { addNote, markTranslation, syncImagePolicy } from './content.js';
|
|
7
|
+
import { planImages, importImage } from './images.js';
|
|
8
|
+
import { validate, finalize } from './validate.js';
|
|
9
|
+
import { exportBundle } from './export.js';
|
|
10
|
+
import { configSchema, noteMetaSchema, theme } from './model.js';
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program.name('releasekit').description('Git-based visual release content and agent skills').version('0.1.0')
|
|
13
|
+
.option('--cwd <directory>', 'project working directory', process.cwd())
|
|
14
|
+
.option('--json', 'print machine-readable results');
|
|
15
|
+
const project = () => Project.find(path.resolve(program.opts().cwd));
|
|
16
|
+
function emit(value, summary) {
|
|
17
|
+
console.log(program.opts().json || !summary ? JSON.stringify(value, null, 2) : summary);
|
|
18
|
+
}
|
|
19
|
+
program.command('init').description('Initialize content and install project skills')
|
|
20
|
+
.option('--product <name>', 'product name')
|
|
21
|
+
.option('--tools <tools>', 'comma-separated codex,claude,cursor')
|
|
22
|
+
.addOption(new Option('--themes <policy>', 'project image variants').choices(['both', 'dark', 'light']))
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
const tools = options.tools === undefined ? undefined : configSchema.shape.tools.parse(options.tools.split(',').map(s => s.trim()).filter(Boolean));
|
|
25
|
+
emit(await initProject(project(), { ...options, tools }));
|
|
26
|
+
});
|
|
27
|
+
program.command('update').description('Refresh managed skills while preserving user edits')
|
|
28
|
+
.action(async () => { const result = await installSkills(project()); emit(result); if (result.conflicts.length)
|
|
29
|
+
process.exitCode = 1; });
|
|
30
|
+
program.command('prepare <version>').description('Create a draft from pinned Git commits')
|
|
31
|
+
.option('--from <ref>', 'comparison start commit or tag')
|
|
32
|
+
.option('--to <ref>', 'comparison end commit or tag', 'HEAD')
|
|
33
|
+
.option('--previous <version>', 'explicit previous release')
|
|
34
|
+
.option('--from-root', 'explicitly include the whole history')
|
|
35
|
+
.option('--first-release', 'start an independent release line')
|
|
36
|
+
.option('--date <YYYY-MM-DD>', 'release date, defaults to the current UTC date')
|
|
37
|
+
.action(async (version, options) => emit(await prepare(project(), version, options)));
|
|
38
|
+
const note = program.command('note').description('Manage individual release notes');
|
|
39
|
+
note.command('add <version> <id>').description('Scaffold a note and its locale files')
|
|
40
|
+
.addOption(new Option('--category <category>', 'note category').choices(['feature', 'improvement', 'fix', 'security']).default('feature'))
|
|
41
|
+
.option('--no-image', 'make this note intentionally text-only')
|
|
42
|
+
.action(async (version, id, options) => {
|
|
43
|
+
await addNote(project(), version, id, noteMetaSchema.shape.category.parse(options.category), options.image);
|
|
44
|
+
emit({ version, note: id, status: 'draft' });
|
|
45
|
+
});
|
|
46
|
+
const images = program.command('image').description('Plan themed illustrations and register selected files');
|
|
47
|
+
images.command('plan <version>').description('Write pending prompts and report asset counts without calling a model')
|
|
48
|
+
.option('--sync-config', 'apply current project image settings to this draft')
|
|
49
|
+
.action(async (version, options) => {
|
|
50
|
+
const instance = project();
|
|
51
|
+
if (options.syncConfig)
|
|
52
|
+
await syncImagePolicy(instance, version);
|
|
53
|
+
emit(await planImages(instance, version));
|
|
54
|
+
});
|
|
55
|
+
images.command('import <version> <note>').description('Import a selected raster variant')
|
|
56
|
+
.addOption(new Option('--theme <theme>', 'variant to register').choices(['dark', 'light']).makeOptionMandatory())
|
|
57
|
+
.requiredOption('--file <file>', 'selected local PNG, JPEG, or WebP')
|
|
58
|
+
.action(async (version, id, options) => emit(await importImage(project(), version, id, theme.parse(options.theme), path.resolve(program.opts().cwd, options.file))));
|
|
59
|
+
const translation = program.command('translation').description('Track source freshness for reviewed translations');
|
|
60
|
+
translation.command('mark <version> <note>').description('Mark an already reviewed translation current')
|
|
61
|
+
.requiredOption('--locale <locale>', 'translation language code')
|
|
62
|
+
.action(async (version, id, options) => emit({ sourceHash: await markTranslation(project(), version, id, options.locale) }));
|
|
63
|
+
program.command('validate [version]').description('Validate one release or all releases')
|
|
64
|
+
.action(async (version) => {
|
|
65
|
+
const instance = project();
|
|
66
|
+
const versions = version ? [version] : await instance.versions();
|
|
67
|
+
const results = await Promise.all(versions.map(v => validate(instance, v)));
|
|
68
|
+
emit(results);
|
|
69
|
+
if (results.some(r => !r.valid))
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
});
|
|
72
|
+
program.command('finalize <version>').description('Validate and mark local release content ready')
|
|
73
|
+
.action(async (version) => emit(await finalize(project(), version)));
|
|
74
|
+
program.command('export').description('Export recent version groups and selected image variants')
|
|
75
|
+
.requiredOption('--current <version>', 'current release version')
|
|
76
|
+
.option('--limit <count>', 'number of version groups, including current', value => Number(value))
|
|
77
|
+
.option('--locale <locale>', 'output language, defaults to the project source language')
|
|
78
|
+
.requiredOption('--out <directory>', 'new output directory')
|
|
79
|
+
.action(async (options) => emit(await exportBundle(project(), options.current, { ...options, out: path.resolve(program.opts().cwd, options.out) })));
|
|
80
|
+
async function main() {
|
|
81
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
82
|
+
if (major < 22 || major === 22 && minor < 12)
|
|
83
|
+
throw new Error('ReleaseKit requires Node.js 22.12 or later.');
|
|
84
|
+
await program.parseAsync();
|
|
85
|
+
}
|
|
86
|
+
main().catch((error) => {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
console.error(program.opts().json ? JSON.stringify({ error: message }) : message);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type NoteMeta } from './model.js';
|
|
2
|
+
import { Project } from './project.js';
|
|
3
|
+
export declare function addNote(project: Project, version: string, id: string, category: NoteMeta['category'], image: boolean): Promise<void>;
|
|
4
|
+
export declare function markTranslation(project: Project, version: string, id: string, language: string): Promise<string>;
|
|
5
|
+
export declare function syncImagePolicy(project: Project, version: string): Promise<void>;
|
|
6
|
+
export declare function readVisual(project: Project, version: string, id: string): Promise<{
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
scene: {
|
|
9
|
+
archetype: "data-view" | "device-view" | "editorial-scene" | "icon-tile" | "object-detail" | "spatial-view" | "symbol-pair" | "ui-detail";
|
|
10
|
+
subject: string;
|
|
11
|
+
message: string;
|
|
12
|
+
focus: string;
|
|
13
|
+
composition: string;
|
|
14
|
+
context: string;
|
|
15
|
+
elements: string[];
|
|
16
|
+
preserve: string[];
|
|
17
|
+
avoid: string[];
|
|
18
|
+
text: string[];
|
|
19
|
+
references: string[];
|
|
20
|
+
};
|
|
21
|
+
variants: {
|
|
22
|
+
dark?: {
|
|
23
|
+
file: string;
|
|
24
|
+
sha256: string;
|
|
25
|
+
sceneHash: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
} | undefined;
|
|
29
|
+
light?: {
|
|
30
|
+
file: string;
|
|
31
|
+
sha256: string;
|
|
32
|
+
sceneHash: string;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
} | undefined;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
38
|
+
export declare function checkReferenceFiles(project: Project, references: string[]): Promise<void>;
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { locale, noteMetaSchema, visualSchema } from './model.js';
|
|
3
|
+
import { identifier, writeYaml, writeNote, readNote, noteHash, exists, readYaml } from './files.js';
|
|
4
|
+
import { Project, editable } from './project.js';
|
|
5
|
+
export async function addNote(project, version, id, category, image) {
|
|
6
|
+
const release = await project.release(version);
|
|
7
|
+
editable(release);
|
|
8
|
+
identifier(id);
|
|
9
|
+
if (release.notes.some(note => note.id === id) || await exists(await project.releaseFile(version, `notes/${id}`))) {
|
|
10
|
+
throw new Error(`Note ${id} already exists; its content was preserved.`);
|
|
11
|
+
}
|
|
12
|
+
const note = noteMetaSchema.parse({ id, category, commits: [], paths: [], image });
|
|
13
|
+
for (const language of release.locales) {
|
|
14
|
+
await writeNote(await project.releaseFile(version, `notes/${id}/${language}.md`), { title: '', alt: '', sourceHash: null, body: '' });
|
|
15
|
+
}
|
|
16
|
+
if (image) {
|
|
17
|
+
await writeYaml(await project.releaseFile(version, `visuals/${id}.yaml`), {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
scene: { archetype: '', subject: '', message: '', focus: '', composition: '', context: '', elements: [], preserve: [], avoid: [], text: [], references: [] },
|
|
20
|
+
variants: {},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
release.notes.push(note);
|
|
24
|
+
release.contentHash = null;
|
|
25
|
+
await project.save(release);
|
|
26
|
+
}
|
|
27
|
+
export async function markTranslation(project, version, id, language) {
|
|
28
|
+
const release = await project.release(version);
|
|
29
|
+
editable(release);
|
|
30
|
+
identifier(id);
|
|
31
|
+
locale.parse(language);
|
|
32
|
+
if (!release.notes.some(n => n.id === id))
|
|
33
|
+
throw new Error(`Unknown note: ${id}`);
|
|
34
|
+
if (!release.locales.includes(language) || language === release.sourceLocale)
|
|
35
|
+
throw new Error('Choose a configured translation locale, not the source locale.');
|
|
36
|
+
const source = await readNote(await project.releaseFile(version, `notes/${id}/${release.sourceLocale}.md`));
|
|
37
|
+
const file = await project.releaseFile(version, `notes/${id}/${language}.md`);
|
|
38
|
+
const translated = await readNote(file);
|
|
39
|
+
if (!source.body || !translated.body)
|
|
40
|
+
throw new Error('Write and review both texts before marking the translation current.');
|
|
41
|
+
translated.sourceHash = noteHash(source);
|
|
42
|
+
await writeNote(file, translated);
|
|
43
|
+
return translated.sourceHash;
|
|
44
|
+
}
|
|
45
|
+
export async function syncImagePolicy(project, version) {
|
|
46
|
+
const release = await project.release(version);
|
|
47
|
+
editable(release);
|
|
48
|
+
const policy = (await project.config()).visuals;
|
|
49
|
+
// Files are retained when changing themes; only the active scheduling policy changes.
|
|
50
|
+
release.visuals = policy;
|
|
51
|
+
release.contentHash = null;
|
|
52
|
+
await project.save(release);
|
|
53
|
+
}
|
|
54
|
+
export async function readVisual(project, version, id) {
|
|
55
|
+
return readYaml(await project.releaseFile(version, `visuals/${identifier(id)}.yaml`), visualSchema);
|
|
56
|
+
}
|
|
57
|
+
export async function checkReferenceFiles(project, references) {
|
|
58
|
+
const { within } = await import('./files.js');
|
|
59
|
+
for (const reference of references) {
|
|
60
|
+
const file = await within(project.root, reference);
|
|
61
|
+
if (!(await fs.stat(file)).isFile())
|
|
62
|
+
throw new Error(`Reference is not a file: ${reference}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/export.d.ts
ADDED
package/dist/export.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Project } from './project.js';
|
|
4
|
+
import { bundleSchema, themes, locale } from './model.js';
|
|
5
|
+
import { validate } from './validate.js';
|
|
6
|
+
import { exists, readNote, write } from './files.js';
|
|
7
|
+
import { readVisual } from './content.js';
|
|
8
|
+
export async function exportBundle(project, current, options) {
|
|
9
|
+
const config = await project.config();
|
|
10
|
+
const language = locale.parse(options.locale ?? config.sourceLocale);
|
|
11
|
+
const history = await project.history(current, options.limit ?? config.history.limit);
|
|
12
|
+
const bundle = { schemaVersion: 1, currentVersion: current, locale: language, releases: [] };
|
|
13
|
+
const copies = [];
|
|
14
|
+
for (const release of history) {
|
|
15
|
+
if (release.status !== 'ready')
|
|
16
|
+
throw new Error(`Release ${release.version} is still a draft.`);
|
|
17
|
+
if (!release.locales.includes(language))
|
|
18
|
+
throw new Error(`Release ${release.version} has no ${language} locale.`);
|
|
19
|
+
const checked = await validate(project, release.version);
|
|
20
|
+
if (!checked.valid)
|
|
21
|
+
throw new Error(checked.errors.join('\n'));
|
|
22
|
+
const entry = { version: release.version, releasedAt: release.releasedAt, previous: release.previous, notes: [] };
|
|
23
|
+
for (const note of release.notes) {
|
|
24
|
+
const text = await readNote(await project.releaseFile(release.version, `notes/${note.id}/${language}.md`));
|
|
25
|
+
const exported = { id: note.id, category: note.category, title: text.title, bodyMarkdown: text.body, image: null };
|
|
26
|
+
if (note.image) {
|
|
27
|
+
const visual = await readVisual(project, release.version, note.id);
|
|
28
|
+
exported.image = { alt: text.alt, fallbackTheme: themes(release.visuals)[0], variants: {} };
|
|
29
|
+
for (const variant of themes(release.visuals)) {
|
|
30
|
+
const asset = visual.variants[variant];
|
|
31
|
+
const relative = `assets/${release.version}/${path.posix.basename(asset.file)}`;
|
|
32
|
+
exported.image.variants[variant] = { src: relative, width: asset.width, height: asset.height };
|
|
33
|
+
copies.push({ src: await project.releaseFile(release.version, asset.file), relative });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
entry.notes.push(exported);
|
|
37
|
+
}
|
|
38
|
+
bundle.releases.push(entry);
|
|
39
|
+
}
|
|
40
|
+
bundleSchema.parse(bundle);
|
|
41
|
+
const destination = path.resolve(options.out);
|
|
42
|
+
const contentRoot = await project.content('releases');
|
|
43
|
+
const relativeToContent = path.relative(contentRoot, destination);
|
|
44
|
+
const gitRoot = path.join(project.root, '.git');
|
|
45
|
+
const relativeToGit = path.relative(gitRoot, destination);
|
|
46
|
+
const isInside = (relative) => relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
47
|
+
if (destination === project.root || isInside(relativeToContent) || isInside(relativeToGit)) {
|
|
48
|
+
throw new Error('Export to a separate output directory, outside source content and Git metadata.');
|
|
49
|
+
}
|
|
50
|
+
if (await exists(destination))
|
|
51
|
+
throw new Error('The export destination already exists. Choose a new output directory.');
|
|
52
|
+
// No filesystem output is created until every selected release has passed validation.
|
|
53
|
+
for (const copy of copies)
|
|
54
|
+
await write(path.join(destination, copy.relative), await fs.readFile(copy.src));
|
|
55
|
+
await write(path.join(destination, 'release-notes.json'), JSON.stringify(bundle, null, 2) + '\n');
|
|
56
|
+
return { file: path.join(destination, 'release-notes.json'), releases: bundle.releases.length, assets: copies.length };
|
|
57
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
import { type NoteText } from './model.js';
|
|
3
|
+
export declare const KIT_DIR = "releasekit";
|
|
4
|
+
export declare function digest(value: string | Uint8Array): string;
|
|
5
|
+
export declare function canonical(value: unknown): string;
|
|
6
|
+
export declare function identifier(value: string): string;
|
|
7
|
+
export declare function exists(file: string): Promise<boolean>;
|
|
8
|
+
export declare function within(root: string, relative: string): Promise<string>;
|
|
9
|
+
export declare function write(file: string, content: string | Uint8Array): Promise<void>;
|
|
10
|
+
export declare function writeYaml(file: string, value: unknown): Promise<void>;
|
|
11
|
+
export declare function parseYaml(text: string): unknown;
|
|
12
|
+
export declare function readYaml<T>(file: string, schema: ZodType<T>): Promise<T>;
|
|
13
|
+
export declare function readNote(file: string): Promise<NoteText>;
|
|
14
|
+
export declare function noteHash(note: NoteText): string;
|
|
15
|
+
export declare function writeNote(file: string, note: NoteText): Promise<void>;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { parseDocument, stringify } from 'yaml';
|
|
5
|
+
import { noteTextSchema, segment } from './model.js';
|
|
6
|
+
export const KIT_DIR = 'releasekit';
|
|
7
|
+
export function digest(value) {
|
|
8
|
+
return createHash('sha256').update(value).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
export function canonical(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object')
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
15
|
+
return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
|
16
|
+
}
|
|
17
|
+
export function identifier(value) {
|
|
18
|
+
segment.parse(value);
|
|
19
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value) || value.endsWith('.')) {
|
|
20
|
+
throw new Error(`Unsupported filesystem identifier: ${value}`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export async function exists(file) {
|
|
25
|
+
try {
|
|
26
|
+
await fs.stat(file);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return false;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function contained(root, target) {
|
|
36
|
+
const relative = path.relative(root, target);
|
|
37
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
38
|
+
}
|
|
39
|
+
export async function within(root, relative) {
|
|
40
|
+
if (!relative || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.includes('\\') || relative.split('/').includes('..')) {
|
|
41
|
+
throw new Error(`Expected a contained relative path: ${relative}`);
|
|
42
|
+
}
|
|
43
|
+
const absoluteRoot = path.resolve(root);
|
|
44
|
+
const result = path.resolve(absoluteRoot, relative);
|
|
45
|
+
if (!contained(absoluteRoot, result))
|
|
46
|
+
throw new Error(`Path escapes its content directory: ${relative}`);
|
|
47
|
+
let ancestor = result;
|
|
48
|
+
while (!(await exists(ancestor))) {
|
|
49
|
+
const parent = path.dirname(ancestor);
|
|
50
|
+
if (parent === ancestor)
|
|
51
|
+
throw new Error(`Cannot resolve path: ${relative}`);
|
|
52
|
+
ancestor = parent;
|
|
53
|
+
}
|
|
54
|
+
let rootAncestor = absoluteRoot;
|
|
55
|
+
while (!(await exists(rootAncestor)))
|
|
56
|
+
rootAncestor = path.dirname(rootAncestor);
|
|
57
|
+
const realRoot = path.resolve(await fs.realpath(rootAncestor), path.relative(rootAncestor, absoluteRoot));
|
|
58
|
+
if (!contained(realRoot, path.resolve(await fs.realpath(ancestor), path.relative(ancestor, result)))) {
|
|
59
|
+
throw new Error(`Symbolic link escapes its content directory: ${relative}`);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
export async function write(file, content) {
|
|
64
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
65
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
66
|
+
await fs.writeFile(temporary, content, { flag: 'wx' });
|
|
67
|
+
try {
|
|
68
|
+
await fs.rename(temporary, file);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function writeYaml(file, value) {
|
|
76
|
+
await write(file, stringify(value, { lineWidth: 100 }));
|
|
77
|
+
}
|
|
78
|
+
export function parseYaml(text) {
|
|
79
|
+
const document = parseDocument(text, { uniqueKeys: true });
|
|
80
|
+
if (document.errors.length || document.warnings.length) {
|
|
81
|
+
throw new Error([...document.errors, ...document.warnings].map(e => e.message).join('\n'));
|
|
82
|
+
}
|
|
83
|
+
return document.toJS({ maxAliasCount: 50 });
|
|
84
|
+
}
|
|
85
|
+
export async function readYaml(file, schema) {
|
|
86
|
+
try {
|
|
87
|
+
return schema.parse(parseYaml(await fs.readFile(file, 'utf8')));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : error}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function readNote(file) {
|
|
94
|
+
const contents = (await fs.readFile(file, 'utf8')).replace(/\r\n/g, '\n');
|
|
95
|
+
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(contents);
|
|
96
|
+
if (!match)
|
|
97
|
+
throw new Error(`Missing Markdown frontmatter: ${file}`);
|
|
98
|
+
const metadata = noteTextSchema.parse(parseYaml(match[1]));
|
|
99
|
+
return { ...metadata, body: match[2].trim() };
|
|
100
|
+
}
|
|
101
|
+
export function noteHash(note) {
|
|
102
|
+
return digest(canonical({ title: note.title, alt: note.alt, body: note.body }));
|
|
103
|
+
}
|
|
104
|
+
export async function writeNote(file, note) {
|
|
105
|
+
const { body, ...metadata } = note;
|
|
106
|
+
await write(file, `---\n${stringify(metadata)}---\n\n${body.trim()}\n`);
|
|
107
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Evidence, type Release } from './model.js';
|
|
2
|
+
export declare function git(cwd: string, args: string[], input?: string): string;
|
|
3
|
+
export declare function repoRoot(cwd: string): string;
|
|
4
|
+
export declare function resolveCommit(root: string, ref: string): string;
|
|
5
|
+
export declare function isAncestor(root: string, base: string, head: string): boolean;
|
|
6
|
+
export declare function collect(root: string, from: string | null, to: string): {
|
|
7
|
+
evidence: Evidence;
|
|
8
|
+
patch: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function checkPrevious(root: string, previous: Release, evidence: Evidence): void;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { evidenceSchema } from './model.js';
|
|
3
|
+
export function git(cwd, args, input) {
|
|
4
|
+
const result = spawnSync('git', args, {
|
|
5
|
+
cwd, input, encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024,
|
|
6
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
|
7
|
+
});
|
|
8
|
+
if (result.error)
|
|
9
|
+
throw new Error(`Git could not complete the request: ${result.error.message}`);
|
|
10
|
+
if (result.status !== 0)
|
|
11
|
+
throw new Error(`git ${args[0]}: ${result.stderr.trim() || 'command failed'}`);
|
|
12
|
+
return result.stdout;
|
|
13
|
+
}
|
|
14
|
+
export function repoRoot(cwd) {
|
|
15
|
+
return git(cwd, ['rev-parse', '--show-toplevel']).trim();
|
|
16
|
+
}
|
|
17
|
+
export function resolveCommit(root, ref) {
|
|
18
|
+
if (!ref || ref.startsWith('-'))
|
|
19
|
+
throw new Error('A Git reference must name a commit, branch, or tag.');
|
|
20
|
+
return git(root, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
|
|
21
|
+
}
|
|
22
|
+
export function isAncestor(root, base, head) {
|
|
23
|
+
const result = spawnSync('git', ['merge-base', '--is-ancestor', base, head], { cwd: root, windowsHide: true, encoding: 'utf8' });
|
|
24
|
+
if (result.status === 0)
|
|
25
|
+
return true;
|
|
26
|
+
if (result.status === 1)
|
|
27
|
+
return false;
|
|
28
|
+
throw new Error(`Cannot establish Git ancestry: ${result.stderr || result.error?.message}`);
|
|
29
|
+
}
|
|
30
|
+
export function collect(root, from, to) {
|
|
31
|
+
if (git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true') {
|
|
32
|
+
throw new Error('Complete the shallow Git history before preparing a release; no fetch was performed.');
|
|
33
|
+
}
|
|
34
|
+
const toSha = resolveCommit(root, to);
|
|
35
|
+
const fromSha = from === null ? null : resolveCommit(root, from);
|
|
36
|
+
if (fromSha === toSha)
|
|
37
|
+
throw new Error('The start and end resolve to the same commit; the range is empty.');
|
|
38
|
+
if (fromSha && !isAncestor(root, fromSha, toSha)) {
|
|
39
|
+
throw new Error('The start commit is not an ancestor of the end commit. Choose an explicit range on this release line.');
|
|
40
|
+
}
|
|
41
|
+
const base = fromSha ?? git(root, ['hash-object', '-t', 'tree', '--stdin'], '').trim();
|
|
42
|
+
const log = git(root, ['log', '--no-show-signature', '--format=%H%x00%s', fromSha ? `${fromSha}..${toSha}` : toSha, '--']);
|
|
43
|
+
const commits = log.trimEnd() ? log.trimEnd().split('\n').map(line => {
|
|
44
|
+
const separator = line.indexOf('\0');
|
|
45
|
+
return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
|
|
46
|
+
}) : [];
|
|
47
|
+
const names = git(root, ['diff', '--no-ext-diff', '--no-textconv', '--name-status', '-z', '--find-renames', base, toSha, '--']).split('\0');
|
|
48
|
+
const files = [];
|
|
49
|
+
for (let i = 0; i < names.length - 1;) {
|
|
50
|
+
const status = names[i++];
|
|
51
|
+
const first = names[i++];
|
|
52
|
+
if (/^[RC]/.test(status))
|
|
53
|
+
files.push({ status, oldPath: first, path: names[i++] });
|
|
54
|
+
else
|
|
55
|
+
files.push({ status, path: first });
|
|
56
|
+
}
|
|
57
|
+
const patch = git(root, ['diff', '--no-ext-diff', '--no-textconv', '--find-renames', base, toSha, '--']);
|
|
58
|
+
return {
|
|
59
|
+
evidence: evidenceSchema.parse({ schemaVersion: 1, source: { fromRef: from, fromSha, toRef: to, toSha }, commits, files }),
|
|
60
|
+
patch,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function checkPrevious(root, previous, evidence) {
|
|
64
|
+
const boundary = evidence.source.fromSha;
|
|
65
|
+
if (!boundary || !isAncestor(root, previous.source.toSha, boundary)) {
|
|
66
|
+
throw new Error('The previous release is not an ancestor of this comparison start. Choose the correct previous release.');
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Theme, type Visual } from './model.js';
|
|
2
|
+
import { Project } from './project.js';
|
|
3
|
+
export declare function inspectImage(bytes: Buffer): Promise<{
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
extension: string;
|
|
7
|
+
sha256: string;
|
|
8
|
+
}>;
|
|
9
|
+
export interface ImageRequest {
|
|
10
|
+
note: string;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
reason: 'missing' | 'stale';
|
|
13
|
+
promptFile: string;
|
|
14
|
+
compositionReference: string | null;
|
|
15
|
+
}
|
|
16
|
+
export declare function planImages(project: Project, version: string): Promise<{
|
|
17
|
+
version: string;
|
|
18
|
+
configuredThemes: ("dark" | "light")[];
|
|
19
|
+
requestedAssets: number;
|
|
20
|
+
readyAssets: number;
|
|
21
|
+
pendingAssets: number;
|
|
22
|
+
requests: ImageRequest[];
|
|
23
|
+
costNote: string;
|
|
24
|
+
}>;
|
|
25
|
+
export declare function importImage(project: Project, version: string, noteId: string, variant: Theme, source: string): Promise<{
|
|
26
|
+
file: string;
|
|
27
|
+
sha256: string;
|
|
28
|
+
sceneHash: string;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function validateImages(project: Project, version: string, noteId: string, visual: Visual, errors: string[], warnings: string[]): Promise<void>;
|