Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
17e7ac5
test(pins): pin what craftar ui serves without --allow-writes
llima Oct 24, 2026
7e301e4
refactor(core): one builder of the sync result, for craftar mcp and c…
llima Oct 24, 2026
1b44167
refactor(core): one reader of an orphan-drift body, drift show's and …
llima Oct 24, 2026
969f3b1
feat(ui): the sync and discard routes of craftar ui --allow-writes
llima Oct 24, 2026
1c97cf8
feat(ui): POST under --allow-writes, behind Origin, Sec-Fetch-Site, C…
llima Oct 24, 2026
05434eb
fix(ui): a body over 4096 bytes gets its 413 before the connection cl…
llima Oct 24, 2026
879e164
refactor(ui): one enqueue for GET and POST, WRITE_PATTERNS as the one…
llima Oct 24, 2026
458a0a0
refactor(ui): keep the read-only server's comments; one deps for ever…
llima Oct 24, 2026
b03a1d4
refactor(ui): keep the read-only route's comment
llima Oct 24, 2026
22542f5
feat(ui): the page's Sync and Discard edit under --allow-writes, conf…
llima Oct 24, 2026
d98eb5b
fix(ui): Discard edit reaches its workspace, and a write's result sur…
llima Oct 24, 2026
167fad2
feat(ui): craftar ui --allow-writes
llima Oct 24, 2026
78be833
test(mcp): an unreadable registry in allow-writes and revoke-writes; …
llima Oct 24, 2026
e4d813a
fix(ui): syncToolResult takes the statuses after the rebase onto 0.36.0
llima Oct 24, 2026
90deb40
test(ui): a discarded orphan-drift reads hand-edited orphan: discarded
llima Oct 24, 2026
8506200
docs(readme): craftar ui --allow-writes
llima Oct 24, 2026
e23f192
chore: update version
llima Oct 24, 2026
ac1f06b
fix(ui): the page names the example file through EXAMPLE_SETTINGS
llima Oct 24, 2026
ff6dfe0
fix(ui): a write refuses a remote Forge as the workspace's configurat…
llima Oct 24, 2026
6e7020f
refactor(ui): the digest is read where the workspace is
llima Oct 24, 2026
0a88b1f
fix(ui): no Discard edit where the route always refuses
llima Oct 24, 2026
0b35dcd
fix(ui): diff route names agents-md.primary orphans
llima Oct 24, 2026
b18c93c
docs(readme): to 0.37.0 names the one answer that changes without the…
llima Oct 24, 2026
2f2b15c
refactor(ui): no primaryOrphan test on an orphan-drift row
llima Oct 24, 2026
1fa7007
Revert "fix(ui): diff route names agents-md.primary orphans"
llima Oct 24, 2026
e3b829b
test(ui): the page's diff route keeps its words for an agents-md.prim…
llima Oct 24, 2026
ab6a986
refactor(core): one spelling of a discarded orphan's reason
llima Oct 24, 2026
3306a13
refactor(cli): drop the readText import drift show no longer uses
llima Oct 24, 2026
c4e27a3
test(pins): norm() matches a Windows path as JSON holds it
llima Oct 24, 2026
c886529
fix(ui): drain an oversized body before closing, so the 413 reaches t…
llima Oct 24, 2026
863857a
test(ui): the 413 is answered after the client ends the body
llima Oct 24, 2026
1653ae9
refactor(ui): the drain cap is checked on every discarded chunk
llima Oct 24, 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
12 changes: 9 additions & 3 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.36.0",
"version": "0.37.0",
"description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.",
"license": "MIT",
"type": "module",
Expand Down
31 changes: 19 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { canonicalValue } from "./core/sections.js";
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
import { preferredLine, renderImportReport } from "./core/import-report.js";
import { driftList, unmanaged, type DriftRow } from "./core/drift.js";
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, forgeLine, nextSyncLine, notProduced, readText, statusPath, statusReport, targetsReport } from "./core/report.js";
import { EXAMPLE_CONTENT_HIDDEN, diffReport, explainFile, explainSkip, forgeLine, nextSyncLine, notProduced, orphanDriftBody, statusPath, statusReport, targetsReport } from "./core/report.js";
import { diffIngredients, listVariants, profileOf, type Distance, type IngredientDiff } from "./core/variants.js";
import { exists, gitDirty, gitIgnored, gitIsRepo, gitUnheld, loadForge, realpathOfNearest, cannotResolve, type UnheldPath } from "./core/forge.js";
import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./core/promote.js";
Expand Down Expand Up @@ -55,7 +55,7 @@ import { findGit, cloneRepos, listRepos, planClone, type CloneResult, type Desti
import { byHand, profileRepos, type RepoEntry } from "./core/repos.js";
import { readlineIo } from "./prompt.js";
import { recipeDiffLine, type RecipeOp } from "./core/recipe-edit.js";
import { syncWorkspace, editRecipes, confirmWrites } from "./core/write-actions.js";
import { syncWorkspace, editRecipes, confirmWrites, DISCARDED_ORPHAN } from "./core/write-actions.js";
import { localKeys, readLocalFile } from "./core/workspace-yaml.js";
import { HUNK_CLASSES, INGREDIENT_TYPES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, type IngredientRef, type IngredientType, type Take, type UnifyPlan } from "./schema/index.js";
import { EXAMPLE_SETTINGS } from "./emitters/shared.js";
Expand All @@ -76,7 +76,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.36.0");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.37.0");

/* ---------------------------------------------------------------- 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 Expand Up @@ -232,7 +232,7 @@ async function applyAndReport(ws: Workspace, p: Plan, st: FileStatus[], lock: Lo
if (o.forgeLine) console.log(o.forgeLine);
console.log(` ${verb} ${pc.green(String(o.result.written.length))}, removed ${pc.magenta(String(o.result.removed.length))} orphan(s), skipped ${pc.yellow(String(o.result.skipped.length))}`);
for (const f of o.result.written) console.log(` ${pc.green("+")} ${f}`);
for (const f of o.result.removed) console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? "hand-edited orphan: discarded" : orphanReason(p, st, f)})`);
for (const f of o.result.removed) console.log(` ${pc.magenta("-")} ${f} (${opts.overwritePaths?.has(f) ? DISCARDED_ORPHAN : orphanReason(p, st, f)})`);
for (const s of o.result.skipped) console.log(` ${pc.yellow("!")} ${s.path} ${explainSkip(s)}`);
for (const w of p.warnings) console.log(` ${pc.yellow("warn")} ${w}`);
if (o.registry) console.log(` ${pc.yellow("warn")} registry not updated (${o.registry.file}): ${o.registry.message}`);
Expand Down Expand Up @@ -913,11 +913,11 @@ drift
// The whole file as a removal: what `drift discard` would do to it.
console.log(pc.bold(`--- ${named.path} (disk, orphan-drift)`));
console.log(pc.bold(`+++ ${named.path} (forge: no longer produced — \`craftar drift discard\` removes it)`));
// Spec 27 Ruling 4: the on-disk content of the example file is never printed.
if (named.path === EXAMPLE_SETTINGS) {
console.log(` ${EXAMPLE_CONTENT_HIDDEN}`);
const { body, note } = await orphanDriftBody(ws.root, named);
if (note) {
console.log(` ${note}`);
} else {
console.log(renderDiff((await readText(path.join(ws.root, named.path))) ?? "", "", DIFF_PAINT));
console.log(renderDiff(body ?? "", "", DIFF_PAINT));
}
} else await printFileDiff(ws.root, named);
if (refusalBlock(unset)) process.exitCode = 1;
Expand Down Expand Up @@ -1275,10 +1275,11 @@ workspaces
program
.command("ui")
.description(
"A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only — writes no workspace, lock, registry or Forge — and reaches no network; Ctrl-C stops it",
"A local dashboard: the table of `craftar workspaces` and, for one workspace, its file states, a file's diff and explanation, and its Forge's recipes and ingredients — in a browser, on 127.0.0.1 only, behind an address that works for this run. Reads only unless --allow-writes — then a sync or the discard of one hand-edited file, confirmed in the page, never --overwrite-drift — and reaches no network; Ctrl-C stops it",
)
.option("--port <n>", "the port to listen on, 0 to 65535; 0 takes a free one", "4747")
.action(async (o: { port: string }) => {
.option("--allow-writes", "serve Sync and Discard edit on the page — a sync of one workspace, or the discard of one hand-edited file, each confirmed in the page; workspaces with a path Forge only")
.action(async (o: { port: string; allowWrites?: boolean }) => {
// Every refusal comes before the bind (spec 36 §4.2).
const port = parsePort(o.port);
if (port === null) fail("--port takes a whole number from 0 to 65535");
Expand All @@ -1290,17 +1291,23 @@ program
fail(errorText(e));
}
const version = program.version() ?? "unknown";
const allowWrites = o.allowWrites === true;
let ui: UiServer;
try {
// The one line a failing request leaves in the terminal; nothing is printed per request otherwise.
ui = await startUi({ home: craftarHome(), version, port, onError: (line) => console.error(line) });
ui = await startUi({ home: craftarHome(), version, port, allowWrites, onError: (line) => console.error(line) });
} catch (e) {
// The code only, never the message.
const code = (e as NodeJS.ErrnoException).code;
if (code === "EADDRINUSE") fail(`port ${port} is in use on 127.0.0.1 — pass --port <other>, or --port 0 for a free one`);
fail(`cannot listen on 127.0.0.1:${port}: ${code ?? "unknown"}`);
}
console.log(pc.bold(`craftar ui — craftar ${version} · read-only · ${reg.workspaces.length} registered`));
if (allowWrites) {
console.log(pc.bold(`craftar ui — craftar ${version} · writes allowed · ${reg.workspaces.length} registered`));
console.log(" writes allowed: sync, discard one hand-edited file");
} else {
console.log(pc.bold(`craftar ui — craftar ${version} · read-only · ${reg.workspaces.length} registered`));
}
console.log(` open ${ui.url}`);
console.log(" the address works for this run only, on this machine — Ctrl-C stops it");
// The first signal closes the listener and lets the process end; a second one, while closing, ends it at once.
Expand Down
11 changes: 11 additions & 0 deletions src/core/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ export async function diffReport(ws: Pick<Workspace, "root">, rows: FileStatus[]
return out;
}

/**
* What an `orphan-drift` file holds, for a surface that shows it before a discard (spec 40 §6.5, `drift show <path>`):
* the whole body read from disk, or — for EXAMPLE_SETTINGS, whose content is never shown — `{ body: null, note:
* EXAMPLE_CONTENT_HIDDEN }`. The one reader of such a body.
*/
export async function orphanDriftBody(root: string, s: Pick<FileStatus, "path" | "state">): Promise<{ body: string | null; note: string | null }> {
if (s.state !== "orphan-drift") throw new Error("state must be orphan-drift");
if (s.path === EXAMPLE_SETTINGS) return { body: null, note: EXAMPLE_CONTENT_HIDDEN };
return { body: (await readText(path.join(root, s.path))) ?? "", note: null };
}

/** What `craftar explain` says about one generated file, as data (spec 35 §4.5). */
export interface ExplainedFile {
path: string;
Expand Down
40 changes: 29 additions & 11 deletions src/core/write-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import { localKeys } from "./workspace-yaml.js";
import type { Lock } from "../schema/index.js";
import type { Pin } from "./mcp-protocol.js";

/** The words for the orphan-drift a discard removes (`drift discard` and the page's). */
export const DISCARDED_ORPHAN = "hand-edited orphan: discarded";

/* ------------------------------------------------------------------ */
/* syncWorkspace */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -206,6 +209,31 @@ export interface SyncToolResult {
registry: "updated" | "not updated" | "off";
}

/** The `sync` result (spec 40 §5.6) — one builder, shared by `craftar mcp`'s `sync` tool and `craftar ui`'s write routes. */
export function syncToolResult(
ws: Workspace,
p: Plan,
statuses: readonly FileStatus[],
outcome: SyncOutcome,
registryOff: boolean,
discarded?: string,
): SyncToolResult {
return {
profile: ws.config.profile,
recipes: [...p.resolution.recipes],
targets: [...p.resolution.targets],
forge: outcome.forgeLine,
written: outcome.result.written,
removed: outcome.result.removed.map((path) => ({
path,
reason: path === discarded ? DISCARDED_ORPHAN : orphanReason(p, statuses, path),
})),
skipped: outcome.result.skipped.map((s) => ({ path: s.path, state: s.state, reason: explainSkip(s) })),
warnings: p.warnings,
registry: registryOff ? "off" : outcome.registry ? "not updated" : "updated",
};
}

/**
* The `add_recipe` / `remove_recipe` tool result (spec 40 §5.6).
*/
Expand Down Expand Up @@ -296,17 +324,7 @@ export function mcpWriters(home: string, registryOff: boolean): McpWriters {

const outcome = await syncWorkspace(ws, p, statuses, lock, { home, registryOff });

return {
profile: ws.config.profile,
recipes: [...p.resolution.recipes],
targets: [...p.resolution.targets],
forge: outcome.forgeLine,
written: outcome.result.written,
removed: outcome.result.removed.map((path) => ({ path, reason: orphanReason(p, statuses, path) })),
skipped: outcome.result.skipped.map((s) => ({ path: s.path, state: s.state, reason: explainSkip(s) })),
warnings: p.warnings,
registry: registryOff ? "off" : outcome.registry ? "not updated" : "updated",
};
return syncToolResult(ws, p, statuses, outcome, registryOff);
},

async editRecipes(
Expand Down
Loading
Loading