loops-cli 0.1.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.
@@ -0,0 +1,37 @@
1
+ // src/config.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ var CONFIG_FILE = ".loops.json";
5
+ async function loadConfig(dir) {
6
+ try {
7
+ const raw = await fs.readFile(path.join(dir, CONFIG_FILE), "utf-8");
8
+ return JSON.parse(raw);
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+ async function saveConfig(dir, config) {
14
+ await fs.writeFile(
15
+ path.join(dir, CONFIG_FILE),
16
+ JSON.stringify(config, null, 2) + "\n"
17
+ );
18
+ }
19
+ async function findProjects(baseDir) {
20
+ const projects = [];
21
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
24
+ const dir = path.join(baseDir, entry.name);
25
+ const config = await loadConfig(dir);
26
+ if (config) {
27
+ projects.push({ dir, config });
28
+ }
29
+ }
30
+ return projects;
31
+ }
32
+
33
+ export {
34
+ loadConfig,
35
+ saveConfig,
36
+ findProjects
37
+ };
@@ -0,0 +1,10 @@
1
+ import {
2
+ findProjects,
3
+ loadConfig,
4
+ saveConfig
5
+ } from "./chunk-BOQYMFCY.js";
6
+ export {
7
+ findProjects,
8
+ loadConfig,
9
+ saveConfig
10
+ };
package/dist/index.js ADDED
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ findProjects,
4
+ loadConfig,
5
+ saveConfig
6
+ } from "./chunk-BOQYMFCY.js";
7
+
8
+ // src/index.ts
9
+ import { program } from "commander";
10
+
11
+ // src/commands/init.ts
12
+ import fs from "fs/promises";
13
+ import path from "path";
14
+ var DEFAULT_API = "http://localhost:8787";
15
+ async function initCommand(projectName, opts) {
16
+ const apiUrl = opts.apiUrl || DEFAULT_API;
17
+ const projectDir = path.resolve(projectName);
18
+ await fs.mkdir(projectDir, { recursive: true });
19
+ await fs.mkdir(path.join(projectDir, "scripts"), { recursive: true });
20
+ const res = await fetch(`${apiUrl}/projects`, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify({ name: projectName })
24
+ });
25
+ if (!res.ok) {
26
+ const body = await res.text();
27
+ console.error(`Failed to create project: ${res.status} ${body}`);
28
+ process.exit(1);
29
+ }
30
+ const { projectId, authToken, baseTag } = await res.json();
31
+ await saveConfig(projectDir, {
32
+ name: projectName,
33
+ projectId,
34
+ apiUrl,
35
+ authToken,
36
+ scripts: []
37
+ });
38
+ const guide = `# Loops Project: ${projectName}
39
+
40
+ ## How this works
41
+ - Scripts live in ./scripts/ \u2014 each file is served to your Webflow site
42
+ - Edit scripts here, they auto-sync to the live site when \`loops start\` is running
43
+ - Read logs.json to see browser output and errors
44
+ - To add a new script: \`loops add ${projectName} <name>\`
45
+
46
+ ## Scripts in this project
47
+ (none yet \u2014 use \`loops add\` to create scripts)
48
+
49
+ ## Webflow coding rules
50
+ - The HTML and CSS are built in Webflow. NEVER generate HTML with JavaScript.
51
+ - Select elements by their Webflow class names (\`.hero-section_home\`, \`.nav_link\`).
52
+ - Code adds BEHAVIOR: animations, interactions, form logic, API calls.
53
+ - Use GSAP/ScrollTrigger for animations.
54
+ - Work with the existing DOM \u2014 query it, animate it, enhance it. Don't replace it.
55
+ - Always verify selectors exist before operating on them. Log if not found.
56
+ `;
57
+ await fs.writeFile(path.join(projectDir, "LOOPS.md"), guide);
58
+ await fs.writeFile(path.join(projectDir, "logs.json"), "[]\n");
59
+ console.log(`
60
+ Created project "${projectName}" (${projectId})
61
+ `);
62
+ console.log(`Paste this in Webflow \u2192 Site Settings \u2192 Custom Code \u2192 Before </body>:
63
+ `);
64
+ console.log(` ${baseTag}
65
+ `);
66
+ console.log(`Now add scripts with: loops add ${projectName} <script-name>
67
+ `);
68
+ }
69
+
70
+ // src/commands/add.ts
71
+ import fs2 from "fs/promises";
72
+ import path2 from "path";
73
+ async function addCommand(projectName, scriptName, opts) {
74
+ const projectDir = path2.resolve(projectName);
75
+ const config = await loadConfig(projectDir);
76
+ if (!config) {
77
+ console.error(
78
+ `No .loops.json found in ${projectDir}. Run "loops init ${projectName}" first.`
79
+ );
80
+ process.exit(1);
81
+ }
82
+ if (config.scripts.includes(scriptName)) {
83
+ console.error(`Script "${scriptName}" already exists in ${projectName}.`);
84
+ process.exit(1);
85
+ }
86
+ const scriptPath = path2.join(projectDir, "scripts", `${scriptName}.js`);
87
+ await fs2.writeFile(
88
+ scriptPath,
89
+ `// ${scriptName} \u2014 ${config.name}
90
+ console.log("[Loops] ${scriptName} loaded");
91
+ `
92
+ );
93
+ config.scripts.push(scriptName);
94
+ await saveConfig(projectDir, config);
95
+ await updateLoopsMd(projectDir, config.scripts, config.name);
96
+ const scriptTag = `<script src="${config.apiUrl}/s/${config.projectId}/${scriptName}"></script>`;
97
+ console.log(`
98
+ Added script "${scriptName}" to ${projectName}
99
+ `);
100
+ console.log(`Paste this on the relevant page in Webflow \u2192 Before </body>:
101
+ `);
102
+ console.log(` ${scriptTag}
103
+ `);
104
+ }
105
+ async function updateLoopsMd(projectDir, scripts, projectName) {
106
+ const mdPath = path2.join(projectDir, "LOOPS.md");
107
+ try {
108
+ let content = await fs2.readFile(mdPath, "utf-8");
109
+ const scriptsSection = scripts.length > 0 ? scripts.map((s) => `- ${s}.js`).join("\n") : "(none yet)";
110
+ content = content.replace(
111
+ /## Scripts in this project\n[\s\S]*?(?=\n## )/,
112
+ `## Scripts in this project
113
+ ${scriptsSection}
114
+
115
+ `
116
+ );
117
+ await fs2.writeFile(mdPath, content);
118
+ } catch {
119
+ }
120
+ }
121
+
122
+ // src/commands/remove.ts
123
+ import fs3 from "fs/promises";
124
+ import path3 from "path";
125
+ async function removeCommand(projectName, scriptName) {
126
+ const projectDir = path3.resolve(projectName);
127
+ const config = await loadConfig(projectDir);
128
+ if (!config) {
129
+ console.error(
130
+ `No .loops.json found in ${projectDir}. Run "loops init ${projectName}" first.`
131
+ );
132
+ process.exit(1);
133
+ }
134
+ if (!config.scripts.includes(scriptName)) {
135
+ console.error(`Script "${scriptName}" not found in ${projectName}.`);
136
+ process.exit(1);
137
+ }
138
+ try {
139
+ await fetch(`${config.apiUrl}/code/${config.projectId}/${scriptName}`, {
140
+ method: "DELETE",
141
+ headers: { Authorization: `Bearer ${config.authToken}` }
142
+ });
143
+ } catch {
144
+ console.error("Warning: could not delete from API (may not be running).");
145
+ }
146
+ const scriptPath = path3.join(projectDir, "scripts", `${scriptName}.js`);
147
+ try {
148
+ await fs3.unlink(scriptPath);
149
+ } catch {
150
+ }
151
+ config.scripts = config.scripts.filter((s) => s !== scriptName);
152
+ await saveConfig(projectDir, config);
153
+ console.log(`Removed script "${scriptName}" from ${projectName}.`);
154
+ }
155
+
156
+ // src/commands/start.ts
157
+ import fs4 from "fs/promises";
158
+ import path4 from "path";
159
+ import { watch } from "chokidar";
160
+ async function startCommand(opts) {
161
+ const interval = parseInt(opts.pollInterval || "3000", 10);
162
+ const cwd = process.cwd();
163
+ const projects = await findProjects(cwd);
164
+ if (projects.length === 0) {
165
+ const { loadConfig: loadConfig2 } = await import("./config-MNZSQUWZ.js");
166
+ const config = await loadConfig2(cwd);
167
+ if (config) {
168
+ projects.push({ dir: cwd, config });
169
+ } else {
170
+ console.error(
171
+ "No Loops projects found. Run `loops init <name>` first."
172
+ );
173
+ process.exit(1);
174
+ }
175
+ }
176
+ console.log(`
177
+ Watching ${projects.length} project(s)
178
+ `);
179
+ for (const { dir, config } of projects) {
180
+ const scriptCount = config.scripts.length;
181
+ console.log(
182
+ ` ${config.name}: ${scriptCount} script(s)`
183
+ );
184
+ startWatcher(dir, config);
185
+ startPoller(dir, config, interval);
186
+ }
187
+ console.log("");
188
+ process.on("SIGINT", () => {
189
+ console.log("\nLoops stopped.");
190
+ process.exit(0);
191
+ });
192
+ }
193
+ function startWatcher(projectDir, config) {
194
+ const scriptsDir = path4.join(projectDir, "scripts");
195
+ const watcher = watch(path4.join(scriptsDir, "*.js"), {
196
+ ignoreInitial: false,
197
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
198
+ });
199
+ let pushing = false;
200
+ const pushFile = async (filePath) => {
201
+ if (pushing) return;
202
+ pushing = true;
203
+ try {
204
+ const scriptName = path4.basename(filePath, ".js");
205
+ const code = await fs4.readFile(filePath, "utf-8");
206
+ const res = await fetch(
207
+ `${config.apiUrl}/code/${config.projectId}/${scriptName}`,
208
+ {
209
+ method: "PUT",
210
+ headers: {
211
+ "Content-Type": "text/plain",
212
+ Authorization: `Bearer ${config.authToken}`
213
+ },
214
+ body: code
215
+ }
216
+ );
217
+ if (!res.ok) {
218
+ const body = await res.text();
219
+ console.error(` Push failed (${scriptName}): ${res.status} ${body}`);
220
+ } else {
221
+ const { version } = await res.json();
222
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString();
223
+ console.log(
224
+ `[${time}] ${config.name}/${scriptName} \u2192 v${version} (${code.length}b)`
225
+ );
226
+ }
227
+ } catch (err) {
228
+ console.error(` Push error: ${err.message}`);
229
+ } finally {
230
+ pushing = false;
231
+ }
232
+ };
233
+ watcher.on("add", pushFile);
234
+ watcher.on("change", pushFile);
235
+ }
236
+ function startPoller(projectDir, config, intervalMs) {
237
+ const logsPath = path4.join(projectDir, "logs.json");
238
+ let lastTimestamp = null;
239
+ const poll = async () => {
240
+ try {
241
+ const url = new URL(`${config.apiUrl}/logs/${config.projectId}`);
242
+ if (lastTimestamp) {
243
+ url.searchParams.set("since", lastTimestamp);
244
+ }
245
+ const res = await fetch(url.toString(), {
246
+ headers: { Authorization: `Bearer ${config.authToken}` }
247
+ });
248
+ if (!res.ok) return;
249
+ const newEntries = await res.json();
250
+ if (newEntries.length === 0) return;
251
+ let existing = [];
252
+ try {
253
+ const raw = await fs4.readFile(logsPath, "utf-8");
254
+ existing = JSON.parse(raw);
255
+ } catch {
256
+ existing = [];
257
+ }
258
+ const merged = [...existing, ...newEntries].slice(-1e3);
259
+ await fs4.writeFile(logsPath, JSON.stringify(merged, null, 2) + "\n");
260
+ const pageHtmlEntries = newEntries.filter((e) => e.type === "page-html");
261
+ if (pageHtmlEntries.length > 0) {
262
+ const latest = pageHtmlEntries[pageHtmlEntries.length - 1];
263
+ const html = Array.isArray(latest.args) ? latest.args[0] : "";
264
+ if (typeof html === "string" && html.length > 0) {
265
+ const contextPath = path4.join(projectDir, "page-context.html");
266
+ await fs4.writeFile(contextPath, html);
267
+ }
268
+ }
269
+ lastTimestamp = newEntries[newEntries.length - 1].timestamp;
270
+ const errors = newEntries.filter(
271
+ (e) => e.type === "error" || e.type === "unhandled-error" || e.type === "unhandled-rejection"
272
+ );
273
+ const time = (/* @__PURE__ */ new Date()).toLocaleTimeString();
274
+ if (errors.length > 0) {
275
+ console.log(
276
+ `[${time}] ${config.name}: +${newEntries.length} logs (${errors.length} error(s))`
277
+ );
278
+ } else {
279
+ console.log(`[${time}] ${config.name}: +${newEntries.length} logs`);
280
+ }
281
+ } catch {
282
+ }
283
+ };
284
+ poll();
285
+ setInterval(poll, intervalMs);
286
+ }
287
+
288
+ // src/commands/status.ts
289
+ async function statusCommand() {
290
+ const cwd = process.cwd();
291
+ const projects = await findProjects(cwd);
292
+ const cwdConfig = await loadConfig(cwd);
293
+ if (cwdConfig) {
294
+ projects.push({ dir: cwd, config: cwdConfig });
295
+ }
296
+ if (projects.length === 0) {
297
+ console.log("No Loops projects found.");
298
+ return;
299
+ }
300
+ console.log(`
301
+ ${projects.length} project(s)
302
+ `);
303
+ for (const { config } of projects) {
304
+ console.log(` ${config.name} (${config.projectId})`);
305
+ console.log(` API: ${config.apiUrl}`);
306
+ if (config.scripts.length > 0) {
307
+ console.log(` Scripts: ${config.scripts.join(", ")}`);
308
+ } else {
309
+ console.log(` Scripts: (none)`);
310
+ }
311
+ console.log("");
312
+ }
313
+ }
314
+
315
+ // src/commands/exportCmd.ts
316
+ import fs5 from "fs/promises";
317
+ import path5 from "path";
318
+ async function exportCommand(projectName, opts) {
319
+ const projectDir = path5.resolve(projectName);
320
+ const config = await loadConfig(projectDir);
321
+ if (!config) {
322
+ console.error(
323
+ `No .loops.json found in ${projectDir}. Run "loops init ${projectName}" first.`
324
+ );
325
+ process.exit(1);
326
+ }
327
+ if (config.scripts.length === 0) {
328
+ console.log("No scripts to export.");
329
+ return;
330
+ }
331
+ if (opts.inline) {
332
+ console.log("<!-- Loops export: paste in Webflow Custom Code -->");
333
+ console.log("<script>");
334
+ for (const scriptName of config.scripts) {
335
+ const scriptPath = path5.join(
336
+ projectDir,
337
+ "scripts",
338
+ `${scriptName}.js`
339
+ );
340
+ const code = await fs5.readFile(scriptPath, "utf-8");
341
+ console.log(`
342
+ // === ${scriptName}.js ===`);
343
+ console.log(`(function(){`);
344
+ console.log(code);
345
+ console.log(`})();`);
346
+ }
347
+ console.log("</script>");
348
+ } else {
349
+ const exportDir = path5.join(projectDir, "export");
350
+ await fs5.mkdir(exportDir, { recursive: true });
351
+ for (const scriptName of config.scripts) {
352
+ const srcPath = path5.join(projectDir, "scripts", `${scriptName}.js`);
353
+ const code = await fs5.readFile(srcPath, "utf-8");
354
+ const wrapped = `(function(){
355
+ ${code}
356
+ })();
357
+ `;
358
+ const destPath = path5.join(exportDir, `${scriptName}.js`);
359
+ await fs5.writeFile(destPath, wrapped);
360
+ console.log(` Exported: export/${scriptName}.js`);
361
+ }
362
+ console.log(`
363
+ ${config.scripts.length} script(s) exported to ${exportDir}`);
364
+ }
365
+ }
366
+
367
+ // src/commands/clearLogs.ts
368
+ import fs6 from "fs/promises";
369
+ import path6 from "path";
370
+ async function clearLogsCommand(project) {
371
+ const projectDir = path6.resolve(project);
372
+ const config = await loadConfig(projectDir);
373
+ if (!config) {
374
+ console.error(`No Loops project found in "${project}".`);
375
+ process.exit(1);
376
+ }
377
+ const res = await fetch(`${config.apiUrl}/logs/${config.projectId}`, {
378
+ method: "DELETE",
379
+ headers: { Authorization: `Bearer ${config.authToken}` }
380
+ });
381
+ if (!res.ok) {
382
+ const body = await res.text();
383
+ console.error(`Failed to clear remote logs: ${res.status} ${body}`);
384
+ process.exit(1);
385
+ }
386
+ const logsPath = path6.join(projectDir, "logs.json");
387
+ await fs6.writeFile(logsPath, "[]\n");
388
+ console.log(`Cleared logs for "${config.name}".`);
389
+ }
390
+
391
+ // src/index.ts
392
+ program.name("loops").description("Live coding tool for Webflow").version("0.1.0");
393
+ program.command("init <project-name>").description("Create a new Loops project").option("--api-url <url>", "API URL", "http://localhost:8787").action(initCommand);
394
+ program.command("add <project> <script-name>").description("Add a script to a project").action(addCommand);
395
+ program.command("remove <project> <script-name>").description("Remove a script from a project").action(removeCommand);
396
+ program.command("start").description("Watch all projects and sync scripts/logs").option("--poll-interval <ms>", "Log poll interval in ms", "3000").action(startCommand);
397
+ program.command("status").description("Show all projects and scripts").action(statusCommand);
398
+ program.command("export <project>").description("Export scripts for self-hosting").option("--inline", "Output code ready to paste in Webflow").action(exportCommand);
399
+ program.command("clear-logs <project>").description("Delete all logs for a project (remote + local)").action(clearLogsCommand);
400
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "loops-cli",
3
+ "version": "0.1.0",
4
+ "description": "Live coding tool for Webflow. Edit scripts locally, sync to staging, debug with live logs.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "loops": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "webflow",
18
+ "live-coding",
19
+ "cli",
20
+ "scripts",
21
+ "staging"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsup src/index.ts --format esm --target node22 --clean",
25
+ "dev": "tsup src/index.ts --format esm --target node22 --watch"
26
+ },
27
+ "dependencies": {
28
+ "commander": "^13.0.0",
29
+ "chokidar": "^4.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@loops/shared": "*",
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.5.0",
35
+ "@types/node": "^22.0.0"
36
+ }
37
+ }