@effect-motion/cli 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@effect-motion/cli",
3
- "version": "0.3.2",
4
- "description": "Command line for effect-motion: scaffold projects (init), preview scenes with hot reload (studio), and render videos from motion.config.ts (render)",
3
+ "version": "0.5.0",
4
+ "description": "Command line for effect-motion: preview scenes with hot reload (studio) and render videos from motion.config.ts (render). Scaffold new projects with `pnpm create effect-motion`",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -31,7 +31,6 @@
31
31
  },
32
32
  "files": [
33
33
  "dist",
34
- "templates",
35
34
  "studio-app"
36
35
  ],
37
36
  "publishConfig": {
@@ -41,8 +40,8 @@
41
40
  "@effect/platform-node": "4.0.0-beta.98",
42
41
  "effect": "4.0.0-beta.98",
43
42
  "vite": "^7.0.0",
44
- "@effect-motion/export": "^0.3.2",
45
- "effect-motion": "^0.3.2"
43
+ "@effect-motion/export": "^0.5.0",
44
+ "effect-motion": "^0.5.0"
46
45
  },
47
46
  "devDependencies": {
48
47
  "@types/node": "^26.1.1",
@@ -52,7 +51,7 @@
52
51
  "react-dom": "^19.2.0",
53
52
  "typescript": "^7.0.2",
54
53
  "vitest": "^4.1.10",
55
- "@effect-motion/react": "^0.3.2"
54
+ "@effect-motion/react": "^0.5.0"
56
55
  },
57
56
  "scripts": {
58
57
  "build": "tsc -p tsconfig.build.json",
@@ -1,146 +1,68 @@
1
1
  import { Player, type PlayerProps } from "@effect-motion/react";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { projectRoot } from "./project";
2
+ import { useMemo, useState } from "react";
3
+ // generated by `motion studio`: statically re-exports the user's studio.ts,
4
+ // which places the entire scene graph in Vite's module graph (ordinary HMR
5
+ // covers scene edits/additions/removals — no bespoke watchers)
6
+ import studioModule, { studioPath } from "./entry";
7
+ // the CLI's own contract module, copied beside the app at prepare time —
8
+ // one brand, one entry-normalization implementation, shared with `motion`
9
+ import {
10
+ type ResolvedEntry,
11
+ resolveEntries,
12
+ type StudioConfig,
13
+ StudioConfigTypeId,
14
+ } from "./StudioConfig";
4
15
 
5
- // minimal local mirror of the CLI's config types — the app deliberately
6
- // avoids importing @effect-motion/cli so it has zero resolution assumptions
7
- // beyond the project's own dependencies
8
- type TargetLike = {
9
- readonly name?: string;
10
- readonly scene?: string;
11
- readonly settings?: Record<string, unknown>;
12
- };
13
- type ConfigLike = { readonly targets?: ReadonlyArray<TargetLike> };
14
-
15
- type SceneEntry = {
16
- /** picker identity: `target:<name>` for config targets, `file:<path>` otherwise */
17
- readonly key: string;
18
- readonly label: string;
19
- /** project-relative module path */
20
- readonly path: string;
21
- readonly registered: boolean;
22
- readonly settings: PlayerProps["settings"] | undefined;
23
- readonly load: () => Promise<unknown>;
24
- };
25
-
26
- // every scene in src/scenes is previewable without registration; vite keeps
27
- // this list current as files appear and disappear
28
- const globbed = import.meta.glob("../../src/scenes/*.ts");
29
-
30
- const normalize = (p: string) => p.replace(/^\.\//, "").replace(/^\//, "");
31
- const globKey = (p: string) => normalize(p.replace(/^(\.\.\/)+/, ""));
32
- const fileLabel = (key: string) =>
33
- key
34
- .split("/")
35
- .at(-1)
36
- ?.replace(/\.(ts|tsx|mts|js|mjs)$/, "") ?? key;
16
+ // selection survives the full-page reloads that scene edits trigger
17
+ const selectionFromHash = () =>
18
+ decodeURIComponent(window.location.hash.slice(1)) || null;
37
19
 
38
- const loadByAbsolutePath = (key: string) => () =>
39
- import(/* @vite-ignore */ `/@fs${projectRoot}/${key}`);
20
+ type Resolved =
21
+ | { readonly _tag: "ready"; readonly entries: ReadonlyArray<ResolvedEntry> }
22
+ | { readonly _tag: "error"; readonly message: string };
40
23
 
41
- const buildEntries = (config: ConfigLike | null): ReadonlyArray<SceneEntry> => {
42
- // one entry per config target (a scene file can back several targets with
43
- // different settings), plus one per scenes-dir file no target references
44
- const globLoaders = new Map(
45
- Object.entries(globbed).map(([path, load]) => [globKey(path), load]),
46
- );
47
- const referenced = new Set<string>();
48
- const entries: Array<SceneEntry> = [];
49
- for (const target of config?.targets ?? []) {
50
- if (typeof target?.scene !== "string" || typeof target.name !== "string")
51
- continue;
52
- const path = normalize(target.scene);
53
- referenced.add(path);
54
- entries.push({
55
- key: `target:${target.name}`,
56
- label: target.name,
57
- path,
58
- registered: true,
59
- // a registered scene previews with its target settings so the
60
- // preview aspect matches the export
61
- settings: target.settings as PlayerProps["settings"],
62
- load: globLoaders.get(path) ?? loadByAbsolutePath(path),
63
- });
64
- }
65
- for (const [path, load] of globLoaders) {
66
- if (referenced.has(path)) continue;
67
- entries.push({
68
- key: `file:${path}`,
69
- label: fileLabel(path),
70
- path,
71
- registered: false,
72
- settings: undefined,
73
- load,
74
- });
24
+ const resolve = (): Resolved => {
25
+ try {
26
+ return {
27
+ _tag: "ready",
28
+ entries: resolveEntries(studioModule, studioPath),
29
+ };
30
+ } catch (error) {
31
+ return {
32
+ _tag: "error",
33
+ message: error instanceof Error ? error.message : String(error),
34
+ };
75
35
  }
76
- return entries.sort((a, b) => a.label.localeCompare(b.label));
77
36
  };
78
37
 
79
- // selection survives the full-page reloads that scene edits trigger
80
- const selectionFromHash = () =>
81
- decodeURIComponent(window.location.hash.slice(1)) || null;
82
-
83
- type SceneState =
84
- | { readonly _tag: "idle" }
85
- | { readonly _tag: "loading" }
86
- | { readonly _tag: "error"; readonly key: string; readonly message: string }
87
- | {
88
- readonly _tag: "ready";
89
- readonly key: string;
90
- readonly scene: PlayerProps["scene"];
91
- };
38
+ // the config's layers cover the union of every registered scene's
39
+ // resources (typed in the user's studio.ts); the dynamic boundary here is
40
+ // backed by that authoring-time check
41
+ const configLayers =
42
+ typeof studioModule === "object" &&
43
+ studioModule !== null &&
44
+ StudioConfigTypeId in studioModule
45
+ ? (studioModule as StudioConfig).layers
46
+ : undefined;
92
47
 
93
48
  export const App = () => {
94
- const [config, setConfig] = useState<ConfigLike | null>(null);
95
- const [configError, setConfigError] = useState<string | null>(null);
96
49
  const [selected, setSelected] = useState<string | null>(selectionFromHash);
97
- const [state, setState] = useState<SceneState>({ _tag: "idle" });
50
+ const resolved = useMemo(resolve, []);
98
51
 
99
- useEffect(() => {
100
- // the config is optional for studio — a project without one still
101
- // previews everything in src/scenes
102
- import(/* @vite-ignore */ `/@fs${projectRoot}/motion.config.ts`).then(
103
- (module_) => setConfig((module_.default ?? null) as ConfigLike | null),
104
- () => setConfig(null),
52
+ if (resolved._tag === "error") {
53
+ return (
54
+ <main className="studio-main">
55
+ <div className="studio-error">
56
+ <h2>Studio entrypoint failed to load</h2>
57
+ <pre>{resolved.message}</pre>
58
+ </div>
59
+ </main>
105
60
  );
106
- }, []);
61
+ }
107
62
 
108
- const entries = useMemo(() => buildEntries(config), [config]);
63
+ const entries = resolved.entries;
109
64
  const entry = entries.find((e) => e.key === selected) ?? entries[0];
110
65
 
111
- useEffect(() => {
112
- if (entry === undefined) return;
113
- let cancelled = false;
114
- setState({ _tag: "loading" });
115
- setConfigError(null);
116
- entry.load().then(
117
- (module_) => {
118
- if (cancelled) return;
119
- const scene = (module_ as { scene?: PlayerProps["scene"] }).scene;
120
- if (scene === undefined) {
121
- setState({
122
- _tag: "error",
123
- key: entry.key,
124
- message: `${entry.path} has no \`scene\` export`,
125
- });
126
- } else {
127
- setState({ _tag: "ready", key: entry.key, scene });
128
- }
129
- },
130
- (error) => {
131
- if (cancelled) return;
132
- setState({
133
- _tag: "error",
134
- key: entry.key,
135
- message: `failed to load ${entry.path}\n\n${error instanceof Error ? error.message : String(error)}`,
136
- });
137
- },
138
- );
139
- return () => {
140
- cancelled = true;
141
- };
142
- }, [entry]);
143
-
144
66
  return (
145
67
  <>
146
68
  <nav className="studio-sidebar">
@@ -157,40 +79,40 @@ export const App = () => {
157
79
  }}
158
80
  >
159
81
  {e.label}
160
- <small>{e.registered ? e.path : `${e.path} (unregistered)`}</small>
82
+ {e.label !== e.key && <small>{e.key}</small>}
161
83
  </button>
162
84
  ))}
163
- {entries.length === 0 && (
164
- <p className="studio-empty">
165
- No scenes found — add one in src/scenes/
166
- </p>
167
- )}
168
- {configError !== null && <p className="studio-empty">{configError}</p>}
169
85
  </nav>
170
86
  <main className="studio-main">
171
- {state._tag === "error" && (
172
- <div className="studio-error">
173
- <h2>Scene failed to load</h2>
174
- <pre>{state.message}</pre>
175
- </div>
176
- )}
177
- {state._tag === "ready" && (
178
- <Player
179
- key={state.key}
180
- scene={state.scene}
181
- autoPlay
182
- defaultRepeatMode
183
- {...(entry?.settings !== undefined
184
- ? { settings: entry.settings }
185
- : {})}
186
- />
187
- )}
188
- {(state._tag === "idle" || state._tag === "loading") &&
189
- entries.length === 0 && (
190
- <p className="studio-empty">
191
- Create src/scenes/my-scene.ts exporting a `scene` to get started.
192
- </p>
193
- )}
87
+ {entry !== undefined &&
88
+ (() => {
89
+ // options.fps is a preview override of the scene's own rate,
90
+ // so it lands in settings.frameRate (the Player's one clock,
91
+ // which wins over its fps prop); everything else is a plain
92
+ // Player prop and wins over the studio defaults
93
+ const {
94
+ fps,
95
+ settings: entrySettings,
96
+ ...playerProps
97
+ } = entry.options;
98
+ const settings =
99
+ fps !== undefined
100
+ ? { ...entrySettings, frameRate: fps }
101
+ : entrySettings;
102
+ return (
103
+ <Player
104
+ key={entry.key}
105
+ scene={entry.scene as PlayerProps["scene"] as never}
106
+ autoPlay
107
+ defaultRepeatMode
108
+ {...(settings !== undefined ? { settings } : {})}
109
+ {...(configLayers !== undefined
110
+ ? { renderLayers: configLayers as never }
111
+ : {})}
112
+ {...playerProps}
113
+ />
114
+ );
115
+ })()}
194
116
  </main>
195
117
  </>
196
118
  );
@@ -0,0 +1,5 @@
1
+ // In-repo typecheck stub: re-exports the CLI's contract module. At prepare
2
+ // time this file is NOT copied — the built dist/StudioConfig.js is placed
3
+ // next to the app instead, so the app and CLI share one implementation
4
+ // resolved against the user's project node_modules.
5
+ export * from "../src/StudioConfig.js";
@@ -0,0 +1,4 @@
1
+ // OVERWRITTEN by `motion studio` at prepare time with a static /@fs import
2
+ // of the user's studio entrypoint — this stub only types the app in-repo.
3
+ export const studioPath = "";
4
+ export default undefined as unknown;
package/dist/Config.d.ts DELETED
@@ -1,78 +0,0 @@
1
- import type * as Runner from "effect-motion/Runner";
2
- /**
3
- * The `motion.config.ts` contract. This module is intentionally
4
- * browser-safe (the studio app imports the user's config directly), so it
5
- * must stay free of Node-only imports — loading/discovery live in
6
- * ConfigLoader.ts.
7
- */
8
- /**
9
- * Per-target render settings: the Runner `Settings` subset an export can
10
- * honor, plus `dpr`. From the author's perspective dpr is a rendering
11
- * setting like width/height; the render command maps it to the export
12
- * package's supersampling option.
13
- */
14
- export interface TargetSettings {
15
- readonly width?: number;
16
- readonly height?: number;
17
- readonly frameRate?: number;
18
- readonly seed?: Runner.Seed;
19
- readonly maxFrames?: number;
20
- readonly backgroundColor?: Runner.Settings["backgroundColor"];
21
- readonly dpr?: number;
22
- }
23
- /** v1 ships MP4 only; the field exists so more containers slot in later. */
24
- export type OutputFormat = "mp4";
25
- export interface MotionTarget {
26
- /** Unique per config — doubles as the output file basename. */
27
- readonly name: string;
28
- /** Path to a module exporting `scene`, relative to the config file. */
29
- readonly scene: string;
30
- readonly settings?: TargetSettings;
31
- /** Output DIRECTORY (never a file), relative to the config file. */
32
- readonly output?: string;
33
- readonly format?: OutputFormat;
34
- /** Frame cap — required in practice for an infinite scene. */
35
- readonly frames?: number;
36
- }
37
- export interface MotionConfig {
38
- readonly targets: ReadonlyArray<MotionTarget>;
39
- }
40
- /** Identity helper that types `motion.config.ts` (vite/vitest convention). */
41
- export declare const defineConfig: (config: MotionConfig) => MotionConfig;
42
- export declare const DEFAULT_OUTPUT_DIR = "./output";
43
- export declare const DEFAULT_FORMAT: OutputFormat;
44
- /**
45
- * Validate the default export of a loaded config module. Plain structural
46
- * checks (not Schema): the config is authored in TS against `MotionConfig`,
47
- * so this only guards the untyped escape hatches (JS configs, `as any`).
48
- */
49
- export declare const validateConfig: (value: unknown, configPath: string) => MotionConfig;
50
- /** Flag values a `motion render` invocation can lay over a target. */
51
- export interface RenderOverrides {
52
- readonly width?: number;
53
- readonly height?: number;
54
- readonly frameRate?: number;
55
- readonly dpr?: number;
56
- readonly seed?: Runner.Seed;
57
- readonly maxFrames?: number;
58
- readonly frames?: number;
59
- readonly outDir?: string;
60
- readonly format?: OutputFormat;
61
- }
62
- /** A target with overrides applied and output location derived. */
63
- export interface ResolvedTarget {
64
- readonly name: string;
65
- readonly scene: string;
66
- readonly settings: TargetSettings;
67
- readonly frames: number | undefined;
68
- /** Output directory (still config-relative — the caller resolves). */
69
- readonly outDir: string;
70
- /** Derived file name: `<name>.<format>` — never user-specified. */
71
- readonly fileName: string;
72
- }
73
- /**
74
- * Precedence, highest wins: CLI flags → target config → library defaults.
75
- * Library defaults are NOT materialized here — leaving fields undefined
76
- * lets the Runner's own defaults apply, so there is one source of truth.
77
- */
78
- export declare const resolveTarget: (target: MotionTarget, overrides?: RenderOverrides) => ResolvedTarget;
package/dist/Config.js DELETED
@@ -1,67 +0,0 @@
1
- import { MotionCliError } from "./MotionCliError.js";
2
- /** Identity helper that types `motion.config.ts` (vite/vitest convention). */
3
- export const defineConfig = (config) => config;
4
- export const DEFAULT_OUTPUT_DIR = "./output";
5
- export const DEFAULT_FORMAT = "mp4";
6
- /**
7
- * Validate the default export of a loaded config module. Plain structural
8
- * checks (not Schema): the config is authored in TS against `MotionConfig`,
9
- * so this only guards the untyped escape hatches (JS configs, `as any`).
10
- */
11
- export const validateConfig = (value, configPath) => {
12
- const fail = (problem) => {
13
- throw new MotionCliError({
14
- reason: "ConfigInvalid",
15
- message: `${configPath}: ${problem}`,
16
- });
17
- };
18
- if (typeof value !== "object" || value === null) {
19
- return fail("default export is not a config object (did you forget `export default defineConfig({...})`?)");
20
- }
21
- const config = value;
22
- if (!Array.isArray(config.targets)) {
23
- return fail("config has no `targets` array");
24
- }
25
- const seen = new Set();
26
- for (const [i, target] of config.targets.entries()) {
27
- if (typeof target !== "object" || target === null) {
28
- return fail(`targets[${i}] is not an object`);
29
- }
30
- if (typeof target.name !== "string" || target.name.length === 0) {
31
- return fail(`targets[${i}] is missing a \`name\``);
32
- }
33
- if (typeof target.scene !== "string" || target.scene.length === 0) {
34
- return fail(`target "${target.name}" is missing a \`scene\` path`);
35
- }
36
- if (seen.has(target.name)) {
37
- return fail(`duplicate target name "${target.name}" (names double as output filenames, so they must be unique)`);
38
- }
39
- seen.add(target.name);
40
- if (target.format !== undefined && target.format !== "mp4") {
41
- return fail(`target "${target.name}" has unsupported format "${target.format}" (v1 supports "mp4")`);
42
- }
43
- }
44
- return config;
45
- };
46
- const definedEntries = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
47
- /**
48
- * Precedence, highest wins: CLI flags → target config → library defaults.
49
- * Library defaults are NOT materialized here — leaving fields undefined
50
- * lets the Runner's own defaults apply, so there is one source of truth.
51
- */
52
- export const resolveTarget = (target, overrides = {}) => {
53
- const { frames: overrideFrames, outDir, format, ...settingsOverrides } = overrides;
54
- const settings = {
55
- ...definedEntries(target.settings ?? {}),
56
- ...definedEntries(settingsOverrides),
57
- };
58
- const resolvedFormat = format ?? target.format ?? DEFAULT_FORMAT;
59
- return {
60
- name: target.name,
61
- scene: target.scene,
62
- settings,
63
- frames: overrideFrames ?? target.frames,
64
- outDir: outDir ?? target.output ?? DEFAULT_OUTPUT_DIR,
65
- fileName: `${target.name}.${resolvedFormat}`,
66
- };
67
- };
@@ -1,15 +0,0 @@
1
- import * as Effect from "effect/Effect";
2
- import { FileSystem } from "effect/FileSystem";
3
- import { Path } from "effect/Path";
4
- import { type MotionConfig } from "./Config.js";
5
- import { MotionCliError } from "./MotionCliError.js";
6
- import type { ViteLoader } from "./ViteLoader.js";
7
- export declare const CONFIG_FILE = "motion.config.ts";
8
- /**
9
- * Resolve the config path: an explicit `--config` wins; otherwise walk up
10
- * from `cwd` to the nearest motion.config.ts (tsc `-p` semantics). Fails
11
- * with ConfigNotFound naming both escape hatches.
12
- */
13
- export declare const findConfig: (cwd: string, explicit?: string) => Effect.Effect<string, MotionCliError, FileSystem | Path>;
14
- /** Load + validate a config module through the shared Vite loader. */
15
- export declare const loadConfig: (loader: ViteLoader, configPath: string) => Effect.Effect<MotionConfig, MotionCliError>;
@@ -1,58 +0,0 @@
1
- import * as Effect from "effect/Effect";
2
- import { FileSystem } from "effect/FileSystem";
3
- import { Path } from "effect/Path";
4
- import { validateConfig } from "./Config.js";
5
- import { MotionCliError } from "./MotionCliError.js";
6
- export const CONFIG_FILE = "motion.config.ts";
7
- /**
8
- * Resolve the config path: an explicit `--config` wins; otherwise walk up
9
- * from `cwd` to the nearest motion.config.ts (tsc `-p` semantics). Fails
10
- * with ConfigNotFound naming both escape hatches.
11
- */
12
- export const findConfig = (cwd, explicit) => Effect.gen(function* () {
13
- const fs = yield* FileSystem;
14
- const path = yield* Path;
15
- if (explicit !== undefined) {
16
- const resolved = path.resolve(cwd, explicit);
17
- const exists = yield* orFalse(fs.exists(resolved));
18
- if (!exists) {
19
- return yield* new MotionCliError({
20
- reason: "ConfigNotFound",
21
- message: `config file not found: ${resolved}`,
22
- });
23
- }
24
- return resolved;
25
- }
26
- let dir = path.resolve(cwd);
27
- while (true) {
28
- const candidate = path.join(dir, CONFIG_FILE);
29
- if (yield* orFalse(fs.exists(candidate)))
30
- return candidate;
31
- const parent = path.dirname(dir);
32
- if (parent === dir)
33
- break;
34
- dir = parent;
35
- }
36
- return yield* new MotionCliError({
37
- reason: "ConfigNotFound",
38
- message: `no ${CONFIG_FILE} found from ${cwd} upward — ` +
39
- `create one (export default defineConfig({ targets: [...] })), pass --config <path>, ` +
40
- `or pass a scene file directly (motion render ./src/scenes/foo.ts)`,
41
- });
42
- });
43
- // fs.exists fails on permission errors etc. — treat those as "not here"
44
- const orFalse = (effect) => Effect.catchCause(effect, () => Effect.succeed(false));
45
- /** Load + validate a config module through the shared Vite loader. */
46
- export const loadConfig = (loader, configPath) => Effect.gen(function* () {
47
- const module_ = yield* loader.load(configPath);
48
- return yield* Effect.try({
49
- try: () => validateConfig(module_.default, configPath),
50
- catch: (error) => error instanceof MotionCliError
51
- ? error
52
- : new MotionCliError({
53
- reason: "ConfigInvalid",
54
- message: `${configPath}: config validation crashed`,
55
- cause: error,
56
- }),
57
- });
58
- });
@@ -1,9 +0,0 @@
1
- import * as Option from "effect/Option";
2
- import { Command, Prompt } from "effect/unstable/cli";
3
- import { ChildProcessSpawner } from "effect/unstable/process";
4
- import { MotionCliError } from "../MotionCliError.js";
5
- export declare const initCommand: Command.Command<"init", {
6
- readonly directory: Option.Option<string>;
7
- readonly pm: Option.Option<"bun" | "npm" | "pnpm" | "yarn">;
8
- readonly noInstall: boolean;
9
- }, {}, MotionCliError, ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment>;
@@ -1,94 +0,0 @@
1
- import * as Console from "effect/Console";
2
- import * as Effect from "effect/Effect";
3
- import * as Option from "effect/Option";
4
- import { Path } from "effect/Path";
5
- import { Argument, Command, Flag, Prompt } from "effect/unstable/cli";
6
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7
- import { MotionCliError } from "../MotionCliError.js";
8
- import { ensureEmptyDir, resolveProjectDir, scaffoldProject, } from "../scaffold.js";
9
- const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"];
10
- /** The manager that invoked us (`pnpm create …` etc.), if detectable. */
11
- const detectPackageManager = () => {
12
- const agent = process.env.npm_config_user_agent ?? "";
13
- return PACKAGE_MANAGERS.find((pm) => agent.startsWith(pm));
14
- };
15
- const initFlags = {
16
- directory: Argument.string("directory").pipe(Argument.withDescription('Target directory ("." scaffolds into the current directory)'), Argument.optional),
17
- pm: Flag.optional(Flag.choice("pm", PACKAGE_MANAGERS).pipe(Flag.withDescription("Package manager (skips the prompt)"))),
18
- noInstall: Flag.boolean("no-install").pipe(Flag.withDescription("Skip dependency installation")),
19
- };
20
- const promptDirectory = Prompt.text({
21
- message: 'Where should the project be created? ("." for the current directory)',
22
- default: "my-motion-project",
23
- });
24
- const promptPackageManager = Effect.suspend(() => {
25
- const detected = detectPackageManager();
26
- // detected manager listed first so plain Enter picks it
27
- const ordered = [
28
- ...(detected ? [detected] : []),
29
- ...PACKAGE_MANAGERS.filter((pm) => pm !== detected),
30
- ];
31
- return Prompt.select({
32
- message: "Which package manager?",
33
- choices: ordered.map((pm) => ({ title: pm, value: pm })),
34
- });
35
- });
36
- const runInstall = (pm, dir) => Effect.gen(function* () {
37
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
38
- const command = ChildProcess.make(pm, ["install"], {
39
- cwd: dir,
40
- stdin: "inherit",
41
- stdout: "inherit",
42
- stderr: "inherit",
43
- });
44
- yield* Effect.scoped(Effect.gen(function* () {
45
- const handle = yield* spawner.spawn(command);
46
- const code = yield* handle.exitCode;
47
- if (code !== 0) {
48
- return yield* new MotionCliError({
49
- reason: "InstallFailed",
50
- message: `${pm} install exited with code ${code} in ${dir} — run it manually`,
51
- cause: code,
52
- });
53
- }
54
- })).pipe(Effect.catchTag("PlatformError", (cause) => Effect.fail(new MotionCliError({
55
- reason: "InstallFailed",
56
- message: `could not run "${pm} install" in ${dir} — is ${pm} installed?`,
57
- cause,
58
- }))));
59
- });
60
- const handler = (input) => Effect.gen(function* () {
61
- const path = yield* Path;
62
- const cwd = process.cwd();
63
- const dirInput = Option.getOrUndefined(input.directory) ?? (yield* promptDirectory);
64
- const { dir, name } = resolveProjectDir(path, cwd, dirInput);
65
- yield* ensureEmptyDir(dir);
66
- const pm = Option.getOrUndefined(input.pm) ?? (yield* promptPackageManager);
67
- yield* scaffoldProject(dir, name);
68
- yield* Console.log(`Scaffolded ${name} in ${dir}`);
69
- if (input.noInstall) {
70
- yield* Console.log([
71
- "",
72
- "Next steps:",
73
- dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
74
- ` ${pm} install`,
75
- ` ${pm === "npm" ? "npm run" : pm} studio`,
76
- ]
77
- .filter((line) => line !== "")
78
- .join("\n"));
79
- return;
80
- }
81
- yield* runInstall(pm, dir);
82
- yield* Console.log([
83
- "",
84
- `${name} is ready.`,
85
- dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
86
- ` ${pm === "npm" ? "npm run" : pm} studio # preview scenes with hot reload`,
87
- ` ${pm === "npm" ? "npm run" : pm} render # render targets from motion.config.ts`,
88
- ]
89
- .filter((line) => line !== "")
90
- .join("\n"));
91
- }).pipe(
92
- // ctrl-c in a prompt is an interruption, not a failure
93
- Effect.catchTag("QuitError", () => Effect.interrupt));
94
- export const initCommand = Command.make("init", initFlags, handler).pipe(Command.withDescription("Scaffold a new effect-motion project"));