feat(desktop): auto-update bundled skills on app launch
Skills installed via Paseo desktop never updated after first run, so users were stuck on already-fixed skill content. On every launch, if the paseo base skill is present in `~/.agents/skills/`, sync each bundled skill (incl. `references/`) into agents, claude (via symlink/ junction), and codex — overwriting only files whose content differs. Files on disk that are not in the bundle are left alone; deprecation goes through tombstones (e.g. `paseo-orchestrate` redirects to `paseo-epic`). Also refreshes the bundled skill set: drops `paseo-chat`, adds `paseo-advisor` and `paseo-epic`, and turns `paseo-orchestrate` into a tombstone redirecting to `paseo-epic`. Adds a desktop test job to CI on Ubuntu and Windows so the junction/symlink path is exercised on both platforms.
This commit is contained in:
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||
import { syncSkills } from "./skill-sync.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -19,11 +20,12 @@ interface InstallStatus {
|
||||
|
||||
const SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-loop",
|
||||
"paseo-handoff",
|
||||
"paseo-orchestrator",
|
||||
"paseo-chat",
|
||||
"paseo-advisor",
|
||||
"paseo-committee",
|
||||
"paseo-epic",
|
||||
"paseo-handoff",
|
||||
"paseo-loop",
|
||||
"paseo-orchestrate",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -238,60 +240,70 @@ export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
// Skills Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function copySkillFile(
|
||||
sourceFile: string,
|
||||
destDir: string,
|
||||
skillName: string,
|
||||
): Promise<void> {
|
||||
const destSkillDir = path.join(destDir, skillName);
|
||||
await fs.mkdir(destSkillDir, { recursive: true });
|
||||
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
|
||||
}
|
||||
|
||||
async function symlinkSkillDir(
|
||||
skillName: string,
|
||||
targetDir: string,
|
||||
linkParentDir: string,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(linkParentDir, { recursive: true });
|
||||
const target = path.join(targetDir, skillName);
|
||||
const linkPath = path.join(linkParentDir, skillName);
|
||||
|
||||
if (await pathOrSymlinkExists(linkPath)) {
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
await fs.symlink(target, linkPath, "junction");
|
||||
} catch {
|
||||
await copySkillFile(path.join(target, "SKILL.md"), linkParentDir, skillName);
|
||||
}
|
||||
} else {
|
||||
await fs.symlink(target, linkPath);
|
||||
}
|
||||
function getSkillSyncTargets(): {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
} {
|
||||
return {
|
||||
sourceDir: getBundledSkillsDir(),
|
||||
agentsDir: getAgentsSkillsDir(),
|
||||
claudeDir: getClaudeSkillsDir(),
|
||||
codexDir: getCodexSkillsDir(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
const sourceDir = getBundledSkillsDir();
|
||||
const agentsDir = getAgentsSkillsDir();
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
const codexDir = getCodexSkillsDir();
|
||||
const targets = getSkillSyncTargets();
|
||||
log.info("[integrations] installSkills", targets);
|
||||
|
||||
log.info("[integrations] installSkills", { sourceDir, agentsDir, claudeDir, codexDir });
|
||||
|
||||
await Promise.all(
|
||||
SKILL_NAMES.map(async (skillName) => {
|
||||
const sourceFile = path.join(sourceDir, skillName, "SKILL.md");
|
||||
await copySkillFile(sourceFile, agentsDir, skillName);
|
||||
await symlinkSkillDir(skillName, agentsDir, claudeDir);
|
||||
await copySkillFile(sourceFile, codexDir, skillName);
|
||||
}),
|
||||
);
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill install failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
|
||||
log.info("[integrations] installSkills done", result);
|
||||
return getSkillsInstallStatus();
|
||||
}
|
||||
|
||||
export async function autoUpdateSkillsIfInstalled(): Promise<{
|
||||
ran: boolean;
|
||||
changedFiles: number;
|
||||
processedSkills: number;
|
||||
}> {
|
||||
const targets = getSkillSyncTargets();
|
||||
const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md");
|
||||
|
||||
try {
|
||||
await fs.access(installedMarker);
|
||||
} catch {
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncSkills({
|
||||
...targets,
|
||||
skillNames: SKILL_NAMES,
|
||||
onSkillError: (skillName, error) => {
|
||||
log.warn("[integrations] skill auto-update failed", { skillName, error });
|
||||
},
|
||||
});
|
||||
if (result.changedFiles > 0) {
|
||||
log.info("[integrations] auto-updated paseo skills", result);
|
||||
} else {
|
||||
log.info("[integrations] paseo skills already up to date", result);
|
||||
}
|
||||
return { ran: true, ...result };
|
||||
} catch (error) {
|
||||
log.warn("[integrations] auto-update skills aborted", { error });
|
||||
return { ran: false, changedFiles: 0, processedSkills: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
const accessResults = await Promise.all(
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { syncSkills } from "./skill-sync";
|
||||
|
||||
interface Sandbox {
|
||||
root: string;
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
async function makeSandbox(): Promise<Sandbox> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skill-sync-"));
|
||||
const sourceDir = path.join(root, "bundle");
|
||||
const agentsDir = path.join(root, "home", ".agents", "skills");
|
||||
const claudeDir = path.join(root, "home", ".claude", "skills");
|
||||
const codexDir = path.join(root, "home", ".codex", "skills");
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
return { root, sourceDir, agentsDir, claudeDir, codexDir };
|
||||
}
|
||||
|
||||
async function writeBundleSkill(
|
||||
sourceDir: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const skillDir = path.join(sourceDir, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(skillDir, rel);
|
||||
await fs.mkdir(path.dirname(full), { recursive: true });
|
||||
await fs.writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("syncSkills", () => {
|
||||
let sandbox: Sandbox;
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await makeSandbox();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(sandbox.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("overwrites on-disk skill content when the bundle differs", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||
"SKILL.md": "new paseo content",
|
||||
});
|
||||
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||
await fs.mkdir(onDiskSkill, { recursive: true });
|
||||
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old paseo content");
|
||||
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(result.processedSkills).toBe(1);
|
||||
expect(result.changedFiles).toBeGreaterThan(0);
|
||||
const agentsContent = await fs.readFile(
|
||||
path.join(sandbox.agentsDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(agentsContent).toBe("new paseo content");
|
||||
const codexContent = await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(codexContent).toBe("new paseo content");
|
||||
});
|
||||
|
||||
it("installs new bundled skills, including references/, when not present on disk", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo-epic", {
|
||||
"SKILL.md": "epic content",
|
||||
"references/roles.md": "roles content",
|
||||
});
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo-epic"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo-epic", "SKILL.md"), "utf-8")).toBe(
|
||||
"epic content",
|
||||
);
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.agentsDir, "paseo-epic", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(sandbox.codexDir, "paseo-epic", "references", "roles.md"),
|
||||
"utf-8",
|
||||
),
|
||||
).toBe("roles content");
|
||||
|
||||
const claudeLink = path.join(sandbox.claudeDir, "paseo-epic");
|
||||
const lstat = await fs.lstat(claudeLink);
|
||||
expect(lstat.isSymbolicLink()).toBe(true);
|
||||
expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe(
|
||||
"roles content",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves on-disk skills not in the bundle untouched", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
||||
const customSkill = path.join(sandbox.agentsDir, "user-custom-skill");
|
||||
await fs.mkdir(customSkill, { recursive: true });
|
||||
await fs.writeFile(path.join(customSkill, "SKILL.md"), "user content");
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
|
||||
});
|
||||
|
||||
it("does not delete files on disk that are no longer in the bundle", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
|
||||
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
|
||||
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
|
||||
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
|
||||
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale");
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
|
||||
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe(
|
||||
"stale",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports zero changed files on a no-op resync", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", {
|
||||
"SKILL.md": "content",
|
||||
"references/extra.md": "ref",
|
||||
});
|
||||
|
||||
const first = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
expect(first.changedFiles).toBeGreaterThan(0);
|
||||
|
||||
const second = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
});
|
||||
expect(second.changedFiles).toBe(0);
|
||||
});
|
||||
|
||||
it("skips skills listed in skillNames that are missing from the bundle without raising", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
// "paseo-removed" is in skillNames but not in the bundle on disk.
|
||||
|
||||
const errors: Array<{ name: string; error: unknown }> = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo", "paseo-removed"],
|
||||
onSkillError: (name, error) => errors.push({ name, error }),
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(result.processedSkills).toBe(1);
|
||||
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo", "SKILL.md"), "utf-8")).toBe(
|
||||
"content",
|
||||
);
|
||||
await expect(fs.access(path.join(sandbox.agentsDir, "paseo-removed"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("leaves on-disk skill content alone when the skill has been removed from the bundle", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "current" });
|
||||
const deprecatedDir = path.join(sandbox.agentsDir, "paseo-deprecated");
|
||||
await fs.mkdir(deprecatedDir, { recursive: true });
|
||||
await fs.writeFile(path.join(deprecatedDir, "SKILL.md"), "old content");
|
||||
|
||||
await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo", "paseo-deprecated"],
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(deprecatedDir, "SKILL.md"), "utf-8")).toBe("old content");
|
||||
});
|
||||
|
||||
it("does not crash when the source bundle directory is missing", async () => {
|
||||
const missingSourceDir = path.join(sandbox.root, "no-bundle-here");
|
||||
|
||||
const errors: string[] = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: missingSourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
onSkillError: (skillName) => errors.push(skillName),
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(result).toEqual({ changedFiles: 0, processedSkills: 0 });
|
||||
});
|
||||
|
||||
it("reports per-skill errors via onSkillError without throwing", async () => {
|
||||
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
|
||||
// Make agents skill path a file (not a directory) to force a write error
|
||||
await fs.mkdir(sandbox.agentsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sandbox.agentsDir, "paseo"), "blocking file");
|
||||
|
||||
const errors: string[] = [];
|
||||
const result = await syncSkills({
|
||||
sourceDir: sandbox.sourceDir,
|
||||
agentsDir: sandbox.agentsDir,
|
||||
claudeDir: sandbox.claudeDir,
|
||||
codexDir: sandbox.codexDir,
|
||||
skillNames: ["paseo"],
|
||||
onSkillError: (skillName) => errors.push(skillName),
|
||||
});
|
||||
|
||||
expect(errors).toContain("paseo");
|
||||
expect(result.processedSkills).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface SkillSyncOptions {
|
||||
sourceDir: string;
|
||||
agentsDir: string;
|
||||
claudeDir: string;
|
||||
codexDir: string;
|
||||
skillNames: readonly string[];
|
||||
platform?: NodeJS.Platform;
|
||||
onSkillError?: (skillName: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface SkillSyncResult {
|
||||
changedFiles: number;
|
||||
processedSkills: number;
|
||||
}
|
||||
|
||||
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
|
||||
const src = await fs.readFile(srcPath);
|
||||
const dst = await fs.readFile(dstPath).catch(() => null);
|
||||
if (dst && src.equals(dst)) return false;
|
||||
await fs.mkdir(path.dirname(dstPath), { recursive: true });
|
||||
await fs.writeFile(dstPath, src);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
out.push(path.relative(rootDir, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(rootDir);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
|
||||
const files = await listFilesRecursive(srcDir);
|
||||
let changed = 0;
|
||||
for (const rel of files) {
|
||||
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function ensureClaudeSkillLink(
|
||||
skillName: string,
|
||||
agentsDir: string,
|
||||
claudeDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<number> {
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
const target = path.join(agentsDir, skillName);
|
||||
const linkPath = path.join(claudeDir, skillName);
|
||||
|
||||
// Always rebuild the link rather than diffing it. fs.rm with force: true is
|
||||
// a no-op when nothing is there, and matches existing install behavior.
|
||||
// On Windows, `fs.rm` does not follow junctions, so the agents-side content
|
||||
// is preserved.
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
// Junctions don't require Developer Mode / admin like regular symlinks do.
|
||||
await fs.symlink(target, linkPath, "junction");
|
||||
return 0;
|
||||
} catch {
|
||||
return await syncDirectoryFiles(target, linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.symlink(target, linkPath);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
let changedFiles = 0;
|
||||
let processedSkills = 0;
|
||||
|
||||
for (const skillName of options.skillNames) {
|
||||
const bundleSkillDir = path.join(options.sourceDir, skillName);
|
||||
|
||||
const bundleStat = await fs.stat(bundleSkillDir).catch(() => null);
|
||||
if (!bundleStat?.isDirectory()) continue;
|
||||
|
||||
try {
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.agentsDir, skillName),
|
||||
);
|
||||
|
||||
changedFiles += await ensureClaudeSkillLink(
|
||||
skillName,
|
||||
options.agentsDir,
|
||||
options.claudeDir,
|
||||
platform,
|
||||
);
|
||||
|
||||
changedFiles += await syncDirectoryFiles(
|
||||
bundleSkillDir,
|
||||
path.join(options.codexDir, skillName),
|
||||
);
|
||||
|
||||
processedSkills++;
|
||||
} catch (error) {
|
||||
options.onSkillError?.(skillName, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { changedFiles, processedSkills };
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
createBeforeQuitHandler,
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded,
|
||||
} from "./daemon/quit-lifecycle.js";
|
||||
import { autoUpdateSkillsIfInstalled } from "./integrations/integrations-manager.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
const APP_SCHEME = "paseo";
|
||||
@@ -392,6 +393,11 @@ async function bootstrap(): Promise<void> {
|
||||
registerDialogHandlers();
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
|
||||
void autoUpdateSkillsIfInstalled().catch((error) => {
|
||||
log.warn("[integrations] auto-update skills failed", error);
|
||||
});
|
||||
|
||||
await createMainWindow();
|
||||
|
||||
app.on("activate", async () => {
|
||||
|
||||
Reference in New Issue
Block a user