diff --git a/docs/man_pages/project/testing/live.md b/docs/man_pages/project/testing/live.md new file mode 100644 index 0000000000..8720043318 --- /dev/null +++ b/docs/man_pages/project/testing/live.md @@ -0,0 +1,36 @@ +<% if (isJekyll) { %>--- +title: ns live +position: 2 +---<% } %> + +# ns live + +### Description + +Creates a project on preview.nativescript.org, opens its browser IDE and runs it live: on your phone through NativeScript Preview, or on this Mac. Edits from the IDE or its AI agent hot-reload the app. Run the command again in the same folder to reopen the project. + +The project's keys are saved to `.ns-edge.json` in the current folder. Without a create token the project is free: a few hosted agent turns a day, no backend, deleted after 30 days unused. + +### Commands + +Usage | Synopsis +---|--- +Creates a phone project and prints a QR code for NativeScript Preview | `ns live` +Creates a Mac project and runs it on this Mac, streamed into the IDE | `ns live --mac` +Creates an Electron project with native views and runs it on this Mac | `ns live --electron` + +### Options + +* `--new` - Creates another project instead of reopening this folder's. +* `--no-open` - Doesn't open the IDE in the browser. +* `--allow-eval` - With `--mac` or `--electron`, lets the project's agent run code on this Mac. +* `--token ` - A create token for a full project (also read from `NS_EDGE_CREATE_TOKEN`). + +<% if(isHtml) { %> + +### Command Limitations + +* `--mac` and `--electron` need an Apple Silicon Mac with macOS 14.4 or later. +* `ns live` runs `npx @nativescript/cloudflare-cli live`, which you can also run without the NativeScript CLI installed. + +<% } %> diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 154a325910..7007b81ee8 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -262,6 +262,10 @@ registerBuiltInCommand( "preview", () => require("./commands/preview").PreviewCommand, ); +registerBuiltInCommand( + "live", + () => require("./commands/live").LiveCommand, +); registerBuiltInCommand( "debug|ios", diff --git a/lib/commands/live.ts b/lib/commands/live.ts new file mode 100644 index 0000000000..a82fbf5e59 --- /dev/null +++ b/lib/commands/live.ts @@ -0,0 +1,35 @@ +import { IChildProcess } from "../common/declarations"; +import { Command } from "../common/define-command"; +import { inject } from "../common/di"; + +const LIVE_CLI_PACKAGE = "@nativescript/cloudflare-cli"; + +export class LiveCommand extends Command({ + name: "live", + description: + "Creates a live project on preview.nativescript.org, opens its browser IDE and runs it on your phone or this Mac.", + // The flags belong to the live CLI (--mac, --electron, --new, ...), which + // gets the raw command line. + params: "any", + allowUnknownOptions: true, +}) { + private $childProcess = inject("childProcess"); + + public async run(): Promise { + const commandArgs = process.argv.slice(process.argv.indexOf("live") + 1); + // Needs no project, so it runs the live CLI through npx instead of installing it into one. + const child = this.$childProcess.spawn( + "npx", + ["--yes", `${LIVE_CLI_PACKAGE}@latest`, "live", ...commandArgs], + // npx is a .cmd script on Windows, which Node only spawns through a shell. + { stdio: "inherit", shell: process.platform === "win32" }, + ); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode: number | null) => resolve(exitCode ?? 1)); + }); + if (code !== 0) { + this.context.fail(`ns live exited with code ${code}.`, { help: false }); + } + } +} diff --git a/test/commands/live.ts b/test/commands/live.ts new file mode 100644 index 0000000000..eff6c62e27 --- /dev/null +++ b/test/commands/live.ts @@ -0,0 +1,72 @@ +import { EventEmitter } from "events"; +import { assert } from "chai"; +import { LiveCommand } from "../../lib/commands/live"; +import { Yok } from "../../lib/common/yok"; +import { registerCommand } from "../../lib/common/services/command-definition-adapter"; +import { runInInjectionContext } from "../../lib/common/di"; + +function createTestInjector(exitCode: number) { + const spawned: { command: string; args: string[] }[] = []; + const testInjector = new Yok(); + testInjector.register("childProcess", { + spawn: (command: string, args: string[]) => { + spawned.push({ command, args }); + const child = new EventEmitter(); + setImmediate(() => child.emit("close", exitCode)); + return child; + }, + }); + testInjector.register("errors", { + fail: (message: string) => { + throw new Error(message); + }, + failWithHelp: (message: string) => { + throw new Error(message); + }, + }); + testInjector.register("options", {}); + runInInjectionContext(testInjector, () => registerCommand(LiveCommand)); + return { testInjector, spawned }; +} + +describe("live command", () => { + const originalArgv = process.argv; + + afterEach(() => { + process.argv = originalArgv; + }); + + it("runs the live CLI through npx with the flags after `live`", async () => { + process.argv = ["node", "ns", "live", "--mac", "--new"]; + const { testInjector, spawned } = createTestInjector(0); + + await testInjector.resolveCommand("live").execute(["--mac", "--new"]); + + assert.deepEqual(spawned, [ + { + command: "npx", + args: [ + "--yes", + "@nativescript/cloudflare-cli@latest", + "live", + "--mac", + "--new", + ], + }, + ]); + }); + + it("fails when the live CLI exits with an error", async () => { + process.argv = ["node", "ns", "live", "--ios"]; + const { testInjector } = createTestInjector(1); + + let error: Error | undefined; + try { + await testInjector.resolveCommand("live").execute(["--ios"]); + } catch (e) { + error = e; + } + + assert.equal(error?.message, "ns live exited with code 1."); + }); +});