feat(HAI-023): add file attachments support for tasks
- Add core types and store methods for attachment CRUD operations - Add dashboard API routes for upload, download, and delete of attachments - Add attachment UI in TaskDetailModal with upload and management controls - Add CLI 'attach' command for attaching files to tasks - Update documentation with attachment feature usage details
This commit is contained in:
@@ -43,6 +43,9 @@ pnpm dev task list
|
|||||||
|
|
||||||
# Move a task
|
# Move a task
|
||||||
pnpm dev task move HAI-001 todo
|
pnpm dev task move HAI-001 todo
|
||||||
|
|
||||||
|
# Attach a screenshot to a task
|
||||||
|
pnpm dev task attach HAI-001 ./screenshot.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open [http://localhost:4040](http://localhost:4040).
|
Then open [http://localhost:4040](http://localhost:4040).
|
||||||
@@ -63,7 +66,7 @@ hai reuses your existing pi authentication — no separate setup needed.
|
|||||||
| `@hai/core` | Domain model — tasks, board columns, file-based store |
|
| `@hai/core` | Domain model — tasks, board columns, file-based store |
|
||||||
| `@hai/dashboard` | Web UI — Express server + kanban board with SSE |
|
| `@hai/dashboard` | Web UI — Express server + kanban board with SSE |
|
||||||
| `@hai/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
|
| `@hai/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
|
||||||
| `hai` (cli) | CLI — `hai dashboard`, `hai task create/list/move` |
|
| `hai` (cli) | CLI — `hai dashboard`, `hai task create/list/move/attach` |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -77,7 +80,8 @@ Tasks live on disk in `.hai/tasks/` in the project root:
|
|||||||
└── tasks/
|
└── tasks/
|
||||||
└── HAI-001/
|
└── HAI-001/
|
||||||
├── task.json # Metadata (column, deps, timestamps)
|
├── task.json # Metadata (column, deps, timestamps)
|
||||||
└── PROMPT.md # Task specification
|
├── PROMPT.md # Task specification
|
||||||
|
└── attachments/ # Screenshot images (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Board UI
|
### Board UI
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { runDashboard } from "./commands/dashboard.js";
|
import { runDashboard } from "./commands/dashboard.js";
|
||||||
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow } from "./commands/task.js";
|
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach } from "./commands/task.js";
|
||||||
|
|
||||||
const HELP = `
|
const HELP = `
|
||||||
hai — AI-orchestrated task board
|
hai — AI-orchestrated task board
|
||||||
@@ -15,6 +15,7 @@ Usage:
|
|||||||
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
||||||
hai task log <id> <message> Add a log entry
|
hai task log <id> <message> Add a log entry
|
||||||
hai task merge <id> Merge an in-review task and close it
|
hai task merge <id> Merge an in-review task and close it
|
||||||
|
hai task attach <id> <file> Attach a screenshot image to a task
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--port, -p <port> Dashboard port (default: 4040)
|
--port, -p <port> Dashboard port (default: 4040)
|
||||||
@@ -100,6 +101,15 @@ async function main() {
|
|||||||
await runTaskMerge(id);
|
await runTaskMerge(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "attach": {
|
||||||
|
const id = args[2], file = args[3];
|
||||||
|
if (!id || !file) {
|
||||||
|
console.error("Usage: hai task attach <id> <file>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runTaskAttach(id, file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
||||||
console.log("Try: hai task create | list | move");
|
console.log("Try: hai task create | list | move");
|
||||||
|
|||||||
@@ -169,6 +169,49 @@ export async function runTaskMerge(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runTaskAttach(id: string, filePath: string) {
|
||||||
|
const { readFile } = await import("node:fs/promises");
|
||||||
|
const { basename, extname } = await import("node:path");
|
||||||
|
const { resolve } = await import("node:path");
|
||||||
|
|
||||||
|
const resolvedPath = resolve(filePath);
|
||||||
|
const filename = basename(resolvedPath);
|
||||||
|
const ext = extname(filename).toLowerCase();
|
||||||
|
const mimeType = MIME_TYPES[ext];
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
console.error(`Unsupported file type: ${ext}`);
|
||||||
|
console.error(`Supported: ${Object.keys(MIME_TYPES).join(", ")}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: Buffer;
|
||||||
|
try {
|
||||||
|
content = await readFile(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
console.error(`Cannot read file: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await getStore();
|
||||||
|
const attachment = await store.addAttachment(id, filename, content, mimeType);
|
||||||
|
|
||||||
|
const sizeKB = (attachment.size / 1024).toFixed(1);
|
||||||
|
console.log();
|
||||||
|
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
|
||||||
|
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
|
||||||
|
console.log(` Path: .hai/tasks/${id}/attachments/${attachment.filename}`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
export async function runTaskMove(id: string, column: string) {
|
export async function runTaskMove(id: string, column: string) {
|
||||||
if (!COLUMNS.includes(column as Column)) {
|
if (!COLUMNS.includes(column as Column)) {
|
||||||
console.error(`Invalid column: ${column}`);
|
console.error(`Invalid column: ${column}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "./store.js";
|
||||||
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { Task } from "./types.js";
|
import type { Task } from "./types.js";
|
||||||
|
|
||||||
@@ -165,6 +165,85 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Attachment tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("attachments", () => {
|
||||||
|
const TINY_PNG = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("adds an attachment and persists metadata in task.json", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "screenshot.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
expect(attachment.originalName).toBe("screenshot.png");
|
||||||
|
expect(attachment.mimeType).toBe("image/png");
|
||||||
|
expect(attachment.size).toBe(TINY_PNG.length);
|
||||||
|
expect(attachment.filename).toMatch(/^\d+-screenshot\.png$/);
|
||||||
|
|
||||||
|
// Verify metadata persisted
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
expect(updated.attachments).toHaveLength(1);
|
||||||
|
expect(updated.attachments![0].filename).toBe(attachment.filename);
|
||||||
|
|
||||||
|
// Verify file on disk
|
||||||
|
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
expect(content).toEqual(TINY_PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-image mime types", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.addAttachment(task.id, "file.txt", Buffer.from("hello"), "text/plain"),
|
||||||
|
).rejects.toThrow("Invalid mime type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized files", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const bigBuffer = Buffer.alloc(6 * 1024 * 1024); // 6MB
|
||||||
|
await expect(
|
||||||
|
store.addAttachment(task.id, "big.png", bigBuffer, "image/png"),
|
||||||
|
).rejects.toThrow("File too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets attachment path and mime type", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "shot.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
const result = await store.getAttachment(task.id, attachment.filename);
|
||||||
|
expect(result.mimeType).toBe("image/png");
|
||||||
|
expect(result.path).toContain(attachment.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an attachment from disk and metadata", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const attachment = await store.addAttachment(task.id, "del.png", TINY_PNG, "image/png");
|
||||||
|
|
||||||
|
const updated = await store.deleteAttachment(task.id, attachment.filename);
|
||||||
|
expect(updated.attachments).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify file removed from disk
|
||||||
|
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when getting non-existent attachment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.getAttachment(task.id, "nonexistent.png"),
|
||||||
|
).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when deleting non-existent attachment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await expect(
|
||||||
|
store.deleteAttachment(task.id, "nonexistent.png"),
|
||||||
|
).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Concurrent stress test ───────────────────────────────────────
|
// ── Concurrent stress test ───────────────────────────────────────
|
||||||
|
|
||||||
describe("concurrent stress", () => {
|
describe("concurrent stress", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdir, readFile, writeFile, readdir, rename } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||||
import { join, sep } from "node:path";
|
import { join, sep } from "node:path";
|
||||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||||
|
|
||||||
export interface TaskStoreEvents {
|
export interface TaskStoreEvents {
|
||||||
@@ -713,6 +713,118 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ALLOWED_MIME_TYPES = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
async addAttachment(
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
content: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<TaskAttachment> {
|
||||||
|
if (!TaskStore.ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid mime type '${mimeType}'. Allowed: ${[...TaskStore.ALLOWED_MIME_TYPES].join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (content.length > TaskStore.MAX_ATTACHMENT_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`File too large (${content.length} bytes). Maximum: ${TaskStore.MAX_ATTACHMENT_SIZE} bytes (5MB)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const attachDir = join(dir, "attachments");
|
||||||
|
await mkdir(attachDir, { recursive: true });
|
||||||
|
|
||||||
|
// Sanitize filename: keep alphanumeric, dots, hyphens, underscores
|
||||||
|
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const storedName = `${Date.now()}-${sanitized}`;
|
||||||
|
await writeFile(join(attachDir, storedName), content);
|
||||||
|
|
||||||
|
const attachment: TaskAttachment = {
|
||||||
|
filename: storedName,
|
||||||
|
originalName: filename,
|
||||||
|
mimeType,
|
||||||
|
size: content.length,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
if (!task.attachments) task.attachments = [];
|
||||||
|
task.attachments.push(attachment);
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachment(
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<{ path: string; mimeType: string }> {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
const attachment = task.attachments?.find((a) => a.filename === filename);
|
||||||
|
if (!attachment) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error(
|
||||||
|
`Attachment '${filename}' not found on task ${id}`,
|
||||||
|
);
|
||||||
|
err.code = "ENOENT";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: join(dir, "attachments", filename),
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||||
|
return this.withTaskLock(id, async () => {
|
||||||
|
const dir = this.taskDir(id);
|
||||||
|
const task = await this.readTaskJson(dir);
|
||||||
|
const idx = task.attachments?.findIndex((a) => a.filename === filename) ?? -1;
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error(
|
||||||
|
`Attachment '${filename}' not found on task ${id}`,
|
||||||
|
);
|
||||||
|
err.code = "ENOENT";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove file from disk
|
||||||
|
const filePath = join(dir, "attachments", filename);
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
|
||||||
|
task.attachments!.splice(idx, 1);
|
||||||
|
if (task.attachments!.length === 0) {
|
||||||
|
task.attachments = undefined;
|
||||||
|
}
|
||||||
|
task.updatedAt = new Date().toISOString();
|
||||||
|
await this.atomicWriteTaskJson(dir, task);
|
||||||
|
|
||||||
|
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
|
||||||
|
return task;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getRootDir(): string {
|
getRootDir(): string {
|
||||||
return this.rootDir;
|
return this.rootDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export interface TaskLogEntry {
|
|||||||
outcome?: string;
|
outcome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskAttachment {
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -24,6 +32,7 @@ export interface Task {
|
|||||||
steps: TaskStep[];
|
steps: TaskStep[];
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
attachments?: TaskAttachment[];
|
||||||
log: TaskLogEntry[];
|
log: TaskLogEntry[];
|
||||||
size?: "S" | "M" | "L";
|
size?: "S" | "M" | "L";
|
||||||
reviewLevel?: number;
|
reviewLevel?: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
||||||
|
|
||||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
@@ -54,3 +54,19 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
|||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAttachment(id: string, file: File): Promise<TaskAttachment> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await fetch(`/api/tasks/${id}/attachments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed");
|
||||||
|
return data as TaskAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||||
|
return api<Task>(`/tasks/${id}/attachments/${filename}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, TaskDetail, Column, MergeResult } from "@hai/core";
|
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
|
||||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
||||||
|
import { uploadAttachment, deleteAttachment } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
function formatTimestamp(iso: string): string {
|
||||||
@@ -20,6 +21,12 @@ function formatTimestamp(iso: string): string {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: TaskDetail;
|
task: TaskDetail;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -37,6 +44,9 @@ export function TaskDetailModal({
|
|||||||
onMergeTask,
|
onMergeTask,
|
||||||
addToast,
|
addToast,
|
||||||
}: TaskDetailModalProps) {
|
}: TaskDetailModalProps) {
|
||||||
|
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
@@ -92,6 +102,32 @@ export function TaskDetailModal({
|
|||||||
});
|
});
|
||||||
}, [task.id, onMergeTask, onClose, addToast]);
|
}, [task.id, onMergeTask, onClose, addToast]);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const attachment = await uploadAttachment(task.id, file);
|
||||||
|
setAttachments((prev) => [...prev, attachment]);
|
||||||
|
addToast("Screenshot attached", "success");
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, [task.id, addToast]);
|
||||||
|
|
||||||
|
const handleDeleteAttachment = useCallback(async (filename: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAttachment(task.id, filename);
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.filename !== filename));
|
||||||
|
addToast("Attachment deleted", "info");
|
||||||
|
} catch (err: any) {
|
||||||
|
addToast(err.message, "error");
|
||||||
|
}
|
||||||
|
}, [task.id, addToast]);
|
||||||
|
|
||||||
const transitions = VALID_TRANSITIONS[task.column] || [];
|
const transitions = VALID_TRANSITIONS[task.column] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,6 +161,78 @@ export function TaskDetailModal({
|
|||||||
<div className="detail-prompt">(no prompt)</div>
|
<div className="detail-prompt">(no prompt)</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>Attachments</h4>
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", marginBottom: "8px" }}>
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.filename}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
border: "1px solid var(--border, #333)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "4px",
|
||||||
|
background: "var(--bg-secondary, #1a1a2e)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/api/tasks/${task.id}/attachments/${a.filename}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/tasks/${task.id}/attachments/${a.filename}`}
|
||||||
|
alt={a.originalName}
|
||||||
|
style={{ maxWidth: "150px", maxHeight: "100px", display: "block", borderRadius: "4px" }}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.7 }}>
|
||||||
|
{a.originalName} ({formatBytes(a.size)})
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAttachment(a.filename)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "2px",
|
||||||
|
right: "2px",
|
||||||
|
background: "rgba(0,0,0,0.6)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
title="Delete attachment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no attachments)</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleUpload}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading…" : "Attach Screenshot"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{task.dependencies && task.dependencies.length > 0 && (
|
{task.dependencies && task.dependencies.length > 0 && (
|
||||||
<div className="detail-deps">
|
<div className="detail-deps">
|
||||||
<h4>Dependencies</h4>
|
<h4>Dependencies</h4>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hai/core": "workspace:*",
|
"@hai/core": "workspace:*",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { createApiRoutes } from "./routes.js";
|
import { createApiRoutes } from "./routes.js";
|
||||||
import type { TaskStore } from "@hai/core";
|
import type { TaskStore, TaskAttachment } from "@hai/core";
|
||||||
import type { TaskDetail } from "@hai/core";
|
import type { TaskDetail } from "@hai/core";
|
||||||
|
|
||||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
@@ -54,6 +54,49 @@ async function GET(app: express.Express, path: string): Promise<{ status: number
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Helper: send a request with method/body and return { status, body } */
|
||||||
|
async function REQUEST(
|
||||||
|
app: express.Express,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Buffer | string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<{ status: number; body: any }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(0, () => {
|
||||||
|
const addr = server.address() as { port: number };
|
||||||
|
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
|
||||||
|
const req = http.request(
|
||||||
|
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
server.close();
|
||||||
|
try {
|
||||||
|
resolve({ status: res.statusCode!, body: JSON.parse(data) });
|
||||||
|
} catch {
|
||||||
|
resolve({ status: res.statusCode!, body: data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", (err) => { server.close(); reject(err); });
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal multipart/form-data body */
|
||||||
|
function buildMultipart(fieldName: string, filename: string, contentType: string, content: Buffer): { body: Buffer; boundary: string } {
|
||||||
|
const boundary = "----TestBoundary" + Date.now();
|
||||||
|
const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
|
||||||
|
const footer = `\r\n--${boundary}--\r\n`;
|
||||||
|
const body = Buffer.concat([Buffer.from(header), content, Buffer.from(footer)]);
|
||||||
|
return { body, boundary };
|
||||||
|
}
|
||||||
|
|
||||||
describe("GET /tasks/:id", () => {
|
describe("GET /tasks/:id", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
|
||||||
@@ -99,3 +142,97 @@ describe("GET /tasks/:id", () => {
|
|||||||
expect(res.body.error).toContain("Unexpected end of JSON input");
|
expect(res.body.error).toContain("Unexpected end of JSON input");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Attachment routes", () => {
|
||||||
|
const FAKE_ATTACHMENT: TaskAttachment = {
|
||||||
|
filename: "1234-screenshot.png",
|
||||||
|
originalName: "screenshot.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
size: 100,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore({
|
||||||
|
addAttachment: vi.fn().mockResolvedValue(FAKE_ATTACHMENT),
|
||||||
|
getAttachment: vi.fn(),
|
||||||
|
deleteAttachment: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, attachments: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("POST /tasks/:id/attachments — uploads a valid image", async () => {
|
||||||
|
const content = Buffer.from("fake png content");
|
||||||
|
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.filename).toBe("1234-screenshot.png");
|
||||||
|
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
"HAI-001",
|
||||||
|
"screenshot.png",
|
||||||
|
expect.any(Buffer),
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /tasks/:id/attachments — returns 400 for invalid mime type", async () => {
|
||||||
|
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("Invalid mime type 'text/plain'. Allowed: image/png, image/jpeg, image/gif, image/webp"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = Buffer.from("not an image");
|
||||||
|
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("Invalid mime type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /tasks/:id/attachments — returns 400 for oversized file", async () => {
|
||||||
|
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("File too large"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = Buffer.from("small but store rejects");
|
||||||
|
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("File too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
|
||||||
|
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/1234-screenshot.png");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("HAI-001", "1234-screenshot.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||||||
|
const err: NodeJS.ErrnoException = new Error("Attachment not found");
|
||||||
|
err.code = "ENOENT";
|
||||||
|
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/nope.png");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
||||||
import { COLUMNS } from "@hai/core";
|
import { COLUMNS } from "@hai/core";
|
||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
});
|
||||||
|
|
||||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -101,6 +108,55 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload attachment
|
||||||
|
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: "No file provided" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attachment = await store.addAttachment(
|
||||||
|
req.params.id as string,
|
||||||
|
req.file.originalname,
|
||||||
|
req.file.buffer,
|
||||||
|
req.file.mimetype,
|
||||||
|
);
|
||||||
|
res.status(201).json(attachment);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message.includes("Invalid mime type") || err.message.includes("File too large") ? 400 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download attachment
|
||||||
|
router.get("/tasks/:id/attachments/:filename", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path, mimeType } = await store.getAttachment(req.params.id, req.params.filename);
|
||||||
|
res.setHeader("Content-Type", mimeType);
|
||||||
|
createReadStream(path).pipe(res);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Attachment not found" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete attachment
|
||||||
|
router.delete("/tasks/:id/attachments/:filename", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const task = await store.deleteAttachment(req.params.id, req.params.filename);
|
||||||
|
res.json(task);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
res.status(404).json({ error: "Attachment not found" });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get single task with prompt content
|
// Get single task with prompt content
|
||||||
router.get("/tasks/:id", async (req, res) => {
|
router.get("/tasks/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
115
pnpm-lock.yaml
generated
115
pnpm-lock.yaml
generated
@@ -51,12 +51,18 @@ importers:
|
|||||||
'@hai/core':
|
'@hai/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0
|
||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.7.0(react@19.2.4)
|
version: 1.7.0(react@19.2.4)
|
||||||
|
multer:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -1313,6 +1319,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.1.0':
|
||||||
|
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
|
||||||
|
|
||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
@@ -1453,6 +1462,9 @@ packages:
|
|||||||
any-promise@1.3.0:
|
any-promise@1.3.0:
|
||||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||||
|
|
||||||
|
append-field@1.0.0:
|
||||||
|
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||||
|
|
||||||
aria-query@5.3.0:
|
aria-query@5.3.0:
|
||||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||||
|
|
||||||
@@ -1515,6 +1527,13 @@ packages:
|
|||||||
buffer-equal-constant-time@1.0.1:
|
buffer-equal-constant-time@1.0.1:
|
||||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
|
buffer-from@1.1.2:
|
||||||
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1587,6 +1606,10 @@ packages:
|
|||||||
comma-separated-tokens@2.0.3:
|
comma-separated-tokens@2.0.3:
|
||||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||||
|
|
||||||
|
concat-stream@2.0.0:
|
||||||
|
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||||
|
engines: {'0': node >= 6.0}
|
||||||
|
|
||||||
content-disposition@1.0.1:
|
content-disposition@1.0.1:
|
||||||
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
|
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2132,6 +2155,10 @@ packages:
|
|||||||
mdn-data@2.27.1:
|
mdn-data@2.27.1:
|
||||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
|
media-typer@0.3.0:
|
||||||
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
media-typer@1.1.0:
|
media-typer@1.1.0:
|
||||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2224,10 +2251,18 @@ packages:
|
|||||||
micromark@4.0.2:
|
micromark@4.0.2:
|
||||||
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-db@1.54.0:
|
mime-db@1.54.0:
|
||||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-types@3.0.2:
|
mime-types@3.0.2:
|
||||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2247,6 +2282,10 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
multer@2.1.1:
|
||||||
|
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
|
||||||
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
@@ -2437,6 +2476,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@3.6.2:
|
||||||
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2567,10 +2610,17 @@ packages:
|
|||||||
std-env@4.0.0:
|
std-env@4.0.0:
|
||||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||||
|
|
||||||
|
streamsearch@1.1.0:
|
||||||
|
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
string_decoder@1.3.0:
|
||||||
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
@@ -2686,10 +2736,17 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
type-is@1.6.18:
|
||||||
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
type-is@2.0.1:
|
type-is@2.0.1:
|
||||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
typedarray@0.0.6:
|
||||||
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -2734,6 +2791,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4353,6 +4413,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
@@ -4516,6 +4580,8 @@ snapshots:
|
|||||||
|
|
||||||
any-promise@1.3.0: {}
|
any-promise@1.3.0: {}
|
||||||
|
|
||||||
|
append-field@1.0.0: {}
|
||||||
|
|
||||||
aria-query@5.3.0:
|
aria-query@5.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
@@ -4576,6 +4642,12 @@ snapshots:
|
|||||||
|
|
||||||
buffer-equal-constant-time@1.0.1: {}
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
dependencies:
|
||||||
|
streamsearch: 1.1.0
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
@@ -4644,6 +4716,13 @@ snapshots:
|
|||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
|
|
||||||
|
concat-stream@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
buffer-from: 1.1.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 3.6.2
|
||||||
|
typedarray: 0.0.6
|
||||||
|
|
||||||
content-disposition@1.0.1: {}
|
content-disposition@1.0.1: {}
|
||||||
|
|
||||||
content-type@1.0.5: {}
|
content-type@1.0.5: {}
|
||||||
@@ -5363,6 +5442,8 @@ snapshots:
|
|||||||
|
|
||||||
mdn-data@2.27.1: {}
|
mdn-data@2.27.1: {}
|
||||||
|
|
||||||
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|
||||||
merge-descriptors@2.0.0: {}
|
merge-descriptors@2.0.0: {}
|
||||||
@@ -5558,8 +5639,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
mime-db@1.54.0: {}
|
mime-db@1.54.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
mime-types@3.0.2:
|
mime-types@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.54.0
|
mime-db: 1.54.0
|
||||||
@@ -5574,6 +5661,13 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
multer@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
append-field: 1.0.0
|
||||||
|
busboy: 1.6.0
|
||||||
|
concat-stream: 2.0.0
|
||||||
|
type-is: 1.6.18
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
@@ -5787,6 +5881,12 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
|
readable-stream@3.6.2:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
indent-string: 4.0.0
|
indent-string: 4.0.0
|
||||||
@@ -5978,12 +6078,18 @@ snapshots:
|
|||||||
|
|
||||||
std-env@4.0.0: {}
|
std-env@4.0.0: {}
|
||||||
|
|
||||||
|
streamsearch@1.1.0: {}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
is-fullwidth-code-point: 3.0.0
|
is-fullwidth-code-point: 3.0.0
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
string_decoder@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
@@ -6089,12 +6195,19 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
type-is@1.6.18:
|
||||||
|
dependencies:
|
||||||
|
media-typer: 0.3.0
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
type-is@2.0.1:
|
type-is@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
media-typer: 1.1.0
|
media-typer: 1.1.0
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
|
|
||||||
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
uint8array-extras@1.5.0: {}
|
||||||
@@ -6144,6 +6257,8 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
|
|||||||
Reference in New Issue
Block a user