Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
47851f9
fix(remote): credentialFault reads a scheme with one slash, and drops…
llima Oct 18, 2026
3741a8c
fix(remote): urlCredentialIn reads a scheme and its :// through tab /…
llima Oct 18, 2026
bf1c849
fix(init): the interactive init does not offer a recorded path whose …
llima Oct 18, 2026
53183f8
fix(cache): a cache key no clean registry row vouches for is printed …
llima Oct 18, 2026
81d0ea6
test(cli): add CLI tests for single-slash and tab/LF/CR credential sp…
llima Oct 18, 2026
17ea8d4
test(credentials): the interactive init, craftar ui and cache prune's…
llima Oct 18, 2026
20d3524
test(cli): cache prune prints an orphan's key as the unvouched label …
llima Oct 18, 2026
03d861c
docs(readme): to 0.32.1 — the unvouched cache label, the spellings re…
llima Oct 18, 2026
a65788a
fix(cache): the no-snapshot label never re-inserts the key — a home h…
llima Oct 18, 2026
0a04a64
fix(cache): a key unvouched at the snapshot stays labelled for the wh…
llima Oct 18, 2026
78ccc11
test(credentials): the unvouched label through cache prune's text, an…
llima Oct 18, 2026
b471151
docs(readme): to 0.32.1 names workspaces --json and craftar ui, and t…
llima Oct 18, 2026
8aaa6f1
fix(cache): every key is labelled in one pass — a prefix key no longe…
llima Oct 18, 2026
bbe29cf
fix(cache): overlapping keys are labelled as one range — a key that s…
llima Oct 18, 2026
6f29d36
docs(cache): comments name the interval rule, the -<12 hex> end and t…
llima Oct 18, 2026
8023ad7
chore: update version
llima Oct 18, 2026
4ed1618
test(cache): the no-snapshot separator test names the home as path.re…
llima Oct 18, 2026
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
11 changes: 9 additions & 2 deletions README.md

Large diffs are not rendered by default.

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": "craftar",
"version": "0.32.0",
"version": "0.32.1",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE
const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync";

const program = new Command();
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.32.0");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.32.1");

/* ---------------------------------------------------------------- import */
/** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */
Expand Down
62 changes: 56 additions & 6 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { LockBusyError, type LockTiming } from "./home-lock.js";
import { credentialKeys, hideKeys, namedCacheKeys, readRegistry } from "./registry.js";
import { credentialKeys, hideCacheKeys, hideCacheSegment, hideKeys, namedCacheKeys, readRegistry, vouchedKeys } from "./registry.js";
import type { Registry } from "../schema/index.js";
import {
cacheDir,
Expand All @@ -11,6 +11,7 @@ import {
pruneTrees,
removeEntry,
removeLeftover,
REMOVING_PREFIX,
withPruneLock,
type CacheSnapshot,
type GitRunner,
Expand Down Expand Up @@ -72,6 +73,8 @@ export interface PruneOptions extends LockTiming {
rename?: (from: string, to: string) => Promise<void>;
/** Test hook: awaited after each `refresh()` of the prune lock, before the step it precedes. */
beforeStep?: () => Promise<void>;
/** Test hook: a replacement for the real `inspectCache` (spec 39 §5.1). */
inspectCache?: typeof inspectCache;
}

export async function pruneCache(home: string, opts: PruneOptions): Promise<PruneResult> {
Expand All @@ -91,10 +94,19 @@ export async function pruneCache(home: string, opts: PruneOptions): Promise<Prun
// The keys the registry records for a URL with credentials: an entry's name is its key, and such a
// key holds them in clear. Filled once the registry is read; nothing this run says names one.
const hide: string[] = [];
// The unvouched keys: filled after the snapshot is built. A failure before the snapshot
// uses `hideCacheSegment`; a failure after it uses `hideCacheKeys(text, hide, unvouched)` (spec 39 §5.1).
let snapshotBuilt = false;
const unvouched: string[] = [];

// The actual prune work: under the prune lock for a run, no lock for dry-run.
// Everything after the "no forges/" check runs under the prune lock (§4.2 step 0).
const doPrune = async (lock: { refresh: () => Promise<void>; bytes: number }) => {
// Reset the state in case of retry (spec 39 §5.1: an entry unvouched at the snapshot stays labelled for the whole run).
snapshotBuilt = false;
hide.length = 0;
unvouched.length = 0;

const start = Date.now();

// Step 2: Determine naming info (§4.2 step 2) — under the lock so a waiting prune sees the state
Expand Down Expand Up @@ -132,8 +144,9 @@ export async function pruneCache(home: string, opts: PruneOptions): Promise<Prun

// Step 3: Snapshot the cache — under the lock.
let snapshot: CacheSnapshot;
const doInspect = opts.inspectCache ?? inspectCache;
try {
snapshot = await inspectCache(home);
snapshot = await doInspect(home);
} catch (e) {
throw new Error(`cannot read ${forgesDir}: ${(e as Error).message}`);
}
Expand All @@ -142,6 +155,25 @@ export async function pruneCache(home: string, opts: PruneOptions): Promise<Prun
const headerBytes = snapshot.bytes - lock.bytes;
const header = { forges: forgesDir, entries: snapshot.entries.length, bytes: headerBytes };

// The unvouched list: every key in the snapshot that is not vouched (spec 39 §5.1).
// Built now, so `guarded` knows whether the failure came before or after the snapshot.
const vouched = vouchedKeys(named, reg);
unvouched.length = 0; // Clear in case of retry
for (const e of snapshot.entries) if (!vouched.has(e.key)) unvouched.push(e.key);
// Keys inside ~removing- directories: strip the prefix and the trailing `-<digits>`.
for (const r of snapshot.removing) {
const name = path.basename(r.dir);
if (name.startsWith(REMOVING_PREFIX)) {
const rest = name.slice(REMOVING_PREFIX.length);
const lastDash = rest.lastIndexOf("-");
if (lastDash !== -1) {
const maybeKey = rest.slice(0, lastDash);
if (!vouched.has(maybeKey) && !hide.includes(maybeKey)) unvouched.push(maybeKey);
}
}
}
snapshotBuilt = true; // Mark that the snapshot was built for guarded's hide logic.

const removed: PruneRemoved[] = [];
const kept: PruneKept[] = [];
const warnings: string[] = [];
Expand Down Expand Up @@ -303,10 +335,14 @@ export async function pruneCache(home: string, opts: PruneOptions): Promise<Prun
const freedBytes = removed.reduce((sum, r) => sum + r.bytes, 0);

// The report names such an entry by the fixed label, in every path and warning.
const shown = <T extends { path: string }>(rows: T[]): T[] => (hide.length === 0 ? rows : rows.map((r) => ({ ...r, path: hideKeys(r.path, hide) })));
// hideCacheKeys labels both credential and unvouched keys by merged ranges of the original text (spec 39 review rounds 2-3).
// An entry unvouched at the snapshot stays labelled for the whole run (fail closed).
const hidePath = (text: string): string => hideCacheKeys(text, hide, unvouched);
const shownRemoved = removed.map((r) => ({ ...r, path: hidePath(r.path) }));
const shownKept = kept.map((r) => ({ ...r, path: hidePath(r.path) }));

return {
report: { home, dryRun: opts.dryRun, removed: shown(removed), kept: shown(kept), freedBytes, warnings: warnings.map((w) => hideKeys(w, hide)) },
report: { home, dryRun: opts.dryRun, removed: shownRemoved, kept: shownKept, freedBytes, warnings: warnings.map(hidePath) },
header,
};
};
Expand All @@ -317,9 +353,23 @@ export async function pruneCache(home: string, opts: PruneOptions): Promise<Prun
try {
return await doPrune(lock);
} catch (e) {
if (!(e instanceof Error) || hideKeys(e.message, hide) === e.message) throw e;
if (!(e instanceof Error)) throw e;
// Before the snapshot: use hideCacheSegment then hideKeys (spec 39 §5.1).
// Two passes are safe: hideCacheSegment replaces the whole segment after `forges/`, and a
// credential key is a cacheKey — only [a-z0-9._-], ending in `-<12 hex>` — while the inserted
// label holds no such run and starts with `(` and holds spaces, so hideKeys can match neither
// inside nor across it.
// After the snapshot: use hideCacheKeys, one call over the original text (spec 39 review rounds 2-3).
let msg = e.message;
if (!snapshotBuilt) {
msg = hideCacheSegment(msg, home);
msg = hideKeys(msg, hide);
} else {
msg = hideCacheKeys(msg, hide, unvouched);
}
if (msg === e.message) throw e;
// A new error on purpose — no `cause`, no copied `code`: the original carries the name.
throw new Error(hideKeys(e.message, hide));
throw new Error(msg);
}
};

Expand Down
32 changes: 24 additions & 8 deletions src/core/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { ENV_NAME, type Lock, type Registry } from "../schema/index.js";
import { written } from "./capabilities.js";
import { exists } from "./forge.js";
import { renderServer, rendersServers } from "./mcp-server.js";
import { credentialKeys, hideKeys, isMissing, isRegistered, namedCacheKeys, readRegistry, registryFile, rowStatus } from "./registry.js";
import { FULL_SHA, cacheKey, classifyForge, inspectCache, type CacheSnapshot } from "./remote.js";
import { credentialKeys, hideCacheSegment, hideKeys, isMissing, isRegistered, namedCacheKeys, readRegistry, registryFile, rowStatus, vouchedKeys } from "./registry.js";
import { FULL_SHA, cacheKey, classifyForge, CREDENTIAL_HIDDEN, inspectCache, UNVOUCHED_HIDDEN, type CacheSnapshot } from "./remote.js";
import { dockerVersion, dockerFinding, dockerRunner as defaultDockerRunner, fragmentPaths, type DockerRunner } from "./services.js";
import { paramsFor } from "./resolve.js";
import { SchemaError, errorText } from "./schema-fault.js";
Expand Down Expand Up @@ -64,6 +64,8 @@ export interface DoctorOptions {
dotnetVersion?: () => Promise<string | null>;
/** Test seam: injected runner for `docker compose version --short` (spec 31 §4.7). */
dockerRunner?: DockerRunner;
/** Test seam: a replacement for the real `inspectCache` (spec 39 §5.1). */
inspectCache?: typeof inspectCache;
}

async function defaultGitVersion(): Promise<string | null> {
Expand Down Expand Up @@ -95,8 +97,9 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
// which `git` and `cache` need even when the Forge does not load (§5.1).
let snapshot: CacheSnapshot | null = null;
let snapshotError: string | null = null;
const doInspect = opts.inspectCache ?? inspectCache;
try {
snapshot = await inspectCache(opts.home);
snapshot = await doInspect(opts.home);
} catch (e) {
snapshotError = oneLine(e);
}
Expand Down Expand Up @@ -161,22 +164,35 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
// One cause, one line (§13 item 12): a home that is not a directory is `home`'s error.
m("cache", "ok", "not checked ($CRAFTAR_HOME is not a directory)");
} else if (snapshot === null) {
m("cache", "warn", `cannot read the Forge cache: ${hideKeys(snapshotError ?? "", reg === null ? [] : credentialKeys(reg))}`, `fix or move $CRAFTAR_HOME (${home})`);
// No snapshot: use hideCacheSegment, then hideKeys (spec 39 §5.1).
// Two passes are safe: hideCacheSegment replaces the whole segment after `forges/`, and a
// credential key is a cacheKey — only [a-z0-9._-], ending in `-<12 hex>` — while the inserted
// label holds no such run and starts with `(` and holds spaces, so hideKeys can match neither
// inside nor across it.
const msg = hideCacheSegment(snapshotError ?? "", opts.home);
m("cache", "warn", `cannot read the Forge cache: ${hideKeys(msg, reg === null ? [] : credentialKeys(reg))}`, `fix or move $CRAFTAR_HOME (${home})`);
} else {
const summary = `${snapshot.entries.length} entr${snapshot.entries.length === 1 ? "y" : "ies"}, ${MB(snapshot.bytes)}`;
let skip: string | null = registryWhyNot;
if (skip === null && root !== null && merged === null) skip = "the checked craftar.yaml does not read";
const findings: Check[] = [];
// A key the registry records for a URL with credentials is never printed (the name holds them).
const hide = reg === null ? [] : credentialKeys(reg);
// Compute the named map before both loops so both can use it (spec 39 §5.1).
const checked = skip === null && reg !== null && remote && root !== null ? { key: cacheKey(merged!.config.forge.trim()), path: root } : null;
const named = skip === null && reg !== null ? namedCacheKeys(reg, checked) : new Map<string, string[]>();
const vouched = vouchedKeys(named, reg);
// `a fetch never completed`: show key as-is only when vouched, as CREDENTIAL_HIDDEN when credentialKeys holds it, else UNVOUCHED_HIDDEN.
for (const e of snapshot.entries) {
if (!e.fetched) findings.push({ id: "cache", scope: "machine", level: "warn", message: `${hideKeys(e.key, hide)} — a fetch never completed`, fix: CACHE_FIX });
if (!e.fetched) {
const label = vouched.has(e.key) ? e.key : hide.includes(e.key) ? CREDENTIAL_HIDDEN : UNVOUCHED_HIDDEN;
findings.push({ id: "cache", scope: "machine", level: "warn", message: `${label} — a fetch never completed`, fix: CACHE_FIX });
}
}
// `nothing names it`: always UNVOUCHED_HIDDEN (such a key is never vouched).
if (skip === null && reg !== null) {
const checked = remote && root !== null ? { key: cacheKey(merged!.config.forge.trim()), path: root } : null;
const named = namedCacheKeys(reg, checked);
for (const e of snapshot.entries)
if (e.fetched && !named.has(e.key)) findings.push({ id: "cache", scope: "machine", level: "warn", message: `${e.key} — nothing names it`, fix: CACHE_FIX });
if (e.fetched && !named.has(e.key)) findings.push({ id: "cache", scope: "machine", level: "warn", message: `${UNVOUCHED_HIDDEN} — nothing names it`, fix: CACHE_FIX });
}
// A skipped orphan part is said on every cache line, findings included (§4.3, §13 item 6).
const why = skip === null ? "" : ` (orphans not checked: ${skip})`;
Expand Down
3 changes: 3 additions & 0 deletions src/core/init-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ function offerableForge(
exists: (dir: string) => boolean,
): OfferedForge | null {
if (entry.forge.kind === "path") {
// Spec 39 §7, §14 item 8: a gap-2 spelling that once loaded as a path is recorded with a real
// path that holds the credential.
if (credentialFault(entry.forge.source)) return null;
if (entry.forge.key === null) return null;
if (!exists(entry.forge.key)) return null;
return { forge: entry.forge.key, ref: null };
Expand Down
Loading
Loading