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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.25] - 2026-08-15

### Fixed

- Reject stale concurrent `askr add` transactions before they can silently lose a shared route, action registry, authorization, environment, or package-manifest edit.

## [0.0.24] - 2026-08-15

### Added
Expand Down Expand Up @@ -80,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Make database tooling work consistently across supported operating systems.

[Unreleased]: https://github.com/askrjs/askr-cli/compare/v0.0.24...HEAD
[Unreleased]: https://github.com/askrjs/askr-cli/compare/v0.0.25...HEAD
[0.0.25]: https://github.com/askrjs/askr-cli/compare/v0.0.24...v0.0.25
[0.0.24]: https://github.com/askrjs/askr-cli/compare/v0.0.23...v0.0.24
[0.0.23]: https://github.com/askrjs/askr-cli/compare/v0.0.22...v0.0.23
[0.0.22]: https://github.com/askrjs/askr-cli/compare/v0.0.21...v0.0.22
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@askrjs/cli",
"version": "0.0.24",
"version": "0.0.25",
"description": "Unified CLI for the Askr platform",
"homepage": "https://github.com/askrjs/askr-cli#readme",
"bugs": {
Expand Down
45 changes: 35 additions & 10 deletions src/bin/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,10 @@ async function addPage(parsed: ParsedArgs, io: CliIo, writeChanges: WriteChanges
}

const importSpecifier = toImportSpecifier(routesFile, pageFile);
let routeFileContent = "";
let updatedRoutes = "";
try {
const routeFileContent = await fs.readFile(routesFile, "utf8");
routeFileContent = await fs.readFile(routesFile, "utf8");
updatedRoutes = createUpdatedRouteFile(routeFileContent, {
componentName,
importSpecifier,
Expand All @@ -522,7 +523,7 @@ async function addPage(parsed: ParsedArgs, io: CliIo, writeChanges: WriteChanges
title,
}),
},
{ filePath: routesFile, content: updatedRoutes },
{ filePath: routesFile, content: updatedRoutes, expectedContent: routeFileContent },
]);
} catch (error) {
io.error("Failed to write generated page artifacts.");
Expand Down Expand Up @@ -582,6 +583,10 @@ async function addAction(
routePath: parsed.routePath,
slug,
});
const [registryContent, authorizationContent] = await Promise.all([
fs.readFile(registryFile, "utf8"),
fs.readFile(authorizationFile, "utf8"),
]);
const actions = await discoverDeclaredActions(projectRoot, {
filePath: descriptorFile,
content: descriptor,
Expand All @@ -593,8 +598,16 @@ async function addAction(
content: renderActionHandler({ descriptorName, handlerName, slug }),
},
{ filePath: testFile, content: renderActionTest({ descriptorName, slug }) },
{ filePath: registryFile, content: renderServerActionRegistry(actions) },
{ filePath: authorizationFile, content: renderAuthorizationRegistry(actions) },
{
filePath: registryFile,
content: renderServerActionRegistry(actions),
expectedContent: registryContent,
},
{
filePath: authorizationFile,
content: renderAuthorizationRegistry(actions),
expectedContent: authorizationContent,
},
]);
} catch (error) {
io.error("Failed to write generated action artifacts.");
Expand Down Expand Up @@ -633,7 +646,8 @@ async function addDatabase(
return 1;
}
try {
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as {
const manifestContent = await fs.readFile(manifestFile, "utf8");
const manifest = JSON.parse(manifestContent) as {
dependencies?: Record<string, string>;
};
manifest.dependencies = {
Expand All @@ -644,7 +658,10 @@ async function addDatabase(
manifest.dependencies = Object.fromEntries(
Object.entries(manifest.dependencies).sort(([left], [right]) => left.localeCompare(right)),
);
const currentEnvironment = await fs.readFile(environmentFile, "utf8").catch(() => "");
const currentEnvironment = await fs.readFile(environmentFile, "utf8").catch((error) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
});
const environmentLines =
parsed.name === "postgres"
? [
Expand All @@ -653,9 +670,9 @@ async function addDatabase(
]
: ["DATABASE_PATH=./data/app.sqlite"];
const additions = environmentLines.filter(
(line) => !currentEnvironment.includes(`${line.split("=")[0]}=`),
(line) => !currentEnvironment?.includes(`${line.split("=")[0]}=`),
);
const environment = `${currentEnvironment}${currentEnvironment && !currentEnvironment.endsWith("\n") ? "\n" : ""}${additions.join("\n")}${additions.length ? "\n" : ""}`;
const environment = `${currentEnvironment ?? ""}${currentEnvironment && !currentEnvironment.endsWith("\n") ? "\n" : ""}${additions.join("\n")}${additions.length ? "\n" : ""}`;
const driverImport = parsed.name;
const definition = [
"import { defineDatabase } from '@askrjs/orm';",
Expand Down Expand Up @@ -684,8 +701,16 @@ async function addDatabase(
{ filePath: definitionFile, content: definition },
{ filePath: generatedFile, content: generated },
{ filePath: migrationKeep, content: "" },
{ filePath: environmentFile, content: environment },
{ filePath: manifestFile, content: `${JSON.stringify(manifest, null, 2)}\n` },
{
filePath: environmentFile,
content: environment,
expectedContent: currentEnvironment,
},
{
filePath: manifestFile,
content: `${JSON.stringify(manifest, null, 2)}\n`,
expectedContent: manifestContent,
},
]);
} catch (error) {
io.error("Failed to write generated database artifacts.");
Expand Down
116 changes: 116 additions & 0 deletions src/file-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { randomUUID } from "node:crypto";
export interface FileChange {
readonly filePath: string;
readonly content: string;
/** Content observed while planning a shared-file edit; `null` means the file was absent. */
readonly expectedContent?: string | null;
}

interface StagedChange extends FileChange {
Expand All @@ -17,6 +19,98 @@ export interface FileChangeWriterOptions {
readonly replace?: (temporaryPath: string, filePath: string) => Promise<void>;
}

interface FileLock {
readonly lockPath: string;
}

const LOCK_RETRY_MS = 10;
const LOCK_TIMEOUT_MS = 10_000;
const ORPHANED_LOCK_AGE_MS = 30_000;

function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}

function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

async function ownerIsAlive(lockPath: string): Promise<boolean | undefined> {
try {
const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8")) as {
pid?: unknown;
};
if (!Number.isInteger(owner.pid) || (owner.pid as number) <= 0) return undefined;
try {
process.kill(owner.pid as number, 0);
return true;
} catch (error) {
if (isNodeError(error, "ESRCH")) return false;
return true;
}
} catch {
return undefined;
}
}

async function removeOrphanedLock(lockPath: string): Promise<boolean> {
const ownerAlive = await ownerIsAlive(lockPath);
if (ownerAlive === true) return false;
if (ownerAlive === undefined) {
const stat = await fs.stat(lockPath).catch(() => null);
if (!stat || Date.now() - stat.mtimeMs < ORPHANED_LOCK_AGE_MS) return false;
}
await fs.rm(lockPath, { recursive: true, force: true });
return true;
}

async function acquireFileLock(filePath: string): Promise<FileLock> {
const lockPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.askr-lock`);
const deadline = Date.now() + LOCK_TIMEOUT_MS;
while (true) {
try {
await fs.mkdir(lockPath);
await fs.writeFile(
path.join(lockPath, "owner.json"),
`${JSON.stringify({ pid: process.pid })}\n`,
{ flag: "wx" },
);
return { lockPath };
} catch (error) {
if (!isNodeError(error, "EEXIST")) {
await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
if (await removeOrphanedLock(lockPath)) continue;
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for file transaction lock: ${filePath}`);
}
await delay(LOCK_RETRY_MS);
}
}
}

async function releaseFileLocks(locks: readonly FileLock[]): Promise<void> {
await Promise.all(
[...locks].reverse().map((lock) => fs.rm(lock.lockPath, { recursive: true, force: true })),
);
}

async function readCurrentContent(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if (isNodeError(error, "ENOENT")) return null;
throw error;
}
}

function hasExpectedContent(
change: FileChange,
): change is FileChange & { readonly expectedContent: string | null } {
return Object.prototype.hasOwnProperty.call(change, "expectedContent");
}

async function remove(paths: readonly string[]): Promise<void> {
await Promise.all(
paths.map((filePath) => fs.rm(filePath, { force: true }).catch(() => undefined)),
Expand Down Expand Up @@ -53,6 +147,28 @@ export async function writeFileChanges(
throw new Error("File changes contain duplicate target paths.");
}
const replace = options.replace ?? fs.rename;
const guarded = ordered.filter(hasExpectedContent);
const locks: FileLock[] = [];
try {
for (const change of guarded) {
await fs.mkdir(path.dirname(change.filePath), { recursive: true });
locks.push(await acquireFileLock(change.filePath));
}
for (const change of guarded) {
if ((await readCurrentContent(change.filePath)) !== change.expectedContent) {
throw new Error(`File changed before writing: ${change.filePath}`);
}
}
await writeStagedChanges(ordered, replace);
} finally {
await releaseFileLocks(locks);
}
}

async function writeStagedChanges(
ordered: readonly FileChange[],
replace: (temporaryPath: string, filePath: string) => Promise<void>,
): Promise<void> {
const staged: StagedChange[] = [];
try {
for (const change of ordered) {
Expand Down
Loading