Files
sprites/tools/deploy/queue.ts
Christopher Monsanto 8bf512480a Replace the .env deploy command with deploy.json5
An untracked json5 config maps deploy names to a buildFile plus
(subset globs, cmd) entries: the deploy verb builds the named
buildFiles, finishes each, routes the outputs through the subset
globs, and pipes one tar per entry to its command. Every glob must
match an output and every output must be covered by some entry.

The tar no longer carries __key; the upload command names the asset
set itself (smogonctl assets upload sprites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 01:13:13 -04:00

183 lines
5.5 KiB
TypeScript

import fs from 'fs';
import nodePath from 'path';
import tar from 'tar-stream';
type Op = {
type : 'Write',
data : string,
} | {
type : 'Copy',
src : string,
};
type OpEntry = {
type : 'Op',
op : Op,
dst : string,
valid : 'Success' | 'Absolute' | 'Multiple',
debugObjs : unknown[]
};
type DebugEntry = {
type : 'Debug',
obj : unknown,
stray : boolean
};
export type LogEntry = OpEntry | DebugEntry;
export class ActionQueue {
private seen : Map<string, OpEntry | 'MoreThan1'>;
// Have an accessor for this in the future? idk
public log : LogEntry[];
public valid : boolean;
private debugBuffer : unknown[];
constructor() {
this.seen = new Map;
this.log = [];
this.valid = true;
this.debugBuffer = [];
}
throw(obj : unknown) {
this.gdebug(obj, false);
this.valid = false;
}
debug(obj : unknown) {
this.debugBuffer.push(obj);
}
gdebug(obj : unknown, stray : boolean) {
this.log.push({type: 'Debug', obj, stray});
}
private pushOp(op: Op, dst : string) {
dst = nodePath.normalize(dst);
const entry : OpEntry = {
type : 'Op',
op,
dst,
valid : 'Success',
debugObjs : this.debugBuffer
};
this.log.push(entry);
this.debugBuffer = [];
if (nodePath.isAbsolute(dst)) {
this.valid = false;
entry.valid = 'Absolute';
} else {
const lastEntry = this.seen.get(dst);
if (lastEntry === undefined) {
this.seen.set(dst, entry);
} else {
this.valid = false;
entry.valid = 'Multiple';
if (lastEntry !== 'MoreThan1') {
lastEntry.valid = 'Multiple';
}
}
}
}
copy(src : string, dst : string) {
this.pushOp({type: 'Copy', src}, dst);
}
write(data : string, dst : string) {
this.pushOp({type: 'Write', data}, dst);
}
skip() {
for (const obj of this.debugBuffer) {
this.gdebug(obj, true);
}
this.debugBuffer = [];
}
print(level : 'errors' | 'all') {
for (const entry of this.log) {
if (entry.type === 'Op') {
const op = entry.op;
if (entry.valid === 'Success' && level === 'errors')
continue;
let addendum = '';
if (entry.valid !== 'Success') {
addendum = ` (${entry.valid})`;
}
for (const obj of entry.debugObjs) {
console.error("DEBUG:", obj);
}
if (op.type === 'Copy') {
console.error(`COPY${addendum}: ${op.src} ==> ${entry.dst}`);
} else if (op.type === 'Write') {
console.error(`WRITE${addendum}: ${op.data.length} characters ==> ${entry.dst}`);
}
} else if (entry.type === 'Debug') {
let addendum = '';
if (entry.stray) {
addendum = ` (stray)`;
}
console.error(`GDEBUG${addendum}:`, entry.obj);
}
}
}
async run(dir : string, mode : 'link' | 'copy' | 'tar') {
if (!this.valid)
throw new Error(`Invalid ActionQueue`);
if (mode !== 'tar') {
for (const entry of this.log) {
if (entry.type !== 'Op')
continue;
const op = entry.op;
const dst = nodePath.join(dir, entry.dst);
fs.mkdirSync(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);
} else {
fs.copyFileSync(op.src, dst);
fs.chmodSync(dst, 0o644);
}
} else if (op.type === 'Write') {
fs.writeFileSync(dst, op.data);
}
}
} else {
// In this case, I guess its a file rather than a dir.
const out = fs.createWriteStream(dir);
this.pack().pipe(out);
return new Promise<void>((resolve, reject) => {
out.on('error', reject);
out.on('finish', () => resolve());
})
}
}
pack(filter? : (dst : string) => boolean) : NodeJS.ReadableStream {
if (!this.valid)
throw new Error(`Invalid ActionQueue`);
let t = tar.pack();
for (const entry of this.log) {
if (entry.type !== 'Op' || (filter !== undefined && !filter(entry.dst)))
continue;
const op = entry.op;
if (op.type === 'Copy'){
t.entry({name: entry.dst}, fs.readFileSync(op.src));
} else if (op.type === 'Write') {
t.entry({name: entry.dst}, op.data);
}
}
// Without this the archive has no end-of-archive marker, and strict
// readers (Python tarfile in stream mode) die on the truncation.
t.finalize();
return t;
}
}