From a5883f2de44ad8105204093861260015f1845d0f Mon Sep 17 00:00:00 2001 From: Henok Date: Thu, 6 Aug 2026 04:17:22 +0300 Subject: [PATCH 1/5] refactor: centralize CLI error handling --- src/commands/generate.ts | 31 +++++++++---------------------- src/index.ts | 15 +++++++++++---- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index bddc5f9..74ea296 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,5 +1,4 @@ import type { CAC } from "cac"; -import { ZodError } from "zod"; import { generate } from "../generate"; export function registerGenerateCommand( @@ -13,26 +12,14 @@ export function registerGenerateCommand( .action((manifestPath: string, outDirArg: string | undefined, options: { appVersion?: string }) => { const outDir = outDirArg ?? "./out"; - try { - generate({ - manifestPath, - templatesDir, - outDir, - provider, - appVersionOverride: options.appVersion, - }); - console.log(`✓ wrote ${outDir}/installer.sh`); - console.log(`✓ wrote ${outDir}/installer.ps1`); - } catch (err) { - if (err instanceof ZodError) { - console.error("✗ invalid manifest:\n"); - for (const issue of err.issues) { - console.error(` - ${issue.path.join(".") || "(root)"}: ${issue.message}`); - } - process.exit(1); - } - console.error(`✗ ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } + generate({ + manifestPath, + templatesDir, + outDir, + provider, + appVersionOverride: options.appVersion, + }); + console.log(`✓ wrote ${outDir}/installer.sh`); + console.log(`✓ wrote ${outDir}/installer.ps1`); }); } diff --git a/src/index.ts b/src/index.ts index c54d105..37c1aa9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { cac } from "cac"; +import { ZodError } from "zod"; import pkg from "../package.json"; import { registerGenerateCommand } from "./commands/generate"; import { registerInitCommand } from "./commands/init"; @@ -22,11 +23,17 @@ cli.help(); cli.version(pkg.version); try { - cli.parse(); + cli.parse(process.argv, { run: false }); + await cli.runMatchedCommand(); } catch (err) { - // CAC throws if a required argument (like ) is missing. - // We catch it here to print a clean error instead of a stack trace. - console.error(`✗ ${err instanceof Error ? err.message : String(err)}`); + if (err instanceof ZodError) { + console.error("✗ invalid manifest:\n"); + for (const issue of err.issues) { + console.error(` - ${issue.path.join(".") || "(root)"}: ${issue.message}`); + } + } else { + console.error(`✗ ${err instanceof Error ? err.message : String(err)}`); + } process.exit(1); } From a140c00a3faca622be5df34f6bab617176420a31 Mon Sep 17 00:00:00 2001 From: Henok Date: Thu, 6 Aug 2026 04:26:47 +0300 Subject: [PATCH 2/5] refactor: separate checksum parsing from transformation --- src/manifest-parser/manifest.schema.ts | 24 ++++++++++------------ src/manifest-parser/manifest_to_context.ts | 4 ++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/manifest-parser/manifest.schema.ts b/src/manifest-parser/manifest.schema.ts index 6338a78..caffb2a 100644 --- a/src/manifest-parser/manifest.schema.ts +++ b/src/manifest-parser/manifest.schema.ts @@ -32,19 +32,7 @@ const manifestArchiveSchema = fragmentSchema }) .extend({ target: fragmentSchema.shape.target_triple, - checksum: z - .string() - .regex(checksumStringRegex) - .transform((checksum) => { - type ChecksumAlgorithm = (typeof checksumAlgorithms)[number]; - const [style, value] = checksum.split(":", 2) as [ChecksumAlgorithm, string]; - - return { - style, - value, - }; - }) - .optional(), + checksum: z.string().regex(checksumStringRegex).optional(), min_glibc_version: libcVersionSchema.optional(), layout: archiveLayoutSchema.optional(), }) @@ -55,6 +43,16 @@ const manifestArchiveSchema = fragmentSchema zip_style: true, }); +export const checksumStringToChecksumObj = z.transform((checksum: string) => { + type ChecksumAlgorithm = (typeof checksumAlgorithms)[number]; + const [style, value] = checksum.split(":", 2) as [ChecksumAlgorithm, string]; + + return { + style, + value, + }; +}); + const INSTALL_PATH_REGEX = /^(~\/.*|\$[A-Za-z_][A-Za-z0-9_]*(?:\/.*)?)$/; const manifestInstallPathStringSchema = z diff --git a/src/manifest-parser/manifest_to_context.ts b/src/manifest-parser/manifest_to_context.ts index 33ec43c..f6799e2 100644 --- a/src/manifest-parser/manifest_to_context.ts +++ b/src/manifest-parser/manifest_to_context.ts @@ -1,7 +1,7 @@ import type { Fragment } from "../template-context/common_context.schema"; import type { Ps1Context } from "../template-context/ps1_context.schema"; import type { ShContext } from "../template-context/sh_context.schema"; -import type { Manifest } from "./manifest.schema"; +import { checksumStringToChecksumObj, type Manifest } from "./manifest.schema"; import { computePlatformSupport } from "./platforms"; type Context = Ps1Context & ShContext; @@ -146,7 +146,7 @@ function reconstructPlatformSupport( return { id: substitute(archive.id, { app_version }), target_triple: archive.target, - checksum: archive.checksum ?? null, + checksum: archive.checksum ? checksumStringToChecksumObj.parse(archive.checksum) : null, executables: archive.executables ?? (isWindows ? executables.map((n) => `${n}.exe`) : executables), cdylibs: archive.cdylibs ?? cdylibs, cstaticlibs: archive.cstaticlibs ?? cstaticlibs, From 7381784947cc3305e114eaa525a35898b67d2a57 Mon Sep 17 00:00:00 2001 From: Henok Date: Thu, 6 Aug 2026 04:56:02 +0300 Subject: [PATCH 3/5] refactor: decouple manifest placeholder resolution from context reconstruction Extracted the placeholder substitution logic (e.g., resolving `{app_version}`) into a dedicated `resolveManifest` function that mutates the manifest in-place. The original context-building function was appropriately renamed to `reconstructContext`. --- src/manifest-parser/manifest_to_context.ts | 37 +++++++++++++++------- src/render.ts | 6 ++-- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/manifest-parser/manifest_to_context.ts b/src/manifest-parser/manifest_to_context.ts index f6799e2..8bda498 100644 --- a/src/manifest-parser/manifest_to_context.ts +++ b/src/manifest-parser/manifest_to_context.ts @@ -133,7 +133,6 @@ function reconstructPlatformSupport( const windowsArchiveStyle = manifest.windows_archive.style; const unixArchiveStyle = manifest.unix_archive.style; const min_glibc_version = manifest.min_glibc_version; - const app_version = manifest.app_version; // --- const archivesIntermediate = manifest.archives.map((archive) => { @@ -144,7 +143,7 @@ function reconstructPlatformSupport( const zip_depth: "0" | "1" = archiveLayout === "wrapped" ? "1" : "0"; return { - id: substitute(archive.id, { app_version }), + id: archive.id, target_triple: archive.target, checksum: archive.checksum ? checksumStringToChecksumObj.parse(archive.checksum) : null, executables: archive.executables ?? (isWindows ? executables.map((n) => `${n}.exe`) : executables), @@ -165,22 +164,38 @@ function reconstructPlatformSupport( }; } +// Resolve all the placeholder tokens in the fields of manifest +export function resolveManifest(manifest: Manifest): void { + const app_version = manifest.app_version; + + const resolvedTag = substitute(manifest.tag, { app_version }); + manifest.tag = resolvedTag; + + manifest.base_urls = substituteAll(manifest.base_urls, { + owner: manifest.owner, + repo: manifest.repo, + tag: resolvedTag, + }); + + manifest.archives = manifest.archives.map(({ id, ...rest }) => ({ + id: substitute(id, { app_version }), + ...rest, + })); +} + /** * Takes the validated user-facing manifest and resolves it into the * fully-expanded shape shContextSchema/ps1ContextSchema expect. */ type Provider = Context["receipt"]["provider"]; -export function resolveManifest(manifest: Manifest, target: "sh", provider: Provider): ShContext; -export function resolveManifest(manifest: Manifest, target: "ps1", provider: Provider): Ps1Context; -export function resolveManifest( +export function reconstructContext(manifest: Manifest, target: "sh", provider: Provider): ShContext; +export function reconstructContext(manifest: Manifest, target: "ps1", provider: Provider): Ps1Context; +export function reconstructContext( manifest: Manifest, target: "sh" | "ps1", provider: Provider, ): ShContext | Ps1Context { - const resolvedTag = substitute(manifest.tag, { app_version: manifest.app_version }); - - const vars = { owner: manifest.owner, repo: manifest.repo, tag: resolvedTag }; - const resolvedBaseUrls = substituteAll(manifest.base_urls, vars); + resolveManifest(manifest); const reconstructedPlatformSupport = reconstructPlatformSupport(manifest); @@ -198,10 +213,10 @@ export function resolveManifest( const common = { app_name: manifest.app_name, app_version: manifest.app_version, - base_urls: resolvedBaseUrls, + base_urls: manifest.base_urls, hosting: { github: { - artifact_download_path: `/${manifest.owner}/${manifest.repo}/releases/download/${resolvedTag}`, + artifact_download_path: `/${manifest.owner}/${manifest.repo}/releases/download/${manifest.tag}`, }, }, install_success_msg: manifest.install_success_msg, diff --git a/src/render.ts b/src/render.ts index c4543b6..b473589 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,6 +1,6 @@ import { Environment } from "minijinja-js"; import { manifestSchema } from "./manifest-parser/manifest.schema"; -import { resolveManifest } from "./manifest-parser/manifest_to_context"; +import { reconstructContext } from "./manifest-parser/manifest_to_context"; import { ps1ContextSchema } from "./template-context/ps1_context.schema"; import { shContextSchema } from "./template-context/sh_context.schema"; @@ -48,8 +48,8 @@ export function render(input: RenderInput): RenderOutput { const manifest = manifestSchema.parse(rawManifestData); - const shContextRaw = resolveManifest(manifest, "sh", input.provider); - const ps1ContextRaw = resolveManifest(manifest, "ps1", input.provider); + const shContextRaw = reconstructContext(manifest, "sh", input.provider); + const ps1ContextRaw = reconstructContext(manifest, "ps1", input.provider); const shContext = shContextSchema.parse(shContextRaw); const ps1Context = ps1ContextSchema.parse(ps1ContextRaw); From b682713305d7bd40891b4e02379e76448efa38b5 Mon Sep 17 00:00:00 2001 From: Henok Date: Thu, 6 Aug 2026 05:20:40 +0300 Subject: [PATCH 4/5] feat: add `sync` subcommand to automatically fetch checksums Added `ingen sync ` to fetch release asset digests from the GitHub API and automatically inject them into the manifest. It uses `jsonc-parser` to edit the file in-place so custom formatting and comments aren't destroyed. Also added `GITHUB_TOKEN` support to handle rate limits and private repos. --- .changeset/bold-dryers-punch.md | 7 +++ bun.lock | 3 + package.json | 1 + src/commands/sync.ts | 106 ++++++++++++++++++++++++++++++++ src/index.ts | 2 + 5 files changed, 119 insertions(+) create mode 100644 .changeset/bold-dryers-punch.md create mode 100644 src/commands/sync.ts diff --git a/.changeset/bold-dryers-punch.md b/.changeset/bold-dryers-punch.md new file mode 100644 index 0000000..3d12c42 --- /dev/null +++ b/.changeset/bold-dryers-punch.md @@ -0,0 +1,7 @@ +--- +"@hethon/ingen": minor +--- + +Added the `ingen sync` command. + +You can now run `ingen sync ` to automatically fetch asset checksums from your GitHub release and inject them directly into your manifest file. It safely edits the file in-place to preserve your formatting and comments, and supports using a `GITHUB_TOKEN` environment variable for private repositories or avoiding rate limits. diff --git a/bun.lock b/bun.lock index e35f97a..75e7313 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "ingen", "dependencies": { "cac": "^7.0.0", + "jsonc-parser": "^3.3.1", "minijinja-js": "^2.21.0", "zod": "^4.4.3", }, @@ -156,6 +157,8 @@ "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "lint-staged": ["lint-staged@17.2.0", "", { "dependencies": { "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A=="], diff --git a/package.json b/package.json index 25d7ee7..5cd9965 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "cac": "^7.0.0", + "jsonc-parser": "^3.3.1", "minijinja-js": "^2.21.0", "zod": "^4.4.3" }, diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..48d04ba --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,106 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import type { CAC } from "cac"; +import { applyEdits, modify } from "jsonc-parser"; +import { z } from "zod"; +import { manifestSchema } from "../manifest-parser/manifest.schema"; +import { resolveManifest } from "../manifest-parser/manifest_to_context"; + +const githubReleaseSchema = z.object({ + assets: z.array( + z.object({ + name: z.string(), + digest: z.string().nullable().optional(), + }), + ), +}); + +const formattingOptions = { + insertSpaces: true, + tabSize: 4, +}; + +export function registerSyncCommand(cli: CAC) { + cli + .command("sync ", "Fetch checksums from GitHub releases and update the manifest") + .action(async (manifestPath: string) => { + const source = readFileSync(manifestPath, "utf8"); + const { $schema: _, ...rawManifestData } = JSON.parse(source); + const manifest = manifestSchema.parse(rawManifestData); + resolveManifest(manifest); + + console.log(`Syncing checksums from ${manifest.owner}/${manifest.repo}@${manifest.tag}...`); + + const headers: Record = { + "User-Agent": "ingen-cli", + Accept: "application/vnd.github+json", + }; + + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + const response = await fetch( + `https://api.github.com/repos/${manifest.owner}/${manifest.repo}/releases/tags/${manifest.tag}`, + { headers }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch GitHub release: ${response.status} ${response.statusText}`); + } + + const release = githubReleaseSchema.parse(await response.json()); + + const checksums = new Map( + release.assets.filter((asset) => asset.digest).map((asset) => [asset.name, asset.digest]), + ); + + const edits = []; + + let updated = 0; + let unchanged = 0; + let missing = 0; + + for (const [idx, archive] of manifest.archives.entries()) { + const checksum = checksums.get(archive.id); + + if (!checksum) { + console.warn(`⚠ No GitHub digest found for ${archive.id}`); + missing++; + continue; + } + + if (archive.checksum === checksum) { + unchanged++; + continue; + } + + edits.push(...modify(source, ["archives", idx, "checksum"], checksum, { formattingOptions })); + + updated++; + + console.log(`✓ Updated checksum for ${archive.id}`); + } + + if (updated > 0) { + writeFileSync(manifestPath, applyEdits(source, edits)); + } + + console.log(""); + + if (updated > 0) { + console.log(`Updated ${updated} archive checksum(s).`); + } + + if (unchanged > 0) { + console.log(`${unchanged} archive checksum(s) already up to date.`); + } + + if (missing > 0) { + console.log(`${missing} archive checksum(s) could not be resolved.`); + } + + if (updated === 0 && missing === 0) { + console.log("Manifest is already synchronized."); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 37c1aa9..05d0f02 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ZodError } from "zod"; import pkg from "../package.json"; import { registerGenerateCommand } from "./commands/generate"; import { registerInitCommand } from "./commands/init"; +import { registerSyncCommand } from "./commands/sync"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -18,6 +19,7 @@ const cli = cac("ingen"); const templatesDir = join(__dirname, "..", "templates"); registerGenerateCommand(cli, PROVIDER, templatesDir); registerInitCommand(cli); +registerSyncCommand(cli); cli.help(); cli.version(pkg.version); From 127f58800998c582f279d68833211f7871fee235 Mon Sep 17 00:00:00 2001 From: Henok Date: Thu, 6 Aug 2026 05:53:04 +0300 Subject: [PATCH 5/5] Revert "refactor: separate checksum parsing from transformation" Restored the `.transform()` logic directly inside the Zod schema for `archive.checksum`. Extracting it didn't provide enough value and made `manifest_to_context.ts` unnecessarily verbose. Updated `sync.ts` to correctly compare against `archive.checksum?.value` since the parsed manifest now returns the transformed object again. --- src/commands/sync.ts | 2 +- src/manifest-parser/manifest.schema.ts | 24 ++++++++++++---------- src/manifest-parser/manifest_to_context.ts | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 48d04ba..912dd18 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -69,7 +69,7 @@ export function registerSyncCommand(cli: CAC) { continue; } - if (archive.checksum === checksum) { + if (archive.checksum?.value === checksum) { unchanged++; continue; } diff --git a/src/manifest-parser/manifest.schema.ts b/src/manifest-parser/manifest.schema.ts index caffb2a..6338a78 100644 --- a/src/manifest-parser/manifest.schema.ts +++ b/src/manifest-parser/manifest.schema.ts @@ -32,7 +32,19 @@ const manifestArchiveSchema = fragmentSchema }) .extend({ target: fragmentSchema.shape.target_triple, - checksum: z.string().regex(checksumStringRegex).optional(), + checksum: z + .string() + .regex(checksumStringRegex) + .transform((checksum) => { + type ChecksumAlgorithm = (typeof checksumAlgorithms)[number]; + const [style, value] = checksum.split(":", 2) as [ChecksumAlgorithm, string]; + + return { + style, + value, + }; + }) + .optional(), min_glibc_version: libcVersionSchema.optional(), layout: archiveLayoutSchema.optional(), }) @@ -43,16 +55,6 @@ const manifestArchiveSchema = fragmentSchema zip_style: true, }); -export const checksumStringToChecksumObj = z.transform((checksum: string) => { - type ChecksumAlgorithm = (typeof checksumAlgorithms)[number]; - const [style, value] = checksum.split(":", 2) as [ChecksumAlgorithm, string]; - - return { - style, - value, - }; -}); - const INSTALL_PATH_REGEX = /^(~\/.*|\$[A-Za-z_][A-Za-z0-9_]*(?:\/.*)?)$/; const manifestInstallPathStringSchema = z diff --git a/src/manifest-parser/manifest_to_context.ts b/src/manifest-parser/manifest_to_context.ts index 8bda498..c83e7ea 100644 --- a/src/manifest-parser/manifest_to_context.ts +++ b/src/manifest-parser/manifest_to_context.ts @@ -1,7 +1,7 @@ import type { Fragment } from "../template-context/common_context.schema"; import type { Ps1Context } from "../template-context/ps1_context.schema"; import type { ShContext } from "../template-context/sh_context.schema"; -import { checksumStringToChecksumObj, type Manifest } from "./manifest.schema"; +import type { Manifest } from "./manifest.schema"; import { computePlatformSupport } from "./platforms"; type Context = Ps1Context & ShContext; @@ -145,7 +145,7 @@ function reconstructPlatformSupport( return { id: archive.id, target_triple: archive.target, - checksum: archive.checksum ? checksumStringToChecksumObj.parse(archive.checksum) : null, + checksum: archive.checksum ?? null, executables: archive.executables ?? (isWindows ? executables.map((n) => `${n}.exe`) : executables), cdylibs: archive.cdylibs ?? cdylibs, cstaticlibs: archive.cstaticlibs ?? cstaticlibs,