Skip to content

Commit 2bcf964

Browse files
feat(cli/scripts): create command (#50)
* feat(cli/scripts): add command * changeset * feat(cli/scripts): --repo shorthand on init, open-browser prompt after create
1 parent c1896be commit 2bcf964

7 files changed

Lines changed: 372 additions & 36 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@bunny.net/cli": minor
3+
---
4+
5+
Add `bunny scripts create` command
6+
7+
- New `bunny scripts create [name]` command for creating an Edge Script on bunny.net without scaffolding a project. Useful when you already have a project (e.g. ran `bunny scripts init` without `--deploy`) and need a remote script before running `bunny scripts deploy`.
8+
- Defaults the script name to the current directory name, creates a linked pull zone, and links the directory via `.bunny/script.json`.
9+
- Flags: `--type` (`standalone` or `middleware`), `--pull-zone`/`--no-pull-zone`, `--pull-zone-name`, `--link`/`--no-link`.
10+
- Refactored `scripts init` to share the underlying `createScript()` helper.

.changeset/scripts-init-repo-shorthand.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
---
44

55
Add `--repo` alias for `--template-repo` on `bunny scripts init` and accept GitHub `owner/repo` shorthand. When a custom template repo is given without `--type`, the script type now defaults to `standalone`.
6+
7+
After a script is created by `bunny scripts create` (and `bunny scripts init --deploy`), the CLI now prompts to open the linked pull zone hostname in the browser. Declining shows a reminder to make local changes and run `bunny scripts deploy <file>`.

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,10 @@ bunny-cli/
239239
│ │ └── scripts/
240240
│ │ ├── index.ts # defineNamespace("scripts", ...) — registers all script commands
241241
│ │ ├── constants.ts # SCRIPT_MANIFEST, SCRIPT_TYPE_LABELS
242+
│ │ ├── create.ts # Create a remote Edge Script (exports shared `createScript` helper)
242243
│ │ ├── deploy.ts # Deploy code to an Edge Script (publishes by default)
243244
│ │ ├── docs.ts # Open Edge Script documentation in browser
244-
│ │ ├── init.ts # Scaffold a new Edge Script project from a template
245+
│ │ ├── init.ts # Scaffold a new Edge Script project from a template (calls `createScript`)
245246
│ │ ├── link.ts # Link directory to a remote Edge Script (.bunny/script.json)
246247
│ │ ├── list.ts # List all Edge Scripts (Standalone + Middleware)
247248
│ │ ├── show.ts # Show Edge Script details (supports manifest fallback)
@@ -760,6 +761,8 @@ bunny
760761
├── scripts
761762
│ ├── init [--name] [--type] [--template] [--deploy-method] [--deploy] [--skip-git] [--skip-install]
762763
│ │ Create a new Edge Script project from a template
764+
│ ├── create [name] [--type] [--pull-zone] [--pull-zone-name] [--link]
765+
│ │ Create a remote Edge Script (use after init when --deploy was skipped)
763766
│ ├── deploy <file> [id] [--skip-publish]
764767
│ │ Deploy code to an Edge Script (publishes by default)
765768
│ ├── deployments

packages/cli/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,28 @@ When choosing **GitHub Actions**, git is initialized automatically, GitHub-speci
434434

435435
When choosing **CLI**, the `.github/` and `.changeset/` directories are removed from the template and git initialization is skipped.
436436

437+
#### `bunny scripts create`
438+
439+
Create a new Edge Script on bunny.net (without scaffolding a project). Use this when you have an existing project — for example, you ran `bunny scripts init` without `--deploy` — and need a remote script before running `bunny scripts deploy`.
440+
441+
```bash
442+
# Create using current directory name + link .bunny/script.json
443+
bunny scripts create
444+
445+
# Explicit name and type
446+
bunny scripts create my-script --type middleware
447+
448+
# Skip pull zone creation and directory linking
449+
bunny scripts create my-script --no-pull-zone --no-link
450+
```
451+
452+
| Flag | Description |
453+
| ------------------ | ------------------------------------------------------------------------------------------ |
454+
| `--type` | Script type: `standalone` or `middleware` (defaults to manifest, prompts if interactive) |
455+
| `--pull-zone` | Create a linked pull zone (default: true). Use `--no-pull-zone` to skip. |
456+
| `--pull-zone-name` | Name for the linked pull zone |
457+
| `--link` | Link this directory to the new script (default: true). Use `--no-link` to skip. |
458+
437459
#### `bunny scripts deploy`
438460

439461
Deploy code to an Edge Script. Uploads code and publishes by default.
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { basename, resolve } from "node:path";
2+
import { createComputeClient } from "@bunny.net/api";
3+
import type { components } from "@bunny.net/api/generated/compute.d.ts";
4+
import prompts from "prompts";
5+
import { resolveConfig } from "../../config/index.ts";
6+
import { clientOptions } from "../../core/client-options.ts";
7+
import { defineCommand } from "../../core/define-command.ts";
8+
import { UserError } from "../../core/errors.ts";
9+
import { formatKeyValue } from "../../core/format.ts";
10+
import { logger } from "../../core/logger.ts";
11+
import { loadManifest, saveManifest } from "../../core/manifest.ts";
12+
import { confirm, openBrowser, spinner } from "../../core/ui.ts";
13+
import { SCRIPT_MANIFEST } from "./constants.ts";
14+
15+
type EdgeScriptTypes = components["schemas"]["EdgeScriptTypes"];
16+
17+
const COMMAND = "create [name]";
18+
const DESCRIPTION = "Create a new Edge Script on bunny.net.";
19+
20+
const ARG_NAME = "name";
21+
const ARG_NAME_DESCRIPTION = "Script name (defaults to current directory name)";
22+
const ARG_TYPE = "type";
23+
const ARG_TYPE_DESCRIPTION = "Script type: standalone or middleware";
24+
const ARG_PULL_ZONE = "pull-zone";
25+
const ARG_PULL_ZONE_DESCRIPTION =
26+
"Create a linked pull zone (default: true). Use --no-pull-zone to skip.";
27+
const ARG_PULL_ZONE_NAME = "pull-zone-name";
28+
const ARG_PULL_ZONE_NAME_DESCRIPTION = "Name for the linked pull zone";
29+
const ARG_LINK = "link";
30+
const ARG_LINK_DESCRIPTION =
31+
"Link this directory to the new script (default: true). Use --no-link to skip.";
32+
33+
interface CreateArgs {
34+
[ARG_NAME]?: string;
35+
[ARG_TYPE]?: string;
36+
[ARG_PULL_ZONE]?: boolean;
37+
[ARG_PULL_ZONE_NAME]?: string;
38+
[ARG_LINK]?: boolean;
39+
}
40+
41+
interface CreatedScript {
42+
id: number;
43+
name: string;
44+
scriptType: EdgeScriptTypes;
45+
hostname?: string;
46+
}
47+
48+
/**
49+
* Create the remote Edge Script via the compute API.
50+
*
51+
* Shared between `scripts create` and `scripts init`. Returns the
52+
* created script's id, name, type, and (if a pull zone was created)
53+
* its default hostname.
54+
*/
55+
export async function createScript(opts: {
56+
profile: string;
57+
apiKey?: string;
58+
verbose: boolean;
59+
name: string;
60+
scriptType: EdgeScriptTypes;
61+
createLinkedPullZone: boolean;
62+
linkedPullZoneName?: string;
63+
}): Promise<CreatedScript> {
64+
const config = resolveConfig(opts.profile, opts.apiKey);
65+
const client = createComputeClient(clientOptions(config, opts.verbose));
66+
67+
const spin = spinner(`Creating script "${opts.name}"...`);
68+
spin.start();
69+
70+
const { data: script } = await client.POST("/compute/script", {
71+
body: {
72+
Name: opts.name,
73+
ScriptType: opts.scriptType,
74+
CreateLinkedPullZone: opts.createLinkedPullZone,
75+
...(opts.linkedPullZoneName
76+
? { LinkedPullZoneName: opts.linkedPullZoneName }
77+
: {}),
78+
},
79+
});
80+
81+
spin.stop();
82+
83+
if (!script || script.Id == null) {
84+
throw new UserError("Failed to create Edge Script.");
85+
}
86+
87+
return {
88+
id: script.Id,
89+
name: script.Name ?? opts.name,
90+
scriptType: opts.scriptType,
91+
hostname: script.LinkedPullZones?.[0]?.DefaultHostname ?? undefined,
92+
};
93+
}
94+
95+
/**
96+
* Create a new Edge Script on bunny.net (without scaffolding a project).
97+
*
98+
* Use this when you already have a project (e.g. you ran `init` without
99+
* `--deploy`, or you're working in a custom directory) and need a remote
100+
* script before running `bunny scripts deploy`.
101+
*
102+
* By default the script name is the current directory name, a linked
103+
* pull zone is created, and the directory is linked via
104+
* `.bunny/script.json`.
105+
*
106+
* @example
107+
* ```bash
108+
* # Create using current directory name + linked manifest
109+
* bunny scripts create
110+
*
111+
* # Explicit name, middleware type, no pull zone
112+
* bunny scripts create my-script --type middleware --no-pull-zone
113+
*
114+
* # Create without linking (.bunny/script.json untouched)
115+
* bunny scripts create my-script --no-link
116+
* ```
117+
*/
118+
export const scriptsCreateCommand = defineCommand<CreateArgs>({
119+
command: COMMAND,
120+
describe: DESCRIPTION,
121+
examples: [
122+
["$0 scripts create", "Create using current directory name"],
123+
[
124+
"$0 scripts create my-script --type middleware",
125+
"Create a middleware script with an explicit name",
126+
],
127+
[
128+
"$0 scripts create my-script --no-pull-zone --no-link",
129+
"Skip pull zone creation and directory linking",
130+
],
131+
],
132+
133+
builder: (yargs) =>
134+
yargs
135+
.positional(ARG_NAME, {
136+
type: "string",
137+
describe: ARG_NAME_DESCRIPTION,
138+
})
139+
.option(ARG_TYPE, {
140+
type: "string",
141+
choices: ["standalone", "middleware"],
142+
describe: ARG_TYPE_DESCRIPTION,
143+
})
144+
.option(ARG_PULL_ZONE, {
145+
type: "boolean",
146+
describe: ARG_PULL_ZONE_DESCRIPTION,
147+
})
148+
.option(ARG_PULL_ZONE_NAME, {
149+
type: "string",
150+
describe: ARG_PULL_ZONE_NAME_DESCRIPTION,
151+
})
152+
.option(ARG_LINK, {
153+
type: "boolean",
154+
describe: ARG_LINK_DESCRIPTION,
155+
}),
156+
157+
handler: async (args) => {
158+
const { profile, output, verbose, apiKey } = args;
159+
const isInteractive = output !== "json" && process.stdout.isTTY;
160+
161+
const name = args[ARG_NAME] ?? basename(resolve(process.cwd()));
162+
if (!name) throw new UserError("Script name is required.");
163+
164+
// Resolve script type: explicit flag → manifest → prompt → error.
165+
let scriptType: EdgeScriptTypes | undefined;
166+
if (args[ARG_TYPE]) {
167+
scriptType = args[ARG_TYPE] === "standalone" ? 1 : 2;
168+
} else {
169+
const manifest = loadManifest(SCRIPT_MANIFEST);
170+
if (manifest.scriptType === 1 || manifest.scriptType === 2) {
171+
scriptType = manifest.scriptType as EdgeScriptTypes;
172+
} else if (isInteractive) {
173+
const { value } = await prompts({
174+
type: "select",
175+
name: "value",
176+
message: "Script type:",
177+
choices: [
178+
{ title: "Standalone — handles requests independently", value: 1 },
179+
{
180+
title: "Middleware — processes requests before/after origin",
181+
value: 2,
182+
},
183+
],
184+
});
185+
scriptType = value;
186+
}
187+
}
188+
if (scriptType !== 1 && scriptType !== 2) {
189+
throw new UserError(
190+
"Script type is required.",
191+
"Pass --type standalone or --type middleware.",
192+
);
193+
}
194+
195+
const created = await createScript({
196+
profile,
197+
apiKey,
198+
verbose,
199+
name,
200+
scriptType,
201+
createLinkedPullZone: args[ARG_PULL_ZONE] !== false,
202+
linkedPullZoneName: args[ARG_PULL_ZONE_NAME],
203+
});
204+
205+
// Decide whether to link this directory to the new script.
206+
const existing = loadManifest(SCRIPT_MANIFEST);
207+
const linkArg = args[ARG_LINK];
208+
let shouldLink: boolean;
209+
if (linkArg !== undefined) {
210+
shouldLink = linkArg;
211+
} else if (isInteractive && existing.id && existing.id !== created.id) {
212+
shouldLink = await confirm(
213+
`Replace existing link to ${existing.name ?? existing.id}?`,
214+
);
215+
} else {
216+
shouldLink = true;
217+
}
218+
219+
if (shouldLink) {
220+
saveManifest(SCRIPT_MANIFEST, {
221+
id: created.id,
222+
name: created.name,
223+
scriptType: created.scriptType,
224+
});
225+
}
226+
227+
if (output === "json") {
228+
logger.log(
229+
JSON.stringify(
230+
{
231+
id: created.id,
232+
name: created.name,
233+
scriptType: created.scriptType,
234+
hostname: created.hostname ?? null,
235+
linked: shouldLink,
236+
},
237+
null,
238+
2,
239+
),
240+
);
241+
return;
242+
}
243+
244+
const entries: Array<{ key: string; value: string }> = [
245+
{ key: "ID", value: String(created.id) },
246+
{ key: "Name", value: created.name },
247+
{
248+
key: "Type",
249+
value: created.scriptType === 1 ? "Standalone" : "Middleware",
250+
},
251+
];
252+
if (created.hostname) {
253+
entries.push({ key: "Hostname", value: created.hostname });
254+
}
255+
256+
logger.success(`Created script "${created.name}" (${created.id}).`);
257+
logger.log();
258+
logger.log(formatKeyValue(entries, output));
259+
260+
if (shouldLink) {
261+
logger.log();
262+
logger.success(`Linked .bunny/script.json → ${created.id}.`);
263+
}
264+
265+
logger.log();
266+
267+
if (created.hostname && isInteractive) {
268+
const shouldOpen = await confirm("Open script in browser?");
269+
if (shouldOpen) {
270+
const url = created.hostname.startsWith("http")
271+
? created.hostname
272+
: `https://${created.hostname}`;
273+
logger.dim(` Opening ${url}`);
274+
openBrowser(url);
275+
} else {
276+
logger.dim(
277+
" Make changes locally, then run `bunny scripts deploy <file>` to publish.",
278+
);
279+
}
280+
} else {
281+
logger.dim(` Deploy: bunny scripts deploy <file>`);
282+
}
283+
},
284+
});

packages/cli/src/commands/scripts/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineNamespace } from "../../core/define-namespace.ts";
2+
import { scriptsCreateCommand } from "./create.ts";
23
import { scriptsDeleteCommand } from "./delete.ts";
34
import { scriptsDeployCommand } from "./deploy.ts";
45
import { scriptsDeploymentsNamespace } from "./deployments/index.ts";
@@ -13,6 +14,7 @@ export const scriptsNamespace = defineNamespace(
1314
"scripts",
1415
"Manage Edge Scripts.",
1516
[
17+
scriptsCreateCommand,
1618
scriptsDeleteCommand,
1719
scriptsDeployCommand,
1820
scriptsDeploymentsNamespace,

0 commit comments

Comments
 (0)