@devinilabs/reelstack 1.2.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.
Files changed (145) hide show
  1. package/LICENSE +128 -0
  2. package/README.md +125 -0
  3. package/cli/beats.js +124 -0
  4. package/cli/bootstrap.js +124 -0
  5. package/cli/capture.js +34 -0
  6. package/cli/direction.js +114 -0
  7. package/cli/icons.js +49 -0
  8. package/cli/index.js +101 -0
  9. package/cli/init.js +253 -0
  10. package/cli/license.js +168 -0
  11. package/cli/lint.js +865 -0
  12. package/cli/preview.js +59 -0
  13. package/cli/render.js +404 -0
  14. package/cli/scaffold.js +239 -0
  15. package/cli/smoke.js +76 -0
  16. package/cli/update.js +26 -0
  17. package/cli/utils.js +184 -0
  18. package/docs/buyers-guide.md +220 -0
  19. package/docs/design-discipline.md +130 -0
  20. package/docs/family-galleries/dark.md +95 -0
  21. package/docs/family-galleries/forbidden.md +78 -0
  22. package/docs/family-galleries/glass.md +98 -0
  23. package/docs/family-galleries/paper.md +82 -0
  24. package/docs/family-galleries/warm.md +86 -0
  25. package/docs/superpowers/plans/2026-05-09-reelstack-init-readiness-gate.md +1166 -0
  26. package/docs/superpowers/specs/2026-05-09-reelstack-init-readiness-gate-design.md +233 -0
  27. package/families/dark/components/DriftingSpotlights.tsx +59 -0
  28. package/families/dark/components/FilmGrain.tsx +44 -0
  29. package/families/dark/components/ForestCard.tsx +43 -0
  30. package/families/dark/components/GridBackground.tsx +29 -0
  31. package/families/dark/components/RadialVignette.tsx +21 -0
  32. package/families/dark/components/Scanlines.tsx +35 -0
  33. package/families/dark/components/SegmentOpacity.ts +37 -0
  34. package/families/dark/components/index.ts +13 -0
  35. package/families/dark/index.ts +31 -0
  36. package/families/dark/palette.ts +98 -0
  37. package/families/dark/presets/claudedispatch.ts +46 -0
  38. package/families/dark/presets/codedrop.ts +37 -0
  39. package/families/dark/presets/gpt55.ts +54 -0
  40. package/families/dark/presets/notebooklm.ts +50 -0
  41. package/families/dark/presets/resourcescta.ts +35 -0
  42. package/families/dark/presets/skills.ts +40 -0
  43. package/families/dark/presets/stitch.ts +46 -0
  44. package/families/dark/presets/stitch2.ts +43 -0
  45. package/families/dark/typography.ts +16 -0
  46. package/families/forbidden/components/ForbiddenCausticBlobs.tsx +52 -0
  47. package/families/forbidden/components/NewsprintTexture.tsx +28 -0
  48. package/families/forbidden/components/TintedShadow.tsx +36 -0
  49. package/families/forbidden/components/index.ts +38 -0
  50. package/families/forbidden/index.ts +17 -0
  51. package/families/forbidden/palette.ts +88 -0
  52. package/families/forbidden/presets/heretic.ts +44 -0
  53. package/families/forbidden/typography.ts +18 -0
  54. package/families/glass/components/BreakdownCard.tsx +158 -0
  55. package/families/glass/components/CausticBlobs.tsx +49 -0
  56. package/families/glass/components/Counter.tsx +72 -0
  57. package/families/glass/components/EyebrowPill.tsx +59 -0
  58. package/families/glass/components/FilmStrip.tsx +202 -0
  59. package/families/glass/components/FloatingGlyphs.tsx +78 -0
  60. package/families/glass/components/GlassCard.tsx +58 -0
  61. package/families/glass/components/GlassCardBezel.tsx +45 -0
  62. package/families/glass/components/HairlineGrid.tsx +30 -0
  63. package/families/glass/components/IridescentRing.tsx +114 -0
  64. package/families/glass/components/IridescentText.tsx +98 -0
  65. package/families/glass/components/LightBeam.tsx +46 -0
  66. package/families/glass/components/ParticleBurst.tsx +62 -0
  67. package/families/glass/components/SonarRings.tsx +81 -0
  68. package/families/glass/components/StaggeredWords.tsx +74 -0
  69. package/families/glass/components/index.ts +20 -0
  70. package/families/glass/index.ts +31 -0
  71. package/families/glass/palette.ts +93 -0
  72. package/families/glass/presets/claudewatch.ts +64 -0
  73. package/families/glass/presets/claudewatchcta.ts +43 -0
  74. package/families/glass/presets/graphify.ts +45 -0
  75. package/families/glass/presets/gstack.ts +48 -0
  76. package/families/glass/presets/jcode.ts +50 -0
  77. package/families/glass/presets/lilagents.ts +52 -0
  78. package/families/glass/presets/paperclip.ts +43 -0
  79. package/families/glass/typography.ts +15 -0
  80. package/families/index.ts +49 -0
  81. package/families/paper/components/CardSpring.tsx +42 -0
  82. package/families/paper/components/CreamGrid.tsx +26 -0
  83. package/families/paper/components/EditorialSerifText.tsx +51 -0
  84. package/families/paper/components/GreenAccentCard.tsx +10 -0
  85. package/families/paper/components/PaperShadow.tsx +30 -0
  86. package/families/paper/components/ScaleBlurText.tsx +40 -0
  87. package/families/paper/components/index.ts +11 -0
  88. package/families/paper/index.ts +23 -0
  89. package/families/paper/palette.ts +102 -0
  90. package/families/paper/presets/designreel.ts +32 -0
  91. package/families/paper/presets/devini3d.ts +45 -0
  92. package/families/paper/presets/justdrop.ts +39 -0
  93. package/families/paper/presets/opus.ts +48 -0
  94. package/families/paper/typography.ts +17 -0
  95. package/families/warm/components/AccentGlow.tsx +60 -0
  96. package/families/warm/components/BentoCell.tsx +56 -0
  97. package/families/warm/components/BentoGrid.tsx +30 -0
  98. package/families/warm/components/FilmGrain.tsx +36 -0
  99. package/families/warm/components/ScaleBlurCounter.tsx +71 -0
  100. package/families/warm/components/WarmSurface.tsx +35 -0
  101. package/families/warm/components/index.ts +11 -0
  102. package/families/warm/index.ts +19 -0
  103. package/families/warm/palette.ts +81 -0
  104. package/families/warm/presets/huashu.ts +49 -0
  105. package/families/warm/presets/mempalace.ts +51 -0
  106. package/families/warm/typography.ts +17 -0
  107. package/package.json +85 -0
  108. package/reference/dark/claudedispatch.tsx +2441 -0
  109. package/reference/dark/notebooklm.tsx +2316 -0
  110. package/reference/dark/stitch.tsx +3040 -0
  111. package/reference/forbidden/heretic.tsx +2636 -0
  112. package/reference/glass/claudewatch.tsx +3827 -0
  113. package/reference/glass/graphify.tsx +2418 -0
  114. package/reference/glass/paperclip.tsx +2218 -0
  115. package/reference/paper/designreel.tsx +883 -0
  116. package/reference/paper/justdrop.tsx +1898 -0
  117. package/reference/paper/opus.tsx +1770 -0
  118. package/reference/warm/huashu.tsx +3413 -0
  119. package/reference/warm/mempalace.tsx +2909 -0
  120. package/skill/SKILL.md +229 -0
  121. package/skill/commands/reelstack-beats.md +20 -0
  122. package/skill/commands/reelstack-capture.md +24 -0
  123. package/skill/commands/reelstack-critique.md +15 -0
  124. package/skill/commands/reelstack-dark.md +40 -0
  125. package/skill/commands/reelstack-direction.md +17 -0
  126. package/skill/commands/reelstack-forbidden.md +25 -0
  127. package/skill/commands/reelstack-glass.md +39 -0
  128. package/skill/commands/reelstack-icons.md +22 -0
  129. package/skill/commands/reelstack-init.md +17 -0
  130. package/skill/commands/reelstack-lint.md +22 -0
  131. package/skill/commands/reelstack-paper.md +36 -0
  132. package/skill/commands/reelstack-render.md +20 -0
  133. package/skill/commands/reelstack-warm.md +36 -0
  134. package/templates/dark/template.tsx +115 -0
  135. package/templates/forbidden/template.tsx +111 -0
  136. package/templates/glass/template.tsx +201 -0
  137. package/templates/paper/template.tsx +133 -0
  138. package/templates/warm/template.tsx +210 -0
  139. package/utils/ai-purple-blocklist.ts +13 -0
  140. package/utils/banned-fonts.ts +11 -0
  141. package/utils/cubic-bezier.ts +36 -0
  142. package/utils/easing.ts +84 -0
  143. package/utils/grid.ts +13 -0
  144. package/utils/render-presets.json +56 -0
  145. package/utils/safe-zones.tsx +57 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `reelstack scaffold` — copies a family template into the buyer's
3
+ * src/<Name>Reel.tsx, substitutes preset metadata (palette, BEATs,
4
+ * frame count, copy), and patches src/Root.tsx to register the new
5
+ * composition.
6
+ */
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const os = require("node:os");
10
+ const { info, success, fail, warn, c, REELSTACK_HOME } = require("./utils");
11
+
12
+ const REELSTACK_PKG = path.dirname(__dirname); // resolves to the package root
13
+ const TEMPLATES_DIR = path.join(REELSTACK_PKG, "templates");
14
+ const FAMILIES_DIR = path.join(REELSTACK_PKG, "families");
15
+
16
+ const VALID_FAMILIES = ["glass", "paper", "dark", "warm", "forbidden"];
17
+
18
+ /**
19
+ * Find the reference reel for a given family + preset, falling back from the
20
+ * post-init runtime location to the in-package dev location. Returns the
21
+ * absolute path if found, else null.
22
+ *
23
+ * 1. ~/.reelstack/reference/<family>/<preset>.tsx (post-init)
24
+ * 2. <package-root>/reference/<family>/<preset>.tsx (dev / fresh install)
25
+ */
26
+ function findReference(family, preset) {
27
+ const candidates = [
28
+ path.join(REELSTACK_HOME, "reference", family, `${preset}.tsx`),
29
+ path.join(REELSTACK_PKG, "reference", family, `${preset}.tsx`),
30
+ ];
31
+ for (const p of candidates) {
32
+ if (fs.existsSync(p)) return p;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Resolve the reference path for the chosen preset, optionally overridden by
39
+ * the buyer-supplied `--reference=<preset>` flag. Searches all families when
40
+ * the override is given (the buyer can cite any preset name without having
41
+ * to also state its family). Falls back to the canonical reference if the
42
+ * override doesn't resolve. Returns { path, source: "canonical"|"override" }
43
+ * or null if no reference exists at all.
44
+ */
45
+ function resolveReference(family, preset, overridePreset) {
46
+ if (overridePreset) {
47
+ // Try the chosen family first, then walk the rest.
48
+ for (const f of [family, ...VALID_FAMILIES.filter((x) => x !== family)]) {
49
+ const p = findReference(f, overridePreset);
50
+ if (p) return { path: p, source: "override" };
51
+ }
52
+ warn(`--reference=${overridePreset} not found in any family. Falling back to canonical reference for ${preset}.`);
53
+ }
54
+ const canonical = findReference(family, preset);
55
+ return canonical ? { path: canonical, source: "canonical" } : null;
56
+ }
57
+
58
+ function loadPreset(family, presetName) {
59
+ const presetFile = path.join(FAMILIES_DIR, family, "presets", `${presetName}.ts`);
60
+ if (!fs.existsSync(presetFile)) {
61
+ throw new Error(`Preset "${presetName}" not found in family "${family}".`);
62
+ }
63
+ // Read the TS source and naively extract the exported `preset` object via
64
+ // regex. We don't want a TS runtime dependency in the CLI. The preset
65
+ // files are simple by design: a single `export const preset = { … } as const;`.
66
+ const src = fs.readFileSync(presetFile, "utf8");
67
+ // We don't need the full structure for scaffolding — only the fields we
68
+ // template into the output. Hand-roll a tiny extractor for the keys we use.
69
+ const grab = (key) => {
70
+ const m = src.match(new RegExp(`${key}\\s*:\\s*([0-9]+)`));
71
+ return m ? Number(m[1]) : null;
72
+ };
73
+ const grabStr = (key) => {
74
+ const m = src.match(new RegExp(`${key}\\s*:\\s*"([^"]+)"`));
75
+ return m ? m[1] : null;
76
+ };
77
+ return {
78
+ name: presetName,
79
+ family,
80
+ durationFrames: grab("durationFrames"),
81
+ fps: grab("fps") || 30,
82
+ hook: grabStr("hook") || "",
83
+ sub: grabStr("sub") || "",
84
+ cta: grabStr("cta") || "",
85
+ source: grabStr("source") || "",
86
+ };
87
+ }
88
+
89
+ function loadTemplate(family) {
90
+ const tplFile = path.join(TEMPLATES_DIR, family, "template.tsx");
91
+ if (!fs.existsSync(tplFile)) {
92
+ throw new Error(`Template for family "${family}" not found at ${tplFile}.`);
93
+ }
94
+ return fs.readFileSync(tplFile, "utf8");
95
+ }
96
+
97
+ function substitute(template, preset, name) {
98
+ return template
99
+ .replace(/__REEL_NAME__/g, name)
100
+ .replace(/__PRESET_NAME__/g, preset.name)
101
+ .replace(/__SOURCE_REEL__/g, preset.source)
102
+ .replace(/__DURATION_FRAMES__/g, String(preset.durationFrames || 1800))
103
+ .replace(/__FPS__/g, String(preset.fps))
104
+ .replace(/__HOOK__/g, preset.hook)
105
+ .replace(/__SUB__/g, preset.sub)
106
+ .replace(/__CTA__/g, preset.cta);
107
+ }
108
+
109
+ /**
110
+ * Inject a `/** REFERENCE: <abs-path> *\/` comment block into a scaffolded
111
+ * reel right after the auto-generated header JSDoc and before the first
112
+ * import. The buyer's Claude Code instance picks this up on subsequent
113
+ * iteration ("make this scene punchier") and reads the reference to ground
114
+ * its motion-vocabulary suggestions.
115
+ */
116
+ function injectReferenceComment(tsx, refPath) {
117
+ const block = `/** REFERENCE: ${refPath} */\n`;
118
+ // Find the end of the leading auto-generated /** ... */ header JSDoc.
119
+ const headerEnd = tsx.indexOf("*/");
120
+ if (headerEnd === -1) {
121
+ // No header — prepend instead.
122
+ return block + tsx;
123
+ }
124
+ const insertAt = headerEnd + "*/".length;
125
+ // Skip the immediate newline so the comment lands on its own line.
126
+ const after = tsx[insertAt] === "\n" ? insertAt + 1 : insertAt;
127
+ return tsx.slice(0, after) + block + tsx.slice(after);
128
+ }
129
+
130
+ function patchRoot(rootPath, name, durationFrames) {
131
+ if (!fs.existsSync(rootPath)) {
132
+ fail(`src/Root.tsx not found at ${rootPath}. Skipping registration.`);
133
+ return false;
134
+ }
135
+ const src = fs.readFileSync(rootPath, "utf8");
136
+ if (src.includes(`id="${name}Reel"`)) {
137
+ info(`${name}Reel already registered in Root.tsx — skipping.`);
138
+ return true;
139
+ }
140
+ // Insert a Composition before the closing tag of the parent <Folder> or root.
141
+ // We insert right before the last </> or </Folder>.
142
+ const importLine = `import { ${name}Reel } from "./${name}Reel";\n`;
143
+ const compositionBlock = `
144
+ <Composition
145
+ id="${name}Reel"
146
+ component={${name}Reel}
147
+ durationInFrames={${durationFrames}}
148
+ fps={30}
149
+ width={1080}
150
+ height={1920}
151
+ />
152
+ `;
153
+ // Add import after the last existing import.
154
+ const lastImport = src.lastIndexOf("\nimport ");
155
+ const afterLastImport = src.indexOf("\n", lastImport + 1);
156
+ let next = src.slice(0, afterLastImport + 1) + importLine + src.slice(afterLastImport + 1);
157
+ // Insert composition before the last </Folder> or </> closer.
158
+ const insertAt = next.lastIndexOf("</Folder>") !== -1
159
+ ? next.lastIndexOf("</Folder>")
160
+ : next.lastIndexOf("</>");
161
+ if (insertAt === -1) {
162
+ fail("Could not find </Folder> or </> in Root.tsx. Add the composition manually.");
163
+ return false;
164
+ }
165
+ next = next.slice(0, insertAt) + compositionBlock + next.slice(insertAt);
166
+ fs.writeFileSync(rootPath, next, "utf8");
167
+ return true;
168
+ }
169
+
170
+ function parseArgs(argv) {
171
+ const args = {};
172
+ for (const a of argv) {
173
+ const m = a.match(/^--([^=]+)=(.*)$/);
174
+ if (m) args[m[1]] = m[2];
175
+ }
176
+ return args;
177
+ }
178
+
179
+ async function run(argv, opts = {}) {
180
+ const args = parseArgs(argv);
181
+ const family = args.family;
182
+ const preset = args.preset;
183
+ const name = args.name;
184
+ const cwd = opts.cwd || process.cwd();
185
+
186
+ if (!family || !preset || !name) {
187
+ fail("Usage: reelstack scaffold --family=<f> --preset=<p> --name=<MyReel> [--vo=<path>] [--reference=<preset>]");
188
+ process.exit(2);
189
+ }
190
+ if (!VALID_FAMILIES.includes(family)) {
191
+ fail(`Unknown family "${family}". Valid: ${VALID_FAMILIES.join(", ")}.`);
192
+ process.exit(2);
193
+ }
194
+
195
+ const presetMeta = loadPreset(family, preset);
196
+ const template = loadTemplate(family);
197
+ let tsx = substitute(template, presetMeta, name);
198
+
199
+ // v1.1.1+ — resolve the reference reel for this preset (or buyer's override
200
+ // via --reference=<preset>) so a `/** REFERENCE: <path> */` comment can be
201
+ // injected into the scaffold, and a hint surfaced after Studio.
202
+ const ref = resolveReference(family, preset, args.reference);
203
+ if (ref) {
204
+ tsx = injectReferenceComment(tsx, ref.path);
205
+ }
206
+
207
+ const outFile = path.join(cwd, "src", `${name}Reel.tsx`);
208
+ if (fs.existsSync(outFile) && !args.force) {
209
+ fail(`${outFile} already exists. Pass --force to overwrite.`);
210
+ process.exit(2);
211
+ }
212
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
213
+ fs.writeFileSync(outFile, tsx, "utf8");
214
+ success(`Created ${path.relative(cwd, outFile)}`);
215
+
216
+ const rootPath = path.join(cwd, "src", "Root.tsx");
217
+ const registered = patchRoot(rootPath, name, presetMeta.durationFrames || 1800);
218
+ if (registered) success(`Registered ${name}Reel in src/Root.tsx`);
219
+
220
+ if (args.vo) {
221
+ info(`Voiceover passed: ${args.vo}. Run \`reelstack beats ${args.vo}\` to lock motion to whisper-cli output.`);
222
+ }
223
+
224
+ console.log("");
225
+ console.log(c.gray(` Family: `) + family);
226
+ console.log(c.gray(` Preset: `) + `${preset} (from ${presetMeta.source})`);
227
+ console.log(c.gray(` Frame count: `) + `${presetMeta.durationFrames} (${(presetMeta.durationFrames / 30).toFixed(1)}s @ 30fps)`);
228
+ console.log(c.gray(` Output: `) + path.relative(cwd, outFile));
229
+ console.log("");
230
+ console.log(c.cyan("→ ") + `Open Remotion Studio with ${c.bold("npm run dev")} and select "${name}Reel" from the comp list.`);
231
+ if (ref) {
232
+ console.log(c.cyan("ℹ ") + `Reference reel available: ${ref.path}`);
233
+ console.log(c.gray(" Claude will study this reel when iterating on your scaffold. Open it"));
234
+ console.log(c.gray(" yourself to learn the family's motion vocabulary."));
235
+ }
236
+ console.log("");
237
+ }
238
+
239
+ module.exports = { run };
package/cli/smoke.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tier 4: smoke test. Runs after Tiers 1-3 pass and proves the full stack
3
+ * actually loads. Three fast, non-destructive checks.
4
+ *
5
+ * Steps:
6
+ * 1. scaffold-demo — reelstack scaffold --family=glass --preset=graphify --name=Demo
7
+ * 2. remotion-compositions — npx remotion compositions (proves Demo registers)
8
+ * 3. ffmpeg-version — ffmpeg -version (proves binary executes)
9
+ */
10
+ const { spawnSync } = require("node:child_process");
11
+ const { info, success, fail, c } = require("./utils");
12
+ const scaffold = require("./scaffold");
13
+
14
+ const STEPS = [
15
+ {
16
+ name: "scaffold-demo",
17
+ label: "Scaffolding Demo.tsx",
18
+ async run({ projectDir, scaffoldFn }) {
19
+ const fn = scaffoldFn || scaffold.run;
20
+ await fn(["--family=glass", "--preset=graphify", "--name=Demo"], { cwd: projectDir });
21
+ return { ok: true };
22
+ },
23
+ },
24
+ {
25
+ name: "remotion-compositions",
26
+ label: "Loading Remotion compositions",
27
+ async run({ projectDir, spawnFn }) {
28
+ const fn = spawnFn || spawnSync;
29
+ const result = fn("npx", ["remotion", "compositions"], {
30
+ cwd: projectDir,
31
+ stdio: "pipe",
32
+ encoding: "utf8",
33
+ });
34
+ return result.status === 0
35
+ ? { ok: true }
36
+ : { ok: false, message: (result.stderr || result.stdout || "unknown error").toString().trim() };
37
+ },
38
+ },
39
+ {
40
+ name: "ffmpeg-version",
41
+ label: "Validating ffmpeg",
42
+ async run({ spawnFn }) {
43
+ const fn = spawnFn || spawnSync;
44
+ const result = fn("ffmpeg", ["-version"], { stdio: "pipe", encoding: "utf8" });
45
+ return result.status === 0
46
+ ? { ok: true }
47
+ : { ok: false, message: (result.stderr || result.stdout || "ffmpeg failed").toString().trim() };
48
+ },
49
+ },
50
+ ];
51
+
52
+ async function runAll(opts) {
53
+ for (const step of STEPS) {
54
+ info(step.label + "…");
55
+ let outcome;
56
+ try {
57
+ outcome = await step.run(opts);
58
+ } catch (err) {
59
+ outcome = { ok: false, message: err.message };
60
+ }
61
+ if (!outcome.ok) {
62
+ fail(`Smoke test failed at: ${step.name}`);
63
+ if (outcome.message) {
64
+ const lines = outcome.message.split("\n").filter((l) => l.trim());
65
+ const lastLine = lines[lines.length - 1] || outcome.message;
66
+ const truncated = lastLine.length > 200 ? lastLine.slice(0, 197) + "…" : lastLine;
67
+ console.log(c.gray(" " + truncated));
68
+ }
69
+ return { passed: false, failedAt: step.name };
70
+ }
71
+ success(step.label);
72
+ }
73
+ return { passed: true, failedAt: null };
74
+ }
75
+
76
+ module.exports = { STEPS, runAll };
package/cli/update.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `reelstack update` — print guidance for upgrading via npm.
3
+ *
4
+ * ReelStack updates are now distributed via npm. Running
5
+ * `npx @devinilabs/reelstack@latest init` is the canonical upgrade path:
6
+ * it re-validates the license, installs the latest runtime into
7
+ * ~/.reelstack/, and refreshes ~/.claude/skills/reelstack/.
8
+ *
9
+ * State (license key, machine-id, ~/.reelstack/state.json) is preserved
10
+ * across updates because init never overwrites those files on a re-run.
11
+ */
12
+ const { banner, info, c } = require("./utils");
13
+
14
+ async function run() {
15
+ banner();
16
+ console.log("");
17
+ info("ReelStack updates are now distributed via npm.");
18
+ console.log("");
19
+ console.log(c.gray(" Run this from any Remotion project to upgrade to the latest:"));
20
+ console.log(` ${c.cyan("npx @devinilabs/reelstack@latest init")}`);
21
+ console.log("");
22
+ console.log(c.gray(" Your license key + ~/.reelstack/state.json are preserved across updates."));
23
+ console.log("");
24
+ }
25
+
26
+ module.exports = { run };
package/cli/utils.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared CLI helpers: ANSI colors, banner, readline prompts, file ops.
3
+ * No external dependencies — keeps the install lean.
4
+ */
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const readline = require("node:readline");
8
+ const os = require("node:os");
9
+ const { spawnSync } = require("node:child_process");
10
+
11
+ const NO_COLOR = process.env.NO_COLOR === "1" || !process.stdout.isTTY;
12
+
13
+ const wrap = (open, close) => (s) =>
14
+ NO_COLOR ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`;
15
+
16
+ const c = {
17
+ bold: wrap(1, 22),
18
+ dim: wrap(2, 22),
19
+ red: wrap(31, 39),
20
+ green: wrap(32, 39),
21
+ yellow: wrap(33, 39),
22
+ blue: wrap(34, 39),
23
+ magenta: wrap(35, 39),
24
+ cyan: wrap(36, 39),
25
+ gray: wrap(90, 39),
26
+ };
27
+
28
+ const REELSTACK_HOME = path.join(os.homedir(), ".reelstack");
29
+ const LICENSE_CACHE = path.join(REELSTACK_HOME, ".license");
30
+ const CLAUDE_SKILLS_HOME = path.join(os.homedir(), ".claude", "skills");
31
+ const CLAUDE_COMMANDS_HOME = path.join(os.homedir(), ".claude", "commands");
32
+
33
+ function banner() {
34
+ // Read version from package.json so the banner stays in sync across releases.
35
+ const PKG_VERSION = require("../package.json").version;
36
+ // Pad-right to keep the box visually balanced regardless of version length.
37
+ const versionLine = ` v${PKG_VERSION} · reelstack.dev`;
38
+ const padded = versionLine.padEnd(43, " ");
39
+ const lines = [
40
+ "",
41
+ c.magenta(" ┌─────────────────────────────────────────┐"),
42
+ c.magenta(" │") + " " + c.bold(c.cyan("ReelStack")) + c.gray(" — Premium 9:16 Reel OS ") + c.magenta("│"),
43
+ c.magenta(" │") + c.gray(padded) + c.magenta("│"),
44
+ c.magenta(" └─────────────────────────────────────────┘"),
45
+ "",
46
+ ];
47
+ console.log(lines.join("\n"));
48
+ }
49
+
50
+ function info(msg) { console.log(c.cyan("ℹ ") + msg); }
51
+ function success(msg) { console.log(c.green("✓ ") + msg); }
52
+ function warn(msg) { console.warn(c.yellow("⚠ ") + msg); }
53
+ function fail(msg) { console.error(c.red("✗ ") + msg); }
54
+
55
+ function ensureDir(dir) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ }
58
+
59
+ function readJson(file) {
60
+ return JSON.parse(fs.readFileSync(file, "utf8"));
61
+ }
62
+
63
+ function writeFileSafe(file, contents) {
64
+ ensureDir(path.dirname(file));
65
+ fs.writeFileSync(file, contents, "utf8");
66
+ }
67
+
68
+ function ask(question) {
69
+ return new Promise((resolve) => {
70
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
71
+ rl.question(c.cyan("? ") + question + " ", (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim());
74
+ });
75
+ });
76
+ }
77
+
78
+ async function askYesNo(question, defaultYes = true) {
79
+ const suffix = defaultYes ? "(Y/n)" : "(y/N)";
80
+ const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
81
+ if (!answer) return defaultYes;
82
+ return answer === "y" || answer === "yes";
83
+ }
84
+
85
+ function which(bin) {
86
+ const PATH = (process.env.PATH || "").split(path.delimiter);
87
+ const exts = process.platform === "win32"
88
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
89
+ : [""];
90
+ for (const dir of PATH) {
91
+ for (const ext of exts) {
92
+ const candidate = path.join(dir, bin + ext);
93
+ if (fs.existsSync(candidate)) return candidate;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ const STATE_FILE = path.join(REELSTACK_HOME, "state.json");
100
+
101
+ function loadState() {
102
+ try {
103
+ return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ function saveState(patch) {
110
+ const current = loadState();
111
+ const next = { ...current, ...patch };
112
+ ensureDir(path.dirname(STATE_FILE));
113
+ fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2), "utf8");
114
+ }
115
+
116
+ // Returns { kind, command, install }:
117
+ // kind: "auto" — install() can run silently with user consent (e.g. brew on macOS)
118
+ // kind: "guide" — print command and let the user run it themselves
119
+ function getPlatformInstaller(platform, bin, opts = {}) {
120
+ const whichFn = opts.whichFn || which;
121
+
122
+ if (platform === "darwin") {
123
+ if (whichFn("brew")) {
124
+ return {
125
+ kind: "auto",
126
+ command: `brew install ${bin}`,
127
+ install() {
128
+ return spawnSync("brew", ["install", bin], { stdio: "inherit" });
129
+ },
130
+ };
131
+ }
132
+ return {
133
+ kind: "guide",
134
+ command: `Install Homebrew (https://brew.sh), then: brew install ${bin}`,
135
+ install: null,
136
+ };
137
+ }
138
+
139
+ if (platform === "linux") {
140
+ return {
141
+ kind: "guide",
142
+ command: [
143
+ `apt-get install ${bin} # Debian/Ubuntu`,
144
+ `dnf install ${bin} # Fedora`,
145
+ `pacman -S ${bin} # Arch`,
146
+ ].join("\n "),
147
+ install: null,
148
+ };
149
+ }
150
+
151
+ if (platform === "win32") {
152
+ return {
153
+ kind: "guide",
154
+ command: [
155
+ `choco install ${bin} # Chocolatey`,
156
+ `scoop install ${bin} # Scoop`,
157
+ ].join("\n "),
158
+ install: null,
159
+ };
160
+ }
161
+
162
+ return {
163
+ kind: "guide",
164
+ command: `Install ${bin} via your platform's package manager`,
165
+ install: null,
166
+ };
167
+ }
168
+
169
+ module.exports = {
170
+ c,
171
+ banner,
172
+ info, success, warn, fail,
173
+ ensureDir, readJson, writeFileSafe,
174
+ ask, askYesNo,
175
+ which,
176
+ REELSTACK_HOME,
177
+ LICENSE_CACHE,
178
+ CLAUDE_SKILLS_HOME,
179
+ CLAUDE_COMMANDS_HOME,
180
+ STATE_FILE,
181
+ loadState,
182
+ saveState,
183
+ getPlatformInstaller,
184
+ };