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
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:
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, test } from "vitest";
|
||||
import { space } from "..";
|
||||
|
||||
describe("cli", () => {
|
||||
test("test spaces", ({ expect }) => {
|
||||
expect(space(300)).toMatchInlineSnapshot(
|
||||
'" "'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(" ");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, " ");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "http://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "!**/__tests__/**"],
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["SOURCEMAPS"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import chalk from "chalk";
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("log-update");
|
||||
|
||||
chalk.level = 0;
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user