chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:11 +08:00
commit 70bf21e064
5213 changed files with 731895 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
# @cloudflare/cli
## 0.1.13
### Patch Changes
- Updated dependencies [[`0283a1f`](https://github.com/cloudflare/workers-sdk/commit/0283a1fcdc635244f731010422e513e8b4ab0be3)]:
- @cloudflare/workers-utils@0.26.0
## 0.1.12
### Patch Changes
- Updated dependencies [[`aad35b7`](https://github.com/cloudflare/workers-sdk/commit/aad35b79d07df1bb764a4a5912d6b4328a34474b), [`1ac96a1`](https://github.com/cloudflare/workers-sdk/commit/1ac96a14b7fb022acada114ab8793fe8a4ba79a5), [`1ca8d8f`](https://github.com/cloudflare/workers-sdk/commit/1ca8d8f0bbd012a1d65cabadf7b6987b252775e9)]:
- @cloudflare/workers-utils@0.25.1
## 0.1.11
### Patch Changes
- Updated dependencies [[`aa5d580`](https://github.com/cloudflare/workers-sdk/commit/aa5d5801450b7e4417bfdbd477f86de3a4bc6933)]:
- @cloudflare/workers-utils@0.25.0
## 0.1.10
### Patch Changes
- Updated dependencies [[`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359), [`cfd6205`](https://github.com/cloudflare/workers-sdk/commit/cfd6205fe86f6afd74b5881f09524c93c83b8359)]:
- @cloudflare/workers-utils@0.24.0
## 0.1.9
### Patch Changes
- Updated dependencies [[`673b09e`](https://github.com/cloudflare/workers-sdk/commit/673b09e0fa26368125fb527596a8eb5d31c27302), [`5dfb788`](https://github.com/cloudflare/workers-sdk/commit/5dfb788595a2104b4b0922cfce3d69a2f1d881eb)]:
- @cloudflare/workers-utils@0.23.2
## 0.1.8
### Patch Changes
- Updated dependencies [[`ecfdd5a`](https://github.com/cloudflare/workers-sdk/commit/ecfdd5a6c60b9c6f99c28f9294da656933c2a5fd)]:
- @cloudflare/workers-utils@0.23.1
## 0.1.7
### Patch Changes
- Updated dependencies [[`c6c61b5`](https://github.com/cloudflare/workers-sdk/commit/c6c61b59431443b2bcda25f3af7624dd2ce19b9b), [`b502d54`](https://github.com/cloudflare/workers-sdk/commit/b502d5445b9e9e030020a3d65c0334507393aa64), [`c4f45e8`](https://github.com/cloudflare/workers-sdk/commit/c4f45e8b8694c60fb1808f7fbb130e4b4893d20c)]:
- @cloudflare/workers-utils@0.23.0
## 0.1.6
### Patch Changes
- [#14112](https://github.com/cloudflare/workers-sdk/pull/14112) [`3a746ac`](https://github.com/cloudflare/workers-sdk/commit/3a746ac56a40b805e38f26ef5328e44917b543e6) Thanks [@penalosa](https://github.com/penalosa)! - Pin non-bundled runtime dependencies to exact versions
Dependencies that are not bundled into a package's published output are installed directly into consumers' dependency trees, so they are now pinned to exact versions instead of semver ranges. This closes a supply-chain gap where an unpinned external dependency could resolve to a compromised upstream release on a fresh install. A new `pnpm check:pinned-deps` lint enforces this for all published packages (and for the shared pnpm catalog) going forward.
- Updated dependencies [[`e86489a`](https://github.com/cloudflare/workers-sdk/commit/e86489a5743ff9bad7bcb5b444ad3d952d5b0164), [`337e912`](https://github.com/cloudflare/workers-sdk/commit/337e9124cfa461a99ce7ffb800dcc341f7b2f026), [`65b5f9e`](https://github.com/cloudflare/workers-sdk/commit/65b5f9e1855651c2df2c1bdfc8930141e36413d5)]:
- @cloudflare/workers-utils@0.22.1
## 0.1.5
### Patch Changes
- Updated dependencies [[`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`599b27a`](https://github.com/cloudflare/workers-sdk/commit/599b27aafb9bc432524a35eb4e5a414de21bef41), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee), [`e3c862a`](https://github.com/cloudflare/workers-sdk/commit/e3c862a99f9b633ca288306eae8a8c3a900590ee)]:
- @cloudflare/workers-utils@0.22.0
## 0.1.4
### Patch Changes
- Updated dependencies [[`90092c0`](https://github.com/cloudflare/workers-sdk/commit/90092c0bca526e2e08a25fe7969534426eb6fd9f)]:
- @cloudflare/workers-utils@0.21.1
## 0.1.3
### Patch Changes
- Updated dependencies [[`248bc08`](https://github.com/cloudflare/workers-sdk/commit/248bc08152cf9f792d98c8c78f8fb1417b1bb3b3)]:
- @cloudflare/workers-utils@0.21.0
## 0.1.2
### Patch Changes
- Updated dependencies [[`f3fed88`](https://github.com/cloudflare/workers-sdk/commit/f3fed8859b612d424388fe45a1d638cf6b1c42c7)]:
- @cloudflare/workers-utils@0.20.0
## 0.1.1
### Patch Changes
- Updated dependencies [[`2b8c0cc`](https://github.com/cloudflare/workers-sdk/commit/2b8c0ccb9ede7487bd96cfc51b3262a717bb532c)]:
- @cloudflare/workers-utils@0.19.0
## 0.1.0
### Minor Changes
- [#13651](https://github.com/cloudflare/workers-sdk/pull/13651) [`47ac63f`](https://github.com/cloudflare/workers-sdk/commit/47ac63f05ca86d781110490ff21ff88f2828fbbf) Thanks [@penalosa](https://github.com/penalosa)! - Publish `@cloudflare/cli-shared-helpers` and `@cloudflare/workers-utils` to npm
### Patch Changes
- Updated dependencies [[`5680287`](https://github.com/cloudflare/workers-sdk/commit/56802879641c123ee11160d77ecaf104915cd826), [`47ac63f`](https://github.com/cloudflare/workers-sdk/commit/47ac63f05ca86d781110490ff21ff88f2828fbbf)]:
- @cloudflare/workers-utils@0.18.0
## 1.4.0
### Minor Changes
- [#13144](https://github.com/cloudflare/workers-sdk/pull/13144) [`db60b94`](https://github.com/cloudflare/workers-sdk/commit/db60b94d9620e7608df3e98876d5df4fde952ecf) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add gitignore helpers for appending Wrangler-related entries
New `maybeAppendWranglerToGitIgnore` and `maybeAppendWranglerToGitIgnoreLikeFile` functions that automatically append Wrangler-related entries (`.wrangler`, `.dev.vars*`, `.env*`, and their negated example patterns) to `.gitignore` or similar ignore files. Existing entries are detected and skipped to avoid duplicates.
## 1.3.0
### Minor Changes
- [#13068](https://github.com/cloudflare/workers-sdk/pull/13068) [`e631a94`](https://github.com/cloudflare/workers-sdk/commit/e631a946466ded834763497d094da9e93d3d3721) Thanks [@dario-piotrowicz](https://github.com/dario-piotrowicz)! - Add `runCommand` and `quoteShellArgs`, `installPackages` and `installWrangler` utilities to `@cloudflare/cli/command`
These utilities are now available from `@cloudflare/cli` as dedicated sub-path exports: `runCommand` and `quoteShellArgs` via `@cloudflare/cli/command`, and `installPackages` and `installWrangler` via `@cloudflare/cli/packages`. This makes them reusable across packages in the SDK without duplication.
## 1.2.1
### Patch Changes
- [#11940](https://github.com/cloudflare/workers-sdk/pull/11940) [`69ff962`](https://github.com/cloudflare/workers-sdk/commit/69ff9620487a6ae979f369eb1dbac887ce46e246) Thanks [@penalosa](https://github.com/penalosa)! - Mark macOS version compatibility errors as user errors
When checking macOS version compatibility, the CLI now throws `UserError` instead of generic `Error`. This ensures that version incompatibility issues are properly classified as user-facing errors that shouldn't be reported to Sentry.
- [#11967](https://github.com/cloudflare/workers-sdk/pull/11967) [`202c59e`](https://github.com/cloudflare/workers-sdk/commit/202c59e4f4f28419fb6ac0aa8c7dc3960a0c8d3e) Thanks [@emily-shen](https://github.com/emily-shen)! - chore: update undici
The following dependency versions have been updated:
| Dependency | From | To |
| ---------- | ------ | ------ |
| undici | 7.14.0 | 7.18.2 |
## 1.2.0
### Minor Changes
- [#11578](https://github.com/cloudflare/workers-sdk/pull/11578) [`4201472`](https://github.com/cloudflare/workers-sdk/commit/4201472291fa1c864dbcca40c173a76e5b571a04) Thanks [@gpanders](https://github.com/gpanders)! - Add showCursor helper function to show or hide the cursor in the terminal
## 1.1.4
### Patch Changes
- [#11448](https://github.com/cloudflare/workers-sdk/pull/11448) [`2b4813b`](https://github.com/cloudflare/workers-sdk/commit/2b4813b18076817bb739491246313c32b403651f) Thanks [@edmundhung](https://github.com/edmundhung)! - Builds package with esbuild `v0.27.0`
## 1.1.3
### Patch Changes
- [#10764](https://github.com/cloudflare/workers-sdk/pull/10764) [`79a6b7d`](https://github.com/cloudflare/workers-sdk/commit/79a6b7dd811fea5a413b084fcd281915a418a85a) Thanks [@emily-shen](https://github.com/emily-shen)! - fix: respect the log level set by wrangler when logging using @cloudflare/cli
## 1.1.2
### Patch Changes
- [#10492](https://github.com/cloudflare/workers-sdk/pull/10492) [`8ae9323`](https://github.com/cloudflare/workers-sdk/commit/8ae9323a5c45f3efe3685e36b1536cc25d39fbfb) Thanks [@gpanders](https://github.com/gpanders)! - Include cursor in text prompts
## 1.1.1
### Patch Changes
- [#4768](https://github.com/cloudflare/workers-sdk/pull/4768) [`c3e410c2`](https://github.com/cloudflare/workers-sdk/commit/c3e410c2797f5c59b9ea0f63c20feef643366df2) Thanks [@petebacondarwin](https://github.com/petebacondarwin)! - ci: bump undici versions to 5.28.2
## 1.1.0
### Minor Changes
- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added a new `SelectRefreshablePrompt` component
This component supports infinite scrolling, and the ability to refresh the items on 'R' keypress. See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details.
* [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added `@clack/core`'s `MultiSelectPrompt` component
See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details.
- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Added `processArguments` helper function
See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details.
### Patch Changes
- [#4779](https://github.com/cloudflare/workers-sdk/pull/4779) [`06bb99e1`](https://github.com/cloudflare/workers-sdk/commit/06bb99e1c91ffe5305343a17353912e734f5bd0c) Thanks [@1000hz](https://github.com/1000hz)! - Downgraded `chalk` dependency from `^5.2.0` to `^2.4.2`
This was done for compatibility reasons with the version used in the `wrangler` package. See [#4310](https://github.com/cloudflare/workers-sdk/pull/4310) for more details.
## 1.0.0
### Major Changes
- [#4189](https://github.com/cloudflare/workers-sdk/pull/4189) [`05798038`](https://github.com/cloudflare/workers-sdk/commit/05798038c85a83afb2c0e8ea9533c31a6fbe3e91) Thanks [@gabivlj](https://github.com/gabivlj)! - Move helper cli files of C3 into @cloudflare/cli and make Wrangler and C3 depend on it
### Patch Changes
- [#4271](https://github.com/cloudflare/workers-sdk/pull/4271) [`70016b2b`](https://github.com/cloudflare/workers-sdk/commit/70016b2bb514ea95f1ce0db3582e194c31df4c14) Thanks [@gabivlj](https://github.com/gabivlj)! - change backgrounds of statuses from red to more appropriate ones
+5
View File
@@ -0,0 +1,5 @@
# Cloudflare CLI
> Internal utility package for workers-sdk. Not intended for external use — APIs may change without notice.
All things related to rendering the CLI for workers-sdk.
@@ -0,0 +1,161 @@
import os from "node:os";
import ci from "ci-info";
import { beforeEach, describe, it, vi } from "vitest";
import { checkMacOSVersion } from "../check-macos-version";
vi.mock("node:os");
vi.mock("ci-info", () => ({
default: { isCI: false },
isCI: false,
}));
const mockOs = vi.mocked(os);
describe("checkMacOSVersion", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ci).isCI = false;
});
it("should not throw on non-macOS platforms", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should not throw on macOS 13.5.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("22.6.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should not throw on macOS 14.0.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("23.0.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should not throw on macOS 13.6.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("22.7.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should throw error on macOS 12.7.6", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("21.6.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow(
"Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (12.6.0)"
);
});
it("should throw error on macOS 13.4.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("22.4.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).toThrow(
"Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (13.4.0)"
);
});
it("should handle invalid Darwin version format gracefully", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("invalid-version");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should handle very old Darwin versions gracefully", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("19.6.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
it("should not throw when running in CI", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.mocked(ci).isCI = true;
mockOs.release.mockReturnValue("21.6.0");
expect(() => checkMacOSVersion({ shouldThrow: true })).not.toThrow();
});
});
describe("checkMacOSVersion with shouldThrow=false", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ci).isCI = false;
});
it("should not warn on non-macOS platforms", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).not.toHaveBeenCalled();
});
it("should not warn on macOS 13.5.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("22.6.0");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).not.toHaveBeenCalled();
});
it("should warn on macOS 12.7.6", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("21.6.0");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"⚠️ Warning: Unsupported macOS version detected (12.6.0)"
)
);
});
it("should warn on macOS 13.4.0", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("22.4.0");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"⚠️ Warning: Unsupported macOS version detected (13.4.0)"
)
);
});
it("should not warn when running in CI", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
vi.mocked(ci).isCI = true;
mockOs.release.mockReturnValue("21.6.0");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).not.toHaveBeenCalled();
});
it("should not warn on invalid Darwin version format", ({ expect }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
mockOs.release.mockReturnValue("invalid-version");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
checkMacOSVersion({ shouldThrow: false });
expect(warnSpy).not.toHaveBeenCalled();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { describe, test } from "vitest";
import { space } from "..";
describe("cli", () => {
test("test spaces", ({ expect }) => {
expect(space(300)).toMatchInlineSnapshot(
'""'
);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from "cross-spawn";
import { afterEach, beforeEach, describe, test, vi } from "vitest";
import { quoteShellArgs, runCommand } from "../command";
import type { ChildProcess } from "node:child_process";
// We can change how the mock spawn works by setting these variables
let spawnResultCode = 0;
let spawnStdout: string | undefined = undefined;
let spawnStderr: string | undefined = undefined;
vi.mock("cross-spawn");
describe("Command Helpers", () => {
afterEach(() => {
spawnResultCode = 0;
spawnStdout = undefined;
spawnStderr = undefined;
});
beforeEach(() => {
vi.mocked(spawn).mockImplementation(() => {
return {
on: vi.fn().mockImplementation((event, cb) => {
if (event === "close") {
cb(spawnResultCode);
}
}),
stdout: {
on(_event: "data", cb: (data: string) => void) {
if (spawnStdout !== undefined) {
cb(spawnStdout);
}
},
},
stderr: {
on(_event: "data", cb: (data: string) => void) {
if (spawnStderr !== undefined) {
cb(spawnStderr);
}
},
},
} as unknown as ChildProcess;
});
});
test("runCommand", async ({ expect }) => {
await runCommand(["ls", "-l"]);
expect(spawn).toHaveBeenCalledWith("ls", ["-l"], {
stdio: "inherit",
env: process.env,
signal: expect.any(AbortSignal),
});
});
describe("quoteShellArgs", () => {
test.runIf(process.platform !== "win32")("mac", async ({ expect }) => {
expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev");
expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`'24.02 foo-bar'`);
expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual(
`'foo/10 bar/20-baz/'`
);
});
test.runIf(process.platform === "win32")("windows", async ({ expect }) => {
expect(quoteShellArgs([`pages:dev`])).toEqual("pages:dev");
expect(quoteShellArgs([`24.02 foo-bar`])).toEqual(`"24.02 foo-bar"`);
expect(quoteShellArgs([`foo/10 bar/20-baz/`])).toEqual(
`"foo/10 bar/20-baz/"`
);
});
});
});
+445
View File
@@ -0,0 +1,445 @@
import { appendFileSync, existsSync, statSync, writeFileSync } from "node:fs";
import { readFileSync } from "@cloudflare/workers-utils";
import { beforeEach, describe, test, vi } from "vitest";
import {
maybeAppendWranglerToGitIgnore,
maybeAppendWranglerToGitIgnoreLikeFile,
} from "../gitignore";
import type { PathLike } from "node:fs";
import type { Mock } from "vitest";
vi.mock("node:fs");
vi.mock("@cloudflare/workers-utils");
vi.mock("../interactive", () => ({
spinner: () => ({
start: vi.fn(),
stop: vi.fn(),
update: vi.fn(),
}),
}));
describe("maybeAppendWranglerToGitIgnoreLikeFile", () => {
let writeFileSyncMock: Mock;
let appendFileSyncMock: Mock;
beforeEach(() => {
vi.resetAllMocks();
writeFileSyncMock = vi.mocked(writeFileSync);
appendFileSyncMock = vi.mocked(appendFileSync);
});
test("should append the wrangler section to a standard gitignore file", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.vscode`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should not touch the file if it already contains all wrangler files", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.vscode
.wrangler
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock).not.toHaveBeenCalled();
});
test("should not touch the file if it contains all wrangler files (and can cope with comments)", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.wrangler # This is for wrangler
.dev.vars* # this is for wrangler and getPlatformProxy
!.dev.vars.example # more comments
.env* # even more
!.env.example # and a final one
.vscode
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock).not.toHaveBeenCalled();
});
test("should append to the file the missing wrangler files when some are already present (should add the section heading if including .wrangler and some others)", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.dev.vars*
.vscode`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
# wrangler files
.wrangler
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should append to the file the missing wrangler files when some are already present (should not add the section heading if .wrangler already exists)", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.wrangler
.dev.vars*
.vscode`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should append to the file the missing wrangler files when some are already present (should not add the section heading if only adding .wrangler)", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.vscode`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
.wrangler
",
],
]
`);
});
test("when it appends to the file it doesn't include an empty line only if there was one already", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.vscode
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
.wrangler
",
],
]
`);
});
test("should create the file if it didn't exist already", ({ expect }) => {
mockIgnoreFile("my-project/.gitignore", "");
// pretend that the file doesn't exist
vi.mocked(existsSync).mockImplementation(() => false);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
// writeFile wrote the (empty) file
expect(writeFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"",
],
]
`);
// and the correct lines were then added to it
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should add the wildcard .dev.vars* entry even if a .dev.vars is already included", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.dev.vars
.vscode
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should not add the .env entries if some form of .env entries are already included", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
.env
.env.*
!.env.example
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
",
],
]
`);
});
test("should not add the .wrangler entry if a .wrangler/ is already included", ({
expect,
}) => {
mockIgnoreFile(
"my-project/.gitignore",
`
node_modules
.wrangler/ # This is for wrangler
.vscode
`
);
maybeAppendWranglerToGitIgnoreLikeFile("my-project/.gitignore");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
function mockIgnoreFile(path: string, content: string) {
vi.mocked(existsSync).mockImplementation(
(filePath: PathLike) => filePath === path
);
vi.mocked(readFileSync).mockImplementation((filePath: string) =>
filePath === path ? content.replace(/\n\s*/g, "\n") : ""
);
}
});
describe("maybeAppendWranglerToGitIgnore", () => {
let appendFileSyncMock: Mock;
beforeEach(() => {
vi.resetAllMocks();
appendFileSyncMock = vi.mocked(appendFileSync);
vi.mocked(statSync).mockImplementation(((path: string) => ({
isDirectory() {
return path.endsWith(".git");
},
})) as typeof statSync);
});
test("should not create the gitignore file if neither the .git directory not the .gitingore file exist", ({
expect,
}) => {
// no .gitignore file exists
vi.mocked(existsSync).mockImplementation(() => false);
// neither a .git directory does
vi.mocked(statSync).mockImplementation(() => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
maybeAppendWranglerToGitIgnore("my-project");
expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled();
expect(appendFileSyncMock).not.toHaveBeenCalled();
});
test("should create a .gitignore file when .git directory exists", ({
expect,
}) => {
// no .gitignore file exists
vi.mocked(existsSync).mockImplementation(() => false);
vi.mocked(readFileSync).mockImplementation(() => "");
maybeAppendWranglerToGitIgnore("my-project");
// writeFileSync created the (empty) file
expect(vi.mocked(writeFileSync).mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"",
],
]
`);
// and the correct wrangler lines were appended to it
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
test("should append wrangler entries when the .gitignore file exists but the .git directory does not", ({
expect,
}) => {
// .gitignore exists
vi.mocked(existsSync).mockImplementation(
(filePath: PathLike) => filePath === "my-project/.gitignore"
);
vi.mocked(readFileSync).mockImplementation((filePath: string) =>
filePath === "my-project/.gitignore" ? "\nnode_modules\n.vscode\n" : ""
);
// .git directory does NOT exist
vi.mocked(statSync).mockImplementation(() => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
maybeAppendWranglerToGitIgnore("my-project");
expect(appendFileSyncMock.mock.calls).toMatchInlineSnapshot(`
[
[
"my-project/.gitignore",
"
# wrangler files
.wrangler
.dev.vars*
!.dev.vars.example
.env*
!.env.example
",
],
]
`);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils";
import { afterEach, describe, test, vi } from "vitest";
import { runCommand } from "../command";
import { installPackages, installWrangler } from "../packages";
vi.mock("../command");
vi.mock("@cloudflare/workers-utils", () => ({
readFileSync: vi.fn(),
parsePackageJSON: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));
describe("Package Helpers", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("installPackages", async () => {
type TestCase = {
pm: "npm" | "pnpm" | "yarn" | "bun";
initialArgs: string[];
additionalArgs?: string[];
};
const cases: TestCase[] = [
{ pm: "npm", initialArgs: ["npm", "install"] },
{ pm: "pnpm", initialArgs: ["pnpm", "install"] },
{ pm: "bun", initialArgs: ["bun", "add"] },
{ pm: "yarn", initialArgs: ["yarn", "add"] },
];
test.for(cases)(
"with $pm",
async ({ pm, initialArgs, additionalArgs }, { expect }) => {
const mockPkgJson = {
dependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "^1.2.3",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);
const packages = ["foo", "bar@latest", "baz@1.2.3"];
await installPackages(pm, packages);
expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
[...initialArgs, ...packages, ...(additionalArgs ?? [])],
expect.anything()
);
if (pm === "npm") {
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json"));
expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({
dependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "1.2.3",
},
});
}
}
);
const devCases: TestCase[] = [
{ pm: "npm", initialArgs: ["npm", "install", "--save-dev"] },
{ pm: "pnpm", initialArgs: ["pnpm", "install", "--save-dev"] },
{ pm: "bun", initialArgs: ["bun", "add", "-d"] },
{ pm: "yarn", initialArgs: ["yarn", "add", "-D"] },
];
test.for(devCases)(
"with $pm (dev = true)",
async ({ pm, initialArgs, additionalArgs }, { expect }) => {
const mockPkgJson = {
devDependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "^1.2.3",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);
const packages = ["foo", "bar@latest", "baz@1.2.3"];
await installPackages(pm, packages, { dev: true });
expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
[...initialArgs, ...packages, ...(additionalArgs ?? [])],
expect.anything()
);
if (pm === "npm") {
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
expect(writeFileCall[0]).toBe(resolve(process.cwd(), "package.json"));
expect(JSON.parse(writeFileCall[1] as string)).toMatchObject({
devDependencies: {
foo: "^1.0.0",
bar: "^2.0.0",
baz: "1.2.3",
},
});
}
}
);
});
test("installWrangler", async ({ expect }) => {
const mockPkgJson = {
devDependencies: {
wrangler: "^4.0.0",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);
await installWrangler("npm", false);
expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
["npm", "install", "--save-dev", "wrangler@latest"],
expect.anything()
);
});
test("installWrangler updates existing workers-types", async ({ expect }) => {
const mockPkgJson = {
devDependencies: {
"@cloudflare/workers-types": "^4.20241011.0",
wrangler: "^3.60.3",
},
};
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockPkgJson));
vi.mocked(parsePackageJSON).mockReturnValue(mockPkgJson);
await installWrangler("npm", false);
expect(vi.mocked(runCommand)).toHaveBeenCalledWith(
[
"npm",
"install",
"--save-dev",
"wrangler@latest",
"@cloudflare/workers-types@latest",
],
expect.anything()
);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { inputPrompt } from "./interactive";
import type { Arg, PromptConfig } from "./interactive";
export const processArgument = async <T>(
args: Record<string, Arg>,
name: string,
promptConfig: PromptConfig
) => {
const value = args[name];
const result = await inputPrompt<T>({
...promptConfig,
// Accept the default value if the arg is already set
acceptDefault: promptConfig.acceptDefault ?? value !== undefined,
defaultValue: value ?? promptConfig.defaultValue,
});
// Update value in args before returning the result
args[name] = result as Arg;
return result;
};
+109
View File
@@ -0,0 +1,109 @@
import os from "node:os";
import { UserError } from "@cloudflare/workers-utils";
import ci from "ci-info";
/**
* Minimum macOS version required for workerd compatibility.
*/
const MINIMUM_MACOS_VERSION = "13.5.0";
/**
* Checks the current macOS version for workerd compatibility.
*
* This function is a no-op on non-Darwin platforms and in CI environments.
*
* @param options - Configuration object
* @param options.shouldThrow - If true, throws an error on unsupported versions. If false, logs a warning.
*/
export function checkMacOSVersion(options: { shouldThrow: boolean }): void {
if (process.platform !== "darwin") {
return;
}
if (ci.isCI) {
return;
}
const release = os.release();
const macOSVersion = darwinVersionToMacOSVersion(release);
if (macOSVersion && isVersionLessThan(macOSVersion, MINIMUM_MACOS_VERSION)) {
if (options.shouldThrow) {
throw new UserError(
`Unsupported macOS version: The Cloudflare Workers runtime cannot run on the current version of macOS (${macOSVersion}). ` +
`The minimum requirement is macOS ${MINIMUM_MACOS_VERSION}+. See https://github.com/cloudflare/workerd?tab=readme-ov-file#running-workerd ` +
`If you cannot upgrade your version of macOS, you could try running in a DevContainer setup with a supported version of Linux (glibc 2.35+ required).`,
{ telemetryMessage: false }
);
} else {
/* eslint-disable-next-line no-console -- this package has no logger to use
(TODO: disable the `no-console` rule on packages that do not use a logger) */
console.warn(
`⚠️ Warning: Unsupported macOS version detected (${macOSVersion}). ` +
`The Cloudflare Workers runtime may not work correctly on macOS versions below ${MINIMUM_MACOS_VERSION}. ` +
`Consider upgrading to macOS ${MINIMUM_MACOS_VERSION}+ or using a DevContainer setup with a supported version of Linux (glibc 2.35+ required).`
);
}
}
}
/**
* Converts Darwin kernel version to macOS version.
* Darwin 21.x.x = macOS 12.x (Monterey)
* Darwin 22.x.x = macOS 13.x (Ventura)
* Darwin 23.x.x = macOS 14.x (Sonoma)
* etc.
*/
function darwinVersionToMacOSVersion(darwinVersion: string): string | null {
const match = darwinVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
const major = parseInt(match[1], 10);
if (major >= 20) {
const macOSMajor = major - 9;
const minor = parseInt(match[2], 10);
const patch = parseInt(match[3], 10);
return `${macOSMajor}.${minor}.${patch}`;
}
return null;
}
/**
* Simple semver comparison for major.minor.patch versions.
* Validates that both versions follow the M.m.p format before comparison.
*/
function isVersionLessThan(version1: string, version2: string): boolean {
const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/;
const match1 = version1.match(versionRegex);
const match2 = version2.match(versionRegex);
if (!match1 || !match2) {
throw new Error(
`Invalid version format. Expected M.m.p format, got: ${version1}, ${version2}`
);
}
const [major1, minor1, patch1] = [
parseInt(match1[1], 10),
parseInt(match1[2], 10),
parseInt(match1[3], 10),
];
const [major2, minor2, patch2] = [
parseInt(match2[1], 10),
parseInt(match2[2], 10),
parseInt(match2[3], 10),
];
if (major1 !== major2) {
return major1 < major2;
}
if (minor1 !== minor2) {
return minor1 < minor2;
}
return patch1 < patch2;
}
+34
View File
@@ -0,0 +1,34 @@
import chalk from "chalk";
const { white, gray, dim, hidden, bold, cyanBright, bgCyan } = chalk;
const brandColor = chalk.hex("#BD5B08");
const black = chalk.hex("#111");
const blue = chalk.hex("#0E838F");
const bgBlue = black.bgHex("#0E838F");
const red = chalk.hex("#AB2526");
const bgRed = black.bgHex("#AB2526");
const green = chalk.hex("#218529");
const bgGreen = black.bgHex("#218529");
const yellow = chalk.hex("#7F7322");
const bgYellow = black.bgHex("#7F7322");
export {
blue,
bgBlue,
green,
bgGreen,
red,
bgRed,
yellow,
bgYellow,
cyanBright,
bgCyan,
brandColor,
dim,
white,
gray,
hidden,
bold,
};
+189
View File
@@ -0,0 +1,189 @@
import { spawn } from "cross-spawn";
import { CancelError } from "./error";
import { isInteractive, spinner } from "./interactive";
import { stripAnsi } from ".";
/**
* Command is a string array, like ['git', 'commit', '-m', '"Initial commit"']
*/
type Command = string[];
export type RunOptions = {
startText?: string;
doneText?: string | ((output: string) => string);
silent?: boolean;
captureOutput?: boolean;
useSpinner?: boolean;
env?: NodeJS.ProcessEnv;
cwd?: string;
/** If defined this function is called to all you to transform the output from the command into a new string. */
transformOutput?: (output: string) => string;
};
type PrintOptions<T> = {
promise: Promise<T> | (() => Promise<T>);
useSpinner?: boolean;
startText: string;
doneText?: string | ((output: T) => string);
};
/**
* An awaitable wrapper around `spawn` that optionally displays progress to user and processes/captures the command's output
*
* @param command - The command to run as an array of strings
* @param opts.silent - Should the command's stdout and stderr be dispalyed to the user
* @param opts.captureOutput - Should the output of the command the returned as a string.
* @param opts.env - An object of environment variables to be injected when running the command
* @param opts.cwd - The directory in which the command should be run
* @param opts.useSpinner - Should a spinner be shown when running the command
* @param opts.startText - Spinner start text
* @param opts.endText - Spinner end text
* @param opts.transformOutput - A transformer to be run on command output before returning
*
* @returns Output collected from the stdout of the command, if `captureOutput` was set to true. Otherwise `null`.
*/
export const runCommand = async (
command: Command,
opts: RunOptions = {}
): Promise<string> => {
return printAsyncStatus({
useSpinner: opts.useSpinner ?? opts.silent,
startText: opts.startText || quoteShellArgs(command),
doneText: opts.doneText,
promise() {
const [executable, ...args] = command;
const abortController = new AbortController();
const cmd = spawn(executable, [...args], {
// TODO: ideally inherit stderr, but npm install uses this for warnings
// stdio: [ioMode, ioMode, "inherit"],
stdio: opts.silent ? "pipe" : "inherit",
env: {
...process.env,
...opts.env,
},
cwd: opts.cwd,
signal: abortController.signal,
});
let output = ``;
if (opts.captureOutput ?? opts.silent) {
cmd.stdout?.on("data", (data) => {
output += data;
});
cmd.stderr?.on("data", (data) => {
output += data;
});
}
let cleanup: (() => void) | null = null;
return new Promise<string>((resolvePromise, reject) => {
const cancel = (signal?: NodeJS.Signals) => {
reject(new CancelError(`Command cancelled`, signal));
abortController.abort(signal ? `${signal} received` : null);
};
process.on("SIGTERM", cancel).on("SIGINT", cancel);
// To cleanup the signal listeners when the promise settles
cleanup = () => {
process.off("SIGTERM", cancel).off("SIGINT", cancel);
};
cmd.on("close", (code) => {
try {
if (code !== 0) {
throw new Error(output, { cause: code });
}
// Process any captured output
const transformOutput =
opts.transformOutput ?? ((result: string) => result);
const processedOutput = transformOutput(stripAnsi(output));
// Send the captured (and processed) output back to the caller
resolvePromise(processedOutput);
} catch (e) {
// Something went wrong.
// Perhaps the command or the transform failed.
reject(new Error(output, { cause: e }));
}
});
cmd.on("error", (error) => {
reject(error);
});
}).finally(() => {
cleanup?.();
});
},
});
};
const printAsyncStatus = async <T>({
promise,
...opts
}: PrintOptions<T>): Promise<T> => {
let s: ReturnType<typeof spinner> | undefined;
if (opts.useSpinner && isInteractive()) {
s = spinner();
}
s?.start(opts?.startText);
if (typeof promise === "function") {
promise = promise();
}
try {
const output = await promise;
const doneText =
typeof opts.doneText === "function"
? opts.doneText(output)
: opts.doneText;
s?.stop(doneText);
} catch (err) {
s?.stop((err as Error).message);
} finally {
s?.stop();
}
return promise;
};
/**
* Formats an array of command line arguments to be displayed to the user
* in a platform safe way. Args used in conjunction with `runCommand` are safe
* since we use `cross-spawn` to handle multi-platform support.
*
* However, when we output commands to the user, we have to make sure that they
* are compatible with the platform they are using.
*
* @param args - The arguments to format to the user
*/
export function quoteShellArgs(args: string[]): string {
if (process.platform === "win32") {
// Simple Windows command prompt quoting if there are special characters.
const specialCharsMatcher = /[&<>[\]|{}^=;!'+,`~\s]/;
return args
.map((arg) =>
arg.match(specialCharsMatcher) ? `"${arg.replaceAll(`"`, `""`)}"` : arg
)
.join(" ");
} else {
return args
.map((s) => {
if (/["\s]/.test(s) && !/'/.test(s)) {
return "'" + s.replace(/(['\\])/g, "\\$1") + "'";
}
if (/["'\s]/.test(s)) {
return '"' + s.replace(/(["\\$`!])/g, "\\$1") + '"';
}
return s;
})
.join(" ");
}
}
+13
View File
@@ -0,0 +1,13 @@
import process from "node:process";
export function showCursor(show: boolean, stream = process.stderr) {
if (!stream.isTTY) {
return;
}
if (show) {
stream.write("\x1b[?25h");
} else {
stream.write("\x1b[?25l");
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Error thrown when an operation is cancelled
*/
export class CancelError extends Error {
constructor(
message?: string,
readonly signal?: NodeJS.Signals
) {
super(message);
}
}
+151
View File
@@ -0,0 +1,151 @@
import { appendFileSync, existsSync, statSync, writeFileSync } from "node:fs";
import { basename } from "node:path";
import { readFileSync } from "@cloudflare/workers-utils";
import { brandColor, dim } from "./colors";
import { spinner } from "./interactive";
/**
* Appends to a file that follows a .gitignore-like structure any missing
* Wrangler-related entries (`.wrangler`, `.dev.vars*`, `.env*`, and their
* negated example patterns).
*
* Creates the file if it does not already exist.
*
* @param filePath Absolute or relative path to the ignore file to update.
*/
export function maybeAppendWranglerToGitIgnoreLikeFile(filePath: string): void {
const filePreExisted = existsSync(filePath);
if (!filePreExisted) {
writeFileSync(filePath, "");
}
const existingGitIgnoreContent = readFileSync(filePath);
const wranglerGitIgnoreFilesToAdd: string[] = [];
const hasDotWrangler = existingGitIgnoreContent.match(
/^\/?\.wrangler(\/|\s|$)/m
);
if (!hasDotWrangler) {
wranglerGitIgnoreFilesToAdd.push(".wrangler");
}
const hasDotDevDotVars = existingGitIgnoreContent.match(
/^\/?\.dev\.vars\*(\s|$)/m
);
if (!hasDotDevDotVars) {
wranglerGitIgnoreFilesToAdd.push(".dev.vars*");
}
const hasDotDevVarsExample = existingGitIgnoreContent.match(
/^!\/?\.dev\.vars\.example(\s|$)/m
);
if (!hasDotDevVarsExample) {
wranglerGitIgnoreFilesToAdd.push("!.dev.vars.example");
}
/**
* We check for the following type of occurrences:
*
* ```
* .env
* .env*
* .env.<local|production|staging|...>
* .env*.<local|production|staging|...>
* ```
*
* Any of these may alone on a line or be followed by a space and a trailing comment:
*
* ```
* .env.<local|production|staging> # some trailing comment
* ```
*/
const hasDotEnv = existingGitIgnoreContent.match(
/^\/?\.env\*?(\..*?)?(\s|$)/m
);
if (!hasDotEnv) {
wranglerGitIgnoreFilesToAdd.push(".env*");
}
const hasDotEnvExample = existingGitIgnoreContent.match(
/^!\/?\.env\.example(\s|$)/m
);
if (!hasDotEnvExample) {
wranglerGitIgnoreFilesToAdd.push("!.env.example");
}
if (wranglerGitIgnoreFilesToAdd.length === 0) {
return;
}
const s = spinner();
s.start(`Adding Wrangler files to the ${basename(filePath)} file`);
const linesToAppend = [
...(filePreExisted
? ["", ...(!existingGitIgnoreContent.match(/\n\s*$/) ? [""] : [])]
: []),
];
if (!hasDotWrangler && wranglerGitIgnoreFilesToAdd.length > 1) {
linesToAppend.push("# wrangler files");
}
wranglerGitIgnoreFilesToAdd.forEach((line) => linesToAppend.push(line));
linesToAppend.push("");
appendFileSync(filePath, linesToAppend.join("\n"));
const fileName = basename(filePath);
s.stop(
`${brandColor(filePreExisted ? "updated" : "created")} ${dim(
`${fileName} file`
)}`
);
}
/**
* Appends any missing Wrangler-related entries to the project's `.gitignore` file.
*
* Bails out only when *neither* a `.gitignore` file nor a `.git` directory exists,
* which indicates the project is likely not targeting/using git. If either one is
* present the entries are appended (creating `.gitignore` if needed).
*
* @param projectPath Root directory of the project.
*/
export function maybeAppendWranglerToGitIgnore(projectPath: string): void {
const gitIgnorePath = `${projectPath}/.gitignore`;
const gitIgnorePreExisted = existsSync(gitIgnorePath);
const gitDirExists = directoryExists(`${projectPath}/.git`);
if (!gitIgnorePreExisted && !gitDirExists) {
// if there is no .gitignore file and neither a .git directory
// then bail as the project is likely not targeting/using git
return;
}
maybeAppendWranglerToGitIgnoreLikeFile(gitIgnorePath);
}
/**
* Checks whether a directory exists at the given path.
*
* Returns `false` when the path does not exist (`ENOENT`). Re-throws any
* other filesystem error.
*
* @param path Path to check
* @returns `true` if a directory exists at `path`, `false` otherwise
*/
function directoryExists(path: string): boolean {
try {
const stat = statSync(path);
return stat.isDirectory();
} catch (error) {
if ((error as { code: string }).code === "ENOENT") {
return false;
}
throw new Error(error as string);
}
}
+280
View File
@@ -0,0 +1,280 @@
import { exit } from "node:process";
import {
bgBlue,
bgGreen,
bgRed,
bgYellow,
brandColor,
dim,
gray,
hidden,
white,
} from "./colors";
import { stderr, stdout } from "./streams";
export const shapes = {
diamond: "◇",
dash: "─",
radioInactive: "○",
radioActive: "●",
backActive: "◀",
backInactive: "◁",
bar: "│",
leftT: "├",
rigthT: "┤",
arrows: {
left: "",
right: "",
},
corners: {
tl: "╭",
bl: "╰",
tr: "╮",
br: "╯",
},
};
export const status = {
error: bgRed(` ERROR `),
warning: bgYellow(` WARNING `),
info: bgBlue(` INFO `),
success: bgGreen(` SUCCESS `),
cancel: white.bgRed(` X `),
};
// Returns a string containing n non-trimmable spaces
// This is useful for places where clack trims lines of output
// but we need leading spaces
export const space = (n = 1) => {
return hidden("\u200A".repeat(n));
};
const LOGGER_LEVELS = {
none: -1,
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
} as const;
export type LoggerLevel = keyof typeof LOGGER_LEVELS;
// Global log level that can be set by consuming packages
let currentLogLevel: LoggerLevel = "log";
export function setLogLevel(level: LoggerLevel) {
currentLogLevel = level;
}
export function getLogLevel(): LoggerLevel {
return currentLogLevel;
}
// Primitive for printing to stdout. Use this instead of
// console.log or printing to stdout directly
export const logRaw = (msg: string) => {
// treat all log calls as 'log' level logs
const currentLevel = getLogLevel();
// Only output if current log level allows 'log' level messages
if (LOGGER_LEVELS[currentLevel] >= LOGGER_LEVELS.log) {
stdout.write(`${msg}\n`);
}
};
// A simple stylized log for use within a prompt
export const log = (msg: string) => {
const lines = msg
.split("\n")
.map((ln) => `${gray(shapes.bar)}${ln.length > 0 ? " " + white(ln) : ""}`);
logRaw(lines.join("\n"));
};
export const newline = () => {
log("");
};
type FormatOptions = {
linePrefix?: string;
firstLinePrefix?: string;
newlineBefore?: boolean;
newlineAfter?: boolean;
formatLine?: (line: string) => string;
multiline?: boolean;
};
export const format = (
msg: string,
{
linePrefix = gray(shapes.bar),
firstLinePrefix = linePrefix,
newlineBefore = false,
newlineAfter = false,
formatLine = (line: string) => white(line),
multiline = true,
}: FormatOptions = {}
) => {
const lines = multiline ? msg.split("\n") : [msg];
const formattedLines = lines.map(
(line, i) =>
(i === 0 ? firstLinePrefix : linePrefix) + space() + formatLine(line)
);
if (newlineBefore) {
formattedLines.unshift(linePrefix);
}
if (newlineAfter) {
formattedLines.push(linePrefix);
}
return formattedLines.join("\n");
};
// Log a simple status update with a style similar to the clack spinner
export const updateStatus = (msg: string, printNewLine = true) => {
logRaw(
format(msg, {
firstLinePrefix: gray(shapes.leftT),
linePrefix: gray(shapes.bar),
newlineAfter: printNewLine,
})
);
};
export const startSection = (
heading: string,
subheading?: string,
printNewLine = true
) => {
logRaw(
`${gray(shapes.corners.tl)} ${brandColor(heading)} ${
subheading ? dim(subheading) : ""
}`
);
if (printNewLine) {
newline();
}
};
export const endSection = (heading: string, subheading?: string) => {
logRaw(
`${gray(shapes.corners.bl)} ${brandColor(heading)} ${
subheading ? dim(subheading) : ""
}\n`
);
};
export const cancel = (
msg: string,
{
// current default is backcompat and makes sense going forward too
shape = shapes.corners.bl,
// current default for backcompat -- TODO: change default to true once all callees have been updated
multiline = false,
} = {}
) => {
logRaw(
format(msg, {
firstLinePrefix: `${gray(shape)} ${status.cancel}`,
linePrefix: gray(shapes.bar),
newlineBefore: true,
formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim"
multiline,
})
);
};
export const warn = (
msg: string,
{
// current default for backcompat -- TODO: change default to shapes.bar once all callees have been updated
shape = shapes.corners.bl,
// current default for backcompat -- TODO: change default to true once all callees have been updated
multiline = false,
newlineBefore = true,
} = {}
) => {
logRaw(
format(msg, {
firstLinePrefix: gray(shape) + space() + status.warning,
linePrefix: gray(shapes.bar),
formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim"
multiline,
newlineBefore,
})
);
};
export const success = (
msg: string,
{
// current default for backcompat -- TODO: change default to shapes.bar once all callees have been updated
shape = shapes.corners.bl,
// current default for backcompat -- TODO: change default to true once all callees have been updated
multiline = false,
} = {}
) => {
logRaw(
format(msg, {
firstLinePrefix: gray(shape) + space() + status.success,
linePrefix: gray(shapes.bar),
newlineBefore: true,
formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim"
multiline,
})
);
};
// Strip the ansi color characters out of the line when calculating
// line length, otherwise the padding will be thrown off
// Used from https://github.com/natemoo-re/clack/blob/main/packages/prompts/src/index.ts
export const stripAnsi = (str: string) => {
const pattern = [
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join("|");
const regex = RegExp(pattern, "g");
return str.replace(linkRegex, "$2").replace(regex, "");
};
// Regular Expression that matches a hyperlink
// e.g. `\u001B]8;;http://example.com/\u001B\\This is a link\u001B]8;;\u001B\`
export const linkRegex =
// eslint-disable-next-line no-control-regex -- regex intentionally matches ANSI escape sequences for hyperlink parsing
/\u001B\]8;;(?<url>.+)\u001B\\(?<label>.+)\u001B\]8;;\u001B\\/g;
// Create a hyperlink in terminal
// It works in iTerm2 and VSCode's terminal, but not macOS built-in terminal app
export const hyperlink = (url: string, label = url) => {
return `\u001B]8;;${url}\u001B\\${label}\u001B]8;;\u001B\\`;
};
export const crash: (msg?: string, extra?: string) => never = (msg, extra) => {
error(msg, extra);
exit(1);
};
export const error = (
msg?: string,
extra?: string,
corner = shapes.corners.bl
) => {
// Only output if current log level allows 'error' level messages
const currentLevel = getLogLevel();
if (msg && LOGGER_LEVELS[currentLevel] >= LOGGER_LEVELS.error) {
stderr.write(
`${gray(corner)} ${status.error} ${dim(msg)}\n${
extra ? space() + extra + "\n" : ""
}`
);
}
};
export { checkMacOSVersion } from "./check-macos-version";
export { showCursor } from "./cursor";
+738
View File
@@ -0,0 +1,738 @@
import {
ConfirmPrompt,
isCancel,
MultiSelectPrompt,
SelectPrompt,
TextPrompt,
} from "@clack/core";
import { createLogUpdate } from "log-update";
import { blue, bold, brandColor, dim, gray, white } from "./colors";
import { CancelError } from "./error";
import SelectRefreshablePrompt from "./select-list";
import { stdout } from "./streams";
import {
cancel,
crash,
logRaw,
newline,
shapes,
space,
status,
stripAnsi,
} from "./index";
import type { OptionWithDetails } from "./select-list";
import type { Prompt } from "@clack/core";
// logUpdate writes text to a TTY (it uses escape sequences to move the cursor
// and clear lines). This function should not be used when running
// non-interactively.
const logUpdate = createLogUpdate(stdout);
export type Arg = string | boolean | string[] | undefined | number;
export const grayBar = gray(shapes.bar);
export const blCorner = gray(shapes.corners.bl);
export const leftT = gray(shapes.leftT);
export type Option = {
label: string; // user-visible string
sublabel?: string; // user-visible string
description?: string;
value: string; // underlying key
hidden?: boolean;
activeIcon?: string;
inactiveIcon?: string;
};
export type BasePromptConfig = {
// Displayed to the user while prompting for this input
question: string;
// Further clarifies the question
helpText?: string;
// The value to use by default
defaultValue?: Arg;
// The error message to display if the initial value is invalid
initialErrorMessage?: string | null;
// Accept the initialValue/defaultValue as if the user pressed ENTER when prompted
acceptDefault?: boolean;
// The status label to be shown after submitting
label: string;
// Pretty-prints the value in the interactive prompt
format?: (value: Arg) => string;
// Returns a user displayed error if the value is invalid
validate?: (value: Arg) => string | Error | undefined;
// Override some/all renderers (can be used for custom renderers before hoisting back into shared code)
renderers?: Partial<ReturnType<typeof getRenderers>>;
// Whether to throw an error if the prompt is crashed or cancelled
throwOnError?: boolean;
};
export type TextPromptConfig = BasePromptConfig & {
type: "text";
initialValue?: string;
};
export type BaseSelectPromptConfig = BasePromptConfig & {
options: Option[];
maxItemsPerPage?: number;
};
export type SelectPromptConfig = BaseSelectPromptConfig & {
type: "select";
};
export type MultiSelectPromptConfig = BaseSelectPromptConfig & {
type: "multiselect";
};
export type ConfirmPromptConfig = BasePromptConfig & {
type: "confirm";
activeText?: string;
inactiveText?: string;
};
export type ListPromptConfig = BasePromptConfig & {
type: "list";
options: OptionWithDetails[];
onRefresh?: () => Promise<OptionWithDetails[]>;
};
export type PromptConfig =
| TextPromptConfig
| ConfirmPromptConfig
| SelectPromptConfig
| MultiSelectPromptConfig
| ListPromptConfig;
type RenderProps =
| Omit<SelectPrompt<Option>, "prompt">
| Omit<MultiSelectPrompt<Option>, "prompt">
| Omit<TextPrompt, "prompt">
| Omit<ConfirmPrompt, "prompt">
| Omit<SelectRefreshablePrompt, "prompt">;
function acceptDefault<T>(
promptConfig: PromptConfig,
renderers: Pick<ReturnType<typeof getRenderers>, "submit">,
initialValue: T
): T {
const error = promptConfig.validate?.(initialValue as Arg);
if (error) {
const errorMessage = error instanceof Error ? error.message : error;
if (promptConfig.throwOnError) {
throw new Error(errorMessage);
} else {
crash(errorMessage);
}
}
const lines = renderers.submit({ value: initialValue as Arg });
logRaw(lines.join("\n"));
return initialValue as T;
}
export const inputPrompt = async <T = string>(
promptConfig: PromptConfig
): Promise<T> => {
const renderers = {
...getRenderers(promptConfig),
...promptConfig.renderers,
};
let prompt:
| SelectPrompt<Option>
| TextPrompt
| ConfirmPrompt
| MultiSelectPrompt<Option>
| SelectRefreshablePrompt;
// Looks up the needed renderer by the current state ('initial', 'submitted', etc.)
const dispatchRender = (
props: RenderProps,
p: Prompt<unknown>
): string | undefined => {
let state = props.state;
if (state === "initial" && promptConfig.initialErrorMessage) {
state = "error";
props.error = promptConfig.initialErrorMessage;
}
const renderedLines = renderers[state](props, p);
return renderedLines.join("\n");
};
if (promptConfig.type === "select") {
const initialValue = String(promptConfig.defaultValue);
if (promptConfig.acceptDefault) {
return acceptDefault<T>(promptConfig, renderers, initialValue as T);
}
prompt = new SelectPrompt({
...promptConfig,
options: promptConfig.options.filter((o) => !o.hidden),
initialValue,
render() {
return dispatchRender(this, prompt);
},
});
} else if (promptConfig.type === "confirm") {
const initialValue = Boolean(promptConfig.defaultValue);
if (promptConfig.acceptDefault) {
return acceptDefault<T>(promptConfig, renderers, initialValue as T);
}
prompt = new ConfirmPrompt({
...promptConfig,
initialValue,
active: promptConfig.activeText || "",
inactive: promptConfig.inactiveText || "",
render() {
return dispatchRender(this, prompt);
},
});
} else if (promptConfig.type == "multiselect") {
let initialValues: string[] | undefined;
if (Array.isArray(promptConfig.defaultValue)) {
initialValues = promptConfig.defaultValue;
} else if (promptConfig.defaultValue !== undefined) {
initialValues = [String(promptConfig.defaultValue)];
}
if (promptConfig.acceptDefault) {
return acceptDefault<T>(promptConfig, renderers, initialValues as T);
}
prompt = new MultiSelectPrompt({
...promptConfig,
options: promptConfig.options,
initialValues: initialValues,
render() {
return dispatchRender(this, prompt);
},
});
} else if (promptConfig.type === "list") {
const initialValue = String(promptConfig.defaultValue);
if (promptConfig.acceptDefault) {
return acceptDefault<T>(promptConfig, renderers, initialValue as T);
}
prompt = new SelectRefreshablePrompt({
...promptConfig,
onRefresh:
promptConfig.onRefresh ?? (() => Promise.resolve(promptConfig.options)),
initialValue,
render() {
return dispatchRender(this, prompt);
},
});
} else {
const initialValue =
promptConfig.initialValue ?? String(promptConfig.defaultValue ?? "");
if (promptConfig.acceptDefault) {
return acceptDefault<T>(promptConfig, renderers, initialValue as T);
}
prompt = new TextPrompt({
...promptConfig,
initialValue: promptConfig.initialValue,
defaultValue: String(promptConfig.defaultValue ?? ""),
render() {
return dispatchRender(this, prompt);
},
});
}
const input = (await prompt.prompt()) as T;
if (isCancel(input)) {
if (promptConfig.throwOnError) {
throw new CancelError("Operation cancelled");
} else {
cancel("Operation cancelled");
process.exit(0);
}
}
return input;
};
type Renderer = (
props: {
state?: string;
error?: string;
cursor?: number;
value: Arg;
},
prompt: Prompt<unknown>
) => string[];
const renderSubmit = (config: PromptConfig, value: string) => {
const { question, label } = config;
if (config.type !== "confirm" && !value) {
return [`${leftT} ${question} ${dim("(skipped)")}`, `${grayBar}`];
}
const content =
config.type === "confirm"
? `${grayBar} ${brandColor(value)} ${dim(label)}`
: `${grayBar} ${brandColor(label)} ${dim(value)}`;
return [`${leftT} ${question}`, content, `${grayBar}`];
};
export const getRenderers = (config: PromptConfig) => {
switch (config.type) {
case "select":
return getSelectRenderers(config);
case "confirm":
return getConfirmRenderers(config);
case "text":
return getTextRenderers(config);
case "multiselect":
return getSelectRenderers(config);
case "list":
return getSelectListRenderers(config);
}
};
const getTextRenderers = (config: TextPromptConfig) => {
const { question } = config;
const helpText = config.helpText ?? "";
const format = config.format ?? ((val: Arg) => String(val));
const defaultValue = config.defaultValue?.toString() ?? "";
const activeRenderer = (props: RenderProps) => {
const { userInputWithCursor } = props as TextPrompt;
return [
`${blCorner} ${bold(question)} ${dim(helpText)}`,
`${space(2)}${format(userInputWithCursor || dim(defaultValue))}`,
``, // extra line for readability
];
};
return {
initial: () => [
`${blCorner} ${bold(question)} ${dim(helpText)}`,
`${space(2)}${gray(format(defaultValue))}`,
``, // extra line for readability
],
active: activeRenderer,
error: ({ value, error }: { value: Arg; error: string }) => [
`${leftT} ${status.error} ${dim(error)}`,
`${grayBar}`,
`${blCorner} ${question} ${dim(helpText)}`,
`${space(2)}${format(value)}`,
``, // extra line for readability
],
submit: ({ value }: { value: Arg }) =>
renderSubmit(config, format(value ?? "")),
cancel: activeRenderer,
};
};
const getSelectRenderers = (
config: SelectPromptConfig | MultiSelectPromptConfig
) => {
const { options, question, helpText: _helpText } = config;
const helpText = _helpText ?? "";
const maxItemsPerPage = config.maxItemsPerPage ?? 32;
const defaultRenderer: Renderer = ({ cursor = 0, value }) => {
const renderOption = (opt: Option, i: number) => {
const { label: optionLabel, value: optionValue } = opt;
const active = i === cursor;
const isInListOfValues =
Array.isArray(value) && value.includes(optionValue);
const color = isInListOfValues || active ? blue : white;
const text = active ? color.underline(optionLabel) : color(optionLabel);
const sublabel = opt.sublabel ? color.grey(opt.sublabel) : "";
const indicator =
isInListOfValues || (active && !Array.isArray(value))
? color(opt.activeIcon ?? shapes.radioActive)
: color(opt.inactiveIcon ?? shapes.radioInactive);
return `${space(2)}${indicator} ${text} ${sublabel}`;
};
const renderOptionCondition = (_: unknown, i: number): boolean => {
if (options.length <= maxItemsPerPage) {
return true;
}
if (i < cursor) {
return options.length - i <= maxItemsPerPage;
}
return cursor + maxItemsPerPage > i;
};
const visibleOptions = options.filter((o) => !o.hidden);
const activeOption = visibleOptions.at(cursor);
const lines = [
`${blCorner} ${bold(question)} ${dim(helpText)}`,
`${
cursor > 0 && options.length > maxItemsPerPage
? `${space(2)}${dim("...")}\n`
: ""
}${visibleOptions
.map(renderOption)
.filter(renderOptionCondition)
.join(`\n`)}${
cursor + maxItemsPerPage < options.length &&
options.length > maxItemsPerPage
? `\n${space(2)}${dim("...")}`
: ""
}`,
``, // extra line for readability
];
if (activeOption?.description) {
// To wrap the text by words instead of characters
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });
const padding = space(2);
const availableWidth =
process.stdout.columns - stripAnsi(padding).length * 2;
// The description cannot have any ANSI code
// As the segmenter will split the code to several segments
const description = stripAnsi(activeOption.description);
const descriptionLines: string[] = [];
let descriptionLineNumber = 0;
for (const data of wordSegmenter.segment(description)) {
let line = descriptionLines[descriptionLineNumber] ?? "";
const currentLineWidth = line.length;
const segmentSize = data.segment.length;
if (currentLineWidth + segmentSize > availableWidth) {
descriptionLineNumber++;
line = "";
// To avoid starting a new line with a space
if (data.segment.match(/^\s+$/)) {
continue;
}
}
descriptionLines[descriptionLineNumber] = line + data.segment;
}
lines.push(
dim(
descriptionLines.map((line) => padding + line + padding).join("\n")
),
``
);
}
return lines;
};
return {
initial: defaultRenderer,
active: defaultRenderer,
confirm: defaultRenderer,
error: (opts: { value: Arg; error: string }, prompt: Prompt<unknown>) => {
return [
`${leftT} ${status.error} ${dim(opts.error)}`,
`${grayBar}`,
...defaultRenderer(opts, prompt),
];
},
submit: ({ value }: { value: Arg }) => {
if (Array.isArray(value)) {
return renderSubmit(
config,
options
.filter((o) => value.includes(o.value))
.map((o) => o.label)
.join(", ")
);
}
return renderSubmit(
config,
options.find((o) => o.value === value)?.label as string
);
},
cancel: defaultRenderer,
};
};
const getSelectListRenderers = (config: ListPromptConfig) => {
const { question, helpText: _helpText } = config;
let options = config.options;
const helpText = _helpText ?? "";
const { rows } = stdout;
const defaultRenderer: Renderer = (
{ cursor, value },
prompt: Prompt<unknown>
) => {
if (prompt instanceof SelectRefreshablePrompt) {
options = prompt.options;
}
cursor = cursor ?? 0;
let smallCursor = 0;
const renderOption = (opt: OptionWithDetails, i: number) => {
const { label: optionLabel, value: optionValueAny } = opt;
const optionValue = optionValueAny.toString() as string;
const active = i === smallCursor;
const isInListOfValues =
Array.isArray(value) && value.includes(optionValue);
const color = isInListOfValues || active ? blue : white;
const text = active ? color.underline(optionLabel) : color(optionLabel);
const indicator =
isInListOfValues || (active && !Array.isArray(value))
? color(shapes.radioActive)
: color(shapes.radioInactive);
const indicatorMargin = 2;
const detailBulletpointMargin = indicatorMargin + 4;
return [
`${space(indicatorMargin)}${indicator} ${text}`,
...opt.details.map(
(detail, j) =>
`${space(detailBulletpointMargin)}${
j === opt.details.length - 1 ? gray(shapes.corners.bl) : grayBar
} ${detail}`
),
];
};
// Create the pages, later on we will choose a "page" that the user can
// navigate on the view
const pages: OptionWithDetails[][] = [];
let current: { size: number; options: OptionWithDetails[] } = {
size: 0,
options: [],
};
const VERTICAL_MARGIN = 6;
for (const option of options) {
// If current accumulation of options + title row + option details size
// is bigger than console rows substracted by a bit of vertical margin,
// add a new page.
const optionHeight = option.details.length + 1;
if (current.size + optionHeight > rows - VERTICAL_MARGIN) {
pages.push(current.options);
current = { size: optionHeight, options: [option] };
continue;
}
current.size += optionHeight;
current.options.push(option);
}
// add the last current as the last page
if (current.size !== 0) {
pages.push(current.options);
}
// choose a page by finding the page that the current cursor is in
let isFirstPage = true;
let isLastPage = false;
let page: OptionWithDetails[] = [];
let len = 0;
for (let i = 0; i < pages.length; i++) {
const pageIter = pages[i];
if (cursor >= len && pageIter.length + len > cursor) {
isFirstPage = i === 0;
isLastPage = i === pages.length - 1;
page = pageIter;
smallCursor = cursor - len;
break;
}
len += pageIter.length;
}
return [
`${blCorner} ${bold(question)} ${dim(helpText)}`,
...(!isFirstPage ? [`${space(2)}${dim("...")}`] : []),
...page.map(renderOption).reduce((prev, now) => [...prev, ...now], []),
...(!isLastPage ? [`${space(2)}${dim("...")}`] : []),
];
};
return {
initial: defaultRenderer,
active: defaultRenderer,
confirm: defaultRenderer,
error: (opts: { value: Arg; error: string }, prompt: Prompt<unknown>) => {
return [
`${leftT} ${status.error} ${dim(opts.error)}`,
`${grayBar}`,
...defaultRenderer(opts, prompt),
];
},
submit: ({ value }: { value: Arg }) => {
if (Array.isArray(value)) {
return renderSubmit(
config,
options
.filter((o) => value.includes(o.value))
.map((o) => o.value)
.join(", ")
);
}
return renderSubmit(
config,
options.find((o) => o.value === value)?.value as string
);
},
cancel: defaultRenderer,
};
};
const getConfirmRenderers = (config: ConfirmPromptConfig) => {
const { activeText, inactiveText, question, helpText: _helpText } = config;
const helpText = _helpText ?? "";
const active = activeText || "Yes";
const inactive = inactiveText || "No";
const defaultRenderer: Renderer = ({ value }) => {
const yesColor = value ? blue.underline : dim;
const noColor = value ? dim : blue.underline;
return [
`${blCorner} ${bold(question)} ${dim(helpText)}`,
`${space(2)}${yesColor(active)} / ${noColor(inactive)}`,
];
};
return {
initial: defaultRenderer,
active: defaultRenderer,
confirm: defaultRenderer,
error: defaultRenderer,
submit: ({ value }: { value: Arg }) =>
renderSubmit(config, value ? "yes" : "no"),
cancel: defaultRenderer,
};
};
export type SpinnerStyle = keyof typeof spinnerFrames;
export const spinnerFrames = {
clockwise: ["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"],
vertical: ["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"],
};
const ellipsisFrames = ["", ".", "..", "...", " ..", " .", ""];
export const spinner = (
frames: string[] = spinnerFrames.clockwise,
color: typeof brandColor = brandColor
) => {
// Alternative animations we considered. Keeping around in case we
// introduce different animations for different use cases.
// const frames = ["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"];
// const frames = ["■", "□", "▪", "▫"];
// const frames = ["✶", "✸", "✹", "✺", "✹", "✷"];
// const frames = ["◜", "◠", "◝", "◞", "◡", "◟"];
// const frames = ["◐", "◓", "◑", "◒"];
// const frames = ["㊂", "㊀", "㊁"];
const frameRate = 120;
let loop: ReturnType<typeof setTimeout> | null = null;
let startMsg: string;
let currentMsg: string;
function clearLoop() {
if (loop) {
clearTimeout(loop);
}
loop = null;
}
return {
start(msg: string, helpText?: string) {
helpText ||= ``;
currentMsg = msg;
startMsg = currentMsg;
if (helpText !== undefined && helpText.length > 0) {
startMsg += ` ${dim(helpText)}`;
}
if (isInteractive()) {
let index = 0;
clearLoop();
loop = setInterval(() => {
index++;
const spinnerFrame = frames[index % frames.length];
const ellipsisFrame = ellipsisFrames[index % ellipsisFrames.length];
if (msg) {
logUpdate(`${color(spinnerFrame)} ${currentMsg} ${ellipsisFrame}`);
}
}, frameRate);
} else {
logRaw(`${leftT} ${startMsg}`);
}
},
update(msg: string) {
currentMsg = msg;
},
stop(msg?: string) {
if (isInteractive()) {
// Write the final message and clear the loop
logUpdate.clear();
if (msg) {
logUpdate(`${leftT} ${startMsg}\n${grayBar} ${msg}`);
logUpdate.done();
newline();
}
clearLoop();
} else {
if (msg !== undefined) {
logRaw(`${grayBar} ${msg}`);
}
newline();
}
},
};
};
type FactoryOrValue<T> = T | (() => T);
const unwrapFactory = <T>(input: FactoryOrValue<T>): T => {
const output = typeof input === "function" ? (input as () => T)() : input;
return output;
};
export const spinnerWhile = async <T>(opts: {
promise: FactoryOrValue<Promise<T>>;
startMessage: FactoryOrValue<string>;
endMessage?: FactoryOrValue<string>;
spinner?: ReturnType<typeof spinner>;
}): Promise<T> => {
const s = opts.spinner ?? spinner();
s.start(unwrapFactory(opts.startMessage));
try {
const result = await unwrapFactory(opts.promise);
return result;
} finally {
s.stop(unwrapFactory(opts.endMessage));
}
};
/**
* Test whether the process is "interactive".
*/
export function isInteractive(): boolean {
try {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
} catch {
return false;
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"name": "@cloudflare/cli-shared-helpers",
"version": "0.1.13",
"description": "Internal shared CLI helpers for workers-sdk. Not intended for external use — APIs may change without notice.",
"keywords": [
"cli",
"cloudflare",
"cloudflare workers",
"workers"
],
"homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/cli#readme",
"bugs": {
"url": "https://github.com/cloudflare/workers-sdk/issues"
},
"license": "MIT OR Apache-2.0",
"author": "wrangler@cloudflare.com",
"repository": {
"type": "git",
"url": "https://github.com/cloudflare/workers-sdk.git",
"directory": "packages/cli"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./args": {
"types": "./dist/args.d.mts",
"import": "./dist/args.mjs"
},
"./colors": {
"types": "./dist/colors.d.mts",
"import": "./dist/colors.mjs"
},
"./command": {
"types": "./dist/command.d.mts",
"import": "./dist/command.mjs"
},
"./error": {
"types": "./dist/error.d.mts",
"import": "./dist/error.mjs"
},
"./gitignore": {
"types": "./dist/gitignore.d.mts",
"import": "./dist/gitignore.mjs"
},
"./interactive": {
"types": "./dist/interactive.d.mts",
"import": "./dist/interactive.mjs"
},
"./packages": {
"types": "./dist/packages.d.mts",
"import": "./dist/packages.mjs"
},
"./streams": {
"types": "./dist/streams.d.mts",
"import": "./dist/streams.mjs"
}
},
"scripts": {
"build": "tsdown",
"check:type": "tsc",
"deploy": "echo 'no deploy'",
"dev": "tsdown --watch",
"test:ci": "vitest run"
},
"dependencies": {
"@clack/core": "1.2.0",
"@cloudflare/workers-utils": "workspace:*",
"chalk": "catalog:default",
"ci-info": "catalog:default",
"cross-spawn": "7.0.6",
"log-update": "5.0.1"
},
"devDependencies": {
"@cloudflare/workers-tsconfig": "workspace:*",
"@types/cross-spawn": "^6.0.2",
"tsdown": "^0.15.9"
},
"volta": {
"extends": "../../package.json"
}
}
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert";
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { parsePackageJSON, readFileSync } from "@cloudflare/workers-utils";
import { brandColor, dim } from "./colors";
import { runCommand } from "./command";
type InstallConfig = {
startText?: string;
doneText?: string;
dev?: boolean;
force?: boolean;
isWorkspaceRoot?: boolean;
};
/**
* Install a list of packages to the local project directory and add it to `package.json`
*
* @param packageManager - The package manager to use for installation
* @param packages - An array of package specifiers to be installed
* @param config.dev - Add packages as `devDependencies`
* @param config.startText - Spinner start text
* @param config.doneText - Spinner done text
* @param config.force - Whether to install with `--force` or not
*/
export const installPackages = async (
packageManager: "npm" | "pnpm" | "yarn" | "bun",
packages: string[],
config: InstallConfig = {}
) => {
const { force, dev, startText, doneText } = config;
const isWorkspaceRoot = config.isWorkspaceRoot ?? false;
if (packages.length === 0) {
let cmd;
switch (packageManager) {
case "yarn":
break;
case "npm":
case "pnpm":
default:
cmd = "install";
break;
}
await runCommand(
[
packageManager,
...(cmd ? [cmd] : []),
...packages,
...(packageManager === "pnpm" ? ["--no-frozen-lockfile"] : []),
...(force === true ? ["--force"] : []),
...getWorkspaceInstallRootFlag(packageManager, isWorkspaceRoot),
],
{
cwd: process.cwd(),
startText,
doneText,
silent: true,
}
);
return;
}
let saveFlag;
let cmd;
switch (packageManager) {
case "yarn":
cmd = "add";
saveFlag = dev ? "-D" : "";
break;
case "bun":
cmd = "add";
saveFlag = dev ? "-d" : "";
break;
case "npm":
case "pnpm":
default:
cmd = "install";
saveFlag = dev ? "--save-dev" : "";
break;
}
await runCommand(
[
packageManager,
cmd,
...(saveFlag ? [saveFlag] : []),
...packages,
...(force === true ? ["--force"] : []),
...getWorkspaceInstallRootFlag(packageManager, isWorkspaceRoot),
],
{
startText,
doneText,
silent: true,
}
);
if (packageManager === "npm") {
// Npm install will update the package.json with a caret-range rather than the exact version/range we asked for.
// We can't use `npm install --save-exact` because that always pins to an exact version, and we want to allow ranges too.
// So let's just fix that up now by rewriting the package.json.
const pkgJsonPath = path.join(process.cwd(), "package.json");
const pkgJson = parsePackageJSON(readFileSync(pkgJsonPath), pkgJsonPath);
const deps = config.dev ? pkgJson.devDependencies : pkgJson.dependencies;
assert(deps, "dependencies should be defined");
for (const pkg of packages) {
const versionMarker = pkg.lastIndexOf("@");
if (versionMarker > 0) {
// (if versionMarker was 0 then this would indicate a scoped package with no version)
const pkgName = pkg.slice(0, versionMarker);
const pkgVersion = pkg.slice(versionMarker + 1);
if (pkgVersion !== "latest") {
deps[pkgName] = pkgVersion;
}
}
}
await writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
}
};
/**
* Returns the potential flag(/s) that need to be added to a package manager's install command when it is
* run at the root of a workspace.
*
* @param packageManager The type of package manager
* @param isWorkspaceRoot Flag indicating whether the install command is being run at the root of a workspace
* @returns an array containing the flag(/s) to use, or an empty array if not supported or not running in the workspace root.
*/
function getWorkspaceInstallRootFlag(
packageManager: "npm" | "pnpm" | "yarn" | "bun",
isWorkspaceRoot: boolean
): string[] {
if (!isWorkspaceRoot) {
return [];
}
switch (packageManager) {
case "pnpm":
return ["--workspace-root"];
case "yarn":
return ["-W"];
case "npm":
case "bun":
// npm and bun don't have the workspace check
return [];
}
}
/**
* Installs the latest version of wrangler in the project directory if it isn't already.
*/
export async function installWrangler(
packageManager: "npm" | "pnpm" | "yarn" | "bun",
isWorkspaceRoot: boolean
) {
const packages = [`wrangler@latest`] satisfies string[];
if (hasDependency("@cloudflare/workers-types")) {
packages.push("@cloudflare/workers-types@latest");
}
// Even if Wrangler is already installed, make sure we install the latest version, as some framework CLIs are pinned to an older version
await installPackages(packageManager, packages, {
dev: true,
isWorkspaceRoot,
startText: `Installing wrangler ${dim(
"A command line tool for building Cloudflare Workers"
)}`,
doneText: `${brandColor("installed")} ${dim(
`via \`${packageManager} install wrangler --save-dev\``
)}`,
});
}
function hasDependency(packageName: string): boolean {
const pkgJsonPath = path.join(process.cwd(), "package.json");
if (!existsSync(pkgJsonPath)) {
return false;
}
const pkgJson = parsePackageJSON(readFileSync(pkgJsonPath), pkgJsonPath);
return Boolean(
pkgJson.dependencies?.[packageName] ||
pkgJson.devDependencies?.[packageName]
);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Dependencies that _are not_ bundled along with @cloudflare/cli-shared-helpers.
*
* This package uses unbundle mode (tsdown unbundle: true), so all dependencies
* remain as bare import specifiers in the dist output and are resolved at
* install time by consumers.
*
* This list is validated by `tools/deployments/validate-package-dependencies.ts`.
*/
export const EXTERNAL_DEPENDENCIES = [
// UI prompt primitives used for interactive CLI flows
"@clack/core",
// Terminal string styling
"chalk",
// CI environment detection
"ci-info",
// Cross-platform child process spawning
"cross-spawn",
// Efficient terminal log updating (spinners, progress)
"log-update",
];
+82
View File
@@ -0,0 +1,82 @@
import { Prompt } from "@clack/core";
import type { SelectPrompt } from "@clack/core";
export type Option = {
label: string;
value: string;
};
export type OptionWithDetails = Option & {
details: string[];
};
export type SelectRefreshableOptions = ConstructorParameters<
typeof SelectPrompt<Option>
>[0] & {
onRefresh: () => Promise<OptionWithDetails[]>;
} & {
options: OptionWithDetails[];
};
export default class SelectRefreshablePrompt extends Prompt<string> {
options: OptionWithDetails[];
cursor = 0;
private get _value() {
return this.options[this.cursor];
}
private changeValue() {
this.value = this._value.value;
}
constructor(opts: SelectRefreshableOptions) {
super(opts, false);
this.options = opts.options;
this.cursor = this.options.findIndex(
({ value }) => value === opts.initialValue
);
if (this.cursor === -1) {
this.cursor = 0;
}
this.changeValue();
this.on("key", (c) => {
if (c !== "r") {
return;
}
void opts
.onRefresh()
.then((newOptions) => {
this.options = [...newOptions];
this.cursor = 0;
this.changeValue();
// Workaround: so 'render' is private in Prompt, but we know what we're doing here (right?) so
// let's just access it anyway.
const that = this as Record<string, unknown>;
if ("render" in that && typeof that.render === "function") {
that.render();
}
})
.catch(() => {});
});
this.on("cursor", (key) => {
switch (key) {
case "left":
case "up":
this.cursor =
this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
break;
case "down":
case "right":
this.cursor =
this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
break;
}
this.changeValue();
});
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* This file simply re-exports the process's writeable streams
* The intention is to provide a boundary to mock in tests
* Anywhere in this package that writes to stdout/stderr,
* should use this module to get references to them.
*/
export const stdout = process.stdout;
export const stderr = process.stderr;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach } from "vitest";
import { stderr, stdout } from "./streams";
import { stripAnsi } from "./index";
// Copied from packages/wrangler/src/__tests__/helpers/collect-cli-output.ts
export function collectCLIOutput() {
const std = { out: "", err: "" };
const onStdOutData = (chunk: Buffer) => (std.out += chunk.toString());
const onStdErrData = (chunk: Buffer) => (std.err += chunk.toString());
beforeEach(() => {
stdout.on("data", onStdOutData);
stderr.on("data", onStdErrData);
});
afterEach(() => {
stdout.off("data", onStdOutData);
stderr.off("data", onStdErrData);
std.out = "";
std.err = "";
});
return std;
}
// Based on the implementation on packages/wrangler/e2e/helpers/normalize.ts
export function normalizeOutput(output: string) {
const functions = [stripAnsi, normalizeSlashes, removeZeroWidthSpaces];
for (const f of functions) {
output = f(output);
}
return output;
}
// Copied from packages/wrangler/e2e/helpers/normalize.ts
export function normalizeSlashes(str: string): string {
return str.replace(/\\/g, "/");
}
// Copied from packages/wrangler/e2e/helpers/normalize.ts
function removeZeroWidthSpaces(output: string) {
return output.replaceAll(/\u200a|\u200b/g, " ");
}
+13
View File
@@ -0,0 +1,13 @@
{
"root": true,
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"include": ["**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"module": "preserve",
"allowJs": false,
"outDir": "dist",
"types": ["node"],
"resolveJsonModule": true
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: [
"index.ts",
"args.ts",
"colors.ts",
"command.ts",
"error.ts",
"gitignore.ts",
"interactive.ts",
"packages.ts",
"streams.ts",
],
platform: "node",
format: "esm",
dts: true,
outDir: "dist",
tsconfig: "tsconfig.json",
sourcemap: process.env.SOURCEMAPS !== "false",
// Transpile each source file individually without bundling dependencies.
// This package is always consumed through a downstream bundler (C3,
// wrangler, miniflare) which handles dependency resolution itself.
// Bundling CJS deps into ESM creates createRequire(import.meta.url) shims
// that break when re-bundled as CJS.
unbundle: true,
});
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "http://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "!**/__tests__/**"],
"outputs": ["dist/**"],
"env": ["SOURCEMAPS"]
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import chalk from "chalk";
import { vi } from "vitest";
vi.mock("log-update");
chalk.level = 0;
+12
View File
@@ -0,0 +1,12 @@
import { defineProject, mergeConfig } from "vitest/config";
import configShared from "../../vitest.shared";
export default mergeConfig(
configShared,
defineProject({
test: {
include: ["**/__tests__/**/*.{test,spec}.{ts,js,tsx,jsx}"],
setupFiles: "./vite.setup.ts",
},
})
);