devfix-cli 1.0.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/bin/index.js +41 -0
- package/devfix-cli-1.0.0.tgz +0 -0
- package/package.json +22 -0
- package/src/commands/analyze.js +101 -0
- package/src/commands/login.js +28 -0
- package/src/commands/logout.js +7 -0
- package/src/commands/whoami.js +23 -0
- package/src/utils/ai.js +23 -0
- package/src/utils/animatedLogo.js +16 -0
- package/src/utils/config.js +33 -0
- package/src/utils/context.js +63 -0
- package/src/utils/cryptoStore.js +35 -0
- package/src/utils/detectStack.js +12 -0
- package/src/utils/prompt.js +29 -0
- package/src/utils/secureStore.js +16 -0
- package/src/utils/session.js +21 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
import { loginCommand } from "../src/commands/login.js";
|
|
5
|
+
import { logoutCommand } from "../src/commands/logout.js";
|
|
6
|
+
import { whoamiCommand } from "../src/commands/whoami.js";
|
|
7
|
+
import { analyzeCommand } from "../src/commands/analyze.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("devfix")
|
|
14
|
+
.description("DevFix CLI - AI tool to solve errors and bugs")
|
|
15
|
+
.version("1.0.0");
|
|
16
|
+
|
|
17
|
+
program.command("login").description("Login and save session for 7 days").action(loginCommand);
|
|
18
|
+
|
|
19
|
+
program.command("logout").description("Logout and clear local session").action(logoutCommand);
|
|
20
|
+
|
|
21
|
+
program.command("whoami").description("Show current login session").action(whoamiCommand);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("analyze")
|
|
25
|
+
.description("Analyze an error/log with AI")
|
|
26
|
+
.option("-f, --file <path>", "Read logs from a file")
|
|
27
|
+
.option("-s, --stack <stack>", "Force stack (kubernetes/docker/nodejs/react/python/git)")
|
|
28
|
+
.option("-m, --model <model>", "OpenRouter model override")
|
|
29
|
+
.option("-c, --context", "Auto collect project/terminal context")
|
|
30
|
+
.argument("[text]", "Error/log text")
|
|
31
|
+
.action((text, options) => {
|
|
32
|
+
analyzeCommand({
|
|
33
|
+
text: text || "",
|
|
34
|
+
file: options.file,
|
|
35
|
+
stack: options.stack,
|
|
36
|
+
model: options.model,
|
|
37
|
+
useContext: options.context,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
program.parse(process.argv);
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devfix-cli",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"bin": {
|
|
5
|
+
"devfix": "bin/index.js"
|
|
6
|
+
},
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"axios": "^1.13.5",
|
|
9
|
+
"boxen": "^8.0.1",
|
|
10
|
+
"chalk": "^5.6.2",
|
|
11
|
+
"cli-spinners": "^3.4.0",
|
|
12
|
+
"commander": "^14.0.3",
|
|
13
|
+
"dotenv": "^17.2.4",
|
|
14
|
+
"inquirer": "^13.2.2",
|
|
15
|
+
"keytar": "^7.9.0",
|
|
16
|
+
"log-update": "^7.1.0",
|
|
17
|
+
"marked": "^15.0.12",
|
|
18
|
+
"marked-terminal": "^7.3.0",
|
|
19
|
+
"ora": "^9.3.0"
|
|
20
|
+
},
|
|
21
|
+
"version": "1.0.0"
|
|
22
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import boxen from "boxen";
|
|
5
|
+
import logUpdate from "log-update";
|
|
6
|
+
|
|
7
|
+
import { marked } from "marked";
|
|
8
|
+
import TerminalRenderer from "marked-terminal";
|
|
9
|
+
|
|
10
|
+
import { readConfig } from "../utils/config.js";
|
|
11
|
+
import { isSessionValid } from "../utils/session.js";
|
|
12
|
+
import { decrypt } from "../utils/cryptoStore.js";
|
|
13
|
+
|
|
14
|
+
import { collectContext } from "../utils/context.js";
|
|
15
|
+
import { detectStack } from "../utils/detectStack.js";
|
|
16
|
+
import { buildPrompt } from "../utils/prompt.js";
|
|
17
|
+
import { askAI } from "../utils/ai.js";
|
|
18
|
+
|
|
19
|
+
import { startDevFixLogo } from "../utils/animatedLogo.js";
|
|
20
|
+
|
|
21
|
+
// Markdown → terminal renderer
|
|
22
|
+
marked.setOptions({
|
|
23
|
+
renderer: new TerminalRenderer(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export async function analyzeCommand({ text, file, stack, model, useContext }) {
|
|
27
|
+
const config = readConfig();
|
|
28
|
+
|
|
29
|
+
if (!isSessionValid(config)) {
|
|
30
|
+
console.log(chalk.red("\n❌ Not logged in or session expired.\nRun: devfix login\n"));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const apiKey = decrypt(config.apiKeyEncrypted);
|
|
35
|
+
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
console.log(chalk.red("\n❌ API key missing. Run: devfix login\n"));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let input = text;
|
|
42
|
+
|
|
43
|
+
if (file) {
|
|
44
|
+
input = fs.readFileSync(file, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!input || input.trim().length < 2) {
|
|
48
|
+
console.log(chalk.red("\n❌ Please provide error text or use --file\n"));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const detected = stack || detectStack(input);
|
|
53
|
+
const usedModel = model || "openai/gpt-4o-mini";
|
|
54
|
+
const context = useContext ? collectContext() : {};
|
|
55
|
+
|
|
56
|
+
// Start animation
|
|
57
|
+
const anim = startDevFixLogo();
|
|
58
|
+
|
|
59
|
+
const interval = setInterval(() => {
|
|
60
|
+
logUpdate(
|
|
61
|
+
`
|
|
62
|
+
${chalk.cyan(`${anim.frame()} DevFix`)} ${chalk.gray("• AI Debugging Assistant")}
|
|
63
|
+
|
|
64
|
+
${chalk.white("Stack:")} ${chalk.yellow(detected)}
|
|
65
|
+
${chalk.white("Model:")} ${chalk.magenta(usedModel)}
|
|
66
|
+
${chalk.white("Context:")} ${useContext ? chalk.green("ON") : chalk.red("OFF")}
|
|
67
|
+
|
|
68
|
+
${chalk.cyan("Analyzing error with AI...")}
|
|
69
|
+
`
|
|
70
|
+
);
|
|
71
|
+
}, 80);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const prompt = buildPrompt({ stack: detected, input, context });
|
|
75
|
+
const answer = await askAI({ apiKey, model: usedModel, prompt });
|
|
76
|
+
|
|
77
|
+
// Stop animation
|
|
78
|
+
clearInterval(interval);
|
|
79
|
+
anim.stop();
|
|
80
|
+
logUpdate.clear();
|
|
81
|
+
|
|
82
|
+
// Print final output
|
|
83
|
+
console.log(
|
|
84
|
+
boxen(chalk.bold.green("✅ DevFix Suggested Fix"), {
|
|
85
|
+
padding: 1,
|
|
86
|
+
borderStyle: "round",
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
console.log(marked(answer));
|
|
91
|
+
console.log();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
clearInterval(interval);
|
|
94
|
+
anim.stop();
|
|
95
|
+
logUpdate.clear();
|
|
96
|
+
|
|
97
|
+
console.log(chalk.red("\n❌ AI request failed\n"));
|
|
98
|
+
console.log(err?.response?.data || err.message);
|
|
99
|
+
console.log();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
|
|
4
|
+
import { writeConfig } from "../utils/config.js";
|
|
5
|
+
import { createSession } from "../utils/session.js";
|
|
6
|
+
import { encrypt } from "../utils/cryptoStore.js";
|
|
7
|
+
|
|
8
|
+
export async function loginCommand() {
|
|
9
|
+
const answers = await inquirer.prompt([
|
|
10
|
+
{ name: "username", message: "Enter username:", type: "input" },
|
|
11
|
+
{ name: "email", message: "Enter email:", type: "input" },
|
|
12
|
+
{ name: "apiKey", message: "Enter OpenRouter API key:", type: "password", mask: "*" },
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const session = createSession({
|
|
16
|
+
username: answers.username.trim(),
|
|
17
|
+
email: answers.email.trim(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const apiKeyEncrypted = encrypt(answers.apiKey.trim());
|
|
21
|
+
|
|
22
|
+
writeConfig({
|
|
23
|
+
...session,
|
|
24
|
+
apiKeyEncrypted,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
console.log(chalk.green("\n✅ Login saved for 7 days.\n"));
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { readConfig, getConfigPath } from "../utils/config.js";
|
|
3
|
+
import { isSessionValid } from "../utils/session.js";
|
|
4
|
+
|
|
5
|
+
export async function whoamiCommand() {
|
|
6
|
+
const config = readConfig();
|
|
7
|
+
|
|
8
|
+
if (!config) {
|
|
9
|
+
console.log(chalk.red("\n❌ Not logged in.\n"));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
console.log(chalk.cyan("\n👤 DevFix User\n"));
|
|
14
|
+
console.log("Username:", config.username);
|
|
15
|
+
console.log("Email:", config.email);
|
|
16
|
+
console.log("Config:", getConfigPath());
|
|
17
|
+
|
|
18
|
+
if (isSessionValid(config)) {
|
|
19
|
+
console.log(chalk.green("\n✅ Session valid until:"), config.expiresAt, "\n");
|
|
20
|
+
} else {
|
|
21
|
+
console.log(chalk.red("\n⏳ Session expired. Please run devfix login\n"));
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/utils/ai.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
export async function askAI({ apiKey, model, prompt }) {
|
|
4
|
+
|
|
5
|
+
const res = await axios.post(
|
|
6
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
7
|
+
{
|
|
8
|
+
model: "openai/gpt-4o-mini",
|
|
9
|
+
messages: [{ role: "user", content: prompt}],
|
|
10
|
+
max_tokens: 800
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${apiKey}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
timeout: 30000
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
return res.data.choices?.[0]?.message?.content || "No response from AI.";
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import spinners from "cli-spinners";
|
|
2
|
+
|
|
3
|
+
const frames = spinners.dots.frames;
|
|
4
|
+
|
|
5
|
+
export function startDevFixLogo() {
|
|
6
|
+
let i = 0;
|
|
7
|
+
|
|
8
|
+
const timer = setInterval(() => {
|
|
9
|
+
i = (i + 1) % frames.length;
|
|
10
|
+
}, 80);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
frame: () => frames[i],
|
|
14
|
+
stop: () => clearInterval(timer),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
const DIR = path.join(os.homedir(), ".devfix");
|
|
6
|
+
const FILE = path.join(DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
export function ensureConfigDir() {
|
|
9
|
+
if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readConfig() {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(FILE)) return null;
|
|
15
|
+
const raw = fs.readFileSync(FILE, "utf-8");
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeConfig(data) {
|
|
23
|
+
ensureConfigDir();
|
|
24
|
+
fs.writeFileSync(FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deleteConfig() {
|
|
28
|
+
if (fs.existsSync(FILE)) fs.unlinkSync(FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getConfigPath() {
|
|
32
|
+
return FILE;
|
|
33
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
function safeExec(cmd) {
|
|
7
|
+
try {
|
|
8
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeReadJson(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
if (!fs.existsSync(filePath)) return null;
|
|
17
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function collectContext() {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
|
|
26
|
+
// Basic system info
|
|
27
|
+
const context = {
|
|
28
|
+
cwd,
|
|
29
|
+
os: `${os.type()} ${os.release()}`,
|
|
30
|
+
platform: os.platform(),
|
|
31
|
+
arch: os.arch(),
|
|
32
|
+
node: safeExec("node -v"),
|
|
33
|
+
npm: safeExec("npm -v"),
|
|
34
|
+
gitBranch: safeExec("git branch --show-current"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Node project detection
|
|
38
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
39
|
+
const pkg = safeReadJson(pkgPath);
|
|
40
|
+
|
|
41
|
+
if (pkg) {
|
|
42
|
+
context.projectType = "node";
|
|
43
|
+
context.projectName = pkg.name || null;
|
|
44
|
+
context.dependencies = Object.keys(pkg.dependencies || {}).slice(0, 30);
|
|
45
|
+
context.devDependencies = Object.keys(pkg.devDependencies || {}).slice(0, 30);
|
|
46
|
+
context.scripts = pkg.scripts || {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Kubernetes tools detection
|
|
50
|
+
const kubectl = safeExec("kubectl version --client=true --output=yaml");
|
|
51
|
+
if (kubectl) {
|
|
52
|
+
context.kubectlInstalled = true;
|
|
53
|
+
context.kubectlVersion = kubectl.slice(0, 500);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const minikube = safeExec("minikube version");
|
|
57
|
+
if (minikube) {
|
|
58
|
+
context.minikubeInstalled = true;
|
|
59
|
+
context.minikubeVersion = minikube;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return context;
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
// Creates a machine-local key (same user on same machine)
|
|
4
|
+
function getSecretKey() {
|
|
5
|
+
const user = process.env.USER || process.env.USERNAME || "devfix";
|
|
6
|
+
const machine = process.env.HOSTNAME || "localmachine";
|
|
7
|
+
|
|
8
|
+
// 32 bytes key for AES-256
|
|
9
|
+
return crypto.createHash("sha256").update(user + machine).digest();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function encrypt(text) {
|
|
13
|
+
const iv = crypto.randomBytes(16);
|
|
14
|
+
const key = getSecretKey();
|
|
15
|
+
|
|
16
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
17
|
+
let encrypted = cipher.update(text, "utf8", "base64");
|
|
18
|
+
encrypted += cipher.final("base64");
|
|
19
|
+
|
|
20
|
+
return iv.toString("base64") + ":" + encrypted;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decrypt(encryptedText) {
|
|
24
|
+
const [ivBase64, encrypted] = encryptedText.split(":");
|
|
25
|
+
if (!ivBase64 || !encrypted) return null;
|
|
26
|
+
|
|
27
|
+
const iv = Buffer.from(ivBase64, "base64");
|
|
28
|
+
const key = getSecretKey();
|
|
29
|
+
|
|
30
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
31
|
+
let decrypted = decipher.update(encrypted, "base64", "utf8");
|
|
32
|
+
decrypted += decipher.final("utf8");
|
|
33
|
+
|
|
34
|
+
return decrypted;
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function detectStack(text) {
|
|
2
|
+
const t = text.toLowerCase();
|
|
3
|
+
|
|
4
|
+
if (t.includes("kubectl") || t.includes("minikube") || t.includes("kubernetes")) return "kubernetes";
|
|
5
|
+
if (t.includes("docker") || t.includes("container")) return "docker";
|
|
6
|
+
if (t.includes("npm") || t.includes("node") || t.includes("express")) return "nodejs";
|
|
7
|
+
if (t.includes("react") || t.includes("vite")) return "react";
|
|
8
|
+
if (t.includes("python") || t.includes("pip")) return "python";
|
|
9
|
+
if (t.includes("git") || t.includes("commit")) return "git";
|
|
10
|
+
|
|
11
|
+
return "general";
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function buildPrompt({ stack, input, context = {} }) {
|
|
2
|
+
return `
|
|
3
|
+
You are DevFix, a CLI debugging assistant.
|
|
4
|
+
|
|
5
|
+
Hard rules:
|
|
6
|
+
- Be concise.
|
|
7
|
+
- Max 5 fix steps.
|
|
8
|
+
- Max 6 commands.
|
|
9
|
+
- No long paragraphs.
|
|
10
|
+
- If log indicates success (no error), say so and ask for the real error.
|
|
11
|
+
- If ambiguous, ask ONLY 1 question.
|
|
12
|
+
|
|
13
|
+
Output format (Markdown):
|
|
14
|
+
### Summary
|
|
15
|
+
### Root Cause
|
|
16
|
+
### Fix (steps)
|
|
17
|
+
### Commands
|
|
18
|
+
### Verify
|
|
19
|
+
### Question (only if needed)
|
|
20
|
+
|
|
21
|
+
Stack: ${stack}
|
|
22
|
+
|
|
23
|
+
Context (optional):
|
|
24
|
+
${Object.keys(context).length ? JSON.stringify(context, null, 2) : "None"}
|
|
25
|
+
|
|
26
|
+
Error/Log:
|
|
27
|
+
${input}
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import keytar from "keytar";
|
|
2
|
+
|
|
3
|
+
const SERVICE = "devfix-cli";
|
|
4
|
+
const ACCOUNT = "openrouter-api-key";
|
|
5
|
+
|
|
6
|
+
export async function saveApiKey(apiKey) {
|
|
7
|
+
await keytar.setPassword(SERVICE, ACCOUNT, apiKey);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function getApiKey() {
|
|
11
|
+
return await keytar.getPassword(SERVICE, ACCOUNT);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function deleteApiKey() {
|
|
15
|
+
await keytar.deletePassword(SERVICE, ACCOUNT);
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function isSessionValid(config) {
|
|
2
|
+
if (!config) return false;
|
|
3
|
+
if (!config.expiresAt) return false;
|
|
4
|
+
|
|
5
|
+
const expiresAt = new Date(config.expiresAt).getTime();
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
|
|
8
|
+
return now < expiresAt;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSession({ username, email }) {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const expires = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
username,
|
|
17
|
+
email,
|
|
18
|
+
loggedInAt: now.toISOString(),
|
|
19
|
+
expiresAt: expires.toISOString(),
|
|
20
|
+
};
|
|
21
|
+
}
|