597 lines
22 KiB
TypeScript
597 lines
22 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
type SharePolicy = "collaborative" | "handoff";
|
|
type ShareKind = "user" | "bucket" | "note";
|
|
|
|
type ShareContext = {
|
|
id: string;
|
|
key: string;
|
|
displayName: string;
|
|
kind: ShareKind;
|
|
policy: SharePolicy;
|
|
targetUserId?: string | null;
|
|
deletedAt?: string | null;
|
|
};
|
|
|
|
type NoteRecord = {
|
|
id: string;
|
|
title: string;
|
|
key: string;
|
|
tags: string[];
|
|
user: string;
|
|
tasks: string[];
|
|
};
|
|
|
|
type TaskShareRef = {
|
|
contextId: string;
|
|
key: string;
|
|
kind: ShareKind;
|
|
policy?: SharePolicy;
|
|
};
|
|
|
|
type NoteRef = {
|
|
noteId: string;
|
|
key: string;
|
|
};
|
|
|
|
type ParsedTag =
|
|
| { kind: "share"; raw: string; key: string; display: string }
|
|
| { kind: "note"; raw: string; key: string; display: string }
|
|
| { kind: "label"; raw: string; key: string; display: string };
|
|
|
|
type TaskRecord = {
|
|
id: string;
|
|
title: string;
|
|
tags?: string[];
|
|
labelTags?: string[];
|
|
shareRefs?: TaskShareRef[];
|
|
noteRefs?: NoteRef[];
|
|
createdBy?: string | null;
|
|
user?: string | string[] | null;
|
|
};
|
|
|
|
type Summary = {
|
|
touched: number;
|
|
personalRefsAdded: number;
|
|
personalRefsRemoved: number;
|
|
ambiguousMultiPersonal: number;
|
|
failures: number;
|
|
};
|
|
|
|
const mode = (process.argv[2] || "report").toLowerCase();
|
|
if (!["backup", "report", "migrate"].includes(mode)) {
|
|
throw new Error("Usage: bun run scripts/context-ownership-migration.ts [backup|report|migrate]");
|
|
}
|
|
|
|
const dataMode = (process.env.TASGRID_DATA_MODE || "prod").toLowerCase() === "dev" ? "dev" : "prod";
|
|
const withDataSuffix = (base: string) => dataMode === "dev" ? `${base}_Dev` : base;
|
|
const TASGRID_COLLECTION = withDataSuffix("TasGrid");
|
|
const CONTEXTS_COLLECTION = withDataSuffix("TasGrid_Contexts");
|
|
const NOTES_COLLECTION = withDataSuffix("TasGrid_Notes");
|
|
const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__";
|
|
|
|
const POCKETBASE_URL = process.env.POCKETBASE_URL || "https://pocketbase.ccllc.pro";
|
|
let authToken = "";
|
|
|
|
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
const normalizeKey = (value: string) =>
|
|
normalizeWhitespace(value)
|
|
.toLowerCase()
|
|
.replace(/^[@#]+/, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
|
|
const buildNoteTag = (value: string) => `#${normalizeWhitespace(value)}`;
|
|
const buildUserContextKey = (userId: string) => `user-${userId}`;
|
|
const unwrapRelationId = (value: unknown): string | null => {
|
|
if (typeof value === "string") return value || null;
|
|
if (Array.isArray(value)) {
|
|
const first = value[0];
|
|
return typeof first === "string" ? first : null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const parseTag = (rawTag: string): ParsedTag => {
|
|
const raw = normalizeWhitespace(rawTag);
|
|
const prefix = raw[0];
|
|
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
|
|
const key = normalizeKey(display);
|
|
|
|
if (prefix === "@") return { kind: "share", raw, key, display };
|
|
if (prefix === "#") return { kind: "note", raw, key, display };
|
|
return { kind: "label", raw, key, display };
|
|
};
|
|
|
|
const parseTags = (rawTags: string[] | undefined | null) =>
|
|
(rawTags || []).map(parseTag).filter(tag => !!tag.display);
|
|
|
|
const dedupeShareRefs = (refs: TaskShareRef[]) => {
|
|
const seen = new Set<string>();
|
|
return refs.filter(ref => {
|
|
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const dedupeNoteRefs = (refs: NoteRef[]) => {
|
|
const seen = new Set<string>();
|
|
return refs.filter(ref => {
|
|
const key = `${ref.noteId}:${ref.key}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const DEFAULT_SHARE_POLICY_BY_KIND: Record<ShareKind, SharePolicy> = {
|
|
user: "collaborative",
|
|
bucket: "handoff",
|
|
note: "handoff"
|
|
};
|
|
|
|
const getNoteSharePolicy = (note: Pick<NoteRecord, "tags"> | undefined): SharePolicy =>
|
|
note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative";
|
|
|
|
const relationId = (record: any, field: string) => unwrapRelationId(record?.[field]);
|
|
|
|
const createOutputDir = async () => {
|
|
const outputDir = path.resolve(process.cwd(), "migration-output", `context-ownership-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
return outputDir;
|
|
};
|
|
|
|
const tryAuthEndpoint = async (pathname: string, email: string, password: string) => {
|
|
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ identity: email, password })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false as const,
|
|
status: response.status,
|
|
body: await response.text(),
|
|
pathname
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true as const,
|
|
payload: await response.json(),
|
|
pathname
|
|
};
|
|
};
|
|
|
|
const auth = async () => {
|
|
const email = process.env.PB_ADMIN_EMAIL || process.env.PB_EMAIL;
|
|
const password = process.env.PB_ADMIN_PASSWORD || process.env.PB_PASSWORD;
|
|
if (!email || !password) {
|
|
throw new Error("Set PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD (or PB_EMAIL/PB_PASSWORD) before running this script.");
|
|
}
|
|
|
|
const authAttempts = [
|
|
await tryAuthEndpoint("/api/collections/_superusers/auth-with-password", email, password),
|
|
await tryAuthEndpoint("/api/admins/auth-with-password", email, password)
|
|
];
|
|
|
|
const success = authAttempts.find(attempt => attempt.ok);
|
|
if (!success) {
|
|
const details = authAttempts
|
|
.map(attempt => `${attempt.pathname}: ${attempt.status} ${attempt.body}`)
|
|
.join(" | ");
|
|
throw new Error(`PocketBase superuser/admin auth failed. ${details}`);
|
|
}
|
|
|
|
const payload = success.payload;
|
|
authToken = payload.token;
|
|
};
|
|
|
|
const request = async (pathname: string, init: RequestInit = {}) => {
|
|
const response = await fetch(`${POCKETBASE_URL}${pathname}`, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(authToken ? { Authorization: authToken } : {}),
|
|
...(init.headers || {})
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
const getFullList = async (collection: string, query: Record<string, string> = {}) => {
|
|
const items: any[] = [];
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const search = new URLSearchParams({ perPage: "500", page: String(page), ...query });
|
|
const payload = await request(`/api/collections/${collection}/records?${search.toString()}`);
|
|
items.push(...(payload.items || []));
|
|
if (!payload.totalPages || page >= payload.totalPages) {
|
|
break;
|
|
}
|
|
page += 1;
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const createRecord = (collection: string, body: Record<string, unknown>) =>
|
|
request(`/api/collections/${collection}/records`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
const updateRecord = (collection: string, id: string, body: Record<string, unknown>) =>
|
|
request(`/api/collections/${collection}/records/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
const syncUserContexts = async (users: any[], contexts: ShareContext[]) => {
|
|
const nextContexts = [...contexts];
|
|
for (const user of users) {
|
|
const displayName = user.name || user.email;
|
|
const existing = nextContexts.find(context =>
|
|
context.kind === "user" &&
|
|
context.targetUserId === user.id &&
|
|
!context.deletedAt
|
|
);
|
|
if (!displayName || existing) continue;
|
|
|
|
const created = await createRecord(CONTEXTS_COLLECTION, {
|
|
key: buildUserContextKey(user.id),
|
|
displayName,
|
|
kind: "user",
|
|
policy: "collaborative",
|
|
targetUserId: user.id
|
|
});
|
|
|
|
nextContexts.push({
|
|
id: created.id,
|
|
key: created.key,
|
|
displayName: created.displayName,
|
|
kind: created.kind,
|
|
policy: "collaborative",
|
|
targetUserId: relationId(created, "targetUserId"),
|
|
deletedAt: created.deletedAt || null
|
|
});
|
|
}
|
|
return nextContexts;
|
|
};
|
|
|
|
const buildNoteLinkMap = (notes: NoteRecord[]) => {
|
|
const links = new Map<string, NoteRef[]>();
|
|
for (const note of notes) {
|
|
for (const taskId of note.tasks || []) {
|
|
const existing = links.get(taskId) || [];
|
|
existing.push({ noteId: note.id, key: note.key });
|
|
links.set(taskId, existing);
|
|
}
|
|
}
|
|
return links;
|
|
};
|
|
|
|
const main = async () => {
|
|
await auth();
|
|
|
|
const [users, rawContexts, rawNotes, rawTasks] = await Promise.all([
|
|
getFullList("users", { fields: "id,name,email" }),
|
|
getFullList(CONTEXTS_COLLECTION),
|
|
getFullList(NOTES_COLLECTION),
|
|
getFullList(TASGRID_COLLECTION)
|
|
]);
|
|
|
|
const contexts = mode === "migrate"
|
|
? await syncUserContexts(users, rawContexts.map((record: any) => ({
|
|
id: record.id,
|
|
key: record.key,
|
|
displayName: record.displayName,
|
|
kind: record.kind || "bucket",
|
|
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
|
|
targetUserId: relationId(record, "targetUserId"),
|
|
deletedAt: record.deletedAt || null
|
|
})))
|
|
: rawContexts.map((record: any) => ({
|
|
id: record.id,
|
|
key: record.key,
|
|
displayName: record.displayName,
|
|
kind: record.kind || "bucket",
|
|
policy: (record.policy || (record.kind === "user" ? "collaborative" : "handoff")) as SharePolicy,
|
|
targetUserId: relationId(record, "targetUserId"),
|
|
deletedAt: record.deletedAt || null
|
|
}));
|
|
|
|
const notes = rawNotes.map((record: any) => ({
|
|
id: record.id,
|
|
title: record.title,
|
|
key: record.key || normalizeKey(record.title || record.id),
|
|
tags: Array.isArray(record.tags) ? record.tags.filter(Boolean) : [],
|
|
user: relationId(record, "user") || "",
|
|
tasks: Array.isArray(record.tasks) ? record.tasks.filter(Boolean) : []
|
|
})) satisfies NoteRecord[];
|
|
|
|
const contextById = new Map(contexts.map(context => [context.id, context]));
|
|
const ownerContextByUserId = new Map(
|
|
contexts
|
|
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
|
|
.map(context => [context.targetUserId as string, context] as const)
|
|
);
|
|
const noteById = new Map(notes.map(note => [note.id, note]));
|
|
const noteByKey = new Map(notes.map(note => [note.key, note]));
|
|
const noteLinksByTaskId = buildNoteLinkMap(notes);
|
|
|
|
const getContextByRef = (ref: TaskShareRef) => {
|
|
if (ref.kind === "note") {
|
|
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
|
|
return note
|
|
? {
|
|
id: note.id,
|
|
key: note.key,
|
|
displayName: note.key || note.title,
|
|
kind: "note" as const,
|
|
policy: getNoteSharePolicy(note),
|
|
targetUserId: note.user
|
|
}
|
|
: null;
|
|
}
|
|
|
|
return contextById.get(ref.contextId)
|
|
|| contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind)
|
|
|| contexts.find(context => !context.deletedAt && normalizeKey(context.displayName) === ref.key)
|
|
|| null;
|
|
};
|
|
|
|
const getTaskSharePolicy = (ref: TaskShareRef): SharePolicy => {
|
|
if (ref.kind === "note") {
|
|
if (ref.policy) return ref.policy;
|
|
const note = noteById.get(ref.contextId) || noteByKey.get(ref.key);
|
|
return getNoteSharePolicy(note);
|
|
}
|
|
const context = getContextByRef(ref);
|
|
if (ref.policy) return ref.policy;
|
|
return context?.policy || DEFAULT_SHARE_POLICY_BY_KIND[ref.kind];
|
|
};
|
|
|
|
const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => {
|
|
if (ref.kind !== "user") return null;
|
|
const context = getContextByRef(ref);
|
|
if (context?.targetUserId) return context.targetUserId;
|
|
if (ref.key.startsWith("user-")) return ref.key.replace(/^user-/, "") || null;
|
|
return null;
|
|
};
|
|
|
|
const getTaskPersonalRefs = (refs: TaskShareRef[]) =>
|
|
refs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref));
|
|
|
|
const resolveMirroredOwnerId = (currentOwnerId: string | null, refs: TaskShareRef[], preferredOwnerId?: string | null) => {
|
|
const personalUserIds = [...new Set(
|
|
refs
|
|
.map(ref => getPersonalContextUserIdFromRef(ref))
|
|
.filter((value): value is string => !!value)
|
|
)];
|
|
|
|
if (personalUserIds.length === 1) return personalUserIds[0];
|
|
if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) return preferredOwnerId;
|
|
return currentOwnerId || null;
|
|
};
|
|
|
|
const buildTaskTags = (labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string) => {
|
|
const tags = [
|
|
...labelTags,
|
|
...shareRefs.map(ref => {
|
|
const context = getContextByRef(ref);
|
|
if (ref.kind === "note") return buildNoteTag(context?.key || ref.key);
|
|
if (context?.displayName) return buildShareTag(context.displayName);
|
|
if (ref.kind === "user" && ref.key.startsWith("user-")) return null;
|
|
return buildShareTag(ref.key);
|
|
}),
|
|
...noteRefs.map(ref => {
|
|
const note = noteById.get(ref.noteId) || noteByKey.get(ref.key);
|
|
return buildNoteTag(note?.key || ref.key);
|
|
})
|
|
];
|
|
|
|
if (favoriteTag && !tags.includes(favoriteTag)) tags.push(favoriteTag);
|
|
return [...new Set(tags.filter((value): value is string => !!value))];
|
|
};
|
|
|
|
const summary: Summary = {
|
|
touched: 0,
|
|
personalRefsAdded: 0,
|
|
personalRefsRemoved: 0,
|
|
ambiguousMultiPersonal: 0,
|
|
failures: 0
|
|
};
|
|
|
|
const reportRows: any[] = [];
|
|
|
|
for (const record of rawTasks as TaskRecord[]) {
|
|
if ((record.tags || []).includes("__template__")) continue;
|
|
|
|
try {
|
|
const legacyTags = record.tags || [];
|
|
const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__");
|
|
const shareRefs = Array.isArray(record.shareRefs)
|
|
? record.shareRefs.map(ref => ({
|
|
contextId: ref.contextId,
|
|
key: ref.key,
|
|
kind: ref.kind,
|
|
policy: ref.policy
|
|
}))
|
|
: parsedTags
|
|
.filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note")
|
|
.map(tag => {
|
|
if (tag.kind === "note") {
|
|
const note = noteByKey.get(tag.key);
|
|
return {
|
|
contextId: note?.id || tag.key,
|
|
key: tag.key,
|
|
kind: "note" as const
|
|
};
|
|
}
|
|
|
|
const context = contexts.find(existing =>
|
|
!existing.deletedAt && (
|
|
existing.key === tag.key ||
|
|
normalizeKey(existing.displayName) === tag.key ||
|
|
buildShareTag(existing.displayName).toLowerCase() === tag.raw.toLowerCase()
|
|
)
|
|
);
|
|
return {
|
|
contextId: context?.id || tag.key,
|
|
key: tag.key,
|
|
kind: (context?.kind || "bucket") as "user" | "bucket",
|
|
policy: context?.policy
|
|
};
|
|
});
|
|
|
|
const noteRefs = dedupeNoteRefs([
|
|
...(Array.isArray(record.noteRefs)
|
|
? record.noteRefs.map(ref => ({ noteId: ref.noteId, key: ref.key }))
|
|
: parsedTags
|
|
.filter(tag => tag.kind === "note")
|
|
.map(tag => {
|
|
const note = noteByKey.get(tag.key);
|
|
return {
|
|
noteId: note?.id || tag.key,
|
|
key: tag.key
|
|
};
|
|
})),
|
|
...(noteLinksByTaskId.get(record.id) || [])
|
|
]);
|
|
|
|
let nextShareRefs = dedupeShareRefs([
|
|
...shareRefs,
|
|
...noteRefs.map(ref => ({
|
|
contextId: ref.noteId,
|
|
key: ref.key,
|
|
kind: "note" as const,
|
|
policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy
|
|
}))
|
|
]);
|
|
|
|
const ownerId = unwrapRelationId(record.user);
|
|
const ownerContext = ownerId ? ownerContextByUserId.get(ownerId) : undefined;
|
|
const shouldRemoveOwnerPersonalRef = !!ownerId &&
|
|
getTaskPersonalRefs(nextShareRefs).length === 0 &&
|
|
nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff");
|
|
|
|
if (ownerContext) {
|
|
const matchesOwnerContext = (ref: TaskShareRef) =>
|
|
ref.kind === "user" && (
|
|
ref.contextId === ownerContext.id ||
|
|
ref.key === ownerContext.key ||
|
|
normalizeKey(ownerContext.displayName) === ref.key
|
|
);
|
|
|
|
if (shouldRemoveOwnerPersonalRef) {
|
|
const before = nextShareRefs.length;
|
|
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
|
|
if (before !== nextShareRefs.length) {
|
|
summary.personalRefsRemoved += before - nextShareRefs.length;
|
|
}
|
|
} else if (!nextShareRefs.some(matchesOwnerContext)) {
|
|
nextShareRefs = [
|
|
{
|
|
contextId: ownerContext.id,
|
|
key: ownerContext.key,
|
|
kind: "user" as const,
|
|
policy: ownerContext.policy
|
|
},
|
|
...nextShareRefs
|
|
];
|
|
summary.personalRefsAdded += 1;
|
|
}
|
|
}
|
|
|
|
nextShareRefs = dedupeShareRefs(nextShareRefs);
|
|
|
|
const labelTags = Array.isArray(record.labelTags)
|
|
? record.labelTags
|
|
: parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display);
|
|
const favoriteTag = legacyTags.find(tag => tag.endsWith("_favorite__"));
|
|
const nextTags = buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag);
|
|
const nextOwnerId = resolveMirroredOwnerId(ownerId, nextShareRefs, ownerId);
|
|
const personalUserIds = [...new Set(nextShareRefs.map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))];
|
|
if (personalUserIds.length > 1) {
|
|
summary.ambiguousMultiPersonal += 1;
|
|
}
|
|
|
|
const changed =
|
|
JSON.stringify(nextShareRefs) !== JSON.stringify(record.shareRefs || []) ||
|
|
JSON.stringify(noteRefs) !== JSON.stringify(record.noteRefs || []) ||
|
|
JSON.stringify(nextTags) !== JSON.stringify(record.tags || []) ||
|
|
nextOwnerId !== ownerId;
|
|
|
|
if (changed) {
|
|
summary.touched += 1;
|
|
reportRows.push({
|
|
id: record.id,
|
|
title: record.title,
|
|
ownerIdBefore: ownerId,
|
|
ownerIdAfter: nextOwnerId,
|
|
personalUserIds
|
|
});
|
|
|
|
if (mode === "migrate") {
|
|
await updateRecord(TASGRID_COLLECTION, record.id, {
|
|
user: nextOwnerId,
|
|
tags: nextTags,
|
|
labelTags,
|
|
shareRefs: nextShareRefs,
|
|
noteRefs
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
summary.failures += 1;
|
|
reportRows.push({
|
|
id: record.id,
|
|
title: record.title,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
if (mode === "backup") {
|
|
const outputDir = await createOutputDir();
|
|
await Promise.all([
|
|
fs.writeFile(path.join(outputDir, "users.json"), JSON.stringify(users, null, 2)),
|
|
fs.writeFile(path.join(outputDir, "contexts.json"), JSON.stringify(contexts, null, 2)),
|
|
fs.writeFile(path.join(outputDir, "notes.json"), JSON.stringify(rawNotes, null, 2)),
|
|
fs.writeFile(path.join(outputDir, "tasks.json"), JSON.stringify(rawTasks, null, 2))
|
|
]);
|
|
console.log(JSON.stringify({ mode, dataMode, outputDir, counts: { users: users.length, contexts: contexts.length, notes: rawNotes.length, tasks: rawTasks.length } }, null, 2));
|
|
return;
|
|
}
|
|
|
|
if (mode === "report") {
|
|
const outputDir = await createOutputDir();
|
|
await fs.writeFile(path.join(outputDir, "context-ownership-report.json"), JSON.stringify({ summary, tasks: reportRows }, null, 2));
|
|
console.log(JSON.stringify({ mode, dataMode, outputDir, summary }, null, 2));
|
|
return;
|
|
}
|
|
|
|
console.log(JSON.stringify({ mode, dataMode, summary }, null, 2));
|
|
};
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|