@dashai/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/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/bin.js +4343 -0
- package/dist/bin.js.map +1 -0
- package/package.json +61 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,4343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { spawn, spawnSync } from 'child_process';
|
|
4
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, watch, chmodSync, renameSync, mkdtempSync, statSync } from 'fs';
|
|
5
|
+
import { resolve, join, dirname, relative } from 'path';
|
|
6
|
+
import { tmpdir, platform, homedir } from 'os';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { intro, log, select, isCancel, cancel, spinner, outro, text, multiselect } from '@clack/prompts';
|
|
9
|
+
import open from 'open';
|
|
10
|
+
import * as tar from 'tar';
|
|
11
|
+
import { pathToFileURL } from 'url';
|
|
12
|
+
|
|
13
|
+
var FILE_NAME = "auth.json";
|
|
14
|
+
var DIR_NAME = "dashwise";
|
|
15
|
+
function configDir() {
|
|
16
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
17
|
+
return join(process.env.XDG_CONFIG_HOME, DIR_NAME);
|
|
18
|
+
}
|
|
19
|
+
if (platform() === "win32" && process.env.APPDATA) {
|
|
20
|
+
return join(process.env.APPDATA, DIR_NAME);
|
|
21
|
+
}
|
|
22
|
+
return join(homedir(), ".config", DIR_NAME);
|
|
23
|
+
}
|
|
24
|
+
function configPath() {
|
|
25
|
+
return join(configDir(), FILE_NAME);
|
|
26
|
+
}
|
|
27
|
+
function readConfig() {
|
|
28
|
+
const path = configPath();
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
const raw = readFileSync(path, "utf-8");
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(raw);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Failed to parse ${path}: ${err.message}. Run \`dashwise login\` to recreate it.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!isPlausibleConfig(parsed)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Config at ${path} is missing required fields. Run \`dashwise login\` to recreate it.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
function writeConfig(config) {
|
|
47
|
+
const dir = configDir();
|
|
48
|
+
if (!existsSync(dir)) {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
const path = configPath();
|
|
52
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
54
|
+
try {
|
|
55
|
+
chmodSync(tmp, 384);
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
renameSync(tmp, path);
|
|
59
|
+
}
|
|
60
|
+
function deleteConfig() {
|
|
61
|
+
const path = configPath();
|
|
62
|
+
if (!existsSync(path)) return;
|
|
63
|
+
rmSync(path);
|
|
64
|
+
}
|
|
65
|
+
function resolveApiUrl() {
|
|
66
|
+
const fromEnv = process.env.DASHWISE_API_URL?.trim();
|
|
67
|
+
if (fromEnv) return fromEnv.replace(/\/+$/, "");
|
|
68
|
+
const cfg = safeReadConfig();
|
|
69
|
+
if (cfg?.apiUrl) return cfg.apiUrl.replace(/\/+$/, "");
|
|
70
|
+
return "http://localhost:3000";
|
|
71
|
+
}
|
|
72
|
+
function readSavedToken() {
|
|
73
|
+
const cfg = safeReadConfig();
|
|
74
|
+
return cfg?.token ?? null;
|
|
75
|
+
}
|
|
76
|
+
function safeReadConfig() {
|
|
77
|
+
try {
|
|
78
|
+
return readConfig();
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isPlausibleConfig(v) {
|
|
84
|
+
if (!v || typeof v !== "object") return false;
|
|
85
|
+
const c2 = v;
|
|
86
|
+
if (typeof c2.apiUrl !== "string") return false;
|
|
87
|
+
if (typeof c2.token !== "string") return false;
|
|
88
|
+
if (typeof c2.tokenId !== "number") return false;
|
|
89
|
+
if (!c2.user || typeof c2.user !== "object") return false;
|
|
90
|
+
const u = c2.user;
|
|
91
|
+
if (typeof u.id !== "number") return false;
|
|
92
|
+
if (typeof u.email !== "string") return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
function success(msg) {
|
|
96
|
+
process.stderr.write(`${pc.green("\u2713")} ${msg}
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
function info(msg) {
|
|
100
|
+
process.stderr.write(`${pc.cyan("\u2139")} ${msg}
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
103
|
+
function warn(msg) {
|
|
104
|
+
process.stderr.write(`${pc.yellow("\u26A0")} ${msg}
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
function fail(msg) {
|
|
108
|
+
process.stderr.write(`${pc.red("\u2717")} ${msg}
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
function out(msg) {
|
|
112
|
+
process.stdout.write(`${msg}
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
115
|
+
function kv(key, value, padTo = 14) {
|
|
116
|
+
const padded = key.padEnd(padTo);
|
|
117
|
+
process.stdout.write(`${pc.bold(padded)} ${value}
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
function code(s) {
|
|
121
|
+
return pc.cyan(s);
|
|
122
|
+
}
|
|
123
|
+
function dim(s) {
|
|
124
|
+
return pc.dim(s);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/commands/dev.ts
|
|
128
|
+
var PREFIX_SDK = `\x1B[36m[sdk]\x1B[0m `;
|
|
129
|
+
var PREFIX_NEXT = `\x1B[35m[next]\x1B[0m `;
|
|
130
|
+
async function devCommand(options) {
|
|
131
|
+
if (options.sdkOnly && options.nextOnly) {
|
|
132
|
+
fail(
|
|
133
|
+
`Cannot pass both ${code("--sdk-only")} and ${code("--next-only")} \u2014 they're mutually exclusive.`
|
|
134
|
+
);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
138
|
+
const manifestPath2 = `${projectRoot}/module.json`;
|
|
139
|
+
if (!existsSync(manifestPath2)) {
|
|
140
|
+
fail(`No ${code("module.json")} found in ${code(projectRoot)}.`);
|
|
141
|
+
info(`Run ${code("dashwise module init <slug>")} to scaffold a new module first.`);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
const savedConfig = (() => {
|
|
145
|
+
try {
|
|
146
|
+
return readConfig();
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
const apiToken = process.env.DASHWISE_API_TOKEN ?? savedConfig?.token;
|
|
152
|
+
if (!apiToken) {
|
|
153
|
+
fail(`Not authenticated. Run ${code("dashwise login")} first.`);
|
|
154
|
+
info(
|
|
155
|
+
"The unified dev loop reads your CLI token from ~/.config/dashwise/auth.json and injects it into both child processes so the SDK can authenticate without you editing .env.local."
|
|
156
|
+
);
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
const apiUrl = options.apiUrl ?? process.env.DASHWISE_API_URL ?? savedConfig?.apiUrl ?? "http://localhost:3000";
|
|
160
|
+
const installationId = options.installationId ?? process.env.DASHWISE_INSTALLATION_ID ?? "local";
|
|
161
|
+
const childEnv = {
|
|
162
|
+
...process.env,
|
|
163
|
+
DASHWISE_API_URL: apiUrl,
|
|
164
|
+
DASHWISE_API_TOKEN: apiToken,
|
|
165
|
+
DASHWISE_INSTALLATION_ID: installationId
|
|
166
|
+
};
|
|
167
|
+
info(`Dev loop starting \u2014 Ctrl-C to stop.`);
|
|
168
|
+
info(` ${dim("api")} ${apiUrl}`);
|
|
169
|
+
info(` ${dim("installation")} ${installationId}`);
|
|
170
|
+
info(` ${dim("token")} ${maskedToken(apiToken)} ${dim(`(from ${apiToken === savedConfig?.token ? "~/.config/dashwise/auth.json" : "$DASHWISE_API_TOKEN"})`)}`);
|
|
171
|
+
info("");
|
|
172
|
+
const children = [];
|
|
173
|
+
if (!options.nextOnly) {
|
|
174
|
+
const sdkChild = spawnSdkWatcher(projectRoot, childEnv);
|
|
175
|
+
children.push({ name: "sdk", child: sdkChild, prefix: PREFIX_SDK });
|
|
176
|
+
}
|
|
177
|
+
if (!options.sdkOnly) {
|
|
178
|
+
const nextChild = spawnNextDev(projectRoot, childEnv, options.port);
|
|
179
|
+
children.push({ name: "next", child: nextChild, prefix: PREFIX_NEXT });
|
|
180
|
+
}
|
|
181
|
+
for (const { child, prefix } of children) {
|
|
182
|
+
pipeWithPrefix(child.stdout, process.stdout, prefix);
|
|
183
|
+
pipeWithPrefix(child.stderr, process.stderr, prefix);
|
|
184
|
+
}
|
|
185
|
+
let shuttingDown = false;
|
|
186
|
+
const forwardSignal = (sig) => {
|
|
187
|
+
if (shuttingDown) return;
|
|
188
|
+
shuttingDown = true;
|
|
189
|
+
info(`
|
|
190
|
+
Received ${sig}, stopping children\u2026`);
|
|
191
|
+
for (const { child } of children) {
|
|
192
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
193
|
+
child.kill(sig);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
198
|
+
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
199
|
+
const results = await Promise.all(
|
|
200
|
+
children.map(
|
|
201
|
+
({ name, child }) => new Promise(
|
|
202
|
+
(resolveExit) => {
|
|
203
|
+
child.on("exit", (exitCode2, signal) => {
|
|
204
|
+
const killed = signal !== null;
|
|
205
|
+
const finalCode = exitCode2 ?? (killed ? 0 : 1);
|
|
206
|
+
resolveExit({ name, code: finalCode, killed });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
const crashed = results.find((r) => !r.killed && r.code !== 0);
|
|
213
|
+
if (crashed && !shuttingDown) {
|
|
214
|
+
warn(`[${crashed.name}] exited with code ${crashed.code} \u2014 stopping others.`);
|
|
215
|
+
for (const { child } of children) {
|
|
216
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
217
|
+
child.kill("SIGTERM");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const exitCode = Math.max(0, ...results.map((r) => r.code));
|
|
222
|
+
if (exitCode === 0) {
|
|
223
|
+
success("Dev loop stopped cleanly.");
|
|
224
|
+
}
|
|
225
|
+
return exitCode;
|
|
226
|
+
}
|
|
227
|
+
function spawnSdkWatcher(projectRoot, env) {
|
|
228
|
+
const child = spawn(
|
|
229
|
+
process.execPath,
|
|
230
|
+
[process.argv[1], "module", "dev", "--dir", projectRoot],
|
|
231
|
+
{
|
|
232
|
+
cwd: projectRoot,
|
|
233
|
+
env,
|
|
234
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
return child;
|
|
238
|
+
}
|
|
239
|
+
function spawnNextDev(projectRoot, env, port) {
|
|
240
|
+
const portArgs = port !== void 0 ? ["--", "--port", String(port)] : [];
|
|
241
|
+
const hasDevNextScript = hasNpmScript(projectRoot, "dev:next");
|
|
242
|
+
const [cmd, args] = hasDevNextScript ? ["npm", ["run", "dev:next", ...portArgs]] : ["npx", ["next", "dev", ...port !== void 0 ? ["--port", String(port)] : []]];
|
|
243
|
+
const child = spawn(cmd, args, {
|
|
244
|
+
cwd: projectRoot,
|
|
245
|
+
env,
|
|
246
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
247
|
+
// Windows requires `shell: true` for `npm` / `npx` because they're
|
|
248
|
+
// `.cmd` shims, not real binaries.
|
|
249
|
+
shell: process.platform === "win32"
|
|
250
|
+
});
|
|
251
|
+
return child;
|
|
252
|
+
}
|
|
253
|
+
function hasNpmScript(projectRoot, scriptName) {
|
|
254
|
+
try {
|
|
255
|
+
const pkgPath = `${projectRoot}/package.json`;
|
|
256
|
+
if (!existsSync(pkgPath)) return false;
|
|
257
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
258
|
+
return typeof pkg.scripts?.[scriptName] === "string";
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function pipeWithPrefix(src, dst, prefix) {
|
|
264
|
+
if (!src) return;
|
|
265
|
+
let leftover = "";
|
|
266
|
+
src.on("data", (chunk) => {
|
|
267
|
+
const text3 = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
268
|
+
const combined = leftover + text3;
|
|
269
|
+
const lines = combined.split("\n");
|
|
270
|
+
leftover = lines.pop() ?? "";
|
|
271
|
+
for (const line of lines) {
|
|
272
|
+
dst.write(`${prefix}${line}
|
|
273
|
+
`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
src.on("end", () => {
|
|
277
|
+
if (leftover.length > 0) {
|
|
278
|
+
dst.write(`${prefix}${leftover}
|
|
279
|
+
`);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function maskedToken(token) {
|
|
284
|
+
if (token.length <= 12) return "****";
|
|
285
|
+
const prefix = token.slice(0, 4);
|
|
286
|
+
const tail = token.slice(-4);
|
|
287
|
+
return `${prefix}\u2026${tail}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/lib/api-client.ts
|
|
291
|
+
var ApiError = class extends Error {
|
|
292
|
+
status;
|
|
293
|
+
code;
|
|
294
|
+
context;
|
|
295
|
+
retriable;
|
|
296
|
+
constructor(status, envelope, fallbackMessage) {
|
|
297
|
+
const code2 = envelope?.code ?? codeFromStatus(status);
|
|
298
|
+
const message = envelope?.message ?? fallbackMessage;
|
|
299
|
+
super(message);
|
|
300
|
+
this.name = "ApiError";
|
|
301
|
+
this.status = status;
|
|
302
|
+
this.code = code2;
|
|
303
|
+
this.context = envelope?.context;
|
|
304
|
+
this.retriable = envelope?.retriable ?? status >= 500;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
async function apiRequest(method, path, body, options = {}) {
|
|
308
|
+
const baseUrl = (options.baseUrl ?? resolveApiUrl()).replace(/\/+$/, "");
|
|
309
|
+
const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
310
|
+
const headers = {
|
|
311
|
+
Accept: "application/json",
|
|
312
|
+
...options.headers ?? {}
|
|
313
|
+
};
|
|
314
|
+
if (body !== void 0) {
|
|
315
|
+
headers["Content-Type"] = "application/json";
|
|
316
|
+
}
|
|
317
|
+
const authMode = options.auth ?? "auto";
|
|
318
|
+
if (authMode === "auto") {
|
|
319
|
+
const saved = readSavedToken();
|
|
320
|
+
if (saved) headers["Authorization"] = `Bearer ${saved}`;
|
|
321
|
+
} else if (typeof authMode === "object" && authMode.token) {
|
|
322
|
+
headers["Authorization"] = `Bearer ${authMode.token}`;
|
|
323
|
+
}
|
|
324
|
+
let res;
|
|
325
|
+
try {
|
|
326
|
+
res = await fetch(url, {
|
|
327
|
+
method,
|
|
328
|
+
headers,
|
|
329
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
throw new ApiError(0, { code: "NETWORK_ERROR", message: err.message }, "Network error");
|
|
333
|
+
}
|
|
334
|
+
if (res.status === 204) {
|
|
335
|
+
return void 0;
|
|
336
|
+
}
|
|
337
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
338
|
+
let parsed = null;
|
|
339
|
+
if (contentType.includes("application/json")) {
|
|
340
|
+
try {
|
|
341
|
+
parsed = await res.json();
|
|
342
|
+
} catch {
|
|
343
|
+
parsed = null;
|
|
344
|
+
}
|
|
345
|
+
} else if (res.status >= 400) {
|
|
346
|
+
try {
|
|
347
|
+
parsed = { message: await res.text() };
|
|
348
|
+
} catch {
|
|
349
|
+
parsed = null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
throw new ApiError(res.status, asEnvelope(parsed), `HTTP ${res.status}`);
|
|
354
|
+
}
|
|
355
|
+
return parsed;
|
|
356
|
+
}
|
|
357
|
+
function asEnvelope(v) {
|
|
358
|
+
if (!v || typeof v !== "object") return null;
|
|
359
|
+
const env = v;
|
|
360
|
+
const out2 = {};
|
|
361
|
+
if (typeof env.code === "string") out2.code = env.code;
|
|
362
|
+
if (typeof env.message === "string") out2.message = env.message;
|
|
363
|
+
if (typeof env.retriable === "boolean") out2.retriable = env.retriable;
|
|
364
|
+
if (env.context && typeof env.context === "object") {
|
|
365
|
+
out2.context = env.context;
|
|
366
|
+
}
|
|
367
|
+
return out2;
|
|
368
|
+
}
|
|
369
|
+
function codeFromStatus(status) {
|
|
370
|
+
if (status === 401) return "UNAUTHENTICATED";
|
|
371
|
+
if (status === 403) return "FORBIDDEN";
|
|
372
|
+
if (status === 404) return "NOT_FOUND";
|
|
373
|
+
if (status === 0) return "NETWORK_ERROR";
|
|
374
|
+
if (status >= 500) return "INTERNAL_ERROR";
|
|
375
|
+
return "HTTP_ERROR";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/commands/login.ts
|
|
379
|
+
var DEFAULT_TOKEN_NAME = "dashwise-cli";
|
|
380
|
+
async function loginCommand(options) {
|
|
381
|
+
const apiUrl = (options.apiUrl ?? resolveApiUrl()).replace(/\/+$/, "");
|
|
382
|
+
intro(`Logging into ${code(apiUrl)}`);
|
|
383
|
+
let dc;
|
|
384
|
+
try {
|
|
385
|
+
dc = await apiRequest(
|
|
386
|
+
"POST",
|
|
387
|
+
"/api/auth/cli/device-code",
|
|
388
|
+
{
|
|
389
|
+
name: options.name ?? DEFAULT_TOKEN_NAME
|
|
390
|
+
},
|
|
391
|
+
{ auth: "public", baseUrl: apiUrl }
|
|
392
|
+
);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
fail(formatApiError("Could not start login flow", err));
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
log.message(
|
|
398
|
+
[
|
|
399
|
+
`Open the verification URL and confirm the code:`,
|
|
400
|
+
``,
|
|
401
|
+
` URL: ${code(dc.verificationUriComplete)}`,
|
|
402
|
+
` Code: ${code(dc.userCode)}`,
|
|
403
|
+
``,
|
|
404
|
+
dim(
|
|
405
|
+
`Code expires in ${formatSeconds(dc.expiresIn)}. The CLI will keep polling until you approve.`
|
|
406
|
+
)
|
|
407
|
+
].join("\n")
|
|
408
|
+
);
|
|
409
|
+
if (!options.noPrompt && process.stdout.isTTY) {
|
|
410
|
+
const choice = await select({
|
|
411
|
+
message: "Open the verification URL in your browser?",
|
|
412
|
+
options: [
|
|
413
|
+
{ value: "open", label: "Open now" },
|
|
414
|
+
{ value: "skip", label: "I\u2019ll open it manually" }
|
|
415
|
+
],
|
|
416
|
+
initialValue: "open"
|
|
417
|
+
});
|
|
418
|
+
if (isCancel(choice)) {
|
|
419
|
+
cancel("Login cancelled.");
|
|
420
|
+
return 130;
|
|
421
|
+
}
|
|
422
|
+
if (choice === "open") {
|
|
423
|
+
try {
|
|
424
|
+
await open(dc.verificationUriComplete);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
info(`Could not open browser automatically (${err.message}). Open the URL above manually.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const sp = spinner();
|
|
431
|
+
sp.start("Waiting for approval");
|
|
432
|
+
const overallDeadline = Date.now() + dc.expiresIn * 1e3;
|
|
433
|
+
let outcome = null;
|
|
434
|
+
const pollIntervalMs = Math.max(1e3, dc.interval * 1e3);
|
|
435
|
+
pollLoop: while (Date.now() < overallDeadline) {
|
|
436
|
+
let res;
|
|
437
|
+
try {
|
|
438
|
+
res = await apiRequest(
|
|
439
|
+
"POST",
|
|
440
|
+
"/api/auth/cli/poll",
|
|
441
|
+
{ deviceCode: dc.deviceCode },
|
|
442
|
+
{ auth: "public", baseUrl: apiUrl }
|
|
443
|
+
);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
sp.stop(`Poll failed: ${err.message}`);
|
|
446
|
+
fail("Poll failed mid-flight; you can re-run `dashwise login` to retry.");
|
|
447
|
+
return 1;
|
|
448
|
+
}
|
|
449
|
+
switch (res.status) {
|
|
450
|
+
case "pending":
|
|
451
|
+
case "slow_down":
|
|
452
|
+
await sleep(pollIntervalMs);
|
|
453
|
+
continue pollLoop;
|
|
454
|
+
case "denied":
|
|
455
|
+
sp.stop("Login denied by user.");
|
|
456
|
+
cancel("Aborted.");
|
|
457
|
+
return 1;
|
|
458
|
+
case "expired":
|
|
459
|
+
sp.stop("Login code expired.");
|
|
460
|
+
fail("The code expired before approval. Run `dashwise login` again.");
|
|
461
|
+
return 1;
|
|
462
|
+
case "approved":
|
|
463
|
+
outcome = res;
|
|
464
|
+
break pollLoop;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!outcome) {
|
|
468
|
+
sp.stop("Timed out waiting for approval.");
|
|
469
|
+
fail("Login took too long to be approved. Run `dashwise login` again.");
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
if (!outcome.token || !outcome.tokenId) {
|
|
473
|
+
sp.stop("Approval received but token missing from response.");
|
|
474
|
+
fail(
|
|
475
|
+
"Approval received but the backend did not return a token. Run `dashwise logout` then `dashwise login` to retry."
|
|
476
|
+
);
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
let me;
|
|
480
|
+
try {
|
|
481
|
+
me = await apiRequest("GET", "/api/auth/me", void 0, {
|
|
482
|
+
auth: { token: outcome.token },
|
|
483
|
+
baseUrl: apiUrl
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
sp.stop("Approval accepted but profile fetch failed.");
|
|
487
|
+
fail(formatApiError("Could not load user profile", err));
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
const cfg = {
|
|
491
|
+
apiUrl,
|
|
492
|
+
token: outcome.token,
|
|
493
|
+
tokenId: outcome.tokenId,
|
|
494
|
+
user: {
|
|
495
|
+
id: me.id,
|
|
496
|
+
email: me.email,
|
|
497
|
+
username: me.username,
|
|
498
|
+
firstName: me.first_name,
|
|
499
|
+
lastName: me.last_name
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
writeConfig(cfg);
|
|
503
|
+
sp.stop("Approved.");
|
|
504
|
+
success(`Logged in as ${code(me.email)}`);
|
|
505
|
+
outro("You can now run `dashwise profile`, `dashwise workspace list`, etc.");
|
|
506
|
+
return 0;
|
|
507
|
+
}
|
|
508
|
+
function sleep(ms) {
|
|
509
|
+
return new Promise((resolve11) => setTimeout(resolve11, ms));
|
|
510
|
+
}
|
|
511
|
+
function formatSeconds(seconds) {
|
|
512
|
+
if (seconds < 60) return `${seconds}s`;
|
|
513
|
+
const minutes = Math.round(seconds / 60);
|
|
514
|
+
return `${minutes}m`;
|
|
515
|
+
}
|
|
516
|
+
function formatApiError(prefix, err) {
|
|
517
|
+
if (err instanceof ApiError) {
|
|
518
|
+
return `${prefix}: ${err.message} (code: ${err.code}, status: ${err.status})`;
|
|
519
|
+
}
|
|
520
|
+
if (err instanceof Error) return `${prefix}: ${err.message}`;
|
|
521
|
+
return `${prefix}: ${String(err)}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/commands/logout.ts
|
|
525
|
+
async function logoutCommand() {
|
|
526
|
+
let cfg;
|
|
527
|
+
try {
|
|
528
|
+
cfg = readConfig();
|
|
529
|
+
} catch {
|
|
530
|
+
deleteConfig();
|
|
531
|
+
success("Cleared a corrupted local credential file.");
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
if (!cfg) {
|
|
535
|
+
info("Already logged out (no saved credentials).");
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
await apiRequest(
|
|
540
|
+
"DELETE",
|
|
541
|
+
`/api/auth/cli-tokens/${cfg.tokenId}`,
|
|
542
|
+
void 0,
|
|
543
|
+
{ auth: { token: cfg.token }, baseUrl: cfg.apiUrl }
|
|
544
|
+
);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
warn(
|
|
547
|
+
`Could not revoke the server-side token (${err.message}). Local credentials cleared anyway.`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
deleteConfig();
|
|
551
|
+
success(`Logged out ${code(cfg.user.email)}`);
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/commands/profile.ts
|
|
556
|
+
async function profileCommand(options = {}) {
|
|
557
|
+
const cfg = readConfig();
|
|
558
|
+
if (!cfg) {
|
|
559
|
+
if (options.json) {
|
|
560
|
+
out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: "Run `dashwise login` first." } }));
|
|
561
|
+
} else {
|
|
562
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
563
|
+
}
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
let me = null;
|
|
567
|
+
let refreshError = null;
|
|
568
|
+
try {
|
|
569
|
+
me = await apiRequest("GET", "/api/auth/me");
|
|
570
|
+
} catch (err) {
|
|
571
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
572
|
+
if (options.json) {
|
|
573
|
+
out(JSON.stringify({ ok: false, error: { code: "TOKEN_INVALID", message: "Saved token is no longer valid." } }));
|
|
574
|
+
} else {
|
|
575
|
+
fail("Saved token is no longer valid. Run `dashwise login` again.");
|
|
576
|
+
}
|
|
577
|
+
return 1;
|
|
578
|
+
}
|
|
579
|
+
refreshError = err.message;
|
|
580
|
+
if (!options.json) {
|
|
581
|
+
warn(
|
|
582
|
+
`Could not refresh profile from the server (${refreshError}). Showing cached values.`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (me) {
|
|
587
|
+
const refreshed = {
|
|
588
|
+
...cfg,
|
|
589
|
+
user: {
|
|
590
|
+
id: me.id,
|
|
591
|
+
email: me.email,
|
|
592
|
+
username: me.username,
|
|
593
|
+
firstName: me.first_name,
|
|
594
|
+
lastName: me.last_name
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
writeConfig(refreshed);
|
|
598
|
+
}
|
|
599
|
+
const u = me ?? toMe(cfg.user);
|
|
600
|
+
let workspaces = [];
|
|
601
|
+
let workspaceError = null;
|
|
602
|
+
try {
|
|
603
|
+
const fetched = await apiRequest(
|
|
604
|
+
"GET",
|
|
605
|
+
"/api/workspaces"
|
|
606
|
+
);
|
|
607
|
+
workspaces = Array.isArray(fetched) ? fetched : [];
|
|
608
|
+
} catch (err) {
|
|
609
|
+
workspaceError = err.message;
|
|
610
|
+
}
|
|
611
|
+
if (options.json) {
|
|
612
|
+
out(
|
|
613
|
+
JSON.stringify({
|
|
614
|
+
ok: true,
|
|
615
|
+
user: {
|
|
616
|
+
id: u.id,
|
|
617
|
+
username: u.username,
|
|
618
|
+
email: u.email,
|
|
619
|
+
first_name: u.first_name,
|
|
620
|
+
last_name: u.last_name
|
|
621
|
+
},
|
|
622
|
+
default_workspace: cfg.workspaceSlug ?? null,
|
|
623
|
+
workspaces,
|
|
624
|
+
api_url: cfg.apiUrl,
|
|
625
|
+
...refreshError ? { profile_refresh_error: refreshError } : {},
|
|
626
|
+
...workspaceError ? { workspaces_error: workspaceError } : {}
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
out("");
|
|
632
|
+
kv("User ID", `${u.id}`);
|
|
633
|
+
kv("Username", u.username);
|
|
634
|
+
kv("Email", u.email);
|
|
635
|
+
kv("Name", `${u.first_name} ${u.last_name}`.trim() || dim("(not set)"));
|
|
636
|
+
out("");
|
|
637
|
+
if (workspaceError) {
|
|
638
|
+
warn(
|
|
639
|
+
`Could not list workspaces (${workspaceError}). Try \`dashwise workspace list\`.`
|
|
640
|
+
);
|
|
641
|
+
} else if (workspaces.length === 0) {
|
|
642
|
+
info("No workspaces yet.");
|
|
643
|
+
} else {
|
|
644
|
+
kv("Workspaces", `${workspaces.length}`);
|
|
645
|
+
for (const w of workspaces) {
|
|
646
|
+
const slug = w.slug ?? `id:${w.id}`;
|
|
647
|
+
const role = w.role ?? w.permissions ?? "";
|
|
648
|
+
const star = cfg.workspaceSlug === slug ? code(" (default)") : "";
|
|
649
|
+
out(` - ${slug}${role ? dim(` [${role}]`) : ""}${star} ${dim(w.name)}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
out("");
|
|
653
|
+
kv("API URL", cfg.apiUrl);
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
function toMe(cached) {
|
|
657
|
+
return {
|
|
658
|
+
id: cached.id,
|
|
659
|
+
email: cached.email,
|
|
660
|
+
username: cached.username,
|
|
661
|
+
first_name: cached.firstName,
|
|
662
|
+
last_name: cached.lastName,
|
|
663
|
+
is_staff: false,
|
|
664
|
+
is_active: true,
|
|
665
|
+
date_joined: ""
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/commands/workspace.ts
|
|
670
|
+
async function workspaceListCommand(options = {}) {
|
|
671
|
+
const cfg = readConfig();
|
|
672
|
+
if (!cfg) {
|
|
673
|
+
if (options.json) {
|
|
674
|
+
out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: "Run `dashwise login` first." } }));
|
|
675
|
+
} else {
|
|
676
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
677
|
+
}
|
|
678
|
+
return 1;
|
|
679
|
+
}
|
|
680
|
+
let workspaces;
|
|
681
|
+
try {
|
|
682
|
+
workspaces = await apiRequest("GET", "/api/workspaces");
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
685
|
+
if (options.json) {
|
|
686
|
+
out(JSON.stringify({ ok: false, error: { code: "TOKEN_INVALID", message: "Saved token is no longer valid." } }));
|
|
687
|
+
} else {
|
|
688
|
+
fail("Saved token is no longer valid. Run `dashwise login` again.");
|
|
689
|
+
}
|
|
690
|
+
return 1;
|
|
691
|
+
}
|
|
692
|
+
if (options.json) {
|
|
693
|
+
out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message: err.message } }));
|
|
694
|
+
} else {
|
|
695
|
+
fail(`Could not list workspaces: ${err.message}`);
|
|
696
|
+
}
|
|
697
|
+
return 1;
|
|
698
|
+
}
|
|
699
|
+
if (options.json) {
|
|
700
|
+
out(
|
|
701
|
+
JSON.stringify({
|
|
702
|
+
ok: true,
|
|
703
|
+
default_workspace: cfg.workspaceSlug ?? null,
|
|
704
|
+
workspaces: Array.isArray(workspaces) ? workspaces : []
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
if (!Array.isArray(workspaces) || workspaces.length === 0) {
|
|
710
|
+
info("No workspaces yet.");
|
|
711
|
+
return 0;
|
|
712
|
+
}
|
|
713
|
+
out("");
|
|
714
|
+
for (const w of workspaces) {
|
|
715
|
+
const slug = w.slug ?? `id:${w.id}`;
|
|
716
|
+
const role = w.role ?? w.permissions ?? "";
|
|
717
|
+
const isDefault = cfg.workspaceSlug === slug;
|
|
718
|
+
const marker = isDefault ? code(" *") : " ";
|
|
719
|
+
out(`${marker} ${slug.padEnd(28)} ${dim(w.name)}${role ? dim(` [${role}]`) : ""}`);
|
|
720
|
+
}
|
|
721
|
+
out("");
|
|
722
|
+
if (cfg.workspaceSlug) {
|
|
723
|
+
info(`Default workspace: ${code(cfg.workspaceSlug)}`);
|
|
724
|
+
} else {
|
|
725
|
+
info("No default workspace set. Use `dashwise workspace use <slug>` to set one.");
|
|
726
|
+
}
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
async function workspaceUseCommand(slug) {
|
|
730
|
+
const cfg = readConfig();
|
|
731
|
+
if (!cfg) {
|
|
732
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
if (typeof slug !== "string" || slug.length === 0) {
|
|
736
|
+
fail("Usage: `dashwise workspace use <slug>`");
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
let workspaces = [];
|
|
740
|
+
try {
|
|
741
|
+
workspaces = await apiRequest("GET", "/api/workspaces");
|
|
742
|
+
} catch (err) {
|
|
743
|
+
fail(`Could not validate the workspace (${err.message}).`);
|
|
744
|
+
return 1;
|
|
745
|
+
}
|
|
746
|
+
const match = workspaces.find(
|
|
747
|
+
(w) => w.slug === slug || `id:${w.id}` === slug || String(w.id) === slug
|
|
748
|
+
);
|
|
749
|
+
if (!match) {
|
|
750
|
+
fail(`Workspace ${code(slug)} not found in your accessible workspaces.`);
|
|
751
|
+
out("");
|
|
752
|
+
info("Run `dashwise workspace list` to see what you can choose.");
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
const matchedSlug = match.slug ?? `id:${match.id}`;
|
|
756
|
+
writeConfig({ ...cfg, workspaceSlug: matchedSlug });
|
|
757
|
+
success(`Default workspace set to ${code(matchedSlug)}`);
|
|
758
|
+
kv("Name", match.name);
|
|
759
|
+
if (match.role ?? match.permissions) {
|
|
760
|
+
kv("Role", String(match.role ?? match.permissions));
|
|
761
|
+
}
|
|
762
|
+
return 0;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/lib/scaffold.ts
|
|
766
|
+
function manifestJson(opts) {
|
|
767
|
+
const kind = opts.kind ?? "hand_authored";
|
|
768
|
+
const manifest = {
|
|
769
|
+
schema_version: 2,
|
|
770
|
+
module: {
|
|
771
|
+
slug: opts.slug,
|
|
772
|
+
name: opts.name,
|
|
773
|
+
version: "0.0.1"
|
|
774
|
+
},
|
|
775
|
+
module_kind: kind,
|
|
776
|
+
owns: {
|
|
777
|
+
tables: [
|
|
778
|
+
{
|
|
779
|
+
slug: "items",
|
|
780
|
+
fields: [{ slug: "title", type: "text", required: true }]
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
if (kind === "custom") {
|
|
786
|
+
manifest.runtime = {
|
|
787
|
+
type: "nextjs",
|
|
788
|
+
kind: "machine",
|
|
789
|
+
machine_size: "shared-cpu-1x",
|
|
790
|
+
server_actions: true
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
if (opts.repositoryUrl) {
|
|
794
|
+
manifest.repository = { type: "git", url: opts.repositoryUrl };
|
|
795
|
+
}
|
|
796
|
+
return JSON.stringify(manifest, null, 2) + "\n";
|
|
797
|
+
}
|
|
798
|
+
function packageJson(opts) {
|
|
799
|
+
return JSON.stringify(
|
|
800
|
+
{
|
|
801
|
+
name: opts.slug,
|
|
802
|
+
version: "0.0.1",
|
|
803
|
+
private: true,
|
|
804
|
+
type: "module",
|
|
805
|
+
scripts: {
|
|
806
|
+
dev: "dashwise module dev",
|
|
807
|
+
"dev:next": "next dev --port 3000",
|
|
808
|
+
"dashwise:generate": "dashwise module generate",
|
|
809
|
+
build: "next build",
|
|
810
|
+
start: "next start"
|
|
811
|
+
},
|
|
812
|
+
dependencies: {
|
|
813
|
+
"@dashai/sdk": "^0.2.0",
|
|
814
|
+
next: "^15.0.0",
|
|
815
|
+
react: "^19.0.0",
|
|
816
|
+
"react-dom": "^19.0.0"
|
|
817
|
+
},
|
|
818
|
+
devDependencies: {
|
|
819
|
+
"@types/node": "^22.0.0",
|
|
820
|
+
"@types/react": "^19.0.0",
|
|
821
|
+
"@types/react-dom": "^19.0.0",
|
|
822
|
+
typescript: "^5.5.0"
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
null,
|
|
826
|
+
2
|
|
827
|
+
) + "\n";
|
|
828
|
+
}
|
|
829
|
+
function tsconfigJson() {
|
|
830
|
+
return JSON.stringify(
|
|
831
|
+
{
|
|
832
|
+
compilerOptions: {
|
|
833
|
+
target: "ES2022",
|
|
834
|
+
lib: ["dom", "dom.iterable", "ES2022"],
|
|
835
|
+
module: "ESNext",
|
|
836
|
+
moduleResolution: "Bundler",
|
|
837
|
+
jsx: "preserve",
|
|
838
|
+
strict: true,
|
|
839
|
+
skipLibCheck: true,
|
|
840
|
+
resolveJsonModule: true,
|
|
841
|
+
esModuleInterop: true,
|
|
842
|
+
isolatedModules: true,
|
|
843
|
+
allowJs: false,
|
|
844
|
+
noEmit: true,
|
|
845
|
+
incremental: true,
|
|
846
|
+
plugins: [{ name: "next" }],
|
|
847
|
+
paths: { "@/*": ["./*"] }
|
|
848
|
+
},
|
|
849
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
850
|
+
exclude: ["node_modules"]
|
|
851
|
+
},
|
|
852
|
+
null,
|
|
853
|
+
2
|
|
854
|
+
) + "\n";
|
|
855
|
+
}
|
|
856
|
+
function nextConfig() {
|
|
857
|
+
return `/** @type {import("next").NextConfig} */
|
|
858
|
+
const nextConfig = {
|
|
859
|
+
typescript: { ignoreBuildErrors: true },
|
|
860
|
+
eslint: { ignoreDuringBuilds: true },
|
|
861
|
+
};
|
|
862
|
+
export default nextConfig;
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
function nextEnvDts() {
|
|
866
|
+
return `/// <reference types="next" />
|
|
867
|
+
/// <reference types="next/image-types/global" />
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
function gitignore() {
|
|
871
|
+
return [
|
|
872
|
+
"# Dependencies",
|
|
873
|
+
"/node_modules",
|
|
874
|
+
"",
|
|
875
|
+
"# Generated by Next.js",
|
|
876
|
+
"/.next",
|
|
877
|
+
"/out",
|
|
878
|
+
"",
|
|
879
|
+
"# Production",
|
|
880
|
+
"/build",
|
|
881
|
+
"",
|
|
882
|
+
"# Misc",
|
|
883
|
+
".DS_Store",
|
|
884
|
+
"*.log",
|
|
885
|
+
"",
|
|
886
|
+
"# Env",
|
|
887
|
+
".env",
|
|
888
|
+
".env.local",
|
|
889
|
+
".env.*.local",
|
|
890
|
+
"",
|
|
891
|
+
"# Editor",
|
|
892
|
+
".vscode/",
|
|
893
|
+
".idea/",
|
|
894
|
+
"",
|
|
895
|
+
"# DashWise-generated SDK output is regenerable from module.json",
|
|
896
|
+
"# (run `dashwise module generate`); keep it out of source control.",
|
|
897
|
+
"/node_modules/@dashai/generated/",
|
|
898
|
+
""
|
|
899
|
+
].join("\n");
|
|
900
|
+
}
|
|
901
|
+
function envLocalExample() {
|
|
902
|
+
return [
|
|
903
|
+
"# Copy to .env.local + fill in real values.",
|
|
904
|
+
"# These vars are auto-injected by `dashwise dev` (the unified dev loop)",
|
|
905
|
+
"# from ~/.config/dashwise/auth.json \u2014 you only need to edit this file if",
|
|
906
|
+
"# you want to point at a non-default backend or run `next dev` standalone.",
|
|
907
|
+
"DASHWISE_API_URL=http://localhost:3000",
|
|
908
|
+
"DASHWISE_API_TOKEN=",
|
|
909
|
+
"DASHWISE_INSTALLATION_ID=local",
|
|
910
|
+
""
|
|
911
|
+
].join("\n");
|
|
912
|
+
}
|
|
913
|
+
function appLayoutTsx(opts) {
|
|
914
|
+
return `export const metadata = {
|
|
915
|
+
title: ${JSON.stringify(opts.name)},
|
|
916
|
+
description: ${JSON.stringify(`DashWise module: ${opts.slug}`)},
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
920
|
+
return (
|
|
921
|
+
<html lang="en">
|
|
922
|
+
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
|
|
923
|
+
</html>
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
`;
|
|
927
|
+
}
|
|
928
|
+
function appPageTsx(opts) {
|
|
929
|
+
return `export default function Page() {
|
|
930
|
+
return (
|
|
931
|
+
<main style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center', padding: 32 }}>
|
|
932
|
+
<div style={{ maxWidth: 560, textAlign: 'center' }}>
|
|
933
|
+
<h1 style={{ fontSize: 28, marginBottom: 8 }}>${escapeHtml(opts.name)}</h1>
|
|
934
|
+
<p style={{ color: '#475569' }}>
|
|
935
|
+
Module slug: <code>${escapeHtml(opts.slug)}</code>
|
|
936
|
+
</p>
|
|
937
|
+
<p style={{ marginTop: 24, color: '#64748b', fontSize: 14 }}>
|
|
938
|
+
Run <code>dashwise module dev</code> to start the watch loop.
|
|
939
|
+
<br />
|
|
940
|
+
Edit <code>module.json</code> to add tables; regenerated types
|
|
941
|
+
land in <code>@dashai/generated</code>.
|
|
942
|
+
</p>
|
|
943
|
+
</div>
|
|
944
|
+
</main>
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
`;
|
|
948
|
+
}
|
|
949
|
+
function readme(opts) {
|
|
950
|
+
const kind = opts.kind ?? "hand_authored";
|
|
951
|
+
if (kind === "custom") {
|
|
952
|
+
return readmeCustom(opts);
|
|
953
|
+
}
|
|
954
|
+
return readmeHandAuthored(opts);
|
|
955
|
+
}
|
|
956
|
+
function readmeHandAuthored(opts) {
|
|
957
|
+
return `# ${opts.name}
|
|
958
|
+
|
|
959
|
+
A DashWise module (hand-authored). Slug: \`${opts.slug}\`.
|
|
960
|
+
|
|
961
|
+
## Get started
|
|
962
|
+
|
|
963
|
+
\`\`\`bash
|
|
964
|
+
npm install
|
|
965
|
+
dashwise dev # one command: watcher + Next.js dev together,
|
|
966
|
+
# auth env vars auto-injected from your CLI config
|
|
967
|
+
\`\`\`
|
|
968
|
+
|
|
969
|
+
If you'd rather run the two halves in separate terminals (e.g. to attach a
|
|
970
|
+
debugger to Next), use the legacy two-terminal flow:
|
|
971
|
+
|
|
972
|
+
\`\`\`bash
|
|
973
|
+
dashwise module generate # produces node_modules/@dashai/generated/
|
|
974
|
+
dashwise module dev # watch module.json + auto-regenerate
|
|
975
|
+
npm run dev:next # plain Next.js dev server (in another terminal)
|
|
976
|
+
\`\`\`
|
|
977
|
+
|
|
978
|
+
## Project layout
|
|
979
|
+
|
|
980
|
+
| Path | What |
|
|
981
|
+
| --- | --- |
|
|
982
|
+
| \`module.json\` | Manifest. Declares tables, dependencies, runtime config. |
|
|
983
|
+
| \`app/\` | Next.js App Router. Edit freely. |
|
|
984
|
+
| \`node_modules/@dashai/generated/\` | Auto-generated typed wrappers. **Do not edit by hand** \u2014 regenerated by \`dashwise module generate\`. |
|
|
985
|
+
|
|
986
|
+
## Imports
|
|
987
|
+
|
|
988
|
+
\`\`\`ts
|
|
989
|
+
import { db } from '@dashai/generated';
|
|
990
|
+
import type { ItemsRow } from '@dashai/generated';
|
|
991
|
+
|
|
992
|
+
// runtime types + errors
|
|
993
|
+
import { createClient, type Where } from '@dashai/sdk';
|
|
994
|
+
\`\`\`
|
|
995
|
+
`;
|
|
996
|
+
}
|
|
997
|
+
function readmeCustom(opts) {
|
|
998
|
+
return `# ${opts.name}
|
|
999
|
+
|
|
1000
|
+
A **custom DashWise module** \u2014 a full Next.js App Router app that
|
|
1001
|
+
runs in a per-installation Fly Machine (P7 substrate). Authenticated
|
|
1002
|
+
via the DashWise NestJS backend through \`@dashai/sdk/auth\`.
|
|
1003
|
+
|
|
1004
|
+
Slug: \`${opts.slug}\`.
|
|
1005
|
+
|
|
1006
|
+
## Get started
|
|
1007
|
+
|
|
1008
|
+
\`\`\`bash
|
|
1009
|
+
npm install
|
|
1010
|
+
dashwise dev # one command: watcher + Next.js dev together,
|
|
1011
|
+
# auth env vars auto-injected from your CLI config
|
|
1012
|
+
\`\`\`
|
|
1013
|
+
|
|
1014
|
+
If you'd rather run the two halves in separate terminals (e.g. to attach a
|
|
1015
|
+
debugger to Next), use the legacy two-terminal flow:
|
|
1016
|
+
|
|
1017
|
+
\`\`\`bash
|
|
1018
|
+
dashwise module generate # produces node_modules/@dashai/generated/
|
|
1019
|
+
dashwise module dev # watch + regen on module.json save (terminal 1)
|
|
1020
|
+
npm run dev:next # next dev on :3000 (terminal 2)
|
|
1021
|
+
\`\`\`
|
|
1022
|
+
|
|
1023
|
+
## Architecture
|
|
1024
|
+
|
|
1025
|
+
- **\`module.json\`** \u2014 manifest (\`module_kind: 'custom'\`, \`runtime.kind: 'machine'\`).
|
|
1026
|
+
- **\`middleware.ts\`** \u2014 \`withAuth\` from \`@dashai/sdk/auth/middleware\`. Redirects unauthenticated requests through the DashWise SSO flow.
|
|
1027
|
+
- **\`app/layout.tsx\`** \u2014 wraps the tree in \`<AuthProvider>\` from \`@dashai/sdk/auth/client\`.
|
|
1028
|
+
- **\`app/page.tsx\`** \u2014 public landing (allowlisted in \`middleware.ts\`).
|
|
1029
|
+
- **\`app/dashboard/page.tsx\`** \u2014 Server Component requiring a valid session via \`getServerSession()\`.
|
|
1030
|
+
- **\`app/api/auth/callback/route.ts\`** \u2014 exchanges the SSO one-time code for a session cookie (uses \`exchangeCode()\` from the SDK).
|
|
1031
|
+
- **\`app/api/auth/sign-in/route.ts\`** \u2014 entry point that redirects to the backend's \`/sso/start\` with the right \`return_to\` + \`client_id\`.
|
|
1032
|
+
- **\`app/api/auth/sign-out/route.ts\`** \u2014 clears the session cookie + tells the backend to revoke.
|
|
1033
|
+
|
|
1034
|
+
## Imports
|
|
1035
|
+
|
|
1036
|
+
\`\`\`ts
|
|
1037
|
+
// Data:
|
|
1038
|
+
import { db } from '@dashai/generated';
|
|
1039
|
+
|
|
1040
|
+
// Auth (server, e.g. inside a Server Component or route handler):
|
|
1041
|
+
import { getServerSession, exchangeCode, signOut } from '@dashai/sdk/auth/server';
|
|
1042
|
+
|
|
1043
|
+
// Auth (client, inside a Client Component):
|
|
1044
|
+
'use client';
|
|
1045
|
+
import { useSession, SignOutButton, AuthProvider } from '@dashai/sdk/auth/client';
|
|
1046
|
+
|
|
1047
|
+
// Runtime types + errors:
|
|
1048
|
+
import { type Where, RowNotFoundError } from '@dashai/sdk';
|
|
1049
|
+
\`\`\`
|
|
1050
|
+
|
|
1051
|
+
## Publish
|
|
1052
|
+
|
|
1053
|
+
\`\`\`bash
|
|
1054
|
+
dashwise module validate # local lint
|
|
1055
|
+
dashwise module publish --bump patch
|
|
1056
|
+
\`\`\`
|
|
1057
|
+
|
|
1058
|
+
## Adding UI blocks
|
|
1059
|
+
|
|
1060
|
+
The scaffold pre-installs two shadcn/ui primitives (\`Button\`, \`Card\`) plus the
|
|
1061
|
+
canonical Tailwind 4 + theme-tokens setup. To add more components:
|
|
1062
|
+
|
|
1063
|
+
\`\`\`bash
|
|
1064
|
+
# shadcn/ui base (free):
|
|
1065
|
+
npx shadcn@latest add input badge dialog dropdown-menu
|
|
1066
|
+
|
|
1067
|
+
# shadcn-studio blocks (free at https://shadcnstudio.com):
|
|
1068
|
+
npx shadcn@latest add @shadcn-studio/hero-section-01
|
|
1069
|
+
|
|
1070
|
+
# shadcn-studio Pro blocks (requires $EMAIL + $LICENSE_KEY env vars):
|
|
1071
|
+
npx shadcn@latest add @ss-components/dashboard-shell-01
|
|
1072
|
+
\`\`\`
|
|
1073
|
+
|
|
1074
|
+
If you're using Claude Code or another IDE that supports the shadcn-studio MCP,
|
|
1075
|
+
trigger \`/cui\` (create new UI) or \`/rui\` (refine existing UI) to walk through
|
|
1076
|
+
the canonical block-discovery flow.
|
|
1077
|
+
|
|
1078
|
+
## Deploying elsewhere (custom domain)
|
|
1079
|
+
|
|
1080
|
+
The scaffolded SSO flow is wired for DashWise's own infrastructure
|
|
1081
|
+
(\`m-<id>.dashwise.net\`). If you want to host this module on your own
|
|
1082
|
+
domain (e.g. \`mymodule.example.com\`), there are two options:
|
|
1083
|
+
|
|
1084
|
+
1. **Wait for custom-domain support (in design).** A future \`--callback-origin\`
|
|
1085
|
+
flag on \`dashwise module init\` plus a \`module_oauth_clients\` allowlist will
|
|
1086
|
+
let modules run at arbitrary domains while still using DashWise SSO. Tracked
|
|
1087
|
+
in [\`modules-author-experience-v1.md\`](https://github.com/sharmaintuize/dashwise-devops-docs/blob/main/plans/modules-author-experience-v1.md) \xA73.
|
|
1088
|
+
2. **Bypass SSO + use a Workspace API Token today.** Replace the SSO middleware
|
|
1089
|
+
with a token-check middleware that reads \`Authorization: Token <key>\` from
|
|
1090
|
+
each request. Tokens are created at \`<dashwise-dashboard>/settings/tokens\`
|
|
1091
|
+
and scoped to a workspace. The SDK accepts these via the standard
|
|
1092
|
+
\`DASHWISE_API_TOKEN\` env var. You'll lose the per-user session model
|
|
1093
|
+
(every request runs as the token's owner) but gain the freedom to deploy
|
|
1094
|
+
anywhere.
|
|
1095
|
+
|
|
1096
|
+
The CLI's \`dashwise dev\` command auto-injects the developer's CLI token (also
|
|
1097
|
+
a bearer token) into local dev \u2014 so local development works regardless of
|
|
1098
|
+
which path you pick for production.
|
|
1099
|
+
`;
|
|
1100
|
+
}
|
|
1101
|
+
function middlewareTs() {
|
|
1102
|
+
return `// Next.js middleware \u2014 gates every route on the DashWise session cookie.
|
|
1103
|
+
// Public routes (and /api/auth/*) bypass the redirect; everything else
|
|
1104
|
+
// 302s through the SSO flow when no \`dashwise.session\` cookie is set.
|
|
1105
|
+
import { withAuth } from '@dashai/sdk/auth/middleware';
|
|
1106
|
+
|
|
1107
|
+
export default withAuth({
|
|
1108
|
+
// \`/sign-in-error\` is public so unauthenticated visitors can actually
|
|
1109
|
+
// see the polished SSO error page \u2014 otherwise a sign-in failure would
|
|
1110
|
+
// redirect-loop trying to re-authenticate.
|
|
1111
|
+
publicPaths: ['/', '/about', '/sign-in-error'],
|
|
1112
|
+
loginPath: '/api/auth/sign-in',
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
export const config = {
|
|
1116
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|public/).*)'],
|
|
1117
|
+
};
|
|
1118
|
+
`;
|
|
1119
|
+
}
|
|
1120
|
+
function appLayoutCustomTsx(opts) {
|
|
1121
|
+
return `import './globals.css';
|
|
1122
|
+
import { AuthProvider } from '@dashai/sdk/auth/client';
|
|
1123
|
+
import { ThemeProvider } from '@/components/theme-provider';
|
|
1124
|
+
|
|
1125
|
+
export const metadata = {
|
|
1126
|
+
title: ${JSON.stringify(opts.name)},
|
|
1127
|
+
description: ${JSON.stringify(`DashWise custom module: ${opts.slug}`)},
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1131
|
+
return (
|
|
1132
|
+
<html lang="en" suppressHydrationWarning>
|
|
1133
|
+
<body className="antialiased">
|
|
1134
|
+
<ThemeProvider
|
|
1135
|
+
attribute="class"
|
|
1136
|
+
defaultTheme="system"
|
|
1137
|
+
enableSystem
|
|
1138
|
+
disableTransitionOnChange
|
|
1139
|
+
>
|
|
1140
|
+
<AuthProvider>{children}</AuthProvider>
|
|
1141
|
+
</ThemeProvider>
|
|
1142
|
+
</body>
|
|
1143
|
+
</html>
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
`;
|
|
1147
|
+
}
|
|
1148
|
+
function appPageCustomTsx(opts) {
|
|
1149
|
+
return `// Public landing page \u2014 listed in middleware.ts publicPaths.
|
|
1150
|
+
//
|
|
1151
|
+
// First-run experience for unauthenticated visitors: hero with the
|
|
1152
|
+
// module name + tagline, a "Sign in with DashWise" button that kicks
|
|
1153
|
+
// off the SSO flow (via /api/auth/sign-in), and a feature card
|
|
1154
|
+
// describing what the module does. After signing in, the user lands
|
|
1155
|
+
// on /dashboard (Server Component, session-validated).
|
|
1156
|
+
//
|
|
1157
|
+
// Styling: shadcn/ui primitives (\`<Button>\`, \`<Card>\`) on Tailwind 4
|
|
1158
|
+
// theme tokens. Add more blocks via \`npx shadcn add @shadcn-studio/...\`
|
|
1159
|
+
// (see README's "Adding UI blocks" section).
|
|
1160
|
+
import Link from 'next/link';
|
|
1161
|
+
import { LogIn, LayoutDashboard } from 'lucide-react';
|
|
1162
|
+
import { Button } from '@/components/ui/button';
|
|
1163
|
+
import {
|
|
1164
|
+
Card,
|
|
1165
|
+
CardContent,
|
|
1166
|
+
CardDescription,
|
|
1167
|
+
CardHeader,
|
|
1168
|
+
CardTitle,
|
|
1169
|
+
} from '@/components/ui/card';
|
|
1170
|
+
|
|
1171
|
+
export default function Page() {
|
|
1172
|
+
return (
|
|
1173
|
+
<main className="flex min-h-dvh flex-col items-center justify-center gap-12 px-6 py-16">
|
|
1174
|
+
<section className="flex max-w-2xl flex-col items-center gap-6 text-center">
|
|
1175
|
+
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
|
|
1176
|
+
${escapeHtml(opts.name)}
|
|
1177
|
+
</h1>
|
|
1178
|
+
<p className="text-balance text-lg text-muted-foreground">
|
|
1179
|
+
A DashWise module. Sign in with your DashWise account to access the
|
|
1180
|
+
dashboard and start using the workspace's data through the typed SDK.
|
|
1181
|
+
</p>
|
|
1182
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
1183
|
+
<Button asChild size="lg">
|
|
1184
|
+
<Link href="/api/auth/sign-in?return_to=/dashboard">
|
|
1185
|
+
<LogIn aria-hidden="true" />
|
|
1186
|
+
Sign in with DashWise
|
|
1187
|
+
</Link>
|
|
1188
|
+
</Button>
|
|
1189
|
+
<Button asChild variant="ghost" size="lg">
|
|
1190
|
+
<Link href="/dashboard">
|
|
1191
|
+
<LayoutDashboard aria-hidden="true" />
|
|
1192
|
+
Go to dashboard
|
|
1193
|
+
</Link>
|
|
1194
|
+
</Button>
|
|
1195
|
+
</div>
|
|
1196
|
+
</section>
|
|
1197
|
+
|
|
1198
|
+
<Card className="w-full max-w-2xl">
|
|
1199
|
+
<CardHeader>
|
|
1200
|
+
<CardTitle>What you can do here</CardTitle>
|
|
1201
|
+
<CardDescription>
|
|
1202
|
+
This is a custom DashWise module \u2014 a full Next.js app authenticated
|
|
1203
|
+
against the DashWise SSO. Edit \`app/page.tsx\` to replace this
|
|
1204
|
+
landing page; everything else is yours to customize.
|
|
1205
|
+
</CardDescription>
|
|
1206
|
+
</CardHeader>
|
|
1207
|
+
<CardContent className="text-sm text-muted-foreground">
|
|
1208
|
+
<ul className="space-y-2">
|
|
1209
|
+
<li>
|
|
1210
|
+
Edit <code className="rounded bg-muted px-1.5 py-0.5">module.json</code>
|
|
1211
|
+
{' '}to declare tables, dependencies, and runtime config.
|
|
1212
|
+
</li>
|
|
1213
|
+
<li>
|
|
1214
|
+
Run <code className="rounded bg-muted px-1.5 py-0.5">dashwise dev</code>
|
|
1215
|
+
{' '}for a one-command local loop with auth env vars auto-injected.
|
|
1216
|
+
</li>
|
|
1217
|
+
<li>
|
|
1218
|
+
Import typed table clients from{' '}
|
|
1219
|
+
<code className="rounded bg-muted px-1.5 py-0.5">@dashai/generated</code>
|
|
1220
|
+
{' '}\u2014 the SDK regenerates on every manifest save.
|
|
1221
|
+
</li>
|
|
1222
|
+
</ul>
|
|
1223
|
+
</CardContent>
|
|
1224
|
+
</Card>
|
|
1225
|
+
</main>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
`;
|
|
1229
|
+
}
|
|
1230
|
+
function appDashboardPageTsx() {
|
|
1231
|
+
return `import { getServerSession } from '@dashai/sdk/auth/server';
|
|
1232
|
+
import { redirect } from 'next/navigation';
|
|
1233
|
+
import { LogOut, ShieldCheck } from 'lucide-react';
|
|
1234
|
+
import { Button } from '@/components/ui/button';
|
|
1235
|
+
import {
|
|
1236
|
+
Card,
|
|
1237
|
+
CardContent,
|
|
1238
|
+
CardDescription,
|
|
1239
|
+
CardHeader,
|
|
1240
|
+
CardTitle,
|
|
1241
|
+
} from '@/components/ui/card';
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Auth-gated Server Component. The middleware ensures the user has
|
|
1245
|
+
* a session cookie before this page renders; \`getServerSession()\`
|
|
1246
|
+
* validates the cookie against the backend's \`/api/auth/sessions/me\`
|
|
1247
|
+
* endpoint, returning the decoded \`Session\` (or \`null\` if stale).
|
|
1248
|
+
*
|
|
1249
|
+
* On a successful render, this is the polished session card \u2014 replace
|
|
1250
|
+
* the contents with whatever your module actually needs to show.
|
|
1251
|
+
*/
|
|
1252
|
+
export default async function Dashboard() {
|
|
1253
|
+
const session = await getServerSession();
|
|
1254
|
+
if (!session) {
|
|
1255
|
+
redirect('/api/auth/sign-in?return_to=/dashboard');
|
|
1256
|
+
}
|
|
1257
|
+
return (
|
|
1258
|
+
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 px-6 py-12">
|
|
1259
|
+
<header className="flex items-start justify-between gap-4">
|
|
1260
|
+
<div>
|
|
1261
|
+
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
|
1262
|
+
<p className="text-sm text-muted-foreground">
|
|
1263
|
+
You're signed in. This page is rendered as a Server Component and
|
|
1264
|
+
validates the session cookie on every request.
|
|
1265
|
+
</p>
|
|
1266
|
+
</div>
|
|
1267
|
+
<form action="/api/auth/sign-out" method="POST">
|
|
1268
|
+
<Button variant="outline" size="sm" type="submit">
|
|
1269
|
+
<LogOut aria-hidden="true" />
|
|
1270
|
+
Sign out
|
|
1271
|
+
</Button>
|
|
1272
|
+
</form>
|
|
1273
|
+
</header>
|
|
1274
|
+
|
|
1275
|
+
<Card>
|
|
1276
|
+
<CardHeader>
|
|
1277
|
+
<CardTitle className="flex items-center gap-2">
|
|
1278
|
+
<ShieldCheck className="size-5 text-primary" aria-hidden="true" />
|
|
1279
|
+
Session
|
|
1280
|
+
</CardTitle>
|
|
1281
|
+
<CardDescription>
|
|
1282
|
+
Decoded from the \`dashwise.session\` cookie via{' '}
|
|
1283
|
+
<code className="rounded bg-muted px-1.5 py-0.5">getServerSession()</code>.
|
|
1284
|
+
</CardDescription>
|
|
1285
|
+
</CardHeader>
|
|
1286
|
+
<CardContent className="space-y-3 text-sm">
|
|
1287
|
+
<Row label="Email" value={session.user.email} />
|
|
1288
|
+
<Row label="User ID" value={String(session.user.id)} />
|
|
1289
|
+
<Row label="Workspace" value={String(session.workspaceId)} />
|
|
1290
|
+
<Row label="Role" value={session.role} />
|
|
1291
|
+
{session.installationId !== undefined && (
|
|
1292
|
+
<Row
|
|
1293
|
+
label="Installation"
|
|
1294
|
+
value={
|
|
1295
|
+
session.moduleSlug
|
|
1296
|
+
? \`\${session.installationId} (\${session.moduleSlug})\`
|
|
1297
|
+
: String(session.installationId)
|
|
1298
|
+
}
|
|
1299
|
+
/>
|
|
1300
|
+
)}
|
|
1301
|
+
</CardContent>
|
|
1302
|
+
</Card>
|
|
1303
|
+
|
|
1304
|
+
<Card>
|
|
1305
|
+
<CardHeader>
|
|
1306
|
+
<CardTitle>Next steps</CardTitle>
|
|
1307
|
+
<CardDescription>
|
|
1308
|
+
Replace this dashboard with your module's actual UI.
|
|
1309
|
+
</CardDescription>
|
|
1310
|
+
</CardHeader>
|
|
1311
|
+
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
1312
|
+
<p>
|
|
1313
|
+
<strong className="text-foreground">1.</strong> Edit{' '}
|
|
1314
|
+
<code className="rounded bg-muted px-1.5 py-0.5">module.json</code>{' '}
|
|
1315
|
+
to add tables, dependencies, and exposed actions.
|
|
1316
|
+
</p>
|
|
1317
|
+
<p>
|
|
1318
|
+
<strong className="text-foreground">2.</strong> Import typed clients
|
|
1319
|
+
from{' '}
|
|
1320
|
+
<code className="rounded bg-muted px-1.5 py-0.5">@dashai/generated</code>{' '}
|
|
1321
|
+
(regenerated on every manifest save).
|
|
1322
|
+
</p>
|
|
1323
|
+
<p>
|
|
1324
|
+
<strong className="text-foreground">3.</strong> Run{' '}
|
|
1325
|
+
<code className="rounded bg-muted px-1.5 py-0.5">dashwise module publish --bump patch</code>{' '}
|
|
1326
|
+
when you're ready to ship.
|
|
1327
|
+
</p>
|
|
1328
|
+
</CardContent>
|
|
1329
|
+
</Card>
|
|
1330
|
+
</main>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
1335
|
+
return (
|
|
1336
|
+
<div className="flex items-baseline justify-between gap-4 border-b border-border/50 pb-2 last:border-0 last:pb-0">
|
|
1337
|
+
<span className="text-muted-foreground">{label}</span>
|
|
1338
|
+
<span className="font-mono text-sm">{value}</span>
|
|
1339
|
+
</div>
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
`;
|
|
1343
|
+
}
|
|
1344
|
+
function appApiAuthSignInTs() {
|
|
1345
|
+
return `// /api/auth/sign-in \u2014 kick off the SSO flow.
|
|
1346
|
+
//
|
|
1347
|
+
// Reads \`?return_to=\` from the caller (defaults to '/'), looks up
|
|
1348
|
+
// the module's installation id from env, redirects the browser to
|
|
1349
|
+
// the DashWise backend's \`/api/auth/sso/start\`. The backend mints
|
|
1350
|
+
// an exchange code + 302s back to \`/api/auth/callback?code=...\`
|
|
1351
|
+
// where we set the session cookie.
|
|
1352
|
+
import { NextResponse } from 'next/server';
|
|
1353
|
+
|
|
1354
|
+
export function GET(request: Request) {
|
|
1355
|
+
const url = new URL(request.url);
|
|
1356
|
+
const returnTo = url.searchParams.get('return_to') ?? '/';
|
|
1357
|
+
const apiBaseUrl = process.env.DASHWISE_API_URL;
|
|
1358
|
+
const installationId = process.env.DASHWISE_INSTALLATION_ID;
|
|
1359
|
+
if (!apiBaseUrl || !installationId) {
|
|
1360
|
+
return new NextResponse(
|
|
1361
|
+
'DASHWISE_API_URL and DASHWISE_INSTALLATION_ID must be set in .env.local',
|
|
1362
|
+
{ status: 500 },
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
// Absolute URL for return_to so the backend knows which host to
|
|
1366
|
+
// bounce back to (the callback path is fixed at /api/auth/callback
|
|
1367
|
+
// by SDK convention).
|
|
1368
|
+
const moduleOrigin = url.origin;
|
|
1369
|
+
const ssoStart = new URL('/api/auth/sso/start', apiBaseUrl);
|
|
1370
|
+
ssoStart.searchParams.set('return_to', \`\${moduleOrigin}\${returnTo}\`);
|
|
1371
|
+
ssoStart.searchParams.set('client_id', installationId);
|
|
1372
|
+
return NextResponse.redirect(ssoStart);
|
|
1373
|
+
}
|
|
1374
|
+
`;
|
|
1375
|
+
}
|
|
1376
|
+
function appApiAuthCallbackTs() {
|
|
1377
|
+
return `// /api/auth/callback \u2014 receives \`?code=&return_to=\` from the
|
|
1378
|
+
// DashWise SSO start endpoint OR \`?error=&error_description=\` if the
|
|
1379
|
+
// SSO flow failed. Two paths:
|
|
1380
|
+
//
|
|
1381
|
+
// 1. Success path: exchange \`?code\` for a session cookie via
|
|
1382
|
+
// \`exchangeCode()\`, then redirect to \`return_to\`.
|
|
1383
|
+
// 2. Error path: SSO returned \`?error=\` \u2014 redirect to the polished
|
|
1384
|
+
// sign-in-error page so the user sees a real error UI instead of
|
|
1385
|
+
// a raw 4xx page. The error code maps to known SSO error codes
|
|
1386
|
+
// (e.g. \`access_denied\`, \`server_error\`, \`invalid_request\`).
|
|
1387
|
+
import { NextResponse } from 'next/server';
|
|
1388
|
+
import { exchangeCode } from '@dashai/sdk/auth/server';
|
|
1389
|
+
|
|
1390
|
+
export async function GET(request: Request) {
|
|
1391
|
+
const url = new URL(request.url);
|
|
1392
|
+
const error = url.searchParams.get('error');
|
|
1393
|
+
if (error) {
|
|
1394
|
+
const target = new URL('/sign-in-error', url.origin);
|
|
1395
|
+
target.searchParams.set('code', error);
|
|
1396
|
+
const description = url.searchParams.get('error_description');
|
|
1397
|
+
if (description) target.searchParams.set('description', description);
|
|
1398
|
+
return NextResponse.redirect(target);
|
|
1399
|
+
}
|
|
1400
|
+
const code = url.searchParams.get('code');
|
|
1401
|
+
const returnTo = url.searchParams.get('return_to') ?? '/';
|
|
1402
|
+
if (!code) {
|
|
1403
|
+
const target = new URL('/sign-in-error', url.origin);
|
|
1404
|
+
target.searchParams.set('code', 'missing_code');
|
|
1405
|
+
return NextResponse.redirect(target);
|
|
1406
|
+
}
|
|
1407
|
+
try {
|
|
1408
|
+
await exchangeCode(code); // sets the dashwise.session cookie
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
const target = new URL('/sign-in-error', url.origin);
|
|
1411
|
+
target.searchParams.set('code', 'exchange_failed');
|
|
1412
|
+
target.searchParams.set('description', (err as Error).message);
|
|
1413
|
+
return NextResponse.redirect(target);
|
|
1414
|
+
}
|
|
1415
|
+
return NextResponse.redirect(new URL(returnTo, url.origin));
|
|
1416
|
+
}
|
|
1417
|
+
`;
|
|
1418
|
+
}
|
|
1419
|
+
function appApiAuthSignOutTs() {
|
|
1420
|
+
return `// /api/auth/sign-out \u2014 best-effort backend revoke + cookie clear.
|
|
1421
|
+
import { NextResponse } from 'next/server';
|
|
1422
|
+
import { signOut } from '@dashai/sdk/auth/server';
|
|
1423
|
+
|
|
1424
|
+
export async function POST() {
|
|
1425
|
+
await signOut();
|
|
1426
|
+
return new NextResponse(null, { status: 204 });
|
|
1427
|
+
}
|
|
1428
|
+
`;
|
|
1429
|
+
}
|
|
1430
|
+
function packageJsonCustom(opts) {
|
|
1431
|
+
return JSON.stringify(
|
|
1432
|
+
{
|
|
1433
|
+
name: opts.slug,
|
|
1434
|
+
version: "0.0.1",
|
|
1435
|
+
private: true,
|
|
1436
|
+
type: "module",
|
|
1437
|
+
scripts: {
|
|
1438
|
+
dev: "dashwise module dev",
|
|
1439
|
+
"dev:next": "next dev --port 3000",
|
|
1440
|
+
"dashwise:generate": "dashwise module generate",
|
|
1441
|
+
build: "next build",
|
|
1442
|
+
start: "next start"
|
|
1443
|
+
},
|
|
1444
|
+
dependencies: {
|
|
1445
|
+
"@dashai/sdk": "^0.2.0",
|
|
1446
|
+
next: "^15.0.0",
|
|
1447
|
+
react: "^19.0.0",
|
|
1448
|
+
"react-dom": "^19.0.0",
|
|
1449
|
+
// Styling system — same shadcn + Tailwind 4 stack as the
|
|
1450
|
+
// main DashWise frontend, so look-and-feel stays consistent
|
|
1451
|
+
// and module authors can add more blocks via the
|
|
1452
|
+
// shadcn-studio MCP (`npx shadcn add @shadcn-studio/...`).
|
|
1453
|
+
tailwindcss: "^4",
|
|
1454
|
+
"@tailwindcss/postcss": "^4",
|
|
1455
|
+
"tw-animate-css": "^1.4.0",
|
|
1456
|
+
shadcn: "^4.0.8",
|
|
1457
|
+
"radix-ui": "^1.4.3",
|
|
1458
|
+
"class-variance-authority": "^0.7.1",
|
|
1459
|
+
clsx: "^2.1.1",
|
|
1460
|
+
"tailwind-merge": "^3.5.0",
|
|
1461
|
+
"lucide-react": "^0.577.0",
|
|
1462
|
+
"next-themes": "^0.4.6"
|
|
1463
|
+
},
|
|
1464
|
+
devDependencies: {
|
|
1465
|
+
"@types/node": "^22.0.0",
|
|
1466
|
+
"@types/react": "^19.0.0",
|
|
1467
|
+
"@types/react-dom": "^19.0.0",
|
|
1468
|
+
typescript: "^5.5.0"
|
|
1469
|
+
}
|
|
1470
|
+
},
|
|
1471
|
+
null,
|
|
1472
|
+
2
|
|
1473
|
+
) + "\n";
|
|
1474
|
+
}
|
|
1475
|
+
function componentsJson() {
|
|
1476
|
+
return JSON.stringify(
|
|
1477
|
+
{
|
|
1478
|
+
$schema: "https://ui.shadcn.com/schema.json",
|
|
1479
|
+
style: "new-york",
|
|
1480
|
+
rsc: true,
|
|
1481
|
+
tsx: true,
|
|
1482
|
+
tailwind: {
|
|
1483
|
+
config: "",
|
|
1484
|
+
css: "app/globals.css",
|
|
1485
|
+
baseColor: "neutral",
|
|
1486
|
+
cssVariables: true,
|
|
1487
|
+
prefix: ""
|
|
1488
|
+
},
|
|
1489
|
+
iconLibrary: "lucide",
|
|
1490
|
+
rtl: false,
|
|
1491
|
+
aliases: {
|
|
1492
|
+
components: "@/components",
|
|
1493
|
+
utils: "@/lib/utils",
|
|
1494
|
+
ui: "@/components/ui",
|
|
1495
|
+
lib: "@/lib",
|
|
1496
|
+
hooks: "@/hooks"
|
|
1497
|
+
},
|
|
1498
|
+
registries: {
|
|
1499
|
+
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
|
1500
|
+
"@ss-components": {
|
|
1501
|
+
url: "https://shadcnstudio.com/r/components/{name}.json",
|
|
1502
|
+
params: {
|
|
1503
|
+
email: "${EMAIL}",
|
|
1504
|
+
license_key: "${LICENSE_KEY}"
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
},
|
|
1509
|
+
null,
|
|
1510
|
+
2
|
|
1511
|
+
) + "\n";
|
|
1512
|
+
}
|
|
1513
|
+
function postcssConfigMjs() {
|
|
1514
|
+
return `const config = {
|
|
1515
|
+
plugins: {
|
|
1516
|
+
"@tailwindcss/postcss": {},
|
|
1517
|
+
},
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
export default config;
|
|
1521
|
+
`;
|
|
1522
|
+
}
|
|
1523
|
+
function globalsCss() {
|
|
1524
|
+
return `@import "tailwindcss";
|
|
1525
|
+
@import "tw-animate-css";
|
|
1526
|
+
@import "shadcn/tailwind.css";
|
|
1527
|
+
|
|
1528
|
+
@custom-variant dark (&:is(.dark *));
|
|
1529
|
+
|
|
1530
|
+
:root {
|
|
1531
|
+
--background: oklch(1 0 0);
|
|
1532
|
+
--foreground: oklch(0.14 0 0);
|
|
1533
|
+
--card: oklch(1 0 0);
|
|
1534
|
+
--card-foreground: oklch(0.14 0 0);
|
|
1535
|
+
--popover: oklch(1 0 0);
|
|
1536
|
+
--popover-foreground: oklch(0.14 0 0);
|
|
1537
|
+
--primary: oklch(0.2 0 0);
|
|
1538
|
+
--primary-foreground: oklch(0.99 0 0);
|
|
1539
|
+
--secondary: oklch(0.97 0 0);
|
|
1540
|
+
--secondary-foreground: oklch(0.2 0 0);
|
|
1541
|
+
--muted: oklch(0.97 0 0);
|
|
1542
|
+
--muted-foreground: oklch(0.56 0 0);
|
|
1543
|
+
--accent: oklch(0.97 0 0);
|
|
1544
|
+
--accent-foreground: oklch(0.2 0 0);
|
|
1545
|
+
--destructive: oklch(0.58 0.24 28.48);
|
|
1546
|
+
--border: oklch(0.92 0 0);
|
|
1547
|
+
--input: oklch(0.92 0 0);
|
|
1548
|
+
--ring: oklch(0.71 0 0);
|
|
1549
|
+
--radius: 0.625rem;
|
|
1550
|
+
|
|
1551
|
+
--font-sans:
|
|
1552
|
+
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
|
1553
|
+
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1554
|
+
--font-mono:
|
|
1555
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
1556
|
+
"Liberation Mono", "Courier New", monospace;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
.dark {
|
|
1560
|
+
--background: oklch(0.14 0 0);
|
|
1561
|
+
--foreground: oklch(0.99 0 0);
|
|
1562
|
+
--card: oklch(0.2 0 0);
|
|
1563
|
+
--card-foreground: oklch(0.99 0 0);
|
|
1564
|
+
--popover: oklch(0.2 0 0);
|
|
1565
|
+
--popover-foreground: oklch(0.99 0 0);
|
|
1566
|
+
--primary: oklch(0.92 0 0);
|
|
1567
|
+
--primary-foreground: oklch(0.2 0 0);
|
|
1568
|
+
--secondary: oklch(0.27 0 0);
|
|
1569
|
+
--secondary-foreground: oklch(0.99 0 0);
|
|
1570
|
+
--muted: oklch(0.27 0 0);
|
|
1571
|
+
--muted-foreground: oklch(0.71 0 0);
|
|
1572
|
+
--accent: oklch(0.27 0 0);
|
|
1573
|
+
--accent-foreground: oklch(0.99 0 0);
|
|
1574
|
+
--destructive: oklch(0.7 0.19 22.23);
|
|
1575
|
+
--border: oklch(0.27 0 0);
|
|
1576
|
+
--input: oklch(0.27 0 0);
|
|
1577
|
+
--ring: oklch(0.56 0 0);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
* {
|
|
1581
|
+
border-color: var(--border);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
body {
|
|
1585
|
+
background-color: var(--background);
|
|
1586
|
+
color: var(--foreground);
|
|
1587
|
+
font-family: var(--font-sans);
|
|
1588
|
+
}
|
|
1589
|
+
`;
|
|
1590
|
+
}
|
|
1591
|
+
function libUtilsTs() {
|
|
1592
|
+
return `import { clsx, type ClassValue } from "clsx";
|
|
1593
|
+
import { twMerge } from "tailwind-merge";
|
|
1594
|
+
|
|
1595
|
+
export function cn(...inputs: ClassValue[]) {
|
|
1596
|
+
return twMerge(clsx(inputs));
|
|
1597
|
+
}
|
|
1598
|
+
`;
|
|
1599
|
+
}
|
|
1600
|
+
function componentsUiButtonTsx() {
|
|
1601
|
+
return `import * as React from "react";
|
|
1602
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
1603
|
+
import { Slot } from "radix-ui";
|
|
1604
|
+
|
|
1605
|
+
import { cn } from "@/lib/utils";
|
|
1606
|
+
|
|
1607
|
+
const buttonVariants = cva(
|
|
1608
|
+
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
1609
|
+
{
|
|
1610
|
+
variants: {
|
|
1611
|
+
variant: {
|
|
1612
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
1613
|
+
destructive:
|
|
1614
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
1615
|
+
outline:
|
|
1616
|
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
|
1617
|
+
secondary:
|
|
1618
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
1619
|
+
ghost:
|
|
1620
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
1621
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
1622
|
+
},
|
|
1623
|
+
size: {
|
|
1624
|
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
1625
|
+
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
|
1626
|
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
1627
|
+
icon: "size-9",
|
|
1628
|
+
},
|
|
1629
|
+
},
|
|
1630
|
+
defaultVariants: {
|
|
1631
|
+
variant: "default",
|
|
1632
|
+
size: "default",
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
function Button({
|
|
1638
|
+
className,
|
|
1639
|
+
variant = "default",
|
|
1640
|
+
size = "default",
|
|
1641
|
+
asChild = false,
|
|
1642
|
+
...props
|
|
1643
|
+
}: React.ComponentProps<"button"> &
|
|
1644
|
+
VariantProps<typeof buttonVariants> & {
|
|
1645
|
+
asChild?: boolean;
|
|
1646
|
+
}) {
|
|
1647
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
1648
|
+
return (
|
|
1649
|
+
<Comp
|
|
1650
|
+
data-slot="button"
|
|
1651
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
1652
|
+
{...props}
|
|
1653
|
+
/>
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
export { Button, buttonVariants };
|
|
1658
|
+
`;
|
|
1659
|
+
}
|
|
1660
|
+
function componentsUiCardTsx() {
|
|
1661
|
+
return `import * as React from "react";
|
|
1662
|
+
|
|
1663
|
+
import { cn } from "@/lib/utils";
|
|
1664
|
+
|
|
1665
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
1666
|
+
return (
|
|
1667
|
+
<div
|
|
1668
|
+
data-slot="card"
|
|
1669
|
+
className={cn(
|
|
1670
|
+
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
|
1671
|
+
className,
|
|
1672
|
+
)}
|
|
1673
|
+
{...props}
|
|
1674
|
+
/>
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
1679
|
+
return (
|
|
1680
|
+
<div
|
|
1681
|
+
data-slot="card-header"
|
|
1682
|
+
className={cn(
|
|
1683
|
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6",
|
|
1684
|
+
className,
|
|
1685
|
+
)}
|
|
1686
|
+
{...props}
|
|
1687
|
+
/>
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
1692
|
+
return (
|
|
1693
|
+
<div
|
|
1694
|
+
data-slot="card-title"
|
|
1695
|
+
className={cn("leading-none font-semibold", className)}
|
|
1696
|
+
{...props}
|
|
1697
|
+
/>
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
1702
|
+
return (
|
|
1703
|
+
<div
|
|
1704
|
+
data-slot="card-description"
|
|
1705
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
1706
|
+
{...props}
|
|
1707
|
+
/>
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
1712
|
+
return (
|
|
1713
|
+
<div
|
|
1714
|
+
data-slot="card-content"
|
|
1715
|
+
className={cn("px-6", className)}
|
|
1716
|
+
{...props}
|
|
1717
|
+
/>
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
1722
|
+
return (
|
|
1723
|
+
<div
|
|
1724
|
+
data-slot="card-footer"
|
|
1725
|
+
className={cn("flex items-center px-6", className)}
|
|
1726
|
+
{...props}
|
|
1727
|
+
/>
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
|
1732
|
+
`;
|
|
1733
|
+
}
|
|
1734
|
+
function componentsThemeProviderTsx() {
|
|
1735
|
+
return `"use client";
|
|
1736
|
+
|
|
1737
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
1738
|
+
|
|
1739
|
+
export function ThemeProvider({
|
|
1740
|
+
children,
|
|
1741
|
+
...props
|
|
1742
|
+
}: React.ComponentProps<typeof NextThemesProvider>) {
|
|
1743
|
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
|
1744
|
+
}
|
|
1745
|
+
`;
|
|
1746
|
+
}
|
|
1747
|
+
function appSignInErrorPageTsx() {
|
|
1748
|
+
return `import Link from 'next/link';
|
|
1749
|
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|
1750
|
+
import { Button } from '@/components/ui/button';
|
|
1751
|
+
import {
|
|
1752
|
+
Card,
|
|
1753
|
+
CardContent,
|
|
1754
|
+
CardDescription,
|
|
1755
|
+
CardHeader,
|
|
1756
|
+
CardTitle,
|
|
1757
|
+
} from '@/components/ui/card';
|
|
1758
|
+
|
|
1759
|
+
const ERROR_MESSAGES: Record<string, string> = {
|
|
1760
|
+
access_denied:
|
|
1761
|
+
"You declined the sign-in request. To use this module, you'll need to authorize it on the DashWise sign-in page.",
|
|
1762
|
+
server_error:
|
|
1763
|
+
'The DashWise sign-in server returned an internal error. This is usually transient \u2014 try again in a moment.',
|
|
1764
|
+
invalid_request:
|
|
1765
|
+
'The sign-in request was malformed. This is almost always a configuration bug \u2014 check that DASHWISE_API_URL and DASHWISE_INSTALLATION_ID are set correctly.',
|
|
1766
|
+
missing_code:
|
|
1767
|
+
'The callback URL was missing the expected exchange code. This usually means the SSO flow was interrupted.',
|
|
1768
|
+
exchange_failed:
|
|
1769
|
+
"The exchange code couldn't be redeemed for a session. The code may have expired (they're single-use and short-lived) \u2014 try signing in again.",
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
interface SignInErrorPageProps {
|
|
1773
|
+
searchParams: Promise<{ code?: string; description?: string }>;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
export default async function SignInErrorPage({ searchParams }: SignInErrorPageProps) {
|
|
1777
|
+
const params = await searchParams;
|
|
1778
|
+
const errorCode = params.code ?? 'unknown';
|
|
1779
|
+
const friendlyMessage = ERROR_MESSAGES[errorCode];
|
|
1780
|
+
return (
|
|
1781
|
+
<main className="flex min-h-dvh items-center justify-center px-6 py-12">
|
|
1782
|
+
<Card className="w-full max-w-lg">
|
|
1783
|
+
<CardHeader>
|
|
1784
|
+
<div className="flex items-start gap-3">
|
|
1785
|
+
<div className="rounded-full bg-destructive/10 p-2">
|
|
1786
|
+
<AlertTriangle className="size-5 text-destructive" aria-hidden="true" />
|
|
1787
|
+
</div>
|
|
1788
|
+
<div className="flex-1">
|
|
1789
|
+
<CardTitle>Sign-in failed</CardTitle>
|
|
1790
|
+
<CardDescription>
|
|
1791
|
+
The DashWise SSO flow couldn't complete.
|
|
1792
|
+
</CardDescription>
|
|
1793
|
+
</div>
|
|
1794
|
+
</div>
|
|
1795
|
+
</CardHeader>
|
|
1796
|
+
<CardContent className="space-y-4">
|
|
1797
|
+
<div className="space-y-1">
|
|
1798
|
+
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
1799
|
+
Error code
|
|
1800
|
+
</div>
|
|
1801
|
+
<code className="block rounded bg-muted px-2 py-1 text-sm">
|
|
1802
|
+
{errorCode}
|
|
1803
|
+
</code>
|
|
1804
|
+
</div>
|
|
1805
|
+
{friendlyMessage && (
|
|
1806
|
+
<p className="text-sm text-muted-foreground">{friendlyMessage}</p>
|
|
1807
|
+
)}
|
|
1808
|
+
{params.description && (
|
|
1809
|
+
<div className="space-y-1">
|
|
1810
|
+
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
1811
|
+
Detail
|
|
1812
|
+
</div>
|
|
1813
|
+
<p className="text-sm text-muted-foreground">{params.description}</p>
|
|
1814
|
+
</div>
|
|
1815
|
+
)}
|
|
1816
|
+
<div className="flex flex-wrap gap-2 pt-2">
|
|
1817
|
+
<Button asChild>
|
|
1818
|
+
<Link href="/api/auth/sign-in?return_to=/dashboard">
|
|
1819
|
+
<RefreshCw aria-hidden="true" />
|
|
1820
|
+
Try again
|
|
1821
|
+
</Link>
|
|
1822
|
+
</Button>
|
|
1823
|
+
<Button asChild variant="ghost">
|
|
1824
|
+
<Link href="/">Back to landing</Link>
|
|
1825
|
+
</Button>
|
|
1826
|
+
</div>
|
|
1827
|
+
</CardContent>
|
|
1828
|
+
</Card>
|
|
1829
|
+
</main>
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
`;
|
|
1833
|
+
}
|
|
1834
|
+
function escapeHtml(s) {
|
|
1835
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1836
|
+
}
|
|
1837
|
+
function isValidSlug(slug) {
|
|
1838
|
+
return /^[a-z][a-z0-9_-]*$/.test(slug);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/commands/module/clone.ts
|
|
1842
|
+
async function moduleCloneCommand(slug, options) {
|
|
1843
|
+
if (!slug) {
|
|
1844
|
+
fail("Usage: `dashwise module clone <slug>`");
|
|
1845
|
+
return 1;
|
|
1846
|
+
}
|
|
1847
|
+
let meta;
|
|
1848
|
+
try {
|
|
1849
|
+
meta = await apiRequest(
|
|
1850
|
+
"GET",
|
|
1851
|
+
`/api/modules/${encodeURIComponent(slug)}/source-tarball`
|
|
1852
|
+
);
|
|
1853
|
+
} catch (err) {
|
|
1854
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
1855
|
+
fail(`Module ${code(slug)} not found.`);
|
|
1856
|
+
return 1;
|
|
1857
|
+
}
|
|
1858
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
1859
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
1860
|
+
return 1;
|
|
1861
|
+
}
|
|
1862
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
1863
|
+
fail(`No access to module ${code(slug)}. You must be a member of its owner workspace.`);
|
|
1864
|
+
return 1;
|
|
1865
|
+
}
|
|
1866
|
+
fail(`Could not fetch module metadata: ${err.message}`);
|
|
1867
|
+
return 1;
|
|
1868
|
+
}
|
|
1869
|
+
const target = resolve(options.dir ?? slug);
|
|
1870
|
+
if (existsSync(target)) {
|
|
1871
|
+
const entries = readdirSync(target);
|
|
1872
|
+
if (entries.length > 0 && !options.force) {
|
|
1873
|
+
fail(
|
|
1874
|
+
`Directory ${code(target)} is not empty. Pass ${code("--force")} to clone anyway.`
|
|
1875
|
+
);
|
|
1876
|
+
return 1;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
const wantGit = !options.noGit && meta.repository_url !== null;
|
|
1880
|
+
if (wantGit) {
|
|
1881
|
+
return cloneViaGit(meta, target);
|
|
1882
|
+
}
|
|
1883
|
+
if (meta.repository_url !== null && options.noGit) {
|
|
1884
|
+
info(`--no-git: skipping git clone for ${code(meta.repository_url)}; writing manifest-only scaffold.`);
|
|
1885
|
+
} else {
|
|
1886
|
+
info(`Module ${code(slug)} has no ${code("repository.url")}; writing manifest-only scaffold.`);
|
|
1887
|
+
}
|
|
1888
|
+
return cloneViaManifest(slug, meta, target);
|
|
1889
|
+
}
|
|
1890
|
+
function cloneViaGit(meta, target) {
|
|
1891
|
+
const repoUrl = meta.repository_url;
|
|
1892
|
+
info(`Cloning ${code(repoUrl)} into ${code(target)}...`);
|
|
1893
|
+
const result = spawnSync("git", ["clone", repoUrl, target], {
|
|
1894
|
+
stdio: "inherit"
|
|
1895
|
+
});
|
|
1896
|
+
if (result.error) {
|
|
1897
|
+
fail(`Failed to invoke git: ${result.error.message}`);
|
|
1898
|
+
return 1;
|
|
1899
|
+
}
|
|
1900
|
+
if (result.status !== 0) {
|
|
1901
|
+
fail(`git clone exited with status ${result.status}.`);
|
|
1902
|
+
return result.status ?? 1;
|
|
1903
|
+
}
|
|
1904
|
+
success(`Cloned ${code(meta.module_slug)} from ${code(repoUrl)}.`);
|
|
1905
|
+
if (meta.current_version) {
|
|
1906
|
+
info(`Current published version: ${code(`v${meta.current_version}`)}`);
|
|
1907
|
+
}
|
|
1908
|
+
return 0;
|
|
1909
|
+
}
|
|
1910
|
+
function cloneViaManifest(slug, meta, target) {
|
|
1911
|
+
if (!meta.manifest) {
|
|
1912
|
+
fail(
|
|
1913
|
+
`Module ${code(slug)} has no published manifest yet (publish at least one version first).`
|
|
1914
|
+
);
|
|
1915
|
+
return 1;
|
|
1916
|
+
}
|
|
1917
|
+
mkdirSync(target, { recursive: true });
|
|
1918
|
+
const manifestObj = meta.manifest;
|
|
1919
|
+
const moduleKind = manifestObj.module_kind === "custom" ? "custom" : "hand_authored";
|
|
1920
|
+
const pkgFn = moduleKind === "custom" ? packageJsonCustom : packageJson;
|
|
1921
|
+
writeFileSync(
|
|
1922
|
+
join(target, "module.json"),
|
|
1923
|
+
JSON.stringify(manifestObj, null, 2) + "\n",
|
|
1924
|
+
"utf-8"
|
|
1925
|
+
);
|
|
1926
|
+
writeFileSync(
|
|
1927
|
+
join(target, "package.json"),
|
|
1928
|
+
pkgFn({ slug: meta.module_slug, name: meta.module_name}),
|
|
1929
|
+
"utf-8"
|
|
1930
|
+
);
|
|
1931
|
+
writeFileSync(join(target, ".gitignore"), gitignore(), "utf-8");
|
|
1932
|
+
writeFileSync(
|
|
1933
|
+
join(target, "README.md"),
|
|
1934
|
+
readmeForClone(meta, moduleKind),
|
|
1935
|
+
"utf-8"
|
|
1936
|
+
);
|
|
1937
|
+
success(`Wrote ${moduleKind} scaffold for ${code(meta.module_slug)} into ${code(target)}`);
|
|
1938
|
+
info(`Next steps:`);
|
|
1939
|
+
out("");
|
|
1940
|
+
out(` ${dim("1.")} ${code(`cd ${target}`)}`);
|
|
1941
|
+
out(` ${dim("2.")} ${code("npm install")}`);
|
|
1942
|
+
out(` ${dim("3.")} ${code("dashwise module generate")}`);
|
|
1943
|
+
out(` ${dim("4.")} Recreate your ${code("app/")} (manifest-only clone \u2014 no source code available).`);
|
|
1944
|
+
out("");
|
|
1945
|
+
if (meta.repository_url) {
|
|
1946
|
+
warn(
|
|
1947
|
+
`Module's manifest declared ${code(meta.repository_url)} as its repo; consider running ${code(`dashwise module clone ${slug}`)} without ${code("--no-git")} to get the full source.`
|
|
1948
|
+
);
|
|
1949
|
+
} else {
|
|
1950
|
+
info(
|
|
1951
|
+
`If you have access to the original source, consider running ${code(`dashwise module connect-git <repo-url>`)} so future clones use it.`
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
return 0;
|
|
1955
|
+
}
|
|
1956
|
+
function readmeForClone(meta, kind) {
|
|
1957
|
+
return `# ${meta.module_name}
|
|
1958
|
+
|
|
1959
|
+
Manifest-only clone of \`${meta.module_slug}\` (${kind}) at version \`${meta.current_version ?? "unpublished"}\`.
|
|
1960
|
+
|
|
1961
|
+
The original source code wasn't available \u2014 this directory contains
|
|
1962
|
+
only the published \`module.json\` + a starter scaffold. Recreate
|
|
1963
|
+
\`app/\` based on the manifest.
|
|
1964
|
+
|
|
1965
|
+
## Get started
|
|
1966
|
+
|
|
1967
|
+
\`\`\`bash
|
|
1968
|
+
npm install
|
|
1969
|
+
dashwise module generate
|
|
1970
|
+
dashwise module dev
|
|
1971
|
+
\`\`\`
|
|
1972
|
+
`;
|
|
1973
|
+
}
|
|
1974
|
+
async function moduleConnectGitCommand(repoUrl, options) {
|
|
1975
|
+
if (!repoUrl || typeof repoUrl !== "string") {
|
|
1976
|
+
fail("Usage: `dashwise module connect-git <repo-url>`");
|
|
1977
|
+
return 1;
|
|
1978
|
+
}
|
|
1979
|
+
if (!isPlausibleRepoUrl(repoUrl)) {
|
|
1980
|
+
fail(`Repo URL ${code(repoUrl)} doesn't look like https:// or git@host:owner/repo.`);
|
|
1981
|
+
return 1;
|
|
1982
|
+
}
|
|
1983
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
1984
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
1985
|
+
if (!existsSync(manifestPath2)) {
|
|
1986
|
+
fail(
|
|
1987
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init\` first.`
|
|
1988
|
+
);
|
|
1989
|
+
return 1;
|
|
1990
|
+
}
|
|
1991
|
+
let manifest;
|
|
1992
|
+
try {
|
|
1993
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
1994
|
+
} catch (err) {
|
|
1995
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
1996
|
+
return 1;
|
|
1997
|
+
}
|
|
1998
|
+
const existing = manifest.repository ?? {};
|
|
1999
|
+
const next = {
|
|
2000
|
+
...existing,
|
|
2001
|
+
type: "git",
|
|
2002
|
+
url: repoUrl
|
|
2003
|
+
};
|
|
2004
|
+
if (options.branch) {
|
|
2005
|
+
next.branch = options.branch;
|
|
2006
|
+
} else if (typeof existing.branch === "string") {
|
|
2007
|
+
next.branch = existing.branch;
|
|
2008
|
+
}
|
|
2009
|
+
manifest.repository = next;
|
|
2010
|
+
writeFileSync(manifestPath2, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
2011
|
+
success(`Connected to ${code(repoUrl)}`);
|
|
2012
|
+
if (next.branch) info(`Branch: ${code(String(next.branch))}`);
|
|
2013
|
+
info(`Updated ${code("module.json")}.`);
|
|
2014
|
+
return 0;
|
|
2015
|
+
}
|
|
2016
|
+
function isPlausibleRepoUrl(url) {
|
|
2017
|
+
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
2018
|
+
try {
|
|
2019
|
+
new URL(url);
|
|
2020
|
+
return true;
|
|
2021
|
+
} catch {
|
|
2022
|
+
return false;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return /^git@[^:]+:[^/]+\/.+/.test(url);
|
|
2026
|
+
}
|
|
2027
|
+
function outputDir(projectRoot) {
|
|
2028
|
+
return join(projectRoot, "node_modules", "@dashai", "generated");
|
|
2029
|
+
}
|
|
2030
|
+
async function moduleGenerateCommand(options) {
|
|
2031
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2032
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2033
|
+
if (!existsSync(manifestPath2)) {
|
|
2034
|
+
fail(
|
|
2035
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init <slug>\` first.`
|
|
2036
|
+
);
|
|
2037
|
+
return 1;
|
|
2038
|
+
}
|
|
2039
|
+
let manifest;
|
|
2040
|
+
try {
|
|
2041
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
2042
|
+
} catch (err) {
|
|
2043
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
2044
|
+
return 1;
|
|
2045
|
+
}
|
|
2046
|
+
let res;
|
|
2047
|
+
try {
|
|
2048
|
+
res = await apiRequest(
|
|
2049
|
+
"POST",
|
|
2050
|
+
"/api/modules/draft/generate-sdk",
|
|
2051
|
+
{
|
|
2052
|
+
manifest,
|
|
2053
|
+
generation: options.generation ?? 0
|
|
2054
|
+
}
|
|
2055
|
+
);
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2058
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2059
|
+
return 1;
|
|
2060
|
+
}
|
|
2061
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
2062
|
+
fail(`Manifest validation failed: ${err.message}`);
|
|
2063
|
+
if (err.context) {
|
|
2064
|
+
process.stderr.write(JSON.stringify(err.context, null, 2) + "\n");
|
|
2065
|
+
}
|
|
2066
|
+
return 1;
|
|
2067
|
+
}
|
|
2068
|
+
fail(`Could not generate SDK: ${err.message}`);
|
|
2069
|
+
return 1;
|
|
2070
|
+
}
|
|
2071
|
+
if (options.dryRun) {
|
|
2072
|
+
info(`Dry run: ${Object.keys(res.files).length} files`);
|
|
2073
|
+
info(`Hash: ${code(res.contentHash)}`);
|
|
2074
|
+
return 0;
|
|
2075
|
+
}
|
|
2076
|
+
const out2 = outputDir(projectRoot);
|
|
2077
|
+
if (existsSync(out2)) {
|
|
2078
|
+
rmSync(out2, { recursive: true, force: true });
|
|
2079
|
+
}
|
|
2080
|
+
mkdirSync(out2, { recursive: true });
|
|
2081
|
+
for (const [relPath, content] of Object.entries(res.files)) {
|
|
2082
|
+
const absPath = join(out2, relPath);
|
|
2083
|
+
const parent = dirname(absPath);
|
|
2084
|
+
if (!existsSync(parent)) {
|
|
2085
|
+
mkdirSync(parent, { recursive: true });
|
|
2086
|
+
}
|
|
2087
|
+
writeFileSync(absPath, content, "utf-8");
|
|
2088
|
+
}
|
|
2089
|
+
success(
|
|
2090
|
+
`Generated ${Object.keys(res.files).length} files into ${code("node_modules/@dashai/generated/")}`
|
|
2091
|
+
);
|
|
2092
|
+
info(`Hash: ${dim(res.contentHash)}`);
|
|
2093
|
+
return 0;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// src/commands/module/dev.ts
|
|
2097
|
+
var DEFAULT_DEBOUNCE_MS = 250;
|
|
2098
|
+
async function moduleDevCommand(options) {
|
|
2099
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2100
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2101
|
+
if (!existsSync(manifestPath2)) {
|
|
2102
|
+
fail(`No ${code("module.json")} found in ${code(projectRoot)}.`);
|
|
2103
|
+
info("Run `dashwise module init <slug>` to scaffold a new module.");
|
|
2104
|
+
return 1;
|
|
2105
|
+
}
|
|
2106
|
+
info(`Watching ${code(manifestPath2)} \u2014 Ctrl-C to stop.`);
|
|
2107
|
+
let generation = 0;
|
|
2108
|
+
const initial = await moduleGenerateCommand({
|
|
2109
|
+
...options.dir !== void 0 ? { dir: options.dir } : {},
|
|
2110
|
+
generation
|
|
2111
|
+
});
|
|
2112
|
+
if (initial !== 0) {
|
|
2113
|
+
warn("Initial regen failed; watcher will retry on the next change.");
|
|
2114
|
+
}
|
|
2115
|
+
generation += 1;
|
|
2116
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
2117
|
+
let timer = null;
|
|
2118
|
+
let isRunning = false;
|
|
2119
|
+
let pendingRegen = false;
|
|
2120
|
+
async function doRegen() {
|
|
2121
|
+
if (isRunning) {
|
|
2122
|
+
pendingRegen = true;
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
isRunning = true;
|
|
2126
|
+
try {
|
|
2127
|
+
const start = Date.now();
|
|
2128
|
+
const exit = await moduleGenerateCommand({
|
|
2129
|
+
...options.dir !== void 0 ? { dir: options.dir } : {},
|
|
2130
|
+
generation
|
|
2131
|
+
});
|
|
2132
|
+
const elapsedMs = Date.now() - start;
|
|
2133
|
+
if (exit === 0) {
|
|
2134
|
+
success(`Regen complete (${elapsedMs}ms, gen=${generation})`);
|
|
2135
|
+
} else {
|
|
2136
|
+
warn(`Regen failed (exit ${exit}, gen=${generation}) \u2014 will retry on next change.`);
|
|
2137
|
+
}
|
|
2138
|
+
generation += 1;
|
|
2139
|
+
} finally {
|
|
2140
|
+
isRunning = false;
|
|
2141
|
+
if (pendingRegen) {
|
|
2142
|
+
pendingRegen = false;
|
|
2143
|
+
setImmediate(doRegen);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
const watcher = watch(manifestPath2, (eventType) => {
|
|
2148
|
+
if (eventType !== "change") return;
|
|
2149
|
+
if (timer !== null) clearTimeout(timer);
|
|
2150
|
+
timer = setTimeout(() => {
|
|
2151
|
+
timer = null;
|
|
2152
|
+
void doRegen();
|
|
2153
|
+
}, debounceMs);
|
|
2154
|
+
});
|
|
2155
|
+
return new Promise((resolveExit) => {
|
|
2156
|
+
const stop = (signal) => {
|
|
2157
|
+
if (timer !== null) clearTimeout(timer);
|
|
2158
|
+
watcher.close();
|
|
2159
|
+
info(`Stopping watcher${signal === "shutdown" ? "" : ` (${signal})`}.`);
|
|
2160
|
+
process.stdout.write(dim("Goodbye.\n"));
|
|
2161
|
+
resolveExit(0);
|
|
2162
|
+
};
|
|
2163
|
+
process.once("SIGINT", () => stop("SIGINT"));
|
|
2164
|
+
process.once("SIGTERM", () => stop("SIGTERM"));
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
async function moduleDiffCommand(options) {
|
|
2168
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2169
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2170
|
+
if (!existsSync(manifestPath2)) {
|
|
2171
|
+
if (options.json) {
|
|
2172
|
+
out(JSON.stringify({ ok: false, error: { code: "NO_MANIFEST", message: `No module.json found in ${projectRoot}.` } }));
|
|
2173
|
+
} else {
|
|
2174
|
+
fail(
|
|
2175
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init <slug>\` first.`
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
return 1;
|
|
2179
|
+
}
|
|
2180
|
+
let manifest;
|
|
2181
|
+
try {
|
|
2182
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
if (options.json) {
|
|
2185
|
+
out(JSON.stringify({ ok: false, error: { code: "MANIFEST_PARSE_FAILED", message: err.message } }));
|
|
2186
|
+
} else {
|
|
2187
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
2188
|
+
}
|
|
2189
|
+
return 1;
|
|
2190
|
+
}
|
|
2191
|
+
const slug = manifest.module?.slug;
|
|
2192
|
+
if (!slug) {
|
|
2193
|
+
if (options.json) {
|
|
2194
|
+
out(JSON.stringify({ ok: false, error: { code: "MANIFEST_MISSING_SLUG", message: "module.json is missing module.slug." } }));
|
|
2195
|
+
} else {
|
|
2196
|
+
fail(`${code("module.json")} is missing ${code("module.slug")}.`);
|
|
2197
|
+
}
|
|
2198
|
+
return 1;
|
|
2199
|
+
}
|
|
2200
|
+
let mod;
|
|
2201
|
+
try {
|
|
2202
|
+
mod = await apiRequest(
|
|
2203
|
+
"GET",
|
|
2204
|
+
`/api/modules/by-slug/${encodeURIComponent(slug)}`
|
|
2205
|
+
);
|
|
2206
|
+
} catch (err) {
|
|
2207
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
2208
|
+
if (options.json) {
|
|
2209
|
+
out(JSON.stringify({ ok: false, error: { code: "MODULE_NOT_FOUND", message: `Module ${slug} not found on the backend.` } }));
|
|
2210
|
+
} else {
|
|
2211
|
+
fail(
|
|
2212
|
+
`Module ${code(slug)} not found on the backend. Create + publish it first; diff has nothing to compare against until then.`
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
return 1;
|
|
2216
|
+
}
|
|
2217
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2218
|
+
if (options.json) {
|
|
2219
|
+
out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: "Run `dashwise login` first." } }));
|
|
2220
|
+
} else {
|
|
2221
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2222
|
+
}
|
|
2223
|
+
return 1;
|
|
2224
|
+
}
|
|
2225
|
+
if (options.json) {
|
|
2226
|
+
out(JSON.stringify({ ok: false, error: { code: "RESOLVE_FAILED", message: err.message } }));
|
|
2227
|
+
} else {
|
|
2228
|
+
fail(`Could not resolve module: ${err.message}`);
|
|
2229
|
+
}
|
|
2230
|
+
return 1;
|
|
2231
|
+
}
|
|
2232
|
+
let diff;
|
|
2233
|
+
try {
|
|
2234
|
+
diff = await apiRequest(
|
|
2235
|
+
"POST",
|
|
2236
|
+
`/api/modules/${mod.id}/draft/diff`,
|
|
2237
|
+
{ manifest }
|
|
2238
|
+
);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
2241
|
+
if (options.json) {
|
|
2242
|
+
out(JSON.stringify({ ok: false, error: { code: "VALIDATION_FAILED", message: err.message, context: err.context ?? null } }));
|
|
2243
|
+
} else {
|
|
2244
|
+
fail(`Manifest validation failed: ${err.message}`);
|
|
2245
|
+
if (err.context) {
|
|
2246
|
+
out(JSON.stringify(err.context, null, 2));
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
return 1;
|
|
2250
|
+
}
|
|
2251
|
+
if (options.json) {
|
|
2252
|
+
out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message: err.message } }));
|
|
2253
|
+
} else {
|
|
2254
|
+
fail(`Could not compute diff: ${err.message}`);
|
|
2255
|
+
}
|
|
2256
|
+
return 1;
|
|
2257
|
+
}
|
|
2258
|
+
if (options.json) {
|
|
2259
|
+
out(JSON.stringify({ ok: true, ...diff }));
|
|
2260
|
+
return 0;
|
|
2261
|
+
}
|
|
2262
|
+
if (diff.unchanged) {
|
|
2263
|
+
success(
|
|
2264
|
+
`No changes vs ${code(diff.current_version ? `v${diff.current_version}` : "(unpublished)")} \u2014 manifest is in sync.`
|
|
2265
|
+
);
|
|
2266
|
+
return 0;
|
|
2267
|
+
}
|
|
2268
|
+
out("");
|
|
2269
|
+
info(
|
|
2270
|
+
diff.current_version ? `Diff vs ${code(`v${diff.current_version}`)}:` : "Diff vs " + code("(unpublished \u2014 first version)") + ":"
|
|
2271
|
+
);
|
|
2272
|
+
if (diff.meta_changes.length > 0) {
|
|
2273
|
+
out("");
|
|
2274
|
+
out(code("Meta:"));
|
|
2275
|
+
for (const m of diff.meta_changes) {
|
|
2276
|
+
out(` ${m.field}: ${dim(JSON.stringify(m.before))} \u2192 ${JSON.stringify(m.after)}`);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
if (diff.tables_added.length > 0) {
|
|
2280
|
+
out("");
|
|
2281
|
+
out(code(`Tables added (${diff.tables_added.length}):`));
|
|
2282
|
+
for (const t of diff.tables_added) out(` + ${t}`);
|
|
2283
|
+
}
|
|
2284
|
+
if (diff.tables_removed.length > 0) {
|
|
2285
|
+
out("");
|
|
2286
|
+
out(code(`Tables removed (${diff.tables_removed.length}):`));
|
|
2287
|
+
for (const t of diff.tables_removed) out(` - ${t}`);
|
|
2288
|
+
}
|
|
2289
|
+
if (diff.tables_changed.length > 0) {
|
|
2290
|
+
out("");
|
|
2291
|
+
out(code(`Tables changed (${diff.tables_changed.length}):`));
|
|
2292
|
+
for (const t of diff.tables_changed) {
|
|
2293
|
+
out(` ${dim("\u2022")} ${code(t.table)}`);
|
|
2294
|
+
for (const f of t.fields_added) {
|
|
2295
|
+
out(` + field ${f.slug} (${f.type})`);
|
|
2296
|
+
}
|
|
2297
|
+
for (const f of t.fields_removed) {
|
|
2298
|
+
out(` - field ${f.slug} (${f.type})`);
|
|
2299
|
+
}
|
|
2300
|
+
for (const f of t.fields_changed) {
|
|
2301
|
+
const changeSummary = Object.entries(f.changes).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ");
|
|
2302
|
+
out(` ~ field ${f.field}: ${dim(changeSummary)}`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
if (diff.deps_added.length > 0) {
|
|
2307
|
+
out("");
|
|
2308
|
+
out(code(`Dependencies added (${diff.deps_added.length}):`));
|
|
2309
|
+
for (const d of diff.deps_added) {
|
|
2310
|
+
out(` + ${d.module}@${d.version}`);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
if (diff.deps_removed.length > 0) {
|
|
2314
|
+
out("");
|
|
2315
|
+
out(code(`Dependencies removed (${diff.deps_removed.length}):`));
|
|
2316
|
+
for (const d of diff.deps_removed) {
|
|
2317
|
+
out(` - ${d.module}@${d.version}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
out("");
|
|
2321
|
+
info("Run `dashwise module publish --bump <patch|minor|major>` when ready.");
|
|
2322
|
+
return 0;
|
|
2323
|
+
}
|
|
2324
|
+
async function moduleInitCommand(slugArg, options) {
|
|
2325
|
+
intro("Scaffolding a new DashWise module");
|
|
2326
|
+
let slug = slugArg;
|
|
2327
|
+
if (!slug && process.stdout.isTTY) {
|
|
2328
|
+
const answer = await text({
|
|
2329
|
+
message: "Module slug?",
|
|
2330
|
+
placeholder: "tasks",
|
|
2331
|
+
validate: (v) => isValidSlug(v) ? void 0 : "Slug must match /^[a-z][a-z0-9_-]*$/"
|
|
2332
|
+
});
|
|
2333
|
+
if (isCancel(answer)) {
|
|
2334
|
+
fail("Aborted.");
|
|
2335
|
+
return 130;
|
|
2336
|
+
}
|
|
2337
|
+
slug = answer;
|
|
2338
|
+
}
|
|
2339
|
+
if (!slug) {
|
|
2340
|
+
fail("Usage: `dashwise module init <slug>`");
|
|
2341
|
+
return 1;
|
|
2342
|
+
}
|
|
2343
|
+
if (!isValidSlug(slug)) {
|
|
2344
|
+
fail(`Invalid slug ${code(slug)} \u2014 must match /^[a-z][a-z0-9_-]*$/`);
|
|
2345
|
+
return 1;
|
|
2346
|
+
}
|
|
2347
|
+
const name = options.name ?? humanizeSlug(slug);
|
|
2348
|
+
const dir = resolve(options.dir ?? slug);
|
|
2349
|
+
if (existsSync(dir)) {
|
|
2350
|
+
const entries = readdirSync(dir);
|
|
2351
|
+
if (entries.length > 0 && !options.force) {
|
|
2352
|
+
fail(
|
|
2353
|
+
`Directory ${code(dir)} is not empty. Pass ${code("--force")} to scaffold anyway (existing files will be overwritten).`
|
|
2354
|
+
);
|
|
2355
|
+
return 1;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (options.simple && options.custom) {
|
|
2359
|
+
fail(
|
|
2360
|
+
`Cannot pass both ${code("--simple")} and ${code("--custom")} \u2014 they're mutually exclusive. ${code("--simple")} selects the legacy minimal scaffold; the default (no flag) is the full Next.js scaffold (formerly ${code("--custom")}).`
|
|
2361
|
+
);
|
|
2362
|
+
return 1;
|
|
2363
|
+
}
|
|
2364
|
+
const kind = options.simple ? "hand_authored" : "custom";
|
|
2365
|
+
const scaffoldOpts = {
|
|
2366
|
+
slug,
|
|
2367
|
+
name,
|
|
2368
|
+
kind,
|
|
2369
|
+
...options.git !== void 0 ? { repositoryUrl: options.git } : {}
|
|
2370
|
+
};
|
|
2371
|
+
const files = [
|
|
2372
|
+
["module.json", manifestJson(scaffoldOpts)],
|
|
2373
|
+
["tsconfig.json", tsconfigJson()],
|
|
2374
|
+
["next.config.mjs", nextConfig()],
|
|
2375
|
+
["next-env.d.ts", nextEnvDts()],
|
|
2376
|
+
[".gitignore", gitignore()],
|
|
2377
|
+
[".env.local.example", envLocalExample()],
|
|
2378
|
+
["README.md", readme(scaffoldOpts)]
|
|
2379
|
+
];
|
|
2380
|
+
if (kind === "custom") {
|
|
2381
|
+
files.push(
|
|
2382
|
+
["package.json", packageJsonCustom(scaffoldOpts)],
|
|
2383
|
+
["middleware.ts", middlewareTs()],
|
|
2384
|
+
["components.json", componentsJson()],
|
|
2385
|
+
["postcss.config.mjs", postcssConfigMjs()],
|
|
2386
|
+
["app/globals.css", globalsCss()],
|
|
2387
|
+
["lib/utils.ts", libUtilsTs()],
|
|
2388
|
+
["components/theme-provider.tsx", componentsThemeProviderTsx()],
|
|
2389
|
+
["components/ui/button.tsx", componentsUiButtonTsx()],
|
|
2390
|
+
["components/ui/card.tsx", componentsUiCardTsx()],
|
|
2391
|
+
["app/layout.tsx", appLayoutCustomTsx(scaffoldOpts)],
|
|
2392
|
+
["app/page.tsx", appPageCustomTsx(scaffoldOpts)],
|
|
2393
|
+
["app/dashboard/page.tsx", appDashboardPageTsx()],
|
|
2394
|
+
["app/sign-in-error/page.tsx", appSignInErrorPageTsx()],
|
|
2395
|
+
["app/api/auth/sign-in/route.ts", appApiAuthSignInTs()],
|
|
2396
|
+
["app/api/auth/callback/route.ts", appApiAuthCallbackTs()],
|
|
2397
|
+
["app/api/auth/sign-out/route.ts", appApiAuthSignOutTs()]
|
|
2398
|
+
);
|
|
2399
|
+
} else {
|
|
2400
|
+
files.push(
|
|
2401
|
+
["package.json", packageJson(scaffoldOpts)],
|
|
2402
|
+
["app/layout.tsx", appLayoutTsx(scaffoldOpts)],
|
|
2403
|
+
["app/page.tsx", appPageTsx(scaffoldOpts)]
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
for (const [relPath, content] of files) {
|
|
2407
|
+
const absPath = join(dir, relPath);
|
|
2408
|
+
const parentDir = join(absPath, "..");
|
|
2409
|
+
if (!existsSync(parentDir)) {
|
|
2410
|
+
mkdirSync(parentDir, { recursive: true });
|
|
2411
|
+
}
|
|
2412
|
+
writeFileSync(absPath, content, "utf-8");
|
|
2413
|
+
}
|
|
2414
|
+
success(
|
|
2415
|
+
`Scaffolded ${code(slug)} (${kind === "custom" ? "custom Next.js" : "simple module"}) in ${code(dir)}`
|
|
2416
|
+
);
|
|
2417
|
+
info(`Next steps:`);
|
|
2418
|
+
process.stdout.write(`
|
|
2419
|
+
${dim("1.")} ${code(`cd ${dir}`)}
|
|
2420
|
+
${dim("2.")} ${code("npm install")}
|
|
2421
|
+
${dim("3.")} ${code("dashwise dev")} ${dim("# one command: watcher + next dev, auth auto-injected")}
|
|
2422
|
+
|
|
2423
|
+
${dim(" (or, for the two-terminal flow:")}
|
|
2424
|
+
${dim(" dashwise module generate")} ${dim("# materialize typed wrappers")}
|
|
2425
|
+
${dim(" dashwise module dev")} ${dim("# watch + auto-regenerate")}
|
|
2426
|
+
${dim(" npm run dev:next # next dev on :3000)")}
|
|
2427
|
+
|
|
2428
|
+
`);
|
|
2429
|
+
outro("Happy authoring.");
|
|
2430
|
+
return 0;
|
|
2431
|
+
}
|
|
2432
|
+
function humanizeSlug(slug) {
|
|
2433
|
+
const words = slug.replace(/[-_]/g, " ").split(/\s+/).filter(Boolean);
|
|
2434
|
+
if (words.length === 0) return slug;
|
|
2435
|
+
const first = words[0];
|
|
2436
|
+
const cap = first.charAt(0).toUpperCase() + first.slice(1);
|
|
2437
|
+
return [cap, ...words.slice(1)].join(" ");
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// src/commands/module/list.ts
|
|
2441
|
+
async function moduleListCommand(options) {
|
|
2442
|
+
const cfg = readConfig();
|
|
2443
|
+
if (!cfg) {
|
|
2444
|
+
if (options.json) {
|
|
2445
|
+
out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: "Run `dashwise login` first." } }));
|
|
2446
|
+
} else {
|
|
2447
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2448
|
+
}
|
|
2449
|
+
return 1;
|
|
2450
|
+
}
|
|
2451
|
+
let workspaceId;
|
|
2452
|
+
const targetSlug = options.workspace ?? cfg.workspaceSlug;
|
|
2453
|
+
if (targetSlug) {
|
|
2454
|
+
try {
|
|
2455
|
+
const workspaces = await apiRequest(
|
|
2456
|
+
"GET",
|
|
2457
|
+
"/api/workspaces"
|
|
2458
|
+
);
|
|
2459
|
+
const match = workspaces.find((w) => w.slug === targetSlug);
|
|
2460
|
+
if (!match) {
|
|
2461
|
+
if (options.json) {
|
|
2462
|
+
out(JSON.stringify({ ok: false, error: { code: "WORKSPACE_NOT_FOUND", message: `Workspace ${targetSlug} not found.` } }));
|
|
2463
|
+
} else {
|
|
2464
|
+
fail(
|
|
2465
|
+
`Workspace ${code(targetSlug)} not found in your accessible workspaces. Run \`dashwise workspace list\` to see the choices.`
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
return 1;
|
|
2469
|
+
}
|
|
2470
|
+
workspaceId = match.id;
|
|
2471
|
+
} catch (err) {
|
|
2472
|
+
if (options.json) {
|
|
2473
|
+
out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message: err.message } }));
|
|
2474
|
+
} else {
|
|
2475
|
+
fail(`Could not load workspaces: ${err.message}`);
|
|
2476
|
+
}
|
|
2477
|
+
return 1;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
const params = new URLSearchParams();
|
|
2481
|
+
if (!options.all) params.set("owned_only", "true");
|
|
2482
|
+
if (workspaceId !== void 0) params.set("workspaceId", String(workspaceId));
|
|
2483
|
+
const path = `/api/modules${params.toString() ? `?${params.toString()}` : ""}`;
|
|
2484
|
+
let modules;
|
|
2485
|
+
try {
|
|
2486
|
+
modules = await apiRequest("GET", path);
|
|
2487
|
+
} catch (err) {
|
|
2488
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2489
|
+
if (options.json) {
|
|
2490
|
+
out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: "Run `dashwise login` first." } }));
|
|
2491
|
+
} else {
|
|
2492
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2493
|
+
}
|
|
2494
|
+
return 1;
|
|
2495
|
+
}
|
|
2496
|
+
if (options.json) {
|
|
2497
|
+
out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message: err.message } }));
|
|
2498
|
+
} else {
|
|
2499
|
+
fail(`Could not list modules: ${err.message}`);
|
|
2500
|
+
}
|
|
2501
|
+
return 1;
|
|
2502
|
+
}
|
|
2503
|
+
if (options.json) {
|
|
2504
|
+
out(
|
|
2505
|
+
JSON.stringify({
|
|
2506
|
+
ok: true,
|
|
2507
|
+
modules: Array.isArray(modules) ? modules : [],
|
|
2508
|
+
total: Array.isArray(modules) ? modules.length : 0
|
|
2509
|
+
})
|
|
2510
|
+
);
|
|
2511
|
+
return 0;
|
|
2512
|
+
}
|
|
2513
|
+
if (!Array.isArray(modules) || modules.length === 0) {
|
|
2514
|
+
info(
|
|
2515
|
+
options.all ? "No modules found." : "No modules owned by you yet. Use `dashwise module init <slug>` to scaffold one."
|
|
2516
|
+
);
|
|
2517
|
+
return 0;
|
|
2518
|
+
}
|
|
2519
|
+
const slugWidth = Math.max(...modules.map((m) => m.slug.length), 4);
|
|
2520
|
+
const versionWidth = Math.max(
|
|
2521
|
+
...modules.map((m) => (m.current_version ?? "(draft)").length),
|
|
2522
|
+
7
|
|
2523
|
+
);
|
|
2524
|
+
out("");
|
|
2525
|
+
out(
|
|
2526
|
+
`${"SLUG".padEnd(slugWidth)} ${"VERSION".padEnd(versionWidth)} NAME`
|
|
2527
|
+
);
|
|
2528
|
+
out(
|
|
2529
|
+
`${"-".repeat(slugWidth)} ${"-".repeat(versionWidth)} ${"-".repeat(20)}`
|
|
2530
|
+
);
|
|
2531
|
+
for (const m of modules) {
|
|
2532
|
+
const version = m.current_version ?? "(draft)";
|
|
2533
|
+
const flags = [
|
|
2534
|
+
m.verified ? code("\u2713") : "",
|
|
2535
|
+
m.public ? dim("public") : ""
|
|
2536
|
+
].filter(Boolean).join(" ");
|
|
2537
|
+
const flagsCol = flags ? ` ${flags}` : "";
|
|
2538
|
+
out(`${m.slug.padEnd(slugWidth)} ${version.padEnd(versionWidth)} ${m.name}${flagsCol}`);
|
|
2539
|
+
}
|
|
2540
|
+
out("");
|
|
2541
|
+
info(`${modules.length} module${modules.length === 1 ? "" : "s"}.`);
|
|
2542
|
+
return 0;
|
|
2543
|
+
}
|
|
2544
|
+
var DEFAULT_EXCLUDE_PATTERNS = [
|
|
2545
|
+
"node_modules",
|
|
2546
|
+
".next",
|
|
2547
|
+
".git",
|
|
2548
|
+
".turbo",
|
|
2549
|
+
".dashwise",
|
|
2550
|
+
".DS_Store",
|
|
2551
|
+
".env",
|
|
2552
|
+
".env.local",
|
|
2553
|
+
".env.development.local",
|
|
2554
|
+
".env.test.local",
|
|
2555
|
+
".env.production.local",
|
|
2556
|
+
"coverage",
|
|
2557
|
+
".nyc_output",
|
|
2558
|
+
".vitest-cache",
|
|
2559
|
+
".idea",
|
|
2560
|
+
".vscode",
|
|
2561
|
+
"dist",
|
|
2562
|
+
"build"
|
|
2563
|
+
];
|
|
2564
|
+
var PER_FILE_BYTE_LIMIT = 10 * 1024 * 1024;
|
|
2565
|
+
var MAX_TARBALL_BYTES = 100 * 1024 * 1024;
|
|
2566
|
+
async function packTarball(opts) {
|
|
2567
|
+
if (!existsSync(opts.cwd)) {
|
|
2568
|
+
throw new Error(`packTarball: cwd not found at ${opts.cwd}`);
|
|
2569
|
+
}
|
|
2570
|
+
const excludeSet = /* @__PURE__ */ new Set([
|
|
2571
|
+
...DEFAULT_EXCLUDE_PATTERNS,
|
|
2572
|
+
...opts.extraExclude ?? []
|
|
2573
|
+
]);
|
|
2574
|
+
const filesIncluded = [];
|
|
2575
|
+
const warnings = [];
|
|
2576
|
+
const candidates = walk(opts.cwd, excludeSet);
|
|
2577
|
+
for (const abs of candidates) {
|
|
2578
|
+
const rel = relative(opts.cwd, abs);
|
|
2579
|
+
const size = statSync(abs).size;
|
|
2580
|
+
if (size > PER_FILE_BYTE_LIMIT) {
|
|
2581
|
+
warnings.push(
|
|
2582
|
+
`${rel} skipped \u2014 ${formatBytes(size)} exceeds ${formatBytes(PER_FILE_BYTE_LIMIT)} per-file cap`
|
|
2583
|
+
);
|
|
2584
|
+
continue;
|
|
2585
|
+
}
|
|
2586
|
+
filesIncluded.push(rel);
|
|
2587
|
+
}
|
|
2588
|
+
if (filesIncluded.length === 0) {
|
|
2589
|
+
throw new Error(
|
|
2590
|
+
`packTarball: nothing to pack in ${opts.cwd} (after applying exclude list)`
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
filesIncluded.sort();
|
|
2594
|
+
const chunks = [];
|
|
2595
|
+
await new Promise((resolveDone, rejectDone) => {
|
|
2596
|
+
const stream = tar.c(
|
|
2597
|
+
{
|
|
2598
|
+
gzip: true,
|
|
2599
|
+
cwd: opts.cwd,
|
|
2600
|
+
// Don't follow symlinks — bundle-local content only.
|
|
2601
|
+
follow: false,
|
|
2602
|
+
// Preserve the exact case of paths; the backend's per-file
|
|
2603
|
+
// policy gate keys off these.
|
|
2604
|
+
portable: true
|
|
2605
|
+
},
|
|
2606
|
+
filesIncluded
|
|
2607
|
+
);
|
|
2608
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
2609
|
+
stream.on("error", rejectDone);
|
|
2610
|
+
stream.on("end", resolveDone);
|
|
2611
|
+
});
|
|
2612
|
+
const bytes = Buffer.concat(chunks);
|
|
2613
|
+
if (bytes.byteLength > MAX_TARBALL_BYTES) {
|
|
2614
|
+
throw new Error(
|
|
2615
|
+
`packTarball: tarball is ${formatBytes(bytes.byteLength)}, exceeds ${formatBytes(MAX_TARBALL_BYTES)} max`
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
return { bytes, filesIncluded, warnings };
|
|
2619
|
+
}
|
|
2620
|
+
function walk(root, excludeSet) {
|
|
2621
|
+
const out2 = [];
|
|
2622
|
+
const stack = [root];
|
|
2623
|
+
while (stack.length > 0) {
|
|
2624
|
+
const dir = stack.pop();
|
|
2625
|
+
if (!dir) continue;
|
|
2626
|
+
let entries;
|
|
2627
|
+
try {
|
|
2628
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2629
|
+
} catch {
|
|
2630
|
+
continue;
|
|
2631
|
+
}
|
|
2632
|
+
for (const entry of entries) {
|
|
2633
|
+
if (excludeSet.has(entry.name)) continue;
|
|
2634
|
+
const abs = join(dir, entry.name);
|
|
2635
|
+
if (entry.isDirectory()) {
|
|
2636
|
+
stack.push(abs);
|
|
2637
|
+
} else if (entry.isFile()) {
|
|
2638
|
+
out2.push(abs);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
return out2;
|
|
2643
|
+
}
|
|
2644
|
+
function formatBytes(n) {
|
|
2645
|
+
if (n < 1024) return `${n}B`;
|
|
2646
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
2647
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// src/commands/module/publish.ts
|
|
2651
|
+
var VALID_BUMP = /* @__PURE__ */ new Set(["patch", "minor", "major"]);
|
|
2652
|
+
async function modulePublishCommand(options) {
|
|
2653
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2654
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2655
|
+
if (!existsSync(manifestPath2)) {
|
|
2656
|
+
fail(
|
|
2657
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init <slug>\` first.`
|
|
2658
|
+
);
|
|
2659
|
+
return 1;
|
|
2660
|
+
}
|
|
2661
|
+
let manifest;
|
|
2662
|
+
try {
|
|
2663
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
2664
|
+
} catch (err) {
|
|
2665
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
2666
|
+
return 1;
|
|
2667
|
+
}
|
|
2668
|
+
const slug = manifest.module?.slug;
|
|
2669
|
+
if (!slug) {
|
|
2670
|
+
fail(`${code("module.json")} is missing ${code("module.slug")}. Cannot publish.`);
|
|
2671
|
+
return 1;
|
|
2672
|
+
}
|
|
2673
|
+
if (!readConfig()) {
|
|
2674
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2675
|
+
return 1;
|
|
2676
|
+
}
|
|
2677
|
+
let bump = options.bump;
|
|
2678
|
+
if (!bump && process.stdout.isTTY) {
|
|
2679
|
+
intro(`Publishing module ${code(slug)}`);
|
|
2680
|
+
const answer = await select({
|
|
2681
|
+
message: "Bump direction?",
|
|
2682
|
+
options: [
|
|
2683
|
+
{ value: "patch", label: "patch \u2014 bug fixes only (recommended for first publish)" },
|
|
2684
|
+
{ value: "minor", label: "minor \u2014 additive changes (new fields, new tables)" },
|
|
2685
|
+
{ value: "major", label: "major \u2014 breaking changes" }
|
|
2686
|
+
],
|
|
2687
|
+
initialValue: "patch"
|
|
2688
|
+
});
|
|
2689
|
+
if (isCancel(answer)) {
|
|
2690
|
+
fail("Aborted.");
|
|
2691
|
+
return 130;
|
|
2692
|
+
}
|
|
2693
|
+
bump = answer;
|
|
2694
|
+
}
|
|
2695
|
+
if (!bump || !VALID_BUMP.has(bump)) {
|
|
2696
|
+
fail("Bump direction required: `--bump patch|minor|major`.");
|
|
2697
|
+
return 1;
|
|
2698
|
+
}
|
|
2699
|
+
let mod;
|
|
2700
|
+
try {
|
|
2701
|
+
mod = await apiRequest(
|
|
2702
|
+
"GET",
|
|
2703
|
+
`/api/modules/by-slug/${encodeURIComponent(slug)}`
|
|
2704
|
+
);
|
|
2705
|
+
} catch (err) {
|
|
2706
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
2707
|
+
fail(
|
|
2708
|
+
`Module ${code(slug)} not found on the backend. Create it via the UI / API first, then re-run publish.`
|
|
2709
|
+
);
|
|
2710
|
+
return 1;
|
|
2711
|
+
}
|
|
2712
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2713
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2714
|
+
return 1;
|
|
2715
|
+
}
|
|
2716
|
+
fail(`Could not resolve module: ${err.message}`);
|
|
2717
|
+
return 1;
|
|
2718
|
+
}
|
|
2719
|
+
info(
|
|
2720
|
+
`Module: ${code(mod.slug)} (id ${mod.id})${mod.current_version ? `, current ${code(`v${mod.current_version}`)}` : dim(", no published version yet")}`
|
|
2721
|
+
);
|
|
2722
|
+
let tarball;
|
|
2723
|
+
try {
|
|
2724
|
+
tarball = await packTarball({
|
|
2725
|
+
cwd: projectRoot,
|
|
2726
|
+
...options.exclude !== void 0 ? { extraExclude: options.exclude } : {}
|
|
2727
|
+
});
|
|
2728
|
+
} catch (err) {
|
|
2729
|
+
fail(`Could not pack tarball: ${err.message}`);
|
|
2730
|
+
return 1;
|
|
2731
|
+
}
|
|
2732
|
+
info(
|
|
2733
|
+
`Packed ${tarball.filesIncluded.length} file${tarball.filesIncluded.length === 1 ? "" : "s"} into ${formatBytes2(tarball.bytes.byteLength)} (cap ${formatBytes2(MAX_TARBALL_BYTES)}).`
|
|
2734
|
+
);
|
|
2735
|
+
for (const w of tarball.warnings) warn(w);
|
|
2736
|
+
if (!options.yes && process.stdout.isTTY && !options.dryRun) {
|
|
2737
|
+
const answer = await select({
|
|
2738
|
+
message: `Upload + publish ${code(`bump:${bump}`)}?`,
|
|
2739
|
+
options: [
|
|
2740
|
+
{ value: "go", label: "Yes, upload now" },
|
|
2741
|
+
{ value: "cancel", label: "No, abort" }
|
|
2742
|
+
],
|
|
2743
|
+
initialValue: "go"
|
|
2744
|
+
});
|
|
2745
|
+
if (isCancel(answer) || answer === "cancel") {
|
|
2746
|
+
fail("Aborted.");
|
|
2747
|
+
return 130;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
const cfg = readConfig();
|
|
2751
|
+
if (!cfg) {
|
|
2752
|
+
fail("Session vanished mid-publish \u2014 re-run `dashwise login`.");
|
|
2753
|
+
return 1;
|
|
2754
|
+
}
|
|
2755
|
+
const url = `${resolveApiUrl()}/api/modules/${mod.id}/publish/cli`;
|
|
2756
|
+
const form = new FormData();
|
|
2757
|
+
const blob = new Blob([new Uint8Array(tarball.bytes)], { type: "application/gzip" });
|
|
2758
|
+
form.set("file", blob, "module.tar.gz");
|
|
2759
|
+
form.set("bump", bump);
|
|
2760
|
+
if (options.dryRun) form.set("dry_run", "true");
|
|
2761
|
+
let res;
|
|
2762
|
+
try {
|
|
2763
|
+
res = await fetch(url, {
|
|
2764
|
+
method: "POST",
|
|
2765
|
+
headers: {
|
|
2766
|
+
Accept: "application/json",
|
|
2767
|
+
Authorization: `Bearer ${cfg.token}`
|
|
2768
|
+
},
|
|
2769
|
+
body: form
|
|
2770
|
+
});
|
|
2771
|
+
} catch (err) {
|
|
2772
|
+
fail(`Network error during upload: ${err.message}`);
|
|
2773
|
+
return 1;
|
|
2774
|
+
}
|
|
2775
|
+
const body = await res.json().catch(() => null);
|
|
2776
|
+
if (!res.ok) {
|
|
2777
|
+
const env = body && typeof body === "object" ? body : null;
|
|
2778
|
+
fail(
|
|
2779
|
+
`Publish failed (${res.status}${env?.code ? ` ${env.code}` : ""}): ${env?.message ?? "(no message)"}`
|
|
2780
|
+
);
|
|
2781
|
+
if (env?.context) {
|
|
2782
|
+
out(JSON.stringify(env.context, null, 2));
|
|
2783
|
+
}
|
|
2784
|
+
return 1;
|
|
2785
|
+
}
|
|
2786
|
+
const result = body;
|
|
2787
|
+
if (!result) {
|
|
2788
|
+
fail("Publish succeeded but the response body was empty or malformed.");
|
|
2789
|
+
return 1;
|
|
2790
|
+
}
|
|
2791
|
+
if (options.dryRun) {
|
|
2792
|
+
success(`Dry run OK \u2014 would publish ${code(`v${result.version}`)}`);
|
|
2793
|
+
} else {
|
|
2794
|
+
success(`Published ${code(mod.slug)} ${code(`v${result.version}`)}`);
|
|
2795
|
+
}
|
|
2796
|
+
info(`Bundle: ${code(result.bundle_url)}`);
|
|
2797
|
+
info(`Hash: ${dim(result.bundle_hash)}`);
|
|
2798
|
+
info(`Size: ${formatBytes2(result.bytes_uploaded)}`);
|
|
2799
|
+
if (result.warnings.length > 0) {
|
|
2800
|
+
warn(`${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"}:`);
|
|
2801
|
+
for (const w of result.warnings) {
|
|
2802
|
+
out(` ${dim("\u2022")} ${w}`);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
if (process.stdout.isTTY && !options.dryRun) outro("Done.");
|
|
2806
|
+
return 0;
|
|
2807
|
+
}
|
|
2808
|
+
function formatBytes2(n) {
|
|
2809
|
+
if (n < 1024) return `${n}B`;
|
|
2810
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
2811
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
2812
|
+
}
|
|
2813
|
+
async function modulePullDataCommand(options) {
|
|
2814
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2815
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2816
|
+
if (!existsSync(manifestPath2)) {
|
|
2817
|
+
fail(
|
|
2818
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init <slug>\` first.`
|
|
2819
|
+
);
|
|
2820
|
+
return 1;
|
|
2821
|
+
}
|
|
2822
|
+
let manifest;
|
|
2823
|
+
try {
|
|
2824
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
2827
|
+
return 1;
|
|
2828
|
+
}
|
|
2829
|
+
const slug = manifest.module?.slug;
|
|
2830
|
+
if (!slug) {
|
|
2831
|
+
fail(`${code("module.json")} is missing ${code("module.slug")}.`);
|
|
2832
|
+
return 1;
|
|
2833
|
+
}
|
|
2834
|
+
let mod;
|
|
2835
|
+
try {
|
|
2836
|
+
mod = await apiRequest(
|
|
2837
|
+
"GET",
|
|
2838
|
+
`/api/modules/by-slug/${encodeURIComponent(slug)}`
|
|
2839
|
+
);
|
|
2840
|
+
} catch (err) {
|
|
2841
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
2842
|
+
fail(`Module ${code(slug)} not found on the backend.`);
|
|
2843
|
+
return 1;
|
|
2844
|
+
}
|
|
2845
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2846
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2847
|
+
return 1;
|
|
2848
|
+
}
|
|
2849
|
+
fail(`Could not resolve module: ${err.message}`);
|
|
2850
|
+
return 1;
|
|
2851
|
+
}
|
|
2852
|
+
const limit = options.limit ?? 100;
|
|
2853
|
+
let res;
|
|
2854
|
+
try {
|
|
2855
|
+
res = await apiRequest(
|
|
2856
|
+
"GET",
|
|
2857
|
+
`/api/modules/${mod.id}/own-data?limit=${limit}`
|
|
2858
|
+
);
|
|
2859
|
+
} catch (err) {
|
|
2860
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
2861
|
+
fail(`Invalid request: ${err.message}`);
|
|
2862
|
+
return 1;
|
|
2863
|
+
}
|
|
2864
|
+
fail(`Could not pull data: ${err.message}`);
|
|
2865
|
+
return 1;
|
|
2866
|
+
}
|
|
2867
|
+
const outPath = options.out ? resolve(options.out) : join(projectRoot, "dashwise-data.json");
|
|
2868
|
+
writeFileSync(outPath, JSON.stringify(res, null, 2) + "\n", "utf-8");
|
|
2869
|
+
let totalRows = 0;
|
|
2870
|
+
for (const t of res.tables) totalRows += t.rows.length;
|
|
2871
|
+
success(`Wrote ${totalRows} row${totalRows === 1 ? "" : "s"} across ${res.tables.length} table${res.tables.length === 1 ? "" : "s"} to ${code(outPath)}`);
|
|
2872
|
+
for (const t of res.tables) {
|
|
2873
|
+
if (t.total_rows > t.rows.length) {
|
|
2874
|
+
warn(
|
|
2875
|
+
`Table ${code(t.slug)} truncated: ${t.rows.length} of ${t.total_rows} rows (use ${code(`--limit ${Math.min(t.total_rows, 1e3)}`)} to grab more).`
|
|
2876
|
+
);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
if (res.tables.length === 0) {
|
|
2880
|
+
info(dim("No owned tables on this module. (Did you publish a manifest with `owns.tables`?)"));
|
|
2881
|
+
}
|
|
2882
|
+
return 0;
|
|
2883
|
+
}
|
|
2884
|
+
async function moduleValidateCommand(options) {
|
|
2885
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
2886
|
+
const manifestPath2 = join(projectRoot, "module.json");
|
|
2887
|
+
if (!existsSync(manifestPath2)) {
|
|
2888
|
+
fail(
|
|
2889
|
+
`No ${code("module.json")} found in ${code(projectRoot)}. Run \`dashwise module init <slug>\` first.`
|
|
2890
|
+
);
|
|
2891
|
+
return 1;
|
|
2892
|
+
}
|
|
2893
|
+
let manifest;
|
|
2894
|
+
try {
|
|
2895
|
+
manifest = JSON.parse(readFileSync(manifestPath2, "utf-8"));
|
|
2896
|
+
} catch (err) {
|
|
2897
|
+
fail(`Could not parse ${code(manifestPath2)}: ${err.message}`);
|
|
2898
|
+
return 1;
|
|
2899
|
+
}
|
|
2900
|
+
try {
|
|
2901
|
+
const res = await apiRequest(
|
|
2902
|
+
"POST",
|
|
2903
|
+
"/api/modules/manifest/validate",
|
|
2904
|
+
manifest
|
|
2905
|
+
);
|
|
2906
|
+
if (res.ok) {
|
|
2907
|
+
success("Manifest is valid.");
|
|
2908
|
+
return 0;
|
|
2909
|
+
}
|
|
2910
|
+
fail(`Unexpected response shape: ${JSON.stringify(res)}`);
|
|
2911
|
+
return 1;
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
if (err instanceof ApiError && err.status === 400) {
|
|
2914
|
+
const issues = readIssues(err.context);
|
|
2915
|
+
if (issues.length > 0) {
|
|
2916
|
+
fail(`Manifest is invalid (${issues.length} issue${issues.length === 1 ? "" : "s"}):`);
|
|
2917
|
+
for (const issue of issues) {
|
|
2918
|
+
const path = issue.path ?? "(root)";
|
|
2919
|
+
const message = issue.message ?? "(no message)";
|
|
2920
|
+
out(` ${dim("\u2022")} ${code(path)}: ${message}`);
|
|
2921
|
+
}
|
|
2922
|
+
} else {
|
|
2923
|
+
fail(`Manifest is invalid: ${err.message}`);
|
|
2924
|
+
}
|
|
2925
|
+
return 1;
|
|
2926
|
+
}
|
|
2927
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
2928
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
2929
|
+
return 1;
|
|
2930
|
+
}
|
|
2931
|
+
fail(`Could not validate manifest: ${err.message}`);
|
|
2932
|
+
return 1;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
function readIssues(context) {
|
|
2936
|
+
if (!context) return [];
|
|
2937
|
+
const v = context["issues"];
|
|
2938
|
+
if (!Array.isArray(v)) return [];
|
|
2939
|
+
return v.map((entry) => {
|
|
2940
|
+
if (!entry || typeof entry !== "object") return null;
|
|
2941
|
+
const e = entry;
|
|
2942
|
+
const issue = {};
|
|
2943
|
+
if (typeof e.path === "string") issue.path = e.path;
|
|
2944
|
+
if (typeof e.message === "string") issue.message = e.message;
|
|
2945
|
+
return issue;
|
|
2946
|
+
}).filter((x) => x !== null);
|
|
2947
|
+
}
|
|
2948
|
+
function manifestPath(dir) {
|
|
2949
|
+
return join(resolve(dir ?? process.cwd()), "module.json");
|
|
2950
|
+
}
|
|
2951
|
+
function readManifest(dir) {
|
|
2952
|
+
const path = manifestPath(dir);
|
|
2953
|
+
if (!existsSync(path)) return null;
|
|
2954
|
+
const raw = readFileSync(path, "utf-8");
|
|
2955
|
+
try {
|
|
2956
|
+
return JSON.parse(raw);
|
|
2957
|
+
} catch (err) {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`Failed to parse ${path}: ${err.message}. Fix or delete the file and re-run.`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
function writeManifest(manifest, dir) {
|
|
2964
|
+
const path = manifestPath(dir);
|
|
2965
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
2966
|
+
}
|
|
2967
|
+
function readManifestOrThrow(dir) {
|
|
2968
|
+
const manifest = readManifest(dir);
|
|
2969
|
+
if (!manifest) {
|
|
2970
|
+
throw new Error(
|
|
2971
|
+
`No module.json found in ${resolve(dir ?? process.cwd())}. Run \`dashwise module init\` first or pass \`--dir <path>\`.`
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
return manifest;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// src/commands/deps/add.ts
|
|
2978
|
+
var MIN_PURPOSE_LEN = 10;
|
|
2979
|
+
var MAX_PURPOSE_LEN = 500;
|
|
2980
|
+
async function depsAddCommand(providerSlug, options) {
|
|
2981
|
+
if (!providerSlug || typeof providerSlug !== "string") {
|
|
2982
|
+
fail("Usage: `dashwise deps add <provider-slug>`");
|
|
2983
|
+
return 1;
|
|
2984
|
+
}
|
|
2985
|
+
if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(providerSlug)) {
|
|
2986
|
+
fail(
|
|
2987
|
+
`Provider slug ${code(providerSlug)} is invalid. Module slugs are kebab-case (e.g. ${code("crm-app")}).`
|
|
2988
|
+
);
|
|
2989
|
+
return 1;
|
|
2990
|
+
}
|
|
2991
|
+
let manifest;
|
|
2992
|
+
try {
|
|
2993
|
+
manifest = readManifestOrThrow(options.dir);
|
|
2994
|
+
} catch (err) {
|
|
2995
|
+
fail(err.message);
|
|
2996
|
+
return 1;
|
|
2997
|
+
}
|
|
2998
|
+
if (manifest.module?.slug === providerSlug) {
|
|
2999
|
+
fail("A module cannot depend on itself.");
|
|
3000
|
+
return 1;
|
|
3001
|
+
}
|
|
3002
|
+
const isInteractive = process.stdout.isTTY;
|
|
3003
|
+
const hasAllNonInteractive = options.table !== void 0 && options.fields !== void 0 && options.purpose !== void 0;
|
|
3004
|
+
if (!isInteractive && !hasAllNonInteractive) {
|
|
3005
|
+
fail(
|
|
3006
|
+
`Non-interactive mode requires ${code("--table")}, ${code("--fields")}, and ${code("--purpose")}.`
|
|
3007
|
+
);
|
|
3008
|
+
return 1;
|
|
3009
|
+
}
|
|
3010
|
+
let providerSummary = null;
|
|
3011
|
+
let resolvedVersion = null;
|
|
3012
|
+
try {
|
|
3013
|
+
const mod = await apiRequest(
|
|
3014
|
+
"GET",
|
|
3015
|
+
`/api/modules/by-slug/${encodeURIComponent(providerSlug)}`
|
|
3016
|
+
);
|
|
3017
|
+
if (mod.current_version) {
|
|
3018
|
+
const versions = await apiRequest(
|
|
3019
|
+
"GET",
|
|
3020
|
+
`/api/modules/${mod.id}/versions`
|
|
3021
|
+
);
|
|
3022
|
+
const current = versions.find((v) => v.version === mod.current_version) ?? versions[0];
|
|
3023
|
+
if (current) {
|
|
3024
|
+
providerSummary = current.manifest_jsonb;
|
|
3025
|
+
resolvedVersion = current.version;
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
3030
|
+
if (!isInteractive) {
|
|
3031
|
+
fail(
|
|
3032
|
+
`Provider ${code(providerSlug)} not found on the backend. Publish it first or check the slug.`
|
|
3033
|
+
);
|
|
3034
|
+
return 1;
|
|
3035
|
+
}
|
|
3036
|
+
warn(
|
|
3037
|
+
`Provider ${code(providerSlug)} not found on the backend (404). You can still declare the dep manually.`
|
|
3038
|
+
);
|
|
3039
|
+
} else if (err instanceof ApiError && err.status === 401) {
|
|
3040
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
3041
|
+
return 1;
|
|
3042
|
+
} else {
|
|
3043
|
+
warn(`Could not fetch provider summary: ${err.message}`);
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
let entry;
|
|
3047
|
+
if (isInteractive) {
|
|
3048
|
+
intro(`Adding dependency on ${code(providerSlug)}`);
|
|
3049
|
+
const built = await collectInteractive(
|
|
3050
|
+
providerSlug,
|
|
3051
|
+
providerSummary,
|
|
3052
|
+
resolvedVersion,
|
|
3053
|
+
options
|
|
3054
|
+
);
|
|
3055
|
+
if (built === null) {
|
|
3056
|
+
outro("Cancelled.");
|
|
3057
|
+
return 130;
|
|
3058
|
+
}
|
|
3059
|
+
entry = built;
|
|
3060
|
+
} else {
|
|
3061
|
+
const fieldList = (options.fields ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3062
|
+
if (fieldList.length === 0) {
|
|
3063
|
+
fail(`${code("--fields")} must list at least one field slug.`);
|
|
3064
|
+
return 1;
|
|
3065
|
+
}
|
|
3066
|
+
const purposeErr = validatePurpose(options.purpose);
|
|
3067
|
+
if (purposeErr) {
|
|
3068
|
+
fail(purposeErr);
|
|
3069
|
+
return 1;
|
|
3070
|
+
}
|
|
3071
|
+
entry = {
|
|
3072
|
+
module: providerSlug,
|
|
3073
|
+
version: options.version ?? (resolvedVersion ? `^${resolvedVersion}` : "^0.0.1"),
|
|
3074
|
+
reads: [{ table: options.table, fields: fieldList }],
|
|
3075
|
+
purpose: options.purpose
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
const deps = manifest.dependencies ?? [];
|
|
3079
|
+
const existingIdx = deps.findIndex((d) => d.module === providerSlug);
|
|
3080
|
+
if (existingIdx >= 0) {
|
|
3081
|
+
if (!options.yes && isInteractive) {
|
|
3082
|
+
const answer = await select({
|
|
3083
|
+
message: `Dep on ${providerSlug} already exists. Replace it?`,
|
|
3084
|
+
options: [
|
|
3085
|
+
{ value: "replace", label: "Replace (loses prior reads/writes)" },
|
|
3086
|
+
{ value: "cancel", label: "Cancel" }
|
|
3087
|
+
],
|
|
3088
|
+
initialValue: "replace"
|
|
3089
|
+
});
|
|
3090
|
+
if (isCancel(answer) || answer === "cancel") {
|
|
3091
|
+
outro("Cancelled.");
|
|
3092
|
+
return 130;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
deps[existingIdx] = entry;
|
|
3096
|
+
} else {
|
|
3097
|
+
deps.push(entry);
|
|
3098
|
+
}
|
|
3099
|
+
manifest.dependencies = deps;
|
|
3100
|
+
try {
|
|
3101
|
+
writeManifest(manifest, options.dir);
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
fail(`Could not write manifest: ${err.message}`);
|
|
3104
|
+
return 1;
|
|
3105
|
+
}
|
|
3106
|
+
success(
|
|
3107
|
+
`${existingIdx >= 0 ? "Replaced" : "Added"} dependency on ${code(providerSlug)}@${entry.version}.`
|
|
3108
|
+
);
|
|
3109
|
+
info(`Reads: ${entry.reads.map((r) => `${code(r.table)} (${r.fields.join(", ")})`).join("; ")}`);
|
|
3110
|
+
info(`Run ${code("dashwise module generate")} to refresh typed wrappers.`);
|
|
3111
|
+
if (isInteractive) outro("Done.");
|
|
3112
|
+
return 0;
|
|
3113
|
+
}
|
|
3114
|
+
async function collectInteractive(providerSlug, summary, resolvedVersion, options) {
|
|
3115
|
+
const reads = [];
|
|
3116
|
+
if (summary && summary.exposes && summary.exposes.length > 0) {
|
|
3117
|
+
info(
|
|
3118
|
+
`Provider exposes ${summary.exposes.length} table${summary.exposes.length === 1 ? "" : "s"}.`
|
|
3119
|
+
);
|
|
3120
|
+
const readableTables = summary.exposes.filter((e) => {
|
|
3121
|
+
const ops = e.operations;
|
|
3122
|
+
if (!ops) return true;
|
|
3123
|
+
return ops.list?.allowed === true || ops.get?.allowed === true;
|
|
3124
|
+
});
|
|
3125
|
+
if (readableTables.length === 0) {
|
|
3126
|
+
warn(
|
|
3127
|
+
`Provider ${code(providerSlug)} exposes tables but none are readable (operations.list / .get all denied).`
|
|
3128
|
+
);
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
const picked = await multiselect({
|
|
3132
|
+
message: "Which tables do you want to read from?",
|
|
3133
|
+
options: readableTables.map((e) => ({
|
|
3134
|
+
value: e.table,
|
|
3135
|
+
label: `${e.table} ${dim(`(${e.fields.length} field${e.fields.length === 1 ? "" : "s"})`)}`
|
|
3136
|
+
})),
|
|
3137
|
+
required: true
|
|
3138
|
+
});
|
|
3139
|
+
if (isCancel(picked)) return null;
|
|
3140
|
+
for (const tableSlug of picked) {
|
|
3141
|
+
const exposeEntry = readableTables.find((e) => e.table === tableSlug);
|
|
3142
|
+
const fieldsPicked = await multiselect({
|
|
3143
|
+
message: `Fields to read from ${code(tableSlug)}:`,
|
|
3144
|
+
options: exposeEntry.fields.map((f) => ({ value: f, label: f })),
|
|
3145
|
+
required: true,
|
|
3146
|
+
initialValues: exposeEntry.fields
|
|
3147
|
+
// pre-select all; user trims down
|
|
3148
|
+
});
|
|
3149
|
+
if (isCancel(fieldsPicked)) return null;
|
|
3150
|
+
reads.push({ table: tableSlug, fields: fieldsPicked });
|
|
3151
|
+
}
|
|
3152
|
+
} else {
|
|
3153
|
+
info("No provider summary available \u2014 enter table + fields manually.");
|
|
3154
|
+
const tableSlug = await text({
|
|
3155
|
+
message: "Provider table slug to read from:",
|
|
3156
|
+
placeholder: "contacts",
|
|
3157
|
+
validate: (v) => /^[a-z][a-z0-9_]*[a-z0-9]$/.test(v) ? void 0 : "Table slugs are snake_case."
|
|
3158
|
+
});
|
|
3159
|
+
if (isCancel(tableSlug)) return null;
|
|
3160
|
+
const fieldsStr = await text({
|
|
3161
|
+
message: "Comma-separated field slugs:",
|
|
3162
|
+
placeholder: "name, email, company",
|
|
3163
|
+
validate: (v) => v.split(",").map((s) => s.trim()).filter(Boolean).length > 0 ? void 0 : "At least one field required."
|
|
3164
|
+
});
|
|
3165
|
+
if (isCancel(fieldsStr)) return null;
|
|
3166
|
+
reads.push({
|
|
3167
|
+
table: tableSlug,
|
|
3168
|
+
fields: fieldsStr.split(",").map((s) => s.trim()).filter(Boolean)
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
const initialPurpose = options.purpose ?? "";
|
|
3172
|
+
const purpose = await text({
|
|
3173
|
+
message: `Why does your module need this dependency? ${dim(`(${MIN_PURPOSE_LEN}-${MAX_PURPOSE_LEN} chars; shown to workspace admins at install consent)`)}`,
|
|
3174
|
+
placeholder: "Look up assignee contact info when rendering tasks.",
|
|
3175
|
+
initialValue: initialPurpose,
|
|
3176
|
+
validate: (v) => validatePurpose(v) ?? void 0
|
|
3177
|
+
});
|
|
3178
|
+
if (isCancel(purpose)) return null;
|
|
3179
|
+
const defaultVersion = options.version ?? (resolvedVersion ? `^${resolvedVersion}` : "^0.0.1");
|
|
3180
|
+
const version = await text({
|
|
3181
|
+
message: `SemVer range to pin: ${dim("(npm-style; e.g. ^1.2.3, ~1.2.0, >=1.0.0)")}`,
|
|
3182
|
+
placeholder: defaultVersion,
|
|
3183
|
+
initialValue: defaultVersion,
|
|
3184
|
+
validate: (v) => /^[\^~]?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$/.test(v) || /^(>=|<=|>|<|=)?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$/.test(v) ? void 0 : "Must be a npm-style semver range (e.g. ^1.2.3)."
|
|
3185
|
+
});
|
|
3186
|
+
if (isCancel(version)) return null;
|
|
3187
|
+
return {
|
|
3188
|
+
module: providerSlug,
|
|
3189
|
+
version,
|
|
3190
|
+
reads,
|
|
3191
|
+
purpose
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
function validatePurpose(purpose) {
|
|
3195
|
+
if (typeof purpose !== "string") return "Purpose must be a string.";
|
|
3196
|
+
if (purpose.length < MIN_PURPOSE_LEN) {
|
|
3197
|
+
return `Purpose must be at least ${MIN_PURPOSE_LEN} characters; got ${purpose.length}.`;
|
|
3198
|
+
}
|
|
3199
|
+
if (purpose.length > MAX_PURPOSE_LEN) {
|
|
3200
|
+
return `Purpose must be at most ${MAX_PURPOSE_LEN} characters; got ${purpose.length}.`;
|
|
3201
|
+
}
|
|
3202
|
+
return null;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// src/commands/deps/check.ts
|
|
3206
|
+
async function depsCheckCommand(options) {
|
|
3207
|
+
let manifest;
|
|
3208
|
+
try {
|
|
3209
|
+
manifest = readManifestOrThrow(options.dir);
|
|
3210
|
+
} catch (err) {
|
|
3211
|
+
fail(err.message);
|
|
3212
|
+
return 1;
|
|
3213
|
+
}
|
|
3214
|
+
const deps = manifest.dependencies ?? [];
|
|
3215
|
+
if (deps.length === 0) {
|
|
3216
|
+
if (options.json) {
|
|
3217
|
+
out(JSON.stringify({ ok: true, deps: [], issues: [] }, null, 2));
|
|
3218
|
+
} else {
|
|
3219
|
+
info("No dependencies declared. Nothing to check.");
|
|
3220
|
+
}
|
|
3221
|
+
return 0;
|
|
3222
|
+
}
|
|
3223
|
+
const issues = [];
|
|
3224
|
+
const perDepStatus = [];
|
|
3225
|
+
for (const dep of deps) {
|
|
3226
|
+
let mod;
|
|
3227
|
+
try {
|
|
3228
|
+
mod = await apiRequest(
|
|
3229
|
+
"GET",
|
|
3230
|
+
`/api/modules/by-slug/${encodeURIComponent(dep.module)}`
|
|
3231
|
+
);
|
|
3232
|
+
} catch (err) {
|
|
3233
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
3234
|
+
issues.push({
|
|
3235
|
+
code: "PROVIDER_NOT_FOUND",
|
|
3236
|
+
module: dep.module,
|
|
3237
|
+
message: `Provider ${dep.module} not found on the backend. It may have been deleted or renamed.`
|
|
3238
|
+
});
|
|
3239
|
+
perDepStatus.push({ module: dep.module, ok: false, resolved_version: null });
|
|
3240
|
+
continue;
|
|
3241
|
+
}
|
|
3242
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
3243
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
3244
|
+
return 1;
|
|
3245
|
+
}
|
|
3246
|
+
issues.push({
|
|
3247
|
+
code: "BACKEND_UNREACHABLE",
|
|
3248
|
+
module: dep.module,
|
|
3249
|
+
message: `Could not reach the backend: ${err.message}`
|
|
3250
|
+
});
|
|
3251
|
+
perDepStatus.push({ module: dep.module, ok: false, resolved_version: null });
|
|
3252
|
+
continue;
|
|
3253
|
+
}
|
|
3254
|
+
let versions;
|
|
3255
|
+
try {
|
|
3256
|
+
versions = await apiRequest(
|
|
3257
|
+
"GET",
|
|
3258
|
+
`/api/modules/${mod.id}/versions`
|
|
3259
|
+
);
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
issues.push({
|
|
3262
|
+
code: "BACKEND_UNREACHABLE",
|
|
3263
|
+
module: dep.module,
|
|
3264
|
+
message: `Could not list versions: ${err.message}`
|
|
3265
|
+
});
|
|
3266
|
+
perDepStatus.push({ module: dep.module, ok: false, resolved_version: null });
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
const matching = pickMatching(versions, dep.version);
|
|
3270
|
+
if (!matching) {
|
|
3271
|
+
issues.push({
|
|
3272
|
+
code: "NO_MATCHING_VERSION",
|
|
3273
|
+
module: dep.module,
|
|
3274
|
+
message: `No published version satisfies ${dep.version}. Available: ${versions.map((v) => v.version).join(", ") || "(none)"}.`,
|
|
3275
|
+
context: { range: dep.version, available: versions.map((v) => v.version) }
|
|
3276
|
+
});
|
|
3277
|
+
perDepStatus.push({ module: dep.module, ok: false, resolved_version: null });
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
const exposes = matching.manifest_jsonb.exposes ?? [];
|
|
3281
|
+
let depHealthy = true;
|
|
3282
|
+
for (const readEntry of dep.reads) {
|
|
3283
|
+
const exposeEntry = exposes.find((e) => e.table === readEntry.table);
|
|
3284
|
+
if (!exposeEntry) {
|
|
3285
|
+
issues.push({
|
|
3286
|
+
code: "TABLE_NOT_EXPOSED",
|
|
3287
|
+
module: dep.module,
|
|
3288
|
+
message: `Table ${code(readEntry.table)} is no longer exposed by ${dep.module}@${matching.version}.`,
|
|
3289
|
+
context: { table: readEntry.table, resolved_version: matching.version }
|
|
3290
|
+
});
|
|
3291
|
+
depHealthy = false;
|
|
3292
|
+
continue;
|
|
3293
|
+
}
|
|
3294
|
+
const exposedFields = new Set(exposeEntry.fields);
|
|
3295
|
+
const missing = readEntry.fields.filter((f) => !exposedFields.has(f));
|
|
3296
|
+
if (missing.length > 0) {
|
|
3297
|
+
issues.push({
|
|
3298
|
+
code: "FIELD_NOT_EXPOSED",
|
|
3299
|
+
module: dep.module,
|
|
3300
|
+
message: `Fields ${missing.map((f) => code(f)).join(", ")} on ${code(readEntry.table)} are no longer exposed by ${dep.module}@${matching.version}.`,
|
|
3301
|
+
context: {
|
|
3302
|
+
table: readEntry.table,
|
|
3303
|
+
missing,
|
|
3304
|
+
resolved_version: matching.version
|
|
3305
|
+
}
|
|
3306
|
+
});
|
|
3307
|
+
depHealthy = false;
|
|
3308
|
+
}
|
|
3309
|
+
const listAllowed = exposeEntry.operations?.list?.allowed !== false;
|
|
3310
|
+
const getAllowed = exposeEntry.operations?.get?.allowed !== false;
|
|
3311
|
+
if (!listAllowed && !getAllowed) {
|
|
3312
|
+
issues.push({
|
|
3313
|
+
code: "OP_NOT_ALLOWED",
|
|
3314
|
+
module: dep.module,
|
|
3315
|
+
message: `Provider ${dep.module}@${matching.version} disallows both list AND get on ${code(readEntry.table)} \u2014 no reads possible.`,
|
|
3316
|
+
context: { table: readEntry.table, resolved_version: matching.version }
|
|
3317
|
+
});
|
|
3318
|
+
depHealthy = false;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
for (const writeEntry of dep.writes ?? []) {
|
|
3322
|
+
const exposeEntry = exposes.find((e) => e.table === writeEntry.table);
|
|
3323
|
+
if (!exposeEntry) continue;
|
|
3324
|
+
for (const op of writeEntry.operations) {
|
|
3325
|
+
const gate = exposeEntry.operations?.[op];
|
|
3326
|
+
if (!gate || gate.allowed !== true) {
|
|
3327
|
+
issues.push({
|
|
3328
|
+
code: "OP_NOT_ALLOWED",
|
|
3329
|
+
module: dep.module,
|
|
3330
|
+
message: `Write operation ${code(op)} on ${code(writeEntry.table)} is not allowed by ${dep.module}@${matching.version}.`,
|
|
3331
|
+
context: {
|
|
3332
|
+
table: writeEntry.table,
|
|
3333
|
+
operation: op,
|
|
3334
|
+
resolved_version: matching.version
|
|
3335
|
+
}
|
|
3336
|
+
});
|
|
3337
|
+
depHealthy = false;
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
perDepStatus.push({
|
|
3342
|
+
module: dep.module,
|
|
3343
|
+
ok: depHealthy,
|
|
3344
|
+
resolved_version: matching.version
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
const ok = issues.length === 0;
|
|
3348
|
+
if (options.json) {
|
|
3349
|
+
out(
|
|
3350
|
+
JSON.stringify(
|
|
3351
|
+
{ ok, deps: perDepStatus, issues },
|
|
3352
|
+
null,
|
|
3353
|
+
2
|
|
3354
|
+
)
|
|
3355
|
+
);
|
|
3356
|
+
return ok ? 0 : 1;
|
|
3357
|
+
}
|
|
3358
|
+
for (const dep of perDepStatus) {
|
|
3359
|
+
if (dep.ok) {
|
|
3360
|
+
success(
|
|
3361
|
+
`${code(dep.module)} \u2192 ${dep.resolved_version ? `v${dep.resolved_version}` : dim("(no version)")}`
|
|
3362
|
+
);
|
|
3363
|
+
} else {
|
|
3364
|
+
fail(`${code(dep.module)} ${dim("(drift detected)")}`);
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
if (issues.length > 0) {
|
|
3368
|
+
out("");
|
|
3369
|
+
warn(`${issues.length} issue${issues.length === 1 ? "" : "s"}:`);
|
|
3370
|
+
for (const issue of issues) {
|
|
3371
|
+
out(` ${dim(`[${issue.code}]`)} ${issue.message}`);
|
|
3372
|
+
}
|
|
3373
|
+
out("");
|
|
3374
|
+
info(
|
|
3375
|
+
`Fix by removing/updating the dep (${code("dashwise deps remove <slug>")} then ${code("dashwise deps add <slug>")}) or by updating the provider.`
|
|
3376
|
+
);
|
|
3377
|
+
} else {
|
|
3378
|
+
out("");
|
|
3379
|
+
success(`All ${perDepStatus.length} dependenc${perDepStatus.length === 1 ? "y is" : "ies are"} healthy.`);
|
|
3380
|
+
}
|
|
3381
|
+
return ok ? 0 : 1;
|
|
3382
|
+
}
|
|
3383
|
+
function pickMatching(versions, range) {
|
|
3384
|
+
for (const v of versions) {
|
|
3385
|
+
if (rangeSatisfied(v.version, range)) return v;
|
|
3386
|
+
}
|
|
3387
|
+
return null;
|
|
3388
|
+
}
|
|
3389
|
+
function rangeSatisfied(version, range) {
|
|
3390
|
+
const vParts = parseSemver(version);
|
|
3391
|
+
if (!vParts) return false;
|
|
3392
|
+
const trimmed = range.trim();
|
|
3393
|
+
const opMatch = trimmed.match(/^(>=|<=|>|<|=|\^|~)?(.+)$/);
|
|
3394
|
+
if (!opMatch) return false;
|
|
3395
|
+
const op = opMatch[1] ?? "=";
|
|
3396
|
+
const target = parseSemver(opMatch[2]);
|
|
3397
|
+
if (!target) return false;
|
|
3398
|
+
const cmp = compareSemver(vParts, target);
|
|
3399
|
+
switch (op) {
|
|
3400
|
+
case "=":
|
|
3401
|
+
return cmp === 0;
|
|
3402
|
+
case ">":
|
|
3403
|
+
return cmp > 0;
|
|
3404
|
+
case "<":
|
|
3405
|
+
return cmp < 0;
|
|
3406
|
+
case ">=":
|
|
3407
|
+
return cmp >= 0;
|
|
3408
|
+
case "<=":
|
|
3409
|
+
return cmp <= 0;
|
|
3410
|
+
case "^":
|
|
3411
|
+
if (cmp < 0) return false;
|
|
3412
|
+
if (target[0] > 0) return vParts[0] === target[0];
|
|
3413
|
+
if (target[1] > 0) return vParts[0] === 0 && vParts[1] === target[1];
|
|
3414
|
+
return vParts[0] === 0 && vParts[1] === 0 && vParts[2] === target[2];
|
|
3415
|
+
case "~":
|
|
3416
|
+
if (cmp < 0) return false;
|
|
3417
|
+
return vParts[0] === target[0] && vParts[1] === target[1];
|
|
3418
|
+
default:
|
|
3419
|
+
return false;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
function parseSemver(s) {
|
|
3423
|
+
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-[a-zA-Z0-9.-]+)?$/);
|
|
3424
|
+
if (!m) return null;
|
|
3425
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
3426
|
+
}
|
|
3427
|
+
function compareSemver(a, b) {
|
|
3428
|
+
for (let i = 0; i < 3; i++) {
|
|
3429
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
3430
|
+
}
|
|
3431
|
+
return 0;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// src/commands/deps/list.ts
|
|
3435
|
+
async function depsListCommand(options) {
|
|
3436
|
+
let manifest;
|
|
3437
|
+
try {
|
|
3438
|
+
manifest = readManifestOrThrow(options.dir);
|
|
3439
|
+
} catch (err) {
|
|
3440
|
+
fail(err.message);
|
|
3441
|
+
return 1;
|
|
3442
|
+
}
|
|
3443
|
+
const deps = manifest.dependencies ?? [];
|
|
3444
|
+
if (options.json) {
|
|
3445
|
+
out(JSON.stringify(deps, null, 2));
|
|
3446
|
+
return 0;
|
|
3447
|
+
}
|
|
3448
|
+
if (deps.length === 0) {
|
|
3449
|
+
info("No dependencies declared in module.json.");
|
|
3450
|
+
info(`Run ${code("dashwise deps add <provider-slug>")} to add one.`);
|
|
3451
|
+
return 0;
|
|
3452
|
+
}
|
|
3453
|
+
out(`${deps.length} dependenc${deps.length === 1 ? "y" : "ies"} declared:`);
|
|
3454
|
+
out("");
|
|
3455
|
+
for (const dep of deps) {
|
|
3456
|
+
out(` ${code(dep.module)}@${dep.version}`);
|
|
3457
|
+
out(` ${dim("purpose:")} ${dep.purpose}`);
|
|
3458
|
+
if (dep.reads.length > 0) {
|
|
3459
|
+
out(` ${dim("reads:")}`);
|
|
3460
|
+
for (const r of dep.reads) {
|
|
3461
|
+
out(` ${code(r.table)} \u2192 ${r.fields.join(", ")}`);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
if (dep.writes && dep.writes.length > 0) {
|
|
3465
|
+
out(` ${dim("writes:")}`);
|
|
3466
|
+
for (const w of dep.writes) {
|
|
3467
|
+
out(
|
|
3468
|
+
` ${code(w.table)} \u2192 ${w.fields.join(", ")} (${w.operations.join("/")})`
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
out("");
|
|
3473
|
+
}
|
|
3474
|
+
return 0;
|
|
3475
|
+
}
|
|
3476
|
+
async function depsRemoveCommand(providerSlug, options) {
|
|
3477
|
+
if (!providerSlug || typeof providerSlug !== "string") {
|
|
3478
|
+
fail("Usage: `dashwise deps remove <provider-slug>`");
|
|
3479
|
+
return 1;
|
|
3480
|
+
}
|
|
3481
|
+
let manifest;
|
|
3482
|
+
try {
|
|
3483
|
+
manifest = readManifestOrThrow(options.dir);
|
|
3484
|
+
} catch (err) {
|
|
3485
|
+
fail(err.message);
|
|
3486
|
+
return 1;
|
|
3487
|
+
}
|
|
3488
|
+
const deps = manifest.dependencies ?? [];
|
|
3489
|
+
const target = deps.find((d) => d.module === providerSlug);
|
|
3490
|
+
if (!target) {
|
|
3491
|
+
info(`No dependency on ${code(providerSlug)} declared; nothing to remove.`);
|
|
3492
|
+
return 0;
|
|
3493
|
+
}
|
|
3494
|
+
if (!options.yes && process.stdout.isTTY) {
|
|
3495
|
+
const answer = await select({
|
|
3496
|
+
message: `Remove dependency on ${providerSlug}?`,
|
|
3497
|
+
options: [
|
|
3498
|
+
{ value: "go", label: "Yes, remove it" },
|
|
3499
|
+
{ value: "cancel", label: "No, keep it" }
|
|
3500
|
+
],
|
|
3501
|
+
initialValue: "go"
|
|
3502
|
+
});
|
|
3503
|
+
if (isCancel(answer) || answer === "cancel") {
|
|
3504
|
+
info("Aborted.");
|
|
3505
|
+
return 130;
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
manifest.dependencies = deps.filter((d) => d.module !== providerSlug);
|
|
3509
|
+
if (manifest.dependencies.length === 0) {
|
|
3510
|
+
delete manifest.dependencies;
|
|
3511
|
+
}
|
|
3512
|
+
try {
|
|
3513
|
+
writeManifest(manifest, options.dir);
|
|
3514
|
+
} catch (err) {
|
|
3515
|
+
fail(`Could not write manifest: ${err.message}`);
|
|
3516
|
+
return 1;
|
|
3517
|
+
}
|
|
3518
|
+
success(`Removed dependency on ${code(providerSlug)}.`);
|
|
3519
|
+
info(`Run ${code("dashwise module generate")} to refresh typed wrappers.`);
|
|
3520
|
+
return 0;
|
|
3521
|
+
}
|
|
3522
|
+
async function queryCommand(options) {
|
|
3523
|
+
const projectRoot = resolve(options.dir ?? process.cwd());
|
|
3524
|
+
try {
|
|
3525
|
+
readManifestOrThrow(projectRoot);
|
|
3526
|
+
} catch (err) {
|
|
3527
|
+
fail(err.message);
|
|
3528
|
+
return 1;
|
|
3529
|
+
}
|
|
3530
|
+
const generatedPath = join(
|
|
3531
|
+
projectRoot,
|
|
3532
|
+
"node_modules",
|
|
3533
|
+
"@dashai",
|
|
3534
|
+
"generated"
|
|
3535
|
+
);
|
|
3536
|
+
if (!existsSync(generatedPath)) {
|
|
3537
|
+
fail(
|
|
3538
|
+
`No ${code("@dashai/generated")} in ${code("node_modules")}. Run ${code("dashwise module generate")} first.`
|
|
3539
|
+
);
|
|
3540
|
+
return 1;
|
|
3541
|
+
}
|
|
3542
|
+
hydrateEnvFromDotenvLocal(projectRoot);
|
|
3543
|
+
const modes = [
|
|
3544
|
+
options.inline !== void 0 ? "inline" : null,
|
|
3545
|
+
options.file !== void 0 ? "file" : null,
|
|
3546
|
+
options.repl === true ? "repl" : null
|
|
3547
|
+
].filter(Boolean);
|
|
3548
|
+
if (modes.length === 0) {
|
|
3549
|
+
fail('Pass one of: `--inline "expr"`, `--file <path>`, or `--repl`.');
|
|
3550
|
+
return 1;
|
|
3551
|
+
}
|
|
3552
|
+
if (modes.length > 1) {
|
|
3553
|
+
fail(`--inline / --file / --repl are mutually exclusive (got: ${modes.join(", ")}).`);
|
|
3554
|
+
return 1;
|
|
3555
|
+
}
|
|
3556
|
+
if (options.explain === true && options.repl === true) {
|
|
3557
|
+
fail("--explain has no effect with --repl. In the REPL, call `.explain()` directly on your builder.");
|
|
3558
|
+
return 1;
|
|
3559
|
+
}
|
|
3560
|
+
if (options.stream === true && options.repl === true) {
|
|
3561
|
+
fail("--stream has no effect with --repl. In the REPL, call `.stream()` directly + use `for await`.");
|
|
3562
|
+
return 1;
|
|
3563
|
+
}
|
|
3564
|
+
if (options.explain === true && options.stream === true) {
|
|
3565
|
+
fail("--explain and --stream are mutually exclusive \u2014 `.explain()` returns a single plan envelope; `.stream()` yields rows.");
|
|
3566
|
+
return 1;
|
|
3567
|
+
}
|
|
3568
|
+
const explain = options.explain === true;
|
|
3569
|
+
const stream = options.stream === true;
|
|
3570
|
+
if (options.repl) return runRepl(generatedPath);
|
|
3571
|
+
if (options.file !== void 0)
|
|
3572
|
+
return runFile(options.file, projectRoot, options.json === true, explain, stream);
|
|
3573
|
+
if (options.inline !== void 0)
|
|
3574
|
+
return runInline(
|
|
3575
|
+
options.inline,
|
|
3576
|
+
projectRoot,
|
|
3577
|
+
generatedPath,
|
|
3578
|
+
options.json === true,
|
|
3579
|
+
explain,
|
|
3580
|
+
stream
|
|
3581
|
+
);
|
|
3582
|
+
return 1;
|
|
3583
|
+
}
|
|
3584
|
+
async function runInline(expression, projectRoot, _generatedPath, jsonCompact, explain, stream) {
|
|
3585
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "dashwise-query-"));
|
|
3586
|
+
const tmpFile = join(tmpDir, "inline.mjs");
|
|
3587
|
+
const cleanedExpr = expression.trim().replace(/;\s*$/, "");
|
|
3588
|
+
const source = [
|
|
3589
|
+
"import { qb, deps, db } from '@dashai/generated';",
|
|
3590
|
+
`export default (async () => (${cleanedExpr}))();`,
|
|
3591
|
+
""
|
|
3592
|
+
].join("\n");
|
|
3593
|
+
writeFileSync(tmpFile, source, "utf-8");
|
|
3594
|
+
try {
|
|
3595
|
+
return await importAndPrint(tmpFile, projectRoot, jsonCompact, explain, stream);
|
|
3596
|
+
} finally {
|
|
3597
|
+
try {
|
|
3598
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
3599
|
+
} catch {
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
async function runFile(file, projectRoot, jsonCompact, explain, stream) {
|
|
3604
|
+
const abs = resolve(projectRoot, file);
|
|
3605
|
+
if (!existsSync(abs)) {
|
|
3606
|
+
fail(`File not found: ${code(abs)}`);
|
|
3607
|
+
return 1;
|
|
3608
|
+
}
|
|
3609
|
+
if (!/\.(mjs|js)$/.test(abs)) {
|
|
3610
|
+
fail(
|
|
3611
|
+
`Only .mjs / .js files are supported in v1. For .ts files, run ${code("npx tsx <file>")} directly. (Tracked: bundle tsx for native .ts support.)`
|
|
3612
|
+
);
|
|
3613
|
+
return 1;
|
|
3614
|
+
}
|
|
3615
|
+
return importAndPrint(abs, projectRoot, jsonCompact, explain, stream);
|
|
3616
|
+
}
|
|
3617
|
+
async function runRepl(generatedPath) {
|
|
3618
|
+
let generated;
|
|
3619
|
+
try {
|
|
3620
|
+
generated = await dynamicImportFromPath(generatedPath);
|
|
3621
|
+
} catch (err) {
|
|
3622
|
+
fail(
|
|
3623
|
+
`Could not import ${code("@dashai/generated")}: ${err.message}`
|
|
3624
|
+
);
|
|
3625
|
+
info(
|
|
3626
|
+
`Try running ${code("dashwise module generate")} to refresh the typed wrappers.`
|
|
3627
|
+
);
|
|
3628
|
+
return 1;
|
|
3629
|
+
}
|
|
3630
|
+
info(`${code("dashwise query --repl")} \u2014 Node REPL with qb / deps / db pre-bound.`);
|
|
3631
|
+
info(`Try: ${code("await qb.<table>.execute()")} Ctrl-D to exit.`);
|
|
3632
|
+
out("");
|
|
3633
|
+
const repl = await import('repl');
|
|
3634
|
+
const server = repl.start({
|
|
3635
|
+
prompt: `${code("dashwise")}${dim(">")} `,
|
|
3636
|
+
useColors: true,
|
|
3637
|
+
useGlobal: false
|
|
3638
|
+
});
|
|
3639
|
+
for (const [key, value] of Object.entries(generated)) {
|
|
3640
|
+
Object.defineProperty(server.context, key, {
|
|
3641
|
+
configurable: false,
|
|
3642
|
+
enumerable: true,
|
|
3643
|
+
writable: false,
|
|
3644
|
+
value
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
return new Promise((resolveExit) => {
|
|
3648
|
+
server.on("exit", () => {
|
|
3649
|
+
out(dim("Goodbye."));
|
|
3650
|
+
resolveExit(0);
|
|
3651
|
+
});
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
async function importAndPrint(file, projectRoot, jsonCompact, explain, stream) {
|
|
3655
|
+
const originalCwd = process.cwd();
|
|
3656
|
+
try {
|
|
3657
|
+
process.chdir(projectRoot);
|
|
3658
|
+
const url = pathToFileURL(file).toString();
|
|
3659
|
+
const mod = await import(url);
|
|
3660
|
+
let result = mod.default;
|
|
3661
|
+
if (result && typeof result.then === "function") {
|
|
3662
|
+
result = await result;
|
|
3663
|
+
}
|
|
3664
|
+
if (result && typeof result.toAst === "function") {
|
|
3665
|
+
if (stream && typeof result.stream === "function") {
|
|
3666
|
+
const iterable = result.stream();
|
|
3667
|
+
let rowCount = 0;
|
|
3668
|
+
for await (const row of iterable) {
|
|
3669
|
+
out(JSON.stringify(row));
|
|
3670
|
+
rowCount++;
|
|
3671
|
+
}
|
|
3672
|
+
if (!jsonCompact) {
|
|
3673
|
+
info(`Streamed ${rowCount} row${rowCount === 1 ? "" : "s"}.`);
|
|
3674
|
+
}
|
|
3675
|
+
return 0;
|
|
3676
|
+
}
|
|
3677
|
+
if (explain && typeof result.explain === "function") {
|
|
3678
|
+
result = await result.explain();
|
|
3679
|
+
} else if (typeof result.execute === "function") {
|
|
3680
|
+
result = await result.execute();
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
if (jsonCompact) {
|
|
3684
|
+
out(JSON.stringify(result));
|
|
3685
|
+
} else {
|
|
3686
|
+
out(JSON.stringify(result, null, 2));
|
|
3687
|
+
}
|
|
3688
|
+
return 0;
|
|
3689
|
+
} catch (err) {
|
|
3690
|
+
fail(`Query failed: ${err.message}`);
|
|
3691
|
+
if (process.env.DASHWISE_DEBUG) {
|
|
3692
|
+
console.error(err);
|
|
3693
|
+
}
|
|
3694
|
+
return 1;
|
|
3695
|
+
} finally {
|
|
3696
|
+
process.chdir(originalCwd);
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
async function dynamicImportFromPath(generatedDir) {
|
|
3700
|
+
const entry = join(generatedDir, "index.js");
|
|
3701
|
+
if (!existsSync(entry)) {
|
|
3702
|
+
throw new Error(`Expected ${entry} to exist. Did 'dashwise module generate' run?`);
|
|
3703
|
+
}
|
|
3704
|
+
const url = pathToFileURL(entry).toString();
|
|
3705
|
+
return await import(url);
|
|
3706
|
+
}
|
|
3707
|
+
function hydrateEnvFromDotenvLocal(projectRoot) {
|
|
3708
|
+
const envFile = join(projectRoot, ".env.local");
|
|
3709
|
+
if (!existsSync(envFile)) return;
|
|
3710
|
+
const raw = readFileSync(envFile, "utf-8");
|
|
3711
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
3712
|
+
const trimmed = line.trim();
|
|
3713
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
3714
|
+
const eq = trimmed.indexOf("=");
|
|
3715
|
+
if (eq <= 0) continue;
|
|
3716
|
+
const key = trimmed.slice(0, eq).trim();
|
|
3717
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
3718
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
3719
|
+
value = value.slice(1, -1);
|
|
3720
|
+
}
|
|
3721
|
+
if (process.env[key] === void 0) {
|
|
3722
|
+
process.env[key] = value;
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
// src/commands/module/query-log.ts
|
|
3728
|
+
var POLL_INTERVAL_MS = 5e3;
|
|
3729
|
+
async function moduleQueryLogCommand(options) {
|
|
3730
|
+
let moduleId;
|
|
3731
|
+
try {
|
|
3732
|
+
moduleId = await resolveModuleId(options.module, options.dir);
|
|
3733
|
+
} catch (err) {
|
|
3734
|
+
fail(err.message);
|
|
3735
|
+
return 1;
|
|
3736
|
+
}
|
|
3737
|
+
if (options.tail) {
|
|
3738
|
+
return runTail(moduleId, options);
|
|
3739
|
+
}
|
|
3740
|
+
return runOnce(moduleId, options);
|
|
3741
|
+
}
|
|
3742
|
+
async function runOnce(moduleId, options) {
|
|
3743
|
+
const params = {};
|
|
3744
|
+
if (options.slow) params.slow = "true";
|
|
3745
|
+
if (options.since) params.since = options.since;
|
|
3746
|
+
if (typeof options.limit === "number") params.limit = String(options.limit);
|
|
3747
|
+
const qs = new URLSearchParams(params).toString();
|
|
3748
|
+
const path = `/api/modules/${moduleId}/query-log${qs ? `?${qs}` : ""}`;
|
|
3749
|
+
let body;
|
|
3750
|
+
try {
|
|
3751
|
+
body = await apiRequest("GET", path);
|
|
3752
|
+
} catch (err) {
|
|
3753
|
+
return handleApiError(err);
|
|
3754
|
+
}
|
|
3755
|
+
if (options.json) {
|
|
3756
|
+
out(JSON.stringify(body));
|
|
3757
|
+
return 0;
|
|
3758
|
+
}
|
|
3759
|
+
if (body.entries.length === 0) {
|
|
3760
|
+
info(
|
|
3761
|
+
options.slow ? "No slow queries in the requested window." : "No query log entries in the requested window."
|
|
3762
|
+
);
|
|
3763
|
+
return 0;
|
|
3764
|
+
}
|
|
3765
|
+
renderTable(body.entries);
|
|
3766
|
+
out("");
|
|
3767
|
+
info(`${body.count} ${body.count === 1 ? "entry" : "entries"}.`);
|
|
3768
|
+
return 0;
|
|
3769
|
+
}
|
|
3770
|
+
async function runTail(moduleId, options) {
|
|
3771
|
+
const initialParams = {};
|
|
3772
|
+
if (options.slow) initialParams.slow = "true";
|
|
3773
|
+
initialParams.since = options.since ?? "5m";
|
|
3774
|
+
if (typeof options.limit === "number") initialParams.limit = String(options.limit);
|
|
3775
|
+
const initialQs = new URLSearchParams(initialParams).toString();
|
|
3776
|
+
info(`Tailing query log for module ${moduleId}. Ctrl-C to stop.`);
|
|
3777
|
+
out("");
|
|
3778
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3779
|
+
let firstPoll = true;
|
|
3780
|
+
printTableHeader();
|
|
3781
|
+
while (true) {
|
|
3782
|
+
try {
|
|
3783
|
+
const path = `/api/modules/${moduleId}/query-log?${firstPoll ? initialQs : buildTailParams(options)}`;
|
|
3784
|
+
const body = await apiRequest("GET", path);
|
|
3785
|
+
const newRows = [];
|
|
3786
|
+
for (const row of [...body.entries].reverse()) {
|
|
3787
|
+
if (seenIds.has(row.request_id)) continue;
|
|
3788
|
+
seenIds.add(row.request_id);
|
|
3789
|
+
newRows.push(row);
|
|
3790
|
+
}
|
|
3791
|
+
for (const row of newRows) {
|
|
3792
|
+
out(formatRow(row));
|
|
3793
|
+
}
|
|
3794
|
+
firstPoll = false;
|
|
3795
|
+
} catch (err) {
|
|
3796
|
+
info(dim(`(tail poll error: ${err.message})`));
|
|
3797
|
+
}
|
|
3798
|
+
await sleep2(POLL_INTERVAL_MS);
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
function buildTailParams(options) {
|
|
3802
|
+
const params = { since: "7s" };
|
|
3803
|
+
if (options.slow) params.slow = "true";
|
|
3804
|
+
if (typeof options.limit === "number") params.limit = String(options.limit);
|
|
3805
|
+
return new URLSearchParams(params).toString();
|
|
3806
|
+
}
|
|
3807
|
+
async function resolveModuleId(moduleArg, dir) {
|
|
3808
|
+
if (moduleArg && moduleArg.length > 0) {
|
|
3809
|
+
const asInt = Number.parseInt(moduleArg, 10);
|
|
3810
|
+
if (Number.isFinite(asInt) && String(asInt) === moduleArg) {
|
|
3811
|
+
return asInt;
|
|
3812
|
+
}
|
|
3813
|
+
return lookupModuleIdBySlug(moduleArg);
|
|
3814
|
+
}
|
|
3815
|
+
const projectRoot = dir ?? process.cwd();
|
|
3816
|
+
try {
|
|
3817
|
+
const manifest = readManifestOrThrow(projectRoot);
|
|
3818
|
+
if (manifest?.module?.slug) {
|
|
3819
|
+
return lookupModuleIdBySlug(manifest.module.slug);
|
|
3820
|
+
}
|
|
3821
|
+
} catch {
|
|
3822
|
+
}
|
|
3823
|
+
throw new Error(
|
|
3824
|
+
`No module specified. Pass --module <id-or-slug> or run inside a module project directory containing module.json.`
|
|
3825
|
+
);
|
|
3826
|
+
}
|
|
3827
|
+
async function lookupModuleIdBySlug(slug) {
|
|
3828
|
+
const found = await apiRequest(
|
|
3829
|
+
"GET",
|
|
3830
|
+
`/api/modules/by-slug/${encodeURIComponent(slug)}`
|
|
3831
|
+
);
|
|
3832
|
+
if (!found || typeof found.id !== "number") {
|
|
3833
|
+
throw new Error(`Module ${code(slug)} not found.`);
|
|
3834
|
+
}
|
|
3835
|
+
return found.id;
|
|
3836
|
+
}
|
|
3837
|
+
function handleApiError(err) {
|
|
3838
|
+
if (err instanceof ApiError) {
|
|
3839
|
+
if (err.status === 401) {
|
|
3840
|
+
fail("Not logged in. Run `dashwise login` first.");
|
|
3841
|
+
return 1;
|
|
3842
|
+
}
|
|
3843
|
+
if (err.status === 403) {
|
|
3844
|
+
fail(
|
|
3845
|
+
`Forbidden \u2014 you are not a member of this module's owner workspace. Use \`dashwise workspace list\` to see what you have access to.`
|
|
3846
|
+
);
|
|
3847
|
+
return 1;
|
|
3848
|
+
}
|
|
3849
|
+
if (err.status === 404) {
|
|
3850
|
+
fail(`Module not found.`);
|
|
3851
|
+
return 1;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
fail(`Could not load query log: ${err.message}`);
|
|
3855
|
+
return 1;
|
|
3856
|
+
}
|
|
3857
|
+
var COL_WIDTHS = {
|
|
3858
|
+
time: 19,
|
|
3859
|
+
// "YYYY-MM-DD HH:MM:SS"
|
|
3860
|
+
endpoint: 7,
|
|
3861
|
+
// "query" | "explain"
|
|
3862
|
+
ms: 6,
|
|
3863
|
+
// "12345" with margin
|
|
3864
|
+
rows: 5,
|
|
3865
|
+
code: 18,
|
|
3866
|
+
request: 12
|
|
3867
|
+
// first 12 chars of request_id (most are UUIDs)
|
|
3868
|
+
};
|
|
3869
|
+
function renderTable(entries) {
|
|
3870
|
+
out("");
|
|
3871
|
+
printTableHeader();
|
|
3872
|
+
for (const row of entries) {
|
|
3873
|
+
out(formatRow(row));
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
function printTableHeader() {
|
|
3877
|
+
out(
|
|
3878
|
+
[
|
|
3879
|
+
"TIME".padEnd(COL_WIDTHS.time),
|
|
3880
|
+
"ENDPT".padEnd(COL_WIDTHS.endpoint),
|
|
3881
|
+
"MS".padStart(COL_WIDTHS.ms),
|
|
3882
|
+
"ROWS".padStart(COL_WIDTHS.rows),
|
|
3883
|
+
"CODE".padEnd(COL_WIDTHS.code),
|
|
3884
|
+
"REQ"
|
|
3885
|
+
].join(" ")
|
|
3886
|
+
);
|
|
3887
|
+
out(
|
|
3888
|
+
[
|
|
3889
|
+
"-".repeat(COL_WIDTHS.time),
|
|
3890
|
+
"-".repeat(COL_WIDTHS.endpoint),
|
|
3891
|
+
"-".repeat(COL_WIDTHS.ms),
|
|
3892
|
+
"-".repeat(COL_WIDTHS.rows),
|
|
3893
|
+
"-".repeat(COL_WIDTHS.code),
|
|
3894
|
+
"-".repeat(COL_WIDTHS.request)
|
|
3895
|
+
].join(" ")
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
function formatRow(row) {
|
|
3899
|
+
const time = formatTimestamp(row.created_at);
|
|
3900
|
+
const endpoint = row.endpoint.padEnd(COL_WIDTHS.endpoint);
|
|
3901
|
+
const ms = String(row.duration_ms).padStart(COL_WIDTHS.ms);
|
|
3902
|
+
const rows = row.row_count === null ? "\u2014".padStart(COL_WIDTHS.rows) : String(row.row_count).padStart(COL_WIDTHS.rows);
|
|
3903
|
+
const errCode = row.error_code ?? "";
|
|
3904
|
+
const codeCol = errCode ? code(errCode).padEnd(COL_WIDTHS.code + (code(errCode).length - errCode.length)) : "OK".padEnd(COL_WIDTHS.code);
|
|
3905
|
+
const req = row.request_id.slice(0, COL_WIDTHS.request);
|
|
3906
|
+
return [time, endpoint, ms, rows, codeCol, dim(req)].join(" ");
|
|
3907
|
+
}
|
|
3908
|
+
function formatTimestamp(iso) {
|
|
3909
|
+
return iso.slice(0, 19).replace("T", " ");
|
|
3910
|
+
}
|
|
3911
|
+
function sleep2(ms) {
|
|
3912
|
+
return new Promise((resolve11) => setTimeout(resolve11, ms));
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
// src/commands/module/action-log.ts
|
|
3916
|
+
var POLL_INTERVAL_MS2 = 5e3;
|
|
3917
|
+
async function moduleActionLogCommand(options) {
|
|
3918
|
+
if (options.installation === void 0 && options.module === void 0) {
|
|
3919
|
+
fail("Must pass either `--installation <id>` or `--module <slug>`.");
|
|
3920
|
+
return 1;
|
|
3921
|
+
}
|
|
3922
|
+
if (options.installation !== void 0 && options.module !== void 0) {
|
|
3923
|
+
fail("`--installation` and `--module` are mutually exclusive \u2014 pass one or the other.");
|
|
3924
|
+
return 1;
|
|
3925
|
+
}
|
|
3926
|
+
let installationId = options.installation;
|
|
3927
|
+
if (installationId === void 0 && options.module !== void 0) {
|
|
3928
|
+
try {
|
|
3929
|
+
const resolved = await apiRequest(
|
|
3930
|
+
"GET",
|
|
3931
|
+
`/api/modules/by-slug/${encodeURIComponent(options.module)}/my-installation`
|
|
3932
|
+
);
|
|
3933
|
+
installationId = resolved.installation_id;
|
|
3934
|
+
} catch (err) {
|
|
3935
|
+
return mapAndPrintError(err, options.json);
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
if (options.tail) {
|
|
3939
|
+
return runTail2(installationId, options);
|
|
3940
|
+
}
|
|
3941
|
+
return runOnce2(installationId, options);
|
|
3942
|
+
}
|
|
3943
|
+
async function runOnce2(installationId, options) {
|
|
3944
|
+
const params = {};
|
|
3945
|
+
if (options.role !== void 0) params.role = options.role;
|
|
3946
|
+
if (options.slow) params.slow = "true";
|
|
3947
|
+
if (options.crossModuleOnly) params.cross_module_only = "true";
|
|
3948
|
+
if (options.since !== void 0) params.since = options.since;
|
|
3949
|
+
if (options.limit !== void 0) params.limit = String(options.limit);
|
|
3950
|
+
let response;
|
|
3951
|
+
try {
|
|
3952
|
+
response = await apiRequest(
|
|
3953
|
+
"GET",
|
|
3954
|
+
`/api/modules/runtime/installations/${installationId}/action-log?${new URLSearchParams(params).toString()}`
|
|
3955
|
+
);
|
|
3956
|
+
} catch (err) {
|
|
3957
|
+
return mapAndPrintError(err, options.json);
|
|
3958
|
+
}
|
|
3959
|
+
if (options.json) {
|
|
3960
|
+
out(JSON.stringify(response));
|
|
3961
|
+
return 0;
|
|
3962
|
+
}
|
|
3963
|
+
printTable(response.entries);
|
|
3964
|
+
if (response.entries.length === 0) {
|
|
3965
|
+
info("No entries.");
|
|
3966
|
+
}
|
|
3967
|
+
return 0;
|
|
3968
|
+
}
|
|
3969
|
+
async function runTail2(installationId, options) {
|
|
3970
|
+
if (options.json) {
|
|
3971
|
+
fail("`--tail` and `--json` are mutually exclusive.");
|
|
3972
|
+
return 1;
|
|
3973
|
+
}
|
|
3974
|
+
info(`Tailing action log for installation ${code(String(installationId))} every 5s. Ctrl-C to stop.`);
|
|
3975
|
+
let lastSeen;
|
|
3976
|
+
const printed = /* @__PURE__ */ new Set();
|
|
3977
|
+
while (true) {
|
|
3978
|
+
const params = {};
|
|
3979
|
+
if (options.role !== void 0) params.role = options.role;
|
|
3980
|
+
if (options.slow) params.slow = "true";
|
|
3981
|
+
if (options.crossModuleOnly) params.cross_module_only = "true";
|
|
3982
|
+
if (lastSeen !== void 0) params.since = lastSeen;
|
|
3983
|
+
else if (options.since !== void 0) params.since = options.since;
|
|
3984
|
+
params.limit = String(options.limit ?? 100);
|
|
3985
|
+
try {
|
|
3986
|
+
const response = await apiRequest(
|
|
3987
|
+
"GET",
|
|
3988
|
+
`/api/modules/runtime/installations/${installationId}/action-log?${new URLSearchParams(params).toString()}`
|
|
3989
|
+
);
|
|
3990
|
+
const fresh = response.entries.filter((e) => !printed.has(e.id)).reverse();
|
|
3991
|
+
if (fresh.length > 0) {
|
|
3992
|
+
for (const entry of fresh) {
|
|
3993
|
+
printed.add(entry.id);
|
|
3994
|
+
printRow(entry);
|
|
3995
|
+
}
|
|
3996
|
+
lastSeen = fresh[fresh.length - 1].created_at;
|
|
3997
|
+
}
|
|
3998
|
+
} catch (err) {
|
|
3999
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
4000
|
+
return mapAndPrintError(err, false);
|
|
4001
|
+
}
|
|
4002
|
+
dim(`(tail: transient error \u2014 ${err.message})`);
|
|
4003
|
+
}
|
|
4004
|
+
await new Promise((resolve11) => setTimeout(resolve11, POLL_INTERVAL_MS2));
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
function printTable(rows) {
|
|
4008
|
+
if (rows.length === 0) {
|
|
4009
|
+
out("");
|
|
4010
|
+
out(" (no entries)");
|
|
4011
|
+
out("");
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
const widths = {
|
|
4015
|
+
time: 19,
|
|
4016
|
+
elapsed: 5,
|
|
4017
|
+
cross: 5,
|
|
4018
|
+
caller: 18,
|
|
4019
|
+
action: 22,
|
|
4020
|
+
code: 18
|
|
4021
|
+
};
|
|
4022
|
+
out("");
|
|
4023
|
+
out(
|
|
4024
|
+
`${"TIME".padEnd(widths.time)} ${"MS".padStart(widths.elapsed)} ${"CROSS".padEnd(widths.cross)} ${"CALLER".padEnd(widths.caller)} ${"ACTION".padEnd(widths.action)} ${"CODE".padEnd(widths.code)}`
|
|
4025
|
+
);
|
|
4026
|
+
out(
|
|
4027
|
+
`${"-".repeat(widths.time)} ${"-".repeat(widths.elapsed)} ${"-".repeat(widths.cross)} ${"-".repeat(widths.caller)} ${"-".repeat(widths.action)} ${"-".repeat(widths.code)}`
|
|
4028
|
+
);
|
|
4029
|
+
for (const r of rows) {
|
|
4030
|
+
printRow(r);
|
|
4031
|
+
}
|
|
4032
|
+
out("");
|
|
4033
|
+
info(`${rows.length} entr${rows.length === 1 ? "y" : "ies"}.`);
|
|
4034
|
+
}
|
|
4035
|
+
function printRow(r) {
|
|
4036
|
+
const time = r.created_at.slice(0, 19).replace("T", " ");
|
|
4037
|
+
const cross = r.cross_module ? code("yes") : dim(" no");
|
|
4038
|
+
const codeCol = r.ok ? dim("-") : code(r.error_code ?? "ERROR");
|
|
4039
|
+
out(
|
|
4040
|
+
`${time.padEnd(19)} ${String(r.elapsed_ms).padStart(5)} ${cross.padEnd(5)} ${r.caller_module_slug.padEnd(18)} ${r.action_name.padEnd(22)} ${codeCol}`
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
function mapAndPrintError(err, json) {
|
|
4044
|
+
if (err instanceof ApiError) {
|
|
4045
|
+
if (err.status === 401) {
|
|
4046
|
+
const msg2 = "Not logged in. Run `dashwise login` first.";
|
|
4047
|
+
if (json) out(JSON.stringify({ ok: false, error: { code: "NOT_LOGGED_IN", message: msg2 } }));
|
|
4048
|
+
else fail(msg2);
|
|
4049
|
+
return 1;
|
|
4050
|
+
}
|
|
4051
|
+
if (err.status === 403) {
|
|
4052
|
+
const msg2 = "Workspace-member access required for this installation.";
|
|
4053
|
+
if (json) out(JSON.stringify({ ok: false, error: { code: "FORBIDDEN", message: msg2 } }));
|
|
4054
|
+
else fail(msg2);
|
|
4055
|
+
return 1;
|
|
4056
|
+
}
|
|
4057
|
+
if (err.status === 404) {
|
|
4058
|
+
const msg2 = "Installation not found.";
|
|
4059
|
+
if (json) out(JSON.stringify({ ok: false, error: { code: "INSTALLATION_NOT_FOUND", message: msg2 } }));
|
|
4060
|
+
else fail(msg2);
|
|
4061
|
+
return 1;
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
const msg = `Could not fetch action log: ${err.message}`;
|
|
4065
|
+
if (json) out(JSON.stringify({ ok: false, error: { code: "FETCH_FAILED", message: err.message } }));
|
|
4066
|
+
else fail(msg);
|
|
4067
|
+
return 1;
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
// src/bin.ts
|
|
4071
|
+
var program = new Command();
|
|
4072
|
+
program.name("dashwise").description("DashWise CLI \u2014 log in, author modules, publish to the marketplace.").version(getVersion(), "-v, --version", "Show the CLI version + exit").option(
|
|
4073
|
+
"--api-url <url>",
|
|
4074
|
+
"Override DashWise backend URL (default: $DASHWISE_API_URL or saved config or http://localhost:3000)"
|
|
4075
|
+
);
|
|
4076
|
+
program.command("login").description("Sign in via the device-code flow.").option("--no-prompt", 'Skip the "open browser" prompt; just print the URL.').option("--name <name>", "Suggested name for the minted token (default: dashwise-cli).").action(async (cmdOpts) => {
|
|
4077
|
+
const apiUrl = program.opts().apiUrl;
|
|
4078
|
+
const exit = await loginCommand({
|
|
4079
|
+
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
4080
|
+
noPrompt: cmdOpts.prompt === false,
|
|
4081
|
+
...cmdOpts.name !== void 0 ? { name: cmdOpts.name } : {}
|
|
4082
|
+
});
|
|
4083
|
+
process.exitCode = exit;
|
|
4084
|
+
});
|
|
4085
|
+
program.command("logout").description("Clear the local credentials and revoke the server-side token.").action(async () => {
|
|
4086
|
+
process.exitCode = await logoutCommand();
|
|
4087
|
+
});
|
|
4088
|
+
program.command("profile").description("Show the current user, default workspace, and accessible workspaces.").option("--json", "Emit JSON to stdout instead of human-readable kv pairs.").action(async (cmdOpts) => {
|
|
4089
|
+
process.exitCode = await profileCommand({
|
|
4090
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4091
|
+
});
|
|
4092
|
+
});
|
|
4093
|
+
var workspace = program.command("workspace").description("Workspace context commands.");
|
|
4094
|
+
workspace.command("list").description("List accessible workspaces.").option("--json", "Emit JSON to stdout instead of human-readable columns.").action(async (cmdOpts) => {
|
|
4095
|
+
process.exitCode = await workspaceListCommand({
|
|
4096
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4097
|
+
});
|
|
4098
|
+
});
|
|
4099
|
+
workspace.command("use <slug>").description("Set the default workspace for subsequent commands.").action(async (slug) => {
|
|
4100
|
+
process.exitCode = await workspaceUseCommand(slug);
|
|
4101
|
+
});
|
|
4102
|
+
program.command("dev").description(
|
|
4103
|
+
"One-command local dev loop: runs the manifest watcher + Next.js dev server together, with `DASHWISE_API_URL`/`DASHWISE_API_TOKEN`/`DASHWISE_INSTALLATION_ID` auto-injected from your saved CLI config. Run `dashwise login` first."
|
|
4104
|
+
).option("--dir <path>", "Project root containing `module.json` (default: current directory).").option(
|
|
4105
|
+
"--installation-id <id>",
|
|
4106
|
+
'Override the `DASHWISE_INSTALLATION_ID` env var passed to children (default: "local").'
|
|
4107
|
+
).option(
|
|
4108
|
+
"--api-url <url>",
|
|
4109
|
+
"Override the `DASHWISE_API_URL` env var passed to children (default: saved CLI config)."
|
|
4110
|
+
).option("--port <number>", "Port for `next dev` (default: Next.js picks 3000 / next free).", parseIntOption).option("--sdk-only", "Only run the manifest watcher; skip `next dev`.").option("--next-only", "Only run `next dev`; skip the manifest watcher.").action(
|
|
4111
|
+
async (cmdOpts) => {
|
|
4112
|
+
process.exitCode = await devCommand({
|
|
4113
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4114
|
+
...cmdOpts.installationId !== void 0 ? { installationId: cmdOpts.installationId } : {},
|
|
4115
|
+
...cmdOpts.apiUrl !== void 0 ? { apiUrl: cmdOpts.apiUrl } : {},
|
|
4116
|
+
...cmdOpts.port !== void 0 ? { port: cmdOpts.port } : {},
|
|
4117
|
+
...cmdOpts.sdkOnly !== void 0 ? { sdkOnly: cmdOpts.sdkOnly } : {},
|
|
4118
|
+
...cmdOpts.nextOnly !== void 0 ? { nextOnly: cmdOpts.nextOnly } : {}
|
|
4119
|
+
});
|
|
4120
|
+
}
|
|
4121
|
+
);
|
|
4122
|
+
var moduleCmd = program.command("module").description("Module authoring commands.");
|
|
4123
|
+
moduleCmd.command("init [slug]").description(
|
|
4124
|
+
"Scaffold a new DashWise module \u2014 full Next.js app with auth wiring + SSO callback + protected dashboard. Pass --simple for the legacy minimal scaffold (no auth)."
|
|
4125
|
+
).option("--dir <path>", "Override the target directory (default: ./<slug>).").option("--name <name>", "Human-readable module name (default: derived from slug).").option("--force", "Overwrite an existing non-empty directory.").option(
|
|
4126
|
+
"--simple",
|
|
4127
|
+
"Use the legacy minimal scaffold (hand-authored, no Next.js auth wiring). Default before 2026-05-18; opt-in now."
|
|
4128
|
+
).option(
|
|
4129
|
+
"--custom",
|
|
4130
|
+
"[Deprecated alias \u2014 kept for backward compatibility.] Equivalent to the default (full Next.js + auth). Will be removed in a future major version; just call `dashwise module init` without flags."
|
|
4131
|
+
).option("--git <url>", "Initial Git repository URL \u2014 written to module.json#repository.url.").action(
|
|
4132
|
+
async (slugArg, cmdOpts) => {
|
|
4133
|
+
process.exitCode = await moduleInitCommand(slugArg, {
|
|
4134
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4135
|
+
...cmdOpts.name !== void 0 ? { name: cmdOpts.name } : {},
|
|
4136
|
+
...cmdOpts.force !== void 0 ? { force: cmdOpts.force } : {},
|
|
4137
|
+
...cmdOpts.simple !== void 0 ? { simple: cmdOpts.simple } : {},
|
|
4138
|
+
...cmdOpts.custom !== void 0 ? { custom: cmdOpts.custom } : {},
|
|
4139
|
+
...cmdOpts.git !== void 0 ? { git: cmdOpts.git } : {}
|
|
4140
|
+
});
|
|
4141
|
+
}
|
|
4142
|
+
);
|
|
4143
|
+
moduleCmd.command("connect-git <repo-url>").description("Link the local module to a Git repository (writes module.json#repository).").option("--dir <path>", "Project root (default: current directory).").option("--branch <name>", "Default branch to publish from (e.g. `main`).").action(
|
|
4144
|
+
async (repoUrl, cmdOpts) => {
|
|
4145
|
+
process.exitCode = await moduleConnectGitCommand(repoUrl, {
|
|
4146
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4147
|
+
...cmdOpts.branch !== void 0 ? { branch: cmdOpts.branch } : {}
|
|
4148
|
+
});
|
|
4149
|
+
}
|
|
4150
|
+
);
|
|
4151
|
+
moduleCmd.command("generate").description("Regenerate typed wrappers from the local module.json into node_modules/@dashai/generated/.").option("--dir <path>", "Project root (default: current directory).").option("--dry-run", "Print summary without writing files.").action(async (cmdOpts) => {
|
|
4152
|
+
process.exitCode = await moduleGenerateCommand({
|
|
4153
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4154
|
+
...cmdOpts.dryRun !== void 0 ? { dryRun: cmdOpts.dryRun } : {}
|
|
4155
|
+
});
|
|
4156
|
+
});
|
|
4157
|
+
moduleCmd.command("dev").description("Watch module.json + regenerate typed wrappers on every save. Ctrl-C to stop.").option("--dir <path>", "Project root (default: current directory).").option("--debounce-ms <n>", "Filesystem change debounce window (default: 250ms).", parseIntOption).action(async (cmdOpts) => {
|
|
4158
|
+
process.exitCode = await moduleDevCommand({
|
|
4159
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4160
|
+
...cmdOpts.debounceMs !== void 0 ? { debounceMs: cmdOpts.debounceMs } : {}
|
|
4161
|
+
});
|
|
4162
|
+
});
|
|
4163
|
+
moduleCmd.command("validate").description("Validate the local module.json against the backend manifest validator.").option("--dir <path>", "Project root (default: current directory).").action(async (cmdOpts) => {
|
|
4164
|
+
process.exitCode = await moduleValidateCommand({
|
|
4165
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
|
|
4166
|
+
});
|
|
4167
|
+
});
|
|
4168
|
+
moduleCmd.command("list").description("List modules you own (or `--all` for owned + public).").option("--all", "Include public modules you can install but do not own.").option("--workspace <slug>", "Filter by workspace slug (default: saved default workspace).").option("--json", "Emit JSON to stdout instead of column-aligned text.").action(async (cmdOpts) => {
|
|
4169
|
+
process.exitCode = await moduleListCommand({
|
|
4170
|
+
...cmdOpts.all !== void 0 ? { all: cmdOpts.all } : {},
|
|
4171
|
+
...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
|
|
4172
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4173
|
+
});
|
|
4174
|
+
});
|
|
4175
|
+
moduleCmd.command("diff").description("Show what would change at the next publish vs the currently-published version.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit the structured diff as JSON instead of the human-readable summary.").action(async (cmdOpts) => {
|
|
4176
|
+
process.exitCode = await moduleDiffCommand({
|
|
4177
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4178
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4179
|
+
});
|
|
4180
|
+
});
|
|
4181
|
+
moduleCmd.command("pull-data").description("Export the module's own-table rows to a local JSON file (for fixtures, local dev seeding).").option("--dir <path>", "Project root (default: current directory).").option("--limit <n>", "Max rows per table (1-1000, default 100).", parseIntOption).option("--out <path>", "Output file path (default: ./dashwise-data.json).").action(async (cmdOpts) => {
|
|
4182
|
+
process.exitCode = await modulePullDataCommand({
|
|
4183
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4184
|
+
...cmdOpts.limit !== void 0 ? { limit: cmdOpts.limit } : {},
|
|
4185
|
+
...cmdOpts.out !== void 0 ? { out: cmdOpts.out } : {}
|
|
4186
|
+
});
|
|
4187
|
+
});
|
|
4188
|
+
moduleCmd.command("clone <slug>").description("Clone a published module locally. Uses git if repository.url is set, else writes a manifest-only scaffold.").option("--dir <path>", "Target directory (default: ./<slug>).").option("--force", "Overwrite an existing non-empty directory.").option("--no-git", "Skip git clone even when repository.url is set; use the manifest-only scaffold.").action(
|
|
4189
|
+
async (slug, cmdOpts) => {
|
|
4190
|
+
process.exitCode = await moduleCloneCommand(slug, {
|
|
4191
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4192
|
+
...cmdOpts.force !== void 0 ? { force: cmdOpts.force } : {},
|
|
4193
|
+
...cmdOpts.git === false ? { noGit: true } : {}
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
);
|
|
4197
|
+
var depsCmd = program.command("deps").description("Cross-module dependency commands (manipulate module.json#dependencies).");
|
|
4198
|
+
depsCmd.command("list").description("List declared cross-module dependencies.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit JSON to stdout instead of human-readable columns.").action(async (cmdOpts) => {
|
|
4199
|
+
process.exitCode = await depsListCommand({
|
|
4200
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4201
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4202
|
+
});
|
|
4203
|
+
});
|
|
4204
|
+
depsCmd.command("add <provider-slug>").description(
|
|
4205
|
+
"Declare a cross-module dependency. Interactive in TTY (picks tables + fields from the provider's exposes); non-interactive requires --table, --fields, --purpose."
|
|
4206
|
+
).option("--dir <path>", "Project root (default: current directory).").option("--table <slug>", "Provider table slug to read from (non-interactive mode).").option("--fields <list>", "Comma-separated field slugs for --table (non-interactive mode).").option("--purpose <text>", "Human-readable explanation (10-500 chars, shown at install consent).").option("--version <range>", "SemVer range to pin (default: ^<provider-current-version>).").option("-y, --yes", "Skip the replace-existing confirmation prompt.").action(
|
|
4207
|
+
async (providerSlug, cmdOpts) => {
|
|
4208
|
+
process.exitCode = await depsAddCommand(providerSlug, {
|
|
4209
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4210
|
+
...cmdOpts.table !== void 0 ? { table: cmdOpts.table } : {},
|
|
4211
|
+
...cmdOpts.fields !== void 0 ? { fields: cmdOpts.fields } : {},
|
|
4212
|
+
...cmdOpts.purpose !== void 0 ? { purpose: cmdOpts.purpose } : {},
|
|
4213
|
+
...cmdOpts.version !== void 0 ? { version: cmdOpts.version } : {},
|
|
4214
|
+
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
|
|
4215
|
+
});
|
|
4216
|
+
}
|
|
4217
|
+
);
|
|
4218
|
+
depsCmd.command("remove <provider-slug>").description("Remove a declared cross-module dependency.").option("--dir <path>", "Project root (default: current directory).").option("-y, --yes", "Skip the confirmation prompt.").action(async (providerSlug, cmdOpts) => {
|
|
4219
|
+
process.exitCode = await depsRemoveCommand(providerSlug, {
|
|
4220
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4221
|
+
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
|
|
4222
|
+
});
|
|
4223
|
+
});
|
|
4224
|
+
depsCmd.command("check").description("Verify each declared dep still resolves to a published provider + that every field/op is still exposed. Exits 1 on drift.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit a JSON drift report to stdout.").action(async (cmdOpts) => {
|
|
4225
|
+
process.exitCode = await depsCheckCommand({
|
|
4226
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4227
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4228
|
+
});
|
|
4229
|
+
});
|
|
4230
|
+
program.command("query").description(
|
|
4231
|
+
'Run a typed query against the local module installation via @dashai/sdk/query. Pass one of `--inline "<expr>"`, `--file <path>`, or `--repl`. `qb`, `deps`, `db` are pre-bound to the local module\'s generated SDK.'
|
|
4232
|
+
).option("--dir <path>", "Project root containing `module.json` (default: current directory).").option("--inline <expression>", "Evaluate a one-liner. e.g., `qb.tasks.where({done:false}).count()`.").option("--file <path>", "Dynamic-import a .mjs/.js file. Default export = Query | AggQuery | Promise | value.").option("--repl", "Open an interactive Node REPL with the generated SDK pre-bound. Top-level await supported.").option("--json", "Emit compact JSON (default: pretty-printed).").option(
|
|
4233
|
+
"--explain",
|
|
4234
|
+
"Call .explain() instead of .execute() \u2014 returns the Postgres plan + estimatedCost without executing the query. MVP supports joined queries; bare own/dep/view paths reject with EXPLAIN_NOT_SUPPORTED_YET."
|
|
4235
|
+
).option(
|
|
4236
|
+
"--stream",
|
|
4237
|
+
"Call .stream() instead of .execute() \u2014 prints rows incrementally as JSON-lines (one row per line). Lets you see progress on big queries + Ctrl-C cleanly midway. Mutually exclusive with --explain and --repl."
|
|
4238
|
+
).action(
|
|
4239
|
+
async (cmdOpts) => {
|
|
4240
|
+
process.exitCode = await queryCommand({
|
|
4241
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4242
|
+
...cmdOpts.inline !== void 0 ? { inline: cmdOpts.inline } : {},
|
|
4243
|
+
...cmdOpts.file !== void 0 ? { file: cmdOpts.file } : {},
|
|
4244
|
+
...cmdOpts.repl !== void 0 ? { repl: cmdOpts.repl } : {},
|
|
4245
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {},
|
|
4246
|
+
...cmdOpts.explain !== void 0 ? { explain: cmdOpts.explain } : {},
|
|
4247
|
+
...cmdOpts.stream !== void 0 ? { stream: cmdOpts.stream } : {}
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
);
|
|
4251
|
+
moduleCmd.command("publish").description("Pack the current directory + upload it as a new module version.").option("--dir <path>", "Project root (default: current directory).").option("--bump <direction>", "Semver bump: patch | minor | major.").option("--dry-run", "Validate + upload but do not persist the version row.").option("-y, --yes", "Skip the confirmation prompt (for CI / scripted use).").option(
|
|
4252
|
+
"--exclude <pattern...>",
|
|
4253
|
+
"Extra exclude patterns (segment-matched) appended to the defaults."
|
|
4254
|
+
).action(
|
|
4255
|
+
async (cmdOpts) => {
|
|
4256
|
+
process.exitCode = await modulePublishCommand({
|
|
4257
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
|
|
4258
|
+
...cmdOpts.bump !== void 0 ? { bump: cmdOpts.bump } : {},
|
|
4259
|
+
...cmdOpts.dryRun !== void 0 ? { dryRun: cmdOpts.dryRun } : {},
|
|
4260
|
+
...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {},
|
|
4261
|
+
...cmdOpts.exclude !== void 0 ? { exclude: cmdOpts.exclude } : {}
|
|
4262
|
+
});
|
|
4263
|
+
}
|
|
4264
|
+
);
|
|
4265
|
+
moduleCmd.command("query-log").description(
|
|
4266
|
+
"Read the per-module SDK query log. Default: 50 most recent entries. `--slow` filters to duration_ms > 500ms; `--since 1h` narrows by time; `--tail` streams new entries every 5s."
|
|
4267
|
+
).option(
|
|
4268
|
+
"--module <id-or-slug>",
|
|
4269
|
+
"Module to inspect (default: slug from current directory's module.json)."
|
|
4270
|
+
).option("--slow", "Filter to slow queries only (duration_ms > 500).").option(
|
|
4271
|
+
"--since <duration>",
|
|
4272
|
+
'Only entries newer than this. Accepts "30m", "1h", "6h", "2d", "7d", or an ISO 8601 timestamp.'
|
|
4273
|
+
).option("--limit <n>", "Max rows per fetch (1-500, default 50).", parseIntOption).option("--tail", "Poll every 5s for new entries. Ctrl-C to stop.").option("--json", "Emit compact JSON instead of the table. Ignored with --tail.").option("--dir <path>", "Override project directory for module.json lookup.").action(
|
|
4274
|
+
async (cmdOpts) => {
|
|
4275
|
+
process.exitCode = await moduleQueryLogCommand({
|
|
4276
|
+
...cmdOpts.module !== void 0 ? { module: cmdOpts.module } : {},
|
|
4277
|
+
...cmdOpts.slow !== void 0 ? { slow: cmdOpts.slow } : {},
|
|
4278
|
+
...cmdOpts.since !== void 0 ? { since: cmdOpts.since } : {},
|
|
4279
|
+
...cmdOpts.limit !== void 0 ? { limit: cmdOpts.limit } : {},
|
|
4280
|
+
...cmdOpts.tail !== void 0 ? { tail: cmdOpts.tail } : {},
|
|
4281
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {},
|
|
4282
|
+
...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
|
|
4283
|
+
});
|
|
4284
|
+
}
|
|
4285
|
+
);
|
|
4286
|
+
moduleCmd.command("action-log").description(
|
|
4287
|
+
"Read the per-installation server-action audit log. Shows both cross-module and own-module dispatches. Filter by role (caller / provider), slow, since, cross-module-only. `--tail` streams new entries every 5s. Pass either `--installation <id>` or `--module <slug>` (slug resolves to your accessible installation)."
|
|
4288
|
+
).option(
|
|
4289
|
+
"--installation <id>",
|
|
4290
|
+
"Installation id to inspect. Mutually exclusive with `--module`.",
|
|
4291
|
+
parseIntOption
|
|
4292
|
+
).option(
|
|
4293
|
+
"--module <slug>",
|
|
4294
|
+
"Module slug. Resolves to the caller's accessible installation of that module (first match if multi-workspace). Mutually exclusive with `--installation`."
|
|
4295
|
+
).option(
|
|
4296
|
+
"--role <caller|provider>",
|
|
4297
|
+
"Which side of cross-module dispatches to show. 'caller' (default) shows outbound; 'provider' shows inbound calls on this installation."
|
|
4298
|
+
).option("--slow", "Filter to slow actions only (elapsed_ms > 500).").option("--cross-module-only", "Hide own-module dispatches (cross_module=false rows).").option(
|
|
4299
|
+
"--since <duration>",
|
|
4300
|
+
'Only entries newer than this. Accepts "30m", "1h", "6h", "2d", "7d", or an ISO 8601 timestamp.'
|
|
4301
|
+
).option("--limit <n>", "Max rows per fetch (1-500, default 50).", parseIntOption).option("--tail", "Poll every 5s for new entries. Ctrl-C to stop.").option("--json", "Emit compact JSON instead of the table. Ignored with --tail.").action(
|
|
4302
|
+
async (cmdOpts) => {
|
|
4303
|
+
if (cmdOpts.role !== void 0 && cmdOpts.role !== "caller" && cmdOpts.role !== "provider") {
|
|
4304
|
+
fail(`--role must be 'caller' or 'provider' (got "${cmdOpts.role}").`);
|
|
4305
|
+
process.exitCode = 1;
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
process.exitCode = await moduleActionLogCommand({
|
|
4309
|
+
...cmdOpts.installation !== void 0 ? { installation: cmdOpts.installation } : {},
|
|
4310
|
+
...cmdOpts.module !== void 0 ? { module: cmdOpts.module } : {},
|
|
4311
|
+
...cmdOpts.role !== void 0 ? { role: cmdOpts.role } : {},
|
|
4312
|
+
...cmdOpts.slow !== void 0 ? { slow: cmdOpts.slow } : {},
|
|
4313
|
+
...cmdOpts.crossModuleOnly !== void 0 ? { crossModuleOnly: cmdOpts.crossModuleOnly } : {},
|
|
4314
|
+
...cmdOpts.since !== void 0 ? { since: cmdOpts.since } : {},
|
|
4315
|
+
...cmdOpts.limit !== void 0 ? { limit: cmdOpts.limit } : {},
|
|
4316
|
+
...cmdOpts.tail !== void 0 ? { tail: cmdOpts.tail } : {},
|
|
4317
|
+
...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
|
|
4318
|
+
});
|
|
4319
|
+
}
|
|
4320
|
+
);
|
|
4321
|
+
(async () => {
|
|
4322
|
+
try {
|
|
4323
|
+
await program.parseAsync(process.argv);
|
|
4324
|
+
} catch (err) {
|
|
4325
|
+
fail(`Unhandled error: ${err.message ?? String(err)}`);
|
|
4326
|
+
if (process.env.DASHWISE_DEBUG) {
|
|
4327
|
+
console.error(err);
|
|
4328
|
+
}
|
|
4329
|
+
process.exitCode = 1;
|
|
4330
|
+
}
|
|
4331
|
+
})();
|
|
4332
|
+
function getVersion() {
|
|
4333
|
+
return "0.1.0";
|
|
4334
|
+
}
|
|
4335
|
+
function parseIntOption(value) {
|
|
4336
|
+
const n = Number.parseInt(value, 10);
|
|
4337
|
+
if (!Number.isFinite(n)) {
|
|
4338
|
+
throw new Error(`Expected an integer, got '${value}'`);
|
|
4339
|
+
}
|
|
4340
|
+
return n;
|
|
4341
|
+
}
|
|
4342
|
+
//# sourceMappingURL=bin.js.map
|
|
4343
|
+
//# sourceMappingURL=bin.js.map
|