Registers the TECH_LOG_STUDIO_SESSION SAME_ORIGIN_COOKIE auth profile and declares the tech-log-studio-http-v1 contribution covering all 18 JSON Studio operations (everything except the multipart uploadStudioAsset), built from two shared builders (safeOperation/keyedOperation) so every KEYED command gets IDEMPOTENCY_REPLAY recovery and a zero retry budget, and every SAFE read gets a plain retry budget, without repeating the declaration shape 18 times. Rescopes the canonical package identity from "tech-log-studio-contract" to "@tech-log/studio-contract" (generator, canonical-source.json, contract-generation.test.ts) because the platform's contribution composer requires an npm-scoped packageId; the unscoped form failed composition. Updates the release-manifest test fixture, which hardcoded an empty expected contract set, to derive its expected packages from the real installed set now that TechLog is always installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
/**
|
|
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
|
|
*
|
|
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
|
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
|
* 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx`
|
|
* 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다.
|
|
*
|
|
* `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이
|
|
* 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만
|
|
* 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { argv, env, exit } from "node:process";
|
|
|
|
const CANONICAL_ROOT =
|
|
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
|
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
|
|
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
|
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
|
|
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
|
|
|
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
|
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
|
|
|
const check = argv.includes("--check");
|
|
|
|
function digestOf(bytes: Buffer | string): string {
|
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
}
|
|
|
|
function operationIdsOf(yaml: string): string[] {
|
|
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
|
|
}
|
|
|
|
function specVersionOf(yaml: string): string {
|
|
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
|
|
if (!match) throw new Error("canonical yaml has no info.version");
|
|
return match[1]!;
|
|
}
|
|
|
|
type CanonicalRecord = Readonly<{
|
|
packageId: string;
|
|
version: string;
|
|
digest: string;
|
|
sourceRevision: string;
|
|
operationIds: readonly string[];
|
|
}>;
|
|
|
|
function fail(problems: readonly string[]): never {
|
|
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
|
|
console.error("Run: corepack pnpm generate:tech-log-contract");
|
|
exit(1);
|
|
}
|
|
|
|
if (check) {
|
|
const vendored = readFileSync(VENDOR_YAML, "utf8");
|
|
const generated = readFileSync(GENERATED, "utf8");
|
|
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
|
|
const problems: string[] = [];
|
|
|
|
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
|
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
|
}
|
|
const vendoredOperations = operationIdsOf(vendored);
|
|
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
|
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
|
}
|
|
if (specVersionOf(vendored) !== record.version) {
|
|
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
|
|
}
|
|
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
|
for (const operationId of record.operationIds) {
|
|
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
|
problems.push(`${GENERATED} is missing operation ${operationId}`);
|
|
}
|
|
}
|
|
if (problems.length > 0) fail(problems);
|
|
console.log(
|
|
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
|
);
|
|
exit(0);
|
|
}
|
|
|
|
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
|
const canonicalText = canonicalBytes.toString("utf8");
|
|
|
|
const record: CanonicalRecord = {
|
|
packageId: "@tech-log/studio-contract",
|
|
version: specVersionOf(canonicalText),
|
|
digest: digestOf(canonicalBytes),
|
|
sourceRevision: execFileSync(
|
|
"git",
|
|
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
|
{ encoding: "utf8" },
|
|
).trim(),
|
|
operationIds: operationIdsOf(canonicalText),
|
|
};
|
|
|
|
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
|
const generated = execFileSync(
|
|
"corepack",
|
|
[
|
|
"pnpm",
|
|
"dlx",
|
|
"--package",
|
|
GENERATOR_TYPESCRIPT,
|
|
"--package",
|
|
OPENAPI_TYPESCRIPT,
|
|
"openapi-typescript",
|
|
CANONICAL_YAML,
|
|
],
|
|
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
);
|
|
|
|
writeFileSync(VENDOR_YAML, canonicalText);
|
|
writeFileSync(GENERATED, generated);
|
|
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
|
console.log(
|
|
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
|
);
|