Make the deploy layer and tool I/O async

DeployCtx read/list/hash return promises (copy and write stay sync:
they only queue ops), the stamped copy helpers and buildfile deploy
blocks await them, ActionQueue materialization and packing use
fs/promises, and the CLI, config loaders, spritesheet writer, and
trim tool follow. Declaration-time globbing and the module-init data
reads stay sync: rules exist by the end of the import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-17 19:10:49 -04:00
parent 1d7e52ca29
commit 40bd3052f5
14 changed files with 137 additions and 122 deletions

View File

@@ -15,8 +15,8 @@ let webpMinisprites = forEachRule(minispriteInputs, {
cmds: ['cwebp -z 9 %f -o %o'],
}, '%B.webp');
deploy(ctx => {
let h = ctx.hash(...webpMinisprites);
deploy(async ctx => {
let h = await ctx.hash(...webpMinisprites);
for (let f of webpMinisprites) {
newspritecopy(ctx, f, {dir: 'minisprites/' + h});
}
@@ -43,10 +43,10 @@ let sheetWebp = rule(sheetPng, ['cwebp -z 9 %f -o %o'], 'spritesheet.webp');
// Hash-stamped css + webp. The css suffix pointer rides in __meta/ for the
// dex to read.
deploy(ctx => {
let wh = ctx.hash(sheetWebp);
deploy(async ctx => {
let wh = await ctx.hash(sheetWebp);
ctx.copy(sheetWebp, `spritesheet-${wh}.webp`);
let src = ctx.read(sheetCss);
let src = await ctx.read(sheetCss);
let css = src.replaceAll('url("./spritesheet.webp")', `url("./spritesheet-${wh}.webp")`);
if (css === src) {
throw new Error('spritesheet.css: no webp urls rewritten');
@@ -54,7 +54,7 @@ deploy(ctx => {
// Suffix from source content: the rewritten css is a pure function
// of (css, webp), so this changes exactly when the served bytes
// change.
let ch = ctx.hash(sheetCss, sheetWebp);
let ch = await ctx.hash(sheetCss, sheetWebp);
ctx.write(`spritesheet-${ch}.css`, css);
ctx.write('__meta/spritesheet_css_suffix.txt', `-${ch}\n`);
});
@@ -65,23 +65,23 @@ deploy(ctx => {
let forumItems = itemPadded();
let forumG6 = gen6Padded();
deploy(ctx => {
deploy(async ctx => {
let manifest = new Manifest(ctx);
for (let f of forumItems) {
itemspritecopy(manifest, f, {dir: 'forumsprites'});
await itemspritecopy(manifest, f, {dir: 'forumsprites'});
}
for (let f of forumG6) {
spritecopy(manifest, f, {dir: 'forumsprites'}, true);
await spritecopy(manifest, f, {dir: 'forumsprites'}, true);
}
manifest.write('__meta/forumsprites/manifest.json');
});
// PMD sprites ship as-is, stamped.
deploy(ctx => {
deploy(async ctx => {
let manifest = new Manifest(ctx);
for (let f of ctx.list('src/pmd')) {
spritecopy(manifest, f, {dir: 'pmd'});
for (let f of await ctx.list('src/pmd')) {
await spritecopy(manifest, f, {dir: 'pmd'});
}
manifest.write('__meta/pmd/manifest.json');
});

View File

@@ -83,10 +83,10 @@ forEachRule(dexMissing, {
let aniChampions = gen10Modelslike();
deploy(ctx => {
deploy(async ctx => {
let seenModels = new Set<string>();
for (let f of ctx.list('src/models')) {
for (let f of await ctx.list('src/models')) {
seenModels.add(f.name);
psSpritecopy(ctx, f, 'ani');
}

View File

@@ -60,14 +60,14 @@ export function toPSID(name: string): string {
// Copy with a content-hash-stamped name and record the unhashed -> hashed
// mapping in `manifest`.
export function stampcopy(manifest: Manifest, f: Sprite, {dir, ext}: Dest, name: string): void {
let h = manifest.ctx.hash(f);
export async function stampcopy(manifest: Manifest, f: Sprite, {dir, ext}: Dest, name: string): Promise<void> {
let h = await manifest.ctx.hash(f);
manifest.set(`${name}.${extOf(f, ext)}`, `${name}-${h}.${extOf(f, ext)}`);
manifest.ctx.copy(f, `${dir}/${name}-${h}.${extOf(f, ext)}`);
}
export function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
allowUnknown = false): void {
export async function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
allowUnknown = false): Promise<void> {
let sn = spritedata.parseFilename(f.name);
let name: string;
@@ -100,11 +100,11 @@ export function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
name += '-gmax';
}
stampcopy(manifest, f, dest, name);
await stampcopy(manifest, f, dest, name);
}
// TODO: merge with above
export function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): void {
export async function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): Promise<void> {
let sn = spritedata.parseFilename(f.name);
if (sn.extension) {
throw new Error(`Not an item sprite: ${f.name}`);
@@ -114,7 +114,7 @@ export function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): void
throw new Error(`Not an item sprite: ${f.name}`);
}
for (let n of sd.names) {
stampcopy(manifest, f, dest, toSmogonAlias(n));
await stampcopy(manifest, f, dest, toSmogonAlias(n));
}
}

View File

@@ -10,34 +10,34 @@ let xyModels = gen9Modelslike();
let xyChampions = gen10Modelslike();
let xyGen5 = gen5Gifs();
deploy(ctx => {
deploy(async ctx => {
let seenModels = new Set<string>();
let manifest = new Manifest(ctx);
let xycopy = (f: Sprite) => {
let xycopy = async (f: Sprite) => {
if (seenModels.has(f.name)) {
return;
}
seenModels.add(f.name);
spritecopy(manifest, f, {dir: 'xy'});
await spritecopy(manifest, f, {dir: 'xy'});
};
for (let f of ctx.list('src/models')) {
xycopy(f);
for (let f of await ctx.list('src/models')) {
await xycopy(f);
}
for (let f of xyModels) {
xycopy(f);
await xycopy(f);
}
for (let f of xyChampions) {
xycopy(f);
await xycopy(f);
}
// Non-model CAPs
for (let f of ctx.list('src/sprites/gen5')) {
for (let f of await ctx.list('src/sprites/gen5')) {
if (f.ext === 'gif') {
xycopy(f);
await xycopy(f);
}
}
for (let f of xyGen5) {
xycopy(f);
await xycopy(f);
}
manifest.write('xy/manifest.json');
});
@@ -46,10 +46,10 @@ deploy(ctx => {
let xyIcons = gen6Trimmed();
deploy(ctx => {
deploy(async ctx => {
let manifest = new Manifest(ctx);
for (let f of xyIcons) {
spritecopy(manifest, f, {dir: 'xyicons'});
await spritecopy(manifest, f, {dir: 'xyicons'});
}
manifest.write('xyicons/manifest.json');
});

View File

@@ -1,5 +1,5 @@
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
export function parseConfig(text: string): Map<string, string> {
let result = new Map<string, string>();
@@ -17,9 +17,15 @@ export function parseConfig(text: string): Map<string, string> {
return result;
}
export function loadConfig(path: string): Map<string, string> {
if (!fs.existsSync(path)) {
return new Map();
export async function loadConfig(path: string): Promise<Map<string, string>> {
let text;
try {
text = await fs.readFile(path, 'utf8');
} catch (err) {
if ((err as {code?: string}).code === 'ENOENT') {
return new Map();
}
throw err;
}
return parseConfig(fs.readFileSync(path, 'utf8'));
return parseConfig(text);
}

View File

@@ -1,6 +1,6 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import b32encode from 'base32-encode';
@@ -20,14 +20,16 @@ export type CopySource = Artifact | SrcFile | string; // string = repo-relativ
// The finish API: nice naming over built artifacts and raw sources. Ops are
// queued in call order, which is the tar entry order.
export type DeployCtx = {
// copy and write queue ops without touching the disk, so they are sync;
// read, list, and hash do file I/O.
copy(src: CopySource, dst: string): void,
write(dst: string, data: string): void,
read(src: CopySource): string,
list(dir: string): SrcFile[],
read(src: CopySource): Promise<string>,
list(dir: string): Promise<SrcFile[]>,
// 8-char base32 content stamp. One source: the digest of its bytes,
// byte-compatible with artifact hashes. Several: a digest of the sorted
// per-source digests (order-insensitive).
hash(...srcs: CopySource[]): string,
hash(...srcs: CopySource[]): Promise<string>,
};
export type DeployFn = (ctx: DeployCtx) => void | Promise<void>;
@@ -61,11 +63,11 @@ export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
}
return typeof src === 'string' ? src : src.path;
};
let digestOf = (src: CopySource): Buffer => {
let digestOf = async (src: CopySource): Promise<Buffer> => {
if (src instanceof Artifact) {
return Buffer.from(src.hash, 'hex');
}
return crypto.createHash('sha256').update(fs.readFileSync(srcPath(src))).digest();
return crypto.createHash('sha256').update(await fs.readFile(srcPath(src))).digest();
};
return {
copy(src: CopySource, dst: string): void {
@@ -74,14 +76,15 @@ export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
write(dst: string, data: string): void {
queue.write(data, dst);
},
read(src: CopySource): string {
return fs.readFileSync(srcPath(src), 'utf8');
async read(src: CopySource): Promise<string> {
return await fs.readFile(srcPath(src), 'utf8');
},
list(dir: string): SrcFile[] {
async list(dir: string): Promise<SrcFile[]> {
let result = [];
// Files only, no dotfiles: the same filtering the build-side
// glob applies to rule inputs.
for (let ent of fs.readdirSync(dir, {withFileTypes: true}).sort((a, b) => a.name < b.name ? -1 : 1)) {
let ents = await fs.readdir(dir, {withFileTypes: true});
for (let ent of ents.sort((a, b) => a.name < b.name ? -1 : 1)) {
if (ent.name.startsWith('.') || (!ent.isFile() && !ent.isSymbolicLink())) {
continue;
}
@@ -90,12 +93,12 @@ export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
}
return result;
},
hash(...srcs: CopySource[]): string {
async hash(...srcs: CopySource[]): Promise<string> {
let [only] = srcs;
if (only !== undefined && srcs.length === 1) {
return shortHash(digestOf(only));
return shortHash(await digestOf(only));
}
let digests = srcs.map(digestOf).sort(Buffer.compare);
let digests = (await Promise.all(srcs.map(digestOf))).sort(Buffer.compare);
let h = crypto.createHash('sha256');
for (let d of digests) {
h.update(d);

View File

@@ -1,5 +1,5 @@
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path';
import JSON5 from 'json5';
@@ -25,10 +25,10 @@ function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(x => typeof x === 'string');
}
export function loadDeployConfig(path: string): DeployConfig {
export async function loadDeployConfig(path: string): Promise<DeployConfig> {
let text: string;
try {
text = fs.readFileSync(path, 'utf8');
text = await fs.readFile(path, 'utf8');
} catch {
throw new BuildError(`missing ${path}; see README ("Deploying") for the schema`);
}

View File

@@ -1,6 +1,6 @@
import {spawn, type ChildProcess} from 'node:child_process';
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as nodePath from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
@@ -95,8 +95,8 @@ function requireOutput(opts: VerbOpts): string {
return opts.output;
}
function discoverDeployFiles(): string[] {
let files = fs.readdirSync('.').filter(f => f.endsWith('.build.ts')).sort();
async function discoverDeployFiles(): Promise<string[]> {
let files = (await fs.readdir('.')).filter(f => f.endsWith('.build.ts')).sort();
if (files.length === 0) {
throw new BuildError('No *.build.ts files at the repo root');
}
@@ -133,7 +133,7 @@ async function buildThen(decls: readonly artifact.RuleDecl[], opts: CommonOpts,
let dbPath = dryRun && db.dbVersion(DB_PATH) !== 2 ? ':memory:' : DB_PATH;
let store = new db.Store(dbPath);
if (!dryRun) {
fs.rmSync(TMP_DIR, {recursive: true, force: true});
await fs.rm(TMP_DIR, {recursive: true, force: true});
}
let ac = new AbortController();
@@ -213,16 +213,16 @@ function waitExit(child: ChildProcess): Promise<number | null> {
}
async function cmdBuild(files: string[], opts: CommonOpts): Promise<void> {
setConfig(loadConfig(opts.config));
setConfig(await loadConfig(opts.config));
// GC needs the full rule universe: only an unfiltered union build
// can know which keys are no longer declared anywhere.
let gc = files.length === 0 && !Boolean(opts.dryRun);
await importDeploys(files.length > 0 ? files : discoverDeployFiles());
await importDeploys(files.length > 0 ? files : await discoverDeployFiles());
process.exitCode = await buildThen(artifact.getDecls(), opts, gc);
}
async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
let config = loadDeployConfig('deploy.json5');
let config = await loadDeployConfig('deploy.json5');
if (names.length === 0) {
for (let [name, target] of config) {
console.log(`${name} (${target.buildFile})`);
@@ -239,7 +239,7 @@ async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
}
return {name, target};
});
setConfig(loadConfig(opts.config));
setConfig(await loadConfig(opts.config));
let files = [...new Set(targets.map(t => t.target.buildFile))];
let specs = await importDeploys(files);
process.exitCode = await buildThen(artifact.getDecls(), opts, false, async () => {
@@ -260,8 +260,8 @@ async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
continue;
}
if (entry.dir) {
fs.mkdirSync(TMP_DIR, {recursive: true});
let tmp = fs.mkdtempSync(nodePath.join(TMP_DIR, 'deploy-'));
await fs.mkdir(TMP_DIR, {recursive: true});
let tmp = await fs.mkdtemp(nodePath.join(TMP_DIR, 'deploy-'));
try {
await aq.run(tmp, 'copy', dst => matched.has(dst));
let cmd = spawn(entry.cmd.replaceAll('%d', tmp),
@@ -270,7 +270,7 @@ async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
return 1;
}
} finally {
fs.rmSync(tmp, {recursive: true, force: true});
await fs.rm(tmp, {recursive: true, force: true});
}
continue;
}
@@ -283,7 +283,7 @@ async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
// also crash on the resulting EPIPE, which reaches both
// stdin and (via streamx's destroy propagation) the pack.
stdin.on('error', () => {});
let pack = aq.pack(dst => matched.has(dst));
let pack = await aq.pack(dst => matched.has(dst));
pack.on('error', () => {});
pack.pipe(stdin);
if (await waitExit(upload) !== 0) {
@@ -297,7 +297,7 @@ async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
async function cmdRun(file: string, opts: VerbOpts): Promise<void> {
let output = requireOutput(opts);
setConfig(loadConfig(opts.config));
setConfig(await loadConfig(opts.config));
let specs = await importDeploys([file]);
process.exitCode = await buildThen(artifact.getDecls(), opts, false, async () => {
let aq = await runFinish(finishOf(specs, file), Boolean(opts.verbose));
@@ -318,8 +318,8 @@ function slugOf(decl: artifact.RuleDecl): string {
async function cmdInspect(paths: string[], opts: VerbOpts): Promise<void> {
let output = requireOutput(opts);
setConfig(loadConfig(opts.config));
await importDeploys(discoverDeployFiles());
setConfig(await loadConfig(opts.config));
await importDeploys(await discoverDeployFiles());
// Accept absolute paths and paths relative to where the user ran the
// command; rules declare repo-root-relative paths.
let targets = paths.map(p => {
@@ -358,17 +358,18 @@ async function cmdInspect(paths: string[], opts: VerbOpts): Promise<void> {
let decls = artifact.getDecls().filter(d => closure.has(d));
console.log(`inspect: ${decls.length} rules`);
let exists = (p: string) => fs.access(p).then(() => true, () => false);
process.exitCode = await buildThen(decls, opts, false, async () => {
for (let decl of decls) {
let dir = nodePath.join(output, slugOf(decl));
fs.mkdirSync(dir, {recursive: true});
await fs.mkdir(dir, {recursive: true});
for (let artifact of decl.outputs) {
let dst = nodePath.join(dir, artifact.filename);
for (let n = 2; fs.existsSync(dst); n++) {
for (let n = 2; await exists(dst); n++) {
dst = nodePath.join(dir, `${artifact.name}-${n}.${artifact.ext}`);
}
fs.copyFileSync(casPath(CAS_DIR, artifact.hash, artifact.ext), dst);
fs.chmodSync(dst, 0o644);
await fs.copyFile(casPath(CAS_DIR, artifact.hash, artifact.ext), dst);
await fs.chmod(dst, 0o644);
console.log(`${nodePath.relative(output, dst)}`);
}
}

View File

@@ -1,5 +1,6 @@
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import {createWriteStream} from 'node:fs';
import * as nodePath from 'node:path';
import tar from 'tar-stream';
@@ -134,25 +135,25 @@ export class ActionQueue {
continue;
let op = entry.op;
let dst = nodePath.join(dir, entry.dst);
fs.mkdirSync(nodePath.dirname(dst), {recursive: true});
await fs.mkdir(nodePath.dirname(dst), {recursive: true});
if (op.type === 'Copy'){
// Read-only sources are CAS objects; their mode must not
// leak into deploy trees (rsync -a would ship it), and a
// hardlink cannot carry its own mode, so copy those.
if (mode === 'link' && (fs.statSync(op.src).mode & 0o200) !== 0) {
fs.linkSync(op.src, dst);
if (mode === 'link' && ((await fs.stat(op.src)).mode & 0o200) !== 0) {
await fs.link(op.src, dst);
} else {
fs.copyFileSync(op.src, dst);
fs.chmodSync(dst, 0o644);
await fs.copyFile(op.src, dst);
await fs.chmod(dst, 0o644);
}
} else if (op.type === 'Write') {
fs.writeFileSync(dst, op.data);
await fs.writeFile(dst, op.data);
}
}
} else {
// In this case, I guess its a file rather than a dir.
let out = fs.createWriteStream(dir);
this.pack(filter).pipe(out);
let out = createWriteStream(dir);
(await this.pack(filter)).pipe(out);
return new Promise<void>((resolve, reject) => {
out.on('error', reject);
out.on('finish', () => resolve());
@@ -160,7 +161,7 @@ export class ActionQueue {
}
}
pack(filter?: (dst: string) => boolean): NodeJS.ReadableStream {
async pack(filter?: (dst: string) => boolean): Promise<NodeJS.ReadableStream> {
if (!this.valid)
throw new Error(`Invalid ActionQueue`);
let t = tar.pack();
@@ -168,7 +169,7 @@ export class ActionQueue {
if (entry.type !== 'Op' || (filter !== undefined && !filter(entry.dst)))
continue;
let op = entry.op;
let data = op.type === 'Copy' ? fs.readFileSync(op.src) : op.data;
let data = op.type === 'Copy' ? await fs.readFile(op.src) : op.data;
// A dying consumer destroys the pack and every pending entry
// sink, and each sink emits the error; the consumer is the one
// reporting the failure, so keep the sinks quiet.

View File

@@ -35,31 +35,31 @@ function makeArtifact(casDir: string, content: string, ext: string) {
return artifact;
}
test('ctx.hash matches the historical single-file stamp for artifacts and files', () => {
test('ctx.hash matches the historical single-file stamp for artifacts and files', async () => {
let dir = tmpdir();
let file = pathlib.join(dir, 'f.png');
fs.writeFileSync(file, 'stamp-me');
let artifact = makeArtifact(pathlib.join(dir, 'cas'), 'stamp-me', 'png');
let ctx = makeCtx(pathlib.join(dir, 'cas'), new ActionQueue());
assert.equal(ctx.hash(file), shortHash('stamp-me'));
assert.equal(ctx.hash(artifact), shortHash('stamp-me'));
assert.equal(await ctx.hash(file), shortHash('stamp-me'));
assert.equal(await ctx.hash(artifact), shortHash('stamp-me'));
});
test('multi-source ctx.hash is order-insensitive and content-sensitive', () => {
test('multi-source ctx.hash is order-insensitive and content-sensitive', async () => {
let dir = tmpdir();
let a = pathlib.join(dir, 'a.png');
let b = pathlib.join(dir, 'b.png');
fs.writeFileSync(a, 'aaa');
fs.writeFileSync(b, 'bbb');
let ctx = makeCtx(pathlib.join(dir, 'cas'), new ActionQueue());
let before = ctx.hash(a, b);
assert.equal(before, ctx.hash(b, a));
assert.notEqual(before, ctx.hash(a));
let before = await ctx.hash(a, b);
assert.equal(before, await ctx.hash(b, a));
assert.notEqual(before, await ctx.hash(a));
fs.writeFileSync(b, 'changed');
assert.notEqual(ctx.hash(a, b), before);
assert.notEqual(await ctx.hash(a, b), before);
});
test('ctx queues artifact copies from the CAS, writes and reads', () => {
test('ctx queues artifact copies from the CAS, writes and reads', async () => {
let dir = tmpdir();
let casDir = pathlib.join(dir, 'cas');
let artifact = makeArtifact(casDir, 'bytes', 'webp');
@@ -67,13 +67,13 @@ test('ctx queues artifact copies from the CAS, writes and reads', () => {
let ctx = makeCtx(casDir, aq);
ctx.write('m.json', '{}');
ctx.copy(artifact, 'sprites/x.webp');
assert.equal(ctx.read(artifact), 'bytes');
assert.equal(await ctx.read(artifact), 'bytes');
let ops = aq.log.filter(e => e.type === 'Op');
assert.deepEqual(ops.map(e => e.dst), ['m.json', 'sprites/x.webp']);
assert.equal((ops[1] as {op: {src: string}}).op.src, casPath(casDir, artifact.hash, 'webp'));
});
test('ctx.list sorts, parses extensions, skips dotfiles and directories', () => {
test('ctx.list sorts, parses extensions, skips dotfiles and directories', async () => {
let dir = tmpdir();
fs.writeFileSync(pathlib.join(dir, 'b.png'), '');
fs.writeFileSync(pathlib.join(dir, 'a.gif'), '');
@@ -81,15 +81,16 @@ test('ctx.list sorts, parses extensions, skips dotfiles and directories', () =>
fs.writeFileSync(pathlib.join(dir, '.hidden'), '');
fs.mkdirSync(pathlib.join(dir, 'subdir'));
let ctx = makeCtx('cas', new ActionQueue());
assert.deepEqual(ctx.list(dir), [
assert.deepEqual(await ctx.list(dir), [
{dir, name: 'a', ext: 'gif', path: pathlib.join(dir, 'a.gif')},
{dir, name: 'b', ext: 'png', path: pathlib.join(dir, 'b.png')},
{dir, name: 'noext', ext: null, path: pathlib.join(dir, 'noext')},
]);
});
function packedEntries(aq: ActionQueue, filter?: (dst: string) => boolean)
async function packedEntries(aq: ActionQueue, filter?: (dst: string) => boolean)
: Promise<{name: string, data: string}[]> {
let packed = await aq.pack(filter);
return new Promise((resolve, reject) => {
let extract = tar.extract();
let entries: {name: string, data: string}[] = [];
@@ -103,7 +104,7 @@ function packedEntries(aq: ActionQueue, filter?: (dst: string) => boolean)
});
extract.on('finish', () => resolve(entries));
extract.on('error', reject);
aq.pack(filter).pipe(extract);
packed.pipe(extract);
});
}
@@ -129,12 +130,12 @@ test('pack with a filter packs only matching entries in order', async () => {
]);
});
test('duplicate and absolute destinations invalidate the queue', () => {
test('duplicate and absolute destinations invalidate the queue', async () => {
let dup = new ActionQueue();
dup.write('a', 'x.txt');
dup.write('b', 'x.txt');
assert.ok(!dup.valid);
assert.throws(() => dup.pack(), /Invalid ActionQueue/);
await assert.rejects(() => dup.pack(), /Invalid ActionQueue/);
let abs = new ActionQueue();
abs.write('a', '/etc/passwd');

View File

@@ -14,8 +14,8 @@ function configFile(text: string): string {
return p;
}
test('loadDeployConfig parses json5 with comments and trailing commas', () => {
let config = loadDeployConfig(configFile(`{
test('loadDeployConfig parses json5 with comments and trailing commas', async () => {
let config = await loadDeployConfig(configFile(`{
// dex assets
assets: {
buildFile: "assets.build.ts",
@@ -31,26 +31,26 @@ test('loadDeployConfig parses json5 with comments and trailing commas', () => {
});
});
test('loadDeployConfig ties the dir flag to %d in the cmd', () => {
let dir = loadDeployConfig(configFile(
test('loadDeployConfig ties the dir flag to %d in the cmd', async () => {
let dir = await loadDeployConfig(configFile(
'{ps: {buildFile: "ps.build.ts", deploy: [{subset: ["ani/**"], dir: true, cmd: "rsync -a %d/ani/ h:a/"}]}}'));
assert.equal(dir.get('ps')!.deploy[0]!.dir, true);
assert.throws(() => loadDeployConfig(configFile(
await assert.rejects(() => loadDeployConfig(configFile(
'{a: {buildFile: "x.build.ts", deploy: [{subset: ["**"], dir: true, cmd: "rsync -a h:a/"}]}}')),
/dir entry's cmd must use %d/);
assert.throws(() => loadDeployConfig(configFile(
await assert.rejects(() => loadDeployConfig(configFile(
'{a: {buildFile: "x.build.ts", deploy: [{subset: ["**"], cmd: "rsync -a %d/ h:a/"}]}}')),
/must not use %d \(missing dir: true\?\)/);
});
test('loadDeployConfig rejects missing files and malformed shapes', () => {
assert.throws(() => loadDeployConfig('/nonexistent/deploy.json5'), /missing/);
assert.throws(() => loadDeployConfig(configFile('[1]')), /must be an object/);
assert.throws(() => loadDeployConfig(configFile('{a: {deploy: []}}')), /expected \{buildFile/);
assert.throws(() => loadDeployConfig(configFile(
test('loadDeployConfig rejects missing files and malformed shapes', async () => {
await assert.rejects(() => loadDeployConfig('/nonexistent/deploy.json5'), /missing/);
await assert.rejects(() => loadDeployConfig(configFile('[1]')), /must be an object/);
await assert.rejects(() => loadDeployConfig(configFile('{a: {deploy: []}}')), /expected \{buildFile/);
await assert.rejects(() => loadDeployConfig(configFile(
'{a: {buildFile: "x.build.ts", deploy: [{subset: "**", cmd: "c"}]}}')),
/needs \{subset/);
assert.throws(() => loadDeployConfig(configFile('{a: {buildFile:')), /deploy.json5:/);
await assert.rejects(() => loadDeployConfig(configFile('{a: {buildFile:')), /deploy.json5:/);
});
test('matchSubsets routes dsts to entries, overlap allowed', () => {

View File

@@ -1,7 +1,7 @@
import spritesmith from 'spritesmith'
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import * as util from 'node:util';
import * as spritedata from '@smogon/sprite-data/index.ts';
@@ -68,5 +68,5 @@ for (let [id, sprite] of sprites) {
}`;
}
fs.writeFileSync(opts.image, result.image, 'binary');
fs.writeFileSync(opts.stylesheet, stylesheet);
await fs.writeFile(opts.image, result.image, 'binary');
await fs.writeFile(opts.stylesheet, stylesheet);

View File

@@ -1,9 +1,12 @@
import * as cp from 'node:child_process';
import * as util from 'node:util';
export function getDims(input: string) {
let info = cp.execFileSync('magick', ['convert', input, '-format', '%w+%h+%@', 'info:'],
{encoding:'utf8'});
let execFile = util.promisify(cp.execFile);
export async function getDims(input: string) {
let {stdout: info} = await execFile('magick',
['convert', input, '-format', '%w+%h+%@', 'info:'], {encoding: 'utf8'});
let [imageWidth, imageHeight, width, height, left, top] =
info.split(/x|\+/g).map(dim => parseInt(dim)) as [number, number, number, number, number, number];
@@ -21,8 +24,8 @@ export function getDims(input: string) {
}
}
export function crop(input: string, {width, height, left, top}: {width: number, height: number, left: number, top: number}, output: string) {
cp.execFileSync('magick', ['convert', input, '+repage', '-crop', `${width}x${height}+${left}+${top}`, output]);
export async function crop(input: string, {width, height, left, top}: {width: number, height: number, left: number, top: number}, output: string) {
await execFile('magick', ['convert', input, '+repage', '-crop', `${width}x${height}+${left}+${top}`, output]);
}
// Trim, preserving displacement from center

View File

@@ -15,7 +15,7 @@ let {values: opts, positionals: files} = parseArgs({
let retVal = 0;
for (let file of files) {
let dims = image.getDims(file);
let dims = await image.getDims(file);
let alreadyCropped = (dims.left === 0 || dims.right === 0) &&
(dims.top === 0 || dims.bottom === 0);
@@ -38,7 +38,7 @@ for (let file of files) {
if (!opts.check && (!alreadyCropped || opts.force)) {
let trimDims = image.losslessTrim(dims);
image.crop(file, trimDims, file);
await image.crop(file, trimDims, file);
}
}