|
| 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 | +}); |
0 commit comments