@spacelr/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.
- package/README.md +64 -0
- package/dist/index.js +1251 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// libs/cli/src/index.ts
|
|
27
|
+
var import_commander13 = require("commander");
|
|
28
|
+
|
|
29
|
+
// libs/cli/src/lib/output.ts
|
|
30
|
+
var import_ora = __toESM(require("ora"));
|
|
31
|
+
var import_picocolors = __toESM(require("picocolors"));
|
|
32
|
+
var jsonMode = false;
|
|
33
|
+
var verboseMode = false;
|
|
34
|
+
function setOutputMode(options) {
|
|
35
|
+
jsonMode = options.json ?? false;
|
|
36
|
+
verboseMode = options.verbose ?? false;
|
|
37
|
+
}
|
|
38
|
+
function isJsonMode() {
|
|
39
|
+
return jsonMode;
|
|
40
|
+
}
|
|
41
|
+
function success(message) {
|
|
42
|
+
if (!jsonMode) {
|
|
43
|
+
console.log(import_picocolors.default.green(`\u2713 ${message}`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function error(message) {
|
|
47
|
+
if (!jsonMode) {
|
|
48
|
+
console.error(import_picocolors.default.red(`\u2717 ${message}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function warn(message) {
|
|
52
|
+
if (!jsonMode) {
|
|
53
|
+
console.warn(import_picocolors.default.yellow(`! ${message}`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function info(message) {
|
|
57
|
+
if (!jsonMode) {
|
|
58
|
+
console.log(import_picocolors.default.cyan(`\u2139 ${message}`));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function verbose(message) {
|
|
62
|
+
if (verboseMode && !jsonMode) {
|
|
63
|
+
console.log(import_picocolors.default.dim(` ${message}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function json(data) {
|
|
67
|
+
console.log(JSON.stringify(data, null, 2));
|
|
68
|
+
}
|
|
69
|
+
function table(headers, rows) {
|
|
70
|
+
if (jsonMode) return;
|
|
71
|
+
const colWidths = headers.map((h, i) => {
|
|
72
|
+
const maxRow = rows.reduce(
|
|
73
|
+
(max, row) => Math.max(max, (row[i] ?? "").length),
|
|
74
|
+
0
|
|
75
|
+
);
|
|
76
|
+
return Math.max(h.length, maxRow);
|
|
77
|
+
});
|
|
78
|
+
const headerLine = headers.map((h, i) => import_picocolors.default.bold(h.padEnd(colWidths[i] ?? 0))).join(" ");
|
|
79
|
+
const separator = colWidths.map((w) => "-".repeat(w)).join(" ");
|
|
80
|
+
console.log(headerLine);
|
|
81
|
+
console.log(separator);
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
console.log(
|
|
84
|
+
row.map((cell, i) => cell.padEnd(colWidths[i] ?? 0)).join(" ")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function spinner(text) {
|
|
89
|
+
if (jsonMode) {
|
|
90
|
+
return (0, import_ora.default)({ text, isSilent: true });
|
|
91
|
+
}
|
|
92
|
+
return (0, import_ora.default)(text).start();
|
|
93
|
+
}
|
|
94
|
+
function formatBytes(bytes) {
|
|
95
|
+
if (bytes === 0) return "0 B";
|
|
96
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
97
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
98
|
+
const value = bytes / Math.pow(1024, i);
|
|
99
|
+
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// libs/cli/src/commands/login.ts
|
|
103
|
+
var http = __toESM(require("http"));
|
|
104
|
+
var crypto2 = __toESM(require("crypto"));
|
|
105
|
+
var import_commander = require("commander");
|
|
106
|
+
var import_open = __toESM(require("open"));
|
|
107
|
+
|
|
108
|
+
// libs/cli/src/lib/auth.ts
|
|
109
|
+
var fs = __toESM(require("fs"));
|
|
110
|
+
var path = __toESM(require("path"));
|
|
111
|
+
var crypto = __toESM(require("crypto"));
|
|
112
|
+
function getCredentialsDir() {
|
|
113
|
+
return path.join(
|
|
114
|
+
process.env["HOME"] ?? process.env["USERPROFILE"] ?? "~",
|
|
115
|
+
".spacelr"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
function getCredentialsFile() {
|
|
119
|
+
return path.join(getCredentialsDir(), "credentials.json");
|
|
120
|
+
}
|
|
121
|
+
function getStoredCredentials() {
|
|
122
|
+
try {
|
|
123
|
+
const file = getCredentialsFile();
|
|
124
|
+
if (!fs.existsSync(file)) return null;
|
|
125
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
126
|
+
return JSON.parse(content);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function storeCredentials(credentials) {
|
|
132
|
+
const dir = getCredentialsDir();
|
|
133
|
+
if (!fs.existsSync(dir)) {
|
|
134
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(
|
|
137
|
+
getCredentialsFile(),
|
|
138
|
+
JSON.stringify(credentials, null, 2),
|
|
139
|
+
{ mode: 384 }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
function clearCredentials() {
|
|
143
|
+
try {
|
|
144
|
+
const file = getCredentialsFile();
|
|
145
|
+
if (fs.existsSync(file)) {
|
|
146
|
+
fs.unlinkSync(file);
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function isTokenExpired(credentials) {
|
|
152
|
+
return Date.now() >= credentials.expiresAt - 6e4;
|
|
153
|
+
}
|
|
154
|
+
function resolveToken(flagToken) {
|
|
155
|
+
if (flagToken) return flagToken;
|
|
156
|
+
if (process.env["SPACELR_TOKEN"]) return process.env["SPACELR_TOKEN"];
|
|
157
|
+
const credentials = getStoredCredentials();
|
|
158
|
+
if (credentials && !isTokenExpired(credentials)) {
|
|
159
|
+
return credentials.accessToken;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
var AuthRequiredError = class extends Error {
|
|
164
|
+
constructor() {
|
|
165
|
+
super('Not logged in. Run "spacelr login" to authenticate.');
|
|
166
|
+
this.name = "AuthRequiredError";
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
function requireAuth(flagToken) {
|
|
170
|
+
const token = resolveToken(flagToken);
|
|
171
|
+
if (!token) {
|
|
172
|
+
throw new AuthRequiredError();
|
|
173
|
+
}
|
|
174
|
+
return token;
|
|
175
|
+
}
|
|
176
|
+
function generatePKCE() {
|
|
177
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
178
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
179
|
+
return { codeVerifier, codeChallenge };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// libs/cli/src/lib/config.ts
|
|
183
|
+
var fs2 = __toESM(require("fs"));
|
|
184
|
+
var path2 = __toESM(require("path"));
|
|
185
|
+
var CONFIG_FILENAME = "spacelr.json";
|
|
186
|
+
function findConfigPath(startDir) {
|
|
187
|
+
let dir = startDir ?? process.cwd();
|
|
188
|
+
while (true) {
|
|
189
|
+
const candidate = path2.join(dir, CONFIG_FILENAME);
|
|
190
|
+
if (fs2.existsSync(candidate)) {
|
|
191
|
+
return candidate;
|
|
192
|
+
}
|
|
193
|
+
const parent = path2.dirname(dir);
|
|
194
|
+
if (parent === dir) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
dir = parent;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function loadConfig(configPath) {
|
|
201
|
+
const resolved = configPath ?? findConfigPath();
|
|
202
|
+
if (!resolved || !fs2.existsSync(resolved)) return null;
|
|
203
|
+
const content = fs2.readFileSync(resolved, "utf-8");
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(content);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Invalid JSON in config file: ${resolved}
|
|
209
|
+
Please check the file for syntax errors.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function saveConfig(config, configPath) {
|
|
214
|
+
const resolved = configPath ?? findConfigPath() ?? path2.join(process.cwd(), CONFIG_FILENAME);
|
|
215
|
+
fs2.writeFileSync(resolved, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
216
|
+
}
|
|
217
|
+
function resolveProjectId(flagValue, config) {
|
|
218
|
+
const projectId = flagValue ?? config?.projectId;
|
|
219
|
+
if (!projectId) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
'Project ID is required. Provide --project <id> or set "projectId" in spacelr.json'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return projectId;
|
|
225
|
+
}
|
|
226
|
+
function resolveApiUrl(flagValue, config) {
|
|
227
|
+
return flagValue ?? config?.apiUrl ?? process.env["SPACELR_API_URL"] ?? "https://api.spacelr.io";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// libs/cli/src/commands/login.ts
|
|
231
|
+
var CLI_CLIENT_ID = "spacelr-cli";
|
|
232
|
+
function createLoginCommand() {
|
|
233
|
+
return new import_commander.Command("login").description("Authenticate with Spacelr via browser").option("--auth-url <url>", "OAuth server URL (defaults to --api-url)").action(async (opts, cmd) => {
|
|
234
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
235
|
+
const config = loadConfig();
|
|
236
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
237
|
+
const authUrl = opts.authUrl ?? apiUrl;
|
|
238
|
+
try {
|
|
239
|
+
const credentials = await performLogin(authUrl, apiUrl);
|
|
240
|
+
storeCredentials(credentials);
|
|
241
|
+
if (isJsonMode()) {
|
|
242
|
+
json({ success: true, expiresAt: credentials.expiresAt });
|
|
243
|
+
} else {
|
|
244
|
+
success("Logged in successfully");
|
|
245
|
+
info(`Credentials stored in ~/.spacelr/credentials.json`);
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
error(err instanceof Error ? err.message : "Login failed");
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function performLogin(authUrl, apiUrl) {
|
|
254
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
255
|
+
const state = crypto2.randomBytes(16).toString("base64url");
|
|
256
|
+
const { port, waitForCode, close } = await startCallbackServer(state);
|
|
257
|
+
const authorizeUrl = buildAuthorizeUrl(authUrl, port, codeChallenge, state);
|
|
258
|
+
info(`Opening browser for authentication...`);
|
|
259
|
+
info(`If the browser doesn't open, visit: ${authorizeUrl}`);
|
|
260
|
+
await (0, import_open.default)(authorizeUrl);
|
|
261
|
+
const spin = spinner("Waiting for authentication...");
|
|
262
|
+
try {
|
|
263
|
+
const code = await waitForCode;
|
|
264
|
+
spin.text = "Exchanging authorization code...";
|
|
265
|
+
const tokenResponse = await exchangeCode(
|
|
266
|
+
authUrl,
|
|
267
|
+
code,
|
|
268
|
+
codeVerifier,
|
|
269
|
+
port
|
|
270
|
+
);
|
|
271
|
+
spin.succeed("Authentication complete");
|
|
272
|
+
return {
|
|
273
|
+
accessToken: tokenResponse.access_token,
|
|
274
|
+
refreshToken: tokenResponse.refresh_token,
|
|
275
|
+
expiresAt: Date.now() + tokenResponse.expires_in * 1e3,
|
|
276
|
+
apiUrl
|
|
277
|
+
};
|
|
278
|
+
} catch (err) {
|
|
279
|
+
spin.fail("Authentication failed");
|
|
280
|
+
throw err;
|
|
281
|
+
} finally {
|
|
282
|
+
close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function buildAuthorizeUrl(apiUrl, port, codeChallenge, state) {
|
|
286
|
+
const params = new URLSearchParams({
|
|
287
|
+
client_id: CLI_CLIENT_ID,
|
|
288
|
+
redirect_uri: `http://127.0.0.1:${port}/callback`,
|
|
289
|
+
response_type: "code",
|
|
290
|
+
code_challenge: codeChallenge,
|
|
291
|
+
code_challenge_method: "S256",
|
|
292
|
+
scope: "openid profile email",
|
|
293
|
+
state
|
|
294
|
+
});
|
|
295
|
+
return `${apiUrl}/auth/authorize?${params.toString()}`;
|
|
296
|
+
}
|
|
297
|
+
async function startCallbackServer(expectedState) {
|
|
298
|
+
return new Promise((resolveServer) => {
|
|
299
|
+
let resolveCode;
|
|
300
|
+
let rejectCode;
|
|
301
|
+
const waitForCode = new Promise((resolve3, reject) => {
|
|
302
|
+
resolveCode = resolve3;
|
|
303
|
+
rejectCode = reject;
|
|
304
|
+
});
|
|
305
|
+
const server = http.createServer((req, res) => {
|
|
306
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
307
|
+
if (url.pathname === "/callback") {
|
|
308
|
+
const code = url.searchParams.get("code");
|
|
309
|
+
const errorParam = url.searchParams.get("error");
|
|
310
|
+
const returnedState = url.searchParams.get("state");
|
|
311
|
+
if (errorParam) {
|
|
312
|
+
const description = url.searchParams.get("error_description") ?? errorParam;
|
|
313
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
314
|
+
res.end(errorPage(description));
|
|
315
|
+
rejectCode(new Error(`OAuth error: ${description}`));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (returnedState !== expectedState) {
|
|
319
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
320
|
+
res.end(errorPage("Invalid state parameter (possible CSRF attack)"));
|
|
321
|
+
rejectCode(new Error("OAuth state mismatch"));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!code) {
|
|
325
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
326
|
+
res.end(errorPage("No authorization code received"));
|
|
327
|
+
rejectCode(new Error("No authorization code received"));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
331
|
+
res.end(successPage());
|
|
332
|
+
resolveCode(code);
|
|
333
|
+
} else {
|
|
334
|
+
res.writeHead(404);
|
|
335
|
+
res.end();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const timeout = setTimeout(() => {
|
|
339
|
+
rejectCode(new Error("Login timed out after 5 minutes"));
|
|
340
|
+
server.close();
|
|
341
|
+
}, 5 * 60 * 1e3);
|
|
342
|
+
server.listen(0, "127.0.0.1", () => {
|
|
343
|
+
const address = server.address();
|
|
344
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
345
|
+
resolveServer({
|
|
346
|
+
port,
|
|
347
|
+
waitForCode,
|
|
348
|
+
close: () => {
|
|
349
|
+
clearTimeout(timeout);
|
|
350
|
+
server.close();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
async function exchangeCode(apiUrl, code, codeVerifier, port) {
|
|
357
|
+
const response = await fetch(`${apiUrl}/auth/token`, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
360
|
+
body: new URLSearchParams({
|
|
361
|
+
grant_type: "authorization_code",
|
|
362
|
+
code,
|
|
363
|
+
code_verifier: codeVerifier,
|
|
364
|
+
client_id: CLI_CLIENT_ID,
|
|
365
|
+
redirect_uri: `http://127.0.0.1:${port}/callback`
|
|
366
|
+
})
|
|
367
|
+
});
|
|
368
|
+
if (!response.ok) {
|
|
369
|
+
let message;
|
|
370
|
+
try {
|
|
371
|
+
const body = await response.json();
|
|
372
|
+
message = body.message ?? body.error ?? response.statusText;
|
|
373
|
+
} catch {
|
|
374
|
+
message = response.statusText;
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`Token exchange failed: ${message}`);
|
|
377
|
+
}
|
|
378
|
+
return await response.json();
|
|
379
|
+
}
|
|
380
|
+
function successPage() {
|
|
381
|
+
return `<!DOCTYPE html>
|
|
382
|
+
<html><head><title>Spacelr CLI</title>
|
|
383
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8f9fa;}
|
|
384
|
+
.box{text-align:center;padding:2rem;}.check{font-size:3rem;color:#22c55e;}</style></head>
|
|
385
|
+
<body><div class="box"><div class="check">✓</div><h1>Authenticated</h1><p>You can close this window and return to the terminal.</p></div></body></html>`;
|
|
386
|
+
}
|
|
387
|
+
function escapeHtml(text) {
|
|
388
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
389
|
+
}
|
|
390
|
+
function errorPage(message) {
|
|
391
|
+
return `<!DOCTYPE html>
|
|
392
|
+
<html><head><title>Spacelr CLI</title>
|
|
393
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8f9fa;}
|
|
394
|
+
.box{text-align:center;padding:2rem;}.x{font-size:3rem;color:#ef4444;}</style></head>
|
|
395
|
+
<body><div class="box"><div class="x">✗</div><h1>Authentication Failed</h1><p>${escapeHtml(message)}</p></div></body></html>`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// libs/cli/src/commands/logout.ts
|
|
399
|
+
var import_commander2 = require("commander");
|
|
400
|
+
function createLogoutCommand() {
|
|
401
|
+
return new import_commander2.Command("logout").description("Clear stored credentials").action(async (_opts, cmd) => {
|
|
402
|
+
cmd.optsWithGlobals();
|
|
403
|
+
const existing = getStoredCredentials();
|
|
404
|
+
clearCredentials();
|
|
405
|
+
if (isJsonMode()) {
|
|
406
|
+
json({ success: true, hadCredentials: !!existing });
|
|
407
|
+
} else if (existing) {
|
|
408
|
+
success("Logged out successfully");
|
|
409
|
+
} else {
|
|
410
|
+
warn("No credentials found");
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// libs/cli/src/commands/whoami.ts
|
|
416
|
+
var import_commander3 = require("commander");
|
|
417
|
+
|
|
418
|
+
// libs/cli/src/lib/api-client.ts
|
|
419
|
+
var ApiClient = class {
|
|
420
|
+
constructor(options) {
|
|
421
|
+
this.refreshing = null;
|
|
422
|
+
this.apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
423
|
+
this.explicitToken = options.token;
|
|
424
|
+
}
|
|
425
|
+
async getToken() {
|
|
426
|
+
if (this.explicitToken) return this.explicitToken;
|
|
427
|
+
const envToken = process.env["SPACELR_TOKEN"];
|
|
428
|
+
if (envToken) return envToken;
|
|
429
|
+
const credentials = getStoredCredentials();
|
|
430
|
+
if (!credentials) return null;
|
|
431
|
+
if (!isTokenExpired(credentials)) {
|
|
432
|
+
return credentials.accessToken;
|
|
433
|
+
}
|
|
434
|
+
if (credentials.refreshToken) {
|
|
435
|
+
return this.refreshAccessToken(credentials);
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
async refreshAccessToken(credentials) {
|
|
440
|
+
if (this.refreshing) return this.refreshing;
|
|
441
|
+
this.refreshing = (async () => {
|
|
442
|
+
try {
|
|
443
|
+
verbose("Refreshing access token...");
|
|
444
|
+
const response = await fetch(`${this.apiUrl}/auth/refresh`, {
|
|
445
|
+
method: "POST",
|
|
446
|
+
headers: { "Content-Type": "application/json" },
|
|
447
|
+
body: JSON.stringify({
|
|
448
|
+
refreshToken: credentials.refreshToken ?? ""
|
|
449
|
+
})
|
|
450
|
+
});
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
verbose("Token refresh failed");
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const data = await response.json();
|
|
456
|
+
const newCredentials = {
|
|
457
|
+
accessToken: data.access_token,
|
|
458
|
+
refreshToken: data.refresh_token ?? credentials.refreshToken,
|
|
459
|
+
// Admin refresh endpoint doesn't return expires_in; default to 1 hour
|
|
460
|
+
expiresAt: Date.now() + 3600 * 1e3,
|
|
461
|
+
apiUrl: credentials.apiUrl
|
|
462
|
+
};
|
|
463
|
+
storeCredentials(newCredentials);
|
|
464
|
+
verbose("Token refreshed successfully");
|
|
465
|
+
return data.access_token;
|
|
466
|
+
} catch {
|
|
467
|
+
verbose("Token refresh error");
|
|
468
|
+
return null;
|
|
469
|
+
} finally {
|
|
470
|
+
this.refreshing = null;
|
|
471
|
+
}
|
|
472
|
+
})();
|
|
473
|
+
return this.refreshing;
|
|
474
|
+
}
|
|
475
|
+
serializeBody(body, contentType) {
|
|
476
|
+
if (!body) return void 0;
|
|
477
|
+
if (contentType === "application/json") {
|
|
478
|
+
return JSON.stringify(body);
|
|
479
|
+
}
|
|
480
|
+
return body;
|
|
481
|
+
}
|
|
482
|
+
async request(path6, options = {}) {
|
|
483
|
+
const { method = "GET", body, headers = {}, timeout = 3e4 } = options;
|
|
484
|
+
const url = `${this.apiUrl}${path6}`;
|
|
485
|
+
const token = await this.getToken();
|
|
486
|
+
if (token) {
|
|
487
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
488
|
+
}
|
|
489
|
+
if (body && !headers["Content-Type"]) {
|
|
490
|
+
headers["Content-Type"] = "application/json";
|
|
491
|
+
}
|
|
492
|
+
const serializedBody = this.serializeBody(body, headers["Content-Type"]);
|
|
493
|
+
const controller = new AbortController();
|
|
494
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
495
|
+
verbose(`${method} ${url}`);
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetch(url, {
|
|
498
|
+
method,
|
|
499
|
+
headers,
|
|
500
|
+
body: serializedBody,
|
|
501
|
+
signal: controller.signal
|
|
502
|
+
});
|
|
503
|
+
if (response.status === 401 && !this.explicitToken) {
|
|
504
|
+
const credentials = getStoredCredentials();
|
|
505
|
+
if (credentials?.refreshToken) {
|
|
506
|
+
const newToken = await this.refreshAccessToken(credentials);
|
|
507
|
+
if (newToken) {
|
|
508
|
+
headers["Authorization"] = `Bearer ${newToken}`;
|
|
509
|
+
const retryController = new AbortController();
|
|
510
|
+
const retryTimer = setTimeout(() => retryController.abort(), timeout);
|
|
511
|
+
const retryResponse = await fetch(url, {
|
|
512
|
+
method,
|
|
513
|
+
headers,
|
|
514
|
+
body: serializedBody,
|
|
515
|
+
signal: retryController.signal
|
|
516
|
+
});
|
|
517
|
+
clearTimeout(retryTimer);
|
|
518
|
+
if (!retryResponse.ok) {
|
|
519
|
+
await this.throwApiError(retryResponse);
|
|
520
|
+
}
|
|
521
|
+
return await retryResponse.json();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
throw new Error(
|
|
525
|
+
'Authentication required. Run "spacelr login" to authenticate.'
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
await this.throwApiError(response);
|
|
530
|
+
}
|
|
531
|
+
const contentType = response.headers.get("content-type");
|
|
532
|
+
if (contentType?.includes("application/json")) {
|
|
533
|
+
return await response.json();
|
|
534
|
+
}
|
|
535
|
+
return await response.text();
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
538
|
+
throw new Error(`Request timed out: ${method} ${path6}`);
|
|
539
|
+
}
|
|
540
|
+
throw err;
|
|
541
|
+
} finally {
|
|
542
|
+
clearTimeout(timer);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async throwApiError(response) {
|
|
546
|
+
let message;
|
|
547
|
+
try {
|
|
548
|
+
const body = await response.json();
|
|
549
|
+
message = body.message ?? body.error ?? response.statusText;
|
|
550
|
+
} catch {
|
|
551
|
+
message = response.statusText;
|
|
552
|
+
}
|
|
553
|
+
throw new Error(`API error (${response.status}): ${message}`);
|
|
554
|
+
}
|
|
555
|
+
async get(path6, timeout) {
|
|
556
|
+
return this.request(path6, { timeout });
|
|
557
|
+
}
|
|
558
|
+
async post(path6, body, timeout) {
|
|
559
|
+
return this.request(path6, { method: "POST", body, timeout });
|
|
560
|
+
}
|
|
561
|
+
async put(path6, body, timeout) {
|
|
562
|
+
return this.request(path6, { method: "PUT", body, timeout });
|
|
563
|
+
}
|
|
564
|
+
async delete(path6, timeout) {
|
|
565
|
+
return this.request(path6, { method: "DELETE", timeout });
|
|
566
|
+
}
|
|
567
|
+
async uploadFile(path6, fileBuffer, filename) {
|
|
568
|
+
const boundary = `----spacelr${Date.now()}`;
|
|
569
|
+
const parts = [];
|
|
570
|
+
const header = Buffer.from(
|
|
571
|
+
`--${boundary}\r
|
|
572
|
+
Content-Disposition: form-data; name="file"; filename="${filename}"\r
|
|
573
|
+
Content-Type: application/zip\r
|
|
574
|
+
\r
|
|
575
|
+
`
|
|
576
|
+
);
|
|
577
|
+
const footer = Buffer.from(`\r
|
|
578
|
+
--${boundary}--\r
|
|
579
|
+
`);
|
|
580
|
+
parts.push(header, fileBuffer, footer);
|
|
581
|
+
const body = Buffer.concat(parts);
|
|
582
|
+
return this.request(path6, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
body,
|
|
585
|
+
headers: {
|
|
586
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`
|
|
587
|
+
},
|
|
588
|
+
timeout: 3e5
|
|
589
|
+
// 5 minutes for uploads
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// libs/cli/src/commands/whoami.ts
|
|
595
|
+
function createWhoamiCommand() {
|
|
596
|
+
return new import_commander3.Command("whoami").description("Show current authenticated user").action(async (_opts, cmd) => {
|
|
597
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
598
|
+
const config = loadConfig();
|
|
599
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
600
|
+
try {
|
|
601
|
+
const token = requireAuth(globalOpts.token);
|
|
602
|
+
const client = new ApiClient({ apiUrl, token });
|
|
603
|
+
const response = await client.get("/auth/me");
|
|
604
|
+
const user = response.user;
|
|
605
|
+
if (isJsonMode()) {
|
|
606
|
+
json(user);
|
|
607
|
+
} else {
|
|
608
|
+
success(`Logged in as ${user.email ?? user.username ?? user.id}`);
|
|
609
|
+
if (user.displayName) info(`Name: ${user.displayName}`);
|
|
610
|
+
if (user.roles?.length) info(`Roles: ${user.roles.join(", ")}`);
|
|
611
|
+
}
|
|
612
|
+
} catch (err) {
|
|
613
|
+
error(err instanceof Error ? err.message : "Failed to get user info");
|
|
614
|
+
process.exitCode = 1;
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// libs/cli/src/commands/projects.ts
|
|
620
|
+
var import_commander4 = require("commander");
|
|
621
|
+
function createProjectsCommand() {
|
|
622
|
+
return new import_commander4.Command("projects").description("List your projects").action(async (_opts, cmd) => {
|
|
623
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
624
|
+
const config = loadConfig();
|
|
625
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
626
|
+
try {
|
|
627
|
+
const token = requireAuth(globalOpts.token);
|
|
628
|
+
const client = new ApiClient({ apiUrl, token });
|
|
629
|
+
const spin = spinner("Fetching projects...");
|
|
630
|
+
const result = await client.get("/projects");
|
|
631
|
+
spin.stop();
|
|
632
|
+
const projects = result.projects;
|
|
633
|
+
if (projects.length === 0) {
|
|
634
|
+
info("No projects found");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const activeProjectId = config?.projectId;
|
|
638
|
+
if (isJsonMode()) {
|
|
639
|
+
json(projects);
|
|
640
|
+
} else {
|
|
641
|
+
table(
|
|
642
|
+
["", "ID", "Name", "Slug"],
|
|
643
|
+
projects.map((p) => [
|
|
644
|
+
p.id === activeProjectId ? "*" : " ",
|
|
645
|
+
p.id,
|
|
646
|
+
p.name,
|
|
647
|
+
p.slug
|
|
648
|
+
])
|
|
649
|
+
);
|
|
650
|
+
if (activeProjectId) {
|
|
651
|
+
info(`
|
|
652
|
+
* = active project (from spacelr.json)`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
} catch (err) {
|
|
656
|
+
error(err instanceof Error ? err.message : "Failed to list projects");
|
|
657
|
+
process.exitCode = 1;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
async function fetchProjects(client) {
|
|
662
|
+
const result = await client.get("/projects");
|
|
663
|
+
return result.projects;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// libs/cli/src/commands/use.ts
|
|
667
|
+
var import_commander5 = require("commander");
|
|
668
|
+
|
|
669
|
+
// libs/cli/src/lib/prompt.ts
|
|
670
|
+
var readline = __toESM(require("readline"));
|
|
671
|
+
async function promptChoice(message, choices) {
|
|
672
|
+
const rl = readline.createInterface({
|
|
673
|
+
input: process.stdin,
|
|
674
|
+
output: process.stderr
|
|
675
|
+
});
|
|
676
|
+
console.error(`
|
|
677
|
+
${message}
|
|
678
|
+
`);
|
|
679
|
+
choices.forEach((choice, i) => {
|
|
680
|
+
console.error(` ${i + 1}) ${choice.label}`);
|
|
681
|
+
});
|
|
682
|
+
console.error();
|
|
683
|
+
return new Promise((resolve3, reject) => {
|
|
684
|
+
rl.question(" Enter number: ", (answer) => {
|
|
685
|
+
rl.close();
|
|
686
|
+
const index = parseInt(answer, 10) - 1;
|
|
687
|
+
if (isNaN(index) || index < 0 || index >= choices.length) {
|
|
688
|
+
reject(new Error("Invalid selection"));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
resolve3(choices[index]);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async function promptConfirm(message) {
|
|
696
|
+
const rl = readline.createInterface({
|
|
697
|
+
input: process.stdin,
|
|
698
|
+
output: process.stderr
|
|
699
|
+
});
|
|
700
|
+
return new Promise((resolve3) => {
|
|
701
|
+
rl.question(`${message} (y/N) `, (answer) => {
|
|
702
|
+
rl.close();
|
|
703
|
+
resolve3(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
async function promptInput(message, defaultValue) {
|
|
708
|
+
const rl = readline.createInterface({
|
|
709
|
+
input: process.stdin,
|
|
710
|
+
output: process.stderr
|
|
711
|
+
});
|
|
712
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
713
|
+
return new Promise((resolve3) => {
|
|
714
|
+
rl.question(`${message}${suffix}: `, (answer) => {
|
|
715
|
+
rl.close();
|
|
716
|
+
resolve3(answer.trim() || defaultValue || "");
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// libs/cli/src/commands/use.ts
|
|
722
|
+
function createUseCommand() {
|
|
723
|
+
return new import_commander5.Command("use").description("Set the active project").argument("[project]", "Project ID or slug").action(async (project, _opts, cmd) => {
|
|
724
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
725
|
+
const config = loadConfig();
|
|
726
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
727
|
+
try {
|
|
728
|
+
const token = requireAuth(globalOpts.token);
|
|
729
|
+
const client = new ApiClient({ apiUrl, token });
|
|
730
|
+
const spin = spinner("Fetching projects...");
|
|
731
|
+
const projects = await fetchProjects(client);
|
|
732
|
+
spin.stop();
|
|
733
|
+
if (projects.length === 0) {
|
|
734
|
+
error("No projects found. Create a project first.");
|
|
735
|
+
process.exitCode = 1;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
let selected;
|
|
739
|
+
if (project) {
|
|
740
|
+
const match = projects.find(
|
|
741
|
+
(p) => p.id === project || p.slug === project
|
|
742
|
+
);
|
|
743
|
+
if (!match) {
|
|
744
|
+
error(`Project "${project}" not found`);
|
|
745
|
+
info("Available projects:");
|
|
746
|
+
for (const p of projects) {
|
|
747
|
+
info(` ${p.slug} (${p.id})`);
|
|
748
|
+
}
|
|
749
|
+
process.exitCode = 1;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
selected = match;
|
|
753
|
+
} else {
|
|
754
|
+
selected = await promptChoice(
|
|
755
|
+
"Select a project:",
|
|
756
|
+
projects.map((p) => ({
|
|
757
|
+
...p,
|
|
758
|
+
label: `${p.name} (${p.slug})`
|
|
759
|
+
}))
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const updatedConfig = { ...config, projectId: selected.id };
|
|
763
|
+
saveConfig(updatedConfig);
|
|
764
|
+
if (isJsonMode()) {
|
|
765
|
+
json({ projectId: selected.id, name: selected.name, slug: selected.slug });
|
|
766
|
+
} else {
|
|
767
|
+
success(`Now using project "${selected.name}" (${selected.slug})`);
|
|
768
|
+
}
|
|
769
|
+
} catch (err) {
|
|
770
|
+
error(err instanceof Error ? err.message : "Failed to select project");
|
|
771
|
+
process.exitCode = 1;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// libs/cli/src/commands/init.ts
|
|
777
|
+
var fs3 = __toESM(require("fs"));
|
|
778
|
+
var path3 = __toESM(require("path"));
|
|
779
|
+
var import_commander6 = require("commander");
|
|
780
|
+
function createInitCommand() {
|
|
781
|
+
return new import_commander6.Command("init").description("Initialize a spacelr.json config in the current directory").action(async (_opts, cmd) => {
|
|
782
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
783
|
+
const existingConfig = loadConfig();
|
|
784
|
+
const configPath = path3.join(process.cwd(), "spacelr.json");
|
|
785
|
+
if (fs3.existsSync(configPath)) {
|
|
786
|
+
warn(`spacelr.json already exists in ${process.cwd()}`);
|
|
787
|
+
info('Use "spacelr use" to change the active project');
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, existingConfig);
|
|
791
|
+
try {
|
|
792
|
+
const token = requireAuth(globalOpts.token);
|
|
793
|
+
const client = new ApiClient({ apiUrl, token });
|
|
794
|
+
const spin = spinner("Fetching projects...");
|
|
795
|
+
const projects = await fetchProjects(client);
|
|
796
|
+
spin.stop();
|
|
797
|
+
if (projects.length === 0) {
|
|
798
|
+
error("No projects found. Create a project in the admin panel first.");
|
|
799
|
+
process.exitCode = 1;
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const selected = await promptChoice(
|
|
803
|
+
"Select a project:",
|
|
804
|
+
projects.map((p) => ({
|
|
805
|
+
...p,
|
|
806
|
+
label: `${p.name} (${p.slug})`
|
|
807
|
+
}))
|
|
808
|
+
);
|
|
809
|
+
const hostingDir = await promptInput(
|
|
810
|
+
"Hosting directory",
|
|
811
|
+
"./dist"
|
|
812
|
+
);
|
|
813
|
+
const config = {
|
|
814
|
+
projectId: selected.id,
|
|
815
|
+
apiUrl: apiUrl !== "https://api.spacelr.io" ? apiUrl : void 0,
|
|
816
|
+
hosting: {
|
|
817
|
+
directory: hostingDir
|
|
818
|
+
},
|
|
819
|
+
database: {
|
|
820
|
+
indexes: {},
|
|
821
|
+
rules: {}
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
saveConfig(config, configPath);
|
|
825
|
+
if (isJsonMode()) {
|
|
826
|
+
json(config);
|
|
827
|
+
} else {
|
|
828
|
+
success(`Created spacelr.json for "${selected.name}"`);
|
|
829
|
+
info(`Project: ${selected.slug} (${selected.id})`);
|
|
830
|
+
info(`Hosting directory: ${hostingDir}`);
|
|
831
|
+
info("");
|
|
832
|
+
info("Next steps:");
|
|
833
|
+
info(" spacelr deploy Deploy your site");
|
|
834
|
+
info(" spacelr db indexes pull Pull database indexes");
|
|
835
|
+
info(" spacelr db rules pull Pull database rules");
|
|
836
|
+
}
|
|
837
|
+
} catch (err) {
|
|
838
|
+
error(err instanceof Error ? err.message : "Initialization failed");
|
|
839
|
+
process.exitCode = 1;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// libs/cli/src/commands/deploy.ts
|
|
845
|
+
var path5 = __toESM(require("path"));
|
|
846
|
+
var import_commander7 = require("commander");
|
|
847
|
+
|
|
848
|
+
// libs/cli/src/lib/zip.ts
|
|
849
|
+
var fs4 = __toESM(require("fs"));
|
|
850
|
+
var path4 = __toESM(require("path"));
|
|
851
|
+
var import_archiver = __toESM(require("archiver"));
|
|
852
|
+
async function createZipBuffer(directory) {
|
|
853
|
+
const absDir = path4.resolve(directory);
|
|
854
|
+
if (!fs4.existsSync(absDir)) {
|
|
855
|
+
throw new Error(`Directory not found: ${absDir}`);
|
|
856
|
+
}
|
|
857
|
+
if (!fs4.statSync(absDir).isDirectory()) {
|
|
858
|
+
throw new Error(`Not a directory: ${absDir}`);
|
|
859
|
+
}
|
|
860
|
+
return new Promise((resolve3, reject) => {
|
|
861
|
+
const chunks = [];
|
|
862
|
+
const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
|
|
863
|
+
archive.on("data", (chunk) => chunks.push(chunk));
|
|
864
|
+
archive.on("end", () => resolve3(Buffer.concat(chunks)));
|
|
865
|
+
archive.on("error", reject);
|
|
866
|
+
archive.directory(absDir, false);
|
|
867
|
+
archive.finalize();
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function countFiles(directory) {
|
|
871
|
+
const absDir = path4.resolve(directory);
|
|
872
|
+
let count = 0;
|
|
873
|
+
function walk(dir) {
|
|
874
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
875
|
+
for (const entry of entries) {
|
|
876
|
+
if (entry.isFile()) {
|
|
877
|
+
count++;
|
|
878
|
+
} else if (entry.isDirectory()) {
|
|
879
|
+
walk(path4.join(dir, entry.name));
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
walk(absDir);
|
|
884
|
+
return count;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// libs/cli/src/commands/deploy.ts
|
|
888
|
+
function createDeployCommand() {
|
|
889
|
+
return new import_commander7.Command("deploy").description("Deploy a directory to Spacelr hosting").argument("[directory]", "Directory to deploy").option("--project <id>", "Project ID").option("--description <text>", "Deployment description").option("--no-wait", "Do not wait for processing to complete").option("--dry-run", "Show what would be deployed without uploading").action(async (directory, opts, cmd) => {
|
|
890
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
891
|
+
const config = loadConfig();
|
|
892
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
893
|
+
try {
|
|
894
|
+
const token = requireAuth(globalOpts.token);
|
|
895
|
+
const projectId = resolveProjectId(opts.project, config);
|
|
896
|
+
const deployDir = directory ?? config?.hosting?.directory ?? ".";
|
|
897
|
+
const absDir = path5.resolve(deployDir);
|
|
898
|
+
const client = new ApiClient({ apiUrl, token });
|
|
899
|
+
await deploy(client, projectId, absDir, {
|
|
900
|
+
description: opts.description,
|
|
901
|
+
wait: opts.wait !== false,
|
|
902
|
+
dryRun: opts.dryRun === true
|
|
903
|
+
});
|
|
904
|
+
} catch (err) {
|
|
905
|
+
error(err instanceof Error ? err.message : "Deployment failed");
|
|
906
|
+
process.exitCode = 1;
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
async function deploy(client, projectId, directory, options) {
|
|
911
|
+
const spin = spinner("Preparing deployment...");
|
|
912
|
+
const fileCount = countFiles(directory);
|
|
913
|
+
verbose(`Found ${fileCount} files in ${directory}`);
|
|
914
|
+
spin.text = "Creating archive...";
|
|
915
|
+
const zipBuffer = await createZipBuffer(directory);
|
|
916
|
+
verbose(`Archive size: ${formatBytes(zipBuffer.length)}`);
|
|
917
|
+
if (options.dryRun) {
|
|
918
|
+
spin.succeed("Dry run complete");
|
|
919
|
+
if (isJsonMode()) {
|
|
920
|
+
json({ dryRun: true, fileCount, archiveSize: zipBuffer.length, directory });
|
|
921
|
+
} else {
|
|
922
|
+
info(`Directory: ${directory}`);
|
|
923
|
+
info(`Files: ${fileCount}`);
|
|
924
|
+
info(`Archive size: ${formatBytes(zipBuffer.length)}`);
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
spin.text = "Creating deployment...";
|
|
929
|
+
const deployment = await client.post(
|
|
930
|
+
"/hosting/deployments",
|
|
931
|
+
{
|
|
932
|
+
projectId,
|
|
933
|
+
description: options.description
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
verbose(`Deployment ID: ${deployment.deploymentId}`);
|
|
937
|
+
spin.text = `Uploading ${formatBytes(zipBuffer.length)}...`;
|
|
938
|
+
await client.uploadFile(
|
|
939
|
+
`/hosting/deployments/${deployment.deploymentId}/upload`,
|
|
940
|
+
zipBuffer,
|
|
941
|
+
"deployment.zip"
|
|
942
|
+
);
|
|
943
|
+
if (!options.wait) {
|
|
944
|
+
spin.succeed("Upload complete");
|
|
945
|
+
info(`Deployment ${deployment.deploymentId} is processing`);
|
|
946
|
+
if (isJsonMode()) {
|
|
947
|
+
json({ deploymentId: deployment.deploymentId, status: "processing" });
|
|
948
|
+
}
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
spin.text = "Processing deployment...";
|
|
952
|
+
const result = await pollDeploymentStatus(
|
|
953
|
+
client,
|
|
954
|
+
deployment.deploymentId,
|
|
955
|
+
spin
|
|
956
|
+
);
|
|
957
|
+
if (result.status === "active") {
|
|
958
|
+
spin.succeed("Deployment active");
|
|
959
|
+
if (isJsonMode()) {
|
|
960
|
+
json(result);
|
|
961
|
+
} else {
|
|
962
|
+
info(`Deployment ID: ${result.deploymentId}`);
|
|
963
|
+
info(`Files: ${result.fileCount}`);
|
|
964
|
+
info(`Size: ${formatBytes(result.totalSizeBytes)}`);
|
|
965
|
+
}
|
|
966
|
+
} else if (result.status === "failed") {
|
|
967
|
+
spin.fail("Deployment failed");
|
|
968
|
+
error(result.errorMessage ?? "Unknown error");
|
|
969
|
+
process.exitCode = 1;
|
|
970
|
+
} else {
|
|
971
|
+
spin.warn(`Deployment status: ${result.status}`);
|
|
972
|
+
if (isJsonMode()) {
|
|
973
|
+
json(result);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async function pollDeploymentStatus(client, deploymentId, spin) {
|
|
978
|
+
const maxAttempts = 120;
|
|
979
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
980
|
+
await sleep(5e3);
|
|
981
|
+
const status = await client.get(
|
|
982
|
+
`/hosting/deployments/${deploymentId}/status`
|
|
983
|
+
);
|
|
984
|
+
verbose(`Poll ${i + 1}: status=${status.status}`);
|
|
985
|
+
spin.text = `Processing deployment... (${status.status})`;
|
|
986
|
+
if (status.status === "active" || status.status === "failed") {
|
|
987
|
+
return status;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
throw new Error("Deployment timed out after 10 minutes");
|
|
991
|
+
}
|
|
992
|
+
function sleep(ms) {
|
|
993
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// libs/cli/src/commands/db/index.ts
|
|
997
|
+
var import_commander12 = require("commander");
|
|
998
|
+
|
|
999
|
+
// libs/cli/src/commands/db/indexes-deploy.ts
|
|
1000
|
+
var import_commander8 = require("commander");
|
|
1001
|
+
function createIndexesDeployCommand() {
|
|
1002
|
+
return new import_commander8.Command("deploy").description("Deploy indexes from config to remote").option("--project <id>", "Project ID").option("--config <path>", "Config file path").option("--yes", "Skip confirmation").action(async (opts, cmd) => {
|
|
1003
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
1004
|
+
const config = loadConfig(opts.config);
|
|
1005
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
1006
|
+
try {
|
|
1007
|
+
const token = requireAuth(globalOpts.token);
|
|
1008
|
+
const projectId = resolveProjectId(opts.project, config);
|
|
1009
|
+
const indexes = config?.database?.indexes;
|
|
1010
|
+
if (!indexes || Object.keys(indexes).length === 0) {
|
|
1011
|
+
warn("No indexes defined in config");
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const indexRules = convertIndexFormat(indexes);
|
|
1015
|
+
const collectionCount = Object.keys(indexRules).length;
|
|
1016
|
+
const totalIndexes = Object.values(indexRules).reduce(
|
|
1017
|
+
(sum, arr) => sum + arr.length,
|
|
1018
|
+
0
|
|
1019
|
+
);
|
|
1020
|
+
if (!opts.yes) {
|
|
1021
|
+
info(
|
|
1022
|
+
`Will deploy ${totalIndexes} index(es) across ${collectionCount} collection(s)`
|
|
1023
|
+
);
|
|
1024
|
+
const confirmed = await promptConfirm("Proceed with deployment?");
|
|
1025
|
+
if (!confirmed) {
|
|
1026
|
+
info("Aborted");
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const spin = spinner("Deploying indexes...");
|
|
1031
|
+
const client = new ApiClient({ apiUrl, token });
|
|
1032
|
+
const result = await client.put(
|
|
1033
|
+
`/databases/${projectId}/index-rules`,
|
|
1034
|
+
{ indexRules }
|
|
1035
|
+
);
|
|
1036
|
+
spin.succeed("Indexes deployed");
|
|
1037
|
+
if (isJsonMode()) {
|
|
1038
|
+
json(result);
|
|
1039
|
+
} else {
|
|
1040
|
+
success(result.message ?? "Indexes synced successfully");
|
|
1041
|
+
}
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
error(err instanceof Error ? err.message : "Failed to deploy indexes");
|
|
1044
|
+
process.exitCode = 1;
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
function convertIndexFormat(indexes) {
|
|
1049
|
+
const result = {};
|
|
1050
|
+
for (const [collection, entries] of Object.entries(indexes)) {
|
|
1051
|
+
result[collection] = entries.map((entry) => {
|
|
1052
|
+
const fields = {};
|
|
1053
|
+
for (const [field, direction] of Object.entries(entry.fields)) {
|
|
1054
|
+
fields[field] = direction === 1 ? "asc" : "desc";
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
fields,
|
|
1058
|
+
...entry.unique !== void 0 && { unique: entry.unique },
|
|
1059
|
+
...entry.sparse !== void 0 && { sparse: entry.sparse },
|
|
1060
|
+
...entry.ttlSeconds !== void 0 && { ttlSeconds: entry.ttlSeconds }
|
|
1061
|
+
};
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
return result;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// libs/cli/src/commands/db/indexes-pull.ts
|
|
1068
|
+
var import_commander9 = require("commander");
|
|
1069
|
+
function createIndexesPullCommand() {
|
|
1070
|
+
return new import_commander9.Command("pull").description("Pull current indexes from remote to config").option("--project <id>", "Project ID").option("--config <path>", "Config file path").action(async (opts, cmd) => {
|
|
1071
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
1072
|
+
const config = loadConfig(opts.config);
|
|
1073
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
1074
|
+
try {
|
|
1075
|
+
const token = requireAuth(globalOpts.token);
|
|
1076
|
+
const projectId = resolveProjectId(opts.project, config);
|
|
1077
|
+
const spin = spinner("Pulling indexes...");
|
|
1078
|
+
const client = new ApiClient({ apiUrl, token });
|
|
1079
|
+
const result = await client.get(`/databases/${projectId}/index-rules`);
|
|
1080
|
+
const indexes = convertFromRemoteFormat(result.indexRules);
|
|
1081
|
+
const updatedConfig = {
|
|
1082
|
+
...config,
|
|
1083
|
+
projectId: config?.projectId ?? projectId,
|
|
1084
|
+
database: {
|
|
1085
|
+
...config?.database,
|
|
1086
|
+
indexes
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
saveConfig(updatedConfig, opts.config);
|
|
1090
|
+
spin.succeed("Indexes pulled");
|
|
1091
|
+
const collectionCount = Object.keys(indexes).length;
|
|
1092
|
+
const totalIndexes = Object.values(indexes).reduce(
|
|
1093
|
+
(sum, arr) => sum + arr.length,
|
|
1094
|
+
0
|
|
1095
|
+
);
|
|
1096
|
+
if (isJsonMode()) {
|
|
1097
|
+
json(indexes);
|
|
1098
|
+
} else {
|
|
1099
|
+
success(
|
|
1100
|
+
`Pulled ${totalIndexes} index(es) from ${collectionCount} collection(s)`
|
|
1101
|
+
);
|
|
1102
|
+
info("Config file updated");
|
|
1103
|
+
}
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
error(err instanceof Error ? err.message : "Failed to pull indexes");
|
|
1106
|
+
process.exitCode = 1;
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
function convertFromRemoteFormat(remoteIndexes) {
|
|
1111
|
+
const result = {};
|
|
1112
|
+
for (const [collection, entries] of Object.entries(remoteIndexes)) {
|
|
1113
|
+
result[collection] = entries.map((entry) => {
|
|
1114
|
+
const fields = {};
|
|
1115
|
+
for (const [field, direction] of Object.entries(entry.fields)) {
|
|
1116
|
+
fields[field] = direction === "asc" ? 1 : -1;
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
fields,
|
|
1120
|
+
...entry.unique !== void 0 && { unique: entry.unique },
|
|
1121
|
+
...entry.sparse !== void 0 && { sparse: entry.sparse },
|
|
1122
|
+
...entry.ttlSeconds !== void 0 && {
|
|
1123
|
+
ttlSeconds: entry.ttlSeconds
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return result;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// libs/cli/src/commands/db/rules-deploy.ts
|
|
1132
|
+
var import_commander10 = require("commander");
|
|
1133
|
+
function createRulesDeployCommand() {
|
|
1134
|
+
return new import_commander10.Command("deploy").description("Deploy rules from config to remote").option("--project <id>", "Project ID").option("--config <path>", "Config file path").option("--yes", "Skip confirmation").action(async (opts, cmd) => {
|
|
1135
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
1136
|
+
const config = loadConfig(opts.config);
|
|
1137
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
1138
|
+
try {
|
|
1139
|
+
const token = requireAuth(globalOpts.token);
|
|
1140
|
+
const projectId = resolveProjectId(opts.project, config);
|
|
1141
|
+
const rules = config?.database?.rules;
|
|
1142
|
+
if (!rules || Object.keys(rules).length === 0) {
|
|
1143
|
+
warn("No rules defined in config");
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const ruleCount = Object.keys(rules).length;
|
|
1147
|
+
if (!opts.yes) {
|
|
1148
|
+
info(`Will deploy rules for ${ruleCount} collection(s)`);
|
|
1149
|
+
const confirmed = await promptConfirm("Proceed with deployment?");
|
|
1150
|
+
if (!confirmed) {
|
|
1151
|
+
info("Aborted");
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const spin = spinner("Validating rules...");
|
|
1156
|
+
const client = new ApiClient({ apiUrl, token });
|
|
1157
|
+
await client.post(
|
|
1158
|
+
`/databases/${projectId}/rules/validate`,
|
|
1159
|
+
{ rules }
|
|
1160
|
+
);
|
|
1161
|
+
spin.text = "Deploying rules...";
|
|
1162
|
+
const result = await client.put(
|
|
1163
|
+
`/databases/${projectId}/rules`,
|
|
1164
|
+
{ rules }
|
|
1165
|
+
);
|
|
1166
|
+
spin.succeed("Rules deployed");
|
|
1167
|
+
if (isJsonMode()) {
|
|
1168
|
+
json(result);
|
|
1169
|
+
} else {
|
|
1170
|
+
success(result.message ?? "Rules deployed successfully");
|
|
1171
|
+
}
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
error(err instanceof Error ? err.message : "Failed to deploy rules");
|
|
1174
|
+
process.exitCode = 1;
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// libs/cli/src/commands/db/rules-pull.ts
|
|
1180
|
+
var import_commander11 = require("commander");
|
|
1181
|
+
function createRulesPullCommand() {
|
|
1182
|
+
return new import_commander11.Command("pull").description("Pull current rules from remote to config").option("--project <id>", "Project ID").option("--config <path>", "Config file path").action(async (opts, cmd) => {
|
|
1183
|
+
const globalOpts = cmd.optsWithGlobals();
|
|
1184
|
+
const config = loadConfig(opts.config);
|
|
1185
|
+
const apiUrl = resolveApiUrl(globalOpts.apiUrl, config);
|
|
1186
|
+
try {
|
|
1187
|
+
const token = requireAuth(globalOpts.token);
|
|
1188
|
+
const projectId = resolveProjectId(opts.project, config);
|
|
1189
|
+
const spin = spinner("Pulling rules...");
|
|
1190
|
+
const client = new ApiClient({ apiUrl, token });
|
|
1191
|
+
const result = await client.get(
|
|
1192
|
+
`/databases/${projectId}/rules`
|
|
1193
|
+
);
|
|
1194
|
+
const rules = result.rules;
|
|
1195
|
+
const updatedConfig = {
|
|
1196
|
+
...config,
|
|
1197
|
+
projectId: config?.projectId ?? projectId,
|
|
1198
|
+
database: {
|
|
1199
|
+
...config?.database,
|
|
1200
|
+
rules
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
saveConfig(updatedConfig, opts.config);
|
|
1204
|
+
spin.succeed("Rules pulled");
|
|
1205
|
+
const ruleCount = Object.keys(rules).length;
|
|
1206
|
+
if (isJsonMode()) {
|
|
1207
|
+
json(rules);
|
|
1208
|
+
} else {
|
|
1209
|
+
success(`Pulled rules for ${ruleCount} collection(s)`);
|
|
1210
|
+
info("Config file updated");
|
|
1211
|
+
}
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
error(err instanceof Error ? err.message : "Failed to pull rules");
|
|
1214
|
+
process.exitCode = 1;
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// libs/cli/src/commands/db/index.ts
|
|
1220
|
+
function createDbCommand() {
|
|
1221
|
+
const indexes = new import_commander12.Command("indexes").description("Manage database indexes");
|
|
1222
|
+
indexes.addCommand(createIndexesDeployCommand());
|
|
1223
|
+
indexes.addCommand(createIndexesPullCommand());
|
|
1224
|
+
const rules = new import_commander12.Command("rules").description("Manage database rules");
|
|
1225
|
+
rules.addCommand(createRulesDeployCommand());
|
|
1226
|
+
rules.addCommand(createRulesPullCommand());
|
|
1227
|
+
const db = new import_commander12.Command("db").description("Database management commands");
|
|
1228
|
+
db.addCommand(indexes);
|
|
1229
|
+
db.addCommand(rules);
|
|
1230
|
+
return db;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// libs/cli/src/index.ts
|
|
1234
|
+
var program = new import_commander13.Command();
|
|
1235
|
+
program.name("spacelr").description("CLI tool for the Spacelr platform").version("0.1.0").option("--api-url <url>", "Override API URL").option("--token <token>", "Explicit auth token (CI mode)").option("--json", "Output as JSON").option("--verbose", "Verbose logging").hook("preAction", (thisCommand) => {
|
|
1236
|
+
const opts = thisCommand.optsWithGlobals();
|
|
1237
|
+
setOutputMode({
|
|
1238
|
+
json: opts["json"],
|
|
1239
|
+
verbose: opts["verbose"]
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
program.addCommand(createLoginCommand());
|
|
1243
|
+
program.addCommand(createLogoutCommand());
|
|
1244
|
+
program.addCommand(createWhoamiCommand());
|
|
1245
|
+
program.addCommand(createProjectsCommand());
|
|
1246
|
+
program.addCommand(createUseCommand());
|
|
1247
|
+
program.addCommand(createInitCommand());
|
|
1248
|
+
program.addCommand(createDeployCommand());
|
|
1249
|
+
program.addCommand(createDbCommand());
|
|
1250
|
+
program.parse();
|
|
1251
|
+
//# sourceMappingURL=index.js.map
|