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
5 changes: 5 additions & 0 deletions .changeset/sites-ci-init-respects-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `ci init` now writes `sites.dir` and `sites.build` from `bunny.jsonc` into the generated workflow, so CI stops deploying the framework preset's directory while a local `sites deploy` uses the configured one; a `bunny.jsonc` below the repo root also gets a job working directory, a prefixed deploy directory, and its own lockfile as the cache path
5 changes: 5 additions & 0 deletions .changeset/sites-confirm-non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `delete`, `deployments publish/prune` and `domains remove` now error with a `--force` hint when there's no TTY to answer their confirmation, instead of hanging on a prompt (and writing it to stdout ahead of `--output json`)
5 changes: 5 additions & 0 deletions .changeset/sites-create-config-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `create` falls back to `sites.name` from `bunny.jsonc` like every other sites command, instead of failing with "Site name is required." in a configured project when it can't prompt
5 changes: 5 additions & 0 deletions .changeset/sites-force-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `--force` on `delete`, `deployments publish/prune` and `domains remove` now errors without an explicit or linked site instead of opening the picker, so a highlighted site can't be acted on with the confirmation already skipped
5 changes: 5 additions & 0 deletions .changeset/sites-link-flag-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `--link` is now only accepted by the commands that can act on it (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`), where an explicit `--link` also links a site resolved from `--site` or `bunny.jsonc`, including under `--output json`; `open`, `ssl`, `delete` and `deployments prune` no longer advertise a flag they ignored
5 changes: 5 additions & 0 deletions .changeset/sites-prune-keep-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `deployments prune --keep` now rejects non-integer and negative counts up front; `--keep abc` reached the pruner as NaN and deleted every deploy except the live and previous ones
5 changes: 5 additions & 0 deletions .changeset/sites-prune-site-positional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

fix(sites): `deployments prune` takes the site as a positional (`prune my-site`) like its sibling subcommands, instead of rejecting it as an unknown argument; `--site` keeps working
39 changes: 22 additions & 17 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ bun ny sites open # open the site's live URL in the br
bun ny sites ci init # add a GitHub Actions workflow (preview on PRs, production on main)
```

Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).
Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).

### Available Scripts

Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/commands/sites/ci/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { clientOptions } from "../../../core/client-options.ts";
import { defineCommand } from "../../../core/define-command.ts";
import { logger } from "../../../core/logger.ts";
import { isInteractive } from "../../../core/ui.ts";
import { loadSiteConfig } from "../config.ts";
import {
type SiteSelectorArgs,
selectSite,
siteLinkOption,
siteOptionBuilder,
} from "../interactive.ts";
import { FRAMEWORK_PRESETS } from "./frameworks.ts";
Expand Down Expand Up @@ -36,7 +38,7 @@ export const sitesCiInitCommand = defineCommand<CiInitArgs>({
],

builder: (yargs) =>
siteOptionBuilder(yargs)
siteLinkOption(siteOptionBuilder(yargs))
.option("framework", {
type: "string",
choices: FRAMEWORK_PRESETS.map((p) => p.id),
Expand Down Expand Up @@ -68,12 +70,17 @@ export const sitesCiInitCommand = defineCommand<CiInitArgs>({
);
}

// `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against.
const siteConfig = loadSiteConfig();
const result = await scaffoldSitesWorkflow({
site: name,
root,
projectRoot: siteConfig?.root,
frameworkId: args.framework,
interactive,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
force: args.force,
dir: siteConfig?.config.dir,
build: siteConfig?.config.build,
});

if (output === "json") {
Expand All @@ -84,6 +91,7 @@ export const sitesCiInitCommand = defineCommand<CiInitArgs>({
path: result.path,
framework: result.preset.id,
packageManager: result.packageManager,
directory: result.dir,
},
null,
2,
Expand All @@ -98,7 +106,7 @@ export const sitesCiInitCommand = defineCommand<CiInitArgs>({
}

logger.success(
`Wrote ${result.path} (${result.preset.label}, deploys ${result.preset.dir}).`,
`Wrote ${result.path} (${result.preset.label}, deploys ${result.dir}).`,
);
logger.log();
await offerGitHubSecret({ apiKey: config.apiKey, root, interactive });
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/sites/ci/scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { projectPrefix } from "./scaffold.ts";

test("projectPrefix is empty when the project is the workflow root", () => {
expect(projectPrefix("/repo", "/repo")).toBe("");
expect(projectPrefix("/repo", undefined)).toBe("");
});

test("projectPrefix returns the POSIX offset for a nested project", () => {
expect(projectPrefix("/repo", "/repo/packages/site")).toBe("packages/site");
});

// A bunny.jsonc above the git root can't be referenced from a repo-rooted workflow.
test("projectPrefix is undefined when the project escapes the workflow root", () => {
expect(projectPrefix("/repo/app", "/repo")).toBeUndefined();
});
142 changes: 131 additions & 11 deletions packages/cli/src/commands/sites/ci/scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, join, relative, sep } from "node:path";
import prompts from "prompts";
import { UserError } from "../../../core/errors.ts";
import { runGit } from "../../../core/git.ts";
Expand All @@ -12,8 +12,13 @@ import {
type FrameworkPreset,
findPreset,
type PackageManager,
readPackageJson,
} from "./frameworks.ts";
import { renderSitesWorkflow, SITES_WORKFLOW_PATH } from "./workflow.ts";
import {
renderSitesWorkflow,
SITES_WORKFLOW_PATH,
workflowPath,
} from "./workflow.ts";

/** The repo root, or null when `cwd` isn't inside a git repository. */
export async function gitTopLevel(cwd: string): Promise<string | null> {
Expand All @@ -30,13 +35,94 @@ export interface ScaffoldResult {
path: string;
preset: FrameworkPreset;
packageManager: PackageManager;
/** Directory the workflow deploys, relative to the repo root: `sites.dir` when configured, else the preset's, prefixed when the project sits below the root. */
dir: string;
}

// Lockfiles that decide the package manager; a nested project may carry its own, in which case setup-node needs to be pointed at it.
const LOCKFILES = [
"bun.lock",
"bun.lockb",
"pnpm-lock.yaml",
"yarn.lock",
"package-lock.json",
];

// The git top level and the bunny.jsonc directory can reach the same place by different paths (macOS /tmp -> /private/tmp), which would read as "outside the repo".
function realOrSelf(path: string): string {
try {
return realpathSync(path);
} catch {
return path;
}
}

/** Where the project sits relative to the workflow root, POSIX-style; "" when they're the same and undefined when the project is outside the root. */
export function projectPrefix(
workflowRoot: string,
projectRoot: string | undefined,
): string | undefined {
if (!projectRoot) return "";
const rel = relative(realOrSelf(workflowRoot), realOrSelf(projectRoot));
if (rel === "") return "";
if (rel.startsWith("..") || isAbsolute(rel)) return undefined;
return rel.split(sep).join("/");
}

interface WorkflowSettings {
/** Directory detection runs in and `sites.dir`/`sites.build` resolve against. */
projectRoot: string;
/** That directory relative to the workflow root, POSIX-style; "" when they're the same. */
prefix: string;
dir?: string;
build?: string;
cacheDependencyPath?: string;
}

// A bunny.jsonc outside the workflow root can't be expressed in a repo-rooted workflow, so its paths are dropped (with a warning) and the preset's are used from the root.
async function workflowSettings(opts: {
root: string;
projectRoot?: string;
dir?: string;
build?: string;
}): Promise<WorkflowSettings> {
const prefix = projectPrefix(opts.root, opts.projectRoot);
if (prefix === undefined) {
logger.warn(
`bunny.jsonc sits outside ${opts.root}, so its \`dir\`/\`build\` can't be used in the workflow.`,
);
return { projectRoot: opts.root, prefix: "" };
}

const projectRoot = opts.projectRoot ?? opts.root;
// Only a lockfile of its own moves the cache lookup; a monorepo-root lockfile is what setup-node finds by default.
const lockfile = prefix
? LOCKFILES.find((name) => existsSync(join(projectRoot, name)))
: undefined;
return {
projectRoot,
prefix,
dir: opts.dir,
build: opts.build,
cacheDependencyPath: lockfile ? workflowPath(prefix, lockfile) : undefined,
};
}

// A configured build on the static preset (an unrecognized bundler, say) gets the JS setup/install steps when the project has a package.json; the toolchain presets already install for themselves.
async function needsJsInstall(
preset: FrameworkPreset,
settings: WorkflowSettings,
): Promise<boolean> {
if (preset.toolchain !== "none" || settings.build === undefined) return false;
return (await readPackageJson(settings.projectRoot)) !== null;
}

/** Resolve the framework preset: explicit id, detection, prompt, static fallback. */
async function resolvePreset(
root: string,
frameworkId: string | undefined,
interactive: boolean,
dir: string | undefined,
): Promise<FrameworkPreset> {
if (frameworkId) {
const preset = findPreset(frameworkId);
Expand All @@ -51,7 +137,7 @@ async function resolvePreset(

const detected = await detectFramework(root);
if (detected) {
logger.info(`Detected ${detected.label} (deploys ${detected.dir}).`);
logger.info(`Detected ${detected.label} (deploys ${dir ?? detected.dir}).`);
return detected;
}

Expand All @@ -72,24 +158,34 @@ async function resolvePreset(
return fallback;
}

// Write `.github/workflows/bunny-sites.yml`; returns null when the user declines to overwrite an existing file, throws when non-interactive and it exists without `force`.
// Write `.github/workflows/bunny-sites.yml`; returns null when the user declines to overwrite an existing file, throws when non-interactive and it exists without `force`. `sites.dir`/`sites.build` from bunny.jsonc win over the preset, so CI deploys what `sites deploy` does; `projectRoot` (the bunny.jsonc directory) is where those paths resolve, and the workflow gets a working directory when it sits below `root`.
export async function scaffoldSitesWorkflow(opts: {
site: string;
root: string;
projectRoot?: string;
frameworkId?: string;
interactive: boolean;
force?: boolean;
dir?: string;
build?: string;
}): Promise<ScaffoldResult | null> {
const settings = await workflowSettings(opts);
const preset = await resolvePreset(
opts.root,
settings.projectRoot,
opts.frameworkId,
opts.interactive,
settings.dir,
);
const packageManager = await detectPackageManager(opts.root);
const packageManager = await detectPackageManager(settings.projectRoot);
const content = renderSitesWorkflow({
site: opts.site,
preset,
packageManager,
dir: settings.dir,
build: settings.build,
workingDirectory: settings.prefix || undefined,
cacheDependencyPath: settings.cacheDependencyPath,
installDeps: await needsJsInstall(preset, settings),
});

const target = join(opts.root, SITES_WORKFLOW_PATH);
Expand All @@ -109,21 +205,45 @@ export async function scaffoldSitesWorkflow(opts: {

mkdirSync(dirname(target), { recursive: true });
await Bun.write(target, content);
return { path: SITES_WORKFLOW_PATH, preset, packageManager };
return {
path: SITES_WORKFLOW_PATH,
preset,
packageManager,
dir: workflowPath(settings.prefix, settings.dir ?? preset.dir),
};
}

/** Print the workflow and setup steps for users who declined the scaffold. */
export async function printWorkflowInstructions(
site: string,
root: string,
config?: { root?: string; dir?: string; build?: string },
): Promise<void> {
const preset = (await detectFramework(root)) ?? findPreset("static");
const settings = await workflowSettings({
root,
projectRoot: config?.root,
dir: config?.dir,
build: config?.build,
});
const preset =
(await detectFramework(settings.projectRoot)) ?? findPreset("static");
if (!preset) return;
const packageManager = await detectPackageManager(root);
const packageManager = await detectPackageManager(settings.projectRoot);
logger.log();
logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`);
logger.log();
logger.log(renderSitesWorkflow({ site, preset, packageManager }));
logger.log(
renderSitesWorkflow({
site,
preset,
packageManager,
dir: settings.dir,
build: settings.build,
workingDirectory: settings.prefix || undefined,
cacheDependencyPath: settings.cacheDependencyPath,
installDeps: await needsJsInstall(preset, settings),
}),
);
printSecretHint();
}

Expand Down
Loading
Loading