@spike-forms/cli 0.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.
- package/README.md +222 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2287 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import * as fs3 from 'fs';
|
|
4
|
+
import * as path3 from 'path';
|
|
5
|
+
import * as os2 from 'os';
|
|
6
|
+
import { platform } from 'os';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { SpikeClient, SpikeError } from '@spike-forms/sdk';
|
|
9
|
+
import { spawn, exec, execSync } from 'child_process';
|
|
10
|
+
import * as http from 'http';
|
|
11
|
+
import * as crypto from 'crypto';
|
|
12
|
+
|
|
13
|
+
var DEFAULT_CONFIG = {
|
|
14
|
+
apiKey: "",
|
|
15
|
+
baseUrl: "https://api.spike.ac"
|
|
16
|
+
};
|
|
17
|
+
var ENV_VARS = {
|
|
18
|
+
API_KEY: "SPIKE_API_KEY",
|
|
19
|
+
TOKEN: "SPIKE_TOKEN",
|
|
20
|
+
API_URL: "SPIKE_API_URL"
|
|
21
|
+
};
|
|
22
|
+
function getConfigPath() {
|
|
23
|
+
const homeDir = os2.homedir();
|
|
24
|
+
return path3.join(homeDir, ".spike", "config.json");
|
|
25
|
+
}
|
|
26
|
+
function getConfigDir() {
|
|
27
|
+
const homeDir = os2.homedir();
|
|
28
|
+
return path3.join(homeDir, ".spike");
|
|
29
|
+
}
|
|
30
|
+
function loadConfigFromFile() {
|
|
31
|
+
const configPath = getConfigPath();
|
|
32
|
+
try {
|
|
33
|
+
if (!fs3.existsSync(configPath)) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const fileContent = fs3.readFileSync(configPath, "utf-8");
|
|
37
|
+
const parsed = JSON.parse(fileContent);
|
|
38
|
+
const config = {};
|
|
39
|
+
if (typeof parsed.apiKey === "string") {
|
|
40
|
+
config.apiKey = parsed.apiKey;
|
|
41
|
+
}
|
|
42
|
+
if (typeof parsed.baseUrl === "string") {
|
|
43
|
+
config.baseUrl = parsed.baseUrl;
|
|
44
|
+
}
|
|
45
|
+
return config;
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function loadConfigFromEnv() {
|
|
51
|
+
const config = {};
|
|
52
|
+
const apiKey = process.env[ENV_VARS.API_KEY] || process.env[ENV_VARS.TOKEN];
|
|
53
|
+
if (apiKey) {
|
|
54
|
+
config.apiKey = apiKey;
|
|
55
|
+
}
|
|
56
|
+
const apiUrl = process.env[ENV_VARS.API_URL];
|
|
57
|
+
if (apiUrl) {
|
|
58
|
+
config.baseUrl = apiUrl;
|
|
59
|
+
}
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
function loadConfig() {
|
|
63
|
+
const fileConfig = loadConfigFromFile();
|
|
64
|
+
const envConfig = loadConfigFromEnv();
|
|
65
|
+
return {
|
|
66
|
+
...DEFAULT_CONFIG,
|
|
67
|
+
...fileConfig,
|
|
68
|
+
...envConfig
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function saveConfig(config) {
|
|
72
|
+
const configDir = getConfigDir();
|
|
73
|
+
const configPath = getConfigPath();
|
|
74
|
+
if (!fs3.existsSync(configDir)) {
|
|
75
|
+
fs3.mkdirSync(configDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
const existingConfig = loadConfigFromFile();
|
|
78
|
+
const mergedConfig = {
|
|
79
|
+
...existingConfig,
|
|
80
|
+
...config
|
|
81
|
+
};
|
|
82
|
+
const cleanConfig = {};
|
|
83
|
+
if (mergedConfig.apiKey) {
|
|
84
|
+
cleanConfig.apiKey = mergedConfig.apiKey;
|
|
85
|
+
}
|
|
86
|
+
if (mergedConfig.baseUrl) {
|
|
87
|
+
cleanConfig.baseUrl = mergedConfig.baseUrl;
|
|
88
|
+
}
|
|
89
|
+
fs3.writeFileSync(configPath, JSON.stringify(cleanConfig, null, 2) + "\n", "utf-8");
|
|
90
|
+
}
|
|
91
|
+
function output(data, format = "table") {
|
|
92
|
+
if (format === "json") {
|
|
93
|
+
outputJson(data);
|
|
94
|
+
} else {
|
|
95
|
+
outputTable(data);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function outputJson(data) {
|
|
99
|
+
console.log(JSON.stringify(data, null, 2));
|
|
100
|
+
}
|
|
101
|
+
function outputTable(data) {
|
|
102
|
+
if (data === null || data === void 0) {
|
|
103
|
+
console.log("No data");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(data)) {
|
|
107
|
+
outputArrayAsTable(data);
|
|
108
|
+
} else if (typeof data === "object") {
|
|
109
|
+
outputObjectAsTable(data);
|
|
110
|
+
} else {
|
|
111
|
+
console.log(String(data));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function outputArrayAsTable(data) {
|
|
115
|
+
if (data.length === 0) {
|
|
116
|
+
console.log("No items found");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const keys = /* @__PURE__ */ new Set();
|
|
120
|
+
for (const item of data) {
|
|
121
|
+
if (item && typeof item === "object") {
|
|
122
|
+
Object.keys(item).forEach((key) => keys.add(key));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const columns = Array.from(keys);
|
|
126
|
+
if (columns.length === 0) {
|
|
127
|
+
data.forEach((item) => console.log(String(item)));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const columnWidths = /* @__PURE__ */ new Map();
|
|
131
|
+
for (const col of columns) {
|
|
132
|
+
let maxWidth = col.length;
|
|
133
|
+
for (const item of data) {
|
|
134
|
+
if (item && typeof item === "object") {
|
|
135
|
+
const value = formatCellValue(item[col]);
|
|
136
|
+
maxWidth = Math.max(maxWidth, value.length);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
columnWidths.set(col, Math.min(maxWidth, 50));
|
|
140
|
+
}
|
|
141
|
+
const headerRow = columns.map((col) => padRight(col.toUpperCase(), columnWidths.get(col))).join(" ");
|
|
142
|
+
console.log(chalk.bold(headerRow));
|
|
143
|
+
const separator = columns.map((col) => "-".repeat(columnWidths.get(col))).join(" ");
|
|
144
|
+
console.log(separator);
|
|
145
|
+
for (const item of data) {
|
|
146
|
+
if (item && typeof item === "object") {
|
|
147
|
+
const row = columns.map((col) => {
|
|
148
|
+
const value = formatCellValue(item[col]);
|
|
149
|
+
return padRight(truncate(value, columnWidths.get(col)), columnWidths.get(col));
|
|
150
|
+
}).join(" ");
|
|
151
|
+
console.log(row);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.log("");
|
|
155
|
+
console.log(chalk.dim(`${data.length} item${data.length === 1 ? "" : "s"}`));
|
|
156
|
+
}
|
|
157
|
+
function outputObjectAsTable(data) {
|
|
158
|
+
const entries = Object.entries(data);
|
|
159
|
+
if (entries.length === 0) {
|
|
160
|
+
console.log("No data");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const maxKeyLength = Math.max(...entries.map(([key]) => key.length));
|
|
164
|
+
for (const [key, value] of entries) {
|
|
165
|
+
const formattedKey = chalk.bold(padRight(key, maxKeyLength));
|
|
166
|
+
const formattedValue = formatCellValue(value);
|
|
167
|
+
console.log(`${formattedKey} ${formattedValue}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function formatCellValue(value) {
|
|
171
|
+
if (value === null || value === void 0) {
|
|
172
|
+
return "-";
|
|
173
|
+
}
|
|
174
|
+
if (typeof value === "boolean") {
|
|
175
|
+
return value ? chalk.green("yes") : chalk.dim("no");
|
|
176
|
+
}
|
|
177
|
+
if (typeof value === "object") {
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
return `[${value.length} items]`;
|
|
180
|
+
}
|
|
181
|
+
return JSON.stringify(value);
|
|
182
|
+
}
|
|
183
|
+
return String(value);
|
|
184
|
+
}
|
|
185
|
+
function padRight(str, length) {
|
|
186
|
+
const visibleLength = stripAnsi(str).length;
|
|
187
|
+
if (visibleLength >= length) {
|
|
188
|
+
return str;
|
|
189
|
+
}
|
|
190
|
+
return str + " ".repeat(length - visibleLength);
|
|
191
|
+
}
|
|
192
|
+
function truncate(str, maxLength) {
|
|
193
|
+
const visibleLength = stripAnsi(str).length;
|
|
194
|
+
if (visibleLength <= maxLength) {
|
|
195
|
+
return str;
|
|
196
|
+
}
|
|
197
|
+
return str.slice(0, maxLength - 3) + "...";
|
|
198
|
+
}
|
|
199
|
+
function stripAnsi(str) {
|
|
200
|
+
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
|
201
|
+
}
|
|
202
|
+
function success(message) {
|
|
203
|
+
console.log(chalk.green(`\u2713 ${message}`));
|
|
204
|
+
}
|
|
205
|
+
function error(message) {
|
|
206
|
+
console.error(chalk.red(`\u2717 ${message}`));
|
|
207
|
+
}
|
|
208
|
+
function warn(message) {
|
|
209
|
+
console.log(chalk.yellow(`\u26A0 ${message}`));
|
|
210
|
+
}
|
|
211
|
+
function info(message) {
|
|
212
|
+
console.log(chalk.cyan(`\u2139 ${message}`));
|
|
213
|
+
}
|
|
214
|
+
function maskApiKey(apiKey) {
|
|
215
|
+
if (!apiKey) {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
if (apiKey.length <= 11) {
|
|
219
|
+
const visibleStart = Math.min(4, apiKey.length - 1);
|
|
220
|
+
return apiKey.slice(0, visibleStart) + "...";
|
|
221
|
+
}
|
|
222
|
+
const prefix = apiKey.slice(0, 7);
|
|
223
|
+
const suffix = apiKey.slice(-4);
|
|
224
|
+
return `${prefix}...${suffix}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/commands/config.ts
|
|
228
|
+
var VALID_KEYS = ["api-key", "base-url"];
|
|
229
|
+
var KEY_MAP = {
|
|
230
|
+
"api-key": "apiKey",
|
|
231
|
+
"base-url": "baseUrl"
|
|
232
|
+
};
|
|
233
|
+
function isValidKey(key) {
|
|
234
|
+
return VALID_KEYS.includes(key);
|
|
235
|
+
}
|
|
236
|
+
function createConfigCommand() {
|
|
237
|
+
const configCommand = new Command("config").description("Manage CLI configuration");
|
|
238
|
+
configCommand.command("set").description("Set a configuration value").argument("<key>", `Configuration key (${VALID_KEYS.join(", ")})`).argument("<value>", "Configuration value").action((key, value) => {
|
|
239
|
+
handleSet(key, value);
|
|
240
|
+
});
|
|
241
|
+
configCommand.command("get").description("Get configuration value(s)").argument("[key]", `Configuration key (${VALID_KEYS.join(", ")})`).option("-f, --format <format>", "Output format (json, table)", "table").action((key, options) => {
|
|
242
|
+
handleGet(key, options.format);
|
|
243
|
+
});
|
|
244
|
+
return configCommand;
|
|
245
|
+
}
|
|
246
|
+
function handleSet(key, value) {
|
|
247
|
+
if (!isValidKey(key)) {
|
|
248
|
+
error(`Invalid configuration key: ${key}`);
|
|
249
|
+
info(`Valid keys are: ${VALID_KEYS.join(", ")}`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
const internalKey = KEY_MAP[key];
|
|
253
|
+
try {
|
|
254
|
+
saveConfig({ [internalKey]: value });
|
|
255
|
+
success(`Configuration '${key}' has been set`);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
error(`Failed to save configuration: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function handleGet(key, format) {
|
|
262
|
+
const config = loadConfig();
|
|
263
|
+
if (key !== void 0) {
|
|
264
|
+
if (!isValidKey(key)) {
|
|
265
|
+
error(`Invalid configuration key: ${key}`);
|
|
266
|
+
info(`Valid keys are: ${VALID_KEYS.join(", ")}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const internalKey = KEY_MAP[key];
|
|
270
|
+
let value = config[internalKey];
|
|
271
|
+
if (key === "api-key" && value) {
|
|
272
|
+
value = maskApiKey(value);
|
|
273
|
+
}
|
|
274
|
+
if (!value) {
|
|
275
|
+
info(`Configuration '${key}' is not set`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (format === "json") {
|
|
279
|
+
output({ [key]: value }, format);
|
|
280
|
+
} else {
|
|
281
|
+
console.log(value);
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
const displayConfig = {};
|
|
285
|
+
if (config.apiKey) {
|
|
286
|
+
displayConfig["api-key"] = maskApiKey(config.apiKey);
|
|
287
|
+
} else {
|
|
288
|
+
displayConfig["api-key"] = "(not set)";
|
|
289
|
+
}
|
|
290
|
+
displayConfig["base-url"] = config.baseUrl || "(not set)";
|
|
291
|
+
info(`Configuration file: ${getConfigPath()}`);
|
|
292
|
+
console.log("");
|
|
293
|
+
output(displayConfig, format);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
var SSE_SCRIPT = `<script>
|
|
297
|
+
(function() {
|
|
298
|
+
var es = new EventSource('/__live-reload');
|
|
299
|
+
es.onmessage = function(event) {
|
|
300
|
+
if (event.data === 'reload') {
|
|
301
|
+
location.reload();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
es.onerror = function() {
|
|
305
|
+
console.log('[Live Preview] Connection lost, attempting to reconnect...');
|
|
306
|
+
};
|
|
307
|
+
})();
|
|
308
|
+
</script>`;
|
|
309
|
+
function injectLiveReload(html) {
|
|
310
|
+
const bodyCloseRegex = /<\/body>/i;
|
|
311
|
+
const match = html.match(bodyCloseRegex);
|
|
312
|
+
if (match && match.index !== void 0) {
|
|
313
|
+
return html.slice(0, match.index) + SSE_SCRIPT + html.slice(match.index);
|
|
314
|
+
}
|
|
315
|
+
return html + SSE_SCRIPT;
|
|
316
|
+
}
|
|
317
|
+
function startLivePreviewServer(filePath) {
|
|
318
|
+
return new Promise((resolve3, reject) => {
|
|
319
|
+
const absolutePath = path3.resolve(filePath);
|
|
320
|
+
const sseClients = [];
|
|
321
|
+
let debounceTimer = null;
|
|
322
|
+
function broadcastToClients(message) {
|
|
323
|
+
const data = `data: ${message}
|
|
324
|
+
|
|
325
|
+
`;
|
|
326
|
+
for (let i = sseClients.length - 1; i >= 0; i--) {
|
|
327
|
+
const client = sseClients[i];
|
|
328
|
+
if (!client) continue;
|
|
329
|
+
try {
|
|
330
|
+
if (!client.writableEnded) {
|
|
331
|
+
client.write(data);
|
|
332
|
+
} else {
|
|
333
|
+
sseClients.splice(i, 1);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
sseClients.splice(i, 1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function onFileChange() {
|
|
341
|
+
if (debounceTimer) {
|
|
342
|
+
clearTimeout(debounceTimer);
|
|
343
|
+
}
|
|
344
|
+
debounceTimer = setTimeout(() => {
|
|
345
|
+
broadcastToClients("reload");
|
|
346
|
+
debounceTimer = null;
|
|
347
|
+
}, 100);
|
|
348
|
+
}
|
|
349
|
+
function handleRequest(req, res) {
|
|
350
|
+
const url = req.url || "/";
|
|
351
|
+
if (url === "/__live-reload") {
|
|
352
|
+
res.writeHead(200, {
|
|
353
|
+
"Content-Type": "text/event-stream",
|
|
354
|
+
"Cache-Control": "no-cache",
|
|
355
|
+
Connection: "keep-alive",
|
|
356
|
+
"Access-Control-Allow-Origin": "*"
|
|
357
|
+
});
|
|
358
|
+
res.write("data: connected\n\n");
|
|
359
|
+
sseClients.push(res);
|
|
360
|
+
req.on("close", () => {
|
|
361
|
+
const index = sseClients.indexOf(res);
|
|
362
|
+
if (index !== -1) {
|
|
363
|
+
sseClients.splice(index, 1);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (url === "/" && req.method === "GET") {
|
|
369
|
+
try {
|
|
370
|
+
const html = fs3.readFileSync(absolutePath, "utf-8");
|
|
371
|
+
const injectedHtml = injectLiveReload(html);
|
|
372
|
+
res.writeHead(200, {
|
|
373
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
374
|
+
"Cache-Control": "no-cache"
|
|
375
|
+
});
|
|
376
|
+
res.end(injectedHtml);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
379
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
380
|
+
res.end(`Error reading file: ${errorMessage}`);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
385
|
+
res.end("Not Found");
|
|
386
|
+
}
|
|
387
|
+
const server = http.createServer(handleRequest);
|
|
388
|
+
server.on("error", (err) => {
|
|
389
|
+
reject(err);
|
|
390
|
+
});
|
|
391
|
+
let watcher = null;
|
|
392
|
+
try {
|
|
393
|
+
watcher = fs3.watch(absolutePath, (eventType) => {
|
|
394
|
+
if (eventType === "change") {
|
|
395
|
+
onFileChange();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
watcher.on("error", (err) => {
|
|
399
|
+
console.error("[Live Preview] File watcher error:", err.message);
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.warn("[Live Preview] Could not start file watcher:", err instanceof Error ? err.message : "Unknown error");
|
|
403
|
+
}
|
|
404
|
+
server.listen(0, "127.0.0.1", () => {
|
|
405
|
+
const address = server.address();
|
|
406
|
+
if (address && typeof address === "object") {
|
|
407
|
+
const port = address.port;
|
|
408
|
+
resolve3(port);
|
|
409
|
+
} else {
|
|
410
|
+
reject(new Error("Failed to get server address"));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async function openBrowser(url) {
|
|
416
|
+
const currentPlatform = platform();
|
|
417
|
+
let command;
|
|
418
|
+
switch (currentPlatform) {
|
|
419
|
+
case "darwin":
|
|
420
|
+
command = `open "${url}"`;
|
|
421
|
+
break;
|
|
422
|
+
case "linux":
|
|
423
|
+
command = `xdg-open "${url}"`;
|
|
424
|
+
break;
|
|
425
|
+
case "win32":
|
|
426
|
+
command = `start "" "${url}"`;
|
|
427
|
+
break;
|
|
428
|
+
default:
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
return new Promise((resolve3) => {
|
|
432
|
+
exec(command, (error2) => {
|
|
433
|
+
if (error2) {
|
|
434
|
+
resolve3(false);
|
|
435
|
+
} else {
|
|
436
|
+
resolve3(true);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/commands/forms.ts
|
|
443
|
+
function createClient() {
|
|
444
|
+
const config = loadConfig();
|
|
445
|
+
if (!config.apiKey) {
|
|
446
|
+
error("API key not configured. Run `spike config set api-key <your-key>` to set it.");
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
return new SpikeClient({
|
|
450
|
+
apiKey: config.apiKey,
|
|
451
|
+
baseUrl: config.baseUrl
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function handleError(err) {
|
|
455
|
+
if (err instanceof SpikeError) {
|
|
456
|
+
error(err.message);
|
|
457
|
+
if (err.code) {
|
|
458
|
+
info(`Error code: ${err.code}`);
|
|
459
|
+
}
|
|
460
|
+
} else if (err instanceof Error) {
|
|
461
|
+
error(err.message);
|
|
462
|
+
} else {
|
|
463
|
+
error("An unexpected error occurred");
|
|
464
|
+
}
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
var PREVIEW_PID_FILE = path3.join(os2.tmpdir(), "spike-preview.pid");
|
|
468
|
+
var PREVIEW_PORT_FILE = path3.join(os2.tmpdir(), "spike-preview.port");
|
|
469
|
+
var PREVIEW_FORM_ID_FILE = path3.join(os2.tmpdir(), "spike-preview.formid");
|
|
470
|
+
function generateFormHtml(form) {
|
|
471
|
+
const config = loadConfig();
|
|
472
|
+
const baseUrl = config.baseUrl || "https://api.spike.ac";
|
|
473
|
+
const actionUrl = `${baseUrl}/f/${form.slug}`;
|
|
474
|
+
return `<!DOCTYPE html>
|
|
475
|
+
<html lang="en">
|
|
476
|
+
<head>
|
|
477
|
+
<meta charset="UTF-8">
|
|
478
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
479
|
+
<title>${form.name}</title>
|
|
480
|
+
<style>
|
|
481
|
+
body {
|
|
482
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
483
|
+
max-width: 600px;
|
|
484
|
+
margin: 40px auto;
|
|
485
|
+
padding: 20px;
|
|
486
|
+
background: #f5f5f5;
|
|
487
|
+
}
|
|
488
|
+
form {
|
|
489
|
+
background: white;
|
|
490
|
+
padding: 30px;
|
|
491
|
+
border-radius: 8px;
|
|
492
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
493
|
+
}
|
|
494
|
+
h1 {
|
|
495
|
+
margin-top: 0;
|
|
496
|
+
color: #333;
|
|
497
|
+
}
|
|
498
|
+
label {
|
|
499
|
+
display: block;
|
|
500
|
+
margin-bottom: 5px;
|
|
501
|
+
font-weight: 500;
|
|
502
|
+
color: #555;
|
|
503
|
+
}
|
|
504
|
+
input, textarea {
|
|
505
|
+
width: 100%;
|
|
506
|
+
padding: 10px;
|
|
507
|
+
margin-bottom: 20px;
|
|
508
|
+
border: 1px solid #ddd;
|
|
509
|
+
border-radius: 4px;
|
|
510
|
+
box-sizing: border-box;
|
|
511
|
+
font-size: 16px;
|
|
512
|
+
}
|
|
513
|
+
textarea {
|
|
514
|
+
min-height: 100px;
|
|
515
|
+
resize: vertical;
|
|
516
|
+
}
|
|
517
|
+
button {
|
|
518
|
+
background: #0070f3;
|
|
519
|
+
color: white;
|
|
520
|
+
padding: 12px 24px;
|
|
521
|
+
border: none;
|
|
522
|
+
border-radius: 4px;
|
|
523
|
+
font-size: 16px;
|
|
524
|
+
cursor: pointer;
|
|
525
|
+
transition: background 0.2s;
|
|
526
|
+
}
|
|
527
|
+
button:hover {
|
|
528
|
+
background: #0051a8;
|
|
529
|
+
}
|
|
530
|
+
</style>
|
|
531
|
+
</head>
|
|
532
|
+
<body>
|
|
533
|
+
<form action="${actionUrl}" method="POST">
|
|
534
|
+
<h1>${form.name}</h1>
|
|
535
|
+
|
|
536
|
+
<label for="name">Name</label>
|
|
537
|
+
<input type="text" id="name" name="name" required>
|
|
538
|
+
|
|
539
|
+
<label for="email">Email</label>
|
|
540
|
+
<input type="email" id="email" name="email" required>
|
|
541
|
+
|
|
542
|
+
<label for="message">Message</label>
|
|
543
|
+
<textarea id="message" name="message" required></textarea>
|
|
544
|
+
|
|
545
|
+
<button type="submit">Submit</button>
|
|
546
|
+
</form>
|
|
547
|
+
</body>
|
|
548
|
+
</html>`;
|
|
549
|
+
}
|
|
550
|
+
function killExistingPreviewServer() {
|
|
551
|
+
try {
|
|
552
|
+
if (!fs3.existsSync(PREVIEW_PID_FILE)) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const pidStr = fs3.readFileSync(PREVIEW_PID_FILE, "utf-8").trim();
|
|
556
|
+
const pid = parseInt(pidStr, 10);
|
|
557
|
+
if (isNaN(pid)) {
|
|
558
|
+
cleanupPreviewTempFiles();
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
process.kill(pid, "SIGTERM");
|
|
563
|
+
info(`Stopped existing preview server (PID: ${pid})`);
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
cleanupPreviewTempFiles();
|
|
567
|
+
return true;
|
|
568
|
+
} catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function cleanupPreviewTempFiles() {
|
|
573
|
+
try {
|
|
574
|
+
if (fs3.existsSync(PREVIEW_PID_FILE)) {
|
|
575
|
+
fs3.unlinkSync(PREVIEW_PID_FILE);
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
if (fs3.existsSync(PREVIEW_PORT_FILE)) {
|
|
581
|
+
fs3.unlinkSync(PREVIEW_PORT_FILE);
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
if (fs3.existsSync(PREVIEW_FORM_ID_FILE)) {
|
|
587
|
+
fs3.unlinkSync(PREVIEW_FORM_ID_FILE);
|
|
588
|
+
}
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function createFormsCommand() {
|
|
593
|
+
const formsCommand = new Command("forms").description("Manage forms");
|
|
594
|
+
formsCommand.command("list").description("List all forms").option("-l, --limit <number>", "Maximum number of forms to return", parseInt).option("-p, --project-id <id>", "Filter forms by project ID").option("-i, --include-inactive", "Include inactive forms in the results").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
595
|
+
await handleList(options);
|
|
596
|
+
});
|
|
597
|
+
formsCommand.command("get").description("Get a specific form by ID").argument("<id>", "Form ID").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
598
|
+
await handleGet2(id, options.format);
|
|
599
|
+
});
|
|
600
|
+
formsCommand.command("create").description("Create a new form").requiredOption("-n, --name <name>", "Name for the form").option("-p, --project-id <id>", "Project ID to add the form to").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
601
|
+
await handleCreate(options);
|
|
602
|
+
});
|
|
603
|
+
formsCommand.command("update").description("Update a form").argument("<id>", "Form ID").option("-n, --name <name>", "New name for the form").option("-p, --project-id <id>", "Project ID to move the form to").option("--is-active <boolean>", "Whether the form should be active", (value) => {
|
|
604
|
+
if (value === "true") return true;
|
|
605
|
+
if (value === "false") return false;
|
|
606
|
+
throw new Error('--is-active must be "true" or "false"');
|
|
607
|
+
}).option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
608
|
+
await handleUpdate(id, options);
|
|
609
|
+
});
|
|
610
|
+
formsCommand.command("delete").description("Delete a form").argument("<id>", "Form ID").action(async (id) => {
|
|
611
|
+
await handleDelete(id);
|
|
612
|
+
});
|
|
613
|
+
formsCommand.command("edit").description("Edit a form with live preview").argument("<id>", "Form ID").option("--file <path>", "Output file path for the HTML", "./form.html").option("--background", "Run preview server as a detached background process").action(async (id, options) => {
|
|
614
|
+
await handleEdit(id, options);
|
|
615
|
+
});
|
|
616
|
+
formsCommand.command("stop-preview").description("Stop the background preview server").action(async () => {
|
|
617
|
+
await handleStopPreview();
|
|
618
|
+
});
|
|
619
|
+
formsCommand.command("save").description("Save local HTML changes to server").argument("[id]", "Form ID (optional if preview server is running)").option("--file <path>", "Path to the HTML file", "./form.html").action(async (id, options) => {
|
|
620
|
+
await handleSave(id, options);
|
|
621
|
+
});
|
|
622
|
+
formsCommand.command("create-page").description("Create a new form and generate an HTML form page (beta)").requiredOption("-n, --name <name>", "Name for the form").option("-p, --project-id <id>", "Project ID to add the form to").option("--file <path>", "Output file path for the HTML", "./form.html").option("--preview", "Start preview server after creation").action(async (options) => {
|
|
623
|
+
await handleCreatePage(options);
|
|
624
|
+
});
|
|
625
|
+
return formsCommand;
|
|
626
|
+
}
|
|
627
|
+
async function handleList(options) {
|
|
628
|
+
try {
|
|
629
|
+
const client = createClient();
|
|
630
|
+
const forms = await client.forms.list({
|
|
631
|
+
limit: options.limit,
|
|
632
|
+
project_id: options.projectId,
|
|
633
|
+
include_inactive: options.includeInactive
|
|
634
|
+
});
|
|
635
|
+
if (forms.length === 0) {
|
|
636
|
+
info("No forms found");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
output(forms, options.format);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
handleError(err);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function handleGet2(id, format) {
|
|
645
|
+
try {
|
|
646
|
+
const client = createClient();
|
|
647
|
+
const form = await client.forms.get(id);
|
|
648
|
+
output(form, format);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
handleError(err);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function handleCreate(options) {
|
|
654
|
+
try {
|
|
655
|
+
const client = createClient();
|
|
656
|
+
const form = await client.forms.create({
|
|
657
|
+
name: options.name,
|
|
658
|
+
project_id: options.projectId
|
|
659
|
+
});
|
|
660
|
+
success(`Form "${form.name}" created successfully`);
|
|
661
|
+
console.log("");
|
|
662
|
+
output(form, options.format);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
handleError(err);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function handleUpdate(id, options) {
|
|
668
|
+
try {
|
|
669
|
+
if (options.name === void 0 && options.projectId === void 0 && options.isActive === void 0) {
|
|
670
|
+
error("No update options provided. Use --name, --project-id, or --is-active to update the form.");
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
const client = createClient();
|
|
674
|
+
const updateData = {};
|
|
675
|
+
if (options.name !== void 0) {
|
|
676
|
+
updateData.name = options.name;
|
|
677
|
+
}
|
|
678
|
+
if (options.projectId !== void 0) {
|
|
679
|
+
updateData.project_id = options.projectId;
|
|
680
|
+
}
|
|
681
|
+
if (options.isActive !== void 0) {
|
|
682
|
+
updateData.is_active = options.isActive;
|
|
683
|
+
}
|
|
684
|
+
const form = await client.forms.update(id, updateData);
|
|
685
|
+
success(`Form "${form.name}" updated successfully`);
|
|
686
|
+
console.log("");
|
|
687
|
+
output(form, options.format);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
handleError(err);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function handleDelete(id) {
|
|
693
|
+
try {
|
|
694
|
+
const client = createClient();
|
|
695
|
+
await client.forms.delete(id);
|
|
696
|
+
success(`Form "${id}" deleted successfully`);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
handleError(err);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async function handleStopPreview() {
|
|
702
|
+
try {
|
|
703
|
+
if (!fs3.existsSync(PREVIEW_PID_FILE)) {
|
|
704
|
+
info("No preview server is currently running");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const pidStr = fs3.readFileSync(PREVIEW_PID_FILE, "utf-8").trim();
|
|
708
|
+
const pid = parseInt(pidStr, 10);
|
|
709
|
+
if (isNaN(pid)) {
|
|
710
|
+
cleanupPreviewTempFiles();
|
|
711
|
+
info("No preview server is currently running");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
process.kill(pid, "SIGTERM");
|
|
716
|
+
success(`Preview server stopped (PID: ${pid})`);
|
|
717
|
+
} catch (killError) {
|
|
718
|
+
if (killError instanceof Error && "code" in killError && killError.code === "ESRCH") {
|
|
719
|
+
info("Preview server was not running");
|
|
720
|
+
} else {
|
|
721
|
+
throw killError;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
cleanupPreviewTempFiles();
|
|
725
|
+
} catch (err) {
|
|
726
|
+
handleError(err);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function handleCreatePage(options) {
|
|
730
|
+
try {
|
|
731
|
+
console.log("\u26A0\uFE0F Note: The create-page command is currently in beta.\n");
|
|
732
|
+
const client = createClient();
|
|
733
|
+
info(`Creating form "${options.name}"...`);
|
|
734
|
+
const form = await client.forms.create({
|
|
735
|
+
name: options.name,
|
|
736
|
+
project_id: options.projectId
|
|
737
|
+
});
|
|
738
|
+
success(`Form "${form.name}" created successfully`);
|
|
739
|
+
const html = generateFormHtml(form);
|
|
740
|
+
info("Saving form HTML to server...");
|
|
741
|
+
const saveResult = await client.forms.saveHtml(form.id, html);
|
|
742
|
+
if (saveResult.success) {
|
|
743
|
+
success("Form HTML saved to server");
|
|
744
|
+
}
|
|
745
|
+
const filePath = path3.resolve(options.file);
|
|
746
|
+
fs3.writeFileSync(filePath, html, "utf-8");
|
|
747
|
+
success(`Form HTML saved locally to ${filePath}`);
|
|
748
|
+
fs3.writeFileSync(PREVIEW_FORM_ID_FILE, form.id, "utf-8");
|
|
749
|
+
if (options.preview) {
|
|
750
|
+
killExistingPreviewServer();
|
|
751
|
+
info("\nMake changes to the HTML file and save to see them in the browser.");
|
|
752
|
+
info("When done, run `spike forms save` to upload changes to the server.\n");
|
|
753
|
+
await startForegroundPreviewServer(filePath);
|
|
754
|
+
}
|
|
755
|
+
} catch (err) {
|
|
756
|
+
handleError(err);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async function handleEdit(id, options) {
|
|
760
|
+
try {
|
|
761
|
+
const client = createClient();
|
|
762
|
+
info(`Fetching form ${id}...`);
|
|
763
|
+
const form = await client.forms.get(id);
|
|
764
|
+
info("Fetching form HTML from server...");
|
|
765
|
+
const { html } = await client.forms.getHtml(id);
|
|
766
|
+
const filePath = path3.resolve(options.file);
|
|
767
|
+
fs3.writeFileSync(filePath, html, "utf-8");
|
|
768
|
+
success(`Form HTML saved to ${filePath}`);
|
|
769
|
+
fs3.writeFileSync(PREVIEW_FORM_ID_FILE, id, "utf-8");
|
|
770
|
+
killExistingPreviewServer();
|
|
771
|
+
info(`
|
|
772
|
+
Editing form: ${form.name}`);
|
|
773
|
+
info("Make changes to the HTML file and save to see them in the browser.");
|
|
774
|
+
info("When done, run `spike forms save` to upload changes to the server.\n");
|
|
775
|
+
if (options.background) {
|
|
776
|
+
await startBackgroundPreviewServer(filePath);
|
|
777
|
+
} else {
|
|
778
|
+
await startForegroundPreviewServer(filePath);
|
|
779
|
+
}
|
|
780
|
+
} catch (err) {
|
|
781
|
+
handleError(err);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function handleSave(id, options) {
|
|
785
|
+
try {
|
|
786
|
+
let formId = id;
|
|
787
|
+
if (!formId) {
|
|
788
|
+
if (fs3.existsSync(PREVIEW_FORM_ID_FILE)) {
|
|
789
|
+
formId = fs3.readFileSync(PREVIEW_FORM_ID_FILE, "utf-8").trim();
|
|
790
|
+
}
|
|
791
|
+
if (!formId) {
|
|
792
|
+
error("No form ID provided and no active preview session found.");
|
|
793
|
+
info("Usage: spike forms save <form-id> --file ./form.html");
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const filePath = path3.resolve(options.file);
|
|
798
|
+
if (!fs3.existsSync(filePath)) {
|
|
799
|
+
error(`File not found: ${filePath}`);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
const html = fs3.readFileSync(filePath, "utf-8");
|
|
803
|
+
if (!html.trim()) {
|
|
804
|
+
error("HTML file is empty");
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
const client = createClient();
|
|
808
|
+
info(`Saving form HTML to server...`);
|
|
809
|
+
const result = await client.forms.saveHtml(formId, html);
|
|
810
|
+
if (result.success) {
|
|
811
|
+
success("Form HTML saved successfully!");
|
|
812
|
+
if (result.html_url) {
|
|
813
|
+
info(`Stored at: ${result.html_url}`);
|
|
814
|
+
}
|
|
815
|
+
} else {
|
|
816
|
+
error("Failed to save form HTML");
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
} catch (err) {
|
|
820
|
+
handleError(err);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function startForegroundPreviewServer(filePath) {
|
|
824
|
+
const port = await startLivePreviewServer(filePath);
|
|
825
|
+
const previewUrl = `http://127.0.0.1:${port}`;
|
|
826
|
+
success(`Preview server running at ${previewUrl}`);
|
|
827
|
+
info("Press Ctrl+C to stop the server");
|
|
828
|
+
const browserOpened = await openBrowser(previewUrl);
|
|
829
|
+
if (!browserOpened) {
|
|
830
|
+
info(`Open ${previewUrl} in your browser to preview the form`);
|
|
831
|
+
}
|
|
832
|
+
await new Promise(() => {
|
|
833
|
+
process.on("SIGINT", () => {
|
|
834
|
+
info("\nStopping preview server...");
|
|
835
|
+
process.exit(0);
|
|
836
|
+
});
|
|
837
|
+
process.on("SIGTERM", () => {
|
|
838
|
+
info("\nStopping preview server...");
|
|
839
|
+
process.exit(0);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
async function startBackgroundPreviewServer(filePath) {
|
|
844
|
+
const serverCode = `
|
|
845
|
+
const http = require('node:http');
|
|
846
|
+
const fs = require('node:fs');
|
|
847
|
+
const path = require('node:path');
|
|
848
|
+
const os = require('node:os');
|
|
849
|
+
|
|
850
|
+
const filePath = ${JSON.stringify(filePath)};
|
|
851
|
+
const pidFile = path.join(os.tmpdir(), 'spike-preview.pid');
|
|
852
|
+
const portFile = path.join(os.tmpdir(), 'spike-preview.port');
|
|
853
|
+
|
|
854
|
+
const SSE_SCRIPT = \`<script>
|
|
855
|
+
(function() {
|
|
856
|
+
var es = new EventSource('/__live-reload');
|
|
857
|
+
es.onmessage = function(event) {
|
|
858
|
+
if (event.data === 'reload') {
|
|
859
|
+
location.reload();
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
es.onerror = function() {
|
|
863
|
+
console.log('[Live Preview] Connection lost, attempting to reconnect...');
|
|
864
|
+
};
|
|
865
|
+
})();
|
|
866
|
+
</script>\`;
|
|
867
|
+
|
|
868
|
+
function injectLiveReload(html) {
|
|
869
|
+
const bodyCloseRegex = /<\\/body>/i;
|
|
870
|
+
const match = html.match(bodyCloseRegex);
|
|
871
|
+
if (match && match.index !== undefined) {
|
|
872
|
+
return html.slice(0, match.index) + SSE_SCRIPT + html.slice(match.index);
|
|
873
|
+
}
|
|
874
|
+
return html + SSE_SCRIPT;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const sseClients = [];
|
|
878
|
+
let debounceTimer = null;
|
|
879
|
+
|
|
880
|
+
function broadcastToClients(message) {
|
|
881
|
+
const data = 'data: ' + message + '\\n\\n';
|
|
882
|
+
for (let i = sseClients.length - 1; i >= 0; i--) {
|
|
883
|
+
const client = sseClients[i];
|
|
884
|
+
if (!client) continue;
|
|
885
|
+
try {
|
|
886
|
+
if (!client.writableEnded) {
|
|
887
|
+
client.write(data);
|
|
888
|
+
} else {
|
|
889
|
+
sseClients.splice(i, 1);
|
|
890
|
+
}
|
|
891
|
+
} catch {
|
|
892
|
+
sseClients.splice(i, 1);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function onFileChange() {
|
|
898
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
899
|
+
debounceTimer = setTimeout(() => {
|
|
900
|
+
broadcastToClients('reload');
|
|
901
|
+
debounceTimer = null;
|
|
902
|
+
}, 100);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function cleanup() {
|
|
906
|
+
try { fs.unlinkSync(pidFile); } catch {}
|
|
907
|
+
try { fs.unlinkSync(portFile); } catch {}
|
|
908
|
+
process.exit(0);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
process.on('SIGTERM', cleanup);
|
|
912
|
+
process.on('SIGINT', cleanup);
|
|
913
|
+
|
|
914
|
+
const server = http.createServer((req, res) => {
|
|
915
|
+
const url = req.url || '/';
|
|
916
|
+
|
|
917
|
+
if (url === '/__live-reload') {
|
|
918
|
+
res.writeHead(200, {
|
|
919
|
+
'Content-Type': 'text/event-stream',
|
|
920
|
+
'Cache-Control': 'no-cache',
|
|
921
|
+
'Connection': 'keep-alive',
|
|
922
|
+
'Access-Control-Allow-Origin': '*'
|
|
923
|
+
});
|
|
924
|
+
res.write('data: connected\\n\\n');
|
|
925
|
+
sseClients.push(res);
|
|
926
|
+
req.on('close', () => {
|
|
927
|
+
const index = sseClients.indexOf(res);
|
|
928
|
+
if (index !== -1) sseClients.splice(index, 1);
|
|
929
|
+
});
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (url === '/' && req.method === 'GET') {
|
|
934
|
+
try {
|
|
935
|
+
const html = fs.readFileSync(filePath, 'utf-8');
|
|
936
|
+
const injectedHtml = injectLiveReload(html);
|
|
937
|
+
res.writeHead(200, {
|
|
938
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
939
|
+
'Cache-Control': 'no-cache'
|
|
940
|
+
});
|
|
941
|
+
res.end(injectedHtml);
|
|
942
|
+
} catch (err) {
|
|
943
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
944
|
+
res.end('Error reading file: ' + err.message);
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
950
|
+
res.end('Not Found');
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
fs.watch(filePath, (eventType) => {
|
|
955
|
+
if (eventType === 'change') onFileChange();
|
|
956
|
+
});
|
|
957
|
+
} catch {}
|
|
958
|
+
|
|
959
|
+
server.listen(0, '127.0.0.1', () => {
|
|
960
|
+
const address = server.address();
|
|
961
|
+
const port = address.port;
|
|
962
|
+
|
|
963
|
+
// Write PID and port to temp files
|
|
964
|
+
fs.writeFileSync(pidFile, process.pid.toString(), 'utf-8');
|
|
965
|
+
fs.writeFileSync(portFile, port.toString(), 'utf-8');
|
|
966
|
+
|
|
967
|
+
// Output port for parent process to read
|
|
968
|
+
console.log('PORT:' + port);
|
|
969
|
+
});
|
|
970
|
+
`;
|
|
971
|
+
return new Promise((resolve3, reject) => {
|
|
972
|
+
const child = spawn("node", ["-e", serverCode], {
|
|
973
|
+
detached: true,
|
|
974
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
975
|
+
});
|
|
976
|
+
let portReceived = false;
|
|
977
|
+
let outputBuffer = "";
|
|
978
|
+
child.stdout?.on("data", (data) => {
|
|
979
|
+
outputBuffer += data.toString();
|
|
980
|
+
const match = outputBuffer.match(/PORT:(\d+)/);
|
|
981
|
+
if (match && !portReceived) {
|
|
982
|
+
portReceived = true;
|
|
983
|
+
const port = parseInt(match[1], 10);
|
|
984
|
+
const previewUrl = `http://127.0.0.1:${port}`;
|
|
985
|
+
success(`Background preview server started at ${previewUrl}`);
|
|
986
|
+
info(`PID: ${child.pid}`);
|
|
987
|
+
info("Run `spike forms stop-preview` to stop the server");
|
|
988
|
+
openBrowser(previewUrl).then((browserOpened) => {
|
|
989
|
+
if (!browserOpened) {
|
|
990
|
+
info(`Open ${previewUrl} in your browser to preview the form`);
|
|
991
|
+
}
|
|
992
|
+
child.unref();
|
|
993
|
+
resolve3();
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
child.on("error", (err) => {
|
|
998
|
+
reject(new Error(`Failed to start background server: ${err.message}`));
|
|
999
|
+
});
|
|
1000
|
+
setTimeout(() => {
|
|
1001
|
+
if (!portReceived) {
|
|
1002
|
+
child.kill();
|
|
1003
|
+
reject(new Error("Timeout waiting for background server to start"));
|
|
1004
|
+
}
|
|
1005
|
+
}, 1e4);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
function createClient2() {
|
|
1009
|
+
const config = loadConfig();
|
|
1010
|
+
if (!config.apiKey) {
|
|
1011
|
+
error("API key not configured. Run `spike config set api-key <your-key>` to set it.");
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
return new SpikeClient({
|
|
1015
|
+
apiKey: config.apiKey,
|
|
1016
|
+
baseUrl: config.baseUrl
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
function handleError2(err) {
|
|
1020
|
+
if (err instanceof SpikeError) {
|
|
1021
|
+
error(err.message);
|
|
1022
|
+
if (err.code) {
|
|
1023
|
+
info(`Error code: ${err.code}`);
|
|
1024
|
+
}
|
|
1025
|
+
} else if (err instanceof Error) {
|
|
1026
|
+
error(err.message);
|
|
1027
|
+
} else {
|
|
1028
|
+
error("An unexpected error occurred");
|
|
1029
|
+
}
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
function createSubmissionsCommand() {
|
|
1033
|
+
const submissionsCommand = new Command("submissions").description("Manage form submissions");
|
|
1034
|
+
submissionsCommand.command("list").description("List submissions for a form").argument("<form-id>", "Form ID").option("-l, --limit <number>", "Maximum number of submissions to return", parseInt).option("-s, --status <status>", "Filter by status (read, unread, spam, starred)").option("--from <date>", "Filter submissions from this date (ISO 8601 format)").option("--to <date>", "Filter submissions to this date (ISO 8601 format)").option("-o, --order <order>", "Sort order (asc, desc)", "desc").option("-f, --format <format>", "Output format (json, table)", "table").action(async (formId, options) => {
|
|
1035
|
+
await handleList2(formId, options);
|
|
1036
|
+
});
|
|
1037
|
+
submissionsCommand.command("export").description("Export all submissions for a form").argument("<form-id>", "Form ID").option("-f, --format <format>", "Output format (json, csv)", "json").action(async (formId, options) => {
|
|
1038
|
+
await handleExport(formId, options.format);
|
|
1039
|
+
});
|
|
1040
|
+
submissionsCommand.command("stats").description("Display submission statistics for a form").argument("<form-id>", "Form ID").option("-f, --format <format>", "Output format (json, table)", "table").action(async (formId, options) => {
|
|
1041
|
+
await handleStats(formId, options.format);
|
|
1042
|
+
});
|
|
1043
|
+
return submissionsCommand;
|
|
1044
|
+
}
|
|
1045
|
+
async function handleList2(formId, options) {
|
|
1046
|
+
try {
|
|
1047
|
+
const client = createClient2();
|
|
1048
|
+
const params = {};
|
|
1049
|
+
if (options.limit !== void 0) {
|
|
1050
|
+
params.limit = options.limit;
|
|
1051
|
+
}
|
|
1052
|
+
if (options.from) {
|
|
1053
|
+
params.since = options.from;
|
|
1054
|
+
}
|
|
1055
|
+
if (options.order === "asc" || options.order === "desc") {
|
|
1056
|
+
params.order = options.order;
|
|
1057
|
+
}
|
|
1058
|
+
if (options.status) {
|
|
1059
|
+
switch (options.status.toLowerCase()) {
|
|
1060
|
+
case "read":
|
|
1061
|
+
params.is_read = true;
|
|
1062
|
+
break;
|
|
1063
|
+
case "unread":
|
|
1064
|
+
params.is_read = false;
|
|
1065
|
+
break;
|
|
1066
|
+
case "spam":
|
|
1067
|
+
params.is_spam = true;
|
|
1068
|
+
break;
|
|
1069
|
+
case "starred":
|
|
1070
|
+
break;
|
|
1071
|
+
default:
|
|
1072
|
+
error(`Invalid status: ${options.status}. Valid values are: read, unread, spam, starred`);
|
|
1073
|
+
process.exit(1);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const submissions = await client.submissions.list(formId, params);
|
|
1077
|
+
let filteredSubmissions = submissions;
|
|
1078
|
+
if (options.status?.toLowerCase() === "starred") {
|
|
1079
|
+
filteredSubmissions = submissions.filter((s) => s.is_starred);
|
|
1080
|
+
}
|
|
1081
|
+
if (options.to) {
|
|
1082
|
+
const toDate = new Date(options.to);
|
|
1083
|
+
filteredSubmissions = filteredSubmissions.filter((s) => new Date(s.created_at) <= toDate);
|
|
1084
|
+
}
|
|
1085
|
+
if (filteredSubmissions.length === 0) {
|
|
1086
|
+
info("No submissions found");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const displayData = filteredSubmissions.map((s) => ({
|
|
1090
|
+
id: s.id,
|
|
1091
|
+
created_at: s.created_at,
|
|
1092
|
+
is_read: s.is_read,
|
|
1093
|
+
is_spam: s.is_spam,
|
|
1094
|
+
is_starred: s.is_starred,
|
|
1095
|
+
data: JSON.stringify(s.data).slice(0, 50) + (JSON.stringify(s.data).length > 50 ? "..." : "")
|
|
1096
|
+
}));
|
|
1097
|
+
output(displayData, options.format);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
handleError2(err);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async function handleExport(formId, format) {
|
|
1103
|
+
try {
|
|
1104
|
+
const client = createClient2();
|
|
1105
|
+
const submissions = await client.submissions.export(formId);
|
|
1106
|
+
if (submissions.length === 0) {
|
|
1107
|
+
info("No submissions to export");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (format === "csv") {
|
|
1111
|
+
outputCsv(submissions);
|
|
1112
|
+
} else {
|
|
1113
|
+
console.log(JSON.stringify(submissions, null, 2));
|
|
1114
|
+
}
|
|
1115
|
+
success(`Exported ${submissions.length} submission${submissions.length === 1 ? "" : "s"}`);
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
handleError2(err);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function outputCsv(submissions) {
|
|
1121
|
+
if (submissions.length === 0) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const dataKeys = /* @__PURE__ */ new Set();
|
|
1125
|
+
for (const submission of submissions) {
|
|
1126
|
+
Object.keys(submission.data).forEach((key) => dataKeys.add(key));
|
|
1127
|
+
}
|
|
1128
|
+
const baseHeaders = ["id", "form_id", "is_spam", "is_read", "is_starred", "ip_address", "user_agent", "created_at"];
|
|
1129
|
+
const allHeaders = [...baseHeaders, ...Array.from(dataKeys)];
|
|
1130
|
+
console.log(allHeaders.map(escapeCsvValue).join(","));
|
|
1131
|
+
for (const submission of submissions) {
|
|
1132
|
+
const row = [
|
|
1133
|
+
submission.id,
|
|
1134
|
+
submission.form_id,
|
|
1135
|
+
String(submission.is_spam),
|
|
1136
|
+
String(submission.is_read),
|
|
1137
|
+
String(submission.is_starred),
|
|
1138
|
+
submission.ip_address || "",
|
|
1139
|
+
submission.user_agent || "",
|
|
1140
|
+
submission.created_at,
|
|
1141
|
+
...Array.from(dataKeys).map((key) => {
|
|
1142
|
+
const value = submission.data[key];
|
|
1143
|
+
if (value === void 0 || value === null) {
|
|
1144
|
+
return "";
|
|
1145
|
+
}
|
|
1146
|
+
if (typeof value === "object") {
|
|
1147
|
+
return JSON.stringify(value);
|
|
1148
|
+
}
|
|
1149
|
+
return String(value);
|
|
1150
|
+
})
|
|
1151
|
+
];
|
|
1152
|
+
console.log(row.map(escapeCsvValue).join(","));
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function escapeCsvValue(value) {
|
|
1156
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) {
|
|
1157
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
1158
|
+
}
|
|
1159
|
+
return value;
|
|
1160
|
+
}
|
|
1161
|
+
async function handleStats(formId, format) {
|
|
1162
|
+
try {
|
|
1163
|
+
const client = createClient2();
|
|
1164
|
+
const stats = await client.submissions.getStats(formId);
|
|
1165
|
+
output(stats, format);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
handleError2(err);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
function createClient3() {
|
|
1171
|
+
const config = loadConfig();
|
|
1172
|
+
if (!config.apiKey) {
|
|
1173
|
+
error("API key not configured. Run `spike config set api-key <your-key>` to set it.");
|
|
1174
|
+
process.exit(1);
|
|
1175
|
+
}
|
|
1176
|
+
return new SpikeClient({
|
|
1177
|
+
apiKey: config.apiKey,
|
|
1178
|
+
baseUrl: config.baseUrl
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
function handleError3(err) {
|
|
1182
|
+
if (err instanceof SpikeError) {
|
|
1183
|
+
error(err.message);
|
|
1184
|
+
if (err.code) {
|
|
1185
|
+
info(`Error code: ${err.code}`);
|
|
1186
|
+
}
|
|
1187
|
+
} else if (err instanceof Error) {
|
|
1188
|
+
error(err.message);
|
|
1189
|
+
} else {
|
|
1190
|
+
error("An unexpected error occurred");
|
|
1191
|
+
}
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
function createProjectsCommand() {
|
|
1195
|
+
const projectsCommand = new Command("projects").description("Manage projects");
|
|
1196
|
+
projectsCommand.command("list").description("List all projects").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1197
|
+
await handleList3(options.format);
|
|
1198
|
+
});
|
|
1199
|
+
projectsCommand.command("get").description("Get a specific project by ID").argument("<id>", "Project ID").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
1200
|
+
await handleGet3(id, options.format);
|
|
1201
|
+
});
|
|
1202
|
+
projectsCommand.command("create").description("Create a new project").requiredOption("-n, --name <name>", "Name for the project").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1203
|
+
await handleCreate2(options);
|
|
1204
|
+
});
|
|
1205
|
+
projectsCommand.command("update").description("Update a project").argument("<id>", "Project ID").option("-n, --name <name>", "New name for the project").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
1206
|
+
await handleUpdate2(id, options);
|
|
1207
|
+
});
|
|
1208
|
+
projectsCommand.command("delete").description("Delete a project").argument("<id>", "Project ID").action(async (id) => {
|
|
1209
|
+
await handleDelete2(id);
|
|
1210
|
+
});
|
|
1211
|
+
return projectsCommand;
|
|
1212
|
+
}
|
|
1213
|
+
async function handleList3(format) {
|
|
1214
|
+
try {
|
|
1215
|
+
const client = createClient3();
|
|
1216
|
+
const projects = await client.projects.list();
|
|
1217
|
+
if (projects.length === 0) {
|
|
1218
|
+
info("No projects found");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
output(projects, format);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
handleError3(err);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function handleGet3(id, format) {
|
|
1227
|
+
try {
|
|
1228
|
+
const client = createClient3();
|
|
1229
|
+
const project = await client.projects.get(id);
|
|
1230
|
+
output(project, format);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
handleError3(err);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async function handleCreate2(options) {
|
|
1236
|
+
try {
|
|
1237
|
+
const client = createClient3();
|
|
1238
|
+
const project = await client.projects.create({
|
|
1239
|
+
name: options.name
|
|
1240
|
+
});
|
|
1241
|
+
success(`Project "${project.name}" created successfully`);
|
|
1242
|
+
console.log("");
|
|
1243
|
+
output(project, options.format);
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
handleError3(err);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function handleUpdate2(id, options) {
|
|
1249
|
+
try {
|
|
1250
|
+
if (options.name === void 0) {
|
|
1251
|
+
error("No update options provided. Use --name to update the project.");
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
const client = createClient3();
|
|
1255
|
+
const updateData = {};
|
|
1256
|
+
if (options.name !== void 0) {
|
|
1257
|
+
updateData.name = options.name;
|
|
1258
|
+
}
|
|
1259
|
+
const project = await client.projects.update(id, updateData);
|
|
1260
|
+
success(`Project "${project.name}" updated successfully`);
|
|
1261
|
+
console.log("");
|
|
1262
|
+
output(project, options.format);
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
handleError3(err);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
async function handleDelete2(id) {
|
|
1268
|
+
try {
|
|
1269
|
+
const client = createClient3();
|
|
1270
|
+
await client.projects.delete(id);
|
|
1271
|
+
success(`Project "${id}" deleted successfully`);
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
handleError3(err);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
function createClient4() {
|
|
1277
|
+
const config = loadConfig();
|
|
1278
|
+
if (!config.apiKey) {
|
|
1279
|
+
error("API key not configured. Run `spike config set api-key <your-key>` to set it.");
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
return new SpikeClient({
|
|
1283
|
+
apiKey: config.apiKey,
|
|
1284
|
+
baseUrl: config.baseUrl
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
function handleError4(err) {
|
|
1288
|
+
if (err instanceof SpikeError) {
|
|
1289
|
+
error(err.message);
|
|
1290
|
+
if (err.code) {
|
|
1291
|
+
info(`Error code: ${err.code}`);
|
|
1292
|
+
}
|
|
1293
|
+
} else if (err instanceof Error) {
|
|
1294
|
+
error(err.message);
|
|
1295
|
+
} else {
|
|
1296
|
+
error("An unexpected error occurred");
|
|
1297
|
+
}
|
|
1298
|
+
process.exit(1);
|
|
1299
|
+
}
|
|
1300
|
+
function createTeamsCommand() {
|
|
1301
|
+
const teamsCommand = new Command("teams").description("Manage teams");
|
|
1302
|
+
teamsCommand.command("list").description("List all teams").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1303
|
+
await handleList4(options.format);
|
|
1304
|
+
});
|
|
1305
|
+
teamsCommand.command("get").description("Get a specific team by ID").argument("<id>", "Team ID").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
1306
|
+
await handleGet4(id, options.format);
|
|
1307
|
+
});
|
|
1308
|
+
teamsCommand.command("create").description("Create a new team").requiredOption("-n, --name <name>", "Name for the team").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1309
|
+
await handleCreate3(options);
|
|
1310
|
+
});
|
|
1311
|
+
teamsCommand.command("members").description("List members of a team").argument("<id>", "Team ID").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
1312
|
+
await handleMembers(id, options.format);
|
|
1313
|
+
});
|
|
1314
|
+
teamsCommand.command("invite").description("Invite a user to a team").argument("<id>", "Team ID").requiredOption("-e, --email <email>", "Email address of the user to invite").requiredOption("-r, --role <role>", "Role for the invited user (admin, member)").option("-f, --format <format>", "Output format (json, table)", "table").action(async (id, options) => {
|
|
1315
|
+
await handleInvite(id, options);
|
|
1316
|
+
});
|
|
1317
|
+
return teamsCommand;
|
|
1318
|
+
}
|
|
1319
|
+
async function handleList4(format) {
|
|
1320
|
+
try {
|
|
1321
|
+
const client = createClient4();
|
|
1322
|
+
const teams = await client.teams.list();
|
|
1323
|
+
if (teams.length === 0) {
|
|
1324
|
+
info("No teams found");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
output(teams, format);
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
handleError4(err);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async function handleGet4(id, format) {
|
|
1333
|
+
try {
|
|
1334
|
+
const client = createClient4();
|
|
1335
|
+
const team = await client.teams.get(id);
|
|
1336
|
+
output(team, format);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
handleError4(err);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function handleCreate3(options) {
|
|
1342
|
+
try {
|
|
1343
|
+
const client = createClient4();
|
|
1344
|
+
const team = await client.teams.create({
|
|
1345
|
+
name: options.name
|
|
1346
|
+
});
|
|
1347
|
+
success(`Team "${team.name}" created successfully`);
|
|
1348
|
+
console.log("");
|
|
1349
|
+
output(team, options.format);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
handleError4(err);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
async function handleMembers(id, format) {
|
|
1355
|
+
try {
|
|
1356
|
+
const client = createClient4();
|
|
1357
|
+
const members = await client.teams.listMembers(id);
|
|
1358
|
+
if (members.length === 0) {
|
|
1359
|
+
info("No members found");
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const displayData = members.map((member) => ({
|
|
1363
|
+
id: member.id,
|
|
1364
|
+
user_id: member.user_id,
|
|
1365
|
+
name: member.user.name,
|
|
1366
|
+
email: member.user.email,
|
|
1367
|
+
role: member.role,
|
|
1368
|
+
created_at: member.created_at
|
|
1369
|
+
}));
|
|
1370
|
+
output(displayData, format);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
handleError4(err);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
async function handleInvite(id, options) {
|
|
1376
|
+
try {
|
|
1377
|
+
const role = options.role.toLowerCase();
|
|
1378
|
+
if (role !== "admin" && role !== "member") {
|
|
1379
|
+
error("Invalid role. Valid values are: admin, member");
|
|
1380
|
+
process.exit(1);
|
|
1381
|
+
}
|
|
1382
|
+
const client = createClient4();
|
|
1383
|
+
const invitation = await client.teams.invite(id, {
|
|
1384
|
+
email: options.email,
|
|
1385
|
+
role
|
|
1386
|
+
});
|
|
1387
|
+
success(`Invitation sent to "${options.email}" as ${role}`);
|
|
1388
|
+
console.log("");
|
|
1389
|
+
output(invitation, options.format);
|
|
1390
|
+
} catch (err) {
|
|
1391
|
+
handleError4(err);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function createClient5() {
|
|
1395
|
+
const config = loadConfig();
|
|
1396
|
+
if (!config.apiKey) {
|
|
1397
|
+
error("API key not configured. Run `spike config set api-key <your-key>` to set it.");
|
|
1398
|
+
process.exit(1);
|
|
1399
|
+
}
|
|
1400
|
+
return new SpikeClient({
|
|
1401
|
+
apiKey: config.apiKey,
|
|
1402
|
+
baseUrl: config.baseUrl
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
function handleError5(err) {
|
|
1406
|
+
if (err instanceof SpikeError) {
|
|
1407
|
+
error(err.message);
|
|
1408
|
+
if (err.code) {
|
|
1409
|
+
info(`Error code: ${err.code}`);
|
|
1410
|
+
}
|
|
1411
|
+
} else if (err instanceof Error) {
|
|
1412
|
+
error(err.message);
|
|
1413
|
+
} else {
|
|
1414
|
+
error("An unexpected error occurred");
|
|
1415
|
+
}
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
}
|
|
1418
|
+
function createUserCommand() {
|
|
1419
|
+
const userCommand = new Command("user").description("Manage user profile and API keys");
|
|
1420
|
+
userCommand.option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1421
|
+
await handleGetProfile(options.format);
|
|
1422
|
+
});
|
|
1423
|
+
userCommand.command("update").description("Update user profile").option("-n, --name <name>", "New name for the user").option("-e, --email <email>", "New email address for the user").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1424
|
+
await handleUpdateProfile(options);
|
|
1425
|
+
});
|
|
1426
|
+
const apiKeysCommand = new Command("api-keys").description("Manage user API keys");
|
|
1427
|
+
apiKeysCommand.option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1428
|
+
await handleListApiKeys(options.format);
|
|
1429
|
+
});
|
|
1430
|
+
apiKeysCommand.command("create").description("Create a new API key").requiredOption("-n, --name <name>", "Name for the API key").option("-f, --format <format>", "Output format (json, table)", "table").action(async (options) => {
|
|
1431
|
+
await handleCreateApiKey(options);
|
|
1432
|
+
});
|
|
1433
|
+
apiKeysCommand.command("delete").description("Delete an API key").argument("<id>", "API key ID").action(async (id) => {
|
|
1434
|
+
await handleDeleteApiKey(id);
|
|
1435
|
+
});
|
|
1436
|
+
userCommand.addCommand(apiKeysCommand);
|
|
1437
|
+
return userCommand;
|
|
1438
|
+
}
|
|
1439
|
+
async function handleGetProfile(format) {
|
|
1440
|
+
try {
|
|
1441
|
+
const client = createClient5();
|
|
1442
|
+
const user = await client.user.get();
|
|
1443
|
+
output(user, format);
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
handleError5(err);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
async function handleUpdateProfile(options) {
|
|
1449
|
+
try {
|
|
1450
|
+
if (options.name === void 0 && options.email === void 0) {
|
|
1451
|
+
error("No update options provided. Use --name or --email to update the profile.");
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
}
|
|
1454
|
+
const client = createClient5();
|
|
1455
|
+
const updateData = {};
|
|
1456
|
+
if (options.name !== void 0) {
|
|
1457
|
+
updateData.name = options.name;
|
|
1458
|
+
}
|
|
1459
|
+
if (options.email !== void 0) {
|
|
1460
|
+
updateData.email = options.email;
|
|
1461
|
+
}
|
|
1462
|
+
const user = await client.user.update(updateData);
|
|
1463
|
+
success("Profile updated successfully");
|
|
1464
|
+
console.log("");
|
|
1465
|
+
output(user, options.format);
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
handleError5(err);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
async function handleListApiKeys(format) {
|
|
1471
|
+
try {
|
|
1472
|
+
const client = createClient5();
|
|
1473
|
+
const apiKeys = await client.user.listApiKeys();
|
|
1474
|
+
if (apiKeys.length === 0) {
|
|
1475
|
+
info("No API keys found");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const displayData = apiKeys.map((key) => ({
|
|
1479
|
+
id: key.id,
|
|
1480
|
+
name: key.name,
|
|
1481
|
+
key: key.key ? maskApiKey(key.key) : "-",
|
|
1482
|
+
last_used_at: key.last_used_at || "Never",
|
|
1483
|
+
created_at: key.created_at
|
|
1484
|
+
}));
|
|
1485
|
+
output(displayData, format);
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
handleError5(err);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
async function handleCreateApiKey(options) {
|
|
1491
|
+
try {
|
|
1492
|
+
const client = createClient5();
|
|
1493
|
+
const apiKey = await client.user.createApiKey({
|
|
1494
|
+
name: options.name
|
|
1495
|
+
});
|
|
1496
|
+
success(`API key "${apiKey.name}" created successfully`);
|
|
1497
|
+
console.log("");
|
|
1498
|
+
if (options.format === "json") {
|
|
1499
|
+
output(apiKey, "json");
|
|
1500
|
+
} else {
|
|
1501
|
+
console.log("Your new API key (save this - it will not be shown again):");
|
|
1502
|
+
console.log("");
|
|
1503
|
+
console.log(` ${apiKey.key}`);
|
|
1504
|
+
console.log("");
|
|
1505
|
+
output({
|
|
1506
|
+
id: apiKey.id,
|
|
1507
|
+
name: apiKey.name,
|
|
1508
|
+
created_at: apiKey.created_at
|
|
1509
|
+
}, "table");
|
|
1510
|
+
}
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
handleError5(err);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
async function handleDeleteApiKey(id) {
|
|
1516
|
+
try {
|
|
1517
|
+
const client = createClient5();
|
|
1518
|
+
await client.user.deleteApiKey(id);
|
|
1519
|
+
success(`API key "${id}" deleted successfully`);
|
|
1520
|
+
} catch (err) {
|
|
1521
|
+
handleError5(err);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
function generateState() {
|
|
1525
|
+
return crypto.randomBytes(32).toString("hex");
|
|
1526
|
+
}
|
|
1527
|
+
function verifyState(received, expected) {
|
|
1528
|
+
return received === expected;
|
|
1529
|
+
}
|
|
1530
|
+
function parseCallbackParams(url) {
|
|
1531
|
+
try {
|
|
1532
|
+
let searchParams;
|
|
1533
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1534
|
+
const parsed = new URL(url);
|
|
1535
|
+
searchParams = parsed.searchParams;
|
|
1536
|
+
} else {
|
|
1537
|
+
const queryIndex = url.indexOf("?");
|
|
1538
|
+
if (queryIndex === -1) {
|
|
1539
|
+
return {};
|
|
1540
|
+
}
|
|
1541
|
+
searchParams = new URLSearchParams(url.slice(queryIndex + 1));
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
key: searchParams.get("key") ?? void 0,
|
|
1545
|
+
state: searchParams.get("state") ?? void 0,
|
|
1546
|
+
error: searchParams.get("error") ?? void 0,
|
|
1547
|
+
message: searchParams.get("message") ?? void 0
|
|
1548
|
+
};
|
|
1549
|
+
} catch {
|
|
1550
|
+
return {};
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
var SUCCESS_HTML = `<!DOCTYPE html>
|
|
1554
|
+
<html lang="en">
|
|
1555
|
+
<head>
|
|
1556
|
+
<meta charset="UTF-8">
|
|
1557
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1558
|
+
<title>Login Successful - Spike CLI</title>
|
|
1559
|
+
<style>
|
|
1560
|
+
body {
|
|
1561
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
1562
|
+
display: flex;
|
|
1563
|
+
justify-content: center;
|
|
1564
|
+
align-items: center;
|
|
1565
|
+
min-height: 100vh;
|
|
1566
|
+
margin: 0;
|
|
1567
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
1568
|
+
color: white;
|
|
1569
|
+
}
|
|
1570
|
+
.container {
|
|
1571
|
+
text-align: center;
|
|
1572
|
+
padding: 2rem;
|
|
1573
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1574
|
+
border-radius: 16px;
|
|
1575
|
+
backdrop-filter: blur(10px);
|
|
1576
|
+
max-width: 400px;
|
|
1577
|
+
}
|
|
1578
|
+
.icon {
|
|
1579
|
+
font-size: 4rem;
|
|
1580
|
+
margin-bottom: 1rem;
|
|
1581
|
+
}
|
|
1582
|
+
h1 {
|
|
1583
|
+
margin: 0 0 0.5rem 0;
|
|
1584
|
+
font-size: 1.5rem;
|
|
1585
|
+
}
|
|
1586
|
+
p {
|
|
1587
|
+
margin: 0;
|
|
1588
|
+
opacity: 0.9;
|
|
1589
|
+
}
|
|
1590
|
+
</style>
|
|
1591
|
+
</head>
|
|
1592
|
+
<body>
|
|
1593
|
+
<div class="container">
|
|
1594
|
+
<div class="icon">\u2713</div>
|
|
1595
|
+
<h1>Login Successful!</h1>
|
|
1596
|
+
<p>You can close this window and return to the terminal.</p>
|
|
1597
|
+
</div>
|
|
1598
|
+
</body>
|
|
1599
|
+
</html>`;
|
|
1600
|
+
function getErrorHtml(errorMessage) {
|
|
1601
|
+
const escapedMessage = errorMessage.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1602
|
+
return `<!DOCTYPE html>
|
|
1603
|
+
<html lang="en">
|
|
1604
|
+
<head>
|
|
1605
|
+
<meta charset="UTF-8">
|
|
1606
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1607
|
+
<title>Login Failed - Spike CLI</title>
|
|
1608
|
+
<style>
|
|
1609
|
+
body {
|
|
1610
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
1611
|
+
display: flex;
|
|
1612
|
+
justify-content: center;
|
|
1613
|
+
align-items: center;
|
|
1614
|
+
min-height: 100vh;
|
|
1615
|
+
margin: 0;
|
|
1616
|
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
1617
|
+
color: white;
|
|
1618
|
+
}
|
|
1619
|
+
.container {
|
|
1620
|
+
text-align: center;
|
|
1621
|
+
padding: 2rem;
|
|
1622
|
+
background: rgba(255, 255, 255, 0.1);
|
|
1623
|
+
border-radius: 16px;
|
|
1624
|
+
backdrop-filter: blur(10px);
|
|
1625
|
+
max-width: 400px;
|
|
1626
|
+
}
|
|
1627
|
+
.icon {
|
|
1628
|
+
font-size: 4rem;
|
|
1629
|
+
margin-bottom: 1rem;
|
|
1630
|
+
}
|
|
1631
|
+
h1 {
|
|
1632
|
+
margin: 0 0 0.5rem 0;
|
|
1633
|
+
font-size: 1.5rem;
|
|
1634
|
+
}
|
|
1635
|
+
p {
|
|
1636
|
+
margin: 0;
|
|
1637
|
+
opacity: 0.9;
|
|
1638
|
+
}
|
|
1639
|
+
.error-message {
|
|
1640
|
+
margin-top: 1rem;
|
|
1641
|
+
padding: 1rem;
|
|
1642
|
+
background: rgba(0, 0, 0, 0.2);
|
|
1643
|
+
border-radius: 8px;
|
|
1644
|
+
font-family: monospace;
|
|
1645
|
+
font-size: 0.9rem;
|
|
1646
|
+
}
|
|
1647
|
+
</style>
|
|
1648
|
+
</head>
|
|
1649
|
+
<body>
|
|
1650
|
+
<div class="container">
|
|
1651
|
+
<div class="icon">\u2717</div>
|
|
1652
|
+
<h1>Login Failed</h1>
|
|
1653
|
+
<p>Something went wrong during authentication.</p>
|
|
1654
|
+
<div class="error-message">${escapedMessage}</div>
|
|
1655
|
+
</div>
|
|
1656
|
+
</body>
|
|
1657
|
+
</html>`;
|
|
1658
|
+
}
|
|
1659
|
+
function createCallbackServer() {
|
|
1660
|
+
let server = null;
|
|
1661
|
+
let port = 0;
|
|
1662
|
+
let expectedState = null;
|
|
1663
|
+
let callbackResolve = null;
|
|
1664
|
+
let timeoutId = null;
|
|
1665
|
+
let callbackReceived = false;
|
|
1666
|
+
function handleRequest(req, res) {
|
|
1667
|
+
if (req.method !== "GET" || !req.url?.startsWith("/callback")) {
|
|
1668
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1669
|
+
res.end("Not Found");
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const params = parseCallbackParams(req.url);
|
|
1673
|
+
if (params.error) {
|
|
1674
|
+
const errorMessage = params.message || params.error;
|
|
1675
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1676
|
+
res.end(getErrorHtml(errorMessage));
|
|
1677
|
+
if (callbackResolve && !callbackReceived) {
|
|
1678
|
+
callbackReceived = true;
|
|
1679
|
+
callbackResolve({
|
|
1680
|
+
success: false,
|
|
1681
|
+
error: errorMessage
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (!params.state || !expectedState || !verifyState(params.state, expectedState)) {
|
|
1687
|
+
const errorMessage = "State verification failed. This may be a security issue.";
|
|
1688
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1689
|
+
res.end(getErrorHtml(errorMessage));
|
|
1690
|
+
if (callbackResolve && !callbackReceived) {
|
|
1691
|
+
callbackReceived = true;
|
|
1692
|
+
callbackResolve({
|
|
1693
|
+
success: false,
|
|
1694
|
+
error: errorMessage
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (!params.key) {
|
|
1700
|
+
const errorMessage = "No API key received in callback.";
|
|
1701
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1702
|
+
res.end(getErrorHtml(errorMessage));
|
|
1703
|
+
if (callbackResolve && !callbackReceived) {
|
|
1704
|
+
callbackReceived = true;
|
|
1705
|
+
callbackResolve({
|
|
1706
|
+
success: false,
|
|
1707
|
+
error: errorMessage
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1713
|
+
res.end(SUCCESS_HTML);
|
|
1714
|
+
if (callbackResolve && !callbackReceived) {
|
|
1715
|
+
callbackReceived = true;
|
|
1716
|
+
callbackResolve({
|
|
1717
|
+
success: true,
|
|
1718
|
+
apiKey: params.key
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
async start() {
|
|
1724
|
+
return new Promise((resolve3, reject) => {
|
|
1725
|
+
server = http.createServer(handleRequest);
|
|
1726
|
+
server.on("error", (err) => {
|
|
1727
|
+
reject(err);
|
|
1728
|
+
});
|
|
1729
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1730
|
+
const address = server.address();
|
|
1731
|
+
if (address && typeof address === "object") {
|
|
1732
|
+
port = address.port;
|
|
1733
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1734
|
+
resolve3({ port, url });
|
|
1735
|
+
} else {
|
|
1736
|
+
reject(new Error("Failed to get server address"));
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
});
|
|
1740
|
+
},
|
|
1741
|
+
async waitForCallback(state, timeoutMs) {
|
|
1742
|
+
expectedState = state;
|
|
1743
|
+
callbackReceived = false;
|
|
1744
|
+
return new Promise((resolve3) => {
|
|
1745
|
+
callbackResolve = resolve3;
|
|
1746
|
+
timeoutId = setTimeout(() => {
|
|
1747
|
+
if (!callbackReceived) {
|
|
1748
|
+
callbackReceived = true;
|
|
1749
|
+
resolve3({
|
|
1750
|
+
success: false,
|
|
1751
|
+
error: "Login timed out. Please try again."
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
}, timeoutMs);
|
|
1755
|
+
});
|
|
1756
|
+
},
|
|
1757
|
+
async stop() {
|
|
1758
|
+
if (timeoutId) {
|
|
1759
|
+
clearTimeout(timeoutId);
|
|
1760
|
+
timeoutId = null;
|
|
1761
|
+
}
|
|
1762
|
+
if (server) {
|
|
1763
|
+
return new Promise((resolve3) => {
|
|
1764
|
+
server.close(() => {
|
|
1765
|
+
server = null;
|
|
1766
|
+
resolve3();
|
|
1767
|
+
});
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/commands/login.ts
|
|
1775
|
+
var DEFAULT_TIMEOUT_SECONDS = 120;
|
|
1776
|
+
var DEFAULT_DASHBOARD_URL = "https://app.spike.ac";
|
|
1777
|
+
function getDashboardUrl() {
|
|
1778
|
+
return process.env.SPIKE_DASHBOARD_URL || DEFAULT_DASHBOARD_URL;
|
|
1779
|
+
}
|
|
1780
|
+
function buildAuthorizationUrl(state, callbackPort) {
|
|
1781
|
+
const dashboardUrl = getDashboardUrl();
|
|
1782
|
+
const callbackUrl = `http://127.0.0.1:${callbackPort}/callback`;
|
|
1783
|
+
const url = new URL("/cli/authorize", dashboardUrl);
|
|
1784
|
+
url.searchParams.set("state", state);
|
|
1785
|
+
url.searchParams.set("callback_url", callbackUrl);
|
|
1786
|
+
return url.toString();
|
|
1787
|
+
}
|
|
1788
|
+
function createLoginCommand() {
|
|
1789
|
+
const loginCommand = new Command("login").description("Authenticate the CLI via browser").option(
|
|
1790
|
+
"-t, --timeout <seconds>",
|
|
1791
|
+
"Timeout for the login flow in seconds",
|
|
1792
|
+
String(DEFAULT_TIMEOUT_SECONDS)
|
|
1793
|
+
).option(
|
|
1794
|
+
"--no-browser",
|
|
1795
|
+
"Skip automatic browser opening and display URL only"
|
|
1796
|
+
).action(async (options) => {
|
|
1797
|
+
await handleLogin(options);
|
|
1798
|
+
});
|
|
1799
|
+
return loginCommand;
|
|
1800
|
+
}
|
|
1801
|
+
async function handleLogin(options) {
|
|
1802
|
+
const timeoutSeconds = parseInt(options.timeout, 10);
|
|
1803
|
+
if (isNaN(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
1804
|
+
error("Invalid timeout value. Please provide a positive number of seconds.");
|
|
1805
|
+
process.exit(1);
|
|
1806
|
+
}
|
|
1807
|
+
const timeoutMs = timeoutSeconds * 1e3;
|
|
1808
|
+
info("Starting login flow...");
|
|
1809
|
+
const state = generateState();
|
|
1810
|
+
const callbackServer = createCallbackServer();
|
|
1811
|
+
let serverInfo;
|
|
1812
|
+
try {
|
|
1813
|
+
serverInfo = await callbackServer.start();
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
error(`Failed to start callback server: ${err instanceof Error ? err.message : String(err)}`);
|
|
1816
|
+
info("You can manually configure your API key with: spike config set api-key <your-key>");
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
}
|
|
1819
|
+
const authUrl = buildAuthorizationUrl(state, serverInfo.port);
|
|
1820
|
+
if (options.browser) {
|
|
1821
|
+
info("Opening browser for authentication...");
|
|
1822
|
+
const browserOpened = await openBrowser(authUrl);
|
|
1823
|
+
if (!browserOpened) {
|
|
1824
|
+
info("Could not open browser automatically.");
|
|
1825
|
+
console.log("");
|
|
1826
|
+
info("Please open the following URL in your browser:");
|
|
1827
|
+
console.log("");
|
|
1828
|
+
console.log(` ${authUrl}`);
|
|
1829
|
+
console.log("");
|
|
1830
|
+
}
|
|
1831
|
+
} else {
|
|
1832
|
+
info("Please open the following URL in your browser:");
|
|
1833
|
+
console.log("");
|
|
1834
|
+
console.log(` ${authUrl}`);
|
|
1835
|
+
console.log("");
|
|
1836
|
+
}
|
|
1837
|
+
info(`Waiting for authentication (timeout: ${timeoutSeconds}s)...`);
|
|
1838
|
+
try {
|
|
1839
|
+
const result = await callbackServer.waitForCallback(state, timeoutMs);
|
|
1840
|
+
if (result.success && result.apiKey) {
|
|
1841
|
+
saveConfig({ apiKey: result.apiKey });
|
|
1842
|
+
console.log("");
|
|
1843
|
+
success("Login successful! API key has been saved.");
|
|
1844
|
+
info("You can now use the CLI to manage your forms and submissions.");
|
|
1845
|
+
} else {
|
|
1846
|
+
console.log("");
|
|
1847
|
+
error(result.error || "Login failed. Please try again.");
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
error(`Login error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1852
|
+
process.exit(1);
|
|
1853
|
+
} finally {
|
|
1854
|
+
await callbackServer.stop();
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
var SKILL_FILE_CONTENT = `# Spike Forms CLI Skill
|
|
1858
|
+
|
|
1859
|
+
This skill provides knowledge about the Spike Forms CLI (\`spike\`) commands for managing forms, submissions, projects, teams, and configuration.
|
|
1860
|
+
|
|
1861
|
+
## Overview
|
|
1862
|
+
|
|
1863
|
+
Spike Forms is a form backend service that handles form submissions. The CLI provides commands to manage all aspects of the service.
|
|
1864
|
+
|
|
1865
|
+
## Authentication
|
|
1866
|
+
|
|
1867
|
+
### Login via Browser
|
|
1868
|
+
|
|
1869
|
+
\`\`\`bash
|
|
1870
|
+
# Authenticate via browser (recommended)
|
|
1871
|
+
spike login
|
|
1872
|
+
|
|
1873
|
+
# Login with custom timeout (in seconds)
|
|
1874
|
+
spike login --timeout 60
|
|
1875
|
+
|
|
1876
|
+
# Login without automatic browser opening (displays URL to copy)
|
|
1877
|
+
spike login --no-browser
|
|
1878
|
+
\`\`\`
|
|
1879
|
+
|
|
1880
|
+
The login command opens your browser to authorize the CLI and automatically saves your API key.
|
|
1881
|
+
|
|
1882
|
+
### Manual Configuration
|
|
1883
|
+
|
|
1884
|
+
\`\`\`bash
|
|
1885
|
+
# Set API key manually
|
|
1886
|
+
spike config set api-key <your-api-key>
|
|
1887
|
+
|
|
1888
|
+
# View current configuration
|
|
1889
|
+
spike config get api-key
|
|
1890
|
+
spike config get base-url
|
|
1891
|
+
|
|
1892
|
+
# Set custom API base URL
|
|
1893
|
+
spike config set base-url https://custom.api.url
|
|
1894
|
+
\`\`\`
|
|
1895
|
+
|
|
1896
|
+
## Forms Management
|
|
1897
|
+
|
|
1898
|
+
### List Forms
|
|
1899
|
+
|
|
1900
|
+
\`\`\`bash
|
|
1901
|
+
# List all forms
|
|
1902
|
+
spike forms list
|
|
1903
|
+
|
|
1904
|
+
# List with options
|
|
1905
|
+
spike forms list --limit 10
|
|
1906
|
+
spike forms list --project-id <project-id>
|
|
1907
|
+
spike forms list --include-inactive
|
|
1908
|
+
spike forms list --format json
|
|
1909
|
+
\`\`\`
|
|
1910
|
+
|
|
1911
|
+
### Get Form Details
|
|
1912
|
+
|
|
1913
|
+
\`\`\`bash
|
|
1914
|
+
# Get a specific form
|
|
1915
|
+
spike forms get <form-id>
|
|
1916
|
+
spike forms get <form-id> --format json
|
|
1917
|
+
\`\`\`
|
|
1918
|
+
|
|
1919
|
+
### Create Forms
|
|
1920
|
+
|
|
1921
|
+
\`\`\`bash
|
|
1922
|
+
# Create a new form
|
|
1923
|
+
spike forms create --name "Contact Form"
|
|
1924
|
+
spike forms create --name "Feedback" --project-id <project-id>
|
|
1925
|
+
\`\`\`
|
|
1926
|
+
|
|
1927
|
+
### Update Forms
|
|
1928
|
+
|
|
1929
|
+
\`\`\`bash
|
|
1930
|
+
# Update form name
|
|
1931
|
+
spike forms update <form-id> --name "New Name"
|
|
1932
|
+
|
|
1933
|
+
# Update form status
|
|
1934
|
+
spike forms update <form-id> --is-active true
|
|
1935
|
+
spike forms update <form-id> --is-active false
|
|
1936
|
+
|
|
1937
|
+
# Move form to a project
|
|
1938
|
+
spike forms update <form-id> --project-id <project-id>
|
|
1939
|
+
\`\`\`
|
|
1940
|
+
|
|
1941
|
+
### Delete Forms
|
|
1942
|
+
|
|
1943
|
+
\`\`\`bash
|
|
1944
|
+
# Delete a form
|
|
1945
|
+
spike forms delete <form-id>
|
|
1946
|
+
\`\`\`
|
|
1947
|
+
|
|
1948
|
+
## Live Preview Workflow (Form Editing)
|
|
1949
|
+
|
|
1950
|
+
The CLI provides a live preview server with hot-reload for editing form HTML templates.
|
|
1951
|
+
|
|
1952
|
+
### Edit Existing Form
|
|
1953
|
+
|
|
1954
|
+
\`\`\`bash
|
|
1955
|
+
# Fetch form and start live preview (foreground)
|
|
1956
|
+
spike forms edit <form-id>
|
|
1957
|
+
|
|
1958
|
+
# Save to custom file path
|
|
1959
|
+
spike forms edit <form-id> --file ./my-form.html
|
|
1960
|
+
|
|
1961
|
+
# Run preview server in background
|
|
1962
|
+
spike forms edit <form-id> --background
|
|
1963
|
+
\`\`\`
|
|
1964
|
+
|
|
1965
|
+
### Create Form Page (Beta)
|
|
1966
|
+
|
|
1967
|
+
\`\`\`bash
|
|
1968
|
+
# Create a new form and generate HTML page
|
|
1969
|
+
spike forms create-page --name "Contact Form"
|
|
1970
|
+
|
|
1971
|
+
# Save to custom file path
|
|
1972
|
+
spike forms create-page --name "Contact Form" --file ./contact.html
|
|
1973
|
+
|
|
1974
|
+
# Create and immediately start preview
|
|
1975
|
+
spike forms create-page --name "Contact Form" --preview
|
|
1976
|
+
|
|
1977
|
+
# Create in a specific project
|
|
1978
|
+
spike forms create-page --name "Contact Form" --project-id <project-id>
|
|
1979
|
+
\`\`\`
|
|
1980
|
+
|
|
1981
|
+
### Stop Background Preview
|
|
1982
|
+
|
|
1983
|
+
\`\`\`bash
|
|
1984
|
+
# Stop the background preview server
|
|
1985
|
+
spike forms stop-preview
|
|
1986
|
+
\`\`\`
|
|
1987
|
+
|
|
1988
|
+
### Live Preview Features
|
|
1989
|
+
|
|
1990
|
+
- **Hot Reload**: Changes to the HTML file automatically refresh the browser
|
|
1991
|
+
- **SSE Connection**: Uses Server-Sent Events for instant updates
|
|
1992
|
+
- **Local Server**: Runs on http://127.0.0.1 with a dynamic port
|
|
1993
|
+
- **Background Mode**: Run the server detached and continue working
|
|
1994
|
+
|
|
1995
|
+
## Submissions Management
|
|
1996
|
+
|
|
1997
|
+
### List Submissions
|
|
1998
|
+
|
|
1999
|
+
\`\`\`bash
|
|
2000
|
+
# List submissions for a form
|
|
2001
|
+
spike submissions list --form-id <form-id>
|
|
2002
|
+
|
|
2003
|
+
# List with options
|
|
2004
|
+
spike submissions list --form-id <form-id> --limit 20
|
|
2005
|
+
spike submissions list --form-id <form-id> --format json
|
|
2006
|
+
\`\`\`
|
|
2007
|
+
|
|
2008
|
+
### Get Submission Details
|
|
2009
|
+
|
|
2010
|
+
\`\`\`bash
|
|
2011
|
+
# Get a specific submission
|
|
2012
|
+
spike submissions get <submission-id>
|
|
2013
|
+
spike submissions get <submission-id> --format json
|
|
2014
|
+
\`\`\`
|
|
2015
|
+
|
|
2016
|
+
### Delete Submissions
|
|
2017
|
+
|
|
2018
|
+
\`\`\`bash
|
|
2019
|
+
# Delete a submission
|
|
2020
|
+
spike submissions delete <submission-id>
|
|
2021
|
+
\`\`\`
|
|
2022
|
+
|
|
2023
|
+
## Projects Management
|
|
2024
|
+
|
|
2025
|
+
### List Projects
|
|
2026
|
+
|
|
2027
|
+
\`\`\`bash
|
|
2028
|
+
# List all projects
|
|
2029
|
+
spike projects list
|
|
2030
|
+
spike projects list --format json
|
|
2031
|
+
\`\`\`
|
|
2032
|
+
|
|
2033
|
+
### Get Project Details
|
|
2034
|
+
|
|
2035
|
+
\`\`\`bash
|
|
2036
|
+
# Get a specific project
|
|
2037
|
+
spike projects get <project-id>
|
|
2038
|
+
\`\`\`
|
|
2039
|
+
|
|
2040
|
+
### Create Projects
|
|
2041
|
+
|
|
2042
|
+
\`\`\`bash
|
|
2043
|
+
# Create a new project
|
|
2044
|
+
spike projects create --name "My Project"
|
|
2045
|
+
spike projects create --name "My Project" --team-id <team-id>
|
|
2046
|
+
\`\`\`
|
|
2047
|
+
|
|
2048
|
+
### Update Projects
|
|
2049
|
+
|
|
2050
|
+
\`\`\`bash
|
|
2051
|
+
# Update project name
|
|
2052
|
+
spike projects update <project-id> --name "New Name"
|
|
2053
|
+
\`\`\`
|
|
2054
|
+
|
|
2055
|
+
### Delete Projects
|
|
2056
|
+
|
|
2057
|
+
\`\`\`bash
|
|
2058
|
+
# Delete a project
|
|
2059
|
+
spike projects delete <project-id>
|
|
2060
|
+
\`\`\`
|
|
2061
|
+
|
|
2062
|
+
## Teams Management
|
|
2063
|
+
|
|
2064
|
+
### List Teams
|
|
2065
|
+
|
|
2066
|
+
\`\`\`bash
|
|
2067
|
+
# List all teams
|
|
2068
|
+
spike teams list
|
|
2069
|
+
spike teams list --format json
|
|
2070
|
+
\`\`\`
|
|
2071
|
+
|
|
2072
|
+
### Get Team Details
|
|
2073
|
+
|
|
2074
|
+
\`\`\`bash
|
|
2075
|
+
# Get a specific team
|
|
2076
|
+
spike teams get <team-id>
|
|
2077
|
+
\`\`\`
|
|
2078
|
+
|
|
2079
|
+
### Create Teams
|
|
2080
|
+
|
|
2081
|
+
\`\`\`bash
|
|
2082
|
+
# Create a new team
|
|
2083
|
+
spike teams create --name "My Team"
|
|
2084
|
+
\`\`\`
|
|
2085
|
+
|
|
2086
|
+
### Update Teams
|
|
2087
|
+
|
|
2088
|
+
\`\`\`bash
|
|
2089
|
+
# Update team name
|
|
2090
|
+
spike teams update <team-id> --name "New Name"
|
|
2091
|
+
\`\`\`
|
|
2092
|
+
|
|
2093
|
+
### Delete Teams
|
|
2094
|
+
|
|
2095
|
+
\`\`\`bash
|
|
2096
|
+
# Delete a team
|
|
2097
|
+
spike teams delete <team-id>
|
|
2098
|
+
\`\`\`
|
|
2099
|
+
|
|
2100
|
+
## Configuration Commands
|
|
2101
|
+
|
|
2102
|
+
\`\`\`bash
|
|
2103
|
+
# Get configuration values
|
|
2104
|
+
spike config get api-key
|
|
2105
|
+
spike config get base-url
|
|
2106
|
+
|
|
2107
|
+
# Set configuration values
|
|
2108
|
+
spike config set api-key <your-api-key>
|
|
2109
|
+
spike config set base-url <api-url>
|
|
2110
|
+
|
|
2111
|
+
# Configuration file location: ~/.spike/config.json
|
|
2112
|
+
\`\`\`
|
|
2113
|
+
|
|
2114
|
+
## Global Options
|
|
2115
|
+
|
|
2116
|
+
All commands support these global options:
|
|
2117
|
+
|
|
2118
|
+
\`\`\`bash
|
|
2119
|
+
# Output format (json or table)
|
|
2120
|
+
spike <command> --format json
|
|
2121
|
+
spike <command> --format table
|
|
2122
|
+
|
|
2123
|
+
# Help
|
|
2124
|
+
spike --help
|
|
2125
|
+
spike <command> --help
|
|
2126
|
+
\`\`\`
|
|
2127
|
+
|
|
2128
|
+
## Common Workflows
|
|
2129
|
+
|
|
2130
|
+
### Setting Up a New Form
|
|
2131
|
+
|
|
2132
|
+
1. Login to authenticate: \`spike login\`
|
|
2133
|
+
2. Create a form: \`spike forms create --name "Contact Form"\`
|
|
2134
|
+
3. Edit with live preview: \`spike forms edit <form-id>\`
|
|
2135
|
+
4. Make changes to the HTML file and see them instantly in the browser
|
|
2136
|
+
|
|
2137
|
+
### Managing Form Submissions
|
|
2138
|
+
|
|
2139
|
+
1. List forms: \`spike forms list\`
|
|
2140
|
+
2. View submissions: \`spike submissions list --form-id <form-id>\`
|
|
2141
|
+
3. Get submission details: \`spike submissions get <submission-id>\`
|
|
2142
|
+
|
|
2143
|
+
### Organizing with Projects and Teams
|
|
2144
|
+
|
|
2145
|
+
1. Create a team: \`spike teams create --name "Marketing"\`
|
|
2146
|
+
2. Create a project: \`spike projects create --name "Website Forms" --team-id <team-id>\`
|
|
2147
|
+
3. Create forms in the project: \`spike forms create --name "Contact" --project-id <project-id>\`
|
|
2148
|
+
|
|
2149
|
+
## Environment Variables
|
|
2150
|
+
|
|
2151
|
+
- \`SPIKE_API_KEY\` or \`SPIKE_TOKEN\`: API key for authentication
|
|
2152
|
+
- \`SPIKE_API_URL\`: Custom API base URL
|
|
2153
|
+
- \`SPIKE_DASHBOARD_URL\`: Custom dashboard URL for login flow
|
|
2154
|
+
`;
|
|
2155
|
+
function getSkillsDir() {
|
|
2156
|
+
const homeDir = os2.homedir();
|
|
2157
|
+
return path3.join(homeDir, ".config", "opencode", "skills", "spike");
|
|
2158
|
+
}
|
|
2159
|
+
function getSkillFilePath() {
|
|
2160
|
+
return path3.join(getSkillsDir(), "SKILL.md");
|
|
2161
|
+
}
|
|
2162
|
+
function isOpenCodeInstalled() {
|
|
2163
|
+
try {
|
|
2164
|
+
const command = process.platform === "win32" ? "where opencode" : "which opencode";
|
|
2165
|
+
execSync(command, { stdio: "ignore" });
|
|
2166
|
+
return true;
|
|
2167
|
+
} catch {
|
|
2168
|
+
return false;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
function ensureSkillInstalled() {
|
|
2172
|
+
const skillsDir = getSkillsDir();
|
|
2173
|
+
const skillFilePath = getSkillFilePath();
|
|
2174
|
+
if (!fs3.existsSync(skillsDir)) {
|
|
2175
|
+
fs3.mkdirSync(skillsDir, { recursive: true });
|
|
2176
|
+
}
|
|
2177
|
+
fs3.writeFileSync(skillFilePath, SKILL_FILE_CONTENT, "utf-8");
|
|
2178
|
+
}
|
|
2179
|
+
function installOpenCode() {
|
|
2180
|
+
try {
|
|
2181
|
+
info("Installing OpenCode via npm...");
|
|
2182
|
+
execSync("npm install -g opencode", { stdio: "inherit" });
|
|
2183
|
+
return true;
|
|
2184
|
+
} catch {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
function createAgentCommand() {
|
|
2189
|
+
const agentCommand = new Command("agent").description("Launch OpenCode AI agent with Spike Forms knowledge").option("--install", "Install OpenCode via npm if not already installed").option("--model <model>", "Model to use with OpenCode").action(async (options) => {
|
|
2190
|
+
await handleAgent(options);
|
|
2191
|
+
});
|
|
2192
|
+
return agentCommand;
|
|
2193
|
+
}
|
|
2194
|
+
async function handleAgent(options) {
|
|
2195
|
+
if (!isOpenCodeInstalled()) {
|
|
2196
|
+
if (options.install) {
|
|
2197
|
+
const installed = installOpenCode();
|
|
2198
|
+
if (!installed) {
|
|
2199
|
+
error("Failed to install OpenCode via npm.");
|
|
2200
|
+
info("Please install OpenCode manually:");
|
|
2201
|
+
console.log("");
|
|
2202
|
+
console.log(" npm install -g opencode");
|
|
2203
|
+
console.log("");
|
|
2204
|
+
info("Or visit: https://opencode.ai for more installation options.");
|
|
2205
|
+
process.exit(1);
|
|
2206
|
+
}
|
|
2207
|
+
success("OpenCode installed successfully");
|
|
2208
|
+
} else {
|
|
2209
|
+
error("OpenCode is not installed.");
|
|
2210
|
+
info("Install OpenCode using one of these methods:");
|
|
2211
|
+
console.log("");
|
|
2212
|
+
console.log(" # Install via npm");
|
|
2213
|
+
console.log(" npm install -g opencode");
|
|
2214
|
+
console.log("");
|
|
2215
|
+
console.log(" # Or use the --install flag");
|
|
2216
|
+
console.log(" spike agent --install");
|
|
2217
|
+
console.log("");
|
|
2218
|
+
info("Or visit: https://opencode.ai for more installation options.");
|
|
2219
|
+
process.exit(1);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
try {
|
|
2223
|
+
ensureSkillInstalled();
|
|
2224
|
+
info("Spike Forms skill installed for OpenCode");
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
warn(`Could not install skill file: ${err instanceof Error ? err.message : String(err)}`);
|
|
2227
|
+
}
|
|
2228
|
+
const args = [];
|
|
2229
|
+
if (options.model) {
|
|
2230
|
+
args.push("--model", options.model);
|
|
2231
|
+
}
|
|
2232
|
+
info("Launching OpenCode...");
|
|
2233
|
+
console.log("");
|
|
2234
|
+
const opencode = spawn("opencode", args, {
|
|
2235
|
+
stdio: "inherit"
|
|
2236
|
+
});
|
|
2237
|
+
opencode.on("close", (code) => {
|
|
2238
|
+
process.exit(code ?? 0);
|
|
2239
|
+
});
|
|
2240
|
+
opencode.on("error", (err) => {
|
|
2241
|
+
error(`Failed to launch OpenCode: ${err.message}`);
|
|
2242
|
+
process.exit(1);
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// src/index.ts
|
|
2247
|
+
var program = new Command();
|
|
2248
|
+
program.name("spike").description("Command-line interface for the Spike Forms API").version("0.1.0");
|
|
2249
|
+
program.option(
|
|
2250
|
+
"-f, --format <format>",
|
|
2251
|
+
"Output format (json, table)",
|
|
2252
|
+
"table"
|
|
2253
|
+
);
|
|
2254
|
+
program.addCommand(createConfigCommand());
|
|
2255
|
+
program.addCommand(createFormsCommand());
|
|
2256
|
+
program.addCommand(createSubmissionsCommand());
|
|
2257
|
+
program.addCommand(createProjectsCommand());
|
|
2258
|
+
program.addCommand(createTeamsCommand());
|
|
2259
|
+
program.addCommand(createUserCommand());
|
|
2260
|
+
program.addCommand(createLoginCommand());
|
|
2261
|
+
program.addCommand(createAgentCommand());
|
|
2262
|
+
async function main() {
|
|
2263
|
+
try {
|
|
2264
|
+
await program.parseAsync(process.argv);
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
handleFatalError(err);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
function handleFatalError(err) {
|
|
2270
|
+
if (err instanceof Error) {
|
|
2271
|
+
error(err.message);
|
|
2272
|
+
} else {
|
|
2273
|
+
error("An unexpected error occurred");
|
|
2274
|
+
}
|
|
2275
|
+
process.exitCode = 1;
|
|
2276
|
+
}
|
|
2277
|
+
process.on("uncaughtException", (err) => {
|
|
2278
|
+
handleFatalError(err);
|
|
2279
|
+
process.exit(1);
|
|
2280
|
+
});
|
|
2281
|
+
process.on("unhandledRejection", (reason) => {
|
|
2282
|
+
handleFatalError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
2283
|
+
process.exit(1);
|
|
2284
|
+
});
|
|
2285
|
+
main();
|
|
2286
|
+
//# sourceMappingURL=index.js.map
|
|
2287
|
+
//# sourceMappingURL=index.js.map
|