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:
co-authored by
Claude Opus 4.8
parent
638a0516c5
commit
63df38105d
+41
-7
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { generateAll } from "./generate/pipeline.js";
|
||||
import { refineSizing } from "./generate/refineSizing.js";
|
||||
import { writeJSON, writeText, ensureDir, readJSON, fileExists } from "./util/fsx.js";
|
||||
import { doneSummary, serveApp } from "./cliSummary.js";
|
||||
import type { AppFramework } from "./generate/app.js";
|
||||
|
||||
export type CloneOptions = {
|
||||
@@ -34,6 +35,9 @@ export type CloneResult = {
|
||||
sourceDir: string;
|
||||
appDir: 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 {
|
||||
@@ -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");
|
||||
|
||||
let stableAppDir: string | undefined;
|
||||
if (out) {
|
||||
// Publish the deliverable to <siteName>/app; keep working artifacts in .clone.
|
||||
exportApp(appDir, out.appDir);
|
||||
logBoth({ event: "exported", app: out.appDir });
|
||||
} else {
|
||||
// latest pointer (runs/ layout, used by --reuse / regen)
|
||||
writeJSON(join(runsDir, siteId, "latest.json"), { runDir, ts: timestamp() });
|
||||
// latest pointer (runs/ layout, used by --reuse / regen) + a `latest` symlink so the
|
||||
// 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 {
|
||||
@@ -543,12 +565,22 @@ async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const url = args.find((a) => !a.startsWith("--"));
|
||||
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);
|
||||
}
|
||||
const mode = parseProductMode(args);
|
||||
const styling = parseProductStyling(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 runsArg = firstFlagValue(args, ["--dev-runs", "--runs"]);
|
||||
// --out=<dir>: clean <dir>/<siteName>/{app,.clone} layout (default: ./output when bare --out).
|
||||
@@ -604,7 +636,8 @@ async function main(): Promise<void> {
|
||||
tier,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -625,7 +658,8 @@ async function main(): Promise<void> {
|
||||
reuseSource,
|
||||
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]}`) {
|
||||
|
||||
@@ -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}`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { structurallySimilar } from "./signature.js";
|
||||
import { generateSiteApp, routeToSegment, routeKey, type RouteArtifact } from "./generateSite.js";
|
||||
import { detectSharedChrome, chromeSignatureId } from "./sharedLayout.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 { seoInventoryToMarkdown } from "../generate/seo.js";
|
||||
import type { AppFramework } from "../generate/app.js";
|
||||
@@ -56,6 +56,8 @@ export type CloneSiteResult = {
|
||||
plan: RoutePlan;
|
||||
routes: RouteArtifact[];
|
||||
siteReport?: SiteReport;
|
||||
/** Stable, timestamp-free path to the app (via the `runs/<site>/latest` symlink), when created. */
|
||||
stableAppDir?: 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 });
|
||||
}
|
||||
|
||||
let stableAppDir: string | undefined;
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user