Make CLI success output copy-paste-safe; add --serve, docs hub, key hygiene

A successful clone printed a bare, timestamped app path that wrapped mid-word
in the terminal, so users' `cd` failed and `npm install && npm run dev` ran in
the wrong directory — a confusing failure from a successful clone. Plus there
was no first-party "see it locally" path, no central docs page, "clone" was
conflated with `git clone`, and key hygiene wasn't stated.

CLI (compiler):
- Add compiler/src/cliSummary.ts: a copy-paste-safe success block on stderr — a
  single QUOTED `cd "<app>" && npm install && npm run dev` line that survives
  terminal wrapping — plus the AGENTS.md safe-to-edit pointers (content.ts,
  components/). The machine-readable {"event":"done",...} JSON stays on stdout.
- Add --serve (npm install + npm run dev in the generated app) and --open
  (also open the browser at the detected dev URL). Wired into single + multi.
- Add writeLatestPointer(): refresh a runs/<site>/latest symlink to the newest
  run so there's a stable, timestamp-free cd/script target. The reuse/regen
  scanners already filter to digit-prefixed run dirs, so `latest` is ignored.
- Tests: cliSummary (quoting, single-line command, Next/Vite roots, stable-path
  preference, --serve/--open hint) and runsLayout (symlink write + in-place
  refresh, timestamp-free stable path).

Docs:
- Add docs/README.md as a central documentation index.
- State prominently that ditto "cloning" = generating a codebase from a live
  URL, NOT `git clone` (no source repo required), in README + docs hub.
- Add "keys are secrets: use $DITTO_API_KEY, never inline/commit, rotate
  anytime" beside the key/auth examples in README + docs/SERVICE.md.
- Document --serve/--open and the latest symlink in README + compiler/README.

Note: the live ditto.site/docs web route and the dashboard's inline-token
snippet live in the marketing-site repo and still need a change there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michael Cole
2026-07-01 17:10:02 -04:00
co-authored by Claude Opus 4.8
parent 638a0516c5
commit 63df38105d
10 changed files with 364 additions and 11 deletions
+21
View File
@@ -6,6 +6,27 @@ so minor/patch semantics are not yet guaranteed.
## [Unreleased] ## [Unreleased]
### Changed — Local CLI success output and orientation
- The `clone-static` CLI now prints a copy-paste-safe success summary to stderr: a
single **quoted** `cd "<app>" && npm install && npm run dev` line (survives terminal
wrapping — no more broken `cd` from a wrapped timestamped path) plus the key
`AGENTS.md` safe-to-edit pointers (`src/app/content.ts`, `src/app/components/`). The
machine-readable `{ "event": "done", ... }` JSON line on stdout is unchanged (now
also carrying `stableApp`).
- Added `--serve` (run `npm install` + `npm run dev` in the generated app) and
`--open` (also launch the browser at the dev URL) so "see it locally" is one flag.
- In the default runs layout, each clone now refreshes a `runs/<site>/latest` symlink
pointing at the newest run, giving a stable, timestamp-free path for `cd` and scripts.
### Added — Docs hub and terminology/secret-hygiene clarifications
- Added [`docs/README.md`](docs/README.md) as a central documentation index.
- Clarified prominently that ditto.site "cloning" means generating a codebase from a
live URL, **not** `git clone` (no source repo required), in the README and docs hub.
- Added an explicit "API keys are secrets — use `$DITTO_API_KEY`, never inline/commit,
rotate anytime" note beside the key/auth examples in the README and `docs/SERVICE.md`.
### Added — `ditto` unpack CLI ### Added — `ditto` unpack CLI
- **`packages/cli`** — a zero-dependency `ditto` command-line helper. `ditto unpack - **`packages/cli`** — a zero-dependency `ditto` command-line helper. `ditto unpack
+29 -2
View File
@@ -15,8 +15,14 @@ Router project by default, or Vite React when requested.
The compiler is not an LLM page author. It is a capture-to-code pipeline: same The compiler is not an LLM page author. It is a capture-to-code pipeline: same
frozen capture in, byte-stable app out. frozen capture in, byte-stable app out.
> **"Cloning" here means generating a codebase from a live URL — not `git clone`.**
> You don't need an existing repository, and you don't need the site's source. Point
> ditto.site at a public URL and it writes you a fresh project from what the page
> renders in a browser.
Read the public development and evaluation method in Read the public development and evaluation method in
[docs/METHODOLOGY.md](docs/METHODOLOGY.md). [docs/METHODOLOGY.md](docs/METHODOLOGY.md). For a map of all the docs, see
[docs/README.md](docs/README.md).
## Usage ## Usage
@@ -35,6 +41,11 @@ curl -sS -X POST "https://api.ditto.site/v1/signup/request" \
The emailed verification link lands on `/api-key?token=...`, which calls The emailed verification link lands on `/api-key?token=...`, which calls
`POST /v1/signup/verify` and displays the new `dtto_live_...` key once. `POST /v1/signup/verify` and displays the new `dtto_live_...` key once.
> **Keys are secrets.** Put your key in an environment variable (`export
> DITTO_API_KEY=dtto_live_...`) and reference `$DITTO_API_KEY` in every command —
> never paste the raw key inline, where it lands in shell history, logs, or a chat.
> Don't commit it. You can rotate a key anytime from the dashboard.
### REST API ### REST API
Start a clone job: Start a clone job:
@@ -169,6 +180,8 @@ src/app/page.tsx, and src/app/ditto.css.
### Local CLI ### Local CLI
```bash ```bash
# this git clone gets the ditto.site tool itself — the URL you clone into a
# codebase comes later, as the argument to `npm run clone`.
git clone https://github.com/ion-design/ditto.site.git git clone https://github.com/ion-design/ditto.site.git
cd ditto.site cd ditto.site
@@ -178,7 +191,17 @@ npx playwright install chromium
npm run clone -- https://example.com/ --out=./output npm run clone -- https://example.com/ --out=./output
``` ```
The generated app lands under `output/<site>/app`. The generated app lands under `output/<site>/app`. On success the CLI prints a
copy-paste-safe summary — a single quoted `cd … && npm install && npm run dev`
line and pointers to the safe-to-edit files (`src/app/content.ts`,
`src/app/components/`; the app's `AGENTS.md` has the full guide).
To skip the copy-paste entirely and go straight to a running preview:
```bash
npm run clone -- https://example.com/ --serve # clone, npm install, npm run dev
npm run clone -- https://example.com/ --open # ...and open the browser too
```
Common local variants: Common local variants:
@@ -189,6 +212,10 @@ npm run clone -- https://example.com/ --framework=vite
npm run validate-site -- runs/site-example.com/<timestamp> npm run validate-site -- runs/site-example.com/<timestamp>
``` ```
Without `--out`, runs land under `runs/<site>/<timestamp>/` and a stable
`runs/<site>/latest` symlink always points at the newest clone, so scripts and
`cd` targets don't depend on the timestamp.
### Local REST And MCP Service ### Local REST And MCP Service
Quick inline mode, with no database: Quick inline mode, with no database:
+9
View File
@@ -16,6 +16,8 @@ npm install
npx playwright install chromium npx playwright install chromium
npm run clone -- https://example.com/ npm run clone -- https://example.com/
npm run clone -- https://example.com/ --serve # then npm install + npm run dev
npm run clone -- https://example.com/ --open # ...and open the browser too
npm run clone -- https://example.com/ --mode=multi --styling=tailwind npm run clone -- https://example.com/ --mode=multi --styling=tailwind
npm run clone -- https://example.com/ --mode=single --framework=vite npm run clone -- https://example.com/ --mode=single --framework=vite
npm run clone-site -- https://example.com/ npm run clone-site -- https://example.com/
@@ -34,6 +36,13 @@ npm run typecheck
Root-level scripts forward to these commands, so `npm run clone -- <url>` works Root-level scripts forward to these commands, so `npm run clone -- <url>` works
from the repository root too. from the repository root too.
("Clone" here = generating a codebase from a live URL, not `git clone`; no source
repo required.) On success the CLI prints a copy-paste-safe summary: a single
quoted `cd … && npm install && npm run dev` line plus safe-to-edit pointers. Pass
`--serve` to run install + dev automatically, or `--open` to also launch the
browser. Without `--out`, a `runs/<site>/latest` symlink always points at the
newest run so paths aren't timestamp-fragile.
Multi-page generation defaults to the fast no-validation path. Use `--validate` Multi-page generation defaults to the fast no-validation path. Use `--validate`
when the clone command itself should run the full build/render/gates QA pass, or when the clone command itself should run the full build/render/gates QA pass, or
run `validate-site` separately. `--concurrency` controls source route capture; run `validate-site` separately. `--concurrency` controls source route capture;
+41 -7
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env -S npx tsx #!/usr/bin/env -S npx tsx
import { basename, dirname, join, resolve, sep } from "node:path"; import { basename, dirname, join, resolve, sep } from "node:path";
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { captureSite, REQUIRED_VIEWPORTS, SAMPLE_VIEWPORTS, type CaptureResult } from "./capture/capture.js"; import { captureSite, REQUIRED_VIEWPORTS, SAMPLE_VIEWPORTS, type CaptureResult } from "./capture/capture.js";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { generateAll } from "./generate/pipeline.js"; import { generateAll } from "./generate/pipeline.js";
import { refineSizing } from "./generate/refineSizing.js"; import { refineSizing } from "./generate/refineSizing.js";
import { writeJSON, writeText, ensureDir, readJSON, fileExists } from "./util/fsx.js"; import { writeJSON, writeText, ensureDir, readJSON, fileExists } from "./util/fsx.js";
import { doneSummary, serveApp } from "./cliSummary.js";
import type { AppFramework } from "./generate/app.js"; import type { AppFramework } from "./generate/app.js";
export type CloneOptions = { export type CloneOptions = {
@@ -34,6 +35,9 @@ export type CloneResult = {
sourceDir: string; sourceDir: string;
appDir: string; appDir: string;
sourceUrl: string; sourceUrl: string;
/** A stable, timestamp-free path to the app (via the `runs/<site>/latest` symlink),
* present in runs-layout mode when the symlink could be created. */
stableAppDir?: string;
}; };
export function siteIdFromUrl(url: string): string { export function siteIdFromUrl(url: string): string {
@@ -462,16 +466,34 @@ export async function runClone(opts: CloneOptions): Promise<CloneResult> {
writeText(join(runDir, "logs", "compiler.log.jsonl"), logEvents.map((e) => JSON.stringify(e)).join("\n") + "\n"); writeText(join(runDir, "logs", "compiler.log.jsonl"), logEvents.map((e) => JSON.stringify(e)).join("\n") + "\n");
let stableAppDir: string | undefined;
if (out) { if (out) {
// Publish the deliverable to <siteName>/app; keep working artifacts in .clone. // Publish the deliverable to <siteName>/app; keep working artifacts in .clone.
exportApp(appDir, out.appDir); exportApp(appDir, out.appDir);
logBoth({ event: "exported", app: out.appDir }); logBoth({ event: "exported", app: out.appDir });
} else { } else {
// latest pointer (runs/ layout, used by --reuse / regen) // latest pointer (runs/ layout, used by --reuse / regen) + a `latest` symlink so the
writeJSON(join(runsDir, siteId, "latest.json"), { runDir, ts: timestamp() }); // app has a stable, timestamp-free path that survives copy-paste.
stableAppDir = writeLatestPointer(runsDir, siteId, runDir);
} }
return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url }; return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url, stableAppDir };
}
/** Record the newest run for a site in the runs layout: a `latest.json` breadcrumb (used by
* `--reuse`/regen) and a `latest` symlink → runDir. Returns the stable app path when the
* symlink was created (symlinks can be unavailable, e.g. Windows without privilege — non-fatal). */
export function writeLatestPointer(runsDir: string, siteId: string, runDir: string): string | undefined {
writeJSON(join(runsDir, siteId, "latest.json"), { runDir, ts: timestamp() });
const link = join(runsDir, siteId, "latest");
try {
// Refresh an existing symlink; refuse (via non-recursive rm) to clobber a real directory.
rmSync(link, { force: true });
symlinkSync(runDir, link, "junction");
return join(link, "generated", "app");
} catch {
return undefined;
}
} }
function copySourceRef(from: string, to: string): void { function copySourceRef(from: string, to: string): void {
@@ -543,12 +565,22 @@ async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const url = args.find((a) => !a.startsWith("--")); const url = args.find((a) => !a.startsWith("--"));
if (!url) { if (!url) {
console.error("usage: clone-static <url> [--mode=single|multi] [--styling=tailwind|css] [--framework=next|vite] [--out=<dir>]"); console.error("usage: clone-static <url> [--mode=single|multi] [--styling=tailwind|css] [--framework=next|vite] [--out=<dir>] [--serve] [--open]");
process.exit(1); process.exit(1);
} }
const mode = parseProductMode(args); const mode = parseProductMode(args);
const styling = parseProductStyling(args); const styling = parseProductStyling(args);
const framework = parseProductFramework(args); const framework = parseProductFramework(args);
// --serve installs deps + starts the dev server after cloning; --open also launches the browser.
const open = hasAnyFlag(args, ["--open"]);
const serve = open || hasAnyFlag(args, ["--serve"]);
const finish = async (res: { appDir: string; stableAppDir?: string }) => {
if (serve) {
await serveApp(res.appDir, { open });
} else {
process.stderr.write(doneSummary({ url, appDir: res.appDir, framework, stableAppDir: res.stableAppDir }));
}
};
const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]); const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]);
const runsArg = firstFlagValue(args, ["--dev-runs", "--runs"]); const runsArg = firstFlagValue(args, ["--dev-runs", "--runs"]);
// --out=<dir>: clean <dir>/<siteName>/{app,.clone} layout (default: ./output when bare --out). // --out=<dir>: clean <dir>/<siteName>/{app,.clone} layout (default: ./output when bare --out).
@@ -604,7 +636,8 @@ async function main(): Promise<void> {
tier, tier,
log: (e) => console.log(JSON.stringify(e)), log: (e) => console.log(JSON.stringify(e)),
}); });
console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir })); console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir, stableApp: res.stableAppDir }));
await finish(res);
return; return;
} }
@@ -625,7 +658,8 @@ async function main(): Promise<void> {
reuseSource, reuseSource,
log: (e) => console.log(JSON.stringify(e)), log: (e) => console.log(JSON.stringify(e)),
}); });
console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir })); console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir, stableApp: res.stableAppDir }));
await finish(res);
} }
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
+112
View File
@@ -0,0 +1,112 @@
/**
* Human-facing success output for the `clone-static` CLI.
*
* The CLI streams machine-readable JSON events to stdout; this module renders the
* copy-paste-friendly summary a person actually reads at the end, plus the optional
* `--serve` runner that installs deps and starts the dev server for them.
*
* Why this exists: a bare `runs/<domain>/<timestamp>/generated/app` path pasted into a
* terminal wraps mid-word, so `cd` fails and the follow-up `npm run dev` runs in the
* wrong directory. The preview command here is a single quoted line that survives
* wrapping.
*/
import { spawn } from "node:child_process";
import { platform } from "node:os";
export type DoneSummaryInput = {
url: string;
/** The exact generated app directory for this run. */
appDir: string;
framework: "next" | "vite";
/** Stable path (a `runs/<site>/latest` symlink target) preferred as the `cd` target when present. */
stableAppDir?: string;
};
/** Double-quote a path for a POSIX shell so spaces and wrapping don't break copy-paste. */
export function shellQuote(p: string): string {
return `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
}
/** The success block. The preview command is one quoted line on purpose. */
export function doneSummary(input: DoneSummaryInput): string {
const root = input.framework === "next" ? "src/app" : "src";
const cdTarget = input.stableAppDir ?? input.appDir;
const lines: string[] = [
"",
`✓ Done — cloned ${input.url}`,
"",
" Preview it locally (copy-paste this one line):",
"",
` cd ${shellQuote(cdTarget)} && npm install && npm run dev`,
"",
" What's safe to edit — full guide in AGENTS.md inside the app:",
` • page copy & content → ${root}/content.ts`,
` • components → ${root}/components/`,
"",
" Or re-run with --serve to install deps and start the dev server for you",
" (add --open to launch the browser too).",
];
if (input.stableAppDir && input.stableAppDir !== input.appDir) {
lines.push("");
lines.push(` The path above is a stable pointer to the newest clone. This exact run:`);
lines.push(` ${input.appDir}`);
}
return lines.join("\n") + "\n";
}
function openBrowser(url: string): void {
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
try {
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
child.on("error", () => {}); // opening a browser is best-effort
child.unref();
} catch {
/* best-effort */
}
}
function runToCompletion(cmd: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolvePromise, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit", shell: platform() === "win32" });
child.on("error", reject);
child.on("exit", (code) =>
code === 0 ? resolvePromise() : reject(new Error(`\`${cmd} ${args.join(" ")}\` exited with code ${code}`)),
);
});
}
/**
* Run `npm install` then `npm run dev` in the generated app (foreground — `npm run dev`
* keeps running until the user stops it). With `open`, launches the default browser at
* the dev URL once the server prints it.
*/
export async function serveApp(appDir: string, opts: { open: boolean }): Promise<void> {
process.stderr.write(`\n→ Installing dependencies in ${appDir}\n`);
await runToCompletion("npm", ["install"], appDir);
process.stderr.write(`→ Starting the dev server (Ctrl-C to stop)\n\n`);
await new Promise<void>((resolvePromise, reject) => {
const dev = spawn("npm", ["run", "dev"], {
cwd: appDir,
stdio: opts.open ? ["inherit", "pipe", "inherit"] : "inherit",
shell: platform() === "win32",
});
if (opts.open && dev.stdout) {
let opened = false;
dev.stdout.on("data", (chunk: Buffer) => {
process.stdout.write(chunk);
if (opened) return;
const m = String(chunk).match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\S*/i);
if (m) {
opened = true;
openBrowser(m[0]);
}
});
}
dev.on("error", reject);
dev.on("exit", (code) =>
code === 0 || code === null ? resolvePromise() : reject(new Error(`\`npm run dev\` exited with code ${code}`)),
);
});
}
+6 -2
View File
@@ -21,7 +21,7 @@ import { structurallySimilar } from "./signature.js";
import { generateSiteApp, routeToSegment, routeKey, type RouteArtifact } from "./generateSite.js"; import { generateSiteApp, routeToSegment, routeKey, type RouteArtifact } from "./generateSite.js";
import { detectSharedChrome, chromeSignatureId } from "./sharedLayout.js"; import { detectSharedChrome, chromeSignatureId } from "./sharedLayout.js";
import { validateSite, type SiteReport } from "./validateSite.js"; import { validateSite, type SiteReport } from "./validateSite.js";
import { siteIdFromUrl, namedOutDirs, exportApp } from "../cli.js"; import { siteIdFromUrl, namedOutDirs, exportApp, writeLatestPointer } from "../cli.js";
import { writeJSON, readJSON, ensureDir, fileExists, writeText } from "../util/fsx.js"; import { writeJSON, readJSON, ensureDir, fileExists, writeText } from "../util/fsx.js";
import { seoInventoryToMarkdown } from "../generate/seo.js"; import { seoInventoryToMarkdown } from "../generate/seo.js";
import type { AppFramework } from "../generate/app.js"; import type { AppFramework } from "../generate/app.js";
@@ -56,6 +56,8 @@ export type CloneSiteResult = {
plan: RoutePlan; plan: RoutePlan;
routes: RouteArtifact[]; routes: RouteArtifact[];
siteReport?: SiteReport; siteReport?: SiteReport;
/** Stable, timestamp-free path to the app (via the `runs/<site>/latest` symlink), when created. */
stableAppDir?: string;
}; };
function timestamp(): string { function timestamp(): string {
@@ -236,8 +238,10 @@ export async function runCloneSite(opts: CloneSiteOptions): Promise<CloneSiteRes
siteReport = await validateSite(runDir, { tier: opts.tier ?? "stage2", routeConcurrency: opts.validationConcurrency, viewportConcurrency: opts.viewportConcurrency, log }); siteReport = await validateSite(runDir, { tier: opts.tier ?? "stage2", routeConcurrency: opts.validationConcurrency, viewportConcurrency: opts.viewportConcurrency, log });
} }
let stableAppDir: string | undefined;
if (out) { exportApp(appDir, out.appDir); log({ event: "exported", app: out.appDir }); } if (out) { exportApp(appDir, out.appDir); log({ event: "exported", app: out.appDir }); }
return { runDir, appDir: out ? out.appDir : appDir, siteId, plan, routes, siteReport }; else { stableAppDir = writeLatestPointer(runsDir, siteId, runDir); }
return { runDir, appDir: out ? out.appDir : appDir, siteId, plan, routes, siteReport, stableAppDir };
} }
type ManifestForRegen = { type ManifestForRegen = {
+53
View File
@@ -0,0 +1,53 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { doneSummary, shellQuote } from "../src/cliSummary.js";
describe("shellQuote", () => {
it("wraps a path in double quotes so spaces survive copy-paste", () => {
assert.equal(shellQuote("/Users/x/runs/site/20260701/generated/app"), '"/Users/x/runs/site/20260701/generated/app"');
assert.equal(shellQuote("/Users/x/My Sites/app"), '"/Users/x/My Sites/app"');
});
it("escapes characters the shell would otherwise interpret inside double quotes", () => {
assert.equal(shellQuote('/a/"b"/$c/`d`/e\\f'), '"/a/\\"b\\"/\\$c/\\`d\\`/e\\\\f"');
});
});
describe("doneSummary", () => {
const base = { url: "https://academyux.com/", appDir: "/runs/academyux.com/20260701-173012/generated/app", framework: "next" as const };
it("emits a single quoted preview line (no mid-word wrap on the path)", () => {
const out = doneSummary(base);
const line = out.split("\n").find((l) => l.includes("cd "));
assert.ok(line, "has a cd line");
// one line, path is quoted so a wrapping terminal can't split the command
assert.match(line!, /cd "\/runs\/academyux\.com\/20260701-173012\/generated\/app" && npm install && npm run dev/);
});
it("points at the Next safe-edit areas from AGENTS.md", () => {
const out = doneSummary(base);
assert.match(out, /src\/app\/content\.ts/);
assert.match(out, /src\/app\/components\//);
assert.match(out, /AGENTS\.md/);
});
it("uses the Vite src root when framework is vite", () => {
const out = doneSummary({ ...base, framework: "vite" });
assert.match(out, /cd .* && npm install && npm run dev/);
assert.match(out, /• page copy & content {2}→ {2}src\/content\.ts/);
assert.match(out, /• components {11}→ {2}src\/components\//);
});
it("prefers the stable path as the cd target and still shows the exact run", () => {
const out = doneSummary({ ...base, stableAppDir: "/runs/academyux.com/latest/generated/app" });
const cdLine = out.split("\n").find((l) => l.includes("cd "))!;
assert.match(cdLine, /cd "\/runs\/academyux\.com\/latest\/generated\/app"/);
assert.match(out, /This exact run/);
assert.match(out, /\/runs\/academyux\.com\/20260701-173012\/generated\/app/);
});
it("mentions the --serve / --open shortcut", () => {
assert.match(doneSummary(base), /--serve/);
assert.match(doneSummary(base), /--open/);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, existsSync, readlinkSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeLatestPointer } from "../src/cli.js";
describe("writeLatestPointer", () => {
it("writes latest.json and a `latest` symlink to the newest run, refreshing it in place", () => {
const runs = mkdtempSync(join(tmpdir(), "ditto-runs-"));
const siteId = "example.com";
const run1 = join(runs, siteId, "20260701-100000");
mkdirSync(join(run1, "generated", "app"), { recursive: true });
const stable1 = writeLatestPointer(runs, siteId, run1);
assert.ok(existsSync(join(runs, siteId, "latest.json")), "writes latest.json breadcrumb");
assert.ok(stable1 && existsSync(stable1), "returned stable app path resolves through the symlink");
assert.equal(realpathSync(readlinkSync(join(runs, siteId, "latest"))), realpathSync(run1));
// A second run must re-point the existing symlink, not throw on the collision.
const run2 = join(runs, siteId, "20260701-200000");
mkdirSync(join(run2, "generated", "app"), { recursive: true });
const stable2 = writeLatestPointer(runs, siteId, run2);
assert.equal(realpathSync(readlinkSync(join(runs, siteId, "latest"))), realpathSync(run2));
assert.ok(stable2 && existsSync(stable2));
// The stable path is timestamp-free (goes through `latest`, not the run's timestamp).
assert.match(stable2!, /example\.com\/latest\/generated\/app$/);
});
});
+57
View File
@@ -0,0 +1,57 @@
# ditto.site Documentation
The central index for ditto.site's docs. Start here.
> **First, the word "clone."** In ditto.site, *cloning* means **generating a
> codebase from a live URL** — it is **not** `git clone`. You do **not** need an
> existing Git repository, and you do **not** need the target site's source code.
> You give ditto.site a public URL; it captures what the page renders in a browser
> and writes you a fresh, runnable project. (The only `git clone` involved is
> optionally cloning *this tool's* repository to run the compiler locally.)
## Get started
| I want to… | Go to |
| --- | --- |
| Understand what ditto.site is and see it run | [Project README](../README.md) |
| Call the hosted **REST API** or **MCP** server | [Project README → Usage](../README.md#usage), [SERVICE.md](SERVICE.md) |
| Turn a clone result JSON into files on disk | [`ditto unpack` CLI](../packages/cli/README.md) |
| Run the **compiler locally** from the command line | [compiler/README.md](../compiler/README.md) |
| Deploy the service | [DEPLOY.md](DEPLOY.md) |
| Read the development & evaluation method | [METHODOLOGY.md](METHODOLOGY.md) |
| Understand responsible-use boundaries | [RESPONSIBLE_USE.md](RESPONSIBLE_USE.md) |
| Cut a release | [RELEASING.md](RELEASING.md) |
## API keys are secrets
Keys look like `dtto_live_...`. Keep them in an environment variable and reference
it in commands:
```bash
export DITTO_API_KEY="dtto_live_..."
curl -sS -H "authorization: Bearer $DITTO_API_KEY" "$DITTO_API_URL/v1/clones"
```
Never paste a raw key inline (it leaks into shell history, logs, and chat), and
never commit one. Rotate a leaked key anytime from the dashboard. See
[SERVICE.md](SERVICE.md) for the full auth model.
## The short version of the workflow
1. **Clone** a URL → `POST /v1/clones` (API) or `npm run clone -- <url>` (local CLI).
2. **Get the app** → unpack the result JSON with [`ditto unpack`](../packages/cli/README.md),
download the `bundle?format=tgz` archive, or read files from `runs/<site>/latest/`.
3. **Preview it**`cd` into the app and `npm install && npm run dev` (or let the
local CLI do it for you with `--serve` / `--open`).
4. **Edit safely** → each generated app ships an `AGENTS.md` describing what's safe
to change (copy in `src/app/content.ts`, components in `src/app/components/`, etc.).
## Full doc list
- [SERVICE.md](SERVICE.md) — REST + MCP service reference (endpoints, options, env vars).
- [DEPLOY.md](DEPLOY.md) — production deployment.
- [METHODOLOGY.md](METHODOLOGY.md) — how the compiler is developed and evaluated.
- [RESPONSIBLE_USE.md](RESPONSIBLE_USE.md) — acceptable-use boundaries.
- [RELEASING.md](RELEASING.md) — release process.
- [../compiler/README.md](../compiler/README.md) — local compiler commands.
- [../packages/cli/README.md](../packages/cli/README.md) — the `ditto unpack` CLI.
+5
View File
@@ -89,6 +89,11 @@ GET /healthz → { ok: true } (unauthenticated)
`/v1/clones*` and `/mcp` are authenticated when `API_KEYS` is set or DB-backed `/v1/clones*` and `/mcp` are authenticated when `API_KEYS` is set or DB-backed
keys exist. Use `Authorization: Bearer <key>` or `x-api-key: <key>`. keys exist. Use `Authorization: Bearer <key>` or `x-api-key: <key>`.
> **Treat keys as secrets.** In any snippet you copy or share, template the key as
> an environment variable (`Authorization: Bearer $DITTO_API_KEY`) rather than an
> inline `dtto_live_...` token — inline keys leak into shell history, logs, and
> pasted transcripts. Never commit a key; rotate a leaked one from the dashboard.
Signup routes are intentionally public only when `SIGNUP_ENABLED=true` **and** Signup routes are intentionally public only when `SIGNUP_ENABLED=true` **and**
`DATABASE_URL` is set. Direct `POST /v1/signup` mints a `dtto_live_...` key `DATABASE_URL` is set. Direct `POST /v1/signup` mints a `dtto_live_...` key
immediately when `SIGNUP_DIRECT_ENABLED=true`. For public production signup, immediately when `SIGNUP_DIRECT_ENABLED=true`. For public production signup,