Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bold-dryers-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hethon/ingen": minor
---

Added the `ingen sync` command.

You can now run `ingen sync <manifest>` 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.
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"cac": "^7.0.0",
"jsonc-parser": "^3.3.1",
"minijinja-js": "^2.21.0",
"zod": "^4.4.3"
},
Expand Down
31 changes: 9 additions & 22 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { CAC } from "cac";
import { ZodError } from "zod";
import { generate } from "../generate";

export function registerGenerateCommand(
Expand All @@ -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`);
});
}
106 changes: 106 additions & 0 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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 <manifest>", "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<string, string> = {
"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?.value === 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.");
}
});
}
17 changes: 13 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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";
import { registerSyncCommand } from "./commands/sync";

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand All @@ -17,16 +19,23 @@ const cli = cac("ingen");
const templatesDir = join(__dirname, "..", "templates");
registerGenerateCommand(cli, PROVIDER, templatesDir);
registerInitCommand(cli);
registerSyncCommand(cli);

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 <manifest>) 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);
}

Expand Down
37 changes: 26 additions & 11 deletions src/manifest-parser/manifest_to_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 ?? null,
executables: archive.executables ?? (isWindows ? executables.map((n) => `${n}.exe`) : executables),
Expand All @@ -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);

Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand Down