Files
sprites/tools/deploy/api.ts
Christopher Monsanto a648c9e622 Let a deploy publish a link as well as a file
A served name carries a content hash, so a reader that composes a fixed path
can't find one. The way out is to publish the set twice, once under the stamped
names and once under the bare ones, and a link is the one thing the queue
couldn't say: Copy dereferences and Write has bytes.

Add a Symlink op beside them. What ctx.symlink takes is not a link target but
the destination of the thing being named, since a published tree is unpacked
somewhere else and its halves can land apart; the path between the two is
computed here and is what gets written. It takes the same duplicate-dst and
absolute-dst checks the other ops do, plus one of its own -- resolved lexically
against the directory it sits in, a target has to stay inside the tree, since
the receiving side extracts as root and a link that walks out of it is the same
write to anywhere that an absolute member name is.

run() makes a real symlink in every mode, clearing the name first so a rerun
over an existing tree behaves the way copy and write already do, and pack()
emits a symlink member with no body, which is what a stream reader on the other
end sees.

refactor digests bytes, and a link has none; what it publishes is the name it
points at, so that is what it records and what a retarget shows up as.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:22:45 -04:00

119 lines
4.4 KiB
TypeScript

import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path';
import b32encode from 'base32-encode';
import {Artifact} from '../build/artifact.ts';
import {casPath} from '../build/cas.ts';
import {type ActionQueue} from './queue.ts';
import * as pathlib from './path.ts';
// A source-tree file from ctx.list(); shaped like a deploy Path plus the
// repo-relative path.
export type SrcFile = pathlib.Path & {
path: string,
};
export type CopySource = Artifact | SrcFile | string; // string = repo-relative path
// 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,
// A second name for something else this tree publishes, carried as a link
// rather than a second copy. `names` is that thing's destination, not a
// link target: what gets written is the path from `dst` to it, since the
// two ends can be moved apart by whoever unpacks the tree.
symlink(dst: string, names: string): void,
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[]): Promise<string>,
};
export type DeployFn = (ctx: DeployCtx) => void | Promise<void>;
// Like rule(): a buildFile registers free-floating deploy blocks next to the
// rules they ship. They run in registration order after the build, sharing
// one ctx (and so one output tree) per buildFile.
let deploys: DeployFn[] = [];
export function deploy(fn: DeployFn): void {
deploys.push(fn);
}
export function getDeploys(): readonly DeployFn[] {
return deploys;
}
export function resetDeploys(): void {
deploys.length = 0;
}
function shortHash(digest: Buffer): string {
return b32encode(digest, 'RFC4648').slice(0, 8);
}
export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
// Every path here is repo-root-relative; the CLI chdirs to the root.
let srcPath = (src: CopySource): string => {
if (src instanceof Artifact) {
return casPath(casDir, src.hash, src.ext);
}
return typeof src === 'string' ? src : src.path;
};
let digestOf = async (src: CopySource): Promise<Buffer> => {
if (src instanceof Artifact) {
return Buffer.from(src.hash, 'hex');
}
return crypto.createHash('sha256').update(await fs.readFile(srcPath(src))).digest();
};
return {
copy(src: CopySource, dst: string): void {
queue.copy(srcPath(src), dst);
},
write(dst: string, data: string): void {
queue.write(data, dst);
},
symlink(dst: string, names: string): void {
queue.symlink(nodePath.relative(nodePath.dirname(nodePath.normalize(dst)), names), dst);
},
async read(src: CopySource): Promise<string> {
return await fs.readFile(srcPath(src), 'utf8');
},
async list(dir: string): Promise<SrcFile[]> {
let result = [];
// Files only, no dotfiles: the same filtering the build-side
// glob applies to rule inputs.
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;
}
let p = pathlib.path(ent.name, {dir});
result.push({...p, path: pathlib.format(p)});
}
return result;
},
async hash(...srcs: CopySource[]): Promise<string> {
let [only] = srcs;
if (only !== undefined && srcs.length === 1) {
return shortHash(await digestOf(only));
}
let digests = (await Promise.all(srcs.map(digestOf))).sort(Buffer.compare);
let h = crypto.createHash('sha256');
for (let d of digests) {
h.update(d);
}
return shortHash(h.digest());
},
};
}