commit 95fc50e2c2472752ae3a6bb490ebd3d8e58494f4 Author: h Date: Sat Sep 19 23:33:24 2026 +0200 feat: lodge server, client and admin diff --git a/.claude/rules/svelte.md b/.claude/rules/svelte.md new file mode 100644 index 0000000..4711cd0 --- /dev/null +++ b/.claude/rules/svelte.md @@ -0,0 +1,23 @@ +You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: + +## Available Svelte MCP Tools: + +### 1. list-sections + +Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. +When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. + +### 2. get-documentation + +Retrieves full documentation content for specific sections. Accepts single or multiple sections. +After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. + +### 3. svelte-autofixer + +Analyzes Svelte code and returns issues and suggestions. +You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. + +### 4. playground-link + +Generates a Svelte Playground link with the provided code. +After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. diff --git a/.claude/rules/ultracite.md b/.claude/rules/ultracite.md new file mode 100644 index 0000000..05ab812 --- /dev/null +++ b/.claude/rules/ultracite.md @@ -0,0 +1,123 @@ +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `bun x ultracite fix` +- **Check for issues**: `bun x ultracite check` +- **Diagnose setup**: `bun x ultracite doctor` + +Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (`", + "css": ".ds-act { font-family: var(--sans); font-size: 1rem; font-weight: 600; padding: 0.125rem 0; color: var(--green); background: none; border: 0; border-bottom: 1px solid currentColor; cursor: pointer; transition: color 150ms var(--ease), background-color 150ms var(--ease); } .ds-act:hover { background: var(--green-wash); } .ds-act:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }" + }, + { + "name": "Text Action, Danger", + "kind": "button", + "refersTo": "action-text-danger", + "description": "Destructive or declining text action in curve red.", + "html": "", + "css": ".ds-act { font-family: var(--sans); font-size: 1rem; font-weight: 600; padding: 0.125rem 0; color: var(--green); background: none; border: 0; border-bottom: 1px solid currentColor; cursor: pointer; transition: color 150ms var(--ease), background-color 150ms var(--ease); } .ds-act:hover { background: var(--green-wash); } .ds-act:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; } .ds-act-danger { color: var(--red); } .ds-act-danger:hover { background: var(--red-wash); }" + }, + { + "name": "Solid Action", + "kind": "button", + "refersTo": "action-solid", + "description": "The single primary act of a page: paper text on ink, green on hover.", + "html": "", + "css": ".ds-solid { font-family: var(--sans); font-size: 1rem; font-weight: 600; padding: 0.625rem 1rem; color: var(--paper); background: var(--ink); border: 0; border-radius: 0; cursor: pointer; transition: background-color 150ms var(--ease); } .ds-solid:hover { background: var(--green); } .ds-solid:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }" + }, + { + "name": "Solid Action, Danger", + "kind": "button", + "refersTo": "action-solid-danger", + "description": "Irreversible deletion, enabled only after the name is typed.", + "html": "", + "css": ".ds-solid-danger { font-family: var(--sans); font-size: 1rem; font-weight: 600; padding: 0.625rem 1rem; color: var(--paper); background: var(--red); border: 0; border-radius: 0; cursor: pointer; } .ds-solid-danger:disabled { opacity: 0.5; cursor: default; } .ds-solid-danger:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }" + }, + { + "name": "Field", + "kind": "input", + "refersTo": "field", + "description": "Underlined input: 2px ink rule that turns curve red on focus.", + "html": "", + "css": ".ds-label { display: block; font-family: var(--sans); font-size: 0.75rem; font-weight: 500; font-stretch: 80%; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.06em; } .ds-field { width: 100%; padding: 0.5rem 0; font-family: var(--mono); font-size: 0.8125rem; color: var(--ink); caret-color: var(--red); background: transparent; border: 0; border-bottom: 2px solid var(--ink); border-radius: 0; } .ds-field::placeholder { color: var(--ink-2); } .ds-field:focus-visible { outline: none; border-bottom-color: var(--red); }" + }, + { + "name": "Top Bar", + "kind": "nav", + "refersTo": "top-bar", + "description": "3rem bar: condensed wordmark, section links with a red underline on the current one, plain tools on the right.", + "html": "
lodge
", + "css": ".ds-bar { display: flex; gap: 1.5rem; align-items: center; height: 3rem; padding: 0 1rem; font-family: var(--sans); color: var(--ink); background: var(--paper); border-bottom: 1px solid var(--rule); } .ds-wordmark { font-size: 1.25rem; font-weight: 800; font-stretch: 68%; letter-spacing: 0.01em; color: inherit; text-decoration: none; } .ds-nav { display: flex; gap: 1.25rem; } .ds-nav a { color: inherit; text-decoration: underline 1px transparent; text-underline-offset: 0.22em; transition: color 150ms var(--ease), text-decoration-color 150ms var(--ease); } .ds-nav a:hover { color: var(--green); text-decoration-color: currentColor; } .ds-nav a[aria-current=page] { text-decoration-thickness: 2px; text-decoration-color: var(--red); } .ds-tools { display: flex; gap: 1.25rem; margin-left: auto; } .ds-plain { padding: 0.25rem 0; font: inherit; color: var(--ink-2); background: none; border: 0; cursor: pointer; transition: color 150ms var(--ease); } .ds-plain:hover { color: var(--ink); } .ds-bar :focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }" + }, + { + "name": "Title Block", + "kind": "custom", + "description": "Ruled dl above the column: uppercase field labels over stamped values.", + "html": "
Repository
beavergram
Depth
52 / 64
Root
3f3f3f3f3f3f
", + "css": ".ds-titleblock { display: grid; grid-template-columns: 1fr 1fr; width: 17.5rem; margin: 0; font-family: var(--sans); color: var(--ink); background: var(--paper); border-right: 1px solid var(--ink); border-bottom: 1px solid var(--ink); } .ds-titleblock div { padding: 0.5rem 0.75rem; border-top: 1px solid var(--rule); } .ds-titleblock div + div + div { border-left: 1px solid var(--rule); } .ds-titleblock .ds-tb-wide { grid-column: 1 / -1; border-top: 0; } .ds-titleblock dt { font-size: 0.75rem; font-weight: 500; font-stretch: 80%; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.06em; } .ds-titleblock dd { margin: 0; } .ds-tb-name, .ds-tb-stamp { font-weight: 600; font-stretch: 68%; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; } .ds-tb-name { font-size: 1.75rem; line-height: 1.1; } .ds-tb-mono { font-family: var(--mono); font-size: 0.8125rem; }" + }, + { + "name": "Strip Log Beds", + "kind": "custom", + "description": "Beds on the depth grid: stamped depth, machine hatch, touch mark, formation label; the selected bed carries the red cursor and beds above it lose their hatch.", + "html": "
", + "css": ".ds-lith { --l: var(--ink); position: relative; background-color: var(--paper); } .ds-lith::after { position: absolute; inset: 0; content: ''; background: var(--l); } .ds-lith-0::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E\") repeat; } .ds-lith-1::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E\") repeat; } .ds-lith-2::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E\") repeat; } .ds-lith-3::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E\") repeat; } .ds-lith-4::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E\") repeat; } .ds-lith-5::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E\") repeat; } .ds-lith-6::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E\") repeat; } .ds-lith-web { background-color: var(--ochre); } .ds-lith-web::after, .ds-lith-gone::after { display: none; } .ds-lith-gone { outline: 1px dashed var(--rule); outline-offset: -1px; } .ds-column { display: flex; flex-direction: column; width: 17.5rem; padding: 1.5rem 0 1rem; font-family: var(--sans); background-color: var(--paper-2); background-image: linear-gradient(var(--grid-strong) 1px, transparent 1px), linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px); background-size: 100% 40px, 100% 8px, 8px 100%; border-right: 1px solid var(--ink); } .ds-bed { position: relative; display: grid; flex: none; grid-template-columns: 2.75rem 4.5rem 0.5rem 1fr; align-items: stretch; padding: 0; font: inherit; color: var(--ink); text-align: left; background: none; border: 0; border-top: 1px solid var(--rule); cursor: pointer; } .ds-depth { padding-right: 0.5rem; font-size: 0.8125rem; font-weight: 600; font-stretch: 68%; font-variant-numeric: tabular-nums; line-height: 1.25rem; color: var(--ink-2); text-align: right; } .ds-fill { border-inline: 1px solid var(--ink); } .ds-bed:hover .ds-fill { --l: var(--green); } .ds-bed-above .ds-fill::after { display: none; } .ds-mark { background: var(--green); } .ds-formation { display: flex; gap: 0.375rem; padding-left: 0.375rem; font-size: 0.75rem; line-height: 1.25rem; color: var(--ink-2); white-space: nowrap; } .ds-formation b { font-weight: 600; font-stretch: 80%; color: var(--ink); } .ds-formation code { font-family: var(--mono); font-size: 0.8125em; } .ds-bed[aria-selected=true]::before { position: absolute; inset: -1px 0 auto; z-index: 1; height: 3px; content: ''; background: var(--red); } .ds-bed[aria-selected=true]::after { position: absolute; top: -1.375rem; right: 0; z-index: 3; width: 0.625rem; height: 2.75rem; content: ''; background: var(--red); } .ds-bed[aria-selected=true] .ds-depth { color: var(--red); } .ds-bed:focus-visible { z-index: 2; outline: 2px solid var(--red); outline-offset: -2px; }" + }, + { + "name": "Lithology Legend", + "kind": "custom", + "description": "The seven machine hatches as CSS masks over ink, plus solid ochre for the web and the dashed outline for a machine that is gone.", + "html": "
", + "css": ".ds-lith { --l: var(--ink); position: relative; background-color: var(--paper); } .ds-lith::after { position: absolute; inset: 0; content: ''; background: var(--l); } .ds-lith-0::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E\") repeat; } .ds-lith-1::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E\") repeat; } .ds-lith-2::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E\") repeat; } .ds-lith-3::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E\") repeat; } .ds-lith-4::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E\") repeat; } .ds-lith-5::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E\") repeat; } .ds-lith-6::after { -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E\") repeat; mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E\") repeat; } .ds-lith-web { background-color: var(--ochre); } .ds-lith-web::after, .ds-lith-gone::after { display: none; } .ds-lith-gone { outline: 1px dashed var(--rule); outline-offset: -1px; } .ds-legend { display: grid; grid-template-columns: repeat(9, 4rem); gap: 0.75rem; padding: 1rem; background: var(--paper); } .ds-swatch { height: 3rem; border: 1px solid var(--ink); } .ds-swatch:hover { --l: var(--green); }" + }, + { + "name": "File Row", + "kind": "custom", + "refersTo": "file-row", + "description": "File list row with its change track; hover is a green wash, the open file inverts to paper on ink.", + "html": "", + "css": ".ds-rows { width: 17rem; padding: 0; margin: 0; list-style: none; font-family: var(--sans); color: var(--ink); background: var(--paper); border-top: 1px solid var(--ink); } .ds-rows a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.125rem 0.75rem; align-items: baseline; padding: 0.5rem 0.375rem; color: inherit; text-decoration: none; border-bottom: 1px solid var(--rule); transition: background-color 150ms var(--ease); } .ds-rows a:hover { background: var(--green-wash); } .ds-rows a[aria-current=true] { color: var(--paper); background: var(--ink); } .ds-rows a:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; } .ds-file { grid-column: 1 / -1; font-weight: 600; } .ds-dir { font-weight: 400; opacity: 0.65; } .ds-track { position: relative; grid-row: 2; grid-column: 1 / -1; height: 0.4375rem; border-bottom: 1px solid currentColor; opacity: 0.85; } .ds-track i { position: absolute; bottom: 0; width: 2px; height: 100%; background: currentColor; } .ds-track .ds-tick-above { opacity: 0.3; } .ds-when { font-size: 0.8125rem; opacity: 0.7; }" + }, + { + "name": "Diff Lines", + "kind": "custom", + "description": "Changes view: mono lines with green and red washes, signed gutters, and a dashed gap for collapsed context.", + "html": "
  1. ## Done
  2. step 3 pending
  3. step 3 verified on both machines
  4. 14 unchanged lines
  5. ## Next
", + "css": ".ds-diff { padding: 0; margin: 0; list-style: none; font-family: var(--mono); font-size: 0.8125rem; line-height: 1.6; white-space: pre; color: var(--ink); background: var(--paper); } .ds-diff li { position: relative; padding: 0 0.75rem 0 1.75rem; } .ds-same { color: var(--ink-2); } .ds-added { background: var(--green-wash); } .ds-removed { background: var(--red-wash); } .ds-added::before, .ds-removed::before { position: absolute; left: 0.5rem; font-weight: 700; } .ds-added::before { color: var(--green); content: '+'; } .ds-removed::before { color: var(--red); content: '\\2212'; } .ds-removed del { text-decoration-color: var(--red); } .ds-diff .ds-gap { padding-block: 0.25rem; margin: 0.5rem 0; font-family: var(--sans); color: var(--ink-2); border-block: 1px dashed var(--rule); }" + } + ], + "narrative": { + "northStar": "The Borehole Strip Log", + "overview": "**Creative North Star: \"The Borehole Strip Log\"**\n\nHistory is a drilled column. Every revision is a bed at its depth, newest at the surface, and the note on screen is the core pulled from the selected depth. The interface is a sheet of survey log paper: a fine green depth grid under the column, ink rules, condensed stamped numerals, and one hatch per machine, the way a geologist's log gives every rock type its own fill. The machines page is the legend of that log.\n\nThe sheet carries no chrome. There are no cards, no boxed buttons, no shadows and no rounded corners. Hierarchy comes from scale contrast (a 4rem condensed repository name against 0.75rem labels), from full-strength ink rules against faint ones, and from fills: ink for the current row, washes for hover, hatches for who wrote what. It refuses the forge layout of a file table with a commits tab, and the metric-card dashboard.\n\nThe dark theme is the same sheet as a night print: the same grid, rules and hatches in pale ink on a near-black blue ground. Nothing is restyled between themes; only the token values change, which is why hatches are masks over the ink colour instead of drawn images.", + "keyCharacteristics": [ + "One column of beds is the navigation: drag it, step it with arrow keys or j/k, and every depth, file and view is a deep link.", + "Type, fills and rules only. Zero radius, zero shadow, zero boxed controls.", + "Three working colours with fixed meanings: green selects and links, red is the depth cursor and destruction, ochre is the web's own hand.", + "Archivo's width axis does the stamping: the narrower the setting, the more the text is data.", + "Reading first: the note sits at a 70ch measure and management lives in the margins." + ], + "rules": [ + { + "name": "The Three Pens Rule", + "body": "Green, red and ochre each have one job. Green means select or go, red means you are here or this destroys, ochre means the web did it. A new state borrows one of those meanings or uses ink; it does not get a fourth colour.", + "section": "colors" + }, + { + "name": "The Text Contrast Rule", + "body": "Green and red are used as text on paper, so they are held at 4.5:1 or better against `log-paper`. This is why the shipped light values are darker than the direction contract's #2f7d62 and #c5402f: those failed as text on #eef0e6. Ochre does not pass as text on paper and is used only as a fill or a rule.", + "section": "colors" + }, + { + "name": "The Night Print Rule", + "body": "The dark ground is `night-ground` (#162026), not the contract's slate #33444c. The owner saw the slate ground during the build and asked for the darker one back; treat this as a decided value. A `--slate` custom property is still declared in the stylesheet but nothing uses it.", + "section": "colors" + }, + { + "name": "The Width Is Meaning Rule", + "body": "Condensed width marks data the system stamped: names, depths, counts, kinds. Sentences a person reads stay at normal width. Do not condense body copy to save space, and do not set a stamp at normal width.", + "section": "typography" + }, + { + "name": "The Label Names A Field Rule", + "body": "An uppercase label always names the value or list directly under it, as in a drawing's title block. It is never a decorative line above a headline.", + "section": "typography" + }, + { + "name": "The One Sheet Rule", + "body": "Everything is printed on the same sheet. If something needs to stand out, give it an ink rule, a fill or a bigger size, not a shadow or a raised surface.", + "section": "elevation" + } + ], + "dos": [ + "Do take every colour from the custom properties so the night print follows for free; add a value to both `:root` and `.dark` or not at all.", + "Do draw any new pattern as an SVG tile used as a CSS mask over `--l`, so it inherits ink and can be recoloured by state.", + "Do keep green and red at 4.5:1 or better on `log-paper` when they carry text, and keep ochre to fills and rules.", + "Do use Archivo's width axis for stamped data (66 to 80%) and normal width for sentences; set numerals tabular.", + "Do make every new view state a URL parameter, the way depth, file and view already are.", + "Do keep motion to 150ms state changes on the shared ease, with the clip-reveal `settle` reserved for a revision arriving live.", + "Do name component classes so they cannot collide with Tailwind utilities. Tailwind is imported globally, so a scoped class called `fixed` picked up `position: fixed` during this build. Avoid bare utility words (`fixed`, `static`, `hidden`, `block`, `flex`, `grid`, `container`, `truncate`, `underline`, `transition`) as class names." + ], + "donts": [ + "Don't add cards, boxed or pill buttons, radii or shadows. Separation is an ink rule, a `rule` line or a fill.", + "Don't build a file table with a commits tab or a metric-card dashboard; history stays a column you drag.", + "Don't give a state a fourth accent colour, and don't set ochre as text.", + "Don't lighten the night ground toward slate; #162026 is the owner's decision.", + "Don't float confirmations in modals or popovers; they open in place.", + "Don't put an uppercase label above a headline as decoration. A label names the field or the list under it.", + "Don't embed hatches as coloured images or fixed-colour backgrounds; they would not follow the theme." + ] + } +} \ No newline at end of file diff --git a/.impeccable/questions/ea064ff5.log b/.impeccable/questions/ea064ff5.log new file mode 100644 index 0000000..e69de29 diff --git a/.impeccable/surfaces/frontend.md b/.impeccable/surfaces/frontend.md new file mode 100644 index 0000000..39403a5 --- /dev/null +++ b/.impeccable/surfaces/frontend.md @@ -0,0 +1,32 @@ +--- +version: 1 +slug: "frontend" +primary_target: "frontend" +related_targets: [] +--- + +# lodge admin + +Scope: the whole authenticated admin (repositories, reader with history, machines, login). Mode: Operate, reading first. + +Audience and job: the single owner reads agent notes and their history from a desk and from a phone; management (machines, restore, delete) is the margin. Must not read as a SaaS dashboard. + +## Direction contract + +THESIS: History is a drilled column. Every revision is a bed at its depth, newest at the surface; the note being read is the core pulled from the selected depth. Refuses the forge layout of file table plus commits tab, and the metric-card dashboard. + +OWN-WORLD: Borehole strip log. Log-paper ground #eef0e6 with a fine depth grid, ink #15211d, survey green #2f7d62 for selection and links, curve red #c5402f for the depth cursor, deletions and destructive acts, ochre #c9a24a for modifications, slate #33444c. Each machine owns a lithology hatch (dots, diagonals, dashes, bricks, crosses, waves) drawn as SVG patterns; the machines page is the legend. Archivo with its width axis for condensed stamped numerals and labels, JetBrains Mono for SHAs, code and diffs. No cards, no button chrome: type, fills and rules only. Dark theme is the same sheet as a night print: slate ground, pale ink. + +STORY: The owner sees which machine wrote what against which commit, drags to any depth, reads the note as it was, compares, and brings a version back. + +FIRST VIEWPORT: Left edge, a full-height strip log about 260px wide: title block (repository name, key, total depth), then tracks: depth numerals, lithology beds sized by change count, formation labels branch@sha where the main commit changes. A red cursor line marks the selected depth. Next, the file list grouped by space, each row with a small track of where it changed. The rest is the note: large file name, one meta line, the text at a 70ch measure. On a phone the log collapses to a thumb-wide rail on the left edge that is dragged; files and reader take turns. + +SIGNATURE INTERACTION: dragging along the column re-renders files and note at that depth live; every depth, file and view is a deep link. Motion grammar: a new revision arriving over SSE settles onto the surface with a clip reveal; nothing else animates beyond 150ms state changes. + +FORM: borehole strip log, position 5 of 7 on the grounded list, seed 3210ed2a. Raises: scrub with deep links (cyclorama), per-file trend track (six-pack), chrome deleted (metro tiles), hierarchy by scale contrast (specimen). + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance + +## Unresolved + +Repositories with more than 500 revisions show only the newest 500 beds. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..04506c8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "svelte": { + "type": "http", + "url": "https://mcp.svelte.dev/mcp" + } + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e051dab --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: local + hooks: + - id: backend-fmt + name: backend fmt + entry: make -C backend fmt + language: system + files: ^backend/ + pass_filenames: false + - id: backend-check + name: backend check + entry: make -C backend check + language: system + files: ^backend/ + pass_filenames: false + - id: frontend-fmt + name: frontend fmt + entry: make -C frontend fmt + language: system + files: ^frontend/ + pass_filenames: false + - id: frontend-check + name: frontend check + entry: make -C frontend check + language: system + files: ^frontend/ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9333590 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +## Code Principles +- **Simplicity**: Write simple, straightforward code +- **Readability**: Make code easy to understand +- **Performance**: Consider performance without sacrificing readability +- **Maintainability**: Write code that's easy to update +- **Reusability**: Create reusable components and functions +- **Less Code = Less Debt**: Minimize code footprint +- **NEVER write comments** - code should be self-documenting +- **Docstrings ONLY if explicitly asked** + +## Checking commands +After writing code, always run: +```shell +make fmt +make check +``` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..58d4ae6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,273 @@ +--- +name: lodge +description: The admin of a notes server for coding agents, drawn as a borehole strip log. +colors: + survey-green: "#24694f" + curve-red: "#b3341f" + ochre: "#c9a24a" + log-paper: "#eef0e6" + log-paper-2: "#e3e7d8" + ink: "#15211d" + ink-2: "#46544d" + rule: "rgb(21 33 29 / 0.2)" + grid: "rgb(36 105 79 / 0.13)" + grid-strong: "rgb(36 105 79 / 0.3)" + green-wash: "rgb(36 105 79 / 0.12)" + red-wash: "rgb(179 52 31 / 0.11)" + night-survey-green: "#74caa6" + night-curve-red: "#f08a74" + night-ochre: "#d9b45e" + night-ground: "#162026" + night-ground-2: "#1d2a31" + night-ink: "#e6eadc" + night-ink-2: "#a5b2a8" + night-rule: "rgb(230 234 220 / 0.2)" + night-grid: "rgb(111 199 163 / 0.09)" + night-grid-strong: "rgb(111 199 163 / 0.24)" + night-green-wash: "rgb(116 202 166 / 0.14)" + night-red-wash: "rgb(240 138 116 / 0.14)" +typography: + display: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "clamp(2.25rem, 6vw, 4rem)" + fontWeight: 700 + lineHeight: 1 + fontVariation: "'wdth' 66" + headline: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "2.5rem" + fontWeight: 700 + lineHeight: 1.05 + fontVariation: "'wdth' 75" + title: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "1.75rem" + fontWeight: 600 + lineHeight: 1.1 + letterSpacing: "0.02em" + fontVariation: "'wdth' 68" + fontFeature: "'tnum'" + body: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "1rem" + fontWeight: 400 + lineHeight: 1.5 + prose: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "1.0625rem" + fontWeight: 400 + lineHeight: 1.65 + label: + fontFamily: "Archivo Variable, system-ui, sans-serif" + fontSize: "0.75rem" + fontWeight: 500 + letterSpacing: "0.06em" + fontVariation: "'wdth' 80" + mono: + fontFamily: "JetBrains Mono Variable, ui-monospace, monospace" + fontSize: "0.8125em" + fontWeight: 400 +rounded: + none: "0" +spacing: + xs: "0.5rem" + sm: "0.75rem" + md: "1rem" + lg: "1.5rem" + xl: "2rem" + 2xl: "3rem" +components: + action-text: + textColor: "{colors.survey-green}" + padding: "0.125rem 0" + rounded: "{rounded.none}" + action-text-hover: + backgroundColor: "{colors.green-wash}" + action-text-danger: + textColor: "{colors.curve-red}" + action-text-danger-hover: + backgroundColor: "{colors.red-wash}" + action-solid: + backgroundColor: "{colors.ink}" + textColor: "{colors.log-paper}" + padding: "0.625rem 1rem" + rounded: "{rounded.none}" + action-solid-hover: + backgroundColor: "{colors.survey-green}" + action-solid-danger: + backgroundColor: "{colors.curve-red}" + textColor: "{colors.log-paper}" + field: + textColor: "{colors.ink}" + padding: "0.5rem 0" + rounded: "{rounded.none}" + width: "100%" + file-row: + textColor: "{colors.ink}" + padding: "0.5rem 0.375rem" + file-row-hover: + backgroundColor: "{colors.green-wash}" + file-row-current: + backgroundColor: "{colors.ink}" + textColor: "{colors.log-paper}" + top-bar: + height: "3rem" + padding: "0 1rem" + code-block: + backgroundColor: "{colors.log-paper-2}" + typography: "{typography.mono}" + padding: "0.875rem 1rem" +--- + +# Design System: lodge + +## Overview + +**Creative North Star: "The Borehole Strip Log"** + +History is a drilled column. Every revision is a bed at its depth, newest at the surface, and the note on screen is the core pulled from the selected depth. The interface is a sheet of survey log paper: a fine green depth grid under the column, ink rules, condensed stamped numerals, and one hatch per machine, the way a geologist's log gives every rock type its own fill. The machines page is the legend of that log. + +The sheet carries no chrome. There are no cards, no boxed buttons, no shadows and no rounded corners. Hierarchy comes from scale contrast (a 4rem condensed repository name against 0.75rem labels), from full-strength ink rules against faint ones, and from fills: ink for the current row, washes for hover, hatches for who wrote what. It refuses the forge layout of a file table with a commits tab, and the metric-card dashboard. + +The dark theme is the same sheet as a night print: the same grid, rules and hatches in pale ink on a near-black blue ground. Nothing is restyled between themes; only the token values change, which is why hatches are masks over the ink colour instead of drawn images. + +**Key Characteristics:** +- One column of beds is the navigation: drag it, step it with arrow keys or j/k, and every depth, file and view is a deep link. +- Type, fills and rules only. Zero radius, zero shadow, zero boxed controls. +- Three working colours with fixed meanings: green selects and links, red is the depth cursor and destruction, ochre is the web's own hand. +- Archivo's width axis does the stamping: the narrower the setting, the more the text is data. +- Reading first: the note sits at a 70ch measure and management lives in the margins. + +## Colors + +A cool greenish log paper and near-black green ink, worked with a survey green, a curve red and a small amount of ochre. Every colour is a CSS custom property that the `.dark` class redefines; components never name a hex. + +### Primary +- **Survey Green** (`survey-green`, night: `night-survey-green`): links on hover, text actions, the selection background of `::selection`, list markers in notes, added lines and the ADDED kind in a bed, the mark beside beds that touched the open file, and the hatch colour of a hovered bed. At 12 to 14% alpha it is the hover wash (`green-wash`) and at 13% and 30% it is the depth grid (`grid`, `grid-strong`). + +### Secondary +- **Curve Red** (`curve-red`, night: `night-curve-red`): the depth cursor (a 3px line across the column with a 0.625rem by 2.75rem handle on its right edge) and the selected depth numeral; the 2px underline of the current nav item and current view; the focus ring and the text caret; deletions, problems and destructive actions. `red-wash` is its hover and removed-line fill. + +### Tertiary +- **Ochre** (`ochre`, night: `night-ochre`): a solid fill, never text. It is the lithology of revisions made from the web admin (restores), and the 1px rule beside a blockquote in a note. + +### Neutral +- **Log Paper** (`log-paper`, night: `night-ground`): the page ground, and the text colour on ink and red fills. +- **Log Paper 2** (`log-paper-2`, night: `night-ground-2`): the ground of the strip log column, code and pre blocks, the pairing command, and loading skeletons. +- **Ink** (`ink`, night: `night-ink`): text, structural 1px rules (column edge, title block base, group headings, list separators on index pages), hatches, the solid action and the current file row. +- **Ink 2** (`ink-2`, night: `night-ink-2`): labels, meta lines, depth numerals, unchanged diff lines, quiet beds, placeholders, the scrollbar thumb. +- **Rule** (`rule`, night: `night-rule`): ink at 20% for the faint separators between rows, title block cells and beds, and the resting underline of links. + +### Named Rules +**The Three Pens Rule.** Green, red and ochre each have one job. Green means select or go, red means you are here or this destroys, ochre means the web did it. A new state borrows one of those meanings or uses ink; it does not get a fourth colour. + +**The Text Contrast Rule.** Green and red are used as text on paper, so they are held at 4.5:1 or better against `log-paper`. This is why the shipped light values are darker than the direction contract's #2f7d62 and #c5402f: those failed as text on #eef0e6. Ochre does not pass as text on paper and is used only as a fill or a rule. + +**The Night Print Rule.** The dark ground is `night-ground` (#162026), not the contract's slate #33444c. The owner saw the slate ground during the build and asked for the darker one back; treat this as a decided value. A `--slate` custom property is still declared in the stylesheet but nothing uses it. + +## Typography + +**Display Font:** Archivo Variable with its width axis (with system-ui, sans-serif) +**Body Font:** Archivo Variable at normal width +**Label/Mono Font:** JetBrains Mono Variable (with ui-monospace, monospace) + +**Character:** One family doing two jobs through its width axis. At normal width it is a plain reading face; squeezed to 62 to 80% it becomes the stamped lettering of a drawing title block. JetBrains Mono, with ligatures off, carries everything a machine produced: SHAs, keys, paths, commands, code, diffs. + +### Hierarchy +- **Display** (700, clamp(2.25rem, 6vw, 4rem), line-height 1, width 66%): repository names on the index and the Machines page heading. The login wordmark is the one larger setting (800, clamp(3.5rem, 10vw, 6rem), width 62%, line-height 0.9). +- **Headline** (700, 2.5rem, line-height 1.05, width 75%): the file name above a note; 1.75rem below 56rem. +- **Title** (600, width 68%, tabular numerals, 0.02em tracking): the stamp. 1.75rem for the repository name in the title block and machine names, 1.375rem for the "Depth N" heading, 1.125rem for counts in fact lines, 0.8125rem for depth numerals on the column. The top bar wordmark is the same idea at 800 weight, 1.25rem. +- **Body** (400, 1rem, line-height 1.5): interface text. Meta and fact lines drop to 0.875rem or 0.9375rem in `ink-2`. +- **Prose** (400, 1.0625rem, line-height 1.65, 70ch measure): rendered notes. Headings inside a note are 700 at width 85%: 1.625rem, 1.3125rem, 1.125rem, line-height 1.2, balanced wrap. +- **Label** (500, 0.75rem, width 80%, uppercase, 0.06em tracking, `ink-2`): field names in the title block (Repository, Depth, Root), space group headings over file lists, form labels, and at 600 weight with 0.04em tracking the change kind (ADDED, MODIFIED, DELETED). +- **Mono** (JetBrains Mono, 0.8125em of its context, ligatures off): SHAs, repository keys, paths, commands, inline code, source and diff views at line-height 1.6, tab size 2. + +### Named Rules +**The Width Is Meaning Rule.** Condensed width marks data the system stamped: names, depths, counts, kinds. Sentences a person reads stay at normal width. Do not condense body copy to save space, and do not set a stamp at normal width. + +**The Label Names A Field Rule.** An uppercase label always names the value or list directly under it, as in a drawing's title block. It is never a decorative line above a headline. + +## Layout + +The signed-in shell is a 3rem top bar with a 1px `rule` base, then the page. + +The reader is a three-track sheet filling the rest of the viewport (`100dvh - 3rem`), each track scrolling on its own: the strip log at 17.5rem, the file list at 20rem, and the note taking the remainder. Below 72rem the first two narrow to 15rem and 17rem. Below 56rem the log collapses to a 4.5rem rail on the left edge (depth numerals and hatch only; formation labels and the Depth and Root cells are dropped) and the files and the reader take turns in the second track, with a "Files" link leading back. The column edge and the title block base are full ink; the edge between files and note is `rule`. + +Index pages (repositories, machines) are a single 72rem column with 3rem top, 1.5rem side and 6rem bottom padding (2rem, 1rem, 4rem below 40rem or 48rem). Their lists are rows separated by a 1px ink top rule with no enclosing box. The login page is a two-track grid: a full-height hatch core clamp(3rem, 12vw, 9rem) wide, then a form capped at 26rem and centred vertically. + +Spacing is a rem rhythm of 0.5, 0.75, 1, 1.5, 2 and 3. Panels pad 1.5rem; the note pads 2rem on top, clamp(1.5rem, 4vw, 4rem) at the sides and 6rem below. Empty and hint text is capped at 34rem, the note at 70ch. + +Inside the column, a bed's height is its size: 20px plus 6px per changed file, capped at six changes. Its grid is 2.75rem depth numerals, 4.5rem hatch, 0.5rem touch mark, then the formation label. Numerals print on every fifth depth, the first, the last and the selected one. A formation label (branch and short SHA) prints only where the main commit changes. The depth grid is 8px squares with a stronger line every 40px. + +Motion: state changes ease over 150ms with `cubic-bezier(0.16, 1, 0.3, 1)`. The only animation is `settle`, a 600ms top-down clip reveal on a bed that has just arrived over SSE. Under reduced motion both collapse to 1ms. + +## Elevation & Depth + +Flat. There is no `box-shadow` anywhere in the build, and no overlays, modals or popovers. Depth in this system is literal: the vertical position of a bed in the column. Layering is tonal and ruled: `log-paper-2` sits under the column and code, ink rules mark structure, `rule` marks rows. Confirmations open in place in the flow of the page rather than floating above it. + +### Named Rules +**The One Sheet Rule.** Everything is printed on the same sheet. If something needs to stand out, give it an ink rule, a fill or a bigger size, not a shadow or a raised surface. + +## Shapes + +Square. No radius is used anywhere and inputs reset it to 0. Borders are 1px hairlines, in ink for structure and `rule` for separation; the only heavier strokes are the 2px field underline, the 2px focus ring, the 2px current-item underline and the 3px depth cursor. Dashed 1px lines mean absence: the outline of a bed whose machine is gone, and the "N unchanged lines" gap in a diff. + +The recurring figure is the lithology hatch. Seven authored SVG tiles (dots 6x6, diagonals 8x8, dashes 12x10, bricks 12x8, crosses 8x8, waves 12x6, chevrons 8x6, all 1px strokes) are applied as repeating CSS masks on a pseudo-element filled with a `--l` colour that defaults to ink. Because the tile is a mask and the colour is a token, hatches follow the theme and can be recoloured per state (green on hover, `ink-2` at 60% for beds that did not touch the open file). A machine takes the tile at its index modulo seven. Web revisions are solid ochre. Revoked or unknown machines get no fill and a dashed outline. Beds above the selected depth lose their hatch, so the column reads as drilled down to the cursor. + +The contract listed six hatches drawn as SVG patterns; the build ships seven, as masks. + +## Components + +### Buttons +Actions are text with a rule under them, not boxes. +- **Shape:** square (0 radius), no border box. +- **Text action:** weight 600 in survey green, 0.125rem vertical padding, a 1px underline in its own colour. Hover fills `green-wash` behind it. The danger variant is curve red with `red-wash`. Links use the same class to act as actions. +- **Solid action:** the one filled control, for the single primary act of a page (sign in, create or copy a pairing command, delete with all history). `log-paper` text on ink, 0.625rem by 1rem padding, no underline; hover turns the fill survey green. The danger variant is filled curve red. +- **Plain tool:** top bar utilities (theme switch labelled "Night print" or "Day print", sign out) are bare `ink-2` text that goes to ink on hover. +- **Disabled:** 50% opacity, default cursor. **Focus:** the global 2px curve red outline at 2px offset. +- **Destructive flow:** confirmations replace the action in place with a question and two text actions; deleting a repository sits in a closed disclosure at the foot of the file list and needs the repository name typed. + +### Cards / Containers +There are none. Groups are made by an uppercase label over a 1px ink rule and rows beneath it. Code, pre blocks and the pairing command are `log-paper-2` fills with no border, the command with a 1px ink rule on its left edge. + +### Inputs / Fields +- **Style:** transparent ground, full width, 0.5rem vertical padding, a 2px ink rule underneath and nothing else. Placeholder in `ink-2`. Tokens and keys are typed in mono. +- **Focus:** the underline turns curve red; the outline is suppressed because the underline is the indicator. The caret is curve red everywhere. +- **Error:** a weight 500 curve red sentence with `role="alert"` above or below the control. + +### Navigation +A 3rem bar: the wordmark "lodge" (800, width 68%, 1.25rem, no underline), then section links at body size with a transparent underline. The current section gets a 2px curve red underline. Below 40rem the Repositories link is dropped because the wordmark already goes there. The view switch over a note (Read, Changes, Source) uses the same treatment plus weight 700 for the current view, sitting on a `rule` line with the restore action pushed to the right. + +Links everywhere are ink with a 1px `rule` underline at 0.22em offset; hover turns the text and the underline survey green. + +### Strip Log +The signature component: a vertical listbox of beds on `log-paper-2` with the depth grid. Above it a title block, a ruled `dl` whose cells are a label over a stamp (Repository across the full width, then Depth as "selected / total" and Root as a mono key). Click or tap selects a depth; pressing with a mouse, or on the already selected bed by touch, starts a scrub that re-renders files and note live and replaces history state until release. Arrow keys and j/k step. The selected bed carries the red cursor and a red numeral, scrolls itself into view, and takes over touch so a thumb drag scrubs instead of scrolling. When a file is open, beds that touched it show a survey green mark in the 0.5rem track and the rest go quiet. + +### File Row +A row in the file list: directory in regular weight at 65% opacity, file name at 600, a change track on the second line, and a relative time at 0.8125rem. The track is a 0.4375rem high baseline with a 2px tick wherever the file changed across the whole history; ticks above the selected depth drop to 30%. Hover is `green-wash`; the open file inverts to `log-paper` on ink. The repository index uses the same idea at a larger size: a 1.75rem high ink-bordered core strip with one hatch cell per recent revision, which turns green on hover along with the name. + +### Note +Three views of one file. Read is sanitised markdown at the prose size and measure, with square survey green list markers, `log-paper-2` code, a 1px ochre rule beside blockquotes and `rule` lines under table rows. Source is mono, preformatted. Changes is a mono line diff with three lines of context: added lines on `green-wash` with a green "+", removed on `red-wash` with a red "−" and a red strikethrough, unchanged lines in `ink-2`, and collapsed runs as a dashed-ruled "N unchanged lines" line in the sans face. + +### Skeleton +Loading is a flat `log-paper-2` block the size of what is coming. It does not shimmer. + +## Do's and Don'ts + +### Do: +- **Do** take every colour from the custom properties so the night print follows for free; add a value to both `:root` and `.dark` or not at all. +- **Do** draw any new pattern as an SVG tile used as a CSS mask over `--l`, so it inherits ink and can be recoloured by state. +- **Do** keep green and red at 4.5:1 or better on `log-paper` when they carry text, and keep ochre to fills and rules. +- **Do** use Archivo's width axis for stamped data (66 to 80%) and normal width for sentences; set numerals tabular. +- **Do** make every new view state a URL parameter, the way depth, file and view already are. +- **Do** keep motion to 150ms state changes on the shared ease, with the clip-reveal `settle` reserved for a revision arriving live. +- **Do** name component classes so they cannot collide with Tailwind utilities. Tailwind is imported globally, so a scoped class called `fixed` picked up `position: fixed` during this build. Avoid bare utility words (`fixed`, `static`, `hidden`, `block`, `flex`, `grid`, `container`, `truncate`, `underline`, `transition`) as class names. + +### Don't: +- **Don't** add cards, boxed or pill buttons, radii or shadows. Separation is an ink rule, a `rule` line or a fill. +- **Don't** build a file table with a commits tab or a metric-card dashboard; history stays a column you drag. +- **Don't** give a state a fourth accent colour, and don't set ochre as text. +- **Don't** lighten the night ground toward slate; #162026 is the owner's decision. +- **Don't** float confirmations in modals or popovers; they open in place. +- **Don't** put an uppercase label above a headline as decoration. A label names the field or the list under it. +- **Don't** embed hatches as coloured images or fixed-colour backgrounds; they would not follow the theme. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..59a9fb0 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +PARTS := $(patsubst %/Makefile,%,$(wildcard */Makefile)) + +.PHONY: env recreate rebuild restart logs down deploy fmt check build pre-deploy post-deploy + +env: + @test -f .env || awk 'FNR == 1 && NR > 1 { print "" } 1' .env.example $(addsuffix /.env.example,$(PARTS)) > .env + @for part in $(PARTS); do test -f $$part/docker-compose.override.yml || cp $$part/docker-compose.override.yml.example $$part/docker-compose.override.yml; done + +recreate: + docker compose up -d --force-recreate + +rebuild: build + docker compose up -d + +restart: + docker compose restart + +logs: + docker compose logs -f --tail=100 + +down: + docker compose down + +deploy: pre-deploy + docker compose up -d + $(MAKE) post-deploy + +fmt check build pre-deploy post-deploy: + @for part in $(PARTS); do $(MAKE) -C $$part $@ || exit 1; done diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..c21c4ae --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,53 @@ +# Product + + + +## Platform + +web + +## Users + +One person: a developer who runs coding agents on several machines (a mac and a linux laptop) and self-hosts lodge for themselves. They open the admin mostly to read agent-written notes and their history, from a desk and from a phone. Anyone may self-host their own instance, but each instance is single-user. + +## Product Purpose + +Agent-written files (plans, notes, architecture docs) stay out of git yet follow the repository to every machine, versioned, with no per-project setup and without touching `.gitignore`. The admin is where the owner reads those files, walks their history, sees which machine changed what at which commit of the main repository, brings an old version back, and connects new machines. + +## Positioning + +Every revision of the notes is pinned to the commit and branch of the main repository it was written against, so "what did the agent know at commit X" is a lookup, not archaeology. Notes are split into shared ones and ones that follow a git branch. + +## Operating Context + +- Repositories are identified by their root commit hash; the display name is the directory name. +- Files live in spaces: `shared` and `branch:`. Rules come from a `.lodge` file at the repository root. +- A revision has a per-repo sequence number, the machine that pushed it, a time, the main repository's branch and SHA, and a list of changes (added, modified, deleted). +- Conflicts resolve by later edit wins; the losing version is kept as its own revision. +- A machine is connected with a one-time command `curl -fsSL /i/ | sh`; the code lives 10 minutes. +- CLI equivalents exist: `lodge log`, `lodge show`, `lodge status`. + +## Capabilities and Constraints + +- Admin scope confirmed by the owner: repository list, file browser per space, revision history with main SHAs, line diff between revisions, restore a file version, delete a repository with its history, list machines and revoke one, add a machine. +- Login: the `ADMIN_TOKEN` is typed once as a password, the server sets an HttpOnly session cookie. +- Static SvelteKit build (adapter-static, SPA fallback) served by the Go server; no SSR. +- Files are mostly markdown and plain text, at most 5 MB each. +- The server URL is never hardcoded; it comes from `PUBLIC_URL` or the request. + +## Brand Commitments + +- Name: lodge, lowercase. +- Must not look like a generic SaaS dashboard (metric cards, icon sidebar). +- Must be comfortable on a phone: opening and reading a note from mobile is a real use. + +## Evidence on Hand + +No logo, no screenshots, no real users besides the owner. Real data exists only on a running instance; demo data for development is synthetic. + +## Product Principles + +- Reading first: the note and its history are the product, management is the margin. +- Every change answers who, when, and against which commit. +- Nothing is lost: deletions and losing edits stay reachable. +- Zero ceremony: one command adds a machine, nothing is configured per project. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bcf11c --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# lodge + +sync for files not meant to be in git + +Runs in Docker, locally the same way as in production. Each part in `backend/`, `frontend/` brings its own `docker-compose.yml`, included from the root one. `COMPOSE_PROFILES` in `.env` selects the services that run on this machine. `docker-compose.override.yml` in each part holds what differs between machines: published ports and the aliases on the external `caddy` network. + +```sh +make env +make recreate +``` + +`make env` creates `.env` from the examples of every part and copies each `docker-compose.override.yml.example`. `make recreate` starts the selected profiles, `make rebuild` builds the images first, `make logs`, `make restart` and `make down` do what they say. + +To deploy, `git pull && make deploy`. It runs `make pre-deploy` in every part, which builds images only when their inputs changed and applies migrations, then `docker compose up -d`, then `make post-deploy`, which restarts the services whose mounted code changed. A deploy system with its own `up` step calls the two hooks around it. What was built and started is remembered as `refs/deploy/*` in the clone. + +`make fmt`, `make check` and `make build` run in every part. Part-specific targets live in the part's own Makefile: `make -C backend ...`. + +## How it works + +`backend/` is one Go module with two binaries. `lodge-server` stores everything in a single SQLite file under `backend/storage/`, serves the API, the built frontend from `frontend/build` and the client binaries it was built together with, so a client always matches its server. `lodge` is the client: a CLI and a daemon in one. + +A repository is identified by the hash of its root commit, so nothing is registered by hand. Files are content-addressed blobs; history is a log of revisions, each carrying the branch and the commit of the main repository at that moment. Every repository has a `shared` space and one space per branch. Which path goes where is set in `.lodge` at the repository root, which is synced like any other file: + +``` +shared docs/agents +shared CLAUDE.local.md +branch .agent +``` + +Synced paths are hidden through `.git/info/exclude`; `.gitignore` is never touched, and files tracked by git are never synced. When two machines edit the same file, the later edit wins and the other version stays in history. + +## Adding a machine + +Set `ADMIN_TOKEN` in `.env`, sign in to the admin with it, open Machines and press "Connect a machine". The same one-time install command, valid for ten minutes, is available without a browser: + +```sh +curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://lodge.example.com/api/codes +``` + +Running the returned `curl ... | sh` on a Mac or a Linux machine downloads the client, logs it in, starts the daemon under launchd or systemd, installs a `post-checkout` hook through `init.templateDir` so that fresh clones receive their files, and drops a skill for coding agents into `~/.claude/skills` and `~/.agents/skills`. Repositories cloned before that are picked up with `lodge add`. Where no daemon can run, set `LODGE_URL` and `LODGE_TOKEN` and call `lodge sync`. + +`make -C backend dev` runs the server without Docker, `make -C backend client` builds the client into `backend/bin`. + +## The admin + +The server serves the admin from `frontend/build`. History is drawn as a borehole log: every revision is a bed, newest on top, hatched by the machine that wrote it and labelled with the branch and commit of the main repository. Dragging along the column, or the arrow keys, shows the notes as they were at that revision, and every depth, file and view is a link. A file can be compared with its previous version and put back on the surface; a repository can be deleted with its history, which leaves the files on the machines alone. Machines are listed with their hatch and can be revoked. + +`make -C frontend dev` runs the admin against a server on `localhost:8080`. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..74964a8 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +bin +storage +.env +.env.* diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..359bb6a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,2 @@ +ADMIN_TOKEN= +PUBLIC_URL= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b0007b7 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +bin +storage diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..6a08464 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,60 @@ +version: "2" + +linters: + default: standard + enable: + - bodyclose + - contextcheck + - copyloopvar + - errorlint + - gocritic + - gosec + - misspell + - perfsprint + - revive + - unconvert + - unparam + - usestdlibvars + settings: + gosec: + excludes: + - G104 + - G204 + - G301 + - G302 + - G304 + - G306 + - G122 + - G124 + - G703 + - G706 + revive: + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true + errcheck: + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - (*database/sql.Rows).Close + - (*database/sql.Tx).Rollback + - (*lodge/internal/server.Store).Close + - (*github.com/fsnotify/fsnotify.Watcher).Close + - os.Unsetenv + - os.Remove + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + +formatters: + enable: + - gofumpt + - goimports + settings: + gofumpt: + module-path: lodge + goimports: + local-prefixes: + - lodge diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4d8e667 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS build + +WORKDIR /src + +ENV CGO_ENABLED=0 + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +RUN go build -trimpath -ldflags "-s -w" -o /out/lodge-server ./cmd/lodge-server +RUN for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64; do \ + GOOS=${target%/*} GOARCH=${target#*/} go build -trimpath -ldflags "-s -w" -o /out/dl/lodge-${target%/*}-${target#*/} ./cmd/lodge || exit 1; \ + done + +FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 + +WORKDIR /app + +COPY --from=build /out/lodge-server /usr/local/bin/lodge-server +COPY --from=build /out/dl ./dl + +CMD ["lodge-server"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..73eb346 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,35 @@ +COMPOSE := cd .. && docker compose +IMAGE := lodge/backend +LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 +changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null +stamp = { git update-ref $(1) HEAD 2>/dev/null || true; } + +.PHONY: fmt check test build pre-deploy post-deploy client dev + +fmt: + go mod tidy + $(LINT) fmt + +check: + $(LINT) run + $(MAKE) test + +test: + go test -race ./... + +build: + $(COMPOSE) build api + +pre-deploy: + @if $(call changed,refs/deploy/backend,.) || ! docker image inspect $(IMAGE) >/dev/null 2>&1; then \ + $(MAKE) build && $(call stamp,refs/deploy/backend); \ + fi + +post-deploy: + @: + +client: + go build -trimpath -ldflags "-s -w" -o bin/lodge ./cmd/lodge + +dev: + LODGE_DB=storage/lodge.db STATIC_DIR=../frontend/build CLIENTS_DIR=bin ADMIN_TOKEN=dev go run ./cmd/lodge-server diff --git a/backend/cmd/lodge-server/main.go b/backend/cmd/lodge-server/main.go new file mode 100644 index 0000000..d8be178 --- /dev/null +++ b/backend/cmd/lodge-server/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "cmp" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "lodge/internal/server" +) + +func main() { + database := cmp.Or(os.Getenv("LODGE_DB"), "/app/storage/lodge.db") + if err := os.MkdirAll(filepath.Dir(database), 0o700); err != nil { + log.Fatal(err) + } + store, err := server.OpenStore(database) + if err != nil { + log.Fatal(err) + } + + config := server.Config{ + AdminToken: os.Getenv("ADMIN_TOKEN"), + PublicURL: os.Getenv("PUBLIC_URL"), + StaticDir: cmp.Or(os.Getenv("STATIC_DIR"), "/app/static"), + ClientsDir: cmp.Or(os.Getenv("CLIENTS_DIR"), "/app/dl"), + } + if config.AdminToken == "" { + log.Print("ADMIN_TOKEN is empty: no machine can be added until it is set") + } + httpServer := &http.Server{ + Addr: cmp.Or(os.Getenv("LISTEN"), ":8080"), + Handler: server.New(store, config), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("listening on %s", httpServer.Addr) + log.Fatal(httpServer.ListenAndServe()) +} diff --git a/backend/cmd/lodge/main.go b/backend/cmd/lodge/main.go new file mode 100644 index 0000000..0a8292b --- /dev/null +++ b/backend/cmd/lodge/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "lodge/internal/client" + "lodge/internal/gitrepo" + "lodge/internal/protocol" +) + +const usage = `lodge keeps agent files out of git and in sync across machines + + lodge login connect this machine using a code from the admin page + lodge install start the daemon, install the git hook and the agent skill + lodge uninstall undo install + lodge update replace this binary with the one the server ships + lodge add [dir] start syncing a repository that was cloned before install + lodge forget [dir] stop syncing a repository + lodge sync [dir] sync once, for machines without a daemon + lodge status [dir] rules, files and oversized files of a repository + lodge log [path] revisions of the repository or of one file + lodge show a file as of a revision or a commit of the main repository + lodge daemon run the sync daemon in the foreground + +status, log and sync accept --json +` + +var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"} + +type status struct { + Root string `json:"root"` + Key string `json:"key"` + Branch string `json:"branch"` + Rules []client.Rule `json:"rules"` + Revision int64 `json:"revision"` + Spaces map[string]protocol.Manifest `json:"spaces"` +} + +func main() { + if len(os.Args) < 2 { + fmt.Print(usage) + return + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + if os.Args[1] == "hook" { + hook(ctx) + return + } + err := run(ctx, os.Args[1], os.Args[2:]) + stop() + if err != nil { + fmt.Fprintln(os.Stderr, "lodge:", err) + os.Exit(1) + } +} + +func hook(ctx context.Context) { + for _, name := range hookEnvironment { + os.Unsetenv(name) + } + if c, err := client.Load(); err == nil { + _ = c.Hook(ctx, ".") + } +} + +func run(ctx context.Context, name string, args []string) error { + flags := flag.NewFlagSet(name, flag.ExitOnError) + asJSON := flags.Bool("json", false, "machine-readable output") + _ = flags.Parse(args) + args = flags.Args() + target := func(fallback string) string { + if len(args) > 0 { + return args[0] + } + return fallback + } + + switch name { + case "login": + if len(args) != 2 { + return errors.New("usage: lodge login ") + } + return client.Login(ctx, args[0], args[1]) + case "help", "-h", "--help": + fmt.Print(usage) + return nil + } + + c, err := client.Load() + if err != nil { + return err + } + switch name { + case "install": + return c.Install() + case "uninstall": + return c.Uninstall() + case "daemon": + return c.Daemon(ctx) + case "update": + updated, err := c.Update(ctx) + if err == nil && !updated { + fmt.Println("already up to date") + } + return err + case "add", "sync": + res, err := c.Sync(ctx, target("."), true) + if err != nil { + return err + } + return output(*asJSON, res, func() { + fmt.Printf("pushed %d, pulled %d\n", res.Pushed, res.Pulled) + for _, path := range res.Skipped { + fmt.Printf("skipped, over 5 MB: %s\n", path) + } + }) + case "forget": + root, err := filepath.Abs(target(".")) + if err != nil { + return err + } + if repo, err := gitrepo.Open(root); err == nil { + root = repo.Root + } + return c.Forget(root) + case "status": + return showStatus(ctx, c, target("."), *asJSON) + case "log": + return showLog(ctx, c, target(""), *asJSON) + case "show": + if len(args) != 2 { + return errors.New("usage: lodge show ") + } + return showFile(ctx, c, args[0], args[1]) + } + return fmt.Errorf("unknown command %q, see lodge help", name) +} + +func output(asJSON bool, value any, human func()) error { + if !asJSON { + human() + return nil + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + +func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool) error { + repo, err := gitrepo.Open(dir) + if err != nil { + return err + } + state, err := c.State(ctx, repo.Key, "") + if err != nil { + return err + } + report := status{Root: repo.Root, Key: repo.Key, Branch: repo.Branch(), Rules: client.LoadRules(repo.Root), Revision: state.Seq, Spaces: state.Spaces} + return output(asJSON, report, func() { + fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision) + for _, rule := range report.Rules { + kind := "branch" + if rule.Shared { + kind = "shared" + } + fmt.Printf(" %s %s\n", kind, rule.Path) + } + for space, manifest := range report.Spaces { + fmt.Printf("\n%s:\n", space) + for path := range manifest { + fmt.Printf(" %s\n", path) + } + } + }) +} + +func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) error { + repo, err := gitrepo.Open(".") + if err != nil { + return err + } + revisions, err := c.Revisions(ctx, repo.Key, path) + if err != nil { + return err + } + return output(asJSON, revisions, func() { + for _, rev := range revisions { + when := time.UnixMilli(rev.Time).Format("2006-01-02 15:04") + fmt.Printf("%d %s %s %s@%.10s\n", rev.Seq, when, rev.Machine, rev.MainBranch, rev.MainSHA) + for _, change := range rev.Changes { + action := "M" + if change.Base == "" { + action = "A" + } else if change.Hash == "" { + action = "D" + } + fmt.Printf(" %s %s (%s)\n", action, change.Path, change.Space) + } + } + }) +} + +func showFile(ctx context.Context, c *client.Client, at, path string) error { + repo, err := gitrepo.Open(".") + if err != nil { + return err + } + state, err := c.State(ctx, repo.Key, at) + if err != nil { + return err + } + for _, space := range []string{protocol.SharedSpace, protocol.BranchSpace(repo.Branch())} { + if file, found := state.Spaces[space][filepath.ToSlash(path)]; found { + data, err := c.Blob(ctx, file.Hash) + if err == nil { + _, err = os.Stdout.Write(data) + } + return err + } + } + return fmt.Errorf("%s did not exist at %s", path, at) +} diff --git a/backend/docker-compose.override.yml.example b/backend/docker-compose.override.yml.example new file mode 100644 index 0000000..3b4f102 --- /dev/null +++ b/backend/docker-compose.override.yml.example @@ -0,0 +1,11 @@ +services: + api: + networks: + default: {} + caddy: + aliases: + - lodge-api + +networks: + caddy: + external: true diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..c8dd5d6 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,14 @@ +services: + api: + build: backend + image: lodge/backend + profiles: [api, services] + restart: unless-stopped + env_file: + - path: .env + required: false + - path: backend/.env + required: false + volumes: + - ./frontend/build:/app/static:ro + - ${STORAGE_ROOT:-./backend/storage}:/app/storage diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4bcea92 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,20 @@ +module lodge + +go 1.27.1 + +require ( + github.com/fsnotify/fsnotify v1.10.1 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..bbfcee1 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,52 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/client/client.go b/backend/internal/client/client.go new file mode 100644 index 0000000..d3c3509 --- /dev/null +++ b/backend/internal/client/client.go @@ -0,0 +1,224 @@ +package client + +import ( + "bytes" + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "lodge/internal/protocol" +) + +const requestTimeout = 15 * time.Second + +var ( + ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page") + errConflict = errors.New("server state changed") +) + +type Client struct { + URL string `json:"url"` + Token string `json:"token"` + DataDir string `json:"-"` +} + +func configPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(cmp.Or(os.Getenv("XDG_CONFIG_HOME"), filepath.Join(home, ".config")), "lodge", "config.json") +} + +func dataDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(cmp.Or(os.Getenv("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "lodge") +} + +func Load() (*Client, error) { + c := &Client{DataDir: dataDir()} + if data, err := os.ReadFile(configPath()); err == nil { + if err := json.Unmarshal(data, c); err != nil { + return nil, err + } + } + c.URL = strings.TrimSuffix(cmp.Or(os.Getenv("LODGE_URL"), c.URL), "/") + c.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), c.Token) + if c.URL == "" || c.Token == "" { + return nil, ErrNotLoggedIn + } + return c, nil +} + +func Login(ctx context.Context, serverURL, code string) error { + name, _ := os.Hostname() + c := &Client{URL: strings.TrimSuffix(serverURL, "/")} + var claim protocol.ClaimResponse + if err := c.call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &claim); err != nil { + return err + } + c.Token = claim.Token + return writeJSONFile(configPath(), c) +} + +func writeJSONFile(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return writeFile(path, data, 0o600) +} + +func writeFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temp := path + ".lodge-tmp" + if err := os.WriteFile(temp, data, mode); err != nil { + return err + } + return os.Rename(temp, path) +} + +func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode < http.StatusBadRequest { + return res, nil + } + defer res.Body.Close() + message, _ := io.ReadAll(io.LimitReader(res.Body, 1024)) + if res.StatusCode == http.StatusConflict { + return nil, errConflict + } + return nil, fmt.Errorf("%s %s: %s: %s", method, path, res.Status, strings.TrimSpace(string(message))) +} + +func (c *Client) call(ctx context.Context, method, path string, in, out any) error { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + var body []byte + if in != nil { + body, _ = json.Marshal(in) + } + res, err := c.request(ctx, method, path, body) + if err != nil { + return err + } + defer res.Body.Close() + if out == nil { + return nil + } + return json.NewDecoder(res.Body).Decode(out) +} + +func (c *Client) State(ctx context.Context, key, at string) (protocol.State, error) { + var state protocol.State + err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/state?at="+url.QueryEscape(at), nil, &state) + return state, err +} + +func (c *Client) Revisions(ctx context.Context, key, path string) ([]protocol.Revision, error) { + var revisions []protocol.Revision + err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/revisions?path="+url.QueryEscape(path), nil, &revisions) + return revisions, err +} + +func hashOf(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func (c *Client) blobPath(hash string) string { + return filepath.Join(c.DataDir, "blobs", hash[:2], hash) +} + +func (c *Client) cacheBlob(data []byte) (string, error) { + hash := hashOf(data) + path := c.blobPath(hash) + if _, err := os.Stat(path); err == nil { + return hash, nil + } + return hash, writeFile(path, data, 0o600) +} + +func (c *Client) Blob(ctx context.Context, hash string) ([]byte, error) { + if data, err := os.ReadFile(c.blobPath(hash)); err == nil { + return data, nil + } + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + res, err := c.request(ctx, http.MethodGet, "/api/blobs/"+hash, nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + if hashOf(data) != hash { + return nil, fmt.Errorf("blob %s arrived corrupted", hash) + } + _, err = c.cacheBlob(data) + return data, err +} + +func (c *Client) uploadBlob(ctx context.Context, hash string) error { + data, err := os.ReadFile(c.blobPath(hash)) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + res, err := c.request(ctx, http.MethodPut, "/api/blobs/"+hash, data) + if err != nil { + return err + } + return res.Body.Close() +} + +func (c *Client) registryPath() string { + return filepath.Join(c.DataDir, "worktrees.json") +} + +func (c *Client) Worktrees() []string { + var roots []string + if data, err := os.ReadFile(c.registryPath()); err == nil { + _ = json.Unmarshal(data, &roots) + } + return roots +} + +func (c *Client) register(root string) error { + roots := c.Worktrees() + if slices.Contains(roots, root) { + return nil + } + roots = slices.DeleteFunc(roots, func(known string) bool { + _, err := os.Stat(known) + return err != nil + }) + return writeJSONFile(c.registryPath(), append(roots, root)) +} + +func (c *Client) Forget(root string) error { + roots := slices.DeleteFunc(c.Worktrees(), func(known string) bool { return known == root }) + return writeJSONFile(c.registryPath(), roots) +} diff --git a/backend/internal/client/daemon.go b/backend/internal/client/daemon.go new file mode 100644 index 0000000..df5d3e3 --- /dev/null +++ b/backend/internal/client/daemon.go @@ -0,0 +1,222 @@ +package client + +import ( + "bufio" + "context" + "encoding/json" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "lodge/internal/gitrepo" + "lodge/internal/protocol" + + "github.com/fsnotify/fsnotify" +) + +const ( + debounce = 2 * time.Second + fullSyncEvery = 5 * time.Minute + minBackoff = 5 * time.Second + maxBackoff = time.Minute + headFile = "HEAD" +) + +type daemon struct { + client *Client + watcher *fsnotify.Watcher + queue chan string + + mu sync.Mutex + owners map[string]string + gitDirs map[string]bool + keys map[string]string + pending map[string]*time.Timer +} + +func (c *Client) Daemon(ctx context.Context) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + if err := os.MkdirAll(c.DataDir, 0o755); err != nil { + return err + } + if err := watcher.Add(c.DataDir); err != nil { + return err + } + d := &daemon{ + client: c, + watcher: watcher, + queue: make(chan string, 256), + owners: map[string]string{}, + gitDirs: map[string]bool{}, + keys: map[string]string{}, + pending: map[string]*time.Timer{}, + } + go d.listen(ctx) + go d.work(ctx) + d.syncAll() + + ticker := time.NewTicker(fullSyncEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + d.syncAll() + case err := <-watcher.Errors: + log.Printf("watcher: %v", err) + case event := <-watcher.Events: + d.handle(event) + } + } +} + +func (d *daemon) handle(event fsnotify.Event) { + if event.Name == d.client.registryPath() { + d.syncAll() + return + } + dir := filepath.Dir(event.Name) + d.mu.Lock() + root, watched := d.owners[dir] + irrelevant := d.gitDirs[dir] && filepath.Base(event.Name) != headFile + d.mu.Unlock() + if watched && !irrelevant && !strings.HasSuffix(event.Name, tempSuffix) { + d.schedule(root) + } +} + +func (d *daemon) schedule(root string) { + d.mu.Lock() + defer d.mu.Unlock() + if timer, waiting := d.pending[root]; waiting { + timer.Reset(debounce) + return + } + d.pending[root] = time.AfterFunc(debounce, func() { + d.mu.Lock() + delete(d.pending, root) + d.mu.Unlock() + d.queue <- root + }) +} + +func (d *daemon) syncAll() { + for _, root := range d.client.Worktrees() { + d.schedule(root) + } +} + +func (d *daemon) work(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case root := <-d.queue: + res, err := d.client.Sync(ctx, root, true) + if err != nil { + log.Printf("%s: %v", root, err) + } else if res.Pushed+res.Pulled > 0 { + log.Printf("%s: pushed %d, pulled %d", root, res.Pushed, res.Pulled) + } + d.watch(root) + } + } +} + +func (d *daemon) watch(root string) { + repo, err := gitrepo.Open(root) + if err != nil { + return + } + dirs := []string{repo.GitDir} + for _, rule := range LoadRules(repo.Root) { + target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) + info, err := os.Stat(target) + for err != nil && target != repo.Root { + target = filepath.Dir(target) + info, err = os.Stat(target) + } + if err != nil { + continue + } + if !info.IsDir() { + dirs = append(dirs, filepath.Dir(target)) + continue + } + _ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error { + if err != nil || !entry.IsDir() { + return nil + } + if entry.Name() == ".git" { + return filepath.SkipDir + } + dirs = append(dirs, path) + return nil + }) + } + d.mu.Lock() + defer d.mu.Unlock() + d.keys[repo.Root] = repo.Key + d.gitDirs[repo.GitDir] = true + for _, dir := range dirs { + if _, watching := d.owners[dir]; !watching && d.watcher.Add(dir) == nil { + d.owners[dir] = repo.Root + } + } +} + +func (d *daemon) listen(ctx context.Context) { + backoff := minBackoff + for ctx.Err() == nil { + started := time.Now() + err := d.stream(ctx) + if time.Since(started) > maxBackoff { + backoff = minBackoff + } + log.Printf("event stream closed, retrying in %s: %v", backoff, err) + select { + case <-ctx.Done(): + case <-time.After(backoff): + } + backoff = min(backoff*2, maxBackoff) + } +} + +func (d *daemon) stream(ctx context.Context) error { + res, err := d.client.request(ctx, http.MethodGet, "/api/events", nil) + if err != nil { + return err + } + defer res.Body.Close() + d.syncAll() + scanner := bufio.NewScanner(res.Body) + for scanner.Scan() { + data, isEvent := strings.CutPrefix(scanner.Text(), "data: ") + var event protocol.Event + if !isEvent || json.Unmarshal([]byte(data), &event) != nil { + continue + } + d.mu.Lock() + var roots []string + for root, key := range d.keys { + if key == event.Repo { + roots = append(roots, root) + } + } + d.mu.Unlock() + for _, root := range roots { + d.schedule(root) + } + } + return scanner.Err() +} diff --git a/backend/internal/client/install.go b/backend/internal/client/install.go new file mode 100644 index 0000000..b455d50 --- /dev/null +++ b/backend/internal/client/install.go @@ -0,0 +1,195 @@ +package client + +import ( + "context" + _ "embed" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +const ( + serviceLabel = "com.kotikot.lodge" + systemdUnit = "lodge.service" + hookName = "post-checkout" +) + +//go:embed skill.md +var skill []byte + +var skillDirs = []string{".claude/skills/lodge", ".agents/skills/lodge"} + +func launchdPlist(binary, logFile string) string { + return fmt.Sprintf(` + + + + Label%s + ProgramArguments%sdaemon + RunAtLoad + KeepAlive + ProcessTypeBackground + StandardErrorPath%s + + +`, serviceLabel, binary, logFile) +} + +func systemdService(binary string) string { + return fmt.Sprintf(`[Unit] +Description=lodge sync daemon + +[Service] +ExecStart=%s daemon +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +`, binary) +} + +func command(name string, args ...string) error { + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func launchdTarget() string { + return "gui/" + strconv.Itoa(os.Getuid()) +} + +func plistPath(home string) string { + return filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist") +} + +func unitPath(home string) string { + return filepath.Join(home, ".config", "systemd", "user", systemdUnit) +} + +func (c *Client) templateDir() string { + return filepath.Join(c.DataDir, "git-template") +} + +func (c *Client) Install() error { + binary, err := os.Executable() + if err != nil { + return err + } + home, err := os.UserHomeDir() + if err != nil { + return err + } + if err := c.installHook(binary); err != nil { + return err + } + for _, dir := range skillDirs { + if err := writeFile(filepath.Join(home, dir, "SKILL.md"), skill, 0o644); err != nil { + return err + } + } + return startService(home, binary, filepath.Join(c.DataDir, "daemon.log")) +} + +func (c *Client) installHook(binary string) error { + template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() + dir := strings.TrimSpace(string(template)) + if dir == "" { + dir = c.templateDir() + if err := command("git", "config", "--global", "init.templateDir", dir); err != nil { + return err + } + } + if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(dir, "~/") { + dir = filepath.Join(home, dir[2:]) + } + hook := filepath.Join(dir, "hooks", hookName) + if existing, err := os.ReadFile(hook); err == nil && !strings.Contains(string(existing), "lodge hook") { + fmt.Fprintf(os.Stderr, "lodge: %s already exists, add this line to it yourself: %q hook\n", hook, binary) + return nil + } + script := fmt.Sprintf("#!/bin/sh\n[ -x %q ] && %q hook\nexit 0\n", binary, binary) + return writeFile(hook, []byte(script), 0o755) +} + +func startService(home, binary, logFile string) error { + if runtime.GOOS == "darwin" { + plist := plistPath(home) + if err := writeFile(plist, []byte(launchdPlist(binary, logFile)), 0o644); err != nil { + return err + } + _ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel) + return command("launchctl", "bootstrap", launchdTarget(), plist) + } + if err := writeFile(unitPath(home), []byte(systemdService(binary)), 0o644); err != nil { + return err + } + if err := command("systemctl", "--user", "daemon-reload"); err != nil { + return err + } + return command("systemctl", "--user", "enable", "--now", systemdUnit) +} + +func restartService() error { + if runtime.GOOS == "darwin" { + return command("launchctl", "kickstart", "-k", launchdTarget()+"/"+serviceLabel) + } + return command("systemctl", "--user", "restart", systemdUnit) +} + +func (c *Client) Uninstall() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + if runtime.GOOS == "darwin" { + _ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel) + _ = os.Remove(plistPath(home)) + } else { + _ = command("systemctl", "--user", "disable", "--now", systemdUnit) + _ = os.Remove(unitPath(home)) + } + template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output() + if strings.TrimSpace(string(template)) == c.templateDir() { + _ = command("git", "config", "--global", "--unset", "init.templateDir") + } + for _, dir := range skillDirs { + _ = os.RemoveAll(filepath.Join(home, dir)) + } + return os.RemoveAll(c.templateDir()) +} + +func (c *Client) Update(ctx context.Context) (bool, error) { + binary, err := os.Executable() + if err != nil { + return false, err + } + current, err := os.ReadFile(binary) + if err != nil { + return false, err + } + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/dl/lodge-%s-%s", runtime.GOOS, runtime.GOARCH), nil) + if err != nil { + return false, err + } + defer res.Body.Close() + latest, err := io.ReadAll(res.Body) + if err != nil { + return false, err + } + if hashOf(latest) == hashOf(current) { + return false, nil + } + if err := writeFile(binary, latest, 0o755); err != nil { + return false, err + } + return true, restartService() +} diff --git a/backend/internal/client/rules.go b/backend/internal/client/rules.go new file mode 100644 index 0000000..24685d1 --- /dev/null +++ b/backend/internal/client/rules.go @@ -0,0 +1,89 @@ +package client + +import ( + "bytes" + "os" + "path" + "path/filepath" + "strings" + + "lodge/internal/gitrepo" + "lodge/internal/protocol" +) + +const ( + RulesFile = ".lodge" + sharedKeyword = "shared" + branchKeyword = "branch" + excludeBegin = "# lodge begin" + excludeEnd = "# lodge end" + DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n" +) + +type Rule struct { + Path string `json:"path"` + Shared bool `json:"shared"` +} + +type Rules []Rule + +func parseRules(text string) Rules { + rules := Rules{{Path: RulesFile, Shared: true}} + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != sharedKeyword && fields[0] != branchKeyword { + continue + } + rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/")) + if protocol.SafePath(rulePath) { + rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword}) + } + } + return rules +} + +func LoadRules(root string) Rules { + data, err := os.ReadFile(filepath.Join(root, RulesFile)) + if err != nil { + return parseRules(DefaultRules) + } + return parseRules(string(data)) +} + +func (rules Rules) paths() []string { + paths := make([]string, len(rules)) + for i, rule := range rules { + paths[i] = rule.Path + } + return paths +} + +func (rules Rules) covers(file string, shared bool) bool { + best := Rule{} + for _, rule := range rules { + matches := file == rule.Path || strings.HasPrefix(file, rule.Path+"/") + if matches && len(rule.Path) > len(best.Path) { + best = rule + } + } + return best.Path != "" && best.Shared == shared +} + +func writeExclude(repo *gitrepo.Repo, rules Rules) error { + file := filepath.Join(repo.CommonDir, "info", "exclude") + current, _ := os.ReadFile(file) + kept := current + if before, rest, found := bytes.Cut(current, []byte(excludeBegin)); found { + _, after, _ := bytes.Cut(rest, []byte(excludeEnd+"\n")) + kept = append(bytes.Clone(before), after...) + } + if len(kept) > 0 && !bytes.HasSuffix(kept, []byte("\n")) { + kept = append(kept, '\n') + } + block := excludeBegin + "\n/" + strings.Join(rules.paths(), "\n/") + "\n" + excludeEnd + "\n" + kept = append(kept, block...) + if bytes.Equal(kept, current) { + return nil + } + return writeFile(file, kept, 0o644) +} diff --git a/backend/internal/client/skill.md b/backend/internal/client/skill.md new file mode 100644 index 0000000..52bdde4 --- /dev/null +++ b/backend/internal/client/skill.md @@ -0,0 +1,32 @@ +--- +name: lodge +description: Where to keep agent notes, plans and working docs so they stay out of git but follow the repository across machines and branches. Use when writing plans, notes, research or handoff docs into a repository, when such files seem to be missing from git status, or when you need what was written earlier at a given commit. +--- + +# lodge + +This machine runs lodge. It syncs agent files that are deliberately kept out of git: they are hidden through `.git/info/exclude`, stored on a separate server, versioned there, and restored automatically after `git clone` on any machine. + +## Where to write + +Run `lodge status` inside the repository to see the rules in effect. The defaults are: + +- `docs/agents/` holds what is true for the whole repository: architecture notes, conventions, research on the codebase. It stays in place when branches change. +- `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch. +- `CLAUDE.local.md` is shared across branches. + +The rules live in `.lodge` at the repository root, one per line, as `shared ` or `branch `. Paths are relative to the root, no globs. The file itself is synced too. + +## Rules of conduct + +- These files are absent from `git status` on purpose. Never `git add -f` them and never add them to `.gitignore`. +- Files tracked by git are never touched by lodge, even under a synced path. +- Files over 5 MB are not synced; `lodge status` lists them. +- A deleted file is deleted on every machine. Its history stays on the server. +- Nothing needs to be run after writing a file: the daemon picks it up within seconds. Without a daemon (CI, cloud sandbox) run `lodge sync` at the start and at the end of the work. + +## Reading history + +- `lodge log [path]` lists revisions with the machine, the branch and the commit of the main repository at that moment. +- `lodge show ` prints a file as it was at that revision, or as it was while that commit was checked out. +- `--json` on `status`, `log` and `sync` gives machine-readable output. diff --git a/backend/internal/client/sync.go b/backend/internal/client/sync.go new file mode 100644 index 0000000..8896523 --- /dev/null +++ b/backend/internal/client/sync.go @@ -0,0 +1,371 @@ +package client + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + + "lodge/internal/gitrepo" + "lodge/internal/protocol" +) + +const ( + maxPasses = 4 + tempSuffix = ".lodge-tmp" +) + +type worktreeState struct { + Branch string `json:"branch"` + Base map[string]protocol.Manifest `json:"base"` + Stash map[string]protocol.Manifest `json:"stash"` +} + +type Result struct { + Pushed int `json:"pushed"` + Pulled int `json:"pulled"` + Skipped []string `json:"skipped"` +} + +type diskFiles struct { + shared protocol.Manifest + branch protocol.Manifest + skipped []string + tracked map[string]bool +} + +func (c *Client) statePath(repo *gitrepo.Repo) string { + return filepath.Join(c.DataDir, "repos", repo.Key, hashOf([]byte(repo.Root))[:16]+".json") +} + +func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState { + st := &worktreeState{} + if data, err := os.ReadFile(c.statePath(repo)); err == nil { + _ = json.Unmarshal(data, st) + } + if st.Base == nil { + st.Base = map[string]protocol.Manifest{} + } + if st.Stash == nil { + st.Stash = map[string]protocol.Manifest{} + } + return st +} + +func (c *Client) lock(repo *gitrepo.Repo) (func(), error) { + path := c.statePath(repo) + ".lock" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + file.Close() + return nil, err + } + return func() { file.Close() }, nil +} + +func (c *Client) Hook(ctx context.Context, dir string) error { + repo, err := gitrepo.Open(dir) + if err != nil { + return err + } + firstSight := !slices.Contains(c.Worktrees(), repo.Root) + _, err = c.Sync(ctx, repo.Root, firstSight) + return err +} + +func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, error) { + var res Result + repo, err := gitrepo.Open(dir) + if err != nil { + return res, err + } + if err := c.register(repo.Root); err != nil { + return res, err + } + unlock, err := c.lock(repo) + if err != nil { + return res, err + } + defer unlock() + + st := c.loadState(repo) + for range maxPasses { + again, err := c.pass(ctx, repo, st, online, &res) + saveErr := writeJSONFile(c.statePath(repo), st) + if err != nil || saveErr != nil || !again { + return res, errors.Join(err, saveErr) + } + } + return res, errors.New("gave up: server state keeps changing") +} + +func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles { + found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())} + for _, rule := range rules { + start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path)) + _ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() && entry.Name() == ".git" { + return filepath.SkipDir + } + if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) { + return nil + } + rel, _ := filepath.Rel(repo.Root, full) + rel = filepath.ToSlash(rel) + if found.tracked[rel] || !rules.covers(rel, rule.Shared) { + return nil + } + info, err := entry.Info() + if err != nil { + return nil + } + if info.Size() > protocol.MaxBlobSize { + found.skipped = append(found.skipped, rel) + return nil + } + data, err := os.ReadFile(full) + if err != nil { + return nil + } + hash, err := c.cacheBlob(data) + if err != nil { + return nil + } + manifest := found.branch + if rule.Shared { + manifest = found.shared + } + manifest[rel] = protocol.File{Hash: hash, Time: info.ModTime().UnixMilli()} + return nil + }) + } + return found +} + +func reconcile(space string, local, base, remote protocol.Manifest, relevant func(string) bool) (first, second []protocol.Change, pull, next protocol.Manifest) { + pull, next = protocol.Manifest{}, maps.Clone(base) + if next == nil { + next = protocol.Manifest{} + } + paths := map[string]bool{} + for _, manifest := range []protocol.Manifest{local, base, remote} { + for path := range manifest { + paths[path] = true + } + } + for path := range paths { + if !relevant(path) { + continue + } + l, b, r := local[path], base[path], remote[path] + mine := protocol.Change{Space: space, Path: path, Base: r.Hash, Hash: l.Hash} + settled := r + switch { + case l.Hash == r.Hash: + case l.Hash == b.Hash, l.Hash == "" && r.Hash != b.Hash: + pull[path] = r + case r.Hash == b.Hash, r.Hash == "", l.Time >= r.Time: + first = append(first, mine) + settled = l + default: + first = append(first, mine) + second = append(second, protocol.Change{Space: space, Path: path, Base: l.Hash, Hash: r.Hash}) + pull[path] = r + } + if settled.Hash == "" { + delete(next, path) + } else { + next[path] = settled + } + } + return first, second, pull, next +} + +func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, online bool, res *Result) (bool, error) { + rules := LoadRules(repo.Root) + if err := writeExclude(repo, rules); err != nil { + return false, err + } + var remote *protocol.State + var offlineErr error + if online { + state, err := c.State(ctx, repo.Key, "") + remote, offlineErr = &state, err + if err != nil { + remote = nil + } + } + + found := c.scan(repo, rules) + branch := cmp.Or(repo.Branch(), st.Branch) + if st.Branch != "" && branch != st.Branch { + if err := c.switchBranch(ctx, repo, st, rules, branch, found, remote); err != nil { + return false, err + } + found = c.scan(repo, rules) + } + st.Branch = branch + res.Skipped = found.skipped + if remote == nil { + return false, offlineErr + } + if remote.Seq == 0 { + st.Base = map[string]protocol.Manifest{} + } + + locals := map[string]protocol.Manifest{protocol.SharedSpace: found.shared} + if branch != "" { + locals[protocol.BranchSpace(branch)] = found.branch + } + active := maps.Clone(locals) + for space, stashed := range st.Stash { + if active[space] == nil { + locals[space] = stashed + } + } + + var first, second []protocol.Change + pulls, bases := map[string]protocol.Manifest{}, map[string]protocol.Manifest{} + spaces := map[string]bool{} + for _, group := range []map[string]protocol.Manifest{locals, st.Base, remote.Spaces} { + for space := range group { + spaces[space] = true + } + } + for space := range spaces { + local, syncing := locals[space] + if !syncing { + st.Base[space] = remote.Spaces[space] + if st.Base[space] == nil { + delete(st.Base, space) + } + continue + } + relevant := protocol.SafePath + if active[space] != nil { + relevant = func(path string) bool { + return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path] + } + } + mine, theirs, pull, next := reconcile(space, local, st.Base[space], remote.Spaces[space], relevant) + first, second, pulls[space], bases[space] = append(first, mine...), append(second, theirs...), pull, next + } + + for _, change := range first { + if change.Hash == "" { + continue + } + if err := c.uploadBlob(ctx, change.Hash); err != nil { + return false, err + } + } + for _, changes := range [][]protocol.Change{first, second} { + if len(changes) == 0 { + continue + } + push := protocol.Push{Name: repo.Name, MainSHA: repo.Head(), MainBranch: branch, Changes: changes} + err := c.call(ctx, http.MethodPost, "/api/repos/"+repo.Key+"/revisions", push, nil) + if errors.Is(err, errConflict) { + return true, nil + } + if err != nil { + return false, err + } + } + res.Pushed += len(first) + + again := false + for space, pull := range pulls { + for path, file := range pull { + if active[space] != nil { + if err := c.materialize(ctx, repo.Root, path, file); err != nil { + return false, err + } + } else if file.Hash == "" { + delete(st.Stash[space], path) + } else if _, err := c.Blob(ctx, file.Hash); err != nil { + return false, err + } else { + st.Stash[space][path] = file + } + res.Pulled++ + again = again || path == RulesFile + } + } + + maps.Copy(st.Base, bases) + return again, nil +} + +func (c *Client) switchBranch(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, rules Rules, branch string, found diskFiles, remote *protocol.State) error { + space := protocol.BranchSpace(branch) + st.Stash[protocol.BranchSpace(st.Branch)] = found.branch + known, exists := st.Stash[space] + if !exists { + known, exists = st.Base[space] + } + if !exists && remote != nil { + known, exists = remote.Spaces[space] + } + if !exists { + return nil + } + incoming := protocol.Manifest{} + for path, file := range known { + if !protocol.SafePath(path) || !rules.covers(path, false) || found.tracked[path] { + continue + } + if _, err := c.Blob(ctx, file.Hash); err != nil { + return err + } + incoming[path] = file + } + for path := range found.branch { + if err := c.materialize(ctx, repo.Root, path, protocol.File{}); err != nil { + return err + } + } + for path, file := range incoming { + if err := c.materialize(ctx, repo.Root, path, file); err != nil { + return err + } + } + delete(st.Stash, space) + return nil +} + +func (c *Client) materialize(ctx context.Context, root, path string, file protocol.File) error { + full := filepath.Join(root, filepath.FromSlash(path)) + if file.Hash != "" { + data, err := c.Blob(ctx, file.Hash) + if err != nil { + return err + } + return writeFile(full, data, 0o644) + } + if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + for dir := filepath.Dir(full); dir != root; dir = filepath.Dir(dir) { + if os.Remove(dir) != nil { + break + } + } + return nil +} diff --git a/backend/internal/client/sync_test.go b/backend/internal/client/sync_test.go new file mode 100644 index 0000000..ad18761 --- /dev/null +++ b/backend/internal/client/sync_test.go @@ -0,0 +1,251 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "lodge/internal/protocol" + "lodge/internal/server" +) + +const adminToken = "admin" + +type machine struct { + t *testing.T + client *Client + root string +} + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return string(out) +} + +func setup(t *testing.T) (*machine, *machine) { + t.Helper() + store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken})) + t.Cleanup(api.Close) + + origin := t.TempDir() + git(t, origin, "init", "-b", "main") + write(t, origin, "README.md", "readme") + write(t, origin, "docs/agents/tracked.md", "in git") + git(t, origin, "add", ".") + git(t, origin, "commit", "-m", "root") + + machines := [2]*machine{} + for i := range machines { + root := filepath.Join(t.TempDir(), "clone") + git(t, origin, "clone", origin, root) + resolved, _ := filepath.EvalSymlinks(root) + machines[i] = &machine{t: t, root: resolved, client: &Client{URL: api.URL, Token: adminToken, DataDir: t.TempDir()}} + } + return machines[0], machines[1] +} + +func write(t *testing.T, root, path, content string) { + t.Helper() + if err := writeFile(filepath.Join(root, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func (m *machine) sync() Result { + m.t.Helper() + res, err := m.client.Sync(context.Background(), m.root, true) + if err != nil { + m.t.Fatal(err) + } + return res +} + +func (m *machine) expect(path, content string) { + m.t.Helper() + data, err := os.ReadFile(filepath.Join(m.root, path)) + if content == "" && err == nil { + m.t.Fatalf("%s should not exist", path) + } + if content != "" && string(data) != content { + m.t.Fatalf("%s = %q (%v), want %q", path, data, err, content) + } +} + +func TestFilesTravelBetweenMachinesAndStayOutOfGit(t *testing.T) { + mac, dell := setup(t) + write(t, mac.root, "docs/agents/plan.md", "v1") + write(t, mac.root, "docs/agents/tracked.md", "edited locally") + mac.sync() + + if status := git(t, mac.root, "status", "--porcelain", "--", "docs/agents/plan.md", ".lodge"); status != "" { + t.Fatalf("agent files are visible to git: %s", status) + } + if _, err := os.Stat(filepath.Join(mac.root, ".gitignore")); err == nil { + t.Fatal(".gitignore must never be created") + } + + dell.sync() + dell.expect("docs/agents/plan.md", "v1") + dell.expect("docs/agents/tracked.md", "in git") + + write(t, dell.root, "docs/agents/plan.md", "v2") + dell.sync() + mac.sync() + mac.expect("docs/agents/plan.md", "v2") + + os.Remove(filepath.Join(dell.root, "docs/agents/plan.md")) + dell.sync() + mac.sync() + mac.expect("docs/agents/plan.md", "") +} + +func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) { + mac, dell := setup(t) + write(t, mac.root, "docs/agents/arch.md", "shared") + write(t, mac.root, ".agent/plan.md", "main plan") + mac.sync() + + git(t, mac.root, "checkout", "-b", "feature") + mac.sync() + mac.expect(".agent/plan.md", "main plan") + write(t, mac.root, ".agent/plan.md", "feature plan") + write(t, mac.root, ".agent/tried.md", "tried") + mac.sync() + + git(t, mac.root, "checkout", "main") + if _, err := mac.client.Sync(context.Background(), mac.root, false); err != nil { + t.Fatal(err) + } + mac.expect(".agent/plan.md", "main plan") + mac.expect(".agent/tried.md", "") + mac.expect("docs/agents/arch.md", "shared") + + git(t, mac.root, "checkout", "feature") + mac.sync() + mac.expect(".agent/plan.md", "feature plan") + mac.expect(".agent/tried.md", "tried") + + dell.sync() + dell.expect(".agent/plan.md", "main plan") + git(t, dell.root, "checkout", "-b", "feature") + dell.sync() + dell.expect(".agent/plan.md", "feature plan") + dell.expect(".agent/tried.md", "tried") +} + +func TestLaterEditWinsAndLoserStaysInHistory(t *testing.T) { + mac, dell := setup(t) + write(t, mac.root, "docs/agents/plan.md", "base") + mac.sync() + dell.sync() + + write(t, mac.root, "docs/agents/plan.md", "from mac") + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(filepath.Join(mac.root, "docs/agents/plan.md"), old, old); err != nil { + t.Fatal(err) + } + write(t, dell.root, "docs/agents/plan.md", "from dell") + dell.sync() + mac.sync() + mac.expect("docs/agents/plan.md", "from dell") + + revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "docs/agents/plan.md") + if err != nil { + t.Fatal(err) + } + if len(revisions) != 4 { + t.Fatalf("want base, dell, losing mac and restored dell revisions, got %d", len(revisions)) + } + state, err := mac.client.State(context.Background(), repoKey(t, mac), "3") + if err != nil { + t.Fatal(err) + } + lost, err := mac.client.Blob(context.Background(), state.Spaces[protocol.SharedSpace]["docs/agents/plan.md"].Hash) + if err != nil || string(lost) != "from mac" { + t.Fatalf("losing version is not in history: %q %v", lost, err) + } +} + +func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) { + mac, dell := setup(t) + write(t, mac.root, RulesFile, "shared notes\n") + write(t, mac.root, "notes/a.md", "a") + mac.sync() + dell.sync() + dell.expect("notes/a.md", "a") + if status := git(t, dell.root, "status", "--porcelain"); status != "" { + t.Fatalf("synced rules did not reach exclude: %s", status) + } + + hash, _ := mac.client.cacheBlob([]byte("#!/bin/sh")) + if err := mac.client.uploadBlob(context.Background(), hash); err != nil { + t.Fatal(err) + } + for _, path := range []string{".git/hooks/pre-commit", "notes/../../escape", "/etc/passwd"} { + push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: path, Hash: hash}}} + err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil) + if err == nil { + t.Fatalf("server accepted hostile path %s", path) + } + } +} + +func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) { + mac, _ := setup(t) + write(t, mac.root, "docs/agents/plan.md", "v1") + mac.sync() + + if err := mac.client.call(context.Background(), http.MethodDelete, "/api/repos/"+repoKey(t, mac), nil, nil); err != nil { + t.Fatal(err) + } + mac.sync() + mac.expect("docs/agents/plan.md", "v1") + revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "") + if err != nil || len(revisions) != 1 { + t.Fatalf("want a fresh history of one revision, got %d (%v)", len(revisions), err) + } +} + +func TestAdminSessionCookie(t *testing.T) { + mac, _ := setup(t) + res, err := http.Post(mac.client.URL+"/api/session", "application/json", strings.NewReader(`{"password":"`+adminToken+`"}`)) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusNoContent || len(res.Cookies()) != 1 { + t.Fatalf("login failed: %d", res.StatusCode) + } + req, _ := http.NewRequest(http.MethodGet, mac.client.URL+"/api/machines", nil) + req.AddCookie(res.Cookies()[0]) + got, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + got.Body.Close() + if got.StatusCode != http.StatusOK { + t.Fatalf("cookie was not accepted: %d", got.StatusCode) + } +} + +func repoKey(t *testing.T, m *machine) string { + t.Helper() + return git(t, m.root, "rev-list", "--max-parents=0", "HEAD")[:40] +} diff --git a/backend/internal/gitrepo/gitrepo.go b/backend/internal/gitrepo/gitrepo.go new file mode 100644 index 0000000..7674b62 --- /dev/null +++ b/backend/internal/gitrepo/gitrepo.go @@ -0,0 +1,103 @@ +package gitrepo + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os/exec" + "path" + "sort" + "strings" +) + +var ErrNoIdentity = errors.New("repository has no commits and no origin to identify it by") + +type Repo struct { + Root string + GitDir string + CommonDir string + Key string + Name string +} + +func run(dir string, args ...string) (string, error) { + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() + return strings.TrimRight(string(out), "\n"), err +} + +func Open(dir string) (*Repo, error) { + root, err := run(dir, "rev-parse", "--show-toplevel") + if err != nil { + return nil, err + } + gitDir, err := run(root, "rev-parse", "--absolute-git-dir") + if err != nil { + return nil, err + } + commonDir, err := run(root, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return nil, err + } + origin, _ := run(root, "config", "--get", "remote.origin.url") + key, err := identity(root, origin) + if err != nil { + return nil, err + } + name := path.Base(normalizeURL(origin)) + if origin == "" { + name = path.Base(root) + } + return &Repo{Root: root, GitDir: gitDir, CommonDir: commonDir, Key: key, Name: name}, nil +} + +func identity(root, origin string) (string, error) { + shallow, _ := run(root, "rev-parse", "--is-shallow-repository") + if shallow != "true" { + roots, err := run(root, "rev-list", "--max-parents=0", "HEAD") + if err == nil && roots != "" { + hashes := strings.Fields(roots) + sort.Strings(hashes) + return hashes[0], nil + } + } + if origin == "" { + return "", ErrNoIdentity + } + sum := sha256.Sum256([]byte(normalizeURL(origin))) + return "tmp-" + hex.EncodeToString(sum[:20]), nil +} + +func normalizeURL(url string) string { + url = strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(url), "/"), ".git") + if _, rest, ok := strings.Cut(url, "://"); ok { + url = rest + } + if _, rest, ok := strings.Cut(url, "@"); ok { + url = rest + } + return strings.ReplaceAll(url, ":", "/") +} + +func (r *Repo) Branch() string { + branch, _ := run(r.Root, "symbolic-ref", "--short", "-q", "HEAD") + return branch +} + +func (r *Repo) Head() string { + sha, _ := run(r.Root, "rev-parse", "-q", "--verify", "HEAD") + return sha +} + +func (r *Repo) Tracked(paths []string) map[string]bool { + tracked := map[string]bool{} + if len(paths) == 0 { + return tracked + } + out, _ := run(r.Root, append([]string{"ls-files", "-z", "--"}, paths...)...) + for _, file := range strings.Split(out, "\x00") { + if file != "" { + tracked[file] = true + } + } + return tracked +} diff --git a/backend/internal/protocol/protocol.go b/backend/internal/protocol/protocol.go new file mode 100644 index 0000000..e4a0113 --- /dev/null +++ b/backend/internal/protocol/protocol.go @@ -0,0 +1,102 @@ +package protocol + +import ( + "path" + "strings" +) + +const ( + SharedSpace = "shared" + branchPrefix = "branch:" + MaxBlobSize = 5 << 20 +) + +type File struct { + Hash string `json:"hash"` + Time int64 `json:"time"` +} + +type Manifest map[string]File + +type State struct { + Seq int64 `json:"seq"` + Spaces map[string]Manifest `json:"spaces"` +} + +type Change struct { + Space string `json:"space"` + Path string `json:"path"` + Base string `json:"base"` + Hash string `json:"hash"` +} + +type Push struct { + Name string `json:"name"` + MainSHA string `json:"main_sha"` + MainBranch string `json:"main_branch"` + Changes []Change `json:"changes"` +} + +type Revision struct { + Seq int64 `json:"seq"` + Machine string `json:"machine"` + Time int64 `json:"time"` + MainSHA string `json:"main_sha"` + MainBranch string `json:"main_branch"` + Changes []Change `json:"changes"` +} + +type Repo struct { + Key string `json:"key"` + Name string `json:"name"` + Seq int64 `json:"seq"` + Time int64 `json:"time"` +} + +type Machine struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt int64 `json:"created_at"` + LastPush int64 `json:"last_push"` + Revisions int64 `json:"revisions"` +} + +type Login struct { + Password string `json:"password"` +} + +type Event struct { + Repo string `json:"repo"` + Seq int64 `json:"seq"` +} + +type ClaimRequest struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type ClaimResponse struct { + Token string `json:"token"` +} + +type CodeResponse struct { + Code string `json:"code"` + Command string `json:"command"` + ExpiresAt int64 `json:"expires_at"` +} + +func BranchSpace(branch string) string { + return branchPrefix + branch +} + +func ValidSpace(space string) bool { + return space == SharedSpace || len(space) > len(branchPrefix) && strings.HasPrefix(space, branchPrefix) +} + +func SafePath(p string) bool { + if p == "" || path.IsAbs(p) || path.Clean(p) != p || strings.ContainsAny(p, "\\\x00") { + return false + } + first, _, _ := strings.Cut(p, "/") + return first != ".." && first != "." && !strings.EqualFold(first, ".git") +} diff --git a/backend/internal/server/install.sh b/backend/internal/server/install.sh new file mode 100644 index 0000000..5efaabe --- /dev/null +++ b/backend/internal/server/install.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "lodge: unsupported architecture $arch" >&2; exit 1 ;; +esac + +bin="$HOME/.local/bin" +mkdir -p "$bin" +curl -fsSL "{{URL}}/dl/lodge-$os-$arch" -o "$bin/lodge.new" +chmod +x "$bin/lodge.new" +mv "$bin/lodge.new" "$bin/lodge" + +"$bin/lodge" login "{{URL}}" "{{CODE}}" +"$bin/lodge" install + +case ":$PATH:" in + *":$bin:"*) ;; + *) echo "lodge: add $bin to your PATH to use the lodge command" ;; +esac diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..51c9bbf --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,409 @@ +package server + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "lodge/internal/protocol" +) + +const ( + heartbeat = 30 * time.Second + sessionCookie = "lodge_session" + loginDelay = time.Second +) + +var ( + //go:embed install.sh + installScript string + + repoKeyPattern = regexp.MustCompile(`^(tmp-)?[0-9a-f]{40,64}$`) + hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + codePattern = regexp.MustCompile(`^[0-9a-f]+$`) +) + +type Config struct { + AdminToken string + PublicURL string + StaticDir string + ClientsDir string +} + +type Server struct { + config Config + store *Store + + mu sync.Mutex + subscribers map[chan protocol.Event]struct{} +} + +type machineKey struct{} + +func New(store *Store, config Config) http.Handler { + s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}} + mux := http.NewServeMux() + mux.HandleFunc("GET /i/{code}", s.install) + mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir)))) + mux.HandleFunc("POST /api/claim", s.claim) + mux.HandleFunc("POST /api/session", s.login) + mux.HandleFunc("GET /api/session", s.admin(noContent)) + mux.HandleFunc("DELETE /api/session", s.logout) + mux.HandleFunc("POST /api/codes", s.admin(s.createCode)) + mux.HandleFunc("GET /api/machines", s.admin(s.machines)) + mux.HandleFunc("DELETE /api/machines/{id}", s.admin(s.deleteMachine)) + mux.HandleFunc("DELETE /api/repos/{key}", s.admin(s.deleteRepo)) + mux.HandleFunc("GET /api/repos", s.auth(s.repos)) + mux.HandleFunc("GET /api/repos/{key}/state", s.auth(s.state)) + mux.HandleFunc("GET /api/repos/{key}/revisions", s.auth(s.revisions)) + mux.HandleFunc("POST /api/repos/{key}/revisions", s.auth(s.push)) + mux.HandleFunc("GET /api/blobs/{hash}", s.auth(s.blob)) + mux.HandleFunc("PUT /api/blobs/{hash}", s.auth(s.putBlob)) + mux.HandleFunc("GET /api/events", s.auth(s.events)) + mux.HandleFunc("GET /api/", http.NotFound) + mux.HandleFunc("GET /", s.static) + return mux +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrConflict): + status = http.StatusConflict + case errors.Is(err, ErrNotFound): + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) +} + +func (s *Server) isAdminToken(token string) bool { + return s.config.AdminToken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.config.AdminToken)) == 1 +} + +func (s *Server) isAdmin(r *http.Request) bool { + if cookie, err := r.Cookie(sessionCookie); err == nil && s.store.SessionValid(r.Context(), cookie.Value) { + return true + } + return s.isAdminToken(bearer(r)) +} + +func noContent(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} + +func bearer(r *http.Request) string { + token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + return token +} + +func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.isAdmin(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var machineID int64 + if !s.isAdmin(r) { + id, err := s.store.MachineID(r.Context(), bearer(r)) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + machineID = id + } + if key := r.PathValue("key"); key != "" && !repoKeyPattern.MatchString(key) { + http.Error(w, "bad repository key", http.StatusBadRequest) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), machineKey{}, machineID))) + } +} + +func (s *Server) publicURL(r *http.Request) string { + if s.config.PublicURL != "" { + return strings.TrimSuffix(s.config.PublicURL, "/") + } + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + scheme = "http" + } + return scheme + "://" + r.Host +} + +func (s *Server) install(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) { + http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/x-shellscript") + _, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript) +} + +func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + Expires: expires, + HttpOnly: true, + Secure: strings.HasPrefix(s.publicURL(r), "https://"), + SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var req protocol.Login + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !s.isAdminToken(req.Password) { + time.Sleep(loginDelay) + http.Error(w, "wrong password", http.StatusUnauthorized) + return + } + token, expires, err := s.store.CreateSession(r.Context()) + if err != nil { + writeError(w, err) + return + } + s.setSession(w, r, token, expires) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(sessionCookie); err == nil { + _ = s.store.DeleteSession(r.Context(), cookie.Value) + } + s.setSession(w, r, "", time.Unix(0, 0)) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) machines(w http.ResponseWriter, r *http.Request) { + machines, err := s.store.Machines(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, machines) +} + +func (s *Server) deleteMachine(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err == nil { + err = s.store.DeleteMachine(r.Context(), id) + } + if err != nil { + writeError(w, ErrNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if !repoKeyPattern.MatchString(key) { + http.Error(w, "bad repository key", http.StatusBadRequest) + return + } + if err := s.store.DeleteRepo(r.Context(), key); err != nil { + writeError(w, err) + return + } + s.publish(protocol.Event{Repo: key}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) createCode(w http.ResponseWriter, r *http.Request) { + code, expires, err := s.store.CreateCode(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, protocol.CodeResponse{ + Code: code, + Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code), + ExpiresAt: expires.UnixMilli(), + }) +} + +func (s *Server) claim(w http.ResponseWriter, r *http.Request) { + var req protocol.ClaimRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + token, err := s.store.Claim(r.Context(), req.Code, req.Name) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, protocol.ClaimResponse{Token: token}) +} + +func (s *Server) repos(w http.ResponseWriter, r *http.Request) { + repos, err := s.store.Repos(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, repos) +} + +func (s *Server) state(w http.ResponseWriter, r *http.Request) { + state, err := s.store.State(r.Context(), r.PathValue("key"), r.URL.Query().Get("at")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, state) +} + +func (s *Server) revisions(w http.ResponseWriter, r *http.Request) { + revisions, err := s.store.Revisions(r.Context(), r.PathValue("key"), r.URL.Query().Get("path")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, revisions) +} + +func (s *Server) push(w http.ResponseWriter, r *http.Request) { + var push protocol.Push + if err := json.NewDecoder(r.Body).Decode(&push); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(push.Changes) == 0 { + http.Error(w, "no changes", http.StatusBadRequest) + return + } + for _, change := range push.Changes { + validHash := change.Hash == "" || hashPattern.MatchString(change.Hash) + if !protocol.ValidSpace(change.Space) || !protocol.SafePath(change.Path) || !validHash || change.Hash == change.Base { + http.Error(w, "bad change for "+change.Path, http.StatusBadRequest) + return + } + } + key := r.PathValue("key") + machineID, _ := r.Context().Value(machineKey{}).(int64) + seq, err := s.store.Push(r.Context(), key, machineID, push) + if err != nil { + writeError(w, err) + return + } + s.publish(protocol.Event{Repo: key, Seq: seq}) + writeJSON(w, http.StatusCreated, protocol.Event{Repo: key, Seq: seq}) +} + +func (s *Server) blob(w http.ResponseWriter, r *http.Request) { + data, err := s.store.Blob(r.Context(), r.PathValue("hash")) + if err != nil { + writeError(w, err) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + _, _ = w.Write(data) +} + +func (s *Server) putBlob(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, protocol.MaxBlobSize)) + if err != nil { + http.Error(w, err.Error(), http.StatusRequestEntityTooLarge) + return + } + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + if hash != r.PathValue("hash") { + http.Error(w, "content does not match hash", http.StatusBadRequest) + return + } + if err := s.store.PutBlob(r.Context(), hash, data); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) publish(event protocol.Event) { + s.mu.Lock() + defer s.mu.Unlock() + for subscriber := range s.subscribers { + select { + case subscriber <- event: + default: + } + } +} + +func (s *Server) events(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + subscriber := make(chan protocol.Event, 16) + s.mu.Lock() + s.subscribers[subscriber] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, subscriber) + s.mu.Unlock() + }() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + fmt.Fprint(w, ": connected\n\n") + flusher.Flush() + ticker := time.NewTicker(heartbeat) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + fmt.Fprint(w, ": ping\n\n") + case event := <-subscriber: + data, _ := json.Marshal(event) + fmt.Fprintf(w, "data: %s\n\n", data) + } + flusher.Flush() + } +} + +func (s *Server) static(w http.ResponseWriter, r *http.Request) { + file := filepath.Join(s.config.StaticDir, filepath.FromSlash(filepath.Clean("/"+r.URL.Path))) + if info, err := os.Stat(file); err != nil || info.IsDir() { + file = filepath.Join(s.config.StaticDir, "index.html") + } + http.ServeFile(w, r, file) +} diff --git a/backend/internal/server/store.go b/backend/internal/server/store.go new file mode 100644 index 0000000..f27f221 --- /dev/null +++ b/backend/internal/server/store.go @@ -0,0 +1,368 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strconv" + "time" + + "lodge/internal/protocol" + + _ "modernc.org/sqlite" +) + +const ( + codeLifetime = 10 * time.Minute + sessionLifetime = 30 * 24 * time.Hour + revisionLimit = 500 + schema = ` +create table if not exists machines ( + id integer primary key, + name text not null, + token_hash text not null unique, + created_at integer not null +); +create table if not exists codes ( + code text primary key, + expires_at integer not null +); +create table if not exists sessions ( + token_hash text primary key, + expires_at integer not null +); +create table if not exists repos ( + key text primary key, + name text not null, + seq integer not null, + time integer not null +); +create table if not exists blobs ( + hash text primary key, + data blob not null +); +create table if not exists revisions ( + repo text not null, + seq integer not null, + machine_id integer, + time integer not null, + main_sha text not null, + main_branch text not null, + primary key (repo, seq) +); +create table if not exists changes ( + repo text not null, + seq integer not null, + space text not null, + path text not null, + base text not null, + hash text not null, + primary key (repo, seq, space, path) +); +create index if not exists changes_by_path on changes (repo, space, path, seq); +` +) + +var ( + ErrConflict = errors.New("state changed since base") + ErrNotFound = errors.New("not found") +) + +type Store struct { + db *sql.DB +} + +func OpenStore(path string) (*Store, error) { + dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + return nil, err + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func randomHex(bytes int) string { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return hex.EncodeToString(buf) +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (s *Store) CreateCode(ctx context.Context) (string, time.Time, error) { + code, expires := randomHex(12), time.Now().Add(codeLifetime) + _, err := s.db.ExecContext(ctx, `insert into codes (code, expires_at) values (?, ?)`, code, expires.UnixMilli()) + return code, expires, err +} + +func (s *Store) CodeValid(ctx context.Context, code string) bool { + var found int + err := s.db.QueryRowContext(ctx, `select 1 from codes where code = ? and expires_at > ?`, code, time.Now().UnixMilli()).Scan(&found) + return err == nil +} + +func (s *Store) Claim(ctx context.Context, code, name string) (string, error) { + now := time.Now().UnixMilli() + res, err := s.db.ExecContext(ctx, `delete from codes where code = ? and expires_at > ?`, code, now) + if err != nil { + return "", err + } + if deleted, _ := res.RowsAffected(); deleted == 0 { + return "", ErrNotFound + } + token := randomHex(32) + _, err = s.db.ExecContext(ctx, `insert into machines (name, token_hash, created_at) values (?, ?, ?)`, name, hashToken(token), now) + return token, err +} + +func (s *Store) MachineID(ctx context.Context, token string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, `select id from machines where token_hash = ?`, hashToken(token)).Scan(&id) + return id, err +} + +func (s *Store) CreateSession(ctx context.Context) (string, time.Time, error) { + token, expires := randomHex(32), time.Now().Add(sessionLifetime) + _, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, expires_at) values (?, ?)`, hashToken(token), expires.UnixMilli()) + return token, expires, err +} + +func (s *Store) SessionValid(ctx context.Context, token string) bool { + var found int + err := s.db.QueryRowContext(ctx, `select 1 from sessions where token_hash = ? and expires_at > ?`, hashToken(token), time.Now().UnixMilli()).Scan(&found) + return err == nil +} + +func (s *Store) DeleteSession(ctx context.Context, token string) error { + _, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ? or expires_at <= ?`, hashToken(token), time.Now().UnixMilli()) + return err +} + +func (s *Store) Machines(ctx context.Context) ([]protocol.Machine, error) { + rows, err := s.db.QueryContext(ctx, ` + select m.id, m.name, m.created_at, coalesce(max(r.time), 0), count(r.seq) + from machines m left join revisions r on r.machine_id = m.id + group by m.id order by m.created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + machines := []protocol.Machine{} + for rows.Next() { + var machine protocol.Machine + if err := rows.Scan(&machine.ID, &machine.Name, &machine.CreatedAt, &machine.LastPush, &machine.Revisions); err != nil { + return nil, err + } + machines = append(machines, machine) + } + return machines, rows.Err() +} + +func (s *Store) DeleteMachine(ctx context.Context, id int64) error { + res, err := s.db.ExecContext(ctx, `delete from machines where id = ?`, id) + if err != nil { + return err + } + if deleted, _ := res.RowsAffected(); deleted == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) DeleteRepo(ctx context.Context, repo string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `delete from changes where repo = ?`, repo); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `delete from revisions where repo = ?`, repo); err != nil { + return err + } + res, err := tx.ExecContext(ctx, `delete from repos where key = ?`, repo) + if err != nil { + return err + } + if deleted, _ := res.RowsAffected(); deleted == 0 { + return ErrNotFound + } + if _, err := tx.ExecContext(ctx, `delete from blobs where hash not in (select hash from changes)`); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) PutBlob(ctx context.Context, hash string, data []byte) error { + _, err := s.db.ExecContext(ctx, `insert or ignore into blobs (hash, data) values (?, ?)`, hash, data) + return err +} + +func (s *Store) Blob(ctx context.Context, hash string) ([]byte, error) { + var data []byte + err := s.db.QueryRowContext(ctx, `select data from blobs where hash = ?`, hash).Scan(&data) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return data, err +} + +func (s *Store) Repos(ctx context.Context) ([]protocol.Repo, error) { + rows, err := s.db.QueryContext(ctx, `select key, name, seq, time from repos order by time desc`) + if err != nil { + return nil, err + } + defer rows.Close() + repos := []protocol.Repo{} + for rows.Next() { + var repo protocol.Repo + if err := rows.Scan(&repo.Key, &repo.Name, &repo.Seq, &repo.Time); err != nil { + return nil, err + } + repos = append(repos, repo) + } + return repos, rows.Err() +} + +func (s *Store) resolveSeq(ctx context.Context, repo, at string) (int64, error) { + var seq sql.NullInt64 + var err error + if n, convErr := strconv.ParseInt(at, 10, 64); at != "" && convErr == nil { + return n, nil + } + if at == "" { + err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ?`, repo).Scan(&seq) + } else { + err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ? and main_sha like ? || '%'`, repo, at).Scan(&seq) + if err == nil && !seq.Valid { + return 0, ErrNotFound + } + } + return seq.Int64, err +} + +func (s *Store) State(ctx context.Context, repo, at string) (protocol.State, error) { + state := protocol.State{Spaces: map[string]protocol.Manifest{}} + seq, err := s.resolveSeq(ctx, repo, at) + if err != nil { + return state, err + } + state.Seq = seq + rows, err := s.db.QueryContext(ctx, ` + select c.space, c.path, c.hash, r.time + from changes c join revisions r on r.repo = c.repo and r.seq = c.seq + where c.repo = ?1 and c.hash != '' and c.seq = ( + select max(seq) from changes + where repo = c.repo and space = c.space and path = c.path and seq <= ?2 + )`, repo, seq) + if err != nil { + return state, err + } + defer rows.Close() + for rows.Next() { + var space, path string + var file protocol.File + if err := rows.Scan(&space, &path, &file.Hash, &file.Time); err != nil { + return state, err + } + if state.Spaces[space] == nil { + state.Spaces[space] = protocol.Manifest{} + } + state.Spaces[space][path] = file + } + return state, rows.Err() +} + +func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Revision, error) { + rows, err := s.db.QueryContext(ctx, ` + select r.seq, coalesce(m.name, iif(r.machine_id is null, 'web', 'revoked')), r.time, r.main_sha, r.main_branch, c.space, c.path, c.base, c.hash + from revisions r + join changes c on c.repo = r.repo and c.seq = r.seq + left join machines m on m.id = r.machine_id + where r.repo = ?1 and (?2 = '' or c.path = ?2) + order by r.seq desc, c.path + limit ?3`, repo, path, revisionLimit) + if err != nil { + return nil, err + } + defer rows.Close() + revisions := []protocol.Revision{} + for rows.Next() { + var rev protocol.Revision + var change protocol.Change + if err := rows.Scan(&rev.Seq, &rev.Machine, &rev.Time, &rev.MainSHA, &rev.MainBranch, &change.Space, &change.Path, &change.Base, &change.Hash); err != nil { + return nil, err + } + if last := len(revisions) - 1; last >= 0 && revisions[last].Seq == rev.Seq { + revisions[last].Changes = append(revisions[last].Changes, change) + continue + } + rev.Changes = []protocol.Change{change} + revisions = append(revisions, rev) + } + return revisions, rows.Err() +} + +func (s *Store) Push(ctx context.Context, repo string, machineID int64, push protocol.Push) (int64, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + for _, change := range push.Changes { + var current string + err := tx.QueryRowContext(ctx, `select hash from changes where repo = ? and space = ? and path = ? order by seq desc limit 1`, repo, change.Space, change.Path).Scan(¤t) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + if current != change.Base { + return 0, ErrConflict + } + if change.Hash == "" { + continue + } + var found int + if err := tx.QueryRowContext(ctx, `select 1 from blobs where hash = ?`, change.Hash).Scan(&found); err != nil { + return 0, fmt.Errorf("blob %s was not uploaded: %w", change.Hash, ErrNotFound) + } + } + + now := time.Now().UnixMilli() + var seq int64 + err = tx.QueryRowContext(ctx, ` + insert into repos (key, name, seq, time) values (?, ?, 1, ?) + on conflict (key) do update set seq = seq + 1, name = excluded.name, time = excluded.time + returning seq`, repo, push.Name, now).Scan(&seq) + if err != nil { + return 0, err + } + machine := sql.NullInt64{Int64: machineID, Valid: machineID != 0} + if _, err := tx.ExecContext(ctx, `insert into revisions (repo, seq, machine_id, time, main_sha, main_branch) values (?, ?, ?, ?, ?, ?)`, repo, seq, machine, now, push.MainSHA, push.MainBranch); err != nil { + return 0, err + } + for _, change := range push.Changes { + if _, err := tx.ExecContext(ctx, `insert into changes (repo, seq, space, path, base, hash) values (?, ?, ?, ?, ?, ?)`, repo, seq, change.Space, change.Path, change.Base, change.Hash); err != nil { + return 0, err + } + } + return seq, tx.Commit() +} diff --git a/caddy/.env.example b/caddy/.env.example new file mode 100644 index 0000000..aa2bddc --- /dev/null +++ b/caddy/.env.example @@ -0,0 +1,2 @@ +LODGE_DOMAIN=lodge.localhost +CLOUDFLARE_API_TOKEN= diff --git a/caddy/.gitignore b/caddy/.gitignore new file mode 100644 index 0000000..acd0b35 --- /dev/null +++ b/caddy/.gitignore @@ -0,0 +1,2 @@ +.env +Caddyfile diff --git a/caddy/Caddyfile.example b/caddy/Caddyfile.example new file mode 100644 index 0000000..d38e81e --- /dev/null +++ b/caddy/Caddyfile.example @@ -0,0 +1,14 @@ +{ + admin localhost:2019 + + log { + format console + } + + servers { + trusted_proxies cloudflare + client_ip_headers Cf-Connecting-Ip + } +} + +import /etc/caddy/projects.d/*/site.caddy diff --git a/caddy/README.md b/caddy/README.md new file mode 100644 index 0000000..ebd199d --- /dev/null +++ b/caddy/README.md @@ -0,0 +1,22 @@ +# caddy + +`site.caddy` routes `{$LODGE_DOMAIN}` to the project's services over the external `caddy` docker network. It works standalone and inside a shared Caddy that fronts several projects. + +Standalone: + +```sh +docker network create caddy +cp Caddyfile.example Caddyfile +cp .env.example .env +docker compose up -d +``` + +Shared Caddy: mount this directory as `/etc/caddy/projects.d/lodge:ro`, keep `import /etc/caddy/projects.d/*/site.caddy` in its Caddyfile and put `LODGE_DOMAIN` in its environment. + +Rules that keep `site.caddy` portable: + +- the domain comes only from the environment variable +- no `tls` block, certificates are configured by the host Caddy (`acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}` in the global block) +- snippets are prefixed with the project slug because Caddy snippets are global +- mount the directory, not the file: a single-file bind mount goes stale when git replaces the inode +- keep `admin localhost:2019`, `admin off` makes `caddy reload` impossible diff --git a/caddy/docker-compose.yml b/caddy/docker-compose.yml new file mode 100644 index 0000000..42ed873 --- /dev/null +++ b/caddy/docker-compose.yml @@ -0,0 +1,23 @@ +services: + caddy: + image: ghcr.io/caddybuilds/caddy-cloudflare:latest + restart: unless-stopped + ports: + - "0.0.0.0:80:80" + - "0.0.0.0:443:443" + - "0.0.0.0:443:443/udp" + networks: + - caddy + env_file: + - .env + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - .:/etc/caddy/projects.d/lodge:ro + - caddy_data:/data + +networks: + caddy: + external: true + +volumes: + caddy_data: diff --git a/caddy/site.caddy b/caddy/site.caddy new file mode 100644 index 0000000..aee9515 --- /dev/null +++ b/caddy/site.caddy @@ -0,0 +1,5 @@ +{$LODGE_DOMAIN} { + encode zstd gzip + + reverse_proxy lodge-api:8080 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f9e2e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +include: + - path: backend/docker-compose.yml + project_directory: . + - path: frontend/docker-compose.yml + project_directory: . diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1048241 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.svelte-kit +build +.env +.env.* diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..7023193 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +PUBLIC_API_BASE_URL=/api +ALLOWED_HOSTS= diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..46a573e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# Env +.env +.env.* +!.env.example + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Impeccable +**/.impeccable/config.local.json +**/.impeccable/hook.cache.json +**/.impeccable/hook.pending.json diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..94ac5a3 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,6 @@ +## Checking commands +After writing code, always run from `frontend/`: +```shell +bun fix +bun check +``` diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000..0cef562 --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,26 @@ +COMPOSE := cd .. && docker compose +changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null +stamp = { git update-ref $(1) HEAD 2>/dev/null || true; } + +.PHONY: fmt check build pre-deploy post-deploy dev + +fmt: + bun run fix + +check: + bun run check + bun run lint + +build: + $(COMPOSE) run --rm frontend-dev sh -c "bun install && bun run build" + +pre-deploy: + @if $(call changed,refs/deploy/frontend,.) || ! test -d build; then \ + $(MAKE) build && $(call stamp,refs/deploy/frontend); \ + fi + +post-deploy: + @: + +dev: + bun run dev diff --git a/frontend/biome.jsonc b/frontend/biome.jsonc new file mode 100644 index 0000000..c7835bb --- /dev/null +++ b/frontend/biome.jsonc @@ -0,0 +1,42 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["ultracite/biome/core", "ultracite/biome/svelte"], + "files": { + "includes": [ + "!.claude", + "!.svelte-kit", + "!build", + "!.mcp.json", + "!.impeccable" + ] + }, + "javascript": { "globals": ["Bun"] }, + "overrides": [ + { + "includes": ["src/lib/components/ui/**"], + "linter": { "enabled": false }, + "assist": { "enabled": false } + }, + { + "includes": ["src/lib/utils.ts"], + "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } + }, + { + "includes": ["**/*.svelte"], + "linter": { + "rules": { + "style": { + "useFilenamingConvention": { + "level": "error", + "options": { "filenameCases": ["kebab-case", "PascalCase"] } + } + } + } + } + }, + { + "includes": ["src/app.d.ts"], + "linter": { "rules": { "style": { "noNamespace": "off" } } } + } + ] +} diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..1a6ea14 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,583 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "lodge", + "devDependencies": { + "@biomejs/biome": "2.5.2", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^1.23.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "@types/bun": "^1.3.0", + "bits-ui": "^2.16.3", + "clsx": "^2.1.1", + "diff": "^9.0.0", + "dompurify": "^3.4.15", + "marked": "^18.0.13", + "mode-watcher": "^1.1.0", + "shadcn-svelte": "^1.3.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "svelte-sonner": "^1.1.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3", + "ultracite": "7.9.0", + "vite": "^8.0.16", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.2", "@biomejs/cli-darwin-x64": "2.5.2", "@biomejs/cli-linux-arm64": "2.5.2", "@biomejs/cli-linux-arm64-musl": "2.5.2", "@biomejs/cli-linux-x64": "2.5.2", "@biomejs/cli-linux-x64-musl": "2.5.2", "@biomejs/cli-win32-arm64": "2.5.2", "@biomejs/cli-win32-x64": "2.5.2" }, "bin": { "biome": "bin/biome" } }, "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A=="], + + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], + + "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], + + "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], + + "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@fontsource-variable/archivo": ["@fontsource-variable/archivo@5.3.0", "", {}, "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + + "@lucide/svelte": ["@lucide/svelte@1.47.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-tpuE7JIK9FzYDWsJx6PxHj9ROyCpnDxZfvHu5q5yHGKS2CFNjzSyf1o9/Ypu2A27GLvkPGqZcIMrF6idM+eQZQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="], + + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.70.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.70.0", "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0" } }, "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.70.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.70.0", "", {}, "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.70.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.70.0", "@typescript-eslint/tsconfig-utils": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.70.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.70.0", "", { "dependencies": { "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "bits-ui": ["bits-ui@2.19.2", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W9upilTmLTIWjuVAD6Ta7XoS6gxRflmCQnXamj+WN1eYxZecghqGENpuSMAKQmVENMLXhh85BZb04LCrBd2+Ng=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cn": ["cn@0.3.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + + "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.11.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrap": ["esrap@2.3.7", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "marked": ["marked@18.0.13", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mode-watcher": ["mode-watcher@1.1.0", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "nypm": ["nypm@0.6.10", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.3.1" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], + + "runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + + "shadcn-svelte": ["shadcn-svelte@1.7.0", "", { "dependencies": { "cn": "^0.3.0", "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.10" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-AK3ZsYOL8PN/RxcEBCHGvQj2TrLfaaBsbw0QohyEb3nn/yPNDRC/+9iSjnTp/uKEe+tAi0tBxB/Yhzno0N7EEg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="], + + "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], + + "svelte-sonner": ["svelte-sonner@1.2.1", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-N00LOWb60fjKXULbpngku+7E9Krb16KpNq4/Eax5Vu7o1Dpbca55QpaLPsB+yp82JIEdFhfJuv5cVXLoe0ZtVA=="], + + "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], + + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], + + "tailwind-variants": ["tailwind-variants@3.3.1", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge", "tailwindcss"] }, "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "ultracite": ["ultracite@7.9.0", "", { "dependencies": { "@clack/prompts": "^1.5.1", "@typescript-eslint/utils": "^8.62.1", "commander": "^15.0.0", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-Lkh6MXuX/vI89jeDIaEiOifI1BVXEJmk6e+igKRGAzdp5jKzeFa5d5A0tpJmHGCxXzd42jVwNpcs7jqm87IfNg=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "mode-watcher/runed": ["runed@0.25.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="], + + "mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="], + + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + + "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], + + "ultracite/commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="], + } +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..9c0e5b3 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/routes/layout.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "luma", + "iconLibrary": "lucide", + "menuColor": "default-translucent", + "menuAccent": "subtle" +} diff --git a/frontend/docker-compose.override.yml.example b/frontend/docker-compose.override.yml.example new file mode 100644 index 0000000..8909d52 --- /dev/null +++ b/frontend/docker-compose.override.yml.example @@ -0,0 +1,11 @@ +services: + frontend-dev: + networks: + default: {} + caddy: + aliases: + - lodge-frontend + +networks: + caddy: + external: true diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml new file mode 100644 index 0000000..26ff1fb --- /dev/null +++ b/frontend/docker-compose.yml @@ -0,0 +1,22 @@ +services: + frontend-dev: + image: oven/bun:alpine + profiles: [frontend-dev] + restart: unless-stopped + working_dir: /app + env_file: + - path: .env + required: false + - path: frontend/.env + required: false + environment: + API_PROXY_TARGET: http://api:8080 + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + - frontend_svelte_kit:/app/.svelte-kit + command: [sh, -c, "bun install && bun run dev --host 0.0.0.0 --port 3000"] + +volumes: + frontend_node_modules: + frontend_svelte_kit: diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..88c7110 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "lodge", + "type": "module", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "ultracite check", + "fix": "ultracite fix" + }, + "devDependencies": { + "@biomejs/biome": "2.5.2", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^1.23.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "@types/bun": "^1.3.0", + "bits-ui": "^2.16.3", + "clsx": "^2.1.1", + "diff": "^9.0.0", + "dompurify": "^3.4.15", + "marked": "^18.0.13", + "mode-watcher": "^1.1.0", + "shadcn-svelte": "^1.3.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "svelte-sonner": "^1.1.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3", + "ultracite": "7.9.0", + "vite": "^8.0.16" + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..520c421 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..1c7072b --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..676d169 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,54 @@ +import { env } from "$env/dynamic/public"; + +const BASE = env.PUBLIC_API_BASE_URL ?? "/api"; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "ApiError"; + } +} + +type Json = Record | unknown[]; + +const send = async ( + method: string, + path: string, + body?: Json +): Promise => { + const init: RequestInit = { credentials: "include", method }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + const res = await fetch(BASE + path, init); + if (!res.ok) { + const detail = (await res.text()).trim(); + throw new ApiError(res.status, detail || res.statusText); + } + return res; +}; + +const request = async ( + method: string, + path: string, + body?: Json +): Promise => { + const res = await send(method, path, body); + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; +}; + +export const api = { + bytes: async (path: string) => + new Uint8Array(await (await send("GET", path)).arrayBuffer()), + del: (path: string) => request("DELETE", path), + events: () => new EventSource(`${BASE}/events`, { withCredentials: true }), + get: (path: string) => request("GET", path), + post: (path: string, body?: Json) => request("POST", path, body), +}; diff --git a/frontend/src/lib/components/Note.svelte b/frontend/src/lib/components/Note.svelte new file mode 100644 index 0000000..7177d08 --- /dev/null +++ b/frontend/src/lib/components/Note.svelte @@ -0,0 +1,253 @@ + + +{#if view === "changes"} +
    + {#each rows as row, index (index)} +
  1. + {#if row.kind === "removed"} + {row.text || " "} + {:else if row.kind === "added"} + {row.text || " "} + {:else} + {row.text || " "} + {/if} +
  2. + {/each} +
+{:else if html} +
+ {@html html} +
+{:else} +
{now}
+{/if} + + diff --git a/frontend/src/lib/components/StripLog.svelte b/frontend/src/lib/components/StripLog.svelte new file mode 100644 index 0000000..5224844 --- /dev/null +++ b/frontend/src/lib/components/StripLog.svelte @@ -0,0 +1,288 @@ + + +
+ {#each revisions as rev, index (rev.seq)} + + {/each} +
+ + diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..7c43079 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1,2 @@ +// place files you want to import through the `$lib` alias in this folder. +// biome-ignore-all lint/suspicious/noEmptySource: temporary diff --git a/frontend/src/lib/lodge.ts b/frontend/src/lib/lodge.ts new file mode 100644 index 0000000..9d9ddd0 --- /dev/null +++ b/frontend/src/lib/lodge.ts @@ -0,0 +1,136 @@ +import { api } from "$lib/api/client"; + +export interface Change { + base: string; + hash: string; + path: string; + space: string; +} + +export interface Revision { + changes: Change[]; + machine: string; + main_branch: string; + main_sha: string; + seq: number; + time: number; +} + +export interface Repo { + key: string; + name: string; + seq: number; + time: number; +} + +export interface Machine { + created_at: number; + id: number; + last_push: number; + name: string; + revisions: number; +} + +export interface FileEntry { + hash: string; + time: number; +} + +export interface RepoState { + seq: number; + spaces: Record>; +} + +export interface Code { + code: string; + command: string; + expires_at: number; +} + +export const SHARED = "shared"; +const PATTERNS = 7; +const WEB = "web"; +const REVOKED = "revoked"; +const SHORT_SHA = 7; +const BRANCH_PREFIX = "branch:"; + +export const lithology = (machine: string, machines: string[]): string => { + if (machine === WEB) { + return "lith lith-web"; + } + const index = machines.indexOf(machine); + if (machine === REVOKED || index < 0) { + return "lith lith-gone"; + } + return `lith lith-${index % PATTERNS}`; +}; + +export const shortSha = (sha: string): string => + sha ? sha.slice(0, SHORT_SHA) : "no commit"; + +export const spaceName = (space: string): string => + space.startsWith(BRANCH_PREFIX) ? space.slice(BRANCH_PREFIX.length) : space; + +export const fileId = (space: string, path: string): string => + `${space}\n${path}`; + +export const changeKind = ( + change: Change +): "added" | "deleted" | "modified" => { + if (!change.base) { + return "added"; + } + return change.hash ? "modified" : "deleted"; +}; + +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 31_536_000_000], + ["month", 2_592_000_000], + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], +]; +const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); +const absolute = new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeStyle: "short", +}); + +export const ago = (time: number): string => { + if (!time) { + return "never"; + } + const delta = time - Date.now(); + for (const [unit, size] of UNITS) { + if (Math.abs(delta) >= size) { + return relative.format(Math.round(delta / size), unit); + } + } + return "just now"; +}; + +export const exact = (time: number): string => absolute.format(time); + +const blobs = new Map>(); + +export const blob = (hash: string): Promise => { + let pending = blobs.get(hash); + if (!pending) { + pending = api.bytes(`/blobs/${hash}`); + blobs.set(hash, pending); + } + return pending; +}; + +const decoder = new TextDecoder("utf-8", { fatal: true }); + +export const text = (bytes: Uint8Array): string | null => { + if (bytes.includes(0)) { + return null; + } + try { + return decoder.decode(bytes); + } catch { + return null; + } +}; diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts new file mode 100644 index 0000000..5ae1069 --- /dev/null +++ b/frontend/src/lib/session.svelte.ts @@ -0,0 +1,32 @@ +import { goto } from "$app/navigation"; +import { ApiError, api } from "$lib/api/client"; + +export const session = $state({ signedIn: false }); + +export const requireSession = async (): Promise => { + try { + await api.get("/session"); + session.signedIn = true; + } catch { + session.signedIn = false; + await goto("/login"); + } +}; + +export const signOut = async (): Promise => { + await api.del("/session"); + session.signedIn = false; + await goto("/login"); +}; + +export const describe = (error: unknown): string => { + if (error instanceof ApiError && error.status === 401) { + session.signedIn = false; + goto("/login"); + return "Your session ended. Sign in again."; + } + if (error instanceof ApiError) { + return error.message; + } + return "The server did not answer. Check that lodge-server is running and try again."; +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..3e2f12b --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,15 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export type WithoutChild = T extends { child?: any } ? Omit : T; +export type WithoutChildren = T extends { children?: any } + ? Omit + : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { + ref?: U | null; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..de5a38c --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,110 @@ + + + + +{#if onLogin} + {@render children()} +{:else if session.signedIn} +
+ lodge + +
+ + +
+
+ {@render children()} +{/if} + + diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..83addb7 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..dc35c52 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,209 @@ + + + + Repositories · lodge + + +
+

Repositories

+ + {#if problem} + + {/if} + + {#if repos === null && !problem} +
+
+ {:else if repos?.length === 0} +
+

+ No repository has been logged yet. Connect a machine, then open any git + repository on it: lodge notices the repository on the next checkout, or + right away with lodge add. +

+ Connect a machine +
+ {:else if repos} + + {/if} +
+ + diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css new file mode 100644 index 0000000..0cd1e5e --- /dev/null +++ b/frontend/src/routes/layout.css @@ -0,0 +1,264 @@ +@import "tailwindcss"; +@import "@fontsource-variable/archivo/wdth.css"; +@import "@fontsource-variable/jetbrains-mono/wght.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --paper: #eef0e6; + --paper-2: #e3e7d8; + --ink: #15211d; + --ink-2: #46544d; + --rule: rgb(21 33 29 / 0.2); + --grid: rgb(36 105 79 / 0.13); + --grid-strong: rgb(36 105 79 / 0.3); + --green: #24694f; + --green-wash: rgb(36 105 79 / 0.12); + --red: #b3341f; + --red-wash: rgb(179 52 31 / 0.11); + --ochre: #c9a24a; + --sans: "Archivo Variable", system-ui, sans-serif; + --mono: "JetBrains Mono Variable", ui-monospace, monospace; + --ease: cubic-bezier(0.16, 1, 0.3, 1); + color-scheme: light; +} + +.dark { + --paper: #162026; + --paper-2: #1d2a31; + --ink: #e6eadc; + --ink-2: #a5b2a8; + --rule: rgb(230 234 220 / 0.2); + --grid: rgb(111 199 163 / 0.09); + --grid-strong: rgb(111 199 163 / 0.24); + --green: #74caa6; + --green-wash: rgb(116 202 166 / 0.14); + --red: #f08a74; + --red-wash: rgb(240 138 116 / 0.14); + --ochre: #d9b45e; + color-scheme: dark; +} + +html { + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 100%; + line-height: 1.5; + scrollbar-color: var(--ink-2) transparent; + scrollbar-width: thin; + -webkit-text-size-adjust: 100%; +} + +body { + min-height: 100dvh; + caret-color: var(--red); +} + +::selection { + color: var(--paper); + background: var(--green); +} + +:focus-visible { + outline: 2px solid var(--red); + outline-offset: 2px; +} + +a { + color: inherit; + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-decoration-color: var(--rule); + text-underline-offset: 0.22em; + transition: + text-decoration-color 150ms var(--ease), + color 150ms var(--ease); +} + +a:hover { + color: var(--green); + text-decoration-color: currentColor; +} + +button { + font: inherit; + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.5; +} + +.stamp { + font-weight: 600; + font-stretch: 68%; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.label { + font-size: 0.75rem; + font-weight: 500; + font-stretch: 80%; + color: var(--ink-2); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.mono { + font-family: var(--mono); + font-size: 0.8125em; + font-variant-ligatures: none; +} + +.act { + padding: 0.125rem 0; + font-weight: 600; + color: var(--green); + background: none; + border: 0; + border-bottom: 1px solid currentColor; + transition: + color 150ms var(--ease), + background-color 150ms var(--ease); +} + +.act:hover:not(:disabled) { + background: var(--green-wash); +} + +.act.danger { + color: var(--red); +} + +.act.danger:hover:not(:disabled) { + background: var(--red-wash); +} + +.act.solid { + padding: 0.625rem 1rem; + color: var(--paper); + background: var(--ink); + border: 0; +} + +.act.solid:hover:not(:disabled) { + background: var(--green); +} + +.act.solid.danger { + color: var(--paper); + background: var(--red); +} + +.field { + width: 100%; + padding: 0.5rem 0; + font: inherit; + color: var(--ink); + background: transparent; + border: 0; + border-bottom: 2px solid var(--ink); + border-radius: 0; +} + +.field::placeholder { + color: var(--ink-2); +} + +.field:focus-visible { + outline: none; + border-bottom-color: var(--red); +} + +.problem { + font-weight: 500; + color: var(--red); +} + +.lith { + --l: var(--ink); + position: relative; + background-color: var(--paper); +} + +.lith::after { + position: absolute; + inset: 0; + content: ""; + background: var(--l); +} + +.lith-0::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E") + repeat; +} + +.lith-1::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E") + repeat; +} + +.lith-2::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E") + repeat; +} + +.lith-3::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E") + repeat; +} + +.lith-4::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E") + repeat; +} + +.lith-5::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E") + repeat; +} + +.lith-6::after { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E") + repeat; +} + +.lith-web { + background-color: var(--ochre); +} + +.lith-web::after, +.lith-gone::after { + display: none; +} + +.lith-gone { + outline: 1px dashed var(--rule); + outline-offset: -1px; +} + +.skeleton { + color: transparent; + background: var(--paper-2); +} + +@keyframes settle { + from { + clip-path: inset(0 0 100% 0); + } + to { + clip-path: inset(0); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 1ms !important; + animation-duration: 1ms !important; + } +} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..8d4a9e6 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,96 @@ + + + + Sign in · lodge + + +
+ +
+

lodge

+

+ Agent notes for every repository, at every depth of their history. +

+ + + {#if problem} + + {/if} + +
+
+ + diff --git a/frontend/src/routes/machines/+page.svelte b/frontend/src/routes/machines/+page.svelte new file mode 100644 index 0000000..f1b2fed --- /dev/null +++ b/frontend/src/routes/machines/+page.svelte @@ -0,0 +1,307 @@ + + + + Machines · lodge + + +
+

Machines

+

+ The legend of the log. Each machine owns a hatch, and every bed it writes is + drawn with it. +

+ + {#if problem} + + {/if} + +
+ {#if code && remaining > 0} +

+ Run this on the new machine. It installs the client, signs in and starts + the daemon. The link works once and expires in + {countdown}. +

+
{code.command}
+
+ + +
+ {:else} + + {/if} +
+ + {#if machines === null && !problem} +
+ {:else if machines?.length === 0} +

+ No machine is connected yet. The command above works on macOS and Linux, + on Intel and ARM. +

+ {:else if machines} +
    + {#each machines as machine (machine.id)} +
  • + + {machine.name} + + {machine.revisions} + {machine.revisions === 1 ? "revision" : "revisions"}, last push + {ago(machine.last_push)}. Connected + . + + + {#if revoking === machine.id} + + Its token stops working at once; files on it stay. + + + + {:else} + + {/if} + +
  • + {/each} +
  • + + web + Versions restored from this page. +
  • +
+ {/if} +
+ + diff --git a/frontend/src/routes/r/[key]/+page.svelte b/frontend/src/routes/r/[key]/+page.svelte new file mode 100644 index 0000000..a54cc54 --- /dev/null +++ b/frontend/src/routes/r/[key]/+page.svelte @@ -0,0 +1,815 @@ + + + + + {path ? `${path.split("/").at(-1)} · ` : ""}{name || "repository"} + · lodge + + + +{#snippet slashed(value: string)} + {#each value.split("/") as part, index (index)} + {index ? "/" : ""}{part} + {/each} +{/snippet} + +
+ + +
+ {#if problem} + + {/if} + + {#if bed} +
+

+ Depth {bed.seq}{bed.seq === latest ? ", the surface" : ""} +

+

+ {bed.machine} + + on {bed.main_branch || "detached"} + {shortSha(bed.main_sha)} +

+
    + {#each bed.changes as change (fileId(change.space, change.path))} +
  • + {changeKind(change)} + {#if change.hash} + {@render slashed(change.path)} + {:else} + {@render slashed(change.path)} + {/if} +
  • + {/each} +
+ {#if bed.seq !== latest} + Back to the surface + {/if} +
+ {/if} + + {#each spaces as group (group)} +

+ {group === SHARED ? "Shared notes" : `Branch ${spaceName(group)}`} +

+ + {:else} + {#if loaded && !problem} +

+ {latest + ? "Nothing had been written yet at this depth. Drag the column up towards the surface." + : "This repository has no revisions yet. Write a file under docs/agents on any connected machine and it lands here within seconds."} +

+ {/if} + {/each} + +
+ Delete this repository +

+ Erases all {latest} revisions from the server. Files on your machines + stay where they are, and a machine that still syncs this repository + starts a new history. Type {name} to confirm. +

+ + +
+
+ +
+ {#if path} + Files +

{path.split("/").at(-1)}

+

+ {space === SHARED ? "shared" : `branch ${spaceName(space)}`} + {path} + {#if written} + written at depth {written.seq} by {written.machine}, + , on + {written.main_branch || "detached"} + {shortSha(written.main_sha)} + {/if} +

+ + {#if entry} +
+ {#each [["read", "Read"], ["changes", "Changes"], ["source", "Source"]] as [ id, title ] (id)} + {#if markdown || id !== "read"} + {title} + {/if} + {/each} + {#if restorable} + {#if confirming === "restore"} + + Put depth {written?.seq} back on the surface? + + + + {:else} + + {/if} + {/if} +
+ + {#if opened === null} +
+ {:else if opened.now === null} +

+ This file is binary, so there is nothing to read here. Fetch it with + lodge show {at} {path}. +

+ {:else} + + {/if} + {:else if repoState} +

+ {path.split("/").at(-1)} + does not exist at depth {at}. + {#if fileHistory.length} + It was last written at depth + {fileHistory[0].seq}. + {/if} +

+ {/if} + {:else} +

+ Pick a file to pull its core from depth {at}. Drag along the column, or + use the arrow keys, to read the notes as they were at any revision. +

+ {/if} +
+
+ + diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..74b479a --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,9 @@ + + lodge + + + + + + + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..1b6906b --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + assets: "build", + fallback: "index.html", + pages: "build", + precompress: false, + strict: false, + }), + }, + preprocess: vitePreprocess(), +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c7b9df5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b3dca69 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +const allowedHosts = process.env.ALLOWED_HOSTS + ? process.env.ALLOWED_HOSTS.split(",") + : undefined; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + allowedHosts, + proxy: { + "/api": { + changeOrigin: true, + target: process.env.API_PROXY_TARGET ?? "http://localhost:8080", + }, + }, + }, +}); diff --git a/renovate.jsonc b/renovate.jsonc new file mode 100644 index 0000000..25ddea3 --- /dev/null +++ b/renovate.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["local>templates/renovate"] +}